From 5c523c9f571d948cdf3f90e85b3821acc259ce74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kris=20Kr=C3=BCg?= Date: Sat, 11 Jul 2026 22:13:53 -0700 Subject: [PATCH] feat: add real-sky-poster skill Codifies the pipeline that replaced the invented constellations in the Futureproof launch artwork with the real sky over Vancouver on opening night (WalksWithASwagger/futureproof-festival#644). The premise is that you cannot generate your way to this. An image model asked for "accurate constellations" produces a fresh set of hallucinated star patterns -- the same failure in a new arrangement. Star positions are exact data, so the skill ports the data instead of asking a model to imagine it. Four stages, and the order is the trick: plate wipe the invented sky, keep the aurora and the brushwork upscale 4x the CLEAN PLATE with a local ESRGAN (free, on-device) render draw the real stars at target scale -- natively, never upscaled kit extend the sky for tall formats, set type, export every size Upscaling the plate and redrawing gives sharp stars at any size; upscaling a finished poster gives blurry ones. Tall formats grow the canvas upward rather than centre-cropping, because cropping a 16:9 painting to a square throws away the left and right edges -- exactly where the constellations are. Upward is toward the zenith, which is where the figures that could not fit the wide frame actually were. Astronomy is standard-library only so it can be proven with no image, no GPU and no network. The smoke test attacks the one claim that matters -- that these are the real stars in the real places -- rather than exercising plumbing: - Polaris altitude equals observer latitude, at four latitudes - Polaris is invisible from Sydney (below the horizon means below the horizon) - the gnomonic projection preserves true angular proportions - the Futureproof night reproduces, including Orion not yet risen and Pegasus behind the viewer -- both deliberately left off the poster Honest about its limits: star shape and rotation are computed, but placement on the canvas is composed to fit the art, and the skill says so rather than overclaiming. Keeps the public-repo boundary: ships code and geometry only. Profiles reference artwork by path and never embed it; weights and fonts are fetched, not committed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014TEQRB683SJV2zYBYoLFBK --- .claude/skills/real-sky-poster/SKILL.md | 125 +++++++++ .../futureproof-salmon-starfield.json | 72 +++++ .claude/skills/real-sky-poster/scripts/kit.py | 245 +++++++++++++++++ .../skills/real-sky-poster/scripts/plate.py | 131 +++++++++ .../skills/real-sky-poster/scripts/render.py | 177 +++++++++++++ .claude/skills/real-sky-poster/scripts/sky.py | 250 ++++++++++++++++++ .../skills/real-sky-poster/scripts/upscale.py | 69 +++++ tests/skills/real-sky-poster/README.md | 26 ++ tests/skills/real-sky-poster/test_sky.py | 118 +++++++++ 9 files changed, 1213 insertions(+) create mode 100644 .claude/skills/real-sky-poster/SKILL.md create mode 100644 .claude/skills/real-sky-poster/profiles/futureproof-salmon-starfield.json create mode 100644 .claude/skills/real-sky-poster/scripts/kit.py create mode 100644 .claude/skills/real-sky-poster/scripts/plate.py create mode 100644 .claude/skills/real-sky-poster/scripts/render.py create mode 100644 .claude/skills/real-sky-poster/scripts/sky.py create mode 100644 .claude/skills/real-sky-poster/scripts/upscale.py create mode 100644 tests/skills/real-sky-poster/README.md create mode 100644 tests/skills/real-sky-poster/test_sky.py diff --git a/.claude/skills/real-sky-poster/SKILL.md b/.claude/skills/real-sky-poster/SKILL.md new file mode 100644 index 0000000..01034e9 --- /dev/null +++ b/.claude/skills/real-sky-poster/SKILL.md @@ -0,0 +1,125 @@ +--- +name: real-sky-poster +description: Replace invented constellations in artwork with the real sky for a given date and place, then export a full sized asset kit. Auto-triggers on "the constellations are wrong/fake/bullshit", "make the stars accurate", "real constellations", "what did the sky actually look like on ", "fix the sky in this poster", "build an asset kit from this artwork". +--- + +# Real Sky Poster + +Take artwork with a made-up night sky, give it the sky that was actually there, and ship every size the campaign needs. + +## Success Criteria + +Done when all of these hold: + +- [ ] **The sky is real.** Every star is a catalogue star at its true magnitude; every figure has its true shape and its true rotation against the horizon for the stated date, time and location. +- [ ] **The astronomy gate passes.** `python3 tests/skills/real-sky-poster/test_sky.py` is green. Polaris altitude equals the observer's latitude, or the reduction is wrong. +- [ ] **Nothing below the horizon is drawn.** If a constellation had not risen, it is not in the picture. Say so rather than faking it. +- [ ] **The artwork is untouched.** `plate.disturbance()` returns a number in the low hundreds. Thousands means the wipe ate somebody's painting — stop and fix the geometry. +- [ ] **Stars are native at every size.** No output contains an upscaled star. +- [ ] **No crop discards constellations.** Tall formats extend the sky; they do not centre-crop. +- [ ] **Copy is verbatim.** Every word traces to a source file. Nothing drafted, nothing paraphrased. +- [ ] Every exported file hits its exact pixel dimensions. + +## Why not just regenerate the image + +Because it will not work. An image model asked for "accurate constellations" produces a *fresh set of hallucinated star patterns* — the same failure in a new arrangement. Star positions are exact data. Port the data; do not ask a model to imagine it. + +This is the same lesson as optical illusions: when the output must be *correct*, compute it, don't generate it. + +## Instructions + +### Inputs + +A **profile** (`profiles/.json`) carrying: + +| Field | What it is | +|---|---| +| `source` | path to the artwork (never committed — see [Boundary](#boundary)) | +| `observer` | `utc` (**UTC, not local** — the easiest way to draw the wrong sky), `lat`, `lon` | +| `protect` | boxes the wipe must never touch: type, figures, anything painted | +| `horizon` | the top edge of the painted mass; above it is open sky | +| `layout` | where each figure sits, and at what scale | +| `zenith` | figures for the extended sky in tall formats | +| `copy` | approved strings + palette | + +`profiles/futureproof-salmon-starfield.json` is the worked example. + +### Stages + +Run in order. Each stage is independently inspectable — look at the output before moving on. + +``` +1. plate.py wipe the invented sky, keep the aurora and the brushwork + -> check disturbance(); should be low hundreds +2. upscale.py 4x the CLEAN PLATE with a local ESRGAN (free, on-device) +3. render.py draw the real stars at target scale — natively, never upscaled +4. kit.py extend the sky for tall formats, set type, export every size +``` + +**Stage order is the whole trick.** Upscale the *plate*, then redraw the stars. Upscaling a finished poster gives you 4x-blurry stars; this gives you 4x sharp ones. It also means the upscaler never sees a fabricated dot it might sharpen into an artifact. + +### Placing figures + +Shape and rotation are computed. **Placement is composed** — and you should say so out loud rather than overclaim. Honest framing: *"every figure has its true shape and orientation; where each sits on the canvas is arranged to fit the art, following the real bearings."* + +Read `Sky.report()` first, then place west-bearing figures left, north low and central, east right. That arrangement is usually a gift: it gave the Futureproof poster "summer setting on one side, winter rising on the other." + +### Growing the canvas, not cropping it + +A 16:9 painting cropped to 1:1 loses its left and right edges — exactly where the constellations live. So tall formats **grow upward**. Upward is toward the zenith, which is where the figures that could not fit the wide frame actually were. The square gains real sky instead of losing it. + +## Quality Check + +- [ ] Did I check `Sky.report()` before placing anything, or did I assume? +- [ ] Is anything drawn that was below the horizon? +- [ ] Did `disturbance()` stay low, and did I *look* at a 100% crop of the artwork's fine detail? +- [ ] Did I A/B the upscale against plain Lanczos before trusting it? (Photo-trained models plasticize brushwork — use an illustration model, never `RealESRGAN_x4plus`.) +- [ ] Does any asset stack a second wordmark on artwork that already has one? +- [ ] Is every string traceable to a source file? + +## HITL Checkpoint + +**Type:** Two checkpoints, because the two halves fail differently. + +**Checkpoint 1 — after the sky (stage 3).** Show the corrected artwork and an annotated key naming each constellation with its real bearing and altitude. The key is what lets a human *check the claim* instead of taking it on faith. + +Human reviews: +- [ ] Does the sky look right, and does the key survive a fact-check? +- [ ] Is the artwork genuinely untouched at 100%? + +**Checkpoint 2 — before publish.** The kit is a marketing artifact. + +Human reviews: +- [ ] Is the copy the approved copy? +- [ ] Do the story safe areas hold? +- [ ] Any brand conflict surfaced rather than silently resolved? + +**Surface conflicts, never resolve them silently.** The Futureproof run turned up two (an OG size that disagreed between code and spec; two competing wordmarks). Both went in the PR body for a human to rule on. + +## Boundary + +Rafiki's public repo is **tool-only**. Artwork, generated assets and campaign copy are gitignored and live in the private knowledge base. This skill ships **code and geometry**; profiles reference artwork by path and never embed it. The smoke test runs with no image, no GPU and no network — which is also why the astronomy is standard-library only. + +## Setup + +Two assets are fetched, not committed (both are binaries, and the repo is tool-only): + +```bash +# Upscale weights — an ILLUSTRATION model. Never RealESRGAN_x4plus on painted art. +curl -L -o ~/Code/ComfyUI/models/upscale_models/4x-UltraSharp.pth \ + https://huggingface.co/Kim2091/UltraSharp/resolve/main/4x-UltraSharp.pth + +# Type faces for stage 4 (swap for whatever the brand actually uses) +mkdir -p .claude/skills/real-sky-poster/scripts/fonts +curl -L -o .claude/skills/real-sky-poster/scripts/fonts/Inter.ttf \ + 'https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf' +curl -L -o .claude/skills/real-sky-poster/scripts/fonts/Cormorant.ttf \ + 'https://raw.githubusercontent.com/google/fonts/main/ofl/cormorantgaramond/CormorantGaramond%5Bwght%5D.ttf' +``` + +Stages 1–3 need only `opencv-python`, `pillow`, `numpy`. Stage 2 runs on ComfyUI's venv (torch + MPS + spandrel already there). **The smoke test needs none of this** — that is the point of keeping the astronomy in the standard library. + +## Reference + +- Worked example: the Futureproof salmon-starfield poster — [WalksWithASwagger/futureproof-festival#644](https://github.com/WalksWithASwagger/futureproof-festival/pull/644) +- Star data: Bright Star Catalogue, J2000. diff --git a/.claude/skills/real-sky-poster/profiles/futureproof-salmon-starfield.json b/.claude/skills/real-sky-poster/profiles/futureproof-salmon-starfield.json new file mode 100644 index 0000000..0683430 --- /dev/null +++ b/.claude/skills/real-sky-poster/profiles/futureproof-salmon-starfield.json @@ -0,0 +1,72 @@ +{ + "$comment": "Geometry only. The artwork itself is never committed to this repo (see .gitignore: assets/, outputs/). Point `source` at a local file.", + + "name": "futureproof-salmon-starfield", + "source": "~/Code/futureproof-festival/public/media/launch/futureproof-salmon-starfield.jpg", + "width": 1376, + "height": 768, + + "observer": { + "$comment": "UTC, not local. 21:00 PDT on Wed 28 Oct 2026 is 04:00 UTC on the 29th.", + "utc": [2026, 10, 29, 4, 0, 0], + "lat": 49.2827, + "lon": -123.1207, + "place": "Vancouver, BC" + }, + + "protect": { + "$comment": "Painted elements the wipe must never touch. Generous on purpose: over-protecting costs a few square pixels of star field, under-protecting destroys the art.", + "title": [412, 0, 962, 188], + "figures": [ + [968, 52, 1112, 222], + [676, 192, 816, 306], + [830, 156, 1000, 280], + [1036, 172, 1220, 334] + ] + }, + + "horizon": { + "$comment": "Top edge of the painted mass, left to right. Everything above is open sky. Where paint reaches high (x 350-560: kelp, eyes, a tail fin) the line must stay above it -- the wipe is destructive.", + "points": [ + [0, 384], [100, 386], [170, 372], [240, 350], [300, 322], [350, 306], + [400, 302], [460, 300], [520, 294], [560, 288], [620, 250], [700, 264], + [780, 244], [860, 234], [920, 218], [990, 228], [1060, 266], [1120, 294], + [1180, 322], [1240, 340], [1300, 346], [1376, 348] + ], + "residual_zones": [[0, 340], [1120, 1376]] + }, + + "layout": { + "$comment": "Where each figure sits: [centre x, centre y, pixels per degree]. Shape and rotation are computed from the real sky; only placement is composed, and it follows the true bearings -- west left, north low and central, east right.", + "Cygnus": [116, 96, 6.6], + "Lyra": [306, 182, 7.4], + "Ursa Major": [176, 292, 7.8], + "Cassiopeia": [1232, 84, 8.2], + "Perseus": [1298, 236, 5.4], + "Pleiades": [1318, 308, 24.0] + }, + + "zenith": { + "$comment": "For tall formats, the canvas grows upward -- toward the zenith. These are the figures that actually live up there and cannot fit the 16:9 crop. [centre x, height as fraction of the extension, pixels per degree].", + "Ursa Minor": [430, 0.19, 11.0], + "Andromeda": [1140, 0.34, 8.4], + "Pegasus": [1180, 0.72, 11.5], + "Cepheus": [742, 0.82, 10.0], + "Draco": [300, 0.56, 5.2] + }, + + "copy": { + "$comment": "Verbatim from the festival repo. Never drafted, never paraphrased. The painting already carries the wordmark, the date and the presenter -- the type layer adds only what the painting does not say.", + "line": "The most honest AI conversation happening this year.", + "line_source": "lib/brand-guide.ts:37", + "date": "October 28-30, 2026", + "venue": "H.R. MacMillan Space Centre · Vancouver", + "url": "futureproof.website", + "palette": { + "ink": [10, 10, 11], + "parchment": [245, 241, 232], + "aurora": [94, 234, 212], + "ember": [252, 165, 165] + } + } +} diff --git a/.claude/skills/real-sky-poster/scripts/kit.py b/.claude/skills/real-sky-poster/scripts/kit.py new file mode 100644 index 0000000..b4f0d8c --- /dev/null +++ b/.claude/skills/real-sky-poster/scripts/kit.py @@ -0,0 +1,245 @@ +"""Stage 4 -- extend the sky, set the type, export every size. + +Tall formats (1:1, 4:5, 9:16) GROW THE CANVAS UPWARD rather than centre-cropping. +Cropping a 16:9 poster to a square throws away the left and right edges -- which +is exactly where the constellations are. Growing upward means growing toward the +zenith, which is where the figures that could not fit the wide frame actually +were. So the tall formats gain real sky instead of losing it. + +Nothing is outpainted: the gradient is extrapolated from the plate's own top +edge and the stars come from the catalogue. + + python3 kit.py +""" + +from __future__ import annotations + +import random +import sys +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +from plate import load +from render import STYLES, StarPainter, place, radius, supersample_for +from sky import STARS, Sky + +FONTS = Path(__file__).parent / "fonts" + +WIDE = [("master-4x", None), ("web-hero", (2752, 1536)), ("luma-cover", (1920, 1080)), + ("wide", (1600, 900)), ("twitter", (1200, 675)), ("og-share", (1200, 630))] +TALL = [("square", 1.0, (1080, 1080)), ("portrait", 1.25, (1080, 1350)), + ("story", 16 / 9, (1080, 1920))] +STORY_SAFE_PX = 250 # platform UI eats the top and bottom of a 9:16 story + + +def extend(profile: dict, base: Image.Image, ext_units: float, + style: str = "stars", seed: int = 20261028) -> Image.Image: + """Grow `ext_units` (in 1x art units) of real sky above the artwork.""" + st = STYLES[style] + scale = base.width / profile["width"] + ext_px = round(ext_units * scale) + arr = np.asarray(base.convert("RGB"), np.float32) + + sky_img = Image.fromarray(_gradient(arr, ext_px, scale, np.random.default_rng(seed))) + + obs = profile["observer"] + sky = Sky(tuple(obs["utc"]), obs["lat"], obs["lon"]) + painter = StarPainter(profile["width"], ext_units, + scale * supersample_for(scale), st) + + rnd = random.Random(seed) + taken = [] + for fig, (cx, fy, ppd) in profile["zenith"].items(): + if not sky.is_up(fig): + continue + for name, (x, y) in place(sky, fig, cx, (1.0 - fy) * ext_units, ppd).items(): + # These carry the extension, so give them a touch more presence. + painter.star(x, y, radius(STARS[name][2], st["star_gain"] * 1.18)) + taken.append((x, y)) + + # A vast empty sky at the painting's own star density reads as static, not + # as stars. Thin it out. + n_field = round(st["field"] * (profile["width"] * ext_units) / 252_000 * 0.42) + n = 0 + while n < n_field: + x, y = rnd.uniform(0, profile["width"]), rnd.uniform(0, ext_units) + if any(abs(x - tx) < 9 and abs(y - ty) < 9 for tx, ty in taken): + continue + painter.star(x, y, radius(rnd.triangular(3.8, 6.4, 5.7), st["star_gain"]), + warmth=rnd.uniform(0.93, 1.0), bright=rnd.uniform(0.45, 0.95)) + n += 1 + + layer = painter.compose((base.width, ext_px)) + sky_img = Image.alpha_composite(sky_img.convert("RGBA"), layer).convert("RGB") + + out = Image.new("RGB", (base.width, ext_px + base.height)) + out.paste(sky_img, (0, 0)) + out.paste(base, (0, ext_px)) + return out + + +def _gradient(arr: np.ndarray, ext_px: int, scale: float, rng) -> np.ndarray: + """Extrapolate the plate's own top edge upward, fading to deep night.""" + w = arr.shape[1] + edge = arr[:max(2, round(6 * scale))].mean(axis=0) + edge = np.asarray(Image.fromarray(edge[None].astype(np.uint8)).filter( + ImageFilter.GaussianBlur(40 * scale)), np.float32)[0] + + deep = np.percentile(arr.reshape(-1, 3), 4, axis=0) * 0.82 + t = (np.linspace(0, 1, ext_px)[::-1] ** 0.85)[:, None, None] + grad = edge[None] * (1 - t) + deep[None, None] * t + + # Low-frequency mottle, or the gradient bands visibly across a big canvas. + noise = rng.normal(0, 1, (max(1, ext_px // 24), max(1, w // 24))) + noise = np.asarray(Image.fromarray(((noise * 40) + 128).clip(0, 255).astype(np.uint8)) + .resize((w, ext_px), Image.BICUBIC) + .filter(ImageFilter.GaussianBlur(6 * scale)), np.float32)[..., None] + return (grad + (noise - 128) * 0.055).clip(0, 255).astype(np.uint8) + + +def _font(name: str, px: int, weight: str): + f = ImageFont.truetype(str(FONTS / f"{name}.ttf"), px) + f.set_variation_by_name(weight) + return f + + +def _wrap(draw, text, fnt, max_w): + """Balanced, not greedy. Greedy leaves orphans -- a line reading just + 'year.' -- so once the line count is known, squeeze the measure to the + narrowest width that still fits in that many lines.""" + def greedy(limit): + lines, cur = [], "" + for word in text.split(): + t = f"{cur} {word}".strip() + if draw.textlength(t, font=fnt) <= limit or not cur: + cur = t + else: + lines.append(cur) + cur = word + return lines + ([cur] if cur else []) + + n = len(greedy(max_w)) + lo, hi = 1, max_w + while hi - lo > 1: + mid = (lo + hi) / 2 + if len(greedy(mid)) <= n: + hi = mid + else: + lo = mid + return greedy(hi) + + +def _tracked(draw, text, fnt, cx, y, fill, track): + """Letterspaced and centred. PIL has no tracking, so place glyph by glyph.""" + widths = [draw.textlength(c, font=fnt) for c in text] + x = cx - (sum(widths) + track * (len(text) - 1)) / 2 + for c, w in zip(text, widths): + draw.text((x, y), c, font=fnt, fill=fill) + x += w + track + + +def set_type(profile: dict, img: Image.Image, ext_px: int, line: str, + date: bool = False) -> Image.Image: + """Type sits in the sky, above the painted title. + + The type layer adds only what the painting does not already say. If the + artwork carries a wordmark, do not stack a second one on top of it. + """ + pal = profile["copy"]["palette"] + d = ImageDraw.Draw(img, "RGBA") + w = img.width + cx = w / 2 + + big = _font("Cormorant", round(w * 0.062), "Medium") + small = _font("Inter", round(w * 0.0165), "Medium") + tiny = _font("Inter", round(w * 0.0150), "Regular") + + lines = _wrap(d, line, big, w * 0.80) + lh, gap = round(w * 0.070), round(w * 0.045) + tail = gap + round(w * 0.030) * (3 if date else 2) + bottom = ext_px - round(w * 0.075) + y = bottom - (len(lines) * lh + tail) + + scrim = Image.new("RGBA", img.size, (0, 0, 0, 0)) + ImageDraw.Draw(scrim).rectangle( + [0, y - lh * 0.6, w, bottom + lh * 0.3], fill=(*pal["ink"], 92)) + img.alpha_composite(scrim.filter(ImageFilter.GaussianBlur(w * 0.045))) + + for ln in lines: + d.text((cx, y), ln, font=big, fill=(*pal["parchment"], 255), anchor="ma") + y += lh + + y += gap + _tracked(d, profile["copy"]["venue"].upper(), small, cx, y, + (*pal["aurora"], 235), w * 0.0028) + y += round(w * 0.030) + if date: + # A 9:16 story has platform UI over its bottom 250px -- which is often + # exactly where a painted date sits. Restate it up here so it survives. + _tracked(d, profile["copy"]["date"].upper(), small, cx, y, + (*pal["ember"], 225), w * 0.0028) + y += round(w * 0.030) + _tracked(d, profile["copy"]["url"].upper(), tiny, cx, y, + (*pal["parchment"], 175), w * 0.0040) + return img + + +def _fit(master: Image.Image, target: tuple[int, int]) -> Image.Image: + """Ratios that are not the artwork's own (an OG card, say) centre-crop.""" + tw, th = target + want = tw / th + w, h = master.size + if abs(want - w / h) > 0.005: + nh = round(w / want) + top = (h - nh) // 2 + master = master.crop((0, top, w, top + nh)) + return master.resize(target, Image.LANCZOS) + + +def build(profile: dict, master_path: str, outdir: str) -> list[Path]: + out = Path(outdir) + out.mkdir(parents=True, exist_ok=True) + master = Image.open(master_path).convert("RGB") + name = profile["name"] + made = [] + + for label, size in WIDE: + img = master if size is None else _fit(master, size) + p = out / f"{name}-{label}-{img.width}x{img.height}.jpg" + img.save(p, quality=94, subsampling=0) + made.append(p) + + # A smaller base keeps the extension cheap; it still oversamples 1080. + base_h = round(2752 * profile["height"] / profile["width"]) + base = master.resize((2752, base_h), Image.LANCZOS) + aw = profile["width"] + + for label, ratio, size in TALL: + ext_units = aw * ratio - profile["height"] + tall = extend(profile, base, ext_units) + scale = base.width / aw + ext_px = round(ext_units * scale * size[0] / tall.width) + + clean = tall.resize(size, Image.LANCZOS) + p = out / f"{name}-{label}-{size[0]}x{size[1]}.jpg" + clean.save(p, quality=94, subsampling=0) + made.append(p) + + titled = set_type(profile, tall.resize(size, Image.LANCZOS).convert("RGBA"), + ext_px, profile["copy"]["line"], date=(label == "story")) + p = out / f"{name}-{label}-titled-{size[0]}x{size[1]}.jpg" + titled.convert("RGB").save(p, quality=94, subsampling=0) + made.append(p) + + return made + + +if __name__ == "__main__": + prof = load(sys.argv[1]) + files = build(prof, sys.argv[2], sys.argv[3]) + for f in files: + im = Image.open(f) + print(f" {f.name:<58} {im.width}x{im.height}") + print(f"\n{len(files)} files -> {sys.argv[3]}") diff --git a/.claude/skills/real-sky-poster/scripts/plate.py b/.claude/skills/real-sky-poster/scripts/plate.py new file mode 100644 index 0000000..42efb1d --- /dev/null +++ b/.claude/skills/real-sky-poster/scripts/plate.py @@ -0,0 +1,131 @@ +"""Stage 1 -- wipe the invented sky, keep the painting. + +Erases every fabricated star and constellation stroke from the open sky and +leaves the aurora, gradient and brushwork untouched, producing a clean plate to +draw the real sky onto. + +This is destructive and it is aimed at somebody's artwork, so the whole design +is defensive: the horizon line and protect boxes come from the profile, the +residual pass is gated on a dark local background, and `disturbance()` measures +whether any paint actually moved. + + python3 plate.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import cv2 +import numpy as np + + +def load(profile_path: str) -> dict: + with open(Path(profile_path).expanduser()) as fh: + return json.load(fh) + + +def sky_mask(profile: dict, pad_protect: int = 0, scale: float = 1.0) -> np.ndarray: + """255 where open sky lives. Geometry is authored at 1x and scaled up.""" + w = round(profile["width"] * scale) + h = round(profile["height"] * scale) + + m = np.zeros((h, w), np.uint8) + pts = [(x * scale, y * scale) for x, y in profile["horizon"]["points"]] + cv2.fillPoly(m, [np.array(pts + [(w, 0), (0, 0)], np.int32)], 255) + + protect = np.zeros((h, w), np.uint8) + boxes = [profile["protect"]["title"], *profile["protect"]["figures"]] + for x0, y0, x1, y1 in boxes: + cv2.rectangle(protect, (round(x0 * scale), round(y0 * scale)), + (round(x1 * scale), round(y1 * scale)), 255, -1) + if pad_protect: + k = max(1, round(pad_protect * scale)) + protect = cv2.dilate(protect, np.ones((k, k), np.uint8)) + + return cv2.bitwise_and(m, cv2.bitwise_not(protect)) + + +def wipe(profile: dict) -> np.ndarray: + img = cv2.imread(str(Path(profile["source"]).expanduser())).astype(np.float32) + if img is None: + raise SystemExit(f"cannot read {profile['source']}") + sky = sky_mask(profile) + + # A grayscale opening removes every bright point and hairline stroke. The + # aurora is far wider than the structuring element, so it survives intact. + opened = cv2.morphologyEx( + img, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 13))) + smooth = cv2.medianBlur(opened.astype(np.uint8), 7).astype(np.float32) + smooth = cv2.GaussianBlur(smooth, (0, 0), 1.6) + + alpha = cv2.GaussianBlur(sky.astype(np.float32) / 255.0, (0, 0), 3.0)[..., None] + plate = (smooth * alpha + img * (1 - alpha)).astype(np.uint8) + return _residual_pass(profile, plate) + + +def _residual_pass(profile: dict, plate: np.ndarray) -> np.ndarray: + """Fabricated dots that sit just *below* the horizon line, in the gaps + between painted shapes. Three separate gates keep this off the artwork: + a shallow band, dark-background only, and dot-sized blobs only. A painted + highlight is bigger and sits on brighter ground than any drawn star.""" + h, w = plate.shape[:2] + pts = profile["horizon"]["points"] + + band = np.zeros((h, w), np.uint8) + lip = [(x, y + 62) for x, y in reversed(pts)] + cv2.fillPoly(band, [np.array(pts + lip, np.int32)], 255) + + protect = np.zeros_like(band) + for x0, y0, x1, y1 in [profile["protect"]["title"], *profile["protect"]["figures"]]: + cv2.rectangle(protect, (x0, y0), (x1, y1), 255, -1) + band[cv2.dilate(protect, np.ones((25, 25), np.uint8)) > 0] = 0 + + # Only where stragglers actually survived. Elsewhere, paint sits right under + # the line and must not be second-guessed. + keep = np.zeros_like(band) + for x0, x1 in profile["horizon"]["residual_zones"]: + keep[:, x0:x1] = 255 + band = cv2.bitwise_and(band, keep) + + v = cv2.cvtColor(plate, cv2.COLOR_BGR2HSV)[..., 2] + band[cv2.medianBlur(v, 31) > 58] = 0 # anything but open night sky + + th = cv2.morphologyEx( + v, cv2.MORPH_TOPHAT, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))) + _, hits = cv2.threshold(cv2.bitwise_and(th, th, mask=band), 12, 255, cv2.THRESH_BINARY) + + dots = np.zeros_like(hits) + n, lbl, stats, _ = cv2.connectedComponentsWithStats(hits, 8) + for i in range(1, n): + if stats[i][4] <= 70: + dots[lbl == i] = 255 + dots = cv2.bitwise_and(cv2.dilate(dots, np.ones((5, 5), np.uint8)), band) + return cv2.inpaint(plate, dots, 4, cv2.INPAINT_TELEA) + + +def disturbance(profile: dict, result: np.ndarray) -> int: + """THE FIDELITY GATE. How many painted pixels moved? + + Saturated, bright pixels are the artwork: the salmon, the ravens, the type. + None of them should change. Run this after every render. A number in the + hundreds is new stars sitting over saturated aurora; a number in the + thousands means the wipe ate somebody's painting.""" + src = cv2.imread(str(Path(profile["source"]).expanduser())) + if result.shape != src.shape: + result = cv2.resize(result, (src.shape[1], src.shape[0]), interpolation=cv2.INTER_AREA) + hsv = cv2.cvtColor(src, cv2.COLOR_BGR2HSV) + art = cv2.morphologyEx( + ((hsv[..., 1] > 70) & (hsv[..., 2] > 80)).astype(np.uint8), + cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) + moved = np.abs(src.astype(int) - result.astype(int)).max(2) > 40 + return int((moved & (art > 0)).sum()) + + +if __name__ == "__main__": + prof = load(sys.argv[1]) + out = wipe(prof) + cv2.imwrite(sys.argv[2], out) + print(f"{sys.argv[2]} painted px disturbed: {disturbance(prof, out)}") diff --git a/.claude/skills/real-sky-poster/scripts/render.py b/.claude/skills/real-sky-poster/scripts/render.py new file mode 100644 index 0000000..c8c3f5a --- /dev/null +++ b/.claude/skills/real-sky-poster/scripts/render.py @@ -0,0 +1,177 @@ +"""Stage 3 -- draw the real stars, at any resolution. + +Stars are drawn procedurally from the catalogue, so every output is native. +Nothing here is ever upscaled: a 4x poster gets 4x stars, not 4x-blurry ones. +That is the whole reason the pipeline upscales the *plate* and redraws, rather +than upscaling a finished image. + + python3 render.py [scale] +""" + +from __future__ import annotations + +import random +import sys + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +from plate import load, sky_mask +from sky import GROUPS, STARS, Sky + +STYLES = { + # The default. No connecting lines: accurate positions, nothing else. Reads + # as sky rather than as an infographic, and never fights the artwork. + "stars": dict(line=None, line_a=0, line_w=0, star_gain=1.05, halo=1.25, + spikes=True, field=300), + "lines": dict(line=(226, 245, 248), line_a=74, line_w=1.25, star_gain=1.10, + halo=1.35, spikes=True, field=250), + "delicate": dict(line=(214, 236, 240), line_a=50, line_w=1.0, star_gain=0.90, + halo=0.85, spikes=False, field=230), +} + +EDGES = { + "Ursa Major": [("Dubhe", "Merak"), ("Merak", "Phecda"), ("Phecda", "Megrez"), + ("Megrez", "Dubhe"), ("Megrez", "Alioth"), ("Alioth", "Mizar"), + ("Mizar", "Alkaid")], + "Cassiopeia": [("Caph", "Schedar"), ("Schedar", "gam Cas"), + ("gam Cas", "Ruchbah"), ("Ruchbah", "Segin")], + "Cygnus": [("Deneb", "Sadr"), ("Sadr", "Albireo"), ("del Cyg", "Sadr"), + ("Sadr", "Gienah")], + "Lyra": [("Vega", "eps Lyr"), ("Vega", "zet Lyr"), ("zet Lyr", "bet Lyr"), + ("bet Lyr", "gam Lyr"), ("gam Lyr", "zet Lyr")], + "Perseus": [("gam Per", "Mirfak"), ("Mirfak", "del Per"), ("del Per", "eps Per"), + ("eps Per", "zet Per"), ("Mirfak", "Algol"), ("Algol", "eps Per")], +} + + +def radius(vmag: float, gain: float) -> float: + """Brighter star, bigger dot. Roughly perceptual, clamped for legibility.""" + return max(0.80, min(3.3, (6.9 - vmag) * 0.46)) * gain + + +def supersample_for(scale: float) -> int: + """Enough antialiasing to hide PIL's hard-edged ellipses, without building a + 22k-wide RGBA buffer at 4x.""" + return 4 if scale <= 1 else 2 + + +class StarPainter: + """Draws in 1x coordinates onto a canvas `unit` times larger, then + downsamples. Halos are small solid discs, gaussian-blurred afterwards, so + they fall off smoothly instead of reading as flat grey bubbles.""" + + def __init__(self, w1x: float, h1x: float, unit: float, style: dict) -> None: + self.u, self.st = unit, style + big = (round(w1x * unit), round(h1x * unit)) + self.lines = Image.new("RGBA", big, (0, 0, 0, 0)) + self.near = Image.new("RGBA", big, (0, 0, 0, 0)) + self.far = Image.new("RGBA", big, (0, 0, 0, 0)) + self.core = Image.new("RGBA", big, (0, 0, 0, 0)) + self.dl, self.dn, self.df, self.dc = ( + ImageDraw.Draw(i) for i in (self.lines, self.near, self.far, self.core)) + + def _disc(self, d, x, y, r, colour) -> None: + u = self.u + d.ellipse([(x - r) * u, (y - r) * u, (x + r) * u, (y + r) * u], fill=colour) + + def star(self, x, y, r, warmth=1.0, bright=1.0) -> None: + halo = self.st["halo"] + self._disc(self.dn, x, y, r * 1.25 * halo, (176, 216, 236, int(185 * bright))) + if r > 1.35: + self._disc(self.df, x, y, r * 1.7 * halo, (150, 205, 230, int(120 * bright))) + + c = (255, int(253 * warmth), int(246 * warmth), int(255 * min(1.0, bright))) + self._disc(self.dc, x, y, r * 0.78, c) + if self.st["spikes"] and r > 2.0: + length, w = r * 4.2, max(1, round(0.55 * self.u)) + for dx, dy in ((length, 0), (0, length)): + self.dc.line([(x - dx) * self.u, (y - dy) * self.u, + (x + dx) * self.u, (y + dy) * self.u], + fill=(*c[:3], 52), width=w) + + def line(self, p, q) -> None: + u = self.u + self.dl.line([p[0] * u, p[1] * u, q[0] * u, q[1] * u], + fill=(*self.st["line"], self.st["line_a"]), + width=max(1, round(self.st["line_w"] * u))) + + def compose(self, out_size): + near = self.near.filter(ImageFilter.GaussianBlur(2.2 * self.u)) + far = self.far.filter(ImageFilter.GaussianBlur(7.0 * self.u)) + layer = Image.new("RGBA", self.lines.size, (0, 0, 0, 0)) + for lyr in (self.lines, far, near, self.core): + layer = Image.alpha_composite(layer, lyr) + return layer.resize(out_size, Image.LANCZOS) + + +def place(sky: Sky, figure: str, cx: float, cy: float, ppd: float): + local = sky.project(GROUPS[figure], ppd) + mx = sum(p[0] for p in local.values()) / len(local) + my = sum(p[1] for p in local.values()) / len(local) + return {n: (cx + x - mx, cy + y - my) for n, (x, y) in local.items()} + + +def render(profile: dict, plate_path: str, scale: float = 1.0, + style: str = "stars", seed: int = 28102026) -> Image.Image: + st = STYLES[style] + base = Image.open(plate_path).convert("RGB") + w1x, h1x = profile["width"], profile["height"] + want = (round(w1x * scale), round(h1x * scale)) + if base.size != want: + raise SystemExit(f"plate is {base.size}, scale {scale} wants {want}") + + obs = profile["observer"] + sky = Sky(tuple(obs["utc"]), obs["lat"], obs["lon"]) + + for fig in profile["layout"]: + if not sky.is_up(fig): + raise SystemExit(f"{fig} is below the horizon on this date. Do not draw it.") + + painter = StarPainter(w1x, h1x, scale * supersample_for(scale), st) + mask = sky_mask(profile, pad_protect=6, scale=scale) + pos = {f: place(sky, f, *xy) for f, xy in profile["layout"].items()} + + if st["line"]: + for fig, edges in EDGES.items(): + if fig in pos: + for a, b in edges: + painter.line(pos[fig][a], pos[fig][b]) + + rnd = random.Random(seed) + taken = [(x, y) for f in pos.values() for x, y in f.values()] + n = 0 + while n < st["field"]: + x, y = rnd.uniform(0, w1x), rnd.uniform(0, h1x) + gy = min(int(y * scale), mask.shape[0] - 1) + gx = min(int(x * scale), mask.shape[1] - 1) + if mask[gy, gx] == 0: + continue + if any(abs(x - tx) < 9 and abs(y - ty) < 9 for tx, ty in taken): + continue + painter.star(x, y, radius(rnd.triangular(3.8, 6.4, 5.7), st["star_gain"]), + warmth=rnd.uniform(0.93, 1.0), bright=rnd.uniform(0.45, 0.95)) + n += 1 + + for stars in pos.values(): + for name, (x, y) in stars.items(): + painter.star(x, y, radius(STARS[name][2], st["star_gain"])) + + layer = painter.compose(base.size) + + # Nothing may spill onto the artwork. + alpha = np.array(layer.getchannel("A"), np.float32) + keep = np.array(Image.fromarray(mask).filter( + ImageFilter.GaussianBlur(1.5 * scale)), np.float32) / 255.0 + layer.putalpha(Image.fromarray((alpha * keep).astype(np.uint8))) + + return Image.alpha_composite(base.convert("RGBA"), layer).convert("RGB") + + +if __name__ == "__main__": + prof = load(sys.argv[1]) + img = render(prof, sys.argv[2], float(sys.argv[4]) if len(sys.argv) > 4 else 1.0) + img.save(sys.argv[3], quality=95, subsampling=0) + print(f"{sys.argv[3]} {img.width}x{img.height}") + print(Sky(tuple(prof["observer"]["utc"]), + prof["observer"]["lat"], prof["observer"]["lon"]).report()) diff --git a/.claude/skills/real-sky-poster/scripts/sky.py b/.claude/skills/real-sky-poster/scripts/sky.py new file mode 100644 index 0000000..db03e42 --- /dev/null +++ b/.claude/skills/real-sky-poster/scripts/sky.py @@ -0,0 +1,250 @@ +"""Real star positions for a given place and moment. + +Standard library only, on purpose: the astronomy is the part that must be +verifiable without an image, a GPU, or a network call. + +Coordinates are J2000 from the Bright Star Catalogue. Reduction is the classic +one -- local sidereal time, hour angle, then alt/az -- which is accurate to well +under a degree for this era. That is far tighter than any poster needs. + +The gate that proves the reduction: Polaris sits at an altitude equal to the +observer's latitude, anywhere on Earth. `python3 tests/skills/real-sky-poster/ +test_sky.py` asserts exactly that. +""" + +from __future__ import annotations + +import math + +# name: (RA hours, Dec degrees, V magnitude) +STARS: dict[str, tuple[float, float, float]] = { + # Ursa Major, the Big Dipper + "Dubhe": (11.0621, 61.7510, 1.79), + "Merak": (11.0307, 56.3825, 2.37), + "Phecda": (11.8972, 53.6948, 2.44), + "Megrez": (12.2570, 57.0326, 3.31), + "Alioth": (12.9005, 55.9598, 1.77), + "Mizar": (13.3987, 54.9254, 2.23), + "Alkaid": (13.7923, 49.3133, 1.86), + # Ursa Minor + "Polaris": (2.5303, 89.2641, 1.98), + "Kochab": (14.8451, 74.1555, 2.08), + "Pherkad": (15.3455, 71.8340, 3.05), + "eps UMi": (16.7661, 82.0373, 4.23), + "zet UMi": (15.7343, 77.7945, 4.32), + "del UMi": (17.5369, 86.5865, 4.36), + "eta UMi": (16.2917, 75.7551, 4.95), + # Cassiopeia + "Caph": (0.1528, 59.1498, 2.27), + "Schedar": (0.6751, 56.5373, 2.24), + "gam Cas": (0.9451, 60.7167, 2.15), + "Ruchbah": (1.4303, 60.2353, 2.68), + "Segin": (1.9066, 63.6701, 3.35), + # Cygnus + "Deneb": (20.6905, 45.2803, 1.25), + "Sadr": (20.3704, 40.2567, 2.23), + "Albireo": (19.5121, 27.9597, 3.05), + "del Cyg": (19.7496, 45.1308, 2.87), + "Gienah": (20.7701, 33.9703, 2.48), + # Lyra + "Vega": (18.6156, 38.7837, 0.03), + "eps Lyr": (18.7390, 39.6703, 4.60), + "zet Lyr": (18.7462, 37.6051, 4.36), + "bet Lyr": (18.8346, 33.3627, 3.52), + "gam Lyr": (18.9824, 32.6896, 3.24), + # Perseus + "Mirfak": (3.4054, 49.8612, 1.79), + "Algol": (3.1361, 40.9556, 2.12), + "zet Per": (3.9022, 31.8836, 2.85), + "del Per": (3.7154, 47.7877, 3.01), + "eps Per": (3.9642, 40.0102, 2.89), + "gam Per": (3.0800, 53.5065, 2.91), + # Pleiades (M45) + "Alcyone": (3.7914, 24.1051, 2.87), + "Atlas": (3.8194, 24.0534, 3.62), + "Electra": (3.7479, 24.1133, 3.70), + "Maia": (3.7638, 24.3675, 3.87), + "Merope": (3.7721, 23.9480, 4.18), + "Taygeta": (3.7535, 24.4672, 4.30), + # Auriga / Taurus + "Capella": (5.2782, 45.9980, 0.08), + "Aldebaran": (4.5987, 16.5093, 0.85), + # Andromeda + Pegasus + "Alpheratz": (0.1398, 29.0904, 2.06), + "del And": (0.6555, 30.8610, 3.27), + "Mirach": (1.1621, 35.6206, 2.06), + "Almach": (2.0650, 42.3297, 2.10), + "Scheat": (23.0629, 28.0828, 2.42), + "Markab": (23.0793, 15.2053, 2.48), + "Algenib": (0.2206, 15.1836, 2.83), + # Orion + "Betelgeuse": (5.9195, 7.4071, 0.50), + "Rigel": (5.2423, -8.2016, 0.13), + "Bellatrix": (5.4188, 6.3497, 1.64), + "Saiph": (5.7959, -9.6696, 2.06), + "Alnitak": (5.6793, -1.9426, 1.77), + "Alnilam": (5.6036, -1.2019, 1.69), + "Mintaka": (5.5334, -0.2991, 2.23), + # Cepheus + "Alderamin": (21.3096, 62.5856, 2.45), + "Alfirk": (21.4776, 70.5607, 3.23), + "gam Cep": (23.6559, 77.6323, 3.21), + "iot Cep": (22.8281, 66.2003, 3.52), + "zet Cep": (22.1810, 58.2012, 3.35), + # Draco + "Eltanin": (17.9435, 51.4889, 2.23), + "Rastaban": (17.5072, 52.3014, 2.79), + "xi Dra": (17.8925, 56.8727, 3.75), + "nu Dra": (17.6873, 55.1731, 4.88), + "del Dra": (19.2093, 67.6615, 3.07), + "eps Dra": (19.8021, 70.2679, 3.83), + "zet Dra": (17.1465, 65.7147, 3.17), + "eta Dra": (16.3999, 61.5141, 2.73), + "iot Dra": (15.4155, 58.9661, 3.29), + "Thuban": (14.0730, 64.3758, 3.65), + "kap Dra": (12.5591, 69.7881, 3.87), + "lam Dra": (11.5313, 69.3312, 3.82), +} + +GROUPS: dict[str, list[str]] = { + "Ursa Major": ["Dubhe", "Merak", "Phecda", "Megrez", "Alioth", "Mizar", "Alkaid"], + "Ursa Minor": ["Polaris", "Kochab", "Pherkad", "eps UMi", "zet UMi", "del UMi", "eta UMi"], + "Cassiopeia": ["Caph", "Schedar", "gam Cas", "Ruchbah", "Segin"], + "Cygnus": ["Deneb", "Sadr", "Albireo", "del Cyg", "Gienah"], + "Lyra": ["Vega", "eps Lyr", "zet Lyr", "bet Lyr", "gam Lyr"], + "Perseus": ["Mirfak", "Algol", "zet Per", "del Per", "eps Per", "gam Per"], + "Pleiades": ["Alcyone", "Atlas", "Electra", "Maia", "Merope", "Taygeta"], + "Andromeda": ["Alpheratz", "del And", "Mirach", "Almach"], + "Pegasus": ["Markab", "Scheat", "Alpheratz", "Algenib"], + "Cepheus": ["Alderamin", "Alfirk", "gam Cep", "iot Cep", "zet Cep"], + "Draco": [ + "Eltanin", "Rastaban", "xi Dra", "nu Dra", "del Dra", "eps Dra", + "zet Dra", "eta Dra", "iot Dra", "Thuban", "kap Dra", "lam Dra", + ], + "Orion": [ + "Betelgeuse", "Rigel", "Bellatrix", "Saiph", "Alnitak", "Alnilam", "Mintaka", + ], + "Auriga": ["Capella"], + "Taurus": ["Aldebaran"], +} + +COMPASS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] + + +class Sky: + """The sky as seen from one place at one moment. + + utc: (year, month, day, hour, minute, second) -- UTC, not local. Convert + first; a wrong timezone is the single easiest way to draw the wrong sky. + """ + + def __init__(self, utc: tuple[int, int, int, int, int, float], + lat: float, lon: float) -> None: + self.utc, self.lat, self.lon = utc, lat, lon + self.lst = self._local_sidereal() + + def _julian_day(self) -> float: + y, m, d, hh, mm, ss = self.utc + if m <= 2: + y, m = y - 1, m + 12 + a = y // 100 + b = 2 - a + a // 4 + day = d + (hh + mm / 60 + ss / 3600) / 24 + return (math.floor(365.25 * (y + 4716)) + math.floor(30.6001 * (m + 1)) + + day + b - 1524.5) + + def _local_sidereal(self) -> float: + jd = self._julian_day() + t = (jd - 2451545.0) / 36525.0 + gmst = (280.46061837 + 360.98564736629 * (jd - 2451545.0) + + 0.000387933 * t * t - t * t * t / 38710000.0) + return (gmst + self.lon) % 360.0 + + def alt_az(self, name: str) -> tuple[float, float]: + """Altitude and azimuth in degrees. Azimuth is clockwise from north.""" + ra_hours, dec_deg, _ = STARS[name] + ha = math.radians((self.lst - ra_hours * 15.0) % 360.0) + dec, lat = math.radians(dec_deg), math.radians(self.lat) + + alt = math.asin(math.sin(dec) * math.sin(lat) + + math.cos(dec) * math.cos(lat) * math.cos(ha)) + az = math.atan2(-math.sin(ha) * math.cos(dec), + math.cos(lat) * math.sin(dec) + - math.sin(lat) * math.cos(dec) * math.cos(ha)) + return math.degrees(alt), math.degrees(az) % 360.0 + + def bearing(self, group: str) -> tuple[float, float]: + """Mean altitude and azimuth of a group, via unit vectors so it behaves + near the pole and across the 0/360 azimuth seam.""" + vx = vy = vz = 0.0 + for name in GROUPS[group]: + alt, az = self.alt_az(name) + a, z = math.radians(alt), math.radians(az) + vx += math.cos(a) * math.cos(z) + vy += math.cos(a) * math.sin(z) + vz += math.sin(a) + n = math.sqrt(vx * vx + vy * vy + vz * vz) + return math.degrees(math.asin(vz / n)), math.degrees(math.atan2(vy, vx)) % 360.0 + + def is_up(self, group: str, horizon: float = 5.0) -> bool: + """Below the horizon means below the horizon. Do not draw it.""" + return self.bearing(group)[0] > horizon + + def project(self, names: list[str], px_per_degree: float) -> dict[str, tuple[float, float]]: + """Gnomonic projection onto the tangent plane at the group's own centre, + zenith up. Preserves the figure's true shape AND its true rotation + against the horizon -- which is what makes a drawn constellation read as + the real thing rather than a clipart version of it. + + Returns local pixel offsets about the group centroid; y grows downward. + """ + horiz = {n: self.alt_az(n) for n in names} + + vx = vy = vz = 0.0 + for alt, az in horiz.values(): + a, z = math.radians(alt), math.radians(az) + vx += math.cos(a) * math.cos(z) + vy += math.cos(a) * math.sin(z) + vz += math.sin(a) + n = math.sqrt(vx * vx + vy * vy + vz * vz) + alt0 = math.asin(vz / n) + az0 = math.atan2(vy, vx) + + out: dict[str, tuple[float, float]] = {} + for name, (alt, az) in horiz.items(): + a, z = math.radians(alt), math.radians(az) + d = z - az0 + cosc = (math.sin(alt0) * math.sin(a) + + math.cos(alt0) * math.cos(a) * math.cos(d)) + xi = math.cos(a) * math.sin(d) / cosc # east -> right + eta = ((math.cos(alt0) * math.sin(a) + - math.sin(alt0) * math.cos(a) * math.cos(d)) / cosc) # zenith -> up + out[name] = (math.degrees(xi) * px_per_degree, + -math.degrees(eta) * px_per_degree) + return out + + def compass(self, az: float) -> str: + return COMPASS[int((az + 11.25) % 360 / 22.5)] + + def report(self) -> str: + """What is actually up, and where. Read this before placing anything.""" + rows = [] + for group in GROUPS: + alt, az = self.bearing(group) + flag = "" if alt > 5 else " <-- BELOW HORIZON, do not draw" + rows.append(f" {group:<12} alt {alt:5.1f} az {az:5.1f} " + f"{self.compass(az):<3}{flag}") + return "\n".join(rows) + + +def angular_separation(a: str, b: str) -> float: + """True angular distance between two stars, in degrees. Independent of + observer -- used to prove a projection preserved the real geometry.""" + ra1, dec1, _ = STARS[a] + ra2, dec2, _ = STARS[b] + r1, d1 = math.radians(ra1 * 15), math.radians(dec1) + r2, d2 = math.radians(ra2 * 15), math.radians(dec2) + return math.degrees(math.acos( + min(1.0, math.sin(d1) * math.sin(d2) + + math.cos(d1) * math.cos(d2) * math.cos(r1 - r2)))) diff --git a/.claude/skills/real-sky-poster/scripts/upscale.py b/.claude/skills/real-sky-poster/scripts/upscale.py new file mode 100644 index 0000000..2785a0d --- /dev/null +++ b/.claude/skills/real-sky-poster/scripts/upscale.py @@ -0,0 +1,69 @@ +"""Stage 2 -- upscale the clean plate with a local ESRGAN. + +Runs on ComfyUI's venv, which already carries torch + MPS + spandrel. No ComfyUI +server, no API, no spend. Seconds on Apple silicon. + +Upscale the PLATE, not the finished poster. The stars get redrawn natively +afterwards, so they stay razor sharp instead of becoming blurry blobs. The +upscaler also never sees a fabricated dot, which is one less thing for it to +sharpen into an artifact. + +Use an illustration-trained model (4x-UltraSharp, 4x_foolhardy_Remacri). Do NOT +use RealESRGAN_x4plus: it is photo-trained and will plasticize brushwork. + + /Users/kk/Code/ComfyUI/.venv/bin/python upscale.py +""" + +from __future__ import annotations + +import sys +import time + +import numpy as np +import torch +from PIL import Image +from spandrel import ModelLoader + +MODEL = "/Users/kk/Code/ComfyUI/models/upscale_models/4x-UltraSharp.pth" +TILE, OVERLAP = 512, 32 + + +def upscale(src: str, dst: str, model_path: str = MODEL) -> None: + dev = "mps" if torch.backends.mps.is_available() else "cpu" + model = ModelLoader().load_from_file(model_path).eval().to(dev) + s = model.scale + print(f"{model.architecture.name} x{s} on {dev}") + + img = Image.open(src).convert("RGB") + w, h = img.size + x = torch.from_numpy(np.asarray(img, np.float32) / 255.0).permute(2, 0, 1)[None].to(dev) + + out = torch.zeros(1, 3, h * s, w * s, device=dev) + weight = torch.zeros(1, 1, h * s, w * s, device=dev) + step = TILE - OVERLAP + t0 = time.time() + + with torch.no_grad(): + for ty in range(0, h, step): + for tx in range(0, w, step): + x0, y0 = min(tx, max(0, w - TILE)), min(ty, max(0, h - TILE)) + x1, y1 = min(x0 + TILE, w), min(y0 + TILE, h) + tile = model(x[:, :, y0:y1, x0:x1]).clamp(0, 1) + + # Cosine feather, or tile seams show in the smooth sky gradient. + th, tw = tile.shape[-2:] + fy = torch.hann_window(th, periodic=False, device=dev).clamp(min=1e-3) + fx = torch.hann_window(tw, periodic=False, device=dev).clamp(min=1e-3) + wgt = (fy[:, None] * fx[None, :])[None, None] + + out[:, :, y0 * s:y0 * s + th, x0 * s:x0 * s + tw] += tile * wgt + weight[:, :, y0 * s:y0 * s + th, x0 * s:x0 * s + tw] += wgt + + out = (out / weight.clamp(min=1e-6)).clamp(0, 1) + arr = (out[0].permute(1, 2, 0).cpu().numpy() * 255).round().astype(np.uint8) + Image.fromarray(arr).save(dst) + print(f"{w}x{h} -> {arr.shape[1]}x{arr.shape[0]} in {time.time() - t0:.1f}s -> {dst}") + + +if __name__ == "__main__": + upscale(sys.argv[1], sys.argv[2]) diff --git a/tests/skills/real-sky-poster/README.md b/tests/skills/real-sky-poster/README.md new file mode 100644 index 0000000..7078fde --- /dev/null +++ b/tests/skills/real-sky-poster/README.md @@ -0,0 +1,26 @@ +# real-sky-poster :: smoke test + +```bash +python3 tests/skills/real-sky-poster/test_sky.py +``` + +Runs offline. No image, no GPU, no network, no API spend, no dependencies — +standard library only, in well under a second. + +## What it actually proves + +The skill makes exactly one claim that matters: *these are the real stars, in +the real places, for the stated date and location.* If that is false, everything +downstream is just a prettier lie. So the test attacks the claim rather than +exercising the plumbing: + +| Gate | Why it is the right gate | +|---|---| +| **Polaris altitude == observer latitude** | True anywhere on Earth, any night. If the sidereal-time or alt/az reduction has a bug, this is the first thing to break. Checked at four latitudes including the equator. | +| **Polaris invisible from Sydney** | Below the horizon means below the horizon. A pipeline that will cheerfully draw an invisible constellation is the exact bug this skill exists to fix. | +| **Projection preserves true proportions** | Drawn distances are compared against catalogue angular separations. Tolerance is *relative*, because the projection is gnomonic and inflates distances a few percent away from the tangent point — a deliberate property (it keeps lines straight and rotation true), not an error. A pixel-exact match would mean the projection was doing nothing. | +| **Futureproof opening night** | The regression fixture. Vancouver, 28 Oct 2026, 21:00 PDT — the sky that shipped. Asserts each figure's compass bearing and altitude, *and* that Orion had not risen and Pegasus was behind the viewer. Both were deliberately left off the poster; this proves they had to be. | + +The imaging stages (`plate`, `upscale`, `render`, `kit`) need artwork, which the +public repo does not carry by design. Verify those visually, against the +`disturbance()` fidelity gate and the dimension gate in `kit.build()`. diff --git a/tests/skills/real-sky-poster/test_sky.py b/tests/skills/real-sky-poster/test_sky.py new file mode 100644 index 0000000..6bad27d --- /dev/null +++ b/tests/skills/real-sky-poster/test_sky.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Offline smoke test for the real-sky-poster skill. + +No image, no GPU, no network, no API spend. Standard library only. + +The whole skill rests on one claim: these are the real stars, in the real +places, for the stated date and location. If that claim is false, everything +downstream is just a prettier lie. So this test attacks the claim directly. + + python3 tests/skills/real-sky-poster/test_sky.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] + / ".claude/skills/real-sky-poster/scripts")) + +from sky import Sky, angular_separation # noqa: E402 + +FAILURES: list[str] = [] + + +def check(name: str, ok: bool, detail: str) -> None: + print(f" {'PASS' if ok else 'FAIL'} {name}: {detail}") + if not ok: + FAILURES.append(name) + + +def test_polaris_altitude_equals_latitude() -> None: + """The load-bearing gate. Polaris sits within ~0.7 deg of the celestial + pole, so its altitude equals the observer's latitude -- anywhere, any night. + If the sidereal-time or alt/az reduction is wrong, this breaks first.""" + print("\nPolaris altitude == observer latitude") + for place, lat, lon in [("Vancouver", 49.2827, -123.1207), + ("Reykjavik", 64.1466, -21.9426), + ("Quito", -0.1807, -78.4678), + ("Singapore", 1.3521, 103.8198)]: + sky = Sky((2026, 10, 29, 4, 0, 0), lat, lon) + alt, _ = sky.alt_az("Polaris") + check(place, abs(alt - lat) < 0.75, f"alt {alt:+.2f} vs lat {lat:+.2f}") + + +def test_southern_hemisphere_cannot_see_polaris() -> None: + """Below the horizon means below the horizon. A skill that will happily + draw an invisible constellation is the bug we set out to fix.""" + print("\nPolaris is not visible from the southern hemisphere") + sky = Sky((2026, 10, 29, 4, 0, 0), -33.8688, 151.2093) # Sydney + alt, _ = sky.alt_az("Polaris") + check("Sydney", alt < 0, f"alt {alt:+.2f} (must be below 0)") + + +def test_projection_preserves_true_geometry() -> None: + """A projected figure must keep its real angular proportions. + + The projection is gnomonic (tangent-plane), which is the right choice: it + keeps straight lines straight and preserves the figure's rotation against + the horizon. The trade is that it inflates distances away from the tangent + point -- a few percent across a figure as wide as the Dipper. That is a + property of the projection, not an error in the catalogue, so the tolerance + here is relative, not absolute. A pixel-exact match would actually mean the + projection was doing nothing. + """ + print("\nProjection preserves the Big Dipper's true proportions") + sky = Sky((2026, 10, 29, 4, 0, 0), 49.2827, -123.1207) + ppd = 10.0 + pos = sky.project(["Dubhe", "Merak", "Mizar", "Alkaid"], ppd) + + for a, b in [("Dubhe", "Merak"), ("Mizar", "Alkaid")]: + (x1, y1), (x2, y2) = pos[a], pos[b] + drawn = ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5 / ppd + true = angular_separation(a, b) + err = abs(drawn - true) / true + check(f"{a}-{b}", err < 0.05, + f"drawn {drawn:.2f} deg vs catalogue {true:.2f} deg ({err:.1%} gnomonic stretch)") + + +def test_futureproof_opening_night() -> None: + """The worked example: Vancouver, Wed 28 Oct 2026, 21:00 PDT (= 04:00 UTC + the 29th). This is the sky that shipped on the festival poster. It is also + what gives the piece its story -- summer setting west, winter rising east.""" + print("\nFutureproof opening night: Vancouver, 28 Oct 2026, 21:00 PDT") + sky = Sky((2026, 10, 29, 4, 0, 0), 49.2827, -123.1207) + + for group, want, lo, hi in [("Cygnus", "WSW", 55, 75), + ("Lyra", "W", 40, 60), + ("Ursa Major", "NNW", 10, 30), + ("Cassiopeia", "NE", 55, 75), + ("Perseus", "ENE", 28, 48), + ("Pleiades", "E", 13, 33)]: + alt, az = sky.bearing(group) + got = sky.compass(az) + check(group, got == want and lo < alt < hi, + f"{got} alt {alt:.0f} (want {want}, alt {lo}-{hi})") + + # Orion has not risen at 21:00 and Pegasus is behind a north-facing view. + # Both were deliberately left off the wide crop. Prove they had to be. + check("Orion below horizon", not sky.is_up("Orion"), + f"alt {sky.bearing('Orion')[0]:.0f} (has not risen)") + peg_az = sky.bearing("Pegasus")[1] + check("Pegasus behind viewer", 100 < peg_az < 200, + f"az {peg_az:.0f} {sky.compass(peg_az)} (not in a north-facing frame)") + + +if __name__ == "__main__": + print("real-sky-poster :: offline astronomy gates") + test_polaris_altitude_equals_latitude() + test_southern_hemisphere_cannot_see_polaris() + test_projection_preserves_true_geometry() + test_futureproof_opening_night() + + print() + if FAILURES: + print(f"FAILED: {', '.join(FAILURES)}") + sys.exit(1) + print("All astronomy gates passed. The sky is real.")