bone_2026/tools/build_deck.py

432 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())