接口开始5%
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""按 schema v3 清单确定性生成不透明长背景与 G01 暖金边框。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def _resolve_inside(workspace: Path, relative_path: str) -> Path:
|
||||
"""解析仓库相对路径,并在任何读写发生前拒绝目录逃逸。"""
|
||||
absolute = (workspace / relative_path).resolve()
|
||||
try:
|
||||
absolute.relative_to(workspace.resolve())
|
||||
except ValueError as error:
|
||||
raise ValueError(f"path escapes workspace: {relative_path}") from error
|
||||
return absolute
|
||||
|
||||
|
||||
def build_opaque_resize(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
|
||||
"""把同宽高比母版按 LANCZOS 直接缩放为无透明通道的 RGB 正式图。"""
|
||||
image = source.convert("RGB")
|
||||
if image.size != output_size:
|
||||
image = image.resize(output_size, Image.Resampling.LANCZOS)
|
||||
return image
|
||||
|
||||
|
||||
def build_opaque_cover_crop(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
|
||||
"""等比 cover 后从中心裁切;算法与旧五模块构建器的可见像素完全一致。"""
|
||||
width, height = output_size
|
||||
image = source.convert("RGB")
|
||||
scale = max(width / image.width, height / image.height)
|
||||
resized = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = max(0, (resized.width - width) // 2)
|
||||
top = max(0, (resized.height - height) // 2)
|
||||
return resized.crop((left, top, left + width, top + height))
|
||||
|
||||
|
||||
def extract_warm_gold_frame(source: Image.Image, config: dict[str, Any]) -> Image.Image:
|
||||
"""复刻旧 Sharp 暖金边框阈值,同时清零全透明像素的隐藏 RGB。"""
|
||||
image = source.convert("RGBA")
|
||||
width, height = image.size
|
||||
border_band = int(config["borderBand"])
|
||||
output = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
result: list[tuple[int, int, int, int]] = []
|
||||
|
||||
for index, (red, green, blue, source_alpha) in enumerate(image.get_flattened_data()):
|
||||
x = index % width
|
||||
y = index // width
|
||||
distance_to_edge = min(x, y, width - 1 - x, height - 1 - y)
|
||||
warm_gold = (
|
||||
red > green > blue
|
||||
and red - green >= int(config["redGreenMin"])
|
||||
and green - blue >= int(config["greenBlueMin"])
|
||||
and red - blue >= int(config["redBlueMin"])
|
||||
and red < int(config["redMaxExclusive"])
|
||||
and blue < int(config["blueMaxExclusive"])
|
||||
)
|
||||
if distance_to_edge >= border_band or not warm_gold:
|
||||
result.append((0, 0, 0, 0))
|
||||
continue
|
||||
|
||||
edge_alpha = max(
|
||||
0,
|
||||
min(
|
||||
255,
|
||||
(red - blue - int(config["alphaOffset"])) * int(config["alphaScale"]),
|
||||
),
|
||||
)
|
||||
alpha = min(source_alpha, edge_alpha)
|
||||
result.append((red, green, blue, alpha) if alpha else (0, 0, 0, 0))
|
||||
|
||||
output.putdata(result)
|
||||
return output
|
||||
|
||||
|
||||
def save_png(image: Image.Image, path: Path) -> None:
|
||||
"""以固定压缩参数和标准 sRGB chunk 保存,保证同输入得到同字节。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info)
|
||||
|
||||
|
||||
def build_manifest(manifest_path: Path, workspace: Path) -> None:
|
||||
"""执行已经由 Node 严格校验的清单,并再次核对真实母版像素尺寸。"""
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
source_path = _resolve_inside(workspace, asset["source"])
|
||||
output_path = _resolve_inside(workspace, asset["output"])
|
||||
expected_source = asset["sourcePixels"]
|
||||
output_pixels = asset["outputPixels"]
|
||||
output_size = (int(output_pixels["width"]), int(output_pixels["height"]))
|
||||
|
||||
with Image.open(source_path) as source:
|
||||
expected_size = (int(expected_source["width"]), int(expected_source["height"]))
|
||||
if source.size != expected_size:
|
||||
raise ValueError(
|
||||
f"{asset['id']} source expected {expected_size[0]}x{expected_size[1]}, "
|
||||
f"got {source.width}x{source.height}"
|
||||
)
|
||||
|
||||
mode = asset["processing"]["mode"]
|
||||
if mode == "opaque-resize":
|
||||
output = build_opaque_resize(source, output_size)
|
||||
elif mode == "opaque-cover-crop":
|
||||
output = build_opaque_cover_crop(source, output_size)
|
||||
elif mode == "warm-gold-frame-extract":
|
||||
output = extract_warm_gold_frame(source, asset["processing"])
|
||||
else:
|
||||
raise ValueError(f"unsupported raster processing mode: {mode}")
|
||||
|
||||
if output.size != output_size:
|
||||
raise ValueError(f"{asset['id']} produced unexpected output size: {output.size}")
|
||||
save_png(output, output_path)
|
||||
print(f"BUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
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