Compare commits
3 commits
06deeb25d1
...
7cd7b01f76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cd7b01f76 | ||
| c86c23cd0d | |||
|
|
3a5f502735 |
32 changed files with 5281 additions and 1076 deletions
600
.agents/PROJECT_CONTEXT.md
Normal file
600
.agents/PROJECT_CONTEXT.md
Normal 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`. Имя: 1–64 символа из латинских
|
||||||
|
букв, цифр, `_`, `-`. `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
105
.agents/PROJECT_ISSUES.md
Normal 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
8
.gitignore
vendored
|
|
@ -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
41
Dockerfile
Normal 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
110
README.md
|
|
@ -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
22
docker-compose.yml
Normal 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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
157
scripts/create_yolo26_smoke_datasets.py
Normal file
157
scripts/create_yolo26_smoke_datasets.py
Normal 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()
|
||||||
99
scripts/run_yolo26_smoke_training.py
Normal file
99
scripts/run_yolo26_smoke_training.py
Normal 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()
|
||||||
83
scripts/verify_mlflow_smoke.py
Normal file
83
scripts/verify_mlflow_smoke.py
Normal 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()
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
"""YOLO Train TUI package."""
|
|
||||||
|
|
||||||
from .config import MlflowConfig, TrainingConfig
|
|
||||||
|
|
||||||
__all__ = ["MlflowConfig", "TrainingConfig"]
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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()
|
|
||||||
6
src/yolo_webui/__init__.py
Normal file
6
src/yolo_webui/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .config import TrainingConfig
|
||||||
|
from .trainer import TrainingEvent, TrainingRunner
|
||||||
|
|
||||||
|
__all__ = ["TrainingConfig", "TrainingEvent", "TrainingRunner"]
|
||||||
|
|
@ -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
555
src/yolo_webui/app.py
Normal 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()
|
||||||
|
|
@ -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)
|
||||||
251
src/yolo_webui/dataset_splitter.py
Normal file
251
src/yolo_webui/dataset_splitter.py
Normal 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)
|
||||||
934
src/yolo_webui/static/app.js
Normal file
934
src/yolo_webui/static/app.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
406
src/yolo_webui/static/index.html
Normal file
406
src/yolo_webui/static/index.html
Normal 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>
|
||||||
811
src/yolo_webui/static/style.css
Normal file
811
src/yolo_webui/static/style.css
Normal 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);
|
||||||
|
}
|
||||||
78
src/yolo_webui/subprocess_runner.py
Normal file
78
src/yolo_webui/subprocess_runner.py
Normal 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())
|
||||||
|
|
@ -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
172
tests/frontend_smoke.js
Normal 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;
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
21
tests/test_frontend.py
Normal 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)
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
63
tests/test_subprocess_runner.py
Normal file
63
tests/test_subprocess_runner.py
Normal 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
|
||||||
|
|
@ -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
243
uv.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue