Миграция с tui на web ui.

This commit is contained in:
malvm 2026-07-17 16:22:25 +04:00
parent 3a5f502735
commit c86c23cd0d
22 changed files with 3010 additions and 907 deletions

8
.gitignore vendored
View file

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

48
Dockerfile Normal file
View file

@ -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"]

23
docker-compose.yml Normal file
View file

@ -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

View file

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

View file

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

View file

@ -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()

View file

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

View file

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

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

@ -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="<h1>YOLO Train Studio Web</h1><p>Static assets are missing. Place index.html under static/.</p>",
status_code=404,
)
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
def get_sessions_dir() -> Path:
path = Path("runs") / "sessions"
path.mkdir(parents=True, exist_ok=True)
return path
@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()

View file

@ -111,7 +111,7 @@ class AugmentationConfig:
class MlflowConfig: class MlflowConfig:
enabled: bool = True enabled: bool = True
tracking_uri: str = "sqlite:///mlflow.db" tracking_uri: str = "sqlite:///mlflow.db"
experiment_name: str = "yolo-tui" experiment_name: str = "yolo-webui"
run_name: str = "" run_name: str = ""
def validate(self) -> None: def validate(self) -> None:
@ -186,9 +186,8 @@ class TrainingConfig:
"workers": self.workers, "workers": self.workers,
"patience": self.patience, "patience": self.patience,
"project": self.project.strip() or "runs/train", "project": self.project.strip() or "runs/train",
# Ultralytics' tqdm output would otherwise paint over Textual's screen. # Enable verbose output so users see active progress and losses in the log console.
# Epoch metrics are sent to the in-app log by callbacks instead. "verbose": True,
"verbose": False,
} }
if self.device.strip(): if self.device.strip():
values["device"] = self.device.strip() values["device"] = self.device.strip()
@ -197,6 +196,16 @@ class TrainingConfig:
values.update(self.augmentation.train_kwargs()) values.update(self.augmentation.train_kwargs())
return values 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]: def to_dict(self) -> dict[str, Any]:
import dataclasses import dataclasses
return dataclasses.asdict(self) return dataclasses.asdict(self)

View file

@ -193,7 +193,7 @@ def split_dataset(
# Resolve classes before creating output so invalid input leaves no partial split. # Resolve classes before creating output so invalid input leaves no partial split.
classes = read_classes(base_dir, classes_path) 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 = base_dir / relative_split_dir
split_dir.mkdir(parents=True, exist_ok=False) 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(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)) _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), "path": str(base_dir),
"train": (relative_split_dir / train_txt_path.name).as_posix(), "train": (relative_split_dir / train_txt_path.name).as_posix(),
"val": (relative_split_dir / val_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( _write_new(
dataset_yaml_path, dataset_yaml_path,
yaml.safe_dump(dataset_data, allow_unicode=True, sort_keys=False), yaml.safe_dump(dataset_data, allow_unicode=True, sort_keys=False),

View file

@ -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 = '<option value="">По умолчанию (Последний запуск)</option>';
names.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
sessionSelect.appendChild(opt);
});
// Restore selection if still exists
const savedProfile = localStorage.getItem('selected_profile') || "";
const finalValue = currentValue || savedProfile;
if (names.includes(finalValue)) {
sessionSelect.value = finalValue;
sessionDeleteBtn.disabled = false;
} else {
sessionSelect.value = "";
sessionDeleteBtn.disabled = true;
}
} catch (e) {
console.error("Failed to load sessions list:", e);
}
}
// --- Datasets Auto-Discovery ---
let discoveredDatasets = [];
async function loadDatasetsList() {
try {
const res = await fetch('/api/datasets');
if (!res.ok) throw new Error();
discoveredDatasets = await res.json();
// Re-populate select
datasetSelect.innerHTML = '';
discoveredDatasets.forEach(item => {
const opt = document.createElement('option');
opt.value = item.path;
opt.textContent = `${item.name} (${item.type === 'directory' ? 'Папка' : 'Конфиг'})`;
datasetSelect.appendChild(opt);
});
// Add custom path option
const customOpt = document.createElement('option');
customOpt.value = '__custom__';
customOpt.textContent = 'Указать путь вручную...';
datasetSelect.appendChild(customOpt);
updateDatasetFieldsState();
} catch (e) {
console.error("Failed to load datasets list:", e);
datasetSelect.innerHTML = '<option value="__custom__">Указать путь вручную...</option>';
updateDatasetFieldsState();
}
}
function updateDatasetFieldsState() {
const val = datasetSelect.value;
const datasetInput = document.getElementById('dataset');
if (val === '__custom__') {
datasetCustomWrapper.style.display = 'block';
} else {
datasetCustomWrapper.style.display = 'none';
datasetInput.value = val;
}
}
datasetSelect.addEventListener('change', updateDatasetFieldsState);
async function loadModelsList() {
try {
const res = await fetch('/api/models');
if (!res.ok) throw new Error();
discoveredModels = await res.json();
} catch (e) {
console.error("Failed to load models list:", e);
}
}
modelSelect.addEventListener('change', updateModelFieldsState);
sessionSelect.addEventListener('change', async () => {
const name = sessionSelect.value;
localStorage.setItem('selected_profile', name);
if (!name) {
sessionDeleteBtn.disabled = true;
localStorage.removeItem('draft_config'); // Reset draft
await loadInitialConfig();
return;
}
sessionDeleteBtn.disabled = false;
try {
const res = await fetch(`/api/sessions/${name}`);
if (!res.ok) throw new Error();
const data = await res.json();
applyConfig(data);
localStorage.setItem('draft_config', JSON.stringify(data));
showNotification(`Профиль "${name}" успешно загружен.`, 'success');
} catch (e) {
showNotification('Не удалось загрузить выбранный профиль.', 'error');
}
});
sessionSaveBtn.addEventListener('click', async () => {
const name = sessionNameInput.value.trim().replace(/[^a-zA-Z0-9_\-]/g, "");
if (!name) {
showNotification('Введите корректное имя профиля (латиница, цифры, дефисы).', 'warning');
return;
}
if (name === "last_run") {
showNotification('Имя "last_run" зарезервировано бэкендом.', 'warning');
return;
}
const config = getFormConfig();
try {
const res = await fetch(`/api/sessions/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!res.ok) throw new Error();
showNotification(`Профиль "${name}" сохранен.`, 'success');
sessionNameInput.value = "";
localStorage.setItem('selected_profile', name);
localStorage.setItem('draft_config', JSON.stringify(config));
await loadSessionsList();
sessionSelect.value = name;
sessionDeleteBtn.disabled = false;
} catch (e) {
showNotification('Не удалось сохранить профиль.', 'error');
}
});
sessionDeleteBtn.addEventListener('click', async () => {
const name = sessionSelect.value;
if (!name) return;
if (!confirm(`Вы действительно хотите удалить профиль "${name}"?`)) return;
try {
const res = await fetch(`/api/sessions/${name}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification(`Профиль "${name}" удален.`, 'success');
sessionSelect.value = "";
sessionDeleteBtn.disabled = true;
localStorage.removeItem('selected_profile');
localStorage.removeItem('draft_config');
await loadSessionsList();
await loadInitialConfig();
}
});
// --- 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();
});
});

View file

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

View file

@ -0,0 +1,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);
}

View file

@ -10,8 +10,8 @@ from pathlib import Path
# Force headless Matplotlib to avoid any thread/process GUI issues # Force headless Matplotlib to avoid any thread/process GUI issues
os.environ["MPLBACKEND"] = "Agg" os.environ["MPLBACKEND"] = "Agg"
from yolo_tui.config import TrainingConfig from yolo_webui.config import TrainingConfig
from yolo_tui.trainer import TrainingEvent, TrainingRunner from yolo_webui.trainer import TrainingEvent, TrainingRunner
@contextmanager @contextmanager
@ -35,7 +35,7 @@ def main(argv: Sequence[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv) args = list(sys.argv[1:] if argv is None else argv)
if not args: if not args:
print( print(
"Usage: python -m yolo_tui.subprocess_runner <config_json_path>", "Usage: python -m yolo_webui.subprocess_runner <config_json_path>",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1
@ -46,7 +46,7 @@ def main(argv: Sequence[str] | None = None) -> int:
with _stop_signal_handlers(runner): with _stop_signal_handlers(runner):
# The parent waits for this marker before sending a cooperative signal. # The parent waits for this marker before sending a cooperative signal.
print("__YOLO_TUI_READY__", flush=True) print("__YOLO_WEBUI_READY__", flush=True)
try: try:
with config_path.open("r", encoding="utf-8") as config_file: with config_path.open("r", encoding="utf-8") as config_file:
config_dict = json.load(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, "total_epochs": event.total_epochs,
} }
# Print structured JSON event so the parent process can parse it # 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: try:
output_dir = runner.train(config, handle_event) output_dir = runner.train(config, handle_event)
if output_dir: if output_dir:
print(f"__YOLO_TUI_RESULT__:{output_dir}", flush=True) print(f"__YOLO_WEBUI_RESULT__:{output_dir}", flush=True)
return 0 return 0
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()

View file

@ -183,7 +183,7 @@ class TrainingRunner:
settings.update({"mlflow": config.mlflow.enabled}) settings.update({"mlflow": config.mlflow.enabled})
with mlflow_environment(config.mlflow): with mlflow_environment(config.mlflow):
model = YOLO(config.model.strip(), task=config.task) model = YOLO(config.resolved_model, task=config.task)
with self._state_lock: with self._state_lock:
self._model = model self._model = model

View file

@ -1,118 +1,90 @@
from __future__ import annotations from __future__ import annotations
import asyncio from fastapi.testclient import TestClient
from textual.widgets import Button, Input, Select, Switch from yolo_webui.app import app
from yolo_tui.app import YoloTrainApp
from yolo_tui.config import AugmentationConfig, DatasetSplitConfig
def test_app_mounts_with_expected_defaults() -> None: def test_get_config_defaults() -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() response = client.get("/api/config/defaults")
async with app.run_test(size=(140, 45)): assert response.status_code == 200
assert app.query_one("#task", Select).value == "detect" data = response.json()
assert app.query_one("#model", Input).value == "yolo11n.pt" assert data["dataset"] == "coco8.yaml"
assert app.query_one("#dataset", Input).value == "coco8.yaml" assert data["model"] == "yolo11n.pt"
assert app.query_one("#augmentation-enabled", Switch).value is True assert data["augmentation"]["enabled"] is True
assert app.query_one("#mosaic", Input).value == "1.0" assert data["mlflow"]["enabled"] is True
assert app.query_one("#auto-augment", Select).value == "randaugment"
assert app.query_one("#mlflow-enabled", Switch).value is True
assert app.query_one("#start-button", Button).disabled is False
assert app.query_one("#stop-button", Button).disabled is True
asyncio.run(exercise())
def test_augmentation_fields_follow_switch() -> None: def test_get_status_idle() -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() response = client.get("/api/train/status")
async with app.run_test(size=(140, 45)) as pilot: assert response.status_code == 200
switch = app.query_one("#augmentation-enabled", Switch) data = response.json()
switch.value = False assert data["status"] == "idle"
await pilot.pause() assert data["epoch"] == 0
assert data["total_epochs"] == 0
assert app.query_one("#mosaic", Input).disabled is True assert isinstance(data["logs"], list)
assert app.query_one("#auto-augment", Select).disabled is True
asyncio.run(exercise())
def test_mlflow_fields_follow_switch() -> None: def test_start_training_validation_error() -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() # Empty dataset is invalid
async with app.run_test(size=(140, 45)) as pilot: bad_config = {
switch = app.query_one("#mlflow-enabled", Switch) "dataset": " ",
switch.value = False "model": "yolo11n.pt",
await pilot.pause() "task": "detect"
}
assert app.query_one("#tracking-uri", Input).disabled is True response = client.post("/api/train/start", json=bad_config)
assert app.query_one("#experiment-name", Input).disabled is True assert response.status_code == 400
assert "Укажите путь или имя датасета" in response.json()["detail"]
asyncio.run(exercise())
def test_read_config_ignores_disabled_augmentation() -> None: def test_stop_training_when_idle() -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() response = client.post("/api/train/stop")
async with app.run_test(size=(140, 45)) as pilot: assert response.status_code == 200
app.query_one("#augmentation-enabled", Switch).value = False assert "Запрос на остановку отправлен" in response.json()["message"]
app.query_one("#hsv-h", Input).value = "not a number"
await pilot.pause()
config = app._read_config()
assert config.augmentation.enabled is False
# When disabled, config.augmentation uses defaults, doesn't parse from UI input
assert config.augmentation.hsv_h == AugmentationConfig(enabled=False).hsv_h
asyncio.run(exercise())
def test_split_fields_follow_switch() -> None: def test_sessions_flow(monkeypatch, tmp_path) -> None:
async def exercise() -> None: client = TestClient(app)
app = YoloTrainApp() # Patch sessions directory to use tmp_path
async with app.run_test(size=(140, 45)) as pilot: monkeypatch.setattr("yolo_webui.app.get_sessions_dir", lambda: tmp_path)
assert app.query_one("#split-ratio", Input).disabled is True
assert app.query_one("#split-classes", Input).disabled is True
app.query_one("#split-enabled", Switch).value = True # 1. Get empty sessions list
await pilot.pause() response = client.get("/api/sessions")
assert response.status_code == 200
assert response.json() == []
assert app.query_one("#split-ratio", Input).disabled is False # 2. Save a session
assert app.query_one("#split-classes", Input).disabled is False config = {
"dataset": "coco8.yaml",
"model": "yolo11n.pt",
"task": "detect"
}
response = client.post("/api/sessions/my_session", json=config)
assert response.status_code == 200
assert "успешно сохранена" in response.json()["message"]
asyncio.run(exercise()) # 3. List sessions should contain 'my_session'
response = client.get("/api/sessions")
assert response.json() == ["my_session"]
# 4. Load session
response = client.get("/api/sessions/my_session")
assert response.status_code == 200
assert response.json()["dataset"] == "coco8.yaml"
def test_read_config_ignores_disabled_split() -> None: # 5. Delete session
async def exercise() -> None: response = client.delete("/api/sessions/my_session")
app = YoloTrainApp() assert response.status_code == 200
async with app.run_test(size=(140, 45)) as pilot: assert "удалена" in response.json()["message"]
app.query_one("#split-enabled", Switch).value = False
app.query_one("#split-ratio", Input).value = "not a float"
await pilot.pause()
config = app._read_config() # 6. List sessions should be empty again
assert config.split.enabled is False response = client.get("/api/sessions")
assert config.split.train_ratio == DatasetSplitConfig(enabled=False).train_ratio assert response.json() == []
asyncio.run(exercise()) # 7. Loading nonexistent session should return 404
response = client.get("/api/sessions/nonexistent")
assert response.status_code == 404
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())

View file

@ -4,8 +4,8 @@ import os
import pytest import pytest
from yolo_tui.config import AugmentationConfig, DatasetSplitConfig, MlflowConfig, TrainingConfig from yolo_webui.config import AugmentationConfig, DatasetSplitConfig, MlflowConfig, TrainingConfig
from yolo_tui.trainer import TrainingRunner, mlflow_environment from yolo_webui.trainer import TrainingRunner, mlflow_environment
def test_train_kwargs_omit_optional_empty_values() -> None: def test_train_kwargs_omit_optional_empty_values() -> None:
@ -23,7 +23,7 @@ def test_train_kwargs_omit_optional_empty_values() -> None:
"workers": 8, "workers": 8,
"patience": 100, "patience": 100,
"project": "runs/train", "project": "runs/train",
"verbose": False, "verbose": True,
} }

View file

@ -5,7 +5,7 @@ from pathlib import Path
import pytest import pytest
import yaml import yaml
from yolo_tui.dataset_splitter import read_classes, split_dataset from yolo_webui.dataset_splitter import read_classes, split_dataset
def test_read_classes_custom_path(tmp_path: Path) -> None: def test_read_classes_custom_path(tmp_path: Path) -> None:
@ -142,7 +142,7 @@ def test_split_dataset_rejects_single_image_without_writing_output(
with pytest.raises(ValueError, match="минимум 2"): with pytest.raises(ValueError, match="минимум 2"):
split_dataset(str(tmp_path), 0.8, "") 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: 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 (user_split / "train.txt").read_text(encoding="utf-8") == "user data\n"
assert first_yaml.parent != second_yaml.parent assert first_yaml.parent != second_yaml.parent
assert user_split not in first_yaml.parents 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"}

View file

@ -4,7 +4,7 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from yolo_tui import subprocess_runner from yolo_webui import subprocess_runner
class FakeRunner: class FakeRunner:
@ -45,8 +45,8 @@ def test_main_returns_zero_after_successful_training(
output = capsys.readouterr() output = capsys.readouterr()
assert return_code == 0 assert return_code == 0
assert runner.prepared is True assert runner.prepared is True
assert "__YOLO_TUI_READY__" in output.out assert "__YOLO_WEBUI_READY__" in output.out
assert "__YOLO_TUI_RESULT__:/tmp/successful-run" in output.out assert "__YOLO_WEBUI_RESULT__:/tmp/successful-run" in output.out
assert "Traceback" not in output.err assert "Traceback" not in output.err

View file

@ -6,8 +6,8 @@ from pathlib import Path
from types import ModuleType, SimpleNamespace from types import ModuleType, SimpleNamespace
from typing import Any from typing import Any
from yolo_tui.config import MlflowConfig, TrainingConfig from yolo_webui.config import MlflowConfig, TrainingConfig
from yolo_tui.trainer import TrainingEvent, TrainingRunner from yolo_webui.trainer import TrainingEvent, TrainingRunner
class FakeProcess: class FakeProcess:
@ -81,10 +81,10 @@ def test_runner_wires_yolo_callbacks_and_returns_output(
output = TrainingRunner().train(config, events.append) output = TrainingRunner().train(config, events.append)
assert output == tmp_path / "run" assert output == tmp_path / "run"
assert constructed == [("model.pt", "pose")] assert constructed == [("models/model.pt", "pose")]
assert settings_updates == [{"mlflow": False}] assert settings_updates == [{"mlflow": False}]
assert train_arguments[0]["data"] == "dataset.yaml" assert train_arguments[0]["data"] == "dataset.yaml"
assert train_arguments[0]["verbose"] is False assert train_arguments[0]["verbose"] is True
assert [event.kind for event in events] == ["info", "started", "epoch", "epoch", "success"] assert [event.kind for event in events] == ["info", "started", "epoch", "epoch", "success"]

243
uv.lock generated
View file

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