1181 lines
28 KiB
Go
1181 lines
28 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"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
type TrainingLabels struct {
|
|
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,omitempty"`
|
|
}
|
|
|
|
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,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
const minTrainingFeedbackCount = 5
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
feedbackPath := filepath.Join(root, "feedback.jsonl")
|
|
count, _ := trainingCountAnnotations(feedbackPath)
|
|
|
|
if count < minTrainingFeedbackCount {
|
|
trainingWriteError(
|
|
w,
|
|
http.StatusBadRequest,
|
|
fmt.Sprintf(
|
|
"Zu wenige Bewertungen. Mindestens %d, aktuell %d.",
|
|
minTrainingFeedbackCount,
|
|
count,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
python := trainingPythonExe()
|
|
|
|
// --------------------------------------------------
|
|
// 1) Scene-Modell trainieren: ResNet18-KNN
|
|
// --------------------------------------------------
|
|
sceneScript := trainingScriptPath("train_scene_model.py")
|
|
|
|
sceneCmd := exec.Command(
|
|
python,
|
|
sceneScript,
|
|
"--root", root,
|
|
)
|
|
|
|
sceneCmd.SysProcAttr = &syscall.SysProcAttr{
|
|
HideWindow: true,
|
|
CreationFlags: 0x08000000,
|
|
}
|
|
|
|
sceneOut, err := sceneCmd.CombinedOutput()
|
|
if err != nil {
|
|
trainingWriteError(
|
|
w,
|
|
http.StatusInternalServerError,
|
|
fmt.Sprintf("scene training failed: %v: %s", err, strings.TrimSpace(string(sceneOut))),
|
|
)
|
|
return
|
|
}
|
|
|
|
// --------------------------------------------------
|
|
// 2) Detector trainieren: YOLO
|
|
// Nur starten, wenn dataset.yaml existiert.
|
|
// --------------------------------------------------
|
|
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")
|
|
|
|
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
|
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
|
|
|
|
if fileExistsNonEmpty(detectorDatasetYAML) && trainCount >= 5 && valCount >= 1 {
|
|
detectorScript := trainingScriptPath("train_detector_model.py")
|
|
|
|
detectorCmd := exec.Command(
|
|
python,
|
|
detectorScript,
|
|
"--root", root,
|
|
"--base", "yolo11n.pt",
|
|
"--epochs", "80",
|
|
"--imgsz", "640",
|
|
)
|
|
|
|
detectorCmd.SysProcAttr = &syscall.SysProcAttr{
|
|
HideWindow: true,
|
|
CreationFlags: 0x08000000,
|
|
}
|
|
|
|
detectorOut, detectorErr := detectorCmd.CombinedOutput()
|
|
detectorOutput = strings.TrimSpace(string(detectorOut))
|
|
|
|
if detectorErr != nil {
|
|
detectorStatus = "failed"
|
|
fmt.Println("⚠️ detector training failed:", detectorErr, detectorOutput)
|
|
} else {
|
|
detectorStatus = "trained"
|
|
}
|
|
} else {
|
|
detectorStatus = "skipped_no_detector_data"
|
|
detectorOutput = fmt.Sprintf(
|
|
"Detector übersprungen: zu wenige YOLO-Box-Labels. Train=%d, Val=%d. Benötigt: mindestens 5 Train und 1 Val.",
|
|
trainCount,
|
|
valCount,
|
|
)
|
|
}
|
|
|
|
message := "Training abgeschlossen. Neue Bilder werden jetzt mit dem Scene-Modell analysiert."
|
|
if detectorStatus == "trained" {
|
|
message = "Training abgeschlossen. Scene-Modell und Detector wurden trainiert."
|
|
}
|
|
if detectorStatus == "failed" {
|
|
message = "Scene-Modell wurde trainiert, Detector-Training ist fehlgeschlagen."
|
|
}
|
|
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"message": message,
|
|
"annotationCount": count,
|
|
"sceneStatus": "trained",
|
|
"sceneOutput": strings.TrimSpace(string(sceneOut)),
|
|
"detectorStatus": detectorStatus,
|
|
"detectorOutput": detectorOutput,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
feedbackPath := filepath.Join(root, "feedback.jsonl")
|
|
count, _ := trainingCountAnnotations(feedbackPath)
|
|
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"feedbackCount": count,
|
|
"requiredCount": minTrainingFeedbackCount,
|
|
"canTrain": count >= minTrainingFeedbackCount,
|
|
})
|
|
}
|
|
|
|
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 {
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
return trainingEmptyPrediction("root_error")
|
|
}
|
|
|
|
python := trainingPythonExe()
|
|
script := trainingScriptPath("predict_scene_model.py")
|
|
|
|
cmd := exec.Command(
|
|
python,
|
|
script,
|
|
"--root", root,
|
|
"--image", framePath,
|
|
)
|
|
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
|
HideWindow: true,
|
|
CreationFlags: 0x08000000,
|
|
}
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
fmt.Println("⚠️ training predict failed:", err, strings.TrimSpace(string(out)))
|
|
return trainingEmptyPrediction("predict_failed")
|
|
}
|
|
|
|
var pred TrainingPrediction
|
|
if err := json.Unmarshal(out, &pred); err != nil {
|
|
fmt.Println("⚠️ training predict json failed:", err, strings.TrimSpace(string(out)))
|
|
return trainingEmptyPrediction("predict_json_failed")
|
|
}
|
|
|
|
if pred.SexPosition == "" {
|
|
pred.SexPosition = "unknown"
|
|
}
|
|
if pred.BodyPartsPresent == nil {
|
|
pred.BodyPartsPresent = []TrainingScoredLabel{}
|
|
}
|
|
if pred.ObjectsPresent == nil {
|
|
pred.ObjectsPresent = []TrainingScoredLabel{}
|
|
}
|
|
if pred.ClothingPresent == nil {
|
|
pred.ClothingPresent = []TrainingScoredLabel{}
|
|
}
|
|
if pred.Boxes == nil {
|
|
pred.Boxes = []TrainingBox{}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Erstmal alles in train schreiben.
|
|
// Später kannst du 80/20 train/val splitten.
|
|
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
|
|
}
|
|
|
|
// Erwartung: box.X/Y/W/H sind bereits YOLO-normalisiert:
|
|
// x_center, y_center, width, height.
|
|
lines = append(lines, fmt.Sprintf(
|
|
"%d %.6f %.6f %.6f %.6f",
|
|
classID,
|
|
x,
|
|
y,
|
|
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 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 "."
|
|
}
|
|
|
|
// Fall A: Prozess läuft im Projekt-Root:
|
|
// ./backend/ml/predict_scene_model.py existiert
|
|
if _, err := os.Stat(filepath.Join(wd, "backend", "ml", "predict_scene_model.py")); err == nil {
|
|
return wd
|
|
}
|
|
|
|
// Fall B: Prozess läuft direkt in /backend:
|
|
// ./ml/predict_scene_model.py existiert
|
|
if _, err := os.Stat(filepath.Join(wd, "ml", "predict_scene_model.py")); err == nil {
|
|
return filepath.Dir(wd)
|
|
}
|
|
|
|
// Fall C: Fallback: Parent testen
|
|
parent := filepath.Dir(wd)
|
|
if _, err := os.Stat(filepath.Join(parent, "backend", "ml", "predict_scene_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) Dev-Fallback: Projektstruktur.
|
|
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 trainingRootDir() (string, error) {
|
|
root := filepath.Join("generated", "training")
|
|
|
|
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,
|
|
})
|
|
}
|