diff --git a/tools/hd_boss_cleanup.py b/tools/hd_boss_cleanup.py new file mode 100644 index 00000000..cf361414 --- /dev/null +++ b/tools/hd_boss_cleanup.py @@ -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() diff --git a/tools/hd_sprite_cleanup.py b/tools/hd_sprite_cleanup.py new file mode 100644 index 00000000..a93c7ca4 --- /dev/null +++ b/tools/hd_sprite_cleanup.py @@ -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() diff --git a/tools/test_hd_boss_cleanup.py b/tools/test_hd_boss_cleanup.py new file mode 100644 index 00000000..26c1738a --- /dev/null +++ b/tools/test_hd_boss_cleanup.py @@ -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() diff --git a/tools/test_hd_sprite_cleanup.py b/tools/test_hd_sprite_cleanup.py new file mode 100644 index 00000000..b3c5611b --- /dev/null +++ b/tools/test_hd_sprite_cleanup.py @@ -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("