Files
RustRaptor/tools/hd_boss_cleanup.py

230 lines
12 KiB
Python

"""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()