Миграция с tui на web ui.
This commit is contained in:
parent
3a5f502735
commit
c86c23cd0d
22 changed files with 3010 additions and 907 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -7,8 +7,14 @@ dist/
|
|||
build/
|
||||
runs/
|
||||
mlruns/
|
||||
mlflow/
|
||||
mlflow.db
|
||||
mlflow.db-shm
|
||||
mlflow.db-wal
|
||||
passport_obb_up/
|
||||
yolo11n.pt
|
||||
*.pt
|
||||
models/
|
||||
datasets/
|
||||
.yolo-webui/
|
||||
.yolo-tui/
|
||||
.DS_Store
|
||||
|
|
|
|||
48
Dockerfile
Normal file
48
Dockerfile
Normal 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
23
docker-compose.yml
Normal 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
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
[project]
|
||||
name = "yolo-train-tui"
|
||||
name = "yolo-train-webui"
|
||||
version = "0.1.0"
|
||||
description = "Terminal UI for training Ultralytics YOLO models with MLflow tracking"
|
||||
description = "Web UI for training Ultralytics YOLO models with MLflow tracking"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.110.0",
|
||||
"mlflow>=3.0",
|
||||
"textual>=1.0",
|
||||
"ultralytics>=8.3",
|
||||
"uvicorn>=0.28.0",
|
||||
"websockets>=12.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
yolo-train-tui = "yolo_tui.app:main"
|
||||
yolo-train-webui = "yolo_webui.app:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
|
@ -23,7 +26,7 @@ requires = ["hatchling"]
|
|||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/yolo_tui"]
|
||||
packages = ["src/yolo_webui"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-q"
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
"""YOLO Train TUI package."""
|
||||
|
||||
from .config import MlflowConfig, TrainingConfig
|
||||
|
||||
__all__ = ["MlflowConfig", "TrainingConfig"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
|
|
@ -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()
|
||||
6
src/yolo_webui/__init__.py
Normal file
6
src/yolo_webui/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .config import TrainingConfig
|
||||
from .trainer import TrainingEvent, TrainingRunner
|
||||
|
||||
__all__ = ["TrainingConfig", "TrainingEvent", "TrainingRunner"]
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
from .app import main
|
||||
|
||||
from yolo_webui.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
477
src/yolo_webui/app.py
Normal file
477
src/yolo_webui/app.py
Normal 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()
|
||||
|
|
@ -111,7 +111,7 @@ class AugmentationConfig:
|
|||
class MlflowConfig:
|
||||
enabled: bool = True
|
||||
tracking_uri: str = "sqlite:///mlflow.db"
|
||||
experiment_name: str = "yolo-tui"
|
||||
experiment_name: str = "yolo-webui"
|
||||
run_name: str = ""
|
||||
|
||||
def validate(self) -> None:
|
||||
|
|
@ -186,9 +186,8 @@ class TrainingConfig:
|
|||
"workers": self.workers,
|
||||
"patience": self.patience,
|
||||
"project": self.project.strip() or "runs/train",
|
||||
# Ultralytics' tqdm output would otherwise paint over Textual's screen.
|
||||
# Epoch metrics are sent to the in-app log by callbacks instead.
|
||||
"verbose": False,
|
||||
# Enable verbose output so users see active progress and losses in the log console.
|
||||
"verbose": True,
|
||||
}
|
||||
if self.device.strip():
|
||||
values["device"] = self.device.strip()
|
||||
|
|
@ -197,6 +196,16 @@ class TrainingConfig:
|
|||
values.update(self.augmentation.train_kwargs())
|
||||
return values
|
||||
|
||||
@property
|
||||
def resolved_model(self) -> str:
|
||||
from pathlib import Path
|
||||
model_path = self.model.strip()
|
||||
if "/" not in model_path and "\\" not in model_path:
|
||||
# Ensure models directory exists inside workspace
|
||||
Path("models").mkdir(parents=True, exist_ok=True)
|
||||
return f"models/{model_path}"
|
||||
return model_path
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
import dataclasses
|
||||
return dataclasses.asdict(self)
|
||||
|
|
@ -193,7 +193,7 @@ def split_dataset(
|
|||
# Resolve classes before creating output so invalid input leaves no partial split.
|
||||
classes = read_classes(base_dir, classes_path)
|
||||
|
||||
relative_split_dir = Path(".yolo-tui") / "splits" / uuid4().hex
|
||||
relative_split_dir = Path(".yolo-webui") / "splits" / uuid4().hex
|
||||
split_dir = base_dir / relative_split_dir
|
||||
split_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
|
|
@ -204,12 +204,44 @@ def split_dataset(
|
|||
_write_new(train_txt_path, "".join(f"{image}\n" for image in train_images))
|
||||
_write_new(val_txt_path, "".join(f"{image}\n" for image in val_images))
|
||||
|
||||
dataset_data = {
|
||||
# Read existing dataset YAML if available to preserve custom tags (e.g., kpt_shape)
|
||||
existing_data = {}
|
||||
if classes_path.strip():
|
||||
cp = Path(classes_path.strip()).expanduser()
|
||||
if cp.is_file() and cp.suffix.lower() in (".yaml", ".yml"):
|
||||
try:
|
||||
existing_data = _load_yaml(cp) or {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not existing_data:
|
||||
yaml_files = sorted(
|
||||
(*base_dir.glob("*.yaml"), *base_dir.glob("*.yml")),
|
||||
key=lambda item: item.name,
|
||||
)
|
||||
for yf in yaml_files:
|
||||
try:
|
||||
data = _load_yaml(yf)
|
||||
if isinstance(data, dict):
|
||||
existing_data = data
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Build dataset metadata, merging existing keys
|
||||
dataset_data = {}
|
||||
if isinstance(existing_data, dict):
|
||||
dataset_data.update(existing_data)
|
||||
|
||||
dataset_data.update({
|
||||
"path": str(base_dir),
|
||||
"train": (relative_split_dir / train_txt_path.name).as_posix(),
|
||||
"val": (relative_split_dir / val_txt_path.name).as_posix(),
|
||||
"names": classes,
|
||||
}
|
||||
})
|
||||
|
||||
if "names" not in dataset_data:
|
||||
dataset_data["names"] = classes
|
||||
|
||||
_write_new(
|
||||
dataset_yaml_path,
|
||||
yaml.safe_dump(dataset_data, allow_unicode=True, sort_keys=False),
|
||||
918
src/yolo_webui/static/app.js
Normal file
918
src/yolo_webui/static/app.js
Normal 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();
|
||||
});
|
||||
});
|
||||
406
src/yolo_webui/static/index.html
Normal file
406
src/yolo_webui/static/index.html
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>YOLO Train Studio</title>
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="header-logo">
|
||||
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
<div class="logo-text">
|
||||
<h1>YOLO Train Studio</h1>
|
||||
<span>Интерфейс обучения моделей</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="http://localhost:5000" target="_blank" class="mlflow-link" id="mlflow-header-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="link-icon">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
Открыть MLflow UI
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-workspace">
|
||||
<!-- Configuration Card -->
|
||||
<section class="pane" id="config-pane">
|
||||
<!-- Session Controls -->
|
||||
<div class="session-controls">
|
||||
<div class="session-header">
|
||||
<svg class="session-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||||
<polyline points="7 3 7 8 15 8"></polyline>
|
||||
</svg>
|
||||
<h3>Профили конфигурации</h3>
|
||||
</div>
|
||||
<div class="session-body">
|
||||
<div class="session-row">
|
||||
<div class="session-field">
|
||||
<label for="session-select">Активный профиль</label>
|
||||
<select id="session-select">
|
||||
<option value="">По умолчанию (Последний запуск)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" id="session-delete-btn" class="btn-action btn-delete" disabled title="Удалить выбранный профиль">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon-small"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="session-row">
|
||||
<div class="session-field">
|
||||
<label for="session-name">Сохранить текущие настройки как профиль</label>
|
||||
<input type="text" id="session-name" placeholder="Введите имя профиля...">
|
||||
</div>
|
||||
<button type="button" id="session-save-btn" class="btn-action btn-save" title="Сохранить настройки">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon-small"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="general">Основное</button>
|
||||
<button class="tab-btn" data-tab="training">Обучение</button>
|
||||
<button class="tab-btn" data-tab="augmentation">Аугментация</button>
|
||||
<button class="tab-btn" data-tab="mlflow">MLflow</button>
|
||||
</div>
|
||||
|
||||
<form id="config-form" class="form-container">
|
||||
<!-- GENERAL TAB -->
|
||||
<div class="tab-content active" id="tab-general">
|
||||
<div class="form-section-title">Модель и Данные</div>
|
||||
<div class="field">
|
||||
<label for="task">Тип задачи</label>
|
||||
<select id="task" name="task">
|
||||
<option value="detect">Detect (Детекция)</option>
|
||||
<option value="segment">Segment (Сегментация)</option>
|
||||
<option value="classify">Classify (Классификация)</option>
|
||||
<option value="pose">Pose (Позы)</option>
|
||||
<option value="obb">OBB (Ориентированные боксы)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="model-select">Выбор модели</label>
|
||||
<select id="model-select">
|
||||
<!-- Populated dynamically via JS based on task -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="model-custom-wrapper" style="display: none;">
|
||||
<label for="model">Имя весов или путь к модели вручную</label>
|
||||
<input type="text" id="model" name="model" placeholder="например, yolo11n.pt или /workspace/runs/.../best.pt">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dataset-select">Выбор датасета</label>
|
||||
<select id="dataset-select">
|
||||
<option value="">Поиск локальных датасетов...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="dataset-custom-wrapper" style="display: none;">
|
||||
<label for="dataset">Путь к датасету вручную</label>
|
||||
<input type="text" id="dataset" name="dataset" placeholder="например, coco8.yaml или /workspace/datasets/custom">
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">Разделение данных (Train/Val)</div>
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<h3>Автоматическое разделение</h3>
|
||||
<p>Разделить датасет на train/val перед запуском</p>
|
||||
</div>
|
||||
<label class="switch-container">
|
||||
<input type="checkbox" id="split-enabled">
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row split-fields">
|
||||
<div class="field">
|
||||
<label for="split-ratio">Доля train (0.1…0.95)</label>
|
||||
<input type="number" id="split-ratio" name="split-ratio" step="0.05" min="0.1" max="0.95" value="0.8">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="split-classes">Файл классов (classes.txt / YAML)</label>
|
||||
<input type="text" id="split-classes" name="split-classes" placeholder="Автопоиск">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TRAINING TAB -->
|
||||
<div class="tab-content" id="tab-training">
|
||||
<div class="form-section-title">Гиперпараметры</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="epochs">Количество эпох</label>
|
||||
<input type="number" id="epochs" name="epochs" min="1" value="100">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="image-size">Размер изображения</label>
|
||||
<input type="number" id="image-size" name="image-size" min="32" value="640">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="batch-size">Размер батча (Batch)</label>
|
||||
<input type="number" id="batch-size" name="batch-size" min="-1" value="16">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="device">Устройство (Device)</label>
|
||||
<input type="text" id="device" name="device" placeholder="cpu, 0, 0,1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="workers">Потоки загрузки (Workers)</label>
|
||||
<input type="number" id="workers" name="workers" min="0" value="8">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="patience">Ожидание (Patience)</label>
|
||||
<input type="number" id="patience" name="patience" min="0" value="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="project">Каталог результатов</label>
|
||||
<input type="text" id="project" name="project" value="runs/train">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="run-name">Имя запуска</label>
|
||||
<input type="text" id="run-name" name="run-name" placeholder="experiment-01">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AUGMENTATION TAB -->
|
||||
<div class="tab-content" id="tab-augmentation">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<h3>Пользовательские аугментации</h3>
|
||||
<p>Передавать параметры аугментации в Ultralytics</p>
|
||||
</div>
|
||||
<label class="switch-container">
|
||||
<input type="checkbox" id="augmentation-enabled" checked>
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="augmentation-fields">
|
||||
<div class="form-section-title">Цветовые (HSV)</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="hsv-h">HSV Hue (0…1)</label>
|
||||
<input type="number" id="hsv-h" name="hsv-h" step="0.005" min="0" max="1" value="0.015">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="hsv-s">HSV Saturation (0…1)</label>
|
||||
<input type="number" id="hsv-s" name="hsv-s" step="0.05" min="0" max="1" value="0.7">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="hsv-v">HSV Value/Brightness (0…1)</label>
|
||||
<input type="number" id="hsv-v" name="hsv-v" step="0.05" min="0" max="1" value="0.4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">Геометрические</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="degrees">Поворот (град.)</label>
|
||||
<input type="number" id="degrees" name="degrees" step="0.5" min="0" value="0.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="translate">Смещение (0…1)</label>
|
||||
<input type="number" id="translate" name="translate" step="0.05" min="0" max="1" value="0.1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="scale">Масштаб (0…1)</label>
|
||||
<input type="number" id="scale" name="scale" step="0.05" min="0" max="1" value="0.5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="shear">Сдвиг (град.)</label>
|
||||
<input type="number" id="shear" name="shear" step="0.5" min="0" value="0.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="perspective">Перспектива (0…1)</label>
|
||||
<input type="number" id="perspective" name="perspective" step="0.001" min="0" max="1" value="0.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="close-mosaic">Закрыть mosaic (эпох)</label>
|
||||
<input type="number" id="close-mosaic" name="close-mosaic" min="0" value="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="flipud">Flip вверх/вниз</label>
|
||||
<input type="number" id="flipud" name="flipud" step="0.1" min="0" max="1" value="0.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="fliplr">Flip влево/вправо</label>
|
||||
<input type="number" id="fliplr" name="fliplr" step="0.1" min="0" max="1" value="0.5">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="bgr">RGB ↔ BGR (0…1)</label>
|
||||
<input type="number" id="bgr" name="bgr" step="0.1" min="0" max="1" value="0.0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">Составные</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="mosaic">Mosaic (0…1)</label>
|
||||
<input type="number" id="mosaic" name="mosaic" step="0.1" min="0" max="1" value="1.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="mixup">MixUp (0…1)</label>
|
||||
<input type="number" id="mixup" name="mixup" step="0.1" min="0" max="1" value="0.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="cutmix">CutMix (0…1)</label>
|
||||
<input type="number" id="cutmix" name="cutmix" step="0.1" min="0" max="1" value="0.0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="copy-paste">Copy-paste (0…1)</label>
|
||||
<input type="number" id="copy-paste" name="copy-paste" step="0.1" min="0" max="1" value="0.0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="erasing">Erasing (0…1)</label>
|
||||
<input type="number" id="erasing" name="erasing" step="0.05" min="0" max="1" value="0.4">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="copy-paste-mode">Режим Copy-paste</label>
|
||||
<select id="copy-paste-mode" name="copy-paste-mode">
|
||||
<option value="flip">Flip</option>
|
||||
<option value="mixup">Mixup</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="auto-augment">AutoAugment политика (classify)</label>
|
||||
<select id="auto-augment" name="auto-augment">
|
||||
<option value="randaugment">RandAugment</option>
|
||||
<option value="autoaugment">AutoAugment</option>
|
||||
<option value="augmix">AugMix</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MLFLOW TAB -->
|
||||
<div class="tab-content" id="tab-mlflow">
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-label">
|
||||
<h3>Интеграция MLflow</h3>
|
||||
<p>Записывать параметры, метрики и checkpoints</p>
|
||||
</div>
|
||||
<label class="switch-container">
|
||||
<input type="checkbox" id="mlflow-enabled" checked>
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mlflow-fields">
|
||||
<div class="field">
|
||||
<label for="tracking-uri">Tracking URI</label>
|
||||
<input type="text" id="tracking-uri" name="tracking-uri" value="sqlite:///mlflow.db" placeholder="http://127.0.0.1:5000">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="experiment-name">Название эксперимента</label>
|
||||
<input type="text" id="experiment-name" name="experiment-name" value="yolo-webui">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="mlflow-run-name">Имя запуска (Run Name)</label>
|
||||
<input type="text" id="mlflow-run-name" name="mlflow-run-name" placeholder="Автоматически">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Monitoring Card -->
|
||||
<section class="pane" id="run-pane">
|
||||
<!-- Status Card -->
|
||||
<div id="status-card" class="status-idle">
|
||||
<div class="status-header">
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot"></span>
|
||||
<h2 id="status-title">ГОТОВО К ЗАПУСКУ</h2>
|
||||
</div>
|
||||
<div class="status-timer" id="status-timer">00:00:00</div>
|
||||
</div>
|
||||
<p id="status-text">Проверьте параметры и начните обучение.</p>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar-fill" id="progress-bar-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="progress-meta">
|
||||
<span id="progress-text">Эпохи: 0 / 100</span>
|
||||
<span id="progress-eta">ETA: --:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button type="button" id="start-btn" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="btn-icon">
|
||||
<path d="M8 5v14l11-7z"></path>
|
||||
</svg>
|
||||
Начать обучение
|
||||
</button>
|
||||
<button type="button" id="stop-btn" class="btn btn-danger" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="btn-icon">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path>
|
||||
</svg>
|
||||
Остановить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Live Chart -->
|
||||
<div class="chart-container-card">
|
||||
<div class="chart-header">
|
||||
<h3>График обучения (Live)</h3>
|
||||
<div class="chart-legend" id="chart-legend"></div>
|
||||
</div>
|
||||
<div class="chart-canvas-wrapper">
|
||||
<canvas id="metricsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Box -->
|
||||
<div class="log-card">
|
||||
<div class="log-header">
|
||||
<h3>Журнал</h3>
|
||||
<div class="log-actions">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="autoscroll" checked>
|
||||
Автопрокрутка
|
||||
</label>
|
||||
<button type="button" id="clear-log-btn" class="btn-text">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-body" id="log-container">
|
||||
<div class="log-line log-level-info">Интерфейс готов. Ожидание запуска...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
805
src/yolo_webui/static/style.css
Normal file
805
src/yolo_webui/static/style.css
Normal 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);
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ from pathlib import Path
|
|||
# Force headless Matplotlib to avoid any thread/process GUI issues
|
||||
os.environ["MPLBACKEND"] = "Agg"
|
||||
|
||||
from yolo_tui.config import TrainingConfig
|
||||
from yolo_tui.trainer import TrainingEvent, TrainingRunner
|
||||
from yolo_webui.config import TrainingConfig
|
||||
from yolo_webui.trainer import TrainingEvent, TrainingRunner
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -35,7 +35,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args:
|
||||
print(
|
||||
"Usage: python -m yolo_tui.subprocess_runner <config_json_path>",
|
||||
"Usage: python -m yolo_webui.subprocess_runner <config_json_path>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
|
@ -46,7 +46,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
|
||||
with _stop_signal_handlers(runner):
|
||||
# The parent waits for this marker before sending a cooperative signal.
|
||||
print("__YOLO_TUI_READY__", flush=True)
|
||||
print("__YOLO_WEBUI_READY__", flush=True)
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as config_file:
|
||||
config_dict = json.load(config_file)
|
||||
|
|
@ -63,12 +63,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
"total_epochs": event.total_epochs,
|
||||
}
|
||||
# Print structured JSON event so the parent process can parse it
|
||||
print(f"__YOLO_TUI_EVENT__:{json.dumps(event_dict)}", flush=True)
|
||||
print(f"__YOLO_WEBUI_EVENT__:{json.dumps(event_dict)}", flush=True)
|
||||
|
||||
try:
|
||||
output_dir = runner.train(config, handle_event)
|
||||
if output_dir:
|
||||
print(f"__YOLO_TUI_RESULT__:{output_dir}", flush=True)
|
||||
print(f"__YOLO_WEBUI_RESULT__:{output_dir}", flush=True)
|
||||
return 0
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
|
@ -183,7 +183,7 @@ class TrainingRunner:
|
|||
settings.update({"mlflow": config.mlflow.enabled})
|
||||
|
||||
with mlflow_environment(config.mlflow):
|
||||
model = YOLO(config.model.strip(), task=config.task)
|
||||
model = YOLO(config.resolved_model, task=config.task)
|
||||
with self._state_lock:
|
||||
self._model = model
|
||||
|
||||
|
|
@ -1,118 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from textual.widgets import Button, Input, Select, Switch
|
||||
|
||||
from yolo_tui.app import YoloTrainApp
|
||||
from yolo_tui.config import AugmentationConfig, DatasetSplitConfig
|
||||
from yolo_webui.app import app
|
||||
|
||||
|
||||
def test_app_mounts_with_expected_defaults() -> None:
|
||||
async def exercise() -> None:
|
||||
app = YoloTrainApp()
|
||||
async with app.run_test(size=(140, 45)):
|
||||
assert app.query_one("#task", Select).value == "detect"
|
||||
assert app.query_one("#model", Input).value == "yolo11n.pt"
|
||||
assert app.query_one("#dataset", Input).value == "coco8.yaml"
|
||||
assert app.query_one("#augmentation-enabled", Switch).value is True
|
||||
assert app.query_one("#mosaic", Input).value == "1.0"
|
||||
assert app.query_one("#auto-augment", Select).value == "randaugment"
|
||||
assert app.query_one("#mlflow-enabled", Switch).value is True
|
||||
assert app.query_one("#start-button", Button).disabled is False
|
||||
assert app.query_one("#stop-button", Button).disabled is True
|
||||
|
||||
asyncio.run(exercise())
|
||||
def test_get_config_defaults() -> None:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/config/defaults")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["dataset"] == "coco8.yaml"
|
||||
assert data["model"] == "yolo11n.pt"
|
||||
assert data["augmentation"]["enabled"] is True
|
||||
assert data["mlflow"]["enabled"] is True
|
||||
|
||||
|
||||
def test_augmentation_fields_follow_switch() -> None:
|
||||
async def exercise() -> None:
|
||||
app = YoloTrainApp()
|
||||
async with app.run_test(size=(140, 45)) as pilot:
|
||||
switch = app.query_one("#augmentation-enabled", Switch)
|
||||
switch.value = False
|
||||
await pilot.pause()
|
||||
|
||||
assert app.query_one("#mosaic", Input).disabled is True
|
||||
assert app.query_one("#auto-augment", Select).disabled is True
|
||||
|
||||
asyncio.run(exercise())
|
||||
def test_get_status_idle() -> None:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/train/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "idle"
|
||||
assert data["epoch"] == 0
|
||||
assert data["total_epochs"] == 0
|
||||
assert isinstance(data["logs"], list)
|
||||
|
||||
|
||||
def test_mlflow_fields_follow_switch() -> None:
|
||||
async def exercise() -> None:
|
||||
app = YoloTrainApp()
|
||||
async with app.run_test(size=(140, 45)) as pilot:
|
||||
switch = app.query_one("#mlflow-enabled", Switch)
|
||||
switch.value = False
|
||||
await pilot.pause()
|
||||
|
||||
assert app.query_one("#tracking-uri", Input).disabled is True
|
||||
assert app.query_one("#experiment-name", Input).disabled is True
|
||||
|
||||
asyncio.run(exercise())
|
||||
def test_start_training_validation_error() -> None:
|
||||
client = TestClient(app)
|
||||
# Empty dataset is invalid
|
||||
bad_config = {
|
||||
"dataset": " ",
|
||||
"model": "yolo11n.pt",
|
||||
"task": "detect"
|
||||
}
|
||||
response = client.post("/api/train/start", json=bad_config)
|
||||
assert response.status_code == 400
|
||||
assert "Укажите путь или имя датасета" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_read_config_ignores_disabled_augmentation() -> None:
|
||||
async def exercise() -> None:
|
||||
app = YoloTrainApp()
|
||||
async with app.run_test(size=(140, 45)) as pilot:
|
||||
app.query_one("#augmentation-enabled", Switch).value = False
|
||||
app.query_one("#hsv-h", Input).value = "not a number"
|
||||
await pilot.pause()
|
||||
|
||||
config = app._read_config()
|
||||
assert config.augmentation.enabled is False
|
||||
# When disabled, config.augmentation uses defaults, doesn't parse from UI input
|
||||
assert config.augmentation.hsv_h == AugmentationConfig(enabled=False).hsv_h
|
||||
|
||||
asyncio.run(exercise())
|
||||
def test_stop_training_when_idle() -> None:
|
||||
client = TestClient(app)
|
||||
response = client.post("/api/train/stop")
|
||||
assert response.status_code == 200
|
||||
assert "Запрос на остановку отправлен" in response.json()["message"]
|
||||
|
||||
|
||||
def test_split_fields_follow_switch() -> None:
|
||||
async def exercise() -> None:
|
||||
app = YoloTrainApp()
|
||||
async with app.run_test(size=(140, 45)) as pilot:
|
||||
assert app.query_one("#split-ratio", Input).disabled is True
|
||||
assert app.query_one("#split-classes", Input).disabled is True
|
||||
def test_sessions_flow(monkeypatch, tmp_path) -> None:
|
||||
client = TestClient(app)
|
||||
# Patch sessions directory to use tmp_path
|
||||
monkeypatch.setattr("yolo_webui.app.get_sessions_dir", lambda: tmp_path)
|
||||
|
||||
app.query_one("#split-enabled", Switch).value = True
|
||||
await pilot.pause()
|
||||
# 1. Get empty sessions list
|
||||
response = client.get("/api/sessions")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
assert app.query_one("#split-ratio", Input).disabled is False
|
||||
assert app.query_one("#split-classes", Input).disabled is False
|
||||
# 2. Save a session
|
||||
config = {
|
||||
"dataset": "coco8.yaml",
|
||||
"model": "yolo11n.pt",
|
||||
"task": "detect"
|
||||
}
|
||||
response = client.post("/api/sessions/my_session", json=config)
|
||||
assert response.status_code == 200
|
||||
assert "успешно сохранена" in response.json()["message"]
|
||||
|
||||
asyncio.run(exercise())
|
||||
# 3. List sessions should contain 'my_session'
|
||||
response = client.get("/api/sessions")
|
||||
assert response.json() == ["my_session"]
|
||||
|
||||
# 4. Load session
|
||||
response = client.get("/api/sessions/my_session")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dataset"] == "coco8.yaml"
|
||||
|
||||
def test_read_config_ignores_disabled_split() -> None:
|
||||
async def exercise() -> None:
|
||||
app = YoloTrainApp()
|
||||
async with app.run_test(size=(140, 45)) as pilot:
|
||||
app.query_one("#split-enabled", Switch).value = False
|
||||
app.query_one("#split-ratio", Input).value = "not a float"
|
||||
await pilot.pause()
|
||||
# 5. Delete session
|
||||
response = client.delete("/api/sessions/my_session")
|
||||
assert response.status_code == 200
|
||||
assert "удалена" in response.json()["message"]
|
||||
|
||||
config = app._read_config()
|
||||
assert config.split.enabled is False
|
||||
assert config.split.train_ratio == DatasetSplitConfig(enabled=False).train_ratio
|
||||
# 6. List sessions should be empty again
|
||||
response = client.get("/api/sessions")
|
||||
assert response.json() == []
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_classify_disables_detection_style_split() -> None:
|
||||
async def exercise() -> None:
|
||||
app = YoloTrainApp()
|
||||
async with app.run_test(size=(140, 45)) as pilot:
|
||||
app.query_one("#split-enabled", Switch).value = True
|
||||
await pilot.pause()
|
||||
|
||||
app.query_one("#task", Select).value = "classify"
|
||||
await pilot.pause()
|
||||
|
||||
assert app.query_one("#split-enabled", Switch).value is False
|
||||
assert app.query_one("#split-enabled", Switch).disabled is True
|
||||
assert app.query_one("#split-ratio", Input).disabled is True
|
||||
assert app.query_one("#split-classes", Input).disabled is True
|
||||
|
||||
asyncio.run(exercise())
|
||||
# 7. Loading nonexistent session should return 404
|
||||
response = client.get("/api/sessions/nonexistent")
|
||||
assert response.status_code == 404
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import os
|
|||
|
||||
import pytest
|
||||
|
||||
from yolo_tui.config import AugmentationConfig, DatasetSplitConfig, MlflowConfig, TrainingConfig
|
||||
from yolo_tui.trainer import TrainingRunner, mlflow_environment
|
||||
from yolo_webui.config import AugmentationConfig, DatasetSplitConfig, MlflowConfig, TrainingConfig
|
||||
from yolo_webui.trainer import TrainingRunner, mlflow_environment
|
||||
|
||||
|
||||
def test_train_kwargs_omit_optional_empty_values() -> None:
|
||||
|
|
@ -23,7 +23,7 @@ def test_train_kwargs_omit_optional_empty_values() -> None:
|
|||
"workers": 8,
|
||||
"patience": 100,
|
||||
"project": "runs/train",
|
||||
"verbose": False,
|
||||
"verbose": True,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from pathlib import Path
|
|||
import pytest
|
||||
import yaml
|
||||
|
||||
from yolo_tui.dataset_splitter import read_classes, split_dataset
|
||||
from yolo_webui.dataset_splitter import read_classes, split_dataset
|
||||
|
||||
|
||||
def test_read_classes_custom_path(tmp_path: Path) -> None:
|
||||
|
|
@ -142,7 +142,7 @@ def test_split_dataset_rejects_single_image_without_writing_output(
|
|||
with pytest.raises(ValueError, match="минимум 2"):
|
||||
split_dataset(str(tmp_path), 0.8, "")
|
||||
|
||||
assert not (tmp_path / ".yolo-tui").exists()
|
||||
assert not (tmp_path / ".yolo-webui").exists()
|
||||
|
||||
|
||||
def test_split_dataset_finds_nested_images_and_labels(tmp_path: Path) -> None:
|
||||
|
|
@ -192,3 +192,36 @@ def test_split_dataset_preserves_existing_split_files(tmp_path: Path) -> None:
|
|||
assert (user_split / "train.txt").read_text(encoding="utf-8") == "user data\n"
|
||||
assert first_yaml.parent != second_yaml.parent
|
||||
assert user_split not in first_yaml.parents
|
||||
|
||||
|
||||
def test_split_dataset_preserves_custom_yaml_keys(tmp_path: Path) -> None:
|
||||
images_dir = tmp_path / "images"
|
||||
labels_dir = tmp_path / "labels"
|
||||
images_dir.mkdir()
|
||||
labels_dir.mkdir()
|
||||
for index in range(2):
|
||||
(images_dir / f"image-{index}.jpg").write_bytes(b"")
|
||||
(labels_dir / f"image-{index}.txt").write_text(
|
||||
"0 0.5 0.5 0.2 0.2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write a dataset YAML containing custom keys like kpt_shape
|
||||
dataset_yaml = tmp_path / "my_config.yaml"
|
||||
dataset_yaml.write_text(
|
||||
yaml.dump({
|
||||
"names": {0: "person"},
|
||||
"kpt_shape": [5, 3],
|
||||
"flip_idx": [0, 2, 1, 4, 3],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Run split specifying our YAML as the classes path
|
||||
_, _, out_yaml_path = split_dataset(str(tmp_path), 0.5, str(dataset_yaml))
|
||||
|
||||
with open(out_yaml_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
assert data["kpt_shape"] == [5, 3]
|
||||
assert data["flip_idx"] == [0, 2, 1, 4, 3]
|
||||
assert data["names"] == {0: "person"}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import json
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from yolo_tui import subprocess_runner
|
||||
from yolo_webui import subprocess_runner
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
|
|
@ -45,8 +45,8 @@ def test_main_returns_zero_after_successful_training(
|
|||
output = capsys.readouterr()
|
||||
assert return_code == 0
|
||||
assert runner.prepared is True
|
||||
assert "__YOLO_TUI_READY__" in output.out
|
||||
assert "__YOLO_TUI_RESULT__:/tmp/successful-run" in output.out
|
||||
assert "__YOLO_WEBUI_READY__" in output.out
|
||||
assert "__YOLO_WEBUI_RESULT__:/tmp/successful-run" in output.out
|
||||
assert "Traceback" not in output.err
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ from pathlib import Path
|
|||
from types import ModuleType, SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from yolo_tui.config import MlflowConfig, TrainingConfig
|
||||
from yolo_tui.trainer import TrainingEvent, TrainingRunner
|
||||
from yolo_webui.config import MlflowConfig, TrainingConfig
|
||||
from yolo_webui.trainer import TrainingEvent, TrainingRunner
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
|
|
@ -81,10 +81,10 @@ def test_runner_wires_yolo_callbacks_and_returns_output(
|
|||
output = TrainingRunner().train(config, events.append)
|
||||
|
||||
assert output == tmp_path / "run"
|
||||
assert constructed == [("model.pt", "pose")]
|
||||
assert constructed == [("models/model.pt", "pose")]
|
||||
assert settings_updates == [{"mlflow": False}]
|
||||
assert train_arguments[0]["data"] == "dataset.yaml"
|
||||
assert train_arguments[0]["verbose"] is False
|
||||
assert train_arguments[0]["verbose"] is True
|
||||
assert [event.kind for event in events] == ["info", "started", "epoch", "epoch", "success"]
|
||||
|
||||
|
||||
|
|
|
|||
243
uv.lock
generated
243
uv.lock
generated
|
|
@ -1049,7 +1049,7 @@ name = "gunicorn"
|
|||
version = "26.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "packaging", marker = "sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
|
||||
wheels = [
|
||||
|
|
@ -1065,6 +1065,34 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huey"
|
||||
version = "3.2.1"
|
||||
|
|
@ -1240,18 +1268,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify-it-py"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "uc-micro-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.12"
|
||||
|
|
@ -1264,23 +1280,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
linkify = [
|
||||
{ name = "linkify-it-py" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
|
|
@ -1420,27 +1419,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdit-py-plugins"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mlflow"
|
||||
version = "3.14.0"
|
||||
|
|
@ -2189,15 +2167,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
|
|
@ -2754,19 +2723,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scikit-learn"
|
||||
version = "1.9.0"
|
||||
|
|
@ -3073,23 +3029,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textual"
|
||||
version = "8.2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||
{ name = "mdit-py-plugins" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pygments" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "threadpoolctl"
|
||||
version = "3.6.0"
|
||||
|
|
@ -3222,15 +3161,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uc-micro-py"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ultralytics"
|
||||
version = "8.4.96"
|
||||
|
|
@ -3309,6 +3239,105 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.8"
|
||||
|
|
@ -3421,29 +3450,37 @@ wheels = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "yolo-train-tui"
|
||||
name = "yolo-train-webui"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "mlflow" },
|
||||
{ name = "textual" },
|
||||
{ name = "ultralytics" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.110.0" },
|
||||
{ name = "mlflow", specifier = ">=3.0" },
|
||||
{ name = "textual", specifier = ">=1.0" },
|
||||
{ name = "ultralytics", specifier = ">=8.3" },
|
||||
{ name = "uvicorn", specifier = ">=0.28.0" },
|
||||
{ name = "websockets", specifier = ">=12.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8.3" }]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest", specifier = ">=8.3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue