asset_quality.py 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. """Lightweight image quality checks for generated game assets."""
  2. from collections import deque
  3. def _as_rgba_image(image_or_path):
  4. from PIL import Image
  5. if isinstance(image_or_path, str):
  6. return Image.open(image_or_path).convert("RGBA")
  7. return image_or_path.convert("RGBA")
  8. def alpha_component_report(image_or_path, max_size=320, threshold=24):
  9. """Return connected-component stats for non-transparent pixels."""
  10. img = _as_rgba_image(image_or_path)
  11. if max(img.size) > max_size:
  12. scale = max_size / max(img.size)
  13. img = img.resize((max(1, int(img.width * scale)), max(1, int(img.height * scale))))
  14. alpha = img.getchannel("A")
  15. data = bytearray(1 if v > threshold else 0 for v in alpha.getdata())
  16. w, h = img.size
  17. opaque = sum(data)
  18. if opaque == 0:
  19. return {
  20. "ok": False,
  21. "reason": "empty_alpha",
  22. "size": [w, h],
  23. "opaque": 0,
  24. "components": [],
  25. "significantComponents": [],
  26. "largestShare": 0,
  27. }
  28. visited = bytearray(w * h)
  29. components = []
  30. for start, is_opaque in enumerate(data):
  31. if not is_opaque or visited[start]:
  32. continue
  33. q = deque([start])
  34. visited[start] = 1
  35. count = 0
  36. min_x = w
  37. min_y = h
  38. max_x = 0
  39. max_y = 0
  40. while q:
  41. idx = q.popleft()
  42. y, x = divmod(idx, w)
  43. count += 1
  44. min_x = min(min_x, x)
  45. min_y = min(min_y, y)
  46. max_x = max(max_x, x)
  47. max_y = max(max_y, y)
  48. for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
  49. if nx < 0 or ny < 0 or nx >= w or ny >= h:
  50. continue
  51. nidx = ny * w + nx
  52. if data[nidx] and not visited[nidx]:
  53. visited[nidx] = 1
  54. q.append(nidx)
  55. components.append({
  56. "pixels": count,
  57. "share": round(count / max(1, opaque), 4),
  58. "bbox": [min_x, min_y, max_x + 1, max_y + 1],
  59. })
  60. components.sort(key=lambda c: c["pixels"], reverse=True)
  61. significant = [
  62. c for c in components
  63. if c["pixels"] >= max(60, int(opaque * 0.06))
  64. ]
  65. return {
  66. "ok": True,
  67. "size": [w, h],
  68. "opaque": opaque,
  69. "opaqueRatio": round(opaque / max(1, w * h), 4),
  70. "components": components[:12],
  71. "significantComponents": significant,
  72. "largestShare": round(components[0]["pixels"] / max(1, opaque), 4),
  73. "secondShare": round(components[1]["pixels"] / max(1, opaque), 4) if len(components) > 1 else 0,
  74. }
  75. def boss_preview_quality(image_or_path):
  76. """Check whether a boss preview reads as one assembled character."""
  77. report = alpha_component_report(image_or_path)
  78. if not report.get("ok"):
  79. return False, "预览图没有有效 Alpha 主体。", report
  80. sig_count = len(report["significantComponents"])
  81. largest = report["largestShare"]
  82. second = report["secondShare"]
  83. opaque_ratio = report["opaqueRatio"]
  84. if opaque_ratio < 0.035:
  85. return False, "关主体量太小,预览图主体不清晰。", report
  86. if sig_count >= 3:
  87. return False, f"关主被拆成 {sig_count} 个明显分离的大块,无法识别为完整角色。", report
  88. if sig_count >= 2 and second >= 0.12:
  89. return False, "关主存在明显分离的大部件,像拆件表而不是完整角色。", report
  90. if largest < 0.74:
  91. return False, "关主最大主体占比过低,整体轮廓不完整。", report
  92. return True, "关主预览主体连贯。", report