8854 lines
221 KiB
Go
8854 lines
221 KiB
Go
// backend\training.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
goprocess "github.com/shirou/gopsutil/v3/process"
|
|
)
|
|
|
|
const trainingUncertainCandidateCount = 10
|
|
|
|
type TrainingLabels struct {
|
|
People []string `json:"people"`
|
|
SexPositions []string `json:"sexPositions"`
|
|
BodyParts []string `json:"bodyParts"`
|
|
Objects []string `json:"objects"`
|
|
Clothing []string `json:"clothing"`
|
|
}
|
|
|
|
type TrainingBox struct {
|
|
Label string `json:"label"`
|
|
Score float64 `json:"score,omitempty"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
W float64 `json:"w"`
|
|
H float64 `json:"h"`
|
|
}
|
|
|
|
type TrainingScoredLabel struct {
|
|
Label string `json:"label"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
type TrainingPrediction struct {
|
|
ModelAvailable bool `json:"modelAvailable"`
|
|
Source string `json:"source,omitempty"`
|
|
|
|
SexPosition string `json:"sexPosition"`
|
|
SexPositionScore float64 `json:"sexPositionScore"`
|
|
PeoplePresent []TrainingScoredLabel `json:"peoplePresent"`
|
|
BodyPartsPresent []TrainingScoredLabel `json:"bodyPartsPresent"`
|
|
ObjectsPresent []TrainingScoredLabel `json:"objectsPresent"`
|
|
ClothingPresent []TrainingScoredLabel `json:"clothingPresent"`
|
|
Boxes []TrainingBox `json:"boxes"`
|
|
Persons []TrainingPosePerson `json:"persons,omitempty"`
|
|
}
|
|
|
|
type TrainingCorrection struct {
|
|
SexPosition string `json:"sexPosition"`
|
|
PeoplePresent []string `json:"peoplePresent"`
|
|
BodyPartsPresent []string `json:"bodyPartsPresent"`
|
|
ObjectsPresent []string `json:"objectsPresent"`
|
|
ClothingPresent []string `json:"clothingPresent"`
|
|
Boxes []TrainingBox `json:"boxes"`
|
|
PosePersons []TrainingPosePerson `json:"posePersons,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"`
|
|
UncertaintyScore float64 `json:"uncertaintyScore,omitempty"`
|
|
Prediction TrainingPrediction `json:"prediction"`
|
|
}
|
|
|
|
type trainingUncertainQueueItem struct {
|
|
SampleID string `json:"sampleId"`
|
|
UncertaintyScore float64 `json:"uncertaintyScore"`
|
|
SourceFile string `json:"sourceFile,omitempty"`
|
|
CreatedAt string `json:"createdAt,omitempty"`
|
|
}
|
|
|
|
type trainingUncertainCandidate struct {
|
|
sample *TrainingSample
|
|
score float64
|
|
}
|
|
|
|
type TrainingFeedbackRequest struct {
|
|
SampleID string `json:"sampleId"`
|
|
Accepted bool `json:"accepted"`
|
|
Negative bool `json:"negative,omitempty"`
|
|
Correction *TrainingCorrection `json:"correction,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
type TrainingFeedbackUpdateRequest struct {
|
|
SampleID string `json:"sampleId"`
|
|
AnsweredAt string `json:"answeredAt"`
|
|
Accepted bool `json:"accepted"`
|
|
Negative bool `json:"negative,omitempty"`
|
|
Correction *TrainingCorrection `json:"correction,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
type TrainingSkipRequest struct {
|
|
SampleID string `json:"sampleId"`
|
|
}
|
|
|
|
type TrainingTrainRequest struct {
|
|
Scope string `json:"scope,omitempty"`
|
|
Targets []string `json:"targets,omitempty"`
|
|
}
|
|
|
|
type trainingTrainTargets struct {
|
|
Detector bool
|
|
Pose bool
|
|
VideoMAE bool
|
|
}
|
|
|
|
func trainingAllTrainTargets() trainingTrainTargets {
|
|
return trainingTrainTargets{
|
|
Detector: true,
|
|
Pose: true,
|
|
VideoMAE: true,
|
|
}
|
|
}
|
|
|
|
func (t trainingTrainTargets) empty() bool {
|
|
return !t.Detector && !t.Pose && !t.VideoMAE
|
|
}
|
|
|
|
func (t trainingTrainTargets) list() []string {
|
|
out := make([]string, 0, 3)
|
|
if t.Detector {
|
|
out = append(out, "detector")
|
|
}
|
|
if t.Pose {
|
|
out = append(out, "pose")
|
|
}
|
|
if t.VideoMAE {
|
|
out = append(out, "videomae")
|
|
}
|
|
return out
|
|
}
|
|
|
|
func trainingReadTrainRequest(r *http.Request) (TrainingTrainRequest, error) {
|
|
var req TrainingTrainRequest
|
|
if r.Body == nil {
|
|
return req, nil
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
return req, err
|
|
}
|
|
|
|
if strings.TrimSpace(string(body)) == "" {
|
|
return req, nil
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return req, err
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
func trainingNormalizeTrainTargets(req TrainingTrainRequest) (trainingTrainTargets, bool, error) {
|
|
scope := strings.ToLower(strings.TrimSpace(req.Scope))
|
|
if scope == "" || scope == "full" || scope == "all" || scope == "complete" {
|
|
return trainingAllTrainTargets(), false, nil
|
|
}
|
|
|
|
if scope != "custom" && scope != "selected" && scope != "partial" {
|
|
return trainingTrainTargets{}, false, fmt.Errorf("unbekannter Trainingsumfang: %s", req.Scope)
|
|
}
|
|
|
|
var targets trainingTrainTargets
|
|
for _, raw := range req.Targets {
|
|
key := strings.ToLower(strings.TrimSpace(raw))
|
|
key = strings.ReplaceAll(key, "_", "")
|
|
key = strings.ReplaceAll(key, "-", "")
|
|
key = strings.ReplaceAll(key, " ", "")
|
|
|
|
switch key {
|
|
case "detector", "yolo", "yolo26", "yolo26detector", "box", "boxes", "boxdetection":
|
|
targets.Detector = true
|
|
case "pose", "yolo26pose", "posedetection":
|
|
targets.Pose = true
|
|
case "videomae", "video", "clip", "scene", "clipanalysis", "clipanalyse":
|
|
targets.VideoMAE = true
|
|
case "":
|
|
continue
|
|
default:
|
|
return trainingTrainTargets{}, true, fmt.Errorf("unbekanntes Training: %s", raw)
|
|
}
|
|
}
|
|
|
|
if targets.empty() {
|
|
return trainingTrainTargets{}, true, errors.New("kein Training ausgewählt")
|
|
}
|
|
|
|
return targets, true, nil
|
|
}
|
|
|
|
type TrainingAnnotation struct {
|
|
SampleID string `json:"sampleId"`
|
|
FrameURL string `json:"frameUrl"`
|
|
SourceFile string `json:"sourceFile"`
|
|
SourcePath string `json:"sourcePath,omitempty"`
|
|
SourceSizeBytes int64 `json:"sourceSizeBytes,omitempty"`
|
|
Second float64 `json:"second"`
|
|
CreatedAt string `json:"createdAt"`
|
|
AnsweredAt string `json:"answeredAt"`
|
|
Prediction TrainingPrediction `json:"prediction"`
|
|
Accepted bool `json:"accepted"`
|
|
Negative bool `json:"negative,omitempty"`
|
|
Correction *TrainingCorrection `json:"correction,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
type TrainingDetectorPrediction struct {
|
|
Available bool `json:"available"`
|
|
Source string `json:"source,omitempty"`
|
|
Boxes []TrainingBox `json:"boxes"`
|
|
}
|
|
|
|
type TrainingKeypoint struct {
|
|
Name string `json:"name"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
Conf float64 `json:"conf"`
|
|
}
|
|
|
|
type TrainingPosePerson struct {
|
|
Label string `json:"label,omitempty"`
|
|
Score float64 `json:"score"`
|
|
Box TrainingBox `json:"box"`
|
|
Keypoints []TrainingKeypoint `json:"keypoints"`
|
|
Quality float64 `json:"quality,omitempty"`
|
|
VisibleKeypoints int `json:"visibleKeypoints,omitempty"`
|
|
Reliable bool `json:"reliable,omitempty"`
|
|
}
|
|
|
|
type TrainingPosePrediction struct {
|
|
Available bool `json:"available"`
|
|
Source string `json:"source,omitempty"`
|
|
PersonCount int `json:"personCount"`
|
|
Persons []TrainingPosePerson `json:"persons"`
|
|
}
|
|
|
|
type TrainingJobStatus struct {
|
|
Running bool `json:"running"`
|
|
Progress int `json:"progress"`
|
|
Step string `json:"step"`
|
|
Message string `json:"message,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
StartedAt string `json:"startedAt,omitempty"`
|
|
FinishedAt string `json:"finishedAt,omitempty"`
|
|
DurationMs int64 `json:"durationMs,omitempty"`
|
|
PreviewURL string `json:"previewUrl,omitempty"`
|
|
Paused bool `json:"paused,omitempty"`
|
|
PauseReason string `json:"pauseReason,omitempty"`
|
|
CPUPercent float64 `json:"cpuPercent,omitempty"`
|
|
TemperatureC float64 `json:"temperatureC,omitempty"`
|
|
|
|
Stage string `json:"stage,omitempty"`
|
|
StageStartedAt string `json:"stageStartedAt,omitempty"`
|
|
StageProgress float64 `json:"stageProgress,omitempty"`
|
|
Epoch int `json:"epoch,omitempty"`
|
|
Epochs int `json:"epochs,omitempty"`
|
|
|
|
MAP50 float64 `json:"map50,omitempty"`
|
|
MAP5095 float64 `json:"map5095,omitempty"`
|
|
Accuracy float64 `json:"accuracy,omitempty"`
|
|
Loss float64 `json:"loss,omitempty"`
|
|
}
|
|
|
|
type TrainingConfidence struct {
|
|
Score float64 `json:"score"`
|
|
Level string `json:"level"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
type TrainingLabelStat struct {
|
|
Label string `json:"label"`
|
|
Count int `json:"count"`
|
|
Confidence TrainingConfidence `json:"confidence"`
|
|
}
|
|
|
|
type TrainingStatsLabels struct {
|
|
People []TrainingLabelStat `json:"people"`
|
|
SexPositions []TrainingLabelStat `json:"sexPositions"`
|
|
BodyParts []TrainingLabelStat `json:"bodyParts"`
|
|
Objects []TrainingLabelStat `json:"objects"`
|
|
Clothing []TrainingLabelStat `json:"clothing"`
|
|
}
|
|
|
|
type TrainingModelInfo struct {
|
|
TrainedAt string `json:"trainedAt,omitempty"`
|
|
TrainedAtMs int64 `json:"trainedAtMs,omitempty"`
|
|
Epochs int `json:"epochs,omitempty"`
|
|
TrainSamples int `json:"trainSamples,omitempty"`
|
|
ValSamples int `json:"valSamples,omitempty"`
|
|
Imgsz int `json:"imgsz,omitempty"`
|
|
Device string `json:"device,omitempty"`
|
|
MAP50 float64 `json:"map50,omitempty"`
|
|
MAP5095 float64 `json:"map5095,omitempty"`
|
|
}
|
|
|
|
type TrainingStatsResponse struct {
|
|
OK bool `json:"ok"`
|
|
FeedbackCount int `json:"feedbackCount"`
|
|
AcceptedCount int `json:"acceptedCount"`
|
|
CorrectedCount int `json:"correctedCount"`
|
|
NegativeCount int `json:"negativeCount"`
|
|
SampleCount int `json:"sampleCount"`
|
|
BoxCount int `json:"boxCount"`
|
|
ModelAvailable bool `json:"modelAvailable"`
|
|
ModelInfo *TrainingModelInfo `json:"modelInfo,omitempty"`
|
|
DetectorModelAvailable bool `json:"detectorModelAvailable"`
|
|
DetectorModelInfo *TrainingModelInfo `json:"detectorModelInfo,omitempty"`
|
|
PoseModelAvailable bool `json:"poseModelAvailable"`
|
|
PoseModelInfo *TrainingModelInfo `json:"poseModelInfo,omitempty"`
|
|
VideoMAEModelAvailable bool `json:"videoMAEModelAvailable"`
|
|
VideoMAEModelInfo *TrainingModelInfo `json:"videoMAEModelInfo,omitempty"`
|
|
Confidence TrainingConfidence `json:"confidence"`
|
|
Labels TrainingStatsLabels `json:"labels"`
|
|
}
|
|
|
|
type trainingProgressEvent struct {
|
|
Type string `json:"type"`
|
|
Stage string `json:"stage"`
|
|
Progress float64 `json:"progress"` // 0..1
|
|
Message string `json:"message,omitempty"`
|
|
Epoch int `json:"epoch,omitempty"`
|
|
Epochs int `json:"epochs,omitempty"`
|
|
SampleID string `json:"sampleId,omitempty"`
|
|
MAP50 *float64 `json:"mAP50,omitempty"`
|
|
MAP5095 *float64 `json:"mAP5095,omitempty"`
|
|
Accuracy *float64 `json:"accuracy,omitempty"`
|
|
Loss *float64 `json:"loss,omitempty"`
|
|
}
|
|
|
|
type TrainingFeedbackListResponse struct {
|
|
OK bool `json:"ok"`
|
|
Items []TrainingAnnotation `json:"items"`
|
|
Total int `json:"total"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
HasMore bool `json:"hasMore"`
|
|
}
|
|
|
|
func trainingFeedbackListHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
limit := 30
|
|
offset := 0
|
|
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
|
if n, err := strconv.Atoi(raw); err == nil {
|
|
limit = n
|
|
}
|
|
}
|
|
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" {
|
|
if n, err := strconv.Atoi(raw); err == nil {
|
|
offset = n
|
|
}
|
|
}
|
|
|
|
if limit < 1 {
|
|
limit = 30
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
items, err := trainingReadAnnotations(root)
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Neueste zuerst.
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
ai := strings.TrimSpace(items[i].AnsweredAt)
|
|
aj := strings.TrimSpace(items[j].AnsweredAt)
|
|
|
|
if ai == aj {
|
|
return items[i].CreatedAt > items[j].CreatedAt
|
|
}
|
|
|
|
return ai > aj
|
|
})
|
|
|
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
filter := strings.TrimSpace(r.URL.Query().Get("filter"))
|
|
|
|
items = trainingFilterAnnotations(items, query, filter)
|
|
|
|
total := len(items)
|
|
|
|
if offset > total {
|
|
offset = total
|
|
}
|
|
|
|
end := offset + limit
|
|
if end > total {
|
|
end = total
|
|
}
|
|
|
|
page := items[offset:end]
|
|
|
|
trainingWriteJSON(w, http.StatusOK, TrainingFeedbackListResponse{
|
|
OK: true,
|
|
Items: page,
|
|
Total: total,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
HasMore: end < total,
|
|
})
|
|
}
|
|
|
|
func trainingReadAnnotations(root string) ([]TrainingAnnotation, error) {
|
|
path := filepath.Join(root, "feedback.jsonl")
|
|
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []TrainingAnnotation{}, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
items := []TrainingAnnotation{}
|
|
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var item TrainingAnnotation
|
|
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Alte Einträge robust machen.
|
|
if strings.TrimSpace(item.FrameURL) == "" && strings.TrimSpace(item.SampleID) != "" {
|
|
item.FrameURL = "/api/training/frame?id=" + item.SampleID
|
|
}
|
|
|
|
items = append(items, item)
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
func trainingFilterAnnotations(
|
|
items []TrainingAnnotation,
|
|
query string,
|
|
filter string,
|
|
) []TrainingAnnotation {
|
|
cleanQuery := strings.ToLower(strings.TrimSpace(query))
|
|
cleanFilter := strings.ToLower(strings.TrimSpace(filter))
|
|
|
|
out := make([]TrainingAnnotation, 0, len(items))
|
|
|
|
for _, item := range items {
|
|
switch cleanFilter {
|
|
case "accepted":
|
|
if !item.Accepted || item.Negative {
|
|
continue
|
|
}
|
|
|
|
case "corrected":
|
|
if item.Accepted || item.Negative {
|
|
continue
|
|
}
|
|
|
|
case "negative":
|
|
if !item.Negative {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if cleanQuery != "" && !trainingAnnotationMatchesQuery(item, cleanQuery) {
|
|
continue
|
|
}
|
|
|
|
out = append(out, item)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingAnnotationMatchesQuery(item TrainingAnnotation, cleanQuery string) bool {
|
|
effective := trainingEffectiveCorrection(item)
|
|
|
|
parts := []string{
|
|
item.SampleID,
|
|
item.SourceFile,
|
|
item.SourcePath,
|
|
item.CreatedAt,
|
|
item.AnsweredAt,
|
|
item.Notes,
|
|
effective.SexPosition,
|
|
}
|
|
if item.Negative {
|
|
parts = append(parts, "negative negativ leer keine labels")
|
|
}
|
|
|
|
parts = append(parts, effective.PeoplePresent...)
|
|
parts = append(parts, effective.BodyPartsPresent...)
|
|
parts = append(parts, effective.ObjectsPresent...)
|
|
parts = append(parts, effective.ClothingPresent...)
|
|
|
|
for _, box := range effective.Boxes {
|
|
parts = append(parts, box.Label)
|
|
}
|
|
|
|
haystack := strings.ToLower(strings.Join(parts, " "))
|
|
|
|
return strings.Contains(haystack, cleanQuery)
|
|
}
|
|
|
|
func trainingRemoveSampleFromUncertainQueue(root string, sampleID string) error {
|
|
sampleID = strings.TrimSpace(sampleID)
|
|
if sampleID == "" {
|
|
return nil
|
|
}
|
|
|
|
items, err := trainingReadUncertainQueue(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
|
|
next := make([]trainingUncertainQueueItem, 0, len(items))
|
|
changed := false
|
|
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item.SampleID) == sampleID {
|
|
changed = true
|
|
continue
|
|
}
|
|
|
|
next = append(next, item)
|
|
}
|
|
|
|
if !changed {
|
|
return nil
|
|
}
|
|
|
|
return trainingWriteUncertainQueue(root, next)
|
|
}
|
|
|
|
func trainingReadValidUncertainCandidates(root string) ([]trainingUncertainCandidate, error) {
|
|
items, err := trainingReadUncertainQueue(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
return []trainingUncertainCandidate{}, nil
|
|
}
|
|
|
|
answered, err := trainingAnsweredSampleIDs(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
candidates := make([]trainingUncertainCandidate, 0, len(items))
|
|
|
|
for _, item := range items {
|
|
id := strings.TrimSpace(item.SampleID)
|
|
if id == "" || answered[id] {
|
|
continue
|
|
}
|
|
|
|
framePath := filepath.Join(root, "frames", id+".jpg")
|
|
if !fileExistsNonEmpty(framePath) {
|
|
continue
|
|
}
|
|
|
|
sample, err := trainingReadSample(root, id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
score := item.UncertaintyScore
|
|
if score <= 0 {
|
|
score = sample.UncertaintyScore
|
|
}
|
|
if score <= 0 {
|
|
score = trainingPredictionUncertaintyScore(sample.Prediction)
|
|
}
|
|
|
|
score = clamp01(score)
|
|
sample.UncertaintyScore = score
|
|
|
|
candidates = append(candidates, trainingUncertainCandidate{
|
|
sample: sample,
|
|
score: score,
|
|
})
|
|
}
|
|
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
if candidates[i].score == candidates[j].score {
|
|
return candidates[i].sample.CreatedAt < candidates[j].sample.CreatedAt
|
|
}
|
|
|
|
return candidates[i].score > candidates[j].score
|
|
})
|
|
|
|
return candidates, nil
|
|
}
|
|
|
|
func trainingWriteUncertainCandidateQueue(root string, candidates []trainingUncertainCandidate) error {
|
|
items := make([]trainingUncertainQueueItem, 0, len(candidates))
|
|
|
|
for _, candidate := range candidates {
|
|
if candidate.sample == nil {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSpace(candidate.sample.SampleID)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
|
|
items = append(items, trainingUncertainQueueItem{
|
|
SampleID: id,
|
|
UncertaintyScore: clamp01(candidate.score),
|
|
SourceFile: candidate.sample.SourceFile,
|
|
CreatedAt: candidate.sample.CreatedAt,
|
|
})
|
|
}
|
|
|
|
return trainingWriteUncertainQueue(root, items)
|
|
}
|
|
|
|
func trainingCreateUncertainCandidateWithProgress(
|
|
root string,
|
|
startedAtMs int64,
|
|
requestID string,
|
|
stepStart int,
|
|
stepTotal int,
|
|
prefix string,
|
|
) (*trainingUncertainCandidate, error) {
|
|
sample, err := trainingCreateNextSampleWithProgressRange(
|
|
startedAtMs,
|
|
requestID,
|
|
stepStart,
|
|
stepTotal,
|
|
prefix,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
score := trainingPredictionUncertaintyScore(sample.Prediction)
|
|
score = clamp01(score)
|
|
|
|
sample.UncertaintyScore = score
|
|
|
|
if err := trainingWriteSample(root, sample); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &trainingUncertainCandidate{
|
|
sample: sample,
|
|
score: score,
|
|
}, nil
|
|
}
|
|
|
|
func trainingUncertainQueuePath(root string) string {
|
|
return filepath.Join(root, "uncertain_queue.json")
|
|
}
|
|
|
|
func trainingReadUncertainQueue(root string) ([]trainingUncertainQueueItem, error) {
|
|
path := trainingUncertainQueuePath(root)
|
|
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []trainingUncertainQueueItem{}, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
var items []trainingUncertainQueueItem
|
|
if err := json.Unmarshal(b, &items); err != nil {
|
|
return []trainingUncertainQueueItem{}, nil
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
func trainingWriteUncertainQueue(root string, items []trainingUncertainQueueItem) error {
|
|
path := trainingUncertainQueuePath(root)
|
|
|
|
if len(items) == 0 {
|
|
_ = os.Remove(path)
|
|
return nil
|
|
}
|
|
|
|
b, err := json.MarshalIndent(items, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(path, b, 0644)
|
|
}
|
|
|
|
func trainingPopQueuedUncertainSample(root string) (*TrainingSample, bool, error) {
|
|
items, err := trainingReadUncertainQueue(root)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
|
|
answered, err := trainingAnsweredSampleIDs(root)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
remaining := make([]trainingUncertainQueueItem, 0, len(items))
|
|
|
|
for index, item := range items {
|
|
id := strings.TrimSpace(item.SampleID)
|
|
if id == "" || answered[id] {
|
|
continue
|
|
}
|
|
|
|
framePath := filepath.Join(root, "frames", id+".jpg")
|
|
if !fileExistsNonEmpty(framePath) {
|
|
continue
|
|
}
|
|
|
|
sample, err := trainingReadSample(root, id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
sample.UncertaintyScore = item.UncertaintyScore
|
|
|
|
remaining = append(remaining, items[index+1:]...)
|
|
|
|
if err := trainingWriteUncertainQueue(root, remaining); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
return sample, true, nil
|
|
}
|
|
|
|
_ = trainingWriteUncertainQueue(root, []trainingUncertainQueueItem{})
|
|
return nil, false, nil
|
|
}
|
|
|
|
func trainingScaleProgress(local float64, start int, end int) int {
|
|
if math.IsNaN(local) || math.IsInf(local, 0) {
|
|
local = 0
|
|
}
|
|
|
|
local = clamp01(local)
|
|
|
|
if end < start {
|
|
end = start
|
|
}
|
|
|
|
return start + int(math.Round(local*float64(end-start)))
|
|
}
|
|
|
|
func trainingApplyStageProgress(s *TrainingJobStatus, stage string, localProgress float64) {
|
|
stage = strings.TrimSpace(stage)
|
|
if stage == "" {
|
|
return
|
|
}
|
|
|
|
if math.IsNaN(localProgress) || math.IsInf(localProgress, 0) {
|
|
localProgress = 0
|
|
}
|
|
localProgress = clamp01(localProgress)
|
|
|
|
if strings.TrimSpace(s.Stage) != stage {
|
|
s.StageStartedAt = time.Now().UTC().Format(time.RFC3339)
|
|
s.Epoch = 0
|
|
s.Epochs = 0
|
|
s.MAP50 = 0
|
|
s.MAP5095 = 0
|
|
s.Accuracy = 0
|
|
s.Loss = 0
|
|
}
|
|
|
|
s.Stage = stage
|
|
s.StageProgress = localProgress
|
|
}
|
|
|
|
func trainingHandleProgressLine(line string, start int, end int, defaultStep string) bool {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
return false
|
|
}
|
|
|
|
var ev trainingProgressEvent
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
return false
|
|
}
|
|
|
|
if ev.Type != "progress" {
|
|
return false
|
|
}
|
|
|
|
progress := trainingScaleProgress(ev.Progress, start, end)
|
|
step := strings.TrimSpace(ev.Message)
|
|
if step == "" {
|
|
step = defaultStep
|
|
}
|
|
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
if progress > s.Progress {
|
|
s.Progress = progress
|
|
}
|
|
|
|
s.Step = step
|
|
|
|
trainingApplyStageProgress(s, ev.Stage, ev.Progress)
|
|
|
|
if ev.Epoch > 0 {
|
|
if s.Epoch <= 0 {
|
|
s.StageStartedAt = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
s.Epoch = ev.Epoch
|
|
}
|
|
|
|
if ev.Epochs > 0 {
|
|
s.Epochs = ev.Epochs
|
|
}
|
|
|
|
if ev.MAP50 != nil && *ev.MAP50 > 0 {
|
|
s.MAP50 = *ev.MAP50
|
|
}
|
|
if ev.MAP5095 != nil && *ev.MAP5095 > 0 {
|
|
s.MAP5095 = *ev.MAP5095
|
|
}
|
|
if ev.Accuracy != nil {
|
|
s.Accuracy = *ev.Accuracy
|
|
}
|
|
if ev.Loss != nil {
|
|
s.Loss = *ev.Loss
|
|
}
|
|
|
|
sampleID := strings.TrimSpace(ev.SampleID)
|
|
if sampleID != "" &&
|
|
!strings.Contains(sampleID, "/") &&
|
|
!strings.Contains(sampleID, "\\") {
|
|
s.PreviewURL = "/api/training/frame?id=" + url.QueryEscape(sampleID)
|
|
}
|
|
})
|
|
|
|
return true
|
|
}
|
|
|
|
func trainingPublishJobStatus(status TrainingJobStatus) {
|
|
b, err := json.Marshal(map[string]any{
|
|
"type": "training_status",
|
|
"training": status,
|
|
"ts": time.Now().UnixMilli(),
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
publishSSE("training", b)
|
|
}
|
|
|
|
type trainingAnalysisStatusEntry struct {
|
|
updatedAt time.Time
|
|
payload map[string]any
|
|
}
|
|
|
|
var trainingAnalysisStatuses = struct {
|
|
sync.Mutex
|
|
items map[string]trainingAnalysisStatusEntry
|
|
}{
|
|
items: map[string]trainingAnalysisStatusEntry{},
|
|
}
|
|
|
|
const trainingAnalysisStatusTTL = 2 * time.Hour
|
|
|
|
func trainingRememberAnalysisPayload(payload map[string]any) {
|
|
requestID := strings.TrimSpace(fmt.Sprint(payload["requestId"]))
|
|
if requestID == "" {
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
copied := make(map[string]any, len(payload))
|
|
for k, v := range payload {
|
|
copied[k] = v
|
|
}
|
|
|
|
trainingAnalysisStatuses.Lock()
|
|
for id, entry := range trainingAnalysisStatuses.items {
|
|
if !entry.updatedAt.IsZero() && now.Sub(entry.updatedAt) > trainingAnalysisStatusTTL {
|
|
delete(trainingAnalysisStatuses.items, id)
|
|
}
|
|
}
|
|
trainingAnalysisStatuses.items[requestID] = trainingAnalysisStatusEntry{
|
|
updatedAt: now,
|
|
payload: copied,
|
|
}
|
|
trainingAnalysisStatuses.Unlock()
|
|
}
|
|
|
|
func trainingAnalysisStatusPayload(requestID string) map[string]any {
|
|
requestID = strings.TrimSpace(requestID)
|
|
|
|
trainingAnalysisStatuses.Lock()
|
|
defer trainingAnalysisStatuses.Unlock()
|
|
|
|
if requestID != "" {
|
|
entry, ok := trainingAnalysisStatuses.items[requestID]
|
|
if !ok || entry.payload == nil {
|
|
return nil
|
|
}
|
|
|
|
out := make(map[string]any, len(entry.payload))
|
|
for k, v := range entry.payload {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
var latest *trainingAnalysisStatusEntry
|
|
for _, entry := range trainingAnalysisStatuses.items {
|
|
if entry.payload == nil || !trainingAnalysisValueTruthy(entry.payload["running"]) {
|
|
continue
|
|
}
|
|
if latest == nil || entry.updatedAt.After(latest.updatedAt) {
|
|
copyEntry := entry
|
|
latest = ©Entry
|
|
}
|
|
}
|
|
|
|
if latest == nil {
|
|
return nil
|
|
}
|
|
|
|
out := make(map[string]any, len(latest.payload))
|
|
for k, v := range latest.payload {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func trainingAnalysisValueTruthy(value any) bool {
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v
|
|
case string:
|
|
return strings.EqualFold(strings.TrimSpace(v), "true") || strings.TrimSpace(v) == "1"
|
|
case int:
|
|
return v != 0
|
|
case int64:
|
|
return v != 0
|
|
case float64:
|
|
return v != 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func trainingPublishAnalysisPayload(payload map[string]any) {
|
|
trainingRememberAnalysisPayload(payload)
|
|
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
publishSSE("analysisProgress", b)
|
|
}
|
|
|
|
func trainingPublishAnalysisStep(
|
|
requestID string,
|
|
startedAtMs int64,
|
|
current int,
|
|
total int,
|
|
sourceFile string,
|
|
message string,
|
|
) {
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
current,
|
|
total,
|
|
sourceFile,
|
|
"",
|
|
message,
|
|
)
|
|
}
|
|
|
|
func trainingPublishAnalysisStepWithPreview(
|
|
requestID string,
|
|
startedAtMs int64,
|
|
current int,
|
|
total int,
|
|
sourceFile string,
|
|
previewURL string,
|
|
message string,
|
|
) {
|
|
progress := 0.0
|
|
if total > 0 {
|
|
progress = float64(current) / float64(total)
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"type": "analysis_progress",
|
|
"scope": "training",
|
|
"requestId": requestID,
|
|
"running": true,
|
|
"phase": "running",
|
|
"progress": progress,
|
|
"startedAtMs": startedAtMs,
|
|
"current": current,
|
|
"total": total,
|
|
"sourceFile": strings.TrimSpace(sourceFile),
|
|
"message": strings.TrimSpace(message),
|
|
"ts": time.Now().UnixMilli(),
|
|
}
|
|
|
|
if strings.TrimSpace(previewURL) != "" {
|
|
payload["previewUrl"] = strings.TrimSpace(previewURL)
|
|
}
|
|
|
|
trainingPublishAnalysisPayload(payload)
|
|
}
|
|
|
|
func trainingPublishAnalysisStarted(
|
|
requestID string,
|
|
total int,
|
|
sourceFile string,
|
|
message string,
|
|
) int64 {
|
|
return trainingPublishAnalysisStartedWithPreview(
|
|
requestID,
|
|
total,
|
|
sourceFile,
|
|
"",
|
|
message,
|
|
)
|
|
}
|
|
|
|
func trainingPublishAnalysisStartedWithPreview(
|
|
requestID string,
|
|
total int,
|
|
sourceFile string,
|
|
previewURL string,
|
|
message string,
|
|
) int64 {
|
|
startedAtMs := time.Now().UnixMilli()
|
|
|
|
payload := map[string]any{
|
|
"type": "analysis_progress",
|
|
"scope": "training",
|
|
"requestId": requestID,
|
|
"running": true,
|
|
"phase": "starting",
|
|
"progress": 0,
|
|
"startedAtMs": startedAtMs,
|
|
"current": 0,
|
|
"total": total,
|
|
"sourceFile": strings.TrimSpace(sourceFile),
|
|
"message": strings.TrimSpace(message),
|
|
"ts": time.Now().UnixMilli(),
|
|
}
|
|
|
|
if strings.TrimSpace(previewURL) != "" {
|
|
payload["previewUrl"] = strings.TrimSpace(previewURL)
|
|
}
|
|
|
|
trainingPublishAnalysisPayload(payload)
|
|
|
|
return startedAtMs
|
|
}
|
|
|
|
func trainingPublishAnalysisFinished(
|
|
requestID string,
|
|
startedAtMs int64,
|
|
total int,
|
|
sourceFile string,
|
|
message string,
|
|
) {
|
|
finishedAtMs := time.Now().UnixMilli()
|
|
durationMs := finishedAtMs - startedAtMs
|
|
if durationMs < 0 {
|
|
durationMs = 0
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"type": "analysis_progress",
|
|
"scope": "training",
|
|
"requestId": requestID,
|
|
"running": false,
|
|
"phase": "done",
|
|
"progress": 1,
|
|
"startedAtMs": startedAtMs,
|
|
"finishedAtMs": finishedAtMs,
|
|
"durationMs": durationMs,
|
|
"current": total,
|
|
"total": total,
|
|
"sourceFile": strings.TrimSpace(sourceFile),
|
|
"message": strings.TrimSpace(message),
|
|
"ts": time.Now().UnixMilli(),
|
|
}
|
|
|
|
trainingPublishAnalysisPayload(payload)
|
|
}
|
|
|
|
func trainingPublishAnalysisError(
|
|
requestID string,
|
|
startedAtMs int64,
|
|
sourceFile string,
|
|
message string,
|
|
err error,
|
|
) {
|
|
finishedAtMs := time.Now().UnixMilli()
|
|
durationMs := finishedAtMs - startedAtMs
|
|
if durationMs < 0 {
|
|
durationMs = 0
|
|
}
|
|
|
|
errText := ""
|
|
if err != nil {
|
|
errText = err.Error()
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"type": "analysis_progress",
|
|
"scope": "training",
|
|
"requestId": requestID,
|
|
"running": false,
|
|
"phase": "error",
|
|
"progress": 0,
|
|
"startedAtMs": startedAtMs,
|
|
"finishedAtMs": finishedAtMs,
|
|
"durationMs": durationMs,
|
|
"sourceFile": strings.TrimSpace(sourceFile),
|
|
"message": strings.TrimSpace(message),
|
|
"error": errText,
|
|
"ts": time.Now().UnixMilli(),
|
|
}
|
|
|
|
trainingPublishAnalysisPayload(payload)
|
|
}
|
|
|
|
func trainingRunCommandStreaming(
|
|
ctx context.Context,
|
|
python string,
|
|
script string,
|
|
onLine func(line string) bool,
|
|
args ...string,
|
|
) (string, error) {
|
|
cmdArgs := append([]string{script}, args...)
|
|
cmd := exec.Command(python, cmdArgs...)
|
|
|
|
hideCommandWindow(cmd)
|
|
opts := trainingRuntimeOptionsFromSettings()
|
|
cmd.Env = trainingCommandEnv(opts)
|
|
prepareTrainingCommandForCancel(cmd)
|
|
applyTrainingLowPriorityBeforeStart(cmd, opts.LowPriority)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if ctx.Err() != nil {
|
|
return "", errTrainingCancelled
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return "", err
|
|
}
|
|
applyTrainingLowPriorityAfterStart(cmd, opts.LowPriority)
|
|
monitorCtx, stopResourcePauseMonitor := context.WithCancel(ctx)
|
|
resumeResourcePause := startTrainingResourcePauseMonitor(monitorCtx, cmd, opts)
|
|
|
|
var outMu sync.Mutex
|
|
var lines []string
|
|
|
|
readPipe := func(scanner *bufio.Scanner) {
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
handled := false
|
|
if onLine != nil {
|
|
handled = onLine(line)
|
|
}
|
|
|
|
// Progress-Events nicht in den finalen Output übernehmen.
|
|
if handled {
|
|
continue
|
|
}
|
|
|
|
outMu.Lock()
|
|
lines = append(lines, line)
|
|
outMu.Unlock()
|
|
}
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
readPipe(bufio.NewScanner(stdout))
|
|
}()
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
readPipe(bufio.NewScanner(stderr))
|
|
}()
|
|
|
|
waitCh := make(chan error, 1)
|
|
go func() {
|
|
waitCh <- cmd.Wait()
|
|
}()
|
|
|
|
select {
|
|
case err = <-waitCh:
|
|
case <-ctx.Done():
|
|
resumeResourcePause()
|
|
terminateTrainingCommand(cmd)
|
|
err = <-waitCh
|
|
}
|
|
stopResourcePauseMonitor()
|
|
resumeResourcePause()
|
|
wg.Wait()
|
|
|
|
outMu.Lock()
|
|
out := strings.Join(lines, "\n")
|
|
outMu.Unlock()
|
|
|
|
if ctx.Err() != nil {
|
|
return strings.TrimSpace(out), errTrainingCancelled
|
|
}
|
|
|
|
return strings.TrimSpace(out), err
|
|
}
|
|
|
|
const minTrainingFeedbackCount = 5
|
|
|
|
const minDetectorTrainCount = 20
|
|
const minDetectorValCount = 3
|
|
const minPoseTrainCount = 20
|
|
const minPoseValCount = 3
|
|
const trainingPoseKeypointCount = 17
|
|
const trainingPoseKeypointMinConfidence = 0.20
|
|
const trainingPoseReliableMinScore = 0.30
|
|
const trainingPoseReliableMinKeypoints = 6
|
|
const trainingPoseReliableMinQuality = 0.45
|
|
const trainingPositionContextMinScore = 0.22
|
|
const trainingPositionContextMaxScore = 0.44
|
|
const trainingPositionContextBoostWeight = 0.60
|
|
const trainingPoseConfirmingContextMinScore = 0.14
|
|
const trainingPoseUnconfirmedMaxScore = 0.38
|
|
const trainingPoseStrongUnconfirmedMinScore = 0.70
|
|
const trainingPoseStrongUnconfirmedMaxScore = 0.46
|
|
|
|
var errTrainingCancelled = errors.New("training cancelled")
|
|
|
|
const (
|
|
trainingPerformanceAuto = "auto"
|
|
trainingPerformanceEco = "eco"
|
|
trainingPerformanceBalanced = "balanced"
|
|
trainingPerformancePerformance = "performance"
|
|
trainingPerformanceCustom = "custom"
|
|
)
|
|
|
|
type trainingRuntimeOptions struct {
|
|
PerformanceMode string
|
|
PowerSaveMode bool
|
|
CPUCoreCount int
|
|
CPUThreads int
|
|
Workers int
|
|
YoloBatchSize int
|
|
LowPriority bool
|
|
VideoMAEEnabled bool
|
|
AutoPauseEnabled bool
|
|
AutoPauseCPUPercent int
|
|
AutoPauseTemperatureC int
|
|
}
|
|
|
|
func normalizeTrainingPerformanceMode(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "", "auto", "automatic", "automatisch":
|
|
return trainingPerformanceAuto
|
|
case "eco", "schonend", "schonmodus", "powersave", "power-save", "power_save":
|
|
return trainingPerformanceEco
|
|
case "balanced", "ausgewogen", "normal":
|
|
return trainingPerformanceBalanced
|
|
case "performance", "leistung", "fast", "schnell":
|
|
return trainingPerformancePerformance
|
|
case "custom", "manual", "manuell":
|
|
return trainingPerformanceCustom
|
|
default:
|
|
return trainingPerformanceAuto
|
|
}
|
|
}
|
|
|
|
func clampTrainingInt(value int, minValue int, maxValue int) int {
|
|
if value < minValue {
|
|
return minValue
|
|
}
|
|
if value > maxValue {
|
|
return maxValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
func trainingPresetValues(mode string, cpuCores int) (threads int, workers int, yoloBatch int, lowPriority bool) {
|
|
cpuCores = clampTrainingInt(cpuCores, 1, 256)
|
|
|
|
switch normalizeTrainingPerformanceMode(mode) {
|
|
case trainingPerformanceEco:
|
|
threads = 1
|
|
if cpuCores >= 4 {
|
|
threads = 2
|
|
}
|
|
workers = 0
|
|
yoloBatch = 1
|
|
if cpuCores >= 6 {
|
|
yoloBatch = 2
|
|
}
|
|
lowPriority = true
|
|
|
|
case trainingPerformancePerformance:
|
|
threads = clampTrainingInt(cpuCores-1, 2, 16)
|
|
workers = clampTrainingInt(threads/2, 2, 4)
|
|
yoloBatch = 4
|
|
if cpuCores >= 8 {
|
|
yoloBatch = 8
|
|
}
|
|
if cpuCores >= 16 {
|
|
yoloBatch = 12
|
|
}
|
|
lowPriority = false
|
|
|
|
default:
|
|
threads = clampTrainingInt(cpuCores/2, 2, 8)
|
|
workers = clampTrainingInt(threads/2, 1, 2)
|
|
yoloBatch = 2
|
|
if cpuCores >= 8 {
|
|
yoloBatch = 4
|
|
}
|
|
if cpuCores >= 16 {
|
|
yoloBatch = 6
|
|
}
|
|
lowPriority = false
|
|
}
|
|
|
|
return threads, workers, yoloBatch, lowPriority
|
|
}
|
|
|
|
func trainingRuntimeOptionsFromSettings() trainingRuntimeOptions {
|
|
s := getSettings()
|
|
return trainingRuntimeOptionsFromRecorderSettings(s)
|
|
}
|
|
|
|
func trainingRuntimeOptionsFromRecorderSettings(s RecorderSettings) trainingRuntimeOptions {
|
|
normalizeTrainingSettings(&s)
|
|
cpuCores := runtime.NumCPU()
|
|
if cpuCores < 1 {
|
|
cpuCores = 1
|
|
}
|
|
|
|
mode := normalizeTrainingPerformanceMode(s.TrainingPerformanceMode)
|
|
if mode == trainingPerformanceAuto {
|
|
if cpuCores <= 4 {
|
|
mode = trainingPerformanceEco
|
|
} else if cpuCores >= 12 {
|
|
mode = trainingPerformancePerformance
|
|
} else {
|
|
mode = trainingPerformanceBalanced
|
|
}
|
|
}
|
|
|
|
presetThreads, presetWorkers, presetBatch, presetLowPriority := trainingPresetValues(mode, cpuCores)
|
|
|
|
opts := trainingRuntimeOptions{
|
|
PerformanceMode: mode,
|
|
PowerSaveMode: mode == trainingPerformanceEco,
|
|
CPUCoreCount: cpuCores,
|
|
CPUThreads: presetThreads,
|
|
Workers: presetWorkers,
|
|
YoloBatchSize: presetBatch,
|
|
LowPriority: presetLowPriority,
|
|
VideoMAEEnabled: s.TrainingVideoMAEEnabled,
|
|
AutoPauseEnabled: s.TrainingAutoPauseEnabled,
|
|
AutoPauseCPUPercent: s.TrainingAutoPauseCPUPercent,
|
|
AutoPauseTemperatureC: s.TrainingAutoPauseTemperatureC,
|
|
}
|
|
|
|
if mode == trainingPerformanceCustom {
|
|
opts.PowerSaveMode = s.TrainingPowerSaveMode
|
|
if s.TrainingCPUThreads > 0 {
|
|
opts.CPUThreads = clampTrainingInt(s.TrainingCPUThreads, 1, cpuCores)
|
|
} else {
|
|
opts.CPUThreads = 0
|
|
}
|
|
opts.Workers = clampTrainingInt(s.TrainingWorkers, 0, 16)
|
|
opts.YoloBatchSize = clampTrainingInt(s.TrainingYoloBatchSize, 0, 64)
|
|
opts.LowPriority = s.TrainingLowPriority
|
|
} else {
|
|
if opts.CPUThreads > cpuCores {
|
|
opts.CPUThreads = cpuCores
|
|
}
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
func trainingYoloEarlyStoppingPatience(epochs int) int {
|
|
epochs = clampTrainingInt(epochs, 1, 300)
|
|
patience := epochs / 4
|
|
if patience < 5 {
|
|
patience = 5
|
|
}
|
|
if patience > 20 {
|
|
patience = 20
|
|
}
|
|
if patience > epochs {
|
|
patience = epochs
|
|
}
|
|
return patience
|
|
}
|
|
|
|
func trainingCommandEnv(opts trainingRuntimeOptions) []string {
|
|
env := os.Environ()
|
|
if opts.CPUThreads <= 0 {
|
|
return env
|
|
}
|
|
|
|
threads := strconv.Itoa(opts.CPUThreads)
|
|
limitVars := []string{
|
|
"OMP_NUM_THREADS",
|
|
"MKL_NUM_THREADS",
|
|
"OPENBLAS_NUM_THREADS",
|
|
"NUMEXPR_NUM_THREADS",
|
|
"VECLIB_MAXIMUM_THREADS",
|
|
"TORCH_NUM_THREADS",
|
|
}
|
|
|
|
for _, name := range limitVars {
|
|
env = append(env, name+"="+threads)
|
|
}
|
|
|
|
return env
|
|
}
|
|
|
|
type trainingResourceSnapshot struct {
|
|
CPUPercent float64
|
|
TemperatureC float64
|
|
TemperatureAvailable bool
|
|
}
|
|
|
|
func trainingRoundMetric(v float64) float64 {
|
|
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
|
|
return 0
|
|
}
|
|
return math.Round(v*10) / 10
|
|
}
|
|
|
|
func trainingReadResourceSnapshot() trainingResourceSnapshot {
|
|
temp, tempOK := getMaxCPUTemperatureC()
|
|
return trainingResourceSnapshot{
|
|
CPUPercent: trainingRoundMetric(getLastCPUUsage()),
|
|
TemperatureC: trainingRoundMetric(temp),
|
|
TemperatureAvailable: tempOK,
|
|
}
|
|
}
|
|
|
|
func trainingPauseReasonForResources(opts trainingRuntimeOptions, snap trainingResourceSnapshot) (string, bool) {
|
|
if !opts.AutoPauseEnabled {
|
|
return "", false
|
|
}
|
|
|
|
reasons := []string{}
|
|
if opts.AutoPauseCPUPercent > 0 && snap.CPUPercent >= float64(opts.AutoPauseCPUPercent) {
|
|
reasons = append(
|
|
reasons,
|
|
fmt.Sprintf("CPU %.0f%% >= %d%%", snap.CPUPercent, opts.AutoPauseCPUPercent),
|
|
)
|
|
}
|
|
if opts.AutoPauseTemperatureC > 0 &&
|
|
snap.TemperatureAvailable &&
|
|
snap.TemperatureC >= float64(opts.AutoPauseTemperatureC) {
|
|
reasons = append(
|
|
reasons,
|
|
fmt.Sprintf("Temperatur %.0f°C >= %d°C", snap.TemperatureC, opts.AutoPauseTemperatureC),
|
|
)
|
|
}
|
|
|
|
if len(reasons) == 0 {
|
|
return "", false
|
|
}
|
|
return strings.Join(reasons, ", "), true
|
|
}
|
|
|
|
func trainingResourcesRecovered(opts trainingRuntimeOptions, snap trainingResourceSnapshot) bool {
|
|
if !opts.AutoPauseEnabled {
|
|
return true
|
|
}
|
|
|
|
if opts.AutoPauseCPUPercent > 0 {
|
|
resumeCPU := float64(opts.AutoPauseCPUPercent - 15)
|
|
if resumeCPU < 50 {
|
|
resumeCPU = 50
|
|
}
|
|
if snap.CPUPercent > resumeCPU {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if opts.AutoPauseTemperatureC > 0 && snap.TemperatureAvailable {
|
|
resumeTemp := float64(opts.AutoPauseTemperatureC - 5)
|
|
if resumeTemp < 40 {
|
|
resumeTemp = 40
|
|
}
|
|
if snap.TemperatureC > resumeTemp {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func trainingProcessTree(pid int32) []*goprocess.Process {
|
|
if pid <= 0 {
|
|
return nil
|
|
}
|
|
|
|
root, err := goprocess.NewProcess(pid)
|
|
if err != nil || root == nil {
|
|
return nil
|
|
}
|
|
|
|
seen := map[int32]bool{}
|
|
var out []*goprocess.Process
|
|
var walk func(*goprocess.Process)
|
|
walk = func(p *goprocess.Process) {
|
|
if p == nil || seen[p.Pid] {
|
|
return
|
|
}
|
|
|
|
seen[p.Pid] = true
|
|
out = append(out, p)
|
|
|
|
children, err := p.Children()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, child := range children {
|
|
walk(child)
|
|
}
|
|
}
|
|
|
|
walk(root)
|
|
return out
|
|
}
|
|
|
|
func trainingSuspendProcessTree(cmd *exec.Cmd) error {
|
|
if cmd == nil || cmd.Process == nil || cmd.Process.Pid <= 0 {
|
|
return nil
|
|
}
|
|
|
|
procs := trainingProcessTree(int32(cmd.Process.Pid))
|
|
if len(procs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var firstErr error
|
|
success := 0
|
|
for i := len(procs) - 1; i >= 0; i-- {
|
|
if err := procs[i].Suspend(); err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
success++
|
|
}
|
|
|
|
if success > 0 {
|
|
return nil
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func trainingResumeProcessTree(cmd *exec.Cmd) error {
|
|
if cmd == nil || cmd.Process == nil || cmd.Process.Pid <= 0 {
|
|
return nil
|
|
}
|
|
|
|
procs := trainingProcessTree(int32(cmd.Process.Pid))
|
|
if len(procs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var firstErr error
|
|
success := 0
|
|
for _, proc := range procs {
|
|
if err := proc.Resume(); err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
success++
|
|
}
|
|
|
|
if success > 0 {
|
|
return nil
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func startTrainingResourcePauseMonitor(ctx context.Context, cmd *exec.Cmd, opts trainingRuntimeOptions) func() {
|
|
if !opts.AutoPauseEnabled || cmd == nil || cmd.Process == nil {
|
|
return func() {}
|
|
}
|
|
|
|
const (
|
|
checkInterval = 5 * time.Second
|
|
requiredHighSamples = 2
|
|
requiredCoolSamples = 2
|
|
minPauseDuration = 45 * time.Second
|
|
)
|
|
|
|
var pauseMu sync.Mutex
|
|
paused := false
|
|
previousStep := ""
|
|
highSamples := 0
|
|
coolSamples := 0
|
|
pauseStartedAt := time.Time{}
|
|
loggedSuspendError := false
|
|
|
|
resumeNow := func(reason string) {
|
|
pauseMu.Lock()
|
|
if !paused {
|
|
pauseMu.Unlock()
|
|
return
|
|
}
|
|
|
|
err := trainingResumeProcessTree(cmd)
|
|
paused = false
|
|
restoreStep := strings.TrimSpace(previousStep)
|
|
previousStep = ""
|
|
pauseMu.Unlock()
|
|
|
|
if err != nil {
|
|
appLogln("⚠️ Training konnte nach Ressourcenpause nicht sauber fortgesetzt werden:", err)
|
|
}
|
|
|
|
if strings.TrimSpace(reason) != "" {
|
|
appLogln(reason)
|
|
}
|
|
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
s.Paused = false
|
|
s.PauseReason = ""
|
|
s.CPUPercent = 0
|
|
s.TemperatureC = 0
|
|
s.Message = ""
|
|
if restoreStep != "" && s.Running {
|
|
s.Step = restoreStep
|
|
}
|
|
})
|
|
}
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(checkInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
resumeNow("Training-Ressourcenpause wurde beendet.")
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
|
|
snap := trainingReadResourceSnapshot()
|
|
|
|
pauseMu.Lock()
|
|
isPaused := paused
|
|
pauseAge := time.Since(pauseStartedAt)
|
|
pauseMu.Unlock()
|
|
|
|
if !isPaused {
|
|
reason, shouldPause := trainingPauseReasonForResources(opts, snap)
|
|
if !shouldPause {
|
|
highSamples = 0
|
|
continue
|
|
}
|
|
|
|
highSamples++
|
|
if highSamples < requiredHighSamples {
|
|
continue
|
|
}
|
|
|
|
pauseMu.Lock()
|
|
if paused {
|
|
pauseMu.Unlock()
|
|
continue
|
|
}
|
|
|
|
currentStatus := trainingGetJobStatus()
|
|
previousStep = strings.TrimSpace(currentStatus.Step)
|
|
if previousStep == "" {
|
|
previousStep = "Training läuft..."
|
|
}
|
|
|
|
if err := trainingSuspendProcessTree(cmd); err != nil {
|
|
pauseMu.Unlock()
|
|
highSamples = 0
|
|
if !loggedSuspendError {
|
|
loggedSuspendError = true
|
|
appLogln("⚠️ Training-Ressourcenpause konnte Prozess nicht pausieren:", err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
paused = true
|
|
pauseStartedAt = time.Now()
|
|
coolSamples = 0
|
|
pauseMu.Unlock()
|
|
|
|
appLogln("⏸ Training pausiert wegen Ressourcenlimit:", reason)
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
s.Paused = true
|
|
s.PauseReason = reason
|
|
s.CPUPercent = snap.CPUPercent
|
|
if snap.TemperatureAvailable {
|
|
s.TemperatureC = snap.TemperatureC
|
|
} else {
|
|
s.TemperatureC = 0
|
|
}
|
|
s.Step = "Training pausiert: " + reason
|
|
s.Message = "Training wird automatisch fortgesetzt, sobald CPU/Temperatur wieder im grünen Bereich sind."
|
|
})
|
|
continue
|
|
}
|
|
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
if !s.Running || !s.Paused {
|
|
return
|
|
}
|
|
s.CPUPercent = snap.CPUPercent
|
|
if snap.TemperatureAvailable {
|
|
s.TemperatureC = snap.TemperatureC
|
|
}
|
|
})
|
|
|
|
if pauseAge < minPauseDuration || !trainingResourcesRecovered(opts, snap) {
|
|
coolSamples = 0
|
|
continue
|
|
}
|
|
|
|
coolSamples++
|
|
if coolSamples < requiredCoolSamples {
|
|
continue
|
|
}
|
|
|
|
highSamples = 0
|
|
coolSamples = 0
|
|
resumeNow("▶ Training nach Ressourcenpause fortgesetzt.")
|
|
}
|
|
}()
|
|
|
|
return func() {
|
|
resumeNow("")
|
|
}
|
|
}
|
|
|
|
var trainingJob = struct {
|
|
mu sync.Mutex
|
|
status TrainingJobStatus
|
|
cancel context.CancelFunc
|
|
}{}
|
|
|
|
func trainingSetJobStatus(update func(*TrainingJobStatus)) {
|
|
trainingJob.mu.Lock()
|
|
update(&trainingJob.status)
|
|
snapshot := trainingJob.status
|
|
trainingJob.mu.Unlock()
|
|
|
|
trainingPublishJobStatus(snapshot)
|
|
}
|
|
|
|
func trainingGetJobStatus() TrainingJobStatus {
|
|
trainingJob.mu.Lock()
|
|
defer trainingJob.mu.Unlock()
|
|
return trainingJob.status
|
|
}
|
|
|
|
func trainingStartJob(cancel context.CancelFunc) {
|
|
trainingJob.mu.Lock()
|
|
|
|
startedAt := time.Now().UTC().Format(time.RFC3339)
|
|
trainingJob.status = TrainingJobStatus{
|
|
Running: true,
|
|
Progress: 5,
|
|
Step: "Training wird vorbereitet…",
|
|
StartedAt: startedAt,
|
|
StageStartedAt: startedAt,
|
|
}
|
|
|
|
trainingJob.cancel = cancel
|
|
snapshot := trainingJob.status
|
|
|
|
trainingJob.mu.Unlock()
|
|
|
|
trainingPublishJobStatus(snapshot)
|
|
}
|
|
|
|
func trainingClearJobCancel() {
|
|
trainingJob.mu.Lock()
|
|
trainingJob.cancel = nil
|
|
trainingJob.mu.Unlock()
|
|
}
|
|
|
|
func trainingCleanupPartialDetectorTraining(root string) error {
|
|
// Löscht nur temporäre Trainingsläufe.
|
|
// Feedback, Samples, Frames und Dataset bleiben erhalten.
|
|
runsDir := filepath.Join(root, "detector", "runs")
|
|
|
|
if err := os.RemoveAll(runsDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.MkdirAll(runsDir, 0755)
|
|
}
|
|
|
|
func trainingFinishCancelled(root string) {
|
|
cleanupErr := trainingCleanupPartialDetectorTraining(root)
|
|
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
finishedAt := time.Now().UTC()
|
|
|
|
var durationMs int64
|
|
if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); err == nil {
|
|
durationMs = finishedAt.Sub(startedAt).Milliseconds()
|
|
if durationMs < 0 {
|
|
durationMs = 0
|
|
}
|
|
}
|
|
|
|
s.Running = false
|
|
s.Step = "Training abgebrochen."
|
|
s.Message = "Training wurde abgebrochen. Temporäre Trainingsausgaben wurden gelöscht."
|
|
s.Error = ""
|
|
s.FinishedAt = finishedAt.Format(time.RFC3339)
|
|
s.DurationMs = durationMs
|
|
s.PreviewURL = ""
|
|
s.Paused = false
|
|
s.PauseReason = ""
|
|
s.CPUPercent = 0
|
|
s.TemperatureC = 0
|
|
|
|
if cleanupErr != nil {
|
|
s.Message = "Training wurde abgebrochen, aber temporäre Trainingsausgaben konnten nicht vollständig gelöscht werden."
|
|
s.Error = cleanupErr.Error()
|
|
}
|
|
})
|
|
|
|
trainingClearJobCancel()
|
|
}
|
|
|
|
func trainingRunCommand(python string, script string, args ...string) (string, error) {
|
|
cmdArgs := append([]string{script}, args...)
|
|
cmd := exec.Command(python, cmdArgs...)
|
|
|
|
hideCommandWindow(cmd)
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|
|
|
|
type trainingModelResolution struct {
|
|
BestPath string
|
|
EffectivePath string
|
|
Source string
|
|
TrainedExists bool
|
|
EffectiveExists bool
|
|
}
|
|
|
|
func trainingResolveModel(
|
|
root string,
|
|
kind string,
|
|
trainedSource string,
|
|
) trainingModelResolution {
|
|
bestPath := filepath.Join(root, kind, "model", "best.pt")
|
|
if fileExistsNonEmpty(bestPath) {
|
|
return trainingModelResolution{
|
|
BestPath: bestPath,
|
|
EffectivePath: bestPath,
|
|
Source: trainedSource,
|
|
TrainedExists: true,
|
|
EffectiveExists: true,
|
|
}
|
|
}
|
|
|
|
return trainingModelResolution{
|
|
BestPath: bestPath,
|
|
EffectivePath: bestPath,
|
|
Source: kind + "_missing",
|
|
TrainedExists: false,
|
|
EffectiveExists: false,
|
|
}
|
|
}
|
|
|
|
func trainingResolveDetectorModel(root string) trainingModelResolution {
|
|
return trainingResolveModel(
|
|
root,
|
|
"detector",
|
|
"yolo26_detector",
|
|
)
|
|
}
|
|
|
|
func trainingResolvePoseModel(root string) trainingModelResolution {
|
|
res := trainingResolveModel(
|
|
root,
|
|
"pose",
|
|
"yolo_pose",
|
|
)
|
|
if res.EffectiveExists {
|
|
return res
|
|
}
|
|
|
|
if poseBase, err := embeddedPoseModelPath(); err == nil && fileExistsNonEmpty(poseBase) {
|
|
res.EffectivePath = poseBase
|
|
res.Source = "yolo26_pose_base"
|
|
res.EffectiveExists = true
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func trainingLabelsHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
trainingWriteJSON(w, http.StatusOK, defaultTrainingLabelsFromJSON())
|
|
}
|
|
|
|
const trainingImportVideoDefaultFrameCount = 10
|
|
const trainingImportVideoMaxFrameCount = 30
|
|
|
|
type TrainingImportVideoRequest struct {
|
|
JobID string `json:"jobId"`
|
|
Output string `json:"output"`
|
|
Count int `json:"count"`
|
|
AnalysisRequestID string `json:"analysisRequestId"`
|
|
}
|
|
|
|
type TrainingImportVideoResponse struct {
|
|
OK bool `json:"ok"`
|
|
Accepted bool `json:"accepted,omitempty"`
|
|
Running bool `json:"running,omitempty"`
|
|
RequestID string `json:"requestId,omitempty"`
|
|
Count int `json:"count"`
|
|
Sample *TrainingSample `json:"sample,omitempty"`
|
|
Samples []TrainingSample `json:"samples,omitempty"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Analysis map[string]any `json:"analysis,omitempty"`
|
|
}
|
|
|
|
type TrainingNextRequest struct {
|
|
ForceNew bool
|
|
RefreshPrediction bool
|
|
PreferUncertain bool
|
|
AnalysisRequestID string
|
|
ExcludeIDs map[string]bool
|
|
}
|
|
|
|
type TrainingNextResponse struct {
|
|
OK bool `json:"ok"`
|
|
Accepted bool `json:"accepted,omitempty"`
|
|
Running bool `json:"running,omitempty"`
|
|
RequestID string `json:"requestId,omitempty"`
|
|
Sample *TrainingSample `json:"sample,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Analysis map[string]any `json:"analysis,omitempty"`
|
|
}
|
|
|
|
type trainingNextJobState struct {
|
|
requestID string
|
|
startedAt time.Time
|
|
finishedAt time.Time
|
|
running bool
|
|
statusCode int
|
|
response *TrainingNextResponse
|
|
errorText string
|
|
}
|
|
|
|
var trainingNextJobs = struct {
|
|
sync.Mutex
|
|
items map[string]*trainingNextJobState
|
|
}{
|
|
items: map[string]*trainingNextJobState{},
|
|
}
|
|
|
|
const trainingNextJobTTL = 2 * time.Hour
|
|
|
|
type trainingImportVideoJobState struct {
|
|
requestID string
|
|
startedAt time.Time
|
|
finishedAt time.Time
|
|
running bool
|
|
statusCode int
|
|
response *TrainingImportVideoResponse
|
|
errorText string
|
|
}
|
|
|
|
var trainingImportVideoJobs = struct {
|
|
sync.Mutex
|
|
items map[string]*trainingImportVideoJobState
|
|
}{
|
|
items: map[string]*trainingImportVideoJobState{},
|
|
}
|
|
|
|
const trainingImportVideoJobTTL = 2 * time.Hour
|
|
|
|
func trainingCleanImportVideoCount(count int) int {
|
|
if count <= 0 {
|
|
return trainingImportVideoDefaultFrameCount
|
|
}
|
|
|
|
if count > trainingImportVideoMaxFrameCount {
|
|
return trainingImportVideoMaxFrameCount
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
func trainingSupportedImportVideo(path string) bool {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".mp4", ".m4v", ".mov", ".mkv", ".webm":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func trainingGeneratedAssetIDCandidatesForVideo(videoPath string) []string {
|
|
videoPath = strings.TrimSpace(videoPath)
|
|
if videoPath == "" {
|
|
return nil
|
|
}
|
|
|
|
out := []string{}
|
|
seen := map[string]bool{}
|
|
|
|
add := func(id string) {
|
|
id = stripHotPrefix(strings.TrimSpace(id))
|
|
|
|
if id == "" ||
|
|
id == "." ||
|
|
id == ".." ||
|
|
strings.Contains(id, "/") ||
|
|
strings.Contains(id, "\\") {
|
|
return
|
|
}
|
|
|
|
if seen[id] {
|
|
return
|
|
}
|
|
|
|
seen[id] = true
|
|
out = append(out, id)
|
|
}
|
|
|
|
// Fall 1:
|
|
// Video liegt selbst unter /generated/<id>/...
|
|
//
|
|
// Beispiel:
|
|
// C:\app\generated\abc123\video.mp4
|
|
// => abc123
|
|
slashPath := filepath.ToSlash(filepath.Clean(videoPath))
|
|
parts := strings.Split(slashPath, "/")
|
|
|
|
for i := 0; i+1 < len(parts); i++ {
|
|
if strings.EqualFold(parts[i], "generated") {
|
|
add(parts[i+1])
|
|
}
|
|
}
|
|
|
|
// Fall 2:
|
|
// Video liegt z.B. in done/keep, aber generated/<id>/preview.jpg
|
|
// basiert auf dem Dateinamen ohne Extension.
|
|
//
|
|
// Beispiel:
|
|
// done/keep/model/abc123.mp4
|
|
// => generated/abc123/preview.jpg
|
|
base := filepath.Base(videoPath)
|
|
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
|
add(stem)
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingGeneratedPreviewPathForAssetID(assetID string) (string, bool) {
|
|
assetID = stripHotPrefix(strings.TrimSpace(assetID))
|
|
|
|
if assetID == "" ||
|
|
assetID == "." ||
|
|
assetID == ".." ||
|
|
strings.Contains(assetID, "/") ||
|
|
strings.Contains(assetID, "\\") {
|
|
return "", false
|
|
}
|
|
|
|
previewPath, err := resolvePathRelativeToApp(
|
|
filepath.Join("generated", assetID, "preview.jpg"),
|
|
)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
if !fileExistsNonEmpty(previewPath) {
|
|
return "", false
|
|
}
|
|
|
|
return previewPath, true
|
|
}
|
|
|
|
func trainingPreviewPathForVideo(videoPath string) (string, bool) {
|
|
for _, assetID := range trainingGeneratedAssetIDCandidatesForVideo(videoPath) {
|
|
if previewPath, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok {
|
|
return previewPath, true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func trainingPreviewURLForVideoPath(videoPath string) string {
|
|
videoPath = strings.TrimSpace(videoPath)
|
|
if videoPath == "" {
|
|
return ""
|
|
}
|
|
|
|
if !trainingSupportedImportVideo(videoPath) {
|
|
return ""
|
|
}
|
|
|
|
return "/api/training/video-preview?output=" + url.QueryEscape(videoPath)
|
|
}
|
|
|
|
func trainingPreviewAssetIDForVideo(videoPath string) string {
|
|
candidates := trainingGeneratedAssetIDCandidatesForVideo(videoPath)
|
|
|
|
for _, assetID := range candidates {
|
|
if _, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok {
|
|
return assetID
|
|
}
|
|
}
|
|
|
|
for _, assetID := range candidates {
|
|
if _, err := findFinishedFileByID(assetID); err == nil {
|
|
return assetID
|
|
}
|
|
}
|
|
|
|
if len(candidates) > 0 {
|
|
return candidates[0]
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func trainingVideoPreviewHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
outPath := strings.TrimSpace(r.URL.Query().Get("output"))
|
|
if outPath == "" {
|
|
trainingWriteError(w, http.StatusBadRequest, "output missing")
|
|
return
|
|
}
|
|
|
|
if !trainingSupportedImportVideo(outPath) {
|
|
trainingWriteError(w, http.StatusBadRequest, "unsupported video type")
|
|
return
|
|
}
|
|
|
|
st, err := os.Stat(outPath)
|
|
if err != nil || st == nil || st.IsDir() || st.Size() <= 0 {
|
|
trainingWriteError(w, http.StatusNotFound, "video not found")
|
|
return
|
|
}
|
|
|
|
// Fast path: Wenn /generated/<id>/preview.jpg schon existiert, direkt ausliefern.
|
|
if previewPath, ok := trainingPreviewPathForVideo(outPath); ok {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
servePreviewJPGFile(w, r, previewPath)
|
|
return
|
|
}
|
|
|
|
assetID := trainingPreviewAssetIDForVideo(outPath)
|
|
if assetID == "" {
|
|
trainingWriteError(w, http.StatusNotFound, "preview asset id not found")
|
|
return
|
|
}
|
|
|
|
// Wichtig:
|
|
// Nicht file=preview.jpg setzen.
|
|
// Ohne file=... darf recordPreviewWithBase die Preview bei Bedarf erzeugen.
|
|
r2 := r.Clone(r.Context())
|
|
u := *r.URL
|
|
q := u.Query()
|
|
|
|
q.Set("id", assetID)
|
|
q.Del("output")
|
|
q.Del("file")
|
|
q.Del("fallbackOnly")
|
|
|
|
u.RawQuery = q.Encode()
|
|
r2.URL = &u
|
|
|
|
recordPreviewWithBase(w, r2, "/api/training/video-preview")
|
|
}
|
|
|
|
func trainingFrameSecondsForVideo(duration float64, count int) []float64 {
|
|
count = trainingCleanImportVideoCount(count)
|
|
|
|
if duration <= 0 {
|
|
return []float64{0}
|
|
}
|
|
|
|
if duration <= 2 {
|
|
return []float64{0}
|
|
}
|
|
|
|
minSec := 0.5
|
|
maxSec := duration - 0.5
|
|
|
|
if maxSec <= minSec {
|
|
return []float64{0}
|
|
}
|
|
|
|
out := make([]float64, 0, count)
|
|
|
|
// Lokaler RNG, damit jeder Import neue Frames bekommt.
|
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
|
|
// Mindestabstand zwischen Frames, damit nicht mehrfach fast dieselbe Stelle kommt.
|
|
minDistance := 0.4
|
|
|
|
// Bei sehr kurzen Videos Abstand automatisch verkleinern.
|
|
availableRange := maxSec - minSec
|
|
if availableRange/float64(count) < minDistance {
|
|
minDistance = math.Max(0.1, availableRange/float64(count+1))
|
|
}
|
|
|
|
const maxAttempts = 4000
|
|
|
|
for attempts := 0; len(out) < count && attempts < maxAttempts; attempts++ {
|
|
sec := minSec + rng.Float64()*(maxSec-minSec)
|
|
|
|
// Auf 0.1s runden, damit IDs/Logs lesbar bleiben.
|
|
sec = math.Round(sec*10) / 10
|
|
|
|
tooClose := false
|
|
for _, existing := range out {
|
|
if math.Abs(sec-existing) < minDistance {
|
|
tooClose = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if tooClose {
|
|
continue
|
|
}
|
|
|
|
out = append(out, sec)
|
|
}
|
|
|
|
// Fallback, falls ein extrem kurzes Video nicht genug unterschiedliche Random-Punkte hergibt.
|
|
for len(out) < count {
|
|
sec := minSec + rng.Float64()*(maxSec-minSec)
|
|
sec = math.Round(sec*10) / 10
|
|
out = append(out, sec)
|
|
}
|
|
|
|
sort.Float64s(out)
|
|
|
|
if len(out) == 0 {
|
|
out = append(out, 0)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingRunImportVideoRequest(req TrainingImportVideoRequest) (TrainingImportVideoResponse, int, string) {
|
|
outPath := strings.TrimSpace(req.Output)
|
|
if outPath == "" {
|
|
msg := "output missing"
|
|
return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg
|
|
}
|
|
|
|
if !trainingSupportedImportVideo(outPath) {
|
|
msg := "unsupported video type"
|
|
return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg
|
|
}
|
|
|
|
fi, err := os.Stat(outPath)
|
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
|
if err == nil {
|
|
err = errors.New("video file missing or empty")
|
|
}
|
|
|
|
msg := "video not found: " + err.Error()
|
|
return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg
|
|
}
|
|
|
|
duration := trainingProbeDurationSeconds(outPath)
|
|
if duration <= 0 {
|
|
msg := "Videolaenge konnte nicht bestimmt werden"
|
|
return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
msg := err.Error()
|
|
return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg
|
|
}
|
|
|
|
if err := trainingEnsureDetectorDirs(root); err != nil {
|
|
msg := err.Error()
|
|
return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil {
|
|
msg := err.Error()
|
|
return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil {
|
|
msg := err.Error()
|
|
return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg
|
|
}
|
|
|
|
seconds := trainingFrameSecondsForVideo(duration, req.Count)
|
|
sourceFile := filepath.Base(outPath)
|
|
previewURL := ""
|
|
sourceFileWithFrame := func(index int) string {
|
|
return fmt.Sprintf("%s (%d / %d)", sourceFile, index+1, len(seconds))
|
|
}
|
|
|
|
requestID := strings.TrimSpace(req.AnalysisRequestID)
|
|
if requestID == "" {
|
|
requestID = trainingMakeSampleID(outPath, float64(time.Now().UnixNano()))
|
|
}
|
|
|
|
totalSteps := len(seconds) * 3
|
|
if totalSteps < 1 {
|
|
totalSteps = 1
|
|
}
|
|
|
|
startedAtMs := trainingPublishAnalysisStartedWithPreview(
|
|
requestID,
|
|
totalSteps,
|
|
sourceFile,
|
|
previewURL,
|
|
"Video wird ins Training uebernommen...",
|
|
)
|
|
|
|
var sourceSizeBytes int64
|
|
if st, err := os.Stat(outPath); err == nil && st != nil && !st.IsDir() {
|
|
sourceSizeBytes = st.Size()
|
|
}
|
|
|
|
samples := make([]TrainingSample, 0, len(seconds))
|
|
errs := []string{}
|
|
|
|
for i, second := range seconds {
|
|
stepBase := i * 3
|
|
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
stepBase+1,
|
|
totalSteps,
|
|
sourceFileWithFrame(i),
|
|
previewURL,
|
|
fmt.Sprintf("Frame %d/%d wird extrahiert...", i+1, len(seconds)),
|
|
)
|
|
|
|
id := trainingMakeSampleID(outPath, second)
|
|
framePath := filepath.Join(root, "frames", id+".jpg")
|
|
|
|
if err := trainingExtractFrame(outPath, framePath, second); err != nil {
|
|
errs = append(errs, fmt.Sprintf("Frame bei %.1fs: %v", second, err))
|
|
continue
|
|
}
|
|
|
|
previewURL = "/api/training/frame?id=" + url.QueryEscape(id)
|
|
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
stepBase+2,
|
|
totalSteps,
|
|
sourceFileWithFrame(i),
|
|
previewURL,
|
|
fmt.Sprintf("Frame %d/%d wird analysiert...", i+1, len(seconds)),
|
|
)
|
|
|
|
prediction := trainingPredictFrame(framePath)
|
|
|
|
sample := &TrainingSample{
|
|
SampleID: id,
|
|
FrameURL: "/api/training/frame?id=" + id,
|
|
SourceFile: sourceFileWithFrame(i),
|
|
SourcePath: outPath,
|
|
SourceSizeBytes: sourceSizeBytes,
|
|
Second: second,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Prediction: prediction,
|
|
}
|
|
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
stepBase+3,
|
|
totalSteps,
|
|
sourceFileWithFrame(i),
|
|
previewURL,
|
|
fmt.Sprintf("Frame %d/%d wird gespeichert...", i+1, len(seconds)),
|
|
)
|
|
|
|
if err := trainingWriteSample(root, sample); err != nil {
|
|
_ = os.Remove(framePath)
|
|
errs = append(errs, fmt.Sprintf("Frame bei %.1fs speichern: %v", second, err))
|
|
continue
|
|
}
|
|
|
|
samples = append(samples, *sample)
|
|
}
|
|
|
|
if len(samples) == 0 {
|
|
msg := "keine Trainingsframes erzeugt"
|
|
if len(errs) > 0 {
|
|
msg += ": " + strings.Join(errs, "; ")
|
|
}
|
|
|
|
err := errors.New(msg)
|
|
trainingPublishAnalysisError(
|
|
requestID,
|
|
startedAtMs,
|
|
sourceFile,
|
|
"Video konnte nicht ins Training uebernommen werden.",
|
|
err,
|
|
)
|
|
|
|
return TrainingImportVideoResponse{OK: false, RequestID: requestID, Error: msg}, http.StatusInternalServerError, msg
|
|
}
|
|
|
|
trainingPublishAnalysisFinished(
|
|
requestID,
|
|
startedAtMs,
|
|
totalSteps,
|
|
sourceFile,
|
|
fmt.Sprintf("%d Frames ins Training uebernommen.", len(samples)),
|
|
)
|
|
|
|
return TrainingImportVideoResponse{
|
|
OK: true,
|
|
RequestID: requestID,
|
|
Count: len(samples),
|
|
Sample: &samples[0],
|
|
Samples: samples,
|
|
Errors: errs,
|
|
}, http.StatusOK, ""
|
|
}
|
|
|
|
func trainingPruneImportVideoJobsLocked(now time.Time) {
|
|
for requestID, job := range trainingImportVideoJobs.items {
|
|
if job == nil || job.running {
|
|
continue
|
|
}
|
|
if !job.finishedAt.IsZero() && now.Sub(job.finishedAt) > trainingImportVideoJobTTL {
|
|
delete(trainingImportVideoJobs.items, requestID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func trainingLatestRunningImportVideoJobRequestID() string {
|
|
now := time.Now().UTC()
|
|
|
|
trainingImportVideoJobs.Lock()
|
|
defer trainingImportVideoJobs.Unlock()
|
|
|
|
trainingPruneImportVideoJobsLocked(now)
|
|
|
|
requestID := ""
|
|
var latestStarted time.Time
|
|
|
|
for id, job := range trainingImportVideoJobs.items {
|
|
if job == nil || !job.running {
|
|
continue
|
|
}
|
|
|
|
if requestID == "" || job.startedAt.After(latestStarted) {
|
|
requestID = id
|
|
latestStarted = job.startedAt
|
|
}
|
|
}
|
|
|
|
return requestID
|
|
}
|
|
|
|
func trainingStartImportVideoJob(req TrainingImportVideoRequest) {
|
|
requestID := strings.TrimSpace(req.AnalysisRequestID)
|
|
if requestID == "" {
|
|
requestID = trainingMakeSampleID(req.Output, float64(time.Now().UnixNano()))
|
|
req.AnalysisRequestID = requestID
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
|
|
trainingImportVideoJobs.Lock()
|
|
trainingPruneImportVideoJobsLocked(now)
|
|
if _, exists := trainingImportVideoJobs.items[requestID]; exists {
|
|
trainingImportVideoJobs.Unlock()
|
|
return
|
|
}
|
|
|
|
trainingImportVideoJobs.items[requestID] = &trainingImportVideoJobState{
|
|
requestID: requestID,
|
|
startedAt: now,
|
|
running: true,
|
|
statusCode: http.StatusAccepted,
|
|
}
|
|
trainingImportVideoJobs.Unlock()
|
|
|
|
go func() {
|
|
resp, statusCode, errorText := trainingRunImportVideoRequest(req)
|
|
resp.RequestID = requestID
|
|
|
|
trainingImportVideoJobs.Lock()
|
|
if job := trainingImportVideoJobs.items[requestID]; job != nil {
|
|
job.running = false
|
|
job.finishedAt = time.Now().UTC()
|
|
job.statusCode = statusCode
|
|
job.response = &resp
|
|
job.errorText = strings.TrimSpace(errorText)
|
|
}
|
|
trainingImportVideoJobs.Unlock()
|
|
}()
|
|
}
|
|
|
|
func trainingImportVideoJobResponse(requestID string) (TrainingImportVideoResponse, int) {
|
|
requestID = strings.TrimSpace(requestID)
|
|
if requestID == "" {
|
|
requestID = trainingLatestRunningImportVideoJobRequestID()
|
|
}
|
|
|
|
if requestID == "" {
|
|
return TrainingImportVideoResponse{OK: false, Error: "Kein laufender Import-Job gefunden."}, http.StatusNotFound
|
|
}
|
|
|
|
trainingImportVideoJobs.Lock()
|
|
job := trainingImportVideoJobs.items[requestID]
|
|
trainingImportVideoJobs.Unlock()
|
|
|
|
if job == nil {
|
|
return TrainingImportVideoResponse{OK: false, RequestID: requestID, Error: "Import-Job nicht gefunden."}, http.StatusNotFound
|
|
}
|
|
|
|
if job.running {
|
|
return TrainingImportVideoResponse{
|
|
OK: true,
|
|
Accepted: true,
|
|
Running: true,
|
|
RequestID: requestID,
|
|
Analysis: trainingAnalysisStatusPayload(requestID),
|
|
}, http.StatusAccepted
|
|
}
|
|
|
|
if job.response != nil {
|
|
resp := *job.response
|
|
resp.Running = false
|
|
resp.RequestID = requestID
|
|
resp.Analysis = trainingAnalysisStatusPayload(requestID)
|
|
if resp.Error == "" {
|
|
resp.Error = job.errorText
|
|
}
|
|
statusCode := job.statusCode
|
|
if statusCode < 100 {
|
|
statusCode = http.StatusOK
|
|
}
|
|
return resp, statusCode
|
|
}
|
|
|
|
return TrainingImportVideoResponse{OK: false, RequestID: requestID, Error: job.errorText}, http.StatusInternalServerError
|
|
}
|
|
|
|
func trainingImportVideoStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
requestID := strings.TrimSpace(r.URL.Query().Get("requestId"))
|
|
if requestID == "" {
|
|
requestID = strings.TrimSpace(r.URL.Query().Get("id"))
|
|
}
|
|
|
|
resp, statusCode := trainingImportVideoJobResponse(requestID)
|
|
trainingWriteJSON(w, statusCode, resp)
|
|
}
|
|
|
|
func trainingAnalysisStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
requestID := strings.TrimSpace(r.URL.Query().Get("requestId"))
|
|
if requestID == "" {
|
|
requestID = strings.TrimSpace(r.URL.Query().Get("id"))
|
|
}
|
|
|
|
payload := trainingAnalysisStatusPayload(requestID)
|
|
if payload == nil {
|
|
trainingWriteJSON(w, http.StatusNotFound, map[string]any{
|
|
"ok": false,
|
|
"error": "Analyse-Status nicht gefunden.",
|
|
})
|
|
return
|
|
}
|
|
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"analysis": payload,
|
|
})
|
|
}
|
|
|
|
func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
var req TrainingImportVideoRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
trainingWriteError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
analysisRequestID := strings.TrimSpace(req.AnalysisRequestID)
|
|
if analysisRequestID == "" {
|
|
analysisRequestID = trainingMakeSampleID(req.Output, float64(time.Now().UnixNano()))
|
|
req.AnalysisRequestID = analysisRequestID
|
|
}
|
|
|
|
trainingStartImportVideoJob(req)
|
|
resp, statusCode := trainingImportVideoJobResponse(analysisRequestID)
|
|
trainingWriteJSON(w, statusCode, resp)
|
|
return
|
|
|
|
outPath := strings.TrimSpace(req.Output)
|
|
if outPath == "" {
|
|
trainingWriteError(w, http.StatusBadRequest, "output missing")
|
|
return
|
|
}
|
|
|
|
if !trainingSupportedImportVideo(outPath) {
|
|
trainingWriteError(w, http.StatusBadRequest, "unsupported video type")
|
|
return
|
|
}
|
|
|
|
fi, err := os.Stat(outPath)
|
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
|
if err == nil {
|
|
err = errors.New("video file missing or empty")
|
|
}
|
|
|
|
trainingWriteError(w, http.StatusBadRequest, "video not found: "+err.Error())
|
|
return
|
|
}
|
|
|
|
duration := trainingProbeDurationSeconds(outPath)
|
|
if duration <= 0 {
|
|
trainingWriteError(w, http.StatusBadRequest, "Videolänge konnte nicht bestimmt werden")
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := trainingEnsureDetectorDirs(root); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
seconds := trainingFrameSecondsForVideo(duration, req.Count)
|
|
sourceFile := filepath.Base(outPath)
|
|
previewURL := ""
|
|
sourceFileWithFrame := func(index int) string {
|
|
return fmt.Sprintf("%s (%d / %d)", sourceFile, index+1, len(seconds))
|
|
}
|
|
|
|
requestID := strings.TrimSpace(req.AnalysisRequestID)
|
|
if requestID == "" {
|
|
requestID = trainingMakeSampleID(outPath, float64(time.Now().UnixNano()))
|
|
}
|
|
|
|
totalSteps := len(seconds) * 3
|
|
if totalSteps < 1 {
|
|
totalSteps = 1
|
|
}
|
|
|
|
startedAtMs := trainingPublishAnalysisStartedWithPreview(
|
|
requestID,
|
|
totalSteps,
|
|
sourceFile,
|
|
previewURL,
|
|
"Video wird ins Training übernommen…",
|
|
)
|
|
|
|
var sourceSizeBytes int64
|
|
if st, err := os.Stat(outPath); err == nil && st != nil && !st.IsDir() {
|
|
sourceSizeBytes = st.Size()
|
|
}
|
|
|
|
samples := make([]TrainingSample, 0, len(seconds))
|
|
errs := []string{}
|
|
|
|
for i, second := range seconds {
|
|
stepBase := i * 3
|
|
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
stepBase+1,
|
|
totalSteps,
|
|
sourceFileWithFrame(i),
|
|
previewURL,
|
|
fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)),
|
|
)
|
|
|
|
id := trainingMakeSampleID(outPath, second)
|
|
framePath := filepath.Join(root, "frames", id+".jpg")
|
|
|
|
if err := trainingExtractFrame(outPath, framePath, second); err != nil {
|
|
errs = append(errs, fmt.Sprintf("Frame bei %.1fs: %v", second, err))
|
|
continue
|
|
}
|
|
|
|
// Nach jeder erfolgreichen Extraktion das aktuell verarbeitete Frame zeigen.
|
|
previewURL = "/api/training/frame?id=" + url.QueryEscape(id)
|
|
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
stepBase+2,
|
|
totalSteps,
|
|
sourceFileWithFrame(i),
|
|
previewURL,
|
|
fmt.Sprintf("Frame %d/%d wird analysiert…", i+1, len(seconds)),
|
|
)
|
|
|
|
prediction := trainingPredictFrame(framePath)
|
|
|
|
sample := &TrainingSample{
|
|
SampleID: id,
|
|
FrameURL: "/api/training/frame?id=" + id,
|
|
SourceFile: sourceFileWithFrame(i),
|
|
SourcePath: outPath,
|
|
SourceSizeBytes: sourceSizeBytes,
|
|
Second: second,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Prediction: prediction,
|
|
}
|
|
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
stepBase+3,
|
|
totalSteps,
|
|
sourceFileWithFrame(i),
|
|
previewURL,
|
|
fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)),
|
|
)
|
|
|
|
if err := trainingWriteSample(root, sample); err != nil {
|
|
_ = os.Remove(framePath)
|
|
errs = append(errs, fmt.Sprintf("Frame bei %.1fs speichern: %v", second, err))
|
|
continue
|
|
}
|
|
|
|
samples = append(samples, *sample)
|
|
}
|
|
|
|
if len(samples) == 0 {
|
|
msg := "keine Trainingsframes erzeugt"
|
|
if len(errs) > 0 {
|
|
msg += ": " + strings.Join(errs, "; ")
|
|
}
|
|
|
|
err := errors.New(msg)
|
|
|
|
trainingPublishAnalysisError(
|
|
requestID,
|
|
startedAtMs,
|
|
sourceFile,
|
|
"Video konnte nicht ins Training übernommen werden.",
|
|
err,
|
|
)
|
|
|
|
trainingWriteError(w, http.StatusInternalServerError, msg)
|
|
return
|
|
}
|
|
|
|
trainingPublishAnalysisFinished(
|
|
requestID,
|
|
startedAtMs,
|
|
totalSteps,
|
|
sourceFile,
|
|
fmt.Sprintf("%d Frames ins Training übernommen.", len(samples)),
|
|
)
|
|
|
|
trainingWriteJSON(w, http.StatusOK, TrainingImportVideoResponse{
|
|
OK: true,
|
|
Count: len(samples),
|
|
Sample: &samples[0],
|
|
Samples: samples,
|
|
Errors: errs,
|
|
})
|
|
}
|
|
|
|
func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
req := trainingNextRequestFromHTTP(r)
|
|
|
|
if strings.TrimSpace(req.AnalysisRequestID) == "" {
|
|
req.AnalysisRequestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano()))
|
|
}
|
|
|
|
async := r.URL.Query().Get("async") == "1" ||
|
|
strings.EqualFold(r.URL.Query().Get("async"), "true")
|
|
|
|
if async {
|
|
trainingStartNextJob(req)
|
|
resp, statusCode := trainingNextJobResponse(req.AnalysisRequestID)
|
|
trainingWriteJSON(w, statusCode, resp)
|
|
return
|
|
}
|
|
|
|
resp, statusCode, errorText := trainingRunNextRequest(req)
|
|
if statusCode >= 400 || !resp.OK || resp.Sample == nil {
|
|
if errorText == "" {
|
|
errorText = resp.Error
|
|
}
|
|
if errorText == "" {
|
|
errorText = "Trainingsbild konnte nicht geladen werden."
|
|
}
|
|
trainingWriteError(w, statusCode, errorText)
|
|
return
|
|
}
|
|
|
|
trainingWriteJSON(w, http.StatusOK, resp.Sample)
|
|
}
|
|
|
|
func trainingNextRequestFromHTTP(r *http.Request) TrainingNextRequest {
|
|
forceNew := r.URL.Query().Get("force") == "1" ||
|
|
strings.EqualFold(r.URL.Query().Get("force"), "true")
|
|
|
|
analysisRequestID := strings.TrimSpace(r.URL.Query().Get("analysisRequestId"))
|
|
|
|
excludeIDs := trainingExcludedSampleIDs(r)
|
|
|
|
preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") ||
|
|
strings.EqualFold(r.URL.Query().Get("sampleMode"), "uncertain")
|
|
|
|
refreshPrediction := r.URL.Query().Get("refresh") == "1" ||
|
|
strings.EqualFold(r.URL.Query().Get("refresh"), "true")
|
|
|
|
return TrainingNextRequest{
|
|
ForceNew: forceNew,
|
|
RefreshPrediction: refreshPrediction,
|
|
PreferUncertain: preferUncertain,
|
|
AnalysisRequestID: analysisRequestID,
|
|
ExcludeIDs: excludeIDs,
|
|
}
|
|
}
|
|
|
|
func trainingRunNextRequest(req TrainingNextRequest) (TrainingNextResponse, int, string) {
|
|
forceNew := req.ForceNew
|
|
refreshPrediction := req.RefreshPrediction
|
|
preferUncertain := req.PreferUncertain
|
|
analysisRequestID := strings.TrimSpace(req.AnalysisRequestID)
|
|
if analysisRequestID == "" {
|
|
analysisRequestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano()))
|
|
}
|
|
|
|
excludeIDs := req.ExcludeIDs
|
|
if excludeIDs == nil {
|
|
excludeIDs = map[string]bool{}
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
return TrainingNextResponse{
|
|
OK: false,
|
|
RequestID: analysisRequestID,
|
|
Error: err.Error(),
|
|
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
|
}, http.StatusInternalServerError, err.Error()
|
|
}
|
|
|
|
if !forceNew && !preferUncertain {
|
|
var startedAtMs int64
|
|
|
|
if refreshPrediction {
|
|
startedAtMs = time.Now().UnixMilli()
|
|
trainingPublishAnalysisStep(
|
|
analysisRequestID,
|
|
startedAtMs,
|
|
1,
|
|
2,
|
|
"",
|
|
"Aktuelles Bild wird neu analysiert…",
|
|
)
|
|
}
|
|
|
|
if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs, analysisRequestID, excludeIDs); err != nil {
|
|
if refreshPrediction {
|
|
trainingPublishAnalysisError(
|
|
analysisRequestID,
|
|
startedAtMs,
|
|
"",
|
|
"Aktuelles Bild konnte nicht neu analysiert werden.",
|
|
err,
|
|
)
|
|
}
|
|
|
|
return TrainingNextResponse{
|
|
OK: false,
|
|
RequestID: analysisRequestID,
|
|
Error: err.Error(),
|
|
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
|
}, http.StatusInternalServerError, err.Error()
|
|
} else if ok {
|
|
if refreshPrediction {
|
|
trainingPublishAnalysisFinished(
|
|
analysisRequestID,
|
|
startedAtMs,
|
|
2,
|
|
sample.SourceFile,
|
|
"Analyse abgeschlossen.",
|
|
)
|
|
}
|
|
|
|
return TrainingNextResponse{
|
|
OK: true,
|
|
RequestID: analysisRequestID,
|
|
Sample: sample,
|
|
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
|
}, http.StatusOK, ""
|
|
}
|
|
}
|
|
|
|
totalSteps := 4
|
|
if preferUncertain {
|
|
totalSteps = trainingUncertainCandidateCount*4 + 1
|
|
}
|
|
|
|
startedAtMs := trainingPublishAnalysisStarted(
|
|
analysisRequestID,
|
|
totalSteps,
|
|
"",
|
|
func() string {
|
|
if preferUncertain {
|
|
return "Unsichere Prediction wird gesucht…"
|
|
}
|
|
|
|
return "Neues Trainingsbild wird vorbereitet…"
|
|
}(),
|
|
)
|
|
|
|
var sample *TrainingSample
|
|
|
|
if preferUncertain {
|
|
sample, err = trainingCreateUncertainNextSampleWithProgress(startedAtMs, analysisRequestID)
|
|
} else {
|
|
sample, err = trainingCreateNextSampleWithProgress(startedAtMs, analysisRequestID)
|
|
}
|
|
|
|
if err != nil {
|
|
trainingPublishAnalysisError(
|
|
analysisRequestID,
|
|
startedAtMs,
|
|
"",
|
|
"Trainingsbild konnte nicht erstellt werden.",
|
|
err,
|
|
)
|
|
return TrainingNextResponse{
|
|
OK: false,
|
|
RequestID: analysisRequestID,
|
|
Error: err.Error(),
|
|
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
|
}, http.StatusInternalServerError, err.Error()
|
|
}
|
|
|
|
trainingPublishAnalysisFinished(
|
|
analysisRequestID,
|
|
startedAtMs,
|
|
totalSteps,
|
|
sample.SourceFile,
|
|
"Analyse abgeschlossen.",
|
|
)
|
|
|
|
return TrainingNextResponse{
|
|
OK: true,
|
|
RequestID: analysisRequestID,
|
|
Sample: sample,
|
|
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
|
}, http.StatusOK, ""
|
|
}
|
|
|
|
func trainingPruneNextJobsLocked(now time.Time) {
|
|
for requestID, job := range trainingNextJobs.items {
|
|
if job == nil || job.running {
|
|
continue
|
|
}
|
|
if !job.finishedAt.IsZero() && now.Sub(job.finishedAt) > trainingNextJobTTL {
|
|
delete(trainingNextJobs.items, requestID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func trainingLatestRunningNextJobRequestID() string {
|
|
now := time.Now().UTC()
|
|
|
|
trainingNextJobs.Lock()
|
|
defer trainingNextJobs.Unlock()
|
|
|
|
trainingPruneNextJobsLocked(now)
|
|
|
|
requestID := ""
|
|
var latestStarted time.Time
|
|
|
|
for id, job := range trainingNextJobs.items {
|
|
if job == nil || !job.running {
|
|
continue
|
|
}
|
|
|
|
if requestID == "" || job.startedAt.After(latestStarted) {
|
|
requestID = id
|
|
latestStarted = job.startedAt
|
|
}
|
|
}
|
|
|
|
return requestID
|
|
}
|
|
|
|
func trainingStartNextJob(req TrainingNextRequest) {
|
|
requestID := strings.TrimSpace(req.AnalysisRequestID)
|
|
if requestID == "" {
|
|
requestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano()))
|
|
req.AnalysisRequestID = requestID
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
|
|
trainingNextJobs.Lock()
|
|
trainingPruneNextJobsLocked(now)
|
|
if _, exists := trainingNextJobs.items[requestID]; exists {
|
|
trainingNextJobs.Unlock()
|
|
return
|
|
}
|
|
|
|
trainingNextJobs.items[requestID] = &trainingNextJobState{
|
|
requestID: requestID,
|
|
startedAt: now,
|
|
running: true,
|
|
statusCode: http.StatusAccepted,
|
|
}
|
|
trainingNextJobs.Unlock()
|
|
|
|
go func() {
|
|
resp, statusCode, errorText := trainingRunNextRequest(req)
|
|
resp.RequestID = requestID
|
|
resp.Analysis = trainingAnalysisStatusPayload(requestID)
|
|
|
|
trainingNextJobs.Lock()
|
|
if job := trainingNextJobs.items[requestID]; job != nil {
|
|
job.running = false
|
|
job.finishedAt = time.Now().UTC()
|
|
job.statusCode = statusCode
|
|
job.response = &resp
|
|
job.errorText = strings.TrimSpace(errorText)
|
|
}
|
|
trainingNextJobs.Unlock()
|
|
}()
|
|
}
|
|
|
|
func trainingNextJobResponse(requestID string) (TrainingNextResponse, int) {
|
|
requestID = strings.TrimSpace(requestID)
|
|
if requestID == "" {
|
|
requestID = trainingLatestRunningNextJobRequestID()
|
|
}
|
|
|
|
if requestID == "" {
|
|
return TrainingNextResponse{OK: false, Error: "Kein laufender Analyse-Job gefunden."}, http.StatusNotFound
|
|
}
|
|
|
|
trainingNextJobs.Lock()
|
|
job := trainingNextJobs.items[requestID]
|
|
trainingNextJobs.Unlock()
|
|
|
|
if job == nil {
|
|
return TrainingNextResponse{
|
|
OK: false,
|
|
RequestID: requestID,
|
|
Error: "Analyse-Job nicht gefunden.",
|
|
Analysis: trainingAnalysisStatusPayload(requestID),
|
|
}, http.StatusNotFound
|
|
}
|
|
|
|
if job.running {
|
|
return TrainingNextResponse{
|
|
OK: true,
|
|
Accepted: true,
|
|
Running: true,
|
|
RequestID: requestID,
|
|
Analysis: trainingAnalysisStatusPayload(requestID),
|
|
}, http.StatusAccepted
|
|
}
|
|
|
|
if job.response != nil {
|
|
resp := *job.response
|
|
resp.Running = false
|
|
resp.RequestID = requestID
|
|
resp.Analysis = trainingAnalysisStatusPayload(requestID)
|
|
if resp.Error == "" {
|
|
resp.Error = job.errorText
|
|
}
|
|
statusCode := job.statusCode
|
|
if statusCode < 100 {
|
|
statusCode = http.StatusOK
|
|
}
|
|
return resp, statusCode
|
|
}
|
|
|
|
return TrainingNextResponse{
|
|
OK: false,
|
|
RequestID: requestID,
|
|
Error: job.errorText,
|
|
Analysis: trainingAnalysisStatusPayload(requestID),
|
|
}, http.StatusInternalServerError
|
|
}
|
|
|
|
func trainingNextStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
requestID := strings.TrimSpace(r.URL.Query().Get("requestId"))
|
|
if requestID == "" {
|
|
requestID = strings.TrimSpace(r.URL.Query().Get("id"))
|
|
}
|
|
|
|
resp, statusCode := trainingNextJobResponse(requestID)
|
|
trainingWriteJSON(w, statusCode, resp)
|
|
}
|
|
|
|
func trainingLatestOpenSample(
|
|
root string,
|
|
refreshPrediction bool,
|
|
startedAtMs int64,
|
|
requestID string,
|
|
excludeIDs map[string]bool,
|
|
) (*TrainingSample, bool, error) {
|
|
answered, err := trainingAnsweredSampleIDs(root)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
samplesDir := filepath.Join(root, "samples")
|
|
|
|
entries, err := os.ReadDir(samplesDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
|
|
type sampleFile struct {
|
|
id string
|
|
path string
|
|
modTime time.Time
|
|
}
|
|
|
|
files := []sampleFile{}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
name := entry.Name()
|
|
if strings.ToLower(filepath.Ext(name)) != ".json" {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSuffix(name, filepath.Ext(name))
|
|
if id == "" || answered[id] || excludeIDs[id] {
|
|
continue
|
|
}
|
|
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
files = append(files, sampleFile{
|
|
id: id,
|
|
path: filepath.Join(samplesDir, name),
|
|
modTime: info.ModTime(),
|
|
})
|
|
}
|
|
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return files[i].modTime.After(files[j].modTime)
|
|
})
|
|
|
|
for _, file := range files {
|
|
sample, err := trainingReadSample(root, file.id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
framePath := filepath.Join(root, "frames", sample.SampleID+".jpg")
|
|
if !fileExistsNonEmpty(framePath) {
|
|
continue
|
|
}
|
|
|
|
if refreshPrediction {
|
|
sourceFile := strings.TrimSpace(sample.SourceFile)
|
|
if sourceFile == "" {
|
|
sourceFile = filepath.Base(sample.SourcePath)
|
|
}
|
|
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
1,
|
|
2,
|
|
sourceFile,
|
|
sample.FrameURL,
|
|
"Aktuelles Bild wird analysiert…",
|
|
)
|
|
|
|
sample.Prediction = trainingPredictFrame(framePath)
|
|
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
2,
|
|
2,
|
|
sourceFile,
|
|
sample.FrameURL,
|
|
"Analyse-Ergebnis wird gespeichert…",
|
|
)
|
|
|
|
if err := trainingWriteSample(root, sample); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
|
|
return sample, true, nil
|
|
}
|
|
|
|
return nil, false, nil
|
|
}
|
|
|
|
func trainingExcludedSampleIDs(r *http.Request) map[string]bool {
|
|
out := map[string]bool{}
|
|
|
|
for _, raw := range r.URL.Query()["exclude"] {
|
|
for _, part := range strings.Split(raw, ",") {
|
|
id := strings.TrimSpace(part)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.Contains(id, "/") || strings.Contains(id, "\\") {
|
|
continue
|
|
}
|
|
|
|
out[id] = true
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingAnsweredSampleIDs(root string) (map[string]bool, error) {
|
|
out := map[string]bool{}
|
|
|
|
path := filepath.Join(root, "feedback.jsonl")
|
|
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return out, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var row TrainingAnnotation
|
|
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSpace(row.SampleID)
|
|
if id != "" {
|
|
out[id] = true
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func trainingFrameHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") {
|
|
trainingWriteError(w, http.StatusBadRequest, "invalid frame id")
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
path := filepath.Join(root, "frames", id+".jpg")
|
|
|
|
if _, err := os.Stat(path); err != nil {
|
|
trainingWriteError(w, http.StatusNotFound, "frame not found")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.ServeFile(w, r, path)
|
|
}
|
|
|
|
func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeedbackRequest) []TrainingBox {
|
|
if req.Negative {
|
|
return []TrainingBox{}
|
|
}
|
|
|
|
boxes := []TrainingBox{}
|
|
|
|
if req.Correction != nil {
|
|
boxes = append(boxes, req.Correction.Boxes...)
|
|
} else if req.Accepted {
|
|
boxes = append(boxes, sample.Prediction.Boxes...)
|
|
}
|
|
|
|
return boxes
|
|
}
|
|
|
|
func trainingNegativeCorrection() *TrainingCorrection {
|
|
return &TrainingCorrection{
|
|
SexPosition: trainingNoSexPositionLabel,
|
|
PeoplePresent: []string{},
|
|
BodyPartsPresent: []string{},
|
|
ObjectsPresent: []string{},
|
|
ClothingPresent: []string{},
|
|
Boxes: []TrainingBox{},
|
|
PosePersons: []TrainingPosePerson{},
|
|
}
|
|
}
|
|
|
|
func trainingNormalizeCorrectionForStorage(correction *TrainingCorrection) *TrainingCorrection {
|
|
if correction == nil {
|
|
return nil
|
|
}
|
|
|
|
normalized := *correction
|
|
normalized.SexPosition = normalizeSexPositionLabel(normalized.SexPosition)
|
|
if isNoSexPositionLabel(normalized.SexPosition) {
|
|
normalized.SexPosition = trainingNoSexPositionLabel
|
|
normalized.PosePersons = []TrainingPosePerson{}
|
|
}
|
|
|
|
return &normalized
|
|
}
|
|
|
|
func trainingAnnotationEffectiveSexPosition(annotation TrainingAnnotation) string {
|
|
if annotation.Negative {
|
|
return trainingNoSexPositionLabel
|
|
}
|
|
|
|
if annotation.Correction != nil {
|
|
return normalizeSexPositionLabel(annotation.Correction.SexPosition)
|
|
}
|
|
|
|
return normalizeSexPositionLabel(annotation.Prediction.SexPosition)
|
|
}
|
|
|
|
func trainingStripPosePersonsForNoSexPosition(annotation TrainingAnnotation) TrainingAnnotation {
|
|
if !isNoSexPositionLabel(trainingAnnotationEffectiveSexPosition(annotation)) {
|
|
return annotation
|
|
}
|
|
|
|
annotation.Prediction.Persons = nil
|
|
if annotation.Correction != nil {
|
|
correction := trainingNormalizeCorrectionForStorage(annotation.Correction)
|
|
correction.PosePersons = []TrainingPosePerson{}
|
|
annotation.Correction = correction
|
|
}
|
|
|
|
return annotation
|
|
}
|
|
|
|
func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
var req TrainingFeedbackRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
trainingWriteError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
req.SampleID = strings.TrimSpace(req.SampleID)
|
|
if req.SampleID == "" {
|
|
trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
|
|
return
|
|
}
|
|
if req.Negative {
|
|
req.Accepted = false
|
|
req.Correction = trainingNegativeCorrection()
|
|
}
|
|
req.Correction = trainingNormalizeCorrectionForStorage(req.Correction)
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
sample, err := trainingReadSample(root, req.SampleID)
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusNotFound, "sample not found")
|
|
return
|
|
}
|
|
|
|
annotation := TrainingAnnotation{
|
|
SampleID: sample.SampleID,
|
|
FrameURL: sample.FrameURL,
|
|
SourceFile: sample.SourceFile,
|
|
SourcePath: sample.SourcePath,
|
|
SourceSizeBytes: sample.SourceSizeBytes,
|
|
Second: sample.Second,
|
|
CreatedAt: sample.CreatedAt,
|
|
AnsweredAt: time.Now().UTC().Format(time.RFC3339),
|
|
Prediction: sample.Prediction,
|
|
Accepted: req.Accepted,
|
|
Negative: req.Negative,
|
|
Correction: req.Correction,
|
|
Notes: strings.TrimSpace(req.Notes),
|
|
}
|
|
annotation = trainingStripPosePersonsForNoSexPosition(annotation)
|
|
|
|
if !annotation.Accepted && annotation.Correction == nil {
|
|
trainingWriteError(w, http.StatusBadRequest, "correction missing")
|
|
return
|
|
}
|
|
|
|
if err := trainingAppendAnnotation(root, annotation); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
detectorBoxes := trainingDetectorBoxesForAnnotation(sample, req)
|
|
|
|
if req.Negative || len(detectorBoxes) > 0 {
|
|
if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil {
|
|
appLogln("⚠️ detector sample write failed:", err)
|
|
}
|
|
}
|
|
|
|
trainingDeletePoseSample(root, req.SampleID)
|
|
if sexPosition := trainingSexPositionForFeedback(sample, req); !isNoSexPositionLabel(sexPosition) {
|
|
if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes, req.Correction); err != nil {
|
|
appLogln("pose sample write failed:", err)
|
|
}
|
|
}
|
|
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
})
|
|
}
|
|
|
|
func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut && r.Method != http.MethodPost {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
var req TrainingFeedbackUpdateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
trainingWriteError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
req.SampleID = strings.TrimSpace(req.SampleID)
|
|
req.AnsweredAt = strings.TrimSpace(req.AnsweredAt)
|
|
if req.Negative {
|
|
req.Accepted = false
|
|
req.Correction = trainingNegativeCorrection()
|
|
}
|
|
req.Correction = trainingNormalizeCorrectionForStorage(req.Correction)
|
|
|
|
if req.SampleID == "" {
|
|
trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
|
|
return
|
|
}
|
|
|
|
if req.AnsweredAt == "" {
|
|
trainingWriteError(w, http.StatusBadRequest, "answeredAt missing")
|
|
return
|
|
}
|
|
|
|
if strings.Contains(req.SampleID, "/") || strings.Contains(req.SampleID, "\\") {
|
|
trainingWriteError(w, http.StatusBadRequest, "invalid sampleId")
|
|
return
|
|
}
|
|
|
|
if !req.Accepted && req.Correction == nil {
|
|
trainingWriteError(w, http.StatusBadRequest, "correction missing")
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
items, err := trainingReadAnnotations(root)
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
matchIndex := -1
|
|
|
|
for i, item := range items {
|
|
if strings.TrimSpace(item.SampleID) == req.SampleID &&
|
|
strings.TrimSpace(item.AnsweredAt) == req.AnsweredAt {
|
|
matchIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if matchIndex < 0 {
|
|
trainingWriteError(w, http.StatusNotFound, "feedback not found")
|
|
return
|
|
}
|
|
|
|
old := items[matchIndex]
|
|
|
|
updated := old
|
|
updated.Accepted = req.Accepted
|
|
updated.Negative = req.Negative
|
|
updated.Notes = strings.TrimSpace(req.Notes)
|
|
|
|
if req.Accepted {
|
|
updated.Correction = nil
|
|
} else {
|
|
updated.Correction = req.Correction
|
|
}
|
|
updated = trainingStripPosePersonsForNoSexPosition(updated)
|
|
|
|
items[matchIndex] = updated
|
|
|
|
if err := trainingWriteAnnotations(root, items); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
sample, sampleErr := trainingReadSample(root, req.SampleID)
|
|
if sampleErr != nil {
|
|
sample = &TrainingSample{
|
|
SampleID: old.SampleID,
|
|
FrameURL: old.FrameURL,
|
|
SourceFile: old.SourceFile,
|
|
SourcePath: old.SourcePath,
|
|
SourceSizeBytes: old.SourceSizeBytes,
|
|
Second: old.Second,
|
|
CreatedAt: old.CreatedAt,
|
|
Prediction: old.Prediction,
|
|
}
|
|
}
|
|
|
|
trainingDeleteDetectorSample(root, req.SampleID)
|
|
|
|
detectorBoxes := trainingDetectorBoxesForAnnotation(sample, TrainingFeedbackRequest{
|
|
SampleID: req.SampleID,
|
|
Accepted: req.Accepted,
|
|
Negative: req.Negative,
|
|
Correction: req.Correction,
|
|
Notes: req.Notes,
|
|
})
|
|
|
|
if req.Negative || len(detectorBoxes) > 0 {
|
|
if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil {
|
|
appLogln("⚠️ detector sample update failed:", err)
|
|
}
|
|
}
|
|
|
|
trainingDeletePoseSample(root, req.SampleID)
|
|
if sexPosition := trainingSexPositionForFeedback(sample, TrainingFeedbackRequest{
|
|
SampleID: req.SampleID,
|
|
Accepted: req.Accepted,
|
|
Negative: req.Negative,
|
|
Correction: req.Correction,
|
|
Notes: req.Notes,
|
|
}); !isNoSexPositionLabel(sexPosition) {
|
|
if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes, req.Correction); err != nil {
|
|
appLogln("pose sample update failed:", err)
|
|
}
|
|
}
|
|
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"item": updated,
|
|
})
|
|
}
|
|
|
|
func trainingSkipHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
var req TrainingSkipRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
trainingWriteError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
sampleID := strings.TrimSpace(req.SampleID)
|
|
if sampleID == "" {
|
|
trainingWriteError(w, http.StatusBadRequest, "sampleId missing")
|
|
return
|
|
}
|
|
|
|
if strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
|
|
trainingWriteError(w, http.StatusBadRequest, "invalid sampleId")
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Aus Uncertain-Queue entfernen, falls es dort noch liegt.
|
|
if err := trainingRemoveSampleFromUncertainQueue(root, sampleID); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Sample + Frame löschen.
|
|
trainingDeleteSampleFiles(root, sampleID)
|
|
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"sampleId": sampleID,
|
|
})
|
|
}
|
|
|
|
func trainingHasDetectorTrainingData(imagesDir string, labelsDir string) bool {
|
|
imageExts := map[string]bool{
|
|
".jpg": true,
|
|
".jpeg": true,
|
|
".png": true,
|
|
".webp": true,
|
|
}
|
|
|
|
entries, err := os.ReadDir(imagesDir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
count := 0
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
if !imageExts[ext] {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
|
labelPath := filepath.Join(labelsDir, id+".txt")
|
|
|
|
if fileExists(labelPath) {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return count >= 5
|
|
}
|
|
|
|
func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
req, err := trainingReadTrainRequest(r)
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
targets, customTargets, err := trainingNormalizeTrainTargets(req)
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
current := trainingGetJobStatus()
|
|
if current.Running {
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"message": "Training läuft bereits.",
|
|
"training": current,
|
|
})
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := trainingEnsureDetectorDirs(root); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Falls bisher alles zufällig in train gelandet ist, erzeugen wir mindestens
|
|
// ein Validation-Sample durch Kopieren. Bei mehr Daten solltest du später
|
|
// einen echten 80/20 Split verwenden.
|
|
if err := trainingEnsurePoseDirs(root); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := trainingEnsureVideoMAEDirs(root); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := trainingEnsureDetectorValidationSample(root); err != nil {
|
|
appLogln("⚠️ detector val sample ensure failed:", err)
|
|
}
|
|
|
|
if err := trainingEnsurePoseValidationSample(root); err != nil {
|
|
appLogln("pose val sample ensure failed:", err)
|
|
}
|
|
|
|
feedbackPath := filepath.Join(root, "feedback.jsonl")
|
|
feedbackCount, _ := trainingCountAnnotations(feedbackPath)
|
|
|
|
if feedbackCount < minTrainingFeedbackCount {
|
|
trainingWriteError(
|
|
w,
|
|
http.StatusBadRequest,
|
|
fmt.Sprintf(
|
|
"Zu wenige Bewertungen für das YOLO26-Training. Mindestens %d, aktuell %d.",
|
|
minTrainingFeedbackCount,
|
|
feedbackCount,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train")
|
|
detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train")
|
|
detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val")
|
|
detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
|
|
detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml")
|
|
poseTrainImages := filepath.Join(root, "pose", "dataset", "images", "train")
|
|
poseTrainLabels := filepath.Join(root, "pose", "dataset", "labels", "train")
|
|
poseValImages := filepath.Join(root, "pose", "dataset", "images", "val")
|
|
poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
|
|
poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml")
|
|
videoMAEManifest := trainingVideoMAEManifestPath(root)
|
|
|
|
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
|
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
|
|
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
|
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
|
|
poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels)
|
|
poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels)
|
|
videoMAETrainCount, videoMAEValCount := trainingCountVideoMAEManifestSamples(root)
|
|
videoMAEEligibleCount, _ := trainingCountVideoMAEEligibleAnnotations(root)
|
|
|
|
detectorDataReady := fileExistsNonEmpty(detectorDatasetYAML) &&
|
|
trainCount >= minDetectorTrainCount &&
|
|
valCount >= minDetectorValCount &&
|
|
positiveTrainCount > 0 &&
|
|
positiveValCount > 0
|
|
poseDataReady := fileExistsNonEmpty(poseDatasetYAML) &&
|
|
poseTrainCount >= minPoseTrainCount &&
|
|
poseValCount >= minPoseValCount
|
|
videoMAEDataReady := videoMAEEligibleCount >= minVideoMAETrainCount ||
|
|
(videoMAETrainCount >= minVideoMAETrainCount && videoMAEValCount >= minVideoMAEValCount)
|
|
|
|
runtimeOpts := trainingRuntimeOptionsFromSettings()
|
|
selectedDataReady :=
|
|
(targets.Detector && detectorDataReady) ||
|
|
(targets.Pose && poseDataReady) ||
|
|
(targets.VideoMAE && videoMAEDataReady)
|
|
|
|
if customTargets {
|
|
if targets.Detector && !detectorDataReady {
|
|
trainingWriteError(
|
|
w,
|
|
http.StatusBadRequest,
|
|
fmt.Sprintf(
|
|
"YOLO26 Detector ist noch nicht trainingsbereit. Train=%d (%d positiv), Val=%d (%d positiv). Benoetigt: mindestens %d Train, %d Val und je ein positives Beispiel.",
|
|
trainCount,
|
|
positiveTrainCount,
|
|
valCount,
|
|
positiveValCount,
|
|
minDetectorTrainCount,
|
|
minDetectorValCount,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
if targets.Pose && !poseDataReady {
|
|
trainingWriteError(
|
|
w,
|
|
http.StatusBadRequest,
|
|
fmt.Sprintf(
|
|
"YOLO26 Pose ist noch nicht trainingsbereit. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.",
|
|
poseTrainCount,
|
|
poseValCount,
|
|
minPoseTrainCount,
|
|
minPoseValCount,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
if targets.VideoMAE && !runtimeOpts.VideoMAEEnabled {
|
|
trainingWriteError(w, http.StatusBadRequest, "VideoMAE ist in den Training-Settings deaktiviert.")
|
|
return
|
|
}
|
|
|
|
if targets.VideoMAE && !videoMAEDataReady {
|
|
trainingWriteError(
|
|
w,
|
|
http.StatusBadRequest,
|
|
fmt.Sprintf(
|
|
"VideoMAE ist noch nicht trainingsbereit. Eligible=%d, Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.",
|
|
videoMAEEligibleCount,
|
|
videoMAETrainCount,
|
|
videoMAEValCount,
|
|
minVideoMAETrainCount,
|
|
minVideoMAEValCount,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
goto startTraining
|
|
}
|
|
|
|
if selectedDataReady {
|
|
goto startTraining
|
|
}
|
|
|
|
if !fileExistsNonEmpty(detectorDatasetYAML) ||
|
|
trainCount < minDetectorTrainCount ||
|
|
valCount < minDetectorValCount ||
|
|
positiveTrainCount == 0 ||
|
|
positiveValCount == 0 {
|
|
trainingWriteError(
|
|
w,
|
|
http.StatusBadRequest,
|
|
fmt.Sprintf(
|
|
"Zu wenige YOLO26-Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.",
|
|
trainCount,
|
|
positiveTrainCount,
|
|
valCount,
|
|
positiveValCount,
|
|
minDetectorTrainCount,
|
|
minDetectorValCount,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
if !fileExistsNonEmpty(poseDatasetYAML) ||
|
|
poseTrainCount < minPoseTrainCount ||
|
|
poseValCount < minPoseValCount {
|
|
trainingWriteError(
|
|
w,
|
|
http.StatusBadRequest,
|
|
fmt.Sprintf(
|
|
"Zu wenige YOLO26-Pose-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.",
|
|
poseTrainCount,
|
|
poseValCount,
|
|
minPoseTrainCount,
|
|
minPoseValCount,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
startTraining:
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
trainingStartJob(cancel)
|
|
|
|
go trainingRunJob(ctx, root, feedbackCount, targets)
|
|
|
|
trainingWriteJSON(w, http.StatusAccepted, map[string]any{
|
|
"ok": true,
|
|
"message": "Training gestartet.",
|
|
"training": trainingGetJobStatus(),
|
|
"targets": targets.list(),
|
|
"detector": map[string]any{
|
|
"trainCount": trainCount,
|
|
"valCount": valCount,
|
|
"positiveTrainCount": positiveTrainCount,
|
|
"positiveValCount": positiveValCount,
|
|
"requiredTrain": minDetectorTrainCount,
|
|
"requiredVal": minDetectorValCount,
|
|
"datasetYAML": detectorDatasetYAML,
|
|
"usesSceneCLIP": false,
|
|
"usesSceneKNN": false,
|
|
"source": "yolo26_detector",
|
|
"detectsPosition": false,
|
|
},
|
|
"pose": map[string]any{
|
|
"trainCount": poseTrainCount,
|
|
"valCount": poseValCount,
|
|
"requiredTrain": minPoseTrainCount,
|
|
"requiredVal": minPoseValCount,
|
|
"datasetYAML": poseDatasetYAML,
|
|
"source": "yolo26_pose",
|
|
},
|
|
"videomae": map[string]any{
|
|
"eligibleCount": videoMAEEligibleCount,
|
|
"trainCount": videoMAETrainCount,
|
|
"valCount": videoMAEValCount,
|
|
"requiredTrain": minVideoMAETrainCount,
|
|
"requiredVal": minVideoMAEValCount,
|
|
"manifest": videoMAEManifest,
|
|
"dataReady": videoMAEDataReady,
|
|
"source": "videomae_clip",
|
|
},
|
|
})
|
|
}
|
|
|
|
func trainingCancelHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
trainingJob.mu.Lock()
|
|
status := trainingJob.status
|
|
cancel := trainingJob.cancel
|
|
trainingJob.mu.Unlock()
|
|
|
|
if !status.Running {
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"message": "Es läuft kein Training.",
|
|
"training": status,
|
|
})
|
|
return
|
|
}
|
|
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
s.Step = "Training wird abgebrochen…"
|
|
s.Message = ""
|
|
s.Error = ""
|
|
})
|
|
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
|
|
trainingWriteJSON(w, http.StatusAccepted, map[string]any{
|
|
"ok": true,
|
|
"message": "Training wird abgebrochen.",
|
|
"training": trainingGetJobStatus(),
|
|
})
|
|
}
|
|
|
|
func trainingRunJob(ctx context.Context, root string, count int, targets trainingTrainTargets) {
|
|
if err := ensureMLPythonSetup(ctx); err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
trainingFinishCancelled(root)
|
|
return
|
|
}
|
|
|
|
appLogln("ML-Python setup for training failed:", err)
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
finishedAt := time.Now().UTC()
|
|
var durationMs int64
|
|
if startedAt, parseErr := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); parseErr == nil {
|
|
durationMs = finishedAt.Sub(startedAt).Milliseconds()
|
|
if durationMs < 0 {
|
|
durationMs = 0
|
|
}
|
|
}
|
|
|
|
s.Running = false
|
|
s.Progress = 100
|
|
s.Step = "Training fehlgeschlagen."
|
|
s.Message = "ML-Python-Umgebung konnte nicht vorbereitet werden."
|
|
s.Error = err.Error()
|
|
s.FinishedAt = finishedAt.Format(time.RFC3339)
|
|
s.DurationMs = durationMs
|
|
s.PreviewURL = ""
|
|
s.Paused = false
|
|
s.PauseReason = ""
|
|
s.CPUPercent = 0
|
|
s.TemperatureC = 0
|
|
})
|
|
trainingClearJobCancel()
|
|
return
|
|
}
|
|
|
|
python := trainingPythonExe()
|
|
appLogln("ML-Python für Training:", python)
|
|
runtimeOpts := trainingRuntimeOptionsFromSettings()
|
|
appLogf(
|
|
"Training-Laufzeit: mode=%s cpuCores=%d schonmodus=%v threads=%d workers=%d yoloBatch=%d lowPriority=%v videoMAE=%v autoPause=%v cpuLimit=%d tempLimit=%d targets=%s",
|
|
runtimeOpts.PerformanceMode,
|
|
runtimeOpts.CPUCoreCount,
|
|
runtimeOpts.PowerSaveMode,
|
|
runtimeOpts.CPUThreads,
|
|
runtimeOpts.Workers,
|
|
runtimeOpts.YoloBatchSize,
|
|
runtimeOpts.LowPriority,
|
|
runtimeOpts.VideoMAEEnabled,
|
|
runtimeOpts.AutoPauseEnabled,
|
|
runtimeOpts.AutoPauseCPUPercent,
|
|
runtimeOpts.AutoPauseTemperatureC,
|
|
strings.Join(targets.list(), ","),
|
|
)
|
|
|
|
cleanOutput := func(text string) string {
|
|
out := strings.TrimSpace(text)
|
|
if len(out) > 1500 {
|
|
out = out[:1500] + "…"
|
|
}
|
|
return out
|
|
}
|
|
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
s.Progress = 10
|
|
s.Step = "YOLO26-Daten werden geprüft…"
|
|
})
|
|
|
|
detectorOutput := ""
|
|
detectorStatus := "skipped"
|
|
detectorDurationMs := int64(0)
|
|
poseOutput := ""
|
|
poseStatus := "skipped"
|
|
poseDurationMs := int64(0)
|
|
videoMAEOutput := ""
|
|
videoMAEStatus := "skipped"
|
|
videoMAEDurationMs := int64(0)
|
|
|
|
detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml")
|
|
detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train")
|
|
detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train")
|
|
detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val")
|
|
detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
|
|
|
|
if err := trainingEnsureDetectorValidationSample(root); err != nil {
|
|
appLogln("⚠️ detector val sample ensure failed:", err)
|
|
}
|
|
|
|
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
|
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
|
|
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
|
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
|
|
|
|
fmt.Printf(
|
|
"🔎 detector data: train=%d (%d positive) val=%d (%d positive) yaml=%v\n",
|
|
trainCount,
|
|
positiveTrainCount,
|
|
valCount,
|
|
positiveValCount,
|
|
fileExistsNonEmpty(detectorDatasetYAML),
|
|
)
|
|
|
|
if !targets.Detector {
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
trainingApplyStageProgress(s, "detector", 1)
|
|
if s.Progress < 58 {
|
|
s.Progress = 58
|
|
}
|
|
s.Step = "YOLO26 Detector wurde nicht ausgewählt."
|
|
})
|
|
detectorStatus = "skipped_unselected"
|
|
detectorOutput = "YOLO26 Detector übersprungen: nicht ausgewählt."
|
|
appLogln(detectorOutput)
|
|
} else if fileExistsNonEmpty(detectorDatasetYAML) &&
|
|
trainCount >= minDetectorTrainCount &&
|
|
valCount >= minDetectorValCount &&
|
|
positiveTrainCount > 0 &&
|
|
positiveValCount > 0 {
|
|
detectorStartedAt := time.Now()
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
trainingApplyStageProgress(s, "detector", 0)
|
|
s.Epochs = trainingDetectorEpochs()
|
|
s.Progress = 15
|
|
s.Step = "YOLO26 Detector wird trainiert…"
|
|
})
|
|
|
|
detectorModel := trainingResolveDetectorModel(root)
|
|
detectorBasePath := detectorModel.BestPath
|
|
if !detectorModel.TrainedExists {
|
|
detectorBasePath = "yolo26n.pt"
|
|
if p, err := trainingMLCacheFilePath("yolo26n.pt"); err == nil {
|
|
detectorBasePath = p
|
|
}
|
|
}
|
|
detectorEpochs := trainingDetectorEpochs()
|
|
detectorPatience := trainingYoloEarlyStoppingPatience(detectorEpochs)
|
|
if detectorModel.TrainedExists {
|
|
appLogln("YOLO26 Detector Fine-Tuning startet von:", detectorBasePath)
|
|
}
|
|
|
|
detectorScript := trainingScriptPath("train_detector_model.py")
|
|
detectorArgs := []string{
|
|
"--root", root,
|
|
"--base", detectorBasePath,
|
|
"--epochs", strconv.Itoa(detectorEpochs),
|
|
"--imgsz", "640",
|
|
"--workers", strconv.Itoa(runtimeOpts.Workers),
|
|
"--threads", strconv.Itoa(runtimeOpts.CPUThreads),
|
|
"--patience", strconv.Itoa(detectorPatience),
|
|
}
|
|
if runtimeOpts.YoloBatchSize > 0 {
|
|
detectorArgs = append(detectorArgs, "--batch", strconv.Itoa(runtimeOpts.YoloBatchSize))
|
|
}
|
|
|
|
detectorOut, detectorErr := trainingRunCommandStreaming(
|
|
ctx,
|
|
python,
|
|
detectorScript,
|
|
func(line string) bool {
|
|
return trainingHandleProgressLine(
|
|
line,
|
|
15,
|
|
58,
|
|
"YOLO26 Detector wird trainiert…",
|
|
)
|
|
},
|
|
detectorArgs...,
|
|
)
|
|
detectorDurationMs = time.Since(detectorStartedAt).Milliseconds()
|
|
|
|
if errors.Is(detectorErr, errTrainingCancelled) {
|
|
appLogln("⛔ YOLO26 detector training cancelled")
|
|
trainingFinishCancelled(root)
|
|
return
|
|
}
|
|
|
|
detectorOutput = detectorOut
|
|
detectorOutputClean := cleanOutput(detectorOutput)
|
|
|
|
if detectorErr != nil {
|
|
detectorStatus = "failed"
|
|
|
|
appLogln("⚠️ YOLO26 detector training failed:", detectorErr)
|
|
if detectorOutputClean != "" {
|
|
appLogln("⚠️ YOLO26 detector output:", detectorOutputClean)
|
|
}
|
|
} else {
|
|
detectorStatus = "trained"
|
|
|
|
if detectorOutputClean != "" {
|
|
appLogln("✅ YOLO26 detector training:", detectorOutputClean)
|
|
}
|
|
}
|
|
} else {
|
|
detectorStatus = "skipped_no_detector_data"
|
|
detectorOutput = fmt.Sprintf(
|
|
"YOLO26 Detector übersprungen: zu wenige Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.",
|
|
trainCount,
|
|
positiveTrainCount,
|
|
valCount,
|
|
positiveValCount,
|
|
minDetectorTrainCount,
|
|
minDetectorValCount,
|
|
)
|
|
|
|
appLogln("⚠️", detectorOutput)
|
|
}
|
|
|
|
detectorOutputClean := cleanOutput(detectorOutput)
|
|
poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml")
|
|
poseTrainImages := filepath.Join(root, "pose", "dataset", "images", "train")
|
|
poseTrainLabels := filepath.Join(root, "pose", "dataset", "labels", "train")
|
|
poseValImages := filepath.Join(root, "pose", "dataset", "images", "val")
|
|
poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
|
|
poseStartedAt := time.Time{}
|
|
|
|
if !targets.Pose {
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
trainingApplyStageProgress(s, "pose", 1)
|
|
if s.Progress < 82 {
|
|
s.Progress = 82
|
|
}
|
|
s.Step = "YOLO26 Pose wurde nicht ausgewählt."
|
|
})
|
|
poseStatus = "skipped_unselected"
|
|
poseOutput = "YOLO26 Pose übersprungen: nicht ausgewählt."
|
|
appLogln(poseOutput)
|
|
} else {
|
|
poseStartedAt = time.Now()
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
trainingApplyStageProgress(s, "pose", 0)
|
|
s.Epochs = trainingDetectorEpochs()
|
|
if s.Progress < 60 {
|
|
s.Progress = 60
|
|
}
|
|
s.Step = "YOLO26 Pose-Daten werden aufgebaut..."
|
|
})
|
|
|
|
if written, err := trainingSyncPoseDataset(root); err != nil {
|
|
poseStatus = "failed"
|
|
poseOutput = "YOLO26 Pose-Dataset konnte nicht aufgebaut werden: " + err.Error()
|
|
appLogln(poseOutput)
|
|
} else {
|
|
appLogln("pose samples synced:", written)
|
|
}
|
|
|
|
if err := trainingEnsurePoseValidationSample(root); err != nil {
|
|
appLogln("pose val sample ensure failed:", err)
|
|
}
|
|
}
|
|
|
|
poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels)
|
|
poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels)
|
|
|
|
fmt.Printf(
|
|
"pose data: train=%d val=%d yaml=%v\n",
|
|
poseTrainCount,
|
|
poseValCount,
|
|
fileExistsNonEmpty(poseDatasetYAML),
|
|
)
|
|
|
|
if targets.Pose &&
|
|
poseStatus != "failed" &&
|
|
fileExistsNonEmpty(poseDatasetYAML) &&
|
|
poseTrainCount >= minPoseTrainCount &&
|
|
poseValCount >= minPoseValCount {
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
trainingApplyStageProgress(s, "pose", 0.04)
|
|
s.Epochs = trainingDetectorEpochs()
|
|
if s.Progress < 62 {
|
|
s.Progress = 62
|
|
}
|
|
s.Step = "YOLO26 Pose wird trainiert..."
|
|
})
|
|
|
|
poseModel := trainingResolvePoseModel(root)
|
|
poseBasePath := "yolo26n-pose.pt"
|
|
if poseModel.EffectiveExists {
|
|
poseBasePath = poseModel.EffectivePath
|
|
}
|
|
poseEpochs := trainingDetectorEpochs()
|
|
posePatience := trainingYoloEarlyStoppingPatience(poseEpochs)
|
|
if poseModel.TrainedExists {
|
|
appLogln("YOLO26 Pose Fine-Tuning startet von:", poseBasePath)
|
|
}
|
|
|
|
poseScript := trainingScriptPath("train_pose_model.py")
|
|
poseArgs := []string{
|
|
"--root", root,
|
|
"--base", poseBasePath,
|
|
"--epochs", strconv.Itoa(poseEpochs),
|
|
"--imgsz", "640",
|
|
"--workers", strconv.Itoa(runtimeOpts.Workers),
|
|
"--threads", strconv.Itoa(runtimeOpts.CPUThreads),
|
|
"--patience", strconv.Itoa(posePatience),
|
|
}
|
|
if runtimeOpts.YoloBatchSize > 0 {
|
|
poseArgs = append(poseArgs, "--batch", strconv.Itoa(runtimeOpts.YoloBatchSize))
|
|
}
|
|
|
|
poseOut, poseErr := trainingRunCommandStreaming(
|
|
ctx,
|
|
python,
|
|
poseScript,
|
|
func(line string) bool {
|
|
return trainingHandleProgressLine(
|
|
line,
|
|
62,
|
|
82,
|
|
"YOLO26 Pose wird trainiert...",
|
|
)
|
|
},
|
|
poseArgs...,
|
|
)
|
|
poseDurationMs = time.Since(poseStartedAt).Milliseconds()
|
|
|
|
if errors.Is(poseErr, errTrainingCancelled) {
|
|
appLogln("YOLO26 pose training cancelled")
|
|
trainingFinishCancelled(root)
|
|
return
|
|
}
|
|
|
|
poseOutput = poseOut
|
|
poseOutputClean := cleanOutput(poseOutput)
|
|
|
|
if poseErr != nil {
|
|
poseStatus = "failed"
|
|
|
|
appLogln("YOLO26 pose training failed:", poseErr)
|
|
if poseOutputClean != "" {
|
|
appLogln("YOLO26 pose output:", poseOutputClean)
|
|
}
|
|
} else {
|
|
poseStatus = "trained"
|
|
|
|
if poseOutputClean != "" {
|
|
appLogln("YOLO26 pose training:", poseOutputClean)
|
|
}
|
|
}
|
|
} else if targets.Pose && poseStatus != "failed" {
|
|
poseStatus = "skipped_no_pose_data"
|
|
poseOutput = fmt.Sprintf(
|
|
"YOLO26 Pose übersprungen: zu wenige Skeleton-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.",
|
|
poseTrainCount,
|
|
poseValCount,
|
|
minPoseTrainCount,
|
|
minPoseValCount,
|
|
)
|
|
appLogln(poseOutput)
|
|
}
|
|
|
|
if poseStatus == "trained" && poseDurationMs <= 0 && !poseStartedAt.IsZero() {
|
|
poseDurationMs = time.Since(poseStartedAt).Milliseconds()
|
|
}
|
|
|
|
poseOutputClean := cleanOutput(poseOutput)
|
|
videoMAEStartedAt := time.Time{}
|
|
videoMAETrainCount := 0
|
|
videoMAEValCount := 0
|
|
|
|
if !targets.VideoMAE {
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
trainingApplyStageProgress(s, "videomae", 1)
|
|
if s.Progress < 98 {
|
|
s.Progress = 98
|
|
}
|
|
s.Step = "VideoMAE wurde nicht ausgewählt."
|
|
})
|
|
videoMAEStatus = "skipped_unselected"
|
|
videoMAEOutput = "VideoMAE übersprungen: nicht ausgewählt."
|
|
appLogln(videoMAEOutput)
|
|
} else if !runtimeOpts.VideoMAEEnabled {
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
trainingApplyStageProgress(s, "videomae", 1)
|
|
if s.Progress < 84 {
|
|
s.Progress = 84
|
|
}
|
|
s.Step = "VideoMAE wurde in den Settings übersprungen."
|
|
})
|
|
videoMAEStatus = "skipped_disabled"
|
|
videoMAEOutput = "VideoMAE übersprungen: in den Training-Settings deaktiviert."
|
|
appLogln(videoMAEOutput)
|
|
} else {
|
|
videoMAEStartedAt = time.Now()
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
trainingApplyStageProgress(s, "videomae", 0)
|
|
s.Epochs = trainingVideoMAEEpochs()
|
|
if s.Progress < 84 {
|
|
s.Progress = 84
|
|
}
|
|
s.Step = "VideoMAE Clip-Daten werden aufgebaut..."
|
|
})
|
|
|
|
var videoMAEWritten int
|
|
var videoMAESyncErr error
|
|
videoMAETrainCount, videoMAEValCount, videoMAEWritten, videoMAESyncErr =
|
|
trainingSyncVideoMAEDataset(ctx, root)
|
|
if errors.Is(videoMAESyncErr, context.Canceled) || errors.Is(videoMAESyncErr, errTrainingCancelled) {
|
|
appLogln("VideoMAE dataset sync cancelled")
|
|
trainingFinishCancelled(root)
|
|
return
|
|
}
|
|
if videoMAESyncErr != nil {
|
|
videoMAEStatus = "failed"
|
|
videoMAEOutput = "VideoMAE-Dataset konnte nicht aufgebaut werden: " + videoMAESyncErr.Error()
|
|
appLogln(videoMAEOutput)
|
|
} else {
|
|
appLogf(
|
|
"VideoMAE samples synced: written=%d train=%d val=%d",
|
|
videoMAEWritten,
|
|
videoMAETrainCount,
|
|
videoMAEValCount,
|
|
)
|
|
}
|
|
|
|
if videoMAEStatus != "failed" &&
|
|
videoMAETrainCount >= minVideoMAETrainCount &&
|
|
videoMAEValCount >= minVideoMAEValCount {
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
trainingApplyStageProgress(s, "videomae", 0.04)
|
|
s.Epochs = trainingVideoMAEEpochs()
|
|
if s.Progress < 86 {
|
|
s.Progress = 86
|
|
}
|
|
s.Step = "VideoMAE Clip-Classifier wird trainiert..."
|
|
})
|
|
|
|
videoMAEScript := trainingScriptPath("train_videomae_model.py")
|
|
videoMAEBase := strings.TrimSpace(os.Getenv("VIDEOMAE_BASE_MODEL"))
|
|
if videoMAEBase == "" {
|
|
if model := trainingResolveVideoMAEModel(root); model.TrainedExists {
|
|
videoMAEBase = model.EffectivePath
|
|
appLogln("VideoMAE Fine-Tuning startet von:", videoMAEBase)
|
|
}
|
|
}
|
|
videoMAEArgs := []string{
|
|
"--root", root,
|
|
"--epochs", strconv.Itoa(trainingVideoMAEEpochs()),
|
|
"--batch-size", strconv.Itoa(trainingVideoMAEBatchSize()),
|
|
"--num-frames", strconv.Itoa(trainingVideoMAENumFrames),
|
|
"--workers", strconv.Itoa(runtimeOpts.Workers),
|
|
"--threads", strconv.Itoa(runtimeOpts.CPUThreads),
|
|
"--patience", strconv.Itoa(trainingVideoMAEEarlyStoppingPatience()),
|
|
}
|
|
if videoMAEBase != "" {
|
|
videoMAEArgs = append(videoMAEArgs, "--base", videoMAEBase)
|
|
}
|
|
|
|
videoMAEOut, videoMAEErr := trainingRunCommandStreaming(
|
|
ctx,
|
|
python,
|
|
videoMAEScript,
|
|
func(line string) bool {
|
|
return trainingHandleProgressLine(
|
|
line,
|
|
86,
|
|
98,
|
|
"VideoMAE Clip-Classifier wird trainiert...",
|
|
)
|
|
},
|
|
videoMAEArgs...,
|
|
)
|
|
videoMAEDurationMs = time.Since(videoMAEStartedAt).Milliseconds()
|
|
|
|
if errors.Is(videoMAEErr, errTrainingCancelled) {
|
|
appLogln("VideoMAE training cancelled")
|
|
trainingFinishCancelled(root)
|
|
return
|
|
}
|
|
|
|
videoMAEOutput = videoMAEOut
|
|
videoMAEOutputClean := cleanOutput(videoMAEOutput)
|
|
|
|
if videoMAEErr != nil {
|
|
videoMAEStatus = "failed"
|
|
appLogln("VideoMAE training failed:", videoMAEErr)
|
|
if videoMAEOutputClean != "" {
|
|
appLogln("VideoMAE output:", videoMAEOutputClean)
|
|
}
|
|
} else {
|
|
videoMAEStatus = "trained"
|
|
if videoMAEOutputClean != "" {
|
|
appLogln("VideoMAE training:", videoMAEOutputClean)
|
|
}
|
|
}
|
|
} else if videoMAEStatus != "failed" {
|
|
videoMAEStatus = "skipped_no_videomae_data"
|
|
videoMAEOutput = fmt.Sprintf(
|
|
"VideoMAE übersprungen: zu wenige Clip-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.",
|
|
videoMAETrainCount,
|
|
videoMAEValCount,
|
|
minVideoMAETrainCount,
|
|
minVideoMAEValCount,
|
|
)
|
|
appLogln(videoMAEOutput)
|
|
}
|
|
}
|
|
|
|
if videoMAEStatus == "trained" && videoMAEDurationMs <= 0 && !videoMAEStartedAt.IsZero() {
|
|
videoMAEDurationMs = time.Since(videoMAEStartedAt).Milliseconds()
|
|
}
|
|
|
|
videoMAEOutputClean := cleanOutput(videoMAEOutput)
|
|
|
|
message := "Training abgeschlossen."
|
|
errorText := ""
|
|
|
|
switch detectorStatus {
|
|
case "trained":
|
|
message = "Training abgeschlossen. YOLO26 Detector wurde trainiert."
|
|
|
|
case "skipped_unselected":
|
|
message = "Training abgeschlossen."
|
|
|
|
case "skipped_no_detector_data":
|
|
message = detectorOutput
|
|
|
|
case "failed":
|
|
message = "YOLO26 Detector ist fehlgeschlagen."
|
|
if detectorOutputClean != "" {
|
|
message += " Grund: " + detectorOutputClean
|
|
}
|
|
errorText = message
|
|
|
|
default:
|
|
message = "Training abgeschlossen, aber YOLO26 wurde nicht trainiert."
|
|
if detectorOutputClean != "" {
|
|
message += " Ausgabe: " + detectorOutputClean
|
|
}
|
|
}
|
|
|
|
if poseStatus == "trained" && detectorStatus == "trained" {
|
|
message = "Training abgeschlossen. YOLO26 Detector und YOLO26 Pose wurden trainiert."
|
|
} else if poseStatus == "trained" && detectorStatus != "failed" {
|
|
message = "Training abgeschlossen. YOLO26 Pose wurde trainiert."
|
|
} else if poseStatus == "skipped_no_pose_data" && detectorStatus == "trained" {
|
|
message += " YOLO26 Pose wurde übersprungen: zu wenige Skeleton-Beispiele."
|
|
} else if poseStatus == "failed" {
|
|
if detectorStatus == "failed" {
|
|
message += " YOLO26 Pose ist ebenfalls fehlgeschlagen."
|
|
} else {
|
|
message = "YOLO26 Pose ist fehlgeschlagen."
|
|
}
|
|
if poseOutputClean != "" {
|
|
message += " Grund: " + poseOutputClean
|
|
}
|
|
errorText = message
|
|
}
|
|
|
|
if videoMAEStatus == "trained" {
|
|
if !strings.Contains(message, "Training abgeschlossen.") {
|
|
message = "Training abgeschlossen. " + message
|
|
}
|
|
message += " VideoMAE wurde trainiert."
|
|
} else if videoMAEStatus == "skipped_no_videomae_data" {
|
|
if detectorStatus == "trained" || poseStatus == "trained" {
|
|
message += " VideoMAE wurde übersprungen: zu wenige Clip-Beispiele."
|
|
}
|
|
} else if videoMAEStatus == "skipped_disabled" {
|
|
if detectorStatus == "trained" || poseStatus == "trained" {
|
|
message += " VideoMAE wurde übersprungen: in den Settings deaktiviert."
|
|
}
|
|
} else if videoMAEStatus == "failed" {
|
|
message += " VideoMAE ist fehlgeschlagen."
|
|
if videoMAEOutputClean != "" {
|
|
message += " Grund: " + videoMAEOutputClean
|
|
}
|
|
}
|
|
|
|
if detectorStatus == "trained" || poseStatus == "trained" || videoMAEStatus == "trained" {
|
|
// Verlauf pro erfolgreich trainiertem Ziel schreiben.
|
|
if detectorStatus == "trained" {
|
|
trainingAppendTargetHistory(root, "detector", detectorStatus, detectorDurationMs, runtimeOpts, TrainingHistoryEntry{
|
|
Epochs: trainingDetectorEpochs(),
|
|
TrainSamples: trainCount,
|
|
ValSamples: valCount,
|
|
Imgsz: 640,
|
|
})
|
|
}
|
|
if poseStatus == "trained" {
|
|
trainingAppendTargetHistory(root, "pose", poseStatus, poseDurationMs, runtimeOpts, TrainingHistoryEntry{
|
|
Epochs: trainingDetectorEpochs(),
|
|
TrainSamples: poseTrainCount,
|
|
ValSamples: poseValCount,
|
|
Imgsz: 640,
|
|
})
|
|
}
|
|
if videoMAEStatus == "trained" {
|
|
trainingAppendTargetHistory(root, "videomae", videoMAEStatus, videoMAEDurationMs, runtimeOpts, TrainingHistoryEntry{
|
|
Epochs: trainingVideoMAEEpochs(),
|
|
TrainSamples: videoMAETrainCount,
|
|
ValSamples: videoMAEValCount,
|
|
Imgsz: trainingVideoMAEFrameSize,
|
|
})
|
|
}
|
|
}
|
|
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
finishedAt := time.Now().UTC()
|
|
|
|
var durationMs int64
|
|
if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); err == nil {
|
|
durationMs = finishedAt.Sub(startedAt).Milliseconds()
|
|
if durationMs < 0 {
|
|
durationMs = 0
|
|
}
|
|
}
|
|
|
|
s.Running = false
|
|
s.Progress = 100
|
|
if strings.TrimSpace(s.Stage) != "" {
|
|
s.StageProgress = 1
|
|
}
|
|
s.Step = "Training abgeschlossen."
|
|
s.Message = message
|
|
s.Error = errorText
|
|
s.FinishedAt = finishedAt.Format(time.RFC3339)
|
|
s.DurationMs = durationMs
|
|
s.PreviewURL = ""
|
|
s.Paused = false
|
|
s.PauseReason = ""
|
|
s.CPUPercent = 0
|
|
s.TemperatureC = 0
|
|
})
|
|
|
|
trainingClearJobCancel()
|
|
}
|
|
|
|
func trainingStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
stats, err := trainingBuildStats(root)
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
trainingWriteJSON(w, http.StatusOK, stats)
|
|
}
|
|
|
|
func trainingBuildStats(root string) (*TrainingStatsResponse, error) {
|
|
grouped, err := trainingGroupedLabels()
|
|
if err != nil {
|
|
// Fallback: Stats sollen trotzdem funktionieren, auch wenn Label-Gruppierung scheitert.
|
|
fallbackLabels := defaultTrainingLabelsFromJSON()
|
|
|
|
grouped = TrainingGroupedLabels{
|
|
People: fallbackLabels.People,
|
|
SexPositions: fallbackLabels.SexPositions,
|
|
BodyParts: fallbackLabels.BodyParts,
|
|
Objects: fallbackLabels.Objects,
|
|
Clothing: fallbackLabels.Clothing,
|
|
}
|
|
}
|
|
|
|
peopleSet := stringSet(grouped.People)
|
|
sexPositionSet := stringSet(grouped.SexPositions)
|
|
bodyPartSet := stringSet(grouped.BodyParts)
|
|
objectSet := stringSet(grouped.Objects)
|
|
clothingSet := stringSet(grouped.Clothing)
|
|
|
|
peopleCounts := map[string]int{}
|
|
sexPositionCounts := map[string]int{}
|
|
bodyPartCounts := map[string]int{}
|
|
objectCounts := map[string]int{}
|
|
clothingCounts := map[string]int{}
|
|
|
|
stats := &TrainingStatsResponse{
|
|
OK: true,
|
|
Labels: TrainingStatsLabels{
|
|
People: []TrainingLabelStat{},
|
|
SexPositions: []TrainingLabelStat{},
|
|
BodyParts: []TrainingLabelStat{},
|
|
Objects: []TrainingLabelStat{},
|
|
Clothing: []TrainingLabelStat{},
|
|
},
|
|
}
|
|
|
|
feedbackPath := filepath.Join(root, "feedback.jsonl")
|
|
b, err := os.ReadFile(feedbackPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples"))
|
|
trainingApplyStatsModelInfo(root, stats)
|
|
return stats, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var annotation TrainingAnnotation
|
|
if err := json.Unmarshal([]byte(line), &annotation); err != nil {
|
|
continue
|
|
}
|
|
|
|
stats.FeedbackCount++
|
|
|
|
if annotation.Negative {
|
|
stats.NegativeCount++
|
|
} else if annotation.Accepted {
|
|
stats.AcceptedCount++
|
|
} else {
|
|
stats.CorrectedCount++
|
|
}
|
|
|
|
effective := trainingEffectiveCorrection(annotation)
|
|
|
|
sexPosition := normalizeSexPositionLabel(effective.SexPosition)
|
|
if len(sexPositionSet) == 0 || sexPositionSet[sexPosition] {
|
|
sexPositionCounts[sexPosition]++
|
|
}
|
|
|
|
for _, label := range effective.BodyPartsPresent {
|
|
clean := strings.TrimSpace(label)
|
|
if clean == "" {
|
|
continue
|
|
}
|
|
if len(bodyPartSet) == 0 || bodyPartSet[clean] {
|
|
bodyPartCounts[clean]++
|
|
}
|
|
}
|
|
|
|
for _, label := range effective.ObjectsPresent {
|
|
clean := strings.TrimSpace(label)
|
|
if clean == "" {
|
|
continue
|
|
}
|
|
if len(objectSet) == 0 || objectSet[clean] {
|
|
objectCounts[clean]++
|
|
}
|
|
}
|
|
|
|
for _, label := range effective.ClothingPresent {
|
|
clean := strings.TrimSpace(label)
|
|
if clean == "" {
|
|
continue
|
|
}
|
|
if len(clothingSet) == 0 || clothingSet[clean] {
|
|
clothingCounts[clean]++
|
|
}
|
|
}
|
|
|
|
for _, box := range effective.Boxes {
|
|
label := strings.TrimSpace(box.Label)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
|
|
stats.BoxCount++
|
|
|
|
switch {
|
|
case peopleSet[label]:
|
|
peopleCounts[label]++
|
|
|
|
case bodyPartSet[label]:
|
|
bodyPartCounts[label]++
|
|
|
|
case objectSet[label]:
|
|
objectCounts[label]++
|
|
|
|
case clothingSet[label]:
|
|
clothingCounts[label]++
|
|
}
|
|
}
|
|
}
|
|
|
|
stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples"))
|
|
trainingApplyStatsModelInfo(root, stats)
|
|
|
|
stats.Labels = TrainingStatsLabels{
|
|
// Personen/Box-Labels brauchen mehr Beispiele, weil der Detector Boxen lernen muss.
|
|
People: trainingStatsMapToList(peopleCounts, 20),
|
|
|
|
// Scene-Positionen sind Sample-Labels, hier reichen grob weniger pro Klasse.
|
|
SexPositions: trainingStatsMapToList(sexPositionCounts, 8),
|
|
|
|
// Detector-Klassen: grob 15 Beispiele pro Label als solide Untergrenze.
|
|
BodyParts: trainingStatsMapToList(bodyPartCounts, 15),
|
|
Objects: trainingStatsMapToList(objectCounts, 15),
|
|
Clothing: trainingStatsMapToList(clothingCounts, 15),
|
|
}
|
|
|
|
stats.Confidence = trainingOverallConfidence(
|
|
stats.FeedbackCount,
|
|
stats.BoxCount,
|
|
stats.AcceptedCount,
|
|
stats.CorrectedCount,
|
|
stats.Labels,
|
|
)
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrection {
|
|
if annotation.Negative {
|
|
return *trainingNegativeCorrection()
|
|
}
|
|
|
|
if annotation.Correction != nil {
|
|
correction := trainingNormalizeCorrectionForStorage(annotation.Correction)
|
|
return *correction
|
|
}
|
|
|
|
p := annotation.Prediction
|
|
sexPosition := normalizeSexPositionLabel(p.SexPosition)
|
|
posePersons := p.Persons
|
|
if isNoSexPositionLabel(sexPosition) {
|
|
posePersons = []TrainingPosePerson{}
|
|
}
|
|
|
|
return TrainingCorrection{
|
|
SexPosition: sexPosition,
|
|
PeoplePresent: trainingScoredLabelsToStrings(p.PeoplePresent),
|
|
BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent),
|
|
ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent),
|
|
ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent),
|
|
Boxes: p.Boxes,
|
|
PosePersons: posePersons,
|
|
}
|
|
}
|
|
|
|
func trainingScoredLabelsToStrings(values []TrainingScoredLabel) []string {
|
|
out := make([]string, 0, len(values))
|
|
seen := map[string]bool{}
|
|
|
|
for _, value := range values {
|
|
label := strings.TrimSpace(value.Label)
|
|
if label == "" || seen[label] {
|
|
continue
|
|
}
|
|
|
|
seen[label] = true
|
|
out = append(out, label)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingStatsMapToList(values map[string]int, target int) []TrainingLabelStat {
|
|
out := make([]TrainingLabelStat, 0, len(values))
|
|
|
|
for label, count := range values {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" || count <= 0 {
|
|
continue
|
|
}
|
|
|
|
out = append(out, TrainingLabelStat{
|
|
Label: label,
|
|
Count: count,
|
|
Confidence: trainingLabelConfidence(count, target),
|
|
})
|
|
}
|
|
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Count == out[j].Count {
|
|
return out[i].Label < out[j].Label
|
|
}
|
|
|
|
return out[i].Count > out[j].Count
|
|
})
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingCountSampleFiles(samplesDir string) int {
|
|
entries, err := os.ReadDir(samplesDir)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
count := 0
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
if strings.ToLower(filepath.Ext(entry.Name())) == ".json" {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
job := trainingGetJobStatus()
|
|
|
|
if !job.Running {
|
|
if err := trainingEnsureDetectorDirs(root); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := trainingEnsurePoseDirs(root); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if err := trainingEnsureVideoMAEDirs(root); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := trainingEnsureDetectorValidationSample(root); err != nil {
|
|
appLogln("⚠️ detector val sample ensure failed:", err)
|
|
}
|
|
if err := trainingEnsurePoseValidationSample(root); err != nil {
|
|
appLogln("pose val sample ensure failed:", err)
|
|
}
|
|
}
|
|
|
|
feedbackPath := filepath.Join(root, "feedback.jsonl")
|
|
feedbackCount, _ := trainingCountAnnotations(feedbackPath)
|
|
|
|
detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml")
|
|
detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train")
|
|
detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train")
|
|
detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val")
|
|
detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
|
|
poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml")
|
|
poseTrainImages := filepath.Join(root, "pose", "dataset", "images", "train")
|
|
poseTrainLabels := filepath.Join(root, "pose", "dataset", "labels", "train")
|
|
poseValImages := filepath.Join(root, "pose", "dataset", "images", "val")
|
|
poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
|
|
detectorModel := trainingResolveDetectorModel(root)
|
|
poseModel := trainingResolvePoseModel(root)
|
|
videoMAEModel := trainingResolveVideoMAEModel(root)
|
|
videoMAEManifest := trainingVideoMAEManifestPath(root)
|
|
|
|
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
|
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
|
|
positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels)
|
|
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
|
|
poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels)
|
|
poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels)
|
|
videoMAETrainCount, videoMAEValCount := trainingCountVideoMAEManifestSamples(root)
|
|
videoMAEEligibleCount, _ := trainingCountVideoMAEEligibleAnnotations(root)
|
|
|
|
datasetReady := fileExistsNonEmpty(detectorDatasetYAML)
|
|
detectorDataReady := datasetReady &&
|
|
trainCount >= minDetectorTrainCount &&
|
|
valCount >= minDetectorValCount &&
|
|
positiveTrainCount > 0 &&
|
|
positiveValCount > 0
|
|
poseDatasetReady := fileExistsNonEmpty(poseDatasetYAML)
|
|
poseDataReady := poseDatasetReady &&
|
|
poseTrainCount >= minPoseTrainCount &&
|
|
poseValCount >= minPoseValCount
|
|
videoMAEDatasetReady := fileExistsNonEmpty(videoMAEManifest)
|
|
videoMAEDataReady := (videoMAETrainCount >= minVideoMAETrainCount &&
|
|
videoMAEValCount >= minVideoMAEValCount) ||
|
|
videoMAEEligibleCount >= minVideoMAETrainCount
|
|
|
|
canTrain := feedbackCount >= minTrainingFeedbackCount &&
|
|
(detectorDataReady || poseDataReady || videoMAEDataReady)
|
|
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"feedbackCount": feedbackCount,
|
|
"requiredCount": minTrainingFeedbackCount,
|
|
"canTrain": canTrain,
|
|
|
|
"training": job,
|
|
|
|
"detector": map[string]any{
|
|
"source": "yolo26_detector",
|
|
"usesSceneCLIP": false,
|
|
"usesSceneKNN": false,
|
|
"usesResNet18KNN": false,
|
|
|
|
"detectsPeople": true,
|
|
"detectsGender": true,
|
|
"detectsSexPosition": false,
|
|
"detectsBodyParts": true,
|
|
"detectsObjects": true,
|
|
"detectsClothing": true,
|
|
"detectsBoxes": true,
|
|
|
|
"trainCount": trainCount,
|
|
"valCount": valCount,
|
|
"positiveTrainCount": positiveTrainCount,
|
|
"positiveValCount": positiveValCount,
|
|
"requiredTrain": minDetectorTrainCount,
|
|
"requiredVal": minDetectorValCount,
|
|
|
|
"datasetReady": datasetReady,
|
|
"datasetYAML": detectorDatasetYAML,
|
|
"dataReady": detectorDataReady,
|
|
|
|
"modelExists": detectorModel.EffectiveExists,
|
|
"modelPath": detectorModel.EffectivePath,
|
|
"trainedModelExists": detectorModel.TrainedExists,
|
|
"trainedModelPath": detectorModel.BestPath,
|
|
"modelSource": detectorModel.Source,
|
|
},
|
|
|
|
"pose": map[string]any{
|
|
"source": "yolo26_pose",
|
|
"usesKeypoints": true,
|
|
"predictsPersons": true,
|
|
"predictsSexPosition": poseModel.TrainedExists,
|
|
"trainedFromFeedback": true,
|
|
"trainCount": poseTrainCount,
|
|
"valCount": poseValCount,
|
|
"requiredTrain": minPoseTrainCount,
|
|
"requiredVal": minPoseValCount,
|
|
"datasetReady": poseDatasetReady,
|
|
"datasetYAML": poseDatasetYAML,
|
|
"dataReady": poseDataReady,
|
|
"modelExists": poseModel.EffectiveExists,
|
|
"modelPath": poseModel.EffectivePath,
|
|
"trainedModelExists": poseModel.TrainedExists,
|
|
"trainedModelPath": poseModel.BestPath,
|
|
"modelSource": poseModel.Source,
|
|
},
|
|
|
|
"scene": map[string]any{
|
|
"source": "videomae_clip",
|
|
"usesVideoMAE": true,
|
|
"usesSceneCLIP": false,
|
|
"usesSceneKNN": false,
|
|
"usesResNet18KNN": false,
|
|
"usesLogisticRegression": false,
|
|
|
|
"predictsSexPosition": videoMAEModel.TrainedExists,
|
|
"predictsPeople": false,
|
|
"predictsGender": false,
|
|
"predictsBodyParts": false,
|
|
"predictsObjects": false,
|
|
"predictsClothing": false,
|
|
"predictsBoxes": false,
|
|
|
|
"feedbackCount": feedbackCount,
|
|
"eligibleCount": videoMAEEligibleCount,
|
|
"trainCount": videoMAETrainCount,
|
|
"valCount": videoMAEValCount,
|
|
"requiredTrain": minVideoMAETrainCount,
|
|
"requiredVal": minVideoMAEValCount,
|
|
"requiredCount": minVideoMAETrainCount,
|
|
"datasetReady": videoMAEDatasetReady,
|
|
"manifest": videoMAEManifest,
|
|
"dataReady": videoMAEDataReady,
|
|
"modelReady": videoMAEModel.EffectiveExists,
|
|
"modelExists": videoMAEModel.EffectiveExists,
|
|
"modelPath": videoMAEModel.EffectivePath,
|
|
"modelSource": videoMAEModel.Source,
|
|
"trainedModelExists": videoMAEModel.TrainedExists,
|
|
"trainedModelPath": videoMAEModel.BestPath,
|
|
},
|
|
|
|
"pipeline": map[string]any{
|
|
"variant": "YOLO26_VIDEO_CLIP_HYBRID",
|
|
|
|
"peopleSource": "yolo26_detector",
|
|
"genderSource": "yolo26_detector",
|
|
"sexPositionSource": "yolo26_pose+box_context+videomae_clip",
|
|
"bodyPartsSource": "yolo26_detector",
|
|
"objectsSource": "yolo26_detector",
|
|
"clothingSource": "yolo26_detector",
|
|
"boxesSource": "yolo26_detector",
|
|
|
|
"usesSceneKNNForDetection": false,
|
|
"usesSceneCLIP": false,
|
|
"usesSceneKNN": false,
|
|
"usesVideoMAE": true,
|
|
"usesYOLOForDetection": true,
|
|
"usesYOLOForSexPosition": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
func trainingApplyStatsModelInfo(root string, stats *TrainingStatsResponse) {
|
|
detectorAvailable := trainingStatsModelAvailableFor(root, "detector")
|
|
detectorInfo := trainingReadModelInfoFor(root, "detector")
|
|
poseAvailable := trainingStatsModelAvailableFor(root, "pose")
|
|
poseInfo := trainingReadModelInfoFor(root, "pose")
|
|
videoMAEModel := trainingResolveVideoMAEModel(root)
|
|
videoMAEInfo := trainingReadModelInfoFor(root, "videomae")
|
|
|
|
// modelAvailable/modelInfo bleiben aus Kompatibilitaetsgruenden der Detector.
|
|
stats.ModelAvailable = detectorAvailable
|
|
stats.ModelInfo = detectorInfo
|
|
stats.DetectorModelAvailable = detectorAvailable
|
|
stats.DetectorModelInfo = detectorInfo
|
|
stats.PoseModelAvailable = poseAvailable
|
|
stats.PoseModelInfo = poseInfo
|
|
stats.VideoMAEModelAvailable = videoMAEModel.EffectiveExists
|
|
stats.VideoMAEModelInfo = videoMAEInfo
|
|
}
|
|
|
|
func trainingStatsModelAvailable(root string) bool {
|
|
return trainingStatsModelAvailableFor(root, "detector")
|
|
}
|
|
|
|
func trainingStatsModelAvailableFor(root string, kind string) bool {
|
|
modelPath := filepath.Join(root, kind, "model", "best.pt")
|
|
if kind == "videomae" {
|
|
modelPath = filepath.Join(root, kind, "model", "config.json")
|
|
}
|
|
return fileExistsNonEmpty(modelPath)
|
|
}
|
|
|
|
// trainingReadModelInfo liest Versions-/Datums-Infos zum aktuell trainierten
|
|
// Detector-Modell. Datum/Version stammen primär aus status.json (vom Trainingsskript),
|
|
// Fallback ist die Änderungszeit der best.pt-Datei.
|
|
func trainingReadModelInfo(root string) *TrainingModelInfo {
|
|
return trainingReadModelInfoFor(root, "detector")
|
|
}
|
|
|
|
func trainingReadModelInfoFor(root string, kind string) *TrainingModelInfo {
|
|
modelPath := filepath.Join(root, kind, "model", "best.pt")
|
|
if kind == "videomae" {
|
|
modelPath = filepath.Join(root, kind, "model", "config.json")
|
|
}
|
|
|
|
fi, err := os.Stat(modelPath)
|
|
if err != nil || fi.IsDir() || fi.Size() <= 0 {
|
|
return nil
|
|
}
|
|
|
|
info := &TrainingModelInfo{
|
|
TrainedAt: fi.ModTime().UTC().Format(time.RFC3339),
|
|
TrainedAtMs: fi.ModTime().UnixMilli(),
|
|
}
|
|
|
|
statusPath := filepath.Join(root, kind, "model", "status.json")
|
|
if b, err := os.ReadFile(statusPath); err == nil {
|
|
var raw struct {
|
|
TrainedAt string `json:"trainedAt"`
|
|
Epochs int `json:"epochs"`
|
|
TrainSamples int `json:"trainSamples"`
|
|
ValSamples int `json:"valSamples"`
|
|
Imgsz int `json:"imgsz"`
|
|
Device string `json:"device"`
|
|
MAP50 float64 `json:"mAP50"`
|
|
MAP5095 float64 `json:"mAP5095"`
|
|
}
|
|
|
|
if json.Unmarshal(b, &raw) == nil {
|
|
if trimmed := strings.TrimSpace(raw.TrainedAt); trimmed != "" {
|
|
if t, err := time.Parse(time.RFC3339, trimmed); err == nil {
|
|
info.TrainedAt = t.UTC().Format(time.RFC3339)
|
|
info.TrainedAtMs = t.UnixMilli()
|
|
}
|
|
}
|
|
|
|
info.Epochs = raw.Epochs
|
|
info.TrainSamples = raw.TrainSamples
|
|
info.ValSamples = raw.ValSamples
|
|
info.Imgsz = raw.Imgsz
|
|
info.Device = strings.TrimSpace(raw.Device)
|
|
info.MAP50 = raw.MAP50
|
|
info.MAP5095 = raw.MAP5095
|
|
}
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
type TrainingHistoryEntry struct {
|
|
TrainedAt string `json:"trainedAt,omitempty"`
|
|
TrainedAtMs int64 `json:"trainedAtMs,omitempty"`
|
|
Target string `json:"target,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
DurationMs int64 `json:"durationMs,omitempty"`
|
|
Epochs int `json:"epochs,omitempty"`
|
|
TrainSamples int `json:"trainSamples,omitempty"`
|
|
ValSamples int `json:"valSamples,omitempty"`
|
|
Imgsz int `json:"imgsz,omitempty"`
|
|
Device string `json:"device,omitempty"`
|
|
MAP50 float64 `json:"map50,omitempty"`
|
|
MAP5095 float64 `json:"map5095,omitempty"`
|
|
PerformanceMode string `json:"performanceMode,omitempty"`
|
|
CPUCoreCount int `json:"cpuCoreCount,omitempty"`
|
|
CPUThreads int `json:"cpuThreads,omitempty"`
|
|
Workers int `json:"workers,omitempty"`
|
|
YoloBatchSize int `json:"yoloBatchSize,omitempty"`
|
|
LowPriority bool `json:"lowPriority,omitempty"`
|
|
AutoPauseEnabled bool `json:"autoPauseEnabled,omitempty"`
|
|
AutoPauseCPUPercent int `json:"autoPauseCpuPercent,omitempty"`
|
|
AutoPauseTemperatureC int `json:"autoPauseTemperatureC,omitempty"`
|
|
}
|
|
|
|
type TrainingHistoryResponse struct {
|
|
OK bool `json:"ok"`
|
|
Entries []TrainingHistoryEntry `json:"entries"`
|
|
}
|
|
|
|
func trainingHistoryPath(root string) string {
|
|
return filepath.Join(root, "detector", "training_history.jsonl")
|
|
}
|
|
|
|
// trainingAppendTargetHistory haengt nach einem erfolgreichen Trainingsziel einen
|
|
// Verlaufseintrag an. Alte History-Zeilen ohne Target bleiben weiterhin lesbar.
|
|
func trainingHistoryKindForTarget(target string) string {
|
|
switch strings.ToLower(strings.TrimSpace(target)) {
|
|
case "pose":
|
|
return "pose"
|
|
case "videomae", "video_mae", "scene":
|
|
return "videomae"
|
|
default:
|
|
return "detector"
|
|
}
|
|
}
|
|
|
|
func trainingAppendTargetHistory(root string, target string, status string, durationMs int64, runtimeOpts trainingRuntimeOptions, fallback TrainingHistoryEntry) {
|
|
kind := trainingHistoryKindForTarget(target)
|
|
info := trainingReadModelInfoFor(root, kind)
|
|
now := time.Now().UTC()
|
|
|
|
entry := TrainingHistoryEntry{
|
|
TrainedAt: now.Format(time.RFC3339),
|
|
TrainedAtMs: now.UnixMilli(),
|
|
Target: kind,
|
|
Status: strings.TrimSpace(status),
|
|
DurationMs: durationMs,
|
|
Epochs: fallback.Epochs,
|
|
TrainSamples: fallback.TrainSamples,
|
|
ValSamples: fallback.ValSamples,
|
|
Imgsz: fallback.Imgsz,
|
|
Device: strings.TrimSpace(fallback.Device),
|
|
MAP50: fallback.MAP50,
|
|
MAP5095: fallback.MAP5095,
|
|
PerformanceMode: runtimeOpts.PerformanceMode,
|
|
CPUCoreCount: runtimeOpts.CPUCoreCount,
|
|
CPUThreads: runtimeOpts.CPUThreads,
|
|
Workers: runtimeOpts.Workers,
|
|
YoloBatchSize: runtimeOpts.YoloBatchSize,
|
|
LowPriority: runtimeOpts.LowPriority,
|
|
AutoPauseEnabled: runtimeOpts.AutoPauseEnabled,
|
|
AutoPauseCPUPercent: runtimeOpts.AutoPauseCPUPercent,
|
|
AutoPauseTemperatureC: runtimeOpts.AutoPauseTemperatureC,
|
|
}
|
|
|
|
if info != nil {
|
|
if strings.TrimSpace(info.TrainedAt) != "" {
|
|
entry.TrainedAt = info.TrainedAt
|
|
}
|
|
if info.TrainedAtMs > 0 {
|
|
entry.TrainedAtMs = info.TrainedAtMs
|
|
}
|
|
if info.Epochs > 0 {
|
|
entry.Epochs = info.Epochs
|
|
}
|
|
if info.TrainSamples > 0 {
|
|
entry.TrainSamples = info.TrainSamples
|
|
}
|
|
if info.ValSamples > 0 {
|
|
entry.ValSamples = info.ValSamples
|
|
}
|
|
if info.Imgsz > 0 {
|
|
entry.Imgsz = info.Imgsz
|
|
}
|
|
if strings.TrimSpace(info.Device) != "" {
|
|
entry.Device = strings.TrimSpace(info.Device)
|
|
}
|
|
if info.MAP50 > 0 {
|
|
entry.MAP50 = info.MAP50
|
|
}
|
|
if info.MAP5095 > 0 {
|
|
entry.MAP5095 = info.MAP5095
|
|
}
|
|
}
|
|
|
|
if entry.DurationMs <= 0 {
|
|
job := trainingGetJobStatus()
|
|
if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(job.StartedAt)); err == nil {
|
|
if ms := now.Sub(startedAt).Milliseconds(); ms > 0 {
|
|
entry.DurationMs = ms
|
|
}
|
|
}
|
|
}
|
|
|
|
b, err := json.Marshal(entry)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
path := trainingHistoryPath(root)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
appLogln("⚠️ training history dir konnte nicht erstellt werden:", err)
|
|
return
|
|
}
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
appLogln("⚠️ training history konnte nicht geöffnet werden:", err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
if _, err := f.Write(append(b, '\n')); err != nil {
|
|
appLogln("⚠️ training history konnte nicht geschrieben werden:", err)
|
|
}
|
|
}
|
|
|
|
// trainingReadHistory liest die Verlaufseinträge, neueste zuerst.
|
|
func trainingReadHistory(root string, limit int) []TrainingHistoryEntry {
|
|
b, err := os.ReadFile(trainingHistoryPath(root))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
lines := strings.Split(string(b), "\n")
|
|
entries := make([]TrainingHistoryEntry, 0, len(lines))
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var e TrainingHistoryEntry
|
|
if json.Unmarshal([]byte(line), &e) != nil {
|
|
continue
|
|
}
|
|
|
|
entries = append(entries, e)
|
|
}
|
|
|
|
// Datei ist chronologisch → umdrehen, damit der neueste Lauf oben steht.
|
|
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
|
|
entries[i], entries[j] = entries[j], entries[i]
|
|
}
|
|
|
|
if limit > 0 && len(entries) > limit {
|
|
entries = entries[:limit]
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
func trainingHistoryHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
entries := trainingReadHistory(root, 50)
|
|
if entries == nil {
|
|
entries = []TrainingHistoryEntry{}
|
|
}
|
|
|
|
trainingWriteJSON(w, http.StatusOK, TrainingHistoryResponse{
|
|
OK: true,
|
|
Entries: entries,
|
|
})
|
|
}
|
|
|
|
func trainingConfidenceFromScore(score float64) TrainingConfidence {
|
|
if math.IsNaN(score) || math.IsInf(score, 0) {
|
|
score = 0
|
|
}
|
|
|
|
score = clamp01(score)
|
|
|
|
level := "none"
|
|
label := "Keine"
|
|
|
|
switch {
|
|
case score >= 0.75:
|
|
level = "high"
|
|
label = "Hoch"
|
|
case score >= 0.45:
|
|
level = "mid"
|
|
label = "Mittel"
|
|
case score > 0:
|
|
level = "low"
|
|
label = "Niedrig"
|
|
}
|
|
|
|
return TrainingConfidence{
|
|
Score: score,
|
|
Level: level,
|
|
Label: label,
|
|
}
|
|
}
|
|
|
|
func trainingLabelConfidence(count int, target int) TrainingConfidence {
|
|
if target <= 0 {
|
|
target = 10
|
|
}
|
|
|
|
if count <= 0 {
|
|
return trainingConfidenceFromScore(0)
|
|
}
|
|
|
|
// Grobe Datenabdeckung: target erreicht = 100%.
|
|
// sqrt macht kleine Mengen etwas weniger hart, aber 1 Treffer bleibt niedrig.
|
|
score := math.Sqrt(float64(count) / float64(target*2))
|
|
|
|
return trainingConfidenceFromScore(score)
|
|
}
|
|
|
|
func trainingSaturationScore(value int, target int) float64 {
|
|
if value <= 0 || target <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// Sanfter Anstieg, aber nie über 1.
|
|
return clamp01(math.Sqrt(float64(value) / float64(target)))
|
|
}
|
|
|
|
func trainingAverageLabelConfidence(labels TrainingStatsLabels) float64 {
|
|
values := []float64{}
|
|
|
|
appendScores := func(items []TrainingLabelStat) {
|
|
for _, item := range items {
|
|
values = append(values, clamp01(item.Confidence.Score))
|
|
}
|
|
}
|
|
|
|
appendScores(labels.People)
|
|
appendScores(labels.SexPositions)
|
|
appendScores(labels.BodyParts)
|
|
appendScores(labels.Objects)
|
|
appendScores(labels.Clothing)
|
|
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
|
|
sum := 0.0
|
|
for _, value := range values {
|
|
sum += value
|
|
}
|
|
|
|
return clamp01(sum / float64(len(values)))
|
|
}
|
|
|
|
func trainingOverallConfidence(
|
|
feedbackCount int,
|
|
boxCount int,
|
|
acceptedCount int,
|
|
correctedCount int,
|
|
labels TrainingStatsLabels,
|
|
) TrainingConfidence {
|
|
if feedbackCount <= 0 {
|
|
return trainingConfidenceFromScore(0)
|
|
}
|
|
|
|
// Datenmenge: 300 Feedbacks sind grob "voll", darunter anteilig.
|
|
feedbackScore := trainingSaturationScore(feedbackCount, 300)
|
|
|
|
// Detector-Daten: 1000 Boxen sind grob "voll", darunter anteilig.
|
|
boxScore := trainingSaturationScore(boxCount, 1000)
|
|
|
|
// Label-Abdeckung aus den einzelnen Label-Confidence-Werten.
|
|
labelScore := trainingAverageLabelConfidence(labels)
|
|
|
|
// Modell-/Prediction-Zustimmung:
|
|
// Viele "Passt so"-Antworten bedeuten, dass die Vorhersagen brauchbar sind.
|
|
// Bei 4/229 ist dieser Teil bewusst sehr niedrig.
|
|
agreementScore := 0.0
|
|
decisionCount := acceptedCount + correctedCount
|
|
if decisionCount > 0 {
|
|
agreementScore = clamp01(float64(acceptedCount) / float64(decisionCount))
|
|
}
|
|
|
|
// Korrekturquote als zusätzlicher Dämpfer.
|
|
// 98% korrigiert soll die Gesamt-Confidence sichtbar drücken,
|
|
// aber nicht alle gesammelten Daten entwerten.
|
|
correctionRate := 0.0
|
|
if decisionCount > 0 {
|
|
correctionRate = clamp01(float64(correctedCount) / float64(decisionCount))
|
|
}
|
|
|
|
correctionPenalty := 1.0 - math.Min(0.45, correctionRate*0.45)
|
|
|
|
// Gesamt:
|
|
// - Datenmenge zählt viel
|
|
// - Boxen und Label-Abdeckung zählen mittel
|
|
// - echte Modell-Zustimmung zählt bewusst mit rein
|
|
score :=
|
|
feedbackScore*0.30 +
|
|
boxScore*0.25 +
|
|
labelScore*0.25 +
|
|
agreementScore*0.20
|
|
|
|
score *= correctionPenalty
|
|
|
|
return trainingConfidenceFromScore(score)
|
|
}
|
|
|
|
func stringSet(values []string) map[string]bool {
|
|
out := map[string]bool{}
|
|
|
|
for _, value := range values {
|
|
clean := strings.TrimSpace(value)
|
|
if clean == "" {
|
|
continue
|
|
}
|
|
|
|
out[clean] = true
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingRecognitionEnabled() bool {
|
|
return getSettings().TrainingRecognitionEnabled
|
|
}
|
|
|
|
func trainingDetectorEpochs() int {
|
|
epochs := getSettings().TrainingDetectorEpochs
|
|
|
|
if epochs < 1 {
|
|
return 60
|
|
}
|
|
|
|
if epochs > 300 {
|
|
return 300
|
|
}
|
|
|
|
return epochs
|
|
}
|
|
|
|
func trainingDeleteAllHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
|
|
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := os.RemoveAll(root); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, fmt.Sprintf("Trainingsdaten konnten nicht gelöscht werden: %v", err))
|
|
return
|
|
}
|
|
|
|
// Basisordner direkt wieder anlegen, damit die nächsten API-Aufrufe sauber funktionieren.
|
|
if _, err := trainingRootDir(); err != nil {
|
|
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
|
*s = TrainingJobStatus{}
|
|
})
|
|
|
|
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"message": "Alle Trainingsdaten wurden gelöscht.",
|
|
})
|
|
}
|
|
|
|
func trainingCountDetectorSamples(imagesDir string, labelsDir string) int {
|
|
imageExts := map[string]bool{
|
|
".jpg": true,
|
|
".jpeg": true,
|
|
".png": true,
|
|
".webp": true,
|
|
}
|
|
|
|
entries, err := os.ReadDir(imagesDir)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
count := 0
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
if !imageExts[ext] {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
|
labelPath := filepath.Join(labelsDir, id+".txt")
|
|
|
|
if fileExists(labelPath) {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
func trainingCountPositiveDetectorSamples(imagesDir string, labelsDir string) int {
|
|
imageExts := map[string]bool{
|
|
".jpg": true,
|
|
".jpeg": true,
|
|
".png": true,
|
|
".webp": true,
|
|
}
|
|
|
|
entries, err := os.ReadDir(imagesDir)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
count := 0
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
if !imageExts[ext] {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
|
labelPath := filepath.Join(labelsDir, id+".txt")
|
|
|
|
if fileExistsNonEmpty(labelPath) {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
func trainingWriteDetectorDatasetYAML(root string) error {
|
|
datasetDir := filepath.Join(root, "detector", "dataset")
|
|
|
|
absDatasetDir, err := filepath.Abs(datasetDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
namesYAML, err := trainingDetectorDatasetNamesYAML()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
path := filepath.Join(datasetDir, "dataset.yaml")
|
|
yoloPath := filepath.ToSlash(absDatasetDir)
|
|
|
|
body := fmt.Sprintf(`path: %s
|
|
train: images/train
|
|
val: images/val
|
|
|
|
names:
|
|
%s`, yoloPath, namesYAML)
|
|
|
|
return os.WriteFile(path, []byte(body), 0644)
|
|
}
|
|
|
|
func trainingWritePoseDatasetYAML(root string) error {
|
|
datasetDir := filepath.Join(root, "pose", "dataset")
|
|
|
|
absDatasetDir, err := filepath.Abs(datasetDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
namesYAML, err := trainingPoseDatasetNamesYAML()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
path := filepath.Join(datasetDir, "dataset.yaml")
|
|
yoloPath := filepath.ToSlash(absDatasetDir)
|
|
|
|
body := fmt.Sprintf(`path: %s
|
|
train: images/train
|
|
val: images/val
|
|
|
|
kpt_shape: [17, 3]
|
|
flip_idx: [0, 2, 1, 4, 3, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16, 15]
|
|
|
|
names:
|
|
%s`, yoloPath, namesYAML)
|
|
|
|
return os.WriteFile(path, []byte(body), 0644)
|
|
}
|
|
|
|
func trainingEnsureDetectorDirs(root string) error {
|
|
dirs := []string{
|
|
filepath.Join(root, "detector"),
|
|
filepath.Join(root, "detector", "dataset"),
|
|
filepath.Join(root, "detector", "dataset", "images"),
|
|
filepath.Join(root, "detector", "dataset", "images", "train"),
|
|
filepath.Join(root, "detector", "dataset", "images", "val"),
|
|
filepath.Join(root, "detector", "dataset", "labels"),
|
|
filepath.Join(root, "detector", "dataset", "labels", "train"),
|
|
filepath.Join(root, "detector", "dataset", "labels", "val"),
|
|
filepath.Join(root, "detector", "runs"),
|
|
}
|
|
|
|
for _, dir := range dirs {
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := trainingWriteDetectorDatasetYAML(root); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func trainingEnsurePoseDirs(root string) error {
|
|
dirs := []string{
|
|
filepath.Join(root, "pose"),
|
|
filepath.Join(root, "pose", "dataset"),
|
|
filepath.Join(root, "pose", "dataset", "images"),
|
|
filepath.Join(root, "pose", "dataset", "images", "train"),
|
|
filepath.Join(root, "pose", "dataset", "images", "val"),
|
|
filepath.Join(root, "pose", "dataset", "labels"),
|
|
filepath.Join(root, "pose", "dataset", "labels", "train"),
|
|
filepath.Join(root, "pose", "dataset", "labels", "val"),
|
|
filepath.Join(root, "pose", "runs"),
|
|
}
|
|
|
|
for _, dir := range dirs {
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := trainingWritePoseDatasetYAML(root); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func trainingCreateNextSample() (*TrainingSample, error) {
|
|
settings := getSettings()
|
|
|
|
doneDir := strings.TrimSpace(settings.DoneDir)
|
|
if doneDir == "" {
|
|
return nil, errors.New("doneDir ist leer")
|
|
}
|
|
|
|
videoPath, err := trainingPickRandomVideo(doneDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
duration := trainingProbeDurationSeconds(videoPath)
|
|
second := trainingRandomSecond(duration)
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := trainingEnsureDetectorDirs(root); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := trainingMakeSampleID(videoPath, second)
|
|
framePath := filepath.Join(root, "frames", id+".jpg")
|
|
|
|
if err := trainingExtractFrame(videoPath, framePath, second); err != nil {
|
|
// Fallback auf Sekunde 0, falls random second nicht funktioniert.
|
|
second = 0
|
|
id = trainingMakeSampleID(videoPath, second)
|
|
framePath = filepath.Join(root, "frames", id+".jpg")
|
|
|
|
if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil {
|
|
return nil, appErrorf("frame extraction failed: %v / fallback: %w", err, err2)
|
|
}
|
|
}
|
|
|
|
prediction := trainingPredictFrame(framePath)
|
|
|
|
var sourceSizeBytes int64
|
|
if st, err := os.Stat(videoPath); err == nil && st != nil && !st.IsDir() {
|
|
sourceSizeBytes = st.Size()
|
|
}
|
|
|
|
sample := &TrainingSample{
|
|
SampleID: id,
|
|
FrameURL: "/api/training/frame?id=" + id,
|
|
SourceFile: filepath.Base(videoPath),
|
|
SourcePath: videoPath,
|
|
SourceSizeBytes: sourceSizeBytes,
|
|
Second: second,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Prediction: prediction,
|
|
}
|
|
|
|
if err := trainingWriteSample(root, sample); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return sample, nil
|
|
}
|
|
|
|
func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64, requestID string) (*TrainingSample, error) {
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
candidates, err := trainingReadValidUncertainCandidates(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Ziel:
|
|
// Vor jeder Auswahl sollen wieder 5 Kandidaten verglichen werden.
|
|
// Wenn noch 4 aus der Queue übrig sind, wird genau 1 neues Bild extrahiert.
|
|
// Wenn keine Queue existiert, wird ein kompletter 5er-Batch aufgebaut.
|
|
missing := trainingUncertainCandidateCount - len(candidates)
|
|
if missing < 1 {
|
|
// Trotzdem mindestens 1 neues Bild nachziehen,
|
|
// damit die Auswahl nach jedem Speichern frisch bleibt.
|
|
missing = 1
|
|
}
|
|
|
|
candidateWindowTotal := trainingUncertainCandidateCount
|
|
|
|
const stepsPerCandidate = 4
|
|
totalSteps := missing*stepsPerCandidate + 1
|
|
|
|
errs := []string{}
|
|
|
|
for i := 0; i < missing; i++ {
|
|
attempt := i + 1
|
|
stepStart := i*stepsPerCandidate + 1
|
|
|
|
prefix := fmt.Sprintf("Kandidat %d/%d: ", attempt, candidateWindowTotal)
|
|
|
|
candidate, err := trainingCreateUncertainCandidateWithProgress(
|
|
root,
|
|
startedAtMs,
|
|
requestID,
|
|
stepStart,
|
|
totalSteps,
|
|
prefix,
|
|
)
|
|
|
|
if err != nil {
|
|
errs = append(errs, err.Error())
|
|
|
|
trainingPublishAnalysisStep(
|
|
requestID,
|
|
startedAtMs,
|
|
stepStart+stepsPerCandidate-1,
|
|
totalSteps,
|
|
"",
|
|
fmt.Sprintf("Kandidat %d/%d fehlgeschlagen…", attempt, candidateWindowTotal),
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
candidates = append(candidates, *candidate)
|
|
|
|
trainingPublishAnalysisStep(
|
|
requestID,
|
|
startedAtMs,
|
|
stepStart+stepsPerCandidate-1,
|
|
totalSteps,
|
|
candidate.sample.SourceFile,
|
|
fmt.Sprintf(
|
|
"Kandidat %d/%d bewertet · Unsicherheit %.0f%%",
|
|
attempt,
|
|
candidateWindowTotal,
|
|
candidate.score*100,
|
|
),
|
|
)
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
if len(errs) > 0 {
|
|
return nil, errors.New(strings.Join(errs, "; "))
|
|
}
|
|
|
|
return nil, errors.New("keine unsicheren Trainingskandidaten gefunden")
|
|
}
|
|
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
if candidates[i].score == candidates[j].score {
|
|
return candidates[i].sample.CreatedAt < candidates[j].sample.CreatedAt
|
|
}
|
|
|
|
return candidates[i].score > candidates[j].score
|
|
})
|
|
|
|
best := candidates[0]
|
|
remaining := candidates[1:]
|
|
|
|
if len(remaining) > trainingUncertainCandidateCount-1 {
|
|
remaining = remaining[:trainingUncertainCandidateCount-1]
|
|
}
|
|
|
|
if err := trainingWriteUncertainCandidateQueue(root, remaining); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
trainingPublishAnalysisStep(
|
|
requestID,
|
|
startedAtMs,
|
|
totalSteps,
|
|
totalSteps,
|
|
best.sample.SourceFile,
|
|
fmt.Sprintf(
|
|
"Unsicherster Kandidat gewählt · Score %.0f%% · Fenster %d/%d",
|
|
best.score*100,
|
|
len(remaining)+1,
|
|
trainingUncertainCandidateCount,
|
|
),
|
|
)
|
|
|
|
best.sample.UncertaintyScore = best.score
|
|
|
|
return best.sample, nil
|
|
}
|
|
|
|
func reloadAIServerModelAfterTraining() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, aiServerURL()+"/reload", nil)
|
|
if err != nil {
|
|
appLogln("⚠️ AI Server Reload Request konnte nicht gebaut werden:", err)
|
|
return
|
|
}
|
|
|
|
addAIServerAuth(req)
|
|
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
appLogln("⚠️ AI Server Reload fehlgeschlagen:", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
appLogln("⚠️ AI Server Reload HTTP", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
return
|
|
}
|
|
|
|
appLogln("✅ AI Server Modell nach Training neu geladen:", strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
func trainingDeleteSampleFiles(root string, sampleID string) {
|
|
sampleID = strings.TrimSpace(sampleID)
|
|
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
|
|
return
|
|
}
|
|
|
|
_ = os.Remove(filepath.Join(root, "samples", sampleID+".json"))
|
|
_ = os.Remove(filepath.Join(root, "frames", sampleID+".jpg"))
|
|
}
|
|
|
|
func trainingPredictionUncertaintyScore(pred TrainingPrediction) float64 {
|
|
if !pred.ModelAvailable {
|
|
return 0.10
|
|
}
|
|
|
|
scores := []float64{}
|
|
|
|
addScore := func(score float64) {
|
|
if math.IsNaN(score) || math.IsInf(score, 0) {
|
|
return
|
|
}
|
|
|
|
if score <= 0 {
|
|
return
|
|
}
|
|
|
|
scores = append(scores, clamp01(score))
|
|
}
|
|
|
|
if !isNoSexPositionLabel(pred.SexPosition) {
|
|
addScore(pred.SexPositionScore)
|
|
}
|
|
|
|
for _, box := range pred.Boxes {
|
|
addScore(box.Score)
|
|
}
|
|
|
|
if len(pred.Boxes) == 0 {
|
|
for _, item := range pred.BodyPartsPresent {
|
|
addScore(item.Score)
|
|
}
|
|
|
|
for _, item := range pred.ObjectsPresent {
|
|
addScore(item.Score)
|
|
}
|
|
|
|
for _, item := range pred.ClothingPresent {
|
|
addScore(item.Score)
|
|
}
|
|
}
|
|
|
|
if len(scores) == 0 {
|
|
// Modell ist verfügbar, erkennt aber nichts.
|
|
// Das kann ein nützliches Hard-Negative oder ein False-Negative sein.
|
|
return 0.35
|
|
}
|
|
|
|
sum := 0.0
|
|
|
|
for _, score := range scores {
|
|
// Höchste Unsicherheit ungefähr im mittleren Bereich.
|
|
// 0.55 ist absichtlich etwas niedriger als 0.75,
|
|
// damit Low/Mid-Confidence-Fälle bevorzugt werden.
|
|
distance := math.Abs(score - 0.55)
|
|
uncertainty := 1.0 - distance/0.55
|
|
sum += clamp01(uncertainty)
|
|
}
|
|
|
|
avg := sum / float64(len(scores))
|
|
|
|
// Viele Boxen mit mittlerer Confidence sind besonders wertvoll.
|
|
boxBonus := math.Min(0.12, float64(len(pred.Boxes))*0.025)
|
|
|
|
// Sehr niedrige Confidence soll ebenfalls auftauchen,
|
|
// aber nicht alle Ergebnisse dominieren.
|
|
lowConfidenceBonus := 0.0
|
|
for _, score := range scores {
|
|
if score >= 0.25 && score <= 0.75 {
|
|
lowConfidenceBonus = 0.08
|
|
break
|
|
}
|
|
}
|
|
|
|
return clamp01(avg + boxBonus + lowConfidenceBonus)
|
|
}
|
|
|
|
func trainingCreateNextSampleWithProgress(startedAtMs int64, requestID string) (*TrainingSample, error) {
|
|
return trainingCreateNextSampleWithProgressRange(
|
|
startedAtMs,
|
|
requestID,
|
|
1,
|
|
4,
|
|
"",
|
|
)
|
|
}
|
|
|
|
func trainingCreateNextSampleWithProgressRange(
|
|
startedAtMs int64,
|
|
requestID string,
|
|
stepStart int,
|
|
stepTotal int,
|
|
prefix string,
|
|
) (*TrainingSample, error) {
|
|
publishStep := func(localStep int, sourceFile string, message string) {
|
|
trainingPublishAnalysisStep(
|
|
requestID,
|
|
startedAtMs,
|
|
stepStart+localStep-1,
|
|
stepTotal,
|
|
sourceFile,
|
|
prefix+message,
|
|
)
|
|
}
|
|
|
|
publishStep(1, "", "Video wird ausgewählt…")
|
|
|
|
settings := getSettings()
|
|
|
|
doneDir := strings.TrimSpace(settings.DoneDir)
|
|
if doneDir == "" {
|
|
return nil, errors.New("doneDir ist leer")
|
|
}
|
|
|
|
videoPath, err := trainingPickRandomVideo(doneDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sourceFile := filepath.Base(videoPath)
|
|
previewURL := ""
|
|
|
|
publishStep = func(localStep int, sourceFile string, message string) {
|
|
trainingPublishAnalysisStepWithPreview(
|
|
requestID,
|
|
startedAtMs,
|
|
stepStart+localStep-1,
|
|
stepTotal,
|
|
sourceFile,
|
|
previewURL,
|
|
prefix+message,
|
|
)
|
|
}
|
|
|
|
publishStep(2, sourceFile, "Bild wird extrahiert…")
|
|
|
|
duration := trainingProbeDurationSeconds(videoPath)
|
|
second := trainingRandomSecond(duration)
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := trainingEnsureDetectorDirs(root); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := trainingMakeSampleID(videoPath, second)
|
|
framePath := filepath.Join(root, "frames", id+".jpg")
|
|
|
|
if err := trainingExtractFrame(videoPath, framePath, second); err != nil {
|
|
publishStep(2, sourceFile, "Bild wird erneut bei Sekunde 0 extrahiert…")
|
|
|
|
second = 0
|
|
id = trainingMakeSampleID(videoPath, second)
|
|
framePath = filepath.Join(root, "frames", id+".jpg")
|
|
|
|
if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil {
|
|
return nil, appErrorf("frame extraction failed: %v / fallback: %w", err, err2)
|
|
}
|
|
}
|
|
|
|
previewURL = "/api/training/frame?id=" + url.QueryEscape(id)
|
|
|
|
publishStep(3, sourceFile, "Bild wird analysiert…")
|
|
|
|
prediction := trainingPredictFrame(framePath)
|
|
|
|
var sourceSizeBytes int64
|
|
if st, err := os.Stat(videoPath); err == nil && st != nil && !st.IsDir() {
|
|
sourceSizeBytes = st.Size()
|
|
}
|
|
|
|
sample := &TrainingSample{
|
|
SampleID: id,
|
|
FrameURL: "/api/training/frame?id=" + id,
|
|
SourceFile: sourceFile,
|
|
SourcePath: videoPath,
|
|
SourceSizeBytes: sourceSizeBytes,
|
|
Second: second,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Prediction: prediction,
|
|
}
|
|
|
|
publishStep(4, sourceFile, "Analyse-Ergebnis wird gespeichert…")
|
|
|
|
if err := trainingWriteSample(root, sample); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return sample, nil
|
|
}
|
|
|
|
func trainingPickRandomVideo(doneDir string) (string, error) {
|
|
extOK := map[string]bool{
|
|
".mp4": true,
|
|
}
|
|
|
|
doneDir = strings.TrimSpace(doneDir)
|
|
if doneDir == "" {
|
|
return "", errors.New("doneDir ist leer")
|
|
}
|
|
|
|
doneAbs, err := filepath.Abs(doneDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var files []string
|
|
|
|
err = filepath.WalkDir(doneAbs, func(path string, d os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(doneAbs, path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
rel = filepath.Clean(rel)
|
|
|
|
// Root "done" selbst erlauben.
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
|
|
parts := strings.Split(rel, string(os.PathSeparator))
|
|
top := strings.ToLower(strings.TrimSpace(parts[0]))
|
|
name := strings.ToLower(strings.TrimSpace(d.Name()))
|
|
|
|
if d.IsDir() {
|
|
// Nur diese Bereiche fürs Trainingsbild:
|
|
// - done/
|
|
// - done/keep/...
|
|
//
|
|
// Alles andere unter done wird ignoriert, z.B.:
|
|
// .postwork_tmp, .trash, generated, training, sonstige Temp-Ordner.
|
|
if top != "keep" {
|
|
return filepath.SkipDir
|
|
}
|
|
|
|
// Innerhalb von keep trotzdem versteckte/temporäre Ordner überspringen.
|
|
if name == ".trash" ||
|
|
name == ".postwork_tmp" ||
|
|
name == "training" ||
|
|
name == "generated" ||
|
|
strings.HasPrefix(name, ".") {
|
|
return filepath.SkipDir
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Dateien nur direkt in done/ oder unter done/keep/... erlauben.
|
|
if len(parts) > 1 && top != "keep" {
|
|
return nil
|
|
}
|
|
|
|
// Keine versteckten/temp-Dateien verwenden.
|
|
if strings.HasPrefix(name, ".") ||
|
|
strings.Contains(name, ".tmp.") ||
|
|
strings.Contains(name, ".part") {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if extOK[ext] {
|
|
files = append(files, path)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
return "", errors.New("keine Videos in done oder done/keep gefunden")
|
|
}
|
|
|
|
return files[rand.Intn(len(files))], nil
|
|
}
|
|
|
|
func trainingExtractFrame(videoPath string, framePath string, second float64) error {
|
|
settings := getSettings()
|
|
|
|
ffmpeg := strings.TrimSpace(settings.FFmpegPath)
|
|
if ffmpeg == "" {
|
|
ffmpeg = "ffmpeg"
|
|
}
|
|
|
|
_ = os.Remove(framePath)
|
|
|
|
ss := strconv.FormatFloat(math.Max(0, second), 'f', 3, 64)
|
|
|
|
cmd := exec.Command(
|
|
ffmpeg,
|
|
"-hide_banner",
|
|
"-loglevel", "error",
|
|
"-ss", ss,
|
|
"-i", videoPath,
|
|
"-frames:v", "1",
|
|
"-q:v", "2",
|
|
"-y",
|
|
framePath,
|
|
)
|
|
|
|
hideCommandWindow(cmd)
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return appErrorf("%w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
if st, err := os.Stat(framePath); err != nil || st.Size() == 0 {
|
|
return errors.New("ffmpeg erzeugte kein Frame")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func trainingPredictFrame(framePath string) TrainingPrediction {
|
|
settings := getSettings()
|
|
if !settings.TrainingRecognitionEnabled {
|
|
return trainingEmptyPrediction("recognition_disabled")
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
appLogln("⚠️ training predict root error:", err)
|
|
return trainingEmptyPrediction("root_error")
|
|
}
|
|
|
|
det := trainingPredictDetector(root, framePath)
|
|
pred := trainingPredictionFromDetector(det)
|
|
|
|
pose := trainingPredictPose(root, framePath)
|
|
pred = trainingApplyPoseToPrediction(pred, pose)
|
|
|
|
return pred
|
|
}
|
|
|
|
func trainingPredictFrameDetectorOnly(framePath string) TrainingPrediction {
|
|
settings := getSettings()
|
|
if !settings.TrainingRecognitionEnabled {
|
|
return trainingEmptyPrediction("recognition_disabled")
|
|
}
|
|
|
|
root, err := trainingRootDir()
|
|
if err != nil {
|
|
appLogln("⚠️ training predict root error:", err)
|
|
return trainingEmptyPrediction("root_error")
|
|
}
|
|
|
|
det := trainingPredictDetector(root, framePath)
|
|
return trainingPredictionFromDetector(det)
|
|
}
|
|
|
|
func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPrediction {
|
|
rawBoxes := det.Boxes
|
|
if rawBoxes == nil {
|
|
rawBoxes = []TrainingBox{}
|
|
}
|
|
|
|
pred := TrainingPrediction{
|
|
ModelAvailable: det.Available,
|
|
Source: det.Source,
|
|
SexPosition: trainingNoSexPositionLabel,
|
|
SexPositionScore: 0,
|
|
PeoplePresent: []TrainingScoredLabel{},
|
|
BodyPartsPresent: []TrainingScoredLabel{},
|
|
ObjectsPresent: []TrainingScoredLabel{},
|
|
ClothingPresent: []TrainingScoredLabel{},
|
|
Boxes: []TrainingBox{},
|
|
}
|
|
|
|
if pred.Source == "" {
|
|
if det.Available {
|
|
pred.Source = "yolo26_detector"
|
|
} else {
|
|
pred.Source = "detector_missing"
|
|
}
|
|
}
|
|
|
|
grouped, err := trainingGroupedLabels()
|
|
if err != nil {
|
|
appLogln("⚠️ detector label grouping failed:", err)
|
|
return pred
|
|
}
|
|
|
|
peopleSet := map[string]bool{}
|
|
for _, label := range grouped.People {
|
|
clean := strings.TrimSpace(label)
|
|
if clean != "" {
|
|
peopleSet[clean] = true
|
|
}
|
|
}
|
|
|
|
detectionSet := map[string]bool{}
|
|
|
|
for _, label := range grouped.BodyParts {
|
|
clean := strings.TrimSpace(label)
|
|
if clean != "" {
|
|
detectionSet[clean] = true
|
|
}
|
|
}
|
|
|
|
for _, label := range grouped.Objects {
|
|
clean := strings.TrimSpace(label)
|
|
if clean != "" {
|
|
detectionSet[clean] = true
|
|
}
|
|
}
|
|
|
|
for _, label := range grouped.Clothing {
|
|
clean := strings.TrimSpace(label)
|
|
if clean != "" {
|
|
detectionSet[clean] = true
|
|
}
|
|
}
|
|
|
|
visibleBoxes := []TrainingBox{}
|
|
|
|
for _, box := range rawBoxes {
|
|
if box.Score > 0 && box.Score < 0.25 {
|
|
continue
|
|
}
|
|
|
|
label := strings.TrimSpace(box.Label)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
|
|
box.Label = label
|
|
|
|
if peopleSet[label] {
|
|
visibleBoxes = append(visibleBoxes, box)
|
|
continue
|
|
}
|
|
|
|
if detectionSet[label] {
|
|
visibleBoxes = append(visibleBoxes, box)
|
|
}
|
|
}
|
|
|
|
pred.Boxes = visibleBoxes
|
|
|
|
pred.PeoplePresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.People)
|
|
pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.BodyParts)
|
|
pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Objects)
|
|
pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Clothing)
|
|
|
|
return pred
|
|
}
|
|
|
|
func trainingPredictDetector(root string, framePath string) TrainingDetectorPrediction {
|
|
python := trainingPythonExe()
|
|
script := trainingScriptPath("predict_detector_model.py")
|
|
|
|
model := trainingResolveDetectorModel(root)
|
|
if !model.EffectiveExists {
|
|
return TrainingDetectorPrediction{
|
|
Available: false,
|
|
Source: model.Source,
|
|
Boxes: []TrainingBox{},
|
|
}
|
|
}
|
|
|
|
confValues := []string{"0.30"}
|
|
|
|
best := TrainingDetectorPrediction{
|
|
Available: true,
|
|
Source: "yolo26_detector",
|
|
Boxes: []TrainingBox{},
|
|
}
|
|
|
|
for _, conf := range confValues {
|
|
cmd := exec.Command(
|
|
python,
|
|
script,
|
|
"--root", root,
|
|
"--image", framePath,
|
|
"--model", model.EffectivePath,
|
|
"--conf", conf,
|
|
"--imgsz", "640",
|
|
)
|
|
|
|
hideCommandWindow(cmd)
|
|
|
|
var stdout strings.Builder
|
|
var stderr strings.Builder
|
|
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
|
|
outText := strings.TrimSpace(stdout.String())
|
|
errText := strings.TrimSpace(stderr.String())
|
|
|
|
if errText != "" {
|
|
appLogln("🔎 detector stderr:", errText)
|
|
}
|
|
|
|
if err != nil {
|
|
appLogln("⚠️ detector predict failed")
|
|
appLogln(" conf:", conf)
|
|
appLogln(" error:", err)
|
|
appLogln(" stdout:", outText)
|
|
appLogln(" stderr:", errText)
|
|
continue
|
|
}
|
|
|
|
if outText == "" {
|
|
appLogln("⚠️ detector predict empty stdout")
|
|
appLogln(" conf:", conf)
|
|
appLogln(" stderr:", errText)
|
|
continue
|
|
}
|
|
|
|
var det TrainingDetectorPrediction
|
|
if err := json.Unmarshal([]byte(outText), &det); err != nil {
|
|
appLogln("⚠️ detector predict json failed:", err)
|
|
appLogln(" conf:", conf)
|
|
appLogln(" stdout:", outText)
|
|
appLogln(" stderr:", errText)
|
|
continue
|
|
}
|
|
|
|
if det.Boxes == nil {
|
|
det.Boxes = []TrainingBox{}
|
|
}
|
|
|
|
det.Source = model.Source
|
|
|
|
best = det
|
|
|
|
if len(det.Boxes) > 0 {
|
|
return det
|
|
}
|
|
}
|
|
|
|
if best.Boxes == nil {
|
|
best.Boxes = []TrainingBox{}
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
func trainingScoredLabelsFromDetectorBoxes(
|
|
boxes []TrainingBox,
|
|
group []string,
|
|
) []TrainingScoredLabel {
|
|
groupSet := map[string]bool{}
|
|
|
|
for _, label := range group {
|
|
clean := strings.TrimSpace(label)
|
|
if clean != "" {
|
|
groupSet[clean] = true
|
|
}
|
|
}
|
|
|
|
best := map[string]float64{}
|
|
|
|
for _, box := range boxes {
|
|
label := strings.TrimSpace(box.Label)
|
|
if label == "" || !groupSet[label] {
|
|
continue
|
|
}
|
|
|
|
score := box.Score
|
|
if score <= 0 {
|
|
score = 1
|
|
}
|
|
|
|
if old, ok := best[label]; !ok || score > old {
|
|
best[label] = score
|
|
}
|
|
}
|
|
|
|
out := make([]TrainingScoredLabel, 0, len(best))
|
|
|
|
for label, score := range best {
|
|
out = append(out, TrainingScoredLabel{
|
|
Label: label,
|
|
Score: score,
|
|
})
|
|
}
|
|
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Score == out[j].Score {
|
|
return out[i].Label < out[j].Label
|
|
}
|
|
|
|
return out[i].Score > out[j].Score
|
|
})
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDetectorPrediction) TrainingPrediction {
|
|
boxes := det.Boxes
|
|
if boxes == nil {
|
|
boxes = []TrainingBox{}
|
|
}
|
|
|
|
grouped, err := trainingGroupedLabels()
|
|
if err != nil {
|
|
appLogln("⚠️ detector label grouping failed:", err)
|
|
|
|
pred.Boxes = boxes
|
|
pred.BodyPartsPresent = []TrainingScoredLabel{}
|
|
pred.ObjectsPresent = []TrainingScoredLabel{}
|
|
pred.ClothingPresent = []TrainingScoredLabel{}
|
|
return pred
|
|
}
|
|
|
|
// Wichtig:
|
|
// Ab jetzt kommen diese drei Bereiche ausschließlich vom Object Detector.
|
|
// Kein Scene-KNN-Fallback, damit keine Labels ohne Box angezeigt werden.
|
|
pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.BodyParts)
|
|
pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Objects)
|
|
pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Clothing)
|
|
pred.Boxes = boxes
|
|
|
|
if det.Available {
|
|
if pred.Source == "" {
|
|
pred.Source = "yolo26_detector"
|
|
} else {
|
|
pred.Source = pred.Source + "+yolo_detector"
|
|
}
|
|
pred.ModelAvailable = true
|
|
}
|
|
|
|
return pred
|
|
}
|
|
|
|
func trainingPredictPose(root string, framePath string) TrainingPosePrediction {
|
|
python := trainingPythonExe()
|
|
script := trainingScriptPath("predict_pose_model.py")
|
|
|
|
model := trainingResolvePoseModel(root)
|
|
if !model.EffectiveExists {
|
|
return TrainingPosePrediction{
|
|
Available: false,
|
|
Source: model.Source,
|
|
Persons: []TrainingPosePerson{},
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(
|
|
python,
|
|
script,
|
|
"--root", root,
|
|
"--image", framePath,
|
|
"--model", model.EffectivePath,
|
|
"--conf", "0.30",
|
|
"--imgsz", "640",
|
|
)
|
|
|
|
hideCommandWindow(cmd)
|
|
|
|
var stdout strings.Builder
|
|
var stderr strings.Builder
|
|
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
|
|
outText := strings.TrimSpace(stdout.String())
|
|
errText := strings.TrimSpace(stderr.String())
|
|
|
|
if errText != "" {
|
|
appLogln("🔎 pose stderr:", errText)
|
|
}
|
|
|
|
if err != nil {
|
|
appLogln("⚠️ pose predict failed:", err)
|
|
appLogln(" stdout:", outText)
|
|
return TrainingPosePrediction{
|
|
Available: false,
|
|
Source: "pose_error",
|
|
Persons: []TrainingPosePerson{},
|
|
}
|
|
}
|
|
|
|
if outText == "" {
|
|
return TrainingPosePrediction{
|
|
Available: false,
|
|
Source: "pose_empty",
|
|
Persons: []TrainingPosePerson{},
|
|
}
|
|
}
|
|
|
|
var pose TrainingPosePrediction
|
|
if err := json.Unmarshal([]byte(outText), &pose); err != nil {
|
|
appLogln("⚠️ pose predict json failed:", err)
|
|
return TrainingPosePrediction{
|
|
Available: false,
|
|
Source: "pose_json_error",
|
|
Persons: []TrainingPosePerson{},
|
|
}
|
|
}
|
|
|
|
if pose.Persons == nil {
|
|
pose.Persons = []TrainingPosePerson{}
|
|
}
|
|
|
|
pose = trainingAnnotatePosePrediction(pose)
|
|
pose.Source = model.Source
|
|
|
|
return pose
|
|
}
|
|
|
|
func trainingCombinePositionScore(scores map[string]float64, positionSet map[string]bool, label string, score float64) {
|
|
label = normalizeSexPositionLabel(label)
|
|
if isNoSexPositionLabel(label) || !positionSet[label] || score <= 0 {
|
|
return
|
|
}
|
|
|
|
score = clamp01(score)
|
|
current := clamp01(scores[label])
|
|
scores[label] = clamp01(1 - (1-current)*(1-score))
|
|
}
|
|
|
|
func trainingAppendPredictionSource(pred *TrainingPrediction, source string) {
|
|
source = strings.TrimSpace(source)
|
|
if source == "" {
|
|
return
|
|
}
|
|
|
|
current := strings.TrimSpace(pred.Source)
|
|
if current == "" {
|
|
pred.Source = source
|
|
return
|
|
}
|
|
|
|
for _, part := range strings.Split(current, "+") {
|
|
if strings.TrimSpace(part) == source {
|
|
return
|
|
}
|
|
}
|
|
|
|
pred.Source = current + "+" + source
|
|
}
|
|
|
|
func trainingFuseHybridPositionScores(
|
|
poseScores map[string]float64,
|
|
contextScores map[string]float64,
|
|
) (string, float64, bool, bool) {
|
|
labels := map[string]bool{}
|
|
|
|
for label := range poseScores {
|
|
if !isNoSexPositionLabel(label) {
|
|
labels[label] = true
|
|
}
|
|
}
|
|
|
|
for label := range contextScores {
|
|
if !isNoSexPositionLabel(label) {
|
|
labels[label] = true
|
|
}
|
|
}
|
|
|
|
bestPosition := ""
|
|
bestPositionScore := 0.0
|
|
bestHasPose := false
|
|
bestHasContext := false
|
|
|
|
for label := range labels {
|
|
poseScore := clamp01(poseScores[label])
|
|
contextScore := clamp01(contextScores[label])
|
|
|
|
score := 0.0
|
|
hasPose := poseScore > 0
|
|
hasContext := contextScore > 0
|
|
|
|
if hasPose {
|
|
if hasContext && contextScore >= trainingPoseConfirmingContextMinScore {
|
|
score = poseScore
|
|
|
|
// Kontext boostet die Pose nur, wenn er dieselbe Position wirklich stützt.
|
|
boost := clamp01(contextScore * trainingPositionContextBoostWeight)
|
|
score = clamp01(1 - (1-score)*(1-boost))
|
|
} else {
|
|
// Unbestätigte Pose bleibt nutzbar, dominiert aber nicht mehr gegen
|
|
// stärkere Box-/Szenen-Signale. Das reduziert False-Positives bei
|
|
// falsch erkannten oder schlecht sitzenden Skeletten.
|
|
maxUnconfirmedScore := trainingPoseUnconfirmedMaxScore
|
|
if poseScore >= trainingPoseStrongUnconfirmedMinScore {
|
|
maxUnconfirmedScore = trainingPoseStrongUnconfirmedMaxScore
|
|
}
|
|
score = math.Min(maxUnconfirmedScore, poseScore)
|
|
}
|
|
} else if contextScore >= trainingPositionContextMinScore {
|
|
// Reiner Box-/Szenen-Kontext darf eine unsichere Prediction liefern,
|
|
// aber keine hohe Sicherheit vortaeuschen.
|
|
score = math.Min(trainingPositionContextMaxScore, contextScore)
|
|
}
|
|
|
|
if score > bestPositionScore {
|
|
bestPosition = label
|
|
bestPositionScore = score
|
|
bestHasPose = hasPose
|
|
bestHasContext = hasContext
|
|
}
|
|
|
|
}
|
|
|
|
return bestPosition, clamp01(bestPositionScore), bestHasPose, bestHasContext
|
|
}
|
|
|
|
func trainingIsFinite01(v float64) bool {
|
|
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1
|
|
}
|
|
|
|
func trainingNormalizedBox(box TrainingBox) (TrainingBox, bool) {
|
|
x := clamp01(box.X)
|
|
y := clamp01(box.Y)
|
|
w := clamp01(box.W)
|
|
h := clamp01(box.H)
|
|
|
|
if w <= 0 || h <= 0 {
|
|
return TrainingBox{}, false
|
|
}
|
|
|
|
if x+w > 1 {
|
|
w = 1 - x
|
|
}
|
|
if y+h > 1 {
|
|
h = 1 - y
|
|
}
|
|
|
|
if w <= 0 || h <= 0 {
|
|
return TrainingBox{}, false
|
|
}
|
|
|
|
box.X = x
|
|
box.Y = y
|
|
box.W = w
|
|
box.H = h
|
|
return box, true
|
|
}
|
|
|
|
func trainingBoxArea(box TrainingBox) float64 {
|
|
box, ok := trainingNormalizedBox(box)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
|
|
return box.W * box.H
|
|
}
|
|
|
|
func trainingBoxCenter(box TrainingBox) (float64, float64, bool) {
|
|
box, ok := trainingNormalizedBox(box)
|
|
if !ok {
|
|
return 0, 0, false
|
|
}
|
|
|
|
return box.X + box.W/2, box.Y + box.H/2, true
|
|
}
|
|
|
|
func trainingBoxGap(a TrainingBox, b TrainingBox) float64 {
|
|
a, okA := trainingNormalizedBox(a)
|
|
b, okB := trainingNormalizedBox(b)
|
|
if !okA || !okB {
|
|
return 1
|
|
}
|
|
|
|
dx := math.Max(0, math.Max(a.X-(b.X+b.W), b.X-(a.X+a.W)))
|
|
dy := math.Max(0, math.Max(a.Y-(b.Y+b.H), b.Y-(a.Y+a.H)))
|
|
|
|
return math.Sqrt(dx*dx + dy*dy)
|
|
}
|
|
|
|
func trainingBoxOverlapRatio(a TrainingBox, b TrainingBox) float64 {
|
|
a, okA := trainingNormalizedBox(a)
|
|
b, okB := trainingNormalizedBox(b)
|
|
if !okA || !okB {
|
|
return 0
|
|
}
|
|
|
|
left := math.Max(a.X, b.X)
|
|
top := math.Max(a.Y, b.Y)
|
|
right := math.Min(a.X+a.W, b.X+b.W)
|
|
bottom := math.Min(a.Y+a.H, b.Y+b.H)
|
|
|
|
if right <= left || bottom <= top {
|
|
return 0
|
|
}
|
|
|
|
intersection := (right - left) * (bottom - top)
|
|
minArea := math.Min(trainingBoxArea(a), trainingBoxArea(b))
|
|
if minArea <= 0 {
|
|
return 0
|
|
}
|
|
|
|
return clamp01(intersection / minArea)
|
|
}
|
|
|
|
func trainingBoxHorizontalOverlapRatio(a TrainingBox, b TrainingBox) float64 {
|
|
a, okA := trainingNormalizedBox(a)
|
|
b, okB := trainingNormalizedBox(b)
|
|
if !okA || !okB {
|
|
return 0
|
|
}
|
|
|
|
left := math.Max(a.X, b.X)
|
|
right := math.Min(a.X+a.W, b.X+b.W)
|
|
if right <= left {
|
|
return 0
|
|
}
|
|
|
|
minWidth := math.Min(a.W, b.W)
|
|
if minWidth <= 0 {
|
|
return 0
|
|
}
|
|
|
|
return clamp01((right - left) / minWidth)
|
|
}
|
|
|
|
func trainingBoxVerticalOverlapRatio(a TrainingBox, b TrainingBox) float64 {
|
|
a, okA := trainingNormalizedBox(a)
|
|
b, okB := trainingNormalizedBox(b)
|
|
if !okA || !okB {
|
|
return 0
|
|
}
|
|
|
|
top := math.Max(a.Y, b.Y)
|
|
bottom := math.Min(a.Y+a.H, b.Y+b.H)
|
|
if bottom <= top {
|
|
return 0
|
|
}
|
|
|
|
minHeight := math.Min(a.H, b.H)
|
|
if minHeight <= 0 {
|
|
return 0
|
|
}
|
|
|
|
return clamp01((bottom - top) / minHeight)
|
|
}
|
|
|
|
func trainingBoxesByLabel(boxes []TrainingBox, labels ...string) []TrainingBox {
|
|
wanted := map[string]bool{}
|
|
for _, label := range labels {
|
|
clean := strings.TrimSpace(label)
|
|
if clean != "" {
|
|
wanted[clean] = true
|
|
}
|
|
}
|
|
|
|
out := []TrainingBox{}
|
|
for _, box := range boxes {
|
|
label := strings.TrimSpace(box.Label)
|
|
if !wanted[label] {
|
|
continue
|
|
}
|
|
if normalized, ok := trainingNormalizedBox(box); ok {
|
|
out = append(out, normalized)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingAnyBoxesNear(a []TrainingBox, b []TrainingBox, margin float64) bool {
|
|
for _, left := range a {
|
|
for _, right := range b {
|
|
if trainingBoxGap(left, right) <= margin || trainingBoxOverlapRatio(left, right) > 0 {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func trainingPointNearBox(x float64, y float64, box TrainingBox, margin float64) bool {
|
|
if !trainingIsFinite01(x) || !trainingIsFinite01(y) {
|
|
return false
|
|
}
|
|
|
|
box, ok := trainingNormalizedBox(box)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
return x >= box.X-margin &&
|
|
x <= box.X+box.W+margin &&
|
|
y >= box.Y-margin &&
|
|
y <= box.Y+box.H+margin
|
|
}
|
|
|
|
func trainingAnyPoseKeypointNearBoxes(
|
|
pose TrainingPosePrediction,
|
|
keypointNames []string,
|
|
boxes []TrainingBox,
|
|
margin float64,
|
|
) bool {
|
|
if len(boxes) == 0 {
|
|
return false
|
|
}
|
|
|
|
nameSet := stringSet(keypointNames)
|
|
|
|
for _, person := range pose.Persons {
|
|
if !trainingPosePersonReliable(person) {
|
|
continue
|
|
}
|
|
|
|
for _, point := range person.Keypoints {
|
|
if !nameSet[strings.TrimSpace(point.Name)] {
|
|
continue
|
|
}
|
|
if point.Conf < trainingPoseKeypointMinConfidence {
|
|
continue
|
|
}
|
|
|
|
for _, box := range boxes {
|
|
if trainingPointNearBox(point.X, point.Y, box, margin) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func trainingPoseKeypointStats(person TrainingPosePerson) (int, float64) {
|
|
if len(person.Keypoints) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
visible := 0
|
|
totalConf := 0.0
|
|
|
|
for _, point := range person.Keypoints {
|
|
if point.Conf < trainingPoseKeypointMinConfidence ||
|
|
!trainingIsFinite01(point.X) ||
|
|
!trainingIsFinite01(point.Y) {
|
|
continue
|
|
}
|
|
|
|
visible++
|
|
totalConf += clamp01(point.Conf)
|
|
}
|
|
|
|
if visible == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
coverage := clamp01(float64(visible) / float64(trainingPoseKeypointCount))
|
|
avgConf := clamp01(totalConf / float64(visible))
|
|
|
|
return visible, clamp01(coverage*0.45 + avgConf*0.55)
|
|
}
|
|
|
|
func trainingPoseKeypointQuality(person TrainingPosePerson) float64 {
|
|
_, quality := trainingPoseKeypointStats(person)
|
|
return quality
|
|
}
|
|
|
|
func trainingAnnotatePosePersonQuality(person TrainingPosePerson) TrainingPosePerson {
|
|
visible, quality := trainingPoseKeypointStats(person)
|
|
person.VisibleKeypoints = visible
|
|
person.Quality = quality
|
|
person.Reliable = person.Score >= trainingPoseReliableMinScore &&
|
|
visible >= trainingPoseReliableMinKeypoints &&
|
|
quality >= trainingPoseReliableMinQuality
|
|
|
|
return person
|
|
}
|
|
|
|
func trainingPosePersonReliable(person TrainingPosePerson) bool {
|
|
visible := person.VisibleKeypoints
|
|
quality := person.Quality
|
|
|
|
if visible == 0 && quality == 0 && len(person.Keypoints) > 0 {
|
|
visible, quality = trainingPoseKeypointStats(person)
|
|
}
|
|
|
|
return person.Score >= trainingPoseReliableMinScore &&
|
|
visible >= trainingPoseReliableMinKeypoints &&
|
|
quality >= trainingPoseReliableMinQuality
|
|
}
|
|
|
|
func trainingAnnotatePosePrediction(pose TrainingPosePrediction) TrainingPosePrediction {
|
|
for i := range pose.Persons {
|
|
pose.Persons[i] = trainingAnnotatePosePersonQuality(pose.Persons[i])
|
|
}
|
|
|
|
if pose.PersonCount == 0 {
|
|
pose.PersonCount = len(pose.Persons)
|
|
}
|
|
|
|
return pose
|
|
}
|
|
|
|
func trainingReliablePosePrediction(pose TrainingPosePrediction) TrainingPosePrediction {
|
|
pose = trainingAnnotatePosePrediction(pose)
|
|
|
|
reliable := make([]TrainingPosePerson, 0, len(pose.Persons))
|
|
for _, person := range pose.Persons {
|
|
if trainingPosePersonReliable(person) {
|
|
reliable = append(reliable, person)
|
|
}
|
|
}
|
|
|
|
pose.Persons = reliable
|
|
pose.PersonCount = len(reliable)
|
|
|
|
return pose
|
|
}
|
|
|
|
func trainingScenePersonBoxes(pred TrainingPrediction, pose TrainingPosePrediction) []TrainingBox {
|
|
detectorBoxes := []TrainingBox{}
|
|
poseBoxes := []TrainingBox{}
|
|
|
|
for _, box := range pred.Boxes {
|
|
if trainingIsPersonLikeLabel(box.Label) {
|
|
if normalized, ok := trainingNormalizedBox(box); ok {
|
|
detectorBoxes = append(detectorBoxes, normalized)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, person := range pose.Persons {
|
|
if !trainingPosePersonReliable(person) {
|
|
continue
|
|
}
|
|
|
|
if normalized, ok := trainingNormalizedBox(person.Box); ok {
|
|
poseBoxes = append(poseBoxes, normalized)
|
|
}
|
|
}
|
|
|
|
if len(detectorBoxes) >= len(poseBoxes) && len(detectorBoxes) > 0 {
|
|
return detectorBoxes
|
|
}
|
|
|
|
return poseBoxes
|
|
}
|
|
|
|
func trainingIsPersonLikeLabel(label string) bool {
|
|
label = strings.TrimSpace(label)
|
|
return label == "person" || strings.HasPrefix(label, "person_")
|
|
}
|
|
|
|
type trainingPersonPairSignals struct {
|
|
close bool
|
|
overlap bool
|
|
horizontal bool
|
|
vertical bool
|
|
stacked bool
|
|
}
|
|
|
|
func trainingScenePersonPairSignals(boxes []TrainingBox) trainingPersonPairSignals {
|
|
signals := trainingPersonPairSignals{}
|
|
|
|
for i := 0; i < len(boxes); i++ {
|
|
a, okA := trainingNormalizedBox(boxes[i])
|
|
if !okA {
|
|
continue
|
|
}
|
|
|
|
for j := i + 1; j < len(boxes); j++ {
|
|
b, okB := trainingNormalizedBox(boxes[j])
|
|
if !okB {
|
|
continue
|
|
}
|
|
|
|
gap := trainingBoxGap(a, b)
|
|
overlap := trainingBoxOverlapRatio(a, b)
|
|
close := gap <= 0.08 || overlap >= 0.08
|
|
if close {
|
|
signals.close = true
|
|
}
|
|
if overlap >= 0.18 {
|
|
signals.overlap = true
|
|
}
|
|
|
|
if close && a.W > a.H*1.15 && b.W > b.H*1.15 {
|
|
signals.horizontal = true
|
|
}
|
|
if close && a.H > a.W*1.25 && b.H > b.W*1.25 {
|
|
signals.vertical = true
|
|
}
|
|
|
|
_, ay, okACenter := trainingBoxCenter(a)
|
|
_, by, okBCenter := trainingBoxCenter(b)
|
|
if okACenter && okBCenter &&
|
|
gap <= 0.12 &&
|
|
trainingBoxHorizontalOverlapRatio(a, b) >= 0.35 &&
|
|
math.Abs(ay-by) >= 0.12 {
|
|
signals.stacked = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return signals
|
|
}
|
|
|
|
type trainingPosePoint struct {
|
|
x float64
|
|
y float64
|
|
}
|
|
|
|
type trainingPosePersonGeometry struct {
|
|
box TrainingBox
|
|
hasBox bool
|
|
center trainingPosePoint
|
|
torsoAngle float64
|
|
hasTorsoAxis bool
|
|
axisX float64
|
|
axisY float64
|
|
perpX float64
|
|
perpY float64
|
|
bodyLong float64
|
|
bodyCross float64
|
|
elongated bool
|
|
lying bool
|
|
upright bool
|
|
straddling bool
|
|
kneesBelowHips bool
|
|
kneesWide bool
|
|
allFours bool
|
|
bentOrKneeling bool
|
|
}
|
|
|
|
func trainingPosePointByName(person TrainingPosePerson, name string) (trainingPosePoint, bool) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return trainingPosePoint{}, false
|
|
}
|
|
|
|
for _, point := range person.Keypoints {
|
|
if strings.TrimSpace(point.Name) != name {
|
|
continue
|
|
}
|
|
if point.Conf < trainingPoseKeypointMinConfidence ||
|
|
!trainingIsFinite01(point.X) ||
|
|
!trainingIsFinite01(point.Y) {
|
|
continue
|
|
}
|
|
|
|
return trainingPosePoint{x: point.X, y: point.Y}, true
|
|
}
|
|
|
|
return trainingPosePoint{}, false
|
|
}
|
|
|
|
func trainingPoseMidpoint(person TrainingPosePerson, names ...string) (trainingPosePoint, bool) {
|
|
count := 0
|
|
x := 0.0
|
|
y := 0.0
|
|
|
|
for _, name := range names {
|
|
point, ok := trainingPosePointByName(person, name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
count++
|
|
x += point.x
|
|
y += point.y
|
|
}
|
|
|
|
if count == 0 {
|
|
return trainingPosePoint{}, false
|
|
}
|
|
|
|
return trainingPosePoint{
|
|
x: x / float64(count),
|
|
y: y / float64(count),
|
|
}, true
|
|
}
|
|
|
|
func trainingPosePointDistance(a trainingPosePoint, okA bool, b trainingPosePoint, okB bool) float64 {
|
|
if !okA || !okB {
|
|
return 0
|
|
}
|
|
|
|
dx := a.x - b.x
|
|
dy := a.y - b.y
|
|
return math.Sqrt(dx*dx + dy*dy)
|
|
}
|
|
|
|
func trainingPoseProjectedDistance(a trainingPosePoint, okA bool, b trainingPosePoint, okB bool, axisX float64, axisY float64) float64 {
|
|
if !okA || !okB {
|
|
return 0
|
|
}
|
|
|
|
return math.Abs((a.x-b.x)*axisX + (a.y-b.y)*axisY)
|
|
}
|
|
|
|
func trainingPoseAxisAlignment(a trainingPosePersonGeometry, b trainingPosePersonGeometry) (float64, bool) {
|
|
if !a.hasTorsoAxis || !b.hasTorsoAxis {
|
|
return 0, false
|
|
}
|
|
|
|
// Körperachsen sind richtungslos: 0° und 180° gelten beide als parallel.
|
|
return math.Abs(math.Cos(a.torsoAngle - b.torsoAngle)), true
|
|
}
|
|
|
|
func trainingPoseExtentsAlongAxis(person TrainingPosePerson, origin trainingPosePoint, axisX float64, axisY float64, perpX float64, perpY float64) (float64, float64, bool) {
|
|
minLong := 0.0
|
|
maxLong := 0.0
|
|
minCross := 0.0
|
|
maxCross := 0.0
|
|
count := 0
|
|
|
|
for _, point := range person.Keypoints {
|
|
if point.Conf < trainingPoseKeypointMinConfidence ||
|
|
!trainingIsFinite01(point.X) ||
|
|
!trainingIsFinite01(point.Y) {
|
|
continue
|
|
}
|
|
|
|
dx := point.X - origin.x
|
|
dy := point.Y - origin.y
|
|
long := dx*axisX + dy*axisY
|
|
cross := dx*perpX + dy*perpY
|
|
|
|
if count == 0 {
|
|
minLong = long
|
|
maxLong = long
|
|
minCross = cross
|
|
maxCross = cross
|
|
} else {
|
|
minLong = math.Min(minLong, long)
|
|
maxLong = math.Max(maxLong, long)
|
|
minCross = math.Min(minCross, cross)
|
|
maxCross = math.Max(maxCross, cross)
|
|
}
|
|
|
|
count++
|
|
}
|
|
|
|
if count < 3 {
|
|
return 0, 0, false
|
|
}
|
|
|
|
return math.Abs(maxLong - minLong), math.Abs(maxCross - minCross), true
|
|
}
|
|
|
|
func trainingPosePersonGeometryFor(person TrainingPosePerson) trainingPosePersonGeometry {
|
|
box, hasBox := trainingNormalizedBox(person.Box)
|
|
center := trainingPosePoint{x: 0.5, y: 0.5}
|
|
if hasBox {
|
|
if x, y, ok := trainingBoxCenter(box); ok {
|
|
center = trainingPosePoint{x: x, y: y}
|
|
}
|
|
}
|
|
|
|
leftHip, okLeftHip := trainingPosePointByName(person, "left_hip")
|
|
rightHip, okRightHip := trainingPosePointByName(person, "right_hip")
|
|
leftKnee, okLeftKnee := trainingPosePointByName(person, "left_knee")
|
|
rightKnee, okRightKnee := trainingPosePointByName(person, "right_knee")
|
|
|
|
hip, okHip := trainingPoseMidpoint(person, "left_hip", "right_hip")
|
|
shoulder, okShoulder := trainingPoseMidpoint(person, "left_shoulder", "right_shoulder")
|
|
knee, okKnee := trainingPoseMidpoint(person, "left_knee", "right_knee")
|
|
|
|
if okHip {
|
|
center = hip
|
|
}
|
|
|
|
torsoDX := 0.0
|
|
torsoDY := 0.0
|
|
torsoLen := 0.0
|
|
torsoAngle := 0.0
|
|
hasTorsoAxis := false
|
|
axisX := 0.0
|
|
axisY := 1.0
|
|
perpX := -1.0
|
|
perpY := 0.0
|
|
if okHip && okShoulder {
|
|
rawDX := hip.x - shoulder.x
|
|
rawDY := hip.y - shoulder.y
|
|
torsoDX = math.Abs(rawDX)
|
|
torsoDY = math.Abs(rawDY)
|
|
torsoLen = math.Sqrt(rawDX*rawDX + rawDY*rawDY)
|
|
if torsoLen >= 0.07 {
|
|
hasTorsoAxis = true
|
|
axisX = rawDX / torsoLen
|
|
axisY = rawDY / torsoLen
|
|
perpX = -axisY
|
|
perpY = axisX
|
|
torsoAngle = math.Atan2(axisY, axisX)
|
|
}
|
|
}
|
|
|
|
hipWidth := trainingPosePointDistance(leftHip, okLeftHip, rightHip, okRightHip)
|
|
kneeWidth := trainingPosePointDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee)
|
|
|
|
kneesBelowHips := okKnee && okHip && knee.y > hip.y+0.045
|
|
if hasTorsoAxis && okKnee && okHip {
|
|
kneeProjection := (knee.x-hip.x)*axisX + (knee.y-hip.y)*axisY
|
|
kneesBelowHips = kneeProjection > 0.045
|
|
}
|
|
|
|
if hasTorsoAxis && okLeftKnee && okRightKnee {
|
|
kneeWidth = trainingPoseProjectedDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee, perpX, perpY)
|
|
|
|
if okLeftHip && okRightHip {
|
|
hipWidth = trainingPoseProjectedDistance(leftHip, okLeftHip, rightHip, okRightHip, perpX, perpY)
|
|
}
|
|
}
|
|
|
|
kneesWide := kneeWidth > 0 && kneeWidth >= math.Max(0.11, hipWidth*1.12)
|
|
straddling := kneesBelowHips && kneesWide
|
|
|
|
bodyLong := torsoLen
|
|
bodyCross := math.Max(hipWidth, kneeWidth)
|
|
|
|
if hasTorsoAxis {
|
|
if poseLong, poseCross, ok := trainingPoseExtentsAlongAxis(person, center, axisX, axisY, perpX, perpY); ok {
|
|
bodyLong = math.Max(bodyLong, poseLong)
|
|
bodyCross = math.Max(bodyCross, poseCross)
|
|
}
|
|
|
|
if hasBox {
|
|
boxLong := math.Abs(axisX)*box.W + math.Abs(axisY)*box.H
|
|
boxCross := math.Abs(perpX)*box.W + math.Abs(perpY)*box.H
|
|
bodyLong = math.Max(bodyLong, boxLong)
|
|
bodyCross = math.Max(bodyCross, boxCross)
|
|
}
|
|
} else if hasBox {
|
|
bodyLong = math.Max(box.W, box.H)
|
|
bodyCross = math.Min(box.W, box.H)
|
|
}
|
|
|
|
elongated := bodyLong >= math.Max(0.18, bodyCross*1.18)
|
|
|
|
torsoHorizontal := torsoLen >= 0.07 && torsoDX >= torsoDY*1.15
|
|
torsoVertical := torsoLen >= 0.07 && torsoDY >= torsoDX*1.15
|
|
|
|
boxHorizontal := hasBox && box.W >= box.H*1.05
|
|
boxVertical := hasBox && box.H >= box.W*1.25
|
|
|
|
lying := torsoHorizontal || boxHorizontal || (hasTorsoAxis && elongated && !boxVertical)
|
|
upright := torsoVertical || boxVertical
|
|
allFours := kneesBelowHips && !straddling && (torsoHorizontal || (hasTorsoAxis && elongated))
|
|
bentOrKneeling := allFours || (kneesBelowHips && !straddling)
|
|
|
|
return trainingPosePersonGeometry{
|
|
box: box,
|
|
hasBox: hasBox,
|
|
center: center,
|
|
torsoAngle: torsoAngle,
|
|
hasTorsoAxis: hasTorsoAxis,
|
|
axisX: axisX,
|
|
axisY: axisY,
|
|
perpX: perpX,
|
|
perpY: perpY,
|
|
bodyLong: bodyLong,
|
|
bodyCross: bodyCross,
|
|
elongated: elongated,
|
|
lying: lying,
|
|
upright: upright,
|
|
straddling: straddling,
|
|
kneesBelowHips: kneesBelowHips,
|
|
kneesWide: kneesWide,
|
|
allFours: allFours,
|
|
bentOrKneeling: bentOrKneeling,
|
|
}
|
|
}
|
|
|
|
func trainingAddPosePairGeometryScores(
|
|
scores map[string]float64,
|
|
positionSet map[string]bool,
|
|
pose TrainingPosePrediction,
|
|
) {
|
|
add := func(label string, score float64) {
|
|
trainingCombinePositionScore(scores, positionSet, label, score)
|
|
}
|
|
|
|
pose = trainingReliablePosePrediction(pose)
|
|
if len(pose.Persons) < 2 {
|
|
return
|
|
}
|
|
|
|
geometries := make([]trainingPosePersonGeometry, 0, len(pose.Persons))
|
|
for _, person := range pose.Persons {
|
|
geometries = append(geometries, trainingPosePersonGeometryFor(person))
|
|
}
|
|
|
|
for i := 0; i < len(geometries); i++ {
|
|
left := geometries[i]
|
|
if !left.hasBox {
|
|
continue
|
|
}
|
|
|
|
for j := i + 1; j < len(geometries); j++ {
|
|
right := geometries[j]
|
|
if !right.hasBox {
|
|
continue
|
|
}
|
|
|
|
gap := trainingBoxGap(left.box, right.box)
|
|
overlap := trainingBoxOverlapRatio(left.box, right.box)
|
|
horizontalOverlap := trainingBoxHorizontalOverlapRatio(left.box, right.box)
|
|
verticalOverlap := trainingBoxVerticalOverlapRatio(left.box, right.box)
|
|
close := gap <= 0.12 || overlap >= 0.08
|
|
if !close {
|
|
continue
|
|
}
|
|
|
|
top := left
|
|
bottom := right
|
|
if right.center.y < left.center.y {
|
|
top = right
|
|
bottom = left
|
|
}
|
|
|
|
topAbove := top.center.y <= bottom.center.y-0.055
|
|
horizontalStack := horizontalOverlap >= 0.35 && (topAbove || overlap >= 0.22)
|
|
lateralStack := verticalOverlap >= 0.35 && overlap >= 0.12
|
|
strongStack := horizontalStack || lateralStack
|
|
|
|
axisAlignment, hasAxisAlignment := trainingPoseAxisAlignment(left, right)
|
|
axesParallel := hasAxisAlignment && axisAlignment >= 0.74
|
|
axesCrossed := hasAxisAlignment && axisAlignment <= 0.56
|
|
|
|
hasStrongRiderShape := func(g trainingPosePersonGeometry) bool {
|
|
return g.straddling ||
|
|
(g.kneesWide && g.kneesBelowHips && (g.upright || axesCrossed))
|
|
}
|
|
hasWeakRiderShape := func(g trainingPosePersonGeometry) bool {
|
|
return g.kneesWide && g.kneesBelowHips
|
|
}
|
|
|
|
leftHasStrongRiderShape := hasStrongRiderShape(left)
|
|
rightHasStrongRiderShape := hasStrongRiderShape(right)
|
|
topHasStrongRiderShape := hasStrongRiderShape(top)
|
|
topHasWeakRiderShape := hasWeakRiderShape(top)
|
|
|
|
rider := top
|
|
base := bottom
|
|
riderHasStrongShape := topHasStrongRiderShape
|
|
riderHasWeakShape := topHasWeakRiderShape
|
|
if leftHasStrongRiderShape != rightHasStrongRiderShape {
|
|
if leftHasStrongRiderShape {
|
|
rider = left
|
|
base = right
|
|
riderHasStrongShape = true
|
|
riderHasWeakShape = hasWeakRiderShape(left)
|
|
} else {
|
|
rider = right
|
|
base = left
|
|
riderHasStrongShape = true
|
|
riderHasWeakShape = hasWeakRiderShape(right)
|
|
}
|
|
}
|
|
|
|
if strongStack && riderHasStrongShape && !axesParallel {
|
|
add("cowgirl", 0.20)
|
|
add("reverse_cowgirl", 0.17)
|
|
|
|
if base.lying {
|
|
add("cowgirl", 0.12)
|
|
add("reverse_cowgirl", 0.10)
|
|
}
|
|
if rider.straddling {
|
|
add("cowgirl", 0.08)
|
|
add("reverse_cowgirl", 0.06)
|
|
}
|
|
} else if strongStack && riderHasWeakShape && !hasAxisAlignment {
|
|
// Ohne verwertbare Achsen bleibt Cowgirl nur ein schwaches Signal.
|
|
// Sobald die Achsen parallel sind, sieht die Szene eher nach
|
|
// Missionary/Überlagerung aus und soll nicht in Cowgirl kippen.
|
|
add("cowgirl", 0.08)
|
|
add("reverse_cowgirl", 0.06)
|
|
}
|
|
|
|
if strongStack && (bottom.lying || (axesParallel && topAbove)) {
|
|
if !topHasStrongRiderShape || axesParallel {
|
|
add("missionary", 0.14)
|
|
}
|
|
if axesParallel {
|
|
add("missionary", 0.08)
|
|
}
|
|
if !top.straddling && !topHasStrongRiderShape {
|
|
add("missionary", 0.08)
|
|
}
|
|
}
|
|
|
|
bothLying := left.lying && right.lying
|
|
sameLevel := math.Abs(left.center.y-right.center.y) <= 0.15
|
|
sideBySide := math.Abs(left.center.x-right.center.x) >= 0.10
|
|
parallelSideBySide := axesParallel && left.elongated && right.elongated && close && !strongStack
|
|
if (bothLying && (sameLevel || sideBySide)) || parallelSideBySide {
|
|
add("spooning", 0.18)
|
|
if overlap >= 0.10 {
|
|
add("prone_bone", 0.07)
|
|
}
|
|
}
|
|
|
|
leftBent := left.allFours || left.bentOrKneeling
|
|
rightBent := right.allFours || right.bentOrKneeling
|
|
if leftBent != rightBent {
|
|
other := right
|
|
if rightBent {
|
|
other = left
|
|
}
|
|
|
|
add("doggy", 0.16)
|
|
if other.upright {
|
|
add("doggy", 0.06)
|
|
add("standing_doggy", 0.07)
|
|
}
|
|
if left.lying || right.lying {
|
|
add("prone_bone", 0.06)
|
|
}
|
|
} else if left.allFours || right.allFours {
|
|
add("doggy", 0.13)
|
|
if left.lying || right.lying {
|
|
add("prone_bone", 0.07)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func trainingAddPoseSceneContextScores(
|
|
scores map[string]float64,
|
|
positionSet map[string]bool,
|
|
pred TrainingPrediction,
|
|
pose TrainingPosePrediction,
|
|
) {
|
|
add := func(label string, score float64) {
|
|
trainingCombinePositionScore(scores, positionSet, label, score)
|
|
}
|
|
|
|
pose = trainingReliablePosePrediction(pose)
|
|
personBoxes := trainingScenePersonBoxes(pred, pose)
|
|
personCount := len(personBoxes)
|
|
if len(pose.Persons) > personCount {
|
|
personCount = len(pose.Persons)
|
|
}
|
|
|
|
pair := trainingScenePersonPairSignals(personBoxes)
|
|
trainingAddPosePairGeometryScores(scores, positionSet, pose)
|
|
|
|
penisBoxes := trainingBoxesByLabel(pred.Boxes, "penis")
|
|
pussyBoxes := trainingBoxesByLabel(pred.Boxes, "pussy", "vagina", "vulva", "labia")
|
|
assBoxes := trainingBoxesByLabel(pred.Boxes, "ass", "anus")
|
|
breastBoxes := trainingBoxesByLabel(pred.Boxes, "breasts")
|
|
tongueBoxes := trainingBoxesByLabel(pred.Boxes, "tongue")
|
|
toyBoxes := trainingBoxesByLabel(pred.Boxes, "dildo", "vibrator", "strapon", "buttplug")
|
|
|
|
hasPenis := len(penisBoxes) > 0
|
|
hasPussy := len(pussyBoxes) > 0
|
|
hasAss := len(assBoxes) > 0
|
|
hasBreasts := len(breastBoxes) > 0
|
|
hasTongue := len(tongueBoxes) > 0
|
|
hasToy := len(toyBoxes) > 0
|
|
|
|
headNames := []string{"nose", "left_eye", "right_eye", "left_ear", "right_ear"}
|
|
handNames := []string{"left_wrist", "right_wrist"}
|
|
hipNames := []string{"left_hip", "right_hip"}
|
|
|
|
headNearPenis := trainingAnyPoseKeypointNearBoxes(pose, headNames, penisBoxes, 0.09)
|
|
headNearPussy := trainingAnyPoseKeypointNearBoxes(pose, headNames, pussyBoxes, 0.09)
|
|
headNearAss := trainingAnyPoseKeypointNearBoxes(pose, headNames, assBoxes, 0.09)
|
|
handNearPenis := trainingAnyPoseKeypointNearBoxes(pose, handNames, penisBoxes, 0.08)
|
|
handNearPussy := trainingAnyPoseKeypointNearBoxes(pose, handNames, pussyBoxes, 0.08)
|
|
handNearToy := trainingAnyPoseKeypointNearBoxes(pose, handNames, toyBoxes, 0.08)
|
|
hipsNearGenitals := trainingAnyPoseKeypointNearBoxes(pose, hipNames, append(penisBoxes, pussyBoxes...), 0.08)
|
|
|
|
if personCount >= 2 {
|
|
add("missionary", 0.04)
|
|
add("doggy", 0.04)
|
|
add("cowgirl", 0.04)
|
|
add("reverse_cowgirl", 0.04)
|
|
add("standing_doggy", 0.04)
|
|
add("spooning", 0.04)
|
|
add("69", 0.03)
|
|
}
|
|
|
|
if pair.close {
|
|
add("missionary", 0.04)
|
|
add("doggy", 0.04)
|
|
add("cowgirl", 0.04)
|
|
add("spooning", 0.04)
|
|
}
|
|
if pair.overlap {
|
|
add("missionary", 0.05)
|
|
add("cowgirl", 0.05)
|
|
add("prone_bone", 0.04)
|
|
}
|
|
if pair.horizontal {
|
|
add("spooning", 0.12)
|
|
add("prone_bone", 0.07)
|
|
}
|
|
if pair.vertical {
|
|
add("standing", 0.08)
|
|
add("standing_doggy", 0.10)
|
|
}
|
|
if pair.stacked {
|
|
add("missionary", 0.07)
|
|
add("cowgirl", 0.07)
|
|
add("reverse_cowgirl", 0.06)
|
|
add("facesitting", 0.05)
|
|
}
|
|
|
|
if hasPenis && hasPussy {
|
|
add("missionary", 0.08)
|
|
add("doggy", 0.08)
|
|
add("cowgirl", 0.08)
|
|
add("reverse_cowgirl", 0.07)
|
|
add("prone_bone", 0.06)
|
|
add("standing_doggy", 0.06)
|
|
add("spooning", 0.05)
|
|
|
|
if trainingAnyBoxesNear(penisBoxes, pussyBoxes, 0.09) || hipsNearGenitals {
|
|
add("missionary", 0.08)
|
|
add("doggy", 0.08)
|
|
add("cowgirl", 0.08)
|
|
add("reverse_cowgirl", 0.07)
|
|
}
|
|
}
|
|
|
|
if hasPenis && (headNearPenis || hasTongue) {
|
|
add("blowjob", 0.16)
|
|
if headNearPenis {
|
|
add("blowjob", 0.10)
|
|
}
|
|
}
|
|
if hasPussy && (headNearPussy || hasTongue || trainingAnyBoxesNear(tongueBoxes, pussyBoxes, 0.08)) {
|
|
add("cunnilingus", 0.16)
|
|
if headNearPussy || trainingAnyBoxesNear(tongueBoxes, pussyBoxes, 0.08) {
|
|
add("cunnilingus", 0.10)
|
|
}
|
|
}
|
|
if hasPenis && handNearPenis {
|
|
add("handjob", 0.18)
|
|
}
|
|
if hasPussy && handNearPussy {
|
|
add("fingering", 0.18)
|
|
}
|
|
if hasPenis && hasBreasts && trainingAnyBoxesNear(penisBoxes, breastBoxes, 0.10) {
|
|
add("boobjob", 0.20)
|
|
}
|
|
if hasToy {
|
|
add("toy_play", 0.12)
|
|
if handNearToy ||
|
|
trainingAnyBoxesNear(toyBoxes, pussyBoxes, 0.10) ||
|
|
trainingAnyBoxesNear(toyBoxes, penisBoxes, 0.10) ||
|
|
trainingAnyBoxesNear(toyBoxes, assBoxes, 0.10) {
|
|
add("toy_play", 0.12)
|
|
}
|
|
}
|
|
if personCount >= 2 && (headNearAss || headNearPussy) {
|
|
if hasAss {
|
|
add("facesitting", 0.14)
|
|
}
|
|
if hasPussy && hasPenis {
|
|
add("69", 0.10)
|
|
}
|
|
}
|
|
if hasAss && hasPussy && pair.horizontal {
|
|
add("doggy", 0.07)
|
|
add("prone_bone", 0.07)
|
|
}
|
|
}
|
|
|
|
func trainingApplyPoseToPrediction(pred TrainingPrediction, pose TrainingPosePrediction) TrainingPrediction {
|
|
if pose.Available {
|
|
pred.ModelAvailable = true
|
|
}
|
|
|
|
positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions)
|
|
poseScores := map[string]float64{}
|
|
contextScores := map[string]float64{}
|
|
reliablePose := TrainingPosePrediction{
|
|
Available: pose.Available,
|
|
Source: pose.Source,
|
|
Persons: []TrainingPosePerson{},
|
|
}
|
|
|
|
if pose.Available && len(pose.Persons) > 0 {
|
|
pose = trainingAnnotatePosePrediction(pose)
|
|
pred.Persons = pose.Persons
|
|
reliablePose = trainingReliablePosePrediction(pose)
|
|
}
|
|
|
|
for _, person := range reliablePose.Persons {
|
|
label := normalizeSexPositionLabel(person.Label)
|
|
if isNoSexPositionLabel(label) || !positionSet[label] {
|
|
continue
|
|
}
|
|
|
|
trainingCombinePositionScore(poseScores, positionSet, label, person.Score)
|
|
|
|
if quality := trainingPoseKeypointQuality(person); quality > 0 {
|
|
trainingCombinePositionScore(poseScores, positionSet, label, 0.04*quality)
|
|
}
|
|
}
|
|
|
|
trainingAddPoseSceneContextScores(contextScores, positionSet, pred, reliablePose)
|
|
|
|
bestPosition, bestPositionScore, hasPoseSignal, hasContextSignal :=
|
|
trainingFuseHybridPositionScores(poseScores, contextScores)
|
|
|
|
if bestPosition != "" && (hasPoseSignal || bestPositionScore >= trainingPositionContextMinScore) {
|
|
pred.SexPosition = bestPosition
|
|
pred.SexPositionScore = clamp01(bestPositionScore)
|
|
}
|
|
|
|
if pose.Available {
|
|
trainingAppendPredictionSource(&pred, "yolo_pose")
|
|
}
|
|
|
|
if hasContextSignal {
|
|
trainingAppendPredictionSource(&pred, "box_context")
|
|
}
|
|
|
|
return pred
|
|
}
|
|
|
|
func trainingDetectorLabelContent(
|
|
boxes []TrainingBox,
|
|
classMap map[string]int,
|
|
allowEmpty bool,
|
|
) ([]byte, error) {
|
|
var lines []string
|
|
|
|
for _, box := range boxes {
|
|
label := strings.TrimSpace(box.Label)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
|
|
classID, ok := classMap[label]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
x := clamp01(box.X)
|
|
y := clamp01(box.Y)
|
|
w := clamp01(box.W)
|
|
h := clamp01(box.H)
|
|
|
|
if w <= 0 || h <= 0 {
|
|
continue
|
|
}
|
|
|
|
// Frontend/Predictor nutzen x/y als linke obere Ecke.
|
|
// YOLO erwartet x_center/y_center.
|
|
xCenter := clamp01(x + w/2)
|
|
yCenter := clamp01(y + h/2)
|
|
|
|
lines = append(lines, fmt.Sprintf(
|
|
"%d %.6f %.6f %.6f %.6f",
|
|
classID,
|
|
xCenter,
|
|
yCenter,
|
|
w,
|
|
h,
|
|
))
|
|
}
|
|
|
|
if len(lines) == 0 {
|
|
if allowEmpty {
|
|
return []byte{}, nil
|
|
}
|
|
return nil, errors.New("no valid detector boxes")
|
|
}
|
|
|
|
return []byte(strings.Join(lines, "\n") + "\n"), nil
|
|
}
|
|
|
|
func trainingWriteDetectorSample(
|
|
root string,
|
|
sample *TrainingSample,
|
|
boxes []TrainingBox,
|
|
allowEmpty bool,
|
|
) error {
|
|
if sample == nil {
|
|
return errors.New("sample missing")
|
|
}
|
|
|
|
classMap, err := trainingDetectorClassMap()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
labelContent, err := trainingDetectorLabelContent(boxes, classMap, allowEmpty)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg")
|
|
if _, err := os.Stat(srcFrame); err != nil {
|
|
return appErrorf("frame missing: %w", err)
|
|
}
|
|
|
|
// Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split.
|
|
split := trainingStableSplit(sample.SampleID)
|
|
|
|
imgDir := filepath.Join(root, "detector", "dataset", "images", split)
|
|
lblDir := filepath.Join(root, "detector", "dataset", "labels", split)
|
|
|
|
if err := os.MkdirAll(imgDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(lblDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
dstFrame := filepath.Join(imgDir, sample.SampleID+".jpg")
|
|
if err := copyFile(srcFrame, dstFrame); err != nil {
|
|
return err
|
|
}
|
|
|
|
labelPath := filepath.Join(lblDir, sample.SampleID+".txt")
|
|
return os.WriteFile(labelPath, labelContent, 0644)
|
|
}
|
|
|
|
func trainingSexPositionForFeedback(sample *TrainingSample, req TrainingFeedbackRequest) string {
|
|
if req.Negative {
|
|
return ""
|
|
}
|
|
|
|
if req.Correction != nil {
|
|
return normalizeSexPositionLabel(req.Correction.SexPosition)
|
|
}
|
|
|
|
if req.Accepted && sample != nil {
|
|
return normalizeSexPositionLabel(sample.Prediction.SexPosition)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func trainingPosePersonsForSample(root string, sample *TrainingSample) []TrainingPosePerson {
|
|
if sample == nil {
|
|
return nil
|
|
}
|
|
|
|
if len(sample.Prediction.Persons) > 0 {
|
|
return sample.Prediction.Persons
|
|
}
|
|
|
|
framePath := filepath.Join(root, "frames", sample.SampleID+".jpg")
|
|
if !fileExistsNonEmpty(framePath) {
|
|
return nil
|
|
}
|
|
|
|
pose := trainingPredictPose(root, framePath)
|
|
if !pose.Available || len(pose.Persons) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return pose.Persons
|
|
}
|
|
|
|
func trainingPosePersonsForCorrection(root string, sample *TrainingSample, correction *TrainingCorrection) []TrainingPosePerson {
|
|
if correction != nil && correction.PosePersons != nil {
|
|
persons := make([]TrainingPosePerson, 0, len(correction.PosePersons))
|
|
for _, person := range correction.PosePersons {
|
|
persons = append(persons, trainingAnnotatePosePersonQuality(person))
|
|
}
|
|
return persons
|
|
}
|
|
|
|
return trainingPosePersonsForSample(root, sample)
|
|
}
|
|
|
|
func trainingPoseContextPersonBoxes(boxes []TrainingBox) []TrainingBox {
|
|
out := []TrainingBox{}
|
|
|
|
for _, box := range boxes {
|
|
if !trainingIsPersonLikeLabel(box.Label) {
|
|
continue
|
|
}
|
|
|
|
if normalized, ok := trainingNormalizedBox(box); ok {
|
|
out = append(out, normalized)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func trainingFilterPosePersonsByContext(
|
|
persons []TrainingPosePerson,
|
|
contextBoxes []TrainingBox,
|
|
) []TrainingPosePerson {
|
|
if len(persons) == 0 {
|
|
return persons
|
|
}
|
|
|
|
personBoxes := trainingPoseContextPersonBoxes(contextBoxes)
|
|
if len(personBoxes) == 0 {
|
|
return persons
|
|
}
|
|
|
|
filtered := []TrainingPosePerson{}
|
|
|
|
for _, person := range persons {
|
|
personBox, ok := trainingNormalizedBox(person.Box)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
for _, contextBox := range personBoxes {
|
|
if trainingBoxOverlapRatio(personBox, contextBox) >= 0.12 ||
|
|
trainingBoxGap(personBox, contextBox) <= 0.08 {
|
|
filtered = append(filtered, person)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(filtered) == 0 {
|
|
return persons
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
func trainingPoseLabelContent(persons []TrainingPosePerson, classID int) ([]byte, error) {
|
|
lines := []string{}
|
|
|
|
for _, person := range persons {
|
|
person = trainingAnnotatePosePersonQuality(person)
|
|
if !trainingPosePersonReliable(person) {
|
|
continue
|
|
}
|
|
|
|
if len(person.Keypoints) < trainingPoseKeypointCount {
|
|
continue
|
|
}
|
|
|
|
x := clamp01(person.Box.X)
|
|
y := clamp01(person.Box.Y)
|
|
w := clamp01(person.Box.W)
|
|
h := clamp01(person.Box.H)
|
|
|
|
if w <= 0 || h <= 0 {
|
|
continue
|
|
}
|
|
|
|
xCenter := clamp01(x + w/2)
|
|
yCenter := clamp01(y + h/2)
|
|
|
|
parts := []string{
|
|
strconv.Itoa(classID),
|
|
fmt.Sprintf("%.6f", xCenter),
|
|
fmt.Sprintf("%.6f", yCenter),
|
|
fmt.Sprintf("%.6f", w),
|
|
fmt.Sprintf("%.6f", h),
|
|
}
|
|
|
|
visible := 0
|
|
for i := 0; i < trainingPoseKeypointCount; i++ {
|
|
kp := person.Keypoints[i]
|
|
kx := clamp01(kp.X)
|
|
ky := clamp01(kp.Y)
|
|
visibility := 0
|
|
|
|
if kp.Conf >= trainingPoseKeypointMinConfidence && kx > 0 && ky > 0 {
|
|
visibility = 2
|
|
visible++
|
|
} else {
|
|
kx = 0
|
|
ky = 0
|
|
}
|
|
|
|
parts = append(
|
|
parts,
|
|
fmt.Sprintf("%.6f", kx),
|
|
fmt.Sprintf("%.6f", ky),
|
|
strconv.Itoa(visibility),
|
|
)
|
|
}
|
|
|
|
if visible < 5 {
|
|
continue
|
|
}
|
|
|
|
lines = append(lines, strings.Join(parts, " "))
|
|
}
|
|
|
|
if len(lines) == 0 {
|
|
return nil, errors.New("no valid pose persons")
|
|
}
|
|
|
|
return []byte(strings.Join(lines, "\n") + "\n"), nil
|
|
}
|
|
|
|
func trainingWritePoseSample(
|
|
root string,
|
|
sample *TrainingSample,
|
|
sexPosition string,
|
|
contextBoxes []TrainingBox,
|
|
correction *TrainingCorrection,
|
|
) error {
|
|
if sample == nil {
|
|
return errors.New("sample missing")
|
|
}
|
|
|
|
sexPosition = strings.TrimSpace(sexPosition)
|
|
if isNoSexPositionLabel(sexPosition) {
|
|
return errors.New("pose sex position missing")
|
|
}
|
|
|
|
classMap, err := trainingPoseClassMap()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
classID, ok := classMap[sexPosition]
|
|
if !ok {
|
|
return fmt.Errorf("pose class not configured: %s", sexPosition)
|
|
}
|
|
|
|
persons := trainingPosePersonsForCorrection(root, sample, correction)
|
|
persons = trainingFilterPosePersonsByContext(persons, contextBoxes)
|
|
labelContent, err := trainingPoseLabelContent(persons, classID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg")
|
|
if _, err := os.Stat(srcFrame); err != nil {
|
|
return appErrorf("frame missing: %w", err)
|
|
}
|
|
|
|
split := trainingStableSplit(sample.SampleID)
|
|
|
|
imgDir := filepath.Join(root, "pose", "dataset", "images", split)
|
|
lblDir := filepath.Join(root, "pose", "dataset", "labels", split)
|
|
|
|
if err := os.MkdirAll(imgDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(lblDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
dstFrame := filepath.Join(imgDir, sample.SampleID+".jpg")
|
|
if err := copyFile(srcFrame, dstFrame); err != nil {
|
|
return err
|
|
}
|
|
|
|
labelPath := filepath.Join(lblDir, sample.SampleID+".txt")
|
|
return os.WriteFile(labelPath, labelContent, 0644)
|
|
}
|
|
|
|
func trainingDeleteDetectorSample(root string, sampleID string) {
|
|
sampleID = strings.TrimSpace(sampleID)
|
|
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
|
|
return
|
|
}
|
|
|
|
for _, split := range []string{"train", "val"} {
|
|
labelsDir := filepath.Join(root, "detector", "dataset", "labels", split)
|
|
imagesDir := filepath.Join(root, "detector", "dataset", "images", split)
|
|
|
|
_ = os.Remove(filepath.Join(labelsDir, sampleID+".txt"))
|
|
|
|
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
|
_ = os.Remove(filepath.Join(imagesDir, sampleID+ext))
|
|
}
|
|
}
|
|
}
|
|
|
|
func trainingDeletePoseSample(root string, sampleID string) {
|
|
sampleID = strings.TrimSpace(sampleID)
|
|
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
|
|
return
|
|
}
|
|
|
|
for _, split := range []string{"train", "val"} {
|
|
labelsDir := filepath.Join(root, "pose", "dataset", "labels", split)
|
|
imagesDir := filepath.Join(root, "pose", "dataset", "images", split)
|
|
|
|
_ = os.Remove(filepath.Join(labelsDir, sampleID+".txt"))
|
|
|
|
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
|
_ = os.Remove(filepath.Join(imagesDir, sampleID+ext))
|
|
}
|
|
}
|
|
}
|
|
|
|
func trainingSyncPoseDataset(root string) (int, error) {
|
|
if err := trainingEnsurePoseDirs(root); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
items, err := trainingReadAnnotations(root)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
written := 0
|
|
|
|
for _, item := range items {
|
|
sampleID := strings.TrimSpace(item.SampleID)
|
|
if sampleID == "" {
|
|
continue
|
|
}
|
|
|
|
sample := &TrainingSample{
|
|
SampleID: item.SampleID,
|
|
FrameURL: item.FrameURL,
|
|
SourceFile: item.SourceFile,
|
|
SourcePath: item.SourcePath,
|
|
SourceSizeBytes: item.SourceSizeBytes,
|
|
Second: item.Second,
|
|
CreatedAt: item.CreatedAt,
|
|
UncertaintyScore: 0,
|
|
Prediction: item.Prediction,
|
|
}
|
|
|
|
effective := trainingEffectiveCorrection(item)
|
|
sexPosition := strings.TrimSpace(effective.SexPosition)
|
|
|
|
trainingDeletePoseSample(root, sampleID)
|
|
|
|
if item.Negative || isNoSexPositionLabel(sexPosition) {
|
|
continue
|
|
}
|
|
|
|
if err := trainingWritePoseSample(root, sample, sexPosition, effective.Boxes, &effective); err != nil {
|
|
appLogln("pose sample sync skipped:", sampleID, err)
|
|
continue
|
|
}
|
|
|
|
written++
|
|
}
|
|
|
|
return written, nil
|
|
}
|
|
|
|
func trainingEnsureDetectorValidationSample(root string) error {
|
|
trainImages := filepath.Join(root, "detector", "dataset", "images", "train")
|
|
trainLabels := filepath.Join(root, "detector", "dataset", "labels", "train")
|
|
valImages := filepath.Join(root, "detector", "dataset", "images", "val")
|
|
valLabels := filepath.Join(root, "detector", "dataset", "labels", "val")
|
|
|
|
currentVal := trainingCountDetectorSamples(valImages, valLabels)
|
|
currentPositiveVal := trainingCountPositiveDetectorSamples(valImages, valLabels)
|
|
if currentVal >= minDetectorValCount && currentPositiveVal > 0 {
|
|
return nil
|
|
}
|
|
|
|
if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount ||
|
|
trainingCountPositiveDetectorSamples(trainImages, trainLabels) == 0 {
|
|
return nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(trainImages)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
if err := os.MkdirAll(valImages, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(valLabels, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
copied := 0
|
|
needed := max(0, minDetectorValCount-currentVal)
|
|
needsPositive := currentPositiveVal == 0
|
|
|
|
sort.SliceStable(entries, func(i, j int) bool {
|
|
iID := strings.TrimSuffix(entries[i].Name(), filepath.Ext(entries[i].Name()))
|
|
jID := strings.TrimSuffix(entries[j].Name(), filepath.Ext(entries[j].Name()))
|
|
|
|
return fileExistsNonEmpty(filepath.Join(trainLabels, iID+".txt")) &&
|
|
!fileExistsNonEmpty(filepath.Join(trainLabels, jID+".txt"))
|
|
})
|
|
|
|
for _, e := range entries {
|
|
if copied >= needed && !needsPositive {
|
|
break
|
|
}
|
|
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
|
|
|
srcImage := filepath.Join(trainImages, e.Name())
|
|
srcLabel := filepath.Join(trainLabels, id+".txt")
|
|
|
|
if !fileExistsNonEmpty(srcImage) || !fileExists(srcLabel) {
|
|
continue
|
|
}
|
|
|
|
dstImage := filepath.Join(valImages, e.Name())
|
|
dstLabel := filepath.Join(valLabels, id+".txt")
|
|
|
|
if fileExistsNonEmpty(dstImage) && fileExists(dstLabel) {
|
|
continue
|
|
}
|
|
|
|
if err := copyFile(srcImage, dstImage); err != nil {
|
|
return err
|
|
}
|
|
if err := copyFile(srcLabel, dstLabel); err != nil {
|
|
return err
|
|
}
|
|
|
|
copied++
|
|
if fileExistsNonEmpty(srcLabel) {
|
|
needsPositive = false
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func trainingEnsurePoseValidationSample(root string) error {
|
|
trainImages := filepath.Join(root, "pose", "dataset", "images", "train")
|
|
trainLabels := filepath.Join(root, "pose", "dataset", "labels", "train")
|
|
valImages := filepath.Join(root, "pose", "dataset", "images", "val")
|
|
valLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
|
|
|
|
currentVal := trainingCountDetectorSamples(valImages, valLabels)
|
|
if currentVal >= minPoseValCount {
|
|
return nil
|
|
}
|
|
|
|
if trainingCountDetectorSamples(trainImages, trainLabels) < minPoseTrainCount {
|
|
return nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(trainImages)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
if err := os.MkdirAll(valImages, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(valLabels, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
copied := 0
|
|
needed := max(0, minPoseValCount-currentVal)
|
|
|
|
for _, e := range entries {
|
|
if copied >= needed {
|
|
break
|
|
}
|
|
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
|
|
|
srcImage := filepath.Join(trainImages, e.Name())
|
|
srcLabel := filepath.Join(trainLabels, id+".txt")
|
|
|
|
if !fileExistsNonEmpty(srcImage) || !fileExistsNonEmpty(srcLabel) {
|
|
continue
|
|
}
|
|
|
|
dstImage := filepath.Join(valImages, e.Name())
|
|
dstLabel := filepath.Join(valLabels, id+".txt")
|
|
|
|
if fileExistsNonEmpty(dstImage) && fileExistsNonEmpty(dstLabel) {
|
|
continue
|
|
}
|
|
|
|
if err := copyFile(srcImage, dstImage); err != nil {
|
|
return err
|
|
}
|
|
if err := copyFile(srcLabel, dstLabel); err != nil {
|
|
return err
|
|
}
|
|
|
|
copied++
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func trainingStableSplit(sampleID string) string {
|
|
sum := sha1.Sum([]byte(sampleID))
|
|
if int(sum[0])%5 == 0 {
|
|
return "val"
|
|
}
|
|
return "train"
|
|
}
|
|
|
|
func copyFile(src string, dst string) error {
|
|
b, err := os.ReadFile(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(dst, b, 0644)
|
|
}
|
|
|
|
func trainingEmptyPrediction(source string) TrainingPrediction {
|
|
return TrainingPrediction{
|
|
ModelAvailable: false,
|
|
Source: source,
|
|
SexPosition: trainingNoSexPositionLabel,
|
|
SexPositionScore: 0,
|
|
PeoplePresent: []TrainingScoredLabel{},
|
|
BodyPartsPresent: []TrainingScoredLabel{},
|
|
ObjectsPresent: []TrainingScoredLabel{},
|
|
ClothingPresent: []TrainingScoredLabel{},
|
|
Boxes: []TrainingBox{},
|
|
}
|
|
}
|
|
|
|
func trainingPythonExe() string {
|
|
v := strings.TrimSpace(os.Getenv("TRAINING_PYTHON"))
|
|
if v != "" {
|
|
return v
|
|
}
|
|
return aiServerPythonPath()
|
|
}
|
|
|
|
func trainingProjectRoot() string {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return "."
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(wd, "backend", "ml", "predict_detector_model.py")); err == nil {
|
|
return wd
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(wd, "ml", "predict_detector_model.py")); err == nil {
|
|
return filepath.Dir(wd)
|
|
}
|
|
|
|
parent := filepath.Dir(wd)
|
|
if _, err := os.Stat(filepath.Join(parent, "backend", "ml", "predict_detector_model.py")); err == nil {
|
|
return parent
|
|
}
|
|
|
|
return wd
|
|
}
|
|
|
|
func trainingScriptPath(name string) string {
|
|
// 1) Eingebettete Scripts bevorzugen.
|
|
if dir, err := trainingEmbeddedMLDir(); err == nil {
|
|
p := filepath.Join(dir, name)
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
|
|
// 2) App-/Backend-relativ wie record_paths.go.
|
|
if backendRoot, err := trainingBackendRootDir(); err == nil {
|
|
p := filepath.Join(backendRoot, "ml", name)
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
|
|
// 3) Dev-Fallback.
|
|
root := trainingProjectRoot()
|
|
|
|
p := filepath.Join(root, "backend", "ml", name)
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
|
|
p = filepath.Join("ml", name)
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
|
|
return filepath.Join(root, "backend", "ml", name)
|
|
}
|
|
|
|
func isTempBuildDir(dir string) bool {
|
|
low := strings.ToLower(filepath.Clean(dir))
|
|
|
|
return strings.Contains(low, `\appdata\local\temp`) ||
|
|
strings.Contains(low, `\temp\`) ||
|
|
strings.Contains(low, `\tmp\`) ||
|
|
strings.Contains(low, `\go-build`) ||
|
|
strings.Contains(low, `/tmp/`) ||
|
|
strings.Contains(low, `/go-build`)
|
|
}
|
|
|
|
func trainingBackendRootDir() (string, error) {
|
|
if script, err := resolvePathRelativeToApp(filepath.Join("ml", "predict_detector_model.py")); err == nil {
|
|
if st, statErr := os.Stat(script); statErr == nil && !st.IsDir() {
|
|
return filepath.Dir(filepath.Dir(script)), nil
|
|
}
|
|
}
|
|
|
|
if script, err := resolvePathRelativeToApp(filepath.Join("backend", "ml", "predict_detector_model.py")); err == nil {
|
|
if st, statErr := os.Stat(script); statErr == nil && !st.IsDir() {
|
|
return filepath.Dir(filepath.Dir(script)), nil
|
|
}
|
|
}
|
|
|
|
if dir, err := exeDir(); err == nil && strings.TrimSpace(dir) != "" && !isTempBuildDir(dir) {
|
|
return dir, nil
|
|
}
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(wd, "ml", "predict_detector_model.py")); err == nil {
|
|
return wd, nil
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(wd, "backend", "ml", "predict_detector_model.py")); err == nil {
|
|
return filepath.Join(wd, "backend"), nil
|
|
}
|
|
|
|
projectRoot := trainingProjectRoot()
|
|
return filepath.Join(projectRoot, "backend"), nil
|
|
}
|
|
|
|
func trainingRootDir() (string, error) {
|
|
// Optionaler Override, falls du später explizit einen anderen Speicherort willst.
|
|
// Relative Pfade werden wie in record_paths.go app-relativ aufgelöst.
|
|
if override := strings.TrimSpace(os.Getenv("TRAINING_ROOT")); override != "" {
|
|
root, err := resolvePathRelativeToApp(override)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
root, err = filepath.Abs(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := os.MkdirAll(root, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return root, nil
|
|
}
|
|
|
|
backendRoot, err := trainingBackendRootDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
root, err := filepath.Abs(filepath.Join(backendRoot, "generated", "training"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := os.MkdirAll(root, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return root, nil
|
|
}
|
|
|
|
func trainingWriteSample(root string, sample *TrainingSample) error {
|
|
path := filepath.Join(root, "samples", sample.SampleID+".json")
|
|
b, err := json.MarshalIndent(sample, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, b, 0644)
|
|
}
|
|
|
|
func trainingReadSample(root string, sampleID string) (*TrainingSample, error) {
|
|
if strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
|
|
return nil, errors.New("invalid sample id")
|
|
}
|
|
|
|
path := filepath.Join(root, "samples", sampleID+".json")
|
|
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var sample TrainingSample
|
|
if err := json.Unmarshal(b, &sample); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &sample, nil
|
|
}
|
|
|
|
func trainingAppendAnnotation(root string, annotation TrainingAnnotation) error {
|
|
path := filepath.Join(root, "feedback.jsonl")
|
|
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
b, err := json.Marshal(annotation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := f.Write(append(b, '\n')); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func trainingWriteAnnotations(root string, items []TrainingAnnotation) error {
|
|
path := filepath.Join(root, "feedback.jsonl")
|
|
tmpPath := path + ".tmp"
|
|
|
|
var b strings.Builder
|
|
|
|
for _, item := range items {
|
|
line, err := json.Marshal(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
b.Write(line)
|
|
b.WriteByte('\n')
|
|
}
|
|
|
|
if err := os.WriteFile(tmpPath, []byte(b.String()), 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.Rename(tmpPath, path)
|
|
}
|
|
|
|
func trainingCountAnnotations(path string) (int, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
text := strings.TrimSpace(string(b))
|
|
if text == "" {
|
|
return 0, nil
|
|
}
|
|
|
|
return len(strings.Split(text, "\n")), nil
|
|
}
|
|
|
|
func trainingProbeDurationSeconds(videoPath string) float64 {
|
|
settings := getSettings()
|
|
|
|
ffmpeg := strings.TrimSpace(settings.FFmpegPath)
|
|
ffprobe := "ffprobe"
|
|
|
|
if ffmpeg != "" {
|
|
dir := filepath.Dir(ffmpeg)
|
|
base := filepath.Base(ffmpeg)
|
|
if strings.Contains(strings.ToLower(base), "ffmpeg") {
|
|
ffprobeBase := strings.Replace(base, "ffmpeg", "ffprobe", 1)
|
|
ffprobe = filepath.Join(dir, ffprobeBase)
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(
|
|
ffprobe,
|
|
"-v", "error",
|
|
"-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
videoPath,
|
|
)
|
|
|
|
hideCommandWindow(cmd)
|
|
|
|
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,
|
|
})
|
|
}
|