This commit is contained in:
Linrador 2026-06-29 22:44:55 +02:00
parent d073473d2f
commit 8b5e0b6101
4 changed files with 159 additions and 261 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\TrainingTab.tsx
'use client'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import Button from './Button'
import LoadingSpinner from './LoadingSpinner'
import { formatDuration } from './formatters'
@ -637,11 +637,15 @@ function poseKeypointLabel(name?: string | null) {
return POSE_KEYPOINT_LABELS[key] || key.replace(/_/g, ' ') || 'Punkt'
}
function poseKeypointMarkerClassName(present: boolean, pending: boolean) {
function poseKeypointMarkerClassName(present: boolean, pending: boolean, highlighted = false) {
if (pending) {
return 'fill-amber-500 stroke-white/90 dark:stroke-gray-950/90'
}
if (highlighted) {
return 'fill-cyan-500 stroke-white/95 dark:stroke-gray-950/90'
}
if (present) {
return 'fill-blue-500 stroke-white/90 dark:stroke-gray-950/90'
}
@ -913,20 +917,6 @@ function poseCoordPx(value: number, size: number) {
return clamp01(Number(value)) * Math.max(0, Number(size) || 0)
}
function poseBoxPixelStyle(box: TrainingBox, layerWidth: number, layerHeight: number): CSSProperties {
const x = clamp01(Number(box.x))
const y = clamp01(Number(box.y))
const w = Math.max(0, Math.min(1 - x, clamp01(Number(box.w))))
const h = Math.max(0, Math.min(1 - y, clamp01(Number(box.h))))
return {
left: poseCoordPx(x, layerWidth),
top: poseCoordPx(y, layerHeight),
width: w * Math.max(0, Number(layerWidth) || 0),
height: h * Math.max(0, Number(layerHeight) || 0),
}
}
function backendText(data: any, fallback: string) {
return String(
data?.message ||
@ -4250,6 +4240,7 @@ export default function TrainingTab(props: {
setShowPoseSkeleton(false)
setActivePosePersonIndex(null)
setPendingPoseKeypoint(null)
setHoveredPoseKeypoint(null)
if (poseKeypointMapAnimationFrameRef.current !== null) {
cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current)
poseKeypointMapAnimationFrameRef.current = null
@ -4409,6 +4400,7 @@ export default function TrainingTab(props: {
const [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null)
const [poseInteraction, setPoseInteraction] = useState<PoseInteraction | null>(null)
const [pendingPoseKeypoint, setPendingPoseKeypoint] = useState<PoseKeypointPlacement | null>(null)
const [hoveredPoseKeypoint, setHoveredPoseKeypoint] = useState<PoseKeypointPlacement | null>(null)
const [poseKeypointMapView, setPoseKeypointMapView] = useState<PoseKeypointMapView>('body')
const [poseKeypointMapViewBoxValue, setPoseKeypointMapViewBoxValue] =
useState(POSE_BODY_MAP_VIEW_BOX)
@ -4567,6 +4559,7 @@ export default function TrainingTab(props: {
setShowPoseSkeleton(false)
setActivePosePersonIndex(null)
setPendingPoseKeypoint(null)
setHoveredPoseKeypoint(null)
poseFaceImagePanGestureRef.current = null
poseFaceImagePanCenterRef.current = null
@ -9782,7 +9775,39 @@ export default function TrainingTab(props: {
return (
<div
key={`pose-list-point-${personIndex}-${poseKeypointId(point.name)}-${pointIndex}`}
className="flex items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-[11px] shadow-sm dark:border-white/10 dark:bg-gray-900/70"
className={[
'flex items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-[11px] shadow-sm transition',
uiLocked
? 'cursor-not-allowed opacity-70'
: 'cursor-pointer hover:border-cyan-200 hover:bg-cyan-50/50 dark:hover:border-cyan-300/30 dark:hover:bg-cyan-500/10',
'dark:border-white/10 dark:bg-gray-900/70',
].join(' ')}
onPointerEnter={() => {
if (uiLocked) return
setHoveredPoseKeypoint({
personIndex,
keypointName: poseKeypointId(point.name),
})
}}
onPointerLeave={() => {
const keypointName = poseKeypointId(point.name)
setHoveredPoseKeypoint((current) =>
current?.personIndex === personIndex &&
current.keypointName === keypointName
? null
: current
)
}}
onClick={(event) => {
event.stopPropagation()
if (uiLocked) return
setShowPoseSkeleton(true)
setActivePosePersonIndex(personIndex)
setPendingPoseKeypoint({
personIndex,
keypointName: poseKeypointId(point.name),
})
}}
>
<div className="min-w-0">
<div className="truncate font-bold text-gray-800 dark:text-gray-100">
@ -10244,6 +10269,9 @@ export default function TrainingTab(props: {
const pending =
pendingPoseKeypoint?.personIndex === targetIndex &&
pendingPoseKeypoint.keypointName === shape.keypointName
const highlighted =
hoveredPoseKeypoint?.personIndex === targetIndex &&
hoveredPoseKeypoint.keypointName === shape.keypointName
return (
<circle
@ -10251,7 +10279,7 @@ export default function TrainingTab(props: {
cx={shape.center.x}
cy={shape.center.y}
r={mapMarkerRadius}
className={poseKeypointMarkerClassName(present, pending)}
className={poseKeypointMarkerClassName(present, pending, highlighted)}
strokeWidth={2}
pointerEvents="none"
vectorEffect="non-scaling-stroke"
@ -10266,6 +10294,9 @@ export default function TrainingTab(props: {
const pending =
pendingPoseKeypoint?.personIndex === targetIndex &&
pendingPoseKeypoint.keypointName === keypointName
const highlighted =
hoveredPoseKeypoint?.personIndex === targetIndex &&
hoveredPoseKeypoint.keypointName === keypointName
const active = present || pending
return (
@ -10282,6 +10313,8 @@ export default function TrainingTab(props: {
'fill-transparent stroke-transparent',
pending
? 'hover:stroke-amber-700 focus:stroke-amber-700 dark:hover:stroke-amber-200 dark:focus:stroke-amber-200'
: highlighted
? 'stroke-cyan-600 dark:stroke-cyan-200'
: present
? 'hover:stroke-indigo-800 focus:stroke-indigo-800 dark:hover:stroke-indigo-100 dark:focus:stroke-indigo-100'
: 'hover:stroke-indigo-600 focus:stroke-indigo-600 dark:hover:stroke-indigo-200 dark:focus:stroke-indigo-200',
@ -10290,6 +10323,36 @@ export default function TrainingTab(props: {
strokeWidth={pending || present ? 2.5 : 2}
pointerEvents="all"
vectorEffect="non-scaling-stroke"
onPointerEnter={() => {
if (disabled || targetIndex === null) return
setHoveredPoseKeypoint({
personIndex: targetIndex,
keypointName,
})
}}
onPointerLeave={() => {
setHoveredPoseKeypoint((current) =>
current?.personIndex === targetIndex &&
current.keypointName === keypointName
? null
: current
)
}}
onFocus={() => {
if (disabled || targetIndex === null) return
setHoveredPoseKeypoint({
personIndex: targetIndex,
keypointName,
})
}}
onBlur={() => {
setHoveredPoseKeypoint((current) =>
current?.personIndex === targetIndex &&
current.keypointName === keypointName
? null
: current
)
}}
onClick={() => selectPoseKeypoint(keypointName)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
@ -10725,118 +10788,6 @@ export default function TrainingTab(props: {
className="pointer-events-none absolute z-[305] overflow-visible"
style={imageLayerStyle}
>
{posePersons.map((person, personIndex) => {
if (!shouldRenderPoseOverlayPerson(personIndex)) {
return null
}
if (poseFaceImageZoomActive) {
return null
}
if (!hasVisiblePoseBox(person) && !person.keypoints?.some(isPoseKeypointVisible)) {
return null
}
const reliable = isPosePersonReliable(person)
const activePose = activePosePersonIndex === personIndex
const color = reliable
? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
: POSE_UNRELIABLE_COLOR
const score = Math.round(clamp01(Number(person.score)) * 100)
const quality = Math.round(posePersonQuality(person) * 100)
const visibleKeypoints = posePersonVisibleKeypoints(person)
return (
<div
key={`pose-box-${personIndex}`}
className={[
pendingPoseKeypoint ? 'pointer-events-none' : 'pointer-events-auto',
'absolute rounded border border-dashed',
activePose ? 'bg-blue-500/10' : reliable ? '' : 'bg-slate-500/5',
].join(' ')}
style={{
...poseBoxPixelStyle(person.box, layerWidth, layerHeight),
borderColor: color,
boxShadow: activePose
? `0 0 0 2px ${color}aa`
: reliable ? `0 0 0 1px ${color}55` : `0 0 0 1px ${color}33`,
opacity: reliable || activePose ? 1 : 0.78,
}}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
setActivePosePersonIndex(personIndex)
}}
>
<div
data-pose-control="true"
className={[
pendingPoseKeypoint ? 'pointer-events-none' : 'pointer-events-auto',
'absolute left-0 top-0 z-[360] flex -translate-y-[calc(100%+4px)] touch-none select-none items-center',
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
].join(' ')}
title={`Score ${score}% | Qualitaet ${quality}% | Keypoints ${visibleKeypoints}`}
>
<div
className={[
'group/label flex h-5 max-w-full min-w-0 touch-none select-none items-center overflow-hidden rounded-full text-left',
'text-[10px] font-bold leading-none shadow-md ring-1 transition',
'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50',
'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50',
].join(' ')}
>
<button
type="button"
className={[
'flex h-5 min-w-0 touch-none items-center gap-1 pl-1.5 pr-1',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80',
uiLocked ? 'cursor-default' : 'cursor-pointer',
].join(' ')}
disabled={uiLocked}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setActivePosePersonIndex(personIndex)
}}
>
<span
className="shrink-0 rounded-full px-1 py-0.5 text-[9px] font-black leading-none text-white"
style={{ backgroundColor: color }}
>
{visiblePosePersonCount > 1 ? `#${personIndex + 1}` : 'Pose'}
</span>
<UserGroupIcon className="h-3 w-3 shrink-0 text-current" aria-hidden="true" />
<span className="min-w-0 max-w-[104px] truncate text-current sm:max-w-[132px]">
{score}%
</span>
</button>
<button
type="button"
className={[
'flex h-5 w-5 shrink-0 cursor-pointer touch-none items-center justify-center',
'!bg-red-600 !text-white ring-1 ring-red-800/40 transition',
'hover:!bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/60',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:!bg-red-500 dark:!text-white dark:ring-white/20 dark:hover:!bg-red-600',
].join(' ')}
disabled={uiLocked}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
deletePosePerson(personIndex)
}}
title="Diese Pose aus dem Training entfernen"
aria-label="Pose entfernen"
>
<TrashIcon className="h-3 w-3 shrink-0 !text-white" aria-hidden="true" />
</button>
</div>
</div>
</div>
)
})}
<svg
className="absolute inset-0 h-full w-full overflow-visible"
viewBox={`0 0 ${layerWidth} ${layerHeight}`}
@ -10921,140 +10872,94 @@ export default function TrainingTab(props: {
const left = poseCoordPx(point.x, layerWidth)
const top = poseCoordPx(point.y, layerHeight)
const label = poseKeypointLabel(point.name)
const confidence = Math.round(clamp01(Number(point.conf)) * 100)
const selected =
pendingPoseKeypoint?.personIndex === personIndex &&
pendingPoseKeypoint.keypointName === keypointId
const hovered =
hoveredPoseKeypoint?.personIndex === personIndex &&
hoveredPoseKeypoint.keypointName === keypointId
const dragging =
poseInteraction?.personIndex === personIndex &&
poseInteraction.keypointIndex === pointIndex
const highlighted = selected || hovered || dragging
const showLabel = selected || dragging
const facePlacement = poseFaceImageZoomActive
? poseFaceKeypointLabelPlacement(keypointId)
: null
const labelToLeft = facePlacement?.labelToLeft ?? left > layerWidth * 0.72
const label = poseKeypointLabel(point.name)
const confidence = Math.round(clamp01(Number(point.conf)) * 100)
const dot = (
<span
className="h-2 w-2 shrink-0 rounded-full border border-white shadow-[0_0_0_1px_rgba(0,0,0,0.45)]"
style={{ backgroundColor: color }}
/>
)
const labelToneClass = reliable
? 'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50 dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50'
: 'bg-white/90 text-slate-700 ring-slate-400/30 hover:bg-gray-50 dark:bg-white/90 dark:text-slate-700 dark:ring-black/10'
const text = (
<span className="min-w-0 max-w-[92px] truncate px-1 text-[10px] font-bold leading-none text-current sm:max-w-[120px]">
{label}
</span>
)
return (
<div
data-pose-control="true"
<Fragment
key={`pose-point-${personIndex}-${poseKeypointId(point.name)}-${pointIndex}`}
>
<button
data-pose-control="true"
type="button"
disabled={uiLocked}
className={[
pendingPoseKeypoint ? 'pointer-events-none' : 'pointer-events-auto',
'absolute top-0 flex touch-none items-center whitespace-nowrap rounded-sm',
'absolute z-[350] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 touch-none items-center justify-center rounded-full',
'focus:outline-none focus:ring-2 focus:ring-white/80',
uiLocked ? 'cursor-default opacity-60' : 'cursor-grab active:cursor-grabbing',
].join(' ')}
style={{
left,
top,
opacity: reliable ? 1 : 0.72,
}}
title={`${label} | ${confidence}%`}
aria-label={`${label} verschieben`}
onPointerEnter={() => {
if (uiLocked) return
setHoveredPoseKeypoint({
personIndex,
keypointName: keypointId,
})
}}
onPointerLeave={() => {
setHoveredPoseKeypoint((current) =>
current?.personIndex === personIndex &&
current.keypointName === keypointId
? null
: current
)
}}
onPointerDown={(e) => startPoseKeypointDrag(e, personIndex, pointIndex)}
>
<span
className={[
'block rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.55),0_0_10px_rgba(0,0,0,0.35)] transition-all duration-150',
highlighted ? 'h-4 w-4 ring-4 ring-cyan-300/45' : 'h-2.5 w-2.5 ring-2 ring-black/25',
].join(' ')}
style={{ backgroundColor: highlighted ? '#22d3ee' : color }}
/>
</button>
{showLabel ? (
<div
className="pointer-events-none absolute z-[370] flex max-w-[9rem] items-center gap-1 rounded-full bg-white/95 px-2 py-1 text-[10px] font-black leading-none text-gray-950 shadow-lg ring-1 ring-black/10 dark:bg-white/95 dark:text-gray-950"
style={{
left,
top,
transform: facePlacement?.transform ?? (
labelToLeft
? 'translate(calc(-100% + 4px), -50%)'
: 'translate(-4px, -50%)'
? 'translate(calc(-100% - 12px), -50%)'
: 'translate(12px, -50%)'
),
opacity: reliable ? 1 : 0.72,
opacity: reliable ? 1 : 0.82,
zIndex: facePlacement?.zIndex,
}}
title={`${label} | ${confidence}%`}
>
<div
className={[
'flex h-5 touch-none select-none items-center overflow-hidden rounded-full shadow-md ring-1 transition',
labelToneClass,
].join(' ')}
>
{labelToLeft ? (
<>
<button
type="button"
disabled={uiLocked}
className={[
'flex h-5 w-5 shrink-0 cursor-pointer touch-none items-center justify-center',
'!bg-red-600 !text-white ring-1 ring-red-800/40 transition',
'hover:!bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/60',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:!bg-red-500 dark:!text-white dark:ring-white/20 dark:hover:!bg-red-600',
].join(' ')}
title={`${label} ausblenden`}
aria-label={`${label} ausblenden`}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
removePoseKeypoint(personIndex, pointIndex)
}}
>
<TrashIcon className="h-3 w-3 shrink-0 !text-white" aria-hidden="true" />
</button>
<button
type="button"
disabled={uiLocked}
className={[
'flex h-5 min-w-0 touch-none items-center gap-1 pl-1 pr-1',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80',
uiLocked ? 'cursor-default' : 'cursor-grab active:cursor-grabbing',
].join(' ')}
onPointerDown={(e) => startPoseKeypointDrag(e, personIndex, pointIndex)}
aria-label={`${label} verschieben`}
>
{text}
{dot}
</button>
</>
) : (
<>
<button
type="button"
disabled={uiLocked}
className={[
'flex h-5 min-w-0 touch-none items-center gap-1 pl-1 pr-1',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80',
uiLocked ? 'cursor-default' : 'cursor-grab active:cursor-grabbing',
].join(' ')}
onPointerDown={(e) => startPoseKeypointDrag(e, personIndex, pointIndex)}
aria-label={`${label} verschieben`}
>
{dot}
{text}
</button>
<button
type="button"
disabled={uiLocked}
className={[
'flex h-5 w-5 shrink-0 cursor-pointer touch-none items-center justify-center',
'!bg-red-600 !text-white ring-1 ring-red-800/40 transition',
'hover:!bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/60',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:!bg-red-500 dark:!text-white dark:ring-white/20 dark:hover:!bg-red-600',
].join(' ')}
title={`${label} ausblenden`}
aria-label={`${label} ausblenden`}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
removePoseKeypoint(personIndex, pointIndex)
}}
>
<TrashIcon className="h-3 w-3 shrink-0 !text-white" aria-hidden="true" />
</button>
</>
)}
</div>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="min-w-0 truncate">{label}</span>
</div>
) : null}
</Fragment>
)
})
})}
@ -11683,13 +11588,6 @@ export default function TrainingTab(props: {
})() : null}
</div>
{poseFaceImageZoomApplied ? (
<div
className="pointer-events-none absolute inset-0 z-[420] rounded-lg border-[8px] border-black shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] sm:border-[12px]"
aria-hidden="true"
/>
) : null}
{poseFaceImageZoomActive && poseCorrectionPersonIndex !== null ? (
<div
className="pointer-events-auto absolute left-3 top-3 z-[430] flex max-w-[calc(100%-1.5rem)] flex-wrap items-center gap-1.5 rounded-full bg-black/65 p-1 text-white shadow-lg ring-1 ring-white/20 backdrop-blur"