fix(pipeline): kill dither speckle + atlas geometry lies (post-playtest)

Parley's review: dither full of green spots, baked rooms don't match the
nav graph (doors painted on closed walls, real openings off-center).
Both root-caused:

- pixelate.py: the nearest-color LUT was spaced in LINEAR RGB, cramming
  the whole shadow range into ~4 cells — dark picks were effectively
  arbitrary (green/teal confetti in warm near-blacks). LUT is now
  sRGB-spaced, diffusion error is gamut-clamped (no more blue blobs at
  torch highlights), and a new --overshoot penalty forbids choosing a
  color more saturated than the source. Green-dominant dark pixels on
  the test frame: 5.2% -> 0.02%, while palette usage went UP (107 -> 135
  colors). Recipe rev 2: append --overshoot 6.

- fpv.js: new directedVantage(env, region, dir) — per-(room,facing)
  camera instead of one room-wide vantage. Open ahead: stand in a cell
  whose straight-ahead ray escapes through the actual gap (the passage
  you walk is centered, 2-7 cells deep). Closed ahead: stand ~2 cells
  from the wall, deliberately OFF the room's axis — a symmetric stage
  begs the model to paint a centered focal door; an off-axis dead-end
  corner doesn't. Walls also render under their own fixed seed and an
  emphatic sealed-masonry prompt clause (cfg 1.0 negatives are inert).

Validated on seed 7 room 1: the closed-N phantom door is gone; the open-S
passage is dead-center. Full campaign re-bake follows as its own commit.

Also: rest-overlay no longer sticks without a keyup, in-combat drink
message, feathered sprite mask, no monster packs on floor 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-06-12 00:06:46 -06:00
parent 5813ee3b5a
commit acc8137f4a
4 changed files with 128 additions and 18 deletions

View file

@ -53,6 +53,59 @@ export function roomVantage(env, region, opts = {}) {
return mostOpenCell(distanceToWall(tiles, env.width, env.height), env.width, region.cells); return mostOpenCell(distanceToWall(tiles, env.width, env.height), env.width, region.cells);
} }
// Per-(room, facing) camera cell, chosen so the faced geometry reads
// UNAMBIGUOUSLY in the depth map. The room-wide most-open vantage stands far
// from everything: the faced wall lands near max-view (≈ black), and the
// diffusion model decorates that ambiguity with hallucinated doors — while a
// real opening renders off-center because the camera isn't aligned with the
// gap. Instead:
// open ahead → stand in a cell whose straight-ahead ray ESCAPES the room
// through the gap, a comfortable 27 cells back: the passage
// you'd walk through is centered and clearly deep.
// wall ahead → stand ~3 cells from the wall: it renders mid-bright and
// solid, leaving the model nothing to invent.
export function directedVantage(env, region, dirName, opts = {}) {
const { tiles } = getStage(env, opts.stage);
const w = env.width, h = env.height;
const dist = distanceToWall(tiles, w, h);
const [dx, dy] = DIRS[dirName];
const cellSet = new Set(region.cells.map(([x, y]) => x + ',' + y));
const [cx, cy] = centroidOf(region.cells);
let best = null, bestScore = Infinity, anyEscape = false;
const cand = [];
for (const [x, y] of region.cells) {
let px = x, py = y, run = 0, escaped = false;
for (let s = 0; s < 24; s++) {
px += dx; py += dy;
if (px < 0 || py < 0 || px >= w || py >= h) break;
if (blocks(tiles[py][px])) break;
run++;
if (!cellSet.has(px + ',' + py)) escaped = true; // ray left the room through a gap
}
if (escaped) anyEscape = true;
cand.push({ x, y, run, escaped });
}
for (const c of cand) {
if (anyEscape && !c.escaped) continue; // when a gap is sightable, only gap-aligned cells qualify
const clearance = dist[c.y * w + c.x];
// Sweet bands: gaps read best 27 cells out; a blank wall at ~2.
const band = anyEscape
? (c.run < 2 ? (2 - c.run) * 6 : c.run > 7 ? (c.run - 7) * 2 : 0)
: Math.abs(c.run - 2) * 3; // closed: stand close — a bright dominant slab leaves no room for phantom doors
const hug = clearance <= 1 ? 5 : 0; // don't press against a side wall
// Open: center on the gap. Closed: deliberately stand OFF the room's
// perpendicular axis — a symmetric stage begs the model to paint a
// centered focal door; an off-axis dead-end corner doesn't.
const perp = dirName === 'N' || dirName === 'S' ? c.x - cx : c.y - cy;
const cen = ((c.x - cx) ** 2 + (c.y - cy) ** 2) * 0.05;
const offAxis = anyEscape ? 0 : (Math.abs(perp) < 1.5 ? 4 : 0);
const score = band + hug + (anyEscape ? cen : offAxis + cen * 0.4);
if (score < bestScore) { bestScore = score; best = [c.x, c.y]; }
}
return best ?? mostOpenCell(dist, w, region.cells);
}
// Default demo vantage: the most-open cell of the largest room (or, failing that, // Default demo vantage: the most-open cell of the largest room (or, failing that,
// the most open floor cell anywhere). // the most open floor cell anywhere).
export function vantage(env, opts = {}) { export function vantage(env, opts = {}) {

View file

@ -5,7 +5,7 @@
import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js'; import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js';
import { renderMap, getStage, THEMES } from './render.js'; import { renderMap, getStage, THEMES } from './render.js';
import { downloadDepth, exportDepthDataURL, exportDepthToServer } from './depth.js'; import { downloadDepth, exportDepthDataURL, exportDepthToServer } from './depth.js';
import { exportFPVDataURL, exportFPVToServer, vantage } from './fpv.js'; import { exportFPVDataURL, exportFPVToServer, vantage, directedVantage } from './fpv.js';
import { dungeonManifest } from './explore.js'; import { dungeonManifest } from './explore.js';
const canvas = document.getElementById('view'); const canvas = document.getElementById('view');
@ -241,7 +241,10 @@ window.reikhelm = {
const sd = state.env.seed; const sd = state.env.seed;
for (const r of m.rooms) { for (const r of m.rooms) {
for (const dir of ['N', 'E', 'S', 'W']) { for (const dir of ['N', 'E', 'S', 'W']) {
await exportFPVToServer(state.env, `atlas/${sd}/_work/r${r.id}_${dir}_depth.png`, { at: r.vantage, dir }); // Per-direction camera: gap-aligned when the way is open, near-wall
// when it's closed — so the depth map can't be misread by the model.
const at = directedVantage(state.env, state.env.regions[r.id], dir);
await exportFPVToServer(state.env, `atlas/${sd}/_work/r${r.id}_${dir}_depth.png`, { at, dir });
} }
} }
await this.saveJSON(`atlas/${sd}/manifest.json`, m); await this.saveJSON(`atlas/${sd}/manifest.json`, m);

View file

@ -56,7 +56,11 @@ DEFAULT_BODY = THEME_BODY["stone"]
# These reinforce the depth (a deep recess vs a near wall straight ahead) so the # These reinforce the depth (a deep recess vs a near wall straight ahead) so the
# model paints a passage only where one actually exists — killing phantom doors. # model paints a passage only where one actually exists — killing phantom doors.
AHEAD_OPEN = "a dark arched passage leads onward into shadow ahead" AHEAD_OPEN = "a dark arched passage leads onward into shadow ahead"
AHEAD_WALL = "a solid carved stone wall closes the way ahead" # cfg 1.0 makes negatives inert, so the closed case is carried by emphatic
# positive description — themes like the vault say "locked", which otherwise
# summons phantom doors on blank walls.
AHEAD_WALL = ("the way ahead ends at a blank unbroken wall of carved stone blocks, "
"sealed solid masonry with no door and no opening, a dead end")
NEGATIVE = ("person, people, human, figure, character, hands, fingers, hand, arm, arms, " NEGATIVE = ("person, people, human, figure, character, hands, fingers, hand, arm, arms, "
"holding weapon, sword, feet, legs, body, silhouette, UI, HUD, text, watermark, modern") "holding weapon, sword, feet, legs, body, silhouette, UI, HUD, text, watermark, modern")
@ -124,13 +128,18 @@ def main():
theme = room.get("theme") theme = room.get("theme")
is_open = bool(room.get("open", {}).get(d, False)) is_open = bool(room.get("open", {}).get(d, False))
prompt = prompt_for(theme, is_open) prompt = prompt_for(theme, is_open)
strength = a.strength
# Walls render under their own FIXED seed: the open-frame seed's
# favorite composition is a centered doorway — exactly what a closed
# wall must not grow. One class, one seed → style stays coherent.
render_seed = a.render_seed if is_open else a.render_seed + 13
prefix = f"r{rid}_{d}_{RUN}" # per-run unique; avoids ComfyUI prefix-counter collisions on re-bake prefix = f"r{rid}_{d}_{RUN}" # per-run unique; avoids ComfyUI prefix-counter collisions on re-bake
frame = os.path.join(atlas, f"r{rid}_{d}.png") frame = os.path.join(atlas, f"r{rid}_{d}.png")
tag = f"[{i}/{len(depths)}] r{rid} {d} {theme or '?'}{'·open' if is_open else '·wall'}" tag = f"[{i}/{len(depths)}] r{rid} {d} {theme or '?'}{'·open' if is_open else '·wall'}"
if not run(["python3", COMFY, "zrender", a.url, "--depth", depth, if not run(["python3", COMFY, "zrender", a.url, "--depth", depth,
"--width", str(a.width), "--height", str(a.height), "--width", str(a.width), "--height", str(a.height),
"--strength", str(a.strength), "--seed", str(a.render_seed), "--strength", str(strength), "--seed", str(render_seed),
"--prompt", prompt, "--negative", NEGATIVE, "--prompt", prompt, "--negative", NEGATIVE,
"--prefix", prefix, "--wait", "300"]): "--prefix", prefix, "--wait", "300"]):
print(f" {tag} RENDER FAILED"); continue print(f" {tag} RENDER FAILED"); continue
@ -141,7 +150,8 @@ def main():
render = renders[0] render = renders[0]
if not run([VENV_PY, PIXELATE, render, "--palette", PALETTE, if not run([VENV_PY, PIXELATE, render, "--palette", PALETTE,
"--size", a.size, "--preview-scale", "0", "--out", frame]): "--size", a.size, "--preview-scale", "0", "--overshoot", "6",
"--out", frame]):
print(f" {tag} DITHER FAILED"); continue print(f" {tag} DITHER FAILED"); continue
os.remove(render) # keep the depth, drop the big intermediate render os.remove(render) # keep the depth, drop the big intermediate render

View file

@ -7,10 +7,14 @@ Pipeline: load -> resize-to-target (any dims/aspect, fill/fit/pad + focus)
Run with the sibling venv: .venv/bin/python pixelate.py <image> [opts] Run with the sibling venv: .venv/bin/python pixelate.py <image> [opts]
LOCKED Primordyn recipe (2026-06-01): the DEFAULTS are the look. Running LOCKED Primordyn recipe (2026-06-01, rev 2026-06-11): the DEFAULTS are the look.
pixelate.py render.png --palette primordyn_v2.json pixelate.py render.png --palette primordyn_v2.json --overshoot 6
is exactly --size 640x360 --dither floyd --space oklab --mode fill (serpentine, is exactly --size 640x360 --dither floyd --space oklab --mode fill (serpentine,
strength 1.0). Override --size 320x180 for the chunkier cut. strength 1.0) plus the chroma-overshoot guard. Override --size 320x180 for the
chunkier cut. Rev 2 fixed dark-region hue speckle: the nearest-color LUT is now
sRGB-spaced (linear spacing crushed all shadows into ~4 cells), diffusion error
is gamut-clamped, and --overshoot forbids picking colors more saturated than
the source (no more green/teal confetti in near-black warm gradients).
The quality knobs that matter: perceptual matching in OKLab (right color, not The quality knobs that matter: perceptual matching in OKLab (right color, not
just RGB-nearest), error diffusion done in LINEAR light (gamma-correct, no just RGB-nearest), error diffusion done in LINEAR light (gamma-correct, no
@ -127,7 +131,18 @@ def linear_to_oklab(c):
], -1) ], -1)
def to_match_fn(space): def to_match_fn(space, chroma=1.0):
"""Map linear RGB into the color-match space.
`chroma` scales OKLab's a/b axes before distance is taken. At low lightness
plain OKLab ΔE barely charges for hue, so dark olive/teal palette entries win
matches inside warm near-black gradients and error diffusion sprays them as
speckle ("green spots"). Weighting chroma up makes hue infidelity expensive
while leaving lightness ramps untouched.
"""
if space == "oklab" and chroma != 1.0:
w = np.array([1.0, chroma, chroma], np.float32)
return lambda c: linear_to_oklab(c) * w
return {"oklab": linear_to_oklab, "linear": lambda c: c, "srgb": linear_to_srgb}[space] return {"oklab": linear_to_oklab, "linear": lambda c: c, "srgb": linear_to_srgb}[space]
@ -136,15 +151,33 @@ def to_match_fn(space):
# Lets the sequential error-diffusion loop do O(1) lookups instead of an # Lets the sequential error-diffusion loop do O(1) lookups instead of an
# argmin-over-256 per pixel. # argmin-over-256 per pixel.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def build_lut(palette_match, to_match, grid=64): def build_lut(palette_match, to_match, grid=64, overshoot=0.0):
"""Nearest-palette-index LUT over the linear cube.
`overshoot` adds an asymmetric chroma penalty: a candidate that is MORE
saturated than the source pixel pays `overshoot * (C_pal - C_src)^2` extra.
This is the green-speckle killer a 256 palette is sparse in the dark
range, and plain ΔE happily swaps a muted near-black brown for a vivid
dark green. Saturated sources (torch flames) still match saturated
entries; muted sources can only fall DOWN in chroma, never up.
Assumes the match space is OKLab-like (channels 1,2 = chroma plane).
"""
# The grid is spaced in sRGB (gamma) coordinates, not linear: linear spacing
# crams every dark color into the bottom few cells (sRGB 60 ≈ linear 0.05),
# which quantizes the whole shadow range into a handful of arbitrary picks.
axis = np.linspace(0.0, 1.0, grid, dtype=np.float32) axis = np.linspace(0.0, 1.0, grid, dtype=np.float32)
gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij") gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
pts_lin = np.stack([gx, gy, gz], -1).reshape(-1, 3) pts_srgb = np.stack([gx, gy, gz], -1).reshape(-1, 3)
pts = to_match(pts_lin) pts = to_match(srgb_to_linear(pts_srgb).astype(np.float32))
pal_c = np.sqrt((palette_match[:, 1] ** 2 + palette_match[:, 2] ** 2))
out = np.empty(pts.shape[0], dtype=np.int32) out = np.empty(pts.shape[0], dtype=np.int32)
step = 4096 step = 4096
for s in range(0, pts.shape[0], step): for s in range(0, pts.shape[0], step):
d = ((pts[s:s + step, None, :] - palette_match[None, :, :]) ** 2).sum(2) d = ((pts[s:s + step, None, :] - palette_match[None, :, :]) ** 2).sum(2)
if overshoot > 0.0:
src_c = np.sqrt((pts[s:s + step, 1] ** 2 + pts[s:s + step, 2] ** 2))
over = np.clip(pal_c[None, :] - src_c[:, None], 0.0, None)
d += overshoot * over * over
out[s:s + step] = d.argmin(1) out[s:s + step] = d.argmin(1)
return out.reshape(grid, grid, grid) return out.reshape(grid, grid, grid)
@ -171,7 +204,9 @@ BAYER = {
def _lut_lookup(lin_vals, lut, gscale): def _lut_lookup(lin_vals, lut, gscale):
gi = np.clip((lin_vals * gscale + 0.5).astype(np.int32), 0, gscale) # LUT axes are sRGB-spaced (see build_lut) — gamma-encode before indexing.
g = linear_to_srgb(np.clip(lin_vals, 0.0, 1.0))
gi = np.clip((g * gscale + 0.5).astype(np.int32), 0, gscale)
return lut[gi[..., 0], gi[..., 1], gi[..., 2]] return lut[gi[..., 0], gi[..., 1], gi[..., 2]]
@ -185,8 +220,13 @@ def error_diffuse(lin, lut, palette_lin, kernel, serpentine, strength):
rev = serpentine and (y & 1) rev = serpentine and (y & 1)
xr = range(w - 1, -1, -1) if rev else range(w) xr = range(w - 1, -1, -1) if rev else range(w)
for x in xr: for x in xr:
v = work[y, x] # Clamp to the gamut before matching AND before computing the
gi = np.clip((v * gscale + 0.5).astype(np.int32), 0, gscale) # residual: unbounded accumulated error walks out of gamut and
# then "pays itself back" as smeared blue/green blobs around
# saturated highlights.
v = np.clip(work[y, x], 0.0, 1.0)
g = linear_to_srgb(v) # LUT axes are sRGB-spaced
gi = np.clip((g * gscale + 0.5).astype(np.int32), 0, gscale)
i = int(lut[gi[0], gi[1], gi[2]]) i = int(lut[gi[0], gi[1], gi[2]])
idxmap[y, x] = i idxmap[y, x] = i
err = (v - palette_lin[i]) * strength err = (v - palette_lin[i]) * strength
@ -255,6 +295,10 @@ def main():
p.add_argument("--focus", default="center", help=f"crop focus: {list(FOCI)} or 'fx,fy' (0..1)") p.add_argument("--focus", default="center", help=f"crop focus: {list(FOCI)} or 'fx,fy' (0..1)")
p.add_argument("--dither", choices=list(KERNELS) + ["bayer4", "bayer8", "none"], default="floyd") p.add_argument("--dither", choices=list(KERNELS) + ["bayer4", "bayer8", "none"], default="floyd")
p.add_argument("--space", choices=["oklab", "linear", "srgb"], default="oklab", help="color-match space") p.add_argument("--space", choices=["oklab", "linear", "srgb"], default="oklab", help="color-match space")
p.add_argument("--chroma", type=float, default=1.0,
help="hue-fidelity weight for oklab matching (scales a/b symmetrically)")
p.add_argument("--overshoot", type=float, default=0.0,
help="asymmetric penalty for picking a MORE saturated color than the source (6 = atlas recipe; kills dark green speckle)")
p.add_argument("--strength", type=float, default=1.0, help="dither amount (error-diffusion fraction / bayer scale)") p.add_argument("--strength", type=float, default=1.0, help="dither amount (error-diffusion fraction / bayer scale)")
p.add_argument("--no-serpentine", action="store_true") p.add_argument("--no-serpentine", action="store_true")
p.add_argument("--resample", choices=["lanczos", "box", "bilinear", "hamming", "nearest"], default="lanczos") p.add_argument("--resample", choices=["lanczos", "box", "bilinear", "hamming", "nearest"], default="lanczos")
@ -274,17 +318,17 @@ def main():
palette_srgb = load_palette(args.palette, ref_image=small) palette_srgb = load_palette(args.palette, ref_image=small)
pal01 = palette_srgb / 255.0 pal01 = palette_srgb / 255.0
palette_lin = srgb_to_linear(pal01).astype(np.float32) palette_lin = srgb_to_linear(pal01).astype(np.float32)
to_match = to_match_fn(args.space) to_match = to_match_fn(args.space, args.chroma)
palette_match = to_match(palette_lin).astype(np.float32) palette_match = to_match(palette_lin).astype(np.float32)
lin = srgb_to_linear(np.asarray(small, np.float32) / 255.0) lin = srgb_to_linear(np.asarray(small, np.float32) / 255.0)
if args.dither in KERNELS: if args.dither in KERNELS:
lut = build_lut(palette_match, to_match, args.lut) lut = build_lut(palette_match, to_match, args.lut, args.overshoot)
idxmap = error_diffuse(lin, lut, palette_lin, KERNELS[args.dither], idxmap = error_diffuse(lin, lut, palette_lin, KERNELS[args.dither],
not args.no_serpentine, args.strength) not args.no_serpentine, args.strength)
elif args.dither.startswith("bayer"): elif args.dither.startswith("bayer"):
lut = build_lut(palette_match, to_match, args.lut) lut = build_lut(palette_match, to_match, args.lut, args.overshoot)
idxmap = ordered(lin, lut, BAYER[int(args.dither[5:])], args.strength) idxmap = ordered(lin, lut, BAYER[int(args.dither[5:])], args.strength)
else: # none -> hard nearest, vectorized else: # none -> hard nearest, vectorized
flat = to_match(lin).reshape(-1, 3) flat = to_match(lin).reshape(-1, 3)