#!/usr/bin/env python3 """ Сборка презентации решения из шаблона организаторов `docs/lct_temppalte.pptx`. Шаблон содержит готовые макеты: слайды 7–11 обязательны (титул, описание команды, карточки участников, история команды, «коротко о решении»), слайды 12–29 — оформление для содержательной части. Скрипт заполняет обязательные слайды и подставляет наш контент в подходящие макеты, лишние слайды (вводные инструкции организаторов и библиотеки иконок) удаляются. Запуск: python tools/build_deck.py # docs/deck.pptx python tools/build_deck.py --out other.pptx Про команду: данных о команде у скрипта нет, поэтому поля ФИО, контактов, города и названия команды заполняются заглушками вида «{{...}}» — их нужно заменить в PowerPoint. Всё, что касается задачи и решения, заполнено по фактам из репозитория (`README.md`, `docs/labeling.md`, `models/compare_rules/`). """ from __future__ import annotations import argparse import copy from pathlib import Path from typing import Dict, Iterable, List, Sequence from pptx import Presentation from pptx.util import Emu, Inches REPO = Path(__file__).resolve().parents[1] TEMPLATE = REPO / "docs" / "lct_temppalte.pptx" IMAGES = REPO / "docs" / "img" #: Слайды шаблона, которые войдут в презентацию, в нужном порядке. #: Ключ — номер слайда в шаблоне (нумерация с 1), значение — что он несёт. DECK_ORDER: Sequence[int] = (7, 8, 9, 10, 11, 13, 21, 16, 17, 20, 22, 24, 15, 19, 18, 25) #: Команда известна из `README.md` (раздел «Команда»). Контакты, город и место #: работы/учёбы в репозитории не указаны — они остаются заглушками. TEAM = ( {"name": "Грачев Татьяна", "role": "Капитан"}, {"name": "Грачев Денис", "role": "Разработка"}, ) CONTACT_PLACEHOLDER = ("{{Ник в мессенджере}}", "{{Телефон}}", "{{Место работы/учёбы}}") #: Подписи, оставленные шаблоном как инструкция. Если после заполнения такая #: строка осталась — значит, слайд заполнен не полностью. INSTRUCTION_MARKERS = ( "Опишите", "Расскажите", "Капитан: ФИО", "Имя Фамилия", "__ человек", "Опишите в чем", ) def set_lines(text_frame, lines: Sequence[str]) -> None: """ Заменить содержимое текстового блока, сохранив оформление абзаца. Оформление берётся у первого прогона первого абзаца: шаблонные шрифты, кегли и цвета остаются как в макете. """ paragraphs = text_frame.paragraphs while len(text_frame.paragraphs) > len(lines): element = text_frame.paragraphs[-1]._p element.getparent().remove(element) while len(text_frame.paragraphs) < len(lines): text_frame._txBody.append(copy.deepcopy(text_frame.paragraphs[-1]._p)) for paragraph, text in zip(text_frame.paragraphs, lines): runs = paragraph.runs if not runs: paragraph.add_run().text = text continue runs[0].text = text for run in runs[1:]: run._r.getparent().remove(run._r) def fill_by_placeholder(slide, content: Dict[int, Sequence[str]]) -> None: """Заполнить слайд по индексам плейсхолдеров (idx из шаблона).""" for shape in slide.shapes: if not shape.has_text_frame: continue try: idx = shape.placeholder_format.idx except (ValueError, AttributeError): continue if idx in content: set_lines(shape.text_frame, content[idx]) def drop_shape(shape) -> None: shape._element.getparent().remove(shape._element) def apply_layout(prs, keep: Sequence[int], order: Sequence[int]) -> None: """ Оставить только нужные слайды и расставить их в порядке `order`. Номера — исходные, из шаблона (с 1). Карта «номер → элемент» снимается до удаления: иначе после удаления индексы сдвигаются и порядок перепутается. """ sld_id_lst = prs.slides._sldIdLst elements = list(sld_id_lst) by_original = {index + 1: element for index, element in enumerate(elements)} assert set(order) <= set(keep) and len(set(order)) == len(order), "порядок и состав расходятся" for number in sorted((n for n in by_original if n not in keep), reverse=True): element = by_original[number] prs.part.drop_rel(element.rId) sld_id_lst.remove(element) for element in list(sld_id_lst): sld_id_lst.remove(element) for number in order: sld_id_lst.append(by_original[number]) def slide_by_number(prs, number: int): return prs.slides[number - 1] def fill_team_cards(slide, team: Sequence[dict]) -> None: """ Заполнить карточки участников и убрать лишние. Макет рассчитан на пять человек: карточки — это рамки одинаковой ширины, стоящие слева направо с шагом 2.52 дюйма, а подписи внутри карточки смещены относительно её рамки. Поэтому карточка определяется по рамке, а её элементы — по попаданию в горизонтальные границы рамки. Лишние карточки удаляются целиком: пустая карточка с шаблонной подписью выглядит как незаполненный слайд. """ frames = [ shape for shape in slide.shapes if shape.width is not None and abs(Emu(shape.width).inches - 2.40) < 0.05 and abs(Emu(shape.height).inches - 5.29) < 0.05 ] frames.sort(key=lambda s: Emu(s.left).inches) if len(frames) < len(team): raise ValueError(f"в макете {len(frames)} карточек, а участников {len(team)}") keep = frames[: len(team)] drop = frames[len(team):] def bounds(frame): left = Emu(frame.left).inches return left - 0.05, left + 2.45 for frame in drop: low, high = bounds(frame) for shape in list(slide.shapes): if shape.left is None or shape.top is None: continue center = Emu(shape.left).inches + Emu(shape.width).inches / 2 if low < center < high: drop_shape(shape) for frame, member in zip(keep, team): low, high = bounds(frame) for shape in slide.shapes: if shape.left is None or not shape.has_text_frame: continue center = Emu(shape.left).inches + Emu(shape.width).inches / 2 if not (low < center < high): continue text = shape.text_frame.text.strip() if text == "Имя Фамилия": set_lines(shape.text_frame, [member["name"]]) elif text.startswith("Роль в команде"): set_lines(shape.text_frame, [member["role"], *CONTACT_PLACEHOLDER]) def add_screenshots(slide) -> None: """Заменить три фоторамки макета на снимки интерфейса без искажения пропорций.""" for shape in list(slide.shapes): try: if shape.is_placeholder and shape.placeholder_format.type == 18: drop_shape(shape) except (ValueError, AttributeError): continue placements = [ ("ui-results.png", 0.40, 3.60, 7.40), # таблица результатов — широкая ("ui-detail-violation.png", 8.10, 1.10, 4.40), # панель деталей, нарушение ("ui-model-panel.png", 8.10, 4.40, 4.40), # панель «О модели» ] for name, left, top, width in placements: slide.shapes.add_picture( str(IMAGES / name), Inches(left), Inches(top), width=Inches(width) ) def build(out_path: Path) -> Path: prs = Presentation(str(TEMPLATE)) # --- Обязательные слайды 7–11 ------------------------------------------------- fill_by_placeholder(slide_by_number(prs, 7), { 0: ["Контроль качества денситометрических исследований"], 12: ["Команда {{НАЗВАНИЕ КОМАНДЫ}} · задача от {{ПОСТАНОВЩИК ЗАДАЧИ}}"], }) slide8 = slide_by_number(prs, 8) fill_by_placeholder(slide8, {0: ["Команда {{НАЗВАНИЕ КОМАНДЫ}}"]}) for shape in slide8.shapes: if not shape.has_text_frame: continue text = shape.text_frame.text if text.startswith("Капитан:"): set_lines(shape.text_frame, [ f"Капитан: {TEAM[0]['name']}", f"Кол-во участников: {len(TEAM)} человека", "Краткое описание:", "{{город, место работы или учёбы, как собралась команда}}", ]) elif text.startswith("В чем суть вашего решения"): set_lines(shape.text_frame, [ "Сервис оценивает качество снимка DXA: пригоден ли он для анализа и что именно не так. " "Работает офлайн, до трёх минут на исследование.", ]) elif text.startswith("Что делает ваше решение"): set_lines(shape.text_frame, [ "Правило разметки выбрано измерением, а не по вкусу; модель проверена на то, " "что использует содержимое снимка.", ]) slide9 = slide_by_number(prs, 9) fill_team_cards(slide9, TEAM) slide10 = slide_by_number(prs, 10) fill_by_placeholder(slide10, { 27: ["{{как собрались, участвовали ли вместе в прошлых проектах, интересные факты}}"], 0: ["Команда {{НАЗВАНИЕ}}"], }) for shape in slide10.shapes: if not shape.has_text_frame: continue text = shape.text_frame.text if text.startswith("Расскажите о самых интересных"): set_lines(shape.text_frame, [ "Данных с надёжной разметкой оказалось меньше трети: экспертная оценка сделана " "на уровне исследования, а разметки областей интереса в DICOM нет. Локальная " "vision-модель оказалась непригодна — это выяснилось на калибровке.", ]) elif text.startswith("Что вас вдохновило"): set_lines(shape.text_frame, [ "{{что заинтересовало в задаче}}", ]) slide11 = slide_by_number(prs, 11) fill_by_placeholder(slide11, { 38: [ "ResNet18 с замороженным backbone и линейной головой; один путь предобработки для обучения и API;", "порог решения подобран по F1 и хранится в чекпоинте вместе с весами.", "На фиксированном held-out наборе, пять seed'ов: ROC-AUC 0.6764 [0.6309; 0.7218], F1 0.5676 [0.5270; 0.6082].", ], 42: [ "Помощник для отделения денситометрии: приоритизирует ручной просмотр и снижает долю повторных исследований.", "Разворачивается локально в контейнере, медицинские изображения не покидают контур.", "Экономия — за счёт того, что специалист смотрит в первую очередь снимки с замечаниями.", ], }) # --- Содержательная часть из макетов 12–29 ------------------------------------ fill_by_placeholder(slide_by_number(prs, 13), { 0: ["Задача и результат"], 1: [ "Области: поясничный отдел позвоночника и проксимальный отдел бедренной кости", "Вход: DICOM без разметки, до трёх изображений в исследовании", "Выход: XLSX или CSV, одна строка на снимок — регион, класс качества, тип нарушения", "252 уникальных снимка в 100 исследованиях; 77 снимков с нарушением", "Офлайн, в контейнере, не дольше трёх минут на исследование", ], }) fill_by_placeholder(slide_by_number(prs, 21), { 0: ["Данные и разметка"], 21: ["252"], 18: ["уникальных снимка в 100 исследованиях"], 22: ["77"], 23: ["нарушений в обучающей разметке (30.6 %)"], 24: ["3"], 25: ["снимка без экспертной оценки — помечены явно, а не спрятаны"], }) fill_by_placeholder(slide_by_number(prs, 16), { 0: ["Как построена разметка"], 49: ["01"], 37: ["Экспертная таблица"], 38: ["Оценка сделана на уровне исследования, по критериям: укладка, ось, артефакты, " "позиционирование, область интереса"], 50: ["02"], 39: ["Перенос на снимок"], 40: ["После склейки дублей каждая область встречается в исследовании один раз — " "вердикт переносится однозначно"], 51: ["03"], 41: ["Выбор правила"], 42: ["Два правила сравнили на одном разбиении, пять seed'ов, один эталон: " "0.6764 против 0.6199"], 52: ["04"], 43: ["Тип нарушения"], 44: ["Берётся из структурированных критериев таблицы; комментарии эксперта сохраняем дословно"], }) fill_by_placeholder(slide_by_number(prs, 17), { 0: ["Три решения, принятых по эксперименту"], 49: ["01"], 37: ["Аугментация отключена"], 38: ["Яркость и положение снимка сами являются признаками качества. " "Включение роняло AUC с 0.87 до 0.56"], 50: ["02"], 39: ["Порог 0.5 отвергнут"], 40: ["Вероятности насыщаются. Порог подобран по логиту и F1 и хранится в чекпоинте"], 51: ["03"], 41: ["Vision-модель 9B отвергнута"], 42: ["На калибровке вынесла «непригоден» всем 14 снимкам, включая заведомо качественные"], }) fill_by_placeholder(slide_by_number(prs, 20), { 0: ["Модель использует снимок, а не анатомию"], 14: ["Область почти однозначно определяется шириной кадра, поэтому проверяем, не выучила ли " "модель просто область. Сравниваем с правилом «позвоночник значит нарушение» на тех же снимках."], 15: ["Модель: внутри областей AUC 0.85–0.93"], 16: ["Правило области: ровно 0.500 внутри области"], 17: ["Общий AUC модели 0.854 против 0.529 у правила"], 18: ["Проверка идёт по всему набору, включая обучающие снимки, — значения смещены вверх"], 19: ["Вывод: содержимое снимка даёт вклад, подмены качества анатомией нет"], }) fill_by_placeholder(slide_by_number(prs, 22), { 0: ["Результат на held-out наборе"], 21: ["0.6764"], 18: ["ROC-AUC, 95 % интервал 0.6309–0.7218"], 22: ["0.4759"], 23: ["PR-AUC, интервал 0.4141–0.5377"], 24: ["0.5676"], 25: ["F1, интервал 0.5270–0.6082"], 26: ["5 × 2"], 27: ["прогона: пять seed'ов на два правила; снимки валидации в обучении не участвовали"], }) fill_by_placeholder(slide_by_number(prs, 24), { 0: ["Почему модель простая"], 26: ["Проблема", "Полный fine-tune на двухстах снимках переобучается: train-метрика уходит в единицу, " "качество на валидации — к случайному"], 31: ["Решение", "Замороженный ResNet18 как экстрактор признаков и линейная голова: обучаются тысячи " "параметров вместо миллионов"], 32: ["Результат", "Устойчивые метрики на пяти seed'ах и отсутствие подгонки под обучающую выборку"], }) fill_by_placeholder(slide_by_number(prs, 15), { 0: ["Ограничения"], 15: ["Разметка выведена из оценки исследования: поштучной экспертной оценки снимков нет"], 16: ["252 снимка и 77 нарушений — доверительные интервалы широкие"], 17: ["Эталон — та же экспертная таблица; независимой истины нет"], 18: ["Тип нарушения определяет эвристика, а не обученная модель"], 19: ["Сторона бедра в 7 исследованиях не проверяема: теги латеральности в DICOM пусты"], }) slide19 = slide_by_number(prs, 19) fill_by_placeholder(slide19, { 0: ["Как это выглядит"], 14: ["Интерфейс: загрузка DICOM, таблица результатов, панель деталей и панель «О модели» " "с метриками, словарём нарушений и списком ограничений."], }) add_screenshots(slide19) fill_by_placeholder(slide_by_number(prs, 18), { 0: ["Эксплуатация и упаковка"], 49: ["01"], 37: ["Скорость"], 38: ["15 мс на снимок на ускорителе, 20 мс на CPU; запас к бюджету три минуты более 3000×"], 50: ["02"], 39: ["Требования"], 40: ["Минимально достаточно CPU; чекпоинт 43 МБ; образ содержит код и интерфейс"], 51: ["03"], 41: ["API"], 42: ["Анализ файла, детальный отчёт, DICOM SR, пакетная обработка и выгрузка XLSX"], 52: ["04"], 43: ["Надёжность"], 44: ["Ошибка не выбрасывается исключением: строка получает processing_status = Failure"], 53: ["05"], 45: ["Офлайн"], 46: ["Ни внешних сервисов, ни обращений к CDN: ассеты интерфейса лежат локально"], 54: ["06"], 47: ["Тесты"], 48: ["209 автотестов, включая браузерные сценарии и проверку работы без сети"], }) fill_by_placeholder(slide_by_number(prs, 25), { 0: ["План развития"], 26: ["Поштучная разметка"], 27: ["Разметить снимки специалистом — это снимает главное ограничение"], 28: ["Мультилейбл нарушений"], 29: ["Обучаемый тип нарушения вместо эвристики"], 30: ["Больше данных"], 31: ["500+ исследований, чтобы сузить доверительные интервалы"], 32: ["Калибровка области"], 33: ["Порог определения области под конкретное оборудование"], 34: ["Пилот в клинике"], 35: ["Приоритизация ручного просмотра с обратной связью врача"], }) # --- Оставляем только нужные слайды и расставляем порядок --------------------- apply_layout(prs, DECK_ORDER, DECK_ORDER) out_path.parent.mkdir(parents=True, exist_ok=True) prs.save(str(out_path)) return out_path def check(path: Path) -> List[str]: """ Проверить результат: не осталось ли инструкций шаблона. Заглушки для данных команды помечены «{{...}}» — они ожидаемы и замечанием не считаются, иначе проверка ругалась бы на наши же плейсхолдеры. """ prs = Presentation(str(path)) problems: List[str] = [] for index, slide in enumerate(prs.slides, start=1): for shape in slide.shapes: if not shape.has_text_frame: continue text = shape.text_frame.text if "{{" in text: continue for marker in INSTRUCTION_MARKERS: if marker in text: problems.append(f"слайд {index}: осталась инструкция шаблона «{marker}»") return problems def main(argv: Iterable[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Сборка презентации решения из шаблона") parser.add_argument("--out", default=str(REPO / "docs" / "deck.pptx")) args = parser.parse_args(list(argv) if argv is not None else None) out = Path(args.out) build(out) print(f"собрано: {out} ({out.stat().st_size / 1024 / 1024:.1f} МБ)") prs = Presentation(str(out)) print(f"слайдов: {len(prs.slides)}") problems = check(out) if problems: print("замечания:") for problem in problems: print(" -", problem) return 0 if __name__ == "__main__": raise SystemExit(main())