From 38871e3be77bd8e8f4edc3ba95b24330a51349c7 Mon Sep 17 00:00:00 2001 From: grumd Date: Thu, 8 Jan 2026 18:21:19 +0100 Subject: [PATCH] feat: improve score recognition, autoshrink selection, add processing to b/w --- .../src/services/results/recognizeScore.ts | 4 +- .../{ => components/add-result}/AddResult.tsx | 21 +- .../add-result/ImageDataPreview.tsx | 23 ++ .../add-result/ScreenshotRecognition.tsx | 228 +++++++++++++----- .../components/add-result/compressFile.ts | 15 -- .../components/add-result/cropImage.ts | 52 ---- .../components/add-result/getDate.ts | 14 -- .../components/add-result/imageUtils.ts | 223 +++++++++++++++++ .../add-result/useProcessedImage.ts | 80 +++--- packages/web/src/features/root/Root.tsx | 2 +- packages/web/src/utils/base64.ts | 7 - 11 files changed, 463 insertions(+), 206 deletions(-) rename packages/web/src/features/leaderboards/{ => components/add-result}/AddResult.tsx (94%) create mode 100644 packages/web/src/features/leaderboards/components/add-result/ImageDataPreview.tsx delete mode 100644 packages/web/src/features/leaderboards/components/add-result/compressFile.ts delete mode 100644 packages/web/src/features/leaderboards/components/add-result/cropImage.ts delete mode 100644 packages/web/src/features/leaderboards/components/add-result/getDate.ts create mode 100644 packages/web/src/features/leaderboards/components/add-result/imageUtils.ts delete mode 100644 packages/web/src/utils/base64.ts diff --git a/packages/api/src/services/results/recognizeScore.ts b/packages/api/src/services/results/recognizeScore.ts index 7b3124ee..2ef65528 100644 --- a/packages/api/src/services/results/recognizeScore.ts +++ b/packages/api/src/services/results/recognizeScore.ts @@ -73,8 +73,8 @@ export const recognizeScore = async ( type: 'input_text', text: mix === 'Phoenix' - ? `Extract the vertically lined up white numbers from the photo according to the provided schema. One number per line, some numbers may have leading zeroes. All zeroes have a dot in the middle.` - : `Extract the vertically lined up white numbers from the photo according to the provided schema. One number per line, some numbers may have leading zeroes.`, + ? `Extract the vertically lined up numbers from the processed photo according to the provided schema. One number per line, some numbers may have leading zeroes. All zeroes have a dot in the middle.` + : `Extract the vertically lined up numbers from the processed photo according to the provided schema. One number per line, some numbers may have leading zeroes.`, }, { type: 'input_image', diff --git a/packages/web/src/features/leaderboards/AddResult.tsx b/packages/web/src/features/leaderboards/components/add-result/AddResult.tsx similarity index 94% rename from packages/web/src/features/leaderboards/AddResult.tsx rename to packages/web/src/features/leaderboards/components/add-result/AddResult.tsx index 381d56b3..b8697e88 100644 --- a/packages/web/src/features/leaderboards/AddResult.tsx +++ b/packages/web/src/features/leaderboards/components/add-result/AddResult.tsx @@ -17,7 +17,7 @@ import { FaExclamationCircle } from 'react-icons/fa'; import { IoIosWarning } from 'react-icons/io'; import { useNavigate, useParams } from 'react-router-dom'; -import css from './components/add-result/add-result.module.scss'; +import css from './add-result.module.scss'; import { useConfirmationPopup } from 'components/ConfirmationPopup/useConfirmationPopup'; import Loader from 'components/Loader/Loader'; @@ -29,10 +29,11 @@ import { useUser } from 'hooks/useUser'; import { useLanguage } from 'utils/context/translation'; import { api } from 'utils/trpc'; -import { ScreenshotRecognition } from './components/add-result/ScreenshotRecognition'; -import { compressDataUrl } from './components/add-result/compressFile'; -import { useProcessedImage } from './components/add-result/useProcessedImage'; -import { useSingleChartQuery } from './hooks/useSingleChartQuery'; +import { useSingleChartQuery } from '../../hooks/useSingleChartQuery'; +import { ImageDataPreview } from './ImageDataPreview'; +import { ScreenshotRecognition } from './ScreenshotRecognition'; +import { compressImageData } from './imageUtils'; +import { useProcessedImage } from './useProcessedImage'; const GRADE_OPTIONS = [ { value: 'SSS', label: 'SSS' }, @@ -196,7 +197,7 @@ const AddResult = () => { setError(null); try { - const compressedScreenshot = await compressDataUrl(processedImage.squareDataUrl); + const compressedScreenshot = await compressImageData(processedImage.imageData); const data = { pass: rawData.pass, @@ -255,9 +256,8 @@ const AddResult = () => {
{processedImage && ( - Screenshot )} @@ -329,7 +329,8 @@ const AddResult = () => { <> { diff --git a/packages/web/src/features/leaderboards/components/add-result/ImageDataPreview.tsx b/packages/web/src/features/leaderboards/components/add-result/ImageDataPreview.tsx new file mode 100644 index 00000000..8923ff89 --- /dev/null +++ b/packages/web/src/features/leaderboards/components/add-result/ImageDataPreview.tsx @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; + +interface ImageDataPreviewProps { + imageData: ImageData; + style?: React.CSSProperties; +} + +export const ImageDataPreview = ({ imageData, style }: ImageDataPreviewProps) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.putImageData(imageData, 0, 0); + } + }, [imageData]); + + return ; +}; diff --git a/packages/web/src/features/leaderboards/components/add-result/ScreenshotRecognition.tsx b/packages/web/src/features/leaderboards/components/add-result/ScreenshotRecognition.tsx index 2c11ece0..dc258e76 100644 --- a/packages/web/src/features/leaderboards/components/add-result/ScreenshotRecognition.tsx +++ b/packages/web/src/features/leaderboards/components/add-result/ScreenshotRecognition.tsx @@ -3,12 +3,17 @@ import { AreaSelector, type IArea } from '@bmunozg/react-image-area'; import { Alert, Button, Group, Popover, Stack, Text } from '@mantine/core'; import { useMutation } from '@tanstack/react-query'; import imageCompression from 'browser-image-compression'; -import { useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useLanguage } from 'utils/context/translation'; import { api } from 'utils/trpc'; -import { cropAreaToDataUrl } from './cropImage'; +import { + applyProcessing, + imageDataToCanvas, + prepareImageForRecognition, + shrinkAreaToBrightness, +} from './imageUtils'; export interface RecognizedScore { perfect: number; @@ -21,7 +26,8 @@ export interface RecognizedScore { } interface ScreenshotRecognitionProps { - squareDataUrl: string; + imageData: ImageData; + naturalSize: number; date?: Date | null; showDate?: boolean; onScoreRecognized?: (score: RecognizedScore) => void; @@ -29,19 +35,92 @@ interface ScreenshotRecognitionProps { } export const ScreenshotRecognition = ({ - squareDataUrl, + imageData: originalImageData, + naturalSize, date, mix, showDate, onScoreRecognized, }: ScreenshotRecognitionProps) => { + const canvasRef = useRef(null); + const imageDataRef = useRef(); + + const drawToCanvas = (data: ImageData) => { + const canvas = canvasRef.current; + if (!canvas) return; + canvas.width = data.width; + canvas.height = data.height; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.putImageData(data, 0, 0); + } + }; + + useEffect(() => { + imageDataRef.current = structuredClone(originalImageData); + drawToCanvas(imageDataRef.current); + setAreas([]); + }, [originalImageData]); + const lang = useLanguage(); const [error, setError] = useState(null); const [areas, setAreas] = useState([]); const [recognizedNumbers, setRecognizedNumbers] = useState(null); - // const [croppedPreview, setCroppedPreview] = useState(null); - const imgRef = useRef(null); + const areaSelectorRef = useRef(null); const [isCropping, setIsCropping] = useState(false); + const [debugImage, setDebugImage] = useState(); + const isInteractingRef = useRef(false); + const areasRef = useRef(areas); + areasRef.current = areas; + + const handleAreasChange = (newAreas: IArea[]) => { + isInteractingRef.current = true; + setAreas(newAreas); + }; + + const triggerShrink = useCallback(() => { + if (!isInteractingRef.current) return; + isInteractingRef.current = false; + + const currentAreas = areasRef.current; + + if (currentAreas.length === 0 || !canvasRef.current) return; + + const area = currentAreas[0]; + const displayedSize = canvasRef.current.clientWidth; + + // crop to get a new area + const shrunkArea = shrinkAreaToBrightness(originalImageData, naturalSize, area, displayedSize); + + if (imageDataRef.current) { + // apply processing filter to cropped area + const scale = naturalSize / displayedSize; + applyProcessing( + imageDataRef.current, + originalImageData, + Math.round(shrunkArea.x * scale), + Math.round(shrunkArea.y * scale), + Math.round(shrunkArea.width * scale), + Math.round(shrunkArea.height * scale) + ); + drawToCanvas(imageDataRef.current); + } + + setAreas([shrunkArea]); + }, [naturalSize, originalImageData]); + + useEffect(() => { + const handlePointerUp = () => { + // trigger shrink after AreaSelector finishes event handling + setTimeout(triggerShrink, 0); + }; + + document.addEventListener('pointerup', handlePointerUp, { capture: true }); + + return () => { + document.removeEventListener('pointerup', handlePointerUp, { capture: true }); + }; + }, [triggerShrink]); const recognizeScoreMutation = useMutation(api.results.recognizeScoreMutation.mutationOptions()); @@ -49,20 +128,35 @@ export const ScreenshotRecognition = ({ // if (areas.length === 0 || !imgRef.current) return undefined; // const area = areas[0]; // const displayedSize = imgRef.current.clientWidth; - // cropAreaToDataUrl(squareDataUrl, area, displayedSize).then((t) => setCroppedPreview(t)); + // prepareImageForRecognition(squareDataUrl, area, displayedSize).then((t) => + // setCroppedPreview(t) + // ); // }, [squareDataUrl, areas]); const handleRecognizeScore = async () => { - if (areas.length === 0 || !imgRef.current) return; + if (areas.length === 0 || !canvasRef.current || !imageDataRef.current) return; try { setError(null); setIsCropping(true); - const displayedSize = imgRef.current.clientWidth; - const croppedImage = await cropAreaToDataUrl(squareDataUrl, areas[0], displayedSize); + const displayedSize = canvasRef.current.clientWidth; + + // Get processed ImageData (synchronous) + const croppedImageData = prepareImageForRecognition( + originalImageData, + naturalSize, + areas[0], + displayedSize + ); - // Compress the cropped image before sending for recognition - const croppedFile = await imageCompression.getFilefromDataUrl(croppedImage, 'cropped.png'); + // Convert to canvas and compress for API + const croppedCanvas = imageDataToCanvas(croppedImageData); + const croppedFile = await imageCompression.canvasToFile( + croppedCanvas, + 'image/png', + 'cropped.png', + new Date().getTime() + ); const compressedFile = await imageCompression(croppedFile, { maxWidthOrHeight: 360, maxSizeMB: 0.03, @@ -70,6 +164,8 @@ export const ScreenshotRecognition = ({ }); const compressedImage = await imageCompression.getDataUrlFromFile(compressedFile); + setDebugImage(compressedImage); + const numbers = await recognizeScoreMutation.mutateAsync({ image: compressedImage, mix, @@ -107,7 +203,7 @@ export const ScreenshotRecognition = ({ }; return ( - + {showDate && ( {lang.DATE_TAKEN}: {date ? date.toLocaleString() : lang.UNKNOWN} @@ -116,19 +212,14 @@ export const ScreenshotRecognition = ({ - Screenshot preview + {/* {croppedPreview && Cropped preview} */} @@ -173,46 +264,63 @@ export const ScreenshotRecognition = ({ {recognizedNumbers && ( -
- - Perfect: - - - {recognizedNumbers[0] >= 0 ? recognizedNumbers[0] : '?'} - - - Great: - - - {recognizedNumbers[1] >= 0 ? recognizedNumbers[1] : '?'} - - - Good: - - - {recognizedNumbers[2] >= 0 ? recognizedNumbers[2] : '?'} - - - Bad: - - - {recognizedNumbers[3] >= 0 ? recognizedNumbers[3] : '?'} - - - Miss: - - - {recognizedNumbers[4] >= 0 ? recognizedNumbers[4] : '?'} - - Combo: - {recognizedNumbers[5] >= 0 ? recognizedNumbers[5] : '?'} - - Score: - - - {recognizedNumbers[6] >= 0 ? recognizedNumbers[6] : '?'} - -
+ +
+ + Perfect: + + + {recognizedNumbers[0] >= 0 ? recognizedNumbers[0] : '?'} + + + Great: + + + {recognizedNumbers[1] >= 0 ? recognizedNumbers[1] : '?'} + + + Good: + + + {recognizedNumbers[2] >= 0 ? recognizedNumbers[2] : '?'} + + + Bad: + + + {recognizedNumbers[3] >= 0 ? recognizedNumbers[3] : '?'} + + + Miss: + + + {recognizedNumbers[4] >= 0 ? recognizedNumbers[4] : '?'} + + Combo: + + {recognizedNumbers[5] >= 0 ? recognizedNumbers[5] : '?'} + + + Score: + + + {recognizedNumbers[6] >= 0 ? recognizedNumbers[6] : '?'} + +
+ {debugImage && ( + + )} +