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,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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user