432 lines
24 KiB
Python
432 lines
24 KiB
Python
#!/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())
|