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);
|
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,
|
// 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 = {}) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue