2081 lines
52 KiB
Go
2081 lines
52 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
|
|
}
|
|
|
|
refreshPrediction := r.URL.Query().Get("refresh") == "1" ||
|
|
strings.EqualFold(r.URL.Query().Get("refresh"), "true")
|
|
|
|
if !forceNew {
|
|
if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction); 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, refreshPrediction 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] {
|
|
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 {
|
|
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 erkennt Personen/Gender für die Counts.
|
|
// - Automatisch erkannte Personenboxen werden nicht an das Frontend als sichtbare Boxen zurückgegeben.
|
|
// - Manuell gezeichnete Personenboxen werden trotzdem als Trainingsdaten gespeichert.
|
|
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": true,
|
|
"detectsGender": true,
|
|
"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": "yolo_detector",
|
|
"genderSource": "yolo_detector",
|
|
"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 {
|
|
rawBoxes := det.Boxes
|
|
if rawBoxes == nil {
|
|
rawBoxes = []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: []TrainingBox{},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
// Personen erkennen und zählen.
|
|
// Personenboxen werden jetzt auch sichtbar zurückgegeben,
|
|
// damit sie im Frontend gezeichnet, verschoben, gelöscht und trainiert werden können.
|
|
if peopleSet[label] {
|
|
switch label {
|
|
case "person_male", "male_person":
|
|
pred.MaleCount++
|
|
|
|
case "person_female", "female_person":
|
|
pred.FemaleCount++
|
|
|
|
case "person", "person_unknown":
|
|
pred.UnknownCount++
|
|
}
|
|
|
|
visibleBoxes = append(visibleBoxes, box)
|
|
continue
|
|
}
|
|
|
|
// Nur Bodyparts/Objects/Clothing bleiben als Boxen sichtbar.
|
|
if detectionSet[label] {
|
|
visibleBoxes = append(visibleBoxes, box)
|
|
}
|
|
}
|
|
|
|
pred.PeopleCount = pred.MaleCount + pred.FemaleCount + pred.UnknownCount
|
|
pred.Boxes = visibleBoxes
|
|
|
|
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")
|
|
|
|
modelPath := filepath.Join(root, "detector", "model", "best.pt")
|
|
|
|
if !fileExistsNonEmpty(modelPath) {
|
|
return TrainingDetectorPrediction{
|
|
Available: false,
|
|
Source: "detector_missing",
|
|
Boxes: []TrainingBox{},
|
|
}
|
|
}
|
|
|
|
confValues := []string{"0.40", "0.30", "0.20"}
|
|
|
|
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,
|
|
})
|
|
}
|