视觉审核完成30%
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""Build exact A01 scroll-skin outputs from chroma-backed visual masters."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def remove_chroma_background(
|
||||
image: Image.Image,
|
||||
key: tuple[int, int, int],
|
||||
tolerance: int,
|
||||
feather: int = 72,
|
||||
) -> Image.Image:
|
||||
source = image.convert("RGBA")
|
||||
cleaned = Image.new("RGBA", source.size, (0, 0, 0, 0))
|
||||
result = []
|
||||
key_red, key_green, key_blue = key
|
||||
|
||||
for red, green, blue, source_alpha in source.get_flattened_data():
|
||||
distance = math.sqrt(
|
||||
(red - key_red) ** 2 + (green - key_green) ** 2 + (blue - key_blue) ** 2
|
||||
)
|
||||
alpha_fraction = max(0.0, min(1.0, (distance - tolerance) / feather))
|
||||
alpha_fraction *= source_alpha / 255
|
||||
if alpha_fraction <= 0:
|
||||
result.append((0, 0, 0, 0))
|
||||
continue
|
||||
|
||||
recovered_red = round((red - (1 - alpha_fraction) * key_red) / alpha_fraction)
|
||||
recovered_green = round((green - (1 - alpha_fraction) * key_green) / alpha_fraction)
|
||||
recovered_blue = round((blue - (1 - alpha_fraction) * key_blue) / alpha_fraction)
|
||||
result.append(
|
||||
(
|
||||
max(0, min(255, recovered_red)),
|
||||
max(0, min(255, recovered_green)),
|
||||
max(0, min(255, recovered_blue)),
|
||||
round(alpha_fraction * 255),
|
||||
)
|
||||
)
|
||||
|
||||
cleaned.putdata(result)
|
||||
return cleaned
|
||||
|
||||
|
||||
def trim_transparent(image: Image.Image) -> Image.Image:
|
||||
alpha = image.getchannel("A")
|
||||
bounds = alpha.getbbox()
|
||||
if bounds is None:
|
||||
raise ValueError("chroma removal left no visible artwork")
|
||||
return image.crop(bounds)
|
||||
|
||||
|
||||
def stretch_safe_center(
|
||||
image: Image.Image,
|
||||
*,
|
||||
output_size: tuple[int, int],
|
||||
cap_width: int,
|
||||
padding: int,
|
||||
) -> Image.Image:
|
||||
output_width, output_height = output_size
|
||||
inner_width = output_width - padding * 2
|
||||
inner_height = output_height - padding * 2
|
||||
if inner_width <= 0 or inner_height <= 0:
|
||||
raise ValueError("transparent padding leaves no drawable area")
|
||||
if cap_width <= 0 or cap_width * 2 >= image.width:
|
||||
raise ValueError(f"invalid capWidth {cap_width} for artwork width {image.width}")
|
||||
|
||||
target_cap_width = max(1, round(cap_width * inner_height / image.height))
|
||||
center_width = inner_width - target_cap_width * 2
|
||||
if center_width <= 0:
|
||||
raise ValueError("scaled caps leave no room for the stretch-safe center")
|
||||
|
||||
left = image.crop((0, 0, cap_width, image.height))
|
||||
center = image.crop((cap_width, 0, image.width - cap_width, image.height))
|
||||
right = image.crop((image.width - cap_width, 0, image.width, image.height))
|
||||
resampling = Image.Resampling.LANCZOS
|
||||
left = left.resize((target_cap_width, inner_height), resampling)
|
||||
center = center.resize((center_width, inner_height), resampling)
|
||||
right = right.resize((target_cap_width, inner_height), resampling)
|
||||
|
||||
output = Image.new("RGBA", output_size, (0, 0, 0, 0))
|
||||
output.alpha_composite(left, (padding, padding))
|
||||
output.alpha_composite(center, (padding + target_cap_width, padding))
|
||||
output.alpha_composite(right, (output_width - padding - target_cap_width, padding))
|
||||
return output
|
||||
|
||||
|
||||
def sanitize_output_edges(image: Image.Image) -> Image.Image:
|
||||
cleaned = []
|
||||
for red, green, blue, alpha in image.convert("RGBA").get_flattened_data():
|
||||
chroma_residue = alpha > 0 and green > 120 and green - max(red, blue) > 80
|
||||
light_fringe = 0 < alpha < 255 and red > 235 and green > 235 and blue > 235
|
||||
if alpha == 0 or chroma_residue or light_fringe:
|
||||
cleaned.append((0, 0, 0, 0))
|
||||
else:
|
||||
cleaned.append((red, green, blue, alpha))
|
||||
output = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
output.putdata(cleaned)
|
||||
return output
|
||||
|
||||
|
||||
def reduce_png_palette(image: Image.Image, colors: int = 192) -> Image.Image:
|
||||
alpha = image.getchannel("A")
|
||||
rgb = image.convert("RGB").quantize(
|
||||
colors=colors,
|
||||
method=Image.Quantize.MEDIANCUT,
|
||||
dither=Image.Dither.NONE,
|
||||
).convert("RGB")
|
||||
output = rgb.convert("RGBA")
|
||||
output.putalpha(alpha)
|
||||
return sanitize_output_edges(output)
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if len(value) != 7 or not value.startswith("#"):
|
||||
raise ValueError(f"invalid RGB hex color: {value}")
|
||||
return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def build_asset(asset: dict, workspace: Path) -> None:
|
||||
processing = asset["processing"]
|
||||
alpha = asset["alpha"]
|
||||
pixels = asset["outputPixels"]
|
||||
source_path = workspace / asset["source"]
|
||||
output_path = workspace / asset["output"]
|
||||
|
||||
with Image.open(source_path) as source:
|
||||
cleaned = remove_chroma_background(
|
||||
source,
|
||||
parse_hex_color(processing["keyColor"]),
|
||||
int(processing["keyTolerance"]),
|
||||
)
|
||||
artwork = trim_transparent(cleaned)
|
||||
output = stretch_safe_center(
|
||||
artwork,
|
||||
output_size=(pixels["width"], pixels["height"]),
|
||||
cap_width=int(processing["capWidth"]),
|
||||
padding=int(alpha["transparentOuterPadding"]),
|
||||
)
|
||||
palette_colors = int(processing.get("paletteColors", 192))
|
||||
output = sanitize_output_edges(output)
|
||||
if processing.get("indexedPng"):
|
||||
output = output.quantize(
|
||||
colors=palette_colors,
|
||||
method=Image.Quantize.FASTOCTREE,
|
||||
dither=Image.Dither.NONE,
|
||||
)
|
||||
else:
|
||||
output = reduce_png_palette(output, colors=palette_colors)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
output.save(output_path, format="PNG", optimize=True, pnginfo=png_info)
|
||||
print(f"BUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
def build_manifest(manifest_path: Path, workspace: Path) -> None:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
build_asset(asset, workspace)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
build_manifest(args.manifest.resolve(), args.workspace.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user