ainotate 0.1.0__py3-none-any.whl

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.
ainotate/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """AInotate: annotated screenshots for AI agents (show, don't describe)."""
2
+ __version__ = "0.1.0"
ainotate/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m ainotate` == the `ainotate` CLI."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
ainotate/beautify.py ADDED
@@ -0,0 +1,361 @@
1
+ """Frame an annotated screenshot so it looks finished (Xnapper / CleanShot "Background tool"):
2
+ a soft gradient backdrop, padding, rounded corners, a macOS-like window shadow, optional
3
+ minimal browser/window chrome and a fixed aspect ratio. Pure: no printing, no file writes.
4
+
5
+ apply_frame(img, frame, unit) -> Image; validate_frame(frame) -> list of error strings.
6
+ All sizes scale with `unit` (the render's mark unit), so a retina capture gets a retina frame.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import os
12
+ import random
13
+ from functools import lru_cache
14
+
15
+ from PIL import Image, ImageChops, ImageColor, ImageDraw, ImageFilter, ImageFont
16
+
17
+ from .beautify_color import ( # noqa: F401 (re-export: callers keep importing these from ainotate.beautify)
18
+ PRESETS, _MARK_COLORS, _edge_lightness, _gam, _grain, _grain_tile, _lin, _oklab_lin, _pixels, _thumb,
19
+ auto_hues, auto_stops, bg_stops, dominant_hue, from_oklab, gradient, luminance, oklch, shadow_color, to_oklab,
20
+ )
21
+ from .style import COLORS, LABEL_FILL, load_font # noqa: F401
22
+
23
+ FRAME_DEFAULTS = {
24
+ "bg": "auto", # "auto" | preset | "#hex" | ["#hex", "#hex", ...]
25
+ "pad": None, # px around the window; None = 6% of the longer side
26
+ "radius": None, # px corner radius of the window; None = 1.0 x unit
27
+ "shadow": True, # True | False | 0..1 opacity of the ambient shadow
28
+ "chrome": None, # None | "browser" | "window"
29
+ "url": None, # text in the browser address pill
30
+ "theme": "auto", # chrome colors: "auto" (from the screenshot's top edge) | "light" | "dark"
31
+ "aspect": None, # None | "16:9" | "4:3" | "1:1" | "twitter" | "linkedin" | "W:H"
32
+ "balance": False, # shift the window so off-center content looks centered
33
+ }
34
+
35
+ ASPECTS = {"16:9": 16 / 9, "4:3": 4 / 3, "1:1": 1.0, "twitter": 16 / 9, "linkedin": 1200 / 627}
36
+
37
+ # Real browser-ish chrome colors (Safari/Chrome minimal toolbars)
38
+ CHROME = {
39
+ "light": {"bar": "#F2F2F4", "line": "#D8D8DC", "pill": "#E3E3E8", "text": "#6E6E73"},
40
+ "dark": {"bar": "#2B2B2E", "line": "#141416", "pill": "#3C3C40", "text": "#A1A1A6"},
41
+ }
42
+ LIGHTS = [("#FF5F57", "#E2463F"), ("#FEBC2E", "#E1A116"), ("#28C840", "#1AAB29")]
43
+ # traffic lights (x unit): radius, first center from the left edge, center-to-center step.
44
+ # macOS proportions: 12 pt lights on a 20 pt pitch, so diameter / step = 0.6
45
+ LIGHT_R, LIGHT_X0, LIGHT_STEP = 0.44, 1.4, 1.47
46
+
47
+
48
+
49
+ # ---------- validation ----------
50
+
51
+ def _num(v):
52
+ return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
53
+
54
+
55
+ def _color_ok(c):
56
+ if not isinstance(c, str) or not c:
57
+ return False
58
+ try:
59
+ ImageColor.getrgb(c)
60
+ return True
61
+ except ValueError:
62
+ return False
63
+
64
+
65
+ def _aspect_ratio(a):
66
+ if not isinstance(a, str):
67
+ return None
68
+ if a in ASPECTS:
69
+ return ASPECTS[a]
70
+ if a.count(":") == 1:
71
+ x, y = a.split(":")
72
+ try:
73
+ x, y = float(x), float(y)
74
+ except ValueError:
75
+ return None
76
+ if x > 0 and y > 0 and math.isfinite(x) and math.isfinite(y) and math.isfinite(x / y) and x / y > 0:
77
+ return x / y
78
+ return None
79
+
80
+
81
+ def _check_bg(bg, errs):
82
+ if isinstance(bg, str) and (bg in ("auto", "transparent") or bg in PRESETS):
83
+ return
84
+ if isinstance(bg, list):
85
+ if not 2 <= len(bg) <= 5 or not all(_color_ok(c) for c in bg):
86
+ errs.append("spec.frame: 'bg' gradient must be a list of 2 to 5 colors (#hex or css)")
87
+ return
88
+ if not _color_ok(bg):
89
+ errs.append(f"spec.frame: bad bg {bg!r}; use auto, one of {', '.join(PRESETS)}, transparent, "
90
+ "#hex or [\"#hex\", \"#hex\"]")
91
+
92
+
93
+ def validate_frame(frame) -> list[str]:
94
+ """Every problem with a `frame` value, as spec.py-style messages (empty list = valid)."""
95
+ if frame is None or isinstance(frame, bool):
96
+ return []
97
+ errs: list[str] = []
98
+ if isinstance(frame, (str, list)): # shorthand: just the background
99
+ _check_bg(frame, errs)
100
+ return errs
101
+ if not isinstance(frame, dict):
102
+ return ["spec.frame: must be true, a background (name, color, [colors]) or an object"]
103
+ for k in frame:
104
+ if k not in FRAME_DEFAULTS:
105
+ errs.append(f"spec.frame: unknown key {k!r}; use {', '.join(FRAME_DEFAULTS)}")
106
+ if "bg" in frame:
107
+ _check_bg(frame["bg"], errs)
108
+ for k in ("pad", "radius"):
109
+ if frame.get(k) is not None and not (_num(frame[k]) and frame[k] >= 0):
110
+ errs.append(f"spec.frame: '{k}' must be a number >= 0")
111
+ s = frame.get("shadow", True)
112
+ if not (isinstance(s, bool) or (_num(s) and 0 <= s <= 1)):
113
+ errs.append("spec.frame: 'shadow' must be true, false or a number from 0 to 1")
114
+ if frame.get("chrome") not in (None, "browser", "window"):
115
+ errs.append("spec.frame: 'chrome' must be browser, window or null")
116
+ if frame.get("url") is not None:
117
+ if not isinstance(frame["url"], str):
118
+ errs.append("spec.frame: 'url' must be a string")
119
+ elif frame.get("chrome") != "browser":
120
+ errs.append("spec.frame: 'url' needs chrome 'browser'")
121
+ if frame.get("theme", "auto") not in ("auto", "light", "dark"):
122
+ errs.append("spec.frame: 'theme' must be auto, light or dark")
123
+ if frame.get("aspect") is not None and _aspect_ratio(frame["aspect"]) is None:
124
+ errs.append("spec.frame: 'aspect' must be 16:9, 4:3, 1:1, twitter, linkedin or W:H")
125
+ if not isinstance(frame.get("balance", False), bool):
126
+ errs.append("spec.frame: 'balance' must be true or false")
127
+ return errs
128
+
129
+
130
+ def normalize_frame(frame) -> dict | None:
131
+ """Frame value -> full options dict (None when framing is off). ValueError if invalid."""
132
+ if frame is None or frame is False:
133
+ return None
134
+ errs = validate_frame(frame)
135
+ if errs:
136
+ raise ValueError("invalid frame:\n " + "\n ".join(errs))
137
+ opts = dict(FRAME_DEFAULTS)
138
+ if isinstance(frame, (str, list)):
139
+ opts["bg"] = frame
140
+ elif isinstance(frame, dict):
141
+ opts.update(frame)
142
+ return opts
143
+
144
+
145
+ # ---------- window: chrome bar, corners, hairline ----------
146
+
147
+ def _corner(r, S=4):
148
+ tile = Image.new("L", (r * S, r * S), 0)
149
+ ImageDraw.Draw(tile).ellipse([0, 0, 2 * r * S - 1, 2 * r * S - 1], fill=255)
150
+ return tile.resize((r, r), Image.LANCZOS)
151
+
152
+
153
+ def rounded_mask(size, r):
154
+ """Opaque L mask with anti-aliased rounded corners (only the corners are supersampled)."""
155
+ w, h = size
156
+ m = Image.new("L", size, 255)
157
+ r = int(min(r, w // 2, h // 2))
158
+ if r <= 0:
159
+ return m
160
+ c = _corner(r)
161
+ m.paste(c, (0, 0))
162
+ m.paste(c.transpose(Image.FLIP_LEFT_RIGHT), (w - r, 0))
163
+ m.paste(c.transpose(Image.FLIP_TOP_BOTTOM), (0, h - r))
164
+ m.paste(c.transpose(Image.ROTATE_180), (w - r, h - r))
165
+ return m
166
+
167
+
168
+ _UI_FONTS = ["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/HelveticaNeue.ttc",
169
+ "C:\\Windows\\Fonts\\segoeui.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]
170
+
171
+
172
+ def ui_font(size):
173
+ """A regular-weight UI font for the address bar; the bundled Inter Bold as a fallback."""
174
+ if not os.environ.get("AINOTATE_FONT"):
175
+ for path in _UI_FONTS:
176
+ if os.path.isfile(path):
177
+ try:
178
+ return ImageFont.truetype(path, size)
179
+ except OSError:
180
+ pass
181
+ return load_font(size)
182
+
183
+
184
+ def chrome_theme(img, theme="auto"):
185
+ """light/dark chrome: matches the top edge of the screenshot, like the real browser would."""
186
+ if theme in ("light", "dark"):
187
+ return theme
188
+ band = max(2, round(img.height * 0.04))
189
+ return "light" if luminance(img, (0, 0, img.width, band)) >= 0.55 else "dark"
190
+
191
+
192
+ def chrome_height(kind, u):
193
+ return 0 if not kind else round((3.3 if kind == "browser" else 2.4) * u)
194
+
195
+
196
+ def draw_chrome(width, kind, theme, u, url=None, S=4):
197
+ """The bar above the screenshot, drawn at S x and downsampled."""
198
+ H = chrome_height(kind, u)
199
+ pal = CHROME[theme]
200
+ W4, H4 = width * S, H * S
201
+ bar = Image.new("RGB", (W4, H4), pal["bar"])
202
+ d = ImageDraw.Draw(bar)
203
+ d.rectangle([0, H4 - max(S, round(0.07 * u * S)), W4, H4], fill=pal["line"])
204
+ cy = (H4 - S) / 2
205
+ rad = LIGHT_R * u * S
206
+ x = LIGHT_X0 * u * S
207
+ rim = 0.06 * u * S
208
+ for fill, ring in LIGHTS:
209
+ d.ellipse([x - rad, cy - rad, x + rad, cy + rad], fill=ring)
210
+ d.ellipse([x - rad + rim, cy - rad + rim, x + rad - rim, cy + rad - rim], fill=fill)
211
+ x += LIGHT_STEP * u * S
212
+ lights_end = x - LIGHT_STEP * u * S + rad
213
+ if kind == "browser":
214
+ ph = 2.05 * u * S
215
+ pw = min(max(22 * u * S, 0.42 * W4), W4 - 2 * (lights_end + 1.5 * u * S))
216
+ if pw > 6 * u * S:
217
+ x0 = (W4 - pw) / 2
218
+ d.rounded_rectangle([x0, cy - ph / 2, x0 + pw, cy + ph / 2], radius=0.55 * u * S, fill=pal["pill"])
219
+ text = (url or "").strip()
220
+ font = ui_font(max(8, round(0.98 * u * S)))
221
+ lock_w = 0.95 * u * S
222
+ maxw = pw - 3 * u * S - lock_w
223
+ while text and font.getlength(text) > maxw:
224
+ text = text[:-2] + "…" if len(text) > 2 else ""
225
+ tw = font.getlength(text) if text else 0
226
+ gx = W4 / 2 - (tw + lock_w + (0.45 * u * S if text else 0)) / 2
227
+ _lock(d, gx, cy, u * S, pal["text"])
228
+ if text:
229
+ d.text((gx + lock_w + 0.45 * u * S, cy), text, fill=pal["text"], font=font, anchor="lm")
230
+ return bar.resize((width, H), Image.LANCZOS)
231
+
232
+
233
+ def _lock(d, x, cy, U, color):
234
+ """Small padlock glyph (body + shackle), left edge at x, centered on cy."""
235
+ bw, bh = 0.78 * U, 0.6 * U
236
+ top = cy - bh / 2 + 0.2 * U
237
+ d.rounded_rectangle([x, top, x + bw, top + bh], radius=0.12 * U, fill=color)
238
+ sw = 0.5 * U
239
+ sx = x + (bw - sw) / 2
240
+ d.arc([sx, top - 0.5 * U, sx + sw, top + 0.35 * U], 180, 360, fill=color, width=max(1, round(0.11 * U)))
241
+ d.line([sx + 0.055 * U, top - 0.07 * U, sx + 0.055 * U, top], fill=color, width=max(1, round(0.11 * U)))
242
+ d.line([sx + sw - 0.055 * U, top - 0.07 * U, sx + sw - 0.055 * U, top], fill=color,
243
+ width=max(1, round(0.11 * U)))
244
+
245
+
246
+ # ---------- layout ----------
247
+
248
+ def canvas_size(win, pad, aspect):
249
+ """Window + padding, grown (never cropped) to the aspect ratio."""
250
+ W, H = win[0] + 2 * pad, win[1] + 2 * pad
251
+ r = _aspect_ratio(aspect) if aspect else None
252
+ if r:
253
+ if W / H < r:
254
+ W = math.ceil(H * r)
255
+ else:
256
+ H = math.ceil(W / r)
257
+ return int(W), int(H)
258
+
259
+
260
+ def content_box(img):
261
+ """Bounding box of what differs from the screenshot's edge color (None if all flat)."""
262
+ t = img.convert("RGB")
263
+ corners = [t.getpixel(p) for p in ((0, 0), (t.width - 1, 0), (0, t.height - 1), (t.width - 1, t.height - 1))]
264
+ bgc = max(set(corners), key=corners.count)
265
+ diff = ImageChops.difference(t, Image.new("RGB", t.size, bgc)).convert("L").point(lambda v: 255 if v > 24 else 0)
266
+ return diff.getbbox()
267
+
268
+
269
+ def balance_shift(img, top_extra, pad):
270
+ """(dx, dy) that moves the window so its content, not its border, sits in the middle."""
271
+ box = content_box(img)
272
+ if not box:
273
+ return 0, 0
274
+ w, h = img.size
275
+ lim = 0.6 * pad
276
+ dx = (w - box[2] - box[0]) / 2
277
+ dy = (h - box[3] - box[1]) / 2
278
+ if top_extra: # a chrome bar already weighs the top: correct half as much vertically
279
+ dy /= 2
280
+ return max(-lim, min(lim, dx)), max(-lim, min(lim, dy))
281
+
282
+
283
+ # ---------- main ----------
284
+
285
+ def apply_frame(img: Image.Image, frame, unit: float, source: Image.Image | None = None) -> Image.Image:
286
+ """Put the screenshot on a backdrop. Returns RGB, or RGBA when bg is "transparent".
287
+ frame: True (defaults), a bg name/color/[colors], or an options dict (see FRAME_DEFAULTS).
288
+ source: the same screenshot before any mark was drawn. When given, the auto background and the
289
+ chrome theme are read from it, so a big green label cannot turn the backdrop green."""
290
+ opts = normalize_frame(frame)
291
+ if opts is None:
292
+ return img
293
+ u = float(unit)
294
+ shot = img.convert("RGB")
295
+ ref = shot if source is None else source.convert("RGB")
296
+ w, h = shot.size
297
+
298
+ kind = opts["chrome"]
299
+ theme = chrome_theme(ref, opts["theme"]) if kind else None
300
+ hb = chrome_height(kind, u)
301
+ win = Image.new("RGB", (w, h + hb))
302
+ if kind:
303
+ win.paste(draw_chrome(w, kind, theme, u, opts["url"]), (0, 0))
304
+ win.paste(shot, (0, hb))
305
+ ww, wh = win.size
306
+
307
+ pad = round(0.06 * max(w, h)) if opts["pad"] is None else round(opts["pad"])
308
+ radius = round(1.0 * u if opts["radius"] is None else opts["radius"])
309
+ W, H = canvas_size((ww, wh), pad, opts["aspect"])
310
+ x0, y0 = (W - ww) / 2, (H - wh) / 2
311
+ if opts["balance"]:
312
+ dx, dy = balance_shift(shot, hb, pad)
313
+ x0, y0 = x0 + dx, y0 + dy
314
+ x0, y0 = round(x0), round(y0)
315
+
316
+ transparent = opts["bg"] == "transparent"
317
+ stops = None if transparent else bg_stops(opts["bg"], ref)
318
+ canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0)) if transparent else gradient((W, H), stops).convert("RGBA")
319
+
320
+ mask = rounded_mask((ww, wh), radius)
321
+ strength = 0.0 if opts["shadow"] is False else (0.40 if opts["shadow"] is True else float(opts["shadow"]))
322
+ if strength > 0 and pad > 0:
323
+ tint = (0, 0, 0) if transparent else shadow_color(stops) # cast light: a deeper shade of the backdrop
324
+ canvas = Image.alpha_composite(canvas, _shadow((W, H), mask, (x0, y0), strength, u, pad, tint))
325
+
326
+ # window edge: a hairline that separates it from the backdrop, like a macOS window border
327
+ edge_light = _edge_lightness(win) > 0.5
328
+ t = max(1, round(u / 16))
329
+ inner = Image.new("L", (ww, wh), 0)
330
+ if ww > 2 * t and wh > 2 * t:
331
+ inner.paste(rounded_mask((ww - 2 * t, wh - 2 * t), max(0, radius - t)), (t, t))
332
+ ring = ImageChops.subtract(mask, inner)
333
+ win_rgba = win.convert("RGBA")
334
+ win_rgba.putalpha(mask)
335
+ line = (0, 0, 0, 30) if edge_light else (255, 255, 255, 34)
336
+ hair = Image.new("RGBA", (ww, wh), line[:3] + (0,))
337
+ hair.putalpha(ring.point(lambda v: v * line[3] // 255))
338
+ win_rgba = Image.alpha_composite(win_rgba, hair)
339
+ canvas.alpha_composite(win_rgba, (x0, y0))
340
+ return canvas if transparent else canvas.convert("RGB")
341
+
342
+
343
+ def _shadow(size, mask, pos, strength, u, pad, color=(0, 0, 0)):
344
+ """Two-layer macOS-like window shadow: a wide, soft, low ambient shadow pushed down a little,
345
+ plus a tight contact shadow that anchors the edges. Blurred at 1/4 size for speed."""
346
+ W, H = size
347
+ out = Image.new("RGBA", size, (0, 0, 0, 0))
348
+ k = 4
349
+ layers = ((min(2.0 * u, pad / 2.6), 0.9 * u, strength), (min(0.35 * u, pad / 3), 0.12 * u, strength * 0.55))
350
+ for blur, dy, op in layers:
351
+ sk = k if blur > 6 else 1
352
+ a = Image.new("L", (math.ceil(W / sk), math.ceil(H / sk)), 0)
353
+ m = mask.resize((max(1, round(mask.width / sk)), max(1, round(mask.height / sk))), Image.BOX)
354
+ a.paste(m, (round(pos[0] / sk), round((pos[1] + dy) / sk)))
355
+ a = a.filter(ImageFilter.GaussianBlur(max(0.5, blur / sk)))
356
+ if sk > 1:
357
+ a = a.resize(size, Image.BICUBIC)
358
+ layer = Image.new("RGBA", size, tuple(color) + (0,))
359
+ layer.putalpha(a.point(lambda v: round(v * op)))
360
+ out = Image.alpha_composite(out, layer)
361
+ return out
@@ -0,0 +1,238 @@
1
+ """Color and gradient math for beautify: OKLab conversions, auto backgrounds, gradients, grain.
2
+ Re-exported by beautify."""
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+ from functools import lru_cache
8
+
9
+ from PIL import Image, ImageChops, ImageColor, ImageDraw, ImageFilter
10
+
11
+ from .style import COLORS, LABEL_FILL
12
+
13
+ _MARK_COLORS = list(COLORS.values()) + list(LABEL_FILL.values())
14
+
15
+ # Diagonal gradients, top-left -> bottom-right. Soft and low-chroma so the screenshot stays the
16
+ # subject; each pair stays in one hue family (no muddy midpoints).
17
+ PRESETS = {
18
+ "sunset": ["#FFD9B8", "#F9B5A7", "#E9A1C2"],
19
+ "ocean": ["#B9E3F6", "#8DB8F2", "#7C8FE6"],
20
+ "slate": ["#56647A", "#2C3546", "#1A202C"],
21
+ "mint": ["#DDF7E8", "#A8E6CF", "#83CDC6"],
22
+ "lavender": ["#EEE6FD", "#D2C8F6", "#B3BDF0"],
23
+ "graphite": ["#48484C", "#2A2A2D", "#161618"],
24
+ "white": ["#FBFBFD", "#F0F0F3", "#E4E4E9"],
25
+ }
26
+
27
+
28
+ # ---------- color (OKLab: perceptually even gradients and lightness) ----------
29
+
30
+ def _lin(c):
31
+ c /= 255
32
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
33
+
34
+
35
+ def _gam(c):
36
+ c = 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
37
+ return c * 255
38
+
39
+
40
+ def to_oklab(rgb):
41
+ r, g, b = (_lin(v) for v in rgb[:3])
42
+ l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3)
43
+ m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3)
44
+ s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3)
45
+ return (0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
46
+ 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
47
+ 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s)
48
+
49
+
50
+ def _oklab_lin(L, a, b):
51
+ l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
52
+ m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
53
+ s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3
54
+ return (4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
55
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
56
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)
57
+
58
+
59
+ def from_oklab(lab):
60
+ return tuple(max(0, min(255, round(_gam(max(0.0, min(1.0, v)))))) for v in _oklab_lin(*lab))
61
+
62
+
63
+ def oklch(L, C, h_deg):
64
+ """In-gamut OKLab for (L, C, h): chroma shrinks until the color fits sRGB."""
65
+ h = math.radians(h_deg)
66
+ fits = lambda c: all(-1e-4 <= v <= 1.0001 for v in _oklab_lin(L, c * math.cos(h), c * math.sin(h)))
67
+ lo, hi = 0.0, C
68
+ if not fits(C):
69
+ for _ in range(16):
70
+ mid = (lo + hi) / 2
71
+ lo, hi = (mid, hi) if fits(mid) else (lo, mid)
72
+ C = lo
73
+ return (L, C * math.cos(h), C * math.sin(h))
74
+
75
+
76
+ def luminance(img, box=None):
77
+ """Mean perceptual lightness (OKLab L, 0..1) of an image region."""
78
+ region = img.crop(box) if box else img
79
+ small = region.convert("RGB").resize((max(1, min(64, region.width)), max(1, min(16, region.height))),
80
+ Image.BOX)
81
+ px = list(_pixels(small))
82
+ return sum(to_oklab(p)[0] for p in px) / len(px)
83
+
84
+
85
+ # ---------- auto background ----------
86
+
87
+ def _pixels(im):
88
+ """Pixel values; Pillow 12.3 deprecates getdata() in favor of get_flattened_data()."""
89
+ return im.get_flattened_data() if hasattr(im, "get_flattened_data") else im.getdata()
90
+
91
+
92
+ def _thumb(img, side=72, resample=Image.BOX):
93
+ w, h = img.size
94
+ k = side / max(w, h)
95
+ return img.convert("RGB").resize((max(4, round(w * k)), max(4, round(h * k))), resample)
96
+
97
+
98
+ def dominant_hue(img):
99
+ """(hue in degrees or None, colorfulness 0..1). Annotation colors are ignored: they say
100
+ nothing about the product in the screenshot."""
101
+ marks = [to_oklab(ImageColor.getrgb(c)) for c in _MARK_COLORS]
102
+ bins = [0.0] * 24
103
+ pts = []
104
+ # NEAREST: pixels stay either page colors or exact mark colors (no blends that dodge the filter)
105
+ thumb = _thumb(img, 120, Image.NEAREST)
106
+ for p in _pixels(thumb):
107
+ L, a, b = to_oklab(p)
108
+ C = math.hypot(a, b)
109
+ if C < 0.045 or not 0.25 < L < 0.97:
110
+ continue
111
+ if any(math.dist((L, a, b), m) < 0.035 for m in marks):
112
+ continue
113
+ h = math.degrees(math.atan2(b, a)) % 360
114
+ bins[int(h // 15) % 24] += C
115
+ pts.append((h, C, a, b))
116
+ n = thumb.width * thumb.height
117
+ total = sum(bins)
118
+ if not pts or total / n < 0.0004:
119
+ return None, total / n
120
+ peak = max(range(24), key=lambda i: bins[i - 1] + bins[i] + bins[(i + 1) % 24])
121
+ center = peak * 15 + 7.5
122
+ sa = sum(a for h, C, a, b in pts if abs((h - center + 180) % 360 - 180) <= 30)
123
+ sb = sum(b for h, C, a, b in pts if abs((h - center + 180) % 360 - 180) <= 30)
124
+ return math.degrees(math.atan2(sb, sa)) % 360, total / n
125
+
126
+
127
+ def auto_stops(img):
128
+ """A soft three-stop gradient in the screenshot's own hue family (analogous hues, never
129
+ the complement), lighter behind light screenshots and deeper behind dark ones."""
130
+ hue, _ = dominant_hue(img)
131
+ chroma = 0.10
132
+ if hue is None: # grayscale UI: a calm blue-violet
133
+ hue, chroma = 262, 0.06
134
+ dark = _edge_lightness(img) <= 0.6
135
+ Ls = (0.64, 0.52, 0.42) if dark else (0.90, 0.81, 0.73)
136
+ chroma *= 1.15 if dark else 1.0
137
+ return [oklch(L, chroma * f, h) for L, f, h in zip(Ls, (0.75, 1.0, 0.95), auto_hues(hue, dark))]
138
+
139
+
140
+ def auto_hues(h, dark=False):
141
+ """Three analogous hues around h that never pass through the olive/khaki band (~75-130
142
+ in OKLab), where soft gradients turn muddy: warm hues drift toward rose, yellow-greens
143
+ become mint."""
144
+ if h < 20 or h >= 330:
145
+ return h + 25, h, h - 30
146
+ if h < 100: # orange / amber -> peach, coral, rose
147
+ h = min(h, 62)
148
+ return (h - 15, h - 45, h - 75) if dark else (h + 8, h - 20, h - 48)
149
+ if h < 175: # yellow-green / green -> mint, teal
150
+ h = max(h, 160)
151
+ return h - 10, h + 12, h + 38
152
+ return h - 28, h, h + 30
153
+
154
+
155
+ def _edge_lightness(img):
156
+ t = _thumb(img)
157
+ w, h = t.size
158
+ px = t.load()
159
+ ring = [px[x, y] for x in range(w) for y in (0, 1, h - 2, h - 1)] + \
160
+ [px[x, y] for y in range(h) for x in (0, 1, w - 2, w - 1)]
161
+ return sum(to_oklab(p)[0] for p in ring) / len(ring)
162
+
163
+
164
+ def bg_stops(bg, img):
165
+ if bg == "auto":
166
+ return auto_stops(img)
167
+ if isinstance(bg, str) and bg in PRESETS:
168
+ bg = PRESETS[bg]
169
+ if isinstance(bg, str):
170
+ bg = [bg]
171
+ return [to_oklab(ImageColor.getrgb(c)) for c in bg]
172
+
173
+
174
+ def shadow_color(stops):
175
+ """Window shadow tint: the backdrop's deepest stop, much darker and a bit less saturated.
176
+ A colored shadow reads as light falling on the backdrop; neutral black turns pastels gray."""
177
+ L, a, b = min(stops, key=lambda s: s[0])
178
+ return from_oklab((min(0.24, 0.4 * L), 0.8 * a, 0.8 * b))
179
+
180
+
181
+ # ---------- background painting ----------
182
+
183
+ @lru_cache(maxsize=4)
184
+ def _grain_tile(sigma=1.4, size=128):
185
+ rnd = random.Random(20261007)
186
+ tile = Image.new("L", (size, size))
187
+ tile.putdata([max(0, min(255, round(128 + rnd.gauss(0, sigma)))) for _ in range(size * size)])
188
+ return tile
189
+
190
+
191
+ def _grain(img):
192
+ """Fixed, invisible film grain: kills banding in 8-bit gradients. Deterministic."""
193
+ tile = _grain_tile()
194
+ noise = Image.new("L", img.size)
195
+ for y in range(0, img.height, tile.height):
196
+ for x in range(0, img.width, tile.width):
197
+ noise.paste(tile, (x, y))
198
+ n3 = Image.merge("RGB", (noise, noise, noise))
199
+ return ImageChops.add(img, n3, 1.0, -128)
200
+
201
+
202
+ def gradient(size, stops):
203
+ """Diagonal (top-left -> bottom-right) gradient through OKLab stops, plus a faint light
204
+ bloom in the top-left corner so a flat gradient reads as depth."""
205
+ W, H = size
206
+ if len(stops) == 1:
207
+ return Image.new("RGB", size, from_oklab(stops[0]))
208
+ lut = []
209
+ seg = len(stops) - 1
210
+ for i in range(512):
211
+ t = i / 511 * seg
212
+ k = min(seg - 1, int(t))
213
+ f = t - k
214
+ lab = tuple(stops[k][j] + (stops[k + 1][j] - stops[k][j]) * f for j in range(3))
215
+ lut.append(from_oklab(lab))
216
+ sw = max(2, min(W, 360))
217
+ sh = max(2, round(H * sw / W))
218
+ # the gradient runs along the diagonal, perpendicular lines share a color (CSS 135deg)
219
+ dx, dy = W, H
220
+ norm = dx * dx + dy * dy
221
+ data = []
222
+ for y in range(sh):
223
+ Y = (y + 0.5) / sh * H
224
+ for x in range(sw):
225
+ X = (x + 0.5) / sw * W
226
+ t = (X * dx + Y * dy) / norm
227
+ data.append(lut[max(0, min(511, round(t * 511)))])
228
+ small = Image.new("RGB", (sw, sh))
229
+ small.putdata(data)
230
+ img = small.resize(size, Image.BICUBIC)
231
+ # soft bloom: a big blurred ellipse of a lighter first stop, at the top-left
232
+ L0, a0, b0 = stops[0]
233
+ glow = from_oklab((min(1.0, L0 + 0.12), a0 * 0.85, b0 * 0.85))
234
+ bloom = Image.new("L", (sw, sh), 0)
235
+ ImageDraw.Draw(bloom).ellipse([-0.35 * sw, -0.6 * sh, 0.55 * sw, 0.7 * sh], fill=255)
236
+ bloom = bloom.filter(ImageFilter.GaussianBlur(0.18 * max(sw, sh))).resize(size, Image.BICUBIC)
237
+ img = Image.composite(Image.new("RGB", size, glow), img, bloom.point(lambda v: int(v * 0.45)))
238
+ return _grain(img)
File without changes