added real training progress + bugfixes
This commit is contained in:
parent
34df62aa13
commit
a1000e9085
@ -52,6 +52,7 @@ def main():
|
||||
|
||||
device = 0 if torch.cuda.is_available() else "cpu"
|
||||
|
||||
try:
|
||||
results = model.predict(
|
||||
source=str(image_path),
|
||||
conf=float(args.conf),
|
||||
@ -59,6 +60,16 @@ def main():
|
||||
verbose=False,
|
||||
device=device,
|
||||
)
|
||||
except Exception as e:
|
||||
print(json.dumps({
|
||||
"available": False,
|
||||
"source": "detector_predict_failed",
|
||||
"modelPath": str(model_path),
|
||||
"image": str(image_path),
|
||||
"error": repr(e),
|
||||
"boxes": [],
|
||||
}, ensure_ascii=False))
|
||||
return
|
||||
|
||||
boxes = []
|
||||
model_names = {}
|
||||
|
||||
@ -56,11 +56,13 @@ def main():
|
||||
parser.add_argument("--base", default="yolo11n.pt")
|
||||
parser.add_argument("--epochs", default="80")
|
||||
parser.add_argument("--imgsz", default="640")
|
||||
parser.add_argument("--device", default="cpu")
|
||||
parser.add_argument("--device", default="auto")
|
||||
parser.add_argument("--workers", default="2")
|
||||
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"
|
||||
@ -72,6 +74,11 @@ def main():
|
||||
workers = max(0, safe_int(args.workers, 2))
|
||||
patience = max(0, safe_int(args.patience, 20))
|
||||
|
||||
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}")
|
||||
|
||||
@ -86,7 +93,7 @@ def main():
|
||||
valSamples=val_count,
|
||||
epochs=epochs,
|
||||
imgsz=imgsz,
|
||||
device=args.device,
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
if train_count <= 0:
|
||||
@ -100,6 +107,7 @@ def main():
|
||||
0.03,
|
||||
"YOLO-Basismodell wird geladen…",
|
||||
base=args.base,
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
model = YOLO(args.base)
|
||||
@ -119,6 +127,7 @@ def main():
|
||||
epochs=total,
|
||||
trainSamples=train_count,
|
||||
valSamples=val_count,
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
def on_train_epoch_end(trainer):
|
||||
@ -136,12 +145,14 @@ def main():
|
||||
epochs=total,
|
||||
trainSamples=train_count,
|
||||
valSamples=val_count,
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
def on_fit_epoch_end(trainer):
|
||||
nonlocal best_epoch
|
||||
|
||||
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.
|
||||
@ -161,12 +172,13 @@ def main():
|
||||
|
||||
emit_progress(
|
||||
"detector",
|
||||
0.04 + 0.90 * (epoch / max(1, epochs)),
|
||||
f"Object Detector validiert… Epoche {epoch}/{epochs}",
|
||||
0.04 + 0.90 * (epoch / max(1, total)),
|
||||
f"Object Detector validiert… Epoche {epoch}/{total}",
|
||||
epoch=epoch,
|
||||
epochs=epochs,
|
||||
epochs=total,
|
||||
mAP50=map50,
|
||||
mAP5095=map5095,
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
model.add_callback("on_train_epoch_start", on_train_epoch_start)
|
||||
@ -180,6 +192,8 @@ def main():
|
||||
trainSamples=train_count,
|
||||
valSamples=val_count,
|
||||
epochs=epochs,
|
||||
imgsz=imgsz,
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
result = model.train(
|
||||
@ -189,7 +203,7 @@ def main():
|
||||
project=str(runs_dir),
|
||||
name="detect",
|
||||
exist_ok=True,
|
||||
device=args.device,
|
||||
device=train_device,
|
||||
workers=workers,
|
||||
patience=patience,
|
||||
)
|
||||
@ -200,6 +214,7 @@ def main():
|
||||
"Bestes YOLO-Modell wird übernommen…",
|
||||
lastEpoch=last_epoch,
|
||||
bestEpoch=best_epoch,
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
best = runs_dir / "detect" / "weights" / "best.pt"
|
||||
@ -226,7 +241,7 @@ def main():
|
||||
"valSamples": val_count,
|
||||
"epochs": epochs,
|
||||
"imgsz": imgsz,
|
||||
"device": str(args.device),
|
||||
"device": str(train_device),
|
||||
}
|
||||
|
||||
with (out_dir / "status.json").open("w", encoding="utf-8") as f:
|
||||
|
||||
@ -205,8 +205,9 @@ def main():
|
||||
continue
|
||||
|
||||
label = target_from_annotation(row)
|
||||
if not label:
|
||||
label = "unknown"
|
||||
if not label or label == "unknown":
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
emb = embed_image(clip_model, processor, device, image_path)
|
||||
|
||||
|
||||
113
backend/sse.go
113
backend/sse.go
@ -82,6 +82,119 @@ type taskStateEvent struct {
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
type analysisProgressEvent struct {
|
||||
Type string `json:"type"` // "analysis_progress"
|
||||
Running bool `json:"running"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Progress float64 `json:"progress"` // 0..1
|
||||
Current int `json:"current,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAtMs int64 `json:"startedAtMs,omitempty"`
|
||||
FinishedAtMs int64 `json:"finishedAtMs,omitempty"`
|
||||
DurationMs int64 `json:"durationMs,omitempty"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
func publishAnalysisProgress(ev analysisProgressEvent) {
|
||||
ev.Type = "analysis_progress"
|
||||
ev.TS = time.Now().UnixMilli()
|
||||
|
||||
if ev.Progress < 0 {
|
||||
ev.Progress = 0
|
||||
}
|
||||
if ev.Progress > 1 {
|
||||
ev.Progress = 1
|
||||
}
|
||||
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
publishSSE("analysisProgress", b)
|
||||
}
|
||||
|
||||
func publishAnalysisStarted(total int, message string) int64 {
|
||||
startedAtMs := time.Now().UnixMilli()
|
||||
|
||||
publishAnalysisProgress(analysisProgressEvent{
|
||||
Running: true,
|
||||
Phase: "starting",
|
||||
Progress: 0,
|
||||
Current: 0,
|
||||
Total: total,
|
||||
Message: message,
|
||||
StartedAtMs: startedAtMs,
|
||||
})
|
||||
|
||||
return startedAtMs
|
||||
}
|
||||
|
||||
func publishAnalysisStep(startedAtMs int64, current int, total int, file string, message string) {
|
||||
progress := 0.0
|
||||
if total > 0 {
|
||||
progress = float64(current) / float64(total)
|
||||
}
|
||||
|
||||
publishAnalysisProgress(analysisProgressEvent{
|
||||
Running: true,
|
||||
Phase: "running",
|
||||
Progress: progress,
|
||||
Current: current,
|
||||
Total: total,
|
||||
File: file,
|
||||
Message: message,
|
||||
StartedAtMs: startedAtMs,
|
||||
})
|
||||
}
|
||||
|
||||
func publishAnalysisFinished(startedAtMs int64, total int, message string) {
|
||||
finishedAtMs := time.Now().UnixMilli()
|
||||
durationMs := finishedAtMs - startedAtMs
|
||||
if durationMs < 0 {
|
||||
durationMs = 0
|
||||
}
|
||||
|
||||
publishAnalysisProgress(analysisProgressEvent{
|
||||
Running: false,
|
||||
Phase: "done",
|
||||
Progress: 1,
|
||||
Current: total,
|
||||
Total: total,
|
||||
Message: message,
|
||||
StartedAtMs: startedAtMs,
|
||||
FinishedAtMs: finishedAtMs,
|
||||
DurationMs: durationMs,
|
||||
})
|
||||
}
|
||||
|
||||
func publishAnalysisError(startedAtMs int64, message string, err error) {
|
||||
finishedAtMs := time.Now().UnixMilli()
|
||||
durationMs := finishedAtMs - startedAtMs
|
||||
if durationMs < 0 {
|
||||
durationMs = 0
|
||||
}
|
||||
|
||||
errText := ""
|
||||
if err != nil {
|
||||
errText = err.Error()
|
||||
}
|
||||
|
||||
publishAnalysisProgress(analysisProgressEvent{
|
||||
Running: false,
|
||||
Phase: "error",
|
||||
Progress: 0,
|
||||
Message: message,
|
||||
Error: errText,
|
||||
StartedAtMs: startedAtMs,
|
||||
FinishedAtMs: finishedAtMs,
|
||||
DurationMs: durationMs,
|
||||
})
|
||||
}
|
||||
|
||||
func publishFinishedPostworkStateForJob(
|
||||
j *RecordJob,
|
||||
queue string,
|
||||
|
||||
@ -391,25 +391,44 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
strings.EqualFold(r.URL.Query().Get("refresh"), "true")
|
||||
|
||||
if !forceNew {
|
||||
if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction); err != nil {
|
||||
var startedAtMs int64
|
||||
|
||||
if refreshPrediction {
|
||||
startedAtMs = publishAnalysisStarted(2, "Aktuelles Bild wird neu analysiert…")
|
||||
}
|
||||
|
||||
if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs); err != nil {
|
||||
if refreshPrediction {
|
||||
publishAnalysisError(startedAtMs, "Aktuelles Bild konnte nicht neu analysiert werden.", err)
|
||||
}
|
||||
|
||||
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
} else if ok {
|
||||
if refreshPrediction {
|
||||
publishAnalysisFinished(startedAtMs, 2, "Analyse abgeschlossen.")
|
||||
}
|
||||
|
||||
trainingWriteJSON(w, http.StatusOK, sample)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sample, err := trainingCreateNextSample()
|
||||
startedAtMs := publishAnalysisStarted(4, "Neues Trainingsbild wird vorbereitet…")
|
||||
|
||||
sample, err := trainingCreateNextSampleWithProgress(startedAtMs)
|
||||
if err != nil {
|
||||
publishAnalysisError(startedAtMs, "Trainingsbild konnte nicht erstellt werden.", err)
|
||||
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
publishAnalysisFinished(startedAtMs, 4, "Analyse abgeschlossen.")
|
||||
|
||||
trainingWriteJSON(w, http.StatusOK, sample)
|
||||
}
|
||||
|
||||
func trainingLatestOpenSample(root string, refreshPrediction bool) (*TrainingSample, bool, error) {
|
||||
func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs int64) (*TrainingSample, bool, error) {
|
||||
answered, err := trainingAnsweredSampleIDs(root)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@ -475,11 +494,6 @@ func trainingLatestOpenSample(root string, refreshPrediction bool) (*TrainingSam
|
||||
continue
|
||||
}
|
||||
|
||||
if refreshPrediction {
|
||||
sample.Prediction = trainingPredictFrame(framePath)
|
||||
_ = trainingWriteSample(root, sample)
|
||||
}
|
||||
|
||||
return sample, true, nil
|
||||
}
|
||||
|
||||
@ -603,8 +617,16 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Correction != nil && len(req.Correction.Boxes) > 0 {
|
||||
if err := trainingWriteDetectorSample(root, sample, req.Correction.Boxes); err != nil {
|
||||
detectorBoxes := []TrainingBox{}
|
||||
|
||||
if req.Correction != nil {
|
||||
detectorBoxes = req.Correction.Boxes
|
||||
} else if req.Accepted {
|
||||
detectorBoxes = sample.Prediction.Boxes
|
||||
}
|
||||
|
||||
if len(detectorBoxes) > 0 {
|
||||
if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil {
|
||||
fmt.Println("⚠️ detector sample write failed:", err)
|
||||
}
|
||||
}
|
||||
@ -1014,7 +1036,7 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Pipeline:
|
||||
// - YOLO erkennt Personen/Gender für die Counts.
|
||||
// - Automatisch erkannte Personenboxen werden nicht an das Frontend als sichtbare Boxen zurückgegeben.
|
||||
// - Personenboxen werden jetzt auch sichtbar zurückgegeben.
|
||||
// - Manuell gezeichnete Personenboxen werden trotzdem als Trainingsdaten gespeichert.
|
||||
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
@ -1742,6 +1764,84 @@ func trainingCreateNextSample() (*TrainingSample, error) {
|
||||
return sample, nil
|
||||
}
|
||||
|
||||
func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, error) {
|
||||
publishAnalysisStep(startedAtMs, 1, 4, "", "Video wird ausgewählt…")
|
||||
|
||||
settings := getSettings()
|
||||
|
||||
doneDir := strings.TrimSpace(settings.DoneDir)
|
||||
if doneDir == "" {
|
||||
return nil, errors.New("doneDir ist leer")
|
||||
}
|
||||
|
||||
videoPath, err := trainingPickRandomVideo(doneDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publishAnalysisStep(startedAtMs, 2, 4, filepath.Base(videoPath), "Frame wird extrahiert…")
|
||||
|
||||
duration := trainingProbeDurationSeconds(videoPath)
|
||||
second := trainingRandomSecond(duration)
|
||||
|
||||
root, err := trainingRootDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := trainingEnsureDetectorDirs(root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id := trainingMakeSampleID(videoPath, second)
|
||||
framePath := filepath.Join(root, "frames", id+".jpg")
|
||||
|
||||
if err := trainingExtractFrame(videoPath, framePath, second); err != nil {
|
||||
second = 0
|
||||
id = trainingMakeSampleID(videoPath, second)
|
||||
framePath = filepath.Join(root, "frames", id+".jpg")
|
||||
|
||||
if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil {
|
||||
return nil, fmt.Errorf("frame extraction failed: %v / fallback: %w", err, err2)
|
||||
}
|
||||
}
|
||||
|
||||
publishAnalysisStep(startedAtMs, 3, 4, filepath.Base(videoPath), "Frame wird analysiert…")
|
||||
|
||||
prediction := trainingPredictFrame(framePath)
|
||||
|
||||
var sourceSizeBytes int64
|
||||
if st, err := os.Stat(videoPath); err == nil && st != nil && !st.IsDir() {
|
||||
sourceSizeBytes = st.Size()
|
||||
}
|
||||
|
||||
sample := &TrainingSample{
|
||||
SampleID: id,
|
||||
FrameURL: "/api/training/frame?id=" + id,
|
||||
SourceFile: filepath.Base(videoPath),
|
||||
SourcePath: videoPath,
|
||||
SourceSizeBytes: sourceSizeBytes,
|
||||
Second: second,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Prediction: prediction,
|
||||
}
|
||||
|
||||
publishAnalysisStep(startedAtMs, 4, 4, filepath.Base(videoPath), "Analyse-Ergebnis wird gespeichert…")
|
||||
|
||||
if err := trainingWriteSample(root, sample); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sample, nil
|
||||
}
|
||||
|
||||
func trainingPickRandomVideo(doneDir string) (string, error) {
|
||||
extOK := map[string]bool{
|
||||
".mp4": true,
|
||||
|
||||
@ -14,6 +14,29 @@ type ScoredLabel = {
|
||||
score: number
|
||||
}
|
||||
|
||||
type AnalysisProgressEvent = {
|
||||
type?: 'analysis_progress'
|
||||
running?: boolean
|
||||
phase?: string
|
||||
progress?: number
|
||||
current?: number
|
||||
total?: number
|
||||
file?: string
|
||||
message?: string
|
||||
error?: string
|
||||
startedAtMs?: number
|
||||
finishedAtMs?: number
|
||||
durationMs?: number
|
||||
ts?: number
|
||||
}
|
||||
|
||||
type TrainingStatus = {
|
||||
feedbackCount: number
|
||||
requiredCount: number
|
||||
canTrain: boolean
|
||||
training?: TrainingJobStatus
|
||||
}
|
||||
|
||||
type TrainingJobStatus = {
|
||||
running: boolean
|
||||
progress: number
|
||||
@ -28,13 +51,6 @@ type TrainingJobStatus = {
|
||||
epochs?: number
|
||||
}
|
||||
|
||||
type TrainingStatus = {
|
||||
feedbackCount: number
|
||||
requiredCount: number
|
||||
canTrain: boolean
|
||||
training?: TrainingJobStatus
|
||||
}
|
||||
|
||||
type TrainingPrediction = {
|
||||
modelAvailable: boolean
|
||||
source?: string
|
||||
@ -551,6 +567,15 @@ function clamp01(v: number) {
|
||||
return Math.max(0, Math.min(1, v))
|
||||
}
|
||||
|
||||
function snap01(v: number, epsilon = 0.006) {
|
||||
const n = clamp01(v)
|
||||
|
||||
if (n <= epsilon) return 0
|
||||
if (n >= 1 - epsilon) return 1
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
function normalizeBox(box: TrainingBox): TrainingBox {
|
||||
const x = clamp01(box.x)
|
||||
const y = clamp01(box.y)
|
||||
@ -601,12 +626,13 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState
|
||||
|
||||
const maleCount = safeCount(p?.maleCount)
|
||||
const femaleCount = safeCount(p?.femaleCount)
|
||||
const unknownCount = safeCount(p?.unknownCount)
|
||||
|
||||
return {
|
||||
peopleCount: maleCount + femaleCount,
|
||||
peopleCount: maleCount + femaleCount + unknownCount,
|
||||
maleCount,
|
||||
femaleCount,
|
||||
unknownCount: 0,
|
||||
unknownCount,
|
||||
sexPosition: p?.sexPosition || 'unknown',
|
||||
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
|
||||
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
|
||||
@ -1099,8 +1125,12 @@ function DetectorBoxLabelSelect(props: {
|
||||
const rect = button.getBoundingClientRect()
|
||||
const viewportH = window.innerHeight
|
||||
const viewportW = window.innerWidth
|
||||
const maxHeight = Math.min(260, Math.max(140, viewportH - rect.bottom - 12))
|
||||
const openUp = viewportH - rect.bottom < 180 && rect.top > rect.bottom
|
||||
const spaceBelow = viewportH - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
const openUp = spaceBelow < 180 && spaceAbove > spaceBelow
|
||||
const maxHeight = openUp
|
||||
? Math.min(260, Math.max(140, spaceAbove - 12))
|
||||
: Math.min(260, Math.max(140, spaceBelow - 12))
|
||||
|
||||
setMenuStyle({
|
||||
position: 'fixed',
|
||||
@ -2149,19 +2179,6 @@ export default function TrainingTab(props: {
|
||||
: 'Trainingsbild wird geladen…'
|
||||
)
|
||||
|
||||
let progressTimer: number | undefined
|
||||
|
||||
progressTimer = window.setInterval(() => {
|
||||
setAnalysisProgress((value) => {
|
||||
if (value < 35) return Math.min(35, value + 4)
|
||||
if (value < 65) return Math.min(65, value + 2)
|
||||
if (value < 95) return Math.min(95, value + 1)
|
||||
|
||||
setAnalysisStep('Analyse läuft noch… Ergebnis wird erwartet.')
|
||||
return value
|
||||
})
|
||||
}, 350)
|
||||
|
||||
if (!opts?.preserveNotice) {
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
@ -2219,12 +2236,8 @@ export default function TrainingTab(props: {
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (progressTimer !== undefined) {
|
||||
window.clearInterval(progressTimer)
|
||||
}
|
||||
|
||||
setAnalysisProgress(100)
|
||||
setAnalysisStep('Analyse abgeschlossen.')
|
||||
setAnalysisProgress((value) => Math.max(value, 100))
|
||||
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
|
||||
|
||||
window.setTimeout(() => {
|
||||
setLoading(false)
|
||||
@ -2305,7 +2318,46 @@ export default function TrainingTab(props: {
|
||||
}
|
||||
}
|
||||
|
||||
const onAnalysisProgress = (ev: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(String(ev.data ?? 'null')) as AnalysisProgressEvent
|
||||
|
||||
if (data?.type !== 'analysis_progress') return
|
||||
|
||||
const progress = clampPercent(Number(data.progress ?? 0) * 100)
|
||||
const message = String(data.message || '').trim()
|
||||
|
||||
setAnalysisProgress(progress)
|
||||
|
||||
if (message) {
|
||||
setAnalysisStep(message)
|
||||
}
|
||||
|
||||
if (data.running) {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
if (!data.running && data.phase === 'error') {
|
||||
setLoading(false)
|
||||
setAnalysisProgress(0)
|
||||
setAnalysisStep('')
|
||||
|
||||
if (data.error || data.message) {
|
||||
setError(String(data.error || data.message))
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.running && data.phase === 'done') {
|
||||
setAnalysisProgress(100)
|
||||
setAnalysisStep(message || 'Analyse abgeschlossen.')
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
es.addEventListener('training', onTraining as EventListener)
|
||||
es.addEventListener('analysisProgress', onAnalysisProgress as EventListener)
|
||||
|
||||
es.onerror = () => {
|
||||
// Optional: Polling-Fallback bleibt separat bestehen.
|
||||
@ -2313,6 +2365,7 @@ export default function TrainingTab(props: {
|
||||
|
||||
return () => {
|
||||
es.removeEventListener('training', onTraining as EventListener)
|
||||
es.removeEventListener('analysisProgress', onAnalysisProgress as EventListener)
|
||||
es.close()
|
||||
}
|
||||
}, [applyTrainingStatus])
|
||||
@ -2516,12 +2569,16 @@ export default function TrainingTab(props: {
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const maleCount = safeCount(correction.maleCount)
|
||||
const femaleCount = safeCount(correction.femaleCount)
|
||||
const unknownCount = safeCount(correction.unknownCount)
|
||||
|
||||
const correctionPayload: CorrectionState = {
|
||||
...correction,
|
||||
peopleCount:
|
||||
safeCount(correction.maleCount) +
|
||||
safeCount(correction.femaleCount),
|
||||
unknownCount: 0,
|
||||
maleCount,
|
||||
femaleCount,
|
||||
unknownCount,
|
||||
peopleCount: maleCount + femaleCount + unknownCount,
|
||||
boxes: (correction.boxes ?? [])
|
||||
.map(normalizeBox)
|
||||
.filter((box) => box.label && box.w > 0 && box.h > 0),
|
||||
@ -2741,10 +2798,17 @@ export default function TrainingTab(props: {
|
||||
let x2 = original.x + original.w
|
||||
let y2 = original.y + original.h
|
||||
|
||||
if (boxInteraction.handle.includes('n')) y1 = clamp01(y1 + dy)
|
||||
if (boxInteraction.handle.includes('s')) y2 = clamp01(y2 + dy)
|
||||
if (boxInteraction.handle.includes('w')) x1 = clamp01(x1 + dx)
|
||||
if (boxInteraction.handle.includes('e')) x2 = clamp01(x2 + dx)
|
||||
const pointerX = snap01(clampedPos.x)
|
||||
const pointerY = snap01(clampedPos.y)
|
||||
|
||||
// Wichtig:
|
||||
// Beim Resize folgt die gezogene Ecke direkt dem Pointer.
|
||||
// Dadurch bleibt kein Grab-Offset übrig, wenn der Handle nicht exakt
|
||||
// auf der mathematischen Box-Ecke getroffen wurde.
|
||||
if (boxInteraction.handle.includes('n')) y1 = pointerY
|
||||
if (boxInteraction.handle.includes('s')) y2 = pointerY
|
||||
if (boxInteraction.handle.includes('w')) x1 = pointerX
|
||||
if (boxInteraction.handle.includes('e')) x2 = pointerX
|
||||
|
||||
const left = Math.min(x1, x2)
|
||||
const top = Math.min(y1, y2)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user