420 lines
12 KiB
Python
420 lines
12 KiB
Python
# backend/ml/train_detector_model.py
|
||
|
||
import argparse
|
||
import json
|
||
import shutil
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from ultralytics import YOLO
|
||
from ultralytics.models.yolo.detect.train import DetectionTrainer
|
||
|
||
|
||
def emit_progress(stage, progress, message="", **extra):
|
||
out = {
|
||
"type": "progress",
|
||
"stage": stage,
|
||
"progress": max(0.0, min(1.0, float(progress))),
|
||
"message": message,
|
||
}
|
||
out.update(extra)
|
||
print(json.dumps(out, ensure_ascii=False), flush=True)
|
||
|
||
|
||
def count_yolo_samples(dataset_root: Path, split: str) -> int:
|
||
images_dir = dataset_root / "images" / split
|
||
labels_dir = dataset_root / "labels" / split
|
||
|
||
if not images_dir.exists() or not labels_dir.exists():
|
||
return 0
|
||
|
||
image_exts = {".jpg", ".jpeg", ".png", ".webp"}
|
||
count = 0
|
||
|
||
for image_path in images_dir.iterdir():
|
||
if not image_path.is_file():
|
||
continue
|
||
|
||
if image_path.suffix.lower() not in image_exts:
|
||
continue
|
||
|
||
label_path = labels_dir / f"{image_path.stem}.txt"
|
||
# Eine vorhandene, leere Labeldatei ist ein gültiges YOLO-Negativbeispiel.
|
||
if label_path.exists() and label_path.is_file():
|
||
count += 1
|
||
|
||
return count
|
||
|
||
|
||
def safe_int(value, fallback):
|
||
try:
|
||
return int(value)
|
||
except Exception:
|
||
return fallback
|
||
|
||
|
||
def load_base_model(base):
|
||
base_text = str(base or "").strip()
|
||
base_path = Path(base_text).expanduser()
|
||
|
||
is_explicit_path = base_path.is_absolute() or base_path.parent != Path(".")
|
||
base_exists = base_path.is_file() and base_path.stat().st_size > 0
|
||
if not is_explicit_path or base_exists:
|
||
return YOLO(base_text), base_text
|
||
|
||
base_path.parent.mkdir(parents=True, exist_ok=True)
|
||
model_name = base_path.name
|
||
model = YOLO(model_name)
|
||
|
||
candidates = []
|
||
ckpt_path = getattr(model, "ckpt_path", None)
|
||
if ckpt_path:
|
||
candidates.append(Path(str(ckpt_path)).expanduser())
|
||
|
||
candidates.append(Path.cwd() / model_name)
|
||
candidates.append(Path(model_name))
|
||
|
||
copied = False
|
||
for candidate in candidates:
|
||
try:
|
||
candidate = candidate.resolve()
|
||
target = base_path.resolve()
|
||
except Exception:
|
||
continue
|
||
|
||
if not candidate.exists() or candidate == target:
|
||
continue
|
||
|
||
try:
|
||
shutil.copy2(candidate, target)
|
||
copied = True
|
||
|
||
if candidate.parent == Path.cwd().resolve():
|
||
try:
|
||
candidate.unlink()
|
||
except Exception:
|
||
pass
|
||
break
|
||
except Exception:
|
||
continue
|
||
|
||
if copied and base_path.exists():
|
||
return YOLO(str(base_path)), str(base_path)
|
||
|
||
return model, model_name
|
||
|
||
|
||
def batch_sample_ids(batch):
|
||
if not isinstance(batch, dict):
|
||
return []
|
||
|
||
image_paths = batch.get("im_file")
|
||
if not image_paths:
|
||
return []
|
||
|
||
if isinstance(image_paths, (str, Path)):
|
||
image_paths = [image_paths]
|
||
|
||
out = []
|
||
seen = set()
|
||
|
||
for image_path in image_paths:
|
||
stem = Path(str(image_path)).stem.strip()
|
||
if stem and stem not in seen:
|
||
seen.add(stem)
|
||
out.append(stem)
|
||
|
||
return out
|
||
|
||
|
||
def progress_detection_trainer(train_count, val_count, train_device, fallback_epochs):
|
||
class ProgressDetectionTrainer(DetectionTrainer):
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self._preview_epoch = 0
|
||
self._preview_batch = 0
|
||
|
||
def preprocess_batch(self, batch):
|
||
epoch = int(getattr(self, "epoch", 0)) + 1
|
||
total_epochs = int(getattr(self, "epochs", fallback_epochs) or fallback_epochs)
|
||
|
||
if epoch != self._preview_epoch:
|
||
self._preview_epoch = epoch
|
||
self._preview_batch = 0
|
||
|
||
self._preview_batch += 1
|
||
|
||
# Pro Batch das gerade trainierte Bild melden – ohne Drosselung, damit
|
||
# das Frontend live immer das aktuell trainierte Bild anzeigen kann.
|
||
sample_ids = batch_sample_ids(batch)
|
||
if sample_ids:
|
||
total_batches = max(1, len(self.train_loader))
|
||
completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches)
|
||
progress = 0.04 + 0.90 * (completed / max(1, total_epochs))
|
||
|
||
emit_progress(
|
||
"detector",
|
||
progress,
|
||
"Object Detector trainiert…",
|
||
epoch=epoch,
|
||
epochs=total_epochs,
|
||
sampleId=sample_ids[0],
|
||
trainSamples=train_count,
|
||
valSamples=val_count,
|
||
device=str(train_device),
|
||
)
|
||
|
||
return super().preprocess_batch(batch)
|
||
|
||
return ProgressDetectionTrainer
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--root", required=True)
|
||
parser.add_argument("--base", default="yolo26n.pt")
|
||
parser.add_argument("--epochs", default="80")
|
||
parser.add_argument("--imgsz", default="640")
|
||
parser.add_argument("--device", default="auto")
|
||
parser.add_argument("--workers", default="2")
|
||
parser.add_argument("--batch", default="0")
|
||
parser.add_argument("--threads", default="0")
|
||
parser.add_argument("--patience", default="20")
|
||
args = parser.parse_args()
|
||
|
||
import torch
|
||
|
||
root = Path(args.root).resolve()
|
||
dataset_root = root / "detector" / "dataset"
|
||
yaml_path = dataset_root / "dataset.yaml"
|
||
runs_dir = root / "detector" / "runs"
|
||
out_dir = root / "detector" / "model"
|
||
|
||
epochs = max(1, safe_int(args.epochs, 80))
|
||
imgsz = max(64, safe_int(args.imgsz, 640))
|
||
workers = max(0, safe_int(args.workers, 2))
|
||
batch_size = max(0, safe_int(args.batch, 0))
|
||
threads = max(0, safe_int(args.threads, 0))
|
||
patience = max(0, safe_int(args.patience, 20))
|
||
|
||
if threads > 0:
|
||
torch.set_num_threads(threads)
|
||
try:
|
||
torch.set_num_interop_threads(max(1, min(threads, 4)))
|
||
except Exception:
|
||
pass
|
||
|
||
if str(args.device).lower() == "auto":
|
||
train_device = 0 if torch.cuda.is_available() else "cpu"
|
||
else:
|
||
train_device = args.device
|
||
|
||
if not yaml_path.exists():
|
||
raise SystemExit(f"dataset.yaml not found: {yaml_path}")
|
||
|
||
train_count = count_yolo_samples(dataset_root, "train")
|
||
val_count = count_yolo_samples(dataset_root, "val")
|
||
|
||
emit_progress(
|
||
"detector",
|
||
0.01,
|
||
"YOLO-Dataset wird geprüft…",
|
||
trainSamples=train_count,
|
||
valSamples=val_count,
|
||
epochs=epochs,
|
||
imgsz=imgsz,
|
||
device=str(train_device),
|
||
)
|
||
|
||
if train_count <= 0:
|
||
raise SystemExit("no YOLO train samples found")
|
||
|
||
if val_count <= 0:
|
||
raise SystemExit("no YOLO val samples found")
|
||
|
||
emit_progress(
|
||
"detector",
|
||
0.03,
|
||
"YOLO-Basismodell wird geladen…",
|
||
base=args.base,
|
||
device=str(train_device),
|
||
)
|
||
|
||
model, base_used = load_base_model(args.base)
|
||
|
||
best_epoch = 0
|
||
last_epoch = 0
|
||
best_map50 = 0.0
|
||
best_map5095 = 0.0
|
||
|
||
def on_train_epoch_start(trainer):
|
||
epoch = int(getattr(trainer, "epoch", 0)) + 1
|
||
total = int(getattr(trainer, "epochs", epochs) or epochs)
|
||
|
||
emit_progress(
|
||
"detector",
|
||
0.04 + 0.90 * ((epoch - 1) / max(1, total)),
|
||
f"Object Detector trainiert…",
|
||
epoch=epoch,
|
||
epochs=total,
|
||
trainSamples=train_count,
|
||
valSamples=val_count,
|
||
device=str(train_device),
|
||
)
|
||
|
||
def on_train_epoch_end(trainer):
|
||
nonlocal last_epoch
|
||
|
||
epoch = int(getattr(trainer, "epoch", 0)) + 1
|
||
total = int(getattr(trainer, "epochs", epochs) or epochs)
|
||
last_epoch = max(last_epoch, epoch)
|
||
|
||
emit_progress(
|
||
"detector",
|
||
0.04 + 0.90 * (epoch / max(1, total)),
|
||
f"Object Detector trainiert…",
|
||
epoch=epoch,
|
||
epochs=total,
|
||
trainSamples=train_count,
|
||
valSamples=val_count,
|
||
device=str(train_device),
|
||
)
|
||
|
||
def on_fit_epoch_end(trainer):
|
||
nonlocal best_epoch, best_map50, best_map5095
|
||
|
||
epoch = int(getattr(trainer, "epoch", 0)) + 1
|
||
total = int(getattr(trainer, "epochs", epochs) or epochs)
|
||
metrics = getattr(trainer, "metrics", None) or {}
|
||
|
||
# Ultralytics nutzt je nach Version unterschiedliche Keys.
|
||
map50 = (
|
||
metrics.get("metrics/mAP50(B)")
|
||
or metrics.get("metrics/mAP50")
|
||
or metrics.get("mAP50")
|
||
)
|
||
map5095 = (
|
||
metrics.get("metrics/mAP50-95(B)")
|
||
or metrics.get("metrics/mAP50-95")
|
||
or metrics.get("mAP50-95")
|
||
)
|
||
|
||
if map50 is not None or map5095 is not None:
|
||
best_epoch = epoch
|
||
|
||
# Bestes Modell merken (YOLO speichert best.pt nach Fitness ~ mAP).
|
||
m50 = float(map50) if map50 is not None else 0.0
|
||
if m50 >= best_map50:
|
||
best_map50 = m50
|
||
if map5095 is not None:
|
||
best_map5095 = float(map5095)
|
||
|
||
emit_progress(
|
||
"detector",
|
||
0.04 + 0.90 * (epoch / max(1, total)),
|
||
f"Object Detector validiert…",
|
||
epoch=epoch,
|
||
epochs=total,
|
||
mAP50=map50,
|
||
mAP5095=map5095,
|
||
device=str(train_device),
|
||
)
|
||
|
||
model.add_callback("on_train_epoch_start", on_train_epoch_start)
|
||
model.add_callback("on_train_epoch_end", on_train_epoch_end)
|
||
model.add_callback("on_fit_epoch_end", on_fit_epoch_end)
|
||
|
||
emit_progress(
|
||
"detector",
|
||
0.05,
|
||
"Object Detector Training startet…",
|
||
trainSamples=train_count,
|
||
valSamples=val_count,
|
||
epochs=epochs,
|
||
imgsz=imgsz,
|
||
device=str(train_device),
|
||
)
|
||
|
||
train_kwargs = {
|
||
"trainer": progress_detection_trainer(
|
||
train_count,
|
||
val_count,
|
||
train_device,
|
||
epochs,
|
||
),
|
||
"data": str(yaml_path),
|
||
"epochs": epochs,
|
||
"imgsz": imgsz,
|
||
"project": str(runs_dir),
|
||
"name": "detect",
|
||
"exist_ok": True,
|
||
"device": train_device,
|
||
"workers": workers,
|
||
"patience": patience,
|
||
}
|
||
if batch_size > 0:
|
||
train_kwargs["batch"] = batch_size
|
||
|
||
result = model.train(**train_kwargs)
|
||
|
||
emit_progress(
|
||
"detector",
|
||
0.96,
|
||
"Bestes YOLO-Modell wird übernommen…",
|
||
lastEpoch=last_epoch,
|
||
bestEpoch=best_epoch,
|
||
device=str(train_device),
|
||
)
|
||
|
||
best = runs_dir / "detect" / "weights" / "best.pt"
|
||
last = runs_dir / "detect" / "weights" / "last.pt"
|
||
|
||
if not best.exists():
|
||
if last.exists():
|
||
best = last
|
||
else:
|
||
raise SystemExit(f"best.pt not found after training: {best}")
|
||
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
final_model = out_dir / "best.pt"
|
||
shutil.copy2(best, final_model)
|
||
|
||
result_path = runs_dir / "detect"
|
||
status = {
|
||
"ok": True,
|
||
"model": str(final_model),
|
||
"sourceModel": str(best),
|
||
"baseModel": str(base_used),
|
||
"runs": str(result_path),
|
||
"trainedAt": datetime.now(timezone.utc).isoformat(),
|
||
"trainSamples": train_count,
|
||
"valSamples": val_count,
|
||
"epochs": epochs,
|
||
"imgsz": imgsz,
|
||
"batchSize": batch_size,
|
||
"patience": patience,
|
||
"threads": threads,
|
||
"workers": workers,
|
||
"device": str(train_device),
|
||
"mAP50": round(best_map50, 4),
|
||
"mAP5095": round(best_map5095, 4),
|
||
"bestEpoch": best_epoch,
|
||
}
|
||
|
||
with (out_dir / "status.json").open("w", encoding="utf-8") as f:
|
||
json.dump(status, f, ensure_ascii=False, indent=2)
|
||
|
||
emit_progress(
|
||
"detector",
|
||
1.0,
|
||
"Object Detector fertig.",
|
||
**status,
|
||
)
|
||
|
||
print(json.dumps(status, ensure_ascii=False), flush=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|