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 +1 -1
- package/scripts/detect.py +97 -30
- 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.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
|
-
|
|
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(
|
|
447
|
-
and NEST_FLOOR * c_area <= cv2.contourArea(c
|
|
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
|
|
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
|
|
639
|
-
#
|
|
640
|
-
#
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
#
|
|
644
|
-
#
|
|
645
|
-
#
|
|
646
|
-
#
|
|
647
|
-
#
|
|
648
|
-
#
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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"]) >=
|
|
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.
|
|
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
|
|