Add engine-debug-tools and fix critter animation rendering bugs
build / build (push) Successful in 2m52s

- engine-debug-tools/: a debug dump hotkey + dump2png to inspect
  decoded critter frames independent of the renderer (see root
  README.md's "Debugging" section).
- engine-patches/04,05: fix critter sprite scale and an OpenGL
  texture cache collision, found using that tooling.
- mdlp: fix encodeFrames RLE-diffing upscaled frames against a
  previous-frame reference the real RSD8 decoder never implements
  (verified against Night Dive's original Mac source down to the
  68K assembly) - was rendering as scattered static in-game.
- Upscale pipeline: better color quantization (dithering, pure-ramp
  exclusion) and per-resource model selection.
This commit is contained in:
ml
2026-10-18 06:59:47 +02:00
parent ab2b778f44
commit 9d0a0d5798
21 changed files with 1185 additions and 82 deletions
+34 -14
View File
@@ -21,23 +21,43 @@ RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl gosu patch python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir onnxruntime pillow numpy
RUN pip3 install --no-cache-dir onnxruntime pillow numpy scipy
# The AI upscaler model for mdlp's upscale mod type (see ../README.md
# section 4): RealESRGAN_x4plus_anime_6B, a 6-block RRDBNet ONNX-exported
# from xinntao/Real-ESRGAN's anime-illustration checkpoint (BSD-3-Clause).
# It's trained on flat-color line art, so it keeps hard sprite edges rather
# than smoothing them the way a photo- or video-denoising model would. A
# mod's own "model" field (upscaleDef in ../src/mdlp/upscale.go) is not
# consulted; every resource goes through this one model. upscale_batch.py
# resizes its fixed native 4x output down to whatever scale a mod actually
# asks for.
ARG UPSCALE_MODEL_URL=https://huggingface.co/skillsafe-ai/realesrgan-x4plus-anime-6b/resolve/main/model.onnx
RUN mkdir -p /opt/upscale-model \
&& curl -sSL -o /opt/upscale-model/model.onnx "$UPSCALE_MODEL_URL"
# The AI upscaler models for mdlp's upscale mod type (see ../README.md
# section 4), selected per mod by a bitmaps/*.json file's own "model" field
# (upscaleDef in ../src/mdlp/upscale.go) - upscale_batch.py resolves a name
# straight to $UPSCALE_MODELS_DIR/<name>.onnx, and picks tiled or single-pass
# inference based on each model's own declared ONNX input shape (fixed vs
# dynamic height/width), so adding another model here needs no code change.
# Each model's RGB output is re-quantized to SS1's fixed 256-color palette
# with Floyd-Steinberg dithering (imageToBitmap in ../src/mdlp/upscale.go),
# not a plain nearest-color map - without it, any model's smooth gradients
# can land on a visibly wrong, much darker palette entry in a solid patch
# rather than unnoticeable dithering noise.
# All three are BSD-3-Clause ONNX exports of xinntao/Real-ESRGAN checkpoints:
# - realesrgan-x4plus-anime: a 6-block RRDBNet trained on flat-color
# illustration, keeping hard sprite edges rather than smoothing them the
# way a photo- or video-denoising model would. Dynamic input shape. Default
# choice - sharpest result on most sprites.
# - realesrgan-x4plus: the general-purpose photo checkpoint (16.7M-param
# RRDBNet). Fixed 128x128 input, needs tiling for anything larger, and
# much slower than the other two. Worth trying when the anime model
# mis-renders a specific sprite, but can itself mis-render different ones.
# - realesr-general-x4v3: a compact 1.2M-param SRVGGNet, dynamic input
# shape, ~100x faster than realesrgan-x4plus. Noticeably softer than
# either RRDBNet model, but the most robust against both models' failure
# modes - the model to reach for on a specific sprite neither RRDBNet
# model renders cleanly, not as a blanket replacement.
ARG UPSCALE_MODEL_ANIME_URL=https://huggingface.co/skillsafe-ai/realesrgan-x4plus-anime-6b/resolve/main/model.onnx
ARG UPSCALE_MODEL_GENERAL_URL=https://huggingface.co/qualcomm/Real-ESRGAN-x4plus/resolve/6f5749d5adff39de0127e42bc45c8ec8e6f76a66/Real-ESRGAN-x4plus.onnx
ARG UPSCALE_MODEL_COMPACT_URL=https://huggingface.co/jonathanst29/tinier-upscale-models/resolve/main/realesr-general-x4v3.onnx
RUN mkdir -p /opt/upscale-models \
&& curl -sSL -o /opt/upscale-models/realesrgan-x4plus-anime.onnx "$UPSCALE_MODEL_ANIME_URL" \
&& curl -sSL -o /opt/upscale-models/realesrgan-x4plus.onnx "$UPSCALE_MODEL_GENERAL_URL" \
&& curl -sSL -o /opt/upscale-models/realesr-general-x4v3.onnx "$UPSCALE_MODEL_COMPACT_URL"
COPY mdlp-image/upscale_batch.py /usr/local/bin/upscale_batch.py
RUN chmod 755 /usr/local/bin/upscale_batch.py
ENV UPSCALE_MODEL_PATH=/opt/upscale-model/model.onnx
ENV UPSCALE_MODELS_DIR=/opt/upscale-models
RUN curl -sSLO "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
&& tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" \
+102 -9
View File
@@ -8,7 +8,13 @@ Runs on CPU only, not Vulkan: this Docker image has no real GPU, and
Vulkan software emulation (Mesa lavapipe) gives wrong colors for ML
inference workloads.
Usage: upscale_batch.py <model.onnx> <in-dir> <out-dir> <scale>
Resolves <model-name> to $UPSCALE_MODELS_DIR/<model-name>.onnx and picks
single-pass or tiled inference from that model's own declared ONNX input
shape - a model with a fixed height/width (e.g. 128x128) needs tiling for
any image bigger than that; one with symbolic dimensions runs the whole
image through in one pass, whatever its size.
Usage: upscale_batch.py <model-name> <in-dir> <out-dir> <scale>
"""
import sys
import os
@@ -16,31 +22,118 @@ import os
import numpy as np
import onnxruntime as ort
from PIL import Image
from scipy import ndimage
def extend_opaque_color(rgb, alpha, threshold=128):
"""Replaces every below-threshold-alpha pixel's RGB with its nearest
opaque neighbor's, so the model (RGB-only, see run_tile()) sees a
smooth color field instead of a hard edge against flat black -
otherwise its convolutions blend genuine edge colors with that black
background, leaving dark fringing on the final (correctly re-masked)
output."""
transparent = np.asarray(alpha) < threshold
if not transparent.any() or transparent.all():
return rgb
_, indices = ndimage.distance_transform_edt(transparent, return_indices=True)
arr = np.asarray(rgb)
filled = arr[indices[0], indices[1]]
return Image.fromarray(np.where(transparent[..., None], filled, arr))
def run_tile(session, input_name, tile_rgb):
"""Runs one tile (already at the model's required size) and returns its
upscaled RGB as a uint8 HxWx3 array - handles both the with-batch-dim
([1,3,H,W]) and without ([3,H,W]) output conventions different ONNX
exports use."""
arr = tile_rgb.astype(np.float32) / 255.0
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
out = session.run(None, {input_name: arr})[0]
if out.ndim == 4:
out = out[0]
out = np.clip(out, 0.0, 1.0) * 255.0
return np.transpose(out, (1, 2, 0)).astype(np.uint8)
def upscale_dynamic(session, input_name, rgb_image):
return Image.fromarray(run_tile(session, input_name, np.asarray(rgb_image)))
def upscale_tiled(session, input_name, rgb_image, tile_size, native_scale):
"""Splits an image larger than the model's fixed input size into
overlapping tile_size x tile_size tiles (edge-padding any tile that
would otherwise run past the image bounds), runs each individually, and
stitches the results back into one canvas - later tiles simply
overwrite the small overlap region of earlier ones, which is invisible
at sprite scale."""
src = np.asarray(rgb_image)
h, w = src.shape[:2]
canvas = np.zeros((h * native_scale, w * native_scale, 3), dtype=np.uint8)
overlap = 16
stride = tile_size - overlap
y = 0
while True:
ty = min(y, max(0, h - tile_size))
x = 0
while True:
tx = min(x, max(0, w - tile_size))
tile = src[ty:ty + tile_size, tx:tx + tile_size]
th, tw = tile.shape[:2]
if th < tile_size or tw < tile_size:
tile = np.pad(tile, ((0, tile_size - th), (0, tile_size - tw), (0, 0)), mode="edge")
out = run_tile(session, input_name, tile)[: th * native_scale, : tw * native_scale]
oy, ox = ty * native_scale, tx * native_scale
canvas[oy:oy + th * native_scale, ox:ox + tw * native_scale] = out
if tx + tile_size >= w:
break
x += stride
if ty + tile_size >= h:
break
y += stride
return Image.fromarray(canvas)
def main():
model_path, in_dir, out_dir, scale_arg = sys.argv[1:5]
model_name, in_dir, out_dir, scale_arg = sys.argv[1:5]
scale = int(scale_arg)
models_dir = os.environ["UPSCALE_MODELS_DIR"]
model_path = os.path.join(models_dir, f"{model_name}.onnx")
if not os.path.isfile(model_path):
available = sorted(f[:-5] for f in os.listdir(models_dir) if f.endswith(".onnx"))
sys.exit(f"unknown upscale model {model_name!r} - available: {', '.join(available)}")
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
input_name = session.get_inputs()[0].name
input_info = session.get_inputs()[0]
input_name = input_info.name
# A fixed (integer) height/width means this model only ever accepts
# that exact input size - a dynamic one (reported as a symbolic name
# such as "height") accepts any size in one pass. For a fixed-size
# model, its own declared output shape gives the native scale factor
# directly, with no need to run it just to find out.
fixed_size = input_info.shape[2] if isinstance(input_info.shape[2], int) else None
native_scale = session.get_outputs()[0].shape[-1] // fixed_size if fixed_size else None
names = sorted(n for n in os.listdir(in_dir) if n.lower().endswith(".png"))
for name in names:
img = Image.open(os.path.join(in_dir, name)).convert("RGBA")
rgb = img.convert("RGB")
alpha = img.split()[3]
rgb = extend_opaque_color(rgb, alpha)
# The model has no concept of transparency (3-channel RGB in/out) -
# run it on the flattened RGB only, and scale the alpha mask
# separately with a plain resize. Alpha edges don't need AI
# enhancement, just to end up the right size.
arr = np.asarray(rgb, dtype=np.float32) / 255.0
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
output = session.run(None, {input_name: arr})[0]
output = np.clip(output[0], 0.0, 1.0) * 255.0
output = np.transpose(output, (1, 2, 0)).astype(np.uint8)
upscaled_rgb = Image.fromarray(output, "RGB")
if fixed_size is not None and (img.width > fixed_size or img.height > fixed_size):
upscaled_rgb = upscale_tiled(session, input_name, rgb, fixed_size, native_scale)
elif fixed_size is not None:
padded = np.pad(np.asarray(rgb), ((0, fixed_size - img.height), (0, fixed_size - img.width), (0, 0)), mode="edge")
out = run_tile(session, input_name, padded)
upscaled_rgb = Image.fromarray(out[: img.height * native_scale, : img.width * native_scale])
else:
upscaled_rgb = upscale_dynamic(session, input_name, rgb)
target_size = (img.width * scale, img.height * scale)
if upscaled_rgb.size != target_size: