Add reproducible sprite and boss geometry cleanup tools
This commit is contained in:
1 parent
747faf267b
commit
9e105e3ca5
4 files changed
+744
No files matched your search
@@ -0,0 +1,246 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user