This commit is contained in:
Linrador 2026-04-30 14:40:15 +02:00
parent 03ff0029f2
commit a598ac025f
3 changed files with 261 additions and 83 deletions

View File

@ -784,9 +784,9 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
canTrain := feedbackCount >= minTrainingFeedbackCount
// Pipeline:
// - YOLO trainiert nur BodyParts, Objects, Clothing und deren Boxen.
// - CLIP + Logistic Regression/KNN trainiert die Sexposition.
// - Personen/Gender werden manuell korrigiert und nicht per YOLO erkannt.
// - YOLO erkennt Personen/Gender für die Counts.
// - Automatisch erkannte Personenboxen werden nicht an das Frontend als sichtbare Boxen zurückgegeben.
// - Manuell gezeichnete Personenboxen werden trotzdem als Trainingsdaten gespeichert.
trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"feedbackCount": feedbackCount,
@ -800,8 +800,8 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
"usesSceneKNN": false,
"usesResNet18KNN": false,
"detectsPeople": false,
"detectsGender": false,
"detectsPeople": true,
"detectsGender": true,
"detectsBodyParts": true,
"detectsObjects": true,
"detectsClothing": true,
@ -856,8 +856,8 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
"pipeline": map[string]any{
"variant": "B",
"peopleSource": "manual",
"genderSource": "manual",
"peopleSource": "yolo_detector",
"genderSource": "yolo_detector",
"bodyPartsSource": "yolo_detector",
"objectsSource": "yolo_detector",
"clothingSource": "yolo_detector",
@ -1304,9 +1304,9 @@ func trainingApplyScenePosition(
}
func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPrediction {
boxes := det.Boxes
if boxes == nil {
boxes = []TrainingBox{}
rawBoxes := det.Boxes
if rawBoxes == nil {
rawBoxes = []TrainingBox{}
}
pred := TrainingPrediction{
@ -1321,7 +1321,7 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred
BodyPartsPresent: []TrainingScoredLabel{},
ObjectsPresent: []TrainingScoredLabel{},
ClothingPresent: []TrainingScoredLabel{},
Boxes: boxes,
Boxes: []TrainingBox{},
}
if pred.Source == "" {
@ -1338,36 +1338,78 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred
return pred
}
allowed := map[string]bool{}
for _, label := range grouped.BodyParts {
allowed[strings.TrimSpace(label)] = true
}
for _, label := range grouped.Objects {
allowed[strings.TrimSpace(label)] = true
}
for _, label := range grouped.Clothing {
allowed[strings.TrimSpace(label)] = true
}
filteredBoxes := []TrainingBox{}
for _, box := range boxes {
label := strings.TrimSpace(box.Label)
if allowed[label] {
filteredBoxes = append(filteredBoxes, box)
peopleSet := map[string]bool{}
for _, label := range grouped.People {
clean := strings.TrimSpace(label)
if clean != "" {
peopleSet[clean] = true
}
}
boxes = filteredBoxes
pred.Boxes = boxes
detectionSet := map[string]bool{}
pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.BodyParts)
pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Objects)
pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Clothing)
for _, label := range grouped.BodyParts {
clean := strings.TrimSpace(label)
if clean != "" {
detectionSet[clean] = true
}
}
pred.UnknownCount = 0
pred.PeopleCount = 0
pred.MaleCount = 0
pred.FemaleCount = 0
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 {
label := strings.TrimSpace(box.Label)
if label == "" {
continue
}
box.Label = label
// Personen erkennen und zählen.
// Personenboxen werden jetzt auch sichtbar zurückgegeben,
// damit sie im Frontend gezeichnet, verschoben, gelöscht und trainiert werden können.
if peopleSet[label] {
switch label {
case "person_male", "male_person":
pred.MaleCount++
case "person_female", "female_person":
pred.FemaleCount++
case "person", "person_unknown":
pred.UnknownCount++
}
visibleBoxes = append(visibleBoxes, box)
continue
}
// Nur Bodyparts/Objects/Clothing bleiben als Boxen sichtbar.
if detectionSet[label] {
visibleBoxes = append(visibleBoxes, box)
}
}
pred.PeopleCount = pred.MaleCount + pred.FemaleCount + pred.UnknownCount
pred.Boxes = visibleBoxes
pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.BodyParts)
pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Objects)
pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Clothing)
return pred
}

View File

@ -87,7 +87,10 @@ func trainingDetectorLabels() ([]string, error) {
labels := []string{}
// Bestehende Reihenfolge beibehalten, damit alte Class-IDs stabil bleiben.
// Wichtig:
// People zuerst oder zuletzt ist egal, aber die Reihenfolge bestimmt YOLO-Class-IDs.
// Wenn du schon ein bestehendes Detector-Modell hast, musst du danach neu trainieren.
labels = append(labels, grouped.People...)
labels = append(labels, grouped.BodyParts...)
labels = append(labels, grouped.Objects...)
labels = append(labels, grouped.Clothing...)

View File

@ -124,6 +124,88 @@ function percent(v: number) {
return `${Math.round(v * 100)}%`
}
function scoreLevel(score?: number | null): 'none' | 'low' | 'mid' | 'high' {
const n = Number(score)
if (!Number.isFinite(n)) return 'none'
if (n < 0.5) return 'low'
if (n < 0.75) return 'mid'
return 'high'
}
function scoreBorderClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return 'border-amber-400'
switch (scoreLevel(score)) {
case 'low':
return 'border-red-500'
case 'mid':
return 'border-yellow-400'
case 'high':
return 'border-emerald-400'
default:
return 'border-gray-300'
}
}
function scoreBgClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return 'bg-amber-400'
switch (scoreLevel(score)) {
case 'low':
return 'bg-red-500'
case 'mid':
return 'bg-yellow-400'
case 'high':
return 'bg-emerald-400'
default:
return 'bg-gray-300'
}
}
function scoreHoverClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return ''
switch (scoreLevel(score)) {
case 'low':
return 'hover:bg-red-400'
case 'mid':
return 'hover:bg-yellow-300'
case 'high':
return 'hover:bg-emerald-300'
default:
return 'hover:bg-gray-200'
}
}
function scoreRingClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return 'ring-amber-500'
switch (scoreLevel(score)) {
case 'low':
return 'ring-red-500'
case 'mid':
return 'ring-yellow-400'
case 'high':
return 'ring-emerald-500'
default:
return 'ring-gray-400'
}
}
function scoreDetectionPillClass(score?: number | null) {
switch (scoreLevel(score)) {
case 'low':
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
case 'mid':
return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
case 'high':
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
default:
return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
}
}
function normalizeMovedBox(box: TrainingBox): TrainingBox {
const w = clamp01(box.w)
const h = clamp01(box.h)
@ -137,22 +219,6 @@ function normalizeMovedBox(box: TrainingBox): TrainingBox {
}
}
function scoredLabelsText(items?: ScoredLabel[] | null) {
const list = Array.isArray(items) ? items : []
if (list.length === 0) return '—'
return list
.map((x) => {
const label = String(x?.label ?? '').trim()
if (!label) return null
return `${label} (${percent(Number(x?.score ?? 0))})`
})
.filter(Boolean)
.join(', ')
}
function clampPercent(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(100, v))
@ -209,19 +275,6 @@ function uniqStrings(values: string[]) {
return out
}
function isPersonBoxLabel(label?: string) {
const normalized = String(label || '').trim().toLowerCase()
return (
normalized === 'person' ||
normalized === 'person_male' ||
normalized === 'person_female' ||
normalized === 'person_unknown' ||
normalized === 'male_person' ||
normalized === 'female_person'
)
}
function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
const p = sample?.prediction
@ -246,8 +299,7 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState
w: clamp01(Number(box.w)),
h: clamp01(Number(box.h)),
}))
.filter((box) => box.label && box.w > 0 && box.h > 0)
.filter((box) => !isPersonBoxLabel(box.label)),
.filter((box) => box.label && box.w > 0 && box.h > 0),
}
}
@ -532,6 +584,52 @@ function LabelToggleGrid(props: {
)
}
function ScoredLabelChips(props: {
items?: ScoredLabel[] | null
}) {
const list = Array.isArray(props.items) ? props.items : []
if (list.length === 0) {
return <span className="text-gray-500 dark:text-gray-400"></span>
}
return (
<div className="mt-1 flex flex-wrap gap-1">
{list.map((entry, index) => {
const label = String(entry?.label ?? '').trim()
if (!label) return null
const score = Number(entry?.score ?? NaN)
const item = getSegmentLabelItem(label)
const Icon = item.icon
return (
<span
key={`${label}-${index}`}
title={`${label} ${Number.isFinite(score) ? percent(score) : ''}`}
className={[
'inline-flex max-w-full items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold ring-1',
scoreDetectionPillClass(score),
].join(' ')}
>
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="max-w-[92px] truncate">
{item.text}
</span>
{Number.isFinite(score) ? (
<span className="shrink-0 opacity-80">
{percent(score)}
</span>
) : null}
</span>
)
})}
</div>
)
}
function DetectorBoxLabelSelect(props: {
values: string[]
value: string
@ -1388,20 +1486,48 @@ export default function TrainingTab() {
<div>Weiblich: {sample?.prediction.femaleCount ?? '—'}</div>
<div>
Position: {sample?.prediction.sexPosition ?? '—'}{' '}
{sample ? `(${percent(sample.prediction.sexPositionScore)})` : ''}
<div className="font-medium text-gray-700 dark:text-gray-200">
Position
</div>
{sample ? (
<span
className={[
'mt-1 inline-flex max-w-full items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ring-1',
scoreDetectionPillClass(sample.prediction.sexPositionScore),
].join(' ')}
>
<span className="truncate">
{sample.prediction.sexPosition || '—'}
</span>
<span className="ml-1 shrink-0 opacity-80">
{percent(sample.prediction.sexPositionScore)}
</span>
</span>
) : (
<span></span>
)}
</div>
<div>
Körperteile: {scoredLabelsText(sample?.prediction.bodyPartsPresent)}
<div className="font-medium text-gray-700 dark:text-gray-200">
Körperteile
</div>
<ScoredLabelChips items={sample?.prediction.bodyPartsPresent} />
</div>
<div>
Gegenstände: {scoredLabelsText(sample?.prediction.objectsPresent)}
<div className="font-medium text-gray-700 dark:text-gray-200">
Gegenstände
</div>
<ScoredLabelChips items={sample?.prediction.objectsPresent} />
</div>
<div>
Kleidung: {scoredLabelsText(sample?.prediction.clothingPresent)}
<div className="font-medium text-gray-700 dark:text-gray-200">
Kleidung
</div>
<ScoredLabelChips items={sample?.prediction.clothingPresent} />
</div>
</div>
@ -1441,7 +1567,10 @@ export default function TrainingTab() {
return (
<div
key={`${box.label}-${index}`}
className="flex items-center justify-between gap-2 rounded-md bg-white px-2 py-1 text-[11px] ring-1 ring-gray-200 dark:bg-gray-950 dark:ring-white/10"
className={[
'flex items-center justify-between gap-2 rounded-md px-2 py-1 text-[11px] ring-1',
scoreDetectionPillClass(box.score),
].join(' ')}
>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 text-gray-700 dark:text-gray-200">
<span className="flex min-w-0 items-center gap-1.5">
@ -1583,9 +1712,7 @@ export default function TrainingTab() {
key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
className={[
'pointer-events-none absolute rounded border-2 shadow-[0_0_0_1px_rgba(0,0,0,0.75)]',
isDraft
? 'border-amber-400'
: 'border-emerald-400',
scoreBorderClass(box.score, { draft: isDraft }),
].join(' ')}
style={{
left: `${left}%`,
@ -1600,9 +1727,7 @@ export default function TrainingTab() {
data-box-control="true"
className={[
'pointer-events-auto absolute left-0 top-0 flex h-5 max-w-[min(180px,calc(100vw-24px))] -translate-y-full touch-none items-stretch overflow-hidden rounded-t-md text-[10px] font-semibold leading-none text-black shadow',
isDraft
? 'bg-amber-400'
: 'bg-emerald-400',
scoreBgClass(box.score, { draft: isDraft }),
].join(' ')}
title={isDraft ? box.label : `${item.text} verschieben`}
>
@ -1612,7 +1737,7 @@ export default function TrainingTab() {
'flex min-w-0 touch-none items-center gap-1 px-1.5 text-left',
isDraft
? 'cursor-default'
: 'cursor-move hover:bg-emerald-300',
: ['cursor-move', scoreHoverClass(box.score)].join(' '),
].join(' ')}
disabled={Boolean(isDraft) || uiLocked}
onPointerDown={(e) => {
@ -1741,7 +1866,12 @@ export default function TrainingTab() {
})
}}
>
<span className="block h-2 w-2 rounded-full bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.75)] ring-2 ring-emerald-500 sm:h-2 sm:w-2" />
<span
className={[
'block h-2 w-2 rounded-full bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.75)] ring-2 sm:h-2 sm:w-2',
scoreRingClass(box.score),
].join(' ')}
/>
</button>
))}
</>
@ -1848,7 +1978,10 @@ export default function TrainingTab() {
{hasUsableBox ? (
<div
className="absolute rounded border-2 border-amber-400 bg-amber-400/10 shadow-[0_0_0_1px_rgba(0,0,0,0.85)]"
className={[
'absolute rounded border-2 bg-black/0 shadow-[0_0_0_1px_rgba(0,0,0,0.85)]',
scoreBorderClass(activeBox.score, { draft: Boolean(drawingBox) }),
].join(' ')}
style={{
left: boxLeft,
top: boxTop,