bugfixes
This commit is contained in:
parent
c905753f7a
commit
6e91c352a7
@ -78,6 +78,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
api.HandleFunc("/api/training/feedback", trainingFeedbackHandler)
|
api.HandleFunc("/api/training/feedback", trainingFeedbackHandler)
|
||||||
api.HandleFunc("/api/training/train", trainingTrainHandler)
|
api.HandleFunc("/api/training/train", trainingTrainHandler)
|
||||||
api.HandleFunc("/api/training/status", trainingStatusHandler)
|
api.HandleFunc("/api/training/status", trainingStatusHandler)
|
||||||
|
api.HandleFunc("/api/training/stats", trainingStatsHandler)
|
||||||
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
||||||
|
|
||||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
|
|||||||
@ -128,6 +128,38 @@ type TrainingJobStatus struct {
|
|||||||
FinishedAt string `json:"finishedAt,omitempty"`
|
FinishedAt string `json:"finishedAt,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 TrainingStatsResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
FeedbackCount int `json:"feedbackCount"`
|
||||||
|
AcceptedCount int `json:"acceptedCount"`
|
||||||
|
CorrectedCount int `json:"correctedCount"`
|
||||||
|
SampleCount int `json:"sampleCount"`
|
||||||
|
BoxCount int `json:"boxCount"`
|
||||||
|
ModelAvailable bool `json:"modelAvailable"`
|
||||||
|
Confidence TrainingConfidence `json:"confidence"`
|
||||||
|
Labels TrainingStatsLabels `json:"labels"`
|
||||||
|
}
|
||||||
|
|
||||||
const minTrainingFeedbackCount = 5
|
const minTrainingFeedbackCount = 5
|
||||||
|
|
||||||
const minDetectorTrainCount = 20
|
const minDetectorTrainCount = 20
|
||||||
@ -874,6 +906,438 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
stats.ModelAvailable = trainingStatsModelAvailable(root)
|
||||||
|
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.Accepted {
|
||||||
|
stats.AcceptedCount++
|
||||||
|
} else {
|
||||||
|
stats.CorrectedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
effective := trainingEffectiveCorrection(annotation)
|
||||||
|
|
||||||
|
sexPosition := strings.TrimSpace(effective.SexPosition)
|
||||||
|
if sexPosition == "" {
|
||||||
|
sexPosition = "unknown"
|
||||||
|
}
|
||||||
|
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"))
|
||||||
|
stats.ModelAvailable = trainingStatsModelAvailable(root)
|
||||||
|
|
||||||
|
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.Correction != nil {
|
||||||
|
return *annotation.Correction
|
||||||
|
}
|
||||||
|
|
||||||
|
p := annotation.Prediction
|
||||||
|
|
||||||
|
return TrainingCorrection{
|
||||||
|
PeopleCount: p.PeopleCount,
|
||||||
|
MaleCount: p.MaleCount,
|
||||||
|
FemaleCount: p.FemaleCount,
|
||||||
|
UnknownCount: p.UnknownCount,
|
||||||
|
SexPosition: p.SexPosition,
|
||||||
|
BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent),
|
||||||
|
ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent),
|
||||||
|
ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent),
|
||||||
|
Boxes: p.Boxes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingScoredLabelsToStrings(values []TrainingScoredLabel) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
|
for _, value := range values {
|
||||||
|
label := strings.TrimSpace(value.Label)
|
||||||
|
if label == "" || seen[label] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[label] = true
|
||||||
|
out = append(out, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingStatsMapToList(values map[string]int, target int) []TrainingLabelStat {
|
||||||
|
out := make([]TrainingLabelStat, 0, len(values))
|
||||||
|
|
||||||
|
for label, count := range values {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if label == "" || count <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, TrainingLabelStat{
|
||||||
|
Label: label,
|
||||||
|
Count: count,
|
||||||
|
Confidence: trainingLabelConfidence(count, target),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Count == out[j].Count {
|
||||||
|
return out[i].Label < out[j].Label
|
||||||
|
}
|
||||||
|
|
||||||
|
return out[i].Count > out[j].Count
|
||||||
|
})
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingCountSampleFiles(samplesDir string) int {
|
||||||
|
entries, err := os.ReadDir(samplesDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(filepath.Ext(entry.Name())) == ".json" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingStatsModelAvailable(root string) bool {
|
||||||
|
detectorModelPath := filepath.Join(root, "detector", "model", "best.pt")
|
||||||
|
|
||||||
|
sceneKNNPath := filepath.Join(root, "model", "scene_clip_knn.joblib")
|
||||||
|
sceneLRPath := filepath.Join(root, "model", "scene_clip_lr.joblib")
|
||||||
|
sceneEmbeddingsPath := filepath.Join(root, "model", "scene_clip_embeddings.npz")
|
||||||
|
sceneTargetsPath := filepath.Join(root, "model", "scene_clip_targets.json")
|
||||||
|
|
||||||
|
detectorReady := fileExistsNonEmpty(detectorModelPath)
|
||||||
|
sceneReady :=
|
||||||
|
fileExistsNonEmpty(sceneEmbeddingsPath) &&
|
||||||
|
fileExistsNonEmpty(sceneTargetsPath) &&
|
||||||
|
(fileExistsNonEmpty(sceneKNNPath) || fileExistsNonEmpty(sceneLRPath))
|
||||||
|
|
||||||
|
return detectorReady || sceneReady
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if feedbackCount > 0 {
|
||||||
|
agreementScore = clamp01(float64(acceptedCount) / float64(feedbackCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 feedbackCount > 0 {
|
||||||
|
correctionRate = clamp01(float64(correctedCount) / float64(feedbackCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func trainingRecognitionEnabled() bool {
|
||||||
return getSettings().TrainingRecognitionEnabled
|
return getSettings().TrainingRecognitionEnabled
|
||||||
}
|
}
|
||||||
@ -1754,7 +2218,6 @@ func trainingEnsureDetectorValidationSample(root string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
copied++
|
copied++
|
||||||
fmt.Println("✅ detector val sample duplicated:", id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user