diff --git a/art/stages/hidden-garden/README.md b/art/stages/hidden-garden/README.md index 62ccd9e..f41e69e 100644 --- a/art/stages/hidden-garden/README.md +++ b/art/stages/hidden-garden/README.md @@ -2,7 +2,7 @@ The prologue, drawn from the approved [Hidden Garden concept](../../locations/hidden-garden-clean.md) as a flat side elevation with its figures removed. One chunk, `hg-0-0` (2160×720): the bench where Dexter sits at the start (x ≈ 190–320), the stone path and the timber jetty (path top y = 441), the end of the jetty where Dexter points (x ≈ 1290–1323), and one wrong patch in the pond's reflection below it – a rusty girder with a ladder that isn't there. Clean only. -**Layers** ([layers.json](layers.json), `python scripts/split_layers.py art/stages/hidden-garden`): [03 rear](layers/hg-0-0.03-rear.png) · [04 water](layers/hg-0-0.04-water.png) · [05 play](layers/hg-0-0.05-play.png) · [07 foreground](layers/hg-0-0.07-foreground.png). Everything above the path is one rear layer at scroll 1.0, and the foreground also scrolls at 1.0: rectangles and colours would tear the big tree or the reeds at another speed, so sky/far/mid parallax waits for hand-drawn masks. +**Layers** (#142): the model repainted the chunk as a flat-colour depth key, and the masks come from it ([depth_layers.py](../../../scripts/depth_layers.py), [layers.json](layers.json)). Sky, far and mid scroll slower than the path (0.1, 0.25, 0.6, docs/04), and their plates are filled where the nearer layers hide them, so nothing tears open when the camera moves. The foreground scrolls at 1.2. [00 sky](layers/hg-0-0.00-sky.filled.png) · [01 far](layers/hg-0-0.01-far.filled.png) · [02 mid](layers/hg-0-0.02-mid.filled.png) · [03 rear](layers/hg-0-0.03-rear.png) · [04 water](layers/hg-0-0.04-water.png) · [05 play](layers/hg-0-0.05-play.png) · [07 foreground](layers/hg-0-0.07-foreground.png). The parallax at three camera positions: [parallax.png](images/hg-0-0-parallax.png) (`python scripts/compare.py parallax art/stages/hidden-garden`). | | | | --- | --- | diff --git a/art/stages/hidden-garden/boards.json b/art/stages/hidden-garden/boards.json index 37d0d58..f434451 100644 --- a/art/stages/hidden-garden/boards.json +++ b/art/stages/hidden-garden/boards.json @@ -1,6 +1,6 @@ { "name": "Hidden Garden – stage chunks", - "readme": "The prologue, drawn from the approved [Hidden Garden concept](../../locations/hidden-garden-clean.md) as a flat side elevation with its figures removed. One chunk, `hg-0-0` (2160×720): the bench where Dexter sits at the start (x ≈ 190–320), the stone path and the timber jetty (path top y = 441), the end of the jetty where Dexter points (x ≈ 1290–1323), and one wrong patch in the pond's reflection below it – a rusty girder with a ladder that isn't there. Clean only.\n\n**Layers** ([layers.json](layers.json), `python scripts/split_layers.py art/stages/hidden-garden`): [03 rear](layers/hg-0-0.03-rear.png) · [04 water](layers/hg-0-0.04-water.png) · [05 play](layers/hg-0-0.05-play.png) · [07 foreground](layers/hg-0-0.07-foreground.png). Everything above the path is one rear layer at scroll 1.0, and the foreground also scrolls at 1.0: rectangles and colours would tear the big tree or the reeds at another speed, so sky/far/mid parallax waits for hand-drawn masks.", + "readme": "The prologue, drawn from the approved [Hidden Garden concept](../../locations/hidden-garden-clean.md) as a flat side elevation with its figures removed. One chunk, `hg-0-0` (2160×720): the bench where Dexter sits at the start (x ≈ 190–320), the stone path and the timber jetty (path top y = 441), the end of the jetty where Dexter points (x ≈ 1290–1323), and one wrong patch in the pond's reflection below it – a rusty girder with a ladder that isn't there. Clean only.\n\n**Layers** (#142): the model repainted the chunk as a flat-colour depth key, and the masks come from it ([depth_layers.py](../../../scripts/depth_layers.py), [layers.json](layers.json)). Sky, far and mid scroll slower than the path (0.1, 0.25, 0.6, docs/04), and their plates are filled where the nearer layers hide them, so nothing tears open when the camera moves. The foreground scrolls at 1.2. [00 sky](layers/hg-0-0.00-sky.filled.png) · [01 far](layers/hg-0-0.01-far.filled.png) · [02 mid](layers/hg-0-0.02-mid.filled.png) · [03 rear](layers/hg-0-0.03-rear.png) · [04 water](layers/hg-0-0.04-water.png) · [05 play](layers/hg-0-0.05-play.png) · [07 foreground](layers/hg-0-0.07-foreground.png). The parallax at three camera positions: [parallax.png](images/hg-0-0-parallax.png) (`python scripts/compare.py parallax art/stages/hidden-garden`).", "model": "gpt-image-2.5-sunburst", "size": "2160x720", "style": "FORMAT: a STAGE CONCEPT for a 2D side-scroller explored in all four directions - one wide 2:1 image in strict side view, drawn like an architect's ELEVATION: every path, wall, bed and building runs PARALLEL to the screen from left to right; depth comes only from layers stacked behind each other, never from things receding toward a vanishing point; no path, corridor or bed leads into the distance. The hero is small, about one ninth of the image height. Build real level structure into the scenery: a main route plus at least one higher branch and one lower branch or underground pocket, smooth hills and slopes (some steep enough to slide down), ledges with grabbable edges, something to climb (ladder, vines, scaffold, stairs), a dead end with a small reward glint. DEPTH in clear layers: hazy far background, mid-ground, the play layer, and a dark, out-of-focus foreground of leaves and grass partly covering the bottom edge and corners as if the camera sits behind foliage; reflections where there is water; a few falling leaves. LOOK: detailed hi-bit pixel art, fine 2-pixel grain, the warm palette and light of the style master. TONE: secret horror - calm and pretty first. No text, no logos, no UI.", @@ -24,4 +24,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/art/stages/hidden-garden/export.json b/art/stages/hidden-garden/export.json index 759589e..a627632 100644 --- a/art/stages/hidden-garden/export.json +++ b/art/stages/hidden-garden/export.json @@ -1,12 +1,15 @@ { - "note": "Layers from split_layers.py (layers.json). Everything scrolls at 1.0 until sky/far/mid get hand-drawn masks.", + "note": "Layers from split_layers.py (layers.json) and depth_layers.py (#142). Sky, far and mid are drawn from their filled plates: the art's own pixels plus what the nearer layers hide, so they can scroll slower (docs/04) without holes. At rest the stack still rebuilds the art exactly (--verify).", "chunks": {"hg-0-0": {"x": 0, "y": 0}}, "layers": { + "00-sky": {"file": "layers/{name}.00-sky.filled.png", "z": 0, "scroll": 0.1}, + "01-far": {"file": "layers/{name}.01-far.filled.png", "z": 1, "scroll": 0.25}, + "02-mid": {"file": "layers/{name}.02-mid.filled.png", "z": 2, "scroll": 0.6}, "03-rear": {"file": "layers/{name}.03-rear.png", "z": 3, "scroll": 1.0}, "04-water": {"file": "layers/{name}.04-water.png", "z": 4, "scroll": 1.0}, "05-play": {"file": "layers/{name}.05-play.png", "z": 5, "scroll": 1.0}, - "07-foreground": {"file": "layers/{name}.07-foreground.png", "z": 7, "scroll": 1.0} + "07-foreground": {"file": "layers/{name}.07-foreground.png", "z": 7, "scroll": 1.2} }, - "states": {"clean": ["03-rear", "04-water", "05-play", "07-foreground"]}, + "states": {"clean": ["00-sky", "01-far", "02-mid", "03-rear", "04-water", "05-play", "07-foreground"]}, "composed": {"clean": "images/{name}.png"} } diff --git a/art/stages/hidden-garden/hg-0-0.md b/art/stages/hidden-garden/hg-0-0.md index 3a27299..daf32fb 100644 --- a/art/stages/hidden-garden/hg-0-0.md +++ b/art/stages/hidden-garden/hg-0-0.md @@ -13,4 +13,4 @@ Wide side-scrolling stage chunk at 3:1, drawn as an architect's elevation with e - [hidden-garden-clean.png](../../locations/images/hidden-garden-clean.png) - [style-master-preview.png](../../direction/images/style-master-preview.png) -Model `gpt-image-2.5-sunburst` · pixel factor 1 · source image `images/hg-0-0.png` · rebuild: `python scripts/make_boards.py draw --config art/stages/hidden-garden/boards.json --redo hg-0-0` +Model `gpt-image-2.5-sunburst` · pixel factor 1 · source image `images/hg-0-0.png` · rebuild: `python scripts/make_boards.py draw hg-0-0 --redo --config art/stages/hidden-garden/boards.json` diff --git a/art/stages/hidden-garden/images/hg-0-0-parallax.png b/art/stages/hidden-garden/images/hg-0-0-parallax.png new file mode 100644 index 0000000..3bbc4c7 Binary files /dev/null and b/art/stages/hidden-garden/images/hg-0-0-parallax.png differ diff --git a/art/stages/hidden-garden/layers.json b/art/stages/hidden-garden/layers.json index 72eec95..ce38135 100644 --- a/art/stages/hidden-garden/layers.json +++ b/art/stages/hidden-garden/layers.json @@ -1,5 +1,5 @@ { - "note": "Rules run in order; the last rule that claims a pixel wins. Everything above the path stays one rear layer (scroll 1.0): rectangles would tear the big tree from its trunk at another scroll speed, so sky/far/mid parallax waits for hand-drawn masks.", + "note": "Rules run in order; the last rule that claims a pixel wins. Sky, far and mid come from masks of the model's depth key (scripts/depth_layers.py, #142); the exact rectangles and colours for water, path and foreground follow and win.", "chunks": { "hg-0-0": { "states": { @@ -7,6 +7,18 @@ }, "default": "03-rear", "rules": [ + { + "layer": "00-sky", + "alpha": "masks/hg-0-0.00-sky.png" + }, + { + "layer": "01-far", + "alpha": "masks/hg-0-0.01-far.png" + }, + { + "layer": "02-mid", + "alpha": "masks/hg-0-0.02-mid.png" + }, { "layer": "04-water", "rect": [ diff --git a/art/stages/hidden-garden/layers/hg-0-0.00-sky.filled.png b/art/stages/hidden-garden/layers/hg-0-0.00-sky.filled.png new file mode 100644 index 0000000..eb139ba Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.00-sky.filled.png differ diff --git a/art/stages/hidden-garden/layers/hg-0-0.00-sky.png b/art/stages/hidden-garden/layers/hg-0-0.00-sky.png new file mode 100644 index 0000000..5e2b8e3 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.00-sky.png differ diff --git a/art/stages/hidden-garden/layers/hg-0-0.01-far.filled.png b/art/stages/hidden-garden/layers/hg-0-0.01-far.filled.png new file mode 100644 index 0000000..85e707d Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.01-far.filled.png differ diff --git a/art/stages/hidden-garden/layers/hg-0-0.01-far.png b/art/stages/hidden-garden/layers/hg-0-0.01-far.png new file mode 100644 index 0000000..36c4ff4 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.01-far.png differ diff --git a/art/stages/hidden-garden/layers/hg-0-0.02-mid.filled.png b/art/stages/hidden-garden/layers/hg-0-0.02-mid.filled.png new file mode 100644 index 0000000..25342c1 Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.02-mid.filled.png differ diff --git a/art/stages/hidden-garden/layers/hg-0-0.02-mid.png b/art/stages/hidden-garden/layers/hg-0-0.02-mid.png new file mode 100644 index 0000000..91aba6f Binary files /dev/null and b/art/stages/hidden-garden/layers/hg-0-0.02-mid.png differ diff --git a/art/stages/hidden-garden/layers/hg-0-0.03-rear.png b/art/stages/hidden-garden/layers/hg-0-0.03-rear.png index ec31612..e21f95b 100644 Binary files a/art/stages/hidden-garden/layers/hg-0-0.03-rear.png and b/art/stages/hidden-garden/layers/hg-0-0.03-rear.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.00-sky.png b/art/stages/hidden-garden/masks/hg-0-0.00-sky.png new file mode 100644 index 0000000..578adce Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.00-sky.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.01-far.png b/art/stages/hidden-garden/masks/hg-0-0.01-far.png new file mode 100644 index 0000000..6675a17 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.01-far.png differ diff --git a/art/stages/hidden-garden/masks/hg-0-0.02-mid.png b/art/stages/hidden-garden/masks/hg-0-0.02-mid.png new file mode 100644 index 0000000..7a0e234 Binary files /dev/null and b/art/stages/hidden-garden/masks/hg-0-0.02-mid.png differ diff --git a/docs/09-ART-PIPELINE.md b/docs/09-ART-PIPELINE.md index a8134f9..dfe8827 100644 --- a/docs/09-ART-PIPELINE.md +++ b/docs/09-ART-PIPELINE.md @@ -41,6 +41,24 @@ What Codex's runs proved on Rusty Creek, generalised: 6. **Transition chunks** join different spaces – surface to underground, outside to the Mensa inside. They are generated with *both* neighbours as context and blend the two layer stacks over their height. 7. **Clean and anomaly share everything they can.** The anomaly state replaces only the layers that change (on Rusty Creek, only the structure layers). Masters are rebuilt from the plates and verified pixel-exact (`creek_layers.py`). +## Parallax layers from a depth key + +Painted chunks are one picture. To give them depth without hand masks or redrawing each layer (which drifts), the model repaints the finished chunk as a **depth key**: the same shapes, one flat colour per depth band. The masks come from that key, and a second pass paints what each slow layer has behind the faster ones ([#142](https://github.com/THD-AI/WinCS/issues/142), first used on the Hidden Garden). + +```mermaid +flowchart LR + A["Composed chunk
images/<chunk>.png"] --> K["depth_layers.py key
sunburst repaints it:
sky cyan · far blue · mid green
rear yellow · play red
water navy · foreground magenta"] + K --> M["depth_layers.py masks
snap to the 7 colours · vote specks away
sky edge decided by the real pixel
→ masks/<chunk>.00-sky / 01-far / 02-mid"] + M --> S["split_layers.py
masks first, then the exact rules
for path, water, foreground
every pixel in one layer"] + S --> F["depth_layers.py fill
mask = nearer layers below the skyline
the model paints only that layer
→ layers/<chunk>.0x.filled.png"] + F --> E["export.json · scroll 0.1 / 0.25 / 0.6 / 1.0 / 1.2"] + E --> V["export_stage.py --verify (exact at rest)
compare.py parallax (three camera positions)"] +``` + +- **The key only says which band, never which pixel.** It drifts a few pixels at fine edges, so near the sky the real pixel decides: open sky is ≈ (40–60, 120–140, 240) and hazy far treetops ≈ (80, 120, 180), so "sky" means blue above 200. Path, water and foreground keep their exact rectangles and colour rules, which run after the masks and win. +- **Fills never change the picture at rest.** A fill is kept only under the nearer layers, and the layer's own pixels are copied from the art, so `--verify` still rebuilds the chunk exactly. The camera only ever reveals painted scenery. +- **Several chunks:** a plate at scroll *s* belongs at `chunk.x × 2160 × s`; lane B's loader places it at `chunk.x × 2160` today (commented on [#116](https://github.com/THD-AI/WinCS/pull/116)). + ## Prompting lessons (23 Oct, all runs) | What we tried | What happened | Rule now | diff --git a/scripts/compare.py b/scripts/compare.py index 020efb3..6e4ddbc 100644 --- a/scripts/compare.py +++ b/scripts/compare.py @@ -8,6 +8,10 @@ with a 1 ft ruler (13 px, docs/04) and the documented height of each thing marked; --atlas-dir tmp/atlas reads a review export instead of the game's atlases + python scripts/compare.py parallax art/stages/hidden-garden --state clean --at 0 440 880 --out … + the region's layers from export.json, drawn as the game draws them at each camera x (a layer at + scroll s sits at chunk.x * 2160 * s and moves s px per camera px), one 1280x720 view per row + Paths are relative to the repository. Images and crops are in their own pixels; `--width` sets the common width. """ import argparse @@ -103,6 +107,36 @@ def lineup(specs: list[str], zoom: int = 3) -> tuple[Image.Image, list[str]]: return out, report +VIEW = (1280, 720) + + +def parallax(region: Path, state: str, cameras: list[int]) -> Image.Image: + """The stage at each camera x, every layer offset by its scroll factor; one view per row.""" + config = json.loads((region / "export.json").read_text(encoding="utf-8")) + w, h = 2160, 720 + rows = [] + for cam in cameras: + view = Image.new("RGBA", VIEW, (0, 0, 0, 255)) + for layer_id in config["states"][state]: + layer = config["layers"][layer_id] + for name, chunk in config["chunks"].items(): + path = region / layer["file"].format(name=chunk.get("name", name)) + if not path.exists(): + continue + s = layer["scroll"] + x = round(chunk["x"] * w * s - cam * s) + plate = Image.open(path).convert("RGBA") + if x < 0: # alpha_composite needs a non-negative destination: crop the plate instead + plate, x = plate.crop((-x, 0, plate.width, plate.height)), 0 + if x < VIEW[0] and plate.width: + view.alpha_composite(plate, (x, chunk["y"] * h)) + rows.append(view) + out = Image.new("RGB", (VIEW[0], (VIEW[1] + 8) * len(rows) - 8), GRID) + for i, view in enumerate(rows): + out.paste(view.convert("RGB"), (0, i * (VIEW[1] + 8))) + return out + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="mode", required=True) @@ -116,12 +150,20 @@ def main() -> None: two.add_argument("frames", nargs="+", help="atlas:frame, e.g. hero-move:walk-00") two.add_argument("--out", type=Path, required=True) two.add_argument("--atlas-dir", type=Path, help="read atlases from here (an export to tmp/ before approval)") + three = sub.add_parser("parallax", help="a stage region at several camera positions") + three.add_argument("region", type=Path) + three.add_argument("--state", default="clean") + three.add_argument("--at", type=int, nargs="+", default=[0, 440, 880], help="camera x positions") + three.add_argument("--out", type=Path, required=True) args = parser.parse_args() if getattr(args, "atlas_dir", None): global SPRITES SPRITES = args.atlas_dir if args.atlas_dir.is_absolute() else ROOT / args.atlas_dir if args.mode == "images": out = images(args.ref, args.ours, args.width, args.grid) + elif args.mode == "parallax": + out = parallax(args.region if args.region.is_absolute() else ROOT / args.region, args.state, args.at) + out = out.quantize(256, dither=Image.Dither.NONE) # a review image, not art: keep it small in Git else: out, report = lineup(args.frames) print("\n".join(report)) diff --git a/scripts/depth_layers.py b/scripts/depth_layers.py new file mode 100644 index 0000000..6db4532 --- /dev/null +++ b/scripts/depth_layers.py @@ -0,0 +1,281 @@ +"""Parallax layers for a painted stage chunk: a depth key from the model, masks from the key, fills behind. + + python scripts/depth_layers.py key art/stages/ # API: repaint the chunk as flat depth colours + python scripts/depth_layers.py masks art/stages/ # key -> one mask per layer (committed) + python scripts/depth_layers.py fill art/stages/ # API: paint what nearer layers hide + [--only far mid] [--reuse] # --reuse: re-cut from tmp/ paintings + +1. key: the model repaints the composed chunk with one flat colour per depth band (DEPTH below), same + composition. Raw output stays in tmp/boards//. +2. masks: every pixel snaps to the nearest key colour, small specks are voted away, and the sky/far + edge is refined against the real pixels (the key drifts a few pixels at fine edges). Writes the + parallax bands' masks, art/stages//masks/..png; layers.json uses them as + "alpha" rules before its exact rules for rear, water, play and foreground, so + split_layers.py still sends every pixel to exactly one layer and the stack rebuilds the art exactly. +3. fill: a layer that scrolls slower than the ones in front of it shows what they hid. For each such + layer the model repaints the chunk with the nearer layers masked out, painting only that layer's + scenery behind them; the result is kept only under the nearer layers (and below the layer's own + skyline), so the composed picture at rest is unchanged. Writes layers/..filled.png. + +Needs OPENAI_API_KEY (environment or .env) for key and fill; the key goes in a header file (make_boards.edit). +""" + +from __future__ import annotations + +import argparse +import io +import json +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageChops, ImageFilter + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import make_boards as mb # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +# Depth band -> (docs/04 layer, key colour). Order = back to front. +DEPTH = { + "sky": ("00-sky", (0, 255, 255)), + "far": ("01-far", (0, 0, 255)), + "mid": ("02-mid", (0, 255, 0)), + "rear": ("03-rear", (255, 255, 0)), + "water": ("04-water", (0, 0, 128)), + "play": ("05-play", (255, 0, 0)), + "foreground": ("07-foreground", (255, 0, 255)), +} +BACK_TO_FRONT = list(DEPTH) +PARALLAX = ("sky", "far", "mid") # the bands that scroll slower than the play layer + +KEY_PROMPT = ( + "Repaint this exact image as a FLAT COLOUR DEPTH MAP for a 2D game's parallax layers. Keep every shape exactly " + "where it is, pixel-aligned with the original, same size, no new objects. Fill each region with ONE flat colour, " + "no shading, no outlines, no texture, no text:\n" + "- the open sky and clouds: pure cyan #00FFFF\n" + "- the hazy far tree line and distant forest on the horizon: pure blue #0000FF\n" + "- the mid-distance autumn trees behind the reeds: pure green #00FF00\n" + "- the reeds, bushes, stone walls and the big tree close behind the path: pure yellow #FFFF00\n" + "- the walkable stone path, the bench and the wooden jetty with its posts: pure red #FF0000\n" + "- the pond water with its reflections: navy #000080\n" + "- the dark out-of-focus leaves and grass along the bottom edge, in front of everything: magenta #FF00FF\n" + "Every pixel is exactly one of these seven colours.") + +FILL_PROMPT = ( + "This is one chunk of a side-scrolling stage. Paint ONLY the transparent area, and paint there only {what}, " + "continuing it naturally from what is visible around the hole: the same light, palette, fine 2-pixel grain and " + "scale. Nothing that stands in front of it - no {front}. Strict side view, no characters, no text.") +WHAT = { + "sky": ("the open late-afternoon sky with its clouds and the sun's glow", "trees, reeds, ground or water"), + "far": ("the hazy far tree line and distant forest, down to the bottom of the transparent area", + "near trees, reeds, walls, path or water"), + "mid": ("the mid-distance autumn trees and their trunks, down to the bottom of the transparent area", + "reeds, bushes, walls, path, bench, jetty or water"), +} + + +def snap(key: Image.Image) -> list[str]: + """Each pixel of the key -> its depth band name (nearest key colour).""" + colours = [DEPTH[name][1] for name in BACK_TO_FRONT] + out = [] + for r, g, b in key.convert("RGB").getdata(): + best = min(range(len(colours)), key=lambda i: (r - colours[i][0]) ** 2 + (g - colours[i][1]) ** 2 + (b - colours[i][2]) ** 2) + out.append(BACK_TO_FRONT[best]) + return out + + +def vote(bands: list[str], size: tuple[int, int], radius: int = 2) -> list[str]: + """Remove specks: each band's mask is mode-filtered, the nearest band wins where several claim a pixel.""" + w, h = size + masks = {} + for name in BACK_TO_FRONT: + m = Image.new("L", size) + m.putdata([255 if b == name else 0 for b in bands]) + masks[name] = m.filter(ImageFilter.ModeFilter(2 * radius + 1)) + out = list(bands) + data = {name: masks[name].getdata() for name in BACK_TO_FRONT} + for i in range(w * h): + claim = [name for name in BACK_TO_FRONT if data[name][i]] + if claim: + out[i] = claim[-1] + return out + + +def skylike(px: tuple[int, int, int]) -> bool: + """Sky and cloud colours in the style master's late afternoon: blue, pale blue, white, the warm sun glow. + + Measured on hg-0-0: open sky ≈ (40–60, 120–140, 240); the hazy far treetops ≈ (80, 120, 180) and + (100, 120, 140) – blue, but never above 200 – so the blue test needs b > 200.""" + r, g, b = px + return (b > 200 and b > r + 60) or (r > 200 and g > 200 and b > 180) or (r > 220 and g > 190 and b > 120 and b < r) + + +def refine(bands: list[str], art: Image.Image, reach: int = 6) -> list[str]: + """The key drifts at fine edges: near the sky/far boundary, the real pixel's colour decides.""" + w, h = art.size + rgb = art.convert("RGB").load() + out = list(bands) + def grown(band: str): + m = Image.new("L", art.size) + m.putdata([255 if b == band else 0 for b in bands]) + return m.filter(ImageFilter.MaxFilter(2 * reach + 1)).getdata() + near_sky, near_tree = grown("sky"), {band: grown(band) for band in ("far", "mid", "rear")} + for y in range(h): + for x in range(w): + i = y * w + x + if not near_sky[i] or bands[i] not in ("sky", "far", "mid", "rear"): + continue + is_sky = skylike(rgb[x, y]) + if is_sky and bands[i] in ("far", "mid"): + out[i] = "sky" # a sky-coloured fringe on a tree top (the rear layer keeps its own: exact rules follow) + elif not is_sky and bands[i] == "sky": + # A treetop or leaf edge the key cut off – only right at trees, never a cloud rim or the sun's + # glow. The nearest band wins (a leaf fringe of the big near tree belongs to the rear layer). + tree = next((band for band in ("rear", "mid", "far") if near_tree[band][i]), None) + if tree: + out[i] = tree + return out + + +def write_masks(region: Path, chunk: str, bands: list[str], size: tuple[int, int]) -> list[Path]: + folder = region / "masks" + folder.mkdir(exist_ok=True) + written = [] + for name in PARALLAX: # the nearer layers come from exact rules in layers.json + m = Image.new("L", size) + m.putdata([255 if b == name else 0 for b in bands]) + if m.getbbox() is None: + continue + rgba = Image.new("RGBA", size, (255, 255, 255, 0)) + rgba.putalpha(m) + path = folder / f"{chunk}.{DEPTH[name][0]}.png" + rgba.save(path, optimize=True) + written.append(path) + return written + + +def art_for(region: Path, chunk: str) -> Image.Image: + layers = json.loads((region / "layers.json").read_text(encoding="utf-8")) + return Image.open(region / layers["chunks"][chunk]["states"]["clean"]).convert("RGB") + + +def raw_dir(region: Path) -> Path: + folder = ROOT / "tmp" / "boards" / region.name + folder.mkdir(parents=True, exist_ok=True) + return folder + + +def api(name: str, art: Image.Image, prompt: str, mask: Image.Image | None = None) -> Image.Image | None: + key = mb.api_key() + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "art.png" + art.save(path) + mask_path = None + if mask is not None: + rgba = art.convert("RGBA") + rgba.putalpha(mask.point(lambda v: 0 if v else 255)) # transparent = paint here + mask_path = Path(folder) / "mask.png" + rgba.save(mask_path) + png = mb.edit(name, {"model": "gpt-image-2.5-sunburst", "prompt": prompt, "size": f"{art.width}x{art.height}"}, + [path], key, mask=mask_path) + return None if png is None else Image.open(io.BytesIO(png)).convert("RGB").resize(art.size, Image.Resampling.NEAREST) + + +def bands_from_layers(region: Path, chunk: str, size: tuple[int, int]) -> list[str]: + """The split's own partition (split_layers.py wrote it from the masks and the exact rules).""" + out = ["rear"] * (size[0] * size[1]) + for name in BACK_TO_FRONT: + path = region / "layers" / f"{chunk}.{DEPTH[name][0]}.png" + if path.exists(): + for i, a in enumerate(Image.open(path).getchannel("A").getdata()): + if a: + out[i] = name + return out + + +def skyline(own: list[bool], w: int, h: int, smooth: int = 12) -> list[int]: + """Per column, the layer's top edge – defined everywhere, so its plate spans the whole chunk. + + Columns inside the layer's span without any of its pixels (hidden behind a nearer tree) take the + nearest defined column's top, so the layer continues behind what stands in front of it; a running + minimum over ±smooth px keeps single gaps from cutting a notch. Past the ends of the span there is + no plate (h): the model would invent scenery there – on hg-0-0 it painted sun glow with a flat top – + and a tree line that ends as the camera moves reads naturally.""" + raw = [min((y for y in range(h) if own[y * w + x]), default=None) for x in range(w)] + known = [x for x in range(w) if raw[x] is not None] + if not known: + return [h] * w + filled, j = [], 0 + for x in range(w): + while j + 1 < len(known) and abs(known[j + 1] - x) <= abs(known[j] - x): + j += 1 + filled.append(raw[x] if raw[x] is not None else raw[known[j]]) + tops = [min(filled[max(0, x - smooth):x + smooth + 1]) for x in range(w)] + return [tops[x] if known[0] <= x <= known[-1] else h for x in range(w)] + + +def fill(region: Path, chunk: str, art: Image.Image, only: tuple[str, ...] = PARALLAX, reuse: bool = False) -> None: + """For sky, far and mid: paint what the nearer layers hide, kept only there and below the layer's skyline.""" + w, h = art.size + bands = bands_from_layers(region, chunk, art.size) + for name in only: + own = [b == name for b in bands] + if not any(own): + continue + front = set(BACK_TO_FRONT[BACK_TO_FRONT.index(name) + 1:]) + top = [0] * w if name == "sky" else skyline(own, w, h) + hole = Image.new("L", art.size) + hole.putdata([255 if bands[y * w + x] in front and y >= top[x] else 0 for y in range(h) for x in range(w)]) + # The model gets a 2 px margin so no fringe of the nearer layer survives in its painting, but the + # plate keeps the painting only on pixels the nearer layers own: never over a farther layer. + prompt_hole = hole.filter(ImageFilter.MaxFilter(5)) + in_front = Image.new("L", art.size) + in_front.putdata([255 if b in front else 0 for b in bands]) + what, nots = WHAT[name] + raw = raw_dir(region) / f"{chunk}--fill-{name}.png" + if reuse and raw.exists(): # masks changed, the painting didn't: cut the plate again without the API + painted = Image.open(raw).convert("RGB") + else: + painted = api(f"{chunk}--fill-{name}", art, FILL_PROMPT.format(what=what, front=nots), prompt_hole) + if painted is None: + return + painted.save(raw) + plate = Image.new("RGBA", art.size) + keep = Image.new("L", art.size) + keep.putdata([255 if own[i] else 0 for i in range(w * h)]) + plate.paste(painted.convert("RGBA"), mask=ImageChops.multiply(prompt_hole, in_front)) + plate.paste(art.convert("RGBA"), mask=keep) # its own pixels stay exactly the art's + out = region / "layers" / f"{chunk}.{DEPTH[name][0]}.filled.png" + plate.save(out, optimize=True) + print(f"{chunk}: {name} filled -> {out.relative_to(ROOT)}", flush=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("step", choices=["key", "masks", "fill"]) + parser.add_argument("region", type=Path) + parser.add_argument("chunk") + parser.add_argument("--only", nargs="+", choices=PARALLAX, default=list(PARALLAX), help="fill: these layers") + parser.add_argument("--reuse", action="store_true", help="fill: cut the plates from the paintings in tmp/ again") + args = parser.parse_args() + region = args.region if args.region.is_absolute() else ROOT / args.region + art = art_for(region, args.chunk) + raw = raw_dir(region) / f"{args.chunk}--depth.png" + if args.step == "key": + key = api(f"{args.chunk}--depth", art, KEY_PROMPT) + if key is None: + sys.exit(1) + key.save(raw) + print(f"{args.chunk}: depth key -> {raw.relative_to(ROOT)}") + elif args.step == "masks": + if not raw.exists(): + sys.exit(f"{raw.relative_to(ROOT)} missing: run the key step first") + bands = refine(vote(snap(Image.open(raw)), art.size), art) + for path in write_masks(region, args.chunk, bands, art.size): + print(f"{args.chunk}: mask -> {path.relative_to(ROOT)}") + else: + fill(region, args.chunk, art, tuple(args.only), args.reuse) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_depth_layers.py b/scripts/test_depth_layers.py new file mode 100644 index 0000000..2bb883d --- /dev/null +++ b/scripts/test_depth_layers.py @@ -0,0 +1,70 @@ +"""Offline checks for depth_layers.py: snapping the key, voting specks away, the sky edge, the masks it writes.""" +from pathlib import Path +import tempfile +import unittest + +from PIL import Image + +import depth_layers as dl + +SKY, FAR, REAR = dl.DEPTH["sky"][1], dl.DEPTH["far"][1], dl.DEPTH["rear"][1] + + +class DepthLayerTests(unittest.TestCase): + def test_snap_takes_the_nearest_key_colour(self): + key = Image.new("RGB", (3, 1)) + key.putdata([(10, 240, 250), (20, 10, 230), (250, 250, 30)]) # off-key cyan, blue, yellow + self.assertEqual(dl.snap(key), ["sky", "far", "rear"]) + + def test_vote_removes_a_speck(self): + bands = ["sky"] * 81 + bands[40] = "far" # one stray pixel in the middle of a 9x9 sky + self.assertEqual(dl.vote(bands, (9, 9))[40], "sky") + + def test_refine_moves_a_sky_fringe_and_a_cut_treetop(self): + # 6 wide, 4 tall: sky on top, far trees below. The key put a tree's top pixel in the sky (x=2, y=1) + # and a blue sky pixel into the trees (x=4, y=2). + art = Image.new("RGB", (6, 4), (70, 140, 230)) + for x in range(6): + for y in (2, 3): + art.putpixel((x, y), (90, 100, 80)) + art.putpixel((2, 1), (90, 100, 80)) + art.putpixel((4, 2), (70, 140, 230)) + bands = ["sky"] * 12 + ["far"] * 12 + out = dl.refine(bands, art, reach=2) + self.assertEqual(out[1 * 6 + 2], "far") + self.assertEqual(out[2 * 6 + 4], "sky") + self.assertEqual(out[0], "sky") + + def test_hazy_treetops_are_not_sky(self): + self.assertTrue(dl.skylike((50, 130, 240))) + self.assertTrue(dl.skylike((240, 240, 235))) + self.assertFalse(dl.skylike((80, 120, 180))) + self.assertFalse(dl.skylike((100, 120, 140))) + + def test_refine_leaves_cloud_rims_in_the_sky(self): + # A grey cloud rim far from any tree line stays sky. + art = Image.new("RGB", (20, 20), (70, 140, 230)) + art.putpixel((10, 3), (150, 150, 150)) + bands = ["sky"] * 400 + self.assertEqual(dl.refine(bands, art)[3 * 20 + 10], "sky") + + def test_skyline_spans_every_column(self): + # 8 wide, 5 tall: the layer shows at x=2 (top 1) and x=5 (top 3); everything else is hidden. + own = [False] * 40 + own[1 * 8 + 2] = own[3 * 8 + 5] = True + self.assertEqual(dl.skyline(own, 8, 5, smooth=0), [5, 5, 1, 1, 3, 3, 5, 5]) # no plate past the ends + self.assertEqual(dl.skyline(own, 8, 5, smooth=1)[4], 1) + + def test_masks_are_written_for_the_parallax_bands_only(self): + with tempfile.TemporaryDirectory() as tmp: + region = Path(tmp) + bands = ["sky", "far", "mid", "rear", "play", "water"] + written = dl.write_masks(region, "c-0-0", bands, (6, 1)) + self.assertEqual([p.name for p in written], ["c-0-0.00-sky.png", "c-0-0.01-far.png", "c-0-0.02-mid.png"]) + alpha = list(Image.open(written[1]).getchannel("A").getdata()) + self.assertEqual(alpha, [0, 255, 0, 0, 0, 0]) + + +if __name__ == "__main__": + unittest.main()