from __future__ import annotations import json from io import BytesIO from dataclasses import dataclass from pathlib import Path from PIL import Image, ImageFilter from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE from pptx.util import Inches ROOT = Path(__file__).resolve().parents[1] REVIEW_ROOT = ROOT / "artifacts" / "music-template-extraction" / "review" OUTPUT_ROOT = ROOT / "public" / "ai-courseware-templates" / "extracted" @dataclass(frozen=True) class Selection: candidate: str template_id: str name: str scene: str description: str layout_family: str palette: tuple[str, str, str, str, str] # 十种通用视觉版式各自对应原课件抽样页序号,不与歌词、谱例等课程语义绑定。 page_sample_indexes: tuple[int, ...] # 课程内容图片必须删除;保留人物、花草、纹样等视觉资产。 remove_picture_indexes: dict[int, tuple[int, ...]] # 少量矢量装饰无法由 python-pptx 直接绘制,从原预览中精确裁回。 keep_raster_indexes: dict[int, tuple[int, ...]] page_backgrounds: tuple[str, ...] # 新模板默认仅保留大面积底图和边缘装饰,防止课程内容图片进入母版。 auto_clean: bool = False SELECTIONS = ( Selection( "candidate-01", "ethnic-flower", "民族花园", "民族歌曲与律动", "民族人物、花草边饰与低年级互动", "culture", ("#DF7A2B", "#4C9A71", "#E45667", "#F2C84B", "#FFFDF6"), (0, 2, 2, 2, 3, 3, 3, 2, 2, 4), {6: (1, 2, 3, 13), 16: (1, 2)}, {1: (5,), 16: (11, 13), 22: (5,)}, ("#FFFFFF",) * 10, ), Selection( "candidate-24", "opera-fan", "水墨脸谱", "戏曲鉴赏", "水墨扇面、戏曲人物与脸谱知识", "culture", ("#A93A35", "#354C47", "#C7883C", "#D5B26B", "#F6F0E7"), (0, 1, 3, 1, 2, 1, 2, 3, 3, 3), {14: (6,), 22: (3, 4, 5)}, {}, ("#F6F0E7",) * 10, ), Selection( "candidate-43", "dark-lute", "月夜琵琶", "中学音乐欣赏", "深蓝月夜、东方器乐与沉浸聆听", "academic", ("#B79052", "#315D71", "#D07A50", "#D3B270", "#F7F6F1"), (0, 1, 1, 2, 1, 3, 3, 1, 2, 4), {9: (2, 10, 12)}, {1: (3,), 9: (11,), 36: (2,)}, ("#06364B", "#FFFFFF", "#FFFFFF", "#06364B", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#06364B", "#06364B"), ), Selection( "candidate-05", "spring-outing", "春日郊游", "低年级自然与歌唱", "清新绿意、城市郊游与儿童音乐活动", "story", ("#5EA45A", "#F0B34F", "#E6674D", "#73C9C2", "#F7FFF6"), (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {6: (2, 3, 6, 8), 19: (0,)}, {}, ("#F7FFF6",) * 10, True, ), Selection( "candidate-11", "bird-garden", "花鸟乐园", "自然观察与旋律", "花鸟插画、柔和色块与小学音乐课堂", "story", ("#D84C55", "#318E91", "#F2B24A", "#8CC6B7", "#FFFBE8"), (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {5: (2,), 10: (10,), 16: (5, 6)}, {}, ("#FFFBE8",) * 10, True, ), Selection( "candidate-15", "rainy-cat", "雨天萌猫", "低龄儿歌与节奏", "萌猫、彩旗与轻松活泼的低年级课堂", "story", ("#E98991", "#54A99B", "#F3C665", "#F7A4A8", "#FFFDF8"), (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {6: (1, 2, 3), 12: (4,), 17: (4, 7)}, {}, ("#FFFDF8",) * 10, True, ), Selection( "candidate-20", "ink-jiangnan", "江南水墨", "古诗词与中国音乐", "水墨山水、留白与传统文化鉴赏", "culture", ("#A62F2B", "#314D42", "#C88A42", "#D6BE88", "#FAF8F2"), (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {8: (17,), 15: (12, 14), 30: (0, 1)}, {}, ("#FAF8F2",) * 10, True, ), Selection( "candidate-30", "rhythm-train", "节奏列车", "节拍与课堂活动", "彩色列车、积木色块与可视化节奏训练", "activity", ("#F4B400", "#18A55A", "#5DA9E9", "#F47F52", "#FFFDF5"), (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {}, {1: (2,), 11: (1,)}, ("#FFFDF5",) * 10, True, ), Selection( "candidate-31", "sunset-drum", "丝路鼓歌", "民族器乐与表演", "暖色山河、民族纹样与鼓乐表演", "culture", ("#D9672F", "#274E6A", "#E9B85B", "#8BA35B", "#FFF8E9"), (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {20: (4,)}, {}, ("#FFF8E9",) * 10, True, ), Selection( "candidate-33", "ballroom-silhouette", "黑白舞会", "中高年级舞曲鉴赏", "黑白剪影、钢琴元素与简洁舞台感", "academic", ("#1F1F1F", "#B08B5C", "#8E1F2B", "#D9C9A5", "#FAFAF8"), (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, ), Selection( "candidate-39", "color-theory", "彩彩乐理", "基础乐理与识谱", "明亮几何、键盘与低龄乐理启蒙", "education", ("#29B9A5", "#D91D63", "#F3B33D", "#6F63B5", "#FFFDF7"), (0, 1, 1, 2, 1, 2, 3, 3, 3, 4), {6: (17,), 16: (11,)}, {22: (0, 1)}, ("#FFFDF7",) * 10, True, ), ) def paste_clipped(canvas: Image.Image, layer: Image.Image, left: int, top: int) -> None: right, bottom = left + layer.width, top + layer.height clip_left, clip_top = max(0, left), max(0, top) clip_right, clip_bottom = min(canvas.width, right), min(canvas.height, bottom) if clip_right <= clip_left or clip_bottom <= clip_top: return crop = layer.crop((clip_left - left, clip_top - top, clip_right - left, clip_bottom - top)) canvas.alpha_composite(crop, (clip_left, clip_top)) def picture_layer(shape, slide_width: int, slide_height: int) -> tuple[Image.Image, int, int]: with Image.open(BytesIO(shape.image.blob)) as raw: image = raw.convert("RGBA") crop = ( round(image.width * shape.crop_left), round(image.height * shape.crop_top), round(image.width * (1 - shape.crop_right)), round(image.height * (1 - shape.crop_bottom)), ) image = image.crop(crop) left = round(shape.left / slide_width * 1600) top = round(shape.top / slide_height * 900) width = max(1, round(shape.width / slide_width * 1600)) height = max(1, round(shape.height / slide_height * 900)) return image.resize((width, height), Image.Resampling.LANCZOS), left, top def preview_crop(shape, preview: Image.Image, slide_width: int, slide_height: int) -> tuple[Image.Image, int, int]: left = round(shape.left / slide_width * preview.width) top = round(shape.top / slide_height * preview.height) right = round((shape.left + shape.width) / slide_width * preview.width) bottom = round((shape.top + shape.height) / slide_height * preview.height) crop = preview.crop((max(0, left), max(0, top), min(preview.width, right), min(preview.height, bottom))) target_left = round(shape.left / slide_width * 1600) target_top = round(shape.top / slide_height * 900) target_width = max(1, round(shape.width / slide_width * 1600)) target_height = max(1, round(shape.height / slide_height * 900)) return crop.resize((target_width, target_height), Image.Resampling.LANCZOS).convert("RGBA"), target_left, target_top def should_keep_picture(shape, slide_width: int, slide_height: int) -> bool: left = shape.left / slide_width top = shape.top / slide_height right = (shape.left + shape.width) / slide_width bottom = (shape.top + shape.height) / slide_height area = (shape.width / slide_width) * (shape.height / slide_height) if area >= 0.55: return True edge_anchored = left <= 0.18 or right >= 0.82 or top <= 0.14 or bottom >= 0.86 return edge_anchored and area <= 0.24 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: slide = deck.slides[slide_number - 1] canvas = Image.new("RGBA", (1600, 900), background) with Image.open(preview_path) as source_preview: preview = source_preview.convert("RGBA") for shape_index, shape in enumerate(slide.shapes): if shape_index in remove_pictures or shape.shape_type == MSO_SHAPE_TYPE.MEDIA: continue if getattr(shape, "has_text_frame", False) and shape.text.strip(): continue if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: if auto_clean and shape_index not in keep_raster and not should_keep_picture(shape, deck.slide_width, deck.slide_height): continue layer, left, top = picture_layer(shape, deck.slide_width, deck.slide_height) paste_clipped(canvas, layer, left, top) elif shape_index in keep_raster: layer, left, top = preview_crop(shape, preview, deck.slide_width, deck.slide_height) paste_clipped(canvas, layer, left, top) destination.parent.mkdir(parents=True, exist_ok=True) canvas.convert("RGB").save(destination, quality=94, optimize=True, progressive=True) def is_visually_blank(path: Path) -> bool: """过滤只有纯底色或极少噪点、缩略图中等同空白的背景。""" with Image.open(path) as source: image = source.convert("RGB").resize((160, 90), Image.Resampling.LANCZOS) pixels = list(image.getdata()) median = tuple(sorted(pixel[channel] for pixel in pixels)[len(pixels) // 2] for channel in range(3)) decorated = sum(max(abs(pixel[channel] - median[channel]) for channel in range(3)) > 12 for pixel in pixels) / len(pixels) edge_image = image.convert("L").filter(ImageFilter.FIND_EDGES).crop((2, 2, 158, 88)) edge_pixels = list(edge_image.getdata()) edge_coverage = sum(value > 18 for value in edge_pixels) / len(edge_pixels) return decorated < 0.003 and edge_coverage < 0.003 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]]: deck = Presentation(source) slide_numbers = [int(Path(name).stem.split("-")[1]) for name in samples] role_slide_numbers: list[int] = [] backgrounds: list[Path] = [] for output_index, sample_index in enumerate(page_sample_indexes, start=1): slide_number = slide_numbers[sample_index] destination = target_dir / f"background-{output_index:02d}.jpg" render_clean_slide( deck, slide_number, REVIEW_ROOT / candidate / samples[sample_index], destination, set(remove_picture_indexes.get(slide_number, ())), set(keep_raster_indexes.get(slide_number, ())), page_backgrounds[output_index - 1], auto_clean, ) if is_visually_blank(destination): destination.unlink() continue backgrounds.append(destination) role_slide_numbers.append(slide_number) return backgrounds, role_slide_numbers def make_pptx(backgrounds: list[Path], destination: Path) -> None: deck = Presentation() deck.slide_width = Inches(13.333333) deck.slide_height = Inches(7.5) while deck.slides: slide_id = deck.slides._sldIdLst[0] deck.part.drop_rel(slide_id.rId) del deck.slides._sldIdLst[0] layout = deck.slide_layouts[6] for background in backgrounds: slide = deck.slides.add_slide(layout) slide.shapes.add_picture(str(background), 0, 0, deck.slide_width, deck.slide_height) deck.core_properties.title = "AI 音乐课件模板" deck.core_properties.subject = "按页面角色提取,已清理原课件文案与媒体内容" deck.core_properties.author = "" deck.core_properties.comments = "" destination.parent.mkdir(parents=True, exist_ok=True) deck.save(destination) def main() -> None: manifest = json.loads((REVIEW_ROOT / "manifest.json").read_text(encoding="utf-8-sig")) items = {item["id"]: item for item in manifest["items"]} output: list[dict[str, object]] = [] for selection in SELECTIONS: item = items[selection.candidate] target_dir = OUTPUT_ROOT / selection.template_id target_dir.mkdir(parents=True, exist_ok=True) for stale_background in target_dir.glob("background-*.jpg"): stale_background.unlink() backgrounds, role_slide_numbers = render_role_backgrounds( Path(item["source"]), selection.candidate, item["samples"], selection.page_sample_indexes, target_dir, selection.remove_picture_indexes, selection.keep_raster_indexes, selection.page_backgrounds, selection.auto_clean, ) if len(backgrounds) < 3: raise RuntimeError(f"模板 {selection.template_id} 清理空白页后仅剩 {len(backgrounds)} 页,拒绝发布") template_file = target_dir / f"{selection.template_id}.pptx" make_pptx(backgrounds, template_file) output.append({ "id": selection.template_id, "name": selection.name, "scene": selection.scene, "description": selection.description, "layoutFamily": selection.layout_family, "sourceName": item["name"], "sourcePath": item["source"], "candidate": selection.candidate, "templateFile": f"/ai-courseware-templates/extracted/{selection.template_id}/{selection.template_id}.pptx", "backgrounds": [f"/ai-courseware-templates/extracted/{selection.template_id}/{path.name}" for path in backgrounds], "palette": list(selection.palette), "roleSourceSlides": role_slide_numbers, }) OUTPUT_ROOT.mkdir(parents=True, exist_ok=True) (OUTPUT_ROOT / "manifest.json").write_text(json.dumps({"items": output}, ensure_ascii=False, indent=2), encoding="utf-8") print(f"Extracted {len(output)} object-cleaned role-based templates into {OUTPUT_ROOT}") if __name__ == "__main__": main()