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 +1 -1
- package/scripts/detect.py +66 -22
- package/skills/inject-screenshot/SKILL.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "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
|
|
639
|
-
#
|
|
640
|
-
#
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
#
|
|
644
|
-
#
|
|
645
|
-
#
|
|
646
|
-
#
|
|
647
|
-
#
|
|
648
|
-
#
|
|
649
|
-
|
|
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
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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"]) >=
|
|
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.
|
|
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
|
|