返回 CodeWhale
whale-points.py
根目录 / scripts / brand / whale-points.py
1 #!/usr/bin/env python3
2 """Derive the pet body (`crates/tui/src/tui/ambient_life/whale-points.tsv`).
3
4 python3 scripts/brand/whale-points.py # rewrite the point cloud
5 python3 scripts/brand/whale-points.py --check # exit 1 if it drifted
6 python3 scripts/brand/whale-points.py --preview # print the cloud as ASCII
7
8 The pet is a 980-particle body. Before this script the body was hand-authored
9 and did not follow the product mark: measured against `brand/mark.svg`'s
10 silhouette only ~19-23% of its points landed inside the mark once the cloud was
11 scaled to fill it, so the pet read as static rather than a whale.
12
13 Source of truth is the same one `trace-brand.py` uses: the hero whale of
14 `brand/codewhalemarkfinal.png`. `brand/mark.svg` is the kept trace of that hero,
15 and the founder's app-icon render is the same silhouette (0.93 IoU), so there is
16 exactly one mark and this script derives from it rather than redrawing it.
17
18 Sampling is deliberately contour-only. 980 discs cannot fill a solid
19 silhouette legibly at pet sizes, so the cloud spends its whole budget on the
20 mark's outline (outer edge plus internal boundaries), where each dot buys the
21 most shape. Spacing is even (farthest-point sampling) because clumped sampling
22 reads as noise even where the underlying silhouette is correct.
23
24 Measured against the shipped dot radius, the body this replaced covered ~6% of
25 the mark's outline and buried the rest under a diffuse interior; this one
26 covers ~80% of it continuously.
27
28 Requires `pillow` and `numpy`. No network. No ImageMagick.
29 """
30
31 from __future__ import annotations
32
33 import argparse
34 import pathlib
35 import sys
36
37 try:
38 from PIL import Image
39 import numpy as np
40 except ImportError:
41 raise SystemExit("whale-points.py requires pillow and numpy")
42
43 ROOT = pathlib.Path(__file__).resolve().parents[2]
44 SHEET = ROOT / "brand" / "codewhalemarkfinal.png"
45 OUT = ROOT / "crates" / "tui" / "src" / "tui" / "ambient_life" / "whale-points.tsv"
46
47 # `pet-native.js` rejects any body that is not exactly 980 x 2 finite points in
48 # [-1, 1]; the sim, the served TSV and the desktop client must agree on a count.
49 COUNT = 980
50 # Normalized half-extent of the longer side. The renderers apply one uniform
51 # scale to x and y, so the cloud must be aspect-true to the mark and this is
52 # what sets the pet's on-screen size.
53 HALF_EXTENT = 0.44
54
55
56 def hero_mask(path: pathlib.Path) -> np.ndarray:
57 """The hero whale of the brand sheet, as a boolean ink mask.
58
59 The sheet is a multi-panel page (hero mark, size ramp, icon row, wordmark),
60 so the hero is found rather than assumed: threshold, then keep the largest
61 dark component in the top half, which is the hero mark.
62 """
63 grey = np.array(Image.open(path).convert("L"), dtype=np.float64)
64 h, w = grey.shape
65 ink = grey < 128
66 ink[int(0.52 * h) :, :] = False # below the hero band is the size ramp
67
68 # The hero is the topmost ink on the sheet; flood its component with a
69 # stack so a caption or a stray rule cannot be mistaken for the mark.
70 ys, xs = np.nonzero(ink)
71 if len(ys) == 0:
72 raise SystemExit(f"no ink found in {path}")
73 start = (int(ys[0]), int(xs[np.argmin(ys)]))
74 comp = np.zeros_like(ink)
75 comp[start] = True
76 stack = [start]
77 while stack:
78 y, x = stack.pop()
79 for ny, nx in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)):
80 if 0 <= ny < h and 0 <= nx < w and ink[ny, nx] and not comp[ny, nx]:
81 comp[ny, nx] = True
82 stack.append((ny, nx))
83 return comp
84
85
86 def crop(mask: np.ndarray) -> np.ndarray:
87 ys, xs = np.nonzero(mask)
88 return mask[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
89
90
91 def erode(mask: np.ndarray) -> np.ndarray:
92 out = mask.copy()
93 out[1:, :] &= mask[:-1, :]
94 out[:-1, :] &= mask[1:, :]
95 out[:, 1:] &= mask[:, :-1]
96 out[:, :-1] &= mask[:, 1:]
97 return out
98
99
100 def smooth(mask: np.ndarray, radius: int = 2) -> np.ndarray:
101 """Box-blur the edge before thresholding so the contour is not stair-stepped."""
102 a = mask.astype(np.float64)
103 for _ in range(radius):
104 b = a.copy()
105 b[1:, :] += a[:-1, :]
106 b[:-1, :] += a[1:, :]
107 b[:, 1:] += a[:, :-1]
108 b[:, :-1] += a[:, 1:]
109 a = b / b.max()
110 return a > 0.5
111
112
113 def farthest_point(candidates: np.ndarray, seeds: np.ndarray, want: int) -> np.ndarray:
114 """Even spacing: repeatedly take the candidate furthest from everything chosen.
115
116 This is what stops the cloud reading as noise: uniform-random sampling
117 clumps, and clumps read as speckle at pet sizes no matter how correct the
118 underlying silhouette is.
119 """
120 chosen = list(map(tuple, seeds))
121 if not chosen:
122 chosen.append(tuple(candidates[0]))
123 pts = candidates.astype(np.float64)
124 if len(chosen) < want:
125 base = np.array(chosen, dtype=np.float64)
126 best = np.full(len(pts), np.inf)
127 for p in base:
128 best = np.minimum(best, ((pts - p) ** 2).sum(1))
129 for _ in range(want - len(chosen)):
130 i = int(np.argmax(best))
131 p = pts[i]
132 chosen.append(tuple(candidates[i]))
133 best = np.minimum(best, ((pts - p) ** 2).sum(1))
134 best[i] = -1.0
135 return np.array(chosen, dtype=np.float64)
136
137
138 def cloud(mask: np.ndarray) -> np.ndarray:
139 """Spend the whole budget on the contour, evenly spaced.
140
141 Measured at the shipped dot radius (1.55px where the pet is rendered),
142 spreading points through the interior instead leaves most of the mark's
143 outline undrawn and scatters loose specks inside it - which is what made
144 the pet read as static. A contour-only cloud draws a continuous outline.
145 """
146 rim = mask & ~erode(mask)
147 rys, rxs = np.nonzero(rim)
148 rimp = np.stack([rxs, rys], axis=1).astype(np.float64)
149 if len(rimp) == 0:
150 raise SystemExit("no contour found")
151 # Seeds must be spread across the whole contour. Taking a prefix instead
152 # leaves everything past it undrawn, and farthest-point sampling cannot
153 # recover a region it has no seed near.
154 seed = rimp[np.linspace(0, len(rimp) - 1, min(64, len(rimp))).astype(int)]
155 return farthest_point(rimp, seed, COUNT)
156
157
158 def normalize(pts: np.ndarray, mask: np.ndarray) -> np.ndarray:
159 ys, xs = np.nonzero(mask)
160 cx = (xs.min() + xs.max()) / 2.0
161 cy = (ys.min() + ys.max()) / 2.0
162 span = max(xs.max() - xs.min(), ys.max() - ys.min())
163 scale = (HALF_EXTENT * 2.0) / (span + 1.0)
164 out = np.empty_like(pts)
165 out[:, 0] = (pts[:, 0] - cx) * scale
166 # Screen space is y-down and the mask is y-down, so this keeps the whale
167 # the right way up in both renderers.
168 out[:, 1] = (pts[:, 1] - cy) * scale
169 return out
170
171
172 def render() -> str:
173 mask = smooth(crop(hero_mask(SHEET)))
174 pts = normalize(cloud(mask), mask)
175 return "".join(f"{x:.6f}\t{y:.6f}\n" for x, y in pts)
176
177
178 def rasterize(pts: np.ndarray, w: int, h: int, dot_scale: float = 1.0) -> np.ndarray:
179 """Emulate the shipped paint path so legibility is judged on real output.
180
181 Mirrors `src/workspace/pet.rs` / `pet_watch/graphics.rs`: one uniform scale
182 for both axes, a disc per point, the same radius rule and clamp.
183 """
184 scale = min(w * 0.52, h * 0.85)
185 radius = max(0.68, min(1.55, min(w, h) * 0.00285)) * dot_scale
186 ox, oy = w * 0.5, h * 0.47
187 canvas = np.zeros((h, w), dtype=np.float64)
188 reach = int(radius) + 2
189 for px, py in pts:
190 cx = ox + px * scale
191 cy = oy + py * scale
192 x0, x1 = int(cx) - reach, int(cx) + reach + 1
193 y0, y1 = int(cy) - reach, int(cy) + reach + 1
194 if x1 < 0 or y1 < 0 or x0 >= w or y0 >= h:
195 continue
196 ys, xs = np.mgrid[max(0, y0) : min(h, y1), max(0, x0) : min(w, x1)]
197 d = np.hypot(xs - cx, ys - cy)
198 np.maximum(
199 canvas[max(0, y0) : min(h, y1), max(0, x0) : min(w, x1)],
200 np.clip(radius + 0.5 - d, 0.0, 1.0),
201 out=canvas[max(0, y0) : min(h, y1), max(0, x0) : min(w, x1)],
202 )
203 return canvas
204
205
206 def preview(text: str, label: str, w: int, h: int) -> None:
207 pts = np.array([list(map(float, line.split("\t"))) for line in text.strip().splitlines()])
208 canvas = rasterize(pts, w, h)
209 # Terminal cells are about twice as tall as wide, so the sample grid is
210 # twice as fine vertically as horizontally.
211 cols = 96
212 rows = max(1, int(h / w * cols * 0.5))
213 ramp = " .:-=+*#%@"
214 print(f"# {label} {w}x{h}px -> {cols}x{rows} cells")
215 for r in range(rows):
216 line = ""
217 for c in range(cols):
218 ys = slice(int(r * h / rows), max(int(r * h / rows) + 1, int((r + 1) * h / rows)))
219 xs = slice(int(c * w / cols), max(int(c * w / cols) + 1, int((c + 1) * w / cols)))
220 v = canvas[ys, xs].mean()
221 line += ramp[min(len(ramp) - 1, int(v * len(ramp) * 2.2))]
222 print(line)
223
224
225 def main() -> int:
226 parser = argparse.ArgumentParser(description=__doc__)
227 parser.add_argument("--check", action="store_true", help="fail if the file drifted")
228 parser.add_argument("--preview", action="store_true", help="render at the shipped sizes")
229 args = parser.parse_args()
230
231 text = render()
232 if args.preview:
233 preview(text, "ambient backdrop", 960, 560)
234 preview(text, "pet panel", 420, 260)
235 return 0
236 if args.check:
237 if not OUT.exists() or OUT.read_text() != text:
238 print(f"{OUT} is stale; run scripts/brand/whale-points.py", file=sys.stderr)
239 return 1
240 print(f"{OUT} matches the mark")
241 return 0
242 OUT.write_text(text)
243 print(f"wrote {OUT} ({COUNT} points)")
244 return 0
245
246
247 if __name__ == "__main__":
248 raise SystemExit(main())
249
249 lines PYTHON