diff --git a/.gitignore b/.gitignore
index 3b7f131..7a90b5c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,8 +7,14 @@ dist/
build/
runs/
mlruns/
+mlflow/
mlflow.db
mlflow.db-shm
mlflow.db-wal
passport_obb_up/
-yolo11n.pt
+*.pt
+models/
+datasets/
+.yolo-webui/
+.yolo-tui/
+.DS_Store
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..581411f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,48 @@
+FROM python:3.11-slim
+
+# Build argument: 'cpu' for Mac/CPU-only environments, 'gpu' for CUDA/NVIDIA GPU support
+ARG DEVICE=gpu
+
+# 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/*
+
+# Install uv for fast dependency resolution using pip (avoids ghcr.io network issues)
+RUN pip install --no-cache-dir uv
+
+# Set working directory
+WORKDIR /workspace
+
+# Copy dependency definition
+COPY pyproject.toml ./
+
+# Install dependencies using uv pip in system python to bypass uv.lock file hashes
+# and fetch the correct PyTorch package based on the target DEVICE (CPU or GPU)
+RUN --mount=type=cache,target=/root/.cache/uv \
+ if [ "$DEVICE" = "cpu" ]; then \
+ echo "Installing CPU-only PyTorch..." && \
+ uv pip install --system --extra-index-url https://download.pytorch.org/whl/cpu -r pyproject.toml; \
+ else \
+ echo "Installing GPU (CUDA) PyTorch..." && \
+ uv pip install --system -r pyproject.toml; \
+ fi
+
+# Copy source code and files
+COPY src ./src
+COPY README.md ./
+
+# Install the project itself without re-installing dependencies
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv pip install --system --no-deps -e .
+
+# 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"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..07183db
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+services:
+ webui:
+ build:
+ context: .
+ args:
+ - DEVICE=cpu # 'cpu' for Mac, change to 'gpu' on a Linux server with NVIDIA GPU
+ image: yolo-train-webui:latest
+ ports:
+ - "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
diff --git a/pyproject.toml b/pyproject.toml
index e294b7f..048d9f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,21 +1,24 @@
[project]
-name = "yolo-train-tui"
+name = "yolo-train-webui"
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"
requires-python = ">=3.11"
dependencies = [
+ "fastapi>=0.110.0",
"mlflow>=3.0",
- "textual>=1.0",
"ultralytics>=8.3",
+ "uvicorn>=0.28.0",
+ "websockets>=12.0",
]
[project.scripts]
-yolo-train-tui = "yolo_tui.app:main"
+yolo-train-webui = "yolo_webui.app:main"
[dependency-groups]
dev = [
"pytest>=8.3",
+ "httpx",
]
[build-system]
@@ -23,7 +26,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
-packages = ["src/yolo_tui"]
+packages = ["src/yolo_webui"]
[tool.pytest.ini_options]
addopts = "-q"
diff --git a/src/yolo_tui/__init__.py b/src/yolo_tui/__init__.py
deleted file mode 100644
index 189527f..0000000
--- a/src/yolo_tui/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""YOLO Train TUI package."""
-
-from .config import MlflowConfig, TrainingConfig
-
-__all__ = ["MlflowConfig", "TrainingConfig"]
-
-__version__ = "0.1.0"
-
diff --git a/src/yolo_tui/app.py b/src/yolo_tui/app.py
deleted file mode 100644
index 009ec16..0000000
--- a/src/yolo_tui/app.py
+++ /dev/null
@@ -1,662 +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:
- if self.query_one("#task", Select).value == "classify" and event.value:
- self.query_one("#split-enabled", Switch).value = False
- self.notify(
- "Для classify укажите готовый каталог с train/val по классам.",
- severity="warning",
- )
- return
- for control in self.query(".split-field Input"):
- control.disabled = not event.value
-
- @on(Select.Changed, "#task")
- def task_changed(self, event: Select.Changed) -> None:
- split_switch = self.query_one("#split-enabled", Switch)
- is_classify = event.value == "classify"
- if is_classify and split_switch.value:
- split_switch.value = False
- split_switch.disabled = is_classify
- for control in self.query(".split-field Input"):
- control.disabled = is_classify or not split_switch.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.runner.prepare_run()
- 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, ready=False)
-
- 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 == "__YOLO_TUI_READY__":
- self.runner.mark_subprocess_ready()
- elif 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
- self.runner.clear_subprocess()
-
- 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 Exception as exc:
- import traceback
- if process is not None:
- try:
- if process.poll() is None:
- process.kill()
- process.wait(timeout=5)
- except Exception:
- pass
- self.runner.clear_subprocess()
- details = escape(traceback.format_exc())
- self.app.call_from_thread(
- self._write_log,
- f"[red]{details}[/red]",
- )
- 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()
diff --git a/src/yolo_webui/__init__.py b/src/yolo_webui/__init__.py
new file mode 100644
index 0000000..0ebe4b4
--- /dev/null
+++ b/src/yolo_webui/__init__.py
@@ -0,0 +1,6 @@
+from __future__ import annotations
+
+from .config import TrainingConfig
+from .trainer import TrainingEvent, TrainingRunner
+
+__all__ = ["TrainingConfig", "TrainingEvent", "TrainingRunner"]
diff --git a/src/yolo_tui/__main__.py b/src/yolo_webui/__main__.py
similarity index 54%
rename from src/yolo_tui/__main__.py
rename to src/yolo_webui/__main__.py
index 4668130..debe0d6 100644
--- a/src/yolo_tui/__main__.py
+++ b/src/yolo_webui/__main__.py
@@ -1,6 +1,4 @@
-from .app import main
-
+from yolo_webui.app import main
if __name__ == "__main__":
main()
-
diff --git a/src/yolo_webui/app.py b/src/yolo_webui/app.py
new file mode 100644
index 0000000..34a2f89
--- /dev/null
+++ b/src/yolo_webui/app.py
@@ -0,0 +1,477 @@
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import os
+import subprocess
+import sys
+import tempfile
+import threading
+from dataclasses import asdict, 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")
+
+
+@dataclass
+class LiveState:
+ status: str = "idle" # idle, preparing, training, stopping, finished, 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
+
+ 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
+
+
+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
+
+ def add_websocket(self, websocket: WebSocket) -> None:
+ with self._lock:
+ self.active_websockets.add(websocket)
+
+ 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)
+ # Create a copy under lock to avoid modification during traversal
+ with self._lock:
+ sockets = list(self.active_websockets)
+
+ # Send outside lock to prevent blocking
+ for ws in sockets:
+ try:
+ import asyncio
+ # Check if we are in an event loop
+ try:
+ loop = asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ if loop.is_running():
+ loop.create_task(ws.send_text(payload))
+ else:
+ loop.run_until_complete(ws.send_text(payload))
+ except Exception:
+ pass
+
+ 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"):
+ 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("=")
+ 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)
+
+ with self._lock:
+ if kind == "started" and self.state.status == "preparing":
+ self.state.status = "training"
+ self.broadcast({"type": "status", "status": self.state.status})
+
+ 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()
+
+ stopped = False
+ with self._lock:
+ stopped = self.state.stop_requested
+
+ if rc == 0:
+ with self._lock:
+ self.state.status = "finished"
+ self.add_log("Обучение успешно завершено.", "success")
+ else:
+ if stopped:
+ with self._lock:
+ self.state.status = "finished"
+ self.add_log("Обучение остановлено пользователем.", "warning")
+ else:
+ with self._lock:
+ self.state.status = "failed"
+ self.add_log("Процесс обучения завершился с ошибкой. Проверьте логи выше.", "error")
+
+ 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()
+ self.broadcast({"type": "status", "status": self.state.status, "output_dir": self.state.output_dir})
+
+
+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="
YOLO Train Studio Web
Static assets are missing. Place index.html under static/.
",
+ 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
+
+
+@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):
+ sessions_dir = get_sessions_dir()
+ file_path = sessions_dir / f"{name}.json"
+ 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]):
+ sessions_dir = get_sessions_dir()
+ file_path = sessions_dir / f"{name}.json"
+ 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):
+ sessions_dir = get_sessions_dir()
+ file_path = sessions_dir / f"{name}.json"
+ 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],
+ }
+ await websocket.send_text(json.dumps(state_dict))
+
+ try:
+ while True:
+ # Keep connection alive; discard incoming messages
+ await websocket.receive_text()
+ except WebSocketDisconnect:
+ manager.remove_websocket(websocket)
+ except Exception:
+ 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()
diff --git a/src/yolo_tui/config.py b/src/yolo_webui/config.py
similarity index 94%
rename from src/yolo_tui/config.py
rename to src/yolo_webui/config.py
index 8ae5fc3..3fd9935 100644
--- a/src/yolo_tui/config.py
+++ b/src/yolo_webui/config.py
@@ -111,7 +111,7 @@ class AugmentationConfig:
class MlflowConfig:
enabled: bool = True
tracking_uri: str = "sqlite:///mlflow.db"
- experiment_name: str = "yolo-tui"
+ experiment_name: str = "yolo-webui"
run_name: str = ""
def validate(self) -> None:
@@ -186,9 +186,8 @@ class TrainingConfig:
"workers": self.workers,
"patience": self.patience,
"project": self.project.strip() or "runs/train",
- # Ultralytics' tqdm output would otherwise paint over Textual's screen.
- # Epoch metrics are sent to the in-app log by callbacks instead.
- "verbose": False,
+ # Enable verbose output so users see active progress and losses in the log console.
+ "verbose": True,
}
if self.device.strip():
values["device"] = self.device.strip()
@@ -197,6 +196,16 @@ class TrainingConfig:
values.update(self.augmentation.train_kwargs())
return values
+ @property
+ def resolved_model(self) -> str:
+ from pathlib import Path
+ 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]:
import dataclasses
return dataclasses.asdict(self)
diff --git a/src/yolo_tui/dataset_splitter.py b/src/yolo_webui/dataset_splitter.py
similarity index 87%
rename from src/yolo_tui/dataset_splitter.py
rename to src/yolo_webui/dataset_splitter.py
index c6682b0..04a6d94 100644
--- a/src/yolo_tui/dataset_splitter.py
+++ b/src/yolo_webui/dataset_splitter.py
@@ -193,7 +193,7 @@ def split_dataset(
# Resolve classes before creating output so invalid input leaves no partial split.
classes = read_classes(base_dir, classes_path)
- relative_split_dir = Path(".yolo-tui") / "splits" / uuid4().hex
+ relative_split_dir = Path(".yolo-webui") / "splits" / uuid4().hex
split_dir = base_dir / relative_split_dir
split_dir.mkdir(parents=True, exist_ok=False)
@@ -204,12 +204,44 @@ def split_dataset(
_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))
- dataset_data = {
+ # 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(),
- "names": classes,
- }
+ })
+
+ if "names" not in dataset_data:
+ dataset_data["names"] = classes
+
_write_new(
dataset_yaml_path,
yaml.safe_dump(dataset_data, allow_unicode=True, sort_keys=False),
diff --git a/src/yolo_webui/static/app.js b/src/yolo_webui/static/app.js
new file mode 100644
index 0000000..9e1452b
--- /dev/null
+++ b/src/yolo_webui/static/app.js
@@ -0,0 +1,918 @@
+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) {
+ // Generate datasets based on keys in metrics (excluding epoch)
+ const datasets = [];
+ const colors = ['#f97316', '#10b981', '#3b82f6', '#eab308', '#a855f7'];
+ let colorIdx = 0;
+
+ for (const key in metrics) {
+ if (key !== 'epoch') {
+ datasets.push({
+ label: key,
+ data: [],
+ borderColor: colors[colorIdx % colors.length],
+ backgroundColor: colors[colorIdx % colors.length] + '22',
+ tension: 0.15,
+ fill: false
+ });
+ colorIdx++;
+ }
+ }
+ initChart(datasets);
+ }
+
+ // Add label if not present
+ if (!metricsChart.data.labels.includes(epoch)) {
+ metricsChart.data.labels.push(epoch);
+ }
+
+ // Push data to correct dataset
+ metricsChart.data.datasets.forEach(dataset => {
+ const val = metrics[dataset.label];
+ if (val !== undefined) {
+ dataset.data.push(val);
+ }
+ });
+
+ 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':
+ 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 getFormConfig() {
+ return {
+ dataset: document.getElementById('dataset').value.trim(),
+ model: document.getElementById('model').value.trim(),
+ task: taskSelect.value,
+ epochs: parseInt(document.getElementById('epochs').value) || 100,
+ image_size: parseInt(document.getElementById('image-size').value) || 640,
+ batch_size: parseInt(document.getElementById('batch-size').value) || 16,
+ device: document.getElementById('device').value.trim(),
+ workers: parseInt(document.getElementById('workers').value) || 8,
+ patience: parseInt(document.getElementById('patience').value) || 100,
+ project: document.getElementById('project').value.trim() || 'runs/train',
+ run_name: document.getElementById('run-name').value.trim(),
+ split: {
+ enabled: splitEnabled.checked,
+ train_ratio: parseFloat(splitRatio.value) || 0.8,
+ classes_path: splitClasses.value.trim()
+ },
+ augmentation: {
+ enabled: augmentationEnabled.checked,
+ hsv_h: parseFloat(document.getElementById('hsv-h').value) || 0,
+ hsv_s: parseFloat(document.getElementById('hsv-s').value) || 0,
+ hsv_v: parseFloat(document.getElementById('hsv-v').value) || 0,
+ degrees: parseFloat(document.getElementById('degrees').value) || 0,
+ translate: parseFloat(document.getElementById('translate').value) || 0,
+ scale: parseFloat(document.getElementById('scale').value) || 0,
+ shear: parseFloat(document.getElementById('shear').value) || 0,
+ perspective: parseFloat(document.getElementById('perspective').value) || 0,
+ close_mosaic: parseInt(document.getElementById('close-mosaic').value) || 10,
+ flipud: parseFloat(document.getElementById('flipud').value) || 0,
+ fliplr: parseFloat(document.getElementById('fliplr').value) || 0,
+ bgr: parseFloat(document.getElementById('bgr').value) || 0,
+ mosaic: parseFloat(document.getElementById('mosaic').value) || 0,
+ mixup: parseFloat(document.getElementById('mixup').value) || 0,
+ cutmix: parseFloat(document.getElementById('cutmix').value) || 0,
+ copy_paste: parseFloat(document.getElementById('copy-paste').value) || 0,
+ erasing: parseFloat(document.getElementById('erasing').value) || 0,
+ 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 = '';
+ 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 = '';
+ 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();
+ }
+ });
+
+ // --- 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');
+ }
+ }
+
+ const configForm = document.getElementById('config-form');
+ 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();
+ });
+});
diff --git a/src/yolo_webui/static/index.html b/src/yolo_webui/static/index.html
new file mode 100644
index 0000000..bb488df
--- /dev/null
+++ b/src/yolo_webui/static/index.html
@@ -0,0 +1,406 @@
+
+
+
+
+
+ YOLO Train Studio
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Проверьте параметры и начните обучение.
+
+
+
+ Эпохи: 0 / 100
+ ETA: --:--:--
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Интерфейс готов. Ожидание запуска...
+
+
+
+
+
+
+
+
diff --git a/src/yolo_webui/static/style.css b/src/yolo_webui/static/style.css
new file mode 100644
index 0000000..cb64cac
--- /dev/null
+++ b/src/yolo_webui/static/style.css
@@ -0,0 +1,805 @@
+/* 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 { border-left-color: var(--success); }
+#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 { background-color: var(--success); box-shadow: 0 0 8px var(--success); }
+#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 { color: var(--success); }
+#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);
+}
diff --git a/src/yolo_tui/subprocess_runner.py b/src/yolo_webui/subprocess_runner.py
similarity index 84%
rename from src/yolo_tui/subprocess_runner.py
rename to src/yolo_webui/subprocess_runner.py
index 37cfb65..112ef9c 100644
--- a/src/yolo_tui/subprocess_runner.py
+++ b/src/yolo_webui/subprocess_runner.py
@@ -10,8 +10,8 @@ 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
+from yolo_webui.config import TrainingConfig
+from yolo_webui.trainer import TrainingEvent, TrainingRunner
@contextmanager
@@ -35,7 +35,7 @@ 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_tui.subprocess_runner ",
+ "Usage: python -m yolo_webui.subprocess_runner ",
file=sys.stderr,
)
return 1
@@ -46,7 +46,7 @@ def main(argv: Sequence[str] | None = None) -> int:
with _stop_signal_handlers(runner):
# The parent waits for this marker before sending a cooperative signal.
- print("__YOLO_TUI_READY__", flush=True)
+ print("__YOLO_WEBUI_READY__", flush=True)
try:
with config_path.open("r", encoding="utf-8") as config_file:
config_dict = json.load(config_file)
@@ -63,12 +63,12 @@ def main(argv: Sequence[str] | None = None) -> int:
"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)
+ 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_TUI_RESULT__:{output_dir}", flush=True)
+ print(f"__YOLO_WEBUI_RESULT__:{output_dir}", flush=True)
return 0
except Exception:
traceback.print_exc()
diff --git a/src/yolo_tui/trainer.py b/src/yolo_webui/trainer.py
similarity index 99%
rename from src/yolo_tui/trainer.py
rename to src/yolo_webui/trainer.py
index 6bd0df9..9f81804 100644
--- a/src/yolo_tui/trainer.py
+++ b/src/yolo_webui/trainer.py
@@ -183,7 +183,7 @@ class TrainingRunner:
settings.update({"mlflow": config.mlflow.enabled})
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:
self._model = model
diff --git a/tests/test_app.py b/tests/test_app.py
index 6fd655c..844c87e 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,118 +1,90 @@
from __future__ import annotations
-import asyncio
+from fastapi.testclient import TestClient
-from textual.widgets import Button, Input, Select, Switch
-
-from yolo_tui.app import YoloTrainApp
-from yolo_tui.config import AugmentationConfig, DatasetSplitConfig
+from yolo_webui.app import app
-def test_app_mounts_with_expected_defaults() -> None:
- async def exercise() -> None:
- app = YoloTrainApp()
- async with app.run_test(size=(140, 45)):
- assert app.query_one("#task", Select).value == "detect"
- assert app.query_one("#model", Input).value == "yolo11n.pt"
- assert app.query_one("#dataset", Input).value == "coco8.yaml"
- assert app.query_one("#augmentation-enabled", Switch).value is True
- assert app.query_one("#mosaic", Input).value == "1.0"
- 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_get_config_defaults() -> None:
+ client = TestClient(app)
+ response = client.get("/api/config/defaults")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["dataset"] == "coco8.yaml"
+ assert data["model"] == "yolo11n.pt"
+ assert data["augmentation"]["enabled"] is True
+ assert data["mlflow"]["enabled"] is True
-def test_augmentation_fields_follow_switch() -> None:
- async def exercise() -> None:
- app = YoloTrainApp()
- async with app.run_test(size=(140, 45)) as pilot:
- switch = app.query_one("#augmentation-enabled", Switch)
- switch.value = False
- await pilot.pause()
-
- assert app.query_one("#mosaic", Input).disabled is True
- assert app.query_one("#auto-augment", Select).disabled is True
-
- asyncio.run(exercise())
+def test_get_status_idle() -> None:
+ client = TestClient(app)
+ response = client.get("/api/train/status")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "idle"
+ assert data["epoch"] == 0
+ assert data["total_epochs"] == 0
+ assert isinstance(data["logs"], list)
-def test_mlflow_fields_follow_switch() -> None:
- async def exercise() -> None:
- app = YoloTrainApp()
- async with app.run_test(size=(140, 45)) as pilot:
- switch = app.query_one("#mlflow-enabled", Switch)
- switch.value = False
- await pilot.pause()
-
- assert app.query_one("#tracking-uri", Input).disabled is True
- assert app.query_one("#experiment-name", Input).disabled is True
-
- asyncio.run(exercise())
+def test_start_training_validation_error() -> None:
+ client = TestClient(app)
+ # Empty dataset is invalid
+ bad_config = {
+ "dataset": " ",
+ "model": "yolo11n.pt",
+ "task": "detect"
+ }
+ response = client.post("/api/train/start", json=bad_config)
+ assert response.status_code == 400
+ assert "Укажите путь или имя датасета" in response.json()["detail"]
-def test_read_config_ignores_disabled_augmentation() -> None:
- async def exercise() -> None:
- app = YoloTrainApp()
- async with app.run_test(size=(140, 45)) as pilot:
- app.query_one("#augmentation-enabled", Switch).value = False
- 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_stop_training_when_idle() -> None:
+ client = TestClient(app)
+ response = client.post("/api/train/stop")
+ assert response.status_code == 200
+ assert "Запрос на остановку отправлен" in response.json()["message"]
-def test_split_fields_follow_switch() -> None:
- async def exercise() -> None:
- app = YoloTrainApp()
- async with app.run_test(size=(140, 45)) as pilot:
- assert app.query_one("#split-ratio", Input).disabled is True
- assert app.query_one("#split-classes", Input).disabled is True
+def test_sessions_flow(monkeypatch, tmp_path) -> None:
+ client = TestClient(app)
+ # Patch sessions directory to use tmp_path
+ monkeypatch.setattr("yolo_webui.app.get_sessions_dir", lambda: tmp_path)
- app.query_one("#split-enabled", Switch).value = True
- await pilot.pause()
+ # 1. Get empty sessions list
+ response = client.get("/api/sessions")
+ assert response.status_code == 200
+ assert response.json() == []
- assert app.query_one("#split-ratio", Input).disabled is False
- assert app.query_one("#split-classes", Input).disabled is False
+ # 2. Save a session
+ 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"
-def test_read_config_ignores_disabled_split() -> None:
- async def exercise() -> None:
- app = YoloTrainApp()
- 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()
+ # 5. Delete session
+ response = client.delete("/api/sessions/my_session")
+ assert response.status_code == 200
+ assert "удалена" in response.json()["message"]
- config = app._read_config()
- assert config.split.enabled is False
- assert config.split.train_ratio == DatasetSplitConfig(enabled=False).train_ratio
+ # 6. List sessions should be empty again
+ response = client.get("/api/sessions")
+ assert response.json() == []
- asyncio.run(exercise())
-
-
-def test_classify_disables_detection_style_split() -> None:
- async def exercise() -> None:
- app = YoloTrainApp()
- async with app.run_test(size=(140, 45)) as pilot:
- app.query_one("#split-enabled", Switch).value = True
- await pilot.pause()
-
- app.query_one("#task", Select).value = "classify"
- await pilot.pause()
-
- assert app.query_one("#split-enabled", Switch).value is False
- assert app.query_one("#split-enabled", Switch).disabled is True
- assert app.query_one("#split-ratio", Input).disabled is True
- assert app.query_one("#split-classes", Input).disabled is True
-
- asyncio.run(exercise())
+ # 7. Loading nonexistent session should return 404
+ response = client.get("/api/sessions/nonexistent")
+ assert response.status_code == 404
diff --git a/tests/test_config.py b/tests/test_config.py
index 6110529..e279a5a 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -4,8 +4,8 @@ import os
import pytest
-from yolo_tui.config import AugmentationConfig, DatasetSplitConfig, MlflowConfig, TrainingConfig
-from yolo_tui.trainer import TrainingRunner, mlflow_environment
+from yolo_webui.config import AugmentationConfig, DatasetSplitConfig, MlflowConfig, TrainingConfig
+from yolo_webui.trainer import TrainingRunner, mlflow_environment
def test_train_kwargs_omit_optional_empty_values() -> None:
@@ -23,7 +23,7 @@ def test_train_kwargs_omit_optional_empty_values() -> None:
"workers": 8,
"patience": 100,
"project": "runs/train",
- "verbose": False,
+ "verbose": True,
}
diff --git a/tests/test_splitter.py b/tests/test_splitter.py
index 98afaa6..61dcdb9 100644
--- a/tests/test_splitter.py
+++ b/tests/test_splitter.py
@@ -5,7 +5,7 @@ from pathlib import Path
import pytest
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:
@@ -142,7 +142,7 @@ def test_split_dataset_rejects_single_image_without_writing_output(
with pytest.raises(ValueError, match="минимум 2"):
split_dataset(str(tmp_path), 0.8, "")
- assert not (tmp_path / ".yolo-tui").exists()
+ assert not (tmp_path / ".yolo-webui").exists()
def test_split_dataset_finds_nested_images_and_labels(tmp_path: Path) -> None:
@@ -192,3 +192,36 @@ def test_split_dataset_preserves_existing_split_files(tmp_path: Path) -> None:
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"}
diff --git a/tests/test_subprocess_runner.py b/tests/test_subprocess_runner.py
index 0ed6542..29b9094 100644
--- a/tests/test_subprocess_runner.py
+++ b/tests/test_subprocess_runner.py
@@ -4,7 +4,7 @@ import json
from pathlib import Path
from typing import Any
-from yolo_tui import subprocess_runner
+from yolo_webui import subprocess_runner
class FakeRunner:
@@ -45,8 +45,8 @@ def test_main_returns_zero_after_successful_training(
output = capsys.readouterr()
assert return_code == 0
assert runner.prepared is True
- assert "__YOLO_TUI_READY__" in output.out
- assert "__YOLO_TUI_RESULT__:/tmp/successful-run" in output.out
+ assert "__YOLO_WEBUI_READY__" in output.out
+ assert "__YOLO_WEBUI_RESULT__:/tmp/successful-run" in output.out
assert "Traceback" not in output.err
diff --git a/tests/test_trainer.py b/tests/test_trainer.py
index 5fa52b9..8d0b73c 100644
--- a/tests/test_trainer.py
+++ b/tests/test_trainer.py
@@ -6,8 +6,8 @@ from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Any
-from yolo_tui.config import MlflowConfig, TrainingConfig
-from yolo_tui.trainer import TrainingEvent, TrainingRunner
+from yolo_webui.config import MlflowConfig, TrainingConfig
+from yolo_webui.trainer import TrainingEvent, TrainingRunner
class FakeProcess:
@@ -81,10 +81,10 @@ def test_runner_wires_yolo_callbacks_and_returns_output(
output = TrainingRunner().train(config, events.append)
assert output == tmp_path / "run"
- assert constructed == [("model.pt", "pose")]
+ assert constructed == [("models/model.pt", "pose")]
assert settings_updates == [{"mlflow": False}]
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"]
diff --git a/uv.lock b/uv.lock
index 63f1a87..00e722a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1049,7 +1049,7 @@ name = "gunicorn"
version = "26.0.0"
source = { registry = "https://pypi.org/simple" }
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" }
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" },
]
+[[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]]
name = "huey"
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" },
]
-[[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]]
name = "mako"
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" },
]
-[[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]]
name = "markupsafe"
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" },
]
-[[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]]
name = "mlflow"
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" },
]
-[[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]]
name = "pluggy"
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" },
]
-[[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]]
name = "scikit-learn"
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" },
]
-[[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]]
name = "threadpoolctl"
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" },
]
-[[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]]
name = "ultralytics"
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" },
]
+[[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]]
name = "werkzeug"
version = "3.1.8"
@@ -3421,29 +3450,37 @@ wheels = [
]
[[package]]
-name = "yolo-train-tui"
+name = "yolo-train-webui"
version = "0.1.0"
source = { editable = "." }
dependencies = [
+ { name = "fastapi" },
{ name = "mlflow" },
- { name = "textual" },
{ name = "ultralytics" },
+ { name = "uvicorn" },
+ { name = "websockets" },
]
[package.dev-dependencies]
dev = [
+ { name = "httpx" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
+ { name = "fastapi", specifier = ">=0.110.0" },
{ name = "mlflow", specifier = ">=3.0" },
- { name = "textual", specifier = ">=1.0" },
{ name = "ultralytics", specifier = ">=8.3" },
+ { name = "uvicorn", specifier = ">=0.28.0" },
+ { name = "websockets", specifier = ">=12.0" },
]
[package.metadata.requires-dev]
-dev = [{ name = "pytest", specifier = ">=8.3" }]
+dev = [
+ { name = "httpx" },
+ { name = "pytest", specifier = ">=8.3" },
+]
[[package]]
name = "zipp"