screengraft 0.37.0 → 0.38.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.38.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)
963
+
942
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
@@ -980,14 +1009,32 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
980
1009
  elif nested:
981
1010
  best, why = t, ("the tone quad sits inside the edge quad at %.0f%% of "
982
1011
  "its area — a screen inside a device body" % (ratio * 100))
1012
+ elif shape_tier(e) > shape_tier(t):
1013
+ # Not nested, and the edge quad's own shape says "screen" more
1014
+ # strongly than tone's does. Before 11 Sep 2026 tone won here
1015
+ # unconditionally, on the argument that its band assumption holding
1016
+ # was itself evidence -- and on two of ten labelled photographs that
1017
+ # handed the answer to a tone quad 159% and 249% off, with corner
1018
+ # spreads of 3.4 and 19.9, over an edge quad at 0% with a confident
1019
+ # radius. A band assumption is weaker evidence than four agreeing
1020
+ # corners; the tiers say so and this reads them.
1021
+ best, why = e, ("the two quads aren't nested and the edge quad's "
1022
+ "corners agree on a radius (tier %d) where tone's do "
1023
+ "not (tier %d)" % (shape_tier(e), shape_tier(t)))
983
1024
  else:
984
1025
  best, why = t, ("the two quads aren't nested; tone's band assumption "
985
1026
  "holding is itself evidence that it found a screen")
986
1027
  elif t is None and sat is not None:
987
1028
  # 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")
1029
+ # surviving region detector measured a real corner radius; edge usually
1030
+ # cannot -- but when it did, and more confidently, it wins the same way.
1031
+ if e is not None and shape_tier(e) > shape_tier(sat):
1032
+ best, why = e, ("the edge quad's corners agree on a radius (tier %d) "
1033
+ "where saturation's do not (tier %d)"
1034
+ % (shape_tier(e), shape_tier(sat)))
1035
+ else:
1036
+ best, why = sat, ("the saturation detector found a region with rounded "
1037
+ "corners where tone did not")
991
1038
  else:
992
1039
  best = t or e or sat
993
1040
  if best is None:
@@ -999,7 +1046,7 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
999
1046
  # somewhere else entirely should not erase the fact that two of them
1000
1047
  # landed together. Corroboration by any one independent method is the
1001
1048
  # evidence worth reporting.
1002
- diag = float(np.hypot(*gray.shape[:2]))
1049
+ diag = float(np.hypot(*shape))
1003
1050
  others = [r for r in results if r is not best]
1004
1051
  gaps = [(float(np.max(np.linalg.norm(best["_corners_np"]
1005
1052
  - r["_corners_np"], axis=1))) / diag, r)
@@ -1047,9 +1094,15 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
1047
1094
  # not get a vote on whether the rounded thing is a screen. So the gap is
1048
1095
  # re-measured against peers that also found something screen-shaped; when
1049
1096
  # 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)]
1097
+ # ... and a peer may only veto a result whose shape evidence it at least
1098
+ # matches. On iPhone-2 (11 Sep 2026) a tone quad 143% off, "rounded" at a
1099
+ # spread of 1.43, vetoed a confident edge quad 0.3% off -- the bench's one
1100
+ # "good quad refused". A veto from weaker evidence is not a disagreement
1101
+ # between peers; it is noise outvoting a measurement.
1102
+ peers = [r for r in results if r is not best and has_rounded_corners(r)
1103
+ and shape_tier(r) >= shape_tier(best)]
1051
1104
  if peers:
1052
- diag = float(np.hypot(*gray.shape[:2]))
1105
+ diag = float(np.hypot(*shape))
1053
1106
  peer_gap = min(float(np.max(np.linalg.norm(best["_corners_np"]
1054
1107
  - r["_corners_np"], axis=1))) / diag
1055
1108
  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.38):** 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