diff --git a/backend/data/pending-autostart/admin.json b/backend/data/pending-autostart/admin.json deleted file mode 100644 index fc69ce2..0000000 --- a/backend/data/pending-autostart/admin.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "items": [] -} \ No newline at end of file diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index 369768c..c57f5d5 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 d912a11..7ead139 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 84cd729..1a48d58 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index d9d2673..d27fb2b 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -427,6 +427,13 @@ const POSE_KEYPOINT_ORDER = [ ] as const const POSE_KEYPOINT_OPTIONS = POSE_KEYPOINT_ORDER.map((key) => [key, POSE_KEYPOINT_LABELS[key]] as const) +const POSE_FACE_KEYPOINT_NAME_SET = new Set([ + 'nose', + 'left_eye', + 'right_eye', + 'left_ear', + 'right_ear', +]) type PoseKeypointShape = { keypointName: keyof typeof POSE_KEYPOINT_LABELS @@ -443,6 +450,7 @@ const POSE_BODY_MAP_ASPECT_RATIO_VALUE = 537 / 1386 const POSE_HEAD_MAP_VIEW_BOX = '172.5 0 185 477' const POSE_HEAD_MAP_ASPECT_RATIO = POSE_BODY_MAP_ASPECT_RATIO const POSE_KEYPOINT_MAP_ZOOM_DURATION_MS = 400 +const POSE_FACE_IMAGE_ZOOM_DURATION_MS = 650 function poseKeypointMapViewBox(view: PoseKeypointMapView) { return view === 'head' ? POSE_HEAD_MAP_VIEW_BOX : POSE_BODY_MAP_VIEW_BOX @@ -618,6 +626,176 @@ function isPoseKeypointVisible(point?: TrainingKeypoint | null): point is Traini ) } +type PoseFaceZoomRegion = { + centerX: number + centerY: number + width: number + height: number +} + +type NormalizedImageRect = { + x: number + y: number + w: number + h: number +} + +type PoseFaceImageCamera = { + scale: number + translateX: number + translateY: number + centerX: number + centerY: number + viewportWidth: number + viewportHeight: number + visibleRegion: NormalizedImageRect +} + +type PoseFaceImagePanGesture = { + pointerId: number + source: 'image' | 'preview' + startClientX: number + startClientY: number + startCenterX: number + startCenterY: number + contentWidth: number + contentHeight: number + scale: number + moved: boolean +} + +function poseFaceImageFullCamera(): PoseFaceImageCamera { + return { + scale: 1, + translateX: 0, + translateY: 0, + centerX: 0.5, + centerY: 0.5, + viewportWidth: 1, + viewportHeight: 1, + visibleRegion: { + x: 0, + y: 0, + w: 1, + h: 1, + }, + } +} + +function interpolatePoseFaceImageCamera( + from: PoseFaceImageCamera, + to: PoseFaceImageCamera, + progress: number +): PoseFaceImageCamera { + const eased = easePoseMapZoom(Math.max(0, Math.min(1, progress))) + const lerp = (a: number, b: number) => a + (b - a) * eased + const scale = lerp(from.scale, to.scale) + const translateX = lerp(from.translateX, to.translateX) + const translateY = lerp(from.translateY, to.translateY) + const centerX = lerp(from.centerX, to.centerX) + const centerY = lerp(from.centerY, to.centerY) + const viewportWidth = Math.min(1, Math.max(0.015, lerp(from.viewportWidth, to.viewportWidth))) + const viewportHeight = Math.min(1, Math.max(0.015, lerp(from.viewportHeight, to.viewportHeight))) + + return { + scale, + translateX, + translateY, + centerX, + centerY, + viewportWidth, + viewportHeight, + visibleRegion: { + x: Math.max(0, Math.min(1 - viewportWidth, centerX - viewportWidth / 2)), + y: Math.max(0, Math.min(1 - viewportHeight, centerY - viewportHeight / 2)), + w: viewportWidth, + h: viewportHeight, + }, + } +} + +function normalizedZoomRegion( + centerX: number, + centerY: number, + width: number, + height: number +): PoseFaceZoomRegion { + const w = Math.max(0.08, Math.min(1, Number(width) || 0)) + const h = Math.max(0.08, Math.min(1, Number(height) || 0)) + const cx = clamp01(centerX) + const cy = clamp01(centerY) + const left = Math.max(0, Math.min(1 - w, cx - w / 2)) + const top = Math.max(0, Math.min(1 - h, cy - h / 2)) + + return { + centerX: left + w / 2, + centerY: top + h / 2, + width: w, + height: h, + } +} + +function clampPoseFaceCameraCenter( + centerX: number, + centerY: number, + viewportWidth: number, + viewportHeight: number +) { + const w = Math.min(1, Math.max(0, Number(viewportWidth) || 0)) + const h = Math.min(1, Math.max(0, Number(viewportHeight) || 0)) + const minX = w >= 1 ? 0.5 : w / 2 + const maxX = w >= 1 ? 0.5 : 1 - w / 2 + const minY = h >= 1 ? 0.5 : h / 2 + const maxY = h >= 1 ? 0.5 : 1 - h / 2 + + return { + x: Math.max(minX, Math.min(maxX, clamp01(centerX))), + y: Math.max(minY, Math.min(maxY, clamp01(centerY))), + } +} + +function posePersonFaceZoomRegion(person?: TrainingPosePerson | null): PoseFaceZoomRegion | null { + if (!person) return null + + const facePoints = (person.keypoints ?? []).filter( + (point) => + POSE_FACE_KEYPOINT_NAME_SET.has(poseKeypointId(point.name)) && + isPoseKeypointVisible(point) + ) + + if (facePoints.length > 0) { + const xs = facePoints.map((point) => clamp01(Number(point.x))) + const ys = facePoints.map((point) => clamp01(Number(point.y))) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minY = Math.min(...ys) + const maxY = Math.max(...ys) + const rawW = Math.max(0.035, maxX - minX) + const rawH = Math.max(0.035, maxY - minY) + const pad = Math.max(0.045, Math.max(rawW, rawH) * 1.35) + + return normalizedZoomRegion( + (minX + maxX) / 2, + (minY + maxY) / 2, + Math.max(0.14, rawW + pad * 2), + Math.max(0.16, rawH + pad * 2) + ) + } + + if (hasVisiblePoseBox(person)) { + const box = normalizeBox(person.box) + + return normalizedZoomRegion( + box.x + box.w / 2, + box.y + box.h * 0.18, + Math.max(0.16, box.w * 0.62), + Math.max(0.18, box.h * 0.28) + ) + } + + return null +} + function isPoseReliableKeypoint(point?: TrainingKeypoint | null): point is TrainingKeypoint { if (!point) return false @@ -3862,6 +4040,7 @@ export default function TrainingTab(props: { const frameImageRef = useRef(null) const [imageLayerStyle, setImageLayerStyle] = useState(null) + const [imageBoxSize, setImageBoxSize] = useState({ width: 0, height: 0 }) type ImageContentRect = { left: number @@ -4074,22 +4253,52 @@ export default function TrainingTab(props: { const updateImageLayerStyle = useCallback(() => { const boxEl = imageBoxRef.current - const contentRect = getImageContentRect() + const imgEl = frameImageRef.current - if (!boxEl || !contentRect) { + if (!boxEl || !imgEl) { setImageLayerStyle(null) return } - const boxRect = boxEl.getBoundingClientRect() + const naturalW = imgEl.naturalWidth + const naturalH = imgEl.naturalHeight + const imageLayoutW = imgEl.offsetWidth || imgEl.clientWidth + const imageLayoutH = imgEl.offsetHeight || imgEl.clientHeight + + if ( + naturalW <= 0 || + naturalH <= 0 || + imageLayoutW <= 0 || + imageLayoutH <= 0 + ) { + setImageLayerStyle(null) + return + } + + // Untransformierte Layout-Werte verwenden. getBoundingClientRect() wuerde + // beim Face-Zoom die bereits transformierte Groesse messen und die Kamera + // dadurch schrittweise verschieben. + const scale = Math.min(imageLayoutW / naturalW, imageLayoutH / naturalH) + const contentW = naturalW * scale + const contentH = naturalH * scale + const nextBoxSize = { + width: boxEl.clientWidth || boxEl.offsetWidth, + height: boxEl.clientHeight || boxEl.offsetHeight, + } const nextStyle: CSSProperties = { - left: contentRect.left - boxRect.left, - top: contentRect.top - boxRect.top, - width: contentRect.width, - height: contentRect.height, + left: imgEl.offsetLeft + (imageLayoutW - contentW) / 2, + top: imgEl.offsetTop + (imageLayoutH - contentH) / 2, + width: contentW, + height: contentH, } + setImageBoxSize((prev) => + prev.width === nextBoxSize.width && prev.height === nextBoxSize.height + ? prev + : nextBoxSize + ) + setImageLayerStyle((prev) => { if ( prev && @@ -4103,7 +4312,7 @@ export default function TrainingTab(props: { return nextStyle }) - }, [getImageContentRect]) + }, []) const detectorBoxesScrollRef = useRef(null) const detectorBoxItemRefs = useRef>([]) @@ -4150,6 +4359,15 @@ export default function TrainingTab(props: { useState(POSE_BODY_MAP_VIEW_BOX) const [poseKeypointMapAnimating, setPoseKeypointMapAnimating] = useState(false) const [poseMapSlotHeight, setPoseMapSlotHeight] = useState(0) + const [poseFaceImagePanCenter, setPoseFaceImagePanCenter] = useState<{ + x: number + y: number + } | null>(null) + const [poseFaceImageDisplayCamera, setPoseFaceImageDisplayCamera] = + useState(null) + const [poseFaceImageCameraAnimating, setPoseFaceImageCameraAnimating] = useState(false) + const [poseFaceImageZoomOutActive, setPoseFaceImageZoomOutActive] = useState(false) + const [poseFaceImagePanning, setPoseFaceImagePanning] = useState(false) const [touchMagnifier, setTouchMagnifier] = useState(null) const [boxLabel, setBoxLabel] = useState('') const [activeBoxIndex, setActiveBoxIndex] = useState(null) @@ -4192,6 +4410,11 @@ export default function TrainingTab(props: { const poseKeypointMapViewBoxValueRef = useRef(POSE_BODY_MAP_VIEW_BOX) const poseKeypointMapAnimationFrameRef = useRef(null) const poseMapSlotRef = useRef(null) + const poseFaceImagePanCenterRef = useRef<{ x: number; y: number } | null>(null) + const poseFaceImagePanGestureRef = useRef(null) + const poseFaceImageDisplayCameraRef = useRef(null) + const poseFaceImageCameraAnimationFrameRef = useRef(null) + const poseFaceImageZoomOutActiveRef = useRef(false) const correctionRef = useRef(correction) const hasManualCorrectionRef = useRef(hasManualCorrection) const latestGestureBoxRef = useRef(null) @@ -4331,12 +4554,31 @@ export default function TrainingTab(props: { poseKeypointMapViewRef.current = poseKeypointMapView }, [poseKeypointMapView]) + useEffect(() => { + poseFaceImagePanCenterRef.current = poseFaceImagePanCenter + }, [poseFaceImagePanCenter]) + + useEffect(() => { + poseFaceImageDisplayCameraRef.current = poseFaceImageDisplayCamera + }, [poseFaceImageDisplayCamera]) + + useEffect(() => { + poseFaceImageZoomOutActiveRef.current = poseFaceImageZoomOutActive + }, [poseFaceImageZoomOutActive]) + useEffect(() => { return () => { if (poseKeypointMapAnimationFrameRef.current !== null) { cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) poseKeypointMapAnimationFrameRef.current = null } + + if (poseFaceImageCameraAnimationFrameRef.current !== null) { + cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current) + poseFaceImageCameraAnimationFrameRef.current = null + } + + poseFaceImagePanGestureRef.current = null } }, []) @@ -4385,6 +4627,17 @@ export default function TrainingTab(props: { const visiblePosePersonCount = posePersons.filter((person) => hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) ).length + const visiblePosePersonIndexes = useMemo( + () => + posePersons + .map((person, index) => ({ person, index })) + .filter( + ({ person }) => + hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) + ) + .map(({ index }) => index), + [posePersons] + ) const originalPosePersonCount = sample?.prediction.persons?.length ?? 0 const firstVisiblePosePersonIndex = posePersons.findIndex((person) => hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) @@ -4402,6 +4655,12 @@ export default function TrainingTab(props: { : visiblePosePersonCount === 1 ? firstVisiblePosePersonIndex : null + const poseCorrectionPerson = + poseCorrectionPersonIndex !== null ? posePersons[poseCorrectionPersonIndex] : undefined + const poseCorrectionVisibleIndex = + poseCorrectionPersonIndex !== null + ? visiblePosePersonIndexes.indexOf(poseCorrectionPersonIndex) + : -1 useEffect(() => { setActivePosePersonIndex((current) => @@ -4482,6 +4741,28 @@ export default function TrainingTab(props: { return `${sample.frameUrl}${sep}t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}` }, [sample?.frameUrl, sample?.sampleId, imageReloadKey]) + useEffect(() => { + poseFaceImagePanGestureRef.current = null + poseFaceImagePanCenterRef.current = null + setPoseFaceImagePanCenter(null) + setPoseFaceImagePanning(false) + }, [imageSrc, poseCorrectionPersonIndex, poseKeypointMapView]) + + useEffect(() => { + if (poseFaceImageZoomOutActiveRef.current) { + return + } + + if (poseFaceImageCameraAnimationFrameRef.current !== null) { + cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current) + poseFaceImageCameraAnimationFrameRef.current = null + } + + poseFaceImageDisplayCameraRef.current = null + setPoseFaceImageDisplayCamera(null) + setPoseFaceImageCameraAnimating(false) + }, [imageSrc, poseKeypointMapView]) + const loadingPreviewRawUrlRef = useRef('') const setLoadingPreviewCandidate = useCallback((url: string) => { @@ -7683,6 +7964,58 @@ export default function TrainingTab(props: { }) }, [markManualCorrection]) + const removePoseFaceKeypoints = useCallback((personIndex: number) => { + markManualCorrection() + + setCorrection((prev) => { + const persons = clonePosePersons(prev.posePersons) + const person = persons[personIndex] + + if (!person) return prev + + const keypoints = person.keypoints.map((point) => + POSE_FACE_KEYPOINT_NAME_SET.has(poseKeypointId(point.name)) + ? { + ...point, + x: 0, + y: 0, + conf: 0, + } + : point + ) + + const nextPerson = updatePosePersonQuality({ + ...person, + keypoints, + box: poseBoxFromKeypoints({ + ...person, + keypoints, + }), + }) + + persons[personIndex] = nextPerson + + const next: CorrectionState = { + ...prev, + posePersons: persons, + } + + correctionRef.current = next + return next + }) + + setPendingPoseKeypoint((current) => { + if ( + current?.personIndex === personIndex && + POSE_FACE_KEYPOINT_NAME_SET.has(poseKeypointId(current.keypointName)) + ) { + return null + } + + return current + }) + }, [markManualCorrection]) + const resetPosePersons = useCallback(() => { markManualCorrection() const persons = clonePosePersons(sampleRef.current?.prediction.persons) @@ -7710,6 +8043,26 @@ export default function TrainingTab(props: { setPoseKeypointMapAnimating(false) }, [markManualCorrection]) + const selectAdjacentPoseFace = useCallback((direction: -1 | 1) => { + if (visiblePosePersonIndexes.length === 0) return + + const currentPosition = + poseCorrectionPersonIndex !== null + ? visiblePosePersonIndexes.indexOf(poseCorrectionPersonIndex) + : -1 + const fallbackPosition = direction > 0 ? -1 : 0 + const nextPosition = + (currentPosition >= 0 ? currentPosition : fallbackPosition) + direction + const wrappedPosition = + (nextPosition + visiblePosePersonIndexes.length) % visiblePosePersonIndexes.length + const nextPersonIndex = visiblePosePersonIndexes[wrappedPosition] + + if (nextPersonIndex === undefined) return + + setActivePosePersonIndex(nextPersonIndex) + setPendingPoseKeypoint(null) + }, [poseCorrectionPersonIndex, visiblePosePersonIndexes]) + const startPoseKeypointDrag = useCallback(( e: React.PointerEvent, personIndex: number, @@ -7816,6 +8169,393 @@ export default function TrainingTab(props: { const poseSkeletonVisible = poseModeActive && hasPosePersons && !annotationLayerHidden const showImageBoxes = !annotationLayerHidden && !poseModeActive const showAnnotationMagnifier = showImageBoxes || poseSkeletonVisible + const poseFaceImageZoomActive = + poseModeActive && + poseKeypointMapView === 'head' && + Boolean(poseCorrectionPerson) + const poseFaceImageZoomRegion = useMemo( + () => + poseFaceImageZoomActive + ? posePersonFaceZoomRegion(poseCorrectionPerson) + : null, + [poseCorrectionPerson, poseFaceImageZoomActive] + ) + const poseFaceImageEffectiveZoomRegion = useMemo(() => { + if (!poseFaceImageZoomRegion) return null + + if (!poseFaceImagePanCenter) { + return poseFaceImageZoomRegion + } + + return normalizedZoomRegion( + poseFaceImagePanCenter.x, + poseFaceImagePanCenter.y, + poseFaceImageZoomRegion.width, + poseFaceImageZoomRegion.height + ) + }, [poseFaceImagePanCenter, poseFaceImageZoomRegion]) + const poseFaceImageCamera = useMemo((): PoseFaceImageCamera | null => { + if (!poseFaceImageZoomActive || !poseFaceImageEffectiveZoomRegion || !imageLayerStyle) { + return null + } + + const boxWidth = Number(imageBoxSize.width) + const boxHeight = Number(imageBoxSize.height) + const contentLeft = Number(imageLayerStyle.left) + const contentTop = Number(imageLayerStyle.top) + const contentWidth = Number(imageLayerStyle.width) + const contentHeight = Number(imageLayerStyle.height) + + if ( + !Number.isFinite(boxWidth) || + !Number.isFinite(boxHeight) || + !Number.isFinite(contentLeft) || + !Number.isFinite(contentTop) || + !Number.isFinite(contentWidth) || + !Number.isFinite(contentHeight) || + boxWidth <= 0 || + boxHeight <= 0 || + contentWidth <= 0 || + contentHeight <= 0 + ) { + return null + } + + const targetWidth = Math.max(1, poseFaceImageEffectiveZoomRegion.width * contentWidth) + const targetHeight = Math.max(1, poseFaceImageEffectiveZoomRegion.height * contentHeight) + const fitScale = Math.min(boxWidth / targetWidth, boxHeight / targetHeight) + const scale = Math.min(4.25, Math.max(1.45, fitScale * 0.72)) + const viewportWidth = Math.min(1, Math.max(0.015, boxWidth / (scale * contentWidth))) + const viewportHeight = Math.min(1, Math.max(0.015, boxHeight / (scale * contentHeight))) + const cameraCenter = clampPoseFaceCameraCenter( + poseFaceImageEffectiveZoomRegion.centerX, + poseFaceImageEffectiveZoomRegion.centerY, + viewportWidth, + viewportHeight + ) + const centerX = cameraCenter.x + const centerY = cameraCenter.y + const focusX = contentLeft + centerX * contentWidth + const focusY = contentTop + centerY * contentHeight + const translateX = boxWidth / 2 - focusX * scale + const translateY = boxHeight / 2 - focusY * scale + + return { + scale, + translateX, + translateY, + centerX, + centerY, + viewportWidth, + viewportHeight, + visibleRegion: { + x: Math.max(0, Math.min(1 - viewportWidth, centerX - viewportWidth / 2)), + y: Math.max(0, Math.min(1 - viewportHeight, centerY - viewportHeight / 2)), + w: viewportWidth, + h: viewportHeight, + }, + } + }, [imageBoxSize.height, imageBoxSize.width, imageLayerStyle, poseFaceImageEffectiveZoomRegion, poseFaceImageZoomActive]) + useEffect(() => { + if (poseFaceImageZoomOutActiveRef.current) { + return + } + + if (poseFaceImageCameraAnimationFrameRef.current !== null) { + cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current) + poseFaceImageCameraAnimationFrameRef.current = null + } + + if (!poseFaceImageCamera) { + poseFaceImageDisplayCameraRef.current = null + setPoseFaceImageDisplayCamera(null) + setPoseFaceImageCameraAnimating(false) + return + } + + if (poseFaceImagePanning) { + poseFaceImageDisplayCameraRef.current = poseFaceImageCamera + setPoseFaceImageDisplayCamera(poseFaceImageCamera) + setPoseFaceImageCameraAnimating(false) + return + } + + const fromCamera = poseFaceImageDisplayCameraRef.current ?? poseFaceImageFullCamera() + const toCamera = poseFaceImageCamera + const startedAt = performance.now() + const almostSame = + Math.abs(fromCamera.scale - toCamera.scale) <= 0.001 && + Math.abs(fromCamera.translateX - toCamera.translateX) <= 0.5 && + Math.abs(fromCamera.translateY - toCamera.translateY) <= 0.5 + + if (almostSame) { + poseFaceImageDisplayCameraRef.current = toCamera + setPoseFaceImageDisplayCamera(toCamera) + setPoseFaceImageCameraAnimating(false) + return + } + + setPoseFaceImageCameraAnimating(true) + poseFaceImageDisplayCameraRef.current = fromCamera + setPoseFaceImageDisplayCamera(fromCamera) + + const animateCamera = (now: number) => { + const progress = Math.min(1, (now - startedAt) / POSE_FACE_IMAGE_ZOOM_DURATION_MS) + const nextCamera = interpolatePoseFaceImageCamera(fromCamera, toCamera, progress) + + poseFaceImageDisplayCameraRef.current = nextCamera + setPoseFaceImageDisplayCamera(nextCamera) + + if (progress < 1) { + poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateCamera) + return + } + + poseFaceImageCameraAnimationFrameRef.current = null + poseFaceImageDisplayCameraRef.current = toCamera + setPoseFaceImageDisplayCamera(toCamera) + setPoseFaceImageCameraAnimating(false) + } + + poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateCamera) + }, [poseFaceImageCamera, poseFaceImagePanning]) + const poseFaceImageRenderedCamera = poseFaceImageCamera || poseFaceImageZoomOutActive + ? poseFaceImageDisplayCamera ?? poseFaceImageFullCamera() + : null + const poseFaceImageZoomStyle = useMemo((): CSSProperties | undefined => { + if (!poseFaceImageRenderedCamera) { + return undefined + } + + return { + transform: `matrix(${poseFaceImageRenderedCamera.scale}, 0, 0, ${poseFaceImageRenderedCamera.scale}, ${poseFaceImageRenderedCamera.translateX}, ${poseFaceImageRenderedCamera.translateY})`, + transformOrigin: '0 0', + } + }, [poseFaceImageRenderedCamera]) + const poseFaceImageZoomApplied = Boolean(poseFaceImageZoomStyle) + const setClampedPoseFaceImagePanCenter = useCallback(( + centerX: number, + centerY: number, + camera: PoseFaceImageCamera | null = poseFaceImageCamera + ) => { + if (!camera) return + + const next = clampPoseFaceCameraCenter( + centerX, + centerY, + camera.viewportWidth, + camera.viewportHeight + ) + + poseFaceImagePanCenterRef.current = next + setPoseFaceImagePanCenter((current) => { + if ( + current && + Math.abs(current.x - next.x) <= 0.0005 && + Math.abs(current.y - next.y) <= 0.0005 + ) { + return current + } + + return next + }) + }, [poseFaceImageCamera]) + + const startPoseFaceImagePan = useCallback((event: React.PointerEvent) => { + if ( + !poseFaceImageZoomApplied || + !poseFaceImageCamera || + !imageLayerStyle || + poseFaceImageCameraAnimating + ) { + return + } + if (event.button !== 0) return + if (pendingPoseKeypoint || boxLabel || uiLocked || frameBusy || trainingRunning || saving) return + if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return + + const target = event.target as HTMLElement | null + if (target?.closest('[data-box-control="true"]')) return + if (target?.closest('[data-pose-control="true"]')) return + + const contentWidth = Number(imageLayerStyle.width) + const contentHeight = Number(imageLayerStyle.height) + + if ( + !Number.isFinite(contentWidth) || + !Number.isFinite(contentHeight) || + contentWidth <= 0 || + contentHeight <= 0 + ) { + return + } + + event.preventDefault() + event.stopPropagation() + window.getSelection()?.removeAllRanges() + + poseFaceImagePanGestureRef.current = { + pointerId: event.pointerId, + source: 'image', + startClientX: event.clientX, + startClientY: event.clientY, + startCenterX: poseFaceImageCamera.centerX, + startCenterY: poseFaceImageCamera.centerY, + contentWidth, + contentHeight, + scale: poseFaceImageCamera.scale, + moved: false, + } + setPoseFaceImagePanning(true) + + try { + event.currentTarget.setPointerCapture(event.pointerId) + } catch { + // Pointer-Capture ist nur Komfort; die lokale Geste funktioniert auch ohne. + } + }, [ + boxLabel, + frameBusy, + imageLayerStyle, + pendingPoseKeypoint, + poseFaceImageCamera, + poseFaceImageCameraAnimating, + poseFaceImageZoomApplied, + saving, + trainingRunning, + uiLocked, + ]) + + const startPoseFacePreviewPan = useCallback((event: React.PointerEvent) => { + if (!poseFaceImageZoomApplied || !poseFaceImageCamera || poseFaceImageCameraAnimating) return + if (event.button !== 0) return + + event.preventDefault() + event.stopPropagation() + window.getSelection()?.removeAllRanges() + + poseFaceImagePanGestureRef.current = { + pointerId: event.pointerId, + source: 'preview', + startClientX: event.clientX, + startClientY: event.clientY, + startCenterX: poseFaceImageCamera.centerX, + startCenterY: poseFaceImageCamera.centerY, + contentWidth: 1, + contentHeight: 1, + scale: 1, + moved: false, + } + setPoseFaceImagePanning(true) + + try { + event.currentTarget.setPointerCapture(event.pointerId) + } catch { + // Pointer-Capture ist nur Komfort; die lokale Geste funktioniert auch ohne. + } + }, [poseFaceImageCamera, poseFaceImageCameraAnimating, poseFaceImageZoomApplied]) + + const movePoseFaceImagePan = useCallback((event: React.PointerEvent) => { + const gesture = poseFaceImagePanGestureRef.current + if (!gesture || gesture.pointerId !== event.pointerId) return + + event.preventDefault() + event.stopPropagation() + + const deltaX = event.clientX - gesture.startClientX + const deltaY = event.clientY - gesture.startClientY + + if (!gesture.moved && Math.hypot(deltaX, deltaY) >= 4) { + gesture.moved = true + } + + if (gesture.source === 'image') { + setClampedPoseFaceImagePanCenter( + gesture.startCenterX - deltaX / (gesture.scale * gesture.contentWidth), + gesture.startCenterY - deltaY / (gesture.scale * gesture.contentHeight) + ) + return + } + + const rect = event.currentTarget.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + + setClampedPoseFaceImagePanCenter( + gesture.startCenterX + deltaX / rect.width, + gesture.startCenterY + deltaY / rect.height + ) + }, [setClampedPoseFaceImagePanCenter]) + + const zoomOutPoseFaceImageToBody = useCallback(() => { + if (poseFaceImageCameraAnimationFrameRef.current !== null) { + cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current) + poseFaceImageCameraAnimationFrameRef.current = null + } + + const fromCamera = + poseFaceImageDisplayCameraRef.current ?? + poseFaceImageCamera ?? + poseFaceImageFullCamera() + const toCamera = poseFaceImageFullCamera() + const startedAt = performance.now() + + poseFaceImagePanGestureRef.current = null + poseFaceImagePanCenterRef.current = null + setPoseFaceImagePanCenter(null) + setPoseFaceImagePanning(false) + poseFaceImageZoomOutActiveRef.current = true + setPoseFaceImageZoomOutActive(true) + setPoseFaceImageCameraAnimating(true) + poseFaceImageDisplayCameraRef.current = fromCamera + setPoseFaceImageDisplayCamera(fromCamera) + + // Die rechte Map darf parallel zurueckfahren; die Bildkamera bleibt durch + // den Override sichtbar, bis der Zoom-Out fertig ist. + switchPoseKeypointMapView('body') + + const animateZoomOut = (now: number) => { + const progress = Math.min(1, (now - startedAt) / POSE_FACE_IMAGE_ZOOM_DURATION_MS) + const nextCamera = interpolatePoseFaceImageCamera(fromCamera, toCamera, progress) + + poseFaceImageDisplayCameraRef.current = nextCamera + setPoseFaceImageDisplayCamera(nextCamera) + + if (progress < 1) { + poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateZoomOut) + return + } + + poseFaceImageCameraAnimationFrameRef.current = null + poseFaceImageDisplayCameraRef.current = null + poseFaceImageZoomOutActiveRef.current = false + setPoseFaceImageDisplayCamera(null) + setPoseFaceImageCameraAnimating(false) + setPoseFaceImageZoomOutActive(false) + } + + poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateZoomOut) + }, [poseFaceImageCamera, switchPoseKeypointMapView]) + + const finishPoseFaceImagePan = useCallback((event: React.PointerEvent) => { + const gesture = poseFaceImagePanGestureRef.current + if (!gesture || gesture.pointerId !== event.pointerId) return + + event.preventDefault() + event.stopPropagation() + + try { + event.currentTarget.releasePointerCapture(event.pointerId) + } catch { + // Pointer-Capture war evtl. schon weg. + } + + poseFaceImagePanGestureRef.current = null + setPoseFaceImagePanning(false) + + if (gesture.source === 'preview' && !gesture.moved) { + zoomOutPoseFaceImageToBody() + } + }, [zoomOutPoseFaceImageToBody]) useEffect(() => { if (!poseModeActive) { @@ -9424,7 +10164,12 @@ export default function TrainingTab(props: { } const imageTouchClass = - boxLabel || drawingBox || boxInteraction || poseInteraction || pendingPoseKeypoint + boxLabel || + drawingBox || + boxInteraction || + poseInteraction || + pendingPoseKeypoint || + poseFaceImageZoomApplied ? 'touch-none' : 'touch-pan-y' @@ -9734,12 +10479,19 @@ export default function TrainingTab(props: { imageExpanded ? 'w-full min-h-0 max-h-[var(--image-stage-max-h)] sm:max-h-[var(--image-stage-max-h-sm)] lg:h-full lg:max-h-full lg:self-stretch' : imageStageHeightClass, - 'overflow-visible', + poseFaceImageZoomApplied ? 'overflow-hidden' : 'overflow-visible', ].join(' ')} style={imageStageStyle} > {imageSrc ? ( -
+
e.preventDefault()} + onPointerDownCapture={startPoseFaceImagePan} + onPointerMoveCapture={movePoseFaceImagePan} + onPointerUpCapture={finishPoseFaceImagePan} + onPointerCancelCapture={finishPoseFaceImagePan} onPointerDown={startDrawBox} onPointerMove={moveDrawBox} > @@ -10690,6 +11456,114 @@ export default function TrainingTab(props: { : null })() : null}
+ + {poseFaceImageZoomApplied ? ( + ) : (