|
| 1 | +import tkinter as tk |
| 2 | +import ttkbootstrap as tb |
| 3 | +import numpy as np |
| 4 | +import threading |
| 5 | +import time |
| 6 | +import mss |
| 7 | +import cv2 |
| 8 | +from PIL import Image, ImageTk |
| 9 | + |
| 10 | +# ================= CONFIG ================= |
| 11 | +APP_TITLE = "Scopes – Screen Capture" |
| 12 | +FPS = 30 |
| 13 | + |
| 14 | +# ================= GLOBAL STATE ================= |
| 15 | +running = False |
| 16 | +latest_frame = None |
| 17 | +roi = None |
| 18 | +recording = False |
| 19 | +video_writer = None |
| 20 | +color_indicators = [] |
| 21 | + |
| 22 | +# ================= APP ================= |
| 23 | +app = tb.Window(title=APP_TITLE, themename="darkly", size=(1280, 720)) |
| 24 | +app.grid_columnconfigure(1, weight=1) |
| 25 | +app.grid_rowconfigure(0, weight=1) |
| 26 | + |
| 27 | +# ================= UI ================= |
| 28 | +controls = tb.Frame(app, padding=10) |
| 29 | +controls.grid(row=0, column=0, sticky="ns") |
| 30 | + |
| 31 | +viewer = tb.Frame(app) |
| 32 | +viewer.grid(row=0, column=1, sticky="nsew") |
| 33 | +viewer.grid_columnconfigure(0, weight=1) |
| 34 | +viewer.grid_rowconfigure(0, weight=1) |
| 35 | + |
| 36 | +canvas = tk.Canvas(viewer, bg="black", highlightthickness=0) |
| 37 | +canvas.grid(row=0, column=0, sticky="nsew") |
| 38 | + |
| 39 | +# ================= CONTROLS ================= |
| 40 | +tb.Label(controls, text="Capture", font=("Segoe UI", 11, "bold")).pack(anchor="w") |
| 41 | + |
| 42 | +def toggle_capture(): |
| 43 | + global running |
| 44 | + running = not running |
| 45 | + btn_start.config(text="Stop" if running else "Start") |
| 46 | + |
| 47 | +btn_start = tb.Button(controls, text="Start", bootstyle="success", command=toggle_capture) |
| 48 | +btn_start.pack(fill="x", pady=4) |
| 49 | + |
| 50 | +def toggle_record(): |
| 51 | + global recording, video_writer |
| 52 | + recording = not recording |
| 53 | + btn_rec.config(text="Stop REC" if recording else "Record") |
| 54 | + |
| 55 | + if not recording and video_writer: |
| 56 | + video_writer.release() |
| 57 | + video_writer = None |
| 58 | + |
| 59 | +btn_rec = tb.Button(controls, text="Record", bootstyle="danger", command=toggle_record) |
| 60 | +btn_rec.pack(fill="x", pady=4) |
| 61 | + |
| 62 | +tb.Label(controls, text="Sampling Step").pack(anchor="w", pady=(10, 0)) |
| 63 | +sample_slider = tb.Scale(controls, from_=1, to=10, orient="horizontal") |
| 64 | +sample_slider.set(4) |
| 65 | +sample_slider.pack(fill="x") |
| 66 | + |
| 67 | +tb.Label(controls, text="Gain").pack(anchor="w", pady=(10, 0)) |
| 68 | +gain_slider = tb.Scale(controls, from_=1, to=10, orient="horizontal") |
| 69 | +gain_slider.set(4) |
| 70 | +gain_slider.pack(fill="x") |
| 71 | + |
| 72 | +tb.Separator(controls).pack(fill="x", pady=10) |
| 73 | + |
| 74 | +tb.Label( |
| 75 | + controls, |
| 76 | + text="Mouse drag = ROI\nSPACE = sample color\nESC = quit", |
| 77 | + justify="left" |
| 78 | +).pack(anchor="w") |
| 79 | + |
| 80 | +# ================= COLOR ================= |
| 81 | +def rgb_to_yuv(rgb): |
| 82 | + r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] |
| 83 | + y = 0.299*r + 0.587*g + 0.114*b |
| 84 | + u = -0.147*r - 0.289*g + 0.436*b |
| 85 | + v = 0.615*r - 0.515*g - 0.100*b |
| 86 | + return y, u, v |
| 87 | + |
| 88 | +# ================= DRAW ================= |
| 89 | +def draw_scopes(frame): |
| 90 | + canvas.delete("all") |
| 91 | + |
| 92 | + h, w, _ = frame.shape |
| 93 | + ch, cw = canvas.winfo_height(), canvas.winfo_width() |
| 94 | + if ch < 50 or cw < 50: |
| 95 | + return |
| 96 | + |
| 97 | + step = int(sample_slider.get()) |
| 98 | + gain = gain_slider.get() |
| 99 | + |
| 100 | + small = frame[::step, ::step] / 255.0 |
| 101 | + Y, U, V = rgb_to_yuv(small) |
| 102 | + |
| 103 | + # ---- VECTORSCOPE ---- |
| 104 | + cx, cy, radius = 200, ch//2, 160 |
| 105 | + canvas.create_text(cx, 20, text="VECTORSCOPE", fill="#aaa") |
| 106 | + canvas.create_oval(cx-radius, cy-radius, cx+radius, cy+radius, outline="#444") |
| 107 | + |
| 108 | + xs = cx + U.flatten() * radius * gain |
| 109 | + ys = cy - V.flatten() * radius * gain |
| 110 | + |
| 111 | + for x, y in zip(xs, ys): |
| 112 | + canvas.create_line(x, y, x+1, y, fill="lime") |
| 113 | + |
| 114 | + for r, g, b in color_indicators: |
| 115 | + _, u, v = rgb_to_yuv(np.array([[r, g, b]])) |
| 116 | + ix = cx + u[0] * radius * gain |
| 117 | + iy = cy - v[0] * radius * gain |
| 118 | + canvas.create_oval(ix-6, iy-6, ix+6, iy+6, outline="yellow", width=2) |
| 119 | + |
| 120 | + # ---- HISTOGRAM ---- |
| 121 | + hist_x = 420 |
| 122 | + hist_w = cw - hist_x - 20 |
| 123 | + hist_h = 150 |
| 124 | + hist_y = 60 |
| 125 | + |
| 126 | + canvas.create_text(hist_x, 20, text="HISTOGRAM", fill="#aaa", anchor="w") |
| 127 | + |
| 128 | + for i, col in enumerate(("red", "green", "blue")): |
| 129 | + hist, _ = np.histogram(frame[..., i], bins=256, range=(0, 255)) |
| 130 | + hist = hist / hist.max() if hist.max() > 0 else hist |
| 131 | + for x in range(256): |
| 132 | + y0 = hist_y + hist_h |
| 133 | + y1 = hist_y + hist_h - hist[x] * hist_h |
| 134 | + canvas.create_line( |
| 135 | + hist_x + x * hist_w / 256, y0, |
| 136 | + hist_x + x * hist_w / 256, y1, |
| 137 | + fill=col |
| 138 | + ) |
| 139 | + |
| 140 | + # ---- LUMA ---- |
| 141 | + canvas.create_text(hist_x, hist_y + hist_h + 30, text="LUMA", fill="#aaa", anchor="w") |
| 142 | + hist, _ = np.histogram((Y * 255).astype(np.uint8), bins=256, range=(0, 255)) |
| 143 | + hist = hist / hist.max() if hist.max() > 0 else hist |
| 144 | + |
| 145 | + for x in range(256): |
| 146 | + y0 = hist_y + hist_h + 180 |
| 147 | + y1 = y0 - hist[x] * hist_h |
| 148 | + canvas.create_line( |
| 149 | + hist_x + x * hist_w / 256, y0, |
| 150 | + hist_x + x * hist_w / 256, y1, |
| 151 | + fill="white" |
| 152 | + ) |
| 153 | + |
| 154 | +# ================= CAPTURE THREAD ================= |
| 155 | +def capture_thread(): |
| 156 | + global latest_frame, video_writer |
| 157 | + with mss.mss() as sct: |
| 158 | + monitor = sct.monitors[1] |
| 159 | + while True: |
| 160 | + if running: |
| 161 | + img = np.array(sct.grab(monitor))[:, :, :3] |
| 162 | + |
| 163 | + # Apply ROI safely |
| 164 | + if roi: |
| 165 | + x1, y1, x2, y2 = roi |
| 166 | + # Convert to relative coordinates |
| 167 | + x1_rel = max(0, min(x1 - monitor["left"], img.shape[1]-1)) |
| 168 | + y1_rel = max(0, min(y1 - monitor["top"], img.shape[0]-1)) |
| 169 | + x2_rel = max(0, min(x2 - monitor["left"], img.shape[1])) |
| 170 | + y2_rel = max(0, min(y2 - monitor["top"], img.shape[0])) |
| 171 | + |
| 172 | + if x2_rel > x1_rel and y2_rel > y1_rel: |
| 173 | + img = img[y1_rel:y2_rel, x1_rel:x2_rel] |
| 174 | + else: |
| 175 | + img = None # Invalid ROI |
| 176 | + |
| 177 | + if img is not None: |
| 178 | + latest_frame = img |
| 179 | + |
| 180 | + if recording: |
| 181 | + h, w = img.shape[:2] |
| 182 | + if h > 0 and w > 0: # Make sure dimensions are valid |
| 183 | + if video_writer is None: |
| 184 | + video_writer = cv2.VideoWriter( |
| 185 | + "recording.mp4", |
| 186 | + cv2.VideoWriter_fourcc(*"mp4v"), |
| 187 | + FPS, |
| 188 | + (w, h) |
| 189 | + ) |
| 190 | + if video_writer.isOpened(): |
| 191 | + video_writer.write(cv2.cvtColor(img, cv2.COLOR_RGB2BGR)) |
| 192 | + |
| 193 | + time.sleep(1 / FPS) |
| 194 | + |
| 195 | +threading.Thread(target=capture_thread, daemon=True).start() |
| 196 | + |
| 197 | +# ================= UI LOOP ================= |
| 198 | +def update_ui(): |
| 199 | + if running and latest_frame is not None: |
| 200 | + draw_scopes(latest_frame) |
| 201 | + app.after(33, update_ui) |
| 202 | + |
| 203 | +update_ui() |
| 204 | + |
| 205 | +# ================= ROI ================= |
| 206 | +start_pt = None |
| 207 | + |
| 208 | +def on_mouse_down(e): |
| 209 | + global start_pt |
| 210 | + start_pt = (e.x_root, e.y_root) |
| 211 | + |
| 212 | +def on_mouse_up(e): |
| 213 | + global roi, start_pt |
| 214 | + if not start_pt: |
| 215 | + return |
| 216 | + x1, y1 = start_pt |
| 217 | + x2, y2 = e.x_root, e.y_root |
| 218 | + roi = (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)) |
| 219 | + start_pt = None |
| 220 | + |
| 221 | +canvas.bind("<ButtonPress-1>", on_mouse_down) |
| 222 | +canvas.bind("<ButtonRelease-1>", on_mouse_up) |
| 223 | + |
| 224 | +# ================= INPUT ================= |
| 225 | +def on_key(e): |
| 226 | + global roi |
| 227 | + if e.keysym == "Escape": |
| 228 | + app.destroy() |
| 229 | + if e.keysym == "space": |
| 230 | + x, y = app.winfo_pointerxy() |
| 231 | + with mss.mss() as sct: |
| 232 | + img = sct.grab(sct.monitors[1]) |
| 233 | + r, g, b = img.pixel(x, y) |
| 234 | + color_indicators.append((r/255, g/255, b/255)) |
| 235 | + if e.keysym == "r": |
| 236 | + roi = None |
| 237 | + |
| 238 | +app.bind("<Key>", on_key) |
| 239 | + |
| 240 | +# ================= RUN ================= |
| 241 | +app.mainloop() |
0 commit comments