scan_music_template_library.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import re
  5. import statistics
  6. import sys
  7. import zipfile
  8. from collections import Counter
  9. from pathlib import Path
  10. from xml.etree import ElementTree as ET
  11. SOURCE_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else r"H:\音乐教材PPT")
  12. OUTPUT_PATH = Path(sys.argv[2] if len(sys.argv) > 2 else "artifacts/music-template-extraction/candidates.json")
  13. P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
  14. A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
  15. def xml_root(archive: zipfile.ZipFile, name: str):
  16. try:
  17. return ET.fromstring(archive.read(name))
  18. except (KeyError, ET.ParseError):
  19. return None
  20. def classify_scene(path: Path) -> str:
  21. name = path.stem
  22. if re.search(r"京剧|戏曲|脸谱|古琴|古筝|二胡|琵琶|民乐|民族|非遗|传统|江南|忆江南|茉莉花|采茶|山水|姑苏|十面埋伏|龙舟|赛马|彩云追月|阳关三叠", name):
  23. return "traditional"
  24. if re.search(r"节奏|节拍|律动|游戏|活动|表演|舞蹈|打击乐|鼓|拍手|声势|合奏|创编", name):
  25. return "activity"
  26. if re.search(r"交响|管弦|音乐会|世界|爵士|舞台|歌剧|圆舞曲", name):
  27. return "stage"
  28. if re.search(r"结构|旋律|乐理|和声|调式|音程|项目|探究", name):
  29. return "theory"
  30. if re.search(r"童话|动物|动画|小鸟|小猫|小狗|森林|小屋|儿歌|娃娃|小朋友|儿童|春天|郊游|梦|星星|月亮", name):
  31. return "child"
  32. return "general"
  33. def normalized_name(path: Path) -> str:
  34. value = re.sub(r"[\s_\-—]+", "", path.stem.lower())
  35. value = re.sub(r"(?:课件|新版|新教材|音乐|第?\d+课时|\(\d+\)|(\d+)|\d+)$", "", value)
  36. return value[:80]
  37. def inspect(path: Path) -> dict:
  38. result = {
  39. "path": str(path),
  40. "name": path.stem,
  41. "scene": classify_scene(path),
  42. "size_mb": round(path.stat().st_size / 1024 / 1024, 2),
  43. "valid": False,
  44. "score": 0,
  45. }
  46. try:
  47. with zipfile.ZipFile(path) as archive:
  48. names = archive.namelist()
  49. presentation = xml_root(archive, "ppt/presentation.xml")
  50. if presentation is None:
  51. result["error"] = "missing-presentation"
  52. return result
  53. size_node = presentation.find(f"{{{P_NS}}}sldSz")
  54. cx = int(size_node.attrib.get("cx", 0)) if size_node is not None else 0
  55. cy = int(size_node.attrib.get("cy", 0)) if size_node is not None else 0
  56. ratio = cx / cy if cy else 0
  57. slide_names = sorted(
  58. (name for name in names if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)),
  59. key=lambda name: int(re.search(r"\d+", Path(name).stem).group()),
  60. )
  61. master_count = sum(bool(re.fullmatch(r"ppt/slideMasters/slideMaster\d+\.xml", name)) for name in names)
  62. layout_count = sum(bool(re.fullmatch(r"ppt/slideLayouts/slideLayout\d+\.xml", name)) for name in names)
  63. image_entries = [entry for entry in archive.infolist() if entry.filename.startswith("ppt/media/") and not re.search(r"\.(mp3|mp4|wav|wma|m4a|mov|avi)$", entry.filename, re.I)]
  64. media_entries = [entry for entry in archive.infolist() if entry.filename.startswith("ppt/media/") and re.search(r"\.(mp3|mp4|wav|wma|m4a|mov|avi)$", entry.filename, re.I)]
  65. text_counts = []
  66. shape_counts = []
  67. picture_counts = []
  68. fill_colors = Counter()
  69. signatures = []
  70. for slide_name in slide_names:
  71. root = xml_root(archive, slide_name)
  72. if root is None:
  73. continue
  74. text = "".join(node.text or "" for node in root.iter(f"{{{A_NS}}}t"))
  75. text_counts.append(len(text.strip()))
  76. shapes = len(list(root.iter(f"{{{P_NS}}}sp")))
  77. pictures = len(list(root.iter(f"{{{P_NS}}}pic")))
  78. shape_counts.append(shapes)
  79. picture_counts.append(pictures)
  80. colors = [node.attrib.get("val", "").upper() for node in root.iter(f"{{{A_NS}}}srgbClr") if node.attrib.get("val")]
  81. fill_colors.update(colors)
  82. signatures.append(f"{shapes}:{pictures}:{min(len(text), 300)}:{','.join(colors[:8])}")
  83. slide_count = len(slide_names)
  84. median_text = statistics.median(text_counts) if text_counts else 0
  85. median_shapes = statistics.median(shape_counts) if shape_counts else 0
  86. median_pictures = statistics.median(picture_counts) if picture_counts else 0
  87. unique_layout_ratio = len(set(signatures)) / max(1, slide_count)
  88. common_colors = [color for color, _ in fill_colors.most_common(8)]
  89. visual_signature = hashlib.sha1((str(round(ratio, 3)) + "|" + "|".join(common_colors) + "|" + str(layout_count)).encode()).hexdigest()[:16]
  90. score = 0.0
  91. score += 18 if 1.74 <= ratio <= 1.79 else -18
  92. score += min(16, slide_count * 0.7) if slide_count >= 8 else -12
  93. score += min(12, layout_count * 1.2)
  94. score += min(8, master_count * 2)
  95. score += min(12, median_shapes * 0.9)
  96. score += min(10, median_pictures * 2.2)
  97. score += min(10, unique_layout_ratio * 16)
  98. score += 7 if 3 <= len(common_colors) <= 8 else 1
  99. score += 5 if 12 <= median_text <= 120 else (-6 if median_text > 260 else 0)
  100. score -= min(10, len(media_entries) * 0.35)
  101. score -= 5 if path.stat().st_size > 650 * 1024 * 1024 else 0
  102. result.update(
  103. valid=True,
  104. score=round(score, 2),
  105. ratio=round(ratio, 4),
  106. slides=slide_count,
  107. masters=master_count,
  108. layouts=layout_count,
  109. images=len(image_entries),
  110. media=len(media_entries),
  111. median_text=median_text,
  112. median_shapes=median_shapes,
  113. median_pictures=median_pictures,
  114. unique_layout_ratio=round(unique_layout_ratio, 3),
  115. colors=common_colors,
  116. signature=visual_signature,
  117. name_key=normalized_name(path),
  118. )
  119. except (OSError, zipfile.BadZipFile, RuntimeError) as error:
  120. result["error"] = str(error)
  121. return result
  122. def main() -> None:
  123. files = sorted(SOURCE_DIR.rglob("*.pptx"))
  124. candidates = []
  125. for index, path in enumerate(files, 1):
  126. candidates.append(inspect(path))
  127. if index % 100 == 0:
  128. print(f"scanned {index}/{len(files)}", flush=True)
  129. valid = [item for item in candidates if item["valid"]]
  130. valid.sort(key=lambda item: item["score"], reverse=True)
  131. OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
  132. OUTPUT_PATH.write_text(json.dumps({"source": str(SOURCE_DIR), "total": len(files), "valid": len(valid), "items": valid}, ensure_ascii=False, indent=2), encoding="utf-8")
  133. print(json.dumps({"total": len(files), "valid": len(valid), "output": str(OUTPUT_PATH), "top_score": valid[0]["score"] if valid else None}, ensure_ascii=False))
  134. if __name__ == "__main__":
  135. main()