screengraft 0.13.1

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.
@@ -0,0 +1,180 @@
1
+ """screengraft — M2: the realism pass. Grade, don't regenerate.
2
+
3
+ An injected screenshot is a perfect, evenly-lit rectangle dropped into a
4
+ photograph that is neither. Even with the geometry exact, it reads as pasted
5
+ because three things do not match: its white balance and exposure belong to
6
+ whatever rendered it, its surface has no grain while the photo has a noise
7
+ floor, and a real screen under real light carries reflections that a clean
8
+ screenshot does not.
9
+
10
+ Three independent passes, each measurable and each defaulting to a strength a
11
+ designer can dial back:
12
+
13
+ 1. white balance + exposure -> the light in the room
14
+ 2. grain -> the photo's own noise floor
15
+ 3. specular lift -> the device's REAL reflections, from a
16
+ screen-off reference shot of the same photo
17
+
18
+ The one rule inherited from the build brief: this pass NEVER touches geometry
19
+ and NEVER generates pixels. Everything here is a per-channel statistic measured
20
+ off the photo itself, so a run is deterministic and reproducible.
21
+
22
+ Why Reinhard-in-Lab rather than a full histogram match: a histogram match
23
+ against the surrounding bezel would drag the screenshot's own contrast toward
24
+ the bezel's, which is wrong — the screen is a light source, not a surface, and
25
+ it is *supposed* to have its own range. Matching only the mean and spread of
26
+ the two chroma channels, plus a bounded exposure shift, moves the cast without
27
+ flattening the content.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import cv2
32
+ import numpy as np
33
+
34
+ # The grade is deliberately weak by default. A screen is emissive: it should
35
+ # pick up the room's cast, not become the room's colour.
36
+ DEFAULT_STRENGTH = 0.35 # the UI default; the slider spans 0.10-1.00
37
+
38
+
39
+ def surround_ring(mask: np.ndarray, inner_px: int = 6, outer_px: int = 48) -> np.ndarray:
40
+ """The band of photo just OUTSIDE the screen — the light the screen sits in.
41
+
42
+ Sampling the whole photo would average in a wall three metres away under a
43
+ different light. The bezel and the few centimetres around it are what a
44
+ viewer compares the screen against, so that is what the grade matches.
45
+ `inner_px` steps off the edge first, because those pixels are the
46
+ antialiased blend of screen and bezel and would poison the statistic with
47
+ the screenshot's own colour.
48
+ """
49
+ solid = (mask > 127).astype(np.uint8)
50
+ k_in = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (inner_px * 2 + 1,) * 2)
51
+ k_out = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (outer_px * 2 + 1,) * 2)
52
+ grown = cv2.dilate(solid, k_out)
53
+ skirt = cv2.dilate(solid, k_in)
54
+ return ((grown > 0) & (skirt == 0)).astype(np.uint8)
55
+
56
+
57
+ def _stats(lab: np.ndarray, sel: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
58
+ px = lab[sel.astype(bool)]
59
+ if px.size == 0:
60
+ return np.zeros(3, np.float64), np.ones(3, np.float64)
61
+ return px.mean(axis=0), px.std(axis=0) + 1e-6
62
+
63
+
64
+ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
65
+ strength: float = DEFAULT_STRENGTH) -> np.ndarray:
66
+ """Move the injected screen's cast and exposure toward the surrounding light.
67
+
68
+ Chroma (a,b) is matched on mean AND spread — a cast is exactly a chroma mean
69
+ offset, and a room with weak colour should not receive a saturated screen.
70
+ Luminance is matched on MEAN ONLY, and bounded: a screen is emissive and is
71
+ allowed to be brighter than its surroundings, so rescaling its L spread to
72
+ the bezel's would crush the UI's own contrast. That asymmetry is the whole
73
+ design of this function.
74
+ """
75
+ if strength <= 0:
76
+ return warped
77
+ ring = surround_ring(mask)
78
+ if int(ring.sum()) < 500: # too little context to measure honestly
79
+ return warped
80
+
81
+ lab_photo = cv2.cvtColor(photo, cv2.COLOR_BGR2LAB).astype(np.float64)
82
+ lab_warp = cv2.cvtColor(warped, cv2.COLOR_BGR2LAB).astype(np.float64)
83
+ inside = (mask > 200).astype(np.uint8)
84
+ if int(inside.sum()) < 500:
85
+ return warped
86
+
87
+ m_out, s_out = _stats(lab_photo, ring)
88
+ m_in, s_in = _stats(lab_warp, inside)
89
+
90
+ out = lab_warp.copy()
91
+ # a,b: full Reinhard transfer, scaled by strength.
92
+ for c in (1, 2):
93
+ moved = (lab_warp[:, :, c] - m_in[c]) * float(s_out[c] / s_in[c]) + m_out[c]
94
+ out[:, :, c] = lab_warp[:, :, c] + (moved - lab_warp[:, :, c]) * strength
95
+ # L: mean shift only, and capped at +-12 L* so a dark room cannot switch
96
+ # the screen off. 12 is about a stop; beyond that it stops reading as the
97
+ # same screenshot.
98
+ dL = float(np.clip(m_out[0] - m_in[0], -12.0, 12.0)) * strength
99
+ out[:, :, 0] = lab_warp[:, :, 0] + dL
100
+
101
+ out[:, :, 0] = np.clip(out[:, :, 0], 0, 255)
102
+ out[:, :, 1:] = np.clip(out[:, :, 1:], 0, 255)
103
+ return cv2.cvtColor(out.astype(np.uint8), cv2.COLOR_LAB2BGR)
104
+
105
+
106
+ def measure_grain(photo: np.ndarray, ring: np.ndarray) -> float:
107
+ """The photo's noise floor, in grey levels, measured where the screen isn't.
108
+
109
+ High-pass with a 3x3 median (cheap, edge-preserving) and take the MEDIAN
110
+ absolute deviation of the residual rather than its standard deviation: a
111
+ bezel edge or a highlight inside the ring is a huge outlier, and a mean-based
112
+ estimate would read the edge as noise and dump visible grain on the screen.
113
+ 0.6745 converts MAD to a sigma for a normal distribution.
114
+ """
115
+ g = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY)
116
+ resid = g.astype(np.float32) - cv2.medianBlur(g, 3).astype(np.float32)
117
+ px = resid[ring.astype(bool)]
118
+ if px.size < 500:
119
+ return 0.0
120
+ return float(np.median(np.abs(px - np.median(px))) / 0.6745)
121
+
122
+
123
+ def add_grain(img: np.ndarray, mask: np.ndarray, sigma: float, seed: int = 0) -> np.ndarray:
124
+ """Lay the measured noise floor over the injected screen only.
125
+
126
+ Seeded, so a re-run is byte-identical — determinism is a stated verification
127
+ rule for this tool, and unseeded noise would break it silently. Monochrome
128
+ rather than per-channel: sensor noise after demosaicing is strongly
129
+ correlated across channels, and independent RGB noise reads as colour
130
+ speckle, which is worse than no grain at all.
131
+ """
132
+ if sigma <= 0.05:
133
+ return img
134
+ sigma = float(min(sigma, 6.0)) # beyond this it stops being grain
135
+ rng = np.random.default_rng(seed)
136
+ noise = rng.normal(0.0, sigma, img.shape[:2]).astype(np.float32)
137
+ a = (mask.astype(np.float32) / 255.0)[:, :, None]
138
+ return np.clip(img.astype(np.float32) + noise[:, :, None] * a, 0, 255).astype(np.uint8)
139
+
140
+
141
+ def specular_lift(composite: np.ndarray, screen_off: np.ndarray, mask: np.ndarray,
142
+ strength: float = 0.75) -> np.ndarray:
143
+ """Re-composite the device's REAL reflections over the injected screen.
144
+
145
+ Needs a second photograph of the same scene with the screen off. A dark
146
+ screen is nearly a mirror, so that frame contains the true specular
147
+ highlights — window, lamp, the photographer — in exactly the right places
148
+ with the right shape. Lifting them beats any attempt to invent them, which
149
+ is the single thing every AI mockup tool gets visibly wrong.
150
+
151
+ The highlight layer is what the off-screen has ABOVE its own dark floor, so
152
+ the floor is subtracted first; screen-space SCREEN blend, because
153
+ reflections add light and never subtract it.
154
+ """
155
+ if screen_off is None or strength <= 0:
156
+ return composite
157
+ if screen_off.shape[:2] != composite.shape[:2]:
158
+ raise ValueError("screen-off reference must be the same size as the photo — "
159
+ "it has to be the same shot, tripod-locked, not a re-frame")
160
+ sel = mask > 0
161
+ if not sel.any():
162
+ return composite
163
+
164
+ off_l = cv2.cvtColor(screen_off, cv2.COLOR_BGR2GRAY).astype(np.float32)
165
+ floor = float(np.percentile(off_l[sel], 20)) # the glass at its darkest
166
+ hi = np.clip(off_l - floor, 0, None)
167
+ peak = float(np.percentile(hi[sel], 99.5))
168
+ if peak < 2.0: # nothing specular to lift
169
+ return composite
170
+ hi = np.clip(hi / peak, 0, 1) * (mask.astype(np.float32) / 255.0) * float(strength)
171
+
172
+ base = composite.astype(np.float32) / 255.0
173
+ lifted = 1.0 - (1.0 - base) * (1.0 - hi[:, :, None]) # screen blend
174
+ out = np.clip(lifted * 255.0, 0, 255).astype(np.uint8)
175
+ # A screen blend cannot darken, but the /255 -> *255 round trip quantises:
176
+ # measured 41% of pixels coming back exactly ONE level below the input where
177
+ # the highlight contributes nothing. Clamping to the input enforces the
178
+ # property the maths already has, instead of dimming the whole composite by
179
+ # a level for no reason.
180
+ return np.maximum(out, composite)
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env zsh
2
+ # screengraft — start ui.py as a detached daemon.
3
+ #
4
+ # HISTORY, because this has been wrong twice.
5
+ # 1. `nohup ui.py & disown` from the agent's shell (3 Sep 2026): the
6
+ # server died silently between turns, twice, losing whatever the designer
7
+ # had already entered. nohup blocks SIGHUP but not the session teardown
8
+ # some launchers use.
9
+ # 2. A Terminal.app window via osascript: survives, but leaves a dead
10
+ # "[Process completed]" window behind after every single session. The author
11
+ # ended up with a row of them (4 Sep 2026). Closing them again from
12
+ # AppleScript proved unreliable — Terminal reports stale ttys for dead
13
+ # windows and can leave zero-tab window husks that `close` reports
14
+ # success on without removing.
15
+ #
16
+ # So: `--daemon` double-forks and calls setsid, putting the server in its own
17
+ # session with no controlling terminal. It outlives the launching shell for
18
+ # the same reason a Terminal window did, and there is no window to clean up.
19
+ set -euo pipefail
20
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
21
+ PORT=0
22
+ OUT_DIR=""
23
+
24
+ while [ $# -gt 0 ]; do
25
+ case "$1" in
26
+ --out-dir) OUT_DIR="${2:-}"; shift 2 ;;
27
+ --out-dir=*) OUT_DIR="${1#*=}"; shift ;;
28
+ --port) PORT="${2:-0}"; shift 2 ;;
29
+ --port=*) PORT="${1#*=}"; shift ;;
30
+ ''|*[!0-9]*) echo "error: unknown argument: $1" >&2; exit 2 ;;
31
+ *) PORT="$1"; shift ;;
32
+ esac
33
+ done
34
+
35
+ PYEXE="$HOME/.screengraft/venv/bin/python"
36
+ [ -x "$PYEXE" ] || PYEXE="python3"
37
+ LOG="$(mktemp -t screengraft-ui.XXXXXX).log"
38
+
39
+ if [ -n "$OUT_DIR" ]; then
40
+ "$PYEXE" "$ROOT/scripts/ui.py" --port "$PORT" --no-open --daemon --log "$LOG" --out-dir "$OUT_DIR"
41
+ else
42
+ "$PYEXE" "$ROOT/scripts/ui.py" --port "$PORT" --no-open --daemon --log "$LOG"
43
+ fi
44
+
45
+ # The daemon writes its startup JSON to the log; wait for it, then confirm the
46
+ # server actually answers before telling anyone it is ready.
47
+ for i in $(seq 1 40); do
48
+ [ -s "$LOG" ] && break
49
+ sleep 0.25
50
+ done
51
+ if [ ! -s "$LOG" ]; then
52
+ echo "error: no output from ui.py after 10s; log: $LOG" >&2
53
+ exit 1
54
+ fi
55
+ URL=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['url'])" "$LOG" 2>/dev/null || true)
56
+ if [ -z "$URL" ]; then
57
+ echo "error: couldn't parse url from $LOG:" >&2
58
+ cat "$LOG" >&2
59
+ exit 1
60
+ fi
61
+ sleep 1
62
+ if ! curl -sf -o /dev/null "${URL}api/state"; then
63
+ echo "error: server not responding at $URL after launch. Log: $LOG" >&2
64
+ cat "$LOG" >&2
65
+ exit 1
66
+ fi
67
+ cat "$LOG"
68
+ echo "log: $LOG"
69
+ open "$URL" >/dev/null 2>&1 || true
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ screengraft — preflight. Run this first, every time.
4
+
5
+ Reports what the plugin needs and whether it's there, as JSON, so the skill
6
+ can tell the user plainly what's missing before promising anything.
7
+
8
+ python3 scripts/preflight.py -> JSON report, exit 0 if ready, 1 if not
9
+ python3 scripts/preflight.py --install -> create ~/.screengraft/venv and install
10
+ requirements.txt into it (only run this
11
+ after the user has said yes)
12
+
13
+ OpenCV is a HARD requirement: it is the engine. Without it nothing runs — not
14
+ "results will be worse", nothing. Say exactly that. Optional extras (SAM 2,
15
+ later) are the tier where "works, but worse without" applies.
16
+
17
+ The venv lives outside the plugin folder (~/.screengraft/venv) so it survives
18
+ plugin updates and never touches the user's system Python.
19
+ """
20
+ import argparse
21
+ import json
22
+ import os
23
+ import platform
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+
28
+ HERE = os.path.dirname(os.path.abspath(__file__))
29
+ REQ = os.path.join(HERE, "requirements.txt")
30
+ VENV = os.path.expanduser("~/.screengraft/venv")
31
+ VENV_PY = os.path.join(VENV, "bin", "python")
32
+
33
+ REQUIRED = [
34
+ ("cv2", "opencv-python-headless", "The engine: homography, warp, detection. Nothing runs without it."),
35
+ ("numpy", "numpy", "Array math for the compositing step."),
36
+ ]
37
+
38
+
39
+ def probe(pyexe: str):
40
+ """Ask a given python which required modules import."""
41
+ code = (
42
+ "import json,importlib\n"
43
+ "out={}\n"
44
+ "for m in %r:\n"
45
+ " try:\n"
46
+ " mod=importlib.import_module(m); out[m]=getattr(mod,'__version__','ok')\n"
47
+ " except Exception as e:\n"
48
+ " out[m]=None\n"
49
+ "print(json.dumps(out))"
50
+ ) % [m for m, _, _ in REQUIRED]
51
+ try:
52
+ r = subprocess.run([pyexe, "-c", code], capture_output=True, text=True, timeout=60)
53
+ return json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {m: None for m, _, _ in REQUIRED}
54
+ except Exception:
55
+ return {m: None for m, _, _ in REQUIRED}
56
+
57
+
58
+ def report():
59
+ py_candidates = []
60
+ if os.path.exists(VENV_PY):
61
+ py_candidates.append(("venv", VENV_PY))
62
+ py_candidates.append(("system", sys.executable))
63
+
64
+ chosen = None
65
+ probes = {}
66
+ for label, exe in py_candidates:
67
+ found = probe(exe)
68
+ probes[label] = {"python": exe, "modules": found}
69
+ if all(found.get(m) for m, _, _ in REQUIRED) and chosen is None:
70
+ chosen = (label, exe)
71
+
72
+ missing = [] if chosen else [
73
+ {"module": m, "pip": pkg, "why": why}
74
+ for m, pkg, why in REQUIRED
75
+ if not probes[py_candidates[0][0]]["modules"].get(m)
76
+ ]
77
+ return {
78
+ "ready": chosen is not None,
79
+ "python": chosen[1] if chosen else None,
80
+ "python_source": chosen[0] if chosen else None,
81
+ "platform": platform.platform(),
82
+ "venv": VENV,
83
+ "venv_exists": os.path.exists(VENV_PY),
84
+ "probes": probes,
85
+ "missing": missing,
86
+ "hard_requirement": "OpenCV is the engine. Without it screengraft cannot run at all — this is not a degraded mode, it is no mode.",
87
+ "install_command": f"{sys.executable} {os.path.abspath(__file__)} --install",
88
+ "install_does": f"Creates a virtualenv at {VENV} (nothing touches system Python) and pip-installs "
89
+ f"{', '.join(pkg for _, pkg, _ in REQUIRED)} into it (~60 MB download).",
90
+ "optional": [
91
+ {"name": "SAM 2 (M4)", "status": "not yet used by the plugin",
92
+ "why": "Better screen detection on photos where tone-based detection fails. Optional; results are worse without it, not absent."}
93
+ ],
94
+ "tools": {"open_browser": shutil.which("open") or shutil.which("xdg-open")},
95
+ }
96
+
97
+
98
+ def install():
99
+ os.makedirs(os.path.dirname(VENV), exist_ok=True)
100
+ if not os.path.exists(VENV_PY):
101
+ subprocess.check_call([sys.executable, "-m", "venv", VENV])
102
+ subprocess.check_call([VENV_PY, "-m", "pip", "install", "-q", "--upgrade", "pip"])
103
+ subprocess.check_call([VENV_PY, "-m", "pip", "install", "-q", "-r", REQ])
104
+
105
+
106
+ def main():
107
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
108
+ ap.add_argument("--install", action="store_true", help="Create the venv and install requirements (ask the user first)")
109
+ args = ap.parse_args()
110
+ if args.install:
111
+ install()
112
+ rep = report()
113
+ print(json.dumps(rep, indent=1))
114
+ sys.exit(0 if rep["ready"] else 1)
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,2 @@
1
+ opencv-python-headless>=4.9
2
+ numpy>=1.26
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ screengraft — recent images a designer is likely to want.
4
+
5
+ Scans ~/Desktop and ~/Downloads (the two places people actually save to) for
6
+ image files modified in the last N days, newest first, and prints JSON. The
7
+ UI shows these as pick-one thumbnails so nobody has to type a path.
8
+
9
+ python3 scripts/scan.py [--days 14] [--limit 40] [--thumbs DIR]
10
+
11
+ HEIC (iPhone photos) is converted to JPEG for the thumbnail via `sips` on
12
+ macOS; OpenCV can't read HEIC. Run every time the skill starts — new photos
13
+ appear between sessions.
14
+ """
15
+ import argparse
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import time
21
+
22
+ EXTS = {".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".tif", ".tiff"}
23
+ FOLDERS = ["~/Desktop", "~/Downloads"]
24
+
25
+
26
+ def scan(days: int, limit: int):
27
+ cutoff = time.time() - days * 86400
28
+ items = []
29
+ for f in FOLDERS:
30
+ d = os.path.expanduser(f)
31
+ if not os.path.isdir(d):
32
+ continue
33
+ try:
34
+ names = os.listdir(d)
35
+ except PermissionError:
36
+ continue
37
+ for n in names:
38
+ if n.startswith("."):
39
+ continue
40
+ p = os.path.join(d, n)
41
+ ext = os.path.splitext(n)[1].lower()
42
+ if ext not in EXTS or not os.path.isfile(p):
43
+ continue
44
+ st = os.stat(p)
45
+ if st.st_mtime < cutoff:
46
+ continue
47
+ items.append({"path": p, "name": n, "folder": f, "mtime": st.st_mtime, "bytes": st.st_size, "ext": ext})
48
+ items.sort(key=lambda x: -x["mtime"])
49
+ return items[:limit]
50
+
51
+
52
+ def thumb(item, out_dir: str, size: int = 320):
53
+ """Write a small JPEG thumbnail; returns its path or None."""
54
+ os.makedirs(out_dir, exist_ok=True)
55
+ base = os.path.splitext(item["name"])[0]
56
+ out = os.path.join(out_dir, f"{abs(hash(item['path']))}_{base[:40]}.jpg")
57
+ if os.path.exists(out):
58
+ return out
59
+ if sys.platform == "darwin":
60
+ # sips handles HEIC and everything else natively; no OpenCV needed here.
61
+ r = subprocess.run(["sips", "-s", "format", "jpeg", "-Z", str(size), item["path"], "--out", out],
62
+ capture_output=True, text=True)
63
+ return out if r.returncode == 0 and os.path.exists(out) else None
64
+ try:
65
+ import cv2
66
+ im = cv2.imread(item["path"])
67
+ if im is None:
68
+ return None
69
+ h, w = im.shape[:2]
70
+ s = size / max(h, w)
71
+ im = cv2.resize(im, (max(1, int(w * s)), max(1, int(h * s))), interpolation=cv2.INTER_AREA)
72
+ cv2.imwrite(out, im, [cv2.IMWRITE_JPEG_QUALITY, 80])
73
+ return out
74
+ except Exception:
75
+ return None
76
+
77
+
78
+ def main():
79
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
80
+ ap.add_argument("--days", type=int, default=14)
81
+ ap.add_argument("--limit", type=int, default=40)
82
+ ap.add_argument("--thumbs", help="Directory to write thumbnails into (optional)")
83
+ args = ap.parse_args()
84
+ items = scan(args.days, args.limit)
85
+ if args.thumbs:
86
+ for it in items:
87
+ it["thumb"] = thumb(it, args.thumbs)
88
+ print(json.dumps({"folders": FOLDERS, "days": args.days, "count": len(items), "items": items}, indent=1))
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env zsh
2
+ # Stop the screengraft UI.
3
+ #
4
+ # SIGTERM, not SIGKILL: ui.py catches it and runs its atexit, which clears the
5
+ # session pointer the MCP server reads. A hard kill leaves a stale pointer and
6
+ # the agent then blocks a full timeout against a session nobody is in.
7
+ set -euo pipefail
8
+ if ! pgrep -f 'scripts/ui.py' >/dev/null 2>&1; then
9
+ echo "no screengraft UI running"
10
+ exit 0
11
+ fi
12
+ pkill -TERM -f 'scripts/ui.py' || true
13
+ for i in $(seq 1 20); do
14
+ pgrep -f 'scripts/ui.py' >/dev/null 2>&1 || break
15
+ sleep 0.25
16
+ done
17
+ if pgrep -f 'scripts/ui.py' >/dev/null 2>&1; then
18
+ echo "warning: still running after 5s, forcing" >&2
19
+ pkill -KILL -f 'scripts/ui.py' || true
20
+ fi
21
+ echo "screengraft UI stopped"