|
|
@@ -0,0 +1,103 @@
|
|
|
+"""Lightweight image quality checks for generated game assets."""
|
|
|
+
|
|
|
+from collections import deque
|
|
|
+
|
|
|
+
|
|
|
+def _as_rgba_image(image_or_path):
|
|
|
+ from PIL import Image
|
|
|
+
|
|
|
+ if isinstance(image_or_path, str):
|
|
|
+ return Image.open(image_or_path).convert("RGBA")
|
|
|
+ return image_or_path.convert("RGBA")
|
|
|
+
|
|
|
+
|
|
|
+def alpha_component_report(image_or_path, max_size=320, threshold=24):
|
|
|
+ """Return connected-component stats for non-transparent pixels."""
|
|
|
+ img = _as_rgba_image(image_or_path)
|
|
|
+ if max(img.size) > max_size:
|
|
|
+ scale = max_size / max(img.size)
|
|
|
+ img = img.resize((max(1, int(img.width * scale)), max(1, int(img.height * scale))))
|
|
|
+ alpha = img.getchannel("A")
|
|
|
+ data = bytearray(1 if v > threshold else 0 for v in alpha.getdata())
|
|
|
+ w, h = img.size
|
|
|
+ opaque = sum(data)
|
|
|
+ if opaque == 0:
|
|
|
+ return {
|
|
|
+ "ok": False,
|
|
|
+ "reason": "empty_alpha",
|
|
|
+ "size": [w, h],
|
|
|
+ "opaque": 0,
|
|
|
+ "components": [],
|
|
|
+ "significantComponents": [],
|
|
|
+ "largestShare": 0,
|
|
|
+ }
|
|
|
+
|
|
|
+ visited = bytearray(w * h)
|
|
|
+ components = []
|
|
|
+ for start, is_opaque in enumerate(data):
|
|
|
+ if not is_opaque or visited[start]:
|
|
|
+ continue
|
|
|
+ q = deque([start])
|
|
|
+ visited[start] = 1
|
|
|
+ count = 0
|
|
|
+ min_x = w
|
|
|
+ min_y = h
|
|
|
+ max_x = 0
|
|
|
+ max_y = 0
|
|
|
+ while q:
|
|
|
+ idx = q.popleft()
|
|
|
+ y, x = divmod(idx, w)
|
|
|
+ count += 1
|
|
|
+ min_x = min(min_x, x)
|
|
|
+ min_y = min(min_y, y)
|
|
|
+ max_x = max(max_x, x)
|
|
|
+ max_y = max(max_y, y)
|
|
|
+ for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
|
|
|
+ if nx < 0 or ny < 0 or nx >= w or ny >= h:
|
|
|
+ continue
|
|
|
+ nidx = ny * w + nx
|
|
|
+ if data[nidx] and not visited[nidx]:
|
|
|
+ visited[nidx] = 1
|
|
|
+ q.append(nidx)
|
|
|
+ components.append({
|
|
|
+ "pixels": count,
|
|
|
+ "share": round(count / max(1, opaque), 4),
|
|
|
+ "bbox": [min_x, min_y, max_x + 1, max_y + 1],
|
|
|
+ })
|
|
|
+
|
|
|
+ components.sort(key=lambda c: c["pixels"], reverse=True)
|
|
|
+ significant = [
|
|
|
+ c for c in components
|
|
|
+ if c["pixels"] >= max(60, int(opaque * 0.06))
|
|
|
+ ]
|
|
|
+ return {
|
|
|
+ "ok": True,
|
|
|
+ "size": [w, h],
|
|
|
+ "opaque": opaque,
|
|
|
+ "opaqueRatio": round(opaque / max(1, w * h), 4),
|
|
|
+ "components": components[:12],
|
|
|
+ "significantComponents": significant,
|
|
|
+ "largestShare": round(components[0]["pixels"] / max(1, opaque), 4),
|
|
|
+ "secondShare": round(components[1]["pixels"] / max(1, opaque), 4) if len(components) > 1 else 0,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def boss_preview_quality(image_or_path):
|
|
|
+ """Check whether a boss preview reads as one assembled character."""
|
|
|
+ report = alpha_component_report(image_or_path)
|
|
|
+ if not report.get("ok"):
|
|
|
+ return False, "预览图没有有效 Alpha 主体。", report
|
|
|
+
|
|
|
+ sig_count = len(report["significantComponents"])
|
|
|
+ largest = report["largestShare"]
|
|
|
+ second = report["secondShare"]
|
|
|
+ opaque_ratio = report["opaqueRatio"]
|
|
|
+ if opaque_ratio < 0.035:
|
|
|
+ return False, "关主体量太小,预览图主体不清晰。", report
|
|
|
+ if sig_count >= 3:
|
|
|
+ return False, f"关主被拆成 {sig_count} 个明显分离的大块,无法识别为完整角色。", report
|
|
|
+ if sig_count >= 2 and second >= 0.12:
|
|
|
+ return False, "关主存在明显分离的大部件,像拆件表而不是完整角色。", report
|
|
|
+ if largest < 0.74:
|
|
|
+ return False, "关主最大主体占比过低,整体轮廓不完整。", report
|
|
|
+ return True, "关主预览主体连贯。", report
|