返回 CodeWhale
braille-mark.py
根目录 / scripts / brand / braille-mark.py
1 #!/usr/bin/env python3
2 """Derive the TUI launch-mark assets from the founder raster (PRD section 6).
3
4 The launch mark in `crates/tui/src/tui/mark.rs` is generated here, never
5 hand-drawn. The canonical product mark is the founder-supplied raster
6 `brand/codewhalemarkfinal.png` (1254 x 1254 brand sheet: navy hero whale on
7 white, sizing row, the white-on-navy app icon, colour/mono/reversed rows).
8 Both TUI tiers are proportional/braille derivatives of that file — no
9 redraws, no traced SVG:
10
11 - braille rows <- the hero whale (navy on white, top of the sheet),
12 navy darkness box-filtered to a dot grid, aspect preserved and centred
13 in the rung's box, all-blank edge columns trimmed, the eye carved as one
14 cleared dot;
15 - kitty/sixel PNGs <- the app-icon panel (white whale on the navy rounded
16 square), auto-located as the largest navy blob in the sheet's right
17 middle band, squared, sheet-white keyed to transparent, proportionally
18 resized.
19
20 scripts/brand/braille-mark.py # print + Rust consts
21 scripts/brand/braille-mark.py --png crates/tui/assets/mark-96.png --px 96
22
23 Requires `pillow` (`pip install pillow`). No other dependencies.
24 """
25
26 from __future__ import annotations
27
28 import argparse
29 import collections
30 import pathlib
31 import sys
32
33 try:
34 from PIL import Image
35 except ImportError:
36 raise SystemExit("braille-mark.py requires pillow (`pip install pillow`)")
37
38 ROOT = pathlib.Path(__file__).resolve().parents[2]
39 RASTER = ROOT / "brand" / "codewhalemarkfinal.png"
40
41 # Search bands as fractions of the sheet, so the boxes track the layout
42 # rather than absolute pixels. The app-icon caption ("APP ICON", navy text)
43 # sits above the icon band; the band starts below it.
44 HERO_BAND = (0.0, 1.0, 0.0, 0.52) # x0, x1, y0, y1
45 ICON_BAND = (0.65, 1.0, 0.55, 0.78)
46 HERO_MARGIN = 12
47 ICON_PAD = 10
48 # Sheet background (and the icon's drop shadow, darkest ~211) keys out;
49 # founder navy (~15,33,65) never approaches this.
50 BG_CUTOFF = 200
51 # Braille dot bit for (dot_row, dot_col) inside one cell — U+2800 layout:
52 # dots 1,2,3 are column 0 rows 0..2 (bits 0..2), dots 4,5,6 column 1 rows
53 # 0..2 (bits 3..5), dots 7,8 are row 3 (bits 6,7).
54 DOT_BITS = {
55 (0, 0): 0x01,
56 (1, 0): 0x02,
57 (2, 0): 0x04,
58 (0, 1): 0x08,
59 (1, 1): 0x10,
60 (2, 1): 0x20,
61 (3, 0): 0x40,
62 (3, 1): 0x80,
63 }
64
65
66 def is_navy(pixel: tuple[int, int, int]) -> bool:
67 r, g, b = pixel
68 return b > 60 and b > r + 25 and r < 110 and g < 150
69
70
71 def load_sheet() -> Image.Image:
72 if not RASTER.exists():
73 raise SystemExit(f"founder raster missing: {RASTER}")
74 image = Image.open(RASTER).convert("RGB")
75 width, height = image.size
76 if width != height or width < 800:
77 raise SystemExit(f"unexpected founder sheet geometry: {image.size}")
78 return image
79
80
81 def band_box(image: Image.Image, band: tuple[float, float, float, float]):
82 width, height = image.size
83 return (
84 int(band[0] * width),
85 int(band[1] * width),
86 int(band[2] * height),
87 int(band[3] * height),
88 )
89
90
91 def hero_coverage(image: Image.Image) -> tuple[int, int, list[list[float]]]:
92 """Navy-darkness coverage of the hero whale crop, each in 0..1."""
93 width, height = image.size
94 x0, x1, y0, _ = band_box(image, HERO_BAND)
95 pixels = image.load()
96 xs, ys = [], []
97 for y in range(y0, int(HERO_BAND[3] * height)):
98 for x in range(x0, x1):
99 if is_navy(pixels[x, y]):
100 xs.append(x)
101 ys.append(y)
102 if not xs:
103 raise SystemExit("no navy hero whale found in the founder sheet")
104 box = (
105 max(0, min(xs) - HERO_MARGIN),
106 max(0, min(ys) - HERO_MARGIN),
107 min(width, max(xs) + HERO_MARGIN + 1),
108 min(height, max(ys) + HERO_MARGIN + 1),
109 )
110 crop = image.crop(box)
111 cover = crop.load()
112 cw, ch = crop.size
113 coverage = []
114 for y in range(ch):
115 row = []
116 for x in range(cw):
117 r, g, b = cover[x, y]
118 row.append(max(0.0, min(1.0, (180.0 - (r + g + b) / 3.0) / 120.0)))
119 coverage.append(row)
120 print(f"// hero whale box {box[0]},{box[1]}-{box[2]},{box[3]}", file=sys.stderr)
121 return cw, ch, coverage
122
123
124 def icon_square(image: Image.Image) -> Image.Image:
125 """The app-icon panel squared: white whale on the navy rounded square
126 with the sheet background keyed to transparent. Located as the largest
127 navy blob in the icon band, so the caption text (separate small blobs)
128 can never be mistaken for the mark."""
129 width, height = image.size
130 x0, x1, y0, y1 = band_box(image, ICON_BAND)
131 pixels = image.load()
132 seen = bytearray(width * height)
133 best: list[tuple[int, int]] = []
134 for sy in range(y0, y1):
135 for sx in range(x0, x1):
136 if not is_navy(pixels[sx, sy]) or seen[sy * width + sx]:
137 continue
138 blob, stack = [], collections.deque([(sx, sy)])
139 seen[sy * width + sx] = 1
140 while stack:
141 x, y = stack.pop()
142 blob.append((x, y))
143 for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
144 if (
145 x0 <= nx < x1
146 and y0 <= ny < y1
147 and not seen[ny * width + nx]
148 and is_navy(pixels[nx, ny])
149 ):
150 seen[ny * width + nx] = 1
151 stack.append((nx, ny))
152 if len(blob) > len(best):
153 best = blob
154 if len(best) < 10_000:
155 raise SystemExit("app-icon blob not found in the founder sheet")
156 bx0 = min(x for x, _ in best)
157 bx1 = max(x for x, _ in best)
158 by0 = min(y for _, y in best)
159 by1 = max(y for _, y in best)
160 # The white whale cuts the blob's left side, but its full height shows:
161 # the icon is square, so the edge is the height.
162 edge = by1 - by0 + 1
163 if not 150 <= edge <= 260:
164 raise SystemExit(f"app-icon blob has unexpected height: {edge}")
165 cx = (bx0 + bx1) // 2
166 cy = (by0 + by1) // 2
167 half = edge // 2 + ICON_PAD
168 box = (cx - half, cy - half, cx + half, cy + half)
169 print(
170 f"// app-icon navy blob x {bx0}-{bx1} y {by0}-{by1}, "
171 f"square crop {box[0]},{box[1]}-{box[2]},{box[3]}",
172 file=sys.stderr,
173 )
174 square = image.crop(box).convert("RGBA")
175 sw, sh = square.size
176 ink = square.load()
177 flood = bytearray(sw * sh)
178
179 def is_bg(x: int, y: int) -> bool:
180 r, g, b = ink[x, y][:3]
181 return min(r, g, b) > BG_CUTOFF
182
183 queue = collections.deque()
184 for x in range(sw):
185 for y in (0, sh - 1):
186 if is_bg(x, y):
187 queue.append((x, y))
188 flood[y * sw + x] = 1
189 for y in range(sh):
190 for x in (0, sw - 1):
191 if is_bg(x, y) and not flood[y * sw + x]:
192 queue.append((x, y))
193 flood[y * sw + x] = 1
194 while queue:
195 x, y = queue.popleft()
196 r, g, b, _ = ink[x, y]
197 ink[x, y] = (r, g, b, 0)
198 for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
199 if 0 <= nx < sw and 0 <= ny < sh and not flood[ny * sw + nx] and is_bg(nx, ny):
200 flood[ny * sw + nx] = 1
201 queue.append((nx, ny))
202 return square
203
204
205 def downsample(
206 coverage: list[list[float]], width: int, height: int, dots_w: int, dots_h: int
207 ) -> list[list[float]]:
208 """Box-filter coverage into a dots_w x dots_h grid, aspect preserved and
209 centred. Cells outside the glyph read 0."""
210 scale = min(dots_w / width, dots_h / height)
211 glyph_w = max(1, round(width * scale))
212 glyph_h = max(1, round(height * scale))
213 off_x = (dots_w - glyph_w) // 2
214 off_y = (dots_h - glyph_h) // 2
215 grid = [[0.0] * dots_w for _ in range(dots_h)]
216 for gy in range(glyph_h):
217 y0 = int(gy * height / glyph_h)
218 y1 = max(y0 + 1, int((gy + 1) * height / glyph_h))
219 for gx in range(glyph_w):
220 x0 = int(gx * width / glyph_w)
221 x1 = max(x0 + 1, int((gx + 1) * width / glyph_w))
222 total = 0.0
223 for y in range(y0, min(y1, height)):
224 row = coverage[y]
225 for x in range(x0, min(x1, width)):
226 total += row[x]
227 grid[off_y + gy][off_x + gx] = total / ((y1 - y0) * (x1 - x0))
228 return grid
229
230
231 def eye_hole(coverage: list[list[float]], width: int, height: int) -> tuple[float, float] | None:
232 """Locate the eye: the smallest enclosed hole in the glyph above the
233 raster-speck noise floor (the belly white is the other, far larger,
234 hole). Returns its centroid as a fraction of the glyph's width and
235 height, or None when nothing is enclosed. Works on a coarse copy so
236 the flood fill stays cheap."""
237 scale = max(1, width // 220)
238 cw, ch = width // scale, height // scale
239 solid = [
240 [coverage[y * scale][x * scale] >= 0.5 for x in range(cw)] for y in range(ch)
241 ]
242 seen = [[False] * cw for _ in range(ch)]
243 holes = []
244 for sy in range(ch):
245 for sx in range(cw):
246 if solid[sy][sx] or seen[sy][sx]:
247 continue
248 stack, cells, touches_edge = [(sx, sy)], [], False
249 seen[sy][sx] = True
250 while stack:
251 x, y = stack.pop()
252 cells.append((x, y))
253 if x in (0, cw - 1) or y in (0, ch - 1):
254 touches_edge = True
255 for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
256 if 0 <= nx < cw and 0 <= ny < ch and not solid[ny][nx] and not seen[ny][nx]:
257 seen[ny][nx] = True
258 stack.append((nx, ny))
259 if not touches_edge and len(cells) >= 4:
260 holes.append(cells)
261 if not holes:
262 return None
263 eye = min(holes, key=len)
264 cx = sum(x for x, _ in eye) / len(eye) + 0.5
265 cy = sum(y for _, y in eye) / len(eye) + 0.5
266 return cx / cw, cy / ch
267
268
269 def carve_eye(
270 grid: list[list[float]], width: int, height: int, dots_w: int, dots_h: int, eye: tuple[float, float]
271 ) -> None:
272 """Clear the one dot under the eye's centroid so the eye survives rungs
273 where it is smaller than a dot. Same geometry as `downsample`."""
274 scale = min(dots_w / width, dots_h / height)
275 glyph_w = max(1, round(width * scale))
276 glyph_h = max(1, round(height * scale))
277 off_x = (dots_w - glyph_w) // 2
278 off_y = (dots_h - glyph_h) // 2
279 x = min(dots_w - 1, off_x + int(eye[0] * glyph_w))
280 y = min(dots_h - 1, off_y + int(eye[1] * glyph_h))
281 grid[y][x] = 0.0
282
283
284 def to_braille(grid: list[list[float]], cols: int, rows: int, threshold: float) -> list[str]:
285 lines = []
286 for cy in range(rows):
287 line = []
288 for cx in range(cols):
289 bits = 0
290 for (dy, dx), bit in DOT_BITS.items():
291 if grid[cy * 4 + dy][cx * 2 + dx] >= threshold:
292 bits |= bit
293 line.append(chr(0x2800 + bits) if bits else " ")
294 lines.append("".join(line))
295 return lines
296
297
298 def trim_columns(lines: list[str]) -> list[str]:
299 width = max(len(line) for line in lines)
300 padded = [line.ljust(width) for line in lines]
301 blank = [all(line[x] == " " for line in padded) for x in range(width)]
302 first = next((x for x in range(width) if not blank[x]), 0)
303 last = next((x for x in range(width - 1, -1, -1) if not blank[x]), width - 1)
304 return [line[first : last + 1] for line in padded]
305
306
307 def rust_const(name: str, lines: list[str]) -> str:
308 body = "\n".join(f' "{line}",' for line in lines)
309 return f"const {name}: [&str; {len(lines)}] = [\n{body}\n];"
310
311
312 def write_png(out: pathlib.Path, px: int) -> None:
313 square = icon_square(load_sheet())
314 square.resize((px, px), Image.LANCZOS).save(out)
315 print(f"wrote {out} ({px}x{px}, founder app-icon derivative)")
316
317
318 def main() -> int:
319 parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
320 parser.add_argument(
321 "--rung",
322 action="append",
323 default=None,
324 metavar="NAME:COLSxROWS",
325 help="cell box to render, e.g. SMALL:11x3 (default: SMALL:11x3 TINY:8x2)",
326 )
327 parser.add_argument("--threshold", type=float, default=0.3, help="dot coverage threshold")
328 parser.add_argument("--no-eye", action="store_true", help="do not carve the eye dot")
329 parser.add_argument("--png", type=pathlib.Path, help="write an app-icon PNG instead")
330 parser.add_argument("--px", type=int, default=96, help="PNG edge in pixels")
331 args = parser.parse_args()
332
333 if args.png:
334 write_png(args.png, args.px)
335 return 0
336
337 rungs = args.rung or ["SMALL:11x3", "TINY:8x2"]
338 width, height, coverage = hero_coverage(load_sheet())
339 eye = None if args.no_eye else eye_hole(coverage, width, height)
340 print("// generated by scripts/brand/braille-mark.py from brand/codewhalemarkfinal.png")
341 print(
342 f"// (founder hero whale {width}x{height}px, "
343 f"threshold {args.threshold}, aspect preserved, edge columns trimmed, "
344 f"eye {'carved' if eye else 'not found'})"
345 )
346 for spec in rungs:
347 name, box = spec.split(":")
348 cols, rows = (int(v) for v in box.lower().split("x"))
349 grid = downsample(coverage, width, height, cols * 2, rows * 4)
350 if eye is not None:
351 carve_eye(grid, width, height, cols * 2, rows * 4, eye)
352 lines = trim_columns(to_braille(grid, cols, rows, args.threshold))
353 print(f"\n// {name}: box {cols}x{rows} -> ink {len(lines[0])}x{len(lines)}")
354 print(rust_const(f"{name}_ROWS", lines))
355 print("//", file=sys.stderr)
356 for line in lines:
357 print(f"// |{line}|", file=sys.stderr)
358 return 0
359
360
361 if __name__ == "__main__":
362 raise SystemExit(main())
363
363 lines PYTHON