| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- 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()
|