Add reproducible sprite and boss geometry cleanup tools

This commit is contained in:
raptorreplica committed 2026-10-18 10:40:39 +11:00
1 parent 747faf267b
commit 9e105e3ca5
4 files changed
+744

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
"""Source-registered geometry cleanup preserving generated boss RGB relief.
This produces explicitly DERIVED artwork, never alters a Source or parent PNG,
and does not approve the result. Source controls alpha, gap-fill material and
explicit support-patch tone; existing generated body colors/detail are retained.
"""
from __future__ import annotations
import argparse
import hashlib
import io
import json
from pathlib import Path
import numpy as np
from PIL import Image, ImageFilter, __version__ as PILLOW_VERSION
import hd_frame_policy
METHOD = "derived-generated-boss-source-geometry-rgb-preserved-v1"
def sha(data):
return hashlib.sha256(data).hexdigest()
def checked_input(binding, base):
if type(binding) is not 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 sha(data) != binding["sha256"]:
raise ValueError(f"input hash mismatch: {path}")
return path.resolve(), data
def bounds(value, width, height, label):
if (type(value) is not list or len(value) != 4
or any(type(n) is not int for n in value)):
raise ValueError(f"{label} needs four integer bounds")
l, t, r, b = value
if not (0 <= l < r <= width and 0 <= t < b <= height):
raise ValueError(f"{label} exceeds its canvas")
return l, t, r, b
def signed_generated_detail(image, rectangle, size):
crop = image.crop(tuple(rectangle)).resize(size, Image.Resampling.LANCZOS)
rgb = np.asarray(crop)[:, :, :3].astype(np.float64)
blur = np.asarray(crop.convert("RGB").filter(ImageFilter.GaussianBlur(1.2)))
detail = (rgb - blur).mean(axis=2)
return detail - detail.mean()
def render(recipe, base):
allowed = {"format_version", "label", "asset", "source", "palette", "parent_raw",
"logical_canvas", "origin", "output_scale", "alignment", "patches", "gap_fill"}
if (type(recipe) is not dict or set(recipe) - allowed
or type(recipe.get("format_version")) is not int or recipe["format_version"] != 1):
raise ValueError("unknown recipe fields/version")
asset = recipe["asset"]
if (type(asset) is not str or not asset or len(asset) > 8
or any(c not in "0123456789abcdef" for c in asset)):
raise ValueError("asset needs lowercase hexadecimal identity")
source_path, source_data = checked_input(recipe["source"], base)
raw_path, raw_data = checked_input(recipe["parent_raw"], base)
palette_path, palette_data = checked_input(recipe["palette"], base)
width, height, indices = hd_frame_policy._indices(source_data)
if (type(recipe["logical_canvas"]) is not list or type(recipe["origin"]) is not list
or any(type(n) is not int for n in recipe["logical_canvas"] + recipe["origin"])
or recipe["logical_canvas"] != [width, height] or recipe["origin"] != [0, 0]):
raise ValueError("Source canvas and top-left origin must remain exact")
scale = recipe["output_scale"]
if type(scale) is not int or not 4 <= scale <= 16:
raise ValueError("output_scale needs integer4..16")
if width * height * scale * scale > 4_194_304:
raise ValueError("derived image exceeds runtime pixel ceiling")
if len(palette_data) != 768 or len(raw_data) > 32 * 1024 * 1024:
raise ValueError("invalid palette/raw byte limit")
palette = np.minimum(np.frombuffer(palette_data, dtype=np.uint8), 63)
palette = ((palette << 2) | (palette >> 4)).reshape(256, 3)
source = np.asarray(indices, dtype=np.uint16).reshape(height, width)
source_alpha = np.repeat(np.repeat(source < 256, scale, axis=0), scale, axis=1)
with Image.open(io.BytesIO(raw_data)) as opened:
if opened.format != "PNG" or opened.width * opened.height > 4_194_304:
raise ValueError("parent must be bounded PNG")
raw = opened.convert("RGBA")
alignment = recipe["alignment"]
if type(alignment) is not dict or set(alignment) != {"raw_painted_bounds", "source_painted_bounds"}:
raise ValueError("alignment needs exactly raw/source painted bounds")
rl, rt, rr, rb = bounds(alignment["raw_painted_bounds"], *raw.size, "raw bounds")
sl, st, sr, sb = bounds(alignment["source_painted_bounds"], width, height, "Source bounds")
ys, xs = np.nonzero(source < 256)
actual_source_bounds = [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1]
if actual_source_bounds != [sl, st, sr, sb]:
raise ValueError("declared Source bounds differ from decoded Source")
ax, ay = (rr - rl) / ((sr - sl) * scale), (rb - rt) / ((sb - st) * scale)
affine = [ax, 0, rl - ax * sl * scale, 0, ay, rt - ay * st * scale]
registered = np.array(raw.transform((width * scale, height * scale), Image.Transform.AFFINE,
affine, Image.Resampling.BICUBIC))
rgba = registered.copy()
trusted = registered[:, :, 3] >= 128
fill = recipe["gap_fill"]
if type(fill) is not dict or set(fill) != {"generated_donor", "detail_gain"}:
raise ValueError("gap_fill needs generated donor and detail gain")
donor = bounds(fill["generated_donor"], *raw.size, "gap donor")
gain = fill["detail_gain"]
if type(gain) not in (int, float) or not 0 <= gain <= 1:
raise ValueError("gap detail_gain needs0..1")
gap_detail = signed_generated_detail(raw, donor, (scale, scale))
gap_cells = []
gap_pixel_count = 0
for y, x in np.argwhere(source < 256):
y, x = int(y), int(x)
ys, xs = slice(y * scale, (y + 1) * scale), slice(x * scale, (x + 1) * scale)
missing = ~trusted[ys, xs]
if not missing.any():
continue
target = palette[source[y, x]].astype(np.float64)
limit = min(16.0, max(1.0, float(target.mean()) * 0.25))
texture = np.clip(gap_detail * gain, -limit, limit)
pixels = np.clip(np.rint(target + texture[:, :, None]), 0, 255).astype(np.uint8)
rgba[ys, xs, :3][missing] = pixels[missing]
gap_cells.append([x, y])
gap_pixel_count += int(missing.sum())
patched = np.zeros_like(source_alpha)
patch_records = []
patches = recipe.get("patches", [])
if type(patches) is not list or len(patches) > 32:
raise ValueError("too many support patches")
for patch in patches:
if type(patch) is not dict or set(patch) != {"name", "target", "generated_donor", "detail_gain"}:
raise ValueError("support patch needs name/target/generated_donor/detail_gain")
l, t, r, b = bounds(patch["target"], width, height, "patch target")
donor = bounds(patch["generated_donor"], *raw.size, "patch donor")
gain = patch["detail_gain"]
if type(gain) not in (int, float) or not 0 <= gain <= 1:
raise ValueError("patch detail_gain needs0..1")
detail = signed_generated_detail(raw, donor, ((r - l) * scale, (b - t) * scale))
cells = []
for y in range(t, b):
for x in range(l, r):
index = int(source[y, x])
if not 0 <= index <= 31:
continue # This option repairs Source steel/backing, never arbitrary recoloring.
ys, xs = slice(y * scale, (y + 1) * scale), slice(x * scale, (x + 1) * scale)
if patched[ys, xs].any():
raise ValueError("overlapping support patches")
texture = detail[(y - t) * scale:(y - t + 1) * scale,
(x - l) * scale:(x - l + 1) * scale]
texture = (texture - texture.mean()) * gain
target = palette[index].astype(np.float64)
limit = min(24.0, max(1.0, float(target.mean()) * 0.35))
pixels = np.clip(np.rint(target + np.clip(texture, -limit, limit)[:, :, None]), 0, 255)
rgba[ys, xs, :3] = pixels.astype(np.uint8)
patched[ys, xs] = True
cells.append([x, y])
patch_records.append(dict(name=patch["name"], target=patch["target"], cells=cells,
generated_donor=patch["generated_donor"], detail_gain=gain))
# Logical occupancy is explicit Source geometry, independent of generated material.
rgba[:, :, 3] = np.where(source_alpha, 255, 0)
rgba[~source_alpha, :3] = 0
direct = source_alpha & trusted & ~patched
if not direct.any() or not np.array_equal(rgba[direct, :3], registered[direct, :3]):
raise AssertionError("generated body RGB was not preserved")
direct_fraction = float(direct.sum() / source_alpha.sum())
if direct_fraction < 0.5:
raise ValueError("most generated body is missing; refusing Source-fill relabel")
cell_detail = 0
direct_detail = 0
for y, x in np.argwhere(source < 256):
pixels = rgba[y*scale:(y+1)*scale, x*scale:(x+1)*scale, :3]
cell_detail += int(np.any(pixels != pixels[0, 0], axis=2).sum())
selected = pixels[direct[y*scale:(y+1)*scale, x*scale:(x+1)*scale]]
if len(selected):
direct_detail += int(np.any(selected != selected[0], axis=1).sum())
if direct_detail == 0:
raise ValueError("no internal detail remains; refusing an Original NN relabel")
report = dict(method=METHOD, output_class="DERIVED_SCRIPTED_GEOMETRY_CLEANUP_NOT_UNTOUCHED_GENERATION",
source=source_path.as_posix(), parent_raw=raw_path.as_posix(), palette=palette_path.as_posix(),
logical_canvas=[width, height], origin=[0, 0], output_scale=scale,
full_canvas_affine_output_to_raw=affine, Source_painted_bounds=actual_source_bounds,
Source_painted_cells=int((source < 256).sum()), output_painted_pixels=int(source_alpha.sum()),
direct_generated_RGB_pixels=int(direct.sum()),
direct_generated_fraction_of_painted=direct_fraction,
gap_filled_pixels=gap_pixel_count, gap_filled_cells=gap_cells,
patches=patch_records, internally_nonconstant_pixels=cell_detail,
direct_generated_internally_nonconstant_pixels=direct_detail,
Source_decoder_sha256=sha(Path(hd_frame_policy.__file__).read_bytes()),
numpy_version=np.__version__, pillow_version=PILLOW_VERSION)
return rgba, report
def run_recipe(recipe_path, output):
recipe_path, output = Path(recipe_path), Path(output)
if output.exists():
raise ValueError("output exists; do not overwrite old outputs")
recipe_bytes = recipe_path.read_bytes()
recipe = json.loads(recipe_bytes)
rgba, report = render(recipe, recipe_path.resolve().parent)
buffer = io.BytesIO()
Image.fromarray(rgba).save(buffer, format="PNG", compress_level=9)
png = buffer.getvalue()
name = f"{recipe['asset']}-derived.png"
report["execution"] = dict(status="success", method=METHOD,
recipe_sha256=sha(recipe_bytes), tool_sha256=sha(Path(__file__).read_bytes()),
source_sha256={recipe["asset"]: recipe["source"]["sha256"]},
parent_raw_sha256=[recipe["parent_raw"]["sha256"]],
output_sha256={recipe["asset"]: sha(png)})
report["output"] = dict(file=name, bytes=len(png), RGBA_sha256=sha(rgba.tobytes()))
output.mkdir(parents=True)
(output / name).write_bytes(png)
(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(execution=report["execution"], direct_generated_fraction=report["direct_generated_fraction_of_painted"])))
if __name__ == "__main__":
main()
+246
View File
@@ -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()
+121
View File
@@ -0,0 +1,121 @@
"""Small synthetic geometry/lineage checks; no game or generated artwork."""
import copy
import hashlib
import json
from pathlib import Path
import struct
import tempfile
import unittest
import numpy as np
from PIL import Image
import hd_boss_cleanup as cleanup
class BossCleanup(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.cells = [[256, 10, 11, 256], [12, 13, 10, 12],
[256, 10, 256, 256], [10, 256, 256, 11]]
source = bytearray(struct.pack("<5i", 0, 0, 0, 4, 4))
for y, row in enumerate(self.cells):
for x, value in enumerate(row):
if value < 256:
source += struct.pack("<4i", x, y, y*4+x, 1) + bytes([value])
source += struct.pack("<4i", 0, 0, -1, 0)
(self.root / "Source").write_bytes(source)
palette = bytearray(768)
for i in range(10, 14):
palette[i*3:i*3+3] = bytes([25, 25, 25])
(self.root / "palette").write_bytes(palette)
self.raw = np.zeros((16, 16, 4), dtype=np.uint8)
for y in range(16):
for x in range(16):
self.raw[y, x] = [170 + ((x+y) % 3)*20, 30 + (x % 2)*30, 45 + (y % 3)*20, 255]
self.save_raw()
bind = lambda name: dict(path=name, sha256=cleanup.sha((self.root/name).read_bytes()))
self.recipe = dict(format_version=1, asset="200a2", label="synthetic only",
source=bind("Source"), palette=bind("palette"), parent_raw=bind("raw.png"),
logical_canvas=[4, 4], origin=[0, 0], output_scale=4,
alignment=dict(raw_painted_bounds=[0, 0, 16, 16], source_painted_bounds=[0, 0, 4, 4]),
patches=[], gap_fill=dict(generated_donor=[0, 0, 5, 5], detail_gain=0.6))
def save_raw(self):
Image.fromarray(self.raw).save(self.root / "raw.png")
def test_exact_source_outline_feet_and_generated_body_rgb(self):
pixels, report = cleanup.render(self.recipe, self.root)
expected = np.repeat(np.repeat(np.array(self.cells) < 256, 4, axis=0), 4, axis=1)
self.assertTrue(np.array_equal(pixels[:, :, 3] == 255, expected))
self.assertTrue(np.array_equal(pixels[expected, :3], self.raw[expected, :3]))
self.assertTrue(np.all(pixels[~expected] == 0))
self.assertEqual(np.nonzero(pixels[-1, :, 3])[0].tolist(), list(range(4)) + list(range(12, 16)))
self.assertEqual(report["direct_generated_fraction_of_painted"], 1)
self.assertGreater(report["internally_nonconstant_pixels"], 0)
self.assertFalse(np.all(pixels[expected, 0] == 101), "must preserve generated body, not Source NN")
def test_source_gap_is_opaque_and_explicit_generated_texture_patch_is_local(self):
self.raw[4:8, 4:8, 3] = 0
self.save_raw()
self.recipe["parent_raw"]["sha256"] = cleanup.sha((self.root/"raw.png").read_bytes())
self.recipe["patches"] = [dict(name="steel support", target=[1, 0, 2, 1],
generated_donor=[0, 0, 5, 5], detail_gain=0.5)]
pixels, report = cleanup.render(self.recipe, self.root)
self.assertEqual(report["gap_filled_pixels"], 16)
self.assertIn([1, 1], report["gap_filled_cells"])
self.assertTrue(np.all(pixels[4:8, 4:8, 3] == 255))
self.assertLess(float(abs(pixels[4:8, 4:8, :3].mean() - 101)), 5)
self.assertEqual(report["patches"][0]["cells"], [[1, 0]])
self.assertTrue(np.array_equal(pixels[4:8, 8:12, :3], self.raw[4:8, 8:12, :3]))
self.assertTrue(np.all(pixels[:4, :4] == 0), "Source-empty neighbor stays empty")
def test_input_identity_geometry_and_existing_output_fail_closed(self):
for mutation in [lambda r: r["source"].update(sha256="0"*64),
lambda r: r.update(format_version=True),
lambda r: r.update(logical_canvas=[5, 4]),
lambda r: r.update(origin=[False, 0]),
lambda r: r.update(origin=[1, 0]),
lambda r: r["alignment"].update(source_painted_bounds=[0, 1, 4, 4]),
lambda r: r["gap_fill"].update(generated_donor=[0, 0, 17, 4])]:
with self.subTest(mutation=mutation):
bad = copy.deepcopy(self.recipe)
mutation(bad)
with self.assertRaises(ValueError):
cleanup.render(bad, self.root)
recipe = self.root/"recipe.json"
recipe.write_text(json.dumps(self.recipe))
with self.assertRaises(ValueError):
cleanup.run_recipe(recipe, self.root)
def test_missing_generated_body_is_rejected_instead_of_source_fill_relabel(self):
self.raw[:, :, 3] = 0
self.raw[0:4, 4:8, 3] = 255
self.save_raw()
self.recipe["parent_raw"]["sha256"] = cleanup.sha((self.root/"raw.png").read_bytes())
with self.assertRaisesRegex(ValueError, "most generated body is missing"):
cleanup.render(self.recipe, self.root)
def test_repeatable_derived_output_and_complete_execution_binding(self):
recipe = self.root/"recipe.json"
recipe.write_text(json.dumps(self.recipe, sort_keys=True))
before = {n: (self.root/n).read_bytes() for n in ["Source", "palette", "raw.png"]}
reports = [cleanup.run_recipe(recipe, self.root/f"derived-{i}") for i in range(2)]
images = [(self.root/f"derived-{i}/200a2-derived.png").read_bytes() for i in range(2)]
self.assertEqual(images[0], images[1])
self.assertEqual(reports[0], reports[1])
execution = reports[0]["execution"]
self.assertEqual(execution["status"], "success")
self.assertEqual(execution["method"], cleanup.METHOD)
self.assertEqual(execution["recipe_sha256"], hashlib.sha256(recipe.read_bytes()).hexdigest())
self.assertEqual(execution["tool_sha256"], hashlib.sha256(Path(cleanup.__file__).read_bytes()).hexdigest())
self.assertEqual(execution["source_sha256"], {"200a2": self.recipe["source"]["sha256"]})
self.assertEqual(execution["parent_raw_sha256"], [self.recipe["parent_raw"]["sha256"]])
self.assertEqual(execution["output_sha256"], {"200a2": hashlib.sha256(images[0]).hexdigest()})
self.assertEqual(before, {n: (self.root/n).read_bytes() for n in before})
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
"""Synthetic proofs for derived alpha/material/static-animation cleanup."""
import copy
import io
import json
import struct
import tempfile
import unittest
from pathlib import Path
import numpy as np
from PIL import Image
import hd_frame_policy
from hd_sprite_cleanup import digest, palette_rgb, render_family, run_recipe, viewport_numbers
class SpriteCleanupTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
palette = bytearray(768)
for index, rgb in {1: [3, 3, 3], 2: [20, 10, 6], 3: [40, 30, 20], 4: [0, 0, 12]}.items():
palette[index*3:index*3+3] = bytes(rgb)
self.palette = self.put("palette", bytes(palette))
def put(self, name, data):
path = self.root / name
path.write_bytes(data)
return {"path": str(path), "sha256": digest(data)}
def source(self, name, values):
values = np.array(values)
height, width = values.shape
data = bytearray(struct.pack("<iiiii", 0, 0, 0, width, height))
for y in range(height):
for x in range(width):
if values[y, x] != 256:
data += struct.pack("<iiii", x, y, y*width+x, 1)
data.append(int(values[y, x]))
data += struct.pack("<iiii", 0, 0, -1, 0)
return self.put(name, bytes(data))
def frame(self, asset, source, seed=7):
rgb = np.random.default_rng(seed).integers(20, 230, (32, 32, 4), dtype=np.uint8)
rgb[:, :, 3] = 255
buffer = io.BytesIO()
Image.fromarray(rgb).save(buffer, format="PNG")
return dict(asset=asset, source=source, raw_png=self.put(f"raw{seed}.png", buffer.getvalue()),
viewport=[0, 0, 32, 32], origin=[0, 0])
def recipe(self, frames):
return dict(format_version=1, palette=self.palette, output_scale=8, frames=frames)
def test_alpha_is_exact_source_geometry_not_generated_opaque_background(self):
frame = self.frame("a", self.source("a.pic", [[1, 256], [2, 3]]))
images, report = render_family(self.recipe([frame]), self.root)
expected = np.repeat(np.repeat(np.array([[255, 0], [255, 255]], dtype=np.uint8), 8, 0), 8, 1)
np.testing.assert_array_equal(images[0][:, :, 3], expected)
self.assertTrue(np.all(images[0][:8, 8:] == 0))
self.assertGreater(report["frames"][0]["nonzero_generated_detail_pixels"], 0)
def test_static_backing_exact_despite_different_generated_texture_and_animated_cell_survives(self):
frames = [self.frame("a", self.source("a.pic", [[1, 2], [256, 3]]), 7),
self.frame("b", self.source("b.pic", [[1, 4], [256, 3]]), 8)]
images, report = render_family(self.recipe(frames), self.root)
self.assertEqual(report["Source_identical_shared_cells"], 2)
np.testing.assert_array_equal(images[0][:8, :8], images[1][:8, :8])
np.testing.assert_array_equal(images[0][8:, 8:], images[1][8:, 8:])
self.assertFalse(np.array_equal(images[0][:8, 8:, :3], images[1][:8, 8:, :3]))
palette = palette_rgb((self.root / "palette").read_bytes())
for image, value in zip(images, [2, 4]):
self.assertLess(np.max(np.abs(image[:8, 8:, :3].mean(axis=(0, 1))-palette[value])), 1.0)
def test_deterministic_pngs_and_RGBA_receipts_without_input_mutation(self):
recipe = self.recipe([self.frame("a", self.source("a.pic", [[1, 2], [256, 3]]))])
path = self.root / "recipe.json"
path.write_text(json.dumps(recipe))
before = {p: p.read_bytes() for p in self.root.iterdir() if p.is_file()}
first = run_recipe(path, self.root / "first")
second = run_recipe(path, self.root / "second")
self.assertEqual((self.root / "first/a-derived.png").read_bytes(), (self.root / "second/a-derived.png").read_bytes())
self.assertEqual(first, second)
pixels = np.asarray(Image.open(self.root / "first/a-derived.png"))
self.assertEqual(first["frames"][0]["output"]["RGBA_sha256"], digest(pixels.tobytes()))
self.assertEqual(first["output_class"], "DERIVED_SCRIPTED_CLEANUP_NOT_UNTOUCHED_GENERATION")
self.assertEqual(first["decoder_module"]["sha256"], digest(Path(hd_frame_policy.__file__).read_bytes()))
execution = first["execution"]
self.assertEqual(execution["status"], "success")
self.assertEqual(execution["output_sha256"]["a"], digest((self.root / "first/a-derived.png").read_bytes()))
self.assertEqual(execution["source_sha256"], {"a": recipe["frames"][0]["source"]["sha256"]})
self.assertEqual(execution["parent_raw_sha256"], [recipe["frames"][0]["raw_png"]["sha256"]])
for file, blob in before.items():
self.assertEqual(file.read_bytes(), blob)
with self.assertRaisesRegex(ValueError, "already exists"):
run_recipe(path, self.root / "first")
def test_hash_mismatch_stops_before_output_write(self):
frame = self.frame("a", self.source("a.pic", [[1, 2], [256, 3]]))
frame["raw_png"]["sha256"] = "0" * 64
path = self.root / "recipe.json"
path.write_text(json.dumps(self.recipe([frame])))
with self.assertRaisesRegex(ValueError, "hash mismatch"):
run_recipe(path, self.root / "output")
self.assertFalse((self.root / "output").exists())
def test_fractional_viewport_and_bounds(self):
self.assertEqual(str(viewport_numbers(["1/2", 0, "63/2", 32], [32, 32])[0]), "1/2")
for viewport in [[0, 0, 0, 2], [-1, 0, 2, 2], [0, 0, 33, 32]]:
with self.assertRaisesRegex(ValueError, "viewport"):
viewport_numbers(viewport, [32, 32])
def test_invalid_scale_unknown_fields_and_mismatched_canvases(self):
frame = self.frame("a", self.source("a.pic", [[1, 2], [256, 3]]))
recipe = self.recipe([frame])
for value in [3, 17, True]:
broken = dict(recipe, output_scale=value)
with self.assertRaisesRegex(ValueError, "output_scale"):
render_family(broken, self.root)
with self.assertRaisesRegex(ValueError, "unknown cleanup"):
render_family(dict(recipe, guessed_mask=[]), self.root)
other = self.frame("b", self.source("b.pic", [[1, 2, 3]]), 8)
with self.assertRaisesRegex(ValueError, "consistent logical"):
render_family(self.recipe([frame, other]), self.root)
def test_donor_only_moves_texture_while_source_geometry_and_color_remain_authority(self):
frame = self.frame("a", self.source("a.pic", [[1, 2], [256, 3]]))
frame["detail_donors"] = [dict(target=[0, 0], source=[1, 0])]
images, report = render_family(self.recipe([frame]), self.root)
self.assertEqual(report["frames"][0]["detail_donor_cells"], 1)
self.assertLess(np.max(np.abs(images[0][:8, :8, :3].mean(axis=(0, 1)) - [12, 12, 12])), 1)
broken = copy.deepcopy(frame)
broken["detail_donors"] = [dict(target=[0, 1], source=[1, 0])]
with self.assertRaisesRegex(ValueError, "painted Original"):
render_family(self.recipe([broken]), self.root)
def test_constant_raw_cannot_be_relabelled_as_new_HD(self):
frame = self.frame("a", self.source("a.pic", [[1, 2], [256, 3]]))
image = Image.new("RGBA", (32, 32), (100, 100, 100, 255))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
frame["raw_png"] = self.put("constant.png", buffer.getvalue())
with self.assertRaisesRegex(ValueError, "no generated interior detail"):
render_family(self.recipe([frame]), self.root)
if __name__ == "__main__":
unittest.main()