diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index c57f5d5..418bbc8 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index 7ead139..40d49e4 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index 1a48d58..bfd3cd6 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx b/frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx index d0e0c40..e64b793 100644 --- a/frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx +++ b/frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx @@ -14,6 +14,7 @@ import { PhotoIcon, RectangleGroupIcon, SparklesIcon, + UserGroupIcon, } from '@heroicons/react/20/solid' import Button from './Button' import Modal from './Modal' @@ -29,6 +30,23 @@ export type TrainingFeedbackBox = { h: number } +export type TrainingFeedbackKeypoint = { + name: string + x: number + y: number + conf: number +} + +export type TrainingFeedbackPosePerson = { + label?: string + score: number + box: TrainingFeedbackBox + keypoints: TrainingFeedbackKeypoint[] + quality?: number + visibleKeypoints?: number + reliable?: boolean +} + export type TrainingFeedbackScoredLabel = { label: string score: number @@ -45,6 +63,7 @@ export type TrainingFeedbackPrediction = { objectsPresent: TrainingFeedbackScoredLabel[] clothingPresent: TrainingFeedbackScoredLabel[] boxes?: TrainingFeedbackBox[] + persons?: TrainingFeedbackPosePerson[] } export type TrainingFeedbackCorrection = { @@ -54,6 +73,7 @@ export type TrainingFeedbackCorrection = { objectsPresent: string[] clothingPresent: string[] boxes: TrainingFeedbackBox[] + posePersons?: TrainingFeedbackPosePerson[] } export type TrainingFeedbackAnnotation = { @@ -112,6 +132,125 @@ function normalizeBox(box: TrainingFeedbackBox): TrainingFeedbackBox { } } +const POSE_KEYPOINT_MIN_CONFIDENCE = 0.15 +const POSE_PERSON_MIN_SCORE = 0.30 +const POSE_UNRELIABLE_COLOR = '#94a3b8' +const POSE_PERSON_COLORS = ['#38bdf8', '#a78bfa', '#34d399', '#f59e0b', '#fb7185'] +const POSE_SKELETON_EDGES: Array = [ + ['left_ear', 'left_eye'], + ['left_eye', 'nose'], + ['nose', 'right_eye'], + ['right_eye', 'right_ear'], + ['left_shoulder', 'right_shoulder'], + ['left_shoulder', 'left_elbow'], + ['left_elbow', 'left_wrist'], + ['right_shoulder', 'right_elbow'], + ['right_elbow', 'right_wrist'], + ['left_shoulder', 'left_hip'], + ['right_shoulder', 'right_hip'], + ['left_hip', 'right_hip'], + ['left_hip', 'left_knee'], + ['left_knee', 'left_ankle'], + ['right_hip', 'right_knee'], + ['right_knee', 'right_ankle'], +] +const POSE_KEYPOINT_LABELS: Record = { + nose: 'Nase', + left_eye: 'L Auge', + right_eye: 'R Auge', + left_ear: 'L Ohr', + right_ear: 'R Ohr', + left_shoulder: 'L Schulter', + right_shoulder: 'R Schulter', + left_elbow: 'L Ellbogen', + right_elbow: 'R Ellbogen', + left_wrist: 'L Hand', + right_wrist: 'R Hand', + left_hip: 'L Huefte', + right_hip: 'R Huefte', + left_knee: 'L Knie', + right_knee: 'R Knie', + left_ankle: 'L Fuss', + right_ankle: 'R Fuss', +} + +function poseKeypointId(value?: string | null) { + return String(value || '').trim().toLowerCase().replace(/[\s-]+/g, '_') +} + +function poseKeypointLabel(value?: string | null) { + const id = poseKeypointId(value) + return POSE_KEYPOINT_LABELS[id] || String(value || '').trim() || 'Punkt' +} + +function isPoseKeypointVisible(point?: TrainingFeedbackKeypoint | null): point is TrainingFeedbackKeypoint { + if (!point) return false + + return ( + Number.isFinite(Number(point.x)) && + Number.isFinite(Number(point.y)) && + clamp01(Number(point.conf ?? 1)) >= POSE_KEYPOINT_MIN_CONFIDENCE + ) +} + +function hasVisiblePoseBox(person: TrainingFeedbackPosePerson) { + const box = person.box + const w = Number(box?.w) + const h = Number(box?.h) + + return ( + clamp01(Number(person.score)) >= POSE_PERSON_MIN_SCORE && + Number.isFinite(w) && + Number.isFinite(h) && + w > 0 && + h > 0 + ) +} + +function normalizePosePerson(person: TrainingFeedbackPosePerson): TrainingFeedbackPosePerson | null { + if (!person?.box) return null + + const box = normalizeBox(person.box) + + if (!box) return null + + return { + label: String(person.label || '').trim() || 'person', + score: clamp01(Number(person.score ?? 1)), + box, + keypoints: (person.keypoints ?? []) + .map((point) => ({ + name: poseKeypointId(point.name), + x: clamp01(Number(point.x)), + y: clamp01(Number(point.y)), + conf: clamp01(Number(point.conf ?? 1)), + })) + .filter((point) => point.name), + quality: typeof person.quality === 'number' ? clamp01(Number(person.quality)) : undefined, + visibleKeypoints: + typeof person.visibleKeypoints === 'number' + ? Math.max(0, Math.round(Number(person.visibleKeypoints))) + : undefined, + reliable: typeof person.reliable === 'boolean' ? person.reliable : undefined, + } +} + +function normalizePosePersons(persons?: TrainingFeedbackPosePerson[]) { + return (persons ?? []) + .map(normalizePosePerson) + .filter((person): person is TrainingFeedbackPosePerson => Boolean(person)) +} + +function visiblePosePersons(persons?: TrainingFeedbackPosePerson[]) { + return normalizePosePersons(persons).filter( + (person) => hasVisiblePoseBox(person) || person.keypoints.some(isPoseKeypointVisible) + ) +} + +function poseCoordPx(value: number, size: number) { + return clamp01(Number(value)) * Math.max(0, Number(size) || 0) +} + function annotationEffectiveCorrection( annotation: TrainingFeedbackAnnotation ): TrainingFeedbackCorrection { @@ -123,6 +262,7 @@ function annotationEffectiveCorrection( objectsPresent: [], clothingPresent: [], boxes: [], + posePersons: [], } } @@ -130,6 +270,9 @@ function annotationEffectiveCorrection( return { ...annotation.correction, sexPosition: normalizeSexPositionValue(annotation.correction.sexPosition), + posePersons: normalizePosePersons( + annotation.correction.posePersons ?? annotation.prediction.persons + ), } } @@ -140,6 +283,7 @@ function annotationEffectiveCorrection( objectsPresent: (annotation.prediction.objectsPresent ?? []).map((item) => item.label), clothingPresent: (annotation.prediction.clothingPresent ?? []).map((item) => item.label), boxes: annotation.prediction.boxes ?? [], + posePersons: normalizePosePersons(annotation.prediction.persons), } } @@ -370,6 +514,7 @@ function FilterButton(props: { function ReadonlyTrainingFrameViewer(props: { imageSrc: string boxes: TrainingFeedbackBox[] + posePersons?: TrainingFeedbackPosePerson[] sourceFile?: string className?: string highlightedBoxIndex?: number | null @@ -384,6 +529,10 @@ function ReadonlyTrainingFrameViewer(props: { .map(normalizeBox) .filter((box) => String(box.label || '').trim() && box.w > 0 && box.h > 0) }, [props.boxes]) + const posePersons = useMemo( + () => visiblePosePersons(props.posePersons), + [props.posePersons] + ) type ImageContentRect = { left: number @@ -612,6 +761,92 @@ function ReadonlyTrainingFrameViewer(props: { })} ) : null} + + {loaded && posePersons.length > 0 && imageLayerStyle ? (() => { + const layerWidth = Number(imageLayerStyle.width) + const layerHeight = Number(imageLayerStyle.height) + + if ( + !Number.isFinite(layerWidth) || + !Number.isFinite(layerHeight) || + layerWidth <= 0 || + layerHeight <= 0 + ) { + return null + } + + return ( +
+ + {posePersons.map((person, personIndex) => { + const color = person.reliable === false + ? POSE_UNRELIABLE_COLOR + : POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length] + const keypointsByName = new Map( + person.keypoints.map((point) => [poseKeypointId(point.name), point]) + ) + + return ( + + {POSE_SKELETON_EDGES.map(([from, to]) => { + const fromPoint = keypointsByName.get(from) + const toPoint = keypointsByName.get(to) + + if ( + !isPoseKeypointVisible(fromPoint) || + !isPoseKeypointVisible(toPoint) + ) { + return null + } + + return ( + + ) + })} + + {person.keypoints.map((point, pointIndex) => { + if (!isPoseKeypointVisible(point)) return null + + return ( + + ) + })} + + ) + })} + +
+ ) + })() : null} ) : ( @@ -631,6 +866,7 @@ function FeedbackListItem(props: { }) { const effective = annotationEffectiveCorrection(props.item) const boxCount = effective.boxes?.length ?? 0 + const poseCount = visiblePosePersons(effective.posePersons).length const imageSrc = frameUrlForHistory(props.item) return ( @@ -700,6 +936,10 @@ function FeedbackListItem(props: { {boxCount} Boxen + + {poseCount} Posen + + {normalizeSexPositionValue(effective.sexPosition)} @@ -808,6 +1048,94 @@ function BoxDetailsList(props: { ) } +function PoseDetailsList(props: { + persons?: TrainingFeedbackPosePerson[] +}) { + const persons = visiblePosePersons(props.persons) + + return ( +
+
+
+
+ Pose +
+ +
+ Gespeicherte Skelette +
+
+ + + {persons.length} + +
+ +
+ {persons.length === 0 ? ( +
+ Keine Pose gespeichert. +
+ ) : ( + persons.map((person, index) => { + const color = person.reliable === false + ? POSE_UNRELIABLE_COLOR + : POSE_PERSON_COLORS[index % POSE_PERSON_COLORS.length] + const keypoints = (person.keypoints ?? []).filter(isPoseKeypointVisible) + + return ( +
+
+
+
+
+ +
+
+ Person #{index + 1} +
+ +
+ {keypoints.length} Punkte - Confidence {percent(person.score)} +
+
+
+
+ + {keypoints.length > 0 ? ( +
+ {keypoints.slice(0, 8).map((point) => ( + + {poseKeypointLabel(point.name)} + + ))} + + {keypoints.length > 8 ? ( + + +{keypoints.length - 8} + + ) : null} +
+ ) : null} +
+ ) + }) + )} +
+
+ ) +} + function fileNameFromPath(path?: string) { const clean = String(path || '').trim() if (!clean) return '' @@ -818,6 +1146,7 @@ function fileNameFromPath(path?: string) { function CompactSourceCard(props: { item: TrainingFeedbackAnnotation boxCount: number + poseCount: number }) { const size = fileSizeLabel(props.item.sourceSizeBytes) @@ -853,6 +1182,10 @@ function CompactSourceCard(props: { {props.boxCount} Boxen + + {size ? {size} : null} @@ -984,6 +1317,7 @@ export default function TrainingFeedbackHistoryModal(props: { const negativeCount = props.items.filter((item) => item.negative).length const shownCount = props.items.length const selectedBoxCount = effective?.boxes?.length ?? 0 + const selectedPoseCount = visiblePosePersons(effective?.posePersons).length const filteredItems = useMemo(() => { if (props.onSearchChange) { @@ -1015,6 +1349,9 @@ export default function TrainingFeedbackHistoryModal(props: { ...effectiveItem.objectsPresent, ...effectiveItem.clothingPresent, ...(effectiveItem.boxes ?? []).map((box) => box.label), + ...visiblePosePersons(effectiveItem.posePersons).flatMap((person) => + (person.keypoints ?? []).map((point) => poseKeypointLabel(point.name)) + ), ] .filter(Boolean) .join(' ') @@ -1319,6 +1656,7 @@ export default function TrainingFeedbackHistoryModal(props: { -