screengraft 0.44.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
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
|
@@ -427,7 +427,7 @@ def overlap_frac(inner: np.ndarray, outer: np.ndarray) -> float:
|
|
|
427
427
|
return float(cv2.contourArea(region.astype(np.float32))) / a
|
|
428
428
|
|
|
429
429
|
|
|
430
|
-
def pick_innermost(candidates, best):
|
|
430
|
+
def pick_innermost(candidates, best, quad_of=None):
|
|
431
431
|
"""
|
|
432
432
|
A device photo offers more than one screen-shaped region: the glass screen,
|
|
433
433
|
and the bezel or body it sits in. They nest, and the outer one wins on
|
|
@@ -442,20 +442,33 @@ def pick_innermost(candidates, best):
|
|
|
442
442
|
old 0.35 floor a wave-shaped gradient at 49% of its screen was eligible;
|
|
443
443
|
at 0.55 it isn't (an earlier finding, 3 Sep 2026).
|
|
444
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
|
|
445
457
|
current = best
|
|
446
458
|
for _ in range(4): # screen inside bezel inside body: a few steps is plenty
|
|
447
|
-
|
|
459
|
+
cq = quad_of(current)
|
|
460
|
+
c_area = cv2.contourArea(cq.astype(np.float32))
|
|
448
461
|
inner = [
|
|
449
462
|
c for c in candidates
|
|
450
463
|
if c is not current
|
|
451
464
|
and c[0] >= 0.25 * current[0]
|
|
452
|
-
and quad_contains(
|
|
453
|
-
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
|
|
454
467
|
]
|
|
455
468
|
if not inner:
|
|
456
469
|
return current
|
|
457
470
|
# Largest of the nested ones: the screen, not a panel drawn on it.
|
|
458
|
-
current = max(inner, key=lambda c: cv2.contourArea(c
|
|
471
|
+
current = max(inner, key=lambda c: cv2.contourArea(quad_of(c).astype(np.float32)))
|
|
459
472
|
return current
|
|
460
473
|
|
|
461
474
|
|
|
@@ -668,10 +681,20 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
|
|
|
668
681
|
# Deliberately NOT tier-first ordering of the whole list: that was tried
|
|
669
682
|
# on 11 Sep 2026 and broke three photographs, because a tier-2 WRONG
|
|
670
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
|
+
|
|
671
689
|
def _shape_of(cand):
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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]
|
|
675
698
|
|
|
676
699
|
order = sorted(candidates, key=lambda c: c[0], reverse=True)
|
|
677
700
|
if order:
|
|
@@ -691,7 +714,7 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
|
|
|
691
714
|
# NOT necessarily `cand`: pick_innermost steps inward from it while a
|
|
692
715
|
# comparably screen-like quad nests inside, so the quad that gets
|
|
693
716
|
# validated -- and returned -- can belong to a different candidate.
|
|
694
|
-
picked = pick_innermost(candidates, cand)
|
|
717
|
+
picked = pick_innermost(candidates, cand, quad_of=_refined)
|
|
695
718
|
score, quad, contour, tag = picked
|
|
696
719
|
if refine:
|
|
697
720
|
refined, did_refine = refine_corners(contour, quad, rail_band)
|
|
@@ -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
|
|