render_music_template_candidates.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. from __future__ import annotations
  2. import json
  3. import sys
  4. from io import BytesIO
  5. from pathlib import Path
  6. from PIL import Image, ImageDraw, ImageFont
  7. from pptx import Presentation
  8. from pptx.enum.shapes import MSO_SHAPE_TYPE
  9. CANDIDATES_PATH = Path(sys.argv[1] if len(sys.argv) > 1 else "artifacts/music-template-extraction/candidates.json")
  10. OUTPUT_DIR = Path(sys.argv[2] if len(sys.argv) > 2 else "artifacts/music-template-extraction/review")
  11. QUOTAS = {"child": 7, "general": 12, "traditional": 7, "activity": 7, "stage": 7, "theory": 7}
  12. CANVAS = (1600, 900)
  13. FONT_PATH = Path(r"C:\Windows\Fonts\msyh.ttc")
  14. def color_value(color, fallback: str = "FFFFFF") -> str:
  15. try:
  16. rgb = color.rgb
  17. return f"#{rgb}" if rgb else f"#{fallback}"
  18. except (AttributeError, TypeError, ValueError):
  19. return f"#{fallback}"
  20. def selected_candidates(data: dict) -> list[dict]:
  21. selected: list[dict] = []
  22. seen_names: set[str] = set()
  23. seen_signatures: set[str] = set()
  24. for scene, quota in QUOTAS.items():
  25. eligible = sorted(
  26. (
  27. item for item in data["items"]
  28. if item["scene"] == scene and item["score"] >= 78 and 10 <= item["slides"] <= 60
  29. and item["size_mb"] <= 250 and item["median_text"] <= 120
  30. ),
  31. key=lambda item: item["score"],
  32. reverse=True,
  33. )
  34. count = 0
  35. for item in eligible:
  36. if count >= quota:
  37. break
  38. if item["name_key"] in seen_names or item["signature"] in seen_signatures:
  39. continue
  40. seen_names.add(item["name_key"])
  41. seen_signatures.add(item["signature"])
  42. selected.append(item)
  43. count += 1
  44. return selected
  45. def dimensions(shape, slide_width: int, slide_height: int) -> tuple[int, int, int, int]:
  46. left = round(shape.left / slide_width * CANVAS[0])
  47. top = round(shape.top / slide_height * CANVAS[1])
  48. width = max(1, round(shape.width / slide_width * CANVAS[0]))
  49. height = max(1, round(shape.height / slide_height * CANVAS[1]))
  50. return left, top, width, height
  51. def paste_picture(canvas: Image.Image, shape, box: tuple[int, int, int, int]) -> None:
  52. try:
  53. with Image.open(BytesIO(shape.image.blob)) as source:
  54. image = source.convert("RGBA")
  55. crop = (
  56. round(image.width * shape.crop_left),
  57. round(image.height * shape.crop_top),
  58. round(image.width * (1 - shape.crop_right)),
  59. round(image.height * (1 - shape.crop_bottom)),
  60. )
  61. image = image.crop(crop).resize((box[2], box[3]), Image.Resampling.LANCZOS)
  62. canvas.alpha_composite(image, (box[0], box[1]))
  63. except (OSError, ValueError, AttributeError):
  64. return
  65. def font_for(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
  66. paths = [Path(r"C:\Windows\Fonts\msyhbd.ttc") if bold else FONT_PATH, FONT_PATH]
  67. for path in paths:
  68. try:
  69. return ImageFont.truetype(str(path), max(13, min(size, 74)))
  70. except OSError:
  71. continue
  72. return ImageFont.load_default()
  73. def wrap_text(draw: ImageDraw.ImageDraw, text: str, font, width: int, max_lines: int) -> list[str]:
  74. lines: list[str] = []
  75. for paragraph in (text or "").splitlines():
  76. current = ""
  77. for char in paragraph.strip():
  78. candidate = current + char
  79. if current and draw.textbbox((0, 0), candidate, font=font)[2] > width:
  80. lines.append(current)
  81. current = char
  82. if len(lines) >= max_lines:
  83. return lines
  84. else:
  85. current = candidate
  86. if current:
  87. lines.append(current)
  88. if len(lines) >= max_lines:
  89. break
  90. return lines[:max_lines]
  91. def draw_text(canvas: Image.Image, shape, box: tuple[int, int, int, int]) -> None:
  92. text = getattr(shape, "text", "").strip()
  93. if not text:
  94. return
  95. draw = ImageDraw.Draw(canvas)
  96. first_run = None
  97. try:
  98. first_run = shape.text_frame.paragraphs[0].runs[0]
  99. except (AttributeError, IndexError):
  100. pass
  101. point_size = round((first_run.font.size.pt if first_run and first_run.font.size else 24) * 1.55)
  102. bold = bool(first_run and first_run.font.bold)
  103. font = font_for(point_size, bold)
  104. color = color_value(first_run.font.color if first_run else None, "222222")
  105. line_height = max(18, round(point_size * 1.28))
  106. lines = wrap_text(draw, text, font, max(20, box[2] - 18), max(1, box[3] // line_height))
  107. draw.multiline_text((box[0] + 9, box[1] + 6), "\n".join(lines), font=font, fill=color, spacing=round(point_size * 0.22))
  108. def draw_shape(canvas: Image.Image, shape, slide_width: int, slide_height: int) -> None:
  109. box = dimensions(shape, slide_width, slide_height)
  110. if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
  111. paste_picture(canvas, shape, box)
  112. return
  113. if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
  114. for child in shape.shapes:
  115. draw_shape(canvas, child, slide_width, slide_height)
  116. return
  117. if shape.shape_type == MSO_SHAPE_TYPE.MEDIA:
  118. return
  119. draw = ImageDraw.Draw(canvas, "RGBA")
  120. try:
  121. if shape.fill.type is not None:
  122. fill = color_value(shape.fill.fore_color, "FFFFFF")
  123. draw.rounded_rectangle((box[0], box[1], box[0] + box[2], box[1] + box[3]), radius=min(22, box[3] // 5), fill=fill)
  124. except (AttributeError, TypeError, ValueError):
  125. pass
  126. if getattr(shape, "has_text_frame", False):
  127. draw_text(canvas, shape, box)
  128. def render_slide(deck: Presentation, slide_number: int, output: Path) -> None:
  129. slide = deck.slides[slide_number - 1]
  130. background = "#FFFFFF"
  131. try:
  132. background = color_value(slide.background.fill.fore_color)
  133. except (AttributeError, TypeError, ValueError):
  134. pass
  135. canvas = Image.new("RGBA", CANVAS, background)
  136. for shape in slide.shapes:
  137. draw_shape(canvas, shape, deck.slide_width, deck.slide_height)
  138. output.parent.mkdir(parents=True, exist_ok=True)
  139. canvas.convert("RGB").save(output, quality=88, optimize=True)
  140. def main() -> None:
  141. data = json.loads(CANDIDATES_PATH.read_text(encoding="utf-8"))
  142. rendered: list[dict] = []
  143. for index, item in enumerate(selected_candidates(data), start=1):
  144. candidate_id = f"candidate-{index:02d}"
  145. try:
  146. deck = Presentation(item["path"])
  147. count = len(deck.slides)
  148. slide_numbers = sorted({1, max(2, round(count * 0.25)), max(2, round(count * 0.5)), max(2, round(count * 0.75)), count})
  149. samples = []
  150. for slide_number in slide_numbers:
  151. name = f"slide-{slide_number:02d}.jpg"
  152. render_slide(deck, slide_number, OUTPUT_DIR / candidate_id / name)
  153. samples.append(name)
  154. rendered.append({
  155. "id": candidate_id,
  156. "name": item["name"],
  157. "scene": item["scene"],
  158. "score": item["score"],
  159. "source": item["path"],
  160. "samples": samples,
  161. })
  162. print(f"{candidate_id} {item['scene']} {item['name']}", flush=True)
  163. except (OSError, ValueError, KeyError) as error:
  164. print(f"{candidate_id} failed: {error}", flush=True)
  165. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  166. (OUTPUT_DIR / "manifest.json").write_text(json.dumps({"items": rendered}, ensure_ascii=False, indent=2), encoding="utf-8")
  167. print(json.dumps({"rendered": len(rendered), "output": str(OUTPUT_DIR)}, ensure_ascii=False))
  168. if __name__ == "__main__":
  169. main()