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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.37.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
- if t and e:
971
- tq, eq = t["_corners_np"], e["_corners_np"]
972
- ta = float(cv2.contourArea(tq.astype(np.float32)))
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
- nested = overlap_frac(tq, eq) >= 0.90 and ta < ea
975
- ratio = ta / ea if ea > 0 else 0.0
976
- if nested and ratio < NEST_FLOOR:
977
- best, why = e, ("the tone quad is only %.0f%% of the edge quad it sits "
978
- "inside — that's content drawn on the screen, not the "
979
- "screen" % (ratio * 100))
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 = t, ("the tone quad sits inside the edge quad at %.0f%% of "
982
- "its area — a screen inside a device body" % (ratio * 100))
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 = t, ("the two quads aren't nested; tone's band assumption "
985
- "holding is itself evidence that it found a screen")
986
- elif t is None and sat is not None:
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(*gray.shape[:2]))
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
- peers = [r for r in results if r is not best and has_rounded_corners(r)]
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(*gray.shape[:2]))
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.37):** 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.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