apocaliss92's picture
camstack-train 0.1.0: YOLOv9 root detector fine-tune (job.json in, result.json + bundle.tar out)
ebeed67 verified
Raw History Blame Contribute Delete
4.57 kB
"""Fine-tune the YOLOv9 root detector with Ultralytics.
This is the ONLY module that imports Ultralytics (AGPL-3.0), and only inside
:func:`ultralytics_train`, so the rest of the package — and its tests — never
load it. The hub never imports this package at all.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional, Protocol
from .events import Emitter
from .spec import JobSpec
#: Where Ultralytics' base weights are cached between jobs (the cache volume).
WEIGHTS_DIR = "weights"
@dataclass(frozen=True)
class TrainOutcome:
best_pt: Path
epochs_run: int
best_epoch: int
best_map50: float
best_map50_95: float
seconds: float
class Trainer(Protocol):
def __call__(self, job: JobSpec, data_yaml: Path, work: Path, cache: Path, emitter: Emitter) -> TrainOutcome: ...
def write_data_yaml(root: Path, names: list[str], with_replay: bool, has_val: bool) -> Path:
"""The Ultralytics dataset file. Without a val split, val = train (stated)."""
train = ["images/train"] + (["images/replay"] if with_replay else [])
val = "images/val" if has_val else "images/train"
lines = [f"path: {root.as_posix()}", "train:"]
lines += [f" - {t}" for t in train]
lines.append(f"val: {val}")
lines.append("names:")
lines += [f" {i}: {name!r}" for i, name in enumerate(names)]
path = root / "data.yaml"
path.write_text("\n".join(lines) + "\n", "utf-8")
return path
@dataclass
class EpochTracker:
"""Turns Ultralytics' per-epoch callback into ``epoch`` events + the best epoch."""
emitter: Emitter
epochs: int
started: float
clock: Callable[[], float] = time.monotonic
epochs_run: int = 0
best_epoch: int = 0
best_map50: float = 0.0
best_map50_95: float = 0.0
def on_epoch(self, epoch: int, box_loss: Optional[float], metrics: dict[str, Any]) -> None:
map50 = _number(metrics.get("metrics/mAP50(B)"))
map50_95 = _number(metrics.get("metrics/mAP50-95(B)"))
self.epochs_run = epoch
if map50_95 is not None and map50_95 >= self.best_map50_95:
self.best_epoch, self.best_map50_95 = epoch, map50_95
self.best_map50 = map50 or 0.0
elapsed = self.clock() - self.started
eta = elapsed / epoch * (self.epochs - epoch) if epoch > 0 else None
self.emitter.epoch(epoch, self.epochs, box_loss, map50, map50_95, None if eta is None else round(eta, 1))
def _number(value: Any) -> Optional[float]:
try:
return None if value is None else round(float(value), 5)
except (TypeError, ValueError):
return None
def base_weights(job: JobSpec, cache: Path) -> Path:
"""The base checkpoint, downloaded once into the cache volume."""
from ultralytics.utils.downloads import attempt_download_asset
target = cache / WEIGHTS_DIR / job.base.weights
target.parent.mkdir(parents=True, exist_ok=True)
if not target.is_file():
attempt_download_asset(str(target))
return target
def ultralytics_train(job: JobSpec, data_yaml: Path, work: Path, cache: Path, emitter: Emitter) -> TrainOutcome:
import torch
from ultralytics import YOLO
model = YOLO(str(base_weights(job, cache)))
tracker = EpochTracker(emitter, job.hyper.epochs, time.monotonic())
def on_fit_epoch_end(trainer: Any) -> None:
loss = getattr(trainer, "tloss", None)
box_loss = _number(loss[0]) if loss is not None and len(loss) > 0 else None
tracker.on_epoch(int(trainer.epoch) + 1, box_loss, dict(getattr(trainer, "metrics", {}) or {}))
model.add_callback("on_fit_epoch_end", on_fit_epoch_end)
started = time.monotonic()
model.train(
data=str(data_yaml),
epochs=job.hyper.epochs,
batch=job.hyper.batch,
lr0=job.hyper.lr0,
freeze=job.hyper.freeze or None,
patience=job.hyper.patience,
seed=job.hyper.seed,
deterministic=True,
imgsz=job.base.imgsz,
device=0 if torch.cuda.is_available() else "cpu",
project=str(work / "runs"),
name="train",
exist_ok=True,
plots=False,
verbose=False,
)
best = work / "runs" / "train" / "weights" / "best.pt"
return TrainOutcome(
best_pt=best,
epochs_run=tracker.epochs_run,
best_epoch=tracker.best_epoch,
best_map50=tracker.best_map50,
best_map50_95=tracker.best_map50_95,
seconds=round(time.monotonic() - started, 1),
)