screengraft 0.17.0 → 0.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # screengraft
2
2
 
3
- **Put a UI screenshot onto a photographed screen so the perspective is exactly right.**
3
+ **Put a UI screenshot — or a screen recording — onto a photographed screen so the perspective is exactly right.**
4
4
 
5
5
  ![The fitting workbench: photo with the screen quad on the left, live composite on the right, and a magnified strip across the edge below](docs/workbench.png)
6
6
 
@@ -13,6 +13,9 @@ transform between them. The screenshot lands on the glass because the geometry
13
13
  says it must, not because a model thought it looked about right. Same inputs,
14
14
  same output, every time.
15
15
 
16
+ Point it at a **video** instead and the same fit renders every frame: record a
17
+ prototype, then put the recording inside a real photograph.
18
+
16
19
  ---
17
20
 
18
21
  ## What it does
@@ -26,6 +29,10 @@ same output, every time.
26
29
  - **Realism pass** *(optional)* — matches the screen's white balance and grain to
27
30
  the light in the room, and can lift the device's real reflections from a
28
31
  screen-off frame of the same shot.
32
+ - **Video, not just stills.** The screen source can be an `mp4`/`mov`/`webm`.
33
+ You match the edges on one frame and every frame gets that same geometry — the
34
+ photograph is still, so there is nothing to track and nothing to drift. Output
35
+ is H.264 at CRF 16 or ProRes 422 HQ.
29
36
  - **You confirm every fit.** Detection is advisory and says so; you drag the four
30
37
  edges onto the glass with a magnified loupe. A silent misdetection producing a
31
38
  confident, wrong result is the one failure this tool refuses to have.
@@ -36,6 +43,10 @@ same output, every time.
36
43
  without it. The installer provisions an isolated venv at `~/.screengraft/venv`
37
44
  and never touches your system Python.
38
45
 
46
+ Video rendering also uses **ffmpeg**, which arrives as a wheel (`imageio-ffmpeg`)
47
+ into that same venv — nothing is installed system-wide. It is optional: without
48
+ it, stills work exactly as before.
49
+
39
50
  ## Install as a Claude Code / Cowork plugin
40
51
 
41
52
  ```
@@ -104,15 +115,31 @@ outside the screen mask. It never touches the pixels you designed.
104
115
  3. **Realism pass** *(optional)* — white balance and exposure toward the
105
116
  surrounding light, grain matched to the photo's own noise floor, real
106
117
  speculars lifted from a screen-off reference.
118
+ 4. **Video**, when the source is a clip — everything a fixed photo and a fixed
119
+ quad make constant is computed once, and only the frame changes. Three
120
+ consequences worth naming, because each is a way video normally goes wrong:
121
+ the light match is measured **once** from the frame you fitted on, so the
122
+ screen cannot pulse as your UI scrolls from dark to light; the grain stays
123
+ frozen, because the photograph's own noise does not move; and the screen's
124
+ antialiased **edge is pixel-identical in every frame**, so there is no edge
125
+ crawl. Frame 0 of a render is byte-identical to the still composite — the
126
+ test suite asserts it, because that is what stops the two paths drifting.
107
127
 
108
128
  ## Roadmap
109
129
 
110
- Done: manual warp, advisory detectors, the fitting workbench, the realism pass.
130
+ Done: manual warp, advisory detectors, the fitting workbench, the realism pass,
131
+ video into a still photo.
132
+
133
+ Open: **camera-motion tracking** — the photograph itself must currently be a
134
+ still, so a clip of a moving phone is out of scope; **SAM 2 auto-detect** (built
135
+ and measured in a separate repo; it segments the phone body rather than the
136
+ glass, so it is not shipped); **occluder matte**, so a finger in front of the
137
+ screen stays in front.
111
138
 
112
- Open: video tracking (M3), SAM 2 auto-detect (M4 built and measured in a
113
- separate repo; it currently segments the phone body rather than the glass, so it
114
- is not shipped), occluder matte (M5), so a finger in front of the screen stays in
115
- front.
139
+ Known limits worth stating plainly: detection abstains rather than guessing when
140
+ the background is itself neutral (a pale tiled floor, a plain wall) you place
141
+ the edges by hand there. And a prototype recording has no motion blur, so a very
142
+ fast scroll will strobe; that is a property of the source, not of the composite.
116
143
 
117
144
  ## Contributing
118
145
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.17.0",
4
- "description": "Put a UI screenshot onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
3
+ "version": "0.20.4",
4
+ "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
7
7
  "device-frame",
@@ -9,6 +9,9 @@
9
9
  "perspective",
10
10
  "compositing",
11
11
  "screenshot",
12
+ "prototype",
13
+ "screen-recording",
14
+ "video",
12
15
  "design",
13
16
  "portfolio",
14
17
  "figma"
package/scripts/grade.py CHANGED
@@ -61,48 +61,75 @@ def _stats(lab: np.ndarray, sel: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
61
61
  return px.mean(axis=0), px.std(axis=0) + 1e-6
62
62
 
63
63
 
64
- def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
65
- strength: float = DEFAULT_STRENGTH) -> np.ndarray:
66
- """Move the injected screen's cast and exposure toward the surrounding light.
67
-
68
- Chroma (a,b) is matched on mean AND spread a cast is exactly a chroma mean
69
- offset, and a room with weak colour should not receive a saturated screen.
70
- Luminance is matched on MEAN ONLY, and bounded: a screen is emissive and is
71
- allowed to be brighter than its surroundings, so rescaling its L spread to
72
- the bezel's would crush the UI's own contrast. That asymmetry is the whole
73
- design of this function.
64
+ def light_params(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
65
+ strength: float = DEFAULT_STRENGTH):
66
+ """Measure the correction ONCE, so it can be applied to many frames.
67
+
68
+ Split out of match_light for video. The correction depends on the
69
+ screen's own content through `m_in`/`s_in`, so measuring it per frame makes
70
+ it drift as the UI scrolls from a dark screen to a light one — the injected
71
+ screen would visibly pulse. Deriving the parameters from one frame and
72
+ applying the identical transform to all of them is the whole fix.
73
+
74
+ Returns None when there is too little context to measure honestly, which
75
+ the caller must treat as "leave the frame alone".
74
76
  """
75
77
  if strength <= 0:
76
- return warped
78
+ return None
77
79
  ring = surround_ring(mask)
78
- if int(ring.sum()) < 500: # too little context to measure honestly
79
- return warped
80
-
81
- lab_photo = cv2.cvtColor(photo, cv2.COLOR_BGR2LAB).astype(np.float64)
82
- lab_warp = cv2.cvtColor(warped, cv2.COLOR_BGR2LAB).astype(np.float64)
80
+ if int(ring.sum()) < 500:
81
+ return None
83
82
  inside = (mask > 200).astype(np.uint8)
84
83
  if int(inside.sum()) < 500:
85
- return warped
86
-
84
+ return None
85
+ lab_photo = cv2.cvtColor(photo, cv2.COLOR_BGR2LAB).astype(np.float64)
86
+ lab_warp = cv2.cvtColor(warped, cv2.COLOR_BGR2LAB).astype(np.float64)
87
87
  m_out, s_out = _stats(lab_photo, ring)
88
88
  m_in, s_in = _stats(lab_warp, inside)
89
-
89
+ return {
90
+ "m_in": m_in, "s_in": s_in, "m_out": m_out, "s_out": s_out,
91
+ "strength": float(strength),
92
+ # Same clamp as match_light: a screen is emissive and may be brighter
93
+ # than the room, so L moves by a bounded mean shift only.
94
+ "dL": float(np.clip(m_out[0] - m_in[0], -12.0, 12.0)) * float(strength),
95
+ }
96
+
97
+
98
+ def apply_light(warped: np.ndarray, params) -> np.ndarray:
99
+ """Apply parameters from light_params() to one frame."""
100
+ if params is None:
101
+ return warped
102
+ lab_warp = cv2.cvtColor(warped, cv2.COLOR_BGR2LAB).astype(np.float64)
103
+ m_in, s_in = params["m_in"], params["s_in"]
104
+ m_out, s_out = params["m_out"], params["s_out"]
105
+ strength = params["strength"]
90
106
  out = lab_warp.copy()
91
- # a,b: full Reinhard transfer, scaled by strength.
92
107
  for c in (1, 2):
93
108
  moved = (lab_warp[:, :, c] - m_in[c]) * float(s_out[c] / s_in[c]) + m_out[c]
94
109
  out[:, :, c] = lab_warp[:, :, c] + (moved - lab_warp[:, :, c]) * strength
95
- # L: mean shift only, and capped at +-12 L* so a dark room cannot switch
96
- # the screen off. 12 is about a stop; beyond that it stops reading as the
97
- # same screenshot.
98
- dL = float(np.clip(m_out[0] - m_in[0], -12.0, 12.0)) * strength
99
- out[:, :, 0] = lab_warp[:, :, 0] + dL
100
-
110
+ out[:, :, 0] = lab_warp[:, :, 0] + params["dL"]
101
111
  out[:, :, 0] = np.clip(out[:, :, 0], 0, 255)
102
112
  out[:, :, 1:] = np.clip(out[:, :, 1:], 0, 255)
103
113
  return cv2.cvtColor(out.astype(np.uint8), cv2.COLOR_LAB2BGR)
104
114
 
105
115
 
116
+ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
117
+ strength: float = DEFAULT_STRENGTH) -> np.ndarray:
118
+ """Move the injected screen's cast and exposure toward the surrounding light.
119
+
120
+ Chroma (a,b) is matched on mean AND spread — a cast is exactly a chroma mean
121
+ offset, and a room with weak colour should not receive a saturated screen.
122
+ Luminance is matched on MEAN ONLY, and bounded: a screen is emissive and is
123
+ allowed to be brighter than its surroundings, so rescaling its L spread to
124
+ the bezel's would crush the UI's own contrast. That asymmetry is the whole
125
+ design of this function.
126
+ """
127
+ # One implementation, two entry points: measuring and applying are the same
128
+ # arithmetic whether it runs on a still or on frame 900 of a clip. Keeping a
129
+ # second copy here is how the two paths would drift.
130
+ return apply_light(warped, light_params(photo, warped, mask, strength))
131
+
132
+
106
133
  def measure_grain(photo: np.ndarray, ring: np.ndarray) -> float:
107
134
  """The photo's noise floor, in grey levels, measured where the screen isn't.
108
135
 
@@ -87,7 +87,19 @@ def report():
87
87
  "install_command": f"{sys.executable} {os.path.abspath(__file__)} --install",
88
88
  "install_does": f"Creates a virtualenv at {VENV} (nothing touches system Python) and pip-installs "
89
89
  f"{', '.join(pkg for _, pkg, _ in REQUIRED)} into it (~60 MB download).",
90
+ # Optional, and deliberately NOT part of `ready`. A missing ffmpeg
91
+ # stops video renders and nothing else; making it a hard requirement
92
+ # would fail preflight for every user who only ever injects a
93
+ # screenshot, which is most of them.
90
94
  "optional": [
95
+ {"name": "ffmpeg (video)", "package": "imageio-ffmpeg",
96
+ "status": "installed" if _have_ffmpeg() else "missing",
97
+ "needed_for": "rendering a video; stills do not use it",
98
+ "install_command": f"{sys.executable} {os.path.abspath(__file__)} --install-ffmpeg",
99
+ "install_size": "~25 MB, a wheel with a static binary; nothing system-wide",
100
+ "why": "Encodes video renders. Ships as a wheel with a static binary, "
101
+ "so it lands in the same venv and nothing is installed "
102
+ "system-wide. Stills do not need it."},
91
103
  {"name": "SAM 2 (M4)", "status": "not yet used by the plugin",
92
104
  "why": "Better screen detection on photos where tone-based detection fails. Optional; results are worse without it, not absent."}
93
105
  ],
@@ -95,6 +107,23 @@ def report():
95
107
  }
96
108
 
97
109
 
110
+ def _have_ffmpeg() -> bool:
111
+ """Is the ffmpeg wheel importable IN THE VENV (not in whatever Python runs this)?"""
112
+ code = ("import json\n"
113
+ "try:\n"
114
+ " import imageio_ffmpeg, os\n"
115
+ " p = imageio_ffmpeg.get_ffmpeg_exe()\n"
116
+ " print(json.dumps(bool(p and os.path.exists(p))))\n"
117
+ "except Exception:\n"
118
+ " print('false')\n")
119
+ try:
120
+ r = subprocess.run([VENV_PY, "-c", code], capture_output=True, text=True,
121
+ timeout=60, check=False)
122
+ return r.stdout.strip() == "true"
123
+ except Exception:
124
+ return False
125
+
126
+
98
127
  def install():
99
128
  os.makedirs(os.path.dirname(VENV), exist_ok=True)
100
129
  if not os.path.exists(VENV_PY):
@@ -103,12 +132,31 @@ def install():
103
132
  subprocess.check_call([VENV_PY, "-m", "pip", "install", "-q", "-r", REQ])
104
133
 
105
134
 
135
+ def install_ffmpeg():
136
+ """Add just the video encoder to an existing venv.
137
+
138
+ A separate entry point because of who needs it: someone who installed
139
+ screengraft before video existed has a perfectly good venv with OpenCV in
140
+ it, `ready` is true, and nothing tells them anything is missing until a
141
+ render fails at the end of the job. This adds the one wheel, so the ask is
142
+ "~25 MB for video" rather than "reinstall everything".
143
+ """
144
+ if not os.path.exists(VENV_PY):
145
+ install()
146
+ return
147
+ subprocess.check_call([VENV_PY, "-m", "pip", "install", "-q", "imageio-ffmpeg"])
148
+
149
+
106
150
  def main():
107
151
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
108
152
  ap.add_argument("--install", action="store_true", help="Create the venv and install requirements (ask the user first)")
153
+ ap.add_argument("--install-ffmpeg", action="store_true",
154
+ help="Add just the video encoder to an existing venv (ask the user first)")
109
155
  args = ap.parse_args()
110
156
  if args.install:
111
157
  install()
158
+ elif args.install_ffmpeg:
159
+ install_ffmpeg()
112
160
  rep = report()
113
161
  print(json.dumps(rep, indent=1))
114
162
  sys.exit(0 if rep["ready"] else 1)
@@ -1,2 +1,3 @@
1
1
  opencv-python-headless>=4.9
2
2
  numpy>=1.26
3
+ imageio-ffmpeg>=0.4
package/scripts/ui.py CHANGED
@@ -168,6 +168,69 @@ def _read_image(path: str):
168
168
  return im, p
169
169
 
170
170
 
171
+ VIDEO_EXT = (".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv")
172
+
173
+
174
+ def _have_ffmpeg() -> bool:
175
+ try:
176
+ W.ffmpeg_exe()
177
+ return True
178
+ except RuntimeError:
179
+ return False
180
+
181
+
182
+ def _is_video(path: str) -> bool:
183
+ return str(path).lower().endswith(VIDEO_EXT)
184
+
185
+
186
+ def _read_source(path: str):
187
+ """Read the screen source, which may be a still OR a video.
188
+
189
+ Returns (frame, real_path, meta). For a video the frame is the poster —
190
+ the frame the designer fits on — and `meta` carries what the page needs to
191
+ show a scrubber. Everything downstream of this point treats that frame
192
+ exactly like a screenshot, which is the point: the fit, the loupe, the
193
+ compare view and the preview are all unchanged by video.
194
+ """
195
+ real = _safe_local_path(path)
196
+ if not _is_video(real):
197
+ im, rp = _read_image(real)
198
+ return im, rp, {"video": False}
199
+ n, fps, vw, vh = W.probe_video(real)
200
+ frame = W.read_frame_at(real, 0)
201
+ # Report the encoder's absence HERE, when the clip is chosen, rather than
202
+ # letting the render fail at the end of the job. Someone who installed
203
+ # screengraft before video existed has a working venv with no ffmpeg in it,
204
+ # and nothing else would tell them until they had done all the fitting.
205
+ return frame, real, {"video": True, "frames": n, "fps": fps, "size": [vw, vh],
206
+ "ffmpeg": _have_ffmpeg()}
207
+
208
+
209
+ # Render progress, read by /api/render_status. A ten-second clip is a few
210
+ # hundred frames and a good few seconds of work, which is far too long to hold
211
+ # an HTTP request open — so the render runs on its own thread and the page
212
+ # polls. ThreadingHTTPServer is already the server class, so this needs no
213
+ # other machinery.
214
+ RENDER = {"state": "idle", "done": 0, "total": 0, "output": None, "message": None}
215
+ RENDER_LOCK = threading.Lock()
216
+
217
+
218
+ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame):
219
+ def progress(done, total):
220
+ with RENDER_LOCK:
221
+ RENDER["done"], RENDER["total"] = done, total
222
+ try:
223
+ info = W.compose_video(photo, video_path, corners, dest,
224
+ corner_radius=radius_px, grade=gr, grain=grain,
225
+ preset=preset, fit_frame=fit_frame, progress=progress)
226
+ with RENDER_LOCK:
227
+ RENDER.update(state="done", output=dest, info=info,
228
+ done=info["frames"], total=info["frames"], message=None)
229
+ except Exception as e: # noqa: BLE001 - surfaced to the page
230
+ with RENDER_LOCK:
231
+ RENDER.update(state="error", message=str(e))
232
+
233
+
171
234
  def _guess_type(corners):
172
235
  c = np.array(corners, dtype=float)
173
236
  w = (np.linalg.norm(c[1] - c[0]) + np.linalg.norm(c[2] - c[3])) / 2
@@ -259,6 +322,12 @@ class Handler(BaseHTTPRequestHandler):
259
322
  if time.time() >= deadline:
260
323
  return self._json({"status": "pending", "waited": True})
261
324
  time.sleep(0.15)
325
+ if u.path == "/api/render_status":
326
+ # A poll, so a GET: no body, safe to repeat, and the page hits
327
+ # it once a second while a render runs.
328
+ with RENDER_LOCK:
329
+ return self._json(dict(RENDER))
330
+
262
331
  return self._json({"error": "no such route"}, 404)
263
332
  except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
264
333
  return self._json({"error": str(e)}, 400)
@@ -274,17 +343,26 @@ class Handler(BaseHTTPRequestHandler):
274
343
  dest = os.path.join(SESSION.dir, f"{role}-{int(time.time())}-{name}")
275
344
  with open(dest, "wb") as f:
276
345
  f.write(self._body())
277
- im, real = _read_image(dest)
346
+ # A video is only ever a screen source; a photo must be a still.
347
+ if role == "screenshot":
348
+ im, real, meta = _read_source(dest)
349
+ else:
350
+ im, real = _read_image(dest)
351
+ meta = {"video": False}
278
352
  SESSION.update(**{role: real})
279
- return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
353
+ return self._json({"path": real, "size": [im.shape[1], im.shape[0]], **meta})
280
354
 
281
355
  b = self._jbody()
282
356
 
283
357
  if u.path == "/api/use":
284
358
  role = b["role"]
285
- im, real = _read_image(b["path"])
359
+ if role == "screenshot":
360
+ im, real, meta = _read_source(b["path"])
361
+ else:
362
+ im, real = _read_image(b["path"])
363
+ meta = {"video": False}
286
364
  SESSION.update(**{role: real})
287
- return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
365
+ return self._json({"path": real, "size": [im.shape[1], im.shape[0]], **meta})
288
366
 
289
367
  if u.path == "/api/figma":
290
368
  return self._json(SESSION.enqueue({
@@ -350,9 +428,72 @@ class Handler(BaseHTTPRequestHandler):
350
428
  res["type_guess"] = _guess_type(res["corners"])
351
429
  return self._json(res)
352
430
 
431
+ if u.path == "/api/frame":
432
+ # One frame of the source video as a PNG the page can show — the
433
+ # poster, or whichever frame the scrubber is on. The fit, the
434
+ # loupe and the compare view all work on this exactly as they
435
+ # work on a screenshot, which is why none of them needed
436
+ # changing for video.
437
+ spath = _safe_local_path(SESSION.state["screenshot"])
438
+ if not _is_video(spath):
439
+ return self._json({"error": "the screen source is not a video"}, 400)
440
+ idx = int(b.get("index") or 0)
441
+ frame = W.read_frame_at(spath, idx)
442
+ dest = os.path.join(SESSION.dir, f"frame-{idx:06d}.png")
443
+ cv2.imwrite(dest, frame, [cv2.IMWRITE_PNG_COMPRESSION, 1])
444
+ return self._json({"path": dest, "index": idx,
445
+ "size": [frame.shape[1], frame.shape[0]]})
446
+
447
+ if u.path == "/api/render":
448
+ # Video: same fit, same geometry, N frames instead of one.
449
+ photo, ppath = _read_image(SESSION.state["photo"])
450
+ spath = _safe_local_path(SESSION.state["screenshot"])
451
+ if not _is_video(spath):
452
+ return self._json({"error": "the screen source is not a video"}, 400)
453
+ if not _have_ffmpeg():
454
+ return self._json({"error": "ffmpeg is not installed",
455
+ "needs_ffmpeg": True}, 400)
456
+ with RENDER_LOCK:
457
+ if RENDER["state"] == "running":
458
+ return self._json({"error": "a render is already running"}, 409)
459
+ RENDER.update(state="running", done=0, total=0,
460
+ output=None, message=None)
461
+ corners = b["corners"]
462
+ frac = float(b.get("radius_frac") or 0.0)
463
+ fit_frame = int(b.get("fit_frame") or 0)
464
+ first = W.read_frame_at(spath, fit_frame)
465
+ radius_px = frac * first.shape[1]
466
+ gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
467
+ grain = bool(b.get("grain", gr > 0))
468
+ preset = "prores" if b.get("preset") == "prores" else "web"
469
+ ext = ".mov" if preset == "prores" else ".mp4"
470
+ os.makedirs(OUT_DIR, exist_ok=True)
471
+ stem = (f"{os.path.splitext(os.path.basename(ppath))[0]}__"
472
+ f"{os.path.splitext(os.path.basename(spath))[0]}")
473
+ dest = os.path.join(OUT_DIR, stem + ext)
474
+ i = 2
475
+ while os.path.exists(dest):
476
+ dest = os.path.join(OUT_DIR, f"{stem}-{i}{ext}"); i += 1
477
+ SESSION.update(corners=corners, radius_frac=frac,
478
+ device=b.get("device"), grade=gr)
479
+ # Everything that changes the output goes in the sidecar, for the
480
+ # third time of asking (radius_px, then grade/grain, now the video
481
+ # fields). A render that cannot be reproduced from its own sidecar
482
+ # undercuts the determinism claim.
483
+ result = {"output": dest, "photo": ppath, "screenshot": spath,
484
+ "corners": corners, "radius_frac": frac, "radius_px": radius_px,
485
+ "device": b.get("device"), "grade": gr, "grain": grain,
486
+ "video": True, "preset": preset, "fit_frame": fit_frame,
487
+ "saved": time.time()}
488
+ _write_json_atomic(SESSION.result_path, result)
489
+ threading.Thread(target=_render_worker, daemon=True,
490
+ args=(photo, spath, corners, dest, radius_px,
491
+ gr, grain, preset, fit_frame)).start()
492
+ return self._json({"started": True, "output": dest, "preset": preset})
493
+
353
494
  if u.path in ("/api/preview", "/api/save"):
354
495
  photo, ppath = _read_image(SESSION.state["photo"])
355
- shot, spath = _read_image(SESSION.state["screenshot"])
496
+ shot, spath, _meta = _read_source(SESSION.state["screenshot"])
356
497
  corners = b["corners"]
357
498
  frac = float(b.get("radius_frac") or 0.0)
358
499
  radius_px = frac * shot.shape[1]