extract_music_template_assets.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. from __future__ import annotations
  2. import json
  3. from io import BytesIO
  4. from dataclasses import dataclass
  5. from pathlib import Path
  6. from PIL import Image, ImageFilter
  7. from pptx import Presentation
  8. from pptx.enum.shapes import MSO_SHAPE_TYPE
  9. from pptx.util import Inches
  10. ROOT = Path(__file__).resolve().parents[1]
  11. REVIEW_ROOT = ROOT / "artifacts" / "music-template-extraction" / "review"
  12. OUTPUT_ROOT = ROOT / "public" / "ai-courseware-templates" / "extracted"
  13. @dataclass(frozen=True)
  14. class Selection:
  15. candidate: str
  16. template_id: str
  17. name: str
  18. scene: str
  19. description: str
  20. layout_family: str
  21. palette: tuple[str, str, str, str, str]
  22. # 十种通用视觉版式各自对应原课件抽样页序号,不与歌词、谱例等课程语义绑定。
  23. page_sample_indexes: tuple[int, ...]
  24. # 课程内容图片必须删除;保留人物、花草、纹样等视觉资产。
  25. remove_picture_indexes: dict[int, tuple[int, ...]]
  26. # 少量矢量装饰无法由 python-pptx 直接绘制,从原预览中精确裁回。
  27. keep_raster_indexes: dict[int, tuple[int, ...]]
  28. page_backgrounds: tuple[str, ...]
  29. # 新模板默认仅保留大面积底图和边缘装饰,防止课程内容图片进入母版。
  30. auto_clean: bool = False
  31. SELECTIONS = (
  32. Selection(
  33. "candidate-01", "ethnic-flower", "民族花园", "民族歌曲与律动",
  34. "民族人物、花草边饰与低年级互动", "culture",
  35. ("#DF7A2B", "#4C9A71", "#E45667", "#F2C84B", "#FFFDF6"),
  36. (0, 2, 2, 2, 3, 3, 3, 2, 2, 4),
  37. {6: (1, 2, 3, 13), 16: (1, 2)},
  38. {1: (5,), 16: (11, 13), 22: (5,)},
  39. ("#FFFFFF",) * 10,
  40. ),
  41. Selection(
  42. "candidate-24", "opera-fan", "水墨脸谱", "戏曲鉴赏",
  43. "水墨扇面、戏曲人物与脸谱知识", "culture",
  44. ("#A93A35", "#354C47", "#C7883C", "#D5B26B", "#F6F0E7"),
  45. (0, 1, 3, 1, 2, 1, 2, 3, 3, 3),
  46. {14: (6,), 22: (3, 4, 5)},
  47. {},
  48. ("#F6F0E7",) * 10,
  49. ),
  50. Selection(
  51. "candidate-43", "dark-lute", "月夜琵琶", "中学音乐欣赏",
  52. "深蓝月夜、东方器乐与沉浸聆听", "academic",
  53. ("#B79052", "#315D71", "#D07A50", "#D3B270", "#F7F6F1"),
  54. (0, 1, 1, 2, 1, 3, 3, 1, 2, 4),
  55. {9: (2, 10, 12)},
  56. {1: (3,), 9: (11,), 36: (2,)},
  57. ("#06364B", "#FFFFFF", "#FFFFFF", "#06364B", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#06364B", "#06364B"),
  58. ),
  59. Selection(
  60. "candidate-05", "spring-outing", "春日郊游", "低年级自然与歌唱",
  61. "清新绿意、城市郊游与儿童音乐活动", "story",
  62. ("#5EA45A", "#F0B34F", "#E6674D", "#73C9C2", "#F7FFF6"),
  63. (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {6: (2, 3, 6, 8), 19: (0,)}, {}, ("#F7FFF6",) * 10, True,
  64. ),
  65. Selection(
  66. "candidate-11", "bird-garden", "花鸟乐园", "自然观察与旋律",
  67. "花鸟插画、柔和色块与小学音乐课堂", "story",
  68. ("#D84C55", "#318E91", "#F2B24A", "#8CC6B7", "#FFFBE8"),
  69. (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {5: (2,), 10: (10,), 16: (5, 6)}, {}, ("#FFFBE8",) * 10, True,
  70. ),
  71. Selection(
  72. "candidate-15", "rainy-cat", "雨天萌猫", "低龄儿歌与节奏",
  73. "萌猫、彩旗与轻松活泼的低年级课堂", "story",
  74. ("#E98991", "#54A99B", "#F3C665", "#F7A4A8", "#FFFDF8"),
  75. (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {6: (1, 2, 3), 12: (4,), 17: (4, 7)}, {}, ("#FFFDF8",) * 10, True,
  76. ),
  77. Selection(
  78. "candidate-20", "ink-jiangnan", "江南水墨", "古诗词与中国音乐",
  79. "水墨山水、留白与传统文化鉴赏", "culture",
  80. ("#A62F2B", "#314D42", "#C88A42", "#D6BE88", "#FAF8F2"),
  81. (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {8: (17,), 15: (12, 14), 30: (0, 1)}, {}, ("#FAF8F2",) * 10, True,
  82. ),
  83. Selection(
  84. "candidate-30", "rhythm-train", "节奏列车", "节拍与课堂活动",
  85. "彩色列车、积木色块与可视化节奏训练", "activity",
  86. ("#F4B400", "#18A55A", "#5DA9E9", "#F47F52", "#FFFDF5"),
  87. (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {}, {1: (2,), 11: (1,)}, ("#FFFDF5",) * 10, True,
  88. ),
  89. Selection(
  90. "candidate-31", "sunset-drum", "丝路鼓歌", "民族器乐与表演",
  91. "暖色山河、民族纹样与鼓乐表演", "culture",
  92. ("#D9672F", "#274E6A", "#E9B85B", "#8BA35B", "#FFF8E9"),
  93. (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {20: (4,)}, {}, ("#FFF8E9",) * 10, True,
  94. ),
  95. Selection(
  96. "candidate-33", "ballroom-silhouette", "黑白舞会", "中高年级舞曲鉴赏",
  97. "黑白剪影、钢琴元素与简洁舞台感", "academic",
  98. ("#1F1F1F", "#B08B5C", "#8E1F2B", "#D9C9A5", "#FAFAF8"),
  99. (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {11: tuple(range(8, 18)) + (20, 22, 23, 24, 25, 26, 30)}, {}, ("#FAFAF8",) * 10, True,
  100. ),
  101. Selection(
  102. "candidate-39", "color-theory", "彩彩乐理", "基础乐理与识谱",
  103. "明亮几何、键盘与低龄乐理启蒙", "education",
  104. ("#29B9A5", "#D91D63", "#F3B33D", "#6F63B5", "#FFFDF7"),
  105. (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {6: (17,), 16: (11,)}, {22: (0, 1)}, ("#FFFDF7",) * 10, True,
  106. ),
  107. )
  108. def paste_clipped(canvas: Image.Image, layer: Image.Image, left: int, top: int) -> None:
  109. right, bottom = left + layer.width, top + layer.height
  110. clip_left, clip_top = max(0, left), max(0, top)
  111. clip_right, clip_bottom = min(canvas.width, right), min(canvas.height, bottom)
  112. if clip_right <= clip_left or clip_bottom <= clip_top:
  113. return
  114. crop = layer.crop((clip_left - left, clip_top - top, clip_right - left, clip_bottom - top))
  115. canvas.alpha_composite(crop, (clip_left, clip_top))
  116. def picture_layer(shape, slide_width: int, slide_height: int) -> tuple[Image.Image, int, int]:
  117. with Image.open(BytesIO(shape.image.blob)) as raw:
  118. image = raw.convert("RGBA")
  119. crop = (
  120. round(image.width * shape.crop_left),
  121. round(image.height * shape.crop_top),
  122. round(image.width * (1 - shape.crop_right)),
  123. round(image.height * (1 - shape.crop_bottom)),
  124. )
  125. image = image.crop(crop)
  126. left = round(shape.left / slide_width * 1600)
  127. top = round(shape.top / slide_height * 900)
  128. width = max(1, round(shape.width / slide_width * 1600))
  129. height = max(1, round(shape.height / slide_height * 900))
  130. return image.resize((width, height), Image.Resampling.LANCZOS), left, top
  131. def preview_crop(shape, preview: Image.Image, slide_width: int, slide_height: int) -> tuple[Image.Image, int, int]:
  132. left = round(shape.left / slide_width * preview.width)
  133. top = round(shape.top / slide_height * preview.height)
  134. right = round((shape.left + shape.width) / slide_width * preview.width)
  135. bottom = round((shape.top + shape.height) / slide_height * preview.height)
  136. crop = preview.crop((max(0, left), max(0, top), min(preview.width, right), min(preview.height, bottom)))
  137. target_left = round(shape.left / slide_width * 1600)
  138. target_top = round(shape.top / slide_height * 900)
  139. target_width = max(1, round(shape.width / slide_width * 1600))
  140. target_height = max(1, round(shape.height / slide_height * 900))
  141. return crop.resize((target_width, target_height), Image.Resampling.LANCZOS).convert("RGBA"), target_left, target_top
  142. def should_keep_picture(shape, slide_width: int, slide_height: int) -> bool:
  143. left = shape.left / slide_width
  144. top = shape.top / slide_height
  145. right = (shape.left + shape.width) / slide_width
  146. bottom = (shape.top + shape.height) / slide_height
  147. area = (shape.width / slide_width) * (shape.height / slide_height)
  148. if area >= 0.55:
  149. return True
  150. edge_anchored = left <= 0.18 or right >= 0.82 or top <= 0.14 or bottom >= 0.86
  151. return edge_anchored and area <= 0.24
  152. def render_clean_slide(deck: Presentation, slide_number: int, preview_path: Path, destination: Path, remove_pictures: set[int], keep_raster: set[int], background: str, auto_clean: bool = False) -> None:
  153. slide = deck.slides[slide_number - 1]
  154. canvas = Image.new("RGBA", (1600, 900), background)
  155. with Image.open(preview_path) as source_preview:
  156. preview = source_preview.convert("RGBA")
  157. for shape_index, shape in enumerate(slide.shapes):
  158. if shape_index in remove_pictures or shape.shape_type == MSO_SHAPE_TYPE.MEDIA:
  159. continue
  160. if getattr(shape, "has_text_frame", False) and shape.text.strip():
  161. continue
  162. if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
  163. if auto_clean and shape_index not in keep_raster and not should_keep_picture(shape, deck.slide_width, deck.slide_height):
  164. continue
  165. layer, left, top = picture_layer(shape, deck.slide_width, deck.slide_height)
  166. paste_clipped(canvas, layer, left, top)
  167. elif shape_index in keep_raster:
  168. layer, left, top = preview_crop(shape, preview, deck.slide_width, deck.slide_height)
  169. paste_clipped(canvas, layer, left, top)
  170. destination.parent.mkdir(parents=True, exist_ok=True)
  171. canvas.convert("RGB").save(destination, quality=94, optimize=True, progressive=True)
  172. def is_visually_blank(path: Path) -> bool:
  173. """过滤只有纯底色或极少噪点、缩略图中等同空白的背景。"""
  174. with Image.open(path) as source:
  175. image = source.convert("RGB").resize((160, 90), Image.Resampling.LANCZOS)
  176. pixels = list(image.getdata())
  177. median = tuple(sorted(pixel[channel] for pixel in pixels)[len(pixels) // 2] for channel in range(3))
  178. decorated = sum(max(abs(pixel[channel] - median[channel]) for channel in range(3)) > 12 for pixel in pixels) / len(pixels)
  179. edge_image = image.convert("L").filter(ImageFilter.FIND_EDGES).crop((2, 2, 158, 88))
  180. edge_pixels = list(edge_image.getdata())
  181. edge_coverage = sum(value > 18 for value in edge_pixels) / len(edge_pixels)
  182. return decorated < 0.003 and edge_coverage < 0.003
  183. def render_role_backgrounds(source: Path, candidate: str, samples: list[str], page_sample_indexes: tuple[int, ...], target_dir: Path, remove_picture_indexes: dict[int, tuple[int, ...]], keep_raster_indexes: dict[int, tuple[int, ...]], page_backgrounds: tuple[str, ...], auto_clean: bool = False) -> tuple[list[Path], list[int]]:
  184. deck = Presentation(source)
  185. slide_numbers = [int(Path(name).stem.split("-")[1]) for name in samples]
  186. role_slide_numbers: list[int] = []
  187. backgrounds: list[Path] = []
  188. for output_index, sample_index in enumerate(page_sample_indexes, start=1):
  189. slide_number = slide_numbers[sample_index]
  190. destination = target_dir / f"background-{output_index:02d}.jpg"
  191. render_clean_slide(
  192. deck,
  193. slide_number,
  194. REVIEW_ROOT / candidate / samples[sample_index],
  195. destination,
  196. set(remove_picture_indexes.get(slide_number, ())),
  197. set(keep_raster_indexes.get(slide_number, ())),
  198. page_backgrounds[output_index - 1],
  199. auto_clean,
  200. )
  201. if is_visually_blank(destination):
  202. destination.unlink()
  203. continue
  204. backgrounds.append(destination)
  205. role_slide_numbers.append(slide_number)
  206. return backgrounds, role_slide_numbers
  207. def make_pptx(backgrounds: list[Path], destination: Path) -> None:
  208. deck = Presentation()
  209. deck.slide_width = Inches(13.333333)
  210. deck.slide_height = Inches(7.5)
  211. while deck.slides:
  212. slide_id = deck.slides._sldIdLst[0]
  213. deck.part.drop_rel(slide_id.rId)
  214. del deck.slides._sldIdLst[0]
  215. layout = deck.slide_layouts[6]
  216. for background in backgrounds:
  217. slide = deck.slides.add_slide(layout)
  218. slide.shapes.add_picture(str(background), 0, 0, deck.slide_width, deck.slide_height)
  219. deck.core_properties.title = "AI 音乐课件模板"
  220. deck.core_properties.subject = "按页面角色提取,已清理原课件文案与媒体内容"
  221. deck.core_properties.author = ""
  222. deck.core_properties.comments = ""
  223. destination.parent.mkdir(parents=True, exist_ok=True)
  224. deck.save(destination)
  225. def main() -> None:
  226. manifest = json.loads((REVIEW_ROOT / "manifest.json").read_text(encoding="utf-8-sig"))
  227. items = {item["id"]: item for item in manifest["items"]}
  228. output: list[dict[str, object]] = []
  229. for selection in SELECTIONS:
  230. item = items[selection.candidate]
  231. target_dir = OUTPUT_ROOT / selection.template_id
  232. target_dir.mkdir(parents=True, exist_ok=True)
  233. for stale_background in target_dir.glob("background-*.jpg"):
  234. stale_background.unlink()
  235. backgrounds, role_slide_numbers = render_role_backgrounds(
  236. Path(item["source"]), selection.candidate, item["samples"], selection.page_sample_indexes, target_dir,
  237. selection.remove_picture_indexes, selection.keep_raster_indexes, selection.page_backgrounds,
  238. selection.auto_clean,
  239. )
  240. if len(backgrounds) < 3:
  241. raise RuntimeError(f"模板 {selection.template_id} 清理空白页后仅剩 {len(backgrounds)} 页,拒绝发布")
  242. template_file = target_dir / f"{selection.template_id}.pptx"
  243. make_pptx(backgrounds, template_file)
  244. output.append({
  245. "id": selection.template_id,
  246. "name": selection.name,
  247. "scene": selection.scene,
  248. "description": selection.description,
  249. "layoutFamily": selection.layout_family,
  250. "sourceName": item["name"],
  251. "sourcePath": item["source"],
  252. "candidate": selection.candidate,
  253. "templateFile": f"/ai-courseware-templates/extracted/{selection.template_id}/{selection.template_id}.pptx",
  254. "backgrounds": [f"/ai-courseware-templates/extracted/{selection.template_id}/{path.name}" for path in backgrounds],
  255. "palette": list(selection.palette),
  256. "roleSourceSlides": role_slide_numbers,
  257. })
  258. OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
  259. (OUTPUT_ROOT / "manifest.json").write_text(json.dumps({"items": output}, ensure_ascii=False, indent=2), encoding="utf-8")
  260. print(f"Extracted {len(output)} object-cleaned role-based templates into {OUTPUT_ROOT}")
  261. if __name__ == "__main__":
  262. main()