Download training/elem_sheets.py from ProCreations/nadir: direct link, hf CLI and curl.
- Browser
- Download file 17.6 kB
-
https://huggingface.co/ProCreations/nadir/resolve/main/training/elem_sheets.py
- Command line
-
hf download hf://ProCreations/nadir/training/elem_sheets.py
-
curl -L -o elem_sheets.py https://huggingface.co/ProCreations/nadir/resolve/main/training/elem_sheets.py
17.6 kB
| #!/usr/bin/env python3 | |
| """Element-head teacher data, step 1: contact sheets showing each page image every way the extension can render it. | |
| For each sampled image (logo, icon, formula, photo, backdrop...) of a crawled light page that has a Haiku dark design, | |
| one row: the element in its light page (red outline), then the candidates on the dark surface designed for that spot: | |
| A keep - unchanged | |
| B invert - exactly Chrome's `invert(1) hue-rotate(180deg)` (dark <-> light, hues kept) | |
| C adapt - the runtime's adaptPixels: neutral dark ink -> light, white box removed, brand colours kept | |
| D remove - large CSS background images only: the designed dark surface shows through | |
| Writes OUT/sheets/<id>.png and OUT/meta.<worker>.jsonl (one line per sheet: rows with page id, image index, offers). | |
| """ | |
| import argparse, base64, glob, gzip, hashlib, io, json, math, os, random, sys | |
| from multiprocessing import Pool | |
| from pathlib import Path | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFont | |
| HUE180 = np.array([[-0.574, 1.43, 0.144], [0.426, 0.43, 0.144], [0.426, 1.43, -0.856]]) | |
| SMALL, LARGE = "small", "large" | |
| GEOM = {SMALL: dict(cell=(124, 112), ctx=(232, 112), rows=8), LARGE: dict(cell=(250, 140), ctx=(250, 140), rows=5)} | |
| KIND = {"img": "image", "bg": "CSS background", "pseudo": "CSS icon", "svg": "inline SVG", "svgimage": "SVG image"} | |
| # ---------- colour maths (same as the runtime) ---------- | |
| def lin(u): | |
| u = u / 255.0 | |
| return np.where(u <= 0.04045, u / 12.92, ((u + 0.055) / 1.055) ** 2.4) | |
| def oklab(rgb): # (..., 3) uint8 -> (..., 3) | |
| r, g, b = lin(rgb[..., 0]), lin(rgb[..., 1]), lin(rgb[..., 2]) | |
| l = np.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) | |
| m = np.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) | |
| s = np.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) | |
| return np.stack([0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, | |
| 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, | |
| 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s], -1) | |
| def to_rgb(L, a, b): | |
| l_ = L + 0.3963377774 * a + 0.2158037573 * b | |
| m_ = L - 0.1055613458 * a - 0.0638541728 * b | |
| s_ = L - 0.0894841775 * a - 1.2914855480 * b | |
| l, m, s = l_ ** 3, m_ ** 3, s_ ** 3 | |
| def enc(x): | |
| x = np.clip(x, 0, 1) | |
| return np.round(255 * np.where(x <= 0.0031308, 12.92 * x, 1.055 * np.power(x, 1 / 2.4) - 0.055)) | |
| return np.stack([enc(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), | |
| enc(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), | |
| enc(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)], -1) | |
| def invert(rgba): | |
| x = 1.0 - rgba[..., :3] / 255.0 | |
| out = rgba.copy() | |
| out[..., :3] = np.round(np.clip(x @ HUE180.T, 0, 1) * 255) | |
| return out | |
| def adapt(rgba): | |
| """Port of nadir-map.js adaptPixels (incl. transparent mode). None when there is nothing to adapt.""" | |
| d = rgba.astype(np.float64) | |
| h, w = d.shape[:2] | |
| ring = np.zeros((h, w), bool) | |
| ring[0, :] = ring[-1, :] = ring[:, 0] = ring[:, -1] = True | |
| rp = d[ring] | |
| ringN = len(rp) | |
| opq = rp[:, 3] >= 250 | |
| n = int(opq.sum()) | |
| o = oklab(rp[opq][:, :3]) if n else np.zeros((0, 3)) | |
| lightm = (o[:, 0] > 0.9) & (np.hypot(o[:, 1], o[:, 2]) < 0.04) if n else np.zeros(0, bool) | |
| light = int(lightm.sum()) | |
| bg = None | |
| if n and light / n >= 0.5: | |
| bg = oklab(rp[opq][lightm][:, :3].mean(0)) | |
| elif n > 0.2 * ringN: | |
| return None | |
| else: | |
| a = d[..., 3] >= 128 | |
| if not a.any(): | |
| return None | |
| ol = oklab(d[a][:, :3]) | |
| ink = (ol[:, 0] < 0.5) & (np.hypot(ol[:, 1], ol[:, 2]) < 0.05) | |
| if ink.mean() < 0.08: | |
| return None | |
| out = np.zeros_like(d) | |
| vis = d[..., 3] > 0 | |
| px = d[vis] | |
| ol = oklab(px[:, :3]) | |
| if bg is not None: | |
| dist = np.linalg.norm(ol - bg, axis=1) | |
| cov = np.clip((dist - 0.03) / 0.22, 0, 1) | |
| else: | |
| cov = np.ones(len(px)) | |
| keep = cov > 0 | |
| c = np.where(keep, cov, 1.0) | |
| if bg is not None: | |
| L = (ol[:, 0] - (1 - c) * bg[0]) / c | |
| A = (ol[:, 1] - (1 - c) * bg[1]) / c | |
| B = (ol[:, 2] - (1 - c) * bg[2]) / c | |
| else: | |
| L, A, B = ol[:, 0].copy(), ol[:, 1].copy(), ol[:, 2].copy() | |
| L = np.clip(L, 0, 1) | |
| C = np.hypot(A, B) | |
| neutral = C < 0.05 | |
| lift = (~neutral) & (L < 0.62) | |
| L = np.where(neutral, 0.94 - 0.75 * L, np.where(lift, 0.62 + 0.15 * L / 0.62, L)) | |
| A = np.where(lift, A * 0.92, A) | |
| B = np.where(lift, B * 0.92, B) | |
| rgb = to_rgb(L, A, B) | |
| res = np.zeros((len(px), 4)) | |
| res[:, :3] = rgb | |
| res[:, 3] = np.round(px[:, 3] * cov) | |
| res[~keep] = 0 | |
| out[vis] = res | |
| return out.astype(np.uint8) | |
| # ---------- page data ---------- | |
| def hex_rgba(h): | |
| h = h.lstrip("#") | |
| r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) | |
| a = int(h[6:8], 16) / 255 if len(h) >= 8 else 1.0 | |
| return r, g, b, a | |
| def over(top, base): | |
| r, g, b, a = top | |
| return tuple(round(a * t + (1 - a) * u) for t, u in zip((r, g, b), base)) | |
| def dark_palette(page, design): | |
| """Dark surface per token index and the main dark text colour, from the Haiku design.""" | |
| cols = {int(k): v for k, v in design["colors"].items()} if isinstance(design["colors"], dict) else {int(c["id"].lstrip("t")): c["dark"] for c in design["colors"]} | |
| toks = page["tokens"] | |
| root = next((i for i, t in enumerate(toks) if t["role"] == 0 and t["f"][24] > 0.5), 0) | |
| canvas = hex_rgba(cols[root])[:3] if root in cols else (18, 18, 20) | |
| best, text = -1, (230, 232, 235) | |
| for i, t in enumerate(toks): | |
| if t["role"] == 1 and i in cols and t["f"][19] > best: | |
| best, text = t["f"][19], over(hex_rgba(cols[i]), canvas) | |
| surf = {i: over(hex_rgba(cols[i]), canvas) for i, t in enumerate(toks) if i in cols and t["role"] == 0} | |
| return surf, canvas, text | |
| def content_area(o, big): | |
| """The big thumbnail is letterboxed into a square: cut out the image itself.""" | |
| S = big.shape[0] | |
| if o.get("cropPx"): | |
| nw, nh = o["cropPx"][2], o["cropPx"][3] | |
| elif o.get("kind") == "svg": | |
| nw, nh = o["w"], o["h"] | |
| else: | |
| nw, nh = (o.get("nw") or o["w"]), (o.get("nh") or o["h"]) | |
| nw, nh = max(1, nw), max(1, nh) | |
| s = min(S / nw, S / nh) | |
| dw, dh = max(1, round(nw * s)), max(1, round(nh * s)) | |
| x0, y0 = (S - dw) // 2, (S - dh) // 2 | |
| return big[y0:y0 + dh, x0:x0 + dw] | |
| def decode_png(data_url): | |
| return np.array(Image.open(io.BytesIO(base64.b64decode(data_url.split(",", 1)[1]))).convert("RGBA")) | |
| def bucket(o): | |
| s = o.get("stats") or {} | |
| area = o["w"] * o["h"] | |
| if o.get("logo") or (o.get("homeLink") and o.get("header")): | |
| return "logo" | |
| if o["kind"] == "bg" and area >= 20000: | |
| return "backdrop" | |
| if o["kind"] in ("pseudo",) or o.get("cropPx"): | |
| return "icon" | |
| if s.get("transp", 0) > 0.15: | |
| return "transparent" | |
| if s.get("transp", 1) < 0.02 and s.get("colors", 0) > 0.18 and s.get("stdL", 0) > 0.12: | |
| return "photo" | |
| return "flat" | |
| RATE = {"logo": 1.0, "backdrop": 1.0, "icon": 1.0, "transparent": 1.0, "flat": 0.8, "photo": 0.12} | |
| def candidates(task): | |
| """All sampled elements of one page with everything needed to draw their rows.""" | |
| crawl, haiku, pid, tag, seed = task | |
| rnd = random.Random(seed) | |
| try: | |
| page = json.load(gzip.open(Path(crawl) / "pages" / pid / "light.json.gz")) | |
| design = json.load(open(Path(haiku) / f"{pid}.json")) | |
| if design.get("refusal") or not design.get("colors"): | |
| return [] | |
| surf, canvas, text = dark_palette(page, design) | |
| except Exception: | |
| return [] | |
| out = [] | |
| for i, o in enumerate(page["images"]): | |
| if not o.get("big") or o["w"] * o["h"] < 64 or o["w"] < 4 or o["h"] < 4: | |
| continue | |
| if o["kind"] == "svg" and not o.get("useExt"): # inline SVGs are recoloured by the colour model, not as images | |
| continue | |
| b = bucket(o) | |
| if rnd.random() > RATE[b]: | |
| continue | |
| dsurf = surf.get(o.get("underTok", -1), canvas) | |
| out.append({"page": pid, "crawl": tag, "img": i, "bucket": b, "o": {k: o.get(k) for k in ("kind", "src", "x", "y", "w", "h", "nw", "nh", "cropPx", "logo", "homeLink", "header", "inLink", "surf", "big", "stats")}, | |
| "dsurf": dsurf, "dtext": text, "light": tuple((o.get("surf") or [255, 255, 255])[:3])}) | |
| return out | |
| # ---------- drawing ---------- | |
| def font(sz): | |
| try: | |
| return ImageFont.load_default(size=sz) | |
| except TypeError: | |
| return ImageFont.load_default() | |
| F11, F13, F15 = font(11), font(13), font(15) | |
| def fit(o, box, maxzoom=4.0): | |
| s = min(box[0] / max(1, o["w"]), box[1] / max(1, o["h"]), maxzoom) | |
| return max(1, round(o["w"] * s)), max(1, round(o["h"] * s)), s | |
| def variant_cell(img, o, typ, size, surface, text, mode): | |
| W, H = size | |
| cell = Image.new("RGBA", (W, H), tuple(surface) + (255,)) | |
| if mode != "D": | |
| dw, dh, _ = fit(o, (W - 12, H - 12)) | |
| im = Image.fromarray(img, "RGBA").resize((dw, dh), Image.LANCZOS) | |
| cell.alpha_composite(im, ((W - dw) // 2, (H - dh) // 2)) | |
| if typ == LARGE and o["kind"] == "bg": # content on a background image: in dark mode its text is light | |
| dr = ImageDraw.Draw(cell) | |
| dr.text((12, H // 2 - 22), "Heading on this background", fill=tuple(text), font=F15) | |
| dr.text((12, H // 2 + 2), "Body text that sits on top of it.", fill=tuple(text), font=F13) | |
| return cell | |
| def context_cell(shot, e, size): | |
| W, H = size | |
| o = e["o"] | |
| if shot is not None and o["y"] + o["h"] <= shot.height and o["x"] >= -2 and o["x"] + o["w"] <= shot.width + 2: | |
| m = max(30, 0.75 * max(o["w"], o["h"])) | |
| x0, y0 = max(0, o["x"] - m), max(0, o["y"] - m) | |
| x1, y1 = min(shot.width, o["x"] + o["w"] + m), min(shot.height, o["y"] + o["h"] + m) | |
| crop = shot.crop((int(x0), int(y0), int(x1), int(y1))) | |
| s = min(W / crop.width, H / crop.height, 3.0) | |
| cw, ch = max(1, round(crop.width * s)), max(1, round(crop.height * s)) | |
| crop = crop.resize((cw, ch), Image.LANCZOS).convert("RGBA") | |
| cell = Image.new("RGBA", (W, H), (236, 236, 236, 255)) | |
| ox, oy = (W - cw) // 2, (H - ch) // 2 | |
| cell.alpha_composite(crop, (ox, oy)) | |
| dr = ImageDraw.Draw(cell) | |
| rx, ry = ox + (o["x"] - x0) * s, oy + (o["y"] - y0) * s | |
| dr.rectangle([rx - 2, ry - 2, rx + o["w"] * s + 1, ry + o["h"] * s + 1], outline=(230, 20, 20, 255), width=2) | |
| return cell | |
| # below the captured screenshot: the element on its light surface | |
| cell = Image.new("RGBA", (W, H), tuple(e["light"]) + (255,)) | |
| big = content_area(o, decode_png(o["big"])) | |
| dw, dh, _ = fit(o, (W - 16, H - 16)) | |
| cell.alpha_composite(Image.fromarray(big, "RGBA").resize((dw, dh), Image.LANCZOS), ((W - dw) // 2, (H - dh) // 2)) | |
| ImageDraw.Draw(cell).text((4, H - 14), "(on its light background)", fill=(90, 90, 90), font=F11) | |
| return cell | |
| def draw_sheet(rows, typ, shots, path): | |
| g = GEOM[typ] | |
| cw, ch = g["cell"] | |
| xw, xh = g["ctx"] | |
| pad, lab, cap = 6, 30, 16 | |
| W = lab + xw + 4 * (cw + pad) + pad | |
| rh = max(ch, xh) + cap + 10 | |
| H = 26 + rh * len(rows) | |
| sheet = Image.new("RGB", (W, H), (255, 255, 255)) | |
| dr = ImageDraw.Draw(sheet) | |
| heads = ["light page (red box)", "A keep", "B invert", "C adapt", "D remove"] | |
| xs = [lab, lab + xw + pad] + [lab + xw + pad + k * (cw + pad) for k in range(1, 4)] | |
| for x, t in zip(xs, heads): | |
| dr.text((x + 4, 6), t, fill=(0, 0, 0), font=F13) | |
| meta = [] | |
| for r, e in enumerate(rows): | |
| y = 26 + r * rh | |
| o = e["o"] | |
| dr.text((4, y + ch // 2 - 8), f"#{r + 1}", fill=(0, 0, 0), font=F15) | |
| sheet.paste(context_cell(shots.get(e["page"]), e, (xw, xh)).convert("RGB"), (xs[0], y)) | |
| big = content_area(o, decode_png(o["big"])) | |
| offers = "AB" | |
| inv = invert(big) | |
| ad = adapt(big) | |
| cells = [variant_cell(big, o, typ, (cw, ch), e["dsurf"], e["dtext"], "A"), variant_cell(inv, o, typ, (cw, ch), e["dsurf"], e["dtext"], "B")] | |
| if ad is not None: | |
| offers += "C" | |
| cells.append(variant_cell(ad, o, typ, (cw, ch), e["dsurf"], e["dtext"], "C")) | |
| else: | |
| cells.append(None) | |
| if typ == LARGE and o["kind"] == "bg": | |
| offers += "D" | |
| cells.append(variant_cell(big, o, typ, (cw, ch), e["dsurf"], e["dtext"], "D")) | |
| else: | |
| cells.append(None) | |
| for k, c in enumerate(cells): | |
| x = xs[1 + k] | |
| if c is None: | |
| dr.rectangle([x, y, x + cw - 1, y + ch - 1], fill=(225, 225, 225)) | |
| dr.text((x + cw // 2 - 14, y + ch // 2 - 7), "n/a", fill=(140, 140, 140), font=F13) | |
| else: | |
| sheet.paste(c.convert("RGB"), (x, y)) | |
| flags = [f for f, v in (("logo", o.get("logo")), ("links home", o.get("homeLink")), ("in a link", o.get("inLink") and not o.get("homeLink")), ("in header", o.get("header"))) if v] | |
| _, _, z = fit(o, (cw - 12, ch - 12)) | |
| zoom = f", shown {z:.1f}x" if abs(z - 1) > 0.15 else "" | |
| dr.text((xs[1], y + ch + 2), f"#{r + 1}: {KIND.get(o['kind'], o['kind'])} {o['w']}x{o['h']} px{zoom}" + (" · " + ", ".join(flags) if flags else ""), fill=(60, 60, 60), font=F11) | |
| meta.append({"k": r + 1, "page": e["page"], "crawl": e["crawl"], "img": e["img"], "bucket": e["bucket"], "offer": offers, "kind": o["kind"]}) | |
| sheet.save(path, optimize=True) | |
| return meta | |
| def render_chunk(job): | |
| wid, rows_by_sheet, crawls, out = job | |
| shots = {} | |
| outp = Path(out) | |
| lines = [] | |
| for sid, typ, rows in rows_by_sheet: | |
| for e in rows: | |
| if e["page"] not in shots: | |
| p = Path(crawls[e["crawl"]]) / "pages" / e["page"] / "shot.jpg" | |
| try: | |
| shots[e["page"]] = Image.open(p).convert("RGB") | |
| except Exception: | |
| shots[e["page"]] = None | |
| try: | |
| meta = draw_sheet(rows, typ, shots, outp / "sheets" / f"{sid}.png") | |
| lines.append(json.dumps({"sheet": sid, "type": typ, "rows": meta})) | |
| except Exception as ex: | |
| print("sheet error", sid, repr(ex)[:200], flush=True) | |
| if len(shots) > 64: | |
| shots.clear() | |
| with open(outp / f"meta.{wid}.jsonl", "a") as f: | |
| f.write("\n".join(lines) + ("\n" if lines else "")) | |
| return len(lines) | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--crawl", action="append", required=True, help="TAG=CRAWL_DIR=HAIKU_DIR (repeatable)") | |
| ap.add_argument("--out", required=True) | |
| ap.add_argument("--max-elements", type=int, default=150000) | |
| ap.add_argument("--max-per-src", type=int, default=2) | |
| ap.add_argument("--workers", type=int, default=28) | |
| ap.add_argument("--exclude-pages", default="", help="file of page ids already done") | |
| ap.add_argument("--flat-rate", type=float, default=None, help="sampling rate for opaque flat/photo-like images (mostly 'keep')") | |
| args = ap.parse_args() | |
| if args.flat_rate is not None: | |
| RATE["flat"] = args.flat_rate | |
| out = Path(args.out) | |
| (out / "sheets").mkdir(parents=True, exist_ok=True) | |
| crawls, tasks = {}, [] | |
| done = set(l.strip() for l in open(args.exclude_pages)) if args.exclude_pages and os.path.exists(args.exclude_pages) else set() | |
| for spec in args.crawl: | |
| tag, cdir, hdir = spec.split("=") | |
| crawls[tag] = cdir | |
| for hp in Path(hdir).glob("*.json"): | |
| pid = hp.stem | |
| if len(pid) == 16 and pid not in done and (Path(cdir) / "pages" / pid / "light.json.gz").exists(): | |
| tasks.append((cdir, hdir, pid, tag, int(hashlib.md5(pid.encode()).hexdigest()[:8], 16))) | |
| random.Random(3).shuffle(tasks) | |
| print(len(tasks), "pages with designs", flush=True) | |
| per_src, elems, by_bucket = {}, [], {} | |
| with Pool(args.workers) as pool: | |
| for res in pool.imap_unordered(candidates, tasks, chunksize=16): | |
| for e in res: | |
| s = e["o"]["src"] | |
| if per_src.get(s, 0) >= args.max_per_src: | |
| continue | |
| per_src[s] = per_src.get(s, 0) + 1 | |
| elems.append(e) | |
| by_bucket[e["bucket"]] = by_bucket.get(e["bucket"], 0) + 1 | |
| if len(elems) >= args.max_elements: | |
| break | |
| elems = elems[: args.max_elements] | |
| print(len(elems), "elements", by_bucket, flush=True) | |
| with open(out / "pages_used.txt", "a") as f: | |
| f.write("\n".join(sorted(set(e["page"] for e in elems))) + "\n") | |
| # sheets: small and large elements apart; rows mixed across pages | |
| random.Random(5).shuffle(elems) | |
| groups = {SMALL: [], LARGE: []} | |
| for e in elems: | |
| o = e["o"] | |
| typ = LARGE if (o["w"] * o["h"] >= 20000 or o["w"] > 300) else SMALL | |
| groups[typ].append(e) | |
| sheets = [] | |
| for typ, es in groups.items(): | |
| n = GEOM[typ]["rows"] | |
| for i in range(0, len(es), n): | |
| rows = es[i:i + n] | |
| sid = hashlib.sha1(json.dumps([(r["page"], r["img"]) for r in rows]).encode()).hexdigest()[:16] | |
| sheets.append((sid, typ, rows)) | |
| jobs = [(w, sheets[w::args.workers], crawls, str(out)) for w in range(args.workers)] | |
| with Pool(args.workers) as pool: | |
| n = sum(pool.map(render_chunk, jobs)) | |
| print("sheets", n, flush=True) | |
| if __name__ == "__main__": | |
| main() | |