screengraft 0.42.0 → 0.44.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.42.0",
3
+ "version": "0.44.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:
@@ -635,7 +641,53 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
635
641
  # project has never had the number. Off unless asked for, and it must not
636
642
  # change the answer: it observes the same lists the walk below uses.
637
643
  walked = {}
638
- for cand in sorted(candidates, key=lambda c: c[0], reverse=True):
644
+ # Walk order: by score -- EXCEPT when the best-scoring candidate is not
645
+ # itself confident (tier < 2) while a tier-2 candidate exists in the same
646
+ # list. Two cases, and they are different:
647
+ #
648
+ # * Top is tier 0 (sharp, unmeasurable, or inconsistent corners): it cannot
649
+ # be corroborated and ends in an abstention or a refusal, so nothing is
650
+ # lost by looking past it to ANY tier-2 candidate.
651
+ # * Top is tier 1 (rounded, but the four radii disagree): it is a real
652
+ # finding, and only a tier-2 candidate NESTED INSIDE it may move ahead --
653
+ # the same finding narrowed to the part whose four corners agree, which
654
+ # is how arbitrate() already reads a confident quad inside a loosely
655
+ # rounded one (tiers before area ratio). A tier-2 candidate somewhere
656
+ # ELSE is a different finding and does not displace a rounded top on
657
+ # tier alone. Measured 12 Sep 2026 on 18 labelled photographs: every
658
+ # tier-2 that should jump a tier-1 top lies inside it at >= 0.95
659
+ # overlap; every one that must not (tone's patches on two photographs,
660
+ # an edge quad on a third) overlaps it at 0.00. Without the nesting
661
+ # condition the tone channel promoted a disjoint tier-2 patch 202% off
662
+ # on two photographs and the answer was lost to abstention. The case
663
+ # that needed this is a front-and-back mockup: the edge channel's top
664
+ # candidate is the bounding box of BOTH phones (tier 1, 105% off), and
665
+ # the screen sits inside it at 48% of its area -- under NEST_FLOOR, so
666
+ # pick_innermost cannot step to it on the ratio.
667
+ #
668
+ # Deliberately NOT tier-first ordering of the whole list: that was tried
669
+ # on 11 Sep 2026 and broke three photographs, because a tier-2 WRONG
670
+ # candidate sits deeper in the list on each.
671
+ 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)})
675
+
676
+ order = sorted(candidates, key=lambda c: c[0], reverse=True)
677
+ if order:
678
+ top_q, top_tier = _shape_of(order[0])
679
+ if top_tier < 2:
680
+ strong = []
681
+ for c in order[1:]:
682
+ q, tier = _shape_of(c)
683
+ if tier == 2 and (top_tier == 0
684
+ or overlap_frac(q, top_q) >= NESTED_OVERLAP):
685
+ strong.append(c)
686
+ if strong:
687
+ # Identity, not equality: candidates hold numpy arrays.
688
+ strong_ids = {id(c) for c in strong}
689
+ order = strong + [c for c in order if id(c) not in strong_ids]
690
+ for cand in order:
639
691
  # NOT necessarily `cand`: pick_innermost steps inward from it while a
640
692
  # comparably screen-like quad nests inside, so the quad that gets
641
693
  # validated -- and returned -- can belong to a different candidate.
@@ -730,7 +782,13 @@ def _walk_rows(generated, survived, walked, method, click, refine=True,
730
782
  else:
731
783
  verdict, why = walked.get(id(cand), ("unreached",
732
784
  "a higher-scoring candidate was accepted first"))
733
- rows.append(_trace_row(method, score, quad, tag, verdict, why))
785
+ row = _trace_row(method, score, quad, tag, verdict, why)
786
+ # The shape tier of every proposal, not only the winner's: the walk
787
+ # order rule in _finalize reads tiers, so a bench reading this file
788
+ # has to be able to see what the walk saw (added 12 Sep 2026 while
789
+ # tracing a 105% edge win with a 5% tier-2 candidate right behind it).
790
+ row["tier"] = shape_tier({"corner_radius": measure_corner_radius(contour, quad)})
791
+ rows.append(row)
734
792
  if click is not None:
735
793
  for r in rows:
736
794
  r["contains_click"] = r["verdict"] != "filtered_by_click"
@@ -927,6 +985,18 @@ def has_rounded_corners(result) -> bool:
927
985
  if float(cr["photo_px"]) <= MIN_ROUNDING_PX:
928
986
  return False
929
987
  per = [float(v) for v in cr["per_corner_px"]]
988
+ # ALL FOUR corners, not most of them. A per-corner estimate of exactly 0.0
989
+ # is the estimator saying "no arc here", and a rounded rectangle does not
990
+ # have corners with no arc. The spread test alone let [0, 0, 56, 56]
991
+ # through -- a quad rounded at two corners and square at the other two,
992
+ # a shape no screen has -- because its spread lands at exactly 2.00 and the
993
+ # comparison is <=. That quad shipped as a CONFIDENT answer 124% off
994
+ # (iPhone-8, 12 Sep 2026): the only confidently wrong result the labelled
995
+ # bench has ever produced. Categorical rather than a threshold: on the 14
996
+ # labelled photographs every on-screen quad this accepts has all four
997
+ # corners measured (smallest 1.6px); the wrong one had two at 0.0.
998
+ if min(per) <= 0.0:
999
+ return False
930
1000
  r = float(cr["photo_px"])
931
1001
  spread = (max(per) - min(per)) / max(r, 1e-6)
932
1002
  return spread <= MAX_RADIUS_SPREAD
@@ -1034,7 +1104,7 @@ def arbitrate(results, shape, click=None):
1034
1104
  # the region winning by default.
1035
1105
  inner, outer = (region, e) if ra < ea else (e, region)
1036
1106
  iname, oname = (rname, "edge") if ra < ea else ("edge", rname)
1037
- nested = overlap_frac(inner["_corners_np"], outer["_corners_np"]) >= 0.90
1107
+ nested = overlap_frac(inner["_corners_np"], outer["_corners_np"]) >= NESTED_OVERLAP
1038
1108
  ratio = min(ra, ea) / max(ra, ea) if max(ra, ea) > 0 else 0.0
1039
1109
  ti, to = shape_tier(inner), shape_tier(outer)
1040
1110
  tr, te = shape_tier(region), shape_tier(e)
@@ -1149,8 +1219,17 @@ def arbitrate(results, shape, click=None):
1149
1219
  # spread of 1.43, vetoed a confident edge quad 0.3% off -- the bench's one
1150
1220
  # "good quad refused". A veto from weaker evidence is not a disagreement
1151
1221
  # between peers; it is noise outvoting a measurement.
1222
+ # ... and a peer sitting INSIDE the winner is not disagreeing about where
1223
+ # the screen is. On iPhone-15 (12 Sep 2026) arbitration had just ruled a
1224
+ # tone quad to be content drawn on the edge quad's screen -- nested at 9%
1225
+ # of its area, a rounded card -- and this gate then read that same card as
1226
+ # a credible second opinion 32% of the diagonal away and abstained, holding
1227
+ # a quad 0% from the label. A veto is for two screen-shaped findings in
1228
+ # two PLACES; a card on the screen is one place. The nested case has
1229
+ # already been decided above, on tiers and ratio, by the time this runs.
1152
1230
  peers = [r for r in results if r is not best and has_rounded_corners(r)
1153
- and shape_tier(r) >= shape_tier(best)]
1231
+ and shape_tier(r) >= shape_tier(best)
1232
+ and overlap_frac(r["_corners_np"], best["_corners_np"]) < NESTED_OVERLAP]
1154
1233
  if peers:
1155
1234
  diag = float(np.hypot(*shape))
1156
1235
  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.42):** 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.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.
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