"""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 def boss_part_quality(part_id, image_or_path): """Check an individual rigging part for obvious sheet bleed or bad crops.""" report = alpha_component_report(image_or_path) if not report.get("ok"): return False, "拆件没有有效 Alpha 内容。", report pid = (part_id or "").lower() allow_multi = any(token in pid for token in ( "splash", "burst", "crack", "coin", "spark", "debris", "fragment", "shadow" )) if allow_multi: return True, "特效/碎片类拆件允许多块主体。", report second = report.get("secondShare", 0) sig_count = len(report.get("significantComponents", [])) if sig_count >= 2 and second >= 0.04: return False, f"拆件包含 {sig_count} 个明显分离主体,疑似切进了相邻部件。", report w, h = report.get("size") or [0, 0] small_part = any(token in pid for token in ( "head", "horn", "arm", "leg", "hand", "boot", "weapon", "sword" )) if small_part and max(w, h) >= 620 and min(w, h) >= 420: return False, "拆件尺寸接近完整角色,疑似把主图生成到了单个部件里。", report return True, "拆件主体干净。", report