screengraft 0.44.0 → 0.45.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
package/scripts/detect.py CHANGED
@@ -427,7 +427,7 @@ def overlap_frac(inner: np.ndarray, outer: np.ndarray) -> float:
427
427
  return float(cv2.contourArea(region.astype(np.float32))) / a
428
428
 
429
429
 
430
- def pick_innermost(candidates, best):
430
+ def pick_innermost(candidates, best, quad_of=None):
431
431
  """
432
432
  A device photo offers more than one screen-shaped region: the glass screen,
433
433
  and the bezel or body it sits in. They nest, and the outer one wins on
@@ -442,20 +442,33 @@ def pick_innermost(candidates, best):
442
442
  old 0.35 floor a wave-shaped gradient at 49% of its screen was eligible;
443
443
  at 0.55 it isn't (an earlier finding, 3 Sep 2026).
444
444
  """
445
+ # `quad_of` maps a candidate to the quad its nesting is judged on. The
446
+ # walk passes the REFINED corners; the default is the raw polygon
447
+ # approximation. Raw vertices sit on the corner arcs, not at the corners,
448
+ # and on a phone photographed at ~45 degrees the glass's raw vertex landed
449
+ # ON the body's raw edge line -- `quad_contains` was false, and the body
450
+ # (tier 2, 13% off) shipped with the glass (tier 2, 1%, 87% of its area)
451
+ # one step behind it (12 Sep 2026, a two-phone mockup). Judged on the
452
+ # refined corners the glass is 10px inside the body all round. Replacing
453
+ # the containment test with area overlap was tried first and stepped into
454
+ # wrong inner quads on five photographs -- the strict test is doing work.
455
+ if quad_of is None:
456
+ quad_of = lambda c: c[1] # noqa: E731
445
457
  current = best
446
458
  for _ in range(4): # screen inside bezel inside body: a few steps is plenty
447
- c_area = cv2.contourArea(current[1].astype(np.float32))
459
+ cq = quad_of(current)
460
+ c_area = cv2.contourArea(cq.astype(np.float32))
448
461
  inner = [
449
462
  c for c in candidates
450
463
  if c is not current
451
464
  and c[0] >= 0.25 * current[0]
452
- and quad_contains(current[1], c[1])
453
- and NEST_FLOOR * c_area <= cv2.contourArea(c[1].astype(np.float32)) < c_area
465
+ and quad_contains(cq, quad_of(c))
466
+ and NEST_FLOOR * c_area <= cv2.contourArea(quad_of(c).astype(np.float32)) < c_area
454
467
  ]
455
468
  if not inner:
456
469
  return current
457
470
  # Largest of the nested ones: the screen, not a panel drawn on it.
458
- current = max(inner, key=lambda c: cv2.contourArea(c[1].astype(np.float32)))
471
+ current = max(inner, key=lambda c: cv2.contourArea(quad_of(c).astype(np.float32)))
459
472
  return current
460
473
 
461
474
 
@@ -668,10 +681,20 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
668
681
  # Deliberately NOT tier-first ordering of the whole list: that was tried
669
682
  # on 11 Sep 2026 and broke three photographs, because a tier-2 WRONG
670
683
  # candidate sits deeper in the list on each.
684
+ # Refined corners, once per candidate: the walk order reads tiers off them
685
+ # and pick_innermost judges nesting on them (raw polygon vertices sit on
686
+ # the arcs and can land on a neighbouring quad's edge line -- see there).
687
+ shapes = {}
688
+
671
689
  def _shape_of(cand):
672
- _score, quad, contour, _tag = cand
673
- q = order_quad(refine_corners(contour, quad, rail_band)[0]) if refine else quad
674
- return q, shape_tier({"corner_radius": measure_corner_radius(contour, q)})
690
+ if id(cand) not in shapes:
691
+ _score, quad, contour, _tag = cand
692
+ q = order_quad(refine_corners(contour, quad, rail_band)[0]) if refine else quad
693
+ shapes[id(cand)] = (q, shape_tier({"corner_radius": measure_corner_radius(contour, q)}))
694
+ return shapes[id(cand)]
695
+
696
+ def _refined(cand):
697
+ return _shape_of(cand)[0]
675
698
 
676
699
  order = sorted(candidates, key=lambda c: c[0], reverse=True)
677
700
  if order:
@@ -691,7 +714,7 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
691
714
  # NOT necessarily `cand`: pick_innermost steps inward from it while a
692
715
  # comparably screen-like quad nests inside, so the quad that gets
693
716
  # validated -- and returned -- can belong to a different candidate.
694
- picked = pick_innermost(candidates, cand)
717
+ picked = pick_innermost(candidates, cand, quad_of=_refined)
695
718
  score, quad, contour, tag = picked
696
719
  if refine:
697
720
  refined, did_refine = refine_corners(contour, quad, rail_band)
@@ -5,7 +5,7 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
5
5
 
6
6
  # Inject a screenshot onto a photographed device
7
7
 
8
- **What ships (v0.44):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
8
+ **What ships (v0.45):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
9
9
 
10
10
  The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
11
11