Files
RustRaptor/tools/hd_sprite_cleanup.py

247 lines
12 KiB
Python

"""Deterministic, explicitly DERIVED sprite cleanup; never modifies input artwork.
Recipe v1: palette {path,sha256}, output_scale (4..16), and frames containing
asset, source {path,sha256}, raw_png {path,sha256}, viewport [l,t,r,b], origin.
Viewport values may be exact rational strings. Source alpha/material values
define geometry; bounded high-frequency generated detail supplies the texture.
"""
from __future__ import annotations
import argparse
import hashlib
import io
import json
from fractions import Fraction
from pathlib import Path
import numpy as np
from PIL import Image, ImageFilter, __version__ as PILLOW_VERSION
import hd_frame_policy
from hd_frame_policy import _indices
METHOD = "derived-source-alpha-material-shared-generated-detail-v1"
def digest(data):
return hashlib.sha256(data).hexdigest()
def checked_input(binding, base):
if not isinstance(binding, dict) or set(binding) != {"path", "sha256"}:
raise ValueError("input needs exactly path/sha256")
path = Path(binding["path"])
if not path.is_absolute():
path = base / path
data = path.read_bytes()
if digest(data) != binding["sha256"]:
raise ValueError(f"input hash mismatch: {path}")
return path.resolve(), data
def palette_rgb(data):
if len(data) < 768:
raise ValueError("truncated Original palette")
values = np.minimum(np.frombuffer(data[:768], dtype=np.uint8), 63)
return ((values << 2) | (values >> 4)).reshape(256, 3)
def viewport_numbers(value, image_size):
if not isinstance(value, list) or len(value) != 4:
raise ValueError("viewport needs four exact bounds")
values = [Fraction(v) for v in value]
left, top, right, bottom = values
if not (0 <= left < right <= image_size[0]
and 0 <= top < bottom <= image_size[1]):
raise ValueError("viewport exceeds raw canvas")
return values
def _texture(high, target, strength):
"""Remove generated broad lighting/bevels, retain bounded fine variation."""
if not np.any(target):
return np.zeros_like(high)
limit = min(8.0, max(1.0, float(target.mean()) * 0.20))
# A zero/full Source channel cannot carry symmetric detail. Keeping it exact
# avoids adding a colored tint when clipping the signed texture residual.
limits = np.minimum(limit, np.minimum(target, 255 - target))
detail = high.astype(np.float64) * strength
detail -= detail.mean(axis=(0, 1), keepdims=True)
detail = np.clip(detail, -limits, limits)
detail -= detail.mean(axis=(0, 1), keepdims=True)
return np.clip(detail, -limits, limits)
def render_family(recipe, base):
"""Return separate RGBA arrays and measurements; performs no writes."""
if recipe.get("format_version") != 1:
raise ValueError("unsupported cleanup recipe version")
if set(recipe) - {"format_version", "palette", "output_scale", "detail_strength",
"highpass_radius_pixels", "require_generated_detail", "frames", "label"}:
raise ValueError("unknown cleanup recipe fields")
scale = recipe.get("output_scale", 8)
if type(scale) is not int or not 4 <= scale <= 16:
raise ValueError("output_scale must be integer4..16")
strength = recipe.get("detail_strength", 1.0)
if type(strength) not in (int, float) or not 0 <= strength <= 2:
raise ValueError("detail_strength must be0..2")
radius = recipe.get("highpass_radius_pixels", 1.2)
if type(radius) not in (int, float) or not 0 < radius <= 4:
raise ValueError("highpass radius must be positive and<=4")
frames = recipe.get("frames")
if not isinstance(frames, list) or not 1 <= len(frames) <= 64:
raise ValueError("cleanup needs1..64 complete family frames")
palette_path, palette_data = checked_input(recipe["palette"], base)
palette = palette_rgb(palette_data)
ids = set()
loaded = []
shape = None
inputs = {str(palette_path): digest(palette_data)}
for frame in frames:
if set(frame) - {"asset", "source", "raw_png", "viewport", "origin", "detail_donors"}:
raise ValueError("unknown cleanup frame fields")
asset = frame["asset"]
if not isinstance(asset, str) or not asset or any(c not in "0123456789abcdef" for c in asset) or asset in ids:
raise ValueError("asset IDs must be unique lowercase hexadecimal")
ids.add(asset)
source_path, source_data = checked_input(frame["source"], base)
width, height, cells = _indices(source_data)
if shape is not None and shape != (height, width):
raise ValueError("complete family must have consistent logical canvas")
shape = (height, width)
if width * height * scale * scale > 4_194_304:
raise ValueError("derived frame exceeds runtime PNG pixel ceiling")
origin = frame.get("origin", [0, 0])
if not isinstance(origin, list) or len(origin) != 2 or any(type(v) is not int for v in origin):
raise ValueError("origin needs two integer coordinates")
source = np.asarray(cells, dtype=np.uint16).reshape(height, width)
raw_path, raw_data = checked_input(frame["raw_png"], base)
if len(raw_data) > 32 * 1024 * 1024:
raise ValueError("raw PNG exceeds32MiB")
with Image.open(io.BytesIO(raw_data)) as image:
if image.format != "PNG" or image.width * image.height > 4_194_304:
raise ValueError("raw must be a bounded PNG")
viewport = viewport_numbers(frame["viewport"], image.size)
sampled = np.asarray(image.convert("RGBA").transform(
(width * scale, height * scale), Image.Transform.EXTENT,
tuple(float(v) for v in viewport), Image.Resampling.BICUBIC)).copy()
rgb = sampled[:, :, :3]
blurred = np.asarray(Image.fromarray(rgb).filter(ImageFilter.GaussianBlur(radius)))
high = rgb.astype(np.float64) - blurred.astype(np.float64)
trusted = sampled[:, :, 3] >= 128
high[~trusted] = 0
donors = frame.get("detail_donors", [])
donor_targets = set()
original_high = high.copy()
for donor in donors:
if not isinstance(donor, dict) or set(donor) != {"target", "source"}:
raise ValueError("detail donor needs exactly target/source cells")
positions = []
for key in ["target", "source"]:
cell = donor[key]
if not isinstance(cell, list) or len(cell) != 2 or any(type(v) is not int for v in cell):
raise ValueError("detail donor cell needs two integers")
x, y = cell
if not (0 <= x < width and 0 <= y < height) or source[y, x] == 256:
raise ValueError("detail donor must use painted Original cells")
positions.append((x, y))
(tx, ty), (sx, sy) = positions
if (tx, ty) in donor_targets:
raise ValueError("duplicate detail donor target")
donor_targets.add((tx, ty))
high[ty*scale:(ty+1)*scale, tx*scale:(tx+1)*scale] = original_high[sy*scale:(sy+1)*scale, sx*scale:(sx+1)*scale]
loaded.append(dict(frame=frame, source=source, high=high, trusted=trusted,
viewport=[str(v) for v in viewport], donor_count=len(donors)))
inputs[str(source_path)] = digest(source_data)
inputs[str(raw_path)] = digest(raw_data)
sources = np.stack([item["source"] for item in loaded])
common = np.all(sources == sources[0], axis=0) & (sources[0] < 256)
shared = np.median(np.stack([item["high"] for item in loaded]), axis=0)
outputs = []
reports = []
for item in loaded:
source = item["source"]
height, width = source.shape
rgba = np.zeros((height * scale, width * scale, 4), dtype=np.uint8)
fallback_cells = []
detail_pixels = 0
max_mean_error = 0.0
for y, x in np.argwhere(source < 256):
ys = slice(y * scale, (y + 1) * scale)
xs = slice(x * scale, (x + 1) * scale)
target = palette[source[y, x]].astype(np.float64)
texture = shared[ys, xs] if common[y, x] else item["high"][ys, xs]
detail = _texture(texture, target, strength)
pixels = np.clip(np.rint(target + detail), 0, 255).astype(np.uint8)
rgba[ys, xs, :3] = pixels
rgba[ys, xs, 3] = 255
detail_pixels += int(np.any(pixels != target, axis=2).sum())
max_mean_error = max(max_mean_error, float(np.max(np.abs(pixels.mean(axis=(0, 1)) - target))))
if not np.any(item["trusted"][ys, xs]):
fallback_cells.append([int(x), int(y)])
if recipe.get("require_generated_detail", True) and detail_pixels == 0:
raise ValueError("no generated interior detail survived; refusing HD relabel")
outputs.append(rgba)
reports.append(dict(asset=item["frame"]["asset"], logical_width=width,
logical_height=height, origin=item["frame"].get("origin", [0, 0]),
source_sha256=item["frame"]["source"]["sha256"],
sampled_raw_viewport=item["viewport"], detail_donor_cells=item["donor_count"],
Source_painted_cells=int((source < 256).sum()),
Source_supported_alpha_pixels=int((rgba[:, :, 3] == 255).sum()),
nonzero_generated_detail_pixels=detail_pixels,
maximum_cell_channel_mean_error=max_mean_error,
Source_fill_cells_without_visible_raw=fallback_cells))
for output in outputs[1:]:
for y, x in np.argwhere(common):
if not np.array_equal(output[y*scale:(y+1)*scale, x*scale:(x+1)*scale], outputs[0][y*scale:(y+1)*scale, x*scale:(x+1)*scale]):
raise AssertionError("shared Source backing is not identical")
return outputs, dict(method=METHOD, output_class="DERIVED_SCRIPTED_CLEANUP_NOT_UNTOUCHED_GENERATION",
input_hashes=inputs, output_scale=scale, Source_identical_shared_cells=int(common.sum()),
detail_strength=strength, highpass_radius_pixels=radius, frames=reports,
numpy_version=np.__version__, pillow_version=PILLOW_VERSION)
def run_recipe(recipe_path, output):
recipe_path, output = Path(recipe_path), Path(output)
if output.exists():
raise ValueError("output directory already exists; inputs and old outputs are never overwritten")
recipe_bytes = recipe_path.read_bytes()
recipe = json.loads(recipe_bytes)
arrays, report = render_family(recipe, recipe_path.resolve().parent)
payloads = []
for pixels, frame in zip(arrays, recipe["frames"]):
buffer = io.BytesIO()
Image.fromarray(pixels).save(buffer, format="PNG", compress_level=9)
payloads.append((f"{frame['asset']}-derived.png", buffer.getvalue()))
output.mkdir(parents=True)
for index, ((name, data), frame) in enumerate(zip(payloads, report["frames"])):
(output / name).write_bytes(data)
frame["output"] = dict(file=name, sha256=digest(data), bytes=len(data),
RGBA_sha256=digest(arrays[index].tobytes()))
report["recipe_sha256"] = digest(recipe_bytes)
report["tool_sha256"] = digest(Path(__file__).read_bytes())
report["decoder_module"] = dict(path=str(Path(hd_frame_policy.__file__).resolve()),
sha256=digest(Path(hd_frame_policy.__file__).read_bytes()))
report["execution"] = dict(
status="success", method=METHOD, recipe_sha256=report["recipe_sha256"],
tool_sha256=report["tool_sha256"],
source_sha256={frame["asset"]: frame["source"]["sha256"] for frame in recipe["frames"]},
parent_raw_sha256=sorted({frame["raw_png"]["sha256"] for frame in recipe["frames"]}),
output_sha256={frame["asset"]: frame["output"]["sha256"] for frame in report["frames"]})
(output / "derived-provenance.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("recipe", type=Path)
parser.add_argument("output_new_directory", type=Path)
args = parser.parse_args()
report = run_recipe(args.recipe, args.output_new_directory)
print(json.dumps(dict(method=report["method"], derived_frames=len(report["frames"]),
shared_Source_cells=report["Source_identical_shared_cells"])))
if __name__ == "__main__":
main()