Improve the the cropping process, UI and UX
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
let isProcessing = $state(false);
|
||||
let processedCount = $state(0);
|
||||
let totalCount = $state(0);
|
||||
let detector: blazeface.BlazeFaceModel;
|
||||
let detector: blazeface.BlazeFaceModel | undefined;
|
||||
let detectorPromise: Promise<void> | undefined;
|
||||
|
||||
interface PhotoInfo {
|
||||
name: string;
|
||||
@@ -19,72 +20,93 @@
|
||||
objectUrl?: string;
|
||||
retryCount: number;
|
||||
cropData?: { x: number; y: number; width: number; height: number };
|
||||
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
faceDetectionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'manual';
|
||||
}
|
||||
|
||||
// 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 });
|
||||
function initializeDetector() {
|
||||
if (!detectorPromise) {
|
||||
detectorPromise = (async () => {
|
||||
console.log('Initializing face detector...');
|
||||
await tf.setBackend('webgl');
|
||||
await tf.ready();
|
||||
detector = await blazeface.load();
|
||||
console.log('BlazeFace model loaded');
|
||||
})();
|
||||
}
|
||||
});
|
||||
return detectorPromise;
|
||||
}
|
||||
|
||||
async function processPhotos() {
|
||||
async function processPhotosInParallel() {
|
||||
if (isProcessing) return;
|
||||
|
||||
console.log('Starting processPhotos...');
|
||||
console.log('Starting processPhotos in parallel...');
|
||||
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 validRows = $filteredSheetData.filter((row) => row._isValid);
|
||||
const photoUrls = new Set<string>();
|
||||
const photoMap = new Map<string, any[]>(); // url -> row data
|
||||
const photoMap = new Map<string, any[]>();
|
||||
|
||||
validRows.forEach((row: any) => {
|
||||
const photoUrl = row.pictureUrl;
|
||||
|
||||
if (photoUrl && photoUrl.trim()) {
|
||||
photoUrls.add(photoUrl.trim());
|
||||
if (!photoMap.has(photoUrl.trim())) {
|
||||
photoMap.set(photoUrl.trim(), []);
|
||||
const trimmedUrl = photoUrl.trim();
|
||||
photoUrls.add(trimmedUrl);
|
||||
if (!photoMap.has(trimmedUrl)) {
|
||||
photoMap.set(trimmedUrl, []);
|
||||
}
|
||||
photoMap.get(photoUrl.trim())!.push(row);
|
||||
photoMap.get(trimmedUrl)!.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${photoUrls.size} unique photo URLs`);
|
||||
totalCount = photoUrls.size;
|
||||
console.log(`Found ${totalCount} unique photo URLs`);
|
||||
|
||||
// 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
|
||||
photos = Array.from(photoUrls).map((url) => ({
|
||||
name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname,
|
||||
url,
|
||||
status: 'loading' as const,
|
||||
retryCount: 0,
|
||||
faceDetectionStatus: 'pending' as const
|
||||
}));
|
||||
|
||||
// Process each photo
|
||||
const concurrencyLimit = 5;
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
await loadPhoto(i);
|
||||
await detectFaceForPhoto(i);
|
||||
processedCount++;
|
||||
const promise = (async () => {
|
||||
await loadPhoto(i);
|
||||
processedCount++;
|
||||
})();
|
||||
promises.push(promise);
|
||||
|
||||
if (promises.length >= concurrencyLimit) {
|
||||
await Promise.all(promises);
|
||||
promises.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
isProcessing = false;
|
||||
console.log('All photos processed.');
|
||||
}
|
||||
|
||||
// Initialize detector and process photos
|
||||
onMount(() => {
|
||||
console.log('StepGallery mounted');
|
||||
initializeDetector(); // Start loading model
|
||||
if ($filteredSheetData.length > 0 && $columnMapping.pictureUrl !== undefined) {
|
||||
console.log('Processing photos for gallery step');
|
||||
processPhotosInParallel();
|
||||
} else {
|
||||
console.log('No data to process:', {
|
||||
dataLength: $filteredSheetData.length,
|
||||
pictureUrlMapping: $columnMapping.pictureUrl
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPhoto(index: number, isRetry = false) {
|
||||
const photo = photos[index];
|
||||
|
||||
@@ -165,17 +187,27 @@
|
||||
|
||||
async function detectFaceForPhoto(index: number) {
|
||||
try {
|
||||
await initializeDetector(); // Ensure detector is loaded
|
||||
if (!detector) {
|
||||
photos[index].faceDetectionStatus = 'failed';
|
||||
console.error('Face detector not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
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];
|
||||
const getProbability = (p: number | tf.Tensor) => (typeof p === 'number' ? p : p.dataSync()[0]);
|
||||
|
||||
const face = predictions.sort((a,b) => getProbability(b.probability!) - getProbability(a.probability!))[0];
|
||||
// Coordinates in displayed image space
|
||||
let [x1,y1] = face.topLeft;
|
||||
let [x2,y2] = face.bottomRight;
|
||||
let [x1,y1] = face.topLeft as [number, number];
|
||||
let [x2,y2] = face.bottomRight as [number, number];
|
||||
// Scale to natural image size
|
||||
const scaleX = img.naturalWidth / img.width;
|
||||
const scaleY = img.naturalHeight / img.height;
|
||||
@@ -225,7 +257,8 @@
|
||||
} else {
|
||||
photos[index].faceDetectionStatus = 'failed';
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(`Face detection failed for ${photos[index].name}:`, error);
|
||||
photos[index].faceDetectionStatus = 'failed';
|
||||
}
|
||||
// No need to reassign photos array with $state reactivity
|
||||
@@ -242,13 +275,14 @@
|
||||
await loadPhoto(index, true);
|
||||
}
|
||||
|
||||
function handleCropUpdate(index: number, cropData: { x: number; y: number; width: number; height: number }) {
|
||||
photos[index].cropData = cropData;
|
||||
function handleCropUpdate(index: number, detail: { cropData: { x: number; y: number; width: number; height: number } }) {
|
||||
photos[index].cropData = detail.cropData;
|
||||
photos[index].faceDetectionStatus = 'manual';
|
||||
|
||||
// Save updated crop data to store
|
||||
cropRects.update(crops => ({
|
||||
...crops,
|
||||
[photos[index].url]: cropData
|
||||
[photos[index].url]: detail.cropData
|
||||
}));
|
||||
|
||||
// No need to reassign photos array with $state reactivity
|
||||
@@ -376,7 +410,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden mb-6">
|
||||
<div class="bg-white overflow-hidden mb-6">
|
||||
{#if photos.length === 0 && !isProcessing}
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -388,55 +422,14 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{#each photos as photo, index}
|
||||
{#if photo.status === 'loading'}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
|
||||
<div class="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-2"></div>
|
||||
<span class="text-xs text-gray-600">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
|
||||
<span class="text-xs text-blue-600">Processing photo...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if photo.status === 'success' && photo.objectUrl}
|
||||
<PhotoCard
|
||||
imageUrl={photo.objectUrl}
|
||||
personName={photo.name}
|
||||
isProcessing={photo.faceDetectionStatus === 'processing'}
|
||||
cropData={photo.cropData}
|
||||
on:cropUpdated={(e) => handleCropUpdate(index, e.detail)}
|
||||
/>
|
||||
{:else if photo.status === 'error'}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
|
||||
<div class="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center text-center p-4">
|
||||
<svg class="w-12 h-12 text-red-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs text-red-600 mb-2">Failed to load</span>
|
||||
<button
|
||||
class="text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
onclick={() => retryPhoto(index)}
|
||||
disabled={photo.retryCount >= 3}
|
||||
>
|
||||
{photo.retryCount >= 3 ? 'Max retries' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<h4 class="font-medium text-sm text-gray-900 truncate">{photo.name}</h4>
|
||||
<span class="text-xs text-red-600">Failed to load</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{#each photos as photo, index}
|
||||
<PhotoCard
|
||||
{photo}
|
||||
onCropUpdated={(e) => handleCropUpdate(index, e)}
|
||||
onRetry={() => retryPhoto(index)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user