Compare commits

..

3 commits

Author SHA1 Message Date
srvoyo-cell
7cd7b01f76 Harden WebUI training lifecycle, security, and Docker builds 2026-07-19 14:56:12 +04:00
c86c23cd0d Миграция с tui на web ui. 2026-07-17 16:22:25 +04:00
srvoyo-cell
3a5f502735 Fix training lifecycle and dataset splitting bugs 2026-07-17 13:23:18 +04:00
32 changed files with 5281 additions and 1076 deletions

600
.agents/PROJECT_CONTEXT.md Normal file
View file

@ -0,0 +1,600 @@
# Контекст проекта YOLO Train WebUI
Дата актуализации: 2026-07-18
Этот файл — основной технический контекст проекта для разработчиков и агентов.
История найденных и исправленных дефектов находится в `PROJECT_ISSUES.md`.
## 1. Назначение и границы проекта
YOLO Train WebUI — локальное веб-приложение для настройки и запуска обучения
Ultralytics YOLO. Оно предоставляет форму конфигурации, профили запусков, live-логи,
прогресс по эпохам, графики метрик, мягкую остановку и интеграцию с MLflow.
Поддерживаемые задачи:
- `detect` — детекция объектов;
- `segment` — сегментация;
- `classify` — классификация;
- `pose` — оценка поз;
- `obb` — ориентированные bounding boxes.
Приложение рассчитано на локального доверенного пользователя и один активный запуск
обучения. Это не многопользовательская платформа, не планировщик задач и не сервис
хранения датасетов. В нём нет встроенных учётных записей, ролей или аутентификации.
## 2. Технологии
| Область | Технология |
|---|---|
| Backend/API | Python 3.11+, FastAPI, Uvicorn |
| Обучение | Ultralytics YOLO, PyTorch |
| Эксперименты | MLflow |
| Frontend | HTML, CSS, vanilla JavaScript |
| Графики | Chart.js из CDN |
| Real-time | WebSocket |
| Зависимости | `uv`, frozen-набор в `uv.lock` |
| Упаковка | Hatchling |
| Тесты | pytest, FastAPI TestClient/httpx, Node.js smoke-test |
| Контейнер | Docker, Docker Compose |
Основные зависимости объявлены в `pyproject.toml`: `fastapi`, `uvicorn`,
`websockets`, `ultralytics`, `mlflow`. Dev-группа содержит `pytest` и `httpx`.
Python package называется `yolo-train-webui`, текущая версия — `0.1.0`; wheel
собирается Hatchling только из `src/yolo_webui`.
`yolo_webui.__init__` публично экспортирует `TrainingConfig`, `TrainingEvent` и
`TrainingRunner`.
## 3. Структура репозитория
```text
.
├── .agents/
│ ├── PROJECT_CONTEXT.md # этот технический контекст
│ └── PROJECT_ISSUES.md # аудит и история исправлений
├── src/yolo_webui/
│ ├── __init__.py # публичные Python-экспорты
│ ├── __main__.py # запуск `python -m yolo_webui`
│ ├── app.py # FastAPI, TrainingManager, REST и WebSocket
│ ├── config.py # dataclass-конфигурации и валидация
│ ├── dataset_splitter.py # detection-style train/val splitter
│ ├── subprocess_runner.py # дочерний процесс обучения и stdout-протокол
│ ├── trainer.py # Ultralytics callbacks, MLflow, cancellation
│ └── static/
│ ├── index.html # форма и панель мониторинга
│ ├── app.js # browser state, API, WebSocket, Chart.js
│ └── style.css # всё визуальное оформление
├── tests/
│ ├── test_app.py # API и TrainingManager
│ ├── test_config.py # конфигурация, безопасность, MLflow env
│ ├── test_splitter.py # классы и разбиение датасета
│ ├── test_subprocess_runner.py
│ ├── test_trainer.py # callbacks и остановка
│ ├── test_frontend.py # запуск Node-проверок из pytest
│ └── frontend_smoke.js # browser stubs, форма и графики
├── Dockerfile
├── docker-compose.yml
├── pyproject.toml
├── uv.lock
└── README.md
```
Рабочие каталоги не входят в Git:
- `datasets/` — локальные датасеты;
- `models/` — локальные веса и YAML моделей;
- `runs/` — результаты Ultralytics и JSON-профили;
- `.yolo-webui/` — сгенерированные split-файлы внутри датасетов;
- `mlflow.db`, `mlruns/`, `mlflow/` — локальные данные MLflow;
- `.venv/`, кэши Python и pytest.
## 4. Архитектура во время выполнения
```text
Browser
├── HTTP JSON ───────────────┐
└── WebSocket /api/ws ───────┤
v
FastAPI / TrainingManager (основной процесс Uvicorn)
├── хранит LiveState и WebSocket-клиентов
├── сохраняет профили в runs/sessions
└── запускает background thread
|
v
Python subprocess: yolo_webui.subprocess_runner
├── читает временный JSON config
├── ставит SIGTERM/SIGINT handlers
├── создаёт TrainingRunner
├── запускает Ultralytics YOLO.train()
└── пишет события, логи и результат в stdout
|
├── dataset / generated split
├── models / official model download
├── runs / training artifacts
└── MLflow storage
```
Изоляция обучения в subprocess нужна, чтобы тяжёлый Ultralytics/PyTorch не блокировал
ASGI event loop, stdout можно было транслировать в браузер, а зависший запуск —
принудительно завершить.
Важная деталь: `TrainingRunner` используется в двух процессах.
- В родительском `TrainingManager` он хранит ссылку на subprocess и управляет
сигналами остановки.
- В дочернем процессе отдельный экземпляр владеет моделью Ultralytics и выставляет
`trainer.stop = True`.
## 5. Точки входа и запуск
CLI entry point из `pyproject.toml`:
```bash
uv run yolo-train-webui
```
Альтернативный модульный запуск:
```bash
uv run -m yolo_webui
```
Оба варианта вызывают `yolo_webui.app:main`. CLI принимает:
- `--host`, default `127.0.0.1`;
- `--port`, default `8000`.
Перед запуском Uvicorn устанавливается `MPLBACKEND=Agg`. В Docker приложение слушает
`0.0.0.0:8000` внутри контейнера, но Compose публикует его только как
`127.0.0.1:8000` на host.
## 6. Backend и состояние обучения
### `LiveState`
Глобальный `TrainingManager` хранит единственное состояние:
- `status`;
- текущую и общую эпохи;
- список логов;
- историю метрик;
- `output_dir`;
- `stop_requested`;
- `last_event_kind` для классификации результата.
Состояния:
```text
idle
└── start -> preparing
├── event started -> training
│ ├── normal exit 0 -> succeeded
│ ├── stop -> stopping -> cancelled
│ └── error -> failed
├── stop -> stopping -> cancelled/failed
└── setup error -> failed
```
`finished` больше не создаётся backend-ом; frontend понимает его только для
совместимости со старым состоянием. Новый запуск разрешён лишь когда нет активного
статуса и предыдущий background thread уже завершён.
### Потоки и lock
`TrainingManager._lock` защищает `LiveState`, ссылку на thread и набор WebSocket.
Нельзя выполнять `broadcast()` внутри `with self._lock`: broadcast сам читает
защищённые данные, и повторный захват обычного `threading.Lock` вызовет deadlock.
WebSocket привязывается к event loop Uvicorn при подключении. Вызовы broadcast из
фонового потока передаются через `asyncio.run_coroutine_threadsafe()`. Отправки
сериализуются `asyncio.Lock`; failed-клиенты логируются и удаляются.
### Запуск subprocess
`TrainingManager._run_subprocess()`:
1. сериализует `TrainingConfig.to_dict()` во временный JSON;
2. запускает текущий interpreter с `-u -m yolo_webui.subprocess_runner`;
3. объединяет stderr со stdout;
4. читает поток посимвольно, различая `\r` и `\n` для progress-строк;
5. обновляет состояние и транслирует события;
6. ждёт return code, удаляет временный JSON и очищает ссылку на процесс.
### Внутренний stdout-протокол
Дочерний процесс печатает специальные маркеры:
```text
__YOLO_WEBUI_READY__
__YOLO_WEBUI_EVENT__:{"kind":"epoch","message":"...","epoch":1,"total_epochs":100}
__YOLO_WEBUI_RESULT__:/absolute/path/to/run
```
- `READY` означает, что signal handlers уже установлены и отложенный stop можно
безопасно доставить.
- `EVENT` несёт `kind`, `message`, `epoch`, `total_epochs`.
- `RESULT` передаёт каталог результатов.
- Любая другая строка считается обычным логом.
События от `TrainingRunner`: `info`, `started`, `epoch`, `success`, `cancelled`,
`warning`. Исключение выводится traceback-ом в stderr/stdout и даёт return code `1`.
### Классификация завершения
Backend различает:
- `succeeded` — return code `0` без подтверждённой остановки;
- `cancelled` — был stop и subprocess завершился с `0`, прислал `cancelled` или был
убит force-stop таймером;
- `failed` — ненулевой код без подтверждённой отмены, в том числе реальная ошибка,
случившаяся после нажатия Stop.
## 7. Остановка обучения
Остановка кооперативная и двухуровневая:
1. `POST /api/train/stop` ставит `stop_requested` и статус `stopping`.
2. Если subprocess ещё не прислал `READY`, запрос сохраняется.
3. После `READY` родитель отправляет `SIGTERM`.
4. Signal handler дочернего процесса вызывает `TrainingRunner.request_stop()`.
5. Runner выставляет `trainer.stop = True` сразу либо в ближайшем callback.
6. Ultralytics штатно завершает callbacks и сохранение результатов.
7. Если subprocess не завершился за 30 секунд, parent вызывает `kill()`.
`prepare_run()` перед каждым новым запуском очищает stop-флаги и старый таймер.
## 8. REST и WebSocket API
| Метод | Путь | Назначение |
|---|---|---|
| GET | `/` | Возвращает `static/index.html` |
| GET | `/static/*` | CSS и JavaScript |
| GET | `/api/config/defaults` | Полный default `TrainingConfig` |
| GET | `/api/datasets` | Верхнеуровневые каталоги и YAML из `datasets/` |
| GET | `/api/models` | Верхнеуровневые `.pt/.pth/.yaml/.yml` из `models/` |
| GET | `/api/sessions` | Список профилей без `last_run` |
| GET | `/api/sessions/{name}` | Загрузить профиль; `last_run` читать можно |
| POST | `/api/sessions/{name}` | Сохранить произвольный JSON профиля |
| DELETE | `/api/sessions/{name}` | Удалить профиль |
| GET | `/api/train/status` | Текущее состояние, эпохи, результат, метрики, логи |
| POST | `/api/train/start` | Провалидировать config, сохранить `last_run`, запустить |
| POST | `/api/train/stop` | Запросить остановку |
| WS | `/api/ws` | Init-снимок и live-события |
FastAPI также оставляет включёнными стандартные OpenAPI endpoints: `/docs`,
`/redoc`, `/openapi.json`.
WebSocket server → browser сообщения:
- `init`: полный snapshot состояния, логов и метрик при подключении;
- `status`: новое состояние и опциональный `output_dir`;
- `log`: `message` и `level`;
- `progress`: эпоха, total, извлечённые метрики и сообщение.
Browser → server сообщения не используются; endpoint только читает и отбрасывает их,
поддерживая соединение открытым.
Профили хранятся в `runs/sessions/{name}.json`. Имя: 164 символа из латинских
букв, цифр, `_`, `-`. `last_run` зарезервирован для автосохранения при старте: его
можно прочитать, но нельзя создать или удалить через profile endpoints.
## 9. Конфигурация обучения
### `TrainingConfig`
| Поле | Default | Передача в Ultralytics |
|---|---:|---|
| `dataset` | обязательно; API default `coco8.yaml` | `data` |
| `model` | обязательно; API default `yolo11n.pt` | аргумент конструктора `YOLO()` |
| `task` | `detect` | аргумент конструктора `YOLO()` |
| `epochs` | `100` | `epochs` |
| `image_size` | `640` | `imgsz` |
| `batch_size` | `16` | `batch` |
| `device` | пусто | `device`, только если задано |
| `workers` | `8` | `workers`; `0` допустим |
| `patience` | `100` | `patience`; `0` допустим |
| `project` | `runs/train` | `project` |
| `run_name` | пусто | `name`, только если задано |
| `augmentation` | включена | набор augmentation kwargs |
| `mlflow` | включён | Ultralytics setting и env |
| `split` | выключен | preprocessing до `YOLO.train()` |
Основная валидация:
- `epochs >= 1`, `image_size >= 32`;
- batch положительный или `-1`; `0` и значения `< -1` запрещены;
- `workers >= 0`, `patience >= 0`;
- задача входит в фиксированный список;
- detection-style auto split запрещён для `classify`;
- dataset/model/project проходят security path validation.
Модель без `/` или `\` считается именем и резолвится как `models/{name}`.
### `DatasetSplitConfig`
- `enabled=False`;
- `train_ratio=0.8`, допустимо `0.1…0.95`;
- `classes_path=""`, пустое значение включает автопоиск.
### `MlflowConfig`
- `enabled=True`;
- `tracking_uri="sqlite:///mlflow.db"`;
- `experiment_name="yolo-webui"`;
- `run_name=""`.
Если MLflow включён, tracking URI и experiment name не могут быть пустыми.
`mlflow_environment()` временно выставляет:
- `MLFLOW_TRACKING_URI`;
- `MLFLOW_EXPERIMENT_NAME`;
- `MLFLOW_RUN`;
- `MLFLOW_KEEP_RUN_ACTIVE=False`.
После обучения предыдущие значения окружения восстанавливаются. В Ultralytics
глобальная настройка `mlflow` включается/выключается через `settings.update()`.
### `AugmentationConfig`
Default-параметры:
```text
hsv_h=0.015 hsv_s=0.7 hsv_v=0.4
degrees=0.0 translate=0.1 scale=0.5
shear=0.0 perspective=0.0
flipud=0.0 fliplr=0.5 bgr=0.0
mosaic=1.0 mixup=0.0 cutmix=0.0
copy_paste=0.0 erasing=0.4 close_mosaic=10
copy_paste_mode=flip
auto_augment=randaugment
```
Вероятности и доли валидируются в диапазоне `0…1`; `degrees`, `shear` и
`close_mosaic` не могут быть отрицательными. Режимы copy-paste: `flip`, `mixup`.
Политики AutoAugment: `randaugment`, `autoaugment`, `augmix`. Если augmentation
выключена, эти kwargs вообще не передаются в Ultralytics.
## 10. Работа с датасетами
Auto split предназначен только для detection-style структуры:
```text
dataset/
├── images/
│ └── **/*.{jpg,jpeg,png,bmp,webp,tif,tiff}
└── labels/
└── **/*.txt
```
Изображения и labels могут быть вложенными. Для split нужно минимум два изображения.
Shuffle детерминирован seed-ом `42`; train и val всегда получают минимум по одному
изображению.
Порядок определения классов:
1. явно заданный `classes_path` — авторитетный, без fallback при ошибке;
2. корневой `classes.txt`;
3. `labels/classes.txt`;
4. первый по имени корневой `.yaml/.yml` с полем `names`;
5. вывод диапазона `0…max_id` из всех label-файлов с именами `class_N`.
Поддерживаются text, YAML list и YAML dict. ID должны быть целыми,
неповторяющимися и последовательными от `0`; пустые имена запрещены.
Каждый split создаётся эксклюзивно:
```text
dataset/.yolo-webui/splits/{uuid}/
├── train.txt # абсолютные пути изображений
├── val.txt # абсолютные пути изображений
└── dataset.yaml
```
Итоговый YAML сохраняет дополнительные ключи исходного YAML, например `kpt_shape`
и `flip_idx`, но перезаписывает `path`, `train`, `val` и `names`. Проверенный
результат `read_classes()` всегда авторитетен.
Для `classify` auto split отключён: пользователь должен предоставить готовую
структуру `train/`, `val/` или `test/` с подкаталогами классов.
## 11. TrainingRunner и метрики
Перед обучением Runner:
1. повторно валидирует config;
2. при необходимости создаёт split и заменяет `data` на generated YAML;
3. импортирует Ultralytics;
4. включает/выключает MLflow integration;
5. создаёт `YOLO(config.resolved_model, task=config.task)`;
6. подключает callbacks `on_train_start`, `on_train_epoch_end`, `on_train_end`;
7. вызывает `model.train(**config.train_kwargs())`.
Epoch callback берёт numeric metrics из `trainer.metrics`, форматирует максимум три
первых значения и отправляет их в текстовом сообщении. Parent разбирает пары
`key=value`, поэтому live chart сейчас показывает не более трёх метрик на эпоху.
Если `trainer.save_dir` существует, его путь передаётся parent-у как результат.
Restricted checkpoint loading принудительно включён:
```text
ULTRALYTICS_SAFE_LOAD=1
```
## 12. Frontend
Frontend не имеет сборщика и framework: `index.html`, `style.css` и `app.js`
отдаются FastAPI как статические файлы. Chart.js загружается с jsDelivr CDN.
Левая панель содержит профили и вкладки:
- «Основное» — task, model, dataset, auto split;
- «Обучение» — epochs, image size, batch, device, workers, patience, output;
- «Аугментация» — все поля `AugmentationConfig`;
- «MLflow» — enabled, tracking URI, experiment и run name.
Правая панель содержит статус, timer, progress bar, Start/Stop, live chart и журнал.
Browser state:
- активная вкладка хранится в `localStorage.active_tab`;
- выбранный профиль — `localStorage.selected_profile`;
- черновик формы — `localStorage.draft_config`;
- при старте загрузки приоритет: draft → `last_run` → API defaults;
- список датасетов и моделей запрашивается у backend;
- стандартные модели YOLO11 выбираются динамически по task;
- при disconnect WebSocket переподключается через 5 секунд;
- `init` восстанавливает status, логи, progress и историю графика.
Числа читаются через `Number.parseInt/parseFloat` и проверку `Number.isNaN`. Нельзя
заменять это на `value || default`: допустимые `0` для workers, patience,
close_mosaic и augmentation-параметров должны сохраняться.
Chart datasets создаются по фактически пришедшим ключам. Новая метрика может
появиться на поздней эпохе; пропущенные точки заполняются `null`, чтобы серии не
сдвигались относительно labels.
## 13. Безопасность и доверенная модель
Приложение не имеет аутентификации. Безопасность по умолчанию строится на локальной
публикации и ограничении файловых путей.
Default доверенные корни:
| Назначение | Корни |
|---|---|
| Dataset и classes | `./datasets` |
| Model/checkpoint | `./models`, `./runs` |
| Training output | `./runs` |
Дополнительные корни перечисляются через системный `os.pathsep`:
- `YOLO_WEBUI_DATA_ROOTS`;
- `YOLO_WEBUI_MODEL_ROOTS`;
- `YOLO_WEBUI_RUN_ROOTS`.
Проверка запрещает URL, нормализует путь через `resolve(strict=False)` и проверяет
принадлежность корню, включая существующие symlink-компоненты. Безопасные bare
identifiers разрешены для официальных имён, но существующий одноимённый файл вне
доверенного root отвергается. Model-файлы ограничены расширениями `.pt`, `.pth`,
`.yaml`, `.yml`.
Compose публикует только `127.0.0.1:8000:8000`. Для доступа из сети обязателен
аутентифицирующий reverse proxy и явная оценка риска: API может запускать тяжёлое
обучение, останавливать его и управлять профилями.
## 14. Docker
Dockerfile:
- основан на `python:3.11-slim`;
- устанавливает системные библиотеки для OpenCV/PyTorch/Ultralytics;
- фиксирует `uv==0.10.6`;
- копирует `pyproject.toml`, `uv.lock`, README;
- выполняет `uv sync --locked --no-dev` в `/opt/venv`;
- включает `ULTRALYTICS_SAFE_LOAD=1`;
- запускает `yolo-train-webui --host 0.0.0.0 --port 8000`.
Compose монтирует:
```text
./datasets -> /workspace/datasets
./runs -> /workspace/runs
./models -> /workspace/models
./models/.config -> /root/.config/Ultralytics
```
Порт 8000 опубликован только на loopback. Порт 5000 объявлен в image, но Compose не
запускает и не публикует MLflow UI. GPU reservation оставлена как закомментированный
пример для NVIDIA/Linux.
## 15. Тесты и проверки
Текущий regression suite содержит 49 pytest-тестов.
- `test_app.py`: defaults/status, profiles, deadlock, background WebSocket loop,
финальные состояния.
- `test_config.py`: kwargs, validation, zero-compatible параметры, MLflow env,
security roots и URL.
- `test_splitter.py`: форматы классов, приоритеты, nested data, уникальные outputs,
сохранение YAML metadata.
- `test_subprocess_runner.py`: return codes, READY/RESULT и traceback.
- `test_trainer.py`: Ultralytics callbacks, metrics, ранний stop, сигналы.
- `test_frontend.py` + `frontend_smoke.js`: syntax, сохранение нулей, динамические
chart series в fake browser environment.
Основные команды:
```bash
uv sync --locked
uv run pytest -q
uv run python -m compileall -q src tests
node --check src/yolo_webui/static/app.js
node tests/frontend_smoke.js
uv lock --check
docker compose config
git diff --check
```
Python-команды проекта следует выполнять через `uv run`, чтобы использовать
зафиксированное окружение.
## 16. Согласованное изменение проекта
При добавлении или переименовании config-поля обычно нужно изменить вместе:
1. dataclass, default, validation и `train_kwargs()` в `config.py`;
2. `TrainingConfig.from_dict()`;
3. поле в `static/index.html`;
4. чтение в `getFormConfig()` и восстановление в `applyConfig()` в `app.js`;
5. backend/frontend regression tests;
6. README и этот контекст, если меняется пользовательский контракт.
При добавлении нового состояния обучения нужно обновить:
1. backend state machine и финальную классификацию;
2. WebSocket status payload;
3. `updateUIStatus()`;
4. CSS-селекторы `status-*`;
5. тесты переходов и reconnect snapshot.
При изменении subprocess-протокола синхронно меняются `subprocess_runner.py` и parser
в `TrainingManager._handle_subprocess_line()`. Префиксы протокола нельзя печатать в
обычных логах.
Критические инварианты:
- не вызывать WebSocket send из нового или чужого event loop;
- не вызывать `broadcast()` под `TrainingManager._lock`;
- не объединять `succeeded`, `cancelled`, `failed` в общий `finished`;
- не использовать JS truthiness для числовых полей;
- явно указанный classes-файл всегда авторитетен;
- не создавать split поверх пользовательских файлов;
- не снимать `--locked` с Docker/CI установки;
- не расширять сетевую публикацию без аутентификации;
- сохранять traceback и ошибки доставки в наблюдаемых логах.
## 17. Текущие ограничения
- Только один активный training job и один глобальный in-memory `LiveState`.
- После перезапуска server live state теряется; сохраняются лишь JSON-профили,
`last_run`, training artifacts и MLflow data.
- Нет очереди, scheduler, истории runs API, upload API и файлового браузера.
- Нет встроенной аутентификации и multi-user isolation.
- Discovery просматривает только верхний уровень `datasets/` и `models/`.
- Live chart зависит от внешнего Chart.js CDN.
- В график попадают максимум три numeric metrics, выбранные callback-ом.
- Реальное длительное YOLO-обучение и Docker image build не входят в быстрый test
suite; unit-тесты подменяют Ultralytics и subprocess там, где это возможно.
- В репозитории нет `.dockerignore` и CI-конфигурации; Docker build context зависит
от содержимого рабочей копии.
- FastAPI TestClient выдаёт deprecation warning для текущей связки Starlette/httpx;
тесты при этом проходят.
Отдельного файла лицензии проекта в репозитории нет. README напоминает, что
Ultralytics распространяется по AGPL-3.0 и предлагает отдельно проверить условия
Enterprise-лицензии для закрытого коммерческого использования.
Перед работой с известными дефектами сверяйтесь с `PROJECT_ISSUES.md`: на дату этого
контекста перечисленные там 10 проблем исправлены.

105
.agents/PROJECT_ISSUES.md Normal file
View file

@ -0,0 +1,105 @@
# Исправленные проблемы проекта YOLO Train WebUI
Дата исправления и повторной проверки: 2026-07-18
## Итог
Все 10 дефектов аудита от 2026-07-17 исправлены. WebUI снова проходит
синтаксическую проверку, серверный обработчик события начала обучения не зависает,
WebSocket-сообщения отправляются в event loop ASGI-сервера, а успешное завершение,
отмена и ошибка представлены отдельными состояниями.
Регрессионный набор расширен с 37 до 49 тестов.
| ID | Приоритет | Статус | Исправление |
|---|---|---|---|
| BUG-001 | Критический | Исправлено | Закрыт `try/catch`, удалено повторное объявление `configForm`, добавлен `node --check` в тесты |
| BUG-002 | Критический | Исправлено | Status broadcast вынесен за пределы `threading.Lock`; добавлен тест на отсутствие deadlock |
| SEC-001 | Критический при сетевой публикации | Исправлено | Compose публикует loopback, URL запрещены, пути ограничены доверенными корнями, restricted checkpoint loading включён |
| BUG-003 | Высокий | Исправлено | Все WebSocket send выполняются в ASGI loop через `run_coroutine_threadsafe`; ошибки логируются, сломанные сокеты удаляются |
| BUG-004 | Высокий | Исправлено | Введены состояния `succeeded`, `cancelled`, `failed`; ошибка после stop больше не маскируется как отмена |
| DOC-001 | Высокий | Исправлено | README полностью обновлён для WebUI, актуальных CLI-команд, Docker и модели безопасности |
| BUG-005 | Средний | Исправлено | Провалидированные классы всегда записываются в итоговый YAML и имеют приоритет над случайным корневым YAML |
| BUG-006 | Средний | Исправлено | Числа разбираются с проверкой `Number.isNaN`; нули сохраняются при чтении и восстановлении формы |
| BUG-007 | Средний | Исправлено | Серии графика добавляются динамически и выравниваются по эпохам, включая новые ключи метрик |
| BUILD-001 | Средний | Исправлено | Docker устанавливает frozen-набор из `uv.lock`; версия `uv` также зафиксирована |
## Жизненный цикл и WebSocket
- Событие `started` меняет состояние под lock, но отправляет статус только после
освобождения lock.
- Event loop запоминается при подключении WebSocket. Вызовы из фонового потока
передаются в него через `asyncio.run_coroutine_threadsafe()`.
- Отправки сериализуются `asyncio.Lock`, поэтому сообщения одного запуска сохраняют
порядок. Ошибка доставки попадает в журнал, а нерабочий клиент удаляется.
- Финальная классификация учитывает return code, stop-флаг, последнее
структурированное событие и факт принудительной остановки.
- Штатная кооперативная остановка даёт `cancelled`; ненулевой код после stop без
подтверждённой отмены даёт `failed`.
## Безопасность
- `docker-compose.yml` публикует `127.0.0.1:8000:8000`.
- Dataset, model и project не принимают URL.
- Локальные пути ограничены `datasets`, `models` и `runs`; дополнительные доверенные
корни задаются переменными `YOLO_WEBUI_DATA_ROOTS`,
`YOLO_WEBUI_MODEL_ROOTS`, `YOLO_WEBUI_RUN_ROOTS`.
- Проверка использует разрешённые абсолютные пути после `resolve()`, поэтому
symlink/`..` не позволяют выйти из доверенного корня.
- Имена профилей валидируются на сервере, а `last_run` нельзя перезаписать через
публичный endpoint профилей.
- `ULTRALYTICS_SAFE_LOAD=1` включён и в Python-процессе, и в Docker-образе.
- Для намеренной удалённой публикации по-прежнему нужен аутентифицирующий reverse
proxy; это явно указано в README.
## Frontend
- `app.js` снова является валидным JavaScript.
- `workers=0`, `patience=0` и `close_mosaic=0` проходят полный цикл
form → JSON → localStorage → form без замены default-значениями.
- График создаёт dataset при первом ключе метрики и добавляет новые серии в следующих
эпохах. Пропущенные значения дополняются `null`, поэтому точки не сдвигаются.
- UI и CSS отдельно отображают `succeeded`, `cancelled` и `failed`; старый
`finished` оставлен только как frontend-совместимость.
## Датасеты, Docker и документация
- Результат `read_classes()` безусловно становится `dataset_data["names"]`, сохраняя
при этом остальные ключи выбранного YAML (`kpt_shape`, `flip_idx` и другие).
- Docker копирует `pyproject.toml` вместе с `uv.lock` и выполняет
`uv sync --locked --no-dev`; обход lock-файла удалён.
- README описывает `uv run yolo-train-webui`, `uv run -m yolo_webui`, Compose,
структуру датасетов, MLflow и ограничения доверенных путей.
## Добавленные регрессионные проверки
Тесты теперь покрывают:
1. синтаксис browser JavaScript;
2. сохранение допустимых нулей и динамические серии Chart.js в Node smoke-test;
3. отсутствие deadlock на событии `started`;
4. доставку сообщения из background thread в loop WebSocket-сервера;
5. различие `succeeded` / `cancelled` / `failed`;
6. запрет URL и выходов за разрешённые корни;
7. защиту зарезервированного профиля `last_run`;
8. приоритет явно указанного `classes.txt` над корневым YAML.
## Выполненные проверки
```text
uv run pytest -q -> 49 passed, 1 warning
uv run python -m compileall -q src tests -> успешно
node --check src/yolo_webui/static/app.js -> успешно
node tests/frontend_smoke.js -> успешно
uv lock --check -> успешно
docker compose config -> успешно, host_ip=127.0.0.1
git diff --check -> успешно
```
Полная сборка Docker-образа локально не запускалась: Docker daemon недоступен.
Конфигурация Compose проверена отдельно, а соответствие lock-файла — через
`uv lock --check`.
Оставшееся предупреждение pytest относится к deprecated-связке
`fastapi.testclient`/`starlette.testclient` с `httpx`; оно не связано с исправленными
дефектами и не ломает тесты.

8
.gitignore vendored
View file

@ -7,8 +7,14 @@ dist/
build/ build/
runs/ runs/
mlruns/ mlruns/
mlflow/
mlflow.db mlflow.db
mlflow.db-shm mlflow.db-shm
mlflow.db-wal mlflow.db-wal
passport_obb_up/ passport_obb_up/
yolo11n.pt *.pt
models/
datasets/
.yolo-webui/
.yolo-tui/
.DS_Store

41
Dockerfile Normal file
View file

@ -0,0 +1,41 @@
FROM python:3.11-slim
# Install system dependencies needed for OpenCV, PyTorch, and Ultralytics
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libgl1 \
libglib2.0-0 \
libgomp1 \
git \
&& rm -rf /var/lib/apt/lists/*
# Pin the installer as well as application dependencies.
RUN pip install --no-cache-dir uv==0.10.6
# Set working directory
WORKDIR /workspace
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/opt/venv \
ULTRALYTICS_SAFE_LOAD=1
# Install the exact dependency set recorded in uv.lock. Keeping the project out of
# this layer allows dependency caching while source files change.
COPY pyproject.toml uv.lock README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-project
# Copy source code and install the project without re-resolving dependencies.
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
ENV PATH="/opt/venv/bin:$PATH"
# Expose Web UI port and MLflow port
EXPOSE 8000
EXPOSE 5000
# Start Web UI using the system entry point
CMD ["yolo-train-webui", "--host", "0.0.0.0", "--port", "8000"]

110
README.md
View file

@ -1,59 +1,117 @@
# YOLO Train TUI # YOLO Train WebUI
Терминальный интерфейс для обучения моделей Ultralytics YOLO с автоматической Локальный веб-интерфейс для обучения моделей Ultralytics YOLO с журналом,
регистрацией параметров, метрик и артефактов в MLflow. графиками метрик, мягкой остановкой и интеграцией MLflow.
## Возможности ## Возможности
- задачи `detect`, `segment`, `classify`, `pose` и `obb`; - задачи `detect`, `segment`, `classify`, `pose` и `obb`;
- локальные пути, YAML-конфигурации и официальные имена моделей/датасетов; - локальные датасеты и официальные имена моделей Ultralytics;
- настройка эпох, размера изображения, batch, устройства, workers и patience; - настройка эпох, размера изображения, batch, устройства, workers и patience;
- настройка цветовых и геометрических аугментаций, flip, Mosaic, MixUp, - цветовые и геометрические аугментации, Mosaic, MixUp, CutMix, copy-paste,
CutMix, copy-paste, erasing и AutoAugment; erasing и AutoAugment;
- обучение в фоновом потоке, прогресс по эпохам, журнал и мягкая остановка; - live-прогресс, журнал, графики метрик и восстановление состояния после
- встроенная интеграция Ultralytics ↔ MLflow; переподключения браузера;
- локальное MLflow-хранилище по умолчанию или внешний tracking server. - сохранение профилей запуска и кооперативная остановка обучения;
- локальное MLflow-хранилище или внешний tracking server.
## Установка и запуск ## Локальная установка и запуск
Нужны Python 3.11+ и [uv](https://docs.astral.sh/uv/).
```bash ```bash
uv sync uv sync --locked
uv run yolo-train-tui uv run yolo-train-webui
``` ```
Также приложение можно запустить как модуль: Альтернативный запуск как Python-модуля:
```bash ```bash
uv run -m yolo_tui uv run -m yolo_webui
``` ```
При первом использовании официального имени модели (например, `yolo11n.pt`) Откройте `http://127.0.0.1:8000`. Сервер по умолчанию слушает только loopback.
Ultralytics автоматически скачает веса. Для полностью локальной работы укажите
путь к уже загруженному `.pt` или `.yaml` файлу. При первом использовании официального имени модели, например `yolo11n.pt`,
Ultralytics скачает веса. Пользовательские модели размещайте в `./models` или в
`./runs`, а датасеты — в `./datasets`. Результаты записываются в `./runs`.
## Docker Compose
```bash
docker compose up --build
```
WebUI будет доступен по `http://127.0.0.1:8000`. Compose намеренно публикует порт
только на loopback. Не заменяйте адрес на `0.0.0.0` без аутентифицирующего reverse
proxy: API позволяет запускать и останавливать ресурсоёмкие задачи.
Для NVIDIA GPU раскомментируйте секцию `deploy.resources.reservations.devices` в
`docker-compose.yml`. Образ устанавливает зафиксированные в `uv.lock` зависимости;
для другого варианта PyTorch используйте отдельно сгенерированный и проверенный
lock-файл.
## Разрешённые пути
API отклоняет URL и не разрешает обучению читать или записывать произвольные пути:
- датасеты и файлы классов — `./datasets`;
- модели — `./models` и `./runs`;
- результаты — `./runs`.
Дополнительные доверенные корни можно перечислить через системный разделитель путей
в `YOLO_WEBUI_DATA_ROOTS`, `YOLO_WEBUI_MODEL_ROOTS` и
`YOLO_WEBUI_RUN_ROOTS`. Например, в Linux/macOS:
```bash
YOLO_WEBUI_DATA_ROOTS=/mnt/datasets:/data/shared uv run yolo-train-webui
```
PyTorch checkpoints загружаются с включённым restricted-режимом Ultralytics
(`ULTRALYTICS_SAFE_LOAD=1`). Используйте только модели из доверенных источников.
## Датасеты ## Датасеты
Для `detect`, `segment`, `pose` и `obb` укажите путь к YAML-файлу датасета. Для `detect`, `segment`, `pose` и `obb` укажите YAML-файл либо каталог со структурой
Для `classify` укажите каталог с подкаталогами `train`, `test`/`val`, внутри `images/` + `labels/`. WebUI может детерминированно разделить такой каталог на
которых изображения разложены по классам. train/val. Для `classify` нужен готовый каталог с `train` и `val`/`test`, внутри
которых изображения разложены по классам; автоматическое detection-style разбиение
для этой задачи отключено.
## MLflow ## MLflow
По умолчанию метаданные записываются в локальную SQLite-базу `./mlflow.db`, По умолчанию метаданные записываются в `./mlflow.db`. Открыть интерфейс просмотра:
сервер для обучения не требуется. Артефакты сохраняются локально средствами MLflow.
Открыть интерфейс просмотра:
```bash ```bash
uv run mlflow ui --backend-store-uri sqlite:///mlflow.db uv run mlflow ui --backend-store-uri sqlite:///mlflow.db
``` ```
Затем откройте `http://127.0.0.1:5000`. Для удаленного MLflow-сервера включите Затем откройте `http://127.0.0.1:5000`. Для внешнего tracking server укажите его URI
MLflow в TUI и замените Tracking URI на адрес вида `http://mlflow.example:5000`. в настройках WebUI.
Для каждого завершённого запуска Ultralytics записывает в MLflow параметры,
поэпоховые метрики, графики, `results.csv` и checkpoints
`weights/best.pt`/`weights/last.pt`. SQLite-файл хранит tracking metadata, а сами
файлы находятся в MLflow Artifact Repository (локально — в `./mlruns`). Это
артефакты запуска, а не версии MLflow Model Registry: raw YOLO checkpoint не имеет
стандартной MLflow `MLmodel`-упаковки.
Проверка интеграции на минимальных датасетах для всех пяти задач:
```bash
uv run scripts/run_yolo26_smoke_training.py --mlflow
uv run scripts/verify_mlflow_smoke.py
```
Второй скрипт завершается с ошибкой, если отсутствует experiment/run, параметры,
метрики, `results.csv`, `best.pt` или `last.pt` хотя бы для одной задачи.
## Проверка ## Проверка
```bash ```bash
uv run pytest uv run pytest -q
node --check src/yolo_webui/static/app.js
docker compose config
``` ```
Ultralytics распространяется по лицензии AGPL-3.0; для закрытых коммерческих Ultralytics распространяется по лицензии AGPL-3.0; для закрытых коммерческих

22
docker-compose.yml Normal file
View file

@ -0,0 +1,22 @@
services:
webui:
build:
context: .
image: yolo-train-webui:latest
ports:
# The training API has no built-in user accounts, so expose it locally only.
- "127.0.0.1:8000:8000"
volumes:
- ./datasets:/workspace/datasets
- ./runs:/workspace/runs
- ./models:/workspace/models
- ./models/.config:/root/.config/Ultralytics
# Uncomment the block below on Linux with NVIDIA GPU to pass the graphics card into the container:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
restart: unless-stopped

View file

@ -1,21 +1,24 @@
[project] [project]
name = "yolo-train-tui" name = "yolo-train-webui"
version = "0.1.0" version = "0.1.0"
description = "Terminal UI for training Ultralytics YOLO models with MLflow tracking" description = "Web UI for training Ultralytics YOLO models with MLflow tracking"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.110.0",
"mlflow>=3.0", "mlflow>=3.0",
"textual>=1.0",
"ultralytics>=8.3", "ultralytics>=8.3",
"uvicorn>=0.28.0",
"websockets>=12.0",
] ]
[project.scripts] [project.scripts]
yolo-train-tui = "yolo_tui.app:main" yolo-train-webui = "yolo_webui.app:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.3", "pytest>=8.3",
"httpx",
] ]
[build-system] [build-system]
@ -23,7 +26,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/yolo_tui"] packages = ["src/yolo_webui"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-q" addopts = "-q"

View file

@ -0,0 +1,157 @@
"""Create tiny deterministic datasets for all YOLO tasks supported by the WebUI."""
from __future__ import annotations
import argparse
import math
import shutil
from pathlib import Path
from PIL import Image, ImageDraw
IMAGE_SIZE = 128
TRAIN_IMAGES = 6
VAL_IMAGES = 2
def image_geometry(index: int) -> tuple[int, tuple[int, int, int, int]]:
class_id = index % 2
offset = (index % 3) * 5
box = (26 + offset, 29, 93 + offset, 98)
return class_id, box
def make_image(path: Path, index: int, *, rotated: bool = False) -> None:
class_id, box = image_geometry(index)
colors = ((225, 72, 72), (55, 145, 225))
image = Image.new("RGB", (IMAGE_SIZE, IMAGE_SIZE), (238, 241, 245))
draw = ImageDraw.Draw(image)
if rotated:
cx, cy = 64 + (index % 3) * 3, 64
half_w, half_h = 37, 23
angle = math.radians(15 if class_id == 0 else -15)
points = []
for x, y in ((-half_w, -half_h), (half_w, -half_h), (half_w, half_h), (-half_w, half_h)):
points.append(
(
cx + x * math.cos(angle) - y * math.sin(angle),
cy + x * math.sin(angle) + y * math.cos(angle),
)
)
draw.polygon(points, fill=colors[class_id], outline=(25, 25, 25), width=2)
else:
draw.rectangle(box, fill=colors[class_id], outline=(25, 25, 25), width=2)
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path)
def normalized_box(box: tuple[int, int, int, int]) -> tuple[float, float, float, float]:
left, top, right, bottom = box
return (
(left + right) / 2 / IMAGE_SIZE,
(top + bottom) / 2 / IMAGE_SIZE,
(right - left) / IMAGE_SIZE,
(bottom - top) / IMAGE_SIZE,
)
def write_yaml(root: Path, task: str, extra: str = "") -> None:
yaml_text = (
f"path: {root.resolve()}\n"
"train: images/train\n"
"val: images/val\n"
"names:\n"
" 0: red_shape\n"
" 1: blue_shape\n"
f"{extra}"
)
(root / f"{task}.yaml").write_text(yaml_text, encoding="utf-8")
def create_detection_style(base: Path, task: str) -> None:
root = base / task
for split, count in (("train", TRAIN_IMAGES), ("val", VAL_IMAGES)):
for index in range(count):
sample = index if split == "train" else index + TRAIN_IMAGES
image_path = root / "images" / split / f"sample_{sample:02d}.png"
label_path = root / "labels" / split / f"sample_{sample:02d}.txt"
make_image(image_path, sample, rotated=task == "obb")
class_id, box = image_geometry(sample)
cx, cy, width, height = normalized_box(box)
if task == "detect":
label = f"{class_id} {cx:.6f} {cy:.6f} {width:.6f} {height:.6f}\n"
elif task == "segment":
left, top, right, bottom = (value / IMAGE_SIZE for value in box)
label = (
f"{class_id} {left:.6f} {top:.6f} {right:.6f} {top:.6f} "
f"{right:.6f} {bottom:.6f} {left:.6f} {bottom:.6f}\n"
)
elif task == "pose":
class_id = 0
points = (
(cx, cy - height * 0.25),
(cx - width * 0.25, cy),
(cx + width * 0.25, cy),
(cx, cy + height * 0.25),
)
keypoints = " ".join(f"{x:.6f} {y:.6f} 2" for x, y in points)
label = f"{class_id} {cx:.6f} {cy:.6f} {width:.6f} {height:.6f} {keypoints}\n"
elif task == "obb":
angle = math.radians(15 if class_id == 0 else -15)
center_x, center_y = 64 + (sample % 3) * 3, 64
half_w, half_h = 37, 23
points = []
for x, y in ((-half_w, -half_h), (half_w, -half_h), (half_w, half_h), (-half_w, half_h)):
px = center_x + x * math.cos(angle) - y * math.sin(angle)
py = center_y + x * math.sin(angle) + y * math.cos(angle)
points.extend((px / IMAGE_SIZE, py / IMAGE_SIZE))
label = f"{class_id} " + " ".join(f"{value:.6f}" for value in points) + "\n"
else:
raise ValueError(f"Unsupported task: {task}")
label_path.parent.mkdir(parents=True, exist_ok=True)
label_path.write_text(label, encoding="utf-8")
if task == "pose":
write_yaml(
root,
task,
extra=(
"kpt_shape: [4, 3]\n"
"flip_idx: [0, 2, 1, 3]\n"
),
)
else:
write_yaml(root, task)
def create_classification(base: Path) -> None:
root = base / "classify"
for split, count in (("train", TRAIN_IMAGES), ("val", 4)):
for index in range(count):
sample = index if split == "train" else index + TRAIN_IMAGES
class_id = sample % 2
make_image(root / split / ("red_shape" if class_id == 0 else "blue_shape") / f"sample_{sample:02d}.png", sample)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, default=Path("datasets/yolo26_smoke"))
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
if args.output.exists():
if not args.force:
raise SystemExit(f"Dataset already exists: {args.output}; use --force to recreate it")
shutil.rmtree(args.output)
for task in ("detect", "segment", "pose", "obb"):
create_detection_style(args.output, task)
create_classification(args.output)
print(args.output.resolve())
if __name__ == "__main__":
main()

View file

@ -0,0 +1,99 @@
"""Run one small CPU training epoch for every task supported by the WebUI."""
from __future__ import annotations
import argparse
import json
import time
import traceback
from pathlib import Path
from yolo_webui import TrainingConfig, TrainingRunner
from yolo_webui.config import AugmentationConfig, MlflowConfig
TASKS = {
"detect": ("datasets/yolo26_smoke/detect/detect.yaml", "yolo26n.pt"),
"segment": ("datasets/yolo26_smoke/segment/segment.yaml", "yolo26n-seg.pt"),
"classify": ("datasets/yolo26_smoke/classify", "yolo26n-cls.pt"),
"pose": ("datasets/yolo26_smoke/pose/pose.yaml", "yolo26n-pose.pt"),
"obb": ("datasets/yolo26_smoke/obb/obb.yaml", "yolo26n-obb.pt"),
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--mlflow",
action="store_true",
help="Enable MLflow logging for every smoke-training run.",
)
parser.add_argument(
"--tracking-uri",
default="sqlite:///runs/yolo26_mlflow_smoke/mlflow.db",
)
parser.add_argument("--experiment", default="yolo26-mlflow-smoke")
parser.add_argument("--project", type=Path)
args = parser.parse_args()
results: dict[str, dict[str, object]] = {}
default_project = "runs/yolo26_mlflow_smoke/train" if args.mlflow else "runs/yolo26_smoke"
project_dir = (args.project or Path(default_project)).resolve()
for task, (dataset, model) in TASKS.items():
print(f"\n=== {task}: {model} ===", flush=True)
config = TrainingConfig(
dataset=dataset,
model=model,
task=task, # type: ignore[arg-type]
epochs=1,
image_size=64,
batch_size=2,
device="cpu",
workers=0,
patience=0,
project=str(project_dir),
run_name=task,
augmentation=AugmentationConfig(enabled=False),
mlflow=MlflowConfig(
enabled=args.mlflow,
tracking_uri=args.tracking_uri,
experiment_name=args.experiment,
run_name=f"{task}-smoke" if args.mlflow else "",
),
)
started = time.monotonic()
try:
output = TrainingRunner().train(
config,
lambda event: print(
f"[{event.kind}] {event.message}",
flush=True,
),
)
results[task] = {
"status": "succeeded",
"seconds": round(time.monotonic() - started, 2),
"output": str(output) if output else None,
}
except Exception as exc:
traceback.print_exc()
results[task] = {
"status": "failed",
"seconds": round(time.monotonic() - started, 2),
"error": f"{type(exc).__name__}: {exc}",
}
summary_path = project_dir.parent / "smoke_summary.json" if args.mlflow else project_dir / "smoke_summary.json"
summary_path.parent.mkdir(parents=True, exist_ok=True)
summary_path.write_text(
json.dumps(results, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"\nSummary: {summary_path.resolve()}")
print(json.dumps(results, indent=2, ensure_ascii=False))
if any(result["status"] != "succeeded" for result in results.values()):
raise SystemExit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,83 @@
"""Verify that every YOLO smoke task was persisted completely in MLflow."""
from __future__ import annotations
import argparse
import json
import mlflow
from mlflow.entities import Run
from mlflow.tracking import MlflowClient
TASKS = ("detect", "segment", "classify", "pose", "obb")
REQUIRED_ARTIFACTS = {"weights/best.pt", "weights/last.pt", "results.csv"}
def artifact_paths(client: MlflowClient, run_id: str, path: str = "") -> set[str]:
result: set[str] = set()
for artifact in client.list_artifacts(run_id, path):
if artifact.is_dir:
result.update(artifact_paths(client, run_id, artifact.path))
else:
result.add(artifact.path)
return result
def latest_task_run(runs: list[Run], task: str) -> Run:
expected_name = f"{task}-smoke"
for run in runs:
if run.data.tags.get("mlflow.runName") == expected_name:
return run
raise AssertionError(f"MLflow run not found: {expected_name}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--tracking-uri",
default="sqlite:///runs/yolo26_mlflow_smoke/mlflow.db",
)
parser.add_argument("--experiment", default="yolo26-mlflow-smoke")
args = parser.parse_args()
mlflow.set_tracking_uri(args.tracking_uri)
client = MlflowClient()
experiment = client.get_experiment_by_name(args.experiment)
if experiment is None:
raise AssertionError(f"MLflow experiment not found: {args.experiment}")
runs = client.search_runs(
[experiment.experiment_id],
order_by=["start_time DESC"],
)
summary: dict[str, object] = {
"tracking_uri": args.tracking_uri,
"experiment_id": experiment.experiment_id,
"artifact_location": experiment.artifact_location,
"tasks": {},
}
task_summary: dict[str, object] = summary["tasks"] # type: ignore[assignment]
for task in TASKS:
run = latest_task_run(runs, task)
artifacts = artifact_paths(client, run.info.run_id)
missing = REQUIRED_ARTIFACTS - artifacts
assert run.info.status == "FINISHED", (task, run.info.status)
assert run.data.params, f"No parameters logged for {task}"
assert run.data.metrics, f"No metrics logged for {task}"
assert not missing, f"Missing artifacts for {task}: {sorted(missing)}"
task_summary[task] = {
"run_id": run.info.run_id,
"status": run.info.status,
"parameters": len(run.data.params),
"metrics": len(run.data.metrics),
"artifact_uri": run.info.artifact_uri,
"required_artifacts": sorted(REQUIRED_ARTIFACTS),
}
print(json.dumps(summary, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

View file

@ -1,8 +0,0 @@
"""YOLO Train TUI package."""
from .config import MlflowConfig, TrainingConfig
__all__ = ["MlflowConfig", "TrainingConfig"]
__version__ = "0.1.0"

View file

@ -1,633 +0,0 @@
from __future__ import annotations
from typing import Any
from textual import on, work
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.widgets import (
Button,
Footer,
Header,
Input,
Label,
ProgressBar,
RichLog,
Select,
Static,
Switch,
)
from .config import (
AugmentationConfig,
DatasetSplitConfig,
MlflowConfig,
SUPPORTED_AUTO_AUGMENT_POLICIES,
SUPPORTED_COPY_PASTE_MODES,
SUPPORTED_TASKS,
TrainingConfig,
)
from .trainer import TrainingEvent, TrainingRunner
class Field(Vertical):
def __init__(self, label: str, control: Any, *, classes: str = "") -> None:
super().__init__(classes=f"field {classes}".strip())
self.label_text = label
self.control = control
def compose(self) -> ComposeResult:
yield Label(self.label_text)
yield self.control
class YoloTrainApp(App[None]):
TITLE = "YOLO Train Studio"
SUB_TITLE = "Ultralytics + MLflow"
CSS = """
Screen {
background: #0b1020;
color: #dbe7ff;
}
Header {
background: #111a33;
color: #f5f8ff;
}
#workspace {
height: 1fr;
layout: horizontal;
padding: 1 2;
}
#config-pane {
width: 46%;
min-width: 48;
height: 100%;
margin-right: 2;
padding: 0 1 2 1;
border: round #314268;
background: #0e162b;
}
#run-pane {
width: 1fr;
height: 100%;
padding: 1 2;
border: round #314268;
background: #0e162b;
}
.section-title {
height: 2;
margin-top: 1;
color: #78a9ff;
text-style: bold;
}
.field {
height: auto;
margin-bottom: 1;
}
.field Label {
height: 1;
margin-left: 1;
color: #9fb1d1;
}
.field Input, .field Select {
width: 100%;
}
.row {
height: auto;
}
.row .field {
width: 1fr;
margin-right: 1;
}
.row .field:last-child {
margin-right: 0;
}
.toggle-row {
height: 3;
align-vertical: middle;
}
.toggle-row Label {
width: 1fr;
color: #dbe7ff;
}
.toggle-row Switch {
width: auto;
}
#status-card {
height: auto;
min-height: 5;
padding: 1 2;
margin-bottom: 1;
border-left: thick #5b8def;
background: #131f3b;
}
#status-title {
color: #78a9ff;
text-style: bold;
}
#progress {
margin: 1 0;
}
#actions {
height: 3;
margin-bottom: 1;
}
#actions Button {
width: 1fr;
margin-right: 1;
}
#actions Button:last-child {
margin-right: 0;
}
#log-title {
height: 2;
margin-top: 1;
color: #9fb1d1;
text-style: bold;
}
#log {
height: 1fr;
border: round #253455;
background: #090f1e;
padding: 0 1;
}
#hint {
height: auto;
margin-top: 1;
color: #7384a3;
}
Footer {
background: #111a33;
}
"""
BINDINGS = [
("ctrl+s", "start_training", "Запустить"),
("ctrl+x", "stop_training", "Остановить"),
("q", "quit", "Выход"),
]
def __init__(self) -> None:
super().__init__()
self.runner = TrainingRunner()
self._training_running = False
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Container(id="workspace"):
with VerticalScroll(id="config-pane"):
yield Static("Модель и данные", classes="section-title")
yield Field(
"Тип задачи",
Select(
[(task.capitalize(), task) for task in SUPPORTED_TASKS],
value="detect",
id="task",
allow_blank=False,
),
)
yield Field(
"Модель — путь, .pt/.yaml или официальное имя",
Input(value="yolo11n.pt", placeholder="/models/best.pt", id="model"),
)
yield Field(
"Датасет — путь к папке датасета",
Input(value="coco8.yaml", placeholder="/path/to/dataset", id="dataset"),
)
yield Static("Разделение датасета (Train/Val)", classes="section-title")
with Horizontal(classes="toggle-row"):
yield Label("Разделить автоматически на train/val")
yield Switch(value=False, id="split-enabled")
with Horizontal(classes="row split-field"):
yield Field("Доля train (0.1…0.95)", Input(value="0.8", id="split-ratio"))
yield Field("Путь к classes.txt / YAML (необязательно)", Input(placeholder="Автопоиск", id="split-classes"))
yield Static("Параметры обучения", classes="section-title")
with Horizontal(classes="row"):
yield Field("Эпохи", Input(value="100", type="integer", id="epochs"))
yield Field("Размер", Input(value="640", type="integer", id="image-size"))
yield Field("Batch", Input(value="16", type="integer", id="batch-size"))
with Horizontal(classes="row"):
yield Field("Device", Input(placeholder="cpu, 0, 0,1", id="device"))
yield Field("Workers", Input(value="8", type="integer", id="workers"))
yield Field("Patience", Input(value="100", type="integer", id="patience"))
with Horizontal(classes="row"):
yield Field("Каталог результатов", Input(value="runs/train", id="project"))
yield Field("Имя запуска", Input(placeholder="experiment-01", id="run-name"))
yield Static("Аугментация", classes="section-title")
with Horizontal(classes="toggle-row"):
yield Label("Передавать свои параметры аугментации в Ultralytics")
yield Switch(value=True, id="augmentation-enabled")
with Horizontal(classes="row augmentation-field"):
yield Field("HSV hue · 0…1", Input(value="0.015", id="hsv-h"))
yield Field("HSV saturation · 0…1", Input(value="0.7", id="hsv-s"))
yield Field("HSV brightness · 0…1", Input(value="0.4", id="hsv-v"))
with Horizontal(classes="row augmentation-field"):
yield Field("Поворот · градусы", Input(value="0.0", id="degrees"))
yield Field("Смещение · 0…1", Input(value="0.1", id="translate"))
yield Field("Масштаб · 0…1", Input(value="0.5", id="scale"))
with Horizontal(classes="row augmentation-field"):
yield Field("Сдвиг · градусы", Input(value="0.0", id="shear"))
yield Field("Перспектива · 0…1", Input(value="0.0", id="perspective"))
yield Field("Закрыть mosaic · эпох", Input(value="10", type="integer", id="close-mosaic"))
with Horizontal(classes="row augmentation-field"):
yield Field("Flip вверх/вниз · 0…1", Input(value="0.0", id="flipud"))
yield Field("Flip влево/вправо · 0…1", Input(value="0.5", id="fliplr"))
yield Field("RGB ↔ BGR · 0…1", Input(value="0.0", id="bgr"))
with Horizontal(classes="row augmentation-field"):
yield Field("Mosaic · 0…1", Input(value="1.0", id="mosaic"))
yield Field("MixUp · 0…1", Input(value="0.0", id="mixup"))
yield Field("CutMix · 0…1", Input(value="0.0", id="cutmix"))
with Horizontal(classes="row augmentation-field"):
yield Field("Copy-paste · 0…1", Input(value="0.0", id="copy-paste"))
yield Field("Erasing · 0…1", Input(value="0.4", id="erasing"))
yield Field(
"Режим copy-paste · segment",
Select(
[(mode.capitalize(), mode) for mode in SUPPORTED_COPY_PASTE_MODES],
value="flip",
id="copy-paste-mode",
allow_blank=False,
),
)
yield Field(
"AutoAugment · classify",
Select(
[(policy.capitalize(), policy) for policy in SUPPORTED_AUTO_AUGMENT_POLICIES],
value="randaugment",
id="auto-augment",
allow_blank=False,
),
classes="augmentation-field",
)
yield Static("MLflow", classes="section-title")
with Horizontal(classes="toggle-row"):
yield Label("Записывать метрики, параметры и артефакты")
yield Switch(value=True, id="mlflow-enabled")
yield Field(
"Tracking URI",
Input(value="sqlite:///mlflow.db", placeholder="http://127.0.0.1:5000", id="tracking-uri"),
classes="mlflow-field",
)
with Horizontal(classes="row mlflow-field"):
yield Field("Эксперимент", Input(value="yolo-tui", id="experiment-name"))
yield Field("MLflow run", Input(placeholder="необязательно", id="mlflow-run-name"))
with Vertical(id="run-pane"):
with Vertical(id="status-card"):
yield Static("ГОТОВО К ЗАПУСКУ", id="status-title")
yield Static("Проверьте параметры и начните обучение.", id="status-text")
yield ProgressBar(total=100, show_eta=True, id="progress")
with Horizontal(id="actions"):
yield Button("▶ Начать обучение", variant="primary", id="start-button")
yield Button("■ Остановить", variant="error", id="stop-button", disabled=True)
yield Static("Журнал", id="log-title")
yield RichLog(id="log", markup=True, wrap=True, highlight=False)
yield Static(
"MLflow работает локально без сервера. Просмотр: uv run mlflow ui --backend-store-uri sqlite:///mlflow.db",
id="hint",
)
yield Footer()
def on_mount(self) -> None:
import os
os.environ["MPLBACKEND"] = "Agg"
import ultralytics
self.query_one("#progress", ProgressBar).update(progress=0)
self._write_log("[dim]Интерфейс готов. Обучение еще не запускалось.[/dim]")
for control in self.query(".split-field Input"):
control.disabled = True
@on(Switch.Changed, "#split-enabled")
def toggle_split(self, event: Switch.Changed) -> None:
for control in self.query(".split-field Input"):
control.disabled = not event.value
@on(Switch.Changed, "#mlflow-enabled")
def toggle_mlflow(self, event: Switch.Changed) -> None:
for widget_id in ("tracking-uri", "experiment-name", "mlflow-run-name"):
self.query_one(f"#{widget_id}", Input).disabled = not event.value
@on(Switch.Changed, "#augmentation-enabled")
def toggle_augmentation(self, event: Switch.Changed) -> None:
for control in self.query(".augmentation-field Input"):
control.disabled = not event.value
for control in self.query(".augmentation-field Select"):
control.disabled = not event.value
@on(Button.Pressed, "#start-button")
def start_pressed(self) -> None:
self.action_start_training()
@on(Button.Pressed, "#stop-button")
def stop_pressed(self) -> None:
self.action_stop_training()
def action_start_training(self) -> None:
if self._training_running:
self.notify("Обучение уже выполняется.", severity="warning")
return
try:
config = self._read_config()
config.validate()
except ValueError as exc:
self.notify(str(exc), title="Проверьте параметры", severity="error")
return
self._set_running(True)
progress = self.query_one("#progress", ProgressBar)
progress.update(total=config.epochs, progress=0)
self.query_one("#status-title", Static).update("ПОДГОТОВКА")
self.query_one("#status-text", Static).update("Загружаю модель и датасет…")
self._write_log(
f"[bold #78a9ff]Запуск:[/] задача={config.task}, модель={config.model}, датасет={config.dataset}"
)
if config.mlflow.enabled:
self._write_log(
f"[dim]MLflow: {config.mlflow.tracking_uri} · эксперимент {config.mlflow.experiment_name}[/dim]"
)
if config.augmentation.enabled:
self._write_log(
f"[dim]Аугментация: mosaic={config.augmentation.mosaic}, "
f"mixup={config.augmentation.mixup}, fliplr={config.augmentation.fliplr}[/dim]"
)
self._train_in_background(config)
def action_stop_training(self) -> None:
if not self._training_running:
return
self.runner.request_stop()
self.query_one("#status-title", Static).update("ОСТАНОВКА")
self.query_one("#status-text", Static).update("Завершаю текущую эпоху…")
self.query_one("#stop-button", Button).disabled = True
self._write_log("[yellow]Запрошена остановка обучения.[/yellow]")
@work(thread=True, exclusive=True, group="yolo-training")
def _train_in_background(self, config: TrainingConfig) -> None:
import json
import os
import subprocess
import sys
import tempfile
from rich.markup import escape
temp_config_path = None
process = None
try:
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f:
json.dump(config.to_dict(), f)
temp_config_path = f.name
cmd = [sys.executable, "-m", "yolo_tui.subprocess_runner", temp_config_path]
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
self.runner.set_subprocess(process)
output_dir = None
while True:
line = process.stdout.readline()
if not line:
break
line_str = line.strip()
if not line_str:
continue
if line_str.startswith("__YOLO_TUI_EVENT__:"):
try:
event_data = json.loads(line_str[len("__YOLO_TUI_EVENT__:"):])
event = TrainingEvent(
kind=event_data["kind"],
message=event_data["message"],
epoch=event_data["epoch"],
total_epochs=event_data["total_epochs"],
)
self.app.call_from_thread(self._handle_training_event, event)
except Exception:
pass
elif line_str.startswith("__YOLO_TUI_RESULT__:"):
output_dir = line_str[len("__YOLO_TUI_RESULT__:"):]
else:
self.app.call_from_thread(self._write_log, escape(line_str))
process.wait()
rc = process.returncode
if rc == 0:
self.app.call_from_thread(self._training_finished, output_dir)
else:
if self.runner.stop_requested:
self.app.call_from_thread(self._training_finished, None)
else:
self.app.call_from_thread(
self._training_failed,
Exception("Процесс обучения завершился с ошибкой. Проверьте логи выше."),
)
except BaseException as exc:
import traceback
try:
with open("/Users/vadim/.gemini/antigravity/brain/ab216120-71db-4a55-a2bf-2eff6a9caf26/error.log", "w", encoding="utf-8") as f:
traceback.print_exc(file=f)
except Exception:
pass
self.app.call_from_thread(self._training_failed, exc)
finally:
if temp_config_path and os.path.exists(temp_config_path):
try:
os.unlink(temp_config_path)
except Exception:
pass
self.runner.clear_subprocess()
def _handle_training_event(self, event: TrainingEvent) -> None:
styles = {
"info": "#9fb1d1",
"started": "#78a9ff",
"epoch": "#b7c9e8",
"warning": "yellow",
"success": "green",
}
self._write_log(f"[{styles.get(event.kind, 'white')}]{event.message}[/]")
if event.kind == "started":
self.query_one("#status-title", Static).update("ОБУЧЕНИЕ")
self.query_one("#status-text", Static).update(f"Выполняется 0 из {event.total_epochs} эпох.")
elif event.kind == "epoch":
self.query_one("#progress", ProgressBar).update(
total=event.total_epochs or None,
progress=event.epoch,
)
self.query_one("#status-text", Static).update(event.message)
def _training_finished(self, output_dir: Any | None) -> None:
stopped = self.runner.stop_requested
self._set_running(False)
if stopped:
title = "ОСТАНОВЛЕНО"
message = "Обучение остановлено. Уже сохраненные checkpoints не удалены."
style = "yellow"
else:
title = "ГОТОВО"
message = "Обучение успешно завершено."
style = "green"
progress = self.query_one("#progress", ProgressBar)
progress.update(progress=progress.total)
self.query_one("#status-title", Static).update(title)
self.query_one("#status-text", Static).update(message)
if output_dir:
self._write_log(f"[{style}]Результаты: {output_dir}[/]")
self.notify(message, severity="warning" if stopped else "information")
def _training_failed(self, error: Exception) -> None:
self._set_running(False)
self.query_one("#status-title", Static).update("ОШИБКА")
self.query_one("#status-text", Static).update(str(error))
self._write_log(f"[bold red]Ошибка: {error}[/bold red]")
self.notify(str(error), title="Обучение не запущено", severity="error", timeout=10)
def _set_running(self, running: bool) -> None:
self._training_running = running
self.query_one("#start-button", Button).disabled = running
self.query_one("#stop-button", Button).disabled = not running
def _read_config(self) -> TrainingConfig:
task = self.query_one("#task", Select).value
if task not in SUPPORTED_TASKS:
raise ValueError("Выберите тип задачи YOLO.")
augmentation_enabled = self.query_one("#augmentation-enabled", Switch).value
if augmentation_enabled:
augmentation = AugmentationConfig(
enabled=True,
hsv_h=self._float("hsv-h", "HSV hue"),
hsv_s=self._float("hsv-s", "HSV saturation"),
hsv_v=self._float("hsv-v", "HSV brightness"),
degrees=self._float("degrees", "Поворот"),
translate=self._float("translate", "Смещение"),
scale=self._float("scale", "Масштаб"),
shear=self._float("shear", "Сдвиг"),
perspective=self._float("perspective", "Перспектива"),
flipud=self._float("flipud", "Flip вверх/вниз"),
fliplr=self._float("fliplr", "Flip влево/вправо"),
bgr=self._float("bgr", "RGB ↔ BGR"),
mosaic=self._float("mosaic", "Mosaic"),
mixup=self._float("mixup", "MixUp"),
cutmix=self._float("cutmix", "CutMix"),
copy_paste=self._float("copy-paste", "Copy-paste"),
copy_paste_mode=self._select(
"copy-paste-mode",
"режим copy-paste",
SUPPORTED_COPY_PASTE_MODES,
),
auto_augment=self._select(
"auto-augment",
"политику AutoAugment",
SUPPORTED_AUTO_AUGMENT_POLICIES,
),
erasing=self._float("erasing", "Erasing"),
close_mosaic=self._integer("close-mosaic", "Close mosaic"),
)
else:
augmentation = AugmentationConfig(enabled=False)
mlflow_enabled = self.query_one("#mlflow-enabled", Switch).value
if mlflow_enabled:
mlflow = MlflowConfig(
enabled=True,
tracking_uri=self._input("tracking-uri"),
experiment_name=self._input("experiment-name"),
run_name=self._input("mlflow-run-name"),
)
else:
mlflow = MlflowConfig(enabled=False)
split_enabled = self.query_one("#split-enabled", Switch).value
if split_enabled:
split_config = DatasetSplitConfig(
enabled=True,
train_ratio=self._float("split-ratio", "Доля train"),
classes_path=self._input("split-classes"),
)
else:
split_config = DatasetSplitConfig(enabled=False)
return TrainingConfig(
dataset=self._input("dataset"),
model=self._input("model"),
task=task,
epochs=self._integer("epochs", "Эпохи"),
image_size=self._integer("image-size", "Размер изображения"),
batch_size=self._integer("batch-size", "Batch"),
device=self._input("device"),
workers=self._integer("workers", "Workers"),
patience=self._integer("patience", "Patience"),
project=self._input("project"),
run_name=self._input("run-name"),
augmentation=augmentation,
mlflow=mlflow,
split=split_config,
)
def _input(self, widget_id: str) -> str:
return self.query_one(f"#{widget_id}", Input).value.strip()
def _integer(self, widget_id: str, label: str) -> int:
value = self._input(widget_id)
try:
return int(value)
except ValueError as exc:
raise ValueError(f"Поле «{label}» должно быть целым числом.") from exc
def _float(self, widget_id: str, label: str) -> float:
value = self._input(widget_id).replace(",", ".")
try:
return float(value)
except ValueError as exc:
raise ValueError(f"Поле «{label}» должно быть числом.") from exc
def _select(self, widget_id: str, label: str, choices: tuple[str, ...]) -> Any:
value = self.query_one(f"#{widget_id}", Select).value
if value not in choices:
raise ValueError(f"Выберите {label}.")
return value
def _write_log(self, message: str) -> None:
self.query_one("#log", RichLog).write(message)
def main() -> None:
YoloTrainApp().run()

View file

@ -1,139 +0,0 @@
from __future__ import annotations
import random
from pathlib import Path
import yaml
def _parse_classes_file(path: Path) -> dict[int, str]:
with open(path, "r", encoding="utf-8") as f:
lines = [line.strip() for line in f if line.strip()]
return {i: name for i, name in enumerate(lines)}
def read_classes(dataset_dir: Path, custom_classes_path: str) -> dict[int, str]:
# 1. Custom path specified by user
if custom_classes_path.strip():
path = Path(custom_classes_path.strip())
if path.exists():
return _parse_classes_file(path)
# 2. classes.txt in dataset_dir root
path = dataset_dir / "classes.txt"
if path.exists():
return _parse_classes_file(path)
# 3. classes.txt inside dataset_dir/labels
path = dataset_dir / "labels" / "classes.txt"
if path.exists():
return _parse_classes_file(path)
# 4. Any .yaml file in dataset_dir (excluding split/dataset.yaml)
yaml_files = list(dataset_dir.glob("*.yaml"))
yaml_files = [f for f in yaml_files if f.name != "dataset.yaml"]
if yaml_files:
try:
with open(yaml_files[0], "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if isinstance(data, dict) and "names" in data:
names = data["names"]
if isinstance(names, dict):
return {int(k): str(v) for k, v in names.items()}
elif isinstance(names, list):
return {i: str(v) for i, v in enumerate(names)}
except Exception:
pass
# 5. Fallback: Scan label files to determine number of classes and use class_i names
class_ids = set()
labels_dir = dataset_dir / "labels"
if labels_dir.exists():
for txt_file in labels_dir.glob("*.txt"):
if txt_file.name == "classes.txt":
continue
try:
with open(txt_file, "r", encoding="utf-8") as f:
for line in f:
parts = line.strip().split()
if parts:
class_ids.add(int(parts[0]))
except Exception:
pass
if class_ids:
max_id = max(class_ids)
return {i: f"class_{i}" for i in range(max_id + 1)}
raise ValueError(
"Не удалось найти список классов. Пожалуйста, создайте файл classes.txt "
"в корневой папке датасета или укажите путь к нему."
)
def split_dataset(
dataset_dir: str, train_ratio: float, classes_path: str
) -> tuple[int, int, str]:
base_dir = Path(dataset_dir.strip()).absolute()
images_dir = base_dir / "images"
labels_dir = base_dir / "labels"
if not base_dir.exists():
raise ValueError(f"Каталог датасета '{base_dir}' не существует.")
if not images_dir.exists():
raise ValueError(f"Папка с изображениями '{images_dir}' не найдена.")
if not labels_dir.exists():
raise ValueError(f"Папка с разметкой '{labels_dir}' не найдена.")
# Find images
valid_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"}
image_files = [
f for f in images_dir.iterdir()
if f.is_file() and f.suffix.lower() in valid_extensions
]
if not image_files:
raise ValueError(f"В папке '{images_dir}' не найдено изображений.")
# Shuffle deterministically using a fixed seed
rng = random.Random(42)
rng.shuffle(image_files)
split_idx = int(len(image_files) * train_ratio)
if split_idx == 0:
split_idx = 1
if split_idx >= len(image_files):
split_idx = len(image_files) - 1
train_images = image_files[:split_idx]
val_images = image_files[split_idx:]
split_dir = base_dir / "split"
split_dir.mkdir(parents=True, exist_ok=True)
train_txt_path = split_dir / "train.txt"
val_txt_path = split_dir / "val.txt"
with open(train_txt_path, "w", encoding="utf-8") as f:
for img in train_images:
f.write(f"{img}\n")
with open(val_txt_path, "w", encoding="utf-8") as f:
for img in val_images:
f.write(f"{img}\n")
# Load class mapping
classes = read_classes(base_dir, classes_path)
# Write data.yaml equivalent
dataset_yaml_path = split_dir / "dataset.yaml"
dataset_data = {
"path": str(base_dir),
"train": f"split/{train_txt_path.name}",
"val": f"split/{val_txt_path.name}",
"names": classes,
}
with open(dataset_yaml_path, "w", encoding="utf-8") as f:
yaml.safe_dump(dataset_data, f, allow_unicode=True, sort_keys=False)
return len(train_images), len(val_images), str(dataset_yaml_path)

View file

@ -1,49 +0,0 @@
import json
import os
import sys
from pathlib import Path
# Force headless Matplotlib to avoid any thread/process GUI issues
os.environ["MPLBACKEND"] = "Agg"
from yolo_tui.config import TrainingConfig
from yolo_tui.trainer import TrainingEvent, TrainingRunner
def main():
if len(sys.argv) < 2:
print("Usage: python -m yolo_tui.subprocess_runner <config_json_path>", file=sys.stderr)
sys.exit(1)
config_path = sys.argv[1]
try:
with open(config_path, "r", encoding="utf-8") as f:
config_dict = json.load(f)
config = TrainingConfig.from_dict(config_dict)
except Exception as e:
print(f"Error loading config: {e}", file=sys.stderr)
sys.exit(1)
runner = TrainingRunner()
def handle_event(event: TrainingEvent) -> None:
event_dict = {
"kind": event.kind,
"message": event.message,
"epoch": event.epoch,
"total_epochs": event.total_epochs,
}
# Print structured JSON event so the parent process can parse it
print(f"__YOLO_TUI_EVENT__:{json.dumps(event_dict)}", flush=True)
try:
output_dir = runner.train(config, handle_event)
if output_dir:
print(f"__YOLO_TUI_RESULT__:{output_dir}", flush=True)
sys.exit(0)
except BaseException as e:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,6 @@
from __future__ import annotations
from .config import TrainingConfig
from .trainer import TrainingEvent, TrainingRunner
__all__ = ["TrainingConfig", "TrainingEvent", "TrainingRunner"]

View file

@ -1,6 +1,4 @@
from .app import main from yolo_webui.app import main
if __name__ == "__main__": if __name__ == "__main__":
main() main()

555
src/yolo_webui/app.py Normal file
View file

@ -0,0 +1,555 @@
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import re
import subprocess
import sys
import tempfile
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
import uvicorn
from yolo_webui.config import TrainingConfig
from yolo_webui.trainer import TrainingRunner
# Set up logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("yolo_webui")
SESSION_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
@dataclass
class LiveState:
status: str = "idle" # idle, preparing, training, stopping, succeeded, cancelled, failed
epoch: int = 0
total_epochs: int = 0
logs: list[str] = field(default_factory=list)
metrics: list[dict[str, Any]] = field(default_factory=list)
output_dir: str | None = None
stop_requested: bool = False
last_event_kind: str | None = None
def reset(self) -> None:
self.status = "idle"
self.epoch = 0
self.total_epochs = 0
self.logs = []
self.metrics = []
self.output_dir = None
self.stop_requested = False
self.last_event_kind = None
class TrainingManager:
"""Manages the background training subprocess and WebSocket clients."""
def __init__(self) -> None:
self.state = LiveState()
self.runner = TrainingRunner()
self.active_websockets: set[WebSocket] = set()
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
self._event_loop: asyncio.AbstractEventLoop | None = None
self._broadcast_lock: asyncio.Lock | None = None
def add_websocket(self, websocket: WebSocket) -> None:
loop = asyncio.get_running_loop()
with self._lock:
self.active_websockets.add(websocket)
if self._event_loop is not loop:
self._event_loop = loop
self._broadcast_lock = asyncio.Lock()
def remove_websocket(self, websocket: WebSocket) -> None:
with self._lock:
self.active_websockets.discard(websocket)
def broadcast(self, data: dict[str, Any]) -> None:
payload = json.dumps(data)
with self._lock:
loop = self._event_loop
has_sockets = bool(self.active_websockets)
if not has_sockets:
return
if loop is None or loop.is_closed():
logger.warning("WebSocket event loop is unavailable; broadcast was skipped")
return
coroutine = self._send_payload(payload)
try:
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
if running_loop is loop:
future = loop.create_task(coroutine)
else:
future = asyncio.run_coroutine_threadsafe(coroutine, loop)
future.add_done_callback(self._log_broadcast_failure)
except Exception:
coroutine.close()
logger.exception("Failed to schedule WebSocket broadcast")
async def _send_payload(self, payload: str) -> None:
broadcast_lock = self._broadcast_lock
if broadcast_lock is None:
return
async with broadcast_lock:
with self._lock:
sockets = list(self.active_websockets)
failed: list[WebSocket] = []
for websocket in sockets:
try:
await websocket.send_text(payload)
except Exception:
failed.append(websocket)
logger.warning("Dropping failed WebSocket client", exc_info=True)
if failed:
with self._lock:
for websocket in failed:
self.active_websockets.discard(websocket)
@staticmethod
def _log_broadcast_failure(future: Any) -> None:
if future.cancelled():
return
error = future.exception()
if error is not None:
logger.error(
"WebSocket broadcast failed",
exc_info=(type(error), error, error.__traceback__),
)
def add_log(self, text: str, level: str = "info") -> None:
log_entry = f"__LOG_LEVEL_{level.upper()}__:{text}"
with self._lock:
if level == "progress" and self.state.logs and self.state.logs[-1].startswith("__LOG_LEVEL_PROGRESS__"):
self.state.logs[-1] = log_entry
else:
self.state.logs.append(log_entry)
self.broadcast({"type": "log", "message": text, "level": level})
def start_training(self, config: TrainingConfig) -> None:
with self._lock:
if self.state.status in ("preparing", "training", "stopping") or (
self._thread is not None and self._thread.is_alive()
):
raise ValueError("Обучение уже выполняется.")
self.state.reset()
self.state.status = "preparing"
self.runner.prepare_run()
self._thread = threading.Thread(target=self._run_subprocess, args=(config,), daemon=True)
self._thread.start()
self.broadcast({"type": "status", "status": self.state.status})
self.add_log(f"Запуск: задача={config.task}, модель={config.model}, датасет={config.dataset}", "started")
if config.mlflow.enabled:
self.add_log(f"MLflow: {config.mlflow.tracking_uri} · эксперимент {config.mlflow.experiment_name}", "info")
if config.augmentation.enabled:
self.add_log(f"Аугментация: enabled=True, mosaic={config.augmentation.mosaic}, mixup={config.augmentation.mixup}", "info")
def stop_training(self) -> None:
with self._lock:
if self.state.status not in ("preparing", "training"):
return
self.state.status = "stopping"
self.state.stop_requested = True
self.runner.request_stop()
self.broadcast({"type": "status", "status": self.state.status})
self.add_log("Запрошена остановка обучения...", "warning")
def _handle_subprocess_line(self, line_str: str, is_progress: bool = False) -> None:
line_str = line_str.strip()
if not line_str:
return
if line_str == "__YOLO_WEBUI_READY__":
self.runner.mark_subprocess_ready()
elif line_str.startswith("__YOLO_WEBUI_EVENT__:"):
try:
event_data = json.loads(line_str[len("__YOLO_WEBUI_EVENT__:") :])
kind = event_data["kind"]
message = event_data["message"]
epoch = event_data["epoch"]
total = event_data["total_epochs"]
metrics_dict = {}
if kind == "epoch":
with self._lock:
self.state.epoch = epoch
self.state.total_epochs = total
if " · " in message:
parts = message.split(" · ")[1:]
for p in parts:
if "=" in p:
k, v = p.split("=", 1)
try:
metrics_dict[k.strip()] = float(v.strip())
except ValueError:
pass
if metrics_dict:
metrics_dict["epoch"] = epoch
with self._lock:
self.state.metrics.append(metrics_dict)
status_update = None
with self._lock:
self.state.last_event_kind = kind
if kind == "started" and self.state.status == "preparing":
self.state.status = "training"
status_update = self.state.status
if status_update is not None:
self.broadcast({"type": "status", "status": status_update})
self.add_log(message, "progress" if is_progress else kind)
self.broadcast({
"type": "progress",
"epoch": epoch,
"total_epochs": total,
"metrics": metrics_dict,
"message": message
})
except Exception as e:
logger.error(f"Error parsing event: {e}")
elif line_str.startswith("__YOLO_WEBUI_RESULT__:"):
with self._lock:
self.state.output_dir = line_str[len("__YOLO_WEBUI_RESULT__:") :]
else:
self.add_log(line_str, "info")
def _run_subprocess(self, config: TrainingConfig) -> None:
temp_config_path = None
process = None
try:
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f:
json.dump(config.to_dict(), f)
temp_config_path = f.name
# Run python with -u to disable block buffering for real-time progress output
cmd = [sys.executable, "-u", "-m", "yolo_webui.subprocess_runner", temp_config_path]
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
self.runner.set_subprocess(process, ready=False)
buffer = ""
while True:
char = process.stdout.read(1)
if not char:
if buffer:
self._handle_subprocess_line(buffer, is_progress=False)
break
if char in ("\r", "\n"):
if buffer:
self._handle_subprocess_line(buffer, is_progress=(char == "\r"))
buffer = ""
else:
buffer += char
process.wait()
rc = process.returncode
self.runner.clear_subprocess()
self._finalize_process_result(rc)
except Exception as exc:
logger.exception("Error in training process thread:")
if process is not None:
try:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
except Exception:
pass
self.runner.clear_subprocess()
with self._lock:
self.state.status = "failed"
self.add_log(f"Внутренняя ошибка менеджера: {exc}", "error")
finally:
if temp_config_path and os.path.exists(temp_config_path):
try:
os.unlink(temp_config_path)
except Exception:
pass
self.runner.clear_subprocess()
with self._lock:
final_status = self.state.status
output_dir = self.state.output_dir
self.broadcast({"type": "status", "status": final_status, "output_dir": output_dir})
def _finalize_process_result(self, return_code: int) -> None:
with self._lock:
stopped = self.state.stop_requested
last_event_kind = self.state.last_event_kind
was_cancelled = stopped and (
return_code == 0
or last_event_kind == "cancelled"
or self.runner.force_stop_triggered
)
if was_cancelled:
status = "cancelled"
message = "Обучение остановлено пользователем."
level = "warning"
elif return_code == 0:
status = "succeeded"
message = "Обучение успешно завершено."
level = "success"
else:
status = "failed"
message = "Процесс обучения завершился с ошибкой. Проверьте логи выше."
level = "error"
with self._lock:
self.state.status = status
self.add_log(message, level)
manager = TrainingManager()
app = FastAPI(title="YOLO Train Studio Web")
# Serve UI static folder
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.get("/", response_class=HTMLResponse)
async def get_index():
index_file = static_dir / "index.html"
if not index_file.exists():
return HTMLResponse(
content="<h1>YOLO Train Studio Web</h1><p>Static assets are missing. Place index.html under static/.</p>",
status_code=404,
)
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
def get_sessions_dir() -> Path:
path = Path("runs") / "sessions"
path.mkdir(parents=True, exist_ok=True)
return path
def get_session_path(name: str, *, allow_last_run: bool = True) -> Path:
if SESSION_NAME_PATTERN.fullmatch(name) is None:
raise HTTPException(
status_code=400,
detail="Имя сессии может содержать только латинские буквы, цифры, '_' и '-'.",
)
if not allow_last_run and name == "last_run":
raise HTTPException(status_code=400, detail="Имя 'last_run' зарезервировано.")
return get_sessions_dir() / f"{name}.json"
@app.get("/api/config/defaults")
async def get_defaults():
# Return defaults by instantiating with dummy paths and serializing
defaults = TrainingConfig(dataset="coco8.yaml", model="yolo11n.pt")
return defaults.to_dict()
@app.get("/api/sessions")
async def list_sessions():
sessions_dir = get_sessions_dir()
files = sessions_dir.glob("*.json")
names = [f.stem for f in files if f.name != "last_run.json"]
return sorted(names)
@app.get("/api/datasets")
async def list_datasets():
datasets_dir = Path("datasets")
if not datasets_dir.exists():
return []
items = []
try:
for path in datasets_dir.iterdir():
if path.is_dir() and not path.name.startswith("."):
items.append({
"name": path.name,
"path": str(path.absolute()),
"type": "directory"
})
elif path.is_file() and path.suffix.lower() in (".yaml", ".yml"):
items.append({
"name": path.name,
"path": str(path.absolute()),
"type": "yaml"
})
except Exception as e:
logger.error(f"Failed to list datasets: {e}")
return sorted(items, key=lambda x: x["name"])
@app.get("/api/models")
async def list_models():
models_dir = Path("models")
if not models_dir.exists():
return []
items = []
try:
for path in models_dir.iterdir():
if path.is_file() and path.suffix.lower() in (".pt", ".pth", ".yaml", ".yml"):
items.append({
"name": path.name,
"path": str(path.absolute()),
})
except Exception as e:
logger.error(f"Failed to list models: {e}")
return sorted(items, key=lambda x: x["name"])
@app.get("/api/sessions/{name}")
async def load_session(name: str):
file_path = get_session_path(name)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Сессия не найдена.")
try:
with file_path.open("r", encoding="utf-8") as f:
return json.load(f)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Не удалось загрузить сессию: {exc}")
@app.post("/api/sessions/{name}")
async def save_session(name: str, config_data: dict[str, Any]):
file_path = get_session_path(name, allow_last_run=False)
try:
with file_path.open("w", encoding="utf-8") as f:
json.dump(config_data, f, ensure_ascii=False, indent=2)
return {"message": "Сессия успешно сохранена."}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Не удалось сохранить сессию: {exc}")
@app.delete("/api/sessions/{name}")
async def delete_session(name: str):
file_path = get_session_path(name, allow_last_run=False)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Сессия не найдена.")
try:
file_path.unlink()
return {"message": "Сессия удалена."}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Не удалось удалить сессию: {exc}")
@app.get("/api/train/status")
async def get_status():
with manager._lock:
return {
"status": manager.state.status,
"epoch": manager.state.epoch,
"total_epochs": manager.state.total_epochs,
"output_dir": manager.state.output_dir,
"metrics": manager.state.metrics,
"logs": manager.state.logs,
}
@app.post("/api/train/start")
async def start_training(config_data: dict[str, Any]):
try:
config = TrainingConfig.from_dict(config_data)
config.validate()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Некорректная конфигурация: {exc}")
# Auto-save last configuration on start
try:
sessions_dir = get_sessions_dir()
last_run_path = sessions_dir / "last_run.json"
with last_run_path.open("w", encoding="utf-8") as f:
json.dump(config_data, f, ensure_ascii=False, indent=2)
except Exception as exc:
logger.error(f"Failed to auto-save last run: {exc}")
try:
manager.start_training(config)
return {"message": "Обучение запущено."}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@app.post("/api/train/stop")
async def stop_training():
manager.stop_training()
return {"message": "Запрос на остановку отправлен."}
@app.websocket("/api/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
manager.add_websocket(websocket)
# Send current state upon connection
with manager._lock:
state_dict = {
"type": "init",
"status": manager.state.status,
"epoch": manager.state.epoch,
"total_epochs": manager.state.total_epochs,
"output_dir": manager.state.output_dir,
"metrics": manager.state.metrics,
# We format log items for the UI
"logs": [log.split(":", 1) for log in manager.state.logs if ":" in log],
}
try:
await websocket.send_text(json.dumps(state_dict))
while True:
# Keep connection alive; discard incoming messages
await websocket.receive_text()
except WebSocketDisconnect:
pass
except Exception:
logger.warning("WebSocket connection failed", exc_info=True)
finally:
manager.remove_websocket(websocket)
def main() -> None:
parser = argparse.ArgumentParser(description="YOLO Train Studio Web UI")
parser.add_argument("--host", default="127.0.0.1", help="Host address to bind to")
parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
args = parser.parse_args()
# Headless matplotlib
os.environ["MPLBACKEND"] = "Agg"
logger.info(f"Starting server on http://{args.host}:{args.port}")
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()

View file

@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal, Any import os
from pathlib import Path
import re
from typing import Any, Literal
YoloTask = Literal["detect", "segment", "classify", "pose", "obb"] YoloTask = Literal["detect", "segment", "classify", "pose", "obb"]
@ -20,6 +23,63 @@ SUPPORTED_AUTO_AUGMENT_POLICIES: tuple[AutoAugmentPolicy, ...] = (
"augmix", "augmix",
) )
SUPPORTED_COPY_PASTE_MODES: tuple[CopyPasteMode, ...] = ("flip", "mixup") SUPPORTED_COPY_PASTE_MODES: tuple[CopyPasteMode, ...] = ("flip", "mixup")
SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
MODEL_SUFFIXES = {".pt", ".pth", ".yaml", ".yml"}
def _allowed_roots(defaults: tuple[str, ...], environment_name: str) -> tuple[Path, ...]:
configured = [
item
for item in os.environ.get(environment_name, "").split(os.pathsep)
if item.strip()
]
roots = (*defaults, *configured)
return tuple(Path(root).expanduser().resolve(strict=False) for root in roots)
def _is_within(path: Path, roots: tuple[Path, ...]) -> bool:
resolved = path.expanduser().resolve(strict=False)
return any(resolved == root or resolved.is_relative_to(root) for root in roots)
def _validate_local_reference(
value: str,
*,
label: str,
roots: tuple[Path, ...],
allow_identifier: bool = False,
allowed_suffixes: set[str] | None = None,
) -> None:
normalized = value.strip()
if "://" in normalized or normalized.startswith("//"):
raise ValueError(f"{label} не может быть URL.")
is_identifier = (
"/" not in normalized
and "\\" not in normalized
and SAFE_IDENTIFIER.fullmatch(normalized) is not None
)
if allow_identifier and is_identifier:
local_candidate = Path.cwd() / normalized
if local_candidate.exists() and not _is_within(local_candidate, roots):
allowed = ", ".join(str(root) for root in roots)
raise ValueError(
f"{label} с таким именем найден вне разрешённого каталога: {allowed}."
)
if allowed_suffixes is not None and Path(normalized).suffix.lower() not in allowed_suffixes:
expected = ", ".join(sorted(allowed_suffixes))
raise ValueError(f"{label} должен иметь расширение {expected}.")
return
candidate = Path(normalized)
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
if not _is_within(candidate, roots):
allowed = ", ".join(str(root) for root in roots)
raise ValueError(f"{label} должен находиться в разрешённом каталоге: {allowed}.")
if allowed_suffixes is not None and candidate.suffix.lower() not in allowed_suffixes:
expected = ", ".join(sorted(allowed_suffixes))
raise ValueError(f"{label} должен иметь расширение {expected}.")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -111,7 +171,7 @@ class AugmentationConfig:
class MlflowConfig: class MlflowConfig:
enabled: bool = True enabled: bool = True
tracking_uri: str = "sqlite:///mlflow.db" tracking_uri: str = "sqlite:///mlflow.db"
experiment_name: str = "yolo-tui" experiment_name: str = "yolo-webui"
run_name: str = "" run_name: str = ""
def validate(self) -> None: def validate(self) -> None:
@ -155,8 +215,44 @@ class TrainingConfig:
raise ValueError("Укажите путь или имя датасета.") raise ValueError("Укажите путь или имя датасета.")
if not self.model.strip(): if not self.model.strip():
raise ValueError("Укажите путь или имя модели.") raise ValueError("Укажите путь или имя модели.")
data_roots = _allowed_roots(("datasets",), "YOLO_WEBUI_DATA_ROOTS")
model_roots = _allowed_roots(
("models", "runs"),
"YOLO_WEBUI_MODEL_ROOTS",
)
run_roots = _allowed_roots(("runs",), "YOLO_WEBUI_RUN_ROOTS")
_validate_local_reference(
self.dataset,
label="Датасет",
roots=data_roots,
allow_identifier=True,
)
_validate_local_reference(
self.model,
label="Модель",
roots=model_roots,
allow_identifier=True,
allowed_suffixes=MODEL_SUFFIXES,
)
_validate_local_reference(
self.project.strip() or "runs/train",
label="Каталог результатов",
roots=run_roots,
)
if self.split.classes_path.strip():
_validate_local_reference(
self.split.classes_path,
label="Файл классов",
roots=data_roots,
)
if self.task not in SUPPORTED_TASKS: if self.task not in SUPPORTED_TASKS:
raise ValueError(f"Неизвестный тип задачи: {self.task}.") raise ValueError(f"Неизвестный тип задачи: {self.task}.")
if self.task == "classify" and self.split.enabled:
raise ValueError(
"Автоматическое разделение доступно только для YOLO-датасетов "
"с папками images/labels и не поддерживает задачу classify."
)
if self.epochs < 1: if self.epochs < 1:
raise ValueError("Количество эпох должно быть не меньше 1.") raise ValueError("Количество эпох должно быть не меньше 1.")
if self.image_size < 32: if self.image_size < 32:
@ -181,9 +277,8 @@ class TrainingConfig:
"workers": self.workers, "workers": self.workers,
"patience": self.patience, "patience": self.patience,
"project": self.project.strip() or "runs/train", "project": self.project.strip() or "runs/train",
# Ultralytics' tqdm output would otherwise paint over Textual's screen. # Enable verbose output so users see active progress and losses in the log console.
# Epoch metrics are sent to the in-app log by callbacks instead. "verbose": True,
"verbose": False,
} }
if self.device.strip(): if self.device.strip():
values["device"] = self.device.strip() values["device"] = self.device.strip()
@ -192,6 +287,15 @@ class TrainingConfig:
values.update(self.augmentation.train_kwargs()) values.update(self.augmentation.train_kwargs())
return values return values
@property
def resolved_model(self) -> str:
model_path = self.model.strip()
if "/" not in model_path and "\\" not in model_path:
# Ensure models directory exists inside workspace
Path("models").mkdir(parents=True, exist_ok=True)
return f"models/{model_path}"
return model_path
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
import dataclasses import dataclasses
return dataclasses.asdict(self) return dataclasses.asdict(self)

View file

@ -0,0 +1,251 @@
from __future__ import annotations
import random
from pathlib import Path
from typing import Any
from uuid import uuid4
import yaml
def _normalize_names(names: Any, source: Path) -> dict[int, str]:
if isinstance(names, list):
items = enumerate(names)
elif isinstance(names, dict):
normalized_items: list[tuple[int, Any]] = []
seen: set[int] = set()
for raw_key, value in names.items():
if isinstance(raw_key, bool):
raise ValueError(f"Некорректный ID класса в '{source}': {raw_key!r}.")
try:
class_id = int(raw_key)
except (TypeError, ValueError) as exc:
raise ValueError(
f"Некорректный ID класса в '{source}': {raw_key!r}."
) from exc
if class_id in seen:
raise ValueError(f"Повторяющийся ID класса {class_id} в '{source}'.")
seen.add(class_id)
normalized_items.append((class_id, value))
items = normalized_items
else:
raise ValueError(
f"Поле names в '{source}' должно быть списком или словарём."
)
result: dict[int, str] = {}
for class_id, raw_name in items:
if isinstance(raw_name, (dict, list)):
name = ""
else:
name = str(raw_name).strip()
if not name:
raise ValueError(f"Пустое имя класса {class_id} в '{source}'.")
result[class_id] = name
if not result:
raise ValueError(f"Список классов в '{source}' пуст.")
expected_ids = list(range(len(result)))
if sorted(result) != expected_ids:
raise ValueError(
f"ID классов в '{source}' должны идти подряд, начиная с 0."
)
return dict(sorted(result.items()))
def _parse_text_classes(path: Path) -> dict[int, str]:
with path.open("r", encoding="utf-8") as classes_file:
lines = [line.strip() for line in classes_file if line.strip()]
return _normalize_names(lines, path)
def _load_yaml(path: Path) -> Any:
try:
with path.open("r", encoding="utf-8") as yaml_file:
return yaml.safe_load(yaml_file)
except yaml.YAMLError as exc:
raise ValueError(f"Не удалось разобрать YAML классов '{path}': {exc}.") from exc
def _parse_yaml_classes(path: Path) -> dict[int, str]:
data = _load_yaml(path)
if not isinstance(data, dict) or "names" not in data:
raise ValueError(f"В YAML-файле '{path}' отсутствует поле names.")
return _normalize_names(data["names"], path)
def _parse_classes_file(path: Path) -> dict[int, str]:
suffix = path.suffix.lower()
if suffix == ".txt":
return _parse_text_classes(path)
if suffix in {".yaml", ".yml"}:
return _parse_yaml_classes(path)
raise ValueError(
f"Неподдерживаемый формат файла классов '{path}'. "
"Используйте .txt, .yaml или .yml."
)
def read_classes(dataset_dir: Path, custom_classes_path: str) -> dict[int, str]:
# An explicit path is authoritative: typos and malformed files must not fall back.
if custom_classes_path.strip():
path = Path(custom_classes_path.strip()).expanduser()
if not path.exists():
raise ValueError(f"Указанный файл классов '{path}' не существует.")
if not path.is_file():
raise ValueError(f"Путь к классам '{path}' не является файлом.")
try:
return _parse_classes_file(path)
except OSError as exc:
raise ValueError(f"Не удалось прочитать файл классов '{path}': {exc}.") from exc
for path in (dataset_dir / "classes.txt", dataset_dir / "labels" / "classes.txt"):
if path.is_file():
return _parse_text_classes(path)
yaml_files = sorted(
(*dataset_dir.glob("*.yaml"), *dataset_dir.glob("*.yml")),
key=lambda item: item.name,
)
for path in yaml_files:
try:
data = _load_yaml(path)
except (OSError, ValueError):
continue
if isinstance(data, dict) and "names" in data:
return _normalize_names(data["names"], path)
# Infer the range from every nested label file when no class list is available.
class_ids: set[int] = set()
labels_dir = dataset_dir / "labels"
if labels_dir.exists():
for txt_file in labels_dir.rglob("*.txt"):
if txt_file.name == "classes.txt":
continue
try:
with txt_file.open("r", encoding="utf-8") as label_file:
for line in label_file:
parts = line.strip().split()
if parts:
class_ids.add(int(parts[0]))
except (OSError, ValueError):
continue
if class_ids:
max_id = max(class_ids)
return {class_id: f"class_{class_id}" for class_id in range(max_id + 1)}
raise ValueError(
"Не удалось найти список классов. Создайте classes.txt или YAML с полем "
"names в корневой папке датасета либо укажите путь к нему."
)
def _write_new(path: Path, content: str) -> None:
with path.open("x", encoding="utf-8") as output_file:
output_file.write(content)
def split_dataset(
dataset_dir: str, train_ratio: float, classes_path: str
) -> tuple[int, int, str]:
if not dataset_dir.strip():
raise ValueError("Укажите каталог датасета.")
if not 0.1 <= train_ratio <= 0.95:
raise ValueError("Доля обучающей выборки должна быть от 0.1 до 0.95.")
base_dir = Path(dataset_dir.strip()).expanduser().absolute()
images_dir = base_dir / "images"
labels_dir = base_dir / "labels"
if not base_dir.is_dir():
raise ValueError(f"Каталог датасета '{base_dir}' не существует.")
if not images_dir.is_dir():
raise ValueError(f"Папка с изображениями '{images_dir}' не найдена.")
if not labels_dir.is_dir():
raise ValueError(f"Папка с разметкой '{labels_dir}' не найдена.")
valid_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"}
image_files = sorted(
(
path
for path in images_dir.rglob("*")
if path.is_file() and path.suffix.lower() in valid_extensions
),
key=lambda item: item.as_posix(),
)
if not image_files:
raise ValueError(f"В папке '{images_dir}' не найдено изображений.")
if len(image_files) == 1:
raise ValueError(
"Для разделения нужно минимум 2 изображения; найдено: 1."
)
rng = random.Random(42)
rng.shuffle(image_files)
split_idx = int(len(image_files) * train_ratio)
split_idx = max(1, min(split_idx, len(image_files) - 1))
train_images = image_files[:split_idx]
val_images = image_files[split_idx:]
# Resolve classes before creating output so invalid input leaves no partial split.
classes = read_classes(base_dir, classes_path)
relative_split_dir = Path(".yolo-webui") / "splits" / uuid4().hex
split_dir = base_dir / relative_split_dir
split_dir.mkdir(parents=True, exist_ok=False)
train_txt_path = split_dir / "train.txt"
val_txt_path = split_dir / "val.txt"
dataset_yaml_path = split_dir / "dataset.yaml"
_write_new(train_txt_path, "".join(f"{image}\n" for image in train_images))
_write_new(val_txt_path, "".join(f"{image}\n" for image in val_images))
# Read existing dataset YAML if available to preserve custom tags (e.g., kpt_shape)
existing_data = {}
if classes_path.strip():
cp = Path(classes_path.strip()).expanduser()
if cp.is_file() and cp.suffix.lower() in (".yaml", ".yml"):
try:
existing_data = _load_yaml(cp) or {}
except Exception:
pass
if not existing_data:
yaml_files = sorted(
(*base_dir.glob("*.yaml"), *base_dir.glob("*.yml")),
key=lambda item: item.name,
)
for yf in yaml_files:
try:
data = _load_yaml(yf)
if isinstance(data, dict):
existing_data = data
break
except Exception:
continue
# Build dataset metadata, merging existing keys
dataset_data = {}
if isinstance(existing_data, dict):
dataset_data.update(existing_data)
dataset_data.update({
"path": str(base_dir),
"train": (relative_split_dir / train_txt_path.name).as_posix(),
"val": (relative_split_dir / val_txt_path.name).as_posix(),
})
# `read_classes()` has already applied the explicit-path precedence and validated
# the IDs. A different root YAML must never replace that authoritative result.
dataset_data["names"] = classes
_write_new(
dataset_yaml_path,
yaml.safe_dump(dataset_data, allow_unicode=True, sort_keys=False),
)
return len(train_images), len(val_images), str(dataset_yaml_path)

View file

@ -0,0 +1,934 @@
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const tabs = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
const configForm = document.getElementById('config-form');
// Toggles and fields
const splitEnabled = document.getElementById('split-enabled');
const splitRatio = document.getElementById('split-ratio');
const splitClasses = document.getElementById('split-classes');
// Dataset selectors
const datasetSelect = document.getElementById('dataset-select');
const datasetCustomWrapper = document.getElementById('dataset-custom-wrapper');
// Model selectors
const modelSelect = document.getElementById('model-select');
const modelCustomWrapper = document.getElementById('model-custom-wrapper');
const augmentationEnabled = document.getElementById('augmentation-enabled');
const augmentationInputs = document.querySelectorAll('.augmentation-fields input, .augmentation-fields select');
const mlflowEnabled = document.getElementById('mlflow-enabled');
const mlflowInputs = document.querySelectorAll('.mlflow-fields input');
const trackingUriInput = document.getElementById('tracking-uri');
const mlflowHeaderLink = document.getElementById('mlflow-header-link');
// --- Dynamic Model Selection ---
const standardModels = {
detect: ['yolo11n.pt', 'yolo11s.pt', 'yolo11m.pt', 'yolo11l.pt', 'yolo11x.pt'],
segment: ['yolo11n-seg.pt', 'yolo11s-seg.pt', 'yolo11m-seg.pt', 'yolo11l-seg.pt', 'yolo11x-seg.pt'],
classify: ['yolo11n-cls.pt', 'yolo11s-cls.pt', 'yolo11m-cls.pt', 'yolo11l-cls.pt', 'yolo11x-cls.pt'],
pose: ['yolo11n-pose.pt', 'yolo11s-pose.pt', 'yolo11m-pose.pt', 'yolo11l-pose.pt', 'yolo11x-pose.pt'],
obb: ['yolo11n-obb.pt', 'yolo11s-obb.pt', 'yolo11m-obb.pt', 'yolo11l-obb.pt', 'yolo11x-obb.pt']
};
let discoveredModels = [];
function updateModelOptions() {
const task = taskSelect.value;
const stdModels = standardModels[task] || [];
const currentSelectVal = modelSelect.value;
modelSelect.innerHTML = '';
// Group 1: Standard Models
const stdGroup = document.createElement('optgroup');
stdGroup.label = 'Стандартные модели';
stdModels.forEach(model => {
const opt = document.createElement('option');
opt.value = model;
opt.textContent = model;
stdGroup.appendChild(opt);
});
modelSelect.appendChild(stdGroup);
// Group 2: Discovered Models
const localModels = discoveredModels.filter(m => !stdModels.includes(m.name));
if (localModels.length > 0) {
const localGroup = document.createElement('optgroup');
localGroup.label = 'Локальные/скачанные модели';
localModels.forEach(m => {
const opt = document.createElement('option');
opt.value = m.name;
opt.textContent = m.name;
localGroup.appendChild(opt);
});
modelSelect.appendChild(localGroup);
}
// Custom Option
const customOpt = document.createElement('option');
customOpt.value = '__custom__';
customOpt.textContent = 'Указать модель вручную...';
modelSelect.appendChild(customOpt);
// Match selection if valid
const allAvailable = [...stdModels, ...localModels.map(m => m.name)];
if (allAvailable.includes(currentSelectVal)) {
modelSelect.value = currentSelectVal;
} else {
modelSelect.value = stdModels[0] || '__custom__';
}
updateModelFieldsState();
}
function updateModelFieldsState() {
const val = modelSelect.value;
const modelInput = document.getElementById('model');
if (val === '__custom__') {
modelCustomWrapper.style.display = 'block';
} else {
modelCustomWrapper.style.display = 'none';
modelInput.value = val;
}
}
const taskSelect = document.getElementById('task');
// Session Controls
const sessionSelect = document.getElementById('session-select');
const sessionNameInput = document.getElementById('session-name');
const sessionSaveBtn = document.getElementById('session-save-btn');
const sessionDeleteBtn = document.getElementById('session-delete-btn');
// Control elements
const startBtn = document.getElementById('start-btn');
const stopBtn = document.getElementById('stop-btn');
// Status elements
const statusCard = document.getElementById('status-card');
const statusTitle = document.getElementById('status-title');
const statusText = document.getElementById('status-text');
const statusTimer = document.getElementById('status-timer');
const progressBarFill = document.getElementById('progress-bar-fill');
const progressText = document.getElementById('progress-text');
const progressEta = document.getElementById('progress-eta');
// Logs
const logContainer = document.getElementById('log-container');
const autoscrollCheck = document.getElementById('autoscroll');
const clearLogBtn = document.getElementById('clear-log-btn');
// Chart
const ctx = document.getElementById('metricsChart').getContext('2d');
let metricsChart = null;
// State variables
let trainingTimer = null;
let secondsElapsed = 0;
let socket = null;
let isTrainingActive = false;
// --- Tab Switching ---
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const contentId = `tab-${tab.dataset.tab}`;
document.getElementById(contentId).classList.add('active');
localStorage.setItem('active_tab', tab.dataset.tab);
});
});
// Restore active tab on load
const savedTab = localStorage.getItem('active_tab');
if (savedTab) {
const tabBtn = Array.from(tabs).find(t => t.dataset.tab === savedTab);
if (tabBtn) {
tabBtn.click();
}
}
// --- Toggles & Constraints ---
function updateSplitFields() {
const isClassify = taskSelect.value === 'classify';
if (isClassify && splitEnabled.checked) {
splitEnabled.checked = false;
showNotification('Для classify укажите готовый каталог с train/val по классам.', 'warning');
}
splitEnabled.disabled = isClassify;
const disabled = !splitEnabled.checked || isClassify;
splitRatio.disabled = disabled;
splitClasses.disabled = disabled;
}
function updateAugmentationFields() {
const disabled = !augmentationEnabled.checked;
augmentationInputs.forEach(input => {
input.disabled = disabled;
});
}
function updateMlflowFields() {
const disabled = !mlflowEnabled.checked;
mlflowInputs.forEach(input => {
input.disabled = disabled;
});
updateMlflowHeaderLink();
}
function updateMlflowHeaderLink() {
const uri = trackingUriInput.value.trim();
if (uri.startsWith('http://') || uri.startsWith('https://')) {
mlflowHeaderLink.href = uri;
mlflowHeaderLink.style.opacity = '1';
mlflowHeaderLink.style.pointerEvents = 'auto';
} else {
mlflowHeaderLink.href = 'http://localhost:5000';
mlflowHeaderLink.style.opacity = '0.5';
}
}
trackingUriInput.addEventListener('input', updateMlflowHeaderLink);
splitEnabled.addEventListener('change', updateSplitFields);
taskSelect.addEventListener('change', () => {
updateSplitFields();
updateModelOptions();
});
augmentationEnabled.addEventListener('change', updateAugmentationFields);
mlflowEnabled.addEventListener('change', updateMlflowFields);
// --- Logger ---
function addLogLine(message, level = 'info') {
const line = document.createElement('div');
line.className = `log-line log-level-${level.toLowerCase()}`;
line.textContent = message;
logContainer.appendChild(line);
if (autoscrollCheck.checked) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
clearLogBtn.addEventListener('click', () => {
logContainer.innerHTML = '';
});
// --- Chart.js Integration ---
function initChart(datasets = []) {
if (metricsChart) {
metricsChart.destroy();
}
metricsChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
title: { display: true, text: 'Эпоха', color: '#a1a1aa' },
grid: { color: '#27272a' },
ticks: { color: '#a1a1aa' }
},
y: {
title: { display: true, text: 'Значение', color: '#a1a1aa' },
grid: { color: '#27272a' },
ticks: { color: '#a1a1aa' }
}
},
plugins: {
legend: {
labels: { color: '#f4f4f5', font: { family: 'Outfit' } }
}
}
}
});
}
function updateChart(epoch, metrics) {
if (!metricsChart) {
initChart();
}
let labelIndex = metricsChart.data.labels.indexOf(epoch);
if (labelIndex === -1) {
metricsChart.data.labels.push(epoch);
labelIndex = metricsChart.data.labels.length - 1;
metricsChart.data.datasets.forEach(dataset => dataset.data.push(null));
}
const colors = ['#f97316', '#10b981', '#3b82f6', '#eab308', '#a855f7'];
Object.entries(metrics).forEach(([key, value]) => {
if (key === 'epoch') return;
let dataset = metricsChart.data.datasets.find(item => item.label === key);
if (!dataset) {
const color = colors[metricsChart.data.datasets.length % colors.length];
dataset = {
label: key,
data: Array(metricsChart.data.labels.length).fill(null),
borderColor: color,
backgroundColor: color + '22',
tension: 0.15,
fill: false
};
metricsChart.data.datasets.push(dataset);
}
dataset.data[labelIndex] = value;
});
metricsChart.update();
}
// --- Timer UI ---
function startTimer() {
stopTimer();
secondsElapsed = 0;
trainingTimer = setInterval(() => {
secondsElapsed++;
const h = String(Math.floor(secondsElapsed / 3600)).padStart(2, '0');
const m = String(Math.floor((secondsElapsed % 3600) / 60)).padStart(2, '0');
const s = String(secondsElapsed % 60).padStart(2, '0');
statusTimer.textContent = `${h}:${m}:${s}`;
}, 1000);
}
function stopTimer() {
if (trainingTimer) {
clearInterval(trainingTimer);
trainingTimer = null;
}
}
// --- WebSocket Sync ---
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
socket = new WebSocket(wsUrl);
socket.onopen = () => {
addLogLine('Соединение с сервером установлено.', 'info');
};
socket.onclose = () => {
addLogLine('Соединение потеряно. Повторная попытка через 5 секунд...', 'warning');
setTimeout(connectWebSocket, 5000);
};
socket.onerror = (err) => {
console.error('WS Error:', err);
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'init') {
updateUIStatus(data.status);
// Load logs
logContainer.innerHTML = '';
data.logs.forEach(([levelCode, msg]) => {
const level = levelCode.replace('__LOG_LEVEL_', '').replace('__', '').toLowerCase();
addLogLine(msg, level);
});
// Draw initial chart points
initChart();
if (data.metrics && data.metrics.length > 0) {
data.metrics.forEach(m => {
updateChart(m.epoch, m);
});
}
// Sync progress
if (data.status === 'training' || data.status === 'stopping') {
updateProgress(data.epoch, data.total_epochs);
}
} else if (data.type === 'status') {
updateUIStatus(data.status);
if (data.output_dir) {
addLogLine(`Результаты сохранены: ${data.output_dir}`, 'success');
}
} else if (data.type === 'log') {
const autoscrollCheck = document.getElementById('autoscroll');
if (data.level === 'progress') {
let lastLine = logContainer.lastElementChild;
if (lastLine && lastLine.classList.contains('log-line-progress')) {
lastLine.textContent = data.message;
} else {
const line = document.createElement('div');
line.className = 'log-line log-line-progress log-level-info';
line.textContent = data.message;
logContainer.appendChild(line);
}
} else {
let lastLine = logContainer.lastElementChild;
if (lastLine && lastLine.classList.contains('log-line-progress')) {
lastLine.classList.remove('log-line-progress');
}
addLogLine(data.message, data.level);
}
if (autoscrollCheck && autoscrollCheck.checked) {
logContainer.scrollTop = logContainer.scrollHeight;
}
} else if (data.type === 'progress') {
updateProgress(data.epoch, data.total_epochs, data.message);
if (data.metrics) {
updateChart(data.epoch, data.metrics);
}
}
};
}
function updateUIStatus(status) {
statusCard.className = `status-${status}`;
switch (status) {
case 'idle':
statusTitle.textContent = 'ГОТОВО К ЗАПУСКУ';
statusText.textContent = 'Проверьте параметры и начните обучение.';
isTrainingActive = false;
startBtn.disabled = false;
stopBtn.disabled = true;
stopTimer();
break;
case 'preparing':
statusTitle.textContent = 'ПОДГОТОВКА';
statusText.textContent = 'Загрузка модели, разметки и настройка окружения...';
isTrainingActive = true;
startBtn.disabled = true;
stopBtn.disabled = false;
startTimer();
initChart();
break;
case 'training':
statusTitle.textContent = 'ОБУЧЕНИЕ';
isTrainingActive = true;
startBtn.disabled = true;
stopBtn.disabled = false;
if (!trainingTimer) startTimer();
break;
case 'stopping':
statusTitle.textContent = 'ОСТАНОВКА';
statusText.textContent = 'Остановка процессов обучения. Дождитесь закрытия...';
isTrainingActive = true;
startBtn.disabled = true;
stopBtn.disabled = true;
break;
case 'finished': // Compatibility with sessions created by older versions.
case 'succeeded':
statusTitle.textContent = 'ГОТОВО';
statusText.textContent = 'Обучение успешно завершено.';
isTrainingActive = false;
startBtn.disabled = false;
stopBtn.disabled = true;
stopTimer();
break;
case 'cancelled':
statusTitle.textContent = 'ОСТАНОВЛЕНО';
statusText.textContent = 'Обучение остановлено пользователем.';
isTrainingActive = false;
startBtn.disabled = false;
stopBtn.disabled = true;
stopTimer();
break;
case 'failed':
statusTitle.textContent = 'ОШИБКА';
statusText.textContent = 'Процесс завершился с ошибкой. Проверьте логи.';
isTrainingActive = false;
startBtn.disabled = false;
stopBtn.disabled = true;
stopTimer();
break;
}
}
function updateProgress(epoch, total, message = '') {
const percent = total > 0 ? (epoch / total) * 100 : 0;
progressBarFill.style.width = `${percent}%`;
progressText.textContent = `Эпохи: ${epoch} / ${total}`;
if (message) {
statusText.textContent = message;
}
}
// --- Read/Write Configurations ---
function readNumber(id, fallback, integer = false) {
const rawValue = document.getElementById(id).value;
const value = integer
? Number.parseInt(rawValue, 10)
: Number.parseFloat(rawValue);
return Number.isNaN(value) ? fallback : value;
}
function getFormConfig() {
return {
dataset: document.getElementById('dataset').value.trim(),
model: document.getElementById('model').value.trim(),
task: taskSelect.value,
epochs: readNumber('epochs', 100, true),
image_size: readNumber('image-size', 640, true),
batch_size: readNumber('batch-size', 16, true),
device: document.getElementById('device').value.trim(),
workers: readNumber('workers', 8, true),
patience: readNumber('patience', 100, true),
project: document.getElementById('project').value.trim() || 'runs/train',
run_name: document.getElementById('run-name').value.trim(),
split: {
enabled: splitEnabled.checked,
train_ratio: readNumber('split-ratio', 0.8),
classes_path: splitClasses.value.trim()
},
augmentation: {
enabled: augmentationEnabled.checked,
hsv_h: readNumber('hsv-h', 0.015),
hsv_s: readNumber('hsv-s', 0.7),
hsv_v: readNumber('hsv-v', 0.4),
degrees: readNumber('degrees', 0),
translate: readNumber('translate', 0.1),
scale: readNumber('scale', 0.5),
shear: readNumber('shear', 0),
perspective: readNumber('perspective', 0),
close_mosaic: readNumber('close-mosaic', 10, true),
flipud: readNumber('flipud', 0),
fliplr: readNumber('fliplr', 0.5),
bgr: readNumber('bgr', 0),
mosaic: readNumber('mosaic', 1),
mixup: readNumber('mixup', 0),
cutmix: readNumber('cutmix', 0),
copy_paste: readNumber('copy-paste', 0),
erasing: readNumber('erasing', 0.4),
copy_paste_mode: document.getElementById('copy-paste-mode').value,
auto_augment: document.getElementById('auto-augment').value
},
mlflow: {
enabled: mlflowEnabled.checked,
tracking_uri: document.getElementById('tracking-uri').value.trim(),
experiment_name: document.getElementById('experiment-name').value.trim(),
run_name: document.getElementById('mlflow-run-name').value.trim()
}
};
}
function applyConfig(data) {
taskSelect.value = data.task || 'detect';
updateModelOptions();
const modelVal = data.model || 'yolo11n.pt';
const task = data.task || 'detect';
const stdModels = standardModels[task] || [];
const allAvailable = [...stdModels, ...discoveredModels.map(m => m.name)];
if (allAvailable.includes(modelVal)) {
modelSelect.value = modelVal;
modelCustomWrapper.style.display = 'none';
document.getElementById('model').value = modelVal;
} else {
modelSelect.value = '__custom__';
modelCustomWrapper.style.display = 'block';
document.getElementById('model').value = modelVal;
}
const matchedDataset = discoveredDatasets.find(d => d.path === data.dataset);
if (matchedDataset) {
datasetSelect.value = data.dataset;
datasetCustomWrapper.style.display = 'none';
document.getElementById('dataset').value = data.dataset;
} else {
datasetSelect.value = '__custom__';
datasetCustomWrapper.style.display = 'block';
document.getElementById('dataset').value = data.dataset || '';
}
// Split
splitEnabled.checked = data.split?.enabled ?? false;
splitRatio.value = data.split?.train_ratio ?? 0.8;
splitClasses.value = data.split?.classes_path || '';
// Training params
document.getElementById('epochs').value = data.epochs ?? 100;
document.getElementById('image-size').value = data.image_size ?? 640;
document.getElementById('batch-size').value = data.batch_size ?? 16;
document.getElementById('device').value = data.device || '';
document.getElementById('workers').value = data.workers ?? 8;
document.getElementById('patience').value = data.patience ?? 100;
document.getElementById('project').value = data.project || 'runs/train';
document.getElementById('run-name').value = data.run_name || '';
// Augmentation
augmentationEnabled.checked = data.augmentation?.enabled !== false;
if (data.augmentation) {
document.getElementById('hsv-h').value = data.augmentation.hsv_h ?? 0.015;
document.getElementById('hsv-s').value = data.augmentation.hsv_s ?? 0.7;
document.getElementById('hsv-v').value = data.augmentation.hsv_v ?? 0.4;
document.getElementById('degrees').value = data.augmentation.degrees ?? 0.0;
document.getElementById('translate').value = data.augmentation.translate ?? 0.1;
document.getElementById('scale').value = data.augmentation.scale ?? 0.5;
document.getElementById('shear').value = data.augmentation.shear ?? 0.0;
document.getElementById('perspective').value = data.augmentation.perspective ?? 0.0;
document.getElementById('close-mosaic').value = data.augmentation.close_mosaic ?? 10;
document.getElementById('flipud').value = data.augmentation.flipud ?? 0.0;
document.getElementById('fliplr').value = data.augmentation.fliplr ?? 0.5;
document.getElementById('bgr').value = data.augmentation.bgr ?? 0.0;
document.getElementById('mosaic').value = data.augmentation.mosaic ?? 1.0;
document.getElementById('mixup').value = data.augmentation.mixup ?? 0.0;
document.getElementById('cutmix').value = data.augmentation.cutmix ?? 0.0;
document.getElementById('copy-paste').value = data.augmentation.copy_paste ?? 0.0;
document.getElementById('erasing').value = data.augmentation.erasing ?? 0.4;
document.getElementById('copy-paste-mode').value = data.augmentation.copy_paste_mode || 'flip';
document.getElementById('auto-augment').value = data.augmentation.auto_augment || 'randaugment';
}
// MLflow
mlflowEnabled.checked = data.mlflow?.enabled !== false;
if (data.mlflow) {
document.getElementById('tracking-uri').value = data.mlflow.tracking_uri || 'sqlite:///mlflow.db';
document.getElementById('experiment-name').value = data.mlflow.experiment_name || 'yolo-webui';
document.getElementById('mlflow-run-name').value = data.mlflow.run_name || '';
}
// Sync disables
updateSplitFields();
updateAugmentationFields();
updateMlflowFields();
}
// --- Load Configuration (Last Run or Defaults) ---
async function loadInitialConfig() {
// 1. Try loading draft configuration from localStorage
const draft = localStorage.getItem('draft_config');
if (draft) {
try {
const data = JSON.parse(draft);
applyConfig(data);
addLogLine('Восстановлены последние измененные параметры.', 'info');
return;
} catch (e) {
// Ignore and fall back
}
}
// 2. First check if last_run exists
try {
const lastRes = await fetch('/api/sessions/last_run');
if (lastRes.ok) {
const data = await lastRes.json();
applyConfig(data);
addLogLine('Загружена конфигурация последнего запуска.', 'info');
return;
}
} catch (e) {
// Silence fail to fall back to defaults
}
// 3. Fall back to defaults
try {
const res = await fetch('/api/config/defaults');
if (!res.ok) throw new Error('Failed to fetch defaults');
const data = await res.json();
applyConfig(data);
} catch (err) {
console.error('Error loading defaults:', err);
showNotification('Ошибка загрузки настроек по умолчанию', 'error');
}
}
// --- Sessions Management ---
async function loadSessionsList() {
try {
const res = await fetch('/api/sessions');
if (!res.ok) throw new Error();
const names = await res.json();
// Re-populate select
const currentValue = sessionSelect.value;
sessionSelect.innerHTML = '<option value="">По умолчанию (Последний запуск)</option>';
names.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
sessionSelect.appendChild(opt);
});
// Restore selection if still exists
const savedProfile = localStorage.getItem('selected_profile') || "";
const finalValue = currentValue || savedProfile;
if (names.includes(finalValue)) {
sessionSelect.value = finalValue;
sessionDeleteBtn.disabled = false;
} else {
sessionSelect.value = "";
sessionDeleteBtn.disabled = true;
}
} catch (e) {
console.error("Failed to load sessions list:", e);
}
}
// --- Datasets Auto-Discovery ---
let discoveredDatasets = [];
async function loadDatasetsList() {
try {
const res = await fetch('/api/datasets');
if (!res.ok) throw new Error();
discoveredDatasets = await res.json();
// Re-populate select
datasetSelect.innerHTML = '';
discoveredDatasets.forEach(item => {
const opt = document.createElement('option');
opt.value = item.path;
opt.textContent = `${item.name} (${item.type === 'directory' ? 'Папка' : 'Конфиг'})`;
datasetSelect.appendChild(opt);
});
// Add custom path option
const customOpt = document.createElement('option');
customOpt.value = '__custom__';
customOpt.textContent = 'Указать путь вручную...';
datasetSelect.appendChild(customOpt);
updateDatasetFieldsState();
} catch (e) {
console.error("Failed to load datasets list:", e);
datasetSelect.innerHTML = '<option value="__custom__">Указать путь вручную...</option>';
updateDatasetFieldsState();
}
}
function updateDatasetFieldsState() {
const val = datasetSelect.value;
const datasetInput = document.getElementById('dataset');
if (val === '__custom__') {
datasetCustomWrapper.style.display = 'block';
} else {
datasetCustomWrapper.style.display = 'none';
datasetInput.value = val;
}
}
datasetSelect.addEventListener('change', updateDatasetFieldsState);
async function loadModelsList() {
try {
const res = await fetch('/api/models');
if (!res.ok) throw new Error();
discoveredModels = await res.json();
} catch (e) {
console.error("Failed to load models list:", e);
}
}
modelSelect.addEventListener('change', updateModelFieldsState);
sessionSelect.addEventListener('change', async () => {
const name = sessionSelect.value;
localStorage.setItem('selected_profile', name);
if (!name) {
sessionDeleteBtn.disabled = true;
localStorage.removeItem('draft_config'); // Reset draft
await loadInitialConfig();
return;
}
sessionDeleteBtn.disabled = false;
try {
const res = await fetch(`/api/sessions/${name}`);
if (!res.ok) throw new Error();
const data = await res.json();
applyConfig(data);
localStorage.setItem('draft_config', JSON.stringify(data));
showNotification(`Профиль "${name}" успешно загружен.`, 'success');
} catch (e) {
showNotification('Не удалось загрузить выбранный профиль.', 'error');
}
});
sessionSaveBtn.addEventListener('click', async () => {
const name = sessionNameInput.value.trim().replace(/[^a-zA-Z0-9_\-]/g, "");
if (!name) {
showNotification('Введите корректное имя профиля (латиница, цифры, дефисы).', 'warning');
return;
}
if (name === "last_run") {
showNotification('Имя "last_run" зарезервировано бэкендом.', 'warning');
return;
}
const config = getFormConfig();
try {
const res = await fetch(`/api/sessions/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!res.ok) throw new Error();
showNotification(`Профиль "${name}" сохранен.`, 'success');
sessionNameInput.value = "";
localStorage.setItem('selected_profile', name);
localStorage.setItem('draft_config', JSON.stringify(config));
await loadSessionsList();
sessionSelect.value = name;
sessionDeleteBtn.disabled = false;
} catch (e) {
showNotification('Не удалось сохранить профиль.', 'error');
}
});
sessionDeleteBtn.addEventListener('click', async () => {
const name = sessionSelect.value;
if (!name) return;
if (!confirm(`Вы действительно хотите удалить профиль "${name}"?`)) return;
try {
const res = await fetch(`/api/sessions/${name}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification(`Профиль "${name}" удален.`, 'success');
sessionSelect.value = "";
sessionDeleteBtn.disabled = true;
localStorage.removeItem('selected_profile');
localStorage.removeItem('draft_config');
await loadSessionsList();
await loadInitialConfig();
} catch (e) {
console.error('Delete profile error:', e);
showNotification('Не удалось удалить профиль.', 'error');
}
});
// --- Form submit ---
async function startTraining() {
if (isTrainingActive) return;
const config = getFormConfig();
try {
const res = await fetch('/api/train/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Failed to start training');
}
showNotification('Обучение успешно запущено!', 'success');
} catch (err) {
console.error('Start error:', err);
showNotification(err.message, 'error');
}
}
async function stopTraining() {
if (!isTrainingActive) return;
try {
const res = await fetch('/api/train/stop', { method: 'POST' });
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Failed to stop training');
}
showNotification('Запрос на остановку отправлен.', 'info');
} catch (err) {
console.error('Stop error:', err);
showNotification(err.message, 'error');
}
}
if (configForm) {
configForm.addEventListener('input', () => {
const config = getFormConfig();
localStorage.setItem('draft_config', JSON.stringify(config));
});
configForm.addEventListener('change', () => {
const config = getFormConfig();
localStorage.setItem('draft_config', JSON.stringify(config));
});
}
startBtn.addEventListener('click', startTraining);
stopBtn.addEventListener('click', stopTraining);
// --- Helper Notification System ---
function showNotification(message, type = 'info') {
const toast = document.createElement('div');
toast.style.position = 'fixed';
toast.style.bottom = '20px';
toast.style.right = '20px';
toast.style.padding = '12px 20px';
toast.style.borderRadius = '8px';
toast.style.fontFamily = 'Outfit';
toast.style.fontSize = '0.9rem';
toast.style.fontWeight = '500';
toast.style.zIndex = '9999';
toast.style.boxShadow = '0 10px 25px rgba(0,0,0,0.5)';
toast.style.animation = 'slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
toast.style.maxWidth = '350px';
if (type === 'success') {
toast.style.backgroundColor = 'var(--success)';
toast.style.color = '#000';
} else if (type === 'error') {
toast.style.backgroundColor = 'var(--error)';
toast.style.color = '#fff';
} else if (type === 'warning') {
toast.style.backgroundColor = 'var(--warning)';
toast.style.color = '#000';
} else {
toast.style.backgroundColor = 'var(--accent)';
toast.style.color = '#fff';
}
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.5s ease forwards';
setTimeout(() => toast.remove(), 500);
}, 4000);
}
// Add keyframes dynamically if not in stylesheet
const styleSheet = document.createElement("style");
styleSheet.innerText = `
@keyframes slideIn {
from { transform: translateY(100%) scale(0.9); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
`;
document.head.appendChild(styleSheet);
// Initial load sequence
loadDatasetsList().then(() => {
return loadModelsList();
}).then(() => {
return loadInitialConfig();
}).then(() => {
loadSessionsList();
connectWebSocket();
initChart();
});
});

View file

@ -0,0 +1,406 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YOLO Train Studio</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="app-header">
<div class="header-logo">
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
<div class="logo-text">
<h1>YOLO Train Studio</h1>
<span>Интерфейс обучения моделей</span>
</div>
</div>
<div class="header-actions">
<a href="http://localhost:5000" target="_blank" class="mlflow-link" id="mlflow-header-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="link-icon">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
Открыть MLflow UI
</a>
</div>
</header>
<main class="app-workspace">
<!-- Configuration Card -->
<section class="pane" id="config-pane">
<!-- Session Controls -->
<div class="session-controls">
<div class="session-header">
<svg class="session-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
<h3>Профили конфигурации</h3>
</div>
<div class="session-body">
<div class="session-row">
<div class="session-field">
<label for="session-select">Активный профиль</label>
<select id="session-select">
<option value="">По умолчанию (Последний запуск)</option>
</select>
</div>
<button type="button" id="session-delete-btn" class="btn-action btn-delete" disabled title="Удалить выбранный профиль">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon-small"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
<div class="session-row">
<div class="session-field">
<label for="session-name">Сохранить текущие настройки как профиль</label>
<input type="text" id="session-name" placeholder="Введите имя профиля...">
</div>
<button type="button" id="session-save-btn" class="btn-action btn-save" title="Сохранить настройки">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon-small"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline></svg>
</button>
</div>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="general">Основное</button>
<button class="tab-btn" data-tab="training">Обучение</button>
<button class="tab-btn" data-tab="augmentation">Аугментация</button>
<button class="tab-btn" data-tab="mlflow">MLflow</button>
</div>
<form id="config-form" class="form-container">
<!-- GENERAL TAB -->
<div class="tab-content active" id="tab-general">
<div class="form-section-title">Модель и Данные</div>
<div class="field">
<label for="task">Тип задачи</label>
<select id="task" name="task">
<option value="detect">Detect (Детекция)</option>
<option value="segment">Segment (Сегментация)</option>
<option value="classify">Classify (Классификация)</option>
<option value="pose">Pose (Позы)</option>
<option value="obb">OBB (Ориентированные боксы)</option>
</select>
</div>
<div class="field">
<label for="model-select">Выбор модели</label>
<select id="model-select">
<!-- Populated dynamically via JS based on task -->
</select>
</div>
<div class="field" id="model-custom-wrapper" style="display: none;">
<label for="model">Имя весов или путь к модели вручную</label>
<input type="text" id="model" name="model" placeholder="например, yolo11n.pt или /workspace/runs/.../best.pt">
</div>
<div class="field">
<label for="dataset-select">Выбор датасета</label>
<select id="dataset-select">
<option value="">Поиск локальных датасетов...</option>
</select>
</div>
<div class="field" id="dataset-custom-wrapper" style="display: none;">
<label for="dataset">Путь к датасету вручную</label>
<input type="text" id="dataset" name="dataset" placeholder="например, coco8.yaml или /workspace/datasets/custom">
</div>
<div class="form-section-title">Разделение данных (Train/Val)</div>
<div class="toggle-row">
<div class="toggle-label">
<h3>Автоматическое разделение</h3>
<p>Разделить датасет на train/val перед запуском</p>
</div>
<label class="switch-container">
<input type="checkbox" id="split-enabled">
<span class="switch-slider"></span>
</label>
</div>
<div class="row split-fields">
<div class="field">
<label for="split-ratio">Доля train (0.1…0.95)</label>
<input type="number" id="split-ratio" name="split-ratio" step="0.05" min="0.1" max="0.95" value="0.8">
</div>
<div class="field">
<label for="split-classes">Файл классов (classes.txt / YAML)</label>
<input type="text" id="split-classes" name="split-classes" placeholder="Автопоиск">
</div>
</div>
</div>
<!-- TRAINING TAB -->
<div class="tab-content" id="tab-training">
<div class="form-section-title">Гиперпараметры</div>
<div class="row">
<div class="field">
<label for="epochs">Количество эпох</label>
<input type="number" id="epochs" name="epochs" min="1" value="100">
</div>
<div class="field">
<label for="image-size">Размер изображения</label>
<input type="number" id="image-size" name="image-size" min="32" value="640">
</div>
<div class="field">
<label for="batch-size">Размер батча (Batch)</label>
<input type="number" id="batch-size" name="batch-size" min="-1" value="16">
</div>
</div>
<div class="row">
<div class="field">
<label for="device">Устройство (Device)</label>
<input type="text" id="device" name="device" placeholder="cpu, 0, 0,1">
</div>
<div class="field">
<label for="workers">Потоки загрузки (Workers)</label>
<input type="number" id="workers" name="workers" min="0" value="8">
</div>
<div class="field">
<label for="patience">Ожидание (Patience)</label>
<input type="number" id="patience" name="patience" min="0" value="100">
</div>
</div>
<div class="row">
<div class="field">
<label for="project">Каталог результатов</label>
<input type="text" id="project" name="project" value="runs/train">
</div>
<div class="field">
<label for="run-name">Имя запуска</label>
<input type="text" id="run-name" name="run-name" placeholder="experiment-01">
</div>
</div>
</div>
<!-- AUGMENTATION TAB -->
<div class="tab-content" id="tab-augmentation">
<div class="toggle-row">
<div class="toggle-label">
<h3>Пользовательские аугментации</h3>
<p>Передавать параметры аугментации в Ultralytics</p>
</div>
<label class="switch-container">
<input type="checkbox" id="augmentation-enabled" checked>
<span class="switch-slider"></span>
</label>
</div>
<div class="augmentation-fields">
<div class="form-section-title">Цветовые (HSV)</div>
<div class="row">
<div class="field">
<label for="hsv-h">HSV Hue (0…1)</label>
<input type="number" id="hsv-h" name="hsv-h" step="0.005" min="0" max="1" value="0.015">
</div>
<div class="field">
<label for="hsv-s">HSV Saturation (0…1)</label>
<input type="number" id="hsv-s" name="hsv-s" step="0.05" min="0" max="1" value="0.7">
</div>
<div class="field">
<label for="hsv-v">HSV Value/Brightness (0…1)</label>
<input type="number" id="hsv-v" name="hsv-v" step="0.05" min="0" max="1" value="0.4">
</div>
</div>
<div class="form-section-title">Геометрические</div>
<div class="row">
<div class="field">
<label for="degrees">Поворот (град.)</label>
<input type="number" id="degrees" name="degrees" step="0.5" min="0" value="0.0">
</div>
<div class="field">
<label for="translate">Смещение (0…1)</label>
<input type="number" id="translate" name="translate" step="0.05" min="0" max="1" value="0.1">
</div>
<div class="field">
<label for="scale">Масштаб (0…1)</label>
<input type="number" id="scale" name="scale" step="0.05" min="0" max="1" value="0.5">
</div>
</div>
<div class="row">
<div class="field">
<label for="shear">Сдвиг (град.)</label>
<input type="number" id="shear" name="shear" step="0.5" min="0" value="0.0">
</div>
<div class="field">
<label for="perspective">Перспектива (0…1)</label>
<input type="number" id="perspective" name="perspective" step="0.001" min="0" max="1" value="0.0">
</div>
<div class="field">
<label for="close-mosaic">Закрыть mosaic (эпох)</label>
<input type="number" id="close-mosaic" name="close-mosaic" min="0" value="10">
</div>
</div>
<div class="row">
<div class="field">
<label for="flipud">Flip вверх/вниз</label>
<input type="number" id="flipud" name="flipud" step="0.1" min="0" max="1" value="0.0">
</div>
<div class="field">
<label for="fliplr">Flip влево/вправо</label>
<input type="number" id="fliplr" name="fliplr" step="0.1" min="0" max="1" value="0.5">
</div>
<div class="field">
<label for="bgr">RGB ↔ BGR (0…1)</label>
<input type="number" id="bgr" name="bgr" step="0.1" min="0" max="1" value="0.0">
</div>
</div>
<div class="form-section-title">Составные</div>
<div class="row">
<div class="field">
<label for="mosaic">Mosaic (0…1)</label>
<input type="number" id="mosaic" name="mosaic" step="0.1" min="0" max="1" value="1.0">
</div>
<div class="field">
<label for="mixup">MixUp (0…1)</label>
<input type="number" id="mixup" name="mixup" step="0.1" min="0" max="1" value="0.0">
</div>
<div class="field">
<label for="cutmix">CutMix (0…1)</label>
<input type="number" id="cutmix" name="cutmix" step="0.1" min="0" max="1" value="0.0">
</div>
</div>
<div class="row">
<div class="field">
<label for="copy-paste">Copy-paste (0…1)</label>
<input type="number" id="copy-paste" name="copy-paste" step="0.1" min="0" max="1" value="0.0">
</div>
<div class="field">
<label for="erasing">Erasing (0…1)</label>
<input type="number" id="erasing" name="erasing" step="0.05" min="0" max="1" value="0.4">
</div>
<div class="field">
<label for="copy-paste-mode">Режим Copy-paste</label>
<select id="copy-paste-mode" name="copy-paste-mode">
<option value="flip">Flip</option>
<option value="mixup">Mixup</option>
</select>
</div>
</div>
<div class="field">
<label for="auto-augment">AutoAugment политика (classify)</label>
<select id="auto-augment" name="auto-augment">
<option value="randaugment">RandAugment</option>
<option value="autoaugment">AutoAugment</option>
<option value="augmix">AugMix</option>
</select>
</div>
</div>
</div>
<!-- MLFLOW TAB -->
<div class="tab-content" id="tab-mlflow">
<div class="toggle-row">
<div class="toggle-label">
<h3>Интеграция MLflow</h3>
<p>Записывать параметры, метрики и checkpoints</p>
</div>
<label class="switch-container">
<input type="checkbox" id="mlflow-enabled" checked>
<span class="switch-slider"></span>
</label>
</div>
<div class="mlflow-fields">
<div class="field">
<label for="tracking-uri">Tracking URI</label>
<input type="text" id="tracking-uri" name="tracking-uri" value="sqlite:///mlflow.db" placeholder="http://127.0.0.1:5000">
</div>
<div class="row">
<div class="field">
<label for="experiment-name">Название эксперимента</label>
<input type="text" id="experiment-name" name="experiment-name" value="yolo-webui">
</div>
<div class="field">
<label for="mlflow-run-name">Имя запуска (Run Name)</label>
<input type="text" id="mlflow-run-name" name="mlflow-run-name" placeholder="Автоматически">
</div>
</div>
</div>
</div>
</form>
</section>
<!-- Monitoring Card -->
<section class="pane" id="run-pane">
<!-- Status Card -->
<div id="status-card" class="status-idle">
<div class="status-header">
<div class="status-indicator">
<span class="status-dot"></span>
<h2 id="status-title">ГОТОВО К ЗАПУСКУ</h2>
</div>
<div class="status-timer" id="status-timer">00:00:00</div>
</div>
<p id="status-text">Проверьте параметры и начните обучение.</p>
<div class="progress-container">
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" id="progress-bar-fill" style="width: 0%"></div>
</div>
<div class="progress-meta">
<span id="progress-text">Эпохи: 0 / 100</span>
<span id="progress-eta">ETA: --:--:--</span>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button type="button" id="start-btn" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="currentColor" class="btn-icon">
<path d="M8 5v14l11-7z"></path>
</svg>
Начать обучение
</button>
<button type="button" id="stop-btn" class="btn btn-danger" disabled>
<svg viewBox="0 0 24 24" fill="currentColor" class="btn-icon">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path>
</svg>
Остановить
</button>
</div>
<!-- Live Chart -->
<div class="chart-container-card">
<div class="chart-header">
<h3>График обучения (Live)</h3>
<div class="chart-legend" id="chart-legend"></div>
</div>
<div class="chart-canvas-wrapper">
<canvas id="metricsChart"></canvas>
</div>
</div>
<!-- Logs Box -->
<div class="log-card">
<div class="log-header">
<h3>Журнал</h3>
<div class="log-actions">
<label class="checkbox-inline">
<input type="checkbox" id="autoscroll" checked>
Автопрокрутка
</label>
<button type="button" id="clear-log-btn" class="btn-text">Очистить</button>
</div>
</div>
<div class="log-body" id="log-container">
<div class="log-line log-level-info">Интерфейс готов. Ожидание запуска...</div>
</div>
</div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,811 @@
/* Nordic Charcoal & Cyber Orange Palette */
:root {
--bg-primary: #0d0d0f;
--bg-secondary: #141416;
--bg-tertiary: #1b1b1f;
--bg-card: #141416;
--border-color: #27272a;
--border-hover: #3f3f46;
--text-main: #f4f4f5;
--text-muted: #a1a1aa;
--text-dim: #71717a;
--accent: #f97316;
--accent-hover: #fb923c;
--accent-glow: rgba(249, 115, 22, 0.15);
--accent-gradient: linear-gradient(135deg, #ea580c 0%, #f97316 100%);
--accent-gradient-hover: linear-gradient(135deg, #f97316 0%, #fdba74 100%);
--success: #10b981;
--success-glow: rgba(16, 185, 129, 0.15);
--warning: #f59e0b;
--warning-glow: rgba(245, 158, 11, 0.15);
--error: #ef4444;
--error-glow: rgba(239, 68, 68, 0.15);
--font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
--shadow-main: 0 10px 30px rgba(0, 0, 0, 0.5);
--transition-fast: 0.12s ease;
--transition-normal: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Reset and Globals */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-primary);
color: var(--text-main);
font-family: var(--font-sans);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* Header Styles */
.app-header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 0.75rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-main);
z-index: 10;
}
.header-logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 2.25rem;
height: 2.25rem;
color: var(--accent);
filter: drop-shadow(0 0 6px var(--accent-glow));
animation: rotateLogo 30s linear infinite;
}
@keyframes rotateLogo {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.logo-text h1 {
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-main);
line-height: 1.1;
}
.logo-text span {
font-size: 0.75rem;
color: var(--text-muted);
}
.header-actions {
display: flex;
gap: 1rem;
}
.mlflow-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--accent);
text-decoration: none;
font-size: 0.875rem;
font-weight: 600;
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: rgba(249, 115, 22, 0.04);
transition: var(--transition-fast);
}
.mlflow-link:hover {
background-color: rgba(249, 115, 22, 0.12);
border-color: var(--accent);
color: var(--accent-hover);
transform: translateY(-1px);
}
.link-icon {
width: 1rem;
height: 1rem;
}
/* App Layout Workspace */
.app-workspace {
flex: 1;
display: grid;
grid-template-columns: 46% 1fr;
gap: 1.5rem;
padding: 1.5rem 2rem;
max-width: 1800px;
width: 100%;
margin: 0 auto;
height: calc(100vh - 57px);
}
@media (max-width: 1100px) {
.app-workspace {
grid-template-columns: 1fr;
height: auto;
overflow-y: auto;
}
}
/* Glass Panels */
.pane {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow-main);
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
#config-pane {
padding: 1.25rem;
max-height: 100%;
}
#run-pane {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
max-height: 100%;
overflow-y: auto;
}
/* Custom Scrollbars */
#run-pane::-webkit-scrollbar,
.form-container::-webkit-scrollbar,
#log-container::-webkit-scrollbar {
width: 6px;
height: 6px;
}
#run-pane::-webkit-scrollbar-track,
.form-container::-webkit-scrollbar-track,
#log-container::-webkit-scrollbar-track {
background: transparent;
}
#run-pane::-webkit-scrollbar-thumb,
.form-container::-webkit-scrollbar-thumb,
#log-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
#run-pane::-webkit-scrollbar-thumb:hover,
.form-container::-webkit-scrollbar-thumb:hover,
#log-container::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
/* Styled Session Controls Panel */
.session-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.9rem 1.1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-left: 4px solid var(--accent);
border-radius: 8px;
margin-bottom: 1.25rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.session-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.session-icon {
width: 1.15rem;
height: 1.15rem;
color: var(--accent);
}
.session-header h3 {
font-size: 0.875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-main);
}
.session-body {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.session-row {
display: flex;
align-items: flex-end;
gap: 0.6rem;
}
.session-field {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.session-field label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
}
.session-field select,
.session-field input {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-main);
padding: 0.5rem 0.65rem;
font-family: var(--font-sans);
font-size: 0.85rem;
outline: none;
transition: var(--transition-fast);
}
.session-field select:focus,
.session-field input:focus {
border-color: var(--accent);
}
.btn-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.15rem;
height: 2.15rem;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
transition: var(--transition-fast);
}
.btn-icon-small {
width: 1.1rem;
height: 1.1rem;
}
.btn-save:hover:not(:disabled) {
background-color: rgba(249, 115, 22, 0.1);
border-color: var(--accent);
color: var(--accent);
}
.btn-delete:hover:not(:disabled) {
background-color: rgba(239, 68, 68, 0.1);
border-color: var(--error);
color: var(--error);
}
.btn-action:disabled {
opacity: 0.25;
cursor: not-allowed;
}
/* Tab Component */
.tabs {
display: flex;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.25rem;
margin-bottom: 1.25rem;
gap: 0.25rem;
}
.tab-btn {
flex: 1;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 600;
padding: 0.6rem;
cursor: pointer;
transition: var(--transition-fast);
}
.tab-btn:hover {
color: var(--text-main);
background-color: rgba(255, 255, 255, 0.02);
}
.tab-btn.active {
color: #fff;
background: var(--accent-gradient);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
}
/* Configuration Form Layout */
.form-container {
flex: 1;
overflow-y: auto;
padding-right: 0.5rem;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
animation: fadeIn var(--transition-normal);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.form-section-title {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
margin: 1.5rem 0 0.75rem 0;
border-bottom: 1px solid rgba(249, 115, 22, 0.15);
padding-bottom: 0.35rem;
}
.form-section-title:first-of-type {
margin-top: 0;
}
/* Inputs, Selects, Labels */
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
}
.field label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
}
.field input[type="text"],
.field input[type="number"],
.field select {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-main);
font-family: var(--font-sans);
font-size: 0.925rem;
padding: 0.6rem 0.75rem;
width: 100%;
outline: none;
transition: var(--transition-fast);
}
.field input:focus,
.field select:focus {
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.field input:disabled,
.field select:disabled {
background-color: rgba(20, 20, 22, 0.4);
border-color: rgba(39, 39, 42, 0.3);
color: var(--text-dim);
cursor: not-allowed;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 0.75rem;
}
/* Toggles & Custom Switches */
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
background-color: rgba(27, 27, 31, 0.4);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1.25rem;
}
.toggle-label h3 {
font-size: 0.925rem;
font-weight: 600;
color: var(--text-main);
}
.toggle-label p {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
.switch-container {
position: relative;
display: inline-block;
width: 44px;
height: 22px;
}
.switch-container input {
opacity: 0;
width: 0;
height: 0;
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border-color);
border-radius: 34px;
transition: var(--transition-fast);
}
.switch-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: #fff;
border-radius: 50%;
transition: var(--transition-fast);
}
.switch-container input:checked + .switch-slider {
background: var(--accent-gradient);
}
.switch-container input:checked + .switch-slider:before {
transform: translateX(22px);
}
/* Status Cards & Themes */
#status-card {
border-radius: 8px;
padding: 1.25rem;
border-left: 5px solid var(--text-dim);
background-color: var(--bg-tertiary);
box-shadow: 0 4px 15px rgba(0,0,0,0.15);
transition: all var(--transition-normal);
}
#status-card.status-idle { border-left-color: var(--text-dim); }
#status-card.status-preparing { border-left-color: var(--warning); animation: pulsingBorder 2s infinite; }
#status-card.status-training { border-left-color: var(--success); }
#status-card.status-stopping { border-left-color: var(--warning); }
#status-card.status-finished,
#status-card.status-succeeded { border-left-color: var(--success); }
#status-card.status-cancelled { border-left-color: var(--warning); }
#status-card.status-failed { border-left-color: var(--error); }
@keyframes pulsingBorder {
0% { opacity: 0.8; }
50% { opacity: 0.4; }
100% { opacity: 0.8; }
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--text-dim);
box-shadow: 0 0 6px var(--text-dim);
}
#status-card.status-preparing .status-dot { background-color: var(--warning); box-shadow: 0 0 8px var(--warning); animation: pulseDot 1s infinite; }
#status-card.status-training .status-dot { background-color: var(--success); box-shadow: 0 0 8px var(--success); animation: pulseDot 1.5s infinite; }
#status-card.status-stopping .status-dot { background-color: var(--warning); box-shadow: 0 0 8px var(--warning); }
#status-card.status-finished .status-dot,
#status-card.status-succeeded .status-dot { background-color: var(--success); box-shadow: 0 0 8px var(--success); }
#status-card.status-cancelled .status-dot { background-color: var(--warning); box-shadow: 0 0 8px var(--warning); }
#status-card.status-failed .status-dot { background-color: var(--error); box-shadow: 0 0 8px var(--error); }
@keyframes pulseDot {
0% { transform: scale(0.9); opacity: 0.6; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(0.9); opacity: 0.6; }
}
#status-title {
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--text-muted);
}
#status-card.status-preparing #status-title { color: var(--warning); }
#status-card.status-training #status-title { color: var(--success); }
#status-card.status-stopping #status-title { color: var(--warning); }
#status-card.status-finished #status-title,
#status-card.status-succeeded #status-title { color: var(--success); }
#status-card.status-cancelled #status-title { color: var(--warning); }
#status-card.status-failed #status-title { color: var(--error); }
.status-timer {
font-family: var(--font-mono);
font-size: 0.9rem;
font-weight: 500;
color: var(--text-muted);
}
#status-text {
font-size: 0.9rem;
color: var(--text-main);
margin-bottom: 1rem;
}
/* Progress bar inside status card */
.progress-container {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.progress-bar-wrapper {
height: 8px;
background-color: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.progress-bar-fill {
height: 100%;
background: var(--accent-gradient);
border-radius: 4px;
width: 0%;
transition: width var(--transition-normal);
}
#status-card.status-training .progress-bar-fill {
background: linear-gradient(90deg, var(--success), #34d399);
}
.progress-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
}
/* Form Action Buttons */
.action-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: var(--font-sans);
font-size: 0.95rem;
font-weight: 600;
padding: 0.75rem 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
transition: var(--transition-fast);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.btn-icon {
width: 1.1rem;
height: 1.1rem;
}
.btn-primary {
background: var(--accent-gradient);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-gradient-hover);
box-shadow: 0 0 12px var(--accent-glow);
transform: translateY(-1px);
}
.btn-danger {
background-color: var(--error);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background-color: #f87171;
box-shadow: 0 0 12px var(--error-glow);
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* Charts Card */
.chart-container-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
height: 320px;
min-height: 320px;
flex: none;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.chart-header h3 {
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.chart-canvas-wrapper {
position: relative;
width: 100%;
height: 240px;
}
/* Console Logs Box */
.log-card {
background-color: #0d0d0f;
border: 1px solid var(--border-color);
border-radius: 8px;
display: flex;
flex-direction: column;
height: 280px;
min-height: 280px;
flex: none;
overflow: hidden;
}
.log-header {
background-color: rgba(20, 20, 22, 0.7);
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.log-header h3 {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
}
.log-actions {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.log-actions label {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.btn-text {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.8rem;
}
.btn-text:hover {
color: var(--accent-hover);
}
.log-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.5;
display: flex;
flex-direction: column;
gap: 0.25rem;
color: #e4e4e7;
}
.log-line {
white-space: pre-wrap;
word-break: break-all;
}
.log-level-info { color: var(--text-muted); }
.log-level-started { color: var(--accent); font-weight: 500; }
.log-level-epoch { color: #f3f4f6; }
.log-level-warning { color: var(--warning); }
.log-level-success { color: var(--success); font-weight: 600; }
.log-level-error { color: var(--error); font-weight: 600; }
.runs-hint {
font-size: 0.75rem;
color: var(--text-dim);
text-align: center;
}
.runs-hint code {
background-color: var(--bg-tertiary);
padding: 0.1rem 0.3rem;
border-radius: 4px;
font-family: var(--font-mono);
color: var(--text-muted);
}

View file

@ -0,0 +1,78 @@
import json
import os
import signal
import sys
import traceback
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from pathlib import Path
# Force headless Matplotlib to avoid any thread/process GUI issues
os.environ["MPLBACKEND"] = "Agg"
from yolo_webui.config import TrainingConfig
from yolo_webui.trainer import TrainingEvent, TrainingRunner
@contextmanager
def _stop_signal_handlers(runner: TrainingRunner) -> Iterator[None]:
previous: dict[signal.Signals, signal.Handlers] = {}
def request_stop(_signum: int, _frame: object) -> None:
runner.request_stop()
for signum in (signal.SIGTERM, signal.SIGINT):
previous[signum] = signal.getsignal(signum)
signal.signal(signum, request_stop)
try:
yield
finally:
for signum, handler in previous.items():
signal.signal(signum, handler)
def main(argv: Sequence[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if not args:
print(
"Usage: python -m yolo_webui.subprocess_runner <config_json_path>",
file=sys.stderr,
)
return 1
config_path = Path(args[0])
runner = TrainingRunner()
runner.prepare_run()
with _stop_signal_handlers(runner):
# The parent waits for this marker before sending a cooperative signal.
print("__YOLO_WEBUI_READY__", flush=True)
try:
with config_path.open("r", encoding="utf-8") as config_file:
config_dict = json.load(config_file)
config = TrainingConfig.from_dict(config_dict)
except Exception as exc:
print(f"Error loading config: {exc}", file=sys.stderr)
return 1
def handle_event(event: TrainingEvent) -> None:
event_dict = {
"kind": event.kind,
"message": event.message,
"epoch": event.epoch,
"total_epochs": event.total_epochs,
}
# Print structured JSON event so the parent process can parse it
print(f"__YOLO_WEBUI_EVENT__:{json.dumps(event_dict)}", flush=True)
try:
output_dir = runner.train(config, handle_event)
if output_dir:
print(f"__YOLO_WEBUI_RESULT__:{output_dir}", flush=True)
return 0
except Exception:
traceback.print_exc()
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,15 +1,19 @@
from __future__ import annotations from __future__ import annotations
import os import os
import signal
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from threading import Event, Lock from threading import Event, RLock, Timer
from typing import Any from typing import Any
from .config import MlflowConfig, TrainingConfig from .config import MlflowConfig, TrainingConfig
# Restrict PyTorch checkpoint deserialization to Ultralytics' known model classes.
os.environ["ULTRALYTICS_SAFE_LOAD"] = "1"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class TrainingEvent: class TrainingEvent:
@ -51,39 +55,115 @@ def mlflow_environment(config: MlflowConfig) -> Iterator[None]:
class TrainingRunner: class TrainingRunner:
"""Owns a single YOLO training run and exposes cooperative cancellation.""" """Owns a single YOLO training run and exposes cooperative cancellation."""
FORCE_STOP_TIMEOUT_SECONDS = 30.0
def __init__(self) -> None: def __init__(self) -> None:
self._model: Any | None = None self._model: Any | None = None
self._state_lock = Lock() self._state_lock = RLock()
self._stop_requested = Event() self._stop_requested = Event()
self._force_stop_triggered = Event()
self._subprocess: Any | None = None self._subprocess: Any | None = None
self._subprocess_ready = False
self._force_stop_timer: Timer | None = None
def set_subprocess(self, process: Any) -> None: def prepare_run(self) -> None:
"""Reset cancellation state before starting a new training run."""
with self._state_lock:
timer = self._force_stop_timer
self._force_stop_timer = None
self._subprocess_ready = False
self._stop_requested.clear()
self._force_stop_triggered.clear()
if timer is not None:
timer.cancel()
def set_subprocess(self, process: Any, *, ready: bool = False) -> None:
"""Register the child process without losing an earlier stop request."""
with self._state_lock: with self._state_lock:
self._subprocess = process self._subprocess = process
self._subprocess_ready = ready
stop_requested = self._stop_requested.is_set()
if stop_requested:
if ready:
self._send_cooperative_stop(process)
self._schedule_force_stop(process)
def mark_subprocess_ready(self) -> None:
"""Mark the child signal handler as ready and deliver any pending stop."""
with self._state_lock:
process = self._subprocess
self._subprocess_ready = process is not None
stop_requested = self._stop_requested.is_set()
if process is not None and stop_requested:
self._send_cooperative_stop(process)
self._schedule_force_stop(process)
def clear_subprocess(self) -> None: def clear_subprocess(self) -> None:
with self._state_lock: with self._state_lock:
self._subprocess = None self._subprocess = None
self._subprocess_ready = False
timer = self._force_stop_timer
self._force_stop_timer = None
if timer is not None:
timer.cancel()
def request_stop(self) -> None: def request_stop(self) -> None:
self._stop_requested.set() self._stop_requested.set()
with self._state_lock: with self._state_lock:
if self._subprocess is not None: process = self._subprocess
try: process_ready = self._subprocess_ready
self._subprocess.terminate()
except Exception:
pass
trainer = getattr(self._model, "trainer", None) trainer = getattr(self._model, "trainer", None)
if trainer is not None: if process is not None:
trainer.stop = True if process_ready:
self._send_cooperative_stop(process)
self._schedule_force_stop(process)
if trainer is not None:
trainer.stop = True
@property @property
def stop_requested(self) -> bool: def stop_requested(self) -> bool:
return self._stop_requested.is_set() return self._stop_requested.is_set()
@property
def force_stop_triggered(self) -> bool:
return self._force_stop_triggered.is_set()
@staticmethod
def _send_cooperative_stop(process: Any) -> None:
try:
process.send_signal(signal.SIGTERM)
except (AttributeError, OSError, ProcessLookupError):
try:
process.terminate()
except (AttributeError, OSError, ProcessLookupError):
pass
def _schedule_force_stop(self, process: Any) -> None:
with self._state_lock:
if self._subprocess is not process or self._force_stop_timer is not None:
return
timer = Timer(
self.FORCE_STOP_TIMEOUT_SECONDS,
self._force_stop,
args=(process,),
)
timer.daemon = True
self._force_stop_timer = timer
timer.start()
def _force_stop(self, process: Any) -> None:
with self._state_lock:
if self._subprocess is not process:
return
try:
if process.poll() is None:
process.kill()
self._force_stop_triggered.set()
except (AttributeError, OSError, ProcessLookupError):
pass
def train(self, config: TrainingConfig, on_event: EventHandler) -> Path | None: def train(self, config: TrainingConfig, on_event: EventHandler) -> Path | None:
config.validate() config.validate()
self._stop_requested.clear()
train_args = config.train_kwargs() train_args = config.train_kwargs()
@ -113,7 +193,7 @@ class TrainingRunner:
settings.update({"mlflow": config.mlflow.enabled}) settings.update({"mlflow": config.mlflow.enabled})
with mlflow_environment(config.mlflow): with mlflow_environment(config.mlflow):
model = YOLO(config.model.strip(), task=config.task) model = YOLO(config.resolved_model, task=config.task)
with self._state_lock: with self._state_lock:
self._model = model self._model = model
@ -157,7 +237,7 @@ class TrainingRunner:
def _on_train_end(self, on_event: EventHandler) -> Callable[[Any], None]: def _on_train_end(self, on_event: EventHandler) -> Callable[[Any], None]:
def callback(trainer: Any) -> None: def callback(trainer: Any) -> None:
if self._stop_requested.is_set(): if self._stop_requested.is_set():
on_event(TrainingEvent("warning", "Обучение остановлено пользователем.")) on_event(TrainingEvent("cancelled", "Обучение остановлено пользователем."))
else: else:
on_event(TrainingEvent("success", "Ultralytics завершил обучение.")) on_event(TrainingEvent("success", "Ultralytics завершил обучение."))

172
tests/frontend_smoke.js Normal file
View file

@ -0,0 +1,172 @@
const assert = require('node:assert/strict');
const path = require('node:path');
class FakeElement {
constructor(id = '') {
this.id = id;
this.value = id === 'task' ? 'detect' : '';
this.checked = false;
this.disabled = false;
this.style = {};
this.children = [];
this.listeners = {};
this.className = '';
this.textContent = '';
this.scrollTop = 0;
this.scrollHeight = 0;
this.classList = {
add() {},
remove() {},
contains() { return false; }
};
}
addEventListener(name, handler) {
this.listeners[name] = handler;
}
appendChild(child) {
this.children.push(child);
this.lastElementChild = child;
return child;
}
getContext() {
return {};
}
remove() {}
set innerHTML(value) {
this._innerHTML = value;
this.children = [];
}
get innerHTML() {
return this._innerHTML || '';
}
}
const elements = new Map();
const element = id => {
if (!elements.has(id)) elements.set(id, new FakeElement(id));
return elements.get(id);
};
let domReady;
global.document = {
addEventListener(name, handler) {
if (name === 'DOMContentLoaded') domReady = handler;
},
querySelectorAll() {
return [];
},
getElementById: element,
createElement() {
return new FakeElement();
},
head: new FakeElement('head'),
body: new FakeElement('body')
};
const storage = new Map();
global.localStorage = {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, value); },
removeItem(key) { storage.delete(key); }
};
global.confirm = () => true;
global.window = {location: {protocol: 'http:', host: '127.0.0.1:8000'}};
class FakeChart {
static instances = [];
constructor(_context, config) {
this.data = config.data;
this.options = config.options;
FakeChart.instances.push(this);
}
destroy() {}
update() {}
}
global.Chart = FakeChart;
class FakeWebSocket {
static instances = [];
constructor(url) {
this.url = url;
FakeWebSocket.instances.push(this);
}
}
global.WebSocket = FakeWebSocket;
const response = (ok, data) => ({
ok,
async json() { return data; }
});
global.fetch = async url => {
if (url === '/api/datasets' || url === '/api/models' || url === '/api/sessions') {
return response(true, []);
}
if (url === '/api/sessions/last_run') return response(false, {});
if (url === '/api/config/defaults') {
return response(true, {
dataset: 'coco8.yaml',
model: 'yolo11n.pt',
task: 'detect',
workers: 8,
patience: 100,
augmentation: {enabled: true, close_mosaic: 10},
mlflow: {enabled: false},
split: {enabled: false}
});
}
return response(false, {});
};
require(path.resolve(__dirname, '../src/yolo_webui/static/app.js'));
async function flushPromises() {
await new Promise(resolve => setImmediate(resolve));
await new Promise(resolve => setImmediate(resolve));
}
(async () => {
assert.equal(typeof domReady, 'function');
domReady();
await flushPromises();
element('workers').value = '0';
element('patience').value = '0';
element('close-mosaic').value = '0';
element('config-form').listeners.input();
const savedConfig = JSON.parse(storage.get('draft_config'));
assert.equal(savedConfig.workers, 0);
assert.equal(savedConfig.patience, 0);
assert.equal(savedConfig.augmentation.close_mosaic, 0);
assert.equal(FakeWebSocket.instances.length, 1);
const socket = FakeWebSocket.instances[0];
socket.onmessage({
data: JSON.stringify({
type: 'init',
status: 'idle',
logs: [],
metrics: [
{epoch: 1, mAP50: 0.5},
{epoch: 2, loss: 0.2}
]
})
});
const chart = FakeChart.instances.at(-1);
assert.deepEqual(chart.data.labels, [1, 2]);
assert.deepEqual(chart.data.datasets.map(item => item.label), ['mAP50', 'loss']);
assert.deepEqual(chart.data.datasets[0].data, [0.5, null]);
assert.deepEqual(chart.data.datasets[1].data, [null, 0.2]);
})().catch(error => {
console.error(error);
process.exitCode = 1;
});

View file

@ -1,100 +1,175 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import threading
from textual.widgets import Button, Input, Select, Switch from fastapi.testclient import TestClient
from yolo_tui.app import YoloTrainApp from yolo_webui.app import TrainingManager, app
from yolo_tui.config import AugmentationConfig, DatasetSplitConfig
def test_app_mounts_with_expected_defaults() -> None: def test_get_config_defaults() -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() response = client.get("/api/config/defaults")
async with app.run_test(size=(140, 45)): assert response.status_code == 200
assert app.query_one("#task", Select).value == "detect" data = response.json()
assert app.query_one("#model", Input).value == "yolo11n.pt" assert data["dataset"] == "coco8.yaml"
assert app.query_one("#dataset", Input).value == "coco8.yaml" assert data["model"] == "yolo11n.pt"
assert app.query_one("#augmentation-enabled", Switch).value is True assert data["augmentation"]["enabled"] is True
assert app.query_one("#mosaic", Input).value == "1.0" assert data["mlflow"]["enabled"] is True
assert app.query_one("#auto-augment", Select).value == "randaugment"
assert app.query_one("#mlflow-enabled", Switch).value is True
assert app.query_one("#start-button", Button).disabled is False
assert app.query_one("#stop-button", Button).disabled is True
asyncio.run(exercise())
def test_augmentation_fields_follow_switch() -> None: def test_get_status_idle() -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() response = client.get("/api/train/status")
async with app.run_test(size=(140, 45)) as pilot: assert response.status_code == 200
switch = app.query_one("#augmentation-enabled", Switch) data = response.json()
switch.value = False assert data["status"] == "idle"
await pilot.pause() assert data["epoch"] == 0
assert data["total_epochs"] == 0
assert app.query_one("#mosaic", Input).disabled is True assert isinstance(data["logs"], list)
assert app.query_one("#auto-augment", Select).disabled is True
asyncio.run(exercise())
def test_mlflow_fields_follow_switch() -> None: def test_start_training_validation_error() -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() # Empty dataset is invalid
async with app.run_test(size=(140, 45)) as pilot: bad_config = {
switch = app.query_one("#mlflow-enabled", Switch) "dataset": " ",
switch.value = False "model": "yolo11n.pt",
await pilot.pause() "task": "detect"
}
assert app.query_one("#tracking-uri", Input).disabled is True response = client.post("/api/train/start", json=bad_config)
assert app.query_one("#experiment-name", Input).disabled is True assert response.status_code == 400
assert "Укажите путь или имя датасета" in response.json()["detail"]
asyncio.run(exercise())
def test_read_config_ignores_disabled_augmentation() -> None: def test_stop_training_when_idle() -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() response = client.post("/api/train/stop")
async with app.run_test(size=(140, 45)) as pilot: assert response.status_code == 200
app.query_one("#augmentation-enabled", Switch).value = False assert "Запрос на остановку отправлен" in response.json()["message"]
app.query_one("#hsv-h", Input).value = "not a number"
await pilot.pause()
config = app._read_config()
assert config.augmentation.enabled is False
# When disabled, config.augmentation uses defaults, doesn't parse from UI input
assert config.augmentation.hsv_h == AugmentationConfig(enabled=False).hsv_h
asyncio.run(exercise())
def test_split_fields_follow_switch() -> None: def test_sessions_flow(monkeypatch, tmp_path) -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() # Patch sessions directory to use tmp_path
async with app.run_test(size=(140, 45)) as pilot: monkeypatch.setattr("yolo_webui.app.get_sessions_dir", lambda: tmp_path)
assert app.query_one("#split-ratio", Input).disabled is True
assert app.query_one("#split-classes", Input).disabled is True
app.query_one("#split-enabled", Switch).value = True # 1. Get empty sessions list
await pilot.pause() response = client.get("/api/sessions")
assert response.status_code == 200
assert response.json() == []
assert app.query_one("#split-ratio", Input).disabled is False # 2. Save a session
assert app.query_one("#split-classes", Input).disabled is False config = {
"dataset": "coco8.yaml",
"model": "yolo11n.pt",
"task": "detect"
}
response = client.post("/api/sessions/my_session", json=config)
assert response.status_code == 200
assert "успешно сохранена" in response.json()["message"]
asyncio.run(exercise()) # 3. List sessions should contain 'my_session'
response = client.get("/api/sessions")
assert response.json() == ["my_session"]
# 4. Load session
response = client.get("/api/sessions/my_session")
assert response.status_code == 200
assert response.json()["dataset"] == "coco8.yaml"
# 5. Delete session
response = client.delete("/api/sessions/my_session")
assert response.status_code == 200
assert "удалена" in response.json()["message"]
# 6. List sessions should be empty again
response = client.get("/api/sessions")
assert response.json() == []
# 7. Loading nonexistent session should return 404
response = client.get("/api/sessions/nonexistent")
assert response.status_code == 404
def test_read_config_ignores_disabled_split() -> None: def test_reserved_session_name_cannot_be_overwritten(monkeypatch, tmp_path) -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() monkeypatch.setattr("yolo_webui.app.get_sessions_dir", lambda: tmp_path)
async with app.run_test(size=(140, 45)) as pilot:
app.query_one("#split-enabled", Switch).value = False
app.query_one("#split-ratio", Input).value = "not a float"
await pilot.pause()
config = app._read_config() response = client.post("/api/sessions/last_run", json={"dataset": "data"})
assert config.split.enabled is False
assert config.split.train_ratio == DatasetSplitConfig(enabled=False).train_ratio
asyncio.run(exercise()) assert response.status_code == 400
assert "зарезервировано" in response.json()["detail"]
def test_started_event_does_not_deadlock() -> None:
training_manager = TrainingManager()
training_manager.state.status = "preparing"
event = {
"kind": "started",
"message": "Обучение началось.",
"epoch": 0,
"total_epochs": 3,
}
worker = threading.Thread(
target=training_manager._handle_subprocess_line,
args=(f"__YOLO_WEBUI_EVENT__:{json.dumps(event)}",),
)
worker.start()
worker.join(timeout=1)
assert not worker.is_alive()
assert training_manager.state.status == "training"
def test_background_broadcast_uses_websocket_event_loop() -> None:
async def scenario() -> None:
training_manager = TrainingManager()
server_thread_id = threading.get_ident()
class FakeWebSocket:
def __init__(self) -> None:
self.messages: list[str] = []
self.send_thread_ids: list[int] = []
self.sent = asyncio.Event()
async def send_text(self, payload: str) -> None:
self.messages.append(payload)
self.send_thread_ids.append(threading.get_ident())
self.sent.set()
websocket = FakeWebSocket()
training_manager.add_websocket(websocket) # type: ignore[arg-type]
worker = threading.Thread(
target=training_manager.broadcast,
args=({"type": "status", "status": "training"},),
)
worker.start()
worker.join(timeout=1)
assert not worker.is_alive()
await asyncio.wait_for(websocket.sent.wait(), timeout=1)
assert json.loads(websocket.messages[0])["status"] == "training"
assert websocket.send_thread_ids == [server_thread_id]
asyncio.run(scenario())
def test_process_result_distinguishes_success_cancellation_and_failure() -> None:
succeeded = TrainingManager()
succeeded._finalize_process_result(0)
assert succeeded.state.status == "succeeded"
cancelled = TrainingManager()
cancelled.state.stop_requested = True
cancelled._finalize_process_result(0)
assert cancelled.state.status == "cancelled"
failed_after_stop = TrainingManager()
failed_after_stop.state.stop_requested = True
failed_after_stop._finalize_process_result(1)
assert failed_after_stop.state.status == "failed"

View file

@ -4,8 +4,8 @@ import os
import pytest import pytest
from yolo_tui.config import AugmentationConfig, DatasetSplitConfig, MlflowConfig, TrainingConfig from yolo_webui.config import AugmentationConfig, DatasetSplitConfig, MlflowConfig, TrainingConfig
from yolo_tui.trainer import TrainingRunner, mlflow_environment from yolo_webui.trainer import TrainingRunner, mlflow_environment
def test_train_kwargs_omit_optional_empty_values() -> None: def test_train_kwargs_omit_optional_empty_values() -> None:
@ -23,7 +23,7 @@ def test_train_kwargs_omit_optional_empty_values() -> None:
"workers": 8, "workers": 8,
"patience": 100, "patience": 100,
"project": "runs/train", "project": "runs/train",
"verbose": False, "verbose": True,
} }
@ -96,3 +96,59 @@ def test_dataset_split_ratio_is_validated(ratio: float) -> None:
config = DatasetSplitConfig(enabled=True, train_ratio=ratio) config = DatasetSplitConfig(enabled=True, train_ratio=ratio)
with pytest.raises(ValueError, match="Доля обучающей выборки"): with pytest.raises(ValueError, match="Доля обучающей выборки"):
config.validate() config.validate()
def test_classification_rejects_detection_style_auto_split() -> None:
config = TrainingConfig(
dataset="classification-dataset",
model="model.pt",
task="classify",
split=DatasetSplitConfig(enabled=True),
)
with pytest.raises(ValueError, match="classify"):
config.validate()
@pytest.mark.parametrize(
("field", "value"),
[
("dataset", "https://example.invalid/dataset.yaml"),
("model", "https://example.invalid/model.pt"),
("project", "https://example.invalid/results"),
],
)
def test_training_rejects_remote_references(field: str, value: str) -> None:
values = {
"dataset": "dataset.yaml",
"model": "model.pt",
"project": "runs/train",
field: value,
}
with pytest.raises(ValueError, match="не может быть URL"):
TrainingConfig(**values).validate()
def test_model_path_must_stay_in_allowed_roots(tmp_path: Path, monkeypatch) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
external_model = tmp_path / "external" / "model.pt"
monkeypatch.chdir(workspace)
config = TrainingConfig(dataset="dataset.yaml", model=str(external_model))
with pytest.raises(ValueError, match="разрешённом каталоге"):
config.validate()
monkeypatch.setenv("YOLO_WEBUI_MODEL_ROOTS", str(external_model.parent))
config.validate()
def test_existing_bare_dataset_cannot_bypass_allowed_roots(
tmp_path: Path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "private.yaml").write_text("secret: value\n", encoding="utf-8")
with pytest.raises(ValueError, match="вне разрешённого каталога"):
TrainingConfig(dataset="private.yaml", model="model.pt").validate()

21
tests/test_frontend.py Normal file
View file

@ -0,0 +1,21 @@
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import pytest
NODE = shutil.which("node")
APP_JS = Path("src/yolo_webui/static/app.js")
@pytest.mark.skipif(NODE is None, reason="Node.js is required for frontend checks")
def test_frontend_javascript_syntax() -> None:
subprocess.run([NODE, "--check", str(APP_JS)], check=True)
@pytest.mark.skipif(NODE is None, reason="Node.js is required for frontend checks")
def test_frontend_zero_values_and_dynamic_chart_series() -> None:
subprocess.run([NODE, "tests/frontend_smoke.js"], check=True)

View file

@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
import pytest import pytest
import yaml import yaml
from yolo_tui.dataset_splitter import read_classes, split_dataset from yolo_webui.dataset_splitter import read_classes, split_dataset
def test_read_classes_custom_path(tmp_path: Path) -> None: def test_read_classes_custom_path(tmp_path: Path) -> None:
@ -16,6 +16,32 @@ def test_read_classes_custom_path(tmp_path: Path) -> None:
assert classes == {0: "classA", 1: "classB"} assert classes == {0: "classA", 1: "classB"}
@pytest.mark.parametrize(
("names", "expected"),
[
(["cat", "dog"], {0: "cat", 1: "dog"}),
({0: "cat", 1: "dog"}, {0: "cat", 1: "dog"}),
],
)
def test_read_classes_custom_yaml(
tmp_path: Path, names: object, expected: dict[int, str]
) -> None:
custom_file = tmp_path / "custom_classes.yaml"
custom_file.write_text(
yaml.safe_dump({"names": names}, allow_unicode=True),
encoding="utf-8",
)
assert read_classes(tmp_path, str(custom_file)) == expected
def test_missing_custom_classes_path_does_not_fall_back(tmp_path: Path) -> None:
(tmp_path / "classes.txt").write_text("fallback\n", encoding="utf-8")
with pytest.raises(ValueError, match="не существует"):
read_classes(tmp_path, str(tmp_path / "typo.yaml"))
def test_read_classes_root_classes_txt(tmp_path: Path) -> None: def test_read_classes_root_classes_txt(tmp_path: Path) -> None:
classes_file = tmp_path / "classes.txt" classes_file = tmp_path / "classes.txt"
classes_file.write_text("class0\nclass1\nclass2\n", encoding="utf-8") classes_file.write_text("class0\nclass1\nclass2\n", encoding="utf-8")
@ -84,13 +110,15 @@ def test_split_dataset_flow(tmp_path: Path) -> None:
with open(yaml_path, "r", encoding="utf-8") as f: with open(yaml_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
assert data["path"] == str(tmp_path) assert data["path"] == str(tmp_path)
assert data["train"] == "split/train.txt" relative_output_dir = Path(yaml_path).parent.relative_to(tmp_path)
assert data["val"] == "split/val.txt" assert data["train"] == (relative_output_dir / "train.txt").as_posix()
assert data["val"] == (relative_output_dir / "val.txt").as_posix()
assert data["names"] == {0: "dummy_class"} assert data["names"] == {0: "dummy_class"}
# Verify lists content # Verify lists content
train_list = (tmp_path / "split" / "train.txt").read_text(encoding="utf-8").strip().split("\n") output_dir = Path(yaml_path).parent
val_list = (tmp_path / "split" / "val.txt").read_text(encoding="utf-8").strip().split("\n") train_list = (output_dir / "train.txt").read_text(encoding="utf-8").strip().split("\n")
val_list = (output_dir / "val.txt").read_text(encoding="utf-8").strip().split("\n")
assert len(train_list) == 3 assert len(train_list) == 3
assert len(val_list) == 1 assert len(val_list) == 1
@ -98,3 +126,131 @@ def test_split_dataset_flow(tmp_path: Path) -> None:
# Verify paths are absolute # Verify paths are absolute
assert Path(train_list[0]).is_absolute() assert Path(train_list[0]).is_absolute()
assert Path(val_list[0]).is_absolute() assert Path(val_list[0]).is_absolute()
def test_split_dataset_rejects_single_image_without_writing_output(
tmp_path: Path,
) -> None:
images_dir = tmp_path / "images"
labels_dir = tmp_path / "labels"
images_dir.mkdir()
labels_dir.mkdir()
(images_dir / "only.jpg").write_bytes(b"")
(labels_dir / "only.txt").write_text("0 0.5 0.5 1 1\n", encoding="utf-8")
(tmp_path / "classes.txt").write_text("item\n", encoding="utf-8")
with pytest.raises(ValueError, match="минимум 2"):
split_dataset(str(tmp_path), 0.8, "")
assert not (tmp_path / ".yolo-webui").exists()
def test_split_dataset_finds_nested_images_and_labels(tmp_path: Path) -> None:
images_dir = tmp_path / "images" / "day"
labels_dir = tmp_path / "labels" / "day"
images_dir.mkdir(parents=True)
labels_dir.mkdir(parents=True)
for index in range(2):
(images_dir / f"nested-{index}.jpg").write_bytes(b"")
(labels_dir / f"nested-{index}.txt").write_text(
"2 0.5 0.5 0.2 0.2\n",
encoding="utf-8",
)
train_count, val_count, yaml_path = split_dataset(str(tmp_path), 0.8, "")
assert (train_count, val_count) == (1, 1)
data = yaml.safe_load(Path(yaml_path).read_text(encoding="utf-8"))
assert data["names"] == {0: "class_0", 1: "class_1", 2: "class_2"}
listed_images = "".join(
(Path(yaml_path).parent / filename).read_text(encoding="utf-8")
for filename in ("train.txt", "val.txt")
)
assert "images/day/nested-0.jpg" in listed_images
assert "images/day/nested-1.jpg" in listed_images
def test_split_dataset_preserves_existing_split_files(tmp_path: Path) -> None:
images_dir = tmp_path / "images"
labels_dir = tmp_path / "labels"
images_dir.mkdir()
labels_dir.mkdir()
for index in range(2):
(images_dir / f"image-{index}.jpg").write_bytes(b"")
(labels_dir / f"image-{index}.txt").write_text(
"0 0.5 0.5 0.2 0.2\n",
encoding="utf-8",
)
(tmp_path / "classes.txt").write_text("item\n", encoding="utf-8")
user_split = tmp_path / "split"
user_split.mkdir()
(user_split / "train.txt").write_text("user data\n", encoding="utf-8")
first_yaml = Path(split_dataset(str(tmp_path), 0.5, "")[2])
second_yaml = Path(split_dataset(str(tmp_path), 0.5, "")[2])
assert (user_split / "train.txt").read_text(encoding="utf-8") == "user data\n"
assert first_yaml.parent != second_yaml.parent
assert user_split not in first_yaml.parents
def test_split_dataset_preserves_custom_yaml_keys(tmp_path: Path) -> None:
images_dir = tmp_path / "images"
labels_dir = tmp_path / "labels"
images_dir.mkdir()
labels_dir.mkdir()
for index in range(2):
(images_dir / f"image-{index}.jpg").write_bytes(b"")
(labels_dir / f"image-{index}.txt").write_text(
"0 0.5 0.5 0.2 0.2\n",
encoding="utf-8",
)
# Write a dataset YAML containing custom keys like kpt_shape
dataset_yaml = tmp_path / "my_config.yaml"
dataset_yaml.write_text(
yaml.dump({
"names": {0: "person"},
"kpt_shape": [5, 3],
"flip_idx": [0, 2, 1, 4, 3],
}),
encoding="utf-8",
)
# Run split specifying our YAML as the classes path
_, _, out_yaml_path = split_dataset(str(tmp_path), 0.5, str(dataset_yaml))
with open(out_yaml_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
assert data["kpt_shape"] == [5, 3]
assert data["flip_idx"] == [0, 2, 1, 4, 3]
assert data["names"] == {0: "person"}
def test_explicit_text_classes_override_root_yaml_names(tmp_path: Path) -> None:
images_dir = tmp_path / "images"
labels_dir = tmp_path / "labels"
images_dir.mkdir()
labels_dir.mkdir()
for index in range(2):
(images_dir / f"image-{index}.jpg").write_bytes(b"")
(labels_dir / f"image-{index}.txt").write_text(
"0 0.5 0.5 0.2 0.2\n",
encoding="utf-8",
)
(tmp_path / "dataset.yaml").write_text(
yaml.safe_dump({"names": {0: "old"}}),
encoding="utf-8",
)
classes_path = tmp_path / "custom.txt"
classes_path.write_text("new\n", encoding="utf-8")
_, _, output_yaml = split_dataset(
str(tmp_path),
0.5,
str(classes_path),
)
data = yaml.safe_load(Path(output_yaml).read_text(encoding="utf-8"))
assert data["names"] == {0: "new"}

View file

@ -0,0 +1,63 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from yolo_webui import subprocess_runner
class FakeRunner:
def __init__(self, *, error: Exception | None = None) -> None:
self.error = error
self.prepared = False
self.stop_requested = False
def prepare_run(self) -> None:
self.prepared = True
def request_stop(self) -> None:
self.stop_requested = True
def train(self, config: Any, on_event: Any) -> Path:
if self.error is not None:
raise self.error
return Path("/tmp/successful-run")
def _write_config(tmp_path: Path) -> Path:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"dataset": "dataset.yaml", "model": "model.pt"}),
encoding="utf-8",
)
return config_path
def test_main_returns_zero_after_successful_training(
monkeypatch: Any, tmp_path: Path, capsys: Any
) -> None:
runner = FakeRunner()
monkeypatch.setattr(subprocess_runner, "TrainingRunner", lambda: runner)
return_code = subprocess_runner.main([str(_write_config(tmp_path))])
output = capsys.readouterr()
assert return_code == 0
assert runner.prepared is True
assert "__YOLO_WEBUI_READY__" in output.out
assert "__YOLO_WEBUI_RESULT__:/tmp/successful-run" in output.out
assert "Traceback" not in output.err
def test_main_returns_one_when_training_raises(
monkeypatch: Any, tmp_path: Path, capsys: Any
) -> None:
runner = FakeRunner(error=RuntimeError("training failed"))
monkeypatch.setattr(subprocess_runner, "TrainingRunner", lambda: runner)
return_code = subprocess_runner.main([str(_write_config(tmp_path))])
output = capsys.readouterr()
assert return_code == 1
assert "RuntimeError: training failed" in output.err

View file

@ -1,12 +1,32 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import signal
from pathlib import Path from pathlib import Path
from types import ModuleType, SimpleNamespace from types import ModuleType, SimpleNamespace
from typing import Any from typing import Any
from yolo_tui.config import MlflowConfig, TrainingConfig from yolo_webui.config import MlflowConfig, TrainingConfig
from yolo_tui.trainer import TrainingEvent, TrainingRunner from yolo_webui.trainer import TrainingEvent, TrainingRunner
class FakeProcess:
def __init__(self) -> None:
self.signals: list[int] = []
self.terminate_calls = 0
self.kill_calls = 0
def send_signal(self, signum: int) -> None:
self.signals.append(signum)
def terminate(self) -> None:
self.terminate_calls += 1
def kill(self) -> None:
self.kill_calls += 1
def poll(self) -> None:
return None
def test_runner_wires_yolo_callbacks_and_returns_output( def test_runner_wires_yolo_callbacks_and_returns_output(
@ -61,8 +81,45 @@ def test_runner_wires_yolo_callbacks_and_returns_output(
output = TrainingRunner().train(config, events.append) output = TrainingRunner().train(config, events.append)
assert output == tmp_path / "run" assert output == tmp_path / "run"
assert constructed == [("model.pt", "pose")] assert constructed == [("models/model.pt", "pose")]
assert settings_updates == [{"mlflow": False}] assert settings_updates == [{"mlflow": False}]
assert train_arguments[0]["data"] == "dataset.yaml" assert train_arguments[0]["data"] == "dataset.yaml"
assert train_arguments[0]["verbose"] is False assert train_arguments[0]["verbose"] is True
assert [event.kind for event in events] == ["info", "started", "epoch", "epoch", "success"] assert [event.kind for event in events] == ["info", "started", "epoch", "epoch", "success"]
def test_prepare_run_clears_previous_stop_request() -> None:
runner = TrainingRunner()
runner.request_stop()
assert runner.stop_requested is True
runner.prepare_run()
assert runner.stop_requested is False
def test_early_stop_is_delivered_after_subprocess_ready() -> None:
runner = TrainingRunner()
process = FakeProcess()
runner.request_stop()
runner.set_subprocess(process, ready=False)
assert process.signals == []
runner.mark_subprocess_ready()
assert process.signals == [signal.SIGTERM]
assert process.terminate_calls == 0
runner.clear_subprocess()
def test_ready_subprocess_receives_cooperative_signal_not_terminate() -> None:
runner = TrainingRunner()
process = FakeProcess()
runner.set_subprocess(process, ready=True)
runner.request_stop()
assert process.signals == [signal.SIGTERM]
assert process.terminate_calls == 0
runner.clear_subprocess()

243
uv.lock generated
View file

@ -1049,7 +1049,7 @@ name = "gunicorn"
version = "26.0.0" version = "26.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "packaging" }, { name = "packaging", marker = "sys_platform != 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
wheels = [ wheels = [
@ -1065,6 +1065,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
] ]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]] [[package]]
name = "huey" name = "huey"
version = "3.2.1" version = "3.2.1"
@ -1240,18 +1268,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" },
] ]
[[package]]
name = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.12" version = "1.3.12"
@ -1264,23 +1280,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
] ]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" version = "3.0.3"
@ -1420,27 +1419,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" }, { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" },
] ]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]] [[package]]
name = "mlflow" name = "mlflow"
version = "3.14.0" version = "3.14.0"
@ -2189,15 +2167,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
] ]
[[package]]
name = "platformdirs"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -2754,19 +2723,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
] ]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]] [[package]]
name = "scikit-learn" name = "scikit-learn"
version = "1.9.0" version = "1.9.0"
@ -3073,23 +3029,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
] ]
[[package]]
name = "textual"
version = "8.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
]
[[package]] [[package]]
name = "threadpoolctl" name = "threadpoolctl"
version = "3.6.0" version = "3.6.0"
@ -3222,15 +3161,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
] ]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]
[[package]] [[package]]
name = "ultralytics" name = "ultralytics"
version = "8.4.96" version = "8.4.96"
@ -3309,6 +3239,105 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
] ]
[[package]]
name = "websockets"
version = "16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" },
{ url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" },
{ url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" },
{ url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" },
{ url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" },
{ url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" },
{ url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" },
{ url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" },
{ url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" },
{ url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" },
{ url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" },
{ url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" },
{ url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" },
{ url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" },
{ url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" },
{ url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" },
{ url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" },
{ url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" },
{ url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" },
{ url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" },
{ url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" },
{ url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" },
{ url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" },
{ url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" },
{ url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" },
{ url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" },
{ url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" },
{ url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" },
{ url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" },
{ url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" },
{ url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" },
{ url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" },
{ url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" },
{ url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" },
{ url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" },
{ url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" },
{ url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" },
{ url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" },
{ url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" },
{ url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" },
{ url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" },
{ url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" },
{ url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" },
{ url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" },
{ url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" },
{ url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" },
{ url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" },
{ url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" },
{ url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" },
{ url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" },
{ url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" },
{ url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" },
{ url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" },
{ url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" },
{ url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" },
{ url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" },
{ url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" },
{ url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" },
{ url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" },
{ url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" },
{ url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" },
{ url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" },
{ url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" },
{ url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" },
{ url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" },
{ url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" },
{ url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" },
{ url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" },
]
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.8" version = "3.1.8"
@ -3421,29 +3450,37 @@ wheels = [
] ]
[[package]] [[package]]
name = "yolo-train-tui" name = "yolo-train-webui"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" },
{ name = "mlflow" }, { name = "mlflow" },
{ name = "textual" },
{ name = "ultralytics" }, { name = "ultralytics" },
{ name = "uvicorn" },
{ name = "websockets" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.110.0" },
{ name = "mlflow", specifier = ">=3.0" }, { name = "mlflow", specifier = ">=3.0" },
{ name = "textual", specifier = ">=1.0" },
{ name = "ultralytics", specifier = ">=8.3" }, { name = "ultralytics", specifier = ">=8.3" },
{ name = "uvicorn", specifier = ">=0.28.0" },
{ name = "websockets", specifier = ">=12.0" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.3" }] dev = [
{ name = "httpx" },
{ name = "pytest", specifier = ">=8.3" },
]
[[package]] [[package]]
name = "zipp" name = "zipp"