Files
jiapuapp/design-pipeline/scripts/asset_quality.py
T
2026-07-22 17:31:38 +08:00

128 lines
5.0 KiB
Python

"""Deterministic PNG quality checks for generated design assets."""
from pathlib import Path
from typing import Any
from PIL import Image
def _outer_ring_pixels(image: Image.Image, thickness: int):
width, height = image.size
thickness = max(0, min(thickness, width // 2, height // 2))
for y in range(height):
for x in range(width):
if x < thickness or x >= width - thickness or y < thickness or y >= height - thickness:
yield image.getpixel((x, y))
def analyze_asset(path: Path, spec: dict[str, Any], workspace: Path) -> dict[str, Any]:
"""按 schema v3 物理规格分析单张 PNG,并只输出工作区相对路径。"""
workspace = Path(workspace).resolve()
path = Path(path).resolve()
try:
report_path = path.relative_to(workspace).as_posix()
except ValueError as error:
raise ValueError(f"asset path escapes workspace: {path}") from error
errors: list[str] = []
warnings: list[str] = []
metrics: dict[str, int] = {}
with Image.open(path) as opened:
width, height = opened.size
mode = opened.mode
info = dict(opened.info)
has_alpha = "A" in opened.getbands() or "transparency" in opened.info
image = opened.convert("RGBA")
expected = spec["outputPixels"]
if (width, height) != (expected["width"], expected["height"]):
errors.append(
f"dimensions expected {expected['width']}x{expected['height']}, got {width}x{height}"
)
alpha = spec.get("alpha", {})
if alpha.get("required") and not has_alpha:
errors.append(f"alpha-required asset has no alpha channel or transparency table, got {mode}")
padding = int(alpha.get("transparentOuterPadding", 0))
max_alpha = int(alpha.get("cornerMaxAlpha", 255))
corners = (
image.getpixel((0, 0))[3],
image.getpixel((width - 1, 0))[3],
image.getpixel((0, height - 1))[3],
image.getpixel((width - 1, height - 1))[3],
)
corner_violations = sum(1 for value in corners if value > max_alpha)
metrics["cornerAlphaViolations"] = corner_violations
if corner_violations:
errors.append(f"corners contain {corner_violations} pixels above alpha {max_alpha}")
# 不透明长背景没有边缘/透明度扫描需求;避免无意义地把每张 1440×3600 图
# 展开成数百万个 Python 元组。透明资产仍完整执行原有像素级质量合同。
opaque_padding = 0
if padding > 0:
opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha)
metrics["outerPaddingViolations"] = opaque_padding
if opaque_padding:
errors.append(f"outer padding contains {opaque_padding} pixels above alpha {max_alpha}")
edge = spec.get("edge", {})
needs_edge_pixels = any(edge.get(field) for field in (
"forbidChromaResidue",
"forbidLightFringe",
"premultipliedAlphaCheck",
))
pixels = list(image.get_flattened_data()) if needs_edge_pixels else []
chroma_residue = sum(
1
for red, green, blue, pixel_alpha in pixels
if pixel_alpha > 8 and green > 120 and green - max(red, blue) > 80
)
metrics["chromaResiduePixels"] = chroma_residue
if edge.get("forbidChromaResidue") and chroma_residue:
errors.append(f"chroma residue detected in {chroma_residue} visible pixels")
light_fringe = sum(
1
for red, green, blue, pixel_alpha in pixels
if 0 < pixel_alpha < 255 and red > 235 and green > 235 and blue > 235
)
metrics["lightFringePixels"] = light_fringe
if edge.get("forbidLightFringe") and light_fringe:
errors.append(f"light fringe detected in {light_fringe} partially transparent pixels")
transparent_rgb = sum(
1
for red, green, blue, pixel_alpha in pixels
if pixel_alpha == 0 and (red != 0 or green != 0 or blue != 0)
)
metrics["transparentRgbPixels"] = transparent_rgb
if edge.get("premultipliedAlphaCheck") and transparent_rgb:
errors.append(f"transparent RGB detected in {transparent_rgb} fully transparent pixels")
byte_size = path.stat().st_size
max_bytes = int(spec.get("quality", {}).get("maxBytes", 0))
if max_bytes and byte_size > max_bytes:
errors.append(f"maxBytes {max_bytes} exceeded by file size {byte_size}")
expected_color_space = spec.get("quality", {}).get("colorSpace")
if expected_color_space == "sRGB" and not ("srgb" in info or "icc_profile" in info):
errors.append("PNG does not declare an sRGB chunk or ICC profile")
expected_mode = spec.get("processing", {}).get("outputMode")
if expected_mode and mode != expected_mode:
errors.append(f"output mode expected {expected_mode}, got {mode}")
return {
"id": spec.get("id", path.stem),
"path": report_path,
"width": width,
"height": height,
"mode": mode,
"bytes": byte_size,
"errors": errors,
"warnings": warnings,
"metrics": metrics,
}