nsfwapp/backend/training.go
2026-04-30 11:34:22 +02:00

2032 lines
51 KiB
Go

// backend\training.go
package main
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
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"`
PeopleCount int `json:"peopleCount"`
MaleCount int `json:"maleCount"`
FemaleCount int `json:"femaleCount"`
UnknownCount int `json:"unknownCount"`
SexPosition string `json:"sexPosition"`
SexPositionScore float64 `json:"sexPositionScore"`
BodyPartsPresent []TrainingScoredLabel `json:"bodyPartsPresent"`
ObjectsPresent []TrainingScoredLabel `json:"objectsPresent"`
ClothingPresent []TrainingScoredLabel `json:"clothingPresent"`
Boxes []TrainingBox `json:"boxes"`
}
type TrainingCorrection struct {
PeopleCount int `json:"peopleCount"`
MaleCount int `json:"maleCount"`
FemaleCount int `json:"femaleCount"`
UnknownCount int `json:"unknownCount"`
SexPosition string `json:"sexPosition"`
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"`
Prediction TrainingPrediction `json:"prediction"`
}
type TrainingFeedbackRequest struct {
SampleID string `json:"sampleId"`
Accepted bool `json:"accepted"`
Correction *TrainingCorrection `json:"correction,omitempty"`
Notes string `json:"notes,omitempty"`
}
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"`
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 TrainingScenePositionPrediction struct {
Available bool `json:"available"`
Source string `json:"source,omitempty"`
SexPosition string `json:"sexPosition"`
SexPositionScore float64 `json:"sexPositionScore"`
}
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"`
}
const minTrainingFeedbackCount = 5
const minDetectorTrainCount = 20
const minDetectorValCount = 3
var trainingJob = struct {
mu sync.Mutex
status TrainingJobStatus
}{}
func trainingSetJobStatus(update func(*TrainingJobStatus)) {
trainingJob.mu.Lock()
defer trainingJob.mu.Unlock()
update(&trainingJob.status)
}
func trainingGetJobStatus() TrainingJobStatus {
trainingJob.mu.Lock()
defer trainingJob.mu.Unlock()
return trainingJob.status
}
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
}
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())
}
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")
root, err := trainingRootDir()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if !forceNew {
if sample, ok, err := trainingLatestOpenSample(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
} else if ok {
trainingWriteJSON(w, http.StatusOK, sample)
return
}
}
sample, err := trainingCreateNextSample()
if err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
trainingWriteJSON(w, http.StatusOK, sample)
}
func trainingLatestOpenSample(root string) (*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] {
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
}
// Wichtig: Prediction aktualisieren, damit alte "model_missing"-Samples
// nach einem Training nicht stale bleiben.
sample.Prediction = trainingPredictFrame(framePath)
_ = trainingWriteSample(root, sample)
return sample, true, nil
}
return nil, false, nil
}
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 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
}
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,
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
}
if req.Correction != nil && len(req.Correction.Boxes) > 0 {
if err := trainingWriteDetectorSample(root, sample, req.Correction.Boxes); err != nil {
fmt.Println("⚠️ detector sample write failed:", err)
}
}
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
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 fileExistsNonEmpty(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 := trainingEnsureDetectorValidationSample(root); err != nil {
fmt.Println("⚠️ detector 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 Scene-Positionsmodell. 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")
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
trainingSetJobStatus(func(s *TrainingJobStatus) {
*s = TrainingJobStatus{
Running: true,
Progress: 5,
Step: "Training wird vorbereitet…",
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
})
go trainingRunJob(root, feedbackCount)
trainingWriteJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"message": "Training gestartet.",
"training": trainingGetJobStatus(),
"detector": map[string]any{
"trainCount": trainCount,
"valCount": valCount,
"requiredTrain": minDetectorTrainCount,
"requiredVal": minDetectorValCount,
"datasetYAML": detectorDatasetYAML,
"usesSceneCLIP": true,
"usesSceneKNN": true,
"source": "yolo_detector+scene_position_clip",
},
})
}
func trainingRunJob(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 = "CLIP-Scene-Positionsmodell wird trainiert…"
})
sceneStatus := "skipped"
sceneOutput := ""
sceneScript := trainingScriptPath("train_scene_model.py")
sceneOut, sceneErr := trainingRunCommand(
python,
sceneScript,
"--root", root,
)
sceneOutput = sceneOut
sceneOutputClean := cleanOutput(sceneOutput)
if sceneErr != nil {
sceneStatus = "failed"
fmt.Println("⚠️ scene position training failed:", sceneErr)
if sceneOutputClean != "" {
fmt.Println("⚠️ scene position output:", sceneOutputClean)
}
} else {
sceneStatus = "trained"
if sceneOutputClean != "" {
fmt.Println("✅ scene position training:", sceneOutputClean)
}
}
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Progress = 45
s.Step = "Object Detector-Daten werden geprüft…"
})
detectorOutput := ""
detectorStatus := "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 {
fmt.Println("⚠️ detector val sample ensure failed:", err)
}
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
fmt.Printf(
"🔎 detector data: train=%d val=%d yaml=%v\n",
trainCount,
valCount,
fileExistsNonEmpty(detectorDatasetYAML),
)
if fileExistsNonEmpty(detectorDatasetYAML) &&
trainCount >= minDetectorTrainCount &&
valCount >= minDetectorValCount {
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Progress = 60
s.Step = "Object Detector wird trainiert…"
})
detectorScript := trainingScriptPath("train_detector_model.py")
detectorOut, detectorErr := trainingRunCommand(
python,
detectorScript,
"--root", root,
"--base", "yolo11n.pt",
"--epochs", strconv.Itoa(trainingDetectorEpochs()),
"--imgsz", "640",
)
detectorOutput = detectorOut
detectorOutputClean := cleanOutput(detectorOutput)
if detectorErr != nil {
detectorStatus = "failed"
fmt.Println("⚠️ detector training failed:", detectorErr)
if detectorOutputClean != "" {
fmt.Println("⚠️ detector output:", detectorOutputClean)
}
} else {
detectorStatus = "trained"
if detectorOutputClean != "" {
fmt.Println("✅ detector training:", detectorOutputClean)
}
}
} else {
detectorStatus = "skipped_no_detector_data"
detectorOutput = fmt.Sprintf(
"Object Detector übersprungen: zu wenige Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.",
trainCount,
valCount,
minDetectorTrainCount,
minDetectorValCount,
)
fmt.Println("⚠️", detectorOutput)
}
detectorOutputClean := cleanOutput(detectorOutput)
message := "Training abgeschlossen."
errorParts := []string{}
if sceneStatus == "failed" {
if sceneOutputClean != "" {
errorParts = append(errorParts, "Scene-Positionsmodell fehlgeschlagen: "+sceneOutputClean)
} else {
errorParts = append(errorParts, "Scene-Positionsmodell fehlgeschlagen. Details stehen in der Backend-Konsole.")
}
}
if detectorStatus == "failed" {
if detectorOutputClean != "" {
errorParts = append(errorParts, "Object Detector fehlgeschlagen: "+detectorOutputClean)
} else {
errorParts = append(errorParts, "Object Detector fehlgeschlagen. Details stehen in der Backend-Konsole.")
}
}
switch {
case sceneStatus == "trained" && detectorStatus == "trained":
message = "Training abgeschlossen. CLIP-Scene-Positionsmodell und Object Detector wurden trainiert."
case sceneStatus == "trained" && detectorStatus == "skipped_no_detector_data":
message = "CLIP-Scene-Positionsmodell wurde trainiert. " + detectorOutput
case sceneStatus == "trained" && detectorStatus == "failed":
message = "CLIP-Scene-Positionsmodell wurde trainiert. Object Detector ist fehlgeschlagen."
if detectorOutputClean != "" {
message += " Grund: " + detectorOutputClean
}
case sceneStatus == "failed" && detectorStatus == "trained":
message = "Object Detector wurde trainiert. Scene-Positionsmodell ist fehlgeschlagen."
if sceneOutputClean != "" {
message += " Grund: " + sceneOutputClean
}
case sceneStatus == "failed" && detectorStatus == "skipped_no_detector_data":
message = "Scene-Positionsmodell ist fehlgeschlagen. " + detectorOutput
if sceneOutputClean != "" {
message += " Scene-Grund: " + sceneOutputClean
}
case sceneStatus == "failed" && detectorStatus == "failed":
message = "Scene-Positionsmodell und Object Detector sind fehlgeschlagen."
default:
message = "Training abgeschlossen, aber kein Modell wurde erfolgreich trainiert."
if sceneOutputClean != "" {
message += " Scene-Ausgabe: " + sceneOutputClean
}
if detectorOutputClean != "" {
message += " Detector-Ausgabe: " + detectorOutputClean
}
}
errorText := strings.Join(errorParts, " ")
trainingSetJobStatus(func(s *TrainingJobStatus) {
s.Running = false
s.Progress = 100
s.Step = "Training abgeschlossen."
s.Message = message
s.Error = errorText
s.FinishedAt = time.Now().UTC().Format(time.RFC3339)
})
}
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
}
if err := trainingEnsureDetectorDirs(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
// Praktisch für kleine Datensätze:
// Wenn genug Train-Daten existieren, aber noch zu wenig Val-Daten,
// werden ein paar Train-Samples nach Val kopiert.
if err := trainingEnsureDetectorValidationSample(root); err != nil {
fmt.Println("⚠️ detector val sample ensure failed:", err)
}
feedbackPath := filepath.Join(root, "feedback.jsonl")
feedbackCount, _ := trainingCountAnnotations(feedbackPath)
job := trainingGetJobStatus()
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")
detectorModelPath := filepath.Join(root, "detector", "model", "best.pt")
sceneEmbeddingsPath := filepath.Join(root, "model", "scene_clip_embeddings.npz")
sceneTargetsPath := filepath.Join(root, "model", "scene_clip_targets.json")
sceneKNNPath := filepath.Join(root, "model", "scene_clip_knn.joblib")
sceneLRPath := filepath.Join(root, "model", "scene_clip_lr.joblib")
sceneStatusPath := filepath.Join(root, "model", "status.json")
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
datasetReady := fileExistsNonEmpty(detectorDatasetYAML)
detectorDataReady := datasetReady &&
trainCount >= minDetectorTrainCount &&
valCount >= minDetectorValCount
sceneEmbeddingsExists := fileExistsNonEmpty(sceneEmbeddingsPath)
sceneTargetsExists := fileExistsNonEmpty(sceneTargetsPath)
sceneKNNExists := fileExistsNonEmpty(sceneKNNPath)
sceneLRExists := fileExistsNonEmpty(sceneLRPath)
sceneReady := sceneEmbeddingsExists && sceneTargetsExists && (sceneKNNExists || sceneLRExists)
canTrain := feedbackCount >= minTrainingFeedbackCount
// Pipeline:
// - YOLO trainiert nur BodyParts, Objects, Clothing und deren Boxen.
// - CLIP + Logistic Regression/KNN trainiert die Sexposition.
// - Personen/Gender werden manuell korrigiert und nicht per YOLO erkannt.
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"feedbackCount": feedbackCount,
"requiredCount": minTrainingFeedbackCount,
"canTrain": canTrain,
"training": job,
"detector": map[string]any{
"source": "yolo_detector",
"usesSceneKNN": false,
"usesResNet18KNN": false,
"detectsPeople": false,
"detectsGender": false,
"detectsBodyParts": true,
"detectsObjects": true,
"detectsClothing": true,
"detectsBoxes": true,
"trainCount": trainCount,
"valCount": valCount,
"requiredTrain": minDetectorTrainCount,
"requiredVal": minDetectorValCount,
"datasetReady": datasetReady,
"datasetYAML": detectorDatasetYAML,
"dataReady": detectorDataReady,
"modelExists": fileExistsNonEmpty(detectorModelPath),
"modelPath": detectorModelPath,
},
"scene": map[string]any{
"source": "scene_position_clip",
"usesSceneCLIP": true,
"usesSceneKNN": true,
"usesResNet18KNN": false,
"usesLogisticRegression": true,
"predictsSexPosition": true,
// Wichtig:
// Diese Werte kommen NICHT mehr vom Scene-KNN.
"predictsPeople": false,
"predictsGender": false,
"predictsBodyParts": false,
"predictsObjects": false,
"predictsClothing": false,
"predictsBoxes": false,
"feedbackCount": feedbackCount,
"requiredCount": minTrainingFeedbackCount,
"dataReady": feedbackCount >= minTrainingFeedbackCount,
"modelReady": sceneReady,
"embeddingsExists": sceneEmbeddingsExists,
"targetsExists": sceneTargetsExists,
"knnExists": sceneKNNExists,
"lrExists": sceneLRExists,
"statusExists": fileExistsNonEmpty(sceneStatusPath),
"embeddingsPath": sceneEmbeddingsPath,
"targetsPath": sceneTargetsPath,
"knnPath": sceneKNNPath,
"lrPath": sceneLRPath,
"statusPath": sceneStatusPath,
},
"pipeline": map[string]any{
"variant": "B",
"peopleSource": "manual",
"genderSource": "manual",
"bodyPartsSource": "yolo_detector",
"objectsSource": "yolo_detector",
"clothingSource": "yolo_detector",
"boxesSource": "yolo_detector",
"sexPositionSource": "scene_position_clip_lr_or_knn",
"usesSceneKNNForDetection": false,
"usesYOLOForDetection": true,
},
})
}
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
}
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 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 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 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, fmt.Errorf("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 trainingPickRandomVideo(doneDir string) (string, error) {
extOK := map[string]bool{
".mp4": true,
".mkv": true,
".webm": true,
".mov": true,
".avi": true,
}
var files []string
err := filepath.WalkDir(doneDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
name := strings.ToLower(d.Name())
if name == ".trash" || name == "training" || name == "generated" {
return filepath.SkipDir
}
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 im doneDir 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 fmt.Errorf("%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 {
fmt.Println("⚠️ training predict root error:", err)
return trainingEmptyPrediction("root_error")
}
// 1) YOLO erkennt Boxen, Personen, Körperteile, Gegenstände, Kleidung.
det := trainingPredictDetector(root, framePath)
pred := trainingPredictionFromDetector(det)
// 2) Scene-KNN erkennt ausschließlich die Sexposition.
scene := trainingPredictScenePosition(root, framePath)
pred = trainingApplyScenePosition(pred, scene)
return pred
}
func trainingPredictScenePosition(root string, framePath string) TrainingScenePositionPrediction {
python := trainingPythonExe()
script := trainingScriptPath("predict_scene_model.py")
lrPath := filepath.Join(root, "model", "scene_clip_lr.joblib")
knnPath := filepath.Join(root, "model", "scene_clip_knn.joblib")
if !fileExistsNonEmpty(lrPath) && !fileExistsNonEmpty(knnPath) {
return TrainingScenePositionPrediction{
Available: false,
Source: "scene_position_missing",
SexPosition: "unknown",
SexPositionScore: 0,
}
}
cmd := exec.Command(
python,
script,
"--root", root,
"--image", framePath,
)
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 err != nil {
fmt.Println("⚠️ scene position predict failed:", err)
fmt.Println(" stdout:", outText)
fmt.Println(" stderr:", errText)
return TrainingScenePositionPrediction{
Available: false,
Source: "scene_position_failed",
SexPosition: "unknown",
SexPositionScore: 0,
}
}
if outText == "" {
fmt.Println("⚠️ scene position predict empty stdout")
return TrainingScenePositionPrediction{
Available: false,
Source: "scene_position_empty",
SexPosition: "unknown",
SexPositionScore: 0,
}
}
var scenePred TrainingPrediction
if err := json.Unmarshal([]byte(outText), &scenePred); err != nil {
fmt.Println("⚠️ scene position json failed:", err)
fmt.Println(" stdout:", outText)
return TrainingScenePositionPrediction{
Available: false,
Source: "scene_position_json_failed",
SexPosition: "unknown",
SexPositionScore: 0,
}
}
sexPosition := strings.TrimSpace(scenePred.SexPosition)
if sexPosition == "" {
sexPosition = "unknown"
}
return TrainingScenePositionPrediction{
Available: scenePred.ModelAvailable,
Source: scenePred.Source,
SexPosition: sexPosition,
SexPositionScore: scenePred.SexPositionScore,
}
}
func trainingApplyScenePosition(
pred TrainingPrediction,
scene TrainingScenePositionPrediction,
) TrainingPrediction {
if pred.SexPosition == "" {
pred.SexPosition = "unknown"
}
if scene.Available {
sexPosition := strings.TrimSpace(scene.SexPosition)
if sexPosition == "" {
sexPosition = "unknown"
}
pred.SexPosition = sexPosition
pred.SexPositionScore = scene.SexPositionScore
pred.ModelAvailable = pred.ModelAvailable || scene.Available
if pred.Source == "" {
pred.Source = scene.Source
} else if !strings.Contains(pred.Source, scene.Source) {
pred.Source = pred.Source + "+" + scene.Source
}
}
if pred.SexPosition == "" {
pred.SexPosition = "unknown"
}
return pred
}
func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPrediction {
boxes := det.Boxes
if boxes == nil {
boxes = []TrainingBox{}
}
pred := TrainingPrediction{
ModelAvailable: det.Available,
Source: det.Source,
PeopleCount: 0,
MaleCount: 0,
FemaleCount: 0,
UnknownCount: 0,
SexPosition: "unknown",
SexPositionScore: 0,
BodyPartsPresent: []TrainingScoredLabel{},
ObjectsPresent: []TrainingScoredLabel{},
ClothingPresent: []TrainingScoredLabel{},
Boxes: boxes,
}
if pred.Source == "" {
if det.Available {
pred.Source = "yolo_detector"
} else {
pred.Source = "detector_missing"
}
}
grouped, err := trainingGroupedLabels()
if err != nil {
fmt.Println("⚠️ detector label grouping failed:", err)
return pred
}
allowed := map[string]bool{}
for _, label := range grouped.BodyParts {
allowed[strings.TrimSpace(label)] = true
}
for _, label := range grouped.Objects {
allowed[strings.TrimSpace(label)] = true
}
for _, label := range grouped.Clothing {
allowed[strings.TrimSpace(label)] = true
}
filteredBoxes := []TrainingBox{}
for _, box := range boxes {
label := strings.TrimSpace(box.Label)
if allowed[label] {
filteredBoxes = append(filteredBoxes, box)
}
}
boxes = filteredBoxes
pred.Boxes = boxes
pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.BodyParts)
pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Objects)
pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Clothing)
pred.UnknownCount = 0
pred.PeopleCount = 0
pred.MaleCount = 0
pred.FemaleCount = 0
return pred
}
func trainingPredictDetector(root string, framePath string) TrainingDetectorPrediction {
python := trainingPythonExe()
script := trainingScriptPath("predict_detector_model.py")
modelPath := filepath.Join(root, "detector", "model", "best.pt")
if !fileExistsNonEmpty(modelPath) {
return TrainingDetectorPrediction{
Available: false,
Source: "detector_missing",
Boxes: []TrainingBox{},
}
}
confValues := []string{"0.30", "0.10", "0.03", "0.01"}
best := TrainingDetectorPrediction{
Available: true,
Source: "yolo_detector",
Boxes: []TrainingBox{},
}
for _, conf := range confValues {
cmd := exec.Command(
python,
script,
"--root", root,
"--image", framePath,
"--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 != "" {
fmt.Println("🔎 detector stderr:", errText)
}
if err != nil {
fmt.Println("⚠️ detector predict failed")
fmt.Println(" conf:", conf)
fmt.Println(" error:", err)
fmt.Println(" stdout:", outText)
fmt.Println(" stderr:", errText)
continue
}
if outText == "" {
fmt.Println("⚠️ detector predict empty stdout")
fmt.Println(" conf:", conf)
fmt.Println(" stderr:", errText)
continue
}
var det TrainingDetectorPrediction
if err := json.Unmarshal([]byte(outText), &det); err != nil {
fmt.Println("⚠️ detector predict json failed:", err)
fmt.Println(" conf:", conf)
fmt.Println(" stdout:", outText)
fmt.Println(" stderr:", errText)
continue
}
if det.Boxes == nil {
det.Boxes = []TrainingBox{}
}
if det.Source == "" {
det.Source = "yolo_detector"
}
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 {
fmt.Println("⚠️ 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 = "yolo_detector"
} else {
pred.Source = pred.Source + "+yolo_detector"
}
pred.ModelAvailable = true
}
return pred
}
func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []TrainingBox) error {
if sample == nil {
return errors.New("sample missing")
}
classMap, err := trainingDetectorClassMap()
if err != nil {
return err
}
srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg")
if _, err := os.Stat(srcFrame); err != nil {
return fmt.Errorf("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
}
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 {
return errors.New("no valid detector boxes")
}
labelPath := filepath.Join(lblDir, sample.SampleID+".txt")
return os.WriteFile(labelPath, []byte(strings.Join(lines, "\n")+"\n"), 0644)
}
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)
if currentVal >= minDetectorValCount {
return nil
}
if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount {
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 := minDetectorValCount - 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++
fmt.Println("✅ detector val sample duplicated:", id)
}
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,
PeopleCount: 0,
MaleCount: 0,
FemaleCount: 0,
UnknownCount: 0,
SexPosition: "unknown",
SexPositionScore: 0,
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 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,
})
}