screengraft 0.37.0 → 0.39.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 +105 -21
- 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.39.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
|
@@ -875,6 +875,26 @@ def detect_saturation(bgr: np.ndarray, click=None, trace=None):
|
|
|
875
875
|
return res
|
|
876
876
|
|
|
877
877
|
|
|
878
|
+
def shape_tier(result) -> int:
|
|
879
|
+
"""How strongly a result's own SHAPE says "this is a screen": 0, 1 or 2.
|
|
880
|
+
|
|
881
|
+
2 confident radius -- four per-corner estimates agree within 50%
|
|
882
|
+
1 rounded -- they agree within MAX_RADIUS_SPREAD (2x)
|
|
883
|
+
0 nothing -- sharp, unmeasurable, or wildly inconsistent
|
|
884
|
+
|
|
885
|
+
Both thresholds already existed (measure_corner_radius's `confident`, and
|
|
886
|
+
has_rounded_corners); this only ranks them. Measured 11 Sep 2026 on ten
|
|
887
|
+
hand-labelled photographs, every channel result: **every tier-2 quad was on
|
|
888
|
+
the screen** (worst spread 0.24) and **every wrong quad was >= 1.43 or
|
|
889
|
+
unmeasurable** -- a 6x margin. Tier 1 alone does not separate them (a
|
|
890
|
+
correct tone quad at 1.42 against a wrong one at 1.43), which is exactly why
|
|
891
|
+
a tier-1 result must not outrank or veto a tier-2 one. It was doing both.
|
|
892
|
+
"""
|
|
893
|
+
if (result.get("corner_radius") or {}).get("confident"):
|
|
894
|
+
return 2
|
|
895
|
+
return 1 if has_rounded_corners(result) else 0
|
|
896
|
+
|
|
897
|
+
|
|
878
898
|
def has_rounded_corners(result) -> bool:
|
|
879
899
|
"""Did this quad's own outline actually curve at the corners?
|
|
880
900
|
|
|
@@ -939,7 +959,16 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
|
|
|
939
959
|
|
|
940
960
|
if not results:
|
|
941
961
|
return None
|
|
962
|
+
return arbitrate(results, gray.shape[:2], click)
|
|
942
963
|
|
|
964
|
+
|
|
965
|
+
def arbitrate(results, shape, click=None):
|
|
966
|
+
"""Choose among the channels' accepted results and decide whether to abstain.
|
|
967
|
+
|
|
968
|
+
Split out of detect() on 11 Sep 2026 so the rules here can be tested on
|
|
969
|
+
hand-built results, the way _finalize's nested pick already is. `shape` is
|
|
970
|
+
the photograph's (h, w); the only thing this needs it for is the diagonal.
|
|
971
|
+
"""
|
|
943
972
|
# tone and saturation are the same algorithm on different channels, so they
|
|
944
973
|
# are compared to each other before anything else, on whether the region
|
|
945
974
|
# each found actually has rounded corners. A patch of table cut out of a
|
|
@@ -967,27 +996,76 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
|
|
|
967
996
|
sat = next((r for r in results if r["method"] == "saturation"), None)
|
|
968
997
|
why = "it was the only detector left after the rounded-corner filter" \
|
|
969
998
|
if len(results) == 1 else "its tone band assumption held, which is itself evidence"
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
999
|
+
# ONE region-vs-edge arbitration, for whichever region channel survived the
|
|
1000
|
+
# rounded-corner filter above. Until 11 Sep 2026 only tone got the nesting
|
|
1001
|
+
# rules; saturation-vs-edge fell through to "saturation wins". On iPhone-4
|
|
1002
|
+
# that returned a saturation quad 18% off, nested around an edge quad 3%
|
|
1003
|
+
# off at 94% of its area -- exactly the "screen inside a body" shape the
|
|
1004
|
+
# tone branch already knew how to read.
|
|
1005
|
+
region = t if t is not None else sat
|
|
1006
|
+
rname = "tone" if t is not None else "saturation"
|
|
1007
|
+
if region is not None and e is not None:
|
|
1008
|
+
rq, eq = region["_corners_np"], e["_corners_np"]
|
|
1009
|
+
ra = float(cv2.contourArea(rq.astype(np.float32)))
|
|
973
1010
|
ea = float(cv2.contourArea(eq.astype(np.float32)))
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1011
|
+
# Nesting is read on whichever quad is inside the other. It used to be
|
|
1012
|
+
# read only with the region quad inside the edge quad, so an edge quad
|
|
1013
|
+
# sitting inside a region quad (iPhone-4: edge 3% off inside a
|
|
1014
|
+
# saturation quad 18% off at 94%) was "not nested" and fell through to
|
|
1015
|
+
# the region winning by default.
|
|
1016
|
+
inner, outer = (region, e) if ra < ea else (e, region)
|
|
1017
|
+
iname, oname = (rname, "edge") if ra < ea else ("edge", rname)
|
|
1018
|
+
nested = overlap_frac(inner["_corners_np"], outer["_corners_np"]) >= 0.90
|
|
1019
|
+
ratio = min(ra, ea) / max(ra, ea) if max(ra, ea) > 0 else 0.0
|
|
1020
|
+
ti, to = shape_tier(inner), shape_tier(outer)
|
|
1021
|
+
tr, te = shape_tier(region), shape_tier(e)
|
|
1022
|
+
# Nested: shape evidence first, the area ratio only to break a tie.
|
|
1023
|
+
#
|
|
1024
|
+
# The ratio alone reads "small inside big" as content-inside-screen and
|
|
1025
|
+
# "large inside big" as screen-inside-body, and it is right on the
|
|
1026
|
+
# gradient-screen fixture (41%: a slab on the screen) and on most
|
|
1027
|
+
# photographs. It is wrong exactly when the quads' own corners say the
|
|
1028
|
+
# opposite: a UI content region at 96% of the screen with loose corners
|
|
1029
|
+
# (two of ten labelled photographs, 15% off) is not a screen inside a
|
|
1030
|
+
# body, and a confident phone screen at 34% of a loosely-rounded table
|
|
1031
|
+
# (the hand-built case in test_detect) is not content on it. Tiers
|
|
1032
|
+
# decide those; at equal tiers the ratio still does, unchanged.
|
|
1033
|
+
if nested and to > ti:
|
|
1034
|
+
best, why = outer, ("the %s quad sits inside the %s quad at %.0f%% of its "
|
|
1035
|
+
"area, but the outer quad's corners agree on a radius "
|
|
1036
|
+
"(tier %d) and the inner's do not (tier %d) — content "
|
|
1037
|
+
"inside a screen, not a screen inside a body"
|
|
1038
|
+
% (iname, oname, ratio * 100, to, ti))
|
|
1039
|
+
elif nested and ti > to:
|
|
1040
|
+
best, why = inner, ("the %s quad sits inside the %s quad at %.0f%% of its "
|
|
1041
|
+
"area, and the inner quad's corners agree on a radius "
|
|
1042
|
+
"(tier %d) where the outer's do not (tier %d) — a "
|
|
1043
|
+
"screen on something larger"
|
|
1044
|
+
% (iname, oname, ratio * 100, ti, to))
|
|
1045
|
+
elif nested and ratio < NEST_FLOOR:
|
|
1046
|
+
best, why = outer, ("the %s quad is only %.0f%% of the %s quad it sits "
|
|
1047
|
+
"inside — that's content drawn on the screen, not the "
|
|
1048
|
+
"screen" % (iname, ratio * 100, oname))
|
|
980
1049
|
elif nested:
|
|
981
|
-
best, why =
|
|
982
|
-
|
|
1050
|
+
best, why = inner, ("the %s quad sits inside the %s quad at %.0f%% of "
|
|
1051
|
+
"its area — a screen inside a device body"
|
|
1052
|
+
% (iname, oname, ratio * 100))
|
|
1053
|
+
elif te > tr:
|
|
1054
|
+
# Not nested, and the edge quad's own shape says "screen" more
|
|
1055
|
+
# strongly than the region's does. Before 11 Sep 2026 tone won here
|
|
1056
|
+
# unconditionally, on the argument that its band assumption holding
|
|
1057
|
+
# was itself evidence -- and on two of ten labelled photographs that
|
|
1058
|
+
# handed the answer to a tone quad 159% and 249% off, with corner
|
|
1059
|
+
# spreads of 3.4 and 19.9, over an edge quad at 0% with a confident
|
|
1060
|
+
# radius. A band assumption is weaker evidence than four agreeing
|
|
1061
|
+
# corners; the tiers say so and this reads them.
|
|
1062
|
+
best, why = e, ("the two quads aren't nested and the edge quad's "
|
|
1063
|
+
"corners agree on a radius (tier %d) where %s's do "
|
|
1064
|
+
"not (tier %d)" % (te, rname, tr))
|
|
983
1065
|
else:
|
|
984
|
-
best, why =
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
# tone was dropped for having sharp corners, or never fired. The
|
|
988
|
-
# surviving region detector measured a real corner radius; edge cannot.
|
|
989
|
-
best, why = sat, ("the saturation detector found a region with rounded "
|
|
990
|
-
"corners where tone did not")
|
|
1066
|
+
best, why = region, ("the two quads aren't nested; %s's assumption "
|
|
1067
|
+
"holding is itself evidence that it found a screen"
|
|
1068
|
+
% rname)
|
|
991
1069
|
else:
|
|
992
1070
|
best = t or e or sat
|
|
993
1071
|
if best is None:
|
|
@@ -999,7 +1077,7 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
|
|
|
999
1077
|
# somewhere else entirely should not erase the fact that two of them
|
|
1000
1078
|
# landed together. Corroboration by any one independent method is the
|
|
1001
1079
|
# evidence worth reporting.
|
|
1002
|
-
diag = float(np.hypot(*
|
|
1080
|
+
diag = float(np.hypot(*shape))
|
|
1003
1081
|
others = [r for r in results if r is not best]
|
|
1004
1082
|
gaps = [(float(np.max(np.linalg.norm(best["_corners_np"]
|
|
1005
1083
|
- r["_corners_np"], axis=1))) / diag, r)
|
|
@@ -1047,9 +1125,15 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
|
|
|
1047
1125
|
# not get a vote on whether the rounded thing is a screen. So the gap is
|
|
1048
1126
|
# re-measured against peers that also found something screen-shaped; when
|
|
1049
1127
|
# there are none, being alone is not evidence of being wrong.
|
|
1050
|
-
|
|
1128
|
+
# ... and a peer may only veto a result whose shape evidence it at least
|
|
1129
|
+
# matches. On iPhone-2 (11 Sep 2026) a tone quad 143% off, "rounded" at a
|
|
1130
|
+
# spread of 1.43, vetoed a confident edge quad 0.3% off -- the bench's one
|
|
1131
|
+
# "good quad refused". A veto from weaker evidence is not a disagreement
|
|
1132
|
+
# between peers; it is noise outvoting a measurement.
|
|
1133
|
+
peers = [r for r in results if r is not best and has_rounded_corners(r)
|
|
1134
|
+
and shape_tier(r) >= shape_tier(best)]
|
|
1051
1135
|
if peers:
|
|
1052
|
-
diag = float(np.hypot(*
|
|
1136
|
+
diag = float(np.hypot(*shape))
|
|
1053
1137
|
peer_gap = min(float(np.max(np.linalg.norm(best["_corners_np"]
|
|
1054
1138
|
- r["_corners_np"], axis=1))) / diag
|
|
1055
1139
|
for r in peers)
|
|
@@ -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.39):** 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
|
|