bugfixes
This commit is contained in:
parent
03ff0029f2
commit
a598ac025f
@ -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
|
||||
}
|
||||
|
||||
@ -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...)
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user