From c6956647849f8a3f94b3177b9b27224470708b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Thu, 17 Jul 2025 21:12:26 +0200 Subject: [PATCH] Crop works nicely --- src/lib/components/PhotoCard.svelte | 322 +------------------ src/lib/components/wizard/StepGallery.svelte | 200 ++++++------ 2 files changed, 113 insertions(+), 409 deletions(-) diff --git a/src/lib/components/PhotoCard.svelte b/src/lib/components/PhotoCard.svelte index b380650..d3ad581 100644 --- a/src/lib/components/PhotoCard.svelte +++ b/src/lib/components/PhotoCard.svelte @@ -1,259 +1,21 @@
@@ -281,50 +40,8 @@ src={imageUrl} alt={personName} class="w-full h-full object-cover" - on:load={detectFaceWithMediaPipe} /> - - {#if isDownloadingModel} -
- - - - - Downloading AI Model... -
-
-
-
- {:else if isModelLoading} -
- - - - - Loading AI Model... -
-
-
-
- {:else if isDetectingFace} -
- - - - Detecting Face - - . - . - . - - -
-
-
-
- {/if} - {#if currentCrop}
@@ -352,33 +69,12 @@ - - -
- {#if faceDetectionError} -
- Manual crop -
- {:else if currentCrop && autoDetectedCrop && JSON.stringify(currentCrop) !== JSON.stringify(autoDetectedCrop)} -
- Custom crop -
- {:else if autoDetectedCrop} -
- Auto-cropped -
- {/if} -

{personName}

{#if isProcessing}

Processing...

- {:else if faceDetectionError} -

Using center crop

- {:else if autoDetectedCrop} -

Face detected

{/if}
diff --git a/src/lib/components/wizard/StepGallery.svelte b/src/lib/components/wizard/StepGallery.svelte index ebb43bc..f6088f2 100644 --- a/src/lib/components/wizard/StepGallery.svelte +++ b/src/lib/components/wizard/StepGallery.svelte @@ -3,7 +3,15 @@ import { columnMapping, filteredSheetData, currentStep } from '$lib/stores'; import { downloadDriveImage, isGoogleDriveUrl, createImageObjectUrl } from '$lib/google'; import PhotoCard from '../PhotoCard.svelte'; - + import * as tf from '@tensorflow/tfjs'; + import * as blazeface from '@tensorflow-models/blazeface'; + + let photos: PhotoInfo[] = []; + let isProcessing = false; + let processedCount = 0; + let totalCount = 0; + let detector: blazeface.BlazeFaceModel; + interface PhotoInfo { name: string; url: string; @@ -13,46 +21,40 @@ cropData?: { x: number; y: number; width: number; height: number }; faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed'; } - - let photos: PhotoInfo[] = []; - let isProcessing = false; - let processedCount = 0; - let totalCount = 0; - let faceDetectionInProgress = false; - let faceDetectionCount = { started: 0, completed: 0 }; - - // Process photos when component mounts - onMount(() => { - console.log('StepGallery mounted, processing photos...'); + + // Initialize detector and process photos + onMount(async () => { + console.log('StepGallery mounted, initializing face detector...'); + await tf.setBackend('webgl'); + await tf.ready(); + detector = await blazeface.load(); + console.log('BlazeFace model loaded'); if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) { console.log('Processing photos for gallery step'); processPhotos(); } else { - console.log('No data to process:', { - dataLength: $filteredSheetData.length, - pictureUrlMapping: $columnMapping.pictureUrl - }); + console.log('No data to process:', { dataLength: $filteredSheetData.length, pictureUrlMapping: $columnMapping.pictureUrl }); } }); - + async function processPhotos() { if (isProcessing) return; - + console.log('Starting processPhotos...'); isProcessing = true; processedCount = 0; - + // Get valid and included rows from filteredSheetData const validRows = $filteredSheetData.filter(row => row._isValid); console.log(`Found ${validRows.length} valid rows`); - + // Get unique photos to process const photoUrls = new Set(); const photoMap = new Map(); // url -> row data - + validRows.forEach((row: any) => { const photoUrl = row.pictureUrl; - + if (photoUrl && photoUrl.trim()) { photoUrls.add(photoUrl.trim()); if (!photoMap.has(photoUrl.trim())) { @@ -61,10 +63,10 @@ photoMap.get(photoUrl.trim())!.push(row); } }); - + console.log(`Found ${photoUrls.size} unique photo URLs`); totalCount = photoUrls.size; - + // Initialize photos array photos = Array.from(photoUrls).map(url => ({ name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname, // Use first person's name for display @@ -73,27 +75,27 @@ retryCount: 0, faceDetectionStatus: 'pending' as const })); - + // Process each photo for (let i = 0; i < photos.length; i++) { await loadPhoto(i); + await detectFaceForPhoto(i); processedCount++; } - isProcessing = false; } - + async function loadPhoto(index: number, isRetry = false) { const photo = photos[index]; - + if (!isRetry) { photo.status = 'loading'; photos = [...photos]; // Trigger reactivity } - + try { let objectUrl: string; - + if (isGoogleDriveUrl(photo.url)) { // Download from Google Drive console.log(`Downloading from Google Drive: ${photo.name}`); @@ -103,7 +105,7 @@ // Use direct URL objectUrl = photo.url; } - + // Test if image loads properly await new Promise((resolve, reject) => { const img = new Image(); @@ -114,66 +116,96 @@ }; img.src = objectUrl; }); - + photo.objectUrl = objectUrl; photo.status = 'success'; console.log(`Photo loaded successfully: ${photo.name}`); + // Automatically run face detection to generate crop + await detectFaceForPhoto(index); } catch (error) { console.error(`Failed to load photo for ${photo.name}:`, error); photo.status = 'error'; } - + photos = [...photos]; // Trigger reactivity } - + + async function detectFaceForPhoto(index: number) { + try { + photos[index].faceDetectionStatus = 'processing'; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.src = photos[index].objectUrl!; + await new Promise((r, e) => { img.onload = r; img.onerror = e; }); + const predictions = await detector.estimateFaces(img, false); + if (predictions.length > 0) { + const face = predictions.sort((a,b) => (b.probability?.[0]||0) - (a.probability?.[0]||0))[0]; + // Coordinates in displayed image space + let [x1,y1] = face.topLeft; + let [x2,y2] = face.bottomRight; + // Scale to natural image size + const scaleX = img.naturalWidth / img.width; + const scaleY = img.naturalHeight / img.height; + const faceWidth = (x2 - x1) * scaleX; + const faceHeight = (y2 - y1) * scaleY; + const faceCenterX = (x1 + (x2 - x1)/2) * scaleX; + const faceCenterY = (y1 + (y2 - y1)/2) * scaleY; + // Load crop config from env + const cropRatio = parseFloat(import.meta.env.VITE_CROP_RATIO || '1.0'); + const offsetX = parseFloat(import.meta.env.VITE_FACE_OFFSET_X || '0.0'); + const offsetY = parseFloat(import.meta.env.VITE_FACE_OFFSET_Y || '0.0'); + const cropScale = parseFloat(import.meta.env.VITE_CROP_SCALE || '2.5'); + // Compute crop size and center + let cropWidth = faceWidth * cropScale; + let cropHeight = cropWidth / cropRatio; + let centerX = faceCenterX + cropWidth * offsetX; + let centerY = faceCenterY + cropHeight * offsetY; + // Clamp center to ensure crop fits + centerX = Math.max(cropWidth/2, Math.min(centerX, img.naturalWidth - cropWidth/2)); + centerY = Math.max(cropHeight/2, Math.min(centerY, img.naturalHeight - cropHeight/2)); + const cropX = Math.round(centerX - cropWidth/2); + const cropY = Math.round(centerY - cropHeight/2); + const crop = { + x: Math.max(0, Math.min(cropX, img.naturalWidth - cropWidth)), + y: Math.max(0, Math.min(cropY, img.naturalHeight - cropHeight)), + width: Math.round(Math.min(cropWidth, img.naturalWidth)), + height: Math.round(Math.min(cropHeight, img.naturalHeight)) + }; + photos[index].cropData = crop; + photos[index].faceDetectionStatus = 'completed'; + } else { + photos[index].faceDetectionStatus = 'failed'; + } + } catch { + photos[index].faceDetectionStatus = 'failed'; + } + photos = [...photos]; + } + async function retryPhoto(index: number) { const photo = photos[index]; - + if (photo.retryCount >= 3) { return; // Max retries reached } - + photo.retryCount++; await loadPhoto(index, true); } - + function handleCropUpdate(index: number, cropData: { x: number; y: number; width: number; height: number }) { photos[index].cropData = cropData; photos = [...photos]; // Trigger reactivity } - - function handleFaceDetectionStarted(index: number) { - photos[index].faceDetectionStatus = 'processing'; - faceDetectionCount.started++; - faceDetectionInProgress = true; - photos = [...photos]; // Trigger reactivity - console.log(`Face detection started for photo ${index + 1}, total started: ${faceDetectionCount.started}`); - } - - function handleFaceDetectionCompleted(index: number, detail: { success: boolean; hasAutoDetectedCrop: boolean }) { - photos[index].faceDetectionStatus = detail.success ? 'completed' : 'failed'; - faceDetectionCount.completed++; - - console.log(`Face detection completed for photo ${index + 1}, total completed: ${faceDetectionCount.completed}`); - - // Check if all face detections are complete - if (faceDetectionCount.completed >= faceDetectionCount.started) { - faceDetectionInProgress = false; - console.log('All face detections completed'); - } - - photos = [...photos]; // Trigger reactivity - } - + function canProceed() { const hasPhotos = photos.length > 0; const allLoaded = photos.every(photo => photo.status === 'success'); const allCropped = photos.every(photo => photo.cropData); - const faceDetectionComplete = !faceDetectionInProgress; - - return hasPhotos && allLoaded && allCropped && faceDetectionComplete; + + return hasPhotos && allLoaded && allCropped; } - + // Cleanup object URLs when component is destroyed function cleanupObjectUrls() { photos.forEach(photo => { @@ -182,7 +214,7 @@ } }); } - + // Cleanup on unmount or when photos change $: { // This will run when photos array changes @@ -228,33 +260,10 @@ {/if} - {:else if faceDetectionInProgress} -
-
-
-
- - Detecting faces and auto-cropping... - -
- - {faceDetectionCount.completed} / {faceDetectionCount.started} - -
- - {#if faceDetectionCount.started > 0} -
-
-
- {/if} -
{/if} - {#if !isProcessing && !faceDetectionInProgress && photos.length > 0} + {#if !isProcessing && photos.length > 0}

Processing Summary

@@ -344,10 +353,9 @@ handleCropUpdate(index, e.detail)} - on:faceDetectionStarted={() => handleFaceDetectionStarted(index)} - on:faceDetectionCompleted={(e) => handleFaceDetectionCompleted(index, e.detail)} /> {:else if photo.status === 'error'}
@@ -359,7 +367,7 @@ Failed to load