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 + + + + + + + + + +
+ +
+ + + + + + + Открыть MLflow UI + +
+
+ +
+ +
+ +
+
+ + + + + +

Профили конфигурации

+
+
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ +
+ + + + +
+ +
+ +
+
Модель и Данные
+
+ + +
+
+ + +
+ +
+ + +
+ + +
Разделение данных (Train/Val)
+
+
+

Автоматическое разделение

+

Разделить датасет на train/val перед запуском

+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
Гиперпараметры
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+

Пользовательские аугментации

+

Передавать параметры аугментации в Ultralytics

+
+ +
+ +
+
Цветовые (HSV)
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Геометрические
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Составные
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+

Интеграция MLflow

+

Записывать параметры, метрики и checkpoints

+
+ +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+
+ + +
+ +
+
+
+ +

ГОТОВО К ЗАПУСКУ

+
+
00:00:00
+
+

Проверьте параметры и начните обучение.

+
+
+
+
+
+ Эпохи: 0 / 100 + ETA: --:--:-- +
+
+
+ + +
+ + +
+ + +
+
+

График обучения (Live)

+
+
+
+ +
+
+ + +
+
+

Журнал

+
+ + +
+
+
+
Интерфейс готов. Ожидание запуска...
+
+
+ +
+
+ + + 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"