screengraft 0.43.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.43.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
@@ -130,6 +130,12 @@ ABSTAIN_GAP = 0.15
130
130
  # small part of it. That one ratio separates "step inward to the screen" from
131
131
  # "don't step into a panel", and it arbitrates between the two detectors too.
132
132
  NEST_FLOOR = 0.55
133
+ # "Nested" means this much of the inner quad's area falls inside the outer.
134
+ # One number for every place that asks (arbitrate, the walk order, the
135
+ # abstention veto) so they cannot drift apart. 12 Sep 2026: on 18 labelled
136
+ # photographs the quads that are nested overlap at >= 0.95 and the ones that
137
+ # are not at 0.00 -- there is nothing in between to be careful about.
138
+ NESTED_OVERLAP = 0.90
133
139
 
134
140
 
135
141
  def _odd(n: int) -> int:
@@ -421,7 +427,7 @@ def overlap_frac(inner: np.ndarray, outer: np.ndarray) -> float:
421
427
  return float(cv2.contourArea(region.astype(np.float32))) / a
422
428
 
423
429
 
424
- def pick_innermost(candidates, best):
430
+ def pick_innermost(candidates, best, quad_of=None):
425
431
  """
426
432
  A device photo offers more than one screen-shaped region: the glass screen,
427
433
  and the bezel or body it sits in. They nest, and the outer one wins on
@@ -436,20 +442,33 @@ def pick_innermost(candidates, best):
436
442
  old 0.35 floor a wave-shaped gradient at 49% of its screen was eligible;
437
443
  at 0.55 it isn't (an earlier finding, 3 Sep 2026).
438
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
439
457
  current = best
440
458
  for _ in range(4): # screen inside bezel inside body: a few steps is plenty
441
- c_area = cv2.contourArea(current[1].astype(np.float32))
459
+ cq = quad_of(current)
460
+ c_area = cv2.contourArea(cq.astype(np.float32))
442
461
  inner = [
443
462
  c for c in candidates
444
463
  if c is not current
445
464
  and c[0] >= 0.25 * current[0]
446
- and quad_contains(current[1], c[1])
447
- 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
448
467
  ]
449
468
  if not inner:
450
469
  return current
451
470
  # Largest of the nested ones: the screen, not a panel drawn on it.
452
- 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)))
453
472
  return current
454
473
 
455
474
 
@@ -635,34 +654,67 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
635
654
  # project has never had the number. Off unless asked for, and it must not
636
655
  # change the answer: it observes the same lists the walk below uses.
637
656
  walked = {}
638
- # Walk order: by score -- EXCEPT when the best-scoring candidate has no
639
- # shape evidence at all (tier 0: sharp, unmeasurable, or inconsistent
640
- # corners) while a tier-2 candidate exists in the same list. A tier-0
641
- # winner cannot be corroborated and ends in an abstention or a refusal, so
642
- # nothing is lost by looking past it; a tier-2 candidate has four corners
643
- # agreeing on a radius, which on the labelled corpus has meant "on the
644
- # screen" for every channel-accepted result. Deliberately NOT tier-first
645
- # ordering of the whole list: that was tried on 11 Sep 2026 and broke three
646
- # photographs, because a tier-2 WRONG candidate sits deeper in the list on
647
- # each. This only moves when score alone would have handed the answer to a
648
- # quad with nothing to say for itself.
649
- def _tier_of(cand):
650
- _score, quad, contour, _tag = cand
651
- q = order_quad(refine_corners(contour, quad, rail_band)[0]) if refine else quad
652
- return shape_tier({"corner_radius": measure_corner_radius(contour, q)})
657
+ # Walk order: by score -- EXCEPT when the best-scoring candidate is not
658
+ # itself confident (tier < 2) while a tier-2 candidate exists in the same
659
+ # list. Two cases, and they are different:
660
+ #
661
+ # * Top is tier 0 (sharp, unmeasurable, or inconsistent corners): it cannot
662
+ # be corroborated and ends in an abstention or a refusal, so nothing is
663
+ # lost by looking past it to ANY tier-2 candidate.
664
+ # * Top is tier 1 (rounded, but the four radii disagree): it is a real
665
+ # finding, and only a tier-2 candidate NESTED INSIDE it may move ahead --
666
+ # the same finding narrowed to the part whose four corners agree, which
667
+ # is how arbitrate() already reads a confident quad inside a loosely
668
+ # rounded one (tiers before area ratio). A tier-2 candidate somewhere
669
+ # ELSE is a different finding and does not displace a rounded top on
670
+ # tier alone. Measured 12 Sep 2026 on 18 labelled photographs: every
671
+ # tier-2 that should jump a tier-1 top lies inside it at >= 0.95
672
+ # overlap; every one that must not (tone's patches on two photographs,
673
+ # an edge quad on a third) overlaps it at 0.00. Without the nesting
674
+ # condition the tone channel promoted a disjoint tier-2 patch 202% off
675
+ # on two photographs and the answer was lost to abstention. The case
676
+ # that needed this is a front-and-back mockup: the edge channel's top
677
+ # candidate is the bounding box of BOTH phones (tier 1, 105% off), and
678
+ # the screen sits inside it at 48% of its area -- under NEST_FLOOR, so
679
+ # pick_innermost cannot step to it on the ratio.
680
+ #
681
+ # Deliberately NOT tier-first ordering of the whole list: that was tried
682
+ # on 11 Sep 2026 and broke three photographs, because a tier-2 WRONG
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
+
689
+ def _shape_of(cand):
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]
653
698
 
654
699
  order = sorted(candidates, key=lambda c: c[0], reverse=True)
655
- if order and _tier_of(order[0]) == 0:
656
- # Identity, not equality: candidates hold numpy arrays.
657
- strong = [c for c in order if _tier_of(c) == 2]
658
- if strong:
659
- strong_ids = {id(c) for c in strong}
660
- order = strong + [c for c in order if id(c) not in strong_ids]
700
+ if order:
701
+ top_q, top_tier = _shape_of(order[0])
702
+ if top_tier < 2:
703
+ strong = []
704
+ for c in order[1:]:
705
+ q, tier = _shape_of(c)
706
+ if tier == 2 and (top_tier == 0
707
+ or overlap_frac(q, top_q) >= NESTED_OVERLAP):
708
+ strong.append(c)
709
+ if strong:
710
+ # Identity, not equality: candidates hold numpy arrays.
711
+ strong_ids = {id(c) for c in strong}
712
+ order = strong + [c for c in order if id(c) not in strong_ids]
661
713
  for cand in order:
662
714
  # NOT necessarily `cand`: pick_innermost steps inward from it while a
663
715
  # comparably screen-like quad nests inside, so the quad that gets
664
716
  # validated -- and returned -- can belong to a different candidate.
665
- picked = pick_innermost(candidates, cand)
717
+ picked = pick_innermost(candidates, cand, quad_of=_refined)
666
718
  score, quad, contour, tag = picked
667
719
  if refine:
668
720
  refined, did_refine = refine_corners(contour, quad, rail_band)
@@ -753,7 +805,13 @@ def _walk_rows(generated, survived, walked, method, click, refine=True,
753
805
  else:
754
806
  verdict, why = walked.get(id(cand), ("unreached",
755
807
  "a higher-scoring candidate was accepted first"))
756
- rows.append(_trace_row(method, score, quad, tag, verdict, why))
808
+ row = _trace_row(method, score, quad, tag, verdict, why)
809
+ # The shape tier of every proposal, not only the winner's: the walk
810
+ # order rule in _finalize reads tiers, so a bench reading this file
811
+ # has to be able to see what the walk saw (added 12 Sep 2026 while
812
+ # tracing a 105% edge win with a 5% tier-2 candidate right behind it).
813
+ row["tier"] = shape_tier({"corner_radius": measure_corner_radius(contour, quad)})
814
+ rows.append(row)
757
815
  if click is not None:
758
816
  for r in rows:
759
817
  r["contains_click"] = r["verdict"] != "filtered_by_click"
@@ -1069,7 +1127,7 @@ def arbitrate(results, shape, click=None):
1069
1127
  # the region winning by default.
1070
1128
  inner, outer = (region, e) if ra < ea else (e, region)
1071
1129
  iname, oname = (rname, "edge") if ra < ea else ("edge", rname)
1072
- nested = overlap_frac(inner["_corners_np"], outer["_corners_np"]) >= 0.90
1130
+ nested = overlap_frac(inner["_corners_np"], outer["_corners_np"]) >= NESTED_OVERLAP
1073
1131
  ratio = min(ra, ea) / max(ra, ea) if max(ra, ea) > 0 else 0.0
1074
1132
  ti, to = shape_tier(inner), shape_tier(outer)
1075
1133
  tr, te = shape_tier(region), shape_tier(e)
@@ -1184,8 +1242,17 @@ def arbitrate(results, shape, click=None):
1184
1242
  # spread of 1.43, vetoed a confident edge quad 0.3% off -- the bench's one
1185
1243
  # "good quad refused". A veto from weaker evidence is not a disagreement
1186
1244
  # between peers; it is noise outvoting a measurement.
1245
+ # ... and a peer sitting INSIDE the winner is not disagreeing about where
1246
+ # the screen is. On iPhone-15 (12 Sep 2026) arbitration had just ruled a
1247
+ # tone quad to be content drawn on the edge quad's screen -- nested at 9%
1248
+ # of its area, a rounded card -- and this gate then read that same card as
1249
+ # a credible second opinion 32% of the diagonal away and abstained, holding
1250
+ # a quad 0% from the label. A veto is for two screen-shaped findings in
1251
+ # two PLACES; a card on the screen is one place. The nested case has
1252
+ # already been decided above, on tiers and ratio, by the time this runs.
1187
1253
  peers = [r for r in results if r is not best and has_rounded_corners(r)
1188
- and shape_tier(r) >= shape_tier(best)]
1254
+ and shape_tier(r) >= shape_tier(best)
1255
+ and overlap_frac(r["_corners_np"], best["_corners_np"]) < NESTED_OVERLAP]
1189
1256
  if peers:
1190
1257
  diag = float(np.hypot(*shape))
1191
1258
  peer_gap = min(float(np.max(np.linalg.norm(best["_corners_np"]
@@ -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.43):** 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