#!/usr/bin/env python3 """ Watch face vector reconstruction, v2. Changes over v1: * ring ticks are annular sectors (constant angular width) instead of rects * every tick's colour is sampled directly from the source, no LUT interpolation * glyph alpha is normalised against the *local* fill colour, so vertically graded digits keep their true weight in the dark rows * time digits get multi-stop gradients sampled every 8 px * sub-pixel geometry throughout """ import json, math, os import numpy as np from PIL import Image from skimage import measure SRC = "/mnt/user-data/uploads" OUT = "/mnt/user-data/outputs" os.makedirs(OUT, exist_ok=True) CX_IX, CY_IX = 250.038, 250.049 # dial centre, pixel-index space CX, CY = CX_IX + 0.5, CY_IX + 0.5 # same point in SVG user space PX = 0.5 # index -> SVG offset THEMES = [ ("0fbde6715a76f357bcc5c20cff74c0bf2f156e89.png", "01", "Ember"), ("304a904845dbc44aa0c87eecad2dd3a2621d2ffc.png", "02", "Aurora"), ("a61e76acdc7f17edfda3b11d36ba5d1c25e0739b.png", "03", "Brass"), ("f43ad1228487aa0261183e7268277f0e9ab328f4.png", "04", "Voltage"), ("8fd4a2811dc0227b004a5832479cc0a5d4e26a9f.png", "05", "Reef"), ("f087f3345e5036bfd406719e8d7517c13109d998.png", "06", "Acid"), ("d3a77eea386551ff09b352a77f522bb3c68e925d.png", "07", "Kelp"), ] # ---------------------------------------------------------- ring geometry --- R_IN, R_OUT = 231.09, 249.10 TOP_PHASE, TOP_PITCH, TOP_W, TOP_N = 6.500, 4.000, 3.108, 21 BOT_PHASE, BOT_PITCH, BOT_W, BOT_N = 6.005, 3.000, 2.086, 21 DOT_R, DOT_RAD = 240.10, 9.00 DOT_ANGLES = [0.0, 93.15, 180.0, 266.85] BAND_Y, BAND_H = 286.30, 44.19 COLON = [(237.88, 183.68, 24.24, 21.30), (237.88, 234.57, 24.24, 21.30)] def tick_angles(): """(group, signed centre angle) for all 86 ticks.""" out = [] for k in range(TOP_N): a = TOP_PHASE + TOP_PITCH * k out.append(("top-right", a, TOP_W)) out.append(("top-left", -a, TOP_W)) for k in range(BOT_N): a = BOT_PHASE + BOT_PITCH * k out.append(("bottom-left", 180.0 + a, BOT_W)) out.append(("bottom-right", 180.0 - a, BOT_W)) return out def polar(ang, r): t = math.radians(ang - 90.0) return CX + math.cos(t) * r, CY + math.sin(t) * r def polar_ix(ang, r): t = math.radians(ang - 90.0) return CX_IX + math.cos(t) * r, CY_IX + math.sin(t) * r def sector_path(centre, width, r0=R_IN, r1=R_OUT): a0, a1 = centre - width / 2, centre + width / 2 x1, y1 = polar(a0, r1) x2, y2 = polar(a1, r1) x3, y3 = polar(a1, r0) x4, y4 = polar(a0, r0) return (f"M{x1:.2f},{y1:.2f} A{r1:.2f},{r1:.2f} 0 0 1 {x2:.2f},{y2:.2f} " f"L{x3:.2f},{y3:.2f} A{r0:.2f},{r0:.2f} 0 0 0 {x4:.2f},{y4:.2f} Z") def sample_tick(arr, centre, width): """Median colour of a tick's interior (AA edges excluded).""" px = [] for r in np.arange(R_IN + 3.5, R_OUT - 3.5, 1.0): for d in np.arange(-width * 0.32, width * 0.32 + 1e-9, width * 0.16): x, y = polar_ix(centre + d, r) px.append(arr[int(round(y)), int(round(x))]) return np.median(np.array(px), axis=0) # --------------------------------------------------------------- tracing --- def rdp(pts, eps): if len(pts) < 3: return pts a, b = pts[0], pts[-1] ab = b - a n = math.hypot(ab[0], ab[1]) if n < 1e-9: d = np.hypot(*(pts - a).T) else: d = np.abs(ab[0] * (pts[:, 1] - a[1]) - ab[1] * (pts[:, 0] - a[0])) / n i = int(np.argmax(d)) if d[i] > eps: return np.vstack([rdp(pts[: i + 1], eps)[:-1], rdp(pts[i:], eps)]) return np.vstack([a, b]) def trace(alpha, x0, y0, eps=0.07, ss=6): half = 0.5 / ss # supersampled index -> source continuous coordinate up = np.asarray(Image.fromarray((np.clip(alpha, 0, 1) * 255).astype(np.uint8)) .resize((alpha.shape[1] * ss, alpha.shape[0] * ss), Image.BICUBIC)) / 255.0 up = np.pad(up, 1, mode="constant") parts = [] for c in measure.find_contours(up, 0.5): c = (c - 1) / ss pts = np.column_stack([c[:, 1] + x0 + half, c[:, 0] + y0 + half]) if len(pts) < 10: continue pts = rdp(pts, eps) if len(pts) < 4: continue parts.append("M" + " ".join(f"{x:.2f},{y:.2f}" for x, y in pts[:-1]) + "Z") return " ".join(parts) def full_colour(reg, q=0.97): """Colour of a fully-opaque pixel in a solid-filled region.""" flat = reg.reshape(-1, 3) l = flat.mean(axis=1) cut = np.quantile(l, q) sel = flat[l >= cut] return sel.mean(axis=0) def alpha_solid(arr, box): x0, y0, x1, y1 = box reg = arr[y0:y1, x0:x1].astype(float) e = full_colour(reg) a = reg.mean(axis=2) / max(e.mean(), 1e-6) return np.clip(a, 0, 1), e, x0, y0 def row_ramp(reg): """Per-row fully-opaque colour for a vertically graded glyph.""" h = reg.shape[0] ys, cols = [], [] for y in range(h): row = reg[y] l = row.mean(axis=1) if l.max() < 30: continue sel = row[l >= l.max() * 0.92] if len(sel) < 3: continue ys.append(y) cols.append(sel.mean(axis=0)) if len(ys) < 4: return None, None ys = np.array(ys) cols = np.array(cols) # smooth then extend to every row out = np.zeros((h, 3)) for c in range(3): out[:, c] = np.interp(np.arange(h), ys, np.convolve( cols[:, c], np.ones(5) / 5, mode="same") if len(ys) > 8 else cols[:, c]) out[: ys[0], c] = cols[0, c] out[ys[-1] + 1:, c] = cols[-1, c] return out, (ys[0], ys[-1]) def alpha_graded(arr, box): x0, y0, x1, y1 = box reg = arr[y0:y1, x0:x1].astype(float) ramp, span = row_ramp(reg) if ramp is None: return alpha_solid(arr, box) + (None,) denom = np.maximum(ramp.mean(axis=1), 1e-6)[:, None] a = np.clip(reg.mean(axis=2) / denom, 0, 1) return a, ramp, x0, y0, span def alpha_on_fill(arr, box, fill): x0, y0, x1, y1 = box reg = arr[y0:y1, x0:x1].astype(float) fill = np.array(fill, float) d = reg - fill far = d.reshape(-1, 3) mag = np.linalg.norm(far, axis=1) e = far[mag >= np.quantile(mag, 0.97)].mean(axis=0) a = (d @ e) / max(e @ e, 1e-6) return np.clip(a, 0, 1), fill + e, x0, y0 def hx(c): return "#%02X%02X%02X" % tuple(int(round(min(255, max(0, v)))) for v in c) # --------------------------------------------------------------- elements --- SOLID = [ ("battery-icon", (188, 38, 228, 68)), ("battery-value", (228, 38, 312, 70)), ("sunrise-icon", (136, 72, 178, 108)), ("sunrise-value", (106, 110, 208, 152)), ("weather-icon", (322, 70, 364, 108)), ("weather-value", (258, 110, 428, 152)), ("dist-value", (166, 345, 240, 388)), ("dist-label", (143, 390, 238, 432)), ("steps-value", (261, 345, 366, 388)), ("steps-label", (263, 390, 386, 432)), ] BAND_EL = [ ("bt-icon", (20, 289, 54, 326)), ("date-day", (132, 290, 223, 326)), ("date-md", (225, 290, 370, 326)), ("meridiem", (404, 290, 472, 326)), ] TIME_L = (76, 162, 218, 278) TIME_R = (264, 162, 434, 278) def grad_def(gid, ramp, y0, span): ya, yb = y0 + span[0] + 0.5, y0 + span[1] + 0.5 n = max(2, int((span[1] - span[0]) // 8)) stops = [] for i in range(n + 1): f = i / n yy = int(round(span[0] + f * (span[1] - span[0]))) stops.append(f'') return (f'{"".join(stops)}') def build(path, tid, tname): arr = np.array(Image.open(os.path.join(SRC, path)).convert("RGB")).astype(int) defs, parts, tok = [], [], {} parts.append('') # ---- ring: every tick measured individually groups = {} for gname, centre, width in tick_angles(): raw = sample_tick(arr, centre, width) if raw.mean() < 12: # slot occupied by an anchor dot / not drawn continue col = hx(raw) groups.setdefault(gname, []).append( f'') ring = [] for g in ("top-right", "top-left", "bottom-left", "bottom-right"): ring.append(f'\n ' + "\n ".join(groups[g]) + "\n ") dot_col = hx(np.median(arr[4:13, 245:255].reshape(-1, 3), axis=0)) dots = [] for a in DOT_ANGLES: x, y = polar(a, DOT_R) dots.append(f'') ring.append('\n ' + "\n ".join(dots) + "\n ") parts.append('\n ' + "\n ".join(ring) + "\n") tok["anchorDot"] = dot_col # ---- date band fill = arr[300, 110].astype(float) band = [f''] band_text = None for name, box in BAND_EL: a, e, x0, y0 = alpha_on_fill(arr, box, fill) d = trace(a, x0, y0) if not d: continue band_text = hx(e) band.append(f'') parts.append('\n ' + "\n ".join(band) + "\n") tok["bandFill"], tok["bandText"] = hx(fill), band_text # ---- time tg = [] for gid, name, box in (("gHours", "time-hours", TIME_L), ("gMinutes", "time-minutes", TIME_R)): a, ramp, x0, y0, span = alpha_graded(arr, box) if span is None: continue defs.append(grad_def(gid, ramp, y0, span)) tok[gid] = [hx(ramp[span[0]]), hx(ramp[span[1]])] tg.append((name, f'')) colon_col = hx(np.median(arr[188:200, 242:258].reshape(-1, 3), axis=0)) tok["colon"] = colon_col colon = '' + "".join( f'' for x, y, w, h in COLON) + "" parts.append('\n ' + tg[0][1] + "\n " + colon + "\n " + tg[1][1] + "\n") # ---- complications comp = [] for name, box in SOLID: a, e, x0, y0 = alpha_solid(arr, box) d = trace(a, x0, y0) if not d: continue comp.append(f'') tok[name] = hx(e) parts.append('\n ' + "\n ".join(comp) + "\n") svg = ('\n' f'Watch face {tid} {tname}\n' f'{"".join(defs)}\n' + "\n".join(parts) + "\n\n") p = os.path.join(OUT, f"watchface-{tid}-{tname.lower()}.svg") open(p, "w").write(svg) # ring colour table for the spec tok["ringTop"] = {f"{TOP_PHASE + TOP_PITCH * k:.2f}": hx(sample_tick(arr, TOP_PHASE + TOP_PITCH * k, TOP_W)) for k in range(TOP_N)} tok["ringBottom"] = {f"{BOT_PHASE + BOT_PITCH * k:.2f}": hx(sample_tick(arr, 180 + BOT_PHASE + BOT_PITCH * k, BOT_W)) for k in range(BOT_N)} return p, tok if __name__ == "__main__": tokens = {} for p, tid, tname in THEMES: out, tok = build(p, tid, tname) tok["name"] = tname tokens[tid] = tok print("wrote", out) json.dump(tokens, open(os.path.join(OUT, "watchface-tokens.json"), "w"), indent=2)