nsfwapp/backend/training.go
2026-06-19 07:05:34 +02:00

5681 lines
136 KiB
Go

// backend\training.go
package main
import (
"bufio"
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const trainingUncertainCandidateCount = 10
type TrainingLabels struct {
People []string `json:"people"`
SexPositions []string `json:"sexPositions"`
BodyParts []string `json:"bodyParts"`
Objects []string `json:"objects"`
Clothing []string `json:"clothing"`
}
type TrainingBox struct {
Label string `json:"label"`
Score float64 `json:"score,omitempty"`
X float64 `json:"x"`
Y float64 `json:"y"`
W float64 `json:"w"`
H float64 `json:"h"`
}
type TrainingScoredLabel struct {
Label string `json:"label"`
Score float64 `json:"score"`
}
type TrainingPrediction struct {
ModelAvailable bool `json:"modelAvailable"`
Source string `json:"source,omitempty"`
SexPosition string `json:"sexPosition"`
SexPositionScore float64 `json:"sexPositionScore"`
PeoplePresent []TrainingScoredLabel `json:"peoplePresent"`
BodyPartsPresent []TrainingScoredLabel `json:"bodyPartsPresent"`
ObjectsPresent []TrainingScoredLabel `json:"objectsPresent"`
ClothingPresent []TrainingScoredLabel `json:"clothingPresent"`
Boxes []TrainingBox `json:"boxes"`
Persons []TrainingPosePerson `json:"persons,omitempty"`
}
type TrainingCorrection struct {
SexPosition string `json:"sexPosition"`
PeoplePresent []string `json:"peoplePresent"`
BodyPartsPresent []string `json:"bodyPartsPresent"`
ObjectsPresent []string `json:"objectsPresent"`
ClothingPresent []string `json:"clothingPresent"`
Boxes []TrainingBox `json:"boxes"`
}
type TrainingSample struct {
SampleID string `json:"sampleId"`
FrameURL string `json:"frameUrl"`
SourceFile string `json:"sourceFile"`
SourcePath string `json:"sourcePath,omitempty"`
SourceSizeBytes int64 `json:"sourceSizeBytes,omitempty"`
Second float64 `json:"second"`
CreatedAt string `json:"createdAt"`
UncertaintyScore float64 `json:"uncertaintyScore,omitempty"`
Prediction TrainingPrediction `json:"prediction"`
}
type trainingUncertainQueueItem struct {
SampleID string `json:"sampleId"`
UncertaintyScore float64 `json:"uncertaintyScore"`
SourceFile string `json:"sourceFile,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
type trainingUncertainCandidate struct {
sample *TrainingSample
score float64
}
type TrainingFeedbackRequest struct {
SampleID string `json:"sampleId"`
Accepted bool `json:"accepted"`
Negative bool `json:"negative,omitempty"`
Correction *TrainingCorrection `json:"correction,omitempty"`
Notes string `json:"notes,omitempty"`
}
type TrainingFeedbackUpdateRequest struct {
SampleID string `json:"sampleId"`
AnsweredAt string `json:"answeredAt"`
Accepted bool `json:"accepted"`
Negative bool `json:"negative,omitempty"`
Correction *TrainingCorrection `json:"correction,omitempty"`
Notes string `json:"notes,omitempty"`
}
type TrainingSkipRequest struct {
SampleID string `json:"sampleId"`
}
type TrainingAnnotation struct {
SampleID string `json:"sampleId"`
FrameURL string `json:"frameUrl"`
SourceFile string `json:"sourceFile"`
SourcePath string `json:"sourcePath,omitempty"`
SourceSizeBytes int64 `json:"sourceSizeBytes,omitempty"`
Second float64 `json:"second"`
CreatedAt string `json:"createdAt"`
AnsweredAt string `json:"answeredAt"`
Prediction TrainingPrediction `json:"prediction"`
Accepted bool `json:"accepted"`
Negative bool `json:"negative,omitempty"`
Correction *TrainingCorrection `json:"correction,omitempty"`
Notes string `json:"notes,omitempty"`
}
type TrainingDetectorPrediction struct {
Available bool `json:"available"`
Source string `json:"source,omitempty"`
Boxes []TrainingBox `json:"boxes"`
}
type TrainingKeypoint struct {
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Conf float64 `json:"conf"`
}
type TrainingPosePerson struct {
Label string `json:"label,omitempty"`
Score float64 `json:"score"`
Box TrainingBox `json:"box"`
Keypoints []TrainingKeypoint `json:"keypoints"`
}
type TrainingPosePrediction struct {
Available bool `json:"available"`
Source string `json:"source,omitempty"`
PersonCount int `json:"personCount"`
Persons []TrainingPosePerson `json:"persons"`
}
type TrainingJobStatus struct {
Running bool `json:"running"`
Progress int `json:"progress"`
Step string `json:"step"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
FinishedAt string `json:"finishedAt,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
Stage string `json:"stage,omitempty"`
Epoch int `json:"epoch,omitempty"`
Epochs int `json:"epochs,omitempty"`
MAP50 float64 `json:"map50,omitempty"`
MAP5095 float64 `json:"map5095,omitempty"`
}
type TrainingConfidence struct {
Score float64 `json:"score"`
Level string `json:"level"`
Label string `json:"label"`
}
type TrainingLabelStat struct {
Label string `json:"label"`
Count int `json:"count"`
Confidence TrainingConfidence `json:"confidence"`
}
type TrainingStatsLabels struct {
People []TrainingLabelStat `json:"people"`
SexPositions []TrainingLabelStat `json:"sexPositions"`
BodyParts []TrainingLabelStat `json:"bodyParts"`
Objects []TrainingLabelStat `json:"objects"`
Clothing []TrainingLabelStat `json:"clothing"`
}
type TrainingModelInfo struct {
TrainedAt string `json:"trainedAt,omitempty"`
TrainedAtMs int64 `json:"trainedAtMs,omitempty"`
Epochs int `json:"epochs,omitempty"`
TrainSamples int `json:"trainSamples,omitempty"`
ValSamples int `json:"valSamples,omitempty"`
Imgsz int `json:"imgsz,omitempty"`
Device string `json:"device,omitempty"`
MAP50 float64 `json:"map50,omitempty"`
MAP5095 float64 `json:"map5095,omitempty"`
}
type TrainingStatsResponse struct {
OK bool `json:"ok"`
FeedbackCount int `json:"feedbackCount"`
AcceptedCount int `json:"acceptedCount"`
CorrectedCount int `json:"correctedCount"`
NegativeCount int `json:"negativeCount"`
SampleCount int `json:"sampleCount"`
BoxCount int `json:"boxCount"`
ModelAvailable bool `json:"modelAvailable"`
ModelInfo *TrainingModelInfo `json:"modelInfo,omitempty"`
DetectorModelAvailable bool `json:"detectorModelAvailable"`
DetectorModelInfo *TrainingModelInfo `json:"detectorModelInfo,omitempty"`
PoseModelAvailable bool `json:"poseModelAvailable"`
PoseModelInfo *TrainingModelInfo `json:"poseModelInfo,omitempty"`
Confidence TrainingConfidence `json:"confidence"`
Labels TrainingStatsLabels `json:"labels"`
}
type trainingProgressEvent struct {
Type string `json:"type"`
Stage string `json:"stage"`
Progress float64 `json:"progress"` // 0..1
Message string `json:"message,omitempty"`
Epoch int `json:"epoch,omitempty"`
Epochs int `json:"epochs,omitempty"`
SampleID string `json:"sampleId,omitempty"`
MAP50 *float64 `json:"mAP50,omitempty"`
MAP5095 *float64 `json:"mAP5095,omitempty"`
}
type TrainingFeedbackListResponse struct {
OK bool `json:"ok"`
Items []TrainingAnnotation `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"hasMore"`
}
func trainingFeedbackListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
limit := 30
offset := 0
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil {
limit = n
}
}
if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil {
offset = n
}
}
if limit < 1 {
limit = 30
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
items, err := trainingReadAnnotations(root)
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
// Neueste zuerst.
sort.SliceStable(items, func(i, j int) bool {
ai := strings.TrimSpace(items[i].AnsweredAt)
aj := strings.TrimSpace(items[j].AnsweredAt)
if ai == aj {
return items[i].CreatedAt > items[j].CreatedAt
}
return ai > aj
})
query := strings.TrimSpace(r.URL.Query().Get("q"))
filter := strings.TrimSpace(r.URL.Query().Get("filter"))
items = trainingFilterAnnotations(items, query, filter)
total := len(items)
if offset > total {
offset = total
}
end := offset + limit
if end > total {
end = total
}
page := items[offset:end]
trainingWriteJSON(w, http.StatusOK, TrainingFeedbackListResponse{
OK: true,
Items: page,
Total: total,
Limit: limit,
Offset: offset,
HasMore: end < total,
})
}
func trainingReadAnnotations(root string) ([]TrainingAnnotation, error) {
path := filepath.Join(root, "feedback.jsonl")
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []TrainingAnnotation{}, nil
}
return nil, err
}
items := []TrainingAnnotation{}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var item TrainingAnnotation
if err := json.Unmarshal([]byte(line), &item); err != nil {
continue
}
// Alte Einträge robust machen.
if strings.TrimSpace(item.FrameURL) == "" && strings.TrimSpace(item.SampleID) != "" {
item.FrameURL = "/api/training/frame?id=" + item.SampleID
}
items = append(items, item)
}
return items, nil
}
func trainingFilterAnnotations(
items []TrainingAnnotation,
query string,
filter string,
) []TrainingAnnotation {
cleanQuery := strings.ToLower(strings.TrimSpace(query))
cleanFilter := strings.ToLower(strings.TrimSpace(filter))
out := make([]TrainingAnnotation, 0, len(items))
for _, item := range items {
switch cleanFilter {
case "accepted":
if !item.Accepted || item.Negative {
continue
}
case "corrected":
if item.Accepted || item.Negative {
continue
}
case "negative":
if !item.Negative {
continue
}
}
if cleanQuery != "" && !trainingAnnotationMatchesQuery(item, cleanQuery) {
continue
}
out = append(out, item)
}
return out
}
func trainingAnnotationMatchesQuery(item TrainingAnnotation, cleanQuery string) bool {
effective := trainingEffectiveCorrection(item)
parts := []string{
item.SampleID,
item.SourceFile,
item.SourcePath,
item.CreatedAt,
item.AnsweredAt,
item.Notes,
effective.SexPosition,
}
if item.Negative {
parts = append(parts, "negative negativ leer keine labels")
}
parts = append(parts, effective.PeoplePresent...)
parts = append(parts, effective.BodyPartsPresent...)
parts = append(parts, effective.ObjectsPresent...)
parts = append(parts, effective.ClothingPresent...)
for _, box := range effective.Boxes {
parts = append(parts, box.Label)
}
haystack := strings.ToLower(strings.Join(parts, " "))
return strings.Contains(haystack, cleanQuery)
}
func trainingRemoveSampleFromUncertainQueue(root string, sampleID string) error {
sampleID = strings.TrimSpace(sampleID)
if sampleID == "" {
return nil
}
items, err := trainingReadUncertainQueue(root)
if err != nil {
return err
}
if len(items) == 0 {
return nil
}
next := make([]trainingUncertainQueueItem, 0, len(items))
changed := false
for _, item := range items {
if strings.TrimSpace(item.SampleID) == sampleID {
changed = true
continue
}
next = append(next, item)
}
if !changed {
return nil
}
return trainingWriteUncertainQueue(root, next)
}
func trainingReadValidUncertainCandidates(root string) ([]trainingUncertainCandidate, error) {
items, err := trainingReadUncertainQueue(root)
if err != nil {
return nil, err
}
if len(items) == 0 {
return []trainingUncertainCandidate{}, nil
}
answered, err := trainingAnsweredSampleIDs(root)
if err != nil {
return nil, err
}
candidates := make([]trainingUncertainCandidate, 0, len(items))
for _, item := range items {
id := strings.TrimSpace(item.SampleID)
if id == "" || answered[id] {
continue
}
framePath := filepath.Join(root, "frames", id+".jpg")
if !fileExistsNonEmpty(framePath) {
continue
}
sample, err := trainingReadSample(root, id)
if err != nil {
continue
}
score := item.UncertaintyScore
if score <= 0 {
score = sample.UncertaintyScore
}
if score <= 0 {
score = trainingPredictionUncertaintyScore(sample.Prediction)
}
score = clamp01(score)
sample.UncertaintyScore = score
candidates = append(candidates, trainingUncertainCandidate{
sample: sample,
score: score,
})
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].score == candidates[j].score {
return candidates[i].sample.CreatedAt < candidates[j].sample.CreatedAt
}
return candidates[i].score > candidates[j].score
})
return candidates, nil
}
func trainingWriteUncertainCandidateQueue(root string, candidates []trainingUncertainCandidate) error {
items := make([]trainingUncertainQueueItem, 0, len(candidates))
for _, candidate := range candidates {
if candidate.sample == nil {
continue
}
id := strings.TrimSpace(candidate.sample.SampleID)
if id == "" {
continue
}
items = append(items, trainingUncertainQueueItem{
SampleID: id,
UncertaintyScore: clamp01(candidate.score),
SourceFile: candidate.sample.SourceFile,
CreatedAt: candidate.sample.CreatedAt,
})
}
return trainingWriteUncertainQueue(root, items)
}
func trainingCreateUncertainCandidateWithProgress(
root string,
startedAtMs int64,
requestID string,
stepStart int,
stepTotal int,
prefix string,
) (*trainingUncertainCandidate, error) {
sample, err := trainingCreateNextSampleWithProgressRange(
startedAtMs,
requestID,
stepStart,
stepTotal,
prefix,
)
if err != nil {
return nil, err
}
score := trainingPredictionUncertaintyScore(sample.Prediction)
score = clamp01(score)
sample.UncertaintyScore = score
if err := trainingWriteSample(root, sample); err != nil {
return nil, err
}
return &trainingUncertainCandidate{
sample: sample,
score: score,
}, nil
}
func trainingUncertainQueuePath(root string) string {
return filepath.Join(root, "uncertain_queue.json")
}
func trainingReadUncertainQueue(root string) ([]trainingUncertainQueueItem, error) {
path := trainingUncertainQueuePath(root)
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []trainingUncertainQueueItem{}, nil
}
return nil, err
}
var items []trainingUncertainQueueItem
if err := json.Unmarshal(b, &items); err != nil {
return []trainingUncertainQueueItem{}, nil
}
return items, nil
}
func trainingWriteUncertainQueue(root string, items []trainingUncertainQueueItem) error {
path := trainingUncertainQueuePath(root)
if len(items) == 0 {
_ = os.Remove(path)
return nil
}
b, err := json.MarshalIndent(items, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0644)
}
func trainingPopQueuedUncertainSample(root string) (*TrainingSample, bool, error) {
items, err := trainingReadUncertainQueue(root)
if err != nil {
return nil, false, err
}
if len(items) == 0 {
return nil, false, nil
}
answered, err := trainingAnsweredSampleIDs(root)
if err != nil {
return nil, false, err
}
remaining := make([]trainingUncertainQueueItem, 0, len(items))
for index, item := range items {
id := strings.TrimSpace(item.SampleID)
if id == "" || answered[id] {
continue
}
framePath := filepath.Join(root, "frames", id+".jpg")
if !fileExistsNonEmpty(framePath) {
continue
}
sample, err := trainingReadSample(root, id)
if err != nil {
continue
}
sample.UncertaintyScore = item.UncertaintyScore
remaining = append(remaining, items[index+1:]...)
if err := trainingWriteUncertainQueue(root, remaining); err != nil {
return nil, false, err
}
return sample, true, nil
}
_ = trainingWriteUncertainQueue(root, []trainingUncertainQueueItem{})
return nil, false, nil
}
func trainingScaleProgress(local float64, start int, end int) int {
if math.IsNaN(local) || math.IsInf(local, 0) {
local = 0
}
local = clamp01(local)
if end < start {
end = start
}
return start + int(math.Round(local*float64(end-start)))
}
func trainingHandleProgressLine(line string, start int, end int, defaultStep string) bool {
line = strings.TrimSpace(line)
if line == "" {
return false
}
var ev trainingProgressEvent
if err := json.Unmarshal([]byte(line), &ev); err != nil {
return false
}
if ev.Type != "progress" {
return false
}
progress := trainingScaleProgress(ev.Progress, start, end)
step := strings.TrimSpace(ev.Message)
if step == "" {
step = defaultStep
}
trainingSetJobStatus(func(s *TrainingJobStatus) {
if progress > s.Progress {
s.Progress = progress
}
s.Step = step
if strings.TrimSpace(ev.Stage) != "" {
s.Stage = strings.TrimSpace(ev.Stage)
}
if ev.Epoch > 0 {
s.Epoch = ev.Epoch
}
if ev.Epochs > 0 {
s.Epochs = ev.Epochs
}
if ev.MAP50 != nil && *ev.MAP50 > 0 {
s.MAP50 = *ev.MAP50
}
if ev.MAP5095 != nil && *ev.MAP5095 > 0 {
s.MAP5095 = *ev.MAP5095
}
sampleID := strings.TrimSpace(ev.SampleID)
if sampleID != "" &&
!strings.Contains(sampleID, "/") &&
!strings.Contains(sampleID, "\\") {
s.PreviewURL = "/api/training/frame?id=" + url.QueryEscape(sampleID)
}
})
return true
}
func trainingPublishJobStatus(status TrainingJobStatus) {
b, err := json.Marshal(map[string]any{
"type": "training_status",
"training": status,
"ts": time.Now().UnixMilli(),
})
if err != nil {
return
}
publishSSE("training", b)
}
func trainingPublishAnalysisStep(
requestID string,
startedAtMs int64,
current int,
total int,
sourceFile string,
message string,
) {
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
current,
total,
sourceFile,
"",
message,
)
}
func trainingPublishAnalysisStepWithPreview(
requestID string,
startedAtMs int64,
current int,
total int,
sourceFile string,
previewURL string,
message string,
) {
progress := 0.0
if total > 0 {
progress = float64(current) / float64(total)
}
payload := map[string]any{
"type": "analysis_progress",
"scope": "training",
"requestId": requestID,
"running": true,
"phase": "running",
"progress": progress,
"startedAtMs": startedAtMs,
"current": current,
"total": total,
"sourceFile": strings.TrimSpace(sourceFile),
"message": strings.TrimSpace(message),
"ts": time.Now().UnixMilli(),
}
if strings.TrimSpace(previewURL) != "" {
payload["previewUrl"] = strings.TrimSpace(previewURL)
}
b, err := json.Marshal(payload)
if err != nil {
return
}
publishSSE("analysisProgress", b)
}
func trainingPublishAnalysisStarted(
requestID string,
total int,
sourceFile string,
message string,
) int64 {
return trainingPublishAnalysisStartedWithPreview(
requestID,
total,
sourceFile,
"",
message,
)
}
func trainingPublishAnalysisStartedWithPreview(
requestID string,
total int,
sourceFile string,
previewURL string,
message string,
) int64 {
startedAtMs := time.Now().UnixMilli()
payload := map[string]any{
"type": "analysis_progress",
"scope": "training",
"requestId": requestID,
"running": true,
"phase": "starting",
"progress": 0,
"startedAtMs": startedAtMs,
"current": 0,
"total": total,
"sourceFile": strings.TrimSpace(sourceFile),
"message": strings.TrimSpace(message),
"ts": time.Now().UnixMilli(),
}
if strings.TrimSpace(previewURL) != "" {
payload["previewUrl"] = strings.TrimSpace(previewURL)
}
b, err := json.Marshal(payload)
if err == nil {
publishSSE("analysisProgress", b)
}
return startedAtMs
}
func trainingPublishAnalysisFinished(
requestID string,
startedAtMs int64,
total int,
sourceFile string,
message string,
) {
finishedAtMs := time.Now().UnixMilli()
durationMs := finishedAtMs - startedAtMs
if durationMs < 0 {
durationMs = 0
}
b, err := json.Marshal(map[string]any{
"type": "analysis_progress",
"scope": "training",
"requestId": requestID,
"running": false,
"phase": "done",
"progress": 1,
"startedAtMs": startedAtMs,
"finishedAtMs": finishedAtMs,
"durationMs": durationMs,
"current": total,
"total": total,
"sourceFile": strings.TrimSpace(sourceFile),
"message": strings.TrimSpace(message),
"ts": time.Now().UnixMilli(),
})
if err != nil {
return
}
publishSSE("analysisProgress", b)
}
func trainingPublishAnalysisError(
requestID string,
startedAtMs int64,
sourceFile string,
message string,
err error,
) {
finishedAtMs := time.Now().UnixMilli()
durationMs := finishedAtMs - startedAtMs
if durationMs < 0 {
durationMs = 0
}
errText := ""
if err != nil {
errText = err.Error()
}
b, marshalErr := json.Marshal(map[string]any{
"type": "analysis_progress",
"scope": "training",
"requestId": requestID,
"running": false,
"phase": "error",
"progress": 0,
"startedAtMs": startedAtMs,
"finishedAtMs": finishedAtMs,
"durationMs": durationMs,
"sourceFile": strings.TrimSpace(sourceFile),
"message": strings.TrimSpace(message),
"error": errText,
"ts": time.Now().UnixMilli(),
})
if marshalErr != nil {
return
}
publishSSE("analysisProgress", b)
}
func trainingRunCommandStreaming(
ctx context.Context,
python string,
script string,
onLine func(line string) bool,
args ...string,
) (string, error) {
cmdArgs := append([]string{script}, args...)
cmd := exec.CommandContext(ctx, python, cmdArgs...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return "", err
}
if err := cmd.Start(); err != nil {
return "", err
}
var outMu sync.Mutex
var lines []string
readPipe := func(scanner *bufio.Scanner) {
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
handled := false
if onLine != nil {
handled = onLine(line)
}
// Progress-Events nicht in den finalen Output übernehmen.
if handled {
continue
}
outMu.Lock()
lines = append(lines, line)
outMu.Unlock()
}
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
readPipe(bufio.NewScanner(stdout))
}()
go func() {
defer wg.Done()
readPipe(bufio.NewScanner(stderr))
}()
err = cmd.Wait()
wg.Wait()
outMu.Lock()
out := strings.Join(lines, "\n")
outMu.Unlock()
if ctx.Err() != nil {
return strings.TrimSpace(out), errTrainingCancelled
}
return strings.TrimSpace(out), err
}
const minTrainingFeedbackCount = 5
const minDetectorTrainCount = 20
const minDetectorValCount = 3
const minPoseTrainCount = 20
const minPoseValCount = 3
const trainingPoseKeypointCount = 17
var errTrainingCancelled = errors.New("training cancelled")
var trainingJob = struct {
mu sync.Mutex
status TrainingJobStatus
cancel context.CancelFunc
}{}
func trainingSetJobStatus(update func(*TrainingJobStatus)) {
trainingJob.mu.Lock()
update(&trainingJob.status)
snapshot := trainingJob.status
trainingJob.mu.Unlock()
trainingPublishJobStatus(snapshot)
}
func trainingGetJobStatus() TrainingJobStatus {
trainingJob.mu.Lock()
defer trainingJob.mu.Unlock()
return trainingJob.status
}
func trainingStartJob(cancel context.CancelFunc) {
trainingJob.mu.Lock()
trainingJob.status = TrainingJobStatus{
Running: true,
Progress: 5,
Step: "Training wird vorbereitet…",
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
trainingJob.cancel = cancel
snapshot := trainingJob.status
trainingJob.mu.Unlock()
trainingPublishJobStatus(snapshot)
}
func trainingClearJobCancel() {
trainingJob.mu.Lock()
trainingJob.cancel = nil
trainingJob.mu.Unlock()
}
func trainingCleanupPartialDetectorTraining(root string) error {
// Löscht nur temporäre Trainingsläufe.
// Feedback, Samples, Frames und Dataset bleiben erhalten.
runsDir := filepath.Join(root, "detector", "runs")
if err := os.RemoveAll(runsDir); err != nil {
return err
}
return os.MkdirAll(runsDir, 0755)
}
func trainingFinishCancelled(root string) {
cleanupErr := trainingCleanupPartialDetectorTraining(root)
trainingSetJobStatus(func(s *TrainingJobStatus) {
finishedAt := time.Now().UTC()
var durationMs int64
if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); err == nil {
durationMs = finishedAt.Sub(startedAt).Milliseconds()
if durationMs < 0 {
durationMs = 0
}
}
s.Running = false
s.Step = "Training abgebrochen."
s.Message = "Training wurde abgebrochen. Temporäre Trainingsausgaben wurden gelöscht."
s.Error = ""
s.FinishedAt = finishedAt.Format(time.RFC3339)
s.DurationMs = durationMs
s.PreviewURL = ""
if cleanupErr != nil {
s.Message = "Training wurde abgebrochen, aber temporäre Trainingsausgaben konnten nicht vollständig gelöscht werden."
s.Error = cleanupErr.Error()
}
})
trainingClearJobCancel()
}
func trainingRunCommand(python string, script string, args ...string) (string, error) {
cmdArgs := append([]string{script}, args...)
cmd := exec.Command(python, cmdArgs...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
type trainingModelResolution struct {
BestPath string
EffectivePath string
Source string
TrainedExists bool
EffectiveExists bool
}
func trainingResolveModel(
root string,
kind string,
trainedSource string,
) trainingModelResolution {
bestPath := filepath.Join(root, kind, "model", "best.pt")
if fileExistsNonEmpty(bestPath) {
return trainingModelResolution{
BestPath: bestPath,
EffectivePath: bestPath,
Source: trainedSource,
TrainedExists: true,
EffectiveExists: true,
}
}
return trainingModelResolution{
BestPath: bestPath,
EffectivePath: bestPath,
Source: kind + "_missing",
TrainedExists: false,
EffectiveExists: false,
}
}
func trainingResolveDetectorModel(root string) trainingModelResolution {
return trainingResolveModel(
root,
"detector",
"yolo26_detector",
)
}
func trainingResolvePoseModel(root string) trainingModelResolution {
res := trainingResolveModel(
root,
"pose",
"yolo_pose",
)
if res.EffectiveExists {
return res
}
if poseBase, err := embeddedPoseModelPath(); err == nil && fileExistsNonEmpty(poseBase) {
res.EffectivePath = poseBase
res.Source = "yolo26_pose_base"
res.EffectiveExists = true
}
return res
}
func trainingLabelsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
trainingWriteJSON(w, http.StatusOK, defaultTrainingLabelsFromJSON())
}
const trainingImportVideoDefaultFrameCount = 10
const trainingImportVideoMaxFrameCount = 30
type TrainingImportVideoRequest struct {
JobID string `json:"jobId"`
Output string `json:"output"`
Count int `json:"count"`
AnalysisRequestID string `json:"analysisRequestId"`
}
type TrainingImportVideoResponse struct {
OK bool `json:"ok"`
Count int `json:"count"`
Sample *TrainingSample `json:"sample,omitempty"`
Samples []TrainingSample `json:"samples,omitempty"`
Errors []string `json:"errors,omitempty"`
}
func trainingCleanImportVideoCount(count int) int {
if count <= 0 {
return trainingImportVideoDefaultFrameCount
}
if count > trainingImportVideoMaxFrameCount {
return trainingImportVideoMaxFrameCount
}
return count
}
func trainingSupportedImportVideo(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".mp4", ".m4v", ".mov", ".mkv", ".webm":
return true
default:
return false
}
}
func trainingGeneratedAssetIDCandidatesForVideo(videoPath string) []string {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return nil
}
out := []string{}
seen := map[string]bool{}
add := func(id string) {
id = stripHotPrefix(strings.TrimSpace(id))
if id == "" ||
id == "." ||
id == ".." ||
strings.Contains(id, "/") ||
strings.Contains(id, "\\") {
return
}
if seen[id] {
return
}
seen[id] = true
out = append(out, id)
}
// Fall 1:
// Video liegt selbst unter /generated/<id>/...
//
// Beispiel:
// C:\app\generated\abc123\video.mp4
// => abc123
slashPath := filepath.ToSlash(filepath.Clean(videoPath))
parts := strings.Split(slashPath, "/")
for i := 0; i+1 < len(parts); i++ {
if strings.EqualFold(parts[i], "generated") {
add(parts[i+1])
}
}
// Fall 2:
// Video liegt z.B. in done/keep, aber generated/<id>/preview.jpg
// basiert auf dem Dateinamen ohne Extension.
//
// Beispiel:
// done/keep/model/abc123.mp4
// => generated/abc123/preview.jpg
base := filepath.Base(videoPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
add(stem)
return out
}
func trainingGeneratedPreviewPathForAssetID(assetID string) (string, bool) {
assetID = stripHotPrefix(strings.TrimSpace(assetID))
if assetID == "" ||
assetID == "." ||
assetID == ".." ||
strings.Contains(assetID, "/") ||
strings.Contains(assetID, "\\") {
return "", false
}
previewPath, err := resolvePathRelativeToApp(
filepath.Join("generated", assetID, "preview.jpg"),
)
if err != nil {
return "", false
}
if !fileExistsNonEmpty(previewPath) {
return "", false
}
return previewPath, true
}
func trainingPreviewPathForVideo(videoPath string) (string, bool) {
for _, assetID := range trainingGeneratedAssetIDCandidatesForVideo(videoPath) {
if previewPath, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok {
return previewPath, true
}
}
return "", false
}
func trainingPreviewURLForVideoPath(videoPath string) string {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return ""
}
if !trainingSupportedImportVideo(videoPath) {
return ""
}
return "/api/training/video-preview?output=" + url.QueryEscape(videoPath)
}
func trainingPreviewAssetIDForVideo(videoPath string) string {
candidates := trainingGeneratedAssetIDCandidatesForVideo(videoPath)
for _, assetID := range candidates {
if _, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok {
return assetID
}
}
for _, assetID := range candidates {
if _, err := findFinishedFileByID(assetID); err == nil {
return assetID
}
}
if len(candidates) > 0 {
return candidates[0]
}
return ""
}
func trainingVideoPreviewHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
outPath := strings.TrimSpace(r.URL.Query().Get("output"))
if outPath == "" {
trainingWriteError(w, http.StatusBadRequest, "output missing")
return
}
if !trainingSupportedImportVideo(outPath) {
trainingWriteError(w, http.StatusBadRequest, "unsupported video type")
return
}
st, err := os.Stat(outPath)
if err != nil || st == nil || st.IsDir() || st.Size() <= 0 {
trainingWriteError(w, http.StatusNotFound, "video not found")
return
}
// Fast path: Wenn /generated/<id>/preview.jpg schon existiert, direkt ausliefern.
if previewPath, ok := trainingPreviewPathForVideo(outPath); ok {
w.Header().Set("Cache-Control", "no-store")
servePreviewJPGFile(w, r, previewPath)
return
}
assetID := trainingPreviewAssetIDForVideo(outPath)
if assetID == "" {
trainingWriteError(w, http.StatusNotFound, "preview asset id not found")
return
}
// Wichtig:
// Nicht file=preview.jpg setzen.
// Ohne file=... darf recordPreviewWithBase die Preview bei Bedarf erzeugen.
r2 := r.Clone(r.Context())
u := *r.URL
q := u.Query()
q.Set("id", assetID)
q.Del("output")
q.Del("file")
q.Del("fallbackOnly")
u.RawQuery = q.Encode()
r2.URL = &u
recordPreviewWithBase(w, r2, "/api/training/video-preview")
}
func trainingFrameSecondsForVideo(duration float64, count int) []float64 {
count = trainingCleanImportVideoCount(count)
if duration <= 0 {
return []float64{0}
}
if duration <= 2 {
return []float64{0}
}
minSec := 0.5
maxSec := duration - 0.5
if maxSec <= minSec {
return []float64{0}
}
out := make([]float64, 0, count)
// Lokaler RNG, damit jeder Import neue Frames bekommt.
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Mindestabstand zwischen Frames, damit nicht mehrfach fast dieselbe Stelle kommt.
minDistance := 0.4
// Bei sehr kurzen Videos Abstand automatisch verkleinern.
availableRange := maxSec - minSec
if availableRange/float64(count) < minDistance {
minDistance = math.Max(0.1, availableRange/float64(count+1))
}
const maxAttempts = 4000
for attempts := 0; len(out) < count && attempts < maxAttempts; attempts++ {
sec := minSec + rng.Float64()*(maxSec-minSec)
// Auf 0.1s runden, damit IDs/Logs lesbar bleiben.
sec = math.Round(sec*10) / 10
tooClose := false
for _, existing := range out {
if math.Abs(sec-existing) < minDistance {
tooClose = true
break
}
}
if tooClose {
continue
}
out = append(out, sec)
}
// Fallback, falls ein extrem kurzes Video nicht genug unterschiedliche Random-Punkte hergibt.
for len(out) < count {
sec := minSec + rng.Float64()*(maxSec-minSec)
sec = math.Round(sec*10) / 10
out = append(out, sec)
}
sort.Float64s(out)
if len(out) == 0 {
out = append(out, 0)
}
return out
}
func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req TrainingImportVideoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
trainingWriteError(w, http.StatusBadRequest, "invalid json")
return
}
outPath := strings.TrimSpace(req.Output)
if outPath == "" {
trainingWriteError(w, http.StatusBadRequest, "output missing")
return
}
if !trainingSupportedImportVideo(outPath) {
trainingWriteError(w, http.StatusBadRequest, "unsupported video type")
return
}
fi, err := os.Stat(outPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
if err == nil {
err = errors.New("video file missing or empty")
}
trainingWriteError(w, http.StatusBadRequest, "video not found: "+err.Error())
return
}
duration := trainingProbeDurationSeconds(outPath)
if duration <= 0 {
trainingWriteError(w, http.StatusBadRequest, "Videolänge konnte nicht bestimmt werden")
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := trainingEnsureDetectorDirs(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
seconds := trainingFrameSecondsForVideo(duration, req.Count)
sourceFile := filepath.Base(outPath)
previewURL := ""
requestID := strings.TrimSpace(req.AnalysisRequestID)
if requestID == "" {
requestID = trainingMakeSampleID(outPath, float64(time.Now().UnixNano()))
}
totalSteps := len(seconds) * 3
if totalSteps < 1 {
totalSteps = 1
}
startedAtMs := trainingPublishAnalysisStartedWithPreview(
requestID,
totalSteps,
sourceFile,
previewURL,
"Video wird ins Training übernommen…",
)
var sourceSizeBytes int64
if st, err := os.Stat(outPath); err == nil && st != nil && !st.IsDir() {
sourceSizeBytes = st.Size()
}
samples := make([]TrainingSample, 0, len(seconds))
errs := []string{}
for i, second := range seconds {
stepBase := i * 3
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
stepBase+1,
totalSteps,
sourceFile,
previewURL,
fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)),
)
id := trainingMakeSampleID(outPath, second)
framePath := filepath.Join(root, "frames", id+".jpg")
if err := trainingExtractFrame(outPath, framePath, second); err != nil {
errs = append(errs, fmt.Sprintf("Frame bei %.1fs: %v", second, err))
continue
}
// Nach jeder erfolgreichen Extraktion das aktuell verarbeitete Frame zeigen.
previewURL = "/api/training/frame?id=" + url.QueryEscape(id)
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
stepBase+2,
totalSteps,
sourceFile,
previewURL,
fmt.Sprintf("Frame %d/%d wird analysiert…", i+1, len(seconds)),
)
prediction := trainingPredictFrame(framePath)
sample := &TrainingSample{
SampleID: id,
FrameURL: "/api/training/frame?id=" + id,
SourceFile: sourceFile,
SourcePath: outPath,
SourceSizeBytes: sourceSizeBytes,
Second: second,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Prediction: prediction,
}
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
stepBase+3,
totalSteps,
sourceFile,
previewURL,
fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)),
)
if err := trainingWriteSample(root, sample); err != nil {
_ = os.Remove(framePath)
errs = append(errs, fmt.Sprintf("Frame bei %.1fs speichern: %v", second, err))
continue
}
samples = append(samples, *sample)
}
if len(samples) == 0 {
msg := "keine Trainingsframes erzeugt"
if len(errs) > 0 {
msg += ": " + strings.Join(errs, "; ")
}
err := errors.New(msg)
trainingPublishAnalysisError(
requestID,
startedAtMs,
sourceFile,
"Video konnte nicht ins Training übernommen werden.",
err,
)
trainingWriteError(w, http.StatusInternalServerError, msg)
return
}
trainingPublishAnalysisFinished(
requestID,
startedAtMs,
totalSteps,
sourceFile,
fmt.Sprintf("%d Frames ins Training übernommen.", len(samples)),
)
trainingWriteJSON(w, http.StatusOK, TrainingImportVideoResponse{
OK: true,
Count: len(samples),
Sample: &samples[0],
Samples: samples,
Errors: errs,
})
}
func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
forceNew := r.URL.Query().Get("force") == "1" ||
strings.EqualFold(r.URL.Query().Get("force"), "true")
analysisRequestID := strings.TrimSpace(r.URL.Query().Get("analysisRequestId"))
excludeIDs := trainingExcludedSampleIDs(r)
preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") ||
strings.EqualFold(r.URL.Query().Get("sampleMode"), "uncertain")
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
refreshPrediction := r.URL.Query().Get("refresh") == "1" ||
strings.EqualFold(r.URL.Query().Get("refresh"), "true")
if !forceNew && !preferUncertain {
var startedAtMs int64
if refreshPrediction {
startedAtMs = time.Now().UnixMilli()
trainingPublishAnalysisStep(
analysisRequestID,
startedAtMs,
1,
2,
"",
"Aktuelles Bild wird neu analysiert…",
)
}
if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs, analysisRequestID, excludeIDs); err != nil {
if refreshPrediction {
trainingPublishAnalysisError(
analysisRequestID,
startedAtMs,
"",
"Aktuelles Bild konnte nicht neu analysiert werden.",
err,
)
}
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
} else if ok {
if refreshPrediction {
trainingPublishAnalysisFinished(
analysisRequestID,
startedAtMs,
2,
sample.SourceFile,
"Analyse abgeschlossen.",
)
}
trainingWriteJSON(w, http.StatusOK, sample)
return
}
}
startedAtMs := trainingPublishAnalysisStarted(
analysisRequestID,
func() int {
if preferUncertain {
return trainingUncertainCandidateCount*4 + 1
}
return 4
}(),
"",
func() string {
if preferUncertain {
return "Unsichere Prediction wird gesucht…"
}
return "Neues Trainingsbild wird vorbereitet…"
}(),
)
var sample *TrainingSample
if preferUncertain {
sample, err = trainingCreateUncertainNextSampleWithProgress(startedAtMs, analysisRequestID)
} else {
sample, err = trainingCreateNextSampleWithProgress(startedAtMs, analysisRequestID)
}
if err != nil {
trainingPublishAnalysisError(
analysisRequestID,
startedAtMs,
"",
"Trainingsbild konnte nicht erstellt werden.",
err,
)
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
trainingPublishAnalysisFinished(
analysisRequestID,
startedAtMs,
4,
sample.SourceFile,
"Analyse abgeschlossen.",
)
trainingWriteJSON(w, http.StatusOK, sample)
}
func trainingLatestOpenSample(
root string,
refreshPrediction bool,
startedAtMs int64,
requestID string,
excludeIDs map[string]bool,
) (*TrainingSample, bool, error) {
answered, err := trainingAnsweredSampleIDs(root)
if err != nil {
return nil, false, err
}
samplesDir := filepath.Join(root, "samples")
entries, err := os.ReadDir(samplesDir)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
type sampleFile struct {
id string
path string
modTime time.Time
}
files := []sampleFile{}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.ToLower(filepath.Ext(name)) != ".json" {
continue
}
id := strings.TrimSuffix(name, filepath.Ext(name))
if id == "" || answered[id] || excludeIDs[id] {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, sampleFile{
id: id,
path: filepath.Join(samplesDir, name),
modTime: info.ModTime(),
})
}
sort.Slice(files, func(i, j int) bool {
return files[i].modTime.After(files[j].modTime)
})
for _, file := range files {
sample, err := trainingReadSample(root, file.id)
if err != nil {
continue
}
framePath := filepath.Join(root, "frames", sample.SampleID+".jpg")
if !fileExistsNonEmpty(framePath) {
continue
}
if refreshPrediction {
sourceFile := strings.TrimSpace(sample.SourceFile)
if sourceFile == "" {
sourceFile = filepath.Base(sample.SourcePath)
}
trainingPublishAnalysisStep(
requestID,
startedAtMs,
1,
2,
sourceFile,
"Aktuelles Bild wird analysiert…",
)
sample.Prediction = trainingPredictFrame(framePath)
trainingPublishAnalysisStep(
requestID,
startedAtMs,
2,
2,
sourceFile,
"Analyse-Ergebnis wird gespeichert…",
)
if err := trainingWriteSample(root, sample); err != nil {
return nil, false, err
}
}
return sample, true, nil
}
return nil, false, nil
}
func trainingExcludedSampleIDs(r *http.Request) map[string]bool {
out := map[string]bool{}
for _, raw := range r.URL.Query()["exclude"] {
for _, part := range strings.Split(raw, ",") {
id := strings.TrimSpace(part)
if id == "" {
continue
}
if strings.Contains(id, "/") || strings.Contains(id, "\\") {
continue
}
out[id] = true
}
}
return out
}
func trainingAnsweredSampleIDs(root string) (map[string]bool, error) {
out := map[string]bool{}
path := filepath.Join(root, "feedback.jsonl")
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return out, nil
}
return nil, err
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var row TrainingAnnotation
if err := json.Unmarshal([]byte(line), &row); err != nil {
continue
}
id := strings.TrimSpace(row.SampleID)
if id != "" {
out[id] = true
}
}
return out, nil
}
func trainingFrameHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") {
trainingWriteError(w, http.StatusBadRequest, "invalid frame id")
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
path := filepath.Join(root, "frames", id+".jpg")
if _, err := os.Stat(path); err != nil {
trainingWriteError(w, http.StatusNotFound, "frame not found")
return
}
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, path)
}
func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeedbackRequest) []TrainingBox {
if req.Negative {
return []TrainingBox{}
}
boxes := []TrainingBox{}
if req.Correction != nil {
boxes = append(boxes, req.Correction.Boxes...)
} else if req.Accepted {
boxes = append(boxes, sample.Prediction.Boxes...)
}
return boxes
}
func trainingNegativeCorrection() *TrainingCorrection {
return &TrainingCorrection{
SexPosition: trainingNoSexPositionLabel,
PeoplePresent: []string{},
BodyPartsPresent: []string{},
ObjectsPresent: []string{},
ClothingPresent: []string{},
Boxes: []TrainingBox{},
}
}
func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req TrainingFeedbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
trainingWriteError(w, http.StatusBadRequest, "invalid json")
return
}
req.SampleID = strings.TrimSpace(req.SampleID)
if req.SampleID == "" {
trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
return
}
if req.Negative {
req.Accepted = false
req.Correction = trainingNegativeCorrection()
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
sample, err := trainingReadSample(root, req.SampleID)
if err != nil {
trainingWriteError(w, http.StatusNotFound, "sample not found")
return
}
annotation := TrainingAnnotation{
SampleID: sample.SampleID,
FrameURL: sample.FrameURL,
SourceFile: sample.SourceFile,
SourcePath: sample.SourcePath,
SourceSizeBytes: sample.SourceSizeBytes,
Second: sample.Second,
CreatedAt: sample.CreatedAt,
AnsweredAt: time.Now().UTC().Format(time.RFC3339),
Prediction: sample.Prediction,
Accepted: req.Accepted,
Negative: req.Negative,
Correction: req.Correction,
Notes: strings.TrimSpace(req.Notes),
}
if !annotation.Accepted && annotation.Correction == nil {
trainingWriteError(w, http.StatusBadRequest, "correction missing")
return
}
if err := trainingAppendAnnotation(root, annotation); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
detectorBoxes := trainingDetectorBoxesForAnnotation(sample, req)
if req.Negative || len(detectorBoxes) > 0 {
if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil {
appLogln("⚠️ detector sample write failed:", err)
}
}
trainingDeletePoseSample(root, req.SampleID)
if sexPosition := trainingSexPositionForFeedback(sample, req); !isNoSexPositionLabel(sexPosition) {
if err := trainingWritePoseSample(root, sample, sexPosition); err != nil {
appLogln("pose sample write failed:", err)
}
}
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut && r.Method != http.MethodPost {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req TrainingFeedbackUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
trainingWriteError(w, http.StatusBadRequest, "invalid json")
return
}
req.SampleID = strings.TrimSpace(req.SampleID)
req.AnsweredAt = strings.TrimSpace(req.AnsweredAt)
if req.Negative {
req.Accepted = false
req.Correction = trainingNegativeCorrection()
}
if req.SampleID == "" {
trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
return
}
if req.AnsweredAt == "" {
trainingWriteError(w, http.StatusBadRequest, "answeredAt missing")
return
}
if strings.Contains(req.SampleID, "/") || strings.Contains(req.SampleID, "\\") {
trainingWriteError(w, http.StatusBadRequest, "invalid sampleId")
return
}
if !req.Accepted && req.Correction == nil {
trainingWriteError(w, http.StatusBadRequest, "correction missing")
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
items, err := trainingReadAnnotations(root)
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
matchIndex := -1
for i, item := range items {
if strings.TrimSpace(item.SampleID) == req.SampleID &&
strings.TrimSpace(item.AnsweredAt) == req.AnsweredAt {
matchIndex = i
break
}
}
if matchIndex < 0 {
trainingWriteError(w, http.StatusNotFound, "feedback not found")
return
}
old := items[matchIndex]
updated := old
updated.Accepted = req.Accepted
updated.Negative = req.Negative
updated.Notes = strings.TrimSpace(req.Notes)
if req.Accepted {
updated.Correction = nil
} else {
updated.Correction = req.Correction
}
items[matchIndex] = updated
if err := trainingWriteAnnotations(root, items); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
sample, sampleErr := trainingReadSample(root, req.SampleID)
if sampleErr != nil {
sample = &TrainingSample{
SampleID: old.SampleID,
FrameURL: old.FrameURL,
SourceFile: old.SourceFile,
SourcePath: old.SourcePath,
SourceSizeBytes: old.SourceSizeBytes,
Second: old.Second,
CreatedAt: old.CreatedAt,
Prediction: old.Prediction,
}
}
trainingDeleteDetectorSample(root, req.SampleID)
detectorBoxes := trainingDetectorBoxesForAnnotation(sample, TrainingFeedbackRequest{
SampleID: req.SampleID,
Accepted: req.Accepted,
Negative: req.Negative,
Correction: req.Correction,
Notes: req.Notes,
})
if req.Negative || len(detectorBoxes) > 0 {
if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil {
appLogln("⚠️ detector sample update failed:", err)
}
}
trainingDeletePoseSample(root, req.SampleID)
if sexPosition := trainingSexPositionForFeedback(sample, TrainingFeedbackRequest{
SampleID: req.SampleID,
Accepted: req.Accepted,
Negative: req.Negative,
Correction: req.Correction,
Notes: req.Notes,
}); !isNoSexPositionLabel(sexPosition) {
if err := trainingWritePoseSample(root, sample, sexPosition); err != nil {
appLogln("pose sample update failed:", err)
}
}
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"item": updated,
})
}
func trainingSkipHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req TrainingSkipRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
trainingWriteError(w, http.StatusBadRequest, "invalid json")
return
}
sampleID := strings.TrimSpace(req.SampleID)
if sampleID == "" {
trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
return
}
if strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
trainingWriteError(w, http.StatusBadRequest, "invalid sampleId")
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
// Aus Uncertain-Queue entfernen, falls es dort noch liegt.
if err := trainingRemoveSampleFromUncertainQueue(root, sampleID); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
// Sample + Frame löschen.
trainingDeleteSampleFiles(root, sampleID)
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"sampleId": sampleID,
})
}
func trainingHasDetectorTrainingData(imagesDir string, labelsDir string) bool {
imageExts := map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
}
entries, err := os.ReadDir(imagesDir)
if err != nil {
return false
}
count := 0
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if !imageExts[ext] {
continue
}
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
labelPath := filepath.Join(labelsDir, id+".txt")
if fileExists(labelPath) {
count++
}
}
return count >= 5
}
func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
current := trainingGetJobStatus()
if current.Running {
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "Training läuft bereits.",
"training": current,
})
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := trainingEnsureDetectorDirs(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
// Falls bisher alles zufällig in train gelandet ist, erzeugen wir mindestens
// ein Validation-Sample durch Kopieren. Bei mehr Daten solltest du später
// einen echten 80/20 Split verwenden.
if err := trainingEnsurePoseDirs(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := trainingEnsureDetectorValidationSample(root); err != nil {
appLogln("⚠️ detector val sample ensure failed:", err)
}
if err := trainingEnsurePoseValidationSample(root); err != nil {
appLogln("pose val sample ensure failed:", err)
}
feedbackPath := filepath.Join(root, "feedback.jsonl")
feedbackCount, _ := trainingCountAnnotations(feedbackPath)
if feedbackCount < minTrainingFeedbackCount {
trainingWriteError(
w,
http.StatusBadRequest,
fmt.Sprintf(
"Zu wenige Bewertungen für das YOLO26-Training. Mindestens %d, aktuell %d.",
minTrainingFeedbackCount,
feedbackCount,
),
)
return
}
detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train")
detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train")
detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val")
detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml")
poseTrainImages := filepath.Join(root, "pose", "dataset", "images", "train")
poseTrainLabels := filepath.Join(root, "pose", "dataset", "labels", "train")
poseValImages := filepath.Join(root, "pose", "dataset", "images", "val")
poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml")
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels)
poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels)
if !fileExistsNonEmpty(detectorDatasetYAML) ||
trainCount < minDetectorTrainCount ||
valCount < minDetectorValCount ||
positiveTrainCount == 0 ||
positiveValCount == 0 {
trainingWriteError(
w,
http.StatusBadRequest,
fmt.Sprintf(
"Zu wenige YOLO26-Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.",
trainCount,
positiveTrainCount,
valCount,
positiveValCount,
minDetectorTrainCount,
minDetectorValCount,
),
)
return
}
if !fileExistsNonEmpty(poseDatasetYAML) ||
poseTrainCount < minPoseTrainCount ||
poseValCount < minPoseValCount {
trainingWriteError(
w,
http.StatusBadRequest,
fmt.Sprintf(
"Zu wenige YOLO26-Pose-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.",
poseTrainCount,
poseValCount,
minPoseTrainCount,
minPoseValCount,
),
)
return
}
ctx, cancel := context.WithCancel(context.Background())
trainingStartJob(cancel)
go trainingRunJob(ctx, root, feedbackCount)
trainingWriteJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"message": "Training gestartet.",
"training": trainingGetJobStatus(),
"detector": map[string]any{
"trainCount": trainCount,
"valCount": valCount,
"positiveTrainCount": positiveTrainCount,
"positiveValCount": positiveValCount,
"requiredTrain": minDetectorTrainCount,
"requiredVal": minDetectorValCount,
"datasetYAML": detectorDatasetYAML,
"usesSceneCLIP": false,
"usesSceneKNN": false,
"source": "yolo26_detector",
"detectsPosition": false,
},
"pose": map[string]any{
"trainCount": poseTrainCount,
"valCount": poseValCount,
"requiredTrain": minPoseTrainCount,
"requiredVal": minPoseValCount,
"datasetYAML": poseDatasetYAML,
"source": "yolo26_pose",
},
})
}
func trainingCancelHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
trainingJob.mu.Lock()
status := trainingJob.status
cancel := trainingJob.cancel
trainingJob.mu.Unlock()
if !status.Running {
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "Es läuft kein Training.",
"training": status,
})
return
}
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Step = "Training wird abgebrochen…"
s.Message = ""
s.Error = ""
})
if cancel != nil {
cancel()
}
trainingWriteJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"message": "Training wird abgebrochen.",
"training": trainingGetJobStatus(),
})
}
func trainingRunJob(ctx context.Context, root string, count int) {
python := trainingPythonExe()
cleanOutput := func(text string) string {
out := strings.TrimSpace(text)
if len(out) > 1500 {
out = out[:1500] + "…"
}
return out
}
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Progress = 10
s.Step = "YOLO26-Daten werden geprüft…"
})
detectorOutput := ""
detectorStatus := "skipped"
poseOutput := ""
poseStatus := "skipped"
detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml")
detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train")
detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train")
detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val")
detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
if err := trainingEnsureDetectorValidationSample(root); err != nil {
appLogln("⚠️ detector val sample ensure failed:", err)
}
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
fmt.Printf(
"🔎 detector data: train=%d (%d positive) val=%d (%d positive) yaml=%v\n",
trainCount,
positiveTrainCount,
valCount,
positiveValCount,
fileExistsNonEmpty(detectorDatasetYAML),
)
if fileExistsNonEmpty(detectorDatasetYAML) &&
trainCount >= minDetectorTrainCount &&
valCount >= minDetectorValCount &&
positiveTrainCount > 0 &&
positiveValCount > 0 {
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Progress = 15
s.Step = "YOLO26 Detector wird trainiert…"
})
detectorScript := trainingScriptPath("train_detector_model.py")
detectorOut, detectorErr := trainingRunCommandStreaming(
ctx,
python,
detectorScript,
func(line string) bool {
return trainingHandleProgressLine(
line,
15,
58,
"YOLO26 Detector wird trainiert…",
)
},
"--root", root,
"--base", "yolo26n.pt",
"--epochs", strconv.Itoa(trainingDetectorEpochs()),
"--imgsz", "640",
)
if errors.Is(detectorErr, errTrainingCancelled) {
appLogln("⛔ YOLO26 detector training cancelled")
trainingFinishCancelled(root)
return
}
detectorOutput = detectorOut
detectorOutputClean := cleanOutput(detectorOutput)
if detectorErr != nil {
detectorStatus = "failed"
appLogln("⚠️ YOLO26 detector training failed:", detectorErr)
if detectorOutputClean != "" {
appLogln("⚠️ YOLO26 detector output:", detectorOutputClean)
}
} else {
detectorStatus = "trained"
if detectorOutputClean != "" {
appLogln("✅ YOLO26 detector training:", detectorOutputClean)
}
}
} else {
detectorStatus = "skipped_no_detector_data"
detectorOutput = fmt.Sprintf(
"YOLO26 Detector übersprungen: zu wenige Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.",
trainCount,
positiveTrainCount,
valCount,
positiveValCount,
minDetectorTrainCount,
minDetectorValCount,
)
appLogln("⚠️", detectorOutput)
}
detectorOutputClean := cleanOutput(detectorOutput)
poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml")
poseTrainImages := filepath.Join(root, "pose", "dataset", "images", "train")
poseTrainLabels := filepath.Join(root, "pose", "dataset", "labels", "train")
poseValImages := filepath.Join(root, "pose", "dataset", "images", "val")
poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
trainingSetJobStatus(func(s *TrainingJobStatus) {
if s.Progress < 60 {
s.Progress = 60
}
s.Step = "YOLO26 Pose-Daten werden aufgebaut..."
})
if written, err := trainingSyncPoseDataset(root); err != nil {
poseStatus = "failed"
poseOutput = "YOLO26 Pose-Dataset konnte nicht aufgebaut werden: " + err.Error()
appLogln(poseOutput)
} else {
appLogln("pose samples synced:", written)
}
if err := trainingEnsurePoseValidationSample(root); err != nil {
appLogln("pose val sample ensure failed:", err)
}
poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels)
poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels)
fmt.Printf(
"pose data: train=%d val=%d yaml=%v\n",
poseTrainCount,
poseValCount,
fileExistsNonEmpty(poseDatasetYAML),
)
if poseStatus != "failed" &&
fileExistsNonEmpty(poseDatasetYAML) &&
poseTrainCount >= minPoseTrainCount &&
poseValCount >= minPoseValCount {
trainingSetJobStatus(func(s *TrainingJobStatus) {
if s.Progress < 62 {
s.Progress = 62
}
s.Step = "YOLO26 Pose wird trainiert..."
})
poseBasePath := "yolo26n-pose.pt"
if p, err := embeddedPoseModelPath(); err == nil && fileExistsNonEmpty(p) {
poseBasePath = p
}
poseScript := trainingScriptPath("train_pose_model.py")
poseOut, poseErr := trainingRunCommandStreaming(
ctx,
python,
poseScript,
func(line string) bool {
return trainingHandleProgressLine(
line,
62,
98,
"YOLO26 Pose wird trainiert...",
)
},
"--root", root,
"--base", poseBasePath,
"--epochs", strconv.Itoa(trainingDetectorEpochs()),
"--imgsz", "640",
)
if errors.Is(poseErr, errTrainingCancelled) {
appLogln("YOLO26 pose training cancelled")
trainingFinishCancelled(root)
return
}
poseOutput = poseOut
poseOutputClean := cleanOutput(poseOutput)
if poseErr != nil {
poseStatus = "failed"
appLogln("YOLO26 pose training failed:", poseErr)
if poseOutputClean != "" {
appLogln("YOLO26 pose output:", poseOutputClean)
}
} else {
poseStatus = "trained"
if poseOutputClean != "" {
appLogln("YOLO26 pose training:", poseOutputClean)
}
}
} else if poseStatus != "failed" {
poseStatus = "skipped_no_pose_data"
poseOutput = fmt.Sprintf(
"YOLO26 Pose uebersprungen: zu wenige Skeleton-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.",
poseTrainCount,
poseValCount,
minPoseTrainCount,
minPoseValCount,
)
appLogln(poseOutput)
}
poseOutputClean := cleanOutput(poseOutput)
message := "Training abgeschlossen."
errorText := ""
switch detectorStatus {
case "trained":
message = "Training abgeschlossen. YOLO26 Detector wurde trainiert."
case "skipped_no_detector_data":
message = detectorOutput
case "failed":
message = "YOLO26 Detector ist fehlgeschlagen."
if detectorOutputClean != "" {
message += " Grund: " + detectorOutputClean
}
errorText = message
default:
message = "Training abgeschlossen, aber YOLO26 wurde nicht trainiert."
if detectorOutputClean != "" {
message += " Ausgabe: " + detectorOutputClean
}
}
if poseStatus == "trained" && detectorStatus == "trained" {
message = "Training abgeschlossen. YOLO26 Detector und YOLO26 Pose wurden trainiert."
} else if poseStatus == "trained" && detectorStatus != "failed" {
message = "Training abgeschlossen. YOLO26 Pose wurde trainiert."
} else if poseStatus == "skipped_no_pose_data" && detectorStatus == "trained" {
message += " YOLO26 Pose wurde uebersprungen: zu wenige Skeleton-Beispiele."
} else if poseStatus == "failed" {
if detectorStatus == "failed" {
message += " YOLO26 Pose ist ebenfalls fehlgeschlagen."
} else {
message = "YOLO26 Pose ist fehlgeschlagen."
}
if poseOutputClean != "" {
message += " Grund: " + poseOutputClean
}
errorText = message
}
if detectorStatus == "trained" {
// Verlaufseintrag schreiben, solange die Job-Startzeit für die Dauer noch verfügbar ist.
trainingAppendRunHistory(root)
}
trainingSetJobStatus(func(s *TrainingJobStatus) {
finishedAt := time.Now().UTC()
var durationMs int64
if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); err == nil {
durationMs = finishedAt.Sub(startedAt).Milliseconds()
if durationMs < 0 {
durationMs = 0
}
}
s.Running = false
s.Progress = 100
s.Step = "Training abgeschlossen."
s.Message = message
s.Error = errorText
s.FinishedAt = finishedAt.Format(time.RFC3339)
s.DurationMs = durationMs
s.PreviewURL = ""
})
trainingClearJobCancel()
}
func trainingStatsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
stats, err := trainingBuildStats(root)
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
trainingWriteJSON(w, http.StatusOK, stats)
}
func trainingBuildStats(root string) (*TrainingStatsResponse, error) {
grouped, err := trainingGroupedLabels()
if err != nil {
// Fallback: Stats sollen trotzdem funktionieren, auch wenn Label-Gruppierung scheitert.
fallbackLabels := defaultTrainingLabelsFromJSON()
grouped = TrainingGroupedLabels{
People: fallbackLabels.People,
SexPositions: fallbackLabels.SexPositions,
BodyParts: fallbackLabels.BodyParts,
Objects: fallbackLabels.Objects,
Clothing: fallbackLabels.Clothing,
}
}
peopleSet := stringSet(grouped.People)
sexPositionSet := stringSet(grouped.SexPositions)
bodyPartSet := stringSet(grouped.BodyParts)
objectSet := stringSet(grouped.Objects)
clothingSet := stringSet(grouped.Clothing)
peopleCounts := map[string]int{}
sexPositionCounts := map[string]int{}
bodyPartCounts := map[string]int{}
objectCounts := map[string]int{}
clothingCounts := map[string]int{}
stats := &TrainingStatsResponse{
OK: true,
Labels: TrainingStatsLabels{
People: []TrainingLabelStat{},
SexPositions: []TrainingLabelStat{},
BodyParts: []TrainingLabelStat{},
Objects: []TrainingLabelStat{},
Clothing: []TrainingLabelStat{},
},
}
feedbackPath := filepath.Join(root, "feedback.jsonl")
b, err := os.ReadFile(feedbackPath)
if err != nil {
if os.IsNotExist(err) {
stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples"))
trainingApplyStatsModelInfo(root, stats)
return stats, nil
}
return nil, err
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var annotation TrainingAnnotation
if err := json.Unmarshal([]byte(line), &annotation); err != nil {
continue
}
stats.FeedbackCount++
if annotation.Negative {
stats.NegativeCount++
} else if annotation.Accepted {
stats.AcceptedCount++
} else {
stats.CorrectedCount++
}
effective := trainingEffectiveCorrection(annotation)
sexPosition := normalizeSexPositionLabel(effective.SexPosition)
if len(sexPositionSet) == 0 || sexPositionSet[sexPosition] {
sexPositionCounts[sexPosition]++
}
for _, label := range effective.BodyPartsPresent {
clean := strings.TrimSpace(label)
if clean == "" {
continue
}
if len(bodyPartSet) == 0 || bodyPartSet[clean] {
bodyPartCounts[clean]++
}
}
for _, label := range effective.ObjectsPresent {
clean := strings.TrimSpace(label)
if clean == "" {
continue
}
if len(objectSet) == 0 || objectSet[clean] {
objectCounts[clean]++
}
}
for _, label := range effective.ClothingPresent {
clean := strings.TrimSpace(label)
if clean == "" {
continue
}
if len(clothingSet) == 0 || clothingSet[clean] {
clothingCounts[clean]++
}
}
for _, box := range effective.Boxes {
label := strings.TrimSpace(box.Label)
if label == "" {
continue
}
stats.BoxCount++
switch {
case peopleSet[label]:
peopleCounts[label]++
case bodyPartSet[label]:
bodyPartCounts[label]++
case objectSet[label]:
objectCounts[label]++
case clothingSet[label]:
clothingCounts[label]++
}
}
}
stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples"))
trainingApplyStatsModelInfo(root, stats)
stats.Labels = TrainingStatsLabels{
// Personen/Box-Labels brauchen mehr Beispiele, weil der Detector Boxen lernen muss.
People: trainingStatsMapToList(peopleCounts, 20),
// Scene-Positionen sind Sample-Labels, hier reichen grob weniger pro Klasse.
SexPositions: trainingStatsMapToList(sexPositionCounts, 8),
// Detector-Klassen: grob 15 Beispiele pro Label als solide Untergrenze.
BodyParts: trainingStatsMapToList(bodyPartCounts, 15),
Objects: trainingStatsMapToList(objectCounts, 15),
Clothing: trainingStatsMapToList(clothingCounts, 15),
}
stats.Confidence = trainingOverallConfidence(
stats.FeedbackCount,
stats.BoxCount,
stats.AcceptedCount,
stats.CorrectedCount,
stats.Labels,
)
return stats, nil
}
func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrection {
if annotation.Negative {
return *trainingNegativeCorrection()
}
if annotation.Correction != nil {
return *annotation.Correction
}
p := annotation.Prediction
return TrainingCorrection{
SexPosition: p.SexPosition,
PeoplePresent: trainingScoredLabelsToStrings(p.PeoplePresent),
BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent),
ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent),
ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent),
Boxes: p.Boxes,
}
}
func trainingScoredLabelsToStrings(values []TrainingScoredLabel) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
label := strings.TrimSpace(value.Label)
if label == "" || seen[label] {
continue
}
seen[label] = true
out = append(out, label)
}
return out
}
func trainingStatsMapToList(values map[string]int, target int) []TrainingLabelStat {
out := make([]TrainingLabelStat, 0, len(values))
for label, count := range values {
label = strings.TrimSpace(label)
if label == "" || count <= 0 {
continue
}
out = append(out, TrainingLabelStat{
Label: label,
Count: count,
Confidence: trainingLabelConfidence(count, target),
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count == out[j].Count {
return out[i].Label < out[j].Label
}
return out[i].Count > out[j].Count
})
return out
}
func trainingCountSampleFiles(samplesDir string) int {
entries, err := os.ReadDir(samplesDir)
if err != nil {
return 0
}
count := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.ToLower(filepath.Ext(entry.Name())) == ".json" {
count++
}
}
return count
}
func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
job := trainingGetJobStatus()
if !job.Running {
if err := trainingEnsureDetectorDirs(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := trainingEnsurePoseDirs(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := trainingEnsureDetectorValidationSample(root); err != nil {
appLogln("⚠️ detector val sample ensure failed:", err)
}
if err := trainingEnsurePoseValidationSample(root); err != nil {
appLogln("pose val sample ensure failed:", err)
}
}
feedbackPath := filepath.Join(root, "feedback.jsonl")
feedbackCount, _ := trainingCountAnnotations(feedbackPath)
detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml")
detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train")
detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train")
detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val")
detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml")
poseTrainImages := filepath.Join(root, "pose", "dataset", "images", "train")
poseTrainLabels := filepath.Join(root, "pose", "dataset", "labels", "train")
poseValImages := filepath.Join(root, "pose", "dataset", "images", "val")
poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
detectorModel := trainingResolveDetectorModel(root)
poseModel := trainingResolvePoseModel(root)
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels)
poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels)
datasetReady := fileExistsNonEmpty(detectorDatasetYAML)
detectorDataReady := datasetReady &&
trainCount >= minDetectorTrainCount &&
valCount >= minDetectorValCount &&
positiveTrainCount > 0 &&
positiveValCount > 0
poseDatasetReady := fileExistsNonEmpty(poseDatasetYAML)
poseDataReady := poseDatasetReady &&
poseTrainCount >= minPoseTrainCount &&
poseValCount >= minPoseValCount
canTrain := feedbackCount >= minTrainingFeedbackCount && detectorDataReady && poseDataReady
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"feedbackCount": feedbackCount,
"requiredCount": minTrainingFeedbackCount,
"canTrain": canTrain,
"training": job,
"detector": map[string]any{
"source": "yolo26_detector",
"usesSceneCLIP": false,
"usesSceneKNN": false,
"usesResNet18KNN": false,
"detectsPeople": true,
"detectsGender": true,
"detectsSexPosition": false,
"detectsBodyParts": true,
"detectsObjects": true,
"detectsClothing": true,
"detectsBoxes": true,
"trainCount": trainCount,
"valCount": valCount,
"positiveTrainCount": positiveTrainCount,
"positiveValCount": positiveValCount,
"requiredTrain": minDetectorTrainCount,
"requiredVal": minDetectorValCount,
"datasetReady": datasetReady,
"datasetYAML": detectorDatasetYAML,
"dataReady": detectorDataReady,
"modelExists": detectorModel.EffectiveExists,
"modelPath": detectorModel.EffectivePath,
"trainedModelExists": detectorModel.TrainedExists,
"trainedModelPath": detectorModel.BestPath,
"modelSource": detectorModel.Source,
},
"pose": map[string]any{
"source": "yolo26_pose",
"usesKeypoints": true,
"predictsPersons": true,
"predictsSexPosition": poseModel.TrainedExists,
"trainedFromFeedback": true,
"trainCount": poseTrainCount,
"valCount": poseValCount,
"requiredTrain": minPoseTrainCount,
"requiredVal": minPoseValCount,
"datasetReady": poseDatasetReady,
"datasetYAML": poseDatasetYAML,
"dataReady": poseDataReady,
"modelExists": poseModel.EffectiveExists,
"modelPath": poseModel.EffectivePath,
"trainedModelExists": poseModel.TrainedExists,
"trainedModelPath": poseModel.BestPath,
"modelSource": poseModel.Source,
},
"scene": map[string]any{
"source": "disabled",
"usesSceneCLIP": false,
"usesSceneKNN": false,
"usesResNet18KNN": false,
"usesLogisticRegression": false,
"predictsSexPosition": false,
"predictsPeople": false,
"predictsGender": false,
"predictsBodyParts": false,
"predictsObjects": false,
"predictsClothing": false,
"predictsBoxes": false,
"feedbackCount": feedbackCount,
"requiredCount": minTrainingFeedbackCount,
"dataReady": false,
"modelReady": false,
},
"pipeline": map[string]any{
"variant": "YOLO26_ONLY",
"peopleSource": "yolo26_detector",
"genderSource": "yolo26_detector",
"sexPositionSource": "yolo26_pose",
"bodyPartsSource": "yolo26_detector",
"objectsSource": "yolo26_detector",
"clothingSource": "yolo26_detector",
"boxesSource": "yolo26_detector",
"usesSceneKNNForDetection": false,
"usesSceneCLIP": false,
"usesSceneKNN": false,
"usesYOLOForDetection": true,
"usesYOLOForSexPosition": true,
},
})
}
func trainingApplyStatsModelInfo(root string, stats *TrainingStatsResponse) {
detectorAvailable := trainingStatsModelAvailableFor(root, "detector")
detectorInfo := trainingReadModelInfoFor(root, "detector")
poseAvailable := trainingStatsModelAvailableFor(root, "pose")
poseInfo := trainingReadModelInfoFor(root, "pose")
// modelAvailable/modelInfo bleiben aus Kompatibilitaetsgruenden der Detector.
stats.ModelAvailable = detectorAvailable
stats.ModelInfo = detectorInfo
stats.DetectorModelAvailable = detectorAvailable
stats.DetectorModelInfo = detectorInfo
stats.PoseModelAvailable = poseAvailable
stats.PoseModelInfo = poseInfo
}
func trainingStatsModelAvailable(root string) bool {
return trainingStatsModelAvailableFor(root, "detector")
}
func trainingStatsModelAvailableFor(root string, kind string) bool {
modelPath := filepath.Join(root, kind, "model", "best.pt")
return fileExistsNonEmpty(modelPath)
}
// trainingReadModelInfo liest Versions-/Datums-Infos zum aktuell trainierten
// Detector-Modell. Datum/Version stammen primär aus status.json (vom Trainingsskript),
// Fallback ist die Änderungszeit der best.pt-Datei.
func trainingReadModelInfo(root string) *TrainingModelInfo {
return trainingReadModelInfoFor(root, "detector")
}
func trainingReadModelInfoFor(root string, kind string) *TrainingModelInfo {
modelPath := filepath.Join(root, kind, "model", "best.pt")
fi, err := os.Stat(modelPath)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
return nil
}
info := &TrainingModelInfo{
TrainedAt: fi.ModTime().UTC().Format(time.RFC3339),
TrainedAtMs: fi.ModTime().UnixMilli(),
}
statusPath := filepath.Join(root, kind, "model", "status.json")
if b, err := os.ReadFile(statusPath); err == nil {
var raw struct {
TrainedAt string `json:"trainedAt"`
Epochs int `json:"epochs"`
TrainSamples int `json:"trainSamples"`
ValSamples int `json:"valSamples"`
Imgsz int `json:"imgsz"`
Device string `json:"device"`
MAP50 float64 `json:"mAP50"`
MAP5095 float64 `json:"mAP5095"`
}
if json.Unmarshal(b, &raw) == nil {
if trimmed := strings.TrimSpace(raw.TrainedAt); trimmed != "" {
if t, err := time.Parse(time.RFC3339, trimmed); err == nil {
info.TrainedAt = t.UTC().Format(time.RFC3339)
info.TrainedAtMs = t.UnixMilli()
}
}
info.Epochs = raw.Epochs
info.TrainSamples = raw.TrainSamples
info.ValSamples = raw.ValSamples
info.Imgsz = raw.Imgsz
info.Device = strings.TrimSpace(raw.Device)
info.MAP50 = raw.MAP50
info.MAP5095 = raw.MAP5095
}
}
return info
}
type TrainingHistoryEntry struct {
TrainedAt string `json:"trainedAt,omitempty"`
TrainedAtMs int64 `json:"trainedAtMs,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
Epochs int `json:"epochs,omitempty"`
TrainSamples int `json:"trainSamples,omitempty"`
ValSamples int `json:"valSamples,omitempty"`
Imgsz int `json:"imgsz,omitempty"`
Device string `json:"device,omitempty"`
MAP50 float64 `json:"map50,omitempty"`
MAP5095 float64 `json:"map5095,omitempty"`
}
type TrainingHistoryResponse struct {
OK bool `json:"ok"`
Entries []TrainingHistoryEntry `json:"entries"`
}
func trainingHistoryPath(root string) string {
return filepath.Join(root, "detector", "training_history.jsonl")
}
// trainingAppendRunHistory hängt nach einem erfolgreichen Trainingslauf einen
// Verlaufseintrag an (Datum, mAP, Samples, Epochen, Dauer).
func trainingAppendRunHistory(root string) {
info := trainingReadModelInfo(root)
if info == nil {
return
}
entry := TrainingHistoryEntry{
TrainedAt: info.TrainedAt,
TrainedAtMs: info.TrainedAtMs,
Epochs: info.Epochs,
TrainSamples: info.TrainSamples,
ValSamples: info.ValSamples,
Imgsz: info.Imgsz,
Device: info.Device,
MAP50: info.MAP50,
MAP5095: info.MAP5095,
}
// Dauer aus der Startzeit des aktuellen Jobs ableiten.
job := trainingGetJobStatus()
if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(job.StartedAt)); err == nil {
if ms := time.Now().UTC().Sub(startedAt).Milliseconds(); ms > 0 {
entry.DurationMs = ms
}
}
b, err := json.Marshal(entry)
if err != nil {
return
}
path := trainingHistoryPath(root)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
appLogln("⚠️ training history dir konnte nicht erstellt werden:", err)
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
appLogln("⚠️ training history konnte nicht geöffnet werden:", err)
return
}
defer f.Close()
if _, err := f.Write(append(b, '\n')); err != nil {
appLogln("⚠️ training history konnte nicht geschrieben werden:", err)
}
}
// trainingReadHistory liest die Verlaufseinträge, neueste zuerst.
func trainingReadHistory(root string, limit int) []TrainingHistoryEntry {
b, err := os.ReadFile(trainingHistoryPath(root))
if err != nil {
return nil
}
lines := strings.Split(string(b), "\n")
entries := make([]TrainingHistoryEntry, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var e TrainingHistoryEntry
if json.Unmarshal([]byte(line), &e) != nil {
continue
}
entries = append(entries, e)
}
// Datei ist chronologisch → umdrehen, damit der neueste Lauf oben steht.
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
entries[i], entries[j] = entries[j], entries[i]
}
if limit > 0 && len(entries) > limit {
entries = entries[:limit]
}
return entries
}
func trainingHistoryHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
entries := trainingReadHistory(root, 50)
if entries == nil {
entries = []TrainingHistoryEntry{}
}
trainingWriteJSON(w, http.StatusOK, TrainingHistoryResponse{
OK: true,
Entries: entries,
})
}
func trainingConfidenceFromScore(score float64) TrainingConfidence {
if math.IsNaN(score) || math.IsInf(score, 0) {
score = 0
}
score = clamp01(score)
level := "none"
label := "Keine"
switch {
case score >= 0.75:
level = "high"
label = "Hoch"
case score >= 0.45:
level = "mid"
label = "Mittel"
case score > 0:
level = "low"
label = "Niedrig"
}
return TrainingConfidence{
Score: score,
Level: level,
Label: label,
}
}
func trainingLabelConfidence(count int, target int) TrainingConfidence {
if target <= 0 {
target = 10
}
if count <= 0 {
return trainingConfidenceFromScore(0)
}
// Grobe Datenabdeckung: target erreicht = 100%.
// sqrt macht kleine Mengen etwas weniger hart, aber 1 Treffer bleibt niedrig.
score := math.Sqrt(float64(count) / float64(target*2))
return trainingConfidenceFromScore(score)
}
func trainingSaturationScore(value int, target int) float64 {
if value <= 0 || target <= 0 {
return 0
}
// Sanfter Anstieg, aber nie über 1.
return clamp01(math.Sqrt(float64(value) / float64(target)))
}
func trainingAverageLabelConfidence(labels TrainingStatsLabels) float64 {
values := []float64{}
appendScores := func(items []TrainingLabelStat) {
for _, item := range items {
values = append(values, clamp01(item.Confidence.Score))
}
}
appendScores(labels.People)
appendScores(labels.SexPositions)
appendScores(labels.BodyParts)
appendScores(labels.Objects)
appendScores(labels.Clothing)
if len(values) == 0 {
return 0
}
sum := 0.0
for _, value := range values {
sum += value
}
return clamp01(sum / float64(len(values)))
}
func trainingOverallConfidence(
feedbackCount int,
boxCount int,
acceptedCount int,
correctedCount int,
labels TrainingStatsLabels,
) TrainingConfidence {
if feedbackCount <= 0 {
return trainingConfidenceFromScore(0)
}
// Datenmenge: 300 Feedbacks sind grob "voll", darunter anteilig.
feedbackScore := trainingSaturationScore(feedbackCount, 300)
// Detector-Daten: 1000 Boxen sind grob "voll", darunter anteilig.
boxScore := trainingSaturationScore(boxCount, 1000)
// Label-Abdeckung aus den einzelnen Label-Confidence-Werten.
labelScore := trainingAverageLabelConfidence(labels)
// Modell-/Prediction-Zustimmung:
// Viele "Passt so"-Antworten bedeuten, dass die Vorhersagen brauchbar sind.
// Bei 4/229 ist dieser Teil bewusst sehr niedrig.
agreementScore := 0.0
decisionCount := acceptedCount + correctedCount
if decisionCount > 0 {
agreementScore = clamp01(float64(acceptedCount) / float64(decisionCount))
}
// Korrekturquote als zusätzlicher Dämpfer.
// 98% korrigiert soll die Gesamt-Confidence sichtbar drücken,
// aber nicht alle gesammelten Daten entwerten.
correctionRate := 0.0
if decisionCount > 0 {
correctionRate = clamp01(float64(correctedCount) / float64(decisionCount))
}
correctionPenalty := 1.0 - math.Min(0.45, correctionRate*0.45)
// Gesamt:
// - Datenmenge zählt viel
// - Boxen und Label-Abdeckung zählen mittel
// - echte Modell-Zustimmung zählt bewusst mit rein
score :=
feedbackScore*0.30 +
boxScore*0.25 +
labelScore*0.25 +
agreementScore*0.20
score *= correctionPenalty
return trainingConfidenceFromScore(score)
}
func stringSet(values []string) map[string]bool {
out := map[string]bool{}
for _, value := range values {
clean := strings.TrimSpace(value)
if clean == "" {
continue
}
out[clean] = true
}
return out
}
func trainingRecognitionEnabled() bool {
return getSettings().TrainingRecognitionEnabled
}
func trainingDetectorEpochs() int {
epochs := getSettings().TrainingDetectorEpochs
if epochs < 1 {
return 60
}
if epochs > 300 {
return 300
}
return epochs
}
func trainingDeleteAllHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := os.RemoveAll(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, fmt.Sprintf("Trainingsdaten konnten nicht gelöscht werden: %v", err))
return
}
// Basisordner direkt wieder anlegen, damit die nächsten API-Aufrufe sauber funktionieren.
if _, err := trainingRootDir(); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
trainingSetJobStatus(func(s *TrainingJobStatus) {
*s = TrainingJobStatus{}
})
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "Alle Trainingsdaten wurden gelöscht.",
})
}
func trainingCountDetectorSamples(imagesDir string, labelsDir string) int {
imageExts := map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
}
entries, err := os.ReadDir(imagesDir)
if err != nil {
return 0
}
count := 0
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if !imageExts[ext] {
continue
}
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
labelPath := filepath.Join(labelsDir, id+".txt")
if fileExists(labelPath) {
count++
}
}
return count
}
func trainingCountPositiveDetectorSamples(imagesDir string, labelsDir string) int {
imageExts := map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
}
entries, err := os.ReadDir(imagesDir)
if err != nil {
return 0
}
count := 0
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if !imageExts[ext] {
continue
}
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
labelPath := filepath.Join(labelsDir, id+".txt")
if fileExistsNonEmpty(labelPath) {
count++
}
}
return count
}
func trainingWriteDetectorDatasetYAML(root string) error {
datasetDir := filepath.Join(root, "detector", "dataset")
absDatasetDir, err := filepath.Abs(datasetDir)
if err != nil {
return err
}
namesYAML, err := trainingDetectorDatasetNamesYAML()
if err != nil {
return err
}
path := filepath.Join(datasetDir, "dataset.yaml")
yoloPath := filepath.ToSlash(absDatasetDir)
body := fmt.Sprintf(`path: %s
train: images/train
val: images/val
names:
%s`, yoloPath, namesYAML)
return os.WriteFile(path, []byte(body), 0644)
}
func trainingWritePoseDatasetYAML(root string) error {
datasetDir := filepath.Join(root, "pose", "dataset")
absDatasetDir, err := filepath.Abs(datasetDir)
if err != nil {
return err
}
namesYAML, err := trainingPoseDatasetNamesYAML()
if err != nil {
return err
}
path := filepath.Join(datasetDir, "dataset.yaml")
yoloPath := filepath.ToSlash(absDatasetDir)
body := fmt.Sprintf(`path: %s
train: images/train
val: images/val
kpt_shape: [17, 3]
flip_idx: [0, 2, 1, 4, 3, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16, 15]
names:
%s`, yoloPath, namesYAML)
return os.WriteFile(path, []byte(body), 0644)
}
func trainingEnsureDetectorDirs(root string) error {
dirs := []string{
filepath.Join(root, "detector"),
filepath.Join(root, "detector", "dataset"),
filepath.Join(root, "detector", "dataset", "images"),
filepath.Join(root, "detector", "dataset", "images", "train"),
filepath.Join(root, "detector", "dataset", "images", "val"),
filepath.Join(root, "detector", "dataset", "labels"),
filepath.Join(root, "detector", "dataset", "labels", "train"),
filepath.Join(root, "detector", "dataset", "labels", "val"),
filepath.Join(root, "detector", "runs"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
if err := trainingWriteDetectorDatasetYAML(root); err != nil {
return err
}
return nil
}
func trainingEnsurePoseDirs(root string) error {
dirs := []string{
filepath.Join(root, "pose"),
filepath.Join(root, "pose", "dataset"),
filepath.Join(root, "pose", "dataset", "images"),
filepath.Join(root, "pose", "dataset", "images", "train"),
filepath.Join(root, "pose", "dataset", "images", "val"),
filepath.Join(root, "pose", "dataset", "labels"),
filepath.Join(root, "pose", "dataset", "labels", "train"),
filepath.Join(root, "pose", "dataset", "labels", "val"),
filepath.Join(root, "pose", "runs"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
if err := trainingWritePoseDatasetYAML(root); err != nil {
return err
}
return nil
}
func trainingCreateNextSample() (*TrainingSample, error) {
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
}
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 {
// Fallback auf Sekunde 0, falls random second nicht funktioniert.
second = 0
id = trainingMakeSampleID(videoPath, second)
framePath = filepath.Join(root, "frames", id+".jpg")
if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil {
return nil, appErrorf("frame extraction failed: %v / fallback: %w", err, err2)
}
}
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,
}
if err := trainingWriteSample(root, sample); err != nil {
return nil, err
}
return sample, nil
}
func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64, requestID string) (*TrainingSample, error) {
root, err := trainingRootDir()
if err != nil {
return nil, err
}
candidates, err := trainingReadValidUncertainCandidates(root)
if err != nil {
return nil, err
}
// Ziel:
// Vor jeder Auswahl sollen wieder 5 Kandidaten verglichen werden.
// Wenn noch 4 aus der Queue übrig sind, wird genau 1 neues Bild extrahiert.
// Wenn keine Queue existiert, wird ein kompletter 5er-Batch aufgebaut.
missing := trainingUncertainCandidateCount - len(candidates)
if missing < 1 {
// Trotzdem mindestens 1 neues Bild nachziehen,
// damit die Auswahl nach jedem Speichern frisch bleibt.
missing = 1
}
candidateWindowTotal := trainingUncertainCandidateCount
const stepsPerCandidate = 4
totalSteps := missing*stepsPerCandidate + 1
errs := []string{}
for i := 0; i < missing; i++ {
attempt := i + 1
stepStart := i*stepsPerCandidate + 1
prefix := fmt.Sprintf("Kandidat %d/%d: ", attempt, candidateWindowTotal)
candidate, err := trainingCreateUncertainCandidateWithProgress(
root,
startedAtMs,
requestID,
stepStart,
totalSteps,
prefix,
)
if err != nil {
errs = append(errs, err.Error())
trainingPublishAnalysisStep(
requestID,
startedAtMs,
stepStart+stepsPerCandidate-1,
totalSteps,
"",
fmt.Sprintf("Kandidat %d/%d fehlgeschlagen…", attempt, candidateWindowTotal),
)
continue
}
candidates = append(candidates, *candidate)
trainingPublishAnalysisStep(
requestID,
startedAtMs,
stepStart+stepsPerCandidate-1,
totalSteps,
candidate.sample.SourceFile,
fmt.Sprintf(
"Kandidat %d/%d bewertet · Unsicherheit %.0f%%",
attempt,
candidateWindowTotal,
candidate.score*100,
),
)
}
if len(candidates) == 0 {
if len(errs) > 0 {
return nil, errors.New(strings.Join(errs, "; "))
}
return nil, errors.New("keine unsicheren Trainingskandidaten gefunden")
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].score == candidates[j].score {
return candidates[i].sample.CreatedAt < candidates[j].sample.CreatedAt
}
return candidates[i].score > candidates[j].score
})
best := candidates[0]
remaining := candidates[1:]
if len(remaining) > trainingUncertainCandidateCount-1 {
remaining = remaining[:trainingUncertainCandidateCount-1]
}
if err := trainingWriteUncertainCandidateQueue(root, remaining); err != nil {
return nil, err
}
trainingPublishAnalysisStep(
requestID,
startedAtMs,
totalSteps,
totalSteps,
best.sample.SourceFile,
fmt.Sprintf(
"Unsicherster Kandidat gewählt · Score %.0f%% · Fenster %d/%d",
best.score*100,
len(remaining)+1,
trainingUncertainCandidateCount,
),
)
best.sample.UncertaintyScore = best.score
return best.sample, nil
}
func reloadAIServerModelAfterTraining() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, aiServerURL()+"/reload", nil)
if err != nil {
appLogln("⚠️ AI Server Reload Request konnte nicht gebaut werden:", err)
return
}
addAIServerAuth(req)
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
appLogln("⚠️ AI Server Reload fehlgeschlagen:", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
appLogln("⚠️ AI Server Reload HTTP", resp.StatusCode, strings.TrimSpace(string(body)))
return
}
appLogln("✅ AI Server Modell nach Training neu geladen:", strings.TrimSpace(string(body)))
}
func trainingDeleteSampleFiles(root string, sampleID string) {
sampleID = strings.TrimSpace(sampleID)
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
return
}
_ = os.Remove(filepath.Join(root, "samples", sampleID+".json"))
_ = os.Remove(filepath.Join(root, "frames", sampleID+".jpg"))
}
func trainingPredictionUncertaintyScore(pred TrainingPrediction) float64 {
if !pred.ModelAvailable {
return 0.10
}
scores := []float64{}
addScore := func(score float64) {
if math.IsNaN(score) || math.IsInf(score, 0) {
return
}
if score <= 0 {
return
}
scores = append(scores, clamp01(score))
}
if !isNoSexPositionLabel(pred.SexPosition) {
addScore(pred.SexPositionScore)
}
for _, box := range pred.Boxes {
addScore(box.Score)
}
if len(pred.Boxes) == 0 {
for _, item := range pred.BodyPartsPresent {
addScore(item.Score)
}
for _, item := range pred.ObjectsPresent {
addScore(item.Score)
}
for _, item := range pred.ClothingPresent {
addScore(item.Score)
}
}
if len(scores) == 0 {
// Modell ist verfügbar, erkennt aber nichts.
// Das kann ein nützliches Hard-Negative oder ein False-Negative sein.
return 0.35
}
sum := 0.0
for _, score := range scores {
// Höchste Unsicherheit ungefähr im mittleren Bereich.
// 0.55 ist absichtlich etwas niedriger als 0.75,
// damit Low/Mid-Confidence-Fälle bevorzugt werden.
distance := math.Abs(score - 0.55)
uncertainty := 1.0 - distance/0.55
sum += clamp01(uncertainty)
}
avg := sum / float64(len(scores))
// Viele Boxen mit mittlerer Confidence sind besonders wertvoll.
boxBonus := math.Min(0.12, float64(len(pred.Boxes))*0.025)
// Sehr niedrige Confidence soll ebenfalls auftauchen,
// aber nicht alle Ergebnisse dominieren.
lowConfidenceBonus := 0.0
for _, score := range scores {
if score >= 0.25 && score <= 0.75 {
lowConfidenceBonus = 0.08
break
}
}
return clamp01(avg + boxBonus + lowConfidenceBonus)
}
func trainingCreateNextSampleWithProgress(startedAtMs int64, requestID string) (*TrainingSample, error) {
return trainingCreateNextSampleWithProgressRange(
startedAtMs,
requestID,
1,
4,
"",
)
}
func trainingCreateNextSampleWithProgressRange(
startedAtMs int64,
requestID string,
stepStart int,
stepTotal int,
prefix string,
) (*TrainingSample, error) {
publishStep := func(localStep int, sourceFile string, message string) {
trainingPublishAnalysisStep(
requestID,
startedAtMs,
stepStart+localStep-1,
stepTotal,
sourceFile,
prefix+message,
)
}
publishStep(1, "", "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
}
sourceFile := filepath.Base(videoPath)
previewURL := ""
publishStep = func(localStep int, sourceFile string, message string) {
trainingPublishAnalysisStepWithPreview(
requestID,
startedAtMs,
stepStart+localStep-1,
stepTotal,
sourceFile,
previewURL,
prefix+message,
)
}
publishStep(2, sourceFile, "Bild 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 {
publishStep(2, sourceFile, "Bild wird erneut bei Sekunde 0 extrahiert…")
second = 0
id = trainingMakeSampleID(videoPath, second)
framePath = filepath.Join(root, "frames", id+".jpg")
if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil {
return nil, appErrorf("frame extraction failed: %v / fallback: %w", err, err2)
}
}
previewURL = "/api/training/frame?id=" + url.QueryEscape(id)
publishStep(3, sourceFile, "Bild 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: sourceFile,
SourcePath: videoPath,
SourceSizeBytes: sourceSizeBytes,
Second: second,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Prediction: prediction,
}
publishStep(4, sourceFile, "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,
}
doneDir = strings.TrimSpace(doneDir)
if doneDir == "" {
return "", errors.New("doneDir ist leer")
}
doneAbs, err := filepath.Abs(doneDir)
if err != nil {
return "", err
}
var files []string
err = filepath.WalkDir(doneAbs, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
rel, err := filepath.Rel(doneAbs, path)
if err != nil {
return nil
}
rel = filepath.Clean(rel)
// Root "done" selbst erlauben.
if rel == "." {
return nil
}
parts := strings.Split(rel, string(os.PathSeparator))
top := strings.ToLower(strings.TrimSpace(parts[0]))
name := strings.ToLower(strings.TrimSpace(d.Name()))
if d.IsDir() {
// Nur diese Bereiche fürs Trainingsbild:
// - done/
// - done/keep/...
//
// Alles andere unter done wird ignoriert, z.B.:
// .postwork_tmp, .trash, generated, training, sonstige Temp-Ordner.
if top != "keep" {
return filepath.SkipDir
}
// Innerhalb von keep trotzdem versteckte/temporäre Ordner überspringen.
if name == ".trash" ||
name == ".postwork_tmp" ||
name == "training" ||
name == "generated" ||
strings.HasPrefix(name, ".") {
return filepath.SkipDir
}
return nil
}
// Dateien nur direkt in done/ oder unter done/keep/... erlauben.
if len(parts) > 1 && top != "keep" {
return nil
}
// Keine versteckten/temp-Dateien verwenden.
if strings.HasPrefix(name, ".") ||
strings.Contains(name, ".tmp.") ||
strings.Contains(name, ".part") {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if extOK[ext] {
files = append(files, path)
}
return nil
})
if err != nil {
return "", err
}
if len(files) == 0 {
return "", errors.New("keine Videos in done oder done/keep gefunden")
}
return files[rand.Intn(len(files))], nil
}
func trainingExtractFrame(videoPath string, framePath string, second float64) error {
settings := getSettings()
ffmpeg := strings.TrimSpace(settings.FFmpegPath)
if ffmpeg == "" {
ffmpeg = "ffmpeg"
}
_ = os.Remove(framePath)
ss := strconv.FormatFloat(math.Max(0, second), 'f', 3, 64)
cmd := exec.Command(
ffmpeg,
"-hide_banner",
"-loglevel", "error",
"-ss", ss,
"-i", videoPath,
"-frames:v", "1",
"-q:v", "2",
"-y",
framePath,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
out, err := cmd.CombinedOutput()
if err != nil {
return appErrorf("%w: %s", err, strings.TrimSpace(string(out)))
}
if st, err := os.Stat(framePath); err != nil || st.Size() == 0 {
return errors.New("ffmpeg erzeugte kein Frame")
}
return nil
}
func trainingPredictFrame(framePath string) TrainingPrediction {
settings := getSettings()
if !settings.TrainingRecognitionEnabled {
return trainingEmptyPrediction("recognition_disabled")
}
root, err := trainingRootDir()
if err != nil {
appLogln("⚠️ training predict root error:", err)
return trainingEmptyPrediction("root_error")
}
det := trainingPredictDetector(root, framePath)
pred := trainingPredictionFromDetector(det)
pose := trainingPredictPose(root, framePath)
pred = trainingApplyPoseToPrediction(pred, pose)
return pred
}
func trainingPredictFrameDetectorOnly(framePath string) TrainingPrediction {
settings := getSettings()
if !settings.TrainingRecognitionEnabled {
return trainingEmptyPrediction("recognition_disabled")
}
root, err := trainingRootDir()
if err != nil {
appLogln("⚠️ training predict root error:", err)
return trainingEmptyPrediction("root_error")
}
det := trainingPredictDetector(root, framePath)
return trainingPredictionFromDetector(det)
}
func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPrediction {
rawBoxes := det.Boxes
if rawBoxes == nil {
rawBoxes = []TrainingBox{}
}
pred := TrainingPrediction{
ModelAvailable: det.Available,
Source: det.Source,
SexPosition: trainingNoSexPositionLabel,
SexPositionScore: 0,
PeoplePresent: []TrainingScoredLabel{},
BodyPartsPresent: []TrainingScoredLabel{},
ObjectsPresent: []TrainingScoredLabel{},
ClothingPresent: []TrainingScoredLabel{},
Boxes: []TrainingBox{},
}
if pred.Source == "" {
if det.Available {
pred.Source = "yolo26_detector"
} else {
pred.Source = "detector_missing"
}
}
grouped, err := trainingGroupedLabels()
if err != nil {
appLogln("⚠️ detector label grouping failed:", err)
return pred
}
peopleSet := map[string]bool{}
for _, label := range grouped.People {
clean := strings.TrimSpace(label)
if clean != "" {
peopleSet[clean] = true
}
}
detectionSet := map[string]bool{}
for _, label := range grouped.BodyParts {
clean := strings.TrimSpace(label)
if clean != "" {
detectionSet[clean] = true
}
}
for _, label := range grouped.Objects {
clean := strings.TrimSpace(label)
if clean != "" {
detectionSet[clean] = true
}
}
for _, label := range grouped.Clothing {
clean := strings.TrimSpace(label)
if clean != "" {
detectionSet[clean] = true
}
}
visibleBoxes := []TrainingBox{}
for _, box := range rawBoxes {
if box.Score > 0 && box.Score < 0.25 {
continue
}
label := strings.TrimSpace(box.Label)
if label == "" {
continue
}
box.Label = label
if peopleSet[label] {
visibleBoxes = append(visibleBoxes, box)
continue
}
if detectionSet[label] {
visibleBoxes = append(visibleBoxes, box)
}
}
pred.Boxes = visibleBoxes
pred.PeoplePresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.People)
pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.BodyParts)
pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Objects)
pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Clothing)
return pred
}
func trainingPredictDetector(root string, framePath string) TrainingDetectorPrediction {
python := trainingPythonExe()
script := trainingScriptPath("predict_detector_model.py")
model := trainingResolveDetectorModel(root)
if !model.EffectiveExists {
return TrainingDetectorPrediction{
Available: false,
Source: model.Source,
Boxes: []TrainingBox{},
}
}
confValues := []string{"0.30"}
best := TrainingDetectorPrediction{
Available: true,
Source: "yolo26_detector",
Boxes: []TrainingBox{},
}
for _, conf := range confValues {
cmd := exec.Command(
python,
script,
"--root", root,
"--image", framePath,
"--model", model.EffectivePath,
"--conf", conf,
"--imgsz", "640",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
var stdout strings.Builder
var stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
outText := strings.TrimSpace(stdout.String())
errText := strings.TrimSpace(stderr.String())
if errText != "" {
appLogln("🔎 detector stderr:", errText)
}
if err != nil {
appLogln("⚠️ detector predict failed")
appLogln(" conf:", conf)
appLogln(" error:", err)
appLogln(" stdout:", outText)
appLogln(" stderr:", errText)
continue
}
if outText == "" {
appLogln("⚠️ detector predict empty stdout")
appLogln(" conf:", conf)
appLogln(" stderr:", errText)
continue
}
var det TrainingDetectorPrediction
if err := json.Unmarshal([]byte(outText), &det); err != nil {
appLogln("⚠️ detector predict json failed:", err)
appLogln(" conf:", conf)
appLogln(" stdout:", outText)
appLogln(" stderr:", errText)
continue
}
if det.Boxes == nil {
det.Boxes = []TrainingBox{}
}
det.Source = model.Source
best = det
if len(det.Boxes) > 0 {
return det
}
}
if best.Boxes == nil {
best.Boxes = []TrainingBox{}
}
return best
}
func trainingScoredLabelsFromDetectorBoxes(
boxes []TrainingBox,
group []string,
) []TrainingScoredLabel {
groupSet := map[string]bool{}
for _, label := range group {
clean := strings.TrimSpace(label)
if clean != "" {
groupSet[clean] = true
}
}
best := map[string]float64{}
for _, box := range boxes {
label := strings.TrimSpace(box.Label)
if label == "" || !groupSet[label] {
continue
}
score := box.Score
if score <= 0 {
score = 1
}
if old, ok := best[label]; !ok || score > old {
best[label] = score
}
}
out := make([]TrainingScoredLabel, 0, len(best))
for label, score := range best {
out = append(out, TrainingScoredLabel{
Label: label,
Score: score,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score == out[j].Score {
return out[i].Label < out[j].Label
}
return out[i].Score > out[j].Score
})
return out
}
func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDetectorPrediction) TrainingPrediction {
boxes := det.Boxes
if boxes == nil {
boxes = []TrainingBox{}
}
grouped, err := trainingGroupedLabels()
if err != nil {
appLogln("⚠️ detector label grouping failed:", err)
pred.Boxes = boxes
pred.BodyPartsPresent = []TrainingScoredLabel{}
pred.ObjectsPresent = []TrainingScoredLabel{}
pred.ClothingPresent = []TrainingScoredLabel{}
return pred
}
// Wichtig:
// Ab jetzt kommen diese drei Bereiche ausschließlich vom Object Detector.
// Kein Scene-KNN-Fallback, damit keine Labels ohne Box angezeigt werden.
pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.BodyParts)
pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Objects)
pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Clothing)
pred.Boxes = boxes
if det.Available {
if pred.Source == "" {
pred.Source = "yolo26_detector"
} else {
pred.Source = pred.Source + "+yolo_detector"
}
pred.ModelAvailable = true
}
return pred
}
func trainingPredictPose(root string, framePath string) TrainingPosePrediction {
python := trainingPythonExe()
script := trainingScriptPath("predict_pose_model.py")
model := trainingResolvePoseModel(root)
if !model.EffectiveExists {
return TrainingPosePrediction{
Available: false,
Source: model.Source,
Persons: []TrainingPosePerson{},
}
}
cmd := exec.Command(
python,
script,
"--root", root,
"--image", framePath,
"--model", model.EffectivePath,
"--conf", "0.30",
"--imgsz", "640",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
var stdout strings.Builder
var stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
outText := strings.TrimSpace(stdout.String())
errText := strings.TrimSpace(stderr.String())
if errText != "" {
appLogln("🔎 pose stderr:", errText)
}
if err != nil {
appLogln("⚠️ pose predict failed:", err)
appLogln(" stdout:", outText)
return TrainingPosePrediction{
Available: false,
Source: "pose_error",
Persons: []TrainingPosePerson{},
}
}
if outText == "" {
return TrainingPosePrediction{
Available: false,
Source: "pose_empty",
Persons: []TrainingPosePerson{},
}
}
var pose TrainingPosePrediction
if err := json.Unmarshal([]byte(outText), &pose); err != nil {
appLogln("⚠️ pose predict json failed:", err)
return TrainingPosePrediction{
Available: false,
Source: "pose_json_error",
Persons: []TrainingPosePerson{},
}
}
if pose.Persons == nil {
pose.Persons = []TrainingPosePerson{}
}
pose.Source = model.Source
return pose
}
func trainingApplyPoseToPrediction(pred TrainingPrediction, pose TrainingPosePrediction) TrainingPrediction {
if !pose.Available || len(pose.Persons) == 0 {
return pred
}
pred.Persons = pose.Persons
positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions)
bestPosition := ""
bestPositionScore := 0.0
for _, person := range pose.Persons {
label := strings.TrimSpace(person.Label)
if isNoSexPositionLabel(label) || !positionSet[label] {
continue
}
if person.Score > bestPositionScore {
bestPosition = label
bestPositionScore = person.Score
}
}
if bestPosition != "" {
pred.SexPosition = bestPosition
pred.SexPositionScore = bestPositionScore
}
if pred.Source == "" {
pred.Source = "yolo_pose"
} else {
pred.Source = pred.Source + "+yolo_pose"
}
return pred
}
func trainingDetectorLabelContent(
boxes []TrainingBox,
classMap map[string]int,
allowEmpty bool,
) ([]byte, error) {
var lines []string
for _, box := range boxes {
label := strings.TrimSpace(box.Label)
if label == "" {
continue
}
classID, ok := classMap[label]
if !ok {
continue
}
x := clamp01(box.X)
y := clamp01(box.Y)
w := clamp01(box.W)
h := clamp01(box.H)
if w <= 0 || h <= 0 {
continue
}
// Frontend/Predictor nutzen x/y als linke obere Ecke.
// YOLO erwartet x_center/y_center.
xCenter := clamp01(x + w/2)
yCenter := clamp01(y + h/2)
lines = append(lines, fmt.Sprintf(
"%d %.6f %.6f %.6f %.6f",
classID,
xCenter,
yCenter,
w,
h,
))
}
if len(lines) == 0 {
if allowEmpty {
return []byte{}, nil
}
return nil, errors.New("no valid detector boxes")
}
return []byte(strings.Join(lines, "\n") + "\n"), nil
}
func trainingWriteDetectorSample(
root string,
sample *TrainingSample,
boxes []TrainingBox,
allowEmpty bool,
) error {
if sample == nil {
return errors.New("sample missing")
}
classMap, err := trainingDetectorClassMap()
if err != nil {
return err
}
labelContent, err := trainingDetectorLabelContent(boxes, classMap, allowEmpty)
if err != nil {
return err
}
srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg")
if _, err := os.Stat(srcFrame); err != nil {
return appErrorf("frame missing: %w", err)
}
// Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split.
split := trainingStableSplit(sample.SampleID)
imgDir := filepath.Join(root, "detector", "dataset", "images", split)
lblDir := filepath.Join(root, "detector", "dataset", "labels", split)
if err := os.MkdirAll(imgDir, 0755); err != nil {
return err
}
if err := os.MkdirAll(lblDir, 0755); err != nil {
return err
}
dstFrame := filepath.Join(imgDir, sample.SampleID+".jpg")
if err := copyFile(srcFrame, dstFrame); err != nil {
return err
}
labelPath := filepath.Join(lblDir, sample.SampleID+".txt")
return os.WriteFile(labelPath, labelContent, 0644)
}
func trainingSexPositionForFeedback(sample *TrainingSample, req TrainingFeedbackRequest) string {
if req.Negative {
return ""
}
if req.Correction != nil {
return normalizeSexPositionLabel(req.Correction.SexPosition)
}
if req.Accepted && sample != nil {
return normalizeSexPositionLabel(sample.Prediction.SexPosition)
}
return ""
}
func trainingPosePersonsForSample(root string, sample *TrainingSample) []TrainingPosePerson {
if sample == nil {
return nil
}
if len(sample.Prediction.Persons) > 0 {
return sample.Prediction.Persons
}
framePath := filepath.Join(root, "frames", sample.SampleID+".jpg")
if !fileExistsNonEmpty(framePath) {
return nil
}
pose := trainingPredictPose(root, framePath)
if !pose.Available || len(pose.Persons) == 0 {
return nil
}
return pose.Persons
}
func trainingPoseLabelContent(persons []TrainingPosePerson, classID int) ([]byte, error) {
lines := []string{}
for _, person := range persons {
if len(person.Keypoints) < trainingPoseKeypointCount {
continue
}
x := clamp01(person.Box.X)
y := clamp01(person.Box.Y)
w := clamp01(person.Box.W)
h := clamp01(person.Box.H)
if w <= 0 || h <= 0 {
continue
}
xCenter := clamp01(x + w/2)
yCenter := clamp01(y + h/2)
parts := []string{
strconv.Itoa(classID),
fmt.Sprintf("%.6f", xCenter),
fmt.Sprintf("%.6f", yCenter),
fmt.Sprintf("%.6f", w),
fmt.Sprintf("%.6f", h),
}
visible := 0
for i := 0; i < trainingPoseKeypointCount; i++ {
kp := person.Keypoints[i]
kx := clamp01(kp.X)
ky := clamp01(kp.Y)
visibility := 0
if kp.Conf >= 0.20 && kx > 0 && ky > 0 {
visibility = 2
visible++
} else {
kx = 0
ky = 0
}
parts = append(
parts,
fmt.Sprintf("%.6f", kx),
fmt.Sprintf("%.6f", ky),
strconv.Itoa(visibility),
)
}
if visible < 5 {
continue
}
lines = append(lines, strings.Join(parts, " "))
}
if len(lines) == 0 {
return nil, errors.New("no valid pose persons")
}
return []byte(strings.Join(lines, "\n") + "\n"), nil
}
func trainingWritePoseSample(root string, sample *TrainingSample, sexPosition string) error {
if sample == nil {
return errors.New("sample missing")
}
sexPosition = strings.TrimSpace(sexPosition)
if isNoSexPositionLabel(sexPosition) {
return errors.New("pose sex position missing")
}
classMap, err := trainingPoseClassMap()
if err != nil {
return err
}
classID, ok := classMap[sexPosition]
if !ok {
return fmt.Errorf("pose class not configured: %s", sexPosition)
}
persons := trainingPosePersonsForSample(root, sample)
labelContent, err := trainingPoseLabelContent(persons, classID)
if err != nil {
return err
}
srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg")
if _, err := os.Stat(srcFrame); err != nil {
return appErrorf("frame missing: %w", err)
}
split := trainingStableSplit(sample.SampleID)
imgDir := filepath.Join(root, "pose", "dataset", "images", split)
lblDir := filepath.Join(root, "pose", "dataset", "labels", split)
if err := os.MkdirAll(imgDir, 0755); err != nil {
return err
}
if err := os.MkdirAll(lblDir, 0755); err != nil {
return err
}
dstFrame := filepath.Join(imgDir, sample.SampleID+".jpg")
if err := copyFile(srcFrame, dstFrame); err != nil {
return err
}
labelPath := filepath.Join(lblDir, sample.SampleID+".txt")
return os.WriteFile(labelPath, labelContent, 0644)
}
func trainingDeleteDetectorSample(root string, sampleID string) {
sampleID = strings.TrimSpace(sampleID)
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
return
}
for _, split := range []string{"train", "val"} {
labelsDir := filepath.Join(root, "detector", "dataset", "labels", split)
imagesDir := filepath.Join(root, "detector", "dataset", "images", split)
_ = os.Remove(filepath.Join(labelsDir, sampleID+".txt"))
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
_ = os.Remove(filepath.Join(imagesDir, sampleID+ext))
}
}
}
func trainingDeletePoseSample(root string, sampleID string) {
sampleID = strings.TrimSpace(sampleID)
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
return
}
for _, split := range []string{"train", "val"} {
labelsDir := filepath.Join(root, "pose", "dataset", "labels", split)
imagesDir := filepath.Join(root, "pose", "dataset", "images", split)
_ = os.Remove(filepath.Join(labelsDir, sampleID+".txt"))
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
_ = os.Remove(filepath.Join(imagesDir, sampleID+ext))
}
}
}
func trainingSyncPoseDataset(root string) (int, error) {
if err := trainingEnsurePoseDirs(root); err != nil {
return 0, err
}
items, err := trainingReadAnnotations(root)
if err != nil {
return 0, err
}
written := 0
for _, item := range items {
sampleID := strings.TrimSpace(item.SampleID)
if sampleID == "" {
continue
}
sample := &TrainingSample{
SampleID: item.SampleID,
FrameURL: item.FrameURL,
SourceFile: item.SourceFile,
SourcePath: item.SourcePath,
SourceSizeBytes: item.SourceSizeBytes,
Second: item.Second,
CreatedAt: item.CreatedAt,
UncertaintyScore: 0,
Prediction: item.Prediction,
}
effective := trainingEffectiveCorrection(item)
sexPosition := strings.TrimSpace(effective.SexPosition)
trainingDeletePoseSample(root, sampleID)
if item.Negative || isNoSexPositionLabel(sexPosition) {
continue
}
if err := trainingWritePoseSample(root, sample, sexPosition); err != nil {
appLogln("pose sample sync skipped:", sampleID, err)
continue
}
written++
}
return written, nil
}
func trainingEnsureDetectorValidationSample(root string) error {
trainImages := filepath.Join(root, "detector", "dataset", "images", "train")
trainLabels := filepath.Join(root, "detector", "dataset", "labels", "train")
valImages := filepath.Join(root, "detector", "dataset", "images", "val")
valLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
currentVal := trainingCountDetectorSamples(valImages, valLabels)
currentPositiveVal := trainingCountPositiveDetectorSamples(valImages, valLabels)
if currentVal >= minDetectorValCount && currentPositiveVal > 0 {
return nil
}
if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount ||
trainingCountPositiveDetectorSamples(trainImages, trainLabels) == 0 {
return nil
}
entries, err := os.ReadDir(trainImages)
if err != nil {
return nil
}
if err := os.MkdirAll(valImages, 0755); err != nil {
return err
}
if err := os.MkdirAll(valLabels, 0755); err != nil {
return err
}
copied := 0
needed := max(0, minDetectorValCount-currentVal)
needsPositive := currentPositiveVal == 0
sort.SliceStable(entries, func(i, j int) bool {
iID := strings.TrimSuffix(entries[i].Name(), filepath.Ext(entries[i].Name()))
jID := strings.TrimSuffix(entries[j].Name(), filepath.Ext(entries[j].Name()))
return fileExistsNonEmpty(filepath.Join(trainLabels, iID+".txt")) &&
!fileExistsNonEmpty(filepath.Join(trainLabels, jID+".txt"))
})
for _, e := range entries {
if copied >= needed && !needsPositive {
break
}
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
continue
}
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
srcImage := filepath.Join(trainImages, e.Name())
srcLabel := filepath.Join(trainLabels, id+".txt")
if !fileExistsNonEmpty(srcImage) || !fileExists(srcLabel) {
continue
}
dstImage := filepath.Join(valImages, e.Name())
dstLabel := filepath.Join(valLabels, id+".txt")
if fileExistsNonEmpty(dstImage) && fileExists(dstLabel) {
continue
}
if err := copyFile(srcImage, dstImage); err != nil {
return err
}
if err := copyFile(srcLabel, dstLabel); err != nil {
return err
}
copied++
if fileExistsNonEmpty(srcLabel) {
needsPositive = false
}
}
return nil
}
func trainingEnsurePoseValidationSample(root string) error {
trainImages := filepath.Join(root, "pose", "dataset", "images", "train")
trainLabels := filepath.Join(root, "pose", "dataset", "labels", "train")
valImages := filepath.Join(root, "pose", "dataset", "images", "val")
valLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
currentVal := trainingCountDetectorSamples(valImages, valLabels)
if currentVal >= minPoseValCount {
return nil
}
if trainingCountDetectorSamples(trainImages, trainLabels) < minPoseTrainCount {
return nil
}
entries, err := os.ReadDir(trainImages)
if err != nil {
return nil
}
if err := os.MkdirAll(valImages, 0755); err != nil {
return err
}
if err := os.MkdirAll(valLabels, 0755); err != nil {
return err
}
copied := 0
needed := max(0, minPoseValCount-currentVal)
for _, e := range entries {
if copied >= needed {
break
}
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
continue
}
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
srcImage := filepath.Join(trainImages, e.Name())
srcLabel := filepath.Join(trainLabels, id+".txt")
if !fileExistsNonEmpty(srcImage) || !fileExistsNonEmpty(srcLabel) {
continue
}
dstImage := filepath.Join(valImages, e.Name())
dstLabel := filepath.Join(valLabels, id+".txt")
if fileExistsNonEmpty(dstImage) && fileExistsNonEmpty(dstLabel) {
continue
}
if err := copyFile(srcImage, dstImage); err != nil {
return err
}
if err := copyFile(srcLabel, dstLabel); err != nil {
return err
}
copied++
}
return nil
}
func trainingStableSplit(sampleID string) string {
sum := sha1.Sum([]byte(sampleID))
if int(sum[0])%5 == 0 {
return "val"
}
return "train"
}
func copyFile(src string, dst string) error {
b, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, b, 0644)
}
func trainingEmptyPrediction(source string) TrainingPrediction {
return TrainingPrediction{
ModelAvailable: false,
Source: source,
SexPosition: trainingNoSexPositionLabel,
SexPositionScore: 0,
PeoplePresent: []TrainingScoredLabel{},
BodyPartsPresent: []TrainingScoredLabel{},
ObjectsPresent: []TrainingScoredLabel{},
ClothingPresent: []TrainingScoredLabel{},
Boxes: []TrainingBox{},
}
}
func trainingPythonExe() string {
v := strings.TrimSpace(os.Getenv("TRAINING_PYTHON"))
if v != "" {
return v
}
return "python"
}
func trainingProjectRoot() string {
wd, err := os.Getwd()
if err != nil {
return "."
}
if _, err := os.Stat(filepath.Join(wd, "backend", "ml", "predict_detector_model.py")); err == nil {
return wd
}
if _, err := os.Stat(filepath.Join(wd, "ml", "predict_detector_model.py")); err == nil {
return filepath.Dir(wd)
}
parent := filepath.Dir(wd)
if _, err := os.Stat(filepath.Join(parent, "backend", "ml", "predict_detector_model.py")); err == nil {
return parent
}
return wd
}
func trainingScriptPath(name string) string {
// 1) Eingebettete Scripts bevorzugen.
if dir, err := trainingEmbeddedMLDir(); err == nil {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
// 2) App-/Backend-relativ wie record_paths.go.
if backendRoot, err := trainingBackendRootDir(); err == nil {
p := filepath.Join(backendRoot, "ml", name)
if _, err := os.Stat(p); err == nil {
return p
}
}
// 3) Dev-Fallback.
root := trainingProjectRoot()
p := filepath.Join(root, "backend", "ml", name)
if _, err := os.Stat(p); err == nil {
return p
}
p = filepath.Join("ml", name)
if _, err := os.Stat(p); err == nil {
return p
}
return filepath.Join(root, "backend", "ml", name)
}
func isTempBuildDir(dir string) bool {
low := strings.ToLower(filepath.Clean(dir))
return strings.Contains(low, `\appdata\local\temp`) ||
strings.Contains(low, `\temp\`) ||
strings.Contains(low, `\tmp\`) ||
strings.Contains(low, `\go-build`) ||
strings.Contains(low, `/tmp/`) ||
strings.Contains(low, `/go-build`)
}
func trainingBackendRootDir() (string, error) {
if script, err := resolvePathRelativeToApp(filepath.Join("ml", "predict_detector_model.py")); err == nil {
if st, statErr := os.Stat(script); statErr == nil && !st.IsDir() {
return filepath.Dir(filepath.Dir(script)), nil
}
}
if script, err := resolvePathRelativeToApp(filepath.Join("backend", "ml", "predict_detector_model.py")); err == nil {
if st, statErr := os.Stat(script); statErr == nil && !st.IsDir() {
return filepath.Dir(filepath.Dir(script)), nil
}
}
if dir, err := exeDir(); err == nil && strings.TrimSpace(dir) != "" && !isTempBuildDir(dir) {
return dir, nil
}
wd, err := os.Getwd()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(wd, "ml", "predict_detector_model.py")); err == nil {
return wd, nil
}
if _, err := os.Stat(filepath.Join(wd, "backend", "ml", "predict_detector_model.py")); err == nil {
return filepath.Join(wd, "backend"), nil
}
projectRoot := trainingProjectRoot()
return filepath.Join(projectRoot, "backend"), nil
}
func trainingRootDir() (string, error) {
// Optionaler Override, falls du später explizit einen anderen Speicherort willst.
// Relative Pfade werden wie in record_paths.go app-relativ aufgelöst.
if override := strings.TrimSpace(os.Getenv("TRAINING_ROOT")); override != "" {
root, err := resolvePathRelativeToApp(override)
if err != nil {
return "", err
}
root, err = filepath.Abs(root)
if err != nil {
return "", err
}
if err := os.MkdirAll(root, 0755); err != nil {
return "", err
}
return root, nil
}
backendRoot, err := trainingBackendRootDir()
if err != nil {
return "", err
}
root, err := filepath.Abs(filepath.Join(backendRoot, "generated", "training"))
if err != nil {
return "", err
}
if err := os.MkdirAll(root, 0755); err != nil {
return "", err
}
return root, nil
}
func trainingWriteSample(root string, sample *TrainingSample) error {
path := filepath.Join(root, "samples", sample.SampleID+".json")
b, err := json.MarshalIndent(sample, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0644)
}
func trainingReadSample(root string, sampleID string) (*TrainingSample, error) {
if strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
return nil, errors.New("invalid sample id")
}
path := filepath.Join(root, "samples", sampleID+".json")
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var sample TrainingSample
if err := json.Unmarshal(b, &sample); err != nil {
return nil, err
}
return &sample, nil
}
func trainingAppendAnnotation(root string, annotation TrainingAnnotation) error {
path := filepath.Join(root, "feedback.jsonl")
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
b, err := json.Marshal(annotation)
if err != nil {
return err
}
if _, err := f.Write(append(b, '\n')); err != nil {
return err
}
return nil
}
func trainingWriteAnnotations(root string, items []TrainingAnnotation) error {
path := filepath.Join(root, "feedback.jsonl")
tmpPath := path + ".tmp"
var b strings.Builder
for _, item := range items {
line, err := json.Marshal(item)
if err != nil {
return err
}
b.Write(line)
b.WriteByte('\n')
}
if err := os.WriteFile(tmpPath, []byte(b.String()), 0644); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func trainingCountAnnotations(path string) (int, error) {
b, err := os.ReadFile(path)
if err != nil {
return 0, err
}
text := strings.TrimSpace(string(b))
if text == "" {
return 0, nil
}
return len(strings.Split(text, "\n")), nil
}
func trainingProbeDurationSeconds(videoPath string) float64 {
settings := getSettings()
ffmpeg := strings.TrimSpace(settings.FFmpegPath)
ffprobe := "ffprobe"
if ffmpeg != "" {
dir := filepath.Dir(ffmpeg)
base := filepath.Base(ffmpeg)
if strings.Contains(strings.ToLower(base), "ffmpeg") {
ffprobeBase := strings.Replace(base, "ffmpeg", "ffprobe", 1)
ffprobe = filepath.Join(dir, ffprobeBase)
}
}
cmd := exec.Command(
ffprobe,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
videoPath,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
out, err := cmd.Output()
if err != nil {
return 0
}
v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 {
return 0
}
return v
}
func trainingRandomSecond(duration float64) float64 {
if duration <= 2 {
return 0
}
minSec := 1.0
maxSec := math.Max(minSec, duration-1)
return minSec + rand.Float64()*(maxSec-minSec)
}
func trainingMakeSampleID(videoPath string, second float64) string {
h := sha1.New()
_, _ = h.Write([]byte(videoPath))
_, _ = h.Write([]byte("|"))
_, _ = h.Write([]byte(strconv.FormatFloat(second, 'f', 3, 64)))
_, _ = h.Write([]byte("|"))
_, _ = h.Write([]byte(strconv.FormatInt(time.Now().UnixNano(), 10)))
return hex.EncodeToString(h.Sum(nil))[:20]
}
func trainingWriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func trainingWriteError(w http.ResponseWriter, status int, msg string) {
trainingWriteJSON(w, status, map[string]any{
"ok": false,
"error": msg,
})
}