From f9986addbc361a33c6789f0ad748367af4b387ca Mon Sep 17 00:00:00 2001 From: ConsistentlyInconsistentYT <76083357+ConsistentlyInconsistentYT@users.noreply.github.com> Date: Thu, 4 Sep 2025 06:39:47 +1200 Subject: [PATCH] Create PixelationDecensorer.py allows you to easily decensor moving windows in videos. --- PixelationDecensorer.py | 587 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 587 insertions(+) create mode 100644 PixelationDecensorer.py diff --git a/PixelationDecensorer.py b/PixelationDecensorer.py new file mode 100644 index 0000000..49cdd26 --- /dev/null +++ b/PixelationDecensorer.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +""" +This script works best for pixelation that uses nearest neighbour pixelation as opposed to +kernel averaging pixelation which will give more of a blur. I would recommend trying it out on +a yt-dlp downloaded copy of the Jeff Geerling video https://www.youtube.com/watch?v=acKYYwcxpGk + +Purpose +------- +Recover a higher-resolution (“super-resolution”) view of content seen through a +**stationary pixelation grid** (mosaic/blur grid) while a **window moves +underneath** it. + +The script assumes: +- The grid spacing (cell period) is fixed (e.g. ~25.2 px). +- The grid is static in image coordinates. +- The window/region you care about translates over time (no rotation/scale). + +High-level idea +--------------- +Each frame shows the same underlying scene sampled at *different sub-pixel +phases* because the window slides under the stationary grid. We: +1) **Track the window** by template matching on an *edge ring* around it + (robust to interior changes). +2) **Rebuild a canonical window** coordinate system (top-left aligned to the + first frame). +3) For each frame, compute every **grid cell centre inside the tracked window**, + sample the colour there, and **project it into the canonical window** at the + corresponding location. +4) **Accumulate** samples using either *nearest* or *bilinear* “splatting”. +5) Optionally **fill holes** using a box-filter growth (sum/count) until fully + covered. +6) Save the before-fill and final reconstructions plus optional debug overlays. + +What you do (interactive steps) +------------------------------- +1) **Pick the moving window** + A resizable OpenCV window opens on the first frame. + - Drag a rectangle around the window region you want. + - Press **ENTER** to accept, **R** to reset, **ESC** to cancel. + +2) **Set grid size & phase** + A second window shows the frame with an overlaid red grid you control. + - **Click one grid *intersection*** (corner where two grid lines meet). + - Adjust the **cell size** (spacing) and **phase** so the red lines match the + visible grid precisely. + + Key bindings: + - Cell size (both axes): **W/S** = ±1 px, **. / ,** = ±0.1 px + - Phase X/Y (grid line offset): + • **←/→** = ±1 px X, **↑/↓** = ±1 px Y + • **J/L** = ±0.1 px X, **I/K** = ±0.1 px Y + - **ENTER** accept, **R** reset, **ESC** cancel. + +3) **Let it run** + The script tracks the window across frames, samples centre pixels, projects + them into the canonical window, accumulates, and (optionally) fills holes. + +Outputs +------- +- `reconstruction_sr_before_fill.png` — SR result from raw accumulation. +- `reconstruction_sr.png` — SR result after optional hole-fill. +- (Optional) Debug folder `debug_gridtrack/` with: + - `overlays/grid_XXXXXX.png` — per-frame overlay (tracked window in yellow, + red grid lines, **green dots at cell *centres***). + - `tracking_log.csv` — frame index, tracked box, match response, sample count. + +Key parameters (edit in the "USER SETTINGS" section) +---------------------------------------------------- +- **VIDEO_PATH**: input video. +- **START_AT_FRAME / MAX_FRAMES / FRAME_STRIDE**: which frames to use. +- **CELL_SIZE / CELL_SIZE_Y**: measured grid period (can be non-integer). +- **SR_FACTOR**: 1 = native window resolution; 2–4 = true SR (more detail with + enough motion, slower & more memory). +- **SR_SPLAT_MODE**: `"nearest"` (crisper but aliasy) or `"bilinear"` (smoother, + better sub-pixel integration). +- **TRACK_SEARCH_MARGIN / TRACK_EDGE_RING / TRACK_MIN_RESPONSE**: robustness and + speed of the window tracker. Increase margin if the window jumps; increase + ring to rely more on the border; raise `TRACK_MIN_RESPONSE` to reject bad + matches (falls back to previous position). +- **FILL_MAX_ITERS**: 0 to disable fill; higher for more aggressive coverage. +- **SAVE_DEBUG / SAVE_OVERLAY_EVERY_FRAME / OVERLAY_PERIOD**: control debug + images and CSV logging. + +How it works (algorithm details) +-------------------------------- +- **Window tracking**: builds an *edge-magnitude ring* template from your picked + ROI. Each new frame is searched in a padded region using normalized template + matching. The peak location gives the new top-left; weak peaks retain the + previous location. +- **Centre sampling**: for the current tracked box `(x, y, w, h)`, we generate + the stationary grid’s **centre coordinates** inside the box: +""" + +import os, cv2, math, csv, numpy as np +from pathlib import Path + +# ───────────────────────── USER SETTINGS ───────────────────────── +VIDEO_PATH = "targetvideo.mp4" +START_AT_FRAME = 50 +MAX_FRAMES = 200 +FRAME_STRIDE = 1 + +# Stationary grid (measured) +CELL_SIZE = 25.2 # X period in pixels (float OK) +CELL_SIZE_Y = 25.2 # Y period in pixels; set different if rectangular + +# Super-resolution canvas +SR_FACTOR = 1 # 1=window native; 2–4 true SR (needs many frames) +SR_SPLAT_MODE = "bilinear" # "nearest" or "bilinear" + +# Tracking (translation only) +TRACK_SEARCH_MARGIN = 300 # search band (px) around last top-left +TRACK_EDGE_RING = 100 # border ring thickness for the template (px) +TRACK_MIN_RESPONSE = 0.05 # fallback to previous position if peak below this + +# Hole fill +FILL_MAX_ITERS = 1200 # 0 = disable + +# Debug +SAVE_DEBUG = False +DEBUG_DIR = "debug_gridtrack" +SAVE_OVERLAY_EVERY_FRAME = True # True: all frames; False: every N frames +OVERLAY_PERIOD = 10 # used only if SAVE_OVERLAY_EVERY_FRAME=False +OVERLAY_DRAW_CENTRES = True # draw green dots at centres +# ────────────────────────────────────────────────────────────────── + +# Arrow keys (common OpenCV keycodes) +KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN = 2424832, 2555904, 2490368, 2621440 + +def ensure_dir(p): Path(p).mkdir(parents=True, exist_ok=True) +def clamp(v, lo, hi): return max(lo, min(hi, v)) + +def to_gray_f32(img_bgr): + g = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) + return g.astype(np.float32) + +def draw_window(box, img, color=(0,255,255), thick=2): + x, y, w, h = box + cv2.rectangle(img, (x,y), (x+w-1,y+h-1), color, thick, cv2.LINE_AA) + +# ───────────── Window picker (drag) ───────────── +def window_picker(frame_bgr, title="Pick window (drag). ENTER accept • R reset • ESC cancel", + max_w=1600, max_h=1000): + H, W = frame_bgr.shape[:2] + rect_xyxy, drag = None, None + + def scale(): + try: + _,_,ww,hh = cv2.getWindowImageRect(title) + s = min(ww / max(1,W), hh / max(1,H)) + return s if np.isfinite(s) and s > 0 else min(1.0, max_w/W, max_h/H) + except Exception: + return min(1.0, max_w/W, max_h/H) + + def draw(s): + disp = cv2.resize(frame_bgr, (max(1,int(W*s)), max(1,int(H*s))), interpolation=cv2.INTER_AREA) + hud = "Drag to box the window. ENTER accept • R reset • ESC cancel" + cv2.putText(disp, hud, (10, max(24,int(24*s))), cv2.FONT_HERSHEY_SIMPLEX, + max(0.4,0.6*s), (0,255,255), max(1,int(2*s)), cv2.LINE_AA) + if rect_xyxy: + x1,y1,x2,y2 = rect_xyxy + p1 = (int(round(x1*s)), int(round(y1*s))) + p2 = (int(round(x2*s)), int(round(y2*s))) + cv2.rectangle(disp, p1, p2, (0,255,255), max(1,int(2*s)), cv2.LINE_AA) + return disp + + def on_mouse(ev, x, y, flags, _): + nonlocal drag, rect_xyxy + s = scale() + if ev == cv2.EVENT_LBUTTONDOWN: + drag = (int(round(x/s)), int(round(y/s))) + rect_xyxy = (drag[0], drag[1], drag[0], drag[1]) + elif ev == cv2.EVENT_MOUSEMOVE and drag is not None: + x0,y0 = drag + x1 = int(round(x/s)); y1 = int(round(y/s)) + x0 = clamp(x0,0,W-1); y0 = clamp(y0,0,H-1) + x1 = clamp(x1,0,W-1); y1 = clamp(y1,0,H-1) + rect_xyxy = (min(x0,x1), min(y0,y1), max(x0,x1), max(y0,y1)) + elif ev == cv2.EVENT_LBUTTONUP and drag is not None: + x0,y0 = drag + x1 = int(round(x/s)); y1 = int(round(y/s)) + x0 = clamp(x0,0,W-1); y0 = clamp(y0,0,H-1) + x1 = clamp(x1,0,W-1); y1 = clamp(y1,0,H-1) + rect_xyxy = (min(x0,x1), min(y0,y1), max(x0,y1), max(y0,y1)) + drag = None + + cv2.namedWindow(title, cv2.WINDOW_NORMAL | cv2.WINDOW_GUI_NORMAL) + cv2.resizeWindow(title, min(max_w, W), min(max_h, H)) + cv2.setMouseCallback(title, on_mouse) + + accepted = False + while True: + s = scale() + disp = draw(s) + cv2.imshow(title, disp) + k = cv2.waitKeyEx(20) + if k in (13,10): # ENTER + if rect_xyxy is None: continue + accepted = True; break + if k == 27: break + if k in (ord('r'), ord('R')): rect_xyxy = None + + cv2.destroyWindow(title) + if not accepted or rect_xyxy is None: return None + x1,y1,x2,y2 = rect_xyxy + return (x1, y1, max(1, x2-x1+1), max(1, y2-y1+1)) + +# ───────────── Grid phase picker (click + nudge) ───────────── +def grid_phase_picker(frame_bgr, cell_w=CELL_SIZE, cell_h=CELL_SIZE_Y, + title="Click an intersection. W/S ±1 • . , ±0.1 (size) • Arrows/IJKL = phase. ENTER accept • R reset • ESC cancel", + max_w=1600, max_h=1000): + H, W = frame_bgr.shape[:2] + click = None + cw, ch = float(cell_w), float(cell_h) + # Start phases at 0; clicking sets absolute phase; then arrows/JL/IK nudge + phase_x, phase_y = 0.0, 0.0 + + def scale(): + try: + _,_,ww,hh = cv2.getWindowImageRect(title) + s = min(ww / max(1,W), hh / max(1,H)) + return s if np.isfinite(s) and s > 0 else min(1.0, max_w/W, max_h/H) + except Exception: + return min(1.0, max_w/W, max_h/H) + + def draw(s): + disp = cv2.resize(frame_bgr, (max(1,int(W*s)), max(1,int(H*s))), interpolation=cv2.INTER_AREA) + # draw grid from current phase_x/phase_y and cw/ch + # verticals + x = phase_x + while x < W: + cv2.line(disp, (int(round(x*s)), 0), (int(round(x*s)), int(H*s)-1), (0,0,255), max(1,int(1*s))) + x += cw + # horizontals + y = phase_y + while y < H: + cv2.line(disp, (0, int(round(y*s))), (int(W*s)-1, int(round(y*s))), (0,0,255), max(1,int(1*s))) + y += ch + # click mark + if click is not None: + px, py = click + cv2.circle(disp, (int(round(px*s)), int(round(py*s))), max(2,int(4*s)), (0,255,0), -1, cv2.LINE_AA) + + hud1 = f"Cell≈({ch:.2f},{cw:.2f}) W/S ±1, . , ±0.1" + hud2 = f"Phase≈({phase_y:.2f},{phase_x:.2f}) Arrows ±1 (Y/X), I/K ±0.1 Y, J/L ±0.1 X" + hud3 = "Click any grid intersection → sets phase; ENTER accept • R reset • ESC cancel" + ytxt = max(24,int(24*s)) + for line in (hud1, hud2, hud3): + cv2.putText(disp, line, (10, ytxt), cv2.FONT_HERSHEY_SIMPLEX, + max(0.4,0.6*s), (0,255,255), max(1,int(2*s)), cv2.LINE_AA) + ytxt += max(20,int(20*s)) + return disp + + def on_mouse(ev, x, y, flags, _): + nonlocal click, phase_x, phase_y + if ev == cv2.EVENT_LBUTTONDOWN: + s = scale() + px = x / max(1e-6, s); py = y / max(1e-6, s) + if 0 <= px < W and 0 <= py < H: + click = (float(px), float(py)) + # set absolute phase from click + phase_x = float(px % cw) + phase_y = float(py % ch) + + cv2.namedWindow(title, cv2.WINDOW_NORMAL | cv2.WINDOW_GUI_NORMAL) + cv2.resizeWindow(title, min(max_w, W), min(max_h, H)) + cv2.setMouseCallback(title, on_mouse) + + accepted = False + while True: + s = scale() + disp = draw(s) + cv2.imshow(title, disp) + k = cv2.waitKeyEx(20) + if k in (13,10): # ENTER + if click is None: continue + accepted = True; break + if k == 27: break + if k in (ord('r'), ord('R')): + click = None + phase_x, phase_y = 0.0, 0.0 + + # size adjust + if k in (ord('w'), ord('W')): cw += 1.0; ch += 1.0 + if k in (ord('s'), ord('S')): cw = max(0.1, cw-1.0); ch = max(0.1, ch-1.0) + if k in (ord('.'), ord('>')): cw += 0.1; ch += 0.1 + if k in (ord(','), ord('<')): cw = max(0.1, cw-0.1); ch = max(0.1, ch-0.1) + + # phase adjust (±1 via arrows) + if k == KEY_LEFT: phase_x = (phase_x - 1.0) % cw + if k == KEY_RIGHT: phase_x = (phase_x + 1.0) % cw + if k == KEY_UP: phase_y = (phase_y - 1.0) % ch + if k == KEY_DOWN: phase_y = (phase_y + 1.0) % ch + # fine phase ±0.1 (I/K for Y, J/L for X) + if k in (ord('j'), ord('J')): phase_x = (phase_x - 0.1) % cw + if k in (ord('l'), ord('L')): phase_x = (phase_x + 0.1) % cw + if k in (ord('i'), ord('I')): phase_y = (phase_y - 0.1) % ch + if k in (ord('k'), ord('K')): phase_y = (phase_y + 0.1) % ch + + cv2.destroyWindow(title) + if not accepted or click is None: + return None + return {"phase_x": float(phase_x), "phase_y": float(phase_y), + "cell_w": float(cw), "cell_h": float(ch)} + +# ───────────── tracking ───────────── +def make_edge_ring_template(img_bgr, box, ring=10): + x,y,w,h = box + roi = img_bgr[y:y+h, x:x+w] + g = to_gray_f32(roi) + gx = cv2.Sobel(g, cv2.CV_32F, 1, 0, ksize=3) + gy = cv2.Sobel(g, cv2.CV_32F, 0, 1, ksize=3) + mag = cv2.magnitude(gx, gy) + mask = np.zeros_like(mag, np.uint8) + r = int(max(1, ring)) + mask[:r,:] = 1; mask[-r:,:] = 1; mask[:,:r] = 1; mask[:,-r:] = 1 + tmpl = mag * mask.astype(mag.dtype) + m, s = cv2.meanStdDev(tmpl) + if s[0,0] > 1e-6: + tmpl = (tmpl - m[0,0]) / (s[0,0] + 1e-6) + return tmpl + +def track_window_next(frame_bgr, last_box, tmpl_edge_norm, search_margin=80, min_resp=0.25): + H, W = frame_bgr.shape[:2] + x,y,w,h = last_box + sx0 = clamp(x - search_margin, 0, W-1) + sy0 = clamp(y - search_margin, 0, H-1) + sx1 = clamp(x + w + search_margin, 0, W) + sy1 = clamp(y + h + search_margin, 0, H) + patch = frame_bgr[sy0:sy1, sx0:sx1] + g = to_gray_f32(patch) + gx = cv2.Sobel(g, cv2.CV_32F, 1, 0, ksize=3) + gy = cv2.Sobel(g, cv2.CV_32F, 0, 1, ksize=3) + mag = cv2.magnitude(gx, gy) + pm, ps = cv2.meanStdDev(mag) + if ps[0,0] > 1e-6: + mag = (mag - pm[0,0]) / (ps[0,0] + 1e-6) + res = cv2.matchTemplate(mag, tmpl_edge_norm, cv2.TM_CCOEFF_NORMED) + _, peak, _, loc = cv2.minMaxLoc(res) + dx, dy = loc + nx = int(sx0 + dx); ny = int(sy0 + dy) + nx = clamp(nx, 0, W - w); ny = clamp(ny, 0, H - h) + if peak < float(min_resp): # keep previous if too weak + return (x, y, w, h), float(peak) + return (nx, ny, w, h), float(peak) + +# ───────────── hole fill ───────────── +def fill_grow_sum_count(color_sum_f32, count_sum_f32, max_iters): + if max_iters <= 0: + return color_sum_f32, count_sum_f32 + if color_sum_f32.ndim == 2: + color_sum_f32 = color_sum_f32[..., None] + H, W, C = color_sum_f32.shape + def box_sum_3(img2d): + return cv2.boxFilter(img2d, -1, (3,3), normalize=False, borderType=cv2.BORDER_REFLECT101) + zeros = (count_sum_f32 == 0) + it = 0 + while np.any(zeros): + if it >= max_iters: + print(f"⚠️ Fill reached {max_iters} iters; stopping.") + break + sum_k = box_sum_3(count_sum_f32) + sum_c = np.empty_like(color_sum_f32) + for ch in range(C): + sum_c[..., ch] = box_sum_3(color_sum_f32[..., ch]) + mask2 = zeros.astype(count_sum_f32.dtype) + color_sum_f32 += sum_c * mask2[..., None] + count_sum_f32 += sum_k * mask2 + new_zeros = (count_sum_f32 == 0) + if np.array_equal(new_zeros, zeros): + print("⚠️ Fill made no progress; aborting.") + break + zeros = new_zeros + it += 1 + return color_sum_f32, count_sum_f32 + +# ───────────── main ───────────── +def main(): + if SAVE_DEBUG: + ensure_dir(DEBUG_DIR) + ensure_dir(os.path.join(DEBUG_DIR, "overlays")) + log_path = os.path.join(DEBUG_DIR, "tracking_log.csv") + log_f = open(log_path, "w", newline="") + logger = csv.writer(log_f) + logger.writerow(["frame_idx","x","y","w","h","match_response","samples_this_frame"]) + else: + logger = None + log_f = None + + cap0 = cv2.VideoCapture(VIDEO_PATH) + if not cap0.isOpened(): + raise SystemExit(f"❌ Could not open video: {VIDEO_PATH}") + if START_AT_FRAME > 0: + cap0.set(cv2.CAP_PROP_POS_FRAMES, START_AT_FRAME) + ok, frame0 = cap0.read() + cap0.release() + if not ok: + raise SystemExit("❌ Could not read start frame.") + + # Step 1: pick the moving window + print("🖼️ Pick the moving window…") + init_box = window_picker(frame0) + if init_box is None: + raise SystemExit("Canceled window pick.") + x0, y0, w0, h0 = init_box + + # Step 2: set grid size & phase + print("➕ Click ONE stationary grid intersection and align the grid with keys…") + phase_info = grid_phase_picker(frame0, cell_w=CELL_SIZE, cell_h=CELL_SIZE_Y) + if phase_info is None: + raise SystemExit("Canceled grid phase pick.") + phase_x = phase_info["phase_x"] + phase_y = phase_info["phase_y"] + cell_w = phase_info["cell_w"] + cell_h = phase_info["cell_h"] + + print(f"✅ Window {init_box}, Grid cell≈({cell_h:.3f},{cell_w:.3f}), Phase≈({phase_y:.3f},{phase_x:.3f})") + + tmpl = make_edge_ring_template(frame0, init_box, ring=TRACK_EDGE_RING) + + # Prepare SR canvas (window-relative) + W_sr = int(round(w0 * SR_FACTOR)) + H_sr = int(round(h0 * SR_FACTOR)) + C = frame0.shape[2] if frame0.ndim == 3 else 1 + color_sum = np.zeros((H_sr, W_sr, C), dtype=np.float32) + count_sum = np.zeros((H_sr, W_sr), dtype=np.float32) + + cap = cv2.VideoCapture(VIDEO_PATH) + if not cap.isOpened(): + raise SystemExit(f"❌ Could not re-open video: {VIDEO_PATH}") + if START_AT_FRAME > 0: + cap.set(cv2.CAP_PROP_POS_FRAMES, START_AT_FRAME) + + last_box = (x0, y0, w0, h0) + processed = 0 + fidx = START_AT_FRAME - 1 + + print("🔄 Tracking & projecting…") + while True: + ok, frame = cap.read() + if not ok: + break + fidx += 1 + if (fidx - START_AT_FRAME) % FRAME_STRIDE != 0: + continue + if processed >= MAX_FRAMES: + break + + # Track window + box, resp = track_window_next(frame, last_box, tmpl, + search_margin=TRACK_SEARCH_MARGIN, + min_resp=TRACK_MIN_RESPONSE) + last_box = box + x, y, w, h = box + + # Build stationary **centre** grid inside this window + def first_center_k_at_or_after(a0, phase, step): + # smallest integer k such that phase + (k + 0.5)*step >= a0 + return math.ceil(((a0 - phase) / step) - 0.5) + + kx0 = first_center_k_at_or_after(x, phase_x, cell_w) + ky0 = first_center_k_at_or_after(y, phase_y, cell_h) + + xs = [] + k = kx0 + while True: + cx = phase_x + (k + 0.5) * cell_w + if cx >= x + w: break + if cx >= x: xs.append(cx) + k += 1 + + ys = [] + k = ky0 + while True: + cy = phase_y + (k + 0.5) * cell_h + if cy >= y + h: break + if cy >= y: ys.append(cy) + k += 1 + + samples_this = 0 + if xs and ys: + Xg, Yg = np.meshgrid(np.array(xs, np.float64), + np.array(ys, np.float64), indexing="xy") + Xi = np.rint(Xg).astype(int).clip(0, frame.shape[1]-1) + Yi = np.rint(Yg).astype(int).clip(0, frame.shape[0]-1) + vals = frame[Yi, Xi].astype(np.float32) # (Ny,Nx,3) + + # Project into canonical window coordinates + Xw = (Xg - x) * SR_FACTOR + Yw = (Yg - y) * SR_FACTOR + + Hsr, Wsr = color_sum.shape[:2] + if SR_SPLAT_MODE.lower() == "nearest": + Xn = np.rint(Xw).astype(np.int32) + Yn = np.rint(Yw).astype(np.int32) + valid = (Xn >= 0) & (Xn < Wsr) & (Yn >= 0) & (Yn < Hsr) + if np.any(valid): + xi = Xn[valid].ravel(); yi = Yn[valid].ravel() + v = vals[valid].reshape(-1, C) + idx = yi * Wsr + xi + cs = color_sum.reshape(-1, C) + np.add.at(cs, idx, v) + np.add.at(count_sum.ravel(), idx, 1.0) + samples_this = int(valid.sum()) + else: + x0i = np.floor(Xw).astype(np.int32) + y0i = np.floor(Yw).astype(np.int32) + wx = (Xw - x0i).astype(np.float32) + wy = (Yw - y0i).astype(np.float32) + v = vals.reshape(-1, C) + samples_this = Xw.size + coords = [ + (x0i, y0i, (1 - wx) * (1 - wy)), + (x0i + 1, y0i, wx * (1 - wy)), + (x0i, y0i + 1, (1 - wx) * wy), + (x0i + 1, y0i + 1, wx * wy), + ] + for Xn, Yn, Wn in coords: + valid = (Xn >= 0) & (Xn < Wsr) & (Yn >= 0) & (Yn < Hsr) & (Wn > 0) + if not np.any(valid): continue + xi = Xn[valid].ravel(); yi = Yn[valid].ravel() + wgt = Wn[valid].ravel().astype(np.float32) + idx = yi * Wsr + xi + cs = color_sum.reshape(-1, C) + np.add.at(cs, idx, v[valid.reshape(-1)] * wgt[:, None]) + np.add.at(count_sum.ravel(), idx, wgt) + + processed += 1 + + # Debug: CSV + overlays + if logger is not None: + logger.writerow([fidx, x, y, w, h, f"{resp:.4f}", samples_this]) + log_f.flush() + if SAVE_DEBUG: + overlay_this = SAVE_OVERLAY_EVERY_FRAME or (OVERLAY_PERIOD>0 and processed % OVERLAY_PERIOD==0) + if overlay_this: + vis = frame.copy() + draw_window((x,y,w,h), vis, (0,255,255), 2) + # draw grid lines (for visual reference) + # verticals + xv = phase_x + while xv < frame.shape[1]: + cv2.line(vis, (int(round(xv)), 0), (int(round(xv)), frame.shape[0]-1), + (0,0,255), 1, cv2.LINE_AA) + xv += cell_w + # horizontals + yv = phase_y + while yv < frame.shape[0]: + cv2.line(vis, (0, int(round(yv))), (frame.shape[1]-1, int(round(yv))), + (0,0,255), 1, cv2.LINE_AA) + yv += cell_h + # centres inside the window + if OVERLAY_DRAW_CENTRES and xs and ys: + for xg in xs: + for yg in ys: + cv2.circle(vis, (int(round(xg)), int(round(yg))), 1, (0,255,0), -1, cv2.LINE_AA) + # HUD + cv2.putText(vis, f"f={fidx} resp={resp:.3f} samples={samples_this} " + f"cell=({cell_h:.2f},{cell_w:.2f}) phase=({phase_y:.2f},{phase_x:.2f})", + (10, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,255,255), 2, cv2.LINE_AA) + cv2.imwrite(os.path.join(DEBUG_DIR, "overlays", f"grid_{fidx:06}.png"), vis) + + cap.release() + if SAVE_DEBUG and log_f: + log_f.close() + + if count_sum.sum() == 0: + print("❌ No samples collected; check the picks or parameters.") + return + + recon_before = (color_sum / np.maximum(count_sum, 1e-6)[..., None]).clip(0,255).astype(np.uint8) + cv2.imwrite("reconstruction_sr_before_fill.png", recon_before) + print("✅ wrote reconstruction_sr_before_fill.png") + + if FILL_MAX_ITERS > 0 and np.any(count_sum == 0): + print("🧩 Filling holes (grow)…") + csum_filled, ksum_filled = fill_grow_sum_count(color_sum.copy(), count_sum.copy(), FILL_MAX_ITERS) + recon = (csum_filled / np.maximum(ksum_filled, 1e-6)[..., None]).clip(0,255).astype(np.uint8) + else: + recon = recon_before + + cv2.imwrite("reconstruction_sr.png", recon) + print(f"✅ wrote reconstruction_sr.png (SR_FACTOR={SR_FACTOR}, frames_used={processed})") + + +if __name__ == "__main__": + main()