screengraft 0.43.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.43.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,29 +641,52 @@ 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
- # 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):
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):
650
672
  _score, quad, contour, _tag = cand
651
673
  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)})
674
+ return q, shape_tier({"corner_radius": measure_corner_radius(contour, q)})
653
675
 
654
676
  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]
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]
661
690
  for cand in order:
662
691
  # NOT necessarily `cand`: pick_innermost steps inward from it while a
663
692
  # comparably screen-like quad nests inside, so the quad that gets
@@ -753,7 +782,13 @@ def _walk_rows(generated, survived, walked, method, click, refine=True,
753
782
  else:
754
783
  verdict, why = walked.get(id(cand), ("unreached",
755
784
  "a higher-scoring candidate was accepted first"))
756
- 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)
757
792
  if click is not None:
758
793
  for r in rows:
759
794
  r["contains_click"] = r["verdict"] != "filtered_by_click"
@@ -1069,7 +1104,7 @@ def arbitrate(results, shape, click=None):
1069
1104
  # the region winning by default.
1070
1105
  inner, outer = (region, e) if ra < ea else (e, region)
1071
1106
  iname, oname = (rname, "edge") if ra < ea else ("edge", rname)
1072
- nested = overlap_frac(inner["_corners_np"], outer["_corners_np"]) >= 0.90
1107
+ nested = overlap_frac(inner["_corners_np"], outer["_corners_np"]) >= NESTED_OVERLAP
1073
1108
  ratio = min(ra, ea) / max(ra, ea) if max(ra, ea) > 0 else 0.0
1074
1109
  ti, to = shape_tier(inner), shape_tier(outer)
1075
1110
  tr, te = shape_tier(region), shape_tier(e)
@@ -1184,8 +1219,17 @@ def arbitrate(results, shape, click=None):
1184
1219
  # spread of 1.43, vetoed a confident edge quad 0.3% off -- the bench's one
1185
1220
  # "good quad refused". A veto from weaker evidence is not a disagreement
1186
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.
1187
1230
  peers = [r for r in results if r is not best and has_rounded_corners(r)
1188
- 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]
1189
1233
  if peers:
1190
1234
  diag = float(np.hypot(*shape))
1191
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.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.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