| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157 |
- from __future__ import annotations
- import hashlib
- import json
- import re
- import statistics
- import sys
- import zipfile
- from collections import Counter
- from pathlib import Path
- from xml.etree import ElementTree as ET
- SOURCE_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else r"H:\音乐教材PPT")
- OUTPUT_PATH = Path(sys.argv[2] if len(sys.argv) > 2 else "artifacts/music-template-extraction/candidates.json")
- P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
- A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
- def xml_root(archive: zipfile.ZipFile, name: str):
- try:
- return ET.fromstring(archive.read(name))
- except (KeyError, ET.ParseError):
- return None
- def classify_scene(path: Path) -> str:
- name = path.stem
- if re.search(r"京剧|戏曲|脸谱|古琴|古筝|二胡|琵琶|民乐|民族|非遗|传统|江南|忆江南|茉莉花|采茶|山水|姑苏|十面埋伏|龙舟|赛马|彩云追月|阳关三叠", name):
- return "traditional"
- if re.search(r"节奏|节拍|律动|游戏|活动|表演|舞蹈|打击乐|鼓|拍手|声势|合奏|创编", name):
- return "activity"
- if re.search(r"交响|管弦|音乐会|世界|爵士|舞台|歌剧|圆舞曲", name):
- return "stage"
- if re.search(r"结构|旋律|乐理|和声|调式|音程|项目|探究", name):
- return "theory"
- if re.search(r"童话|动物|动画|小鸟|小猫|小狗|森林|小屋|儿歌|娃娃|小朋友|儿童|春天|郊游|梦|星星|月亮", name):
- return "child"
- return "general"
- def normalized_name(path: Path) -> str:
- value = re.sub(r"[\s_\-—]+", "", path.stem.lower())
- value = re.sub(r"(?:课件|新版|新教材|音乐|第?\d+课时|\(\d+\)|(\d+)|\d+)$", "", value)
- return value[:80]
- def inspect(path: Path) -> dict:
- result = {
- "path": str(path),
- "name": path.stem,
- "scene": classify_scene(path),
- "size_mb": round(path.stat().st_size / 1024 / 1024, 2),
- "valid": False,
- "score": 0,
- }
- try:
- with zipfile.ZipFile(path) as archive:
- names = archive.namelist()
- presentation = xml_root(archive, "ppt/presentation.xml")
- if presentation is None:
- result["error"] = "missing-presentation"
- return result
- size_node = presentation.find(f"{{{P_NS}}}sldSz")
- cx = int(size_node.attrib.get("cx", 0)) if size_node is not None else 0
- cy = int(size_node.attrib.get("cy", 0)) if size_node is not None else 0
- ratio = cx / cy if cy else 0
- slide_names = sorted(
- (name for name in names if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)),
- key=lambda name: int(re.search(r"\d+", Path(name).stem).group()),
- )
- master_count = sum(bool(re.fullmatch(r"ppt/slideMasters/slideMaster\d+\.xml", name)) for name in names)
- layout_count = sum(bool(re.fullmatch(r"ppt/slideLayouts/slideLayout\d+\.xml", name)) for name in names)
- 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)]
- 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)]
- text_counts = []
- shape_counts = []
- picture_counts = []
- fill_colors = Counter()
- signatures = []
- for slide_name in slide_names:
- root = xml_root(archive, slide_name)
- if root is None:
- continue
- text = "".join(node.text or "" for node in root.iter(f"{{{A_NS}}}t"))
- text_counts.append(len(text.strip()))
- shapes = len(list(root.iter(f"{{{P_NS}}}sp")))
- pictures = len(list(root.iter(f"{{{P_NS}}}pic")))
- shape_counts.append(shapes)
- picture_counts.append(pictures)
- colors = [node.attrib.get("val", "").upper() for node in root.iter(f"{{{A_NS}}}srgbClr") if node.attrib.get("val")]
- fill_colors.update(colors)
- signatures.append(f"{shapes}:{pictures}:{min(len(text), 300)}:{','.join(colors[:8])}")
- slide_count = len(slide_names)
- median_text = statistics.median(text_counts) if text_counts else 0
- median_shapes = statistics.median(shape_counts) if shape_counts else 0
- median_pictures = statistics.median(picture_counts) if picture_counts else 0
- unique_layout_ratio = len(set(signatures)) / max(1, slide_count)
- common_colors = [color for color, _ in fill_colors.most_common(8)]
- visual_signature = hashlib.sha1((str(round(ratio, 3)) + "|" + "|".join(common_colors) + "|" + str(layout_count)).encode()).hexdigest()[:16]
- score = 0.0
- score += 18 if 1.74 <= ratio <= 1.79 else -18
- score += min(16, slide_count * 0.7) if slide_count >= 8 else -12
- score += min(12, layout_count * 1.2)
- score += min(8, master_count * 2)
- score += min(12, median_shapes * 0.9)
- score += min(10, median_pictures * 2.2)
- score += min(10, unique_layout_ratio * 16)
- score += 7 if 3 <= len(common_colors) <= 8 else 1
- score += 5 if 12 <= median_text <= 120 else (-6 if median_text > 260 else 0)
- score -= min(10, len(media_entries) * 0.35)
- score -= 5 if path.stat().st_size > 650 * 1024 * 1024 else 0
- result.update(
- valid=True,
- score=round(score, 2),
- ratio=round(ratio, 4),
- slides=slide_count,
- masters=master_count,
- layouts=layout_count,
- images=len(image_entries),
- media=len(media_entries),
- median_text=median_text,
- median_shapes=median_shapes,
- median_pictures=median_pictures,
- unique_layout_ratio=round(unique_layout_ratio, 3),
- colors=common_colors,
- signature=visual_signature,
- name_key=normalized_name(path),
- )
- except (OSError, zipfile.BadZipFile, RuntimeError) as error:
- result["error"] = str(error)
- return result
- def main() -> None:
- files = sorted(SOURCE_DIR.rglob("*.pptx"))
- candidates = []
- for index, path in enumerate(files, 1):
- candidates.append(inspect(path))
- if index % 100 == 0:
- print(f"scanned {index}/{len(files)}", flush=True)
- valid = [item for item in candidates if item["valid"]]
- valid.sort(key=lambda item: item["score"], reverse=True)
- OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
- 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")
- 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))
- if __name__ == "__main__":
- main()
|