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:
parent
5813ee3b5a
commit
acc8137f4a
4 changed files with 128 additions and 18 deletions
|
|
@ -53,6 +53,59 @@ export function roomVantage(env, region, opts = {}) {
|
|||
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 2–7 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 2–7 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,
|
||||
// the most open floor cell anywhere).
|
||||
export function vantage(env, opts = {}) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js';
|
||||
import { renderMap, getStage, THEMES } from './render.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';
|
||||
|
||||
const canvas = document.getElementById('view');
|
||||
|
|
@ -241,7 +241,10 @@ window.reikhelm = {
|
|||
const sd = state.env.seed;
|
||||
for (const r of m.rooms) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,11 @@ DEFAULT_BODY = THEME_BODY["stone"]
|
|||
# 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.
|
||||
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, "
|
||||
"holding weapon, sword, feet, legs, body, silhouette, UI, HUD, text, watermark, modern")
|
||||
|
||||
|
|
@ -124,13 +128,18 @@ def main():
|
|||
theme = room.get("theme")
|
||||
is_open = bool(room.get("open", {}).get(d, False))
|
||||
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
|
||||
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'}"
|
||||
|
||||
if not run(["python3", COMFY, "zrender", a.url, "--depth", depth,
|
||||
"--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,
|
||||
"--prefix", prefix, "--wait", "300"]):
|
||||
print(f" {tag} RENDER FAILED"); continue
|
||||
|
|
@ -141,7 +150,8 @@ def main():
|
|||
render = renders[0]
|
||||
|
||||
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
|
||||
|
||||
os.remove(render) # keep the depth, drop the big intermediate render
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
LOCKED Primordyn recipe (2026-06-01): the DEFAULTS are the look. Running
|
||||
pixelate.py render.png --palette primordyn_v2.json
|
||||
LOCKED Primordyn recipe (2026-06-01, rev 2026-06-11): the DEFAULTS are the look.
|
||||
pixelate.py render.png --palette primordyn_v2.json --overshoot 6
|
||||
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
|
||||
just RGB-nearest), error diffusion done in LINEAR light (gamma-correct, no
|
||||
|
|
@ -127,7 +131,18 @@ def linear_to_oklab(c):
|
|||
], -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]
|
||||
|
||||
|
||||
|
|
@ -136,15 +151,33 @@ def to_match_fn(space):
|
|||
# Lets the sequential error-diffusion loop do O(1) lookups instead of an
|
||||
# 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)
|
||||
gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
|
||||
pts_lin = np.stack([gx, gy, gz], -1).reshape(-1, 3)
|
||||
pts = to_match(pts_lin)
|
||||
pts_srgb = np.stack([gx, gy, gz], -1).reshape(-1, 3)
|
||||
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)
|
||||
step = 4096
|
||||
for s in range(0, pts.shape[0], step):
|
||||
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)
|
||||
return out.reshape(grid, grid, grid)
|
||||
|
||||
|
|
@ -171,7 +204,9 @@ BAYER = {
|
|||
|
||||
|
||||
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]]
|
||||
|
||||
|
||||
|
|
@ -185,8 +220,13 @@ def error_diffuse(lin, lut, palette_lin, kernel, serpentine, strength):
|
|||
rev = serpentine and (y & 1)
|
||||
xr = range(w - 1, -1, -1) if rev else range(w)
|
||||
for x in xr:
|
||||
v = work[y, x]
|
||||
gi = np.clip((v * gscale + 0.5).astype(np.int32), 0, gscale)
|
||||
# Clamp to the gamut before matching AND before computing the
|
||||
# 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]])
|
||||
idxmap[y, x] = i
|
||||
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("--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("--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("--no-serpentine", action="store_true")
|
||||
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)
|
||||
pal01 = palette_srgb / 255.0
|
||||
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)
|
||||
|
||||
lin = srgb_to_linear(np.asarray(small, np.float32) / 255.0)
|
||||
|
||||
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],
|
||||
not args.no_serpentine, args.strength)
|
||||
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)
|
||||
else: # none -> hard nearest, vectorized
|
||||
flat = to_match(lin).reshape(-1, 3)
|
||||
|
|
|
|||
Loading…
Reference in a new issue