diff --git a/.gitignore b/.gitignore index df37a36f..3674b4e2 100644 --- a/.gitignore +++ b/.gitignore @@ -297,4 +297,13 @@ __pycache__/ *.py[cod] *$py.class -node_modules/ \ No newline at end of file +node_modules/ +.claude/ +.cursor/worktrees.json +src/labelling-app/frontend/test-results/.last-run.json +sample-data/ + +# Auto-generated local env files +.env.local +**/.env.local +./server_outputs/ \ No newline at end of file diff --git a/src/labelling-app/backend/package.json b/src/labelling-app/backend/package.json index 207e34cb..2b1c255f 100644 --- a/src/labelling-app/backend/package.json +++ b/src/labelling-app/backend/package.json @@ -8,6 +8,7 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "lint": "eslint src --ext .ts", + "test": "npm run build && node --test dist/services/storagePaths.test.js dist/services/masks.test.js", "test:api": "node scripts/test_api.mjs" }, "dependencies": { diff --git a/src/labelling-app/backend/scripts/start.sh b/src/labelling-app/backend/scripts/start.sh index a05a6e91..0803469f 100644 --- a/src/labelling-app/backend/scripts/start.sh +++ b/src/labelling-app/backend/scripts/start.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail -node --expose-gc dist/index.js +node --expose-gc --max-old-space-size=8192 dist/index.js diff --git a/src/labelling-app/backend/src/routes/projects.ts b/src/labelling-app/backend/src/routes/projects.ts index c91c1b91..fce5b67b 100644 --- a/src/labelling-app/backend/src/routes/projects.ts +++ b/src/labelling-app/backend/src/routes/projects.ts @@ -35,6 +35,7 @@ import { } from "../services/projects"; import { parseFeatherMask, + readBinMaskRaw, getBaseName, getMaskImageBaseName, type ParsedMask, @@ -44,6 +45,8 @@ import { computeColorMap, rawBinaryToSparseMask, generateMaskOverlay, + serializeMaskToFeather, + serializeMaskToBin, } from "../services/masks"; import { uploadMaskBuffer, @@ -219,15 +222,37 @@ router.delete( const projectId = req.params.projectId; const doc = await ensureProjectAccess(projectId, user.uid); - // Delete all masks + // Delete all masks (Cloud Storage .bin files first, then Firestore docs) const masksCollection = getProjectMasksCollection(projectId); const masksSnapshot = await masksCollection.get(); - await Promise.all(masksSnapshot.docs.map((maskDoc: FirebaseFirestore.QueryDocumentSnapshot) => maskDoc.ref.delete())); + await Promise.all( + masksSnapshot.docs.map(async (maskDoc: FirebaseFirestore.QueryDocumentSnapshot) => { + const data = maskDoc.data(); + const storagePath = data?.storagePath as string | undefined; + if (storagePath) { + await deleteFileIfExists(storagePath); + } + await maskDoc.ref.delete(); + }) + ); - // Delete all mask maps + // Delete all mask maps (Cloud Storage colorMap + maskOverlay first, then Firestore docs) const maskMapsCollection = getProjectMaskMapsCollection(projectId); const maskMapsSnapshot = await maskMapsCollection.get(); - await Promise.all(maskMapsSnapshot.docs.map((mapDoc: FirebaseFirestore.QueryDocumentSnapshot) => mapDoc.ref.delete())); + await Promise.all( + maskMapsSnapshot.docs.map(async (mapDoc: FirebaseFirestore.QueryDocumentSnapshot) => { + const data = mapDoc.data(); + const colorMapStoragePath = data?.colorMapStoragePath as string | undefined; + const maskOverlayStoragePath = data?.maskOverlayStoragePath as string | undefined; + if (colorMapStoragePath) { + await deleteFileIfExists(colorMapStoragePath); + } + if (maskOverlayStoragePath) { + await deleteFileIfExists(maskOverlayStoragePath); + } + await mapDoc.ref.delete(); + }) + ); // Delete all images and their storage files const imagesCollection = getProjectImagesCollection(projectId); @@ -289,10 +314,15 @@ router.post( throw new HttpError(400, "VALIDATION_ERROR", "Zip archive is empty"); } - // Separate entries into images and masks based on folder structure - // Expected structure: /image/*.png and /masks/*.feather (or .arrow) - const imageEntries: Array<{ entryName: string; baseName: string; data: Buffer }> = []; - const maskEntries: Array<{ entryName: string; baseName: string; data: Buffer }> = []; + // Store entry references only — no getData() here. Decompress on-demand in the image loop + // to avoid holding all images + all masks in memory at once. + type ZipEntryRef = { entryName: string; baseName: string; entry: { getData: () => Buffer } }; + const imageEntries: ZipEntryRef[] = []; + const maskEntriesByBaseName = new Map< + string, + Array<{ entry: { getData: () => Buffer }; fileName: string; maskIndex: number }> + >(); + let maskIndex = 0; for (const entry of entries) { const entryPath = entry.entryName.toLowerCase(); @@ -303,24 +333,22 @@ router.post( continue; } - const data = entry.getData(); - // Check if it's in the image folder if (entryPath.includes("/image/") || entryPath.startsWith("image/")) { - imageEntries.push({ entryName: entry.entryName, baseName, data }); + imageEntries.push({ entryName: entry.entryName, baseName, entry }); } - // Check if it's in the masks folder and is a feather/arrow file - // Note: entryPath is lowercased, check for both forward and back slashes + // Check if it's in the masks folder and is .bin, .feather, or .arrow else if ( (entryPath.includes("/masks/") || entryPath.includes("\\masks\\") || entryPath.startsWith("masks/") || entryPath.startsWith("masks\\")) && - (entryPath.endsWith(".feather") || entryPath.endsWith(".arrow")) + (entryPath.endsWith(".bin") || entryPath.endsWith(".feather") || entryPath.endsWith(".arrow")) ) { console.log(`[ZIP Upload] Found mask entry: ${entry.entryName}`); - // Use getMaskImageBaseName to strip the _XX suffix for matching with images - // e.g., "image_00.feather" -> imageBaseName="image" to match "image.png" const imageBaseName = getMaskImageBaseName(fileName); - maskEntries.push({ entryName: entry.entryName, baseName: imageBaseName, data }); + const list = maskEntriesByBaseName.get(imageBaseName) || []; + list.push({ entry, fileName, maskIndex }); + maskEntriesByBaseName.set(imageBaseName, list); + maskIndex++; } // Fallback: if no folder structure, treat image files as images else if ( @@ -329,136 +357,171 @@ router.post( entryPath.endsWith(".jpeg") || entryPath.endsWith(".webp") ) { - imageEntries.push({ entryName: entry.entryName, baseName, data }); + imageEntries.push({ entryName: entry.entryName, baseName, entry }); } } - console.log(`[ZIP Upload] Found ${imageEntries.length} images and ${maskEntries.length} mask entries`); - if (maskEntries.length > 0) { - console.log(`[ZIP Upload] Mask entries:`, maskEntries.map(e => e.entryName)); + const totalMaskEntries = maskIndex; + console.log(`[ZIP Upload] Found ${imageEntries.length} images and ${totalMaskEntries} mask entries`); + if (totalMaskEntries > 0) { + console.log(`[ZIP Upload] Mask entries by image:`, Array.from(maskEntriesByBaseName.entries()).map(([k, v]) => `${k}: ${v.length}`)); } if (imageEntries.length === 0) { throw new HttpError(400, "VALIDATION_ERROR", "No images found in zip. Expected images in /image/ folder."); } - // Build a map of masks by image base name for quick lookup - // Group masks by image base name (an image can have multiple masks) - // maskEntry.baseName is already the image base name (e.g., "image" from "image_00.feather") - const masksByBaseName = new Map(); - let maskIndex = 0; - for (const maskEntry of maskEntries) { - console.log(`[ZIP Upload] Parsing mask: ${maskEntry.entryName}, imageBaseName: ${maskEntry.baseName}, dataSize: ${maskEntry.data.length}`); - const parsed = parseFeatherMask(maskEntry.data, maskEntry.baseName, maskIndex); - if (parsed) { - console.log(`[ZIP Upload] Mask parsed successfully for image: ${maskEntry.baseName}, maskIndex: ${maskIndex}`); - const existing = masksByBaseName.get(maskEntry.baseName) || []; - existing.push(parsed); - masksByBaseName.set(maskEntry.baseName, existing); - maskIndex++; - } else { - console.warn(`[ZIP Upload] Failed to parse mask: ${maskEntry.entryName}`); - } - } - console.log(`[ZIP Upload] Total masks parsed: ${maskIndex}, for ${masksByBaseName.size} unique images`) - - // Prepare images with resizing - const prepared: Array<{ - fileName: string; - baseName: string; - resized: { buffer: Buffer; contentType: string }; - parsedMasks: ParsedMask[] | null; - }> = []; - - for (const imageEntry of imageEntries) { - const fileName = path.basename(imageEntry.entryName); - const resized = await resizeImageBuffer(imageEntry.data); - const parsedMasks = masksByBaseName.get(imageEntry.baseName) || null; - prepared.push({ - fileName, - baseName: imageEntry.baseName, - resized: { buffer: resized.buffer, contentType: resized.contentType }, - parsedMasks, - }); - } - + // Process images one at a time: resize → upload → free buffer before moving + // to the next image. Previously all resized buffers were accumulated in a + // `prepared[]` array, holding every image in memory simultaneously before + // any uploads began. This single-pass approach keeps only one resized + // buffer alive at a time, cutting peak heap use by ~(N-1) × imageSize. + const fileNameCount: Record = {}; const saved: Array<{ imageId: string; storagePath: string; maskMapId: string | null }> = []; const imageIds: string[] = []; + let masksCount = 0; try { - for (const item of prepared) { + for (const imageEntry of imageEntries) { + const rawFileName = path.basename(imageEntry.entryName); + const count = fileNameCount[rawFileName] ?? 0; + fileNameCount[rawFileName] = count + 1; + const fileName = + count === 0 + ? rawFileName + : `${getBaseName(rawFileName)}${String(count).padStart(2, "0")}${path.extname(rawFileName)}`; + + // Decompress this image on-demand (freed after resize) + const imageData = imageEntry.entry.getData(); + const resized = await resizeImageBuffer(imageData); const imageId = uuidv4(); - const storagePath = buildStoragePath(projectId, imageId, item.fileName); + const storagePath = buildStoragePath(projectId, imageId, fileName); - await uploadImageBuffer(storagePath, item.resized.buffer, item.resized.contentType); + await uploadImageBuffer(storagePath, resized.buffer, resized.contentType); + const resizedContentType = resized.contentType; + const resizedSizeBytes = resized.buffer.length; let maskMapId: string | null = null; const maskIds: string[] = []; - // Create masks and mask map if we have parsed masks - if (item.parsedMasks && item.parsedMasks.length > 0) { - // Collect mask data for overlay generation - const maskDataForOverlay: Array<{ maskId: string; size: number; binaryMask: SparseBinaryMask }> = []; - const maskSizes: Record = {}; + const imageMaskEntries = maskEntriesByBaseName.get(imageEntry.baseName) || []; + if (imageMaskEntries.length > 0) { + console.log(`[zip upload] Processing ${imageMaskEntries.length} masks for image ${fileName}`); - // Create individual mask documents and upload binary to storage - for (const parsedMask of item.parsedMasks) { - const maskId = uuidv4(); - const preparedMask = prepareMaskFromParsed(maskId, parsedMask); - - // Upload binary mask to Cloud Storage - const maskStoragePath = buildMaskStoragePath(projectId, imageId, maskId); - await uploadMaskBuffer(maskStoragePath, preparedMask.binary); - - // Save mask document to Firestore (with storagePath, not binaryMask) - await getProjectMasksCollection(projectId).doc(maskId).set({ - ...preparedMask.doc, - storagePath: maskStoragePath, - createdAt: FieldValue.serverTimestamp(), - updatedAt: FieldValue.serverTimestamp(), - }); + const maskDataForOverlay: Array<{ maskId: string; size: number; binaryMask: SparseBinaryMask; srcWidth: number; srcHeight: number }> = []; + const maskSizes: Record = {}; - maskIds.push(maskId); - maskSizes[maskId] = preparedMask.doc.size; + for (const me of imageMaskEntries) { + const maskData = me.entry.getData(); + const isBin = me.fileName.toLowerCase().endsWith(".bin"); - // Convert binary to sparse format for overlay generation - const binaryMask = rawBinaryToSparseMask( - preparedMask.binary, - preparedMask.doc.width, - preparedMask.doc.height - ); - maskDataForOverlay.push({ maskId, size: preparedMask.doc.size, binaryMask }); + if (isBin) { + const raw = readBinMaskRaw(maskData, imageEntry.baseName, me.maskIndex); + if (!raw) { + console.warn(`[zip upload] Failed to read .bin mask: ${me.fileName}`); + continue; + } + console.log(`[zip upload] Mask .bin: ${me.fileName}, dimensions: ${raw.width}x${raw.height}, maskIndex: ${me.maskIndex}`); + const maskId = uuidv4(); + const maskStoragePath = buildMaskStoragePath(projectId, imageId, maskId); + await uploadMaskBuffer(maskStoragePath, raw.binary); + + await getProjectMasksCollection(projectId).doc(maskId).set({ + maskId, + labelId: null, + color: null, + storagePath: maskStoragePath, + size: raw.size, + width: raw.width, + height: raw.height, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + + maskIds.push(maskId); + maskSizes[maskId] = raw.size; + + const binaryMask = rawBinaryToSparseMask( + raw.binary as Uint8Array, + raw.width, + raw.height + ); + maskDataForOverlay.push({ + maskId, + size: raw.size, + binaryMask, + srcWidth: raw.width, + srcHeight: raw.height, + }); + } else { + const parsed = parseFeatherMask(maskData, imageEntry.baseName, me.maskIndex); + if (!parsed) { + console.warn(`[zip upload] Failed to parse feather mask: ${me.fileName}`); + continue; + } + console.log(`[zip upload] Parsed mask: ${parsed.baseName}, dimensions: ${parsed.width}x${parsed.height}, maskIndex: ${parsed.maskIndex}`); + const maskId = uuidv4(); + const preparedMask = prepareMaskFromParsed(maskId, parsed); + + const maskStoragePath = buildMaskStoragePath(projectId, imageId, maskId); + await uploadMaskBuffer(maskStoragePath, preparedMask.binary); + + await getProjectMasksCollection(projectId).doc(maskId).set({ + ...preparedMask.doc, + storagePath: maskStoragePath, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + + maskIds.push(maskId); + maskSizes[maskId] = preparedMask.doc.size; + + const binaryMask = rawBinaryToSparseMask( + preparedMask.binary, + preparedMask.doc.width, + preparedMask.doc.height + ); + maskDataForOverlay.push({ + maskId, + size: preparedMask.doc.size, + binaryMask, + srcWidth: preparedMask.doc.width, + srcHeight: preparedMask.doc.height, + }); + } } - // Create mask map with maskLabels dictionary - maskMapId = uuidv4(); + if (maskIds.length > 0) { + maskMapId = uuidv4(); + const colorMapStoragePath = buildColorMapStoragePath(projectId, maskMapId); + await uploadColorMap(colorMapStoragePath, {}); - // Upload empty colorMap to storage (initially no masks are labeled) - const colorMapStoragePath = buildColorMapStoragePath(projectId, maskMapId); - await uploadColorMap(colorMapStoragePath, {}); + console.log(`[zip upload] Generating overlay: target=${TARGET_WIDTH}x${TARGET_HEIGHT}, masks=${maskDataForOverlay.length}`); + const maskOverlay = generateMaskOverlay(maskDataForOverlay, TARGET_WIDTH, TARGET_HEIGHT); + console.log(`[zip upload] Overlay generated: ${maskOverlay.width}x${maskOverlay.height}, dataBase64Len=${maskOverlay.data.length}, maskIds=${maskOverlay.maskIds.length}`); + const maskOverlayStoragePath = buildMaskOverlayStoragePath(projectId, maskMapId); + await uploadMaskOverlay(maskOverlayStoragePath, maskOverlay); - // Generate and upload maskOverlay (2D array with smallest mask at each pixel) - const maskOverlay = generateMaskOverlay(maskDataForOverlay, TARGET_WIDTH, TARGET_HEIGHT); - const maskOverlayStoragePath = buildMaskOverlayStoragePath(projectId, maskMapId); - await uploadMaskOverlay(maskOverlayStoragePath, maskOverlay); + const maskMap = createMaskMapFromMasks( + maskMapId, + imageId, + maskIds, + maskSizes, + colorMapStoragePath, + maskOverlayStoragePath, + TARGET_WIDTH, + TARGET_HEIGHT + ); - const maskMap = createMaskMapFromMasks( - maskMapId, - imageId, - maskIds, - maskSizes, - colorMapStoragePath, - maskOverlayStoragePath, - TARGET_WIDTH, - TARGET_HEIGHT - ); + await getProjectMaskMapsCollection(projectId).doc(maskMapId).set({ + ...maskMap, + maskIds, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); - await getProjectMaskMapsCollection(projectId).doc(maskMapId).set({ - ...maskMap, - maskIds, - createdAt: FieldValue.serverTimestamp(), - updatedAt: FieldValue.serverTimestamp(), - }); + masksCount += 1; + } } // Create image document @@ -474,15 +537,15 @@ router.post( labellerId: null, meta: buildImageMeta( { - fileName: item.fileName, + fileName, status: meta.data.status, tags: meta.data.tags, }, - item.fileName + fileName ), storagePath, - contentType: item.resized.contentType, - sizeBytes: item.resized.buffer.length, + contentType: resizedContentType, + sizeBytes: resizedSizeBytes, createdAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), }, @@ -511,8 +574,6 @@ router.post( ); throw error; } - - const masksCount = prepared.filter(p => p.parsedMasks && p.parsedMasks.length > 0).length; res.status(201).json({ imageIds: saved.map((item) => item.imageId), count: saved.length, @@ -521,6 +582,330 @@ router.post( }) ); +// ============================================================================ +// ZIP DOWNLOAD ROUTE (same format as upload) +// ============================================================================ + +const ZIP_EXPORT_MAX_IMAGES = 10000; +const ZIP_EXPORT_MAX_MASKS = 50000; +const ZIP_DOWNLOAD_CONCURRENCY = 10; + +const streamToBuffer = (stream: NodeJS.ReadableStream): Promise => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); + +async function parallelMap( + items: T[], + fn: (item: T) => Promise, + concurrency: number +): Promise { + const results: R[] = []; + for (let i = 0; i < items.length; i += concurrency) { + const batch = items.slice(i, i + concurrency); + const batchResults = await Promise.all(batch.map(fn)); + results.push(...batchResults); + } + return results; +} + +router.get( + "/:projectId/images/zip", + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = req.user; + if (!user) { + throw new HttpError(401, "UNAUTHORIZED", "Missing auth context"); + } + + const projectId = req.params.projectId; + await ensureProjectAccess(projectId, user.uid); + + const ids = req.query.ids ? String(req.query.ids).split(",") : null; + const status = req.query.status ? String(req.query.status) : null; + const limit = Math.min( + Number(req.query.limit) || 50, + ZIP_EXPORT_MAX_IMAGES + ); + const cursor = req.query.cursor ? String(req.query.cursor) : null; + const masksOnly = req.query.masksOnly === "true"; + + // 1. Gather work: image entries and mask entries (with baseName, index for zip paths) + type ImageWorkItem = { storagePath: string; fileName: string; imageId: string }; + type MaskWorkItem = { storagePath: string; width: number; height: number; baseName: string; index: number; maskId: string; imageId: string }; + + const imageWorkItems: ImageWorkItem[] = []; + let maskWorkItems: MaskWorkItem[] = []; + + if (masksOnly) { + // Mask-centric: all masks that are labelled (labelId set), project-wide + console.log(`[images/zip] masksOnly=true — querying labelled masks`); + const masksSnapshot = await getProjectMasksCollection(projectId) + .where("labelId", "!=", null) + .limit(ZIP_EXPORT_MAX_MASKS) + .get(); + const labelledMaskDocs = masksSnapshot.docs; + console.log(`[images/zip] Found ${labelledMaskDocs.length} labelled mask(s)`); + + if (labelledMaskDocs.length > 0) { + // Resolve imageId for each mask via maskMap (maskIds array-contains maskId) + const maskIdToImageId = new Map(); + await parallelMap(labelledMaskDocs, async (doc) => { + const maskId = doc.id; + const maskMapSnap = await getProjectMaskMapsCollection(projectId) + .where("maskIds", "array-contains", maskId) + .limit(1) + .get(); + if (!maskMapSnap.empty) { + const imageId = maskMapSnap.docs[0].data()?.imageId as string | undefined; + if (imageId) maskIdToImageId.set(maskId, imageId); + } + }, ZIP_DOWNLOAD_CONCURRENCY); + + const imageIds = [...new Set(maskIdToImageId.values())]; + const imagesCollection = getProjectImagesCollection(projectId); + const imageIdToFileName = new Map(); + for (let i = 0; i < imageIds.length; i += 10) { + const chunk = imageIds.slice(i, i + 10); + const snaps = await Promise.all( + chunk.map((id) => imagesCollection.doc(id).get()) + ); + for (const snap of snaps) { + if (snap.exists) { + const data = snap.data(); + const fileName = (data?.meta as { fileName?: string } | undefined)?.fileName as string | undefined; + if (fileName) imageIdToFileName.set(snap.id, fileName); + } + } + } + + const baseNameToIndex = new Map(); + const work: MaskWorkItem[] = []; + for (const doc of labelledMaskDocs) { + const maskId = doc.id; + const data = doc.data(); + const storagePath = data?.storagePath as string | undefined; + const width = (data?.width as number) ?? 0; + const height = (data?.height as number) ?? 0; + if (!storagePath) { + console.log(`[images/zip] Mask ${maskId} has no storagePath, skipping`); + continue; + } + const imageId = maskIdToImageId.get(maskId); + if (!imageId) { + console.log(`[images/zip] No maskMap found for mask ${maskId}, skipping`); + continue; + } + const fileName = imageIdToFileName.get(imageId); + if (!fileName) { + console.log(`[images/zip] No fileName for imageId=${imageId} (mask ${maskId}), skipping`); + continue; + } + const baseName = getBaseName(fileName); + const index = baseNameToIndex.get(baseName) ?? 0; + baseNameToIndex.set(baseName, index + 1); + work.push({ storagePath, width, height, baseName, index, maskId, imageId }); + } + maskWorkItems = work; + } + + console.log( + `[images/zip] Gather complete (masksOnly): ${imageWorkItems.length} images, ${maskWorkItems.length} masks to download` + ); + } else { + // Image-centric: images from query + all their masks + const collection = getProjectImagesCollection(projectId); + let items: FirebaseFirestore.DocumentData[]; + + if (ids && ids.length > 0) { + const chunks: string[][] = []; + for (let i = 0; i < ids.length; i += 10) { + chunks.push(ids.slice(i, i + 10)); + } + const results = await Promise.all( + chunks.map((chunk) => collection.where("imageId", "in", chunk).get()) + ); + items = results.flatMap((snapshot) => + snapshot.docs.map((doc) => normalizeImageData(doc.data())) + ).slice(0, ZIP_EXPORT_MAX_IMAGES); + } else { + let query: FirebaseFirestore.Query = collection.orderBy( + "createdAt", + "desc" + ); + if (status) { + query = query.where("meta.status", "==", status); + } + if (cursor) { + const cursorDoc = await collection.doc(cursor).get(); + if (cursorDoc.exists) { + query = query.startAfter(cursorDoc); + } + } + const snapshot = await query.limit(limit).get(); + items = snapshot.docs.map((doc) => + normalizeImageData(doc.data()) + ); + } + + const idsDesc = ids && ids.length > 0 ? String(ids.length) : "query"; + console.log( + `[images/zip] Start projectId=${projectId} limit=${limit} status=${status ?? "any"} ids=${idsDesc} imageCount=${items.length}` + ); + + const total = items.length; + for (let imageIndex = 0; imageIndex < items.length; imageIndex++) { + const image = items[imageIndex]; + const storagePath = image?.storagePath as string | undefined; + const fileName = (image?.meta as { fileName?: string } | undefined)?.fileName as string | undefined; + const imageId = image?.imageId as string | undefined; + + if (!storagePath || !fileName) { + console.log( + `[images/zip] Skipping image ${imageIndex + 1}/${total} imageId=${imageId ?? "?"} — missing storagePath or fileName` + ); + continue; + } + + const current = imageIndex + 1; + console.log( + `[images/zip] Gathering image ${current}/${total} imageId=${imageId ?? "?"} fileName=${fileName}` + ); + + imageWorkItems.push({ storagePath, fileName, imageId: imageId ?? "unknown" }); + + const maskMapId = image?.maskMapId as string | undefined; + if (!maskMapId) { + console.log(`[images/zip] Image ${fileName} has no maskMapId, skipping masks`); + continue; + } + + const maskMapDoc = await getProjectMaskMapsCollection(projectId).doc(maskMapId).get(); + if (!maskMapDoc.exists) { + console.log(`[images/zip] Image ${fileName} maskMapId=${maskMapId} not found in Firestore`); + continue; + } + const maskMapData = maskMapDoc.data(); + const maskIds = (maskMapData?.maskIds as string[] | undefined) || []; + const baseName = getBaseName(fileName); + console.log( + `[images/zip] Image ${fileName} has ${maskIds.length} mask(s) in maskMap=${maskMapId}` + ); + + for (let i = 0; i < maskIds.length; i++) { + const maskId = maskIds[i]; + const maskDoc = await getProjectMasksCollection(projectId).doc(maskId).get(); + if (!maskDoc.exists) { + console.log(`[images/zip] Mask ${maskId} not found in Firestore, skipping`); + continue; + } + const maskData = maskDoc.data(); + const maskStoragePath = maskData?.storagePath as string | undefined; + const width = (maskData?.width as number) ?? 0; + const height = (maskData?.height as number) ?? 0; + if (!maskStoragePath) { + console.log(`[images/zip] Mask ${maskId} has no storagePath, skipping`); + continue; + } + maskWorkItems.push({ storagePath: maskStoragePath, width, height, baseName, index: i, maskId, imageId: imageId ?? "unknown" }); + } + } + + console.log( + `[images/zip] Gather complete: ${imageWorkItems.length} images, ${maskWorkItems.length} masks to download` + ); + } + + // 2. Download images and masks in parallel batches + const downloadImageBuffer = async (item: ImageWorkItem): Promise => { + console.log(`[images/zip] Downloading image: ${item.fileName} (${item.storagePath})`); + const stream = await getFileStream(item.storagePath); + const buf = await streamToBuffer(stream); + console.log(`[images/zip] Downloaded image: ${item.fileName} (${buf.length} bytes)`); + return buf; + }; + + const downloadMaskBufferWithLog = async (item: MaskWorkItem): Promise => { + try { + console.log(`[images/zip] Downloading mask: ${item.maskId} (${item.storagePath})`); + const buf = await downloadMaskBuffer(item.storagePath); + console.log(`[images/zip] Downloaded mask: ${item.maskId} (${buf.length} bytes)`); + return buf; + } catch (err) { + console.error( + `[images/zip] Mask download failed (imageId=${item.imageId}, maskId=${item.maskId}, path=${item.storagePath}):`, + err instanceof Error ? err.message : err + ); + return null; + } + }; + + let imageBuffers: Buffer[] = []; + if (!masksOnly && imageWorkItems.length > 0) { + console.log(`[images/zip] Starting parallel image download (concurrency=${ZIP_DOWNLOAD_CONCURRENCY})...`); + imageBuffers = await parallelMap(imageWorkItems, downloadImageBuffer, ZIP_DOWNLOAD_CONCURRENCY); + console.log(`[images/zip] All image downloads complete`); + } + + console.log(`[images/zip] Starting parallel mask download (concurrency=${ZIP_DOWNLOAD_CONCURRENCY})...`); + const maskBuffers = await parallelMap(maskWorkItems, downloadMaskBufferWithLog, ZIP_DOWNLOAD_CONCURRENCY); + console.log(`[images/zip] All mask downloads complete`); + + // 3. Build ZIP: image/{imageId}/{fileName}, masks/{baseName}_{index}.bin + const zip = new AdmZip(); + let imagesAdded = 0; + let masksAdded = 0; + let masksFailed = 0; + + for (let i = 0; i < imageWorkItems.length; i++) { + const item = imageWorkItems[i]; + const imageZipPath = `image/${item.imageId}/${item.fileName}`; + zip.addFile(imageZipPath, imageBuffers[i]); + imagesAdded += 1; + console.log(`[images/zip] Added ${imageZipPath}`); + } + for (let i = 0; i < maskWorkItems.length; i++) { + const maskBuf = maskBuffers[i]; + if (maskBuf === null) { + masksFailed += 1; + console.log(`[images/zip] Skipping failed mask ${maskWorkItems[i].maskId} for ${maskWorkItems[i].baseName}`); + continue; + } + try { + const m = maskWorkItems[i]; + const binBuffer = serializeMaskToBin(maskBuf, m.width, m.height); + const indexPadded = String(m.index).padStart(2, "0"); + zip.addFile(`masks/${m.baseName}_${indexPadded}.bin`, binBuffer); + masksAdded += 1; + console.log(`[images/zip] Added masks/${m.baseName}_${indexPadded}.bin`); + } catch (err) { + masksFailed += 1; + console.error( + `[images/zip] Mask serialize failed (maskId=${maskWorkItems[i].maskId}):`, + err instanceof Error ? err.message : err + ); + } + } + + const zipBuffer = zip.toBuffer(); + console.log( + `[images/zip] Done projectId=${projectId} imagesAdded=${imagesAdded} masksAdded=${masksAdded} masksFailed=${masksFailed} zipSizeBytes=${zipBuffer.length}` + ); + + res.setHeader("Content-Type", "application/zip"); + const zipFilename = masksOnly + ? `project-${projectId}-labelled-masks.zip` + : `project-${projectId}-export.zip`; + res.setHeader( + "Content-Disposition", + `attachment; filename="${zipFilename}"` + ); + res.send(zipBuffer); + }) +); + // ============================================================================ // IMAGE ROUTES // ============================================================================ @@ -792,19 +1177,36 @@ router.delete( if (doc.exists) { const data = doc.data(); - // Delete associated mask map and masks + // Delete associated mask map and masks (Cloud Storage first, then Firestore) if (data?.maskMapId) { const maskMapDoc = await getProjectMaskMapsCollection(projectId).doc(data.maskMapId).get(); if (maskMapDoc.exists) { const maskMapData = maskMapDoc.data(); - // Delete all masks in this mask map + // Delete each mask's .bin file from Cloud Storage, then delete mask Firestore doc if (maskMapData?.maskIds) { await Promise.all( - maskMapData.maskIds.map((maskId: string) => - getProjectMasksCollection(projectId).doc(maskId).delete() - ) + maskMapData.maskIds.map(async (maskId: string) => { + const maskDoc = await getProjectMasksCollection(projectId).doc(maskId).get(); + if (maskDoc.exists) { + const maskData = maskDoc.data(); + const maskStoragePath = maskData?.storagePath as string | undefined; + if (maskStoragePath) { + await deleteFileIfExists(maskStoragePath); + } + await maskDoc.ref.delete(); + } + }) ); } + // Delete maskMap's colorMap and maskOverlay from Cloud Storage + const colorMapStoragePath = maskMapData?.colorMapStoragePath as string | undefined; + const maskOverlayStoragePath = maskMapData?.maskOverlayStoragePath as string | undefined; + if (colorMapStoragePath) { + await deleteFileIfExists(colorMapStoragePath); + } + if (maskOverlayStoragePath) { + await deleteFileIfExists(maskOverlayStoragePath); + } await maskMapDoc.ref.delete(); } } @@ -879,6 +1281,198 @@ router.get( }) ); +// ============================================================================ +// MASK IMPORT (persist SAM-generated masks) +// ============================================================================ + +router.post( + "/:projectId/images/:imageId/masks/import", + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = req.user; + if (!user) { + throw new HttpError(401, "UNAUTHORIZED", "Missing auth context"); + } + + const { projectId, imageId } = req.params; + await ensureProjectAccess(projectId, user.uid); + + // Validate the image exists + const imageDoc = await getProjectImagesCollection(projectId).doc(imageId).get(); + if (!imageDoc.exists) { + throw new HttpError(404, "NOT_FOUND", "Image not found"); + } + + // Validate payload + const masksPayload = req.body?.masks; + if (!Array.isArray(masksPayload) || masksPayload.length === 0) { + throw new HttpError(400, "VALIDATION_ERROR", "masks array is required and must not be empty"); + } + + for (let i = 0; i < masksPayload.length; i++) { + const m = masksPayload[i]; + if (!Array.isArray(m.mask) || typeof m.width !== "number" || typeof m.height !== "number") { + throw new HttpError(400, "VALIDATION_ERROR", `masks[${i}] must have mask (number[]), width, and height`); + } + if (m.width <= 0 || m.height <= 0) { + throw new HttpError(400, "VALIDATION_ERROR", `masks[${i}] has invalid dimensions`); + } + if (m.mask.length !== m.width * m.height) { + throw new HttpError(400, "VALIDATION_ERROR", `masks[${i}].mask length (${m.mask.length}) does not match width*height (${m.width * m.height})`); + } + } + + const imageData = imageDoc.data(); + const existingMaskMapId = imageData?.maskMapId as string | undefined; + + // Load existing mask data if mask map already exists + let existingMaskIds: string[] = []; + let existingMaskSizes: Record = {}; + let existingMaskLabels: Record = {}; + let existingMaskDataForOverlay: Array<{ maskId: string; size: number; binaryMask: SparseBinaryMask; srcWidth: number; srcHeight: number }> = []; + + if (existingMaskMapId) { + const existingMaskMapDoc = await getProjectMaskMapsCollection(projectId).doc(existingMaskMapId).get(); + if (existingMaskMapDoc.exists) { + const existingMaskMapData = existingMaskMapDoc.data(); + existingMaskIds = existingMaskMapData?.maskIds || []; + existingMaskSizes = existingMaskMapData?.maskSizes || {}; + existingMaskLabels = existingMaskMapData?.maskLabels || {}; + + // Load existing mask binaries for overlay regeneration + for (const mid of existingMaskIds) { + const mDoc = await getProjectMasksCollection(projectId).doc(mid).get(); + if (!mDoc.exists) continue; + const mData = mDoc.data(); + if (!mData?.storagePath) continue; + try { + const buf = await downloadMaskBuffer(mData.storagePath); + const maskW = mData.width || TARGET_WIDTH; + const maskH = mData.height || TARGET_HEIGHT; + const binaryMask = rawBinaryToSparseMask(buf, maskW, maskH); + existingMaskDataForOverlay.push({ maskId: mid, size: mData.size || 0, binaryMask, srcWidth: maskW, srcHeight: maskH }); + } catch { + console.warn(`[masks/import] Failed to load existing mask ${mid}`); + } + } + } + } + + // Create new mask documents and upload binaries + const newMaskIds: string[] = []; + const newMaskDataForOverlay: Array<{ maskId: string; size: number; binaryMask: SparseBinaryMask; srcWidth: number; srcHeight: number }> = []; + const newMaskSizes: Record = {}; + const newMaskLabels: Record = {}; + + for (const m of masksPayload) { + const maskId = uuidv4(); + const binary = Buffer.from(new Uint8Array(m.mask)); + const binaryMask = rawBinaryToSparseMask(binary, m.width, m.height); + const size = Object.values(binaryMask).reduce((sum, cols) => sum + Object.keys(cols).length, 0); + + // Upload binary to storage + const maskStoragePath = buildMaskStoragePath(projectId, imageId, maskId); + await uploadMaskBuffer(maskStoragePath, binary); + + // Create Firestore document + await getProjectMasksCollection(projectId).doc(maskId).set({ + maskId, + labelId: null, + color: null, + storagePath: maskStoragePath, + size, + width: m.width, + height: m.height, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + + newMaskIds.push(maskId); + newMaskSizes[maskId] = size; + newMaskLabels[maskId] = null; + newMaskDataForOverlay.push({ maskId, size, binaryMask, srcWidth: m.width, srcHeight: m.height }); + } + + // Merge all mask data + const allMaskIds = [...existingMaskIds, ...newMaskIds]; + const allMaskSizes = { ...existingMaskSizes, ...newMaskSizes }; + const allMaskLabels = { ...existingMaskLabels, ...newMaskLabels }; + const allMaskDataForOverlay = [...existingMaskDataForOverlay, ...newMaskDataForOverlay]; + + // Generate maskOverlay from all masks + const maskOverlay = generateMaskOverlay(allMaskDataForOverlay, TARGET_WIDTH, TARGET_HEIGHT); + + // Get project labels for colorMap computation + const projectDoc = await firestore.collection("projects").doc(projectId).get(); + const labels = projectDoc.data()?.labels || {}; + const colorMap = computeColorMap( + allMaskLabels, + allMaskDataForOverlay.map((m) => ({ maskId: m.maskId, binaryMask: m.binaryMask, srcWidth: m.srcWidth, srcHeight: m.srcHeight })), + labels, + TARGET_WIDTH, + TARGET_HEIGHT + ); + + let maskMapId: string; + + if (existingMaskMapId) { + // Update existing mask map + maskMapId = existingMaskMapId; + + const colorMapStoragePath = buildColorMapStoragePath(projectId, maskMapId); + await uploadColorMap(colorMapStoragePath, colorMap); + + const maskOverlayStoragePath = buildMaskOverlayStoragePath(projectId, maskMapId); + await uploadMaskOverlay(maskOverlayStoragePath, maskOverlay); + + await getProjectMaskMapsCollection(projectId).doc(maskMapId).update({ + maskIds: allMaskIds, + maskSizes: allMaskSizes, + maskLabels: allMaskLabels, + width: TARGET_WIDTH, + height: TARGET_HEIGHT, + updatedAt: FieldValue.serverTimestamp(), + }); + } else { + // Create new mask map + maskMapId = uuidv4(); + + const colorMapStoragePath = buildColorMapStoragePath(projectId, maskMapId); + await uploadColorMap(colorMapStoragePath, colorMap); + + const maskOverlayStoragePath = buildMaskOverlayStoragePath(projectId, maskMapId); + await uploadMaskOverlay(maskOverlayStoragePath, maskOverlay); + + const maskMapDoc = createMaskMapFromMasks( + maskMapId, + imageId, + allMaskIds, + allMaskSizes, + colorMapStoragePath, + maskOverlayStoragePath, + TARGET_WIDTH, + TARGET_HEIGHT + ); + + await getProjectMaskMapsCollection(projectId).doc(maskMapId).set({ + ...maskMapDoc, + maskIds: allMaskIds, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + + // Link mask map to image + await getProjectImagesCollection(projectId).doc(imageId).update({ + maskMapId, + updatedAt: FieldValue.serverTimestamp(), + }); + } + + console.log(`[masks/import] Imported ${newMaskIds.length} masks for image ${imageId}, maskMapId=${maskMapId}`); + + res.status(201).json({ maskIds: newMaskIds, maskMapId }); + }) +); + // Get mask overlay for an image (2D array of mask IDs for each pixel) // This is used for hover detection on the frontend router.get( @@ -1017,8 +1611,6 @@ router.patch( // Fetch all masks and their binary data from storage to recompute colorMap const allMaskIds = maskMapData?.maskIds || []; - const width = maskMapData?.width || 0; - const height = maskMapData?.height || 0; const allMasks = await Promise.all( allMaskIds.map(async (mid: string) => { @@ -1031,18 +1623,20 @@ router.patch( try { const binaryBuffer = await downloadMaskBuffer(storagePath); - const binaryMask = rawBinaryToSparseMask(binaryBuffer, width, height); - return { maskId: mid, binaryMask }; + const maskW = maskData.width || TARGET_WIDTH; + const maskH = maskData.height || TARGET_HEIGHT; + const binaryMask = rawBinaryToSparseMask(binaryBuffer, maskW, maskH); + return { maskId: mid, binaryMask, srcWidth: maskW, srcHeight: maskH }; } catch { console.warn(`Failed to download mask ${mid} from storage`); return null; } }) ); - const validMasks = allMasks.filter((m): m is { maskId: string; binaryMask: Record> } => m !== null); + const validMasks = allMasks.filter((m): m is { maskId: string; binaryMask: Record>; srcWidth: number; srcHeight: number } => m !== null); - // Recompute colorMap with updated maskLabels - const colorMap = computeColorMap(maskLabels, validMasks, labels); + // Recompute colorMap with updated maskLabels, scaling to target resolution + const colorMap = computeColorMap(maskLabels, validMasks, labels, TARGET_WIDTH, TARGET_HEIGHT); // Upload updated colorMap to storage const colorMapStoragePath = maskMapData?.colorMapStoragePath; @@ -1079,81 +1673,109 @@ router.patch( throw new HttpError(400, "VALIDATION_ERROR", parsed.error.message); } - const results = await Promise.all( - parsed.data.updates.map(async (update) => { - const maskRef = getProjectMasksCollection(projectId).doc(update.maskId); - const maskDoc = await maskRef.get(); + // Step 1: Update all individual mask documents and collect results + const results: Array<{ maskId: string; success: boolean; labelId?: string | null; color?: string | null; error?: string }> = []; + const successfulUpdates: Array<{ maskId: string; labelId: string | null; color: string | null }> = []; - if (!maskDoc.exists) { - return { maskId: update.maskId, success: false, error: "NOT_FOUND" }; - } + for (const update of parsed.data.updates) { + const maskRef = getProjectMasksCollection(projectId).doc(update.maskId); + const maskDoc = await maskRef.get(); + + if (!maskDoc.exists) { + results.push({ maskId: update.maskId, success: false, error: "NOT_FOUND" }); + continue; + } - const newLabelId = update.labelId; - const newColor = newLabelId && labels[newLabelId] ? labels[newLabelId].color : null; + const newLabelId = update.labelId; + const newColor = newLabelId && labels[newLabelId] ? labels[newLabelId].color : null; - await maskRef.update({ - labelId: newLabelId, - color: newColor, - updatedAt: FieldValue.serverTimestamp(), - }); + await maskRef.update({ + labelId: newLabelId, + color: newColor, + updatedAt: FieldValue.serverTimestamp(), + }); + + results.push({ maskId: update.maskId, success: true, labelId: newLabelId, color: newColor }); + successfulUpdates.push({ maskId: update.maskId, labelId: newLabelId, color: newColor }); + } + + // Step 2: Group successful updates by mask map and update each mask map once + if (successfulUpdates.length > 0) { + // Find which mask maps contain these masks (typically all masks belong to same mask map) + const maskMapIds = new Set(); + const maskToMaskMapId = new Map(); - // Update associated mask map + for (const update of successfulUpdates) { const maskMapsSnapshot = await getProjectMaskMapsCollection(projectId) .where("maskIds", "array-contains", update.maskId) .get(); if (!maskMapsSnapshot.empty) { - const maskMapDoc = maskMapsSnapshot.docs[0]; - const maskMapData = maskMapDoc.data(); + const maskMapId = maskMapsSnapshot.docs[0].id; + maskMapIds.add(maskMapId); + maskToMaskMapId.set(update.maskId, maskMapId); + } + } - // Update maskLabels dictionary (source of truth) - const maskLabels = { ...(maskMapData?.maskLabels || {}) }; - maskLabels[update.maskId] = newLabelId; - - // Fetch all masks and their binary data from storage to recompute colorMap - const allMaskIds = maskMapData?.maskIds || []; - const width = maskMapData?.width || 0; - const height = maskMapData?.height || 0; - - const allMasks = await Promise.all( - allMaskIds.map(async (mid: string) => { - const mDoc = await getProjectMasksCollection(projectId).doc(mid).get(); - if (!mDoc.exists) return null; - - const mData = mDoc.data(); - const storagePath = mData?.storagePath; - if (!storagePath) return null; - - try { - const binaryBuffer = await downloadMaskBuffer(storagePath); - const binaryMask = rawBinaryToSparseMask(binaryBuffer, width, height); - return { maskId: mid, binaryMask }; - } catch { - console.warn(`Failed to download mask ${mid} from storage`); - return null; - } - }) - ); - const validMasks = allMasks.filter((m): m is { maskId: string; binaryMask: Record> } => m !== null); + // Process each affected mask map once with all its updates + for (const maskMapId of maskMapIds) { + const maskMapDoc = await getProjectMaskMapsCollection(projectId).doc(maskMapId).get(); + if (!maskMapDoc.exists) continue; - // Recompute colorMap with updated maskLabels - const colorMap = computeColorMap(maskLabels, validMasks, labels); + const maskMapData = maskMapDoc.data(); - // Upload updated colorMap to storage - const colorMapStoragePath = maskMapData?.colorMapStoragePath; - if (colorMapStoragePath) { - await uploadColorMap(colorMapStoragePath, colorMap); - } + // Collect all updates that belong to this mask map + const updatesForThisMaskMap = successfulUpdates.filter( + (u) => maskToMaskMapId.get(u.maskId) === maskMapId + ); - await maskMapDoc.ref.update({ - maskLabels, - updatedAt: FieldValue.serverTimestamp(), - }); + // Apply ALL updates to the maskLabels dictionary at once + const maskLabels = { ...(maskMapData?.maskLabels || {}) }; + for (const update of updatesForThisMaskMap) { + maskLabels[update.maskId] = update.labelId; } - return { maskId: update.maskId, success: true, labelId: newLabelId, color: newColor }; - }) - ); + // Fetch all masks and their binary data from storage to recompute colorMap + const allMaskIds = maskMapData?.maskIds || []; + + const allMasks = await Promise.all( + allMaskIds.map(async (mid: string) => { + const mDoc = await getProjectMasksCollection(projectId).doc(mid).get(); + if (!mDoc.exists) return null; + + const mData = mDoc.data(); + const storagePath = mData?.storagePath; + if (!storagePath) return null; + + try { + const binaryBuffer = await downloadMaskBuffer(storagePath); + const maskW = mData.width || TARGET_WIDTH; + const maskH = mData.height || TARGET_HEIGHT; + const binaryMask = rawBinaryToSparseMask(binaryBuffer, maskW, maskH); + return { maskId: mid, binaryMask, srcWidth: maskW, srcHeight: maskH }; + } catch { + console.warn(`Failed to download mask ${mid} from storage`); + return null; + } + }) + ); + const validMasks = allMasks.filter((m): m is { maskId: string; binaryMask: Record>; srcWidth: number; srcHeight: number } => m !== null); + + // Recompute colorMap with ALL updated maskLabels at once, scaling to target resolution + const colorMap = computeColorMap(maskLabels, validMasks, labels, TARGET_WIDTH, TARGET_HEIGHT); + + // Upload updated colorMap to storage (once per mask map) + const colorMapStoragePath = maskMapData?.colorMapStoragePath; + if (colorMapStoragePath) { + await uploadColorMap(colorMapStoragePath, colorMap); + } + + await maskMapDoc.ref.update({ + maskLabels, + updatedAt: FieldValue.serverTimestamp(), + }); + } + } res.json({ results }); }) @@ -1200,8 +1822,6 @@ router.delete( // Get remaining mask IDs (excluding the one being deleted) const remainingMaskIds = (maskMapData?.maskIds || []).filter((mid: string) => mid !== maskId); - const width = maskMapData?.width || 0; - const height = maskMapData?.height || 0; // Fetch remaining masks from storage to recompute colorMap const remainingMasks = await Promise.all( @@ -1215,18 +1835,20 @@ router.delete( try { const binaryBuffer = await downloadMaskBuffer(storagePath); - const binaryMask = rawBinaryToSparseMask(binaryBuffer, width, height); - return { maskId: mid, binaryMask }; + const maskW = mData.width || TARGET_WIDTH; + const maskH = mData.height || TARGET_HEIGHT; + const binaryMask = rawBinaryToSparseMask(binaryBuffer, maskW, maskH); + return { maskId: mid, binaryMask, srcWidth: maskW, srcHeight: maskH }; } catch { console.warn(`Failed to download mask ${mid} from storage`); return null; } }) ); - const validMasks = remainingMasks.filter((m): m is { maskId: string; binaryMask: Record> } => m !== null); + const validMasks = remainingMasks.filter((m): m is { maskId: string; binaryMask: Record>; srcWidth: number; srcHeight: number } => m !== null); - // Recompute colorMap without the deleted mask - const colorMap = computeColorMap(maskLabels, validMasks, labels); + // Recompute colorMap without the deleted mask, scaling to target resolution + const colorMap = computeColorMap(maskLabels, validMasks, labels, TARGET_WIDTH, TARGET_HEIGHT); // Upload updated colorMap to storage const colorMapStoragePath = maskMapData?.colorMapStoragePath; diff --git a/src/labelling-app/backend/src/services/cache.ts b/src/labelling-app/backend/src/services/cache.ts index 83203531..3fafba8e 100644 --- a/src/labelling-app/backend/src/services/cache.ts +++ b/src/labelling-app/backend/src/services/cache.ts @@ -133,7 +133,7 @@ export const estimateColorMapSize = ( }; export const estimateMaskOverlaySize = (overlay: { - data: number[]; + data: string; maskIds: string[]; } | null): number => { if (!overlay) { @@ -143,7 +143,8 @@ export const estimateMaskOverlaySize = (overlay: { for (const id of overlay.maskIds || []) { idsBytes += id.length; } - return overlay.data.length * 4 + idsBytes + 64; + // data is a base64 string; JS strings are UTF-16 so ~2 bytes per char. + return overlay.data.length * 2 + idsBytes + 64; }; export const estimateBufferSize = (buffer: Buffer): number => @@ -159,7 +160,7 @@ export const maskOverlayCache = createLruCache<{ width: number; height: number; maskIds: string[]; - data: number[]; + data: string; }>({ maxBytes: toBytes(config.cacheMaskOverlayMb), ttlMs: config.cacheMaskOverlayTtlMs, diff --git a/src/labelling-app/backend/src/services/masks.test.ts b/src/labelling-app/backend/src/services/masks.test.ts new file mode 100644 index 00000000..563ecb94 --- /dev/null +++ b/src/labelling-app/backend/src/services/masks.test.ts @@ -0,0 +1,327 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { tableFromIPC } from "apache-arrow"; +import { + getBaseName, + getMaskImageBaseName, + binaryMaskToSparse, + sparseToBinaryMask, + mergeSparseData, + getMaskIndices, + isSparseDataEmpty, + serializeMaskToFeather, + parseBinMask, + readBinMaskRaw, + serializeMaskToBin, + addMaskAtPixel, + removeMaskAtPixel, + getMaskIndicesAtPixel, + createEmptyMask, + generateMaskOverlay, + rawBinaryToSparseMask, +} from "./masks"; + +describe("masks", () => { + describe("getBaseName", () => { + it("strips extension from filename", () => { + assert.strictEqual(getBaseName("photo.png"), "photo"); + assert.strictEqual(getBaseName("image.jpg"), "image"); + assert.strictEqual(getBaseName("file.webp"), "file"); + }); + it("handles path and returns basename without extension", () => { + assert.strictEqual(getBaseName("folder/photo.png"), "photo"); + assert.strictEqual(getBaseName("a/b/c.x"), "c"); + }); + it("returns full string when no extension", () => { + assert.strictEqual(getBaseName("noext"), "noext"); + }); + }); + + describe("getMaskImageBaseName", () => { + it("strips _XX suffix and extension from mask filename", () => { + assert.strictEqual(getMaskImageBaseName("image_00.feather"), "image"); + assert.strictEqual(getMaskImageBaseName("photo_01.feather"), "photo"); + assert.strictEqual(getMaskImageBaseName("x_99.arrow"), "x"); + }); + it("returns baseName when no _XX suffix", () => { + assert.strictEqual(getMaskImageBaseName("image.feather"), "image"); + assert.strictEqual(getMaskImageBaseName("single.arrow"), "single"); + }); + }); + + describe("binaryMaskToSparse", () => { + it("converts binary mask to sparse row/col map for maskIndex", () => { + const mask = new Uint8Array(4); // 2x2 + mask[0] = 1; + mask[3] = 1; + const data = binaryMaskToSparse(mask, 2, 2, 0); + assert.deepStrictEqual(data["0"]?.["0"], [0]); + assert.deepStrictEqual(data["1"]?.["1"], [0]); + assert.strictEqual(Object.keys(data).length, 2); + }); + it("returns empty object for all-zero mask", () => { + const mask = new Uint8Array(9); + const data = binaryMaskToSparse(mask, 3, 3, 1); + assert.strictEqual(Object.keys(data).length, 0); + }); + }); + + describe("sparseToBinaryMask", () => { + it("converts sparse data back to binary for given maskIndex", () => { + const data: Record> = { + "0": { "0": [0], "1": [1] }, + "1": { "0": [1] }, + }; + const mask = sparseToBinaryMask(data, 2, 2, 0); + assert.strictEqual(mask[0], 1); + assert.strictEqual(mask[1], 0); + assert.strictEqual(mask[2], 0); + assert.strictEqual(mask[3], 0); + const mask1 = sparseToBinaryMask(data, 2, 2, 1); + assert.strictEqual(mask1[1], 1); + assert.strictEqual(mask1[2], 1); + }); + }); + + describe("mergeSparseData", () => { + it("merges multiple sparse maps and combines indices at same pixel", () => { + const a: Record> = { "0": { "0": [0] } }; + const b: Record> = { "0": { "0": [1] } }; + const merged = mergeSparseData([a, b]); + assert.deepStrictEqual(merged["0"]?.["0"], [0, 1]); + }); + it("returns empty for empty array", () => { + const merged = mergeSparseData([]); + assert.strictEqual(Object.keys(merged).length, 0); + }); + }); + + describe("getMaskIndices", () => { + it("returns sorted unique mask indices from sparse data", () => { + const data: Record> = { + "0": { "0": [2, 0], "1": [1] }, + }; + assert.deepStrictEqual(getMaskIndices(data), [0, 1, 2]); + }); + }); + + describe("isSparseDataEmpty", () => { + it("returns true for empty object", () => { + assert.strictEqual(isSparseDataEmpty({}), true); + }); + it("returns false when any keys exist", () => { + assert.strictEqual(isSparseDataEmpty({ "0": {} }), false); + }); + }); + + describe("serializeMaskToFeather", () => { + it("produces valid Arrow IPC with width/height columns", () => { + const width = 4; + const height = 3; + const raw = new Uint8Array(width * height); + raw[0] = 1; + raw[width * height - 1] = 1; + const buffer = Buffer.from(raw); + const feather = serializeMaskToFeather(buffer, width, height); + assert.ok(feather.length > 0); + const table = tableFromIPC(feather); + assert.strictEqual(table.numRows, 1); + const widthCol = table.getChild("width"); + const heightCol = table.getChild("height"); + assert.ok(widthCol !== null); + assert.ok(heightCol !== null); + assert.strictEqual(Number(widthCol?.get(0)), width); + assert.strictEqual(Number(heightCol?.get(0)), height); + }); + }); + + describe("serializeMaskToBin / parseBinMask", () => { + it("serializeMaskToBin produces 8-byte header + raw bytes", () => { + const width = 4; + const height = 3; + const raw = Buffer.alloc(width * height, 0); + raw[0] = 1; + raw[raw.length - 1] = 1; + const bin = serializeMaskToBin(raw, width, height); + assert.strictEqual(bin.length, 8 + width * height); + assert.strictEqual(bin.readUInt32LE(0), width); + assert.strictEqual(bin.readUInt32LE(4), height); + assert.strictEqual(bin[8], 1); + assert.strictEqual(bin[bin.length - 1], 1); + }); + it("parseBinMask reads header and returns ParsedMask", () => { + const width = 2; + const height = 2; + const raw = new Uint8Array([1, 0, 0, 1]); + const bin = serializeMaskToBin(Buffer.from(raw), width, height); + const parsed = parseBinMask(bin, "test", 0); + assert.ok(parsed !== null); + assert.strictEqual(parsed?.baseName, "test"); + assert.strictEqual(parsed?.width, width); + assert.strictEqual(parsed?.height, height); + assert.strictEqual(parsed?.maskIndex, 0); + assert.deepStrictEqual(parsed?.data["0"]?.["0"], [0]); + assert.deepStrictEqual(parsed?.data["1"]?.["1"], [0]); + }); + it("parseBinMask returns null for buffer too short", () => { + assert.strictEqual(parseBinMask(Buffer.alloc(4), "x", 0), null); + }); + it("roundtrip: raw -> serializeMaskToBin -> parseBinMask -> sparse matches binaryMaskToSparse", () => { + const width = 3; + const height = 2; + const raw = new Uint8Array(width * height); + raw[1] = 1; + raw[4] = 1; + const bin = serializeMaskToBin(Buffer.from(raw), width, height); + const parsed = parseBinMask(bin, "img", 1); + assert.ok(parsed !== null); + const expected = binaryMaskToSparse(raw, width, height, 1); + assert.deepStrictEqual(parsed?.data, expected); + }); + }); + + describe("readBinMaskRaw", () => { + it("returns null for buffer too short", () => { + assert.strictEqual(readBinMaskRaw(Buffer.alloc(4), "x", 0), null); + }); + it("reads header and returns RawBinMask with correct fields", () => { + const width = 2; + const height = 2; + const raw = Buffer.from([1, 0, 0, 1]); + const bin = serializeMaskToBin(raw, width, height); + const result = readBinMaskRaw(bin, "test", 1); + assert.ok(result !== null); + assert.strictEqual(result?.baseName, "test"); + assert.strictEqual(result?.width, width); + assert.strictEqual(result?.height, height); + assert.strictEqual(result?.maskIndex, 1); + assert.strictEqual(result?.size, 2); + assert.ok(Buffer.isBuffer(result?.binary)); + assert.strictEqual(result?.binary.length, width * height); + assert.strictEqual(result?.binary[0], 1); + assert.strictEqual(result?.binary[3], 1); + }); + it("returns null for invalid size (header + body length mismatch)", () => { + const bin = Buffer.alloc(8 + 10); + bin.writeUInt32LE(2, 0); + bin.writeUInt32LE(2, 4); + assert.strictEqual(readBinMaskRaw(bin, "x", 0), null); + }); + it("roundtrip: serializeMaskToBin -> readBinMaskRaw preserves dimensions and pixel count", () => { + const width = 4; + const height = 3; + const raw = Buffer.alloc(width * height, 0); + raw[0] = 1; + raw[width * height - 1] = 1; + const bin = serializeMaskToBin(raw, width, height); + const result = readBinMaskRaw(bin, "img", 0); + assert.ok(result !== null); + assert.strictEqual(result?.width, width); + assert.strictEqual(result?.height, height); + assert.strictEqual(result?.size, 2); + assert.strictEqual(result?.binary.length, width * height); + }); + }); + + describe("createEmptyMask", () => { + it("returns serialized mask with empty data and given dimensions", () => { + const m = createEmptyMask(10, 20); + assert.strictEqual(m.width, 10); + assert.strictEqual(m.height, 20); + assert.strictEqual(Object.keys(m.data).length, 0); + }); + }); + + describe("generateMaskOverlay", () => { + it("returns data as a base64 string", () => { + const binary = new Uint8Array([1, 0, 0, 1]); // 2x2: TL and BR set + const binaryMask = rawBinaryToSparseMask(binary, 2, 2); + const overlay = generateMaskOverlay( + [{ maskId: "m1", size: 2, binaryMask, srcWidth: 2, srcHeight: 2 }], + 2, + 2 + ); + assert.strictEqual(typeof overlay.data, "string", "data must be a base64 string"); + assert.ok(overlay.data.length > 0, "base64 string must not be empty"); + }); + + it("data decodes to an Int32Array with correct values", () => { + const binary = new Uint8Array([1, 0, 0, 1]); // 2x2: TL=1, TR=0, BL=0, BR=1 + const binaryMask = rawBinaryToSparseMask(binary, 2, 2); + const overlay = generateMaskOverlay( + [{ maskId: "m1", size: 2, binaryMask, srcWidth: 2, srcHeight: 2 }], + 2, + 2 + ); + + const buf = Buffer.from(overlay.data, "base64"); + const int32 = new Int32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); + + // TL pixel (0,0) → maskIndex 0 + assert.strictEqual(int32[0], 0, "TL pixel should be maskIndex 0"); + // TR pixel (0,1) → no mask (-1) + assert.strictEqual(int32[1], -1, "TR pixel should be -1 (no mask)"); + // BL pixel (1,0) → no mask (-1) + assert.strictEqual(int32[2], -1, "BL pixel should be -1 (no mask)"); + // BR pixel (1,1) → maskIndex 0 + assert.strictEqual(int32[3], 0, "BR pixel should be maskIndex 0"); + }); + + it("returns maskIds in overlay matching input order", () => { + const binary = new Uint8Array([1, 0, 0, 0]); + const binaryMask = rawBinaryToSparseMask(binary, 2, 2); + const overlay = generateMaskOverlay( + [{ maskId: "abc", size: 1, binaryMask, srcWidth: 2, srcHeight: 2 }], + 2, + 2 + ); + assert.deepStrictEqual(overlay.maskIds, ["abc"]); + }); + + it("smallest mask wins when two masks overlap", () => { + // 2x2 grid: both masks cover all 4 pixels; smaller mask (size=2) should win + const fullBinary = new Uint8Array([1, 1, 1, 1]); + const fullMask = rawBinaryToSparseMask(fullBinary, 2, 2); + const overlay = generateMaskOverlay( + [ + { maskId: "big", size: 4, binaryMask: fullMask, srcWidth: 2, srcHeight: 2 }, + { maskId: "small", size: 2, binaryMask: fullMask, srcWidth: 2, srcHeight: 2 }, + ], + 2, + 2 + ); + const buf = Buffer.from(overlay.data, "base64"); + const int32 = new Int32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); + // "small" is index 1 — should win at every pixel + for (let i = 0; i < 4; i++) { + assert.strictEqual(int32[i], 1, `pixel ${i} should be maskIndex 1 (small)`); + } + }); + + it("empty masks array returns all -1", () => { + const overlay = generateMaskOverlay([], 2, 2); + const buf = Buffer.from(overlay.data, "base64"); + const int32 = new Int32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); + for (let i = 0; i < 4; i++) { + assert.strictEqual(int32[i], -1); + } + }); + }); + + describe("addMaskAtPixel / getMaskIndicesAtPixel / removeMaskAtPixel", () => { + it("adds and reads mask index at pixel", () => { + const mask = createEmptyMask(2, 2); + addMaskAtPixel(mask, 0, 0, 0); + addMaskAtPixel(mask, 0, 0, 1); + assert.deepStrictEqual(getMaskIndicesAtPixel(mask, 0, 0), [0, 1]); + assert.deepStrictEqual(getMaskIndicesAtPixel(mask, 1, 1), []); + }); + it("removeMaskAtPixel removes index and cleans empty entries", () => { + const mask = createEmptyMask(2, 2); + addMaskAtPixel(mask, 0, 0, 0); + removeMaskAtPixel(mask, 0, 0, 0); + assert.deepStrictEqual(getMaskIndicesAtPixel(mask, 0, 0), []); + assert.strictEqual(Object.keys(mask.data).length, 0); + }); + }); +}); diff --git a/src/labelling-app/backend/src/services/masks.ts b/src/labelling-app/backend/src/services/masks.ts index 1f018436..d653d60b 100644 --- a/src/labelling-app/backend/src/services/masks.ts +++ b/src/labelling-app/backend/src/services/masks.ts @@ -1,4 +1,4 @@ -import { tableFromIPC } from "apache-arrow"; +import { tableFromArrays, tableFromIPC, tableToIPC } from "apache-arrow"; /** * Sparse 2D mask data: { rowIndex: { colIndex: maskIndices[] } } @@ -312,6 +312,122 @@ export const parseFeatherMask = ( } }; +/** + * Serialize a mask (raw binary + dimensions) to Feather format for ZIP export. + * Produces the same layout as upload: columns "mask" (binary), "width", "height" (int32). + */ +export const serializeMaskToFeather = ( + buffer: Buffer, + width: number, + height: number +): Buffer => { + const table = tableFromArrays({ + mask: [new Uint8Array(buffer)], + width: [width], + height: [height], + }); + const ipc = tableToIPC(table, "file"); + return Buffer.from(ipc); +}; + +/** Header size for .bin mask format: 4 bytes width + 4 bytes height (uint32 LE each). */ +const BIN_MASK_HEADER_SIZE = 8; + +/** + * Parse a .bin mask buffer (8-byte header: width uint32 LE, height uint32 LE, then raw mask bytes). + * Returns ParsedMask or null if invalid. + */ +export const parseBinMask = ( + buffer: Buffer, + baseName: string, + maskIndex: number = 0 +): ParsedMask | null => { + if (buffer.length < BIN_MASK_HEADER_SIZE) { + return null; + } + const width = buffer.readUInt32LE(0); + const height = buffer.readUInt32LE(4); + const expectedSize = width * height; + if (buffer.length !== BIN_MASK_HEADER_SIZE + expectedSize || width === 0 || height === 0) { + return null; + } + const maskData = new Uint8Array(buffer.subarray(BIN_MASK_HEADER_SIZE)); + const data = binaryMaskToSparse(maskData, width, height, maskIndex); + return { + baseName, + data, + width, + height, + maskIndex, + }; +}; + +/** + * Lightweight raw .bin mask — no SparseMaskData conversion. + * Use for zip upload when processing masks on-demand to avoid holding + * thousands of SparseMaskData objects in memory. + */ +export interface RawBinMask { + baseName: string; + width: number; + height: number; + /** Raw pixel bytes (0 or 1), no sparse conversion */ + binary: Buffer; + maskIndex: number; + /** Count of set pixels */ + size: number; +} + +/** + * Read .bin mask buffer: header (width, height) + raw bytes. No binaryMaskToSparse. + * Returns RawBinMask or null if invalid. + */ +export const readBinMaskRaw = ( + buffer: Buffer, + baseName: string, + maskIndex: number +): RawBinMask | null => { + if (buffer.length < BIN_MASK_HEADER_SIZE) return null; + const width = buffer.readUInt32LE(0); + const height = buffer.readUInt32LE(4); + const expectedSize = width * height; + if ( + buffer.length !== BIN_MASK_HEADER_SIZE + expectedSize || + width === 0 || + height === 0 + ) { + return null; + } + const body = buffer.subarray(BIN_MASK_HEADER_SIZE); + let size = 0; + for (let i = 0; i < body.length; i++) { + if (body[i]) size++; + } + return { + baseName, + width, + height, + binary: Buffer.from(body), + maskIndex, + size, + }; +}; + +/** + * Serialize a mask to .bin format for ZIP export: 8-byte header (width, height uint32 LE) + raw mask bytes. + */ +export const serializeMaskToBin = ( + buffer: Buffer, + width: number, + height: number +): Buffer => { + const out = Buffer.allocUnsafe(BIN_MASK_HEADER_SIZE + buffer.length); + out.writeUInt32LE(width, 0); + out.writeUInt32LE(height, 4); + buffer.copy(out, BIN_MASK_HEADER_SIZE, 0, buffer.length); + return out; +}; + /** * Serialize mask for storage in Firestore. */ @@ -510,16 +626,22 @@ export interface MaskMapDocument { * Uses indices instead of full UUIDs to reduce payload size significantly. * The maskIds array maps index -> maskId. * - * For a 1024x1024 image, using indices instead of UUIDs reduces size from - * ~37MB to ~4MB (still large but much more manageable). + * `data` is a base64-encoded little-endian Int32Array (row-major, one int32 + * per pixel). This avoids creating a 2M-element plain JS number[] on the + * backend heap (which would box every element and use ~80 MB for 1920×1080). + * The base64 string for the same overlay is ~11 MB — a single heap object. */ export interface MaskOverlay { width: number; height: number; /** Array of maskIds - index in this array corresponds to index in data */ maskIds: string[]; - /** Flattened row-major array: data[row * width + col] = maskIndex or -1 for no mask */ - data: number[]; + /** + * Base64-encoded little-endian Int32Array. + * data[row * width + col] = maskIndex (≥0) or -1 for no mask. + * Decode: Buffer.from(data, 'base64') → Int32Array + */ + data: string; } /** @@ -684,45 +806,61 @@ export const createMaskMapFromMasks = ( * Algorithm: * 1. Build a maskId -> index map * 2. Create arrays for mask index and size at each pixel, initialized to -1/Infinity - * 3. For each mask, iterate over its pixels + * 3. For each mask, iterate over its pixels (scaling coordinates if needed) * 4. If the mask's size is smaller than the current stored size, update both * - * @param masks - Array of { maskId, size, binaryMask (sparse format) } - * @param width - Image width - * @param height - Image height + * @param masks - Array of { maskId, size, binaryMask (sparse format), srcWidth, srcHeight } + * @param targetWidth - Target overlay width + * @param targetHeight - Target overlay height * @returns MaskOverlay structure with indices instead of maskIds */ export const generateMaskOverlay = ( - masks: Array<{ maskId: string; size: number; binaryMask: SparseBinaryMask }>, - width: number, - height: number + masks: Array<{ maskId: string; size: number; binaryMask: SparseBinaryMask; srcWidth: number; srcHeight: number }>, + targetWidth: number, + targetHeight: number ): MaskOverlay => { + console.log(`[generateMaskOverlay] Starting with ${masks.length} masks, target: ${targetWidth}x${targetHeight}`); + // Build maskId array and index lookup map const maskIds: string[] = masks.map(m => m.maskId); const maskIdToIndex = new Map(); maskIds.forEach((id, idx) => maskIdToIndex.set(id, idx)); // Initialize arrays: mask index at each pixel (-1 = no mask), and current smallest size - const totalPixels = Math.max(0, width * height); + const totalPixels = Math.max(0, targetWidth * targetHeight); const overlayData = new Int32Array(totalPixels); overlayData.fill(-1); const sizeAtPixel = new Float32Array(totalPixels); sizeAtPixel.fill(Number.POSITIVE_INFINITY); - // Process each mask - for (const { maskId, size, binaryMask } of masks) { + // Process each mask – iterate DESTINATION pixels and sample from source + // (nearest-neighbor). This guarantees no gaps when upscaling masks that are + // smaller than the target resolution. + for (const { maskId, size, binaryMask, srcWidth, srcHeight } of masks) { const maskIndex = maskIdToIndex.get(maskId); if (maskIndex === undefined) continue; - for (const [rowKey, cols] of Object.entries(binaryMask)) { - const row = parseInt(rowKey, 10); - if (row < 0 || row >= height) continue; + // Scale factors: target / source + const scaleX = targetWidth / srcWidth; + const scaleY = targetHeight / srcHeight; - for (const colKey of Object.keys(cols)) { - const col = parseInt(colKey, 10); - if (col < 0 || col >= width) continue; + const srcPixelCount = Object.values(binaryMask).reduce((sum, cols) => sum + Object.keys(cols).length, 0); + console.log(`[generateMaskOverlay] Mask ${maskIndex}: src=${srcWidth}x${srcHeight}, scale=(${scaleX.toFixed(4)}, ${scaleY.toFixed(4)}), srcPixels=${srcPixelCount}`); + + for (let targetRow = 0; targetRow < targetHeight; targetRow++) { + const srcRow = Math.floor(targetRow / scaleY); + if (srcRow < 0 || srcRow >= srcHeight) continue; + + // Skip entire row if no mask data for this source row + const rowData = binaryMask[String(srcRow)]; + if (!rowData) continue; - const idx = row * width + col; + for (let targetCol = 0; targetCol < targetWidth; targetCol++) { + const srcCol = Math.floor(targetCol / scaleX); + if (srcCol < 0 || srcCol >= srcWidth) continue; + if (!rowData[String(srcCol)]) continue; + + const idx = targetRow * targetWidth + targetCol; // If this mask is smaller than the current one at this pixel, replace it if (size < sizeAtPixel[idx]) { overlayData[idx] = maskIndex; @@ -732,11 +870,23 @@ export const generateMaskOverlay = ( } } + // Count non-empty pixels in overlay + let filledPixels = 0; + for (let i = 0; i < overlayData.length; i++) { + if (overlayData[i] >= 0) filledPixels++; + } + console.log(`[generateMaskOverlay] Complete: ${targetWidth}x${targetHeight}, totalPixels=${totalPixels}, filledPixels=${filledPixels}`); + + // Encode as base64 instead of converting to a plain number[]. + // Array.from(Int32Array) boxes 2M numbers onto the JS heap (~80 MB for 1920×1080). + // A base64 string for the same data is a single ~11 MB string object. + const dataBase64 = Buffer.from(overlayData.buffer).toString('base64'); + return { - width, - height, + width: targetWidth, + height: targetHeight, maskIds, - data: Array.from(overlayData), + data: dataBase64, }; }; @@ -767,15 +917,23 @@ const blendColors = (colors: string[]): string => { * Compute colorMap from maskLabels and mask binary data. * This rebuilds the entire colorMap from scratch. * + * When targetWidth/targetHeight are provided, coordinates are scaled from each + * mask's native resolution to the target resolution so the colorMap aligns with + * the displayed image (always resized to target on upload). + * * @param maskLabels - Dictionary of maskId -> labelId - * @param masks - Array of mask documents with binaryMask data + * @param masks - Array of mask documents with binaryMask data and source dimensions * @param labels - Project labels configuration - * @returns Computed colorMap + * @param targetWidth - Target width to scale coordinates to (e.g. 1920) + * @param targetHeight - Target height to scale coordinates to (e.g. 1080) + * @returns Computed colorMap in target coordinate space */ export const computeColorMap = ( maskLabels: MaskLabelsMap, - masks: Array<{ maskId: string; binaryMask: SparseBinaryMask }>, - labels: LabelsMap + masks: Array<{ maskId: string; binaryMask: SparseBinaryMask; srcWidth?: number; srcHeight?: number }>, + labels: LabelsMap, + targetWidth?: number, + targetHeight?: number ): SparseColorMap => { // Track colors at each pixel: { rowKey: { colKey: [color1, color2] } } const pixelColors: Record> = {}; @@ -784,7 +942,6 @@ export const computeColorMap = ( for (const mask of masks) { const labelId = maskLabels[mask.maskId]; if (!labelId) { - // Unlabeled mask - doesn't contribute to colorMap continue; } @@ -793,17 +950,34 @@ export const computeColorMap = ( continue; } - // Add this color to each pixel in the mask's binaryMask + const needsScale = targetWidth && targetHeight && mask.srcWidth && mask.srcHeight && + (mask.srcWidth !== targetWidth || mask.srcHeight !== targetHeight); + const scaleX = needsScale ? targetWidth / mask.srcWidth! : 1; + const scaleY = needsScale ? targetHeight / mask.srcHeight! : 1; + for (const [rowKey, cols] of Object.entries(mask.binaryMask)) { + const srcRow = parseInt(rowKey, 10); + + // When scaling, fill all target rows that map back to this source row + const tRowStart = needsScale ? Math.floor(srcRow * scaleY) : srcRow; + const tRowEnd = needsScale ? Math.floor((srcRow + 1) * scaleY) : srcRow + 1; + for (const colKey of Object.keys(cols)) { - if (!pixelColors[rowKey]) { - pixelColors[rowKey] = {}; - } - if (!pixelColors[rowKey][colKey]) { - pixelColors[rowKey][colKey] = []; - } - if (!pixelColors[rowKey][colKey].includes(color)) { - pixelColors[rowKey][colKey].push(color); + const srcCol = parseInt(colKey, 10); + + const tColStart = needsScale ? Math.floor(srcCol * scaleX) : srcCol; + const tColEnd = needsScale ? Math.floor((srcCol + 1) * scaleX) : srcCol + 1; + + for (let tr = tRowStart; tr < tRowEnd; tr++) { + const rk = String(tr); + if (!pixelColors[rk]) pixelColors[rk] = {}; + for (let tc = tColStart; tc < tColEnd; tc++) { + const ck = String(tc); + if (!pixelColors[rk][ck]) pixelColors[rk][ck] = []; + if (!pixelColors[rk][ck].includes(color)) { + pixelColors[rk][ck].push(color); + } + } } } } diff --git a/src/labelling-app/backend/src/services/storage.ts b/src/labelling-app/backend/src/services/storage.ts index 79490c8c..78b57051 100644 --- a/src/labelling-app/backend/src/services/storage.ts +++ b/src/labelling-app/backend/src/services/storage.ts @@ -2,6 +2,13 @@ import { storage } from "../firebase"; import { config } from "../config"; import { HttpError } from "../middleware/error"; import { colorMapCache, maskBufferCache, maskOverlayCache } from "./cache"; +import { + buildColorMapStoragePath, + buildMaskOverlayStoragePath, + buildMaskStoragePath, +} from "./storagePaths"; + +export { buildColorMapStoragePath, buildMaskOverlayStoragePath, buildMaskStoragePath } from "./storagePaths"; const bucket = storage.bucket(); @@ -93,27 +100,6 @@ export const downloadMaskBuffer = async (storagePath: string): Promise = return buffer; }; -/** - * Build storage path for a mask file - */ -export const buildMaskStoragePath = ( - projectId: string, - imageId: string, - maskId: string -): string => { - return `projects/${projectId}/images/${imageId}/masks/${maskId}.bin`; -}; - -/** - * Build storage path for a colorMap file - */ -export const buildColorMapStoragePath = ( - projectId: string, - maskMapId: string -): string => { - return `projects/${projectId}/maskmaps/${maskMapId}/colormap.json`; -}; - /** * Upload colorMap JSON to storage */ @@ -158,28 +144,18 @@ export const downloadColorMap = async ( // ============================================================================ /** - * MaskOverlay structure for storage - * Uses indices instead of full UUIDs to reduce payload size + * MaskOverlay structure for storage. + * data is a base64-encoded little-endian Int32Array (one int32 per pixel, + * value = maskIndex ≥ 0 or -1 for no mask). + * Old files stored data as number[] — those are converted on read. */ interface MaskOverlay { width: number; height: number; - /** Array of maskIds - index in this array corresponds to index in data */ maskIds: string[]; - /** Flattened row-major array: data[row * width + col] = maskIndex or -1 for no mask */ - data: number[]; + data: string; } -/** - * Build storage path for a maskOverlay file - */ -export const buildMaskOverlayStoragePath = ( - projectId: string, - maskMapId: string -): string => { - return `projects/${projectId}/maskmaps/${maskMapId}/maskoverlay.json`; -}; - /** * Upload maskOverlay JSON to storage */ @@ -234,10 +210,19 @@ export const downloadMaskOverlay = async ( console.log(`[storage] JSON string length: ${jsonString.length} chars`); const parsed = JSON.parse(jsonString); - console.log(`[storage] Parsed maskOverlay - width: ${parsed?.width}, height: ${parsed?.height}, maskIds: ${parsed?.maskIds?.length}, data length: ${parsed?.data?.length}`); + console.log(`[storage] Parsed maskOverlay - width: ${parsed?.width}, height: ${parsed?.height}, maskIds: ${parsed?.maskIds?.length}, data type: ${typeof parsed?.data}`); + + if (!parsed) return null; - if (parsed) { - maskOverlayCache.set(storagePath, parsed); + // Backward compatibility: old files stored data as a plain number[] rather + // than a base64-encoded Int32Array. Detect and convert on-the-fly so + // existing overlays continue to work after this change is deployed. + if (Array.isArray(parsed.data)) { + console.log(`[storage] Converting legacy number[] overlay to base64 (${parsed.data.length} elements)`); + const int32 = new Int32Array(parsed.data); + parsed.data = Buffer.from(int32.buffer).toString('base64'); } + + maskOverlayCache.set(storagePath, parsed); return parsed; }; diff --git a/src/labelling-app/backend/src/services/storagePaths.test.ts b/src/labelling-app/backend/src/services/storagePaths.test.ts new file mode 100644 index 00000000..6f31917a --- /dev/null +++ b/src/labelling-app/backend/src/services/storagePaths.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + buildMaskStoragePath, + buildColorMapStoragePath, + buildMaskOverlayStoragePath, +} from "./storagePaths"; + +describe("storagePaths", () => { + describe("buildMaskStoragePath", () => { + it("returns path projects/{projectId}/images/{imageId}/masks/{maskId}.bin", () => { + assert.strictEqual( + buildMaskStoragePath("proj1", "img1", "mask1"), + "projects/proj1/images/img1/masks/mask1.bin" + ); + assert.strictEqual( + buildMaskStoragePath("p", "i", "m"), + "projects/p/images/i/masks/m.bin" + ); + }); + }); + + describe("buildColorMapStoragePath", () => { + it("returns path projects/{projectId}/maskmaps/{maskMapId}/colormap.json", () => { + assert.strictEqual( + buildColorMapStoragePath("proj1", "map1"), + "projects/proj1/maskmaps/map1/colormap.json" + ); + }); + }); + + describe("buildMaskOverlayStoragePath", () => { + it("returns path projects/{projectId}/maskmaps/{maskMapId}/maskoverlay.json", () => { + assert.strictEqual( + buildMaskOverlayStoragePath("proj1", "map1"), + "projects/proj1/maskmaps/map1/maskoverlay.json" + ); + }); + }); +}); diff --git a/src/labelling-app/backend/src/services/storagePaths.ts b/src/labelling-app/backend/src/services/storagePaths.ts new file mode 100644 index 00000000..696d339a --- /dev/null +++ b/src/labelling-app/backend/src/services/storagePaths.ts @@ -0,0 +1,26 @@ +/** + * Pure storage path builders (no Firebase dependency). + * Used by storage.ts and testable without env. + */ + +export const buildMaskStoragePath = ( + projectId: string, + imageId: string, + maskId: string +): string => { + return `projects/${projectId}/images/${imageId}/masks/${maskId}.bin`; +}; + +export const buildColorMapStoragePath = ( + projectId: string, + maskMapId: string +): string => { + return `projects/${projectId}/maskmaps/${maskMapId}/colormap.json`; +}; + +export const buildMaskOverlayStoragePath = ( + projectId: string, + maskMapId: string +): string => { + return `projects/${projectId}/maskmaps/${maskMapId}/maskoverlay.json`; +}; diff --git a/src/labelling-app/frontend/e2e/interactive-map-overlay.spec.ts b/src/labelling-app/frontend/e2e/interactive-map-overlay.spec.ts new file mode 100644 index 00000000..d17f2e89 --- /dev/null +++ b/src/labelling-app/frontend/e2e/interactive-map-overlay.spec.ts @@ -0,0 +1,809 @@ +/** + * InteractiveMapOverlay — integration tests. + * + * Tests cover: + * 1. Component renders correctly in the DOM (two-layer: img + canvas) + * 2. Overlay canvas draws mask colours at correct positions + * 3. getMaskAtPosition hit-testing returns correct mask IDs + * 4. Hover + click interactions work via mouse events + * 5. Handles missing data gracefully (no crash on null maskOverlay / colorMap) + * 6. DPR-aware redraw after emulated zoom + * 7. ResizeObserver redraws on container resize + */ + +import { test, expect } from '@playwright/test'; +import type { CDPSession } from '@playwright/test'; + +/* ──────────────────────────────────────────────────────────────────── */ +/* Constants */ +/* ──────────────────────────────────────────────────────────────────── */ + +const IMG_W = 100; +const IMG_H = 80; +const MASK_IDS = ['mask-A', 'mask-B']; +const OVERLAY_ALPHA = 130; + +/** Build flattened mask overlay array (same layout as mask-hover-accuracy tests). + * mask-A occupies top-left quadrant (x 0-49, y 0-39) + * mask-B occupies top-right quadrant (x 50-99, y 0-39) + * bottom half has no mask (-1) + */ +function buildTestOverlay(): number[] { + const data = new Array(IMG_W * IMG_H).fill(-1); + for (let y = 0; y < 40; y++) { + for (let x = 0; x < 50; x++) data[y * IMG_W + x] = 0; + for (let x = 50; x < IMG_W; x++) data[y * IMG_W + x] = 1; + } + return data; +} + +/** Build a colorMap with mask-A as red, mask-B as blue (matching mask regions). */ +function buildTestColorMap(): Record> { + const cm: Record> = {}; + for (let y = 0; y < 40; y++) { + cm[String(y)] = {}; + for (let x = 0; x < 50; x++) cm[String(y)][String(x)] = '#FF0000'; + for (let x = 50; x < IMG_W; x++) cm[String(y)][String(x)] = '#0000FF'; + } + return cm; +} + +/* ──────────────────────────────────────────────────────────────────── */ +/* Helpers */ +/* ──────────────────────────────────────────────────────────────────── */ + +async function loadApp(page: import('@playwright/test').Page) { + await page.goto('/'); + await expect(page.locator('#root')).not.toBeEmpty(); +} + +async function setDPR(cdp: CDPSession, dpr: number, viewportWidth = 1280, viewportHeight = 720) { + const cssW = Math.round(viewportWidth / dpr); + const cssH = Math.round(viewportHeight / dpr); + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: cssW, + height: cssH, + deviceScaleFactor: dpr, + mobile: false, + }); +} + +async function clearDPR(cdp: CDPSession) { + await cdp.send('Emulation.clearDeviceMetricsOverride'); +} + +/** + * Inject a standalone InteractiveMapOverlay-like DOM structure into the page + * with a visible img + overlay canvas, matching the real component's layout. + * Returns the frame element ID for later queries. + */ +async function injectOverlayDOM( + page: import('@playwright/test').Page, + opts: { + frameId?: string; + frameWidth?: number; + frameHeight?: number; + imgSrc?: string; + } = {} +) { + const { + frameId = '__pw_overlay_test', + frameWidth = 400, + frameHeight = 320, + imgSrc = '', + } = opts; + + await page.evaluate( + ({ frameId, frameWidth, frameHeight, imgSrc }) => { + // Remove previous instance if any + document.getElementById(frameId)?.remove(); + + const frame = document.createElement('div'); + frame.id = frameId; + frame.setAttribute('data-testid', 'interactive-map-overlay'); + frame.style.cssText = ` + width: ${frameWidth}px; height: ${frameHeight}px; + position: relative; overflow: hidden; + background: #1a1a1a; + `; + + if (imgSrc) { + const img = document.createElement('img'); + img.src = imgSrc; + img.style.cssText = 'display:block; width:100%; height:100%; object-fit:fill;'; + frame.appendChild(img); + } + + const canvas = document.createElement('canvas'); + canvas.setAttribute('data-testid', 'interactive-map-canvas'); + canvas.style.cssText = ` + position: absolute; inset: 0; + width: 100%; height: 100%; + pointer-events: none; + `; + frame.appendChild(canvas); + + document.body.appendChild(frame); + }, + { frameId, frameWidth, frameHeight, imgSrc } + ); + + return frameId; +} + +/** + * Draw mask overlay onto the injected canvas using the same drawOverlay logic + * as InteractiveMapOverlay (two-layer approach: mask-only overlay). + */ +async function drawMaskOverlay( + page: import('@playwright/test').Page, + frameId: string, + colorMap: Record>, + srcWidth: number, + srcHeight: number, + alpha = OVERLAY_ALPHA +) { + await page.evaluate( + ({ frameId, colorMap, srcWidth, srcHeight, alpha }) => { + const frame = document.getElementById(frameId)!; + const canvas = frame.querySelector('canvas')!; + const ctx = canvas.getContext('2d')!; + + const rect = frame.getBoundingClientRect(); + const cssW = rect.width; + const cssH = rect.height; + const dpr = window.devicePixelRatio || 1; + + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + + const intW = Math.round(cssW); + const intH = Math.round(cssH); + const imageData = ctx.createImageData(intW, intH); + const data = imageData.data; + + for (const [rowKey, cols] of Object.entries(colorMap)) { + const row = Number(rowKey); + if (row < 0 || row >= srcHeight) continue; + const destY = Math.floor((row / srcHeight) * intH); + + for (const [colKey, hex] of Object.entries(cols)) { + const col = Number(colKey); + if (col < 0 || col >= srcWidth) continue; + const destX = Math.floor((col / srcWidth) * intW); + const dest = (destY * intW + destX) * 4; + const s = hex.replace('#', ''); + data[dest] = parseInt(s.substring(0, 2), 16); + data[dest + 1] = parseInt(s.substring(2, 4), 16); + data[dest + 2] = parseInt(s.substring(4, 6), 16); + data[dest + 3] = alpha; + } + } + + ctx.putImageData(imageData, 0, 0); + }, + { frameId, colorMap, srcWidth, srcHeight, alpha } + ); +} + +/* ──────────────────────────────────────────────────────────────────── */ +/* Tests */ +/* ──────────────────────────────────────────────────────────────────── */ + +test.describe('InteractiveMapOverlay', () => { + + /* ──────── 1. DOM structure renders correctly ──────── */ + + test('renders frame with img and canvas elements', async ({ page }) => { + await loadApp(page); + const frameId = await injectOverlayDOM(page); + + const frame = page.locator(`#${frameId}`); + await expect(frame).toBeVisible(); + await expect(frame.locator('canvas')).toBeAttached(); + + // Frame has correct dimensions + const box = await frame.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.width).toBeCloseTo(400, 0); + expect(box!.height).toBeCloseTo(320, 0); + }); + + test('renders fallback when no image URL', async ({ page }) => { + await loadApp(page); + + // Inject frame WITHOUT img + await page.evaluate(() => { + const frame = document.createElement('div'); + frame.id = '__pw_no_img'; + frame.setAttribute('data-testid', 'interactive-map-overlay'); + frame.style.cssText = 'width:400px; height:320px; position:relative;'; + + const fallback = document.createElement('div'); + fallback.className = 'interactive-map-overlay-fallback'; + fallback.textContent = 'No image URL'; + frame.appendChild(fallback); + + const canvas = document.createElement('canvas'); + canvas.setAttribute('data-testid', 'interactive-map-canvas'); + frame.appendChild(canvas); + + document.body.appendChild(frame); + }); + + await expect(page.locator('#__pw_no_img')).toBeVisible(); + await expect(page.locator('.interactive-map-overlay-fallback')).toContainText('No image URL'); + }); + + /* ──────── 2. Mask overlay draws correct colours ──────── */ + + test('mask overlay canvas draws red and blue regions', async ({ page }) => { + await loadApp(page); + const frameId = await injectOverlayDOM(page); + const colorMap = buildTestColorMap(); + + await drawMaskOverlay(page, frameId, colorMap, IMG_W, IMG_H); + + // Read pixels from the canvas + const pixels = await page.evaluate( + ({ frameId }) => { + const frame = document.getElementById(frameId)!; + const canvas = frame.querySelector('canvas')!; + const ctx = canvas.getContext('2d')!; + + const readPixel = (rx: number, ry: number) => { + const px = Math.floor(rx * canvas.width); + const py = Math.floor(ry * canvas.height); + const d = ctx.getImageData(px, py, 1, 1).data; + return { r: d[0], g: d[1], b: d[2], a: d[3] }; + }; + + return { + // Top-left quadrant (mask-A = red) + topLeft: readPixel(0.25, 0.25), + // Top-right quadrant (mask-B = blue) + topRight: readPixel(0.75, 0.25), + // Bottom centre (no mask = transparent) + bottom: readPixel(0.5, 0.75), + }; + }, + { frameId } + ); + + // mask-A region should be red with correct alpha + expect(pixels.topLeft.r).toBeGreaterThan(200); + expect(pixels.topLeft.b).toBeLessThan(50); + expect(pixels.topLeft.a).toBe(OVERLAY_ALPHA); + + // mask-B region should be blue + expect(pixels.topRight.b).toBeGreaterThan(200); + expect(pixels.topRight.r).toBeLessThan(50); + expect(pixels.topRight.a).toBe(OVERLAY_ALPHA); + + // Bottom region should be transparent (no mask) + expect(pixels.bottom.a).toBe(0); + }); + + test('overlay renders nothing when colorMap is empty', async ({ page }) => { + await loadApp(page); + const frameId = await injectOverlayDOM(page); + + // Draw with empty colorMap + await drawMaskOverlay(page, frameId, {}, IMG_W, IMG_H); + + const pixel = await page.evaluate( + ({ frameId }) => { + const frame = document.getElementById(frameId)!; + const canvas = frame.querySelector('canvas')!; + const ctx = canvas.getContext('2d')!; + if (canvas.width === 0 || canvas.height === 0) return { a: 0 }; + const d = ctx.getImageData( + Math.floor(canvas.width / 2), + Math.floor(canvas.height / 2), + 1, 1 + ).data; + return { a: d[3] }; + }, + { frameId } + ); + + expect(pixel.a).toBe(0); + }); + + /* ──────── 3. getMaskAtPosition hit-testing ──────── */ + + test('hit-testing returns correct mask IDs at known positions', async ({ page }) => { + await loadApp(page); + const overlayData = buildTestOverlay(); + + const result = await page.evaluate( + ({ IMG_W, IMG_H, MASK_IDS, overlayData }) => { + const frame = document.createElement('div'); + frame.style.cssText = 'width:400px; height:320px; position:relative;'; + document.body.appendChild(frame); + + const maskOverlay = { width: IMG_W, height: IMG_H, maskIds: MASK_IDS, data: overlayData }; + + const getMaskAtPosition = (clientX: number, clientY: number): string | null => { + const rect = frame.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + const relativeX = (clientX - rect.left) / rect.width; + const relativeY = (clientY - rect.top) / rect.height; + if (relativeX < 0 || relativeX > 1 || relativeY < 0 || relativeY > 1) return null; + const col = Math.floor(relativeX * maskOverlay.width); + const row = Math.floor(relativeY * maskOverlay.height); + const idx = row * maskOverlay.width + col; + const maskIndex = maskOverlay.data[idx]; + if (maskIndex === undefined || maskIndex < 0) return null; + return maskOverlay.maskIds[maskIndex] ?? null; + }; + + const rect = frame.getBoundingClientRect(); + const results = { + topLeft: getMaskAtPosition(rect.left + rect.width * 0.25, rect.top + rect.height * 0.25), + topRight: getMaskAtPosition(rect.left + rect.width * 0.75, rect.top + rect.height * 0.25), + bottom: getMaskAtPosition(rect.left + rect.width * 0.5, rect.top + rect.height * 0.75), + outside: getMaskAtPosition(rect.left - 10, rect.top - 10), + topLeftEdge: getMaskAtPosition(rect.left + 1, rect.top + 1), + topRightEdge: getMaskAtPosition(rect.left + rect.width - 1, rect.top + 1), + // Boundary: x=50 maps to mask-B (0-indexed, mask-A is 0-49) + boundary: getMaskAtPosition( + rect.left + (50 / IMG_W) * rect.width + 1, + rect.top + rect.height * 0.25 + ), + }; + + document.body.removeChild(frame); + return results; + }, + { IMG_W, IMG_H, MASK_IDS, overlayData } + ); + + expect(result.topLeft).toBe('mask-A'); + expect(result.topRight).toBe('mask-B'); + expect(result.bottom).toBeNull(); + expect(result.outside).toBeNull(); + expect(result.topLeftEdge).toBe('mask-A'); + expect(result.topRightEdge).toBe('mask-B'); + expect(result.boundary).toBe('mask-B'); + }); + + /* ──────── 4. Mouse interaction simulation ──────── */ + + test('mouse events fire on interactive overlay', async ({ page }) => { + await loadApp(page); + + // Inject an interactive overlay with high z-index so it sits above the app UI + await page.evaluate(() => { + const frame = document.createElement('div'); + frame.id = '__pw_mouse_test'; + frame.style.cssText = ` + width: 400px; height: 320px; position: fixed; + top: 50px; left: 50px; z-index: 99999; + cursor: crosshair; background: #333; + `; + document.body.appendChild(frame); + + const canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; pointer-events:none;'; + frame.appendChild(canvas); + + const recorded: string[] = []; + frame.addEventListener('mousemove', () => recorded.push('mousemove')); + frame.addEventListener('mouseleave', () => recorded.push('mouseleave')); + frame.addEventListener('click', () => recorded.push('click')); + + (window as unknown as Record).__pw_events = recorded; + }); + + const frame = page.locator('#__pw_mouse_test'); + const box = await frame.boundingBox(); + expect(box).toBeTruthy(); + + // Move mouse to centre of frame + await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); + await page.waitForTimeout(100); + + // Click + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); + await page.waitForTimeout(100); + + // Move mouse well outside the frame + await page.mouse.move(0, 0); + await page.waitForTimeout(100); + + const recorded = await page.evaluate( + () => (window as unknown as Record).__pw_events as string[] + ); + + expect(recorded).toContain('mousemove'); + expect(recorded).toContain('click'); + expect(recorded).toContain('mouseleave'); + }); + + test('canvas does not intercept pointer events', async ({ page }) => { + await loadApp(page); + const frameId = await injectOverlayDOM(page); + + // Verify the canvas has pointer-events: none + const canvasPointerEvents = await page.evaluate( + ({ frameId }) => { + const frame = document.getElementById(frameId)!; + const canvas = frame.querySelector('canvas')!; + return getComputedStyle(canvas).pointerEvents; + }, + { frameId } + ); + + expect(canvasPointerEvents).toBe('none'); + }); + + /* ──────── 5. Graceful handling of missing data ──────── */ + + test('no crash when maskOverlay is null', async ({ page }) => { + await loadApp(page); + + const result = await page.evaluate(() => { + // Simulate getMaskAtPosition with null maskOverlay + const getMaskAtPosition = ( + maskOverlay: null, + _clientX: number, + _clientY: number + ): string | null => { + if (!maskOverlay) return null; + return null; + }; + + return { + result: getMaskAtPosition(null, 100, 100), + noError: true, + }; + }); + + expect(result.noError).toBe(true); + expect(result.result).toBeNull(); + }); + + test('no crash when colorMap is null — canvas stays clear', async ({ page }) => { + await loadApp(page); + const frameId = await injectOverlayDOM(page); + + // Draw with null-like empty colorMap (simulating null check) + await page.evaluate( + ({ frameId }) => { + const frame = document.getElementById(frameId)!; + const canvas = frame.querySelector('canvas')!; + const ctx = canvas.getContext('2d')!; + const rect = frame.getBoundingClientRect(); + canvas.width = Math.round(rect.width); + canvas.height = Math.round(rect.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Don't draw anything — simulates null colorMap path + }, + { frameId } + ); + + const pixel = await page.evaluate( + ({ frameId }) => { + const canvas = document.getElementById(frameId)!.querySelector('canvas')!; + const ctx = canvas.getContext('2d')!; + if (canvas.width === 0) return { a: 0 }; + const d = ctx.getImageData(canvas.width / 2, canvas.height / 2, 1, 1).data; + return { a: d[3] }; + }, + { frameId } + ); + + expect(pixel.a).toBe(0); + }); + + /* ──────── 6. DPR-aware redraw ──────── */ + + for (const dpr of [1.5, 2]) { + test(`overlay redraws correctly at DPR ${dpr}`, async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + // Set DPR FIRST, then load app and inject DOM at that DPR + await setDPR(cdp, dpr); + await loadApp(page); + + const frameId = await injectOverlayDOM(page); + const colorMap = buildTestColorMap(); + await drawMaskOverlay(page, frameId, colorMap, IMG_W, IMG_H); + + const result = await page.evaluate( + ({ frameId, OVERLAY_ALPHA }) => { + const frame = document.getElementById(frameId)!; + const canvas = frame.querySelector('canvas')!; + const ctx = canvas.getContext('2d')!; + + // putImageData ignores transforms, so read at CSS-pixel positions + // (the ImageData was created at intW x intH = CSS pixel dimensions) + const intW = Math.round(frame.getBoundingClientRect().width); + const intH = Math.round(frame.getBoundingClientRect().height); + + const readPixel = (rx: number, ry: number) => { + const px = Math.floor(rx * intW); + const py = Math.floor(ry * intH); + // Clamp to ImageData bounds + if (px >= intW || py >= intH) return { r: 0, g: 0, b: 0, a: 0 }; + const d = ctx.getImageData(px, py, 1, 1).data; + return { r: d[0], g: d[1], b: d[2], a: d[3] }; + }; + + return { + maskA: readPixel(0.25, 0.25), + maskB: readPixel(0.75, 0.25), + bg: readPixel(0.5, 0.75), + dpr: window.devicePixelRatio, + canvasW: canvas.width, + canvasH: canvas.height, + intW, + intH, + }; + }, + { frameId, OVERLAY_ALPHA } + ); + + expect(result.dpr).toBeCloseTo(dpr, 1); + // Canvas bitmap should be larger than CSS pixels (scaled by DPR) + expect(result.canvasW).toBeGreaterThan(result.intW); + + // Mask A (red) still renders correctly + expect(result.maskA.r).toBeGreaterThan(200); + expect(result.maskA.a).toBe(OVERLAY_ALPHA); + // Mask B (blue) still renders correctly + expect(result.maskB.b).toBeGreaterThan(200); + expect(result.maskB.a).toBe(OVERLAY_ALPHA); + // Background still transparent + expect(result.bg.a).toBe(0); + + await clearDPR(cdp); + }); + } + + /* ──────── 7. ResizeObserver redraws on container resize ──────── */ + + test('overlay redraws after container resize', async ({ page }) => { + await loadApp(page); + const frameId = await injectOverlayDOM(page, { frameWidth: 400, frameHeight: 320 }); + const colorMap = buildTestColorMap(); + await drawMaskOverlay(page, frameId, colorMap, IMG_W, IMG_H); + + // Read initial canvas dimensions + const before = await page.evaluate( + ({ frameId }) => { + const canvas = document.getElementById(frameId)!.querySelector('canvas')!; + return { w: canvas.width, h: canvas.height }; + }, + { frameId } + ); + + // Resize the container + await page.evaluate( + ({ frameId }) => { + const frame = document.getElementById(frameId)!; + frame.style.width = '600px'; + frame.style.height = '480px'; + }, + { frameId } + ); + + await page.waitForTimeout(100); + + // Redraw (simulating what ResizeObserver would trigger) + await drawMaskOverlay(page, frameId, colorMap, IMG_W, IMG_H); + + const after = await page.evaluate( + ({ frameId }) => { + const canvas = document.getElementById(frameId)!.querySelector('canvas')!; + const ctx = canvas.getContext('2d')!; + + const readPixel = (rx: number, ry: number) => { + const px = Math.floor(rx * canvas.width); + const py = Math.floor(ry * canvas.height); + const d = ctx.getImageData(px, py, 1, 1).data; + return { r: d[0], g: d[1], b: d[2], a: d[3] }; + }; + + return { + w: canvas.width, + h: canvas.height, + maskA: readPixel(0.25, 0.25), + maskB: readPixel(0.75, 0.25), + }; + }, + { frameId } + ); + + // Canvas should have grown + expect(after.w).toBeGreaterThan(before.w); + expect(after.h).toBeGreaterThan(before.h); + + // Mask overlay still draws correctly after resize + expect(after.maskA.r).toBeGreaterThan(200); + expect(after.maskA.a).toBe(OVERLAY_ALPHA); + expect(after.maskB.b).toBeGreaterThan(200); + expect(after.maskB.a).toBe(OVERLAY_ALPHA); + }); + + /* ──────── 8. Hit-testing accuracy across DPR levels ──────── */ + + for (const dpr of [0.75, 1, 1.25, 1.5, 2]) { + test(`hit-testing correct at DPR ${dpr}`, async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + await setDPR(cdp, dpr); + await loadApp(page); + + const overlayData = buildTestOverlay(); + + const result = await page.evaluate( + ({ IMG_W, IMG_H, MASK_IDS, overlayData }) => { + const frame = document.createElement('div'); + frame.style.cssText = 'width:400px; height:320px; position:relative;'; + document.body.appendChild(frame); + + const maskOverlay = { width: IMG_W, height: IMG_H, maskIds: MASK_IDS, data: overlayData }; + + const getMaskAtPosition = (clientX: number, clientY: number): string | null => { + const rect = frame.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + const relativeX = (clientX - rect.left) / rect.width; + const relativeY = (clientY - rect.top) / rect.height; + if (relativeX < 0 || relativeX > 1 || relativeY < 0 || relativeY > 1) return null; + const col = Math.floor(relativeX * maskOverlay.width); + const row = Math.floor(relativeY * maskOverlay.height); + const idx = row * maskOverlay.width + col; + const maskIndex = maskOverlay.data[idx]; + if (maskIndex === undefined || maskIndex < 0) return null; + return maskOverlay.maskIds[maskIndex] ?? null; + }; + + const rect = frame.getBoundingClientRect(); + const results = { + topLeft: getMaskAtPosition(rect.left + rect.width * 0.25, rect.top + rect.height * 0.25), + topRight: getMaskAtPosition(rect.left + rect.width * 0.75, rect.top + rect.height * 0.25), + bottom: getMaskAtPosition(rect.left + rect.width * 0.5, rect.top + rect.height * 0.75), + outside: getMaskAtPosition(rect.left - 10, rect.top - 10), + dpr: window.devicePixelRatio, + }; + + document.body.removeChild(frame); + return results; + }, + { IMG_W, IMG_H, MASK_IDS, overlayData } + ); + + expect(result.topLeft).toBe('mask-A'); + expect(result.topRight).toBe('mask-B'); + expect(result.bottom).toBeNull(); + expect(result.outside).toBeNull(); + expect(result.dpr).toBeCloseTo(dpr, 1); + + await clearDPR(cdp); + }); + } + + /* ──────── 9. Two-layer rendering: img visible, canvas on top ──────── */ + + test('image element is visible (not hidden) in two-layer mode', async ({ page }) => { + await loadApp(page); + + await page.evaluate(() => { + const frame = document.createElement('div'); + frame.id = '__pw_twolayer'; + frame.style.cssText = 'width:400px; height:320px; position:relative;'; + + const img = document.createElement('img'); + img.className = 'interactive-map-overlay-image'; + img.style.cssText = 'display:block; width:100%; height:100%; object-fit:fill;'; + // Use a data: URL to avoid cross-origin issues + img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + frame.appendChild(img); + + const canvas = document.createElement('canvas'); + canvas.className = 'interactive-map-overlay-canvas'; + canvas.style.cssText = 'position:absolute; inset:0; width:100%; height:100%; pointer-events:none;'; + frame.appendChild(canvas); + + document.body.appendChild(frame); + }); + + const img = page.locator('#__pw_twolayer img'); + await expect(img).toBeVisible(); + + // Verify the image is NOT hidden (no visibility:hidden) + const visibility = await img.evaluate((el) => getComputedStyle(el).visibility); + expect(visibility).toBe('visible'); + }); + + /* ──────── 10. Highlight rendering ──────── */ + + test('highlight overlay draws on top of base colorMap', async ({ page }) => { + await loadApp(page); + const frameId = await injectOverlayDOM(page); + const overlayData = buildTestOverlay(); + + // Draw base colorMap, then apply highlight to mask-A + const result = await page.evaluate( + ({ frameId, IMG_W, IMG_H, overlayData, OVERLAY_ALPHA }) => { + const frame = document.getElementById(frameId)!; + const canvas = frame.querySelector('canvas')!; + const ctx = canvas.getContext('2d')!; + const rect = frame.getBoundingClientRect(); + const intW = Math.round(rect.width); + const intH = Math.round(rect.height); + const dpr = window.devicePixelRatio || 1; + + canvas.width = Math.round(intW * dpr); + canvas.height = Math.round(intH * dpr); + canvas.style.width = `${intW}px`; + canvas.style.height = `${intH}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, intW, intH); + + const imageData = ctx.createImageData(intW, intH); + const data = imageData.data; + + // Draw base colorMap (mask-A red, mask-B blue) + for (let y = 0; y < 40; y++) { + const destY = Math.floor((y / IMG_H) * intH); + for (let x = 0; x < 50; x++) { + const destX = Math.floor((x / IMG_W) * intW); + const dest = (destY * intW + destX) * 4; + data[dest] = 255; data[dest + 1] = 0; data[dest + 2] = 0; data[dest + 3] = OVERLAY_ALPHA; + } + for (let x = 50; x < IMG_W; x++) { + const destX = Math.floor((x / IMG_W) * intW); + const dest = (destY * intW + destX) * 4; + data[dest] = 0; data[dest + 1] = 0; data[dest + 2] = 255; data[dest + 3] = OVERLAY_ALPHA; + } + } + + // Now apply highlight to mask-A (green, full alpha) + const highlightIndex = 0; // mask-A + for (let i = 0; i < overlayData.length; i++) { + if (overlayData[i] !== highlightIndex) continue; + const srcY = Math.floor(i / IMG_W); + const srcX = i % IMG_W; + const destX = Math.floor((srcX / IMG_W) * intW); + const destY = Math.floor((srcY / IMG_H) * intH); + if (destX >= intW || destY >= intH) continue; + const dest = (destY * intW + destX) * 4; + data[dest] = 0; + data[dest + 1] = 255; + data[dest + 2] = 0; + data[dest + 3] = 255; + } + + ctx.putImageData(imageData, 0, 0); + + // Read back + const readPixel = (rx: number, ry: number) => { + const px = Math.floor(rx * canvas.width); + const py = Math.floor(ry * canvas.height); + const d = ctx.getImageData(px, py, 1, 1).data; + return { r: d[0], g: d[1], b: d[2], a: d[3] }; + }; + + return { + // mask-A should now be green (highlighted) + maskA: readPixel(0.25, 0.25), + // mask-B should still be blue (not highlighted) + maskB: readPixel(0.75, 0.25), + }; + }, + { frameId, IMG_W, IMG_H, overlayData, OVERLAY_ALPHA } + ); + + // mask-A should be green (highlight overrides base) + expect(result.maskA.g).toBe(255); + expect(result.maskA.a).toBe(255); + // mask-B should still be blue + expect(result.maskB.b).toBeGreaterThan(200); + expect(result.maskB.a).toBe(OVERLAY_ALPHA); + }); +}); diff --git a/src/labelling-app/frontend/e2e/mask-hover-accuracy.spec.ts b/src/labelling-app/frontend/e2e/mask-hover-accuracy.spec.ts new file mode 100644 index 00000000..b1e4bd9e --- /dev/null +++ b/src/labelling-app/frontend/e2e/mask-hover-accuracy.spec.ts @@ -0,0 +1,446 @@ +/** + * Mask-hover accuracy tests at multiple zoom / DPR levels. + * + * These tests exercise the same coordinate-mapping logic used by MaskCanvas + * and InteractiveMapOverlay (`getMaskAtPosition` / getBoundingClientRect) + * and verify correct mask ID at known positions — at default and fractional + * DPR (e.g. Ctrl+scroll). + * + * Test levels: + * 1. Coordinate math only — pure JS in page.evaluate + * 2. Canvas pixel readback — verifies rendered mask colours + * 3. CDP DPR emulation — repeats at 0.75×, 1.0×, 1.25×, 1.5×, 2.0× + * 4. Viewport size consistency + */ + +import { test, expect } from '@playwright/test'; +import type { CDPSession } from '@playwright/test'; + +/* ──────────────────────────────────────────────────────────────────── */ +/* Constants */ +/* ──────────────────────────────────────────────────────────────────── */ + +/** Image natural size used across all tests. */ +const IMG_W = 100; +const IMG_H = 80; + +/** Mask regions in the test overlay. + * + * ┌───────────┬───────────┐ + * │ mask-A │ mask-B │ + * │ (0-49, │ (50-99, │ + * │ 0-39) │ 0-39) │ + * ├───────────┴───────────┤ + * │ (no mask) │ + * │ (y 40-79) │ + * └───────────────────────┘ + */ +const MASK_IDS = ['mask-A', 'mask-B']; + +/** Build flattened mask overlay array. */ +function buildTestOverlay(): number[] { + const data = new Array(IMG_W * IMG_H).fill(-1); + for (let y = 0; y < 40; y++) { + for (let x = 0; x < 50; x++) data[y * IMG_W + x] = 0; // mask-A + for (let x = 50; x < IMG_W; x++) data[y * IMG_W + x] = 1; // mask-B + } + return data; +} + +/** Colours for the two masks (matching what applyMaskToBuffer would produce). */ +const MASK_A_COLOR = { r: 255, g: 0, b: 0 }; // red +const MASK_B_COLOR = { r: 0, g: 0, b: 255 }; // blue +const BG_COLOR = { r: 200, g: 200, b: 200 }; // grey background + +/* ──────────────────────────────────────────────────────────────────── */ +/* Helpers */ +/* ──────────────────────────────────────────────────────────────────── */ + +async function loadApp(page: import('@playwright/test').Page) { + await page.goto('/'); + await expect(page.locator('#root')).not.toBeEmpty(); +} + +/** Change the device-pixel-ratio via Chrome DevTools Protocol. */ +async function setDPR(cdp: CDPSession, dpr: number, viewportWidth = 1280, viewportHeight = 720) { + // Viewport in CSS pixels at this DPR + const cssW = Math.round(viewportWidth / dpr); + const cssH = Math.round(viewportHeight / dpr); + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: cssW, + height: cssH, + deviceScaleFactor: dpr, + mobile: false, + }); +} + +async function clearDPR(cdp: CDPSession) { + await cdp.send('Emulation.clearDeviceMetricsOverride'); +} + +/* ──────────────────────────────────────────────────────────────────── */ +/* Tests */ +/* ──────────────────────────────────────────────────────────────────── */ + +test.describe('Mask hover accuracy', () => { + + /* ──────── 1. Coordinate math correctness ──────── */ + + const dprValues = [0.75, 1, 1.25, 1.5, 2]; + + for (const dpr of dprValues) { + test(`getMaskAtPosition returns correct mask at DPR ${dpr}`, async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + await setDPR(cdp, dpr); + await loadApp(page); + + const overlayData = buildTestOverlay(); + + const result = await page.evaluate( + ({ IMG_W, IMG_H, MASK_IDS, overlayData }) => { + // Build DOM — a frame with known CSS size + const frame = document.createElement('div'); + frame.id = '__pw_coord_test'; + frame.style.cssText = ` + width: 400px; height: 320px; + position: relative; + `; + document.body.appendChild(frame); + + const maskOverlay = { + width: IMG_W, + height: IMG_H, + maskIds: MASK_IDS, + data: overlayData, + }; + + // Same logic as MaskCanvas.getMaskAtPosition + const getMaskAtPosition = (clientX: number, clientY: number): string | null => { + const rect = frame.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + const relativeX = (clientX - rect.left) / rect.width; + const relativeY = (clientY - rect.top) / rect.height; + if (relativeX < 0 || relativeX > 1 || relativeY < 0 || relativeY > 1) return null; + const col = Math.floor(relativeX * maskOverlay.width); + const row = Math.floor(relativeY * maskOverlay.height); + const idx = row * maskOverlay.width + col; + const maskIndex = maskOverlay.data[idx]; + if (maskIndex === undefined || maskIndex < 0) return null; + return maskOverlay.maskIds[maskIndex] ?? null; + }; + + const rect = frame.getBoundingClientRect(); + + // Sample points — centres of each quadrant and the bottom half + const results = { + topLeftCenter: getMaskAtPosition( + rect.left + rect.width * 0.25, + rect.top + rect.height * 0.25, + ), + topRightCenter: getMaskAtPosition( + rect.left + rect.width * 0.75, + rect.top + rect.height * 0.25, + ), + bottomCenter: getMaskAtPosition( + rect.left + rect.width * 0.50, + rect.top + rect.height * 0.75, + ), + // Edge cases: just inside mask-A (top-left pixel) + topLeftEdge: getMaskAtPosition( + rect.left + 1, + rect.top + 1, + ), + // Just inside mask-B (top-right pixel) + topRightEdge: getMaskAtPosition( + rect.left + rect.width - 1, + rect.top + 1, + ), + // Outside the frame entirely + outside: getMaskAtPosition( + rect.left - 10, + rect.top - 10, + ), + dpr: window.devicePixelRatio, + frameRect: { w: rect.width, h: rect.height, l: rect.left, t: rect.top }, + }; + + document.body.removeChild(frame); + return results; + }, + { IMG_W, IMG_H, MASK_IDS, overlayData }, + ); + + expect(result.topLeftCenter).toBe('mask-A'); + expect(result.topRightCenter).toBe('mask-B'); + expect(result.bottomCenter).toBeNull(); // bottom half has no mask + expect(result.topLeftEdge).toBe('mask-A'); + expect(result.topRightEdge).toBe('mask-B'); + expect(result.outside).toBeNull(); + // Verify the DPR was actually applied + expect(result.dpr).toBeCloseTo(dpr, 1); + + await clearDPR(cdp); + }); + } + + /* ──────── 2. Canvas pixel readback ──────── */ + + test('canvas pixel colours match mask regions after render', async ({ page }) => { + await loadApp(page); + + const pixels = await page.evaluate( + ({ IMG_W, IMG_H, MASK_A, MASK_B, BG }) => { + // Create a frame + canvas mimicking MaskCanvas + const frame = document.createElement('div'); + frame.style.cssText = ` + width: 400px; height: 320px; + position: relative; + `; + document.body.appendChild(frame); + + const canvas = document.createElement('canvas'); + canvas.width = IMG_W; + canvas.height = IMG_H; + canvas.style.cssText = ` + position: absolute; inset: 0; + width: 100%; height: 100%; + `; + frame.appendChild(canvas); + + const ctx = canvas.getContext('2d')!; + + // Draw background + ctx.fillStyle = `rgb(${BG.r}, ${BG.g}, ${BG.b})`; + ctx.fillRect(0, 0, IMG_W, IMG_H); + + // Draw mask-A (top-left quadrant, red) + ctx.fillStyle = `rgb(${MASK_A.r}, ${MASK_A.g}, ${MASK_A.b})`; + ctx.fillRect(0, 0, 50, 40); + + // Draw mask-B (top-right quadrant, blue) + ctx.fillStyle = `rgb(${MASK_B.r}, ${MASK_B.g}, ${MASK_B.b})`; + ctx.fillRect(50, 0, 50, 40); + + // Read pixels from the canvas bitmap (not CSS-scaled) + const readPixel = (x: number, y: number) => { + const d = ctx.getImageData(x, y, 1, 1).data; + return { r: d[0], g: d[1], b: d[2], a: d[3] }; + }; + + const results = { + maskA_center: readPixel(25, 20), // centre of mask-A + maskB_center: readPixel(75, 20), // centre of mask-B + bg_center: readPixel(50, 60), // centre of bottom (no mask) + maskA_edge: readPixel(0, 0), // top-left pixel (mask-A) + maskB_edge: readPixel(99, 0), // top-right pixel (mask-B) + boundary: readPixel(50, 20), // right on the A/B boundary → should be B + }; + + document.body.removeChild(frame); + return results; + }, + { IMG_W, IMG_H, MASK_A: MASK_A_COLOR, MASK_B: MASK_B_COLOR, BG: BG_COLOR }, + ); + + // Mask A (red) + expect(pixels.maskA_center.r).toBe(255); + expect(pixels.maskA_center.g).toBe(0); + expect(pixels.maskA_center.b).toBe(0); + + // Mask B (blue) + expect(pixels.maskB_center.r).toBe(0); + expect(pixels.maskB_center.g).toBe(0); + expect(pixels.maskB_center.b).toBe(255); + + // Background (grey) + expect(pixels.bg_center.r).toBe(200); + expect(pixels.bg_center.g).toBe(200); + expect(pixels.bg_center.b).toBe(200); + + // Edges + expect(pixels.maskA_edge.r).toBe(255); // red + expect(pixels.maskB_edge.b).toBe(255); // blue + + // Boundary pixel (x=50 is in mask-B territory since mask-A is 0-49) + expect(pixels.boundary.b).toBe(255); + }); + + /* ──────── 3. Pixel readback after viewport resize (zoom simulation) ──────── */ + + for (const dpr of [1.5, 2]) { + test(`canvas pixels stay correct after emulated DPR ${dpr}`, async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + await loadApp(page); + + // Draw at DPR 1 + await page.evaluate( + ({ IMG_W, IMG_H }) => { + const frame = document.createElement('div'); + frame.id = '__pw_pixel_resize'; + frame.style.cssText = ` + width: 400px; height: 320px; + position: relative; + `; + document.body.appendChild(frame); + + const canvas = document.createElement('canvas'); + canvas.id = '__pw_canvas_resize'; + frame.appendChild(canvas); + + // Draw with DPR-aware sizing (our production code path) + const rect = frame.getBoundingClientRect(); + const currentDpr = window.devicePixelRatio || 1; + canvas.width = Math.round(rect.width * currentDpr); + canvas.height = Math.round(rect.height * currentDpr); + canvas.style.cssText = ` + position: absolute; inset: 0; + width: ${rect.width}px; height: ${rect.height}px; + `; + + const ctx = canvas.getContext('2d')!; + // Scale context so drawing coordinates match natural image space + ctx.scale(canvas.width / IMG_W, canvas.height / IMG_H); + + ctx.fillStyle = 'rgb(200,200,200)'; + ctx.fillRect(0, 0, IMG_W, IMG_H); + ctx.fillStyle = 'rgb(255,0,0)'; + ctx.fillRect(0, 0, 50, 40); + ctx.fillStyle = 'rgb(0,0,255)'; + ctx.fillRect(50, 0, 50, 40); + }, + { IMG_W, IMG_H }, + ); + + // Now change DPR (simulating browser zoom) + await setDPR(cdp, dpr); + await page.waitForTimeout(200); // let resize observers fire + + // Re-render the canvas (simulating what MaskCanvas.redraw does on resize) + const pixelsAfter = await page.evaluate( + ({ IMG_W, IMG_H }) => { + const frame = document.getElementById('__pw_pixel_resize')!; + const canvas = document.getElementById('__pw_canvas_resize')! as HTMLCanvasElement; + + // Re-run redraw() logic + const rect = frame.getBoundingClientRect(); + const currentDpr = window.devicePixelRatio || 1; + canvas.width = Math.round(rect.width * currentDpr); + canvas.height = Math.round(rect.height * currentDpr); + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + + const ctx = canvas.getContext('2d')!; + ctx.scale(canvas.width / IMG_W, canvas.height / IMG_H); + + ctx.fillStyle = 'rgb(200,200,200)'; + ctx.fillRect(0, 0, IMG_W, IMG_H); + ctx.fillStyle = 'rgb(255,0,0)'; + ctx.fillRect(0, 0, 50, 40); + ctx.fillStyle = 'rgb(0,0,255)'; + ctx.fillRect(50, 0, 50, 40); + + // Read a pixel in the centre of each region (in bitmap coordinates) + const readPixel = (rx: number, ry: number) => { + const px = Math.floor(rx * canvas.width); + const py = Math.floor(ry * canvas.height); + const d = ctx.getImageData(px, py, 1, 1).data; + return { r: d[0], g: d[1], b: d[2] }; + }; + + return { + maskA: readPixel(0.25, 0.25), // centre of top-left + maskB: readPixel(0.75, 0.25), // centre of top-right + bg: readPixel(0.50, 0.75), // centre of bottom + dpr: currentDpr, + canvasW: canvas.width, + canvasH: canvas.height, + }; + }, + { IMG_W, IMG_H }, + ); + + expect(pixelsAfter.dpr).toBeCloseTo(dpr, 1); + // Mask A — red + expect(pixelsAfter.maskA.r).toBeGreaterThan(200); + expect(pixelsAfter.maskA.b).toBeLessThan(50); + // Mask B — blue + expect(pixelsAfter.maskB.b).toBeGreaterThan(200); + expect(pixelsAfter.maskB.r).toBeLessThan(50); + // Background — grey + expect(pixelsAfter.bg.r).toBeGreaterThan(150); + expect(pixelsAfter.bg.r).toBeLessThan(220); + + await clearDPR(cdp); + }); + } + + /* ──────── 4. Coordinate mapping stays accurate across viewport sizes ──────── */ + + test('coordinate mapping consistent across 4 viewport sizes', async ({ page }) => { + await loadApp(page); + + const overlayData = buildTestOverlay(); + const viewports = [ + { w: 600, h: 400 }, + { w: 800, h: 600 }, + { w: 1280, h: 720 }, + { w: 1920, h: 1080 }, + ]; + + for (const vp of viewports) { + await page.setViewportSize({ width: vp.w, height: vp.h }); + await page.waitForTimeout(100); + + const result = await page.evaluate( + ({ IMG_W, IMG_H, MASK_IDS, overlayData }) => { + let frame = document.getElementById('__pw_vp_test') as HTMLDivElement | null; + if (!frame) { + frame = document.createElement('div'); + frame.id = '__pw_vp_test'; + // responsive container: 80% width, aspect ratio matches image + frame.style.cssText = ` + width: 80%; aspect-ratio: ${IMG_W} / ${IMG_H}; + position: relative; max-width: 800px; + `; + document.body.appendChild(frame); + } + + const maskOverlay = { + width: IMG_W, + height: IMG_H, + maskIds: MASK_IDS, + data: overlayData, + }; + + const getMaskAtPosition = (clientX: number, clientY: number): string | null => { + const rect = frame!.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + const relativeX = (clientX - rect.left) / rect.width; + const relativeY = (clientY - rect.top) / rect.height; + if (relativeX < 0 || relativeX > 1 || relativeY < 0 || relativeY > 1) return null; + const col = Math.floor(relativeX * maskOverlay.width); + const row = Math.floor(relativeY * maskOverlay.height); + const idx = row * maskOverlay.width + col; + const maskIndex = maskOverlay.data[idx]; + if (maskIndex === undefined || maskIndex < 0) return null; + return maskOverlay.maskIds[maskIndex] ?? null; + }; + + const rect = frame.getBoundingClientRect(); + return { + topLeft: getMaskAtPosition(rect.left + rect.width * 0.25, rect.top + rect.height * 0.25), + topRight: getMaskAtPosition(rect.left + rect.width * 0.75, rect.top + rect.height * 0.25), + bottom: getMaskAtPosition(rect.left + rect.width * 0.5, rect.top + rect.height * 0.75), + vpW: window.innerWidth, + frameW: rect.width, + }; + }, + { IMG_W, IMG_H, MASK_IDS, overlayData }, + ); + + expect(result.topLeft, `mask-A at viewport ${vp.w}×${vp.h}`).toBe('mask-A'); + expect(result.topRight, `mask-B at viewport ${vp.w}×${vp.h}`).toBe('mask-B'); + expect(result.bottom, `no mask at viewport ${vp.w}×${vp.h}`).toBeNull(); + } + }); +}); diff --git a/src/labelling-app/frontend/package.json b/src/labelling-app/frontend/package.json index 2eedfc54..cbb5dbaa 100644 --- a/src/labelling-app/frontend/package.json +++ b/src/labelling-app/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "firebase": "^12.7.0", @@ -16,7 +18,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", "@types/node": "^24.10.1", + "@types/pngjs": "^6.0.5", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -25,9 +29,9 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "pngjs": "^7.0.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" } } - diff --git a/src/labelling-app/frontend/playwright.config.ts b/src/labelling-app/frontend/playwright.config.ts new file mode 100644 index 00000000..eef00156 --- /dev/null +++ b/src/labelling-app/frontend/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for labelling-app frontend E2E tests. + * Starts the Vite dev server automatically and runs tests against it. + */ +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: true, + retries: 0, + reporter: 'list', + + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'pnpm dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); diff --git a/src/labelling-app/frontend/src/App.tsx b/src/labelling-app/frontend/src/App.tsx index ab840250..6382d964 100644 --- a/src/labelling-app/frontend/src/App.tsx +++ b/src/labelling-app/frontend/src/App.tsx @@ -1,75 +1,40 @@ -import { useEffect, useState, useRef, useCallback } from 'react'; +/** + * App - Main application shell + * Handles routing, global state, and API orchestration + */ + +import { useEffect, useState, useCallback } from 'react'; import type { CSSProperties } from 'react'; +import type { User } from 'firebase/auth'; import './styles/app-shell.css'; import './styles/utilities.css'; -import type { - RouteId, - Project, - ProjectImage, - ProjectFormData, - ImageStatus, -} from './types'; -import { ProjectList } from './components/ProjectList'; -import { CreateProject } from './components/CreateProject'; -import { ImageUpload } from './components/ImageUpload'; -import { LabelImage } from './components/LabelImage'; -import { PreviewGallery } from './components/PreviewGallery'; +import type { RouteId, Project, ProjectFormData, ImageStatus } from './types'; +import { AuthProvider, useAuth } from './context/AuthContext'; + +// ============ Components ============ import { ConfirmModal } from './components/ui'; +import { Sidebar, NAV_ITEMS } from './components/shared'; +import { + ProjectList, + CreateProject, + ImageUpload, + PreviewGallery, + Login, +} from './pages'; + +// ============ Hooks ============ +import { useNotifications } from './hooks'; + +// ============ API ============ import { listProjects, createProject, getProject, listImages, deleteProject, - getAvailableImages, - acquireLocks, - releaseLocks, - updateImage, uploadZipToBackend, } from './modules/API_Helps'; -const LOCK_BATCH_SIZE = 5; -const LOCK_DURATION_MS = 20 * 60 * 1000; -const LOCK_REFRESH_MS = 5 * 60 * 1000; - -const NAV_ITEMS: Array<{ - id: RouteId; - label: string; - description: string; - icon: string; -}> = [ - { - id: 'projects', - label: 'Projects', - description: 'Browse and select', - icon: 'P', - }, - { - id: 'create', - label: 'Create', - description: 'New project setup', - icon: 'C', - }, - { - id: 'label', - label: 'Label', - description: 'Annotate images', - icon: 'L', - }, - { - id: 'upload', - label: 'Upload', - description: 'Add new images', - icon: 'U', - }, - { - id: 'preview', - label: 'Preview', - description: 'Browse overlays', - icon: 'V', - }, -]; - const PAGE_META: Record = { projects: { eyebrow: 'Workspace', @@ -81,23 +46,19 @@ const PAGE_META: Record ({ '--delay': delay } as CSSProperties); @@ -107,40 +68,38 @@ const getRouteFromHash = (): RouteId => { return match ? match.id : 'projects'; }; -const getErrorMessage = (err: unknown, fallback: string) => - err instanceof Error && err.message ? err.message : fallback; - const getResponseCount = (response: { total?: number; items: unknown[] }) => typeof response.total === 'number' ? response.total : response.items.length; +// ============ Component ============ +type AuthenticatedAppProps = { + user: User; + onSignOut: () => Promise; +}; -function App() { - // Routing +function AuthenticatedApp({ user, onSignOut }: AuthenticatedAppProps) { + // ============ Routing ============ const [route, setRoute] = useState(() => getRouteFromHash()); - // Data state + // ============ Data State ============ const [projects, setProjects] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(null); const [projectDetails, setProjectDetails] = useState(null); - const [availableImages, setAvailableImages] = useState([]); - const [lockedIds, setLockedIds] = useState([]); - // UI state + // ============ UI State ============ const [projectsLoading, setProjectsLoading] = useState(false); - const [queueLoading, setQueueLoading] = useState(false); const [uploading, setUploading] = useState(false); - const [savingAnnotations, setSavingAnnotations] = useState(false); const [creatingProject, setCreatingProject] = useState(false); - const [error, setError] = useState(null); - const [notification, setNotification] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deletingProjectId, setDeletingProjectId] = useState(null); const [switchTarget, setSwitchTarget] = useState(null); - const refreshTimer = useRef(null); + // ============ Hooks ============ + const { notification, error, showNotification, showError, clearNotification, clearError } = + useNotifications(); - // Derived state - const projectFromList = projects.find(p => p.projectId === selectedProjectId) || null; + // ============ Derived State ============ + const projectFromList = projects.find((p) => p.projectId === selectedProjectId) || null; const selectedProject = projectDetails ? { ...projectDetails, @@ -150,26 +109,21 @@ function App() { } : projectFromList; const pageMeta = PAGE_META[route]; + const authDisplayName = user.displayName || user.email || user.uid; - // Navigation + // ============ Navigation ============ const navigate = (id: RouteId) => { if (window.location.hash !== `#${id}`) { window.location.hash = id; } setRoute(id); - setError(null); + clearError(); }; - // Show notification - const showNotification = (message: string) => { - setNotification(message); - setTimeout(() => setNotification(null), 3000); - }; - - // API: Load projects list + // ============ API: Load Projects ============ const refreshProjects = useCallback(async () => { setProjectsLoading(true); - setError(null); + clearError(); try { const response = await listProjects(); const items = (response.items || []).map((item) => ({ @@ -181,6 +135,7 @@ function App() { imageCount: item.imageCount || 0, labeledCount: item.labeledCount || 0, })); + const itemsWithCounts = await Promise.all( items.map(async (item) => { try { @@ -190,11 +145,10 @@ function App() { ]); const total = getResponseCount(totalResponse); const unlabeled = getResponseCount(unlabeledResponse); - const labeled = Math.max(total - unlabeled, 0); return { ...item, imageCount: total, - labeledCount: labeled, + labeledCount: Math.max(total - unlabeled, 0), unlabeledCount: unlabeled, }; } catch { @@ -204,99 +158,36 @@ function App() { ); setProjects(itemsWithCounts); - setSelectedProjectId((prev) => prev ?? itemsWithCounts[0]?.projectId ?? null); - } catch (err: unknown) { - setError(getErrorMessage(err, 'Failed to load projects')); + } catch (err) { + showError(err, 'Failed to load projects'); } finally { setProjectsLoading(false); } - }, []); - - // API: Load project details - const loadProjectDetails = useCallback(async (projectId: string) => { - try { - const details = await getProject(projectId); - setProjectDetails({ - projectId: details.projectId, - name: details.name, - description: details.description || null, - labels: details.labels || {}, - createdAt: details.createdAt || new Date().toISOString(), - imageCount: details.imageCount || 0, - labeledCount: details.labeledCount || 0, - }); - } catch (err: unknown) { - setError(getErrorMessage(err, 'Failed to load project')); - } - }, []); + }, [clearError, showError]); - // API: Load available images and acquire locks - const loadAvailableQueue = useCallback(async (projectId: string) => { - setQueueLoading(true); - try { - const response = await getAvailableImages(projectId, { - limit: LOCK_BATCH_SIZE, - status: 'unlabeled', - includeFileUrl: true, - }); - - const items = response.items || []; - if (items.length === 0) { - setAvailableImages([]); - setLockedIds([]); - return; + // ============ API: Load Project Details ============ + const loadProjectDetails = useCallback( + async (projectId: string) => { + try { + const details = await getProject(projectId); + setProjectDetails({ + projectId: details.projectId, + name: details.name, + description: details.description || null, + labels: details.labels || {}, + createdAt: details.createdAt || new Date().toISOString(), + imageCount: details.imageCount || 0, + labeledCount: details.labeledCount || 0, + }); + } catch (err) { + showError(err, 'Failed to load project'); } + }, + [showError] + ); - const lockResponse = await acquireLocks( - projectId, - items.map((item) => item.imageId), - LOCK_DURATION_MS - ); - - const locked = (lockResponse.results || []) - .filter((result) => result.locked) - .map((result) => result.imageId); - - const lockedImages: ProjectImage[] = items - .filter((item) => locked.includes(item.imageId)) - .map((item) => ({ - imageId: item.imageId, - projectId: projectId, - maskMapId: item.maskMapId || null, - labelComplete: item.labelComplete || false, - reviewed: item.reviewed || false, - meta: { - fileName: item.meta?.fileName || 'Unknown', - width: item.meta?.width || 0, - height: item.meta?.height || 0, - status: item.meta?.status || 'unlabeled', - tags: item.meta?.tags || [], - }, - fileUrl: item.fileUrl, - createdAt: item.createdAt || new Date().toISOString(), - })); - - setAvailableImages(lockedImages); - setLockedIds(locked); - } catch (err: unknown) { - setError(getErrorMessage(err, 'Failed to load images')); - } finally { - setQueueLoading(false); - } - }, []); - - // API: Refresh locks - const refreshLocks = useCallback(async () => { - if (!selectedProjectId || lockedIds.length === 0) { - return; - } - try { - await acquireLocks(selectedProjectId, lockedIds, LOCK_DURATION_MS); - } catch (err: unknown) { - console.error('Failed to refresh locks:', err); - } - }, [lockedIds, selectedProjectId]); + // ============ Effects ============ // Hash change listener useEffect(() => { @@ -304,10 +195,7 @@ function App() { window.history.replaceState(null, '', '#projects'); } - const onHashChange = () => { - setRoute(getRouteFromHash()); - }; - + const onHashChange = () => setRoute(getRouteFromHash()); window.addEventListener('hashchange', onHashChange); return () => window.removeEventListener('hashchange', onHashChange); }, []); @@ -326,41 +214,11 @@ function App() { loadProjectDetails(selectedProjectId); }, [loadProjectDetails, selectedProjectId]); - // Load queue when on label page - useEffect(() => { - if (!selectedProjectId || route !== 'label') { - setAvailableImages([]); - setLockedIds([]); - return; - } - loadAvailableQueue(selectedProjectId); - }, [loadAvailableQueue, selectedProjectId, route]); - - // Refresh locks periodically - useEffect(() => { - if (refreshTimer.current) { - window.clearInterval(refreshTimer.current); - } - - if (route !== 'label' || lockedIds.length === 0) { - return; - } + // ============ Handlers ============ - refreshTimer.current = window.setInterval(() => { - refreshLocks(); - }, LOCK_REFRESH_MS); - - return () => { - if (refreshTimer.current) { - window.clearInterval(refreshTimer.current); - } - }; - }, [lockedIds, refreshLocks, route]); - - // Handler: Create project const handleCreateProject = async (data: ProjectFormData) => { setCreatingProject(true); - setError(null); + clearError(); try { const response = await createProject({ name: data.name, @@ -372,8 +230,8 @@ function App() { await refreshProjects(); showNotification(`Project "${data.name}" created successfully!`); navigate('projects'); - } catch (err: unknown) { - setError(getErrorMessage(err, 'Failed to create project')); + } catch (err) { + showError(err, 'Failed to create project'); } finally { setCreatingProject(false); } @@ -384,29 +242,24 @@ function App() { }; const handleConfirmDeleteProject = async () => { - if (!deleteTarget || deletingProjectId) { - return; - } + if (!deleteTarget || deletingProjectId) return; setDeletingProjectId(deleteTarget.projectId); - setError(null); + clearError(); try { await deleteProject(deleteTarget.projectId); - - setProjects((prev) => prev.filter((project) => project.projectId !== deleteTarget.projectId)); + setProjects((prev) => prev.filter((p) => p.projectId !== deleteTarget.projectId)); if (selectedProjectId === deleteTarget.projectId) { setSelectedProjectId(null); setProjectDetails(null); - setAvailableImages([]); - setLockedIds([]); } showNotification(`Project "${deleteTarget.name}" deleted successfully!`); setDeleteTarget(null); - } catch (err: unknown) { - setError(getErrorMessage(err, 'Failed to delete project')); + } catch (err) { + showError(err, 'Failed to delete project'); } finally { setDeletingProjectId(null); } @@ -421,35 +274,25 @@ function App() { }; const handleConfirmSwitchProject = () => { - if (!switchTarget) { - return; - } + if (!switchTarget) return; setProjectDetails(null); - setAvailableImages([]); - setLockedIds([]); setSelectedProjectId(switchTarget.projectId); setSwitchTarget(null); }; - // Handler: Upload image - const handleUploadImage = async ( - file: File, - meta: { status: ImageStatus; tags: string[] } - ) => { + const handleUploadImage = async (file: File, meta: { status: ImageStatus; tags: string[] }) => { if (!selectedProjectId) { - setError('Select a project first'); + showError('Select a project first'); return; } setUploading(true); - setError(null); + clearError(); try { - const isZip = - file.type.includes('zip') || file.name.toLowerCase().endsWith('.zip'); - + const isZip = file.type.includes('zip') || file.name.toLowerCase().endsWith('.zip'); if (!isZip) { - setError('Only ZIP files are accepted'); + showError('Only ZIP files are accepted'); setUploading(false); return; } @@ -459,139 +302,31 @@ function App() { tags: meta.tags, }); showNotification(`Uploaded ${zipResponse.count} images from "${file.name}"`); - - // Refresh project details to update counts await loadProjectDetails(selectedProjectId); - - // If on label page, reload queue - if (route === 'label') { - await loadAvailableQueue(selectedProjectId); - } - } catch (err: unknown) { - setError(getErrorMessage(err, 'Upload failed')); + } catch (err) { + showError(err, 'Upload failed'); } finally { setUploading(false); } }; - // Handler: Mark image as labeled - const handleMarkLabeled = async (imageId: string) => { - if (!selectedProjectId || !selectedProject) { - setError('Select a project first'); - return false; - } - - setSavingAnnotations(true); + const handleSignOut = async () => { try { - await updateImage(selectedProjectId, imageId, { - meta: { status: 'labeled' }, - }); - showNotification('Image marked as labeled'); - - let remainingLocks = lockedIds; - if (lockedIds.includes(imageId)) { - try { - await releaseLocks(selectedProjectId, [imageId]); - remainingLocks = lockedIds.filter((id) => id !== imageId); - setLockedIds(remainingLocks); - } catch (err: unknown) { - console.warn('Failed to release image lock:', err); - } - } - - // Move to next image in queue - const currentIndex = availableImages.findIndex(img => img.imageId === imageId); - if (currentIndex >= availableImages.length - 1) { - // Reload queue for more images - if (selectedProjectId) { - if (remainingLocks.length > 0) { - try { - await releaseLocks(selectedProjectId, remainingLocks); - } catch (err: unknown) { - console.warn('Failed to release locks:', err); - } - setLockedIds([]); - } - await loadAvailableQueue(selectedProjectId); - } - } - - return true; - } catch (err: unknown) { - setError(getErrorMessage(err, 'Failed to mark image as labeled')); - return false; - } finally { - setSavingAnnotations(false); + await onSignOut(); + } catch (err) { + showError(err, 'Failed to sign out'); } }; - const handleReloadQueue = async () => { - if (!selectedProjectId) { - return; - } - - if (lockedIds.length > 0) { - try { - await releaseLocks(selectedProjectId, lockedIds); - } catch (err: unknown) { - console.warn('Failed to release locks:', err); - } - setLockedIds([]); - } - - await loadAvailableQueue(selectedProjectId); - }; - + // ============ Render ============ return (
- {/* Sidebar Navigation */} - + {/* Sidebar */} + {/* Main Content */}
@@ -603,6 +338,13 @@ function App() {

{pageMeta.subtitle}

+
+

Signed in

+

{authDisplayName}

+
+ {route === 'projects' && ( - )}
@@ -628,7 +361,7 @@ function App() { {notification && (
{notification} -
@@ -638,7 +371,7 @@ function App() { {error && (
{error} -
@@ -669,20 +402,6 @@ function App() { )} - {route === 'label' && ( -
- navigate('projects')} - onMarkLabeled={handleMarkLabeled} - onNextImage={() => {}} - onPrevImage={() => {}} - loading={queueLoading || savingAnnotations} - /> -
- )} - {route === 'upload' && (
- navigate('projects')} - /> + navigate('projects')} />
)} + {/* Delete Confirmation Modal */} setDeleteTarget(null)} @@ -715,6 +432,7 @@ function App() { variant="danger" /> + {/* Switch Project Confirmation Modal */} setSwitchTarget(null)} @@ -729,4 +447,36 @@ function App() { ); } +function AppShell() { + const { user, loading, signOutUser } = useAuth(); + + if (loading) { + return ( +
+
+
+

Authentication

+

Checking session

+

Please wait while we verify your sign-in status.

+
+
+
+ ); + } + + if (!user) { + return ; + } + + return ; +} + +function App() { + return ( + + + + ); +} + export default App; diff --git a/src/labelling-app/frontend/src/components/LabelImage.css b/src/labelling-app/frontend/src/components/LabelImage.css deleted file mode 100644 index 26e54eca..00000000 --- a/src/labelling-app/frontend/src/components/LabelImage.css +++ /dev/null @@ -1,550 +0,0 @@ -/* Label Image Component */ -.label-container { - min-height: 500px; -} - -.label-layout { - display: grid; - grid-template-columns: 1fr 320px; - gap: 20px; -} - -.label-canvas-section { - display: flex; - flex-direction: column; - gap: 12px; -} - -.canvas-toolbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 12px; - background: var(--panel-strong); - border-radius: 12px; -} - -.toolbar-left, -.toolbar-right { - display: flex; - align-items: center; - gap: 8px; -} - -.image-counter { - font-size: 0.85rem; - font-weight: 600; - padding: 0 12px; -} - -.canvas-container { - position: relative; - background: #fff; - border: 1px solid var(--border); - border-radius: 16px; - width: 1024px; - height: 1024px; - overflow: hidden; - cursor: crosshair; -} - -.canvas-wrapper { - position: relative; - display: inline-block; -} - -.canvas-wrapper img { - display: block; - max-width: none; - max-height: none; - user-select: none; - -webkit-user-drag: none; -} - -.canvas-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - height: 100%; - color: var(--muted); -} - -/* Annotation Boxes */ -.annotation-box { - position: absolute; - border: 2px solid var(--box-color, var(--accent)); - background: rgba(224, 107, 63, 0.1); - pointer-events: auto; - cursor: pointer; - transition: border-width 150ms ease, box-shadow 150ms ease, background 150ms ease; -} - -.annotation-box.hovered { - border-width: 3px; - background: rgba(224, 107, 63, 0.2); - box-shadow: 0 0 12px rgba(0, 0, 0, 0.15); -} - -.annotation-box.selected { - border-width: 3px; - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 0 16px rgba(0, 0, 0, 0.2); - background: rgba(224, 107, 63, 0.25); -} - -.annotation-box.drawing { - pointer-events: none; - border-style: dashed; -} - -.box-label { - position: absolute; - top: -24px; - left: -2px; - padding: 2px 8px; - background: var(--box-color, var(--accent)); - color: #fff; - font-size: 0.7rem; - font-weight: 600; - border-radius: 4px 4px 0 0; - white-space: nowrap; -} - -.box-delete { - position: absolute; - top: -24px; - right: -2px; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - background: #e74c3c; - color: #fff; - border: none; - border-radius: 4px 4px 0 0; - cursor: pointer; - font-size: 0.75rem; - font-weight: bold; -} - -/* Label Sidebar */ -.label-sidebar { - display: flex; - flex-direction: column; - gap: 12px; -} - -.image-info-card h4, -.class-selection-card h4, -.annotations-card h4, -.shortcuts-card h4 { - margin: 0 0 12px; - font-size: 0.9rem; - font-weight: 600; -} - -.info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 6px 0; - border-bottom: 1px solid var(--border); -} - -.info-row:last-child { - border-bottom: none; -} - -.info-label { - font-size: 0.75rem; - color: var(--muted); -} - -.info-value { - font-size: 0.85rem; - font-weight: 500; - text-align: right; - overflow: hidden; - text-overflow: ellipsis; - max-width: 180px; -} - -/* Class Selection */ -.class-selection-card .hint { - margin: 0 0 12px; - font-size: 0.75rem; - color: var(--muted); -} - -.class-buttons { - display: flex; - flex-direction: column; - gap: 6px; -} - -.class-select-btn { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - background: #fff; - border: 1px solid var(--border); - border-radius: 10px; - cursor: pointer; - transition: border-color 150ms ease, transform 150ms ease; -} - -.class-select-btn:hover { - transform: translateX(2px); -} - -.class-select-btn.active { - border-color: var(--class-color, var(--accent)); - background: rgba(224, 107, 63, 0.05); -} - -.class-select-btn .class-dot { - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--class-color, var(--accent)); - flex-shrink: 0; -} - -.class-select-btn .class-name { - flex: 1; - font-weight: 500; -} - -.class-select-btn .class-shortcut { - font-size: 0.7rem; - padding: 2px 6px; - background: var(--panel-strong); - border-radius: 4px; - color: var(--muted); -} - -/* Annotations List */ -.annotations-list { - display: flex; - flex-direction: column; - gap: 6px; - max-height: 200px; - overflow-y: auto; -} - -.annotation-item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - background: var(--panel-strong); - border-radius: 8px; - cursor: pointer; - transition: background 150ms ease; -} - -.annotation-item:hover { - background: var(--panel); -} - -.annotation-item.selected { - background: #fff; - box-shadow: 0 0 0 1px var(--accent); -} - -.annotation-color { - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; -} - -.annotation-name { - flex: 1; - font-size: 0.85rem; -} - -.annotation-delete { - display: flex; - padding: 4px; - background: none; - border: none; - color: var(--muted); - cursor: pointer; - border-radius: 4px; - transition: color 150ms ease, background 150ms ease; -} - -.annotation-delete:hover { - color: #e74c3c; - background: rgba(231, 76, 60, 0.1); -} - -/* Keyboard Shortcuts */ -.shortcuts-list { - display: flex; - flex-direction: column; - gap: 6px; -} - -.shortcut { - display: flex; - align-items: center; - gap: 10px; - font-size: 0.8rem; -} - -.shortcut kbd { - padding: 3px 8px; - background: var(--panel-strong); - border-radius: 4px; - font-family: monospace; - font-size: 0.75rem; - min-width: 60px; - text-align: center; -} - -.shortcut span { - color: var(--muted); -} - -/* Label Popup */ -.label-popup { - position: absolute; - z-index: 100; - min-width: 180px; - background: #fff; - border: 1px solid var(--border); - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1); - animation: popup-enter 150ms ease; - overflow: hidden; -} - -@keyframes popup-enter { - from { - opacity: 0; - transform: scale(0.95) translateY(-4px); - } - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -.label-popup-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 12px; - border-bottom: 1px solid var(--border); - background: var(--panel-strong); -} - -.label-popup-header span { - font-size: 0.8rem; - font-weight: 600; - color: var(--ink); -} - -.label-popup-close { - display: flex; - align-items: center; - justify-content: center; - padding: 4px; - background: none; - border: none; - color: var(--muted); - cursor: pointer; - border-radius: 4px; - transition: color 150ms ease, background 150ms ease; -} - -.label-popup-close:hover { - color: var(--ink); - background: rgba(0, 0, 0, 0.05); -} - -.label-popup-options { - display: flex; - flex-direction: column; - padding: 6px; - max-height: 200px; - overflow-y: auto; -} - -.label-popup-option { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - background: transparent; - border: none; - border-radius: 8px; - cursor: pointer; - transition: background 150ms ease; - text-align: left; -} - -.label-popup-option:hover { - background: var(--panel-strong); -} - -.label-popup-option.active { - background: rgba(31, 122, 110, 0.1); -} - -.label-popup-option .option-dot { - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--option-color, var(--accent)); - flex-shrink: 0; -} - -.label-popup-option .option-name { - flex: 1; - font-size: 0.85rem; - font-weight: 500; - color: var(--ink); -} - -.label-popup-option .option-check { - color: var(--accent-2); - flex-shrink: 0; -} - -.label-popup-footer { - padding: 8px 10px; - border-top: 1px solid var(--border); - background: var(--panel-strong); -} - -.label-popup-delete { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 8px 10px; - background: transparent; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.8rem; - font-weight: 500; - color: #e74c3c; - transition: background 150ms ease; -} - -.label-popup-delete:hover { - background: rgba(231, 76, 60, 0.1); -} - -.label-popup-delete svg { - flex-shrink: 0; -} - -/* Label Popup Backdrop */ -.label-popup-backdrop { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 99; -} - -/* Masks List in Sidebar */ -.masks-list { - display: flex; - flex-direction: column; - gap: 4px; - margin-top: 12px; - max-height: 200px; - overflow-y: auto; -} - -.mask-list-item { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - background: transparent; - border: 1px solid transparent; - border-radius: 8px; - cursor: pointer; - transition: background 150ms ease, border-color 150ms ease; - text-align: left; - width: 100%; -} - -.mask-list-item:hover { - background: var(--panel-strong); -} - -.mask-list-item.selected { - background: rgba(31, 122, 110, 0.1); - border-color: var(--accent-2); -} - -.mask-color-dot { - width: 12px; - height: 12px; - border-radius: 50%; - flex-shrink: 0; -} - -.mask-name { - flex: 1; - font-size: 0.85rem; - font-weight: 500; - color: var(--ink); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.mask-label-name { - font-weight: 400; - color: var(--muted); -} - -.mask-size { - font-size: 0.75rem; - color: var(--muted); - flex-shrink: 0; -} - -.mask-list-item.highlighted { - background: rgba(59, 130, 246, 0.15); - border-color: #3B82F6; -} - -.mask-hint { - font-size: 0.75rem; - color: var(--muted); - margin: 8px 0 4px 0; - font-style: italic; -} - -.mask-hover-info { - font-size: 0.8rem; - color: var(--accent-2); - margin-right: 12px; - white-space: nowrap; -} - -.mask-loading { - font-size: 0.8rem; - color: var(--muted); - margin-right: 12px; -} - -@media (max-width: 1100px) { - .label-layout { - grid-template-columns: 1fr; - } -} diff --git a/src/labelling-app/frontend/src/components/LabelImage.tsx b/src/labelling-app/frontend/src/components/LabelImage.tsx deleted file mode 100644 index 316f9663..00000000 --- a/src/labelling-app/frontend/src/components/LabelImage.tsx +++ /dev/null @@ -1,720 +0,0 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; -import type { Project, ProjectImage, SparseColorMap, MaskApiItem, MaskMapApiItem, MaskOverlay } from '../types'; -import { Button, Card, StatusBadge, EmptyState } from './ui'; -import { getImageMasks, getColorMap, getImageMaskOverlay, updateMaskLabel } from '../modules/API_Helps'; -import './LabelImage.css'; - -interface LabelPopupState { - maskId: string; - x: number; - y: number; -} - -interface LabelImageProps { - project: Project | null; - images: ProjectImage[]; - onSelectProject: () => void; - onMarkLabeled: (imageId: string) => Promise; - onNextImage: () => void; - onPrevImage: () => void; - loading?: boolean; -} - -const DISPLAY_SIZE = 1024; -const HOVER_DELAY_MS = 1000; // 1 second delay before showing mask -const UNLABELED_COLOR = '#3B82F6'; // Blue color for unlabeled masks -const LABELED_OPACITY = 0.3; // 30% opacity for labeled masks -const HIGHLIGHT_OPACITY = 1.0; // 100% opacity for highlighted mask on hover - -export function LabelImage({ - project, - images, - onSelectProject, - onMarkLabeled, - onNextImage, - onPrevImage, - loading = false, -}: LabelImageProps) { - const [currentIndex, setCurrentIndex] = useState(0); - const [scale, setScale] = useState({ x: 1, y: 1 }); - const [colorMap, setColorMap] = useState(null); - const [masks, setMasks] = useState([]); - const [maskMap, setMaskMap] = useState(null); - const [maskOverlay, setMaskOverlay] = useState(null); - const [maskLoading, setMaskLoading] = useState(false); - - // Mask selection and labeling state - const [selectedMaskId, setSelectedMaskId] = useState(null); - const [labelPopup, setLabelPopup] = useState(null); - const [labelAssigning, setLabelAssigning] = useState(false); - - // Hover state for mask highlighting - const [hoveredMaskId, setHoveredMaskId] = useState(null); - const [highlightedMaskId, setHighlightedMaskId] = useState(null); - const hoverTimerRef = useRef | null>(null); - - const imageRef = useRef(null); - const canvasRef = useRef(null); - const wrapperRef = useRef(null); - const currentImage = images[currentIndex] || null; - const selectedMask = masks.find(m => m.maskId === selectedMaskId) || null; - - // Fetch masks, colorMap, and maskOverlay when image changes - useEffect(() => { - const fetchMasks = async () => { - if (!project || !currentImage) { - setColorMap(null); - setMasks([]); - setMaskMap(null); - setMaskOverlay(null); - return; - } - - setMaskLoading(true); - try { - // Fetch masks/maskMap and maskOverlay in parallel - // Using the new image-based maskoverlay endpoint for simplicity - const [masksResult, fetchedMaskOverlay] = await Promise.all([ - getImageMasks(project.projectId, currentImage.imageId), - getImageMaskOverlay(project.projectId, currentImage.imageId), - ]); - - const { masks: fetchedMasks, maskMap: fetchedMaskMap } = masksResult; - setMasks(fetchedMasks); - setMaskMap(fetchedMaskMap); - setMaskOverlay(fetchedMaskOverlay); - - // Fetch colorMap separately if we have a maskMap - if (fetchedMaskMap) { - const fetchedColorMap = await getColorMap(project.projectId, fetchedMaskMap.maskMapId); - setColorMap(fetchedColorMap); - } else { - setColorMap(null); - } - } catch (err) { - console.error('Failed to fetch masks:', err); - setColorMap(null); - setMasks([]); - setMaskMap(null); - setMaskOverlay(null); - } finally { - setMaskLoading(false); - } - }; - - fetchMasks(); - }, [project, currentImage?.imageId]); - - // Clear hover state when image changes - useEffect(() => { - setHoveredMaskId(null); - setHighlightedMaskId(null); - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - } - }, [currentImage?.imageId]); - - // Draw mask overlay on canvas - useEffect(() => { - const canvas = canvasRef.current; - const img = imageRef.current; - - if (!canvas || !img || !img.complete) { - return; - } - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - // Set canvas size to match image natural size - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - - // Clear canvas - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Create image data for pixel manipulation - const imageData = ctx.createImageData(canvas.width, canvas.height); - const data = imageData.data; - - // Build a map of maskId -> mask for quick lookup - const masksById = new Map(masks.map(m => [m.maskId, m])); - - // Draw labeled masks from colorMap at 30% opacity - if (colorMap) { - for (const [rowKey, cols] of Object.entries(colorMap)) { - const row = parseInt(rowKey, 10); - if (row < 0 || row >= canvas.height) continue; - - for (const [colKey, hexColor] of Object.entries(cols)) { - const col = parseInt(colKey, 10); - if (col < 0 || col >= canvas.width) continue; - - // Parse hex color - const hex = hexColor.replace('#', ''); - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - - // Set pixel with 30% opacity for labeled masks - const idx = (row * canvas.width + col) * 4; - data[idx] = r; - data[idx + 1] = g; - data[idx + 2] = b; - data[idx + 3] = Math.round(255 * LABELED_OPACITY); - } - } - } - - // Draw highlighted mask at 100% opacity (on hover after 1 second) - if (highlightedMaskId && maskOverlay) { - const highlightedMask = masksById.get(highlightedMaskId); - const isLabeled = highlightedMask?.labelId !== null; - - // Determine the color: use label color if labeled, otherwise blue - let r = 59, g = 130, b = 246; // Default blue - if (isLabeled && highlightedMask?.color) { - const hex = highlightedMask.color.replace('#', ''); - r = parseInt(hex.substring(0, 2), 16); - g = parseInt(hex.substring(2, 4), 16); - b = parseInt(hex.substring(4, 6), 16); - } - - // Find the index for the highlighted maskId - const highlightedIndex = maskOverlay.maskIds.indexOf(highlightedMaskId); - - // Iterate through maskOverlay and highlight all pixels of this mask - if (highlightedIndex !== -1) { - for (let i = 0; i < maskOverlay.data.length; i++) { - if (maskOverlay.data[i] === highlightedIndex) { - const idx = i * 4; - data[idx] = r; - data[idx + 1] = g; - data[idx + 2] = b; - data[idx + 3] = Math.round(255 * HIGHLIGHT_OPACITY); - } - } - } - } - - ctx.putImageData(imageData, 0, 0); - }, [colorMap, maskOverlay, masks, highlightedMaskId, scale]); - - // Handle mouse move on canvas for hover detection - const handleCanvasMouseMove = useCallback((e: React.MouseEvent) => { - if (!maskOverlay || !wrapperRef.current || !imageRef.current) return; - - const rect = wrapperRef.current.getBoundingClientRect(); - - // Convert mouse position to image coordinates (accounting for scale) - const mouseX = (e.clientX - rect.left) / scale.x; - const mouseY = (e.clientY - rect.top) / scale.y; - - // Clamp to image bounds - const col = Math.floor(Math.max(0, Math.min(maskOverlay.width - 1, mouseX))); - const row = Math.floor(Math.max(0, Math.min(maskOverlay.height - 1, mouseY))); - - // Look up the mask index at this position, then convert to maskId - const idx = row * maskOverlay.width + col; - const maskIndex = maskOverlay.data[idx]; - // Convert index to maskId (-1 means no mask) - const maskIdAtPosition = maskIndex >= 0 ? maskOverlay.maskIds[maskIndex] : null; - - // If we moved to a different mask, reset the timer - if (maskIdAtPosition !== hoveredMaskId) { - setHoveredMaskId(maskIdAtPosition); - - // Clear existing timer - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - } - - // Clear highlight immediately when moving away - if (!maskIdAtPosition) { - setHighlightedMaskId(null); - } else { - // Start new timer for 1 second delay - hoverTimerRef.current = setTimeout(() => { - setHighlightedMaskId(maskIdAtPosition); - }, HOVER_DELAY_MS); - } - } - }, [maskOverlay, scale, hoveredMaskId]); - - // Handle mouse leave on canvas - const handleCanvasMouseLeave = useCallback(() => { - setHoveredMaskId(null); - setHighlightedMaskId(null); - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - } - }, []); - - // Handle click on canvas to select mask for labeling - const handleCanvasClick = useCallback((e: React.MouseEvent) => { - if (!maskOverlay || !wrapperRef.current) return; - - const rect = wrapperRef.current.getBoundingClientRect(); - const mouseX = (e.clientX - rect.left) / scale.x; - const mouseY = (e.clientY - rect.top) / scale.y; - - const col = Math.floor(Math.max(0, Math.min(maskOverlay.width - 1, mouseX))); - const row = Math.floor(Math.max(0, Math.min(maskOverlay.height - 1, mouseY))); - - // Look up the mask index at this position, then convert to maskId - const idx = row * maskOverlay.width + col; - const maskIndex = maskOverlay.data[idx]; - // Convert index to maskId (-1 means no mask) - const maskIdAtPosition = maskIndex >= 0 ? maskOverlay.maskIds[maskIndex] : null; - - if (maskIdAtPosition) { - setSelectedMaskId(maskIdAtPosition); - - // Show label popup near the click - setLabelPopup({ - maskId: maskIdAtPosition, - x: e.clientX, - y: e.clientY + 10, - }); - } - }, [maskOverlay, scale]); - - const handleNavigate = useCallback((direction: 'prev' | 'next') => { - setCurrentIndex((prev) => { - if (direction === 'prev' && prev > 0) { - onPrevImage(); - return prev - 1; - } - if (direction === 'next' && prev < images.length - 1) { - onNextImage(); - return prev + 1; - } - return prev; - }); - }, [images.length, onNextImage, onPrevImage]); - - const handleImageLoad = useCallback(() => { - if (imageRef.current) { - const imgWidth = imageRef.current.naturalWidth; - const imgHeight = imageRef.current.naturalHeight; - - if (imgWidth > 0 && imgHeight > 0) { - setScale({ - x: DISPLAY_SIZE / imgWidth, - y: DISPLAY_SIZE / imgHeight, - }); - } else { - setScale({ x: 1, y: 1 }); - } - } - }, []); - - const handleMarkLabeled = async () => { - if (!currentImage) return; - const ok = await onMarkLabeled(currentImage.imageId); - if (ok) { - handleNavigate('next'); - } - }; - - // Handle clicking on a mask in the sidebar list - const handleMaskClick = useCallback((mask: MaskApiItem, event: React.MouseEvent) => { - event.stopPropagation(); - setSelectedMaskId(mask.maskId); - - // Position the popup near the click - const rect = (event.target as HTMLElement).getBoundingClientRect(); - setLabelPopup({ - maskId: mask.maskId, - x: rect.left, - y: rect.bottom + 4, - }); - }, []); - - // Close popup when clicking outside - const handleClosePopup = useCallback(() => { - setLabelPopup(null); - }, []); - - // Assign a label to a mask - const handleAssignLabel = useCallback(async (labelId: string | null) => { - if (!project || !labelPopup) return; - - setLabelAssigning(true); - try { - const result = await updateMaskLabel(project.projectId, labelPopup.maskId, labelId); - - // Update the mask in local state - setMasks(prev => prev.map(m => - m.maskId === labelPopup.maskId - ? { ...m, labelId: result.labelId, color: result.color } - : m - )); - - // Refetch colorMap to show updated overlay - if (maskMap) { - const updatedColorMap = await getColorMap(project.projectId, maskMap.maskMapId); - setColorMap(updatedColorMap); - } - - setLabelPopup(null); - } catch (err) { - console.error('Failed to assign label:', err); - } finally { - setLabelAssigning(false); - } - }, [project, labelPopup, maskMap]); - - // Clear label from a mask - const handleClearLabel = useCallback(() => { - handleAssignLabel(null); - }, [handleAssignLabel]); - - // Keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'ArrowLeft') { - handleNavigate('prev'); - } - if (e.key === 'ArrowRight') { - handleNavigate('next'); - } - if (e.key === 'Enter') { - handleMarkLabeled(); - } - if (e.key === 'Escape') { - handleClosePopup(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleNavigate, currentImage, handleClosePopup]); - - if (!project) { - return ( -
- } - title="No project selected" - description="Select a project to start labeling images." - action={{ label: 'Go to Projects', onClick: onSelectProject }} - /> -
- ); - } - - if (images.length === 0) { - return ( -
- } - title="No images to label" - description="Upload images to this project to start labeling." - /> -
- ); - } - - // Get the highlighted mask info for display - const highlightedMask = highlightedMaskId ? masks.find(m => m.maskId === highlightedMaskId) : null; - - return ( -
-
- {/* Main Canvas Area */} -
-
-
- - - {currentIndex + 1} / {images.length} - - -
-
- {maskLoading && Loading masks...} - {highlightedMask && ( - - Hovering: Mask ({highlightedMask.size.toLocaleString()}px) - {highlightedMask.labelId ? ' - Labeled' : ' - Click to label'} - - )} - -
-
- -
- {currentImage?.fileUrl ? ( -
- {currentImage.meta.fileName} - {/* Overlay canvas for mask visualization */} - -
- ) : ( -
- - No image preview -
- )} -
-
- - {/* Right Sidebar */} -
- {/* Image Info */} - -

Current Image

-
- File - {currentImage?.meta.fileName} -
-
- Status - -
-
- Dimensions - - {currentImage?.meta.width} x {currentImage?.meta.height} - -
-
- - {/* Mask Info */} - -

Masks ({masks.length})

-
- Labeled - - {masks.filter(m => m.labelId !== null).length} - -
-
- Unlabeled - - {masks.filter(m => m.labelId === null).length} - -
-

- Hover over image to reveal masks. Click to label. -

- {masks.length > 0 && ( -
- {masks.map((mask, index) => { - const label = mask.labelId && project.labels[mask.labelId]; - const isSelected = selectedMask?.maskId === mask.maskId; - const isHighlighted = highlightedMaskId === mask.maskId; - return ( - - ); - })} -
- )} -
- - {/* Labels Legend */} - {project.labels && Object.keys(project.labels).length > 0 && ( - -

Labels

-
- {Object.values(project.labels).map((label) => ( -
- - {label.name} -
- ))} -
-
- )} - - {/* Keyboard Shortcuts */} - -

Shortcuts

-
-
- Enter - Mark as labeled -
-
- Left/Right - Navigate images -
-
- Esc - Close popup -
-
-
-
-
- - {/* Label Assignment Popup */} - {labelPopup && project.labels && ( -
-
- Assign Label - -
-
- {Object.values(project.labels).map((label) => { - const isActive = selectedMask?.labelId === label.labelId; - return ( - - ); - })} -
- {selectedMask?.labelId && ( -
- -
- )} -
- )} - - {/* Backdrop to close popup when clicking outside */} - {labelPopup && ( -
- )} -
- ); -} - -function FolderIcon() { - return ( - - - - ); -} - -function ImageIcon() { - return ( - - - - - - ); -} - -function CloseIcon() { - return ( - - - - - ); -} - -function CheckIcon() { - return ( - - - - ); -} - -function TrashIcon() { - return ( - - - - - ); -} diff --git a/src/labelling-app/frontend/src/components/ManagementModal.css b/src/labelling-app/frontend/src/components/ManagementModal.css new file mode 100644 index 00000000..c72a1ad1 --- /dev/null +++ b/src/labelling-app/frontend/src/components/ManagementModal.css @@ -0,0 +1,330 @@ +/* Management modal (preview -> manage) */ +.management-modal-content { + max-width: 1120px; + width: min(1120px, 100%); + position: relative; +} + +.management-modal-wrapper { + position: relative; +} + +.management-modal { + display: grid; + gap: 18px; +} + +/* Prev/Next arrows: fixed to viewport so they stay vertically centered in user's view when modal scrolls */ +.management-nav-arrow { + position: fixed; + top: 50%; + transform: translateY(-50%); + z-index: 110; + width: 44px; + height: 44px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--panel); + color: var(--ink); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; +} + +.management-nav-arrow:hover { + background: var(--color-bg-secondary, rgba(255, 255, 255, 0.08)); + transform: translateY(-50%) scale(1.05); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); +} + +.management-nav-arrow:active { + transform: translateY(-50%) scale(0.98); +} + +.management-nav-arrow--prev { + left: 24px; +} + +.management-nav-arrow--next { + right: 24px; +} + +.management-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.management-header h3 { + margin: 6px 0 0; + font-size: 1.4rem; + font-family: 'Fraunces', serif; +} + +.management-body { + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr); +} + +.management-preview { + display: grid; + gap: 12px; +} + +.management-form { + display: grid; + gap: 16px; +} + +.management-panel { + display: grid; + gap: 12px; + padding: 14px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--panel); +} + +.management-panel h4 { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +.management-switches { + display: grid; + gap: 10px; +} + +.management-checkbox { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.85rem; + color: var(--ink); +} + +.management-checkbox input { + accent-color: var(--accent); +} + +.management-tags { + display: grid; + gap: 10px; +} + +.management-tag-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; +} + +.management-tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.management-mask-summary { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 0.8rem; + color: var(--muted); +} + +/* Only the masks list wrap is limited and scrollable (not the whole panel) */ +.masks-list-wrap { + max-height: 200px; + overflow-y: auto; + overflow-x: hidden; + border-radius: 8px; + min-height: 0; +} + +.masks-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mask-list-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid transparent; + background: var(--panel); + text-align: left; + font-size: 0.85rem; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.mask-list-item:hover { + background: var(--color-bg-secondary, rgba(255, 255, 255, 0.06)); +} + +.mask-list-item.selected, +.mask-list-item.highlighted { + border-color: var(--border); +} + +.mask-list-item.focused { + border-color: var(--accent, #3b82f6); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); +} + +.mask-color-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.mask-name { + flex: 1; + min-width: 0; +} + +.mask-label-name { + color: var(--muted); +} + +.mask-size { + font-size: 0.8rem; + color: var(--muted); +} + +.management-labels-list { + display: grid; + gap: 8px; +} + +.management-label-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.85rem; + color: var(--ink); +} + +.management-label-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.management-label-name { + font-weight: 500; +} + +.management-hint { + font-size: 0.8rem; + color: var(--muted); + margin: 0; +} + +.management-hint.error { + color: #e74c3c; +} + +.management-canvas { + position: relative; + border-radius: 18px; + border: 1px solid var(--border); + background: #fff; + overflow: hidden; +} + +.management-canvas img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.management-overlay { + position: absolute; + inset: 0; + mix-blend-mode: multiply; + opacity: 0.85; + pointer-events: none; +} + +.management-overlay-status { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + background: rgba(255, 255, 255, 0.6); + color: var(--muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.management-overlay-status.empty { + background: rgba(255, 255, 255, 0.3); + color: rgba(71, 85, 105, 0.9); + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6); +} + +.management-preview-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + font-size: 0.8rem; + color: var(--muted); +} + +.management-label-popup { + z-index: 120; + position: fixed; +} + +.management-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +/* SAM Point Tool Toolbar */ +.management-sam-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 6px 0; +} + +.sam-point-count { + font-size: 0.8rem; + color: var(--muted); + min-width: 60px; +} + +.sam-error { + font-size: 0.8rem; + color: var(--danger, #ef4444); + flex: 1 1 100%; + margin-top: 2px; +} + +@media (max-width: 900px) { + .management-body { + grid-template-columns: 1fr; + } +} diff --git a/src/labelling-app/frontend/src/components/ManagementModal.tsx b/src/labelling-app/frontend/src/components/ManagementModal.tsx new file mode 100644 index 00000000..03cac8c3 --- /dev/null +++ b/src/labelling-app/frontend/src/components/ManagementModal.tsx @@ -0,0 +1,977 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { + ImageStatus, + MaskApiItem, + MaskMapApiItem, + MaskOverlay, + Project, + ProjectImage, + SamPoint, + SparseColorMap, +} from '../types'; +import { + batchUpdateMaskLabels, + getColorMap, + getImageMaskOverlay, + getImageMasks, + importSamMasks, + requestSamMasks, + updateImage, +} from '../modules/API_Helps'; +import { Button, Input, Select, StatusBadge, TagBadge, ErrorBoundary } from './ui'; +import { InteractiveMapOverlay, useMaskHover } from './shared'; +import './ui/ErrorBoundary.css'; +import './ManagementModal.css'; + +const STATUS_OPTIONS = [ + { value: 'unlabeled', label: 'Unlabeled' }, + { value: 'in_progress', label: 'In Progress' }, + { value: 'labeled', label: 'Labeled' }, +]; + +const HOVER_DELAY_MS = 0; +const UNLABELED_COLOR = '#3B82F6'; + +interface LabelPopupState { + maskId: string; + x: number; + y: number; +} + +interface ManagementModalProps { + project: Project; + image: ProjectImage; + colorMap: SparseColorMap | null | undefined; + onClose: () => void; + onPrevImage?: () => void; + onNextImage?: () => void; + hasPrevImage?: boolean; + hasNextImage?: boolean; + onImageUpdated: ( + imageId: string, + updates: { + meta?: { + status?: ImageStatus; + tags?: string[]; + }; + labelComplete?: boolean; + reviewed?: boolean; + } + ) => void; + onColorMapUpdated: (imageId: string, colorMap: SparseColorMap | null) => void; +} + +export function ManagementModal({ + project, + image, + colorMap, + onClose, + onPrevImage, + onNextImage, + hasPrevImage, + hasNextImage, + onImageUpdated, + onColorMapUpdated, +}: ManagementModalProps) { + const projectId = project.projectId; + const [editStatus, setEditStatus] = useState(image.meta.status); + const [editTags, setEditTags] = useState(image.meta.tags ? [...image.meta.tags] : []); + const [tagInput, setTagInput] = useState(''); + const [editLabelComplete, setEditLabelComplete] = useState(Boolean(image.labelComplete)); + const [editReviewed, setEditReviewed] = useState(Boolean(image.reviewed)); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const [masks, setMasks] = useState([]); + const [maskMap, setMaskMap] = useState(null); + const [maskOverlay, setMaskOverlay] = useState(null); + const [maskLoading, setMaskLoading] = useState(false); + const [maskError, setMaskError] = useState(null); + const [labelError, setLabelError] = useState(null); + + const [selectedMaskId, setSelectedMaskId] = useState(null); + const [focusedMaskIds, setFocusedMaskIds] = useState([]); + const [labelPopup, setLabelPopup] = useState(null); + const [labelAssigning, setLabelAssigning] = useState(false); + const [activeColorMap, setActiveColorMap] = useState(colorMap); + + // SAM point tool state + const [samToolActive, setSamToolActive] = useState(false); + const [samPoints, setSamPoints] = useState([]); + const [samLoading, setSamLoading] = useState(false); + const [samError, setSamError] = useState(null); + + const labelPopupRef = useRef(null); + const masksRequestIdRef = useRef(0); + const saveDebounceRef = useRef | null>(null); + const imageIdRef = useRef(image.imageId); + const latestPayloadRef = useRef({ + status: editStatus, + tags: editTags, + labelComplete: editLabelComplete, + reviewed: editReviewed, + }); + const DEBOUNCE_MS = 450; + + const { + highlightedMaskId, + handleMouseMove: onOverlayMouseMove, + handleMouseLeave: onOverlayMouseLeave, + reset: resetHover, + } = useMaskHover({ hoverDelay: HOVER_DELAY_MS }); + + useEffect(() => { + setEditStatus(image.meta.status); + setEditTags(image.meta.tags ? [...image.meta.tags] : []); + setTagInput(''); + setEditLabelComplete(Boolean(image.labelComplete)); + setEditReviewed(Boolean(image.reviewed)); + setSaveError(null); + setMaskError(null); + setLabelError(null); + setSelectedMaskId(null); + setFocusedMaskIds([]); + setLabelPopup(null); + resetHover(); + setSamToolActive(false); + setSamPoints([]); + setSamError(null); + }, [image.imageId, image.labelComplete, image.meta.status, image.meta.tags, image.reviewed, resetHover]); + + useEffect(() => { + setActiveColorMap(colorMap); + }, [colorMap, image.imageId]); + + useEffect(() => { + let isActive = true; + const requestId = masksRequestIdRef.current + 1; + masksRequestIdRef.current = requestId; + + const loadMasks = async () => { + setMaskLoading(true); + setMaskError(null); + setMasks([]); + setMaskMap(null); + setMaskOverlay(null); + + try { + const [maskResult, overlay] = await Promise.all([ + getImageMasks(projectId, image.imageId), + getImageMaskOverlay(projectId, image.imageId), + ]); + + if (!isActive || masksRequestIdRef.current !== requestId) { + return; + } + + setMasks(maskResult.masks); + setMaskMap(maskResult.maskMap); + setMaskOverlay(overlay); + + if (maskResult.maskMap) { + try { + const fetchedColorMap = await getColorMap(projectId, maskResult.maskMap.maskMapId); + if (!isActive || masksRequestIdRef.current !== requestId) { + return; + } + setActiveColorMap(fetchedColorMap); + onColorMapUpdated(image.imageId, fetchedColorMap); + } catch { + if (!isActive || masksRequestIdRef.current !== requestId) { + return; + } + setActiveColorMap(null); + onColorMapUpdated(image.imageId, null); + } + } else { + setActiveColorMap(null); + onColorMapUpdated(image.imageId, null); + } + } catch (err) { + if (!isActive || masksRequestIdRef.current !== requestId) { + return; + } + setMaskError(err instanceof Error ? err.message : 'Failed to load masks'); + setMasks([]); + setMaskMap(null); + setMaskOverlay(null); + } finally { + if (isActive && masksRequestIdRef.current === requestId) { + setMaskLoading(false); + } + } + }; + + loadMasks(); + + return () => { + isActive = false; + }; + }, [image.imageId, onColorMapUpdated, projectId]); + + useEffect(() => { + if (!labelPopup) { + return; + } + + const handleOutsideClick = (event: MouseEvent) => { + if (labelPopupRef.current?.contains(event.target as Node)) { + return; + } + setLabelPopup(null); + }; + + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, [labelPopup]); + + const performSave = useCallback( + async ( + pid: string, + iid: string, + payload: { + status: ImageStatus; + tags: string[]; + labelComplete: boolean; + reviewed: boolean; + } + ) => { + setSaving(true); + setSaveError(null); + try { + await updateImage(pid, iid, { + meta: { status: payload.status, tags: payload.tags }, + labelComplete: payload.labelComplete, + reviewed: payload.reviewed, + }); + onImageUpdated(iid, { + meta: { status: payload.status, tags: payload.tags }, + labelComplete: payload.labelComplete, + reviewed: payload.reviewed, + }); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save changes'); + } finally { + setSaving(false); + } + }, + [onImageUpdated] + ); + + useEffect(() => { + imageIdRef.current = image.imageId; + latestPayloadRef.current = { + status: editStatus, + tags: editTags, + labelComplete: editLabelComplete, + reviewed: editReviewed, + }; + + const tagsEqual = + editTags.length === (image.meta.tags?.length ?? 0) && + editTags.every((t, i) => image.meta.tags?.[i] === t); + const unchanged = + editStatus === image.meta.status && + tagsEqual && + editLabelComplete === Boolean(image.labelComplete) && + editReviewed === Boolean(image.reviewed); + if (unchanged) { + if (saveDebounceRef.current) { + clearTimeout(saveDebounceRef.current); + saveDebounceRef.current = null; + } + return; + } + + saveDebounceRef.current = setTimeout(() => { + saveDebounceRef.current = null; + const payload = latestPayloadRef.current; + performSave(projectId, imageIdRef.current, payload); + }, DEBOUNCE_MS); + + return () => { + if (saveDebounceRef.current) { + clearTimeout(saveDebounceRef.current); + saveDebounceRef.current = null; + } + }; + }, [ + editStatus, + editTags, + editLabelComplete, + editReviewed, + image.imageId, + image.meta.status, + image.meta.tags, + image.labelComplete, + image.reviewed, + projectId, + performSave, + ]); + + const handleAddTag = () => { + const trimmed = tagInput.trim().toLowerCase(); + if (trimmed && !editTags.includes(trimmed)) { + setEditTags((prev) => [...prev, trimmed]); + } + setTagInput(''); + }; + + const handleRemoveTag = (tagToRemove: string) => { + setEditTags((prev) => prev.filter((tag) => tag !== tagToRemove)); + }; + + const handleTagKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleAddTag(); + } + }; + + const handleRetrySave = useCallback(() => { + performSave(projectId, image.imageId, { + status: editStatus, + tags: editTags, + labelComplete: editLabelComplete, + reviewed: editReviewed, + }); + }, [projectId, image.imageId, editStatus, editTags, editLabelComplete, editReviewed, performSave]); + + const highlightedMask = highlightedMaskId + ? masks.find((mask) => mask.maskId === highlightedMaskId) || null + : null; + + const selectedMask = selectedMaskId + ? masks.find((mask) => mask.maskId === selectedMaskId) || null + : null; + + const handleOverlayMouseMove = useCallback( + (maskId: string | null, _event: React.MouseEvent) => { + onOverlayMouseMove(maskId); + }, + [onOverlayMouseMove] + ); + + const handleOverlayClick = useCallback( + (maskId: string | null, event: React.MouseEvent) => { + if (!maskId) { + // Clicked empty area -> exit selection mode + setFocusedMaskIds([]); + return; + } + + if (focusedMaskIds.includes(maskId)) { + // Clicked on an already-selected mask -> open label popup + setSelectedMaskId(maskId); + setLabelPopup({ + maskId, + x: event.clientX, + y: event.clientY + 12, + }); + } else { + // Clicked on any mask -> add to selection + setFocusedMaskIds((prev) => [...prev, maskId]); + } + }, + [focusedMaskIds] + ); + + const handleMaskClick = useCallback( + (mask: MaskApiItem, event: React.MouseEvent) => { + event.stopPropagation(); + setFocusedMaskIds((prev) => { + if (prev.includes(mask.maskId)) { + // Remove from set (toggle off) + return prev.filter((id) => id !== mask.maskId); + } + // Add to set + return [...prev, mask.maskId]; + }); + }, + [] + ); + + const handleClosePopup = useCallback(() => { + setLabelPopup(null); + }, []); + + const handleAssignLabel = useCallback( + async (labelId: string | null) => { + if (!labelPopup) return; + setLabelAssigning(true); + setLabelError(null); + + // Build the set of masks to label: all selected masks + const maskIdsToLabel = focusedMaskIds.length > 0 + ? [...new Set([...focusedMaskIds, labelPopup.maskId])] + : [labelPopup.maskId]; + + try { + const updates = maskIdsToLabel.map((id) => ({ maskId: id, labelId })); + const { results } = await batchUpdateMaskLabels(projectId, updates); + + // Build a map of successful results + const resultMap = new Map(); + for (const r of results) { + if (r.success) { + resultMap.set(r.maskId, { labelId: r.labelId ?? null, color: r.color ?? null }); + } + } + + setMasks((prev) => + prev.map((mask) => { + const update = resultMap.get(mask.maskId); + return update + ? { ...mask, labelId: update.labelId, color: update.color } + : mask; + }) + ); + + if (maskMap) { + const updatedColorMap = await getColorMap(projectId, maskMap.maskMapId); + setActiveColorMap(updatedColorMap); + onColorMapUpdated(image.imageId, updatedColorMap); + } + + // Clear selection and popup + setLabelPopup(null); + setFocusedMaskIds([]); + } catch (err) { + setLabelError(err instanceof Error ? err.message : 'Failed to update label'); + } finally { + setLabelAssigning(false); + } + }, + [focusedMaskIds, image.imageId, labelPopup, maskMap, onColorMapUpdated, projectId] + ); + + const handleClearLabel = useCallback(() => { + handleAssignLabel(null); + }, [handleAssignLabel]); + + // ============ SAM Point Tool Handlers ============ + + const handleToggleSamTool = useCallback(() => { + setSamToolActive((prev) => { + if (prev) { + // Deactivating: clear points + setSamPoints([]); + setSamError(null); + } + return !prev; + }); + }, []); + + const handleImageClick = useCallback( + (point: { x: number; y: number }) => { + if (!samToolActive) return; + setSamPoints((prev) => [...prev, { x: point.x, y: point.y, label: 1 }]); + }, + [samToolActive] + ); + + const handleClearSamPoints = useCallback(() => { + setSamPoints([]); + setSamError(null); + }, []); + + /** Reload masks after import */ + const reloadMasks = useCallback(async () => { + try { + const [maskResult, overlay] = await Promise.all([ + getImageMasks(projectId, image.imageId), + getImageMaskOverlay(projectId, image.imageId), + ]); + setMasks(maskResult.masks); + setMaskMap(maskResult.maskMap); + setMaskOverlay(overlay); + if (maskResult.maskMap) { + const fetchedColorMap = await getColorMap(projectId, maskResult.maskMap.maskMapId); + setActiveColorMap(fetchedColorMap); + onColorMapUpdated(image.imageId, fetchedColorMap); + } + } catch { + // Non-critical: masks will refresh on next modal open + } + }, [image.imageId, onColorMapUpdated, projectId]); + + const handleSubmitSamPoints = useCallback(async () => { + if (samPoints.length === 0 || !image.fileUrl) return; + + setSamLoading(true); + setSamError(null); + + try { + // Step 1: Request masks from SAM backend (explicit user action only) + const samResponse = await requestSamMasks(image.fileUrl, samPoints); + + if (!samResponse.masks || samResponse.masks.length === 0) { + setSamError('SAM returned no masks for the given points.'); + return; + } + + // Step 2: Convert SAM response to import payload + const importMasks: Array<{ mask: number[]; width: number; height: number }> = []; + for (const samMask of samResponse.masks) { + if (samMask.mask && Array.isArray(samMask.mask)) { + const height = samMask.mask.length; + const width = height > 0 ? (samMask.mask[0]?.length ?? 0) : 0; + if (width > 0 && height > 0) { + const flat: number[] = []; + for (const row of samMask.mask) { + for (const val of row) { + flat.push(val > 0 ? 1 : 0); + } + } + importMasks.push({ mask: flat, width, height }); + } + } + } + + if (importMasks.length === 0) { + setSamError('Could not extract valid masks from SAM response.'); + return; + } + + // Step 3: Import into labelling backend + await importSamMasks(projectId, image.imageId, { masks: importMasks }); + + // Step 4: Refresh masks and overlay + await reloadMasks(); + + // Step 5: Deactivate tool and clear points + setSamToolActive(false); + setSamPoints([]); + } catch (err) { + setSamError(err instanceof Error ? err.message : 'SAM mask generation failed'); + } finally { + setSamLoading(false); + } + }, [samPoints, image.fileUrl, image.imageId, projectId, reloadMasks]); + + const labeledCount = masks.filter((mask) => mask.labelId !== null).length; + const unlabeledCount = Math.max(masks.length - labeledCount, 0); + const hasLabels = Object.keys(project.labels || {}).length > 0; + const hasMaskData = masks.length > 0 || Boolean(maskOverlay && maskOverlay.maskIds.length > 0); + + const showNavArrows = (hasPrevImage || hasNextImage) && (onPrevImage || onNextImage); + + return ( +
+ {showNavArrows && hasPrevImage && onPrevImage && ( + + )} + {showNavArrows && hasNextImage && onNextImage && ( + + )} +
+
+
+

Image Management

+

{image.meta.fileName}

+

{image.imageId}

+
+ +
+ +
+
+ ( +
+
+

Failed to render image overlay

+

{error.message}

+ +
+
+ )} + > + ({ x: p.x, y: p.y, color: '#ff4444' })) : []} + maskLoading={maskLoading} + statusContent={ + <> + {samLoading && ( +
+
+ Generating masks... +
+ )} + {!maskLoading && !samLoading && maskOverlay && maskOverlay.maskIds.length === 0 && !samToolActive && ( +
+ No masks available +
+ )} + {activeColorMap === undefined && !maskLoading && hasMaskData && ( +
+
+ Loading labels... +
+ )} + {activeColorMap !== undefined && + (!activeColorMap || Object.keys(activeColorMap).length === 0) && + !maskLoading && + hasMaskData && ( +
+ No labeled masks +
+ )} + + } + /> + + +
+ {maskLoading && Loading masks...} + {!maskLoading && !samToolActive && maskOverlay && ( + + Click masks to select, then click a selected mask to label all selected. + + )} + {!maskLoading && !samToolActive && !maskOverlay && ( + Masks are unavailable for this image. + )} + {samToolActive && ( + + Click on the image to place point prompts. Then click "Generate" to create masks. + + )} + {highlightedMask && !samToolActive && ( + + Hovering: Mask ({highlightedMask.size.toLocaleString()}px) + {highlightedMask.labelId ? ' - Labeled' : ' - Click to label'} + + )} +
+ + {/* SAM Point Tool Toolbar */} +
+ + {samToolActive && ( + <> + {samPoints.length} point{samPoints.length !== 1 ? 's' : ''} + + + + )} + {samError && ( + {samError} + )} +
+
+ +
+
+

Progress

+ setEditLabelComplete(event.target.checked)} + /> + Label complete + + +
+
+ +
+

Tags

+
+ setTagInput(event.target.value)} + onKeyDown={handleTagKeyDown} + /> + +
+ {editTags.length > 0 && ( +
+ {editTags.map((tag) => ( + handleRemoveTag(tag)} /> + ))} +
+ )} +
+ +
+

Masks

+
+ {masks.length} total + {labeledCount} labeled + {unlabeledCount} unlabeled +
+ {maskError &&

{maskError}

} + {masks.length === 0 ? ( +

No masks detected for this image.

+ ) : ( +
+
+ {masks.map((mask, index) => { + const label = mask.labelId ? project.labels[mask.labelId] : null; + const isSelected = selectedMask?.maskId === mask.maskId; + const isHighlighted = highlightedMaskId === mask.maskId; + const isFocused = focusedMaskIds.includes(mask.maskId); + return ( + + ); + })} +
+
+ )} +
+ +
+

Labels

+ {!hasLabels ? ( +

No labels configured for this project.

+ ) : ( +
+ {Object.values(project.labels).map((label) => ( +
+ + {label.name} +
+ ))} +
+ )} +
+
+
+ + {(saveError || labelError) && ( +
+ {saveError || labelError} + {saveError && ( + + )} + +
+ )} + +
+ +
+ + {labelPopup && hasLabels && ( +
+
+ Assign Label + +
+
+ {Object.values(project.labels).map((label) => { + const isActive = selectedMask?.labelId === label.labelId; + return ( + + ); + })} +
+ {selectedMask?.labelId && ( +
+ +
+ )} +
+ )} + + {labelPopup && !hasLabels && ( +
+
+ No labels available + +
+
+ Create labels in the project setup. +
+
+ )} +
+
+ ); +} + +function CloseIcon() { + return ( + + + + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + +function TrashIcon() { + return ( + + + + + ); +} diff --git a/src/labelling-app/frontend/src/components/PreviewGallery.css b/src/labelling-app/frontend/src/components/PreviewGallery.css index fe2aa474..5f8dc0ff 100644 --- a/src/labelling-app/frontend/src/components/PreviewGallery.css +++ b/src/labelling-app/frontend/src/components/PreviewGallery.css @@ -65,9 +65,26 @@ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } +.gallery-card-wrapper { + display: block; + cursor: pointer; + outline: none; +} + +.gallery-card-wrapper:focus-visible .gallery-card { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.gallery-card-wrapper:hover .gallery-card { + transform: translateY(-2px); + box-shadow: var(--shadow); +} + .gallery-card { display: grid; gap: 12px; + transition: transform 150ms ease, box-shadow 150ms ease; } .gallery-thumb { @@ -81,7 +98,7 @@ .gallery-thumb img { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; display: block; } diff --git a/src/labelling-app/frontend/src/components/PreviewGallery.tsx b/src/labelling-app/frontend/src/components/PreviewGallery.tsx index 55709bdd..743b96bf 100644 --- a/src/labelling-app/frontend/src/components/PreviewGallery.tsx +++ b/src/labelling-app/frontend/src/components/PreviewGallery.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { Project, ProjectImage, SparseColorMap } from '../types'; import { listImages, getColorMap } from '../modules/API_Helps'; import { Button, Card, EmptyState, LoadingState } from './ui'; @@ -244,51 +244,64 @@ function PreviewCard({ }) { const frameRef = useRef(null); const canvasRef = useRef(null); - const colorCache = useMemo(() => new Map(), []); + const colorCacheRef = useRef(new Map()); const drawOverlay = useCallback(() => { + const colorCache = colorCacheRef.current; const frame = frameRef.current; const canvas = canvasRef.current; if (!frame || !canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; - const width = frame.clientWidth; - const height = frame.clientHeight; - if (width === 0 || height === 0) return; + // Get display dimensions + const displayWidth = frame.clientWidth; + const displayHeight = frame.clientHeight; + if (displayWidth === 0 || displayHeight === 0) return; - const dpr = window.devicePixelRatio || 1; - canvas.width = Math.floor(width * dpr); - canvas.height = Math.floor(height * dpr); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + // Set canvas size to match display size (no DPR scaling for mask alignment) + // This ensures pixel-perfect alignment without subpixel rounding issues + canvas.width = displayWidth; + canvas.height = displayHeight; + canvas.style.width = `${displayWidth}px`; + canvas.style.height = `${displayHeight}px`; - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, displayWidth, displayHeight); if (!colorMap || Object.keys(colorMap).length === 0) { return; } - const srcWidth = image.meta.width || width; - const srcHeight = image.meta.height || height; + const srcWidth = image.meta.width || displayWidth; + const srcHeight = image.meta.height || displayHeight; if (!srcWidth || !srcHeight) return; - const imageData = ctx.createImageData(width, height); + // Create ImageData at display size + const imageData = ctx.createImageData(displayWidth, displayHeight); const data = imageData.data; + // Calculate scale from source (natural) to display coordinates + const scaleX = displayWidth / srcWidth; + const scaleY = displayHeight / srcHeight; + for (const [rowKey, cols] of Object.entries(colorMap)) { const row = Number(rowKey); if (!Number.isFinite(row)) continue; if (row < 0 || row >= srcHeight) continue; - const destY = Math.floor((row / srcHeight) * height); - const destRow = destY * width * 4; + + // Scale row to display coordinates + const destY = Math.floor(row * scaleY); + if (destY < 0 || destY >= displayHeight) continue; + const destRow = destY * displayWidth * 4; for (const [colKey, hexColor] of Object.entries(cols)) { const col = Number(colKey); if (!Number.isFinite(col)) continue; if (col < 0 || col >= srcWidth) continue; - const destX = Math.floor((col / srcWidth) * width); + + // Scale column to display coordinates + const destX = Math.floor(col * scaleX); + if (destX < 0 || destX >= displayWidth) continue; const dest = destRow + destX * 4; let rgb = colorCache.get(hexColor); @@ -313,7 +326,7 @@ function PreviewCard({ } ctx.putImageData(imageData, 0, 0); - }, [colorCache, colorMap, image.meta.height, image.meta.width]); + }, [colorMap, image.meta.height, image.meta.width]); useEffect(() => { drawOverlay(); diff --git a/src/labelling-app/frontend/src/components/ProjectList.tsx b/src/labelling-app/frontend/src/components/ProjectList.tsx index 908e638a..9d04ae34 100644 --- a/src/labelling-app/frontend/src/components/ProjectList.tsx +++ b/src/labelling-app/frontend/src/components/ProjectList.tsx @@ -201,11 +201,13 @@ function ProjectDetails({ project }: { project: Project }) { {project.description || 'No description provided.'}

- Created {new Date(project.createdAt || Date.now()).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} + Created {project.createdAt + ? new Date(project.createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'Unknown date'}

diff --git a/src/labelling-app/frontend/src/components/index.ts b/src/labelling-app/frontend/src/components/index.ts index 5064a228..b6023f06 100644 --- a/src/labelling-app/frontend/src/components/index.ts +++ b/src/labelling-app/frontend/src/components/index.ts @@ -1,6 +1,20 @@ +/** + * Components - Barrel exports + * + * Note: These exports are kept for backwards compatibility. + * Prefer importing from './pages' for page components + * and './components/shared' for shared components. + */ + +// Legacy exports (from old location) export { ProjectList } from './ProjectList'; export { CreateProject } from './CreateProject'; export { ImageUpload } from './ImageUpload'; -export { LabelImage } from './LabelImage'; export { PreviewGallery } from './PreviewGallery'; +export { ManagementModal } from './ManagementModal'; + +// UI components export * from './ui'; + +// Shared components +export * from './shared'; diff --git a/src/labelling-app/frontend/src/components/shared/InteractiveMapOverlay/InteractiveMapOverlay.css b/src/labelling-app/frontend/src/components/shared/InteractiveMapOverlay/InteractiveMapOverlay.css new file mode 100644 index 00000000..4ccdf713 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/InteractiveMapOverlay/InteractiveMapOverlay.css @@ -0,0 +1,63 @@ +/** + * InteractiveMapOverlay - Two-layer styles. + * Visible fills the frame; transparent overlay sits on top. + * No cross-origin pixel reading — mask overlay drawn directly to canvas. + */ + +.interactive-map-overlay { + position: relative; + width: 100%; + overflow: hidden; + background: var(--color-bg-tertiary, #1a1a1a); + border-radius: var(--radius-md, 8px); + touch-action: none; + overscroll-behavior: contain; +} + +/* Image is VISIBLE - fills the frame as the base layer */ +.interactive-map-overlay-image { + display: block; + width: 100%; + height: 100%; + object-fit: fill; +} + +/* Overlay canvas sits on top of the image */ +.interactive-map-overlay-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.interactive-map-overlay-fallback { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 200px; + color: var(--color-text-muted, #888); + font-size: 0.875rem; +} + +.interactive-map-overlay-status { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem 1.5rem; + background: rgba(0, 0, 0, 0.7); + border-radius: var(--radius-md, 8px); + color: var(--color-text-primary, #fff); + font-size: 0.875rem; +} + +.interactive-map-overlay-status.empty { + background: rgba(0, 0, 0, 0.5); +} diff --git a/src/labelling-app/frontend/src/components/shared/InteractiveMapOverlay/InteractiveMapOverlay.tsx b/src/labelling-app/frontend/src/components/shared/InteractiveMapOverlay/InteractiveMapOverlay.tsx new file mode 100644 index 00000000..9afa8343 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/InteractiveMapOverlay/InteractiveMapOverlay.tsx @@ -0,0 +1,378 @@ +/** + * InteractiveMapOverlay - Two-layer rendering (visible img + mask-only overlay canvas). + * + * Uses the same proven approach as PreviewCard and the old ManagementModal: + * - renders the image (visible, fills the frame) + * - draws mask colors as a transparent overlay on top + * + * This avoids the cross-origin canvas taint issue that occurs when reading + * image pixel data from GCS signed URLs via getImageData(). + * + * Hit-testing uses getBoundingClientRect() for zoom-immune coordinate mapping. + * ResizeObserver + matchMedia for redraw. Wheel preventDefault blocks Ctrl+zoom. + */ + +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { MaskApiItem, MaskOverlay, SparseColorMap } from '../../../types'; +import './InteractiveMapOverlay.css'; + +const DEFAULT_OVERLAY_ALPHA = 130; +const UNLABELED_RGB: readonly [number, number, number] = [59, 130, 246]; + +/** Opacity levels for mask overlay (0–255) */ +const UNLABELED_ALPHA = 50; // 10% +const LABELED_ALPHA = 200; // 80% +const FOCUSED_ALPHA = 230; // 90% +const HOVER_BOOST_ALPHA = 130; // ~50% for unlabeled hover +const HOVER_LABELED_ALPHA = 230; // ~90% for labeled hover + +/** Deterministic palette for unlabeled masks (RGB tuples) */ +const MASK_PALETTE: [number, number, number][] = [ + [230, 25, 75], [60, 180, 75], [255, 225, 25], [67, 99, 216], + [245, 130, 49], [145, 30, 180], [66, 212, 244], [240, 50, 230], + [191, 239, 69], [250, 190, 212], [70, 153, 144], [220, 190, 255], + [154, 99, 36], [255, 127, 0], [0, 128, 128], [128, 0, 128], + [255, 0, 255], [0, 255, 255], [128, 128, 0], [128, 0, 0], +]; + +function parseHex(hex: string): [number, number, number] { + const s = hex.replace('#', ''); + if (s.length < 6) return [...UNLABELED_RGB]; + const r = parseInt(s.substring(0, 2), 16); + const g = parseInt(s.substring(2, 4), 16); + const b = parseInt(s.substring(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return [...UNLABELED_RGB]; + return [r, g, b]; +} + +export interface InteractiveMapOverlayProps { + imageUrl: string | undefined; + imageAlt?: string; + imageWidth?: number; + imageHeight?: number; + colorMap: SparseColorMap | null | undefined; + maskOverlay?: MaskOverlay | null; + highlightedMaskId?: string | null; + highlightColor?: string | null; + /** Selected masks rendered at 90% opacity; other masks stay at normal opacity. */ + focusedMaskIds?: string[]; + overlayAlpha?: number; + highlightAlpha?: number; + masks?: MaskApiItem[]; + interactive?: boolean; + onMouseMove?: (maskId: string | null, event: React.MouseEvent) => void; + onMouseLeave?: () => void; + onClick?: (maskId: string | null, event: React.MouseEvent) => void; + /** Fires with normalized (0-1) image coordinates regardless of mask overlay state. */ + onImageClick?: (point: { x: number; y: number }, event: React.MouseEvent) => void; + className?: string; + maskLoading?: boolean; + statusContent?: React.ReactNode; + /** Overlay dots to render on top of the image (e.g. SAM prompt points). */ + overlayDots?: Array<{ x: number; y: number; color?: string }>; +} + +export function InteractiveMapOverlay({ + imageUrl, + imageAlt = 'Image', + imageWidth, + imageHeight, + colorMap, + maskOverlay, + highlightedMaskId, + highlightColor: _highlightColor, + focusedMaskIds = [], + overlayAlpha = DEFAULT_OVERLAY_ALPHA, + highlightAlpha: _highlightAlpha = 255, + masks = [], + interactive = false, + onMouseMove, + onMouseLeave, + onClick, + onImageClick, + className = '', + maskLoading = false, + statusContent, + overlayDots = [], +}: InteractiveMapOverlayProps) { + const frameRef = useRef(null); + const canvasRef = useRef(null); + const [, setImageLoaded] = useState(false); + + const colorCache = useMemo(() => new Map(), []); + + // ============ Overlay Drawing (mask-only, no image pixels) ============ + const drawOverlay = useCallback(() => { + const frame = frameRef.current; + const canvas = canvasRef.current; + if (!frame || !canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const rect = frame.getBoundingClientRect(); + const cssW = rect.width; + const cssH = rect.height; + if (cssW <= 0 || cssH <= 0) return; + + const intW = Math.round(cssW); + const intH = Math.round(cssH); + if (intW <= 0 || intH <= 0) return; + + canvas.width = intW; + canvas.height = intH; + + ctx.clearRect(0, 0, intW, intH); + + const srcWidth = maskOverlay?.width || imageWidth || cssW; + const srcHeight = maskOverlay?.height || imageHeight || cssH; + if (!srcWidth || !srcHeight) return; + + const hasMaskOverlay = Boolean(maskOverlay && maskOverlay.data.length > 0 && masks.length > 0); + const hasColorMap = Boolean(colorMap && Object.keys(colorMap).length > 0); + + if (!hasMaskOverlay && !hasColorMap) return; + + const imageData = ctx.createImageData(intW, intH); + const data = imageData.data; + const overlayWidth = maskOverlay?.width ?? 0; + const overlayHeight = maskOverlay?.height ?? 0; + + if (hasMaskOverlay && maskOverlay) { + const masksById = new Map(masks.map((m) => [m.maskId, m])); + const focusSet = new Set(focusedMaskIds); + const isFocusMode = focusSet.size > 0; + + // Iterate destination pixels (nearest-neighbor) to guarantee no gaps + for (let destY = 0; destY < intH; destY++) { + const srcY = Math.floor((destY / intH) * overlayHeight); + for (let destX = 0; destX < intW; destX++) { + const srcX = Math.floor((destX / intW) * overlayWidth); + const srcIdx = srcY * overlayWidth + srcX; + const maskIndex = maskOverlay.data[srcIdx]; + if (maskIndex < 0) continue; + const maskId = maskOverlay.maskIds[maskIndex]; + if (maskId === undefined) continue; + + const mask = masksById.get(maskId); + const isLabeled = mask?.labelId != null; + let rgb: [number, number, number]; + if (isLabeled && mask?.color) { + let cached = colorCache.get(mask.color); + if (!cached) { + cached = parseHex(mask.color); + colorCache.set(mask.color, cached); + } + rgb = cached; + } else { + rgb = MASK_PALETTE[maskIndex % MASK_PALETTE.length]; + } + + let alpha: number; + if (isFocusMode && focusSet.has(maskId)) { + // Selected masks get boosted opacity + alpha = FOCUSED_ALPHA; + } else if (highlightedMaskId === maskId) { + alpha = isLabeled ? HOVER_LABELED_ALPHA : HOVER_BOOST_ALPHA; + } else { + // Non-selected masks keep their normal opacity + alpha = isLabeled ? LABELED_ALPHA : UNLABELED_ALPHA; + } + + const dest = (destY * intW + destX) * 4; + data[dest] = rgb[0]; + data[dest + 1] = rgb[1]; + data[dest + 2] = rgb[2]; + data[dest + 3] = alpha; + } + } + } else if (hasColorMap && colorMap) { + for (const [rowKey, cols] of Object.entries(colorMap)) { + const row = Number(rowKey); + if (!Number.isFinite(row) || row < 0 || row >= srcHeight) continue; + const destY = Math.floor((row / srcHeight) * intH); + const destRow = destY * intW * 4; + for (const [colKey, hexColor] of Object.entries(cols)) { + const col = Number(colKey); + if (!Number.isFinite(col) || col < 0 || col >= srcWidth) continue; + const destX = Math.floor((col / srcWidth) * intW); + const dest = destRow + destX * 4; + let rgb = colorCache.get(hexColor); + if (!rgb) { + rgb = parseHex(hexColor); + colorCache.set(hexColor, rgb); + } + data[dest] = rgb[0]; + data[dest + 1] = rgb[1]; + data[dest + 2] = rgb[2]; + data[dest + 3] = overlayAlpha; + } + } + } + + ctx.putImageData(imageData, 0, 0); + }, [ + colorMap, + colorCache, + focusedMaskIds, + highlightedMaskId, + imageHeight, + imageWidth, + maskOverlay, + masks, + overlayAlpha, + ]); + + // Redraw when overlay data changes + useEffect(() => { + drawOverlay(); + }, [drawOverlay]); + + // Redraw when frame size changes (browser zoom, layout shift) + useEffect(() => { + const frame = frameRef.current; + if (!frame) return; + const observer = new ResizeObserver(() => drawOverlay()); + observer.observe(frame); + return () => observer.disconnect(); + }, [drawOverlay]); + + // Prevent Ctrl+wheel / Meta+wheel browser zoom over the overlay + useLayoutEffect(() => { + const frame = frameRef.current; + if (!frame) return; + const onWheel = (e: WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + } + }; + frame.addEventListener('wheel', onWheel, { passive: false }); + return () => frame.removeEventListener('wheel', onWheel); + }, []); + + // ============ Mouse Handlers ============ + const getMaskAtPosition = useCallback( + (clientX: number, clientY: number): string | null => { + const frame = frameRef.current; + if (!frame || !maskOverlay || maskOverlay.width === 0 || maskOverlay.height === 0) return null; + + const rect = frame.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + const relativeX = (clientX - rect.left) / rect.width; + const relativeY = (clientY - rect.top) / rect.height; + if (relativeX < 0 || relativeX > 1 || relativeY < 0 || relativeY > 1) return null; + const col = Math.floor(relativeX * maskOverlay.width); + const row = Math.floor(relativeY * maskOverlay.height); + const idx = row * maskOverlay.width + col; + const maskIndex = maskOverlay.data[idx]; + if (maskIndex === undefined || maskIndex < 0) return null; + return maskOverlay.maskIds[maskIndex] ?? null; + }, + [maskOverlay] + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + if (!interactive || !onMouseMove) return; + const maskId = getMaskAtPosition(event.clientX, event.clientY); + onMouseMove(maskId, event); + }, + [interactive, onMouseMove, getMaskAtPosition] + ); + + const handleMouseLeave = useCallback(() => { + if (!interactive || !onMouseLeave) return; + onMouseLeave(); + }, [interactive, onMouseLeave]); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + // Always emit normalized image coordinates if handler provided + if (onImageClick) { + const frame = frameRef.current; + if (frame) { + const rect = frame.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)); + onImageClick({ x, y }, event); + } + } + } + if (!interactive || !onClick) return; + const maskId = getMaskAtPosition(event.clientX, event.clientY); + onClick(maskId, event); + }, + [interactive, onClick, onImageClick, getMaskAtPosition] + ); + + // ============ Render ============ + const aspectRatio = + imageWidth && imageHeight ? `${imageWidth} / ${imageHeight}` : '1 / 1'; + + return ( +
+ {imageUrl ? ( + {imageAlt} setImageLoaded(true)} + onError={() => setImageLoaded(false)} + /> + ) : ( +
+ No image URL +
+ )} + + + + {maskLoading && ( +
+
+ Loading masks... +
+ )} + + {statusContent} + + {overlayDots.length > 0 && overlayDots.map((dot, i) => ( +
+ ))} +
+ ); +} + +export default InteractiveMapOverlay; diff --git a/src/labelling-app/frontend/src/components/shared/InteractiveMapOverlay/index.ts b/src/labelling-app/frontend/src/components/shared/InteractiveMapOverlay/index.ts new file mode 100644 index 00000000..c21b7432 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/InteractiveMapOverlay/index.ts @@ -0,0 +1,5 @@ +/** + * InteractiveMapOverlay - Barrel exports + */ + +export { InteractiveMapOverlay, type InteractiveMapOverlayProps } from './InteractiveMapOverlay'; diff --git a/src/labelling-app/frontend/src/components/shared/LabelPopup/LabelPopup.css b/src/labelling-app/frontend/src/components/shared/LabelPopup/LabelPopup.css new file mode 100644 index 00000000..c2921a0b --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/LabelPopup/LabelPopup.css @@ -0,0 +1,138 @@ +/** + * LabelPopup - Shared popup component styles + */ + +.label-popup { + position: fixed; + z-index: 1000; + min-width: 200px; + background: var(--color-bg-elevated, #2a2a2a); + border: 1px solid var(--color-border, #333); + border-radius: var(--radius-lg, 12px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + overflow: hidden; +} + +.label-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border, #333); + font-weight: 500; + font-size: 0.875rem; +} + +.label-popup-close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--radius-sm, 4px); + color: var(--color-text-muted, #888); + cursor: pointer; + transition: background-color 0.15s, color 0.15s; +} + +.label-popup-close:hover { + background: var(--color-bg-tertiary, #333); + color: var(--color-text-primary, #fff); +} + +.label-popup-options { + display: flex; + flex-direction: column; + padding: 0.5rem; +} + +.label-popup-option { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + background: transparent; + border: none; + border-radius: var(--radius-md, 8px); + color: var(--color-text-primary, #fff); + font-size: 0.875rem; + text-align: left; + cursor: pointer; + transition: background-color 0.15s; +} + +.label-popup-option:hover:not(:disabled) { + background: var(--color-bg-tertiary, #333); +} + +.label-popup-option:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.label-popup-option.active { + background: var(--color-bg-active, rgba(59, 130, 246, 0.15)); +} + +.option-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background-color: var(--option-color, #888); + flex-shrink: 0; +} + +.option-name { + flex: 1; +} + +.option-check { + color: var(--color-primary, #3b82f6); + flex-shrink: 0; +} + +.label-popup-footer { + padding: 0.5rem; + border-top: 1px solid var(--color-border, #333); +} + +.label-popup-delete { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + background: transparent; + border: none; + border-radius: var(--radius-md, 8px); + color: var(--color-danger, #ef4444); + font-size: 0.8125rem; + cursor: pointer; + transition: background-color 0.15s; +} + +.label-popup-delete:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.1); +} + +.label-popup-delete:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.label-popup-hint { + padding: 0.5rem 0.75rem; + color: var(--color-text-muted, #888); + font-size: 0.8125rem; +} + +.label-popup-backdrop { + position: fixed; + inset: 0; + z-index: 999; + background: transparent; +} diff --git a/src/labelling-app/frontend/src/components/shared/LabelPopup/LabelPopup.tsx b/src/labelling-app/frontend/src/components/shared/LabelPopup/LabelPopup.tsx new file mode 100644 index 00000000..bfa398db --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/LabelPopup/LabelPopup.tsx @@ -0,0 +1,196 @@ +/** + * LabelPopup - Shared popup component for assigning labels to masks + * Used by LabelImage and ManagementModal + */ + +import { useCallback, useEffect, useRef } from 'react'; +import type { Label, LabelsMap, MaskApiItem } from '../../../types'; +import './LabelPopup.css'; + +// ============ Types ============ +export interface LabelPopupPosition { + x: number; + y: number; +} + +export interface LabelPopupProps { + /** Position on screen */ + position: LabelPopupPosition; + /** Available labels */ + labels: LabelsMap; + /** Currently selected mask */ + selectedMask: MaskApiItem | null; + /** Whether label assignment is in progress */ + assigning?: boolean; + /** Callback when a label is selected */ + onAssignLabel: (labelId: string | null) => void; + /** Callback to close the popup */ + onClose: () => void; +} + +// ============ Icons ============ +function CloseIcon() { + return ( + + + + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + +function TrashIcon() { + return ( + + + + + ); +} + +// ============ Component ============ +export function LabelPopup({ + position, + labels, + selectedMask, + assigning = false, + onAssignLabel, + onClose, +}: LabelPopupProps) { + const popupRef = useRef(null); + const hasLabels = Object.keys(labels || {}).length > 0; + + // ============ Click Outside Handler ============ + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if (popupRef.current?.contains(event.target as Node)) return; + onClose(); + }; + + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, [onClose]); + + // ============ Keyboard Handler ============ + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // ============ Handlers ============ + const handleLabelClick = useCallback( + (labelId: string) => { + if (assigning) return; + onAssignLabel(labelId); + }, + [assigning, onAssignLabel] + ); + + const handleClearLabel = useCallback(() => { + if (assigning) return; + onAssignLabel(null); + }, [assigning, onAssignLabel]); + + // ============ Render - No Labels ============ + if (!hasLabels) { + return ( + <> +
+
+ No labels available + +
+
+ Create labels in the project setup. +
+
+
+ + ); + } + + // ============ Render - With Labels ============ + const labelsList = Object.values(labels); + + return ( + <> +
+
+ Assign Label + +
+ +
+ {labelsList.map((label: Label) => { + const isActive = selectedMask?.labelId === label.labelId; + return ( + + ); + })} +
+ + {selectedMask?.labelId && ( +
+ +
+ )} +
+ +
+ + ); +} + +export default LabelPopup; diff --git a/src/labelling-app/frontend/src/components/shared/LabelPopup/index.ts b/src/labelling-app/frontend/src/components/shared/LabelPopup/index.ts new file mode 100644 index 00000000..b95f6082 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/LabelPopup/index.ts @@ -0,0 +1,5 @@ +/** + * LabelPopup - Shared popup component exports + */ + +export { LabelPopup, type LabelPopupProps, type LabelPopupPosition } from './LabelPopup'; diff --git a/src/labelling-app/frontend/src/components/shared/MaskCanvas/MaskCanvas.css b/src/labelling-app/frontend/src/components/shared/MaskCanvas/MaskCanvas.css new file mode 100644 index 00000000..51b2d237 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/MaskCanvas/MaskCanvas.css @@ -0,0 +1,79 @@ +/** + * MaskCanvas - Shared canvas component styles + */ + +.mask-canvas { + position: relative; + width: 100%; + overflow: hidden; + background: var(--color-bg-tertiary, #1a1a1a); + border-radius: var(--radius-md, 8px); + /* Prevent touch zoom / pan gestures on the canvas area */ + touch-action: none; + /* Prevent scroll-chaining to parent when scrolling inside canvas */ + overscroll-behavior: contain; +} + +.mask-canvas-image { + display: block; + width: 100%; + height: 100%; + /* Use 'fill' instead of 'contain' to ensure image fills container exactly. + The container's aspect-ratio (set via JS) prevents distortion. + 'contain' would cause letterboxing that misaligns with the canvas overlay. */ + object-fit: fill; +} + +/* Hide the source element — the canvas shows the composite (image + mask). + We keep it in the DOM so it loads and provides naturalWidth/naturalHeight, + but it must be invisible to avoid misalignment with the canvas on zoom. */ +.mask-canvas-image-hidden { + position: absolute; + inset: 0; + visibility: hidden; + pointer-events: none; +} + +.mask-canvas-overlay { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +/* Single composite canvas — fills the frame exactly */ +.mask-canvas-overlay-single { + width: 100%; + height: 100%; +} + +.mask-canvas-fallback { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 200px; + color: var(--color-text-muted, #888); + font-size: 0.875rem; +} + +.mask-canvas-status { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem 1.5rem; + background: rgba(0, 0, 0, 0.7); + border-radius: var(--radius-md, 8px); + color: var(--color-text-primary, #fff); + font-size: 0.875rem; +} + +.mask-canvas-status.empty { + background: rgba(0, 0, 0, 0.5); +} diff --git a/src/labelling-app/frontend/src/components/shared/MaskCanvas/MaskCanvas.tsx b/src/labelling-app/frontend/src/components/shared/MaskCanvas/MaskCanvas.tsx new file mode 100644 index 00000000..760178a5 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/MaskCanvas/MaskCanvas.tsx @@ -0,0 +1,297 @@ +/** + * MaskCanvas - Shared canvas component for rendering mask overlays + * Uses a single composite buffer (image + mask) and one canvas. No separate image layer. + * Used by LabelImage, ManagementModal, and PreviewGallery + */ + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { MaskOverlay, SparseColorMap } from '../../../types'; +import type { CompositeBuffer } from '../../../types/compositeBuffer'; +import { + createCompositeBuffer, + fillImageIntoBuffer, + applyMaskToBuffer, + renderBufferToCanvas, +} from '../../../utils/compositeBuffer'; +import './MaskCanvas.css'; + +// ============ Constants ============ +const DEFAULT_OVERLAY_ALPHA = 130; +const DEFAULT_HIGHLIGHT_ALPHA = 255; + +// ============ Types ============ +export interface MaskCanvasProps { + /** Image source URL */ + imageUrl: string | undefined; + /** Image alt text */ + imageAlt?: string; + /** Image dimensions for aspect ratio */ + imageWidth?: number; + imageHeight?: number; + /** Color map for labeled masks */ + colorMap: SparseColorMap | null | undefined; + /** Mask overlay data for hover detection */ + maskOverlay?: MaskOverlay | null; + /** Currently highlighted mask ID */ + highlightedMaskId?: string | null; + /** Color for highlighted mask (defaults to label color or blue) */ + highlightColor?: string | null; + /** Opacity for labeled masks (0-255) */ + overlayAlpha?: number; + /** Opacity for highlighted mask (0-255) */ + highlightAlpha?: number; + /** Whether to enable mouse interactions */ + interactive?: boolean; + /** Callback when mouse moves over canvas */ + onMouseMove?: (maskId: string | null, event: React.MouseEvent) => void; + /** Callback when mouse leaves canvas */ + onMouseLeave?: () => void; + /** Callback when canvas is clicked */ + onClick?: (maskId: string | null, event: React.MouseEvent) => void; + /** CSS class name */ + className?: string; + /** Loading state for mask data */ + maskLoading?: boolean; + /** Whether image is loading */ + imageLoading?: boolean; + /** Status overlay content */ + statusContent?: React.ReactNode; +} + +// ============ Component ============ +export function MaskCanvas({ + imageUrl, + imageAlt = 'Image', + imageWidth, + imageHeight, + colorMap, + maskOverlay, + highlightedMaskId, + highlightColor, + overlayAlpha = DEFAULT_OVERLAY_ALPHA, + highlightAlpha = DEFAULT_HIGHLIGHT_ALPHA, + interactive = false, + onMouseMove, + onMouseLeave, + onClick, + className = '', + maskLoading = false, + statusContent, +}: MaskCanvasProps) { + const frameRef = useRef(null); + const canvasRef = useRef(null); + const imageRef = useRef(null); + const bufferRef = useRef(null); + const [imageLoaded, setImageLoaded] = useState(false); + + const redraw = useCallback(() => { + const buffer = bufferRef.current; + const canvas = canvasRef.current; + const frame = frameRef.current; + if (!buffer || !canvas || !frame) return; + + // Use getBoundingClientRect for sub-pixel accurate CSS dimensions. + // clientWidth/clientHeight rounds to integers and causes mask↔image drift on + // fractional browser-zoom levels. + const rect = frame.getBoundingClientRect(); + const cssW = rect.width; + const cssH = rect.height; + if (cssW <= 0 || cssH <= 0) return; + + // Scale the canvas bitmap by devicePixelRatio so it stays crisp after + // Ctrl+scroll zoom, pinch-zoom, and on HiDPI screens. + const dpr = window.devicePixelRatio || 1; + const physW = Math.round(cssW * dpr); + const physH = Math.round(cssH * dpr); + + renderBufferToCanvas(buffer, canvas, physW, physH); + + // CSS display size must match the container exactly (fractional px OK). + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + }, []); + + // Build or update composite buffer when image loads or mask data changes + useEffect(() => { + const img = imageRef.current; + if (!img || !img.complete) return; + + const nw = img.naturalWidth; + const nh = img.naturalHeight; + if (nw === 0 || nh === 0) return; + + let buffer = bufferRef.current; + if (!buffer || buffer.width !== nw || buffer.height !== nh) { + buffer = createCompositeBuffer(nw, nh); + bufferRef.current = buffer; + fillImageIntoBuffer(buffer, img); + } else if (!imageLoaded) { + fillImageIntoBuffer(buffer, img); + } + + applyMaskToBuffer(buffer, { + maskOverlay: maskOverlay ?? null, + colorMap, + highlightedMaskId: highlightedMaskId ?? null, + highlightColor: highlightColor ?? null, + overlayAlpha, + highlightAlpha, + }); + + queueMicrotask(() => setImageLoaded(true)); + redraw(); + }, [ + imageLoaded, + imageUrl, + colorMap, + maskOverlay, + highlightedMaskId, + highlightColor, + overlayAlpha, + highlightAlpha, + redraw, + ]); + + // Image onLoad: trigger buffer build + const handleImageLoad = useCallback(() => { + const img = imageRef.current; + if (!img) return; + const nw = img.naturalWidth; + const nh = img.naturalHeight; + if (nw === 0 || nh === 0) return; + const buffer = createCompositeBuffer(nw, nh); + bufferRef.current = buffer; + fillImageIntoBuffer(buffer, img); + setImageLoaded(true); + }, []); + + // When imageUrl changes, reset so we rebuild on new image load + useEffect(() => { + if (!imageUrl) { + bufferRef.current = null; + queueMicrotask(() => setImageLoaded(false)); + } + }, [imageUrl]); + + // Redraw when frame size changes (including browser-zoom driven resize) + useEffect(() => { + const frame = frameRef.current; + if (!frame) return; + const observer = new ResizeObserver(redraw); + observer.observe(frame); + return () => observer.disconnect(); + }, [redraw]); + + // Redraw when devicePixelRatio changes (e.g. drag window between monitors, + // or Ctrl+scroll zoom that doesn't trigger a resize). + useEffect(() => { + const mql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + const onChange = () => redraw(); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, [redraw]); + + // Prevent Ctrl+wheel / Meta+wheel from zooming the browser while the + // cursor is over the canvas. Must use native listener with { passive: false } + // because React's synthetic onWheel is passive and cannot preventDefault. + useLayoutEffect(() => { + const frame = frameRef.current; + if (!frame) return; + const onWheel = (e: WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + } + }; + frame.addEventListener('wheel', onWheel, { passive: false }); + return () => frame.removeEventListener('wheel', onWheel); + }, []); + + // ============ Mouse Handlers ============ + const getMaskAtPosition = useCallback( + (clientX: number, clientY: number): string | null => { + if (!maskOverlay || !frameRef.current) return null; + const rect = frameRef.current.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + if (maskOverlay.width === 0 || maskOverlay.height === 0) return null; + const relativeX = (clientX - rect.left) / rect.width; + const relativeY = (clientY - rect.top) / rect.height; + if (relativeX < 0 || relativeX > 1 || relativeY < 0 || relativeY > 1) return null; + const col = Math.floor(relativeX * maskOverlay.width); + const row = Math.floor(relativeY * maskOverlay.height); + const idx = row * maskOverlay.width + col; + const maskIndex = maskOverlay.data[idx]; + if (maskIndex === undefined || maskIndex < 0) return null; + return maskOverlay.maskIds[maskIndex] ?? null; + }, + [maskOverlay] + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + if (!interactive || !onMouseMove) return; + const maskId = getMaskAtPosition(event.clientX, event.clientY); + onMouseMove(maskId, event); + }, + [interactive, onMouseMove, getMaskAtPosition] + ); + + const handleMouseLeave = useCallback(() => { + if (!interactive || !onMouseLeave) return; + onMouseLeave(); + }, [interactive, onMouseLeave]); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (!interactive || !onClick) return; + const maskId = getMaskAtPosition(event.clientX, event.clientY); + onClick(maskId, event); + }, + [interactive, onClick, getMaskAtPosition] + ); + + // ============ Render ============ + const aspectRatio = + imageWidth && imageHeight ? `${imageWidth} / ${imageHeight}` : '1 / 1'; + + return ( +
+ {imageUrl ? ( + {imageAlt} + ) : ( +
+ No image URL +
+ )} + + + + {maskLoading && ( +
+
+ Loading masks... +
+ )} + + {statusContent} +
+ ); +} + +export default MaskCanvas; diff --git a/src/labelling-app/frontend/src/components/shared/MaskCanvas/index.ts b/src/labelling-app/frontend/src/components/shared/MaskCanvas/index.ts new file mode 100644 index 00000000..42cd74f5 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/MaskCanvas/index.ts @@ -0,0 +1,6 @@ +/** + * MaskCanvas - Shared canvas component exports + */ + +export { MaskCanvas, type MaskCanvasProps } from './MaskCanvas'; +export { useMaskHover, type UseMaskHoverOptions, type UseMaskHoverReturn } from './useMaskHover'; diff --git a/src/labelling-app/frontend/src/components/shared/MaskCanvas/useMaskHover.ts b/src/labelling-app/frontend/src/components/shared/MaskCanvas/useMaskHover.ts new file mode 100644 index 00000000..1e6f8ddd --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/MaskCanvas/useMaskHover.ts @@ -0,0 +1,107 @@ +/** + * useMaskHover - Hook for managing mask hover state with delay + * Provides hover detection with configurable delay before highlighting + */ + +import { useCallback, useRef, useState } from 'react'; + +// ============ Constants ============ +const DEFAULT_HOVER_DELAY_MS = 0; + +// ============ Types ============ +export interface UseMaskHoverOptions { + /** Delay in ms before highlighting (default: 1000ms) */ + hoverDelay?: number; + /** Callback when mask is highlighted */ + onHighlight?: (maskId: string | null) => void; +} + +export interface UseMaskHoverReturn { + /** Currently hovered mask ID (immediate) */ + hoveredMaskId: string | null; + /** Currently highlighted mask ID (after delay) */ + highlightedMaskId: string | null; + /** Handler for mouse move events */ + handleMouseMove: (maskId: string | null) => void; + /** Handler for mouse leave events */ + handleMouseLeave: () => void; + /** Reset all hover state */ + reset: () => void; +} + +// ============ Hook ============ + +/** + * Manages mask hover state with a delay before highlighting + * @param options Configuration options + * @returns Hover state and handlers + */ +export function useMaskHover(options: UseMaskHoverOptions = {}): UseMaskHoverReturn { + const { hoverDelay = DEFAULT_HOVER_DELAY_MS, onHighlight } = options; + + const [hoveredMaskId, setHoveredMaskId] = useState(null); + const [highlightedMaskId, setHighlightedMaskId] = useState(null); + const hoverTimerRef = useRef | null>(null); + + /** + * Clear the hover timer + */ + const clearTimer = useCallback(() => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + + /** + * Handle mouse move - update hover state and start delay timer + */ + const handleMouseMove = useCallback( + (maskId: string | null) => { + if (maskId === hoveredMaskId) return; + + setHoveredMaskId(maskId); + clearTimer(); + + if (!maskId) { + setHighlightedMaskId(null); + onHighlight?.(null); + } else { + hoverTimerRef.current = setTimeout(() => { + setHighlightedMaskId(maskId); + onHighlight?.(maskId); + }, hoverDelay); + } + }, + [clearTimer, hoverDelay, hoveredMaskId, onHighlight] + ); + + /** + * Handle mouse leave - clear all hover state + */ + const handleMouseLeave = useCallback(() => { + setHoveredMaskId(null); + setHighlightedMaskId(null); + clearTimer(); + onHighlight?.(null); + }, [clearTimer, onHighlight]); + + /** + * Reset all hover state + */ + const reset = useCallback(() => { + setHoveredMaskId(null); + setHighlightedMaskId(null); + clearTimer(); + }, [clearTimer]); + + return { + hoveredMaskId, + highlightedMaskId, + handleMouseMove, + handleMouseLeave, + reset, + }; +} + +export default useMaskHover; diff --git a/src/labelling-app/frontend/src/components/shared/Navigation/Sidebar.css b/src/labelling-app/frontend/src/components/shared/Navigation/Sidebar.css new file mode 100644 index 00000000..fc8b65a6 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/Navigation/Sidebar.css @@ -0,0 +1,7 @@ +/** + * Sidebar - Navigation sidebar styles + * Note: Most styles are defined in app-shell.css + * This file contains any sidebar-specific overrides + */ + +/* Sidebar-specific styles can be added here if needed */ diff --git a/src/labelling-app/frontend/src/components/shared/Navigation/Sidebar.tsx b/src/labelling-app/frontend/src/components/shared/Navigation/Sidebar.tsx new file mode 100644 index 00000000..6e84b9b6 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/Navigation/Sidebar.tsx @@ -0,0 +1,69 @@ +/** + * Sidebar - Main navigation sidebar component + * Provides navigation between app routes and displays active project info + */ + +import { NAV_ITEMS } from './navItems'; +import type { SidebarProps } from './navItems'; +import './Sidebar.css'; + +export function Sidebar({ + currentRoute, + selectedProject, + onNavigate, +}: SidebarProps) { + return ( + + ); +} + +export default Sidebar; diff --git a/src/labelling-app/frontend/src/components/shared/Navigation/index.ts b/src/labelling-app/frontend/src/components/shared/Navigation/index.ts new file mode 100644 index 00000000..321b78ed --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/Navigation/index.ts @@ -0,0 +1,6 @@ +/** + * Navigation - Sidebar component exports + */ + +export { Sidebar } from './Sidebar'; +export { NAV_ITEMS, type SidebarProps } from './navItems'; diff --git a/src/labelling-app/frontend/src/components/shared/Navigation/navItems.ts b/src/labelling-app/frontend/src/components/shared/Navigation/navItems.ts new file mode 100644 index 00000000..a26ca5b2 --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/Navigation/navItems.ts @@ -0,0 +1,18 @@ +/** + * Navigation items and types for Sidebar + */ + +import type { RouteId, Project, NavItem } from '../../../types'; + +export const NAV_ITEMS: NavItem[] = [ + { id: 'projects', label: 'Projects', description: 'Browse and select', icon: 'P' }, + { id: 'create', label: 'Create', description: 'New project setup', icon: 'C' }, + { id: 'upload', label: 'Upload', description: 'Add new images', icon: 'U' }, + { id: 'preview', label: 'Manage', description: 'Review + update', icon: 'M' }, +]; + +export interface SidebarProps { + currentRoute: RouteId; + selectedProject: Project | null; + onNavigate: (route: RouteId) => void; +} diff --git a/src/labelling-app/frontend/src/components/shared/index.ts b/src/labelling-app/frontend/src/components/shared/index.ts new file mode 100644 index 00000000..6334939c --- /dev/null +++ b/src/labelling-app/frontend/src/components/shared/index.ts @@ -0,0 +1,19 @@ +/** + * Shared components - Barrel exports + */ + +// MaskCanvas - Canvas component for mask overlays +export { MaskCanvas, useMaskHover } from './MaskCanvas'; +export type { MaskCanvasProps, UseMaskHoverOptions, UseMaskHoverReturn } from './MaskCanvas'; + +// InteractiveMapOverlay - Single canvas composite for ManagementModal +export { InteractiveMapOverlay } from './InteractiveMapOverlay'; +export type { InteractiveMapOverlayProps } from './InteractiveMapOverlay'; + +// LabelPopup - Popup for assigning labels +export { LabelPopup } from './LabelPopup'; +export type { LabelPopupProps, LabelPopupPosition } from './LabelPopup'; + +// Navigation - Sidebar component +export { Sidebar, NAV_ITEMS } from './Navigation'; +export type { SidebarProps } from './Navigation'; diff --git a/src/labelling-app/frontend/src/components/ui/ErrorBoundary.css b/src/labelling-app/frontend/src/components/ui/ErrorBoundary.css new file mode 100644 index 00000000..2b06a7cf --- /dev/null +++ b/src/labelling-app/frontend/src/components/ui/ErrorBoundary.css @@ -0,0 +1,33 @@ +/** + * ErrorBoundary - Fallback UI styles + */ + +.error-boundary-fallback { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 200px; + padding: 2rem; + background: var(--color-bg-secondary, #f5f5f5); + border-radius: var(--radius-md, 8px); + border: 1px solid var(--color-border, #e0e0e0); +} + +.error-boundary-content { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.error-boundary-content h4 { + margin: 0; + color: var(--color-text-primary, #333); +} + +.error-boundary-content .muted { + color: var(--color-text-muted, #888); + margin: 0; +} diff --git a/src/labelling-app/frontend/src/components/ui/ErrorBoundary.tsx b/src/labelling-app/frontend/src/components/ui/ErrorBoundary.tsx new file mode 100644 index 00000000..58dbef73 --- /dev/null +++ b/src/labelling-app/frontend/src/components/ui/ErrorBoundary.tsx @@ -0,0 +1,64 @@ +/** + * ErrorBoundary - React error boundary for catching render errors. + * Displays a fallback UI instead of crashing the whole page. + */ + +import { Component } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; + +export interface ErrorBoundaryProps { + /** Content to render when no error */ + children: ReactNode; + /** Custom fallback UI (receives error and reset function) */ + fallback?: (error: Error, reset: () => void) => ReactNode; + /** Callback when an error is caught */ + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('[ErrorBoundary] Caught error:', error, errorInfo); + this.props.onError?.(error, errorInfo); + } + + reset = () => { + this.setState({ error: null }); + }; + + render() { + if (this.state.error) { + if (this.props.fallback) { + return this.props.fallback(this.state.error, this.reset); + } + + return ( +
+
+

Something went wrong

+

{this.state.error.message}

+ +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/labelling-app/frontend/src/components/ui/Modal.css b/src/labelling-app/frontend/src/components/ui/Modal.css index 02a019fa..7a066ac5 100644 --- a/src/labelling-app/frontend/src/components/ui/Modal.css +++ b/src/labelling-app/frontend/src/components/ui/Modal.css @@ -5,8 +5,9 @@ background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); display: flex; - align-items: center; + align-items: flex-start; justify-content: center; + overflow-y: auto; z-index: 100; padding: 24px; } diff --git a/src/labelling-app/frontend/src/components/ui/Modal.tsx b/src/labelling-app/frontend/src/components/ui/Modal.tsx index e82585bc..4e58f626 100644 --- a/src/labelling-app/frontend/src/components/ui/Modal.tsx +++ b/src/labelling-app/frontend/src/components/ui/Modal.tsx @@ -1,8 +1,9 @@ import { useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import type { ModalProps } from '../../types'; import './Modal.css'; -export function Modal({ isOpen, onClose, title, children }: ModalProps) { +export function Modal({ isOpen, onClose, title, children, contentClassName = '' }: ModalProps) { const handleEscape = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); @@ -22,9 +23,12 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) { if (!isOpen) return null; - return ( + const modalNode = (
-
e.stopPropagation()}> +
e.stopPropagation()} + > {title && (

{title}

@@ -39,6 +43,8 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
); + + return createPortal(modalNode, document.body); } export function ConfirmModal({ diff --git a/src/labelling-app/frontend/src/components/ui/index.ts b/src/labelling-app/frontend/src/components/ui/index.ts index d06577e0..d0581554 100644 --- a/src/labelling-app/frontend/src/components/ui/index.ts +++ b/src/labelling-app/frontend/src/components/ui/index.ts @@ -4,3 +4,5 @@ export { Modal, ConfirmModal } from './Modal'; export { Card, StatCard } from './Card'; export { Badge, ClassBadge, StatusBadge, TagBadge } from './Badge'; export { EmptyState, LoadingState } from './EmptyState'; +export { ErrorBoundary } from './ErrorBoundary'; +export type { ErrorBoundaryProps } from './ErrorBoundary'; diff --git a/src/labelling-app/frontend/src/context/AuthContext.tsx b/src/labelling-app/frontend/src/context/AuthContext.tsx new file mode 100644 index 00000000..77d929e2 --- /dev/null +++ b/src/labelling-app/frontend/src/context/AuthContext.tsx @@ -0,0 +1,85 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { onAuthStateChanged, signInWithPopup, signOut, type User } from 'firebase/auth'; +import { auth, googleAuthProvider } from '../firebaseconfig'; + +type AuthContextValue = { + user: User | null; + loading: boolean; + authenticating: boolean; + error: string | null; + signInWithGoogle: () => Promise; + signOutUser: () => Promise; + clearError: () => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [authenticating, setAuthenticating] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (nextUser) => { + setUser(nextUser); + setLoading(false); + }); + + return unsubscribe; + }, []); + + const clearError = useCallback(() => { + setError(null); + }, []); + + const signInWithGoogle = useCallback(async () => { + setAuthenticating(true); + setError(null); + try { + await signInWithPopup(auth, googleAuthProvider); + } catch (err) { + const message = err instanceof Error ? err.message : 'Google sign-in failed'; + setError(message); + throw err; + } finally { + setAuthenticating(false); + } + }, []); + + const signOutUser = useCallback(async () => { + setError(null); + await signOut(auth); + }, []); + + const value = useMemo( + () => ({ + user, + loading, + authenticating, + error, + signInWithGoogle, + signOutUser, + clearError, + }), + [user, loading, authenticating, error, signInWithGoogle, signOutUser, clearError] + ); + + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/src/labelling-app/frontend/src/firebaseconfig.ts b/src/labelling-app/frontend/src/firebaseconfig.ts index cc618dba..f680d38e 100644 --- a/src/labelling-app/frontend/src/firebaseconfig.ts +++ b/src/labelling-app/frontend/src/firebaseconfig.ts @@ -1,6 +1,6 @@ import { getStorage} from "firebase/storage"; import { initializeApp } from "firebase/app"; -import {getAuth} from "firebase/auth"; +import { getAuth, GoogleAuthProvider } from "firebase/auth"; export const firebaseConfig = { @@ -16,3 +16,4 @@ export const firebaseConfig = { const app = initializeApp(firebaseConfig); export const storage = getStorage(app); export const auth = getAuth(app); +export const googleAuthProvider = new GoogleAuthProvider(); \ No newline at end of file diff --git a/src/labelling-app/frontend/src/hooks/index.ts b/src/labelling-app/frontend/src/hooks/index.ts new file mode 100644 index 00000000..757cbc5a --- /dev/null +++ b/src/labelling-app/frontend/src/hooks/index.ts @@ -0,0 +1,19 @@ +/** + * Custom hooks - Barrel exports + */ + +// Notifications +export { useNotifications } from './useNotifications'; +export type { UseNotificationsReturn } from './useNotifications'; + +// Lock management +export { useLockManagement } from './useLockManagement'; +export type { UseLockManagementOptions, UseLockManagementReturn } from './useLockManagement'; + +// Project state +export { useProjectState } from './useProjectState'; +export type { UseProjectStateReturn } from './useProjectState'; + +// Image queue +export { useImageQueue } from './useImageQueue'; +export type { UseImageQueueOptions, UseImageQueueReturn } from './useImageQueue'; diff --git a/src/labelling-app/frontend/src/hooks/useImageQueue.ts b/src/labelling-app/frontend/src/hooks/useImageQueue.ts new file mode 100644 index 00000000..dbbe0f8f --- /dev/null +++ b/src/labelling-app/frontend/src/hooks/useImageQueue.ts @@ -0,0 +1,175 @@ +/** + * useImageQueue - Hook for managing the labeling image queue + * Handles loading available images and tracking queue navigation + */ + +import { useCallback, useState } from 'react'; +import type { ProjectImage } from '../types'; +import { getAvailableImages } from '../modules/API_Helps'; + +// ============ Constants ============ +const DEFAULT_BATCH_SIZE = 5; + +// ============ Types ============ +export interface UseImageQueueOptions { + /** Number of images to fetch per batch (default: 5) */ + batchSize?: number; +} + +export interface UseImageQueueReturn { + /** Available images in the queue */ + images: ProjectImage[]; + /** Current index in the queue */ + currentIndex: number; + /** Current image */ + currentImage: ProjectImage | null; + /** Whether queue is loading */ + loading: boolean; + /** Load available images */ + loadQueue: (projectId: string, lockedIds?: string[]) => Promise; + /** Move to next image */ + nextImage: () => void; + /** Move to previous image */ + prevImage: () => void; + /** Set current index */ + setIndex: (index: number) => void; + /** Clear the queue */ + clearQueue: () => void; + /** Remove an image from the queue */ + removeImage: (imageId: string) => void; + /** Check if at end of queue */ + isAtEnd: boolean; + /** Check if at start of queue */ + isAtStart: boolean; +} + +// ============ Hook ============ + +/** + * Manages the labeling image queue + * @param options Configuration options + * @returns Queue state and handlers + */ +export function useImageQueue(options: UseImageQueueOptions = {}): UseImageQueueReturn { + const { batchSize = DEFAULT_BATCH_SIZE } = options; + + const [images, setImages] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [loading, setLoading] = useState(false); + + // ============ Computed State ============ + const currentImage = images[currentIndex] || null; + const isAtEnd = currentIndex >= images.length - 1; + const isAtStart = currentIndex === 0; + + // ============ Queue Operations ============ + + /** + * Load available images from the API + * @returns Array of loaded images (for use with lock acquisition) + */ + const loadQueue = useCallback( + async (projectId: string): Promise => { + setLoading(true); + + try { + const response = await getAvailableImages(projectId, { + limit: batchSize, + status: 'unlabeled', + includeFileUrl: true, + }); + + const items = (response.items || []).map((item) => ({ + imageId: item.imageId, + projectId: projectId, + maskMapId: item.maskMapId || null, + labelComplete: item.labelComplete || false, + reviewed: item.reviewed || false, + meta: { + fileName: item.meta?.fileName || 'Unknown', + width: item.meta?.width || 0, + height: item.meta?.height || 0, + status: item.meta?.status || 'unlabeled', + tags: item.meta?.tags || [], + }, + fileUrl: item.fileUrl, + createdAt: item.createdAt || new Date().toISOString(), + })); + + setImages(items); + setCurrentIndex(0); + return items; + } catch (err) { + console.error('Failed to load image queue:', err); + setImages([]); + throw err; + } finally { + setLoading(false); + } + }, + [batchSize] + ); + + /** + * Move to next image in queue + */ + const nextImage = useCallback(() => { + setCurrentIndex((prev) => Math.min(prev + 1, images.length - 1)); + }, [images.length]); + + /** + * Move to previous image in queue + */ + const prevImage = useCallback(() => { + setCurrentIndex((prev) => Math.max(prev - 1, 0)); + }, []); + + /** + * Set current index directly + */ + const setIndex = useCallback( + (index: number) => { + setCurrentIndex(Math.max(0, Math.min(index, images.length - 1))); + }, + [images.length] + ); + + /** + * Clear the queue + */ + const clearQueue = useCallback(() => { + setImages([]); + setCurrentIndex(0); + }, []); + + /** + * Remove an image from the queue + */ + const removeImage = useCallback((imageId: string) => { + setImages((prev) => { + const newImages = prev.filter((img) => img.imageId !== imageId); + // Adjust current index if needed + setCurrentIndex((prevIndex) => + prevIndex >= newImages.length ? Math.max(0, newImages.length - 1) : prevIndex + ); + return newImages; + }); + }, []); + + return { + images, + currentIndex, + currentImage, + loading, + loadQueue, + nextImage, + prevImage, + setIndex, + clearQueue, + removeImage, + isAtEnd, + isAtStart, + }; +} + +export default useImageQueue; diff --git a/src/labelling-app/frontend/src/hooks/useLockManagement.ts b/src/labelling-app/frontend/src/hooks/useLockManagement.ts new file mode 100644 index 00000000..311f4c89 --- /dev/null +++ b/src/labelling-app/frontend/src/hooks/useLockManagement.ts @@ -0,0 +1,199 @@ +/** + * useLockManagement - Hook for managing image locks + * Handles lock acquisition, refresh, and release for labeling workflow + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { acquireLocks, releaseLocks } from '../modules/API_Helps'; + +// ============ Constants ============ +const DEFAULT_LOCK_DURATION_MS = 20 * 60 * 1000; // 20 minutes +const DEFAULT_LOCK_REFRESH_MS = 5 * 60 * 1000; // 5 minutes + +// ============ Types ============ +export interface UseLockManagementOptions { + /** Lock duration in ms (default: 20 minutes) */ + lockDurationMs?: number; + /** Lock refresh interval in ms (default: 5 minutes) */ + lockRefreshMs?: number; + /** Whether to auto-refresh locks */ + autoRefresh?: boolean; +} + +export interface UseLockManagementReturn { + /** Currently locked image IDs */ + lockedIds: string[]; + /** Whether lock operation is in progress */ + loading: boolean; + /** Acquire locks for given image IDs */ + acquire: (projectId: string, imageIds: string[]) => Promise; + /** Release specific locks */ + release: (projectId: string, imageIds: string[]) => Promise; + /** Release all current locks */ + releaseAll: (projectId: string) => Promise; + /** Refresh existing locks */ + refresh: (projectId: string) => Promise; + /** Remove an image ID from locked list (local only) */ + removeLock: (imageId: string) => void; + /** Clear all locks (local only) */ + clearLocks: () => void; +} + +// ============ Hook ============ + +/** + * Manages image lock state and operations + * @param options Configuration options + * @returns Lock state and handlers + */ +export function useLockManagement( + options: UseLockManagementOptions = {} +): UseLockManagementReturn { + const { + lockDurationMs = DEFAULT_LOCK_DURATION_MS, + lockRefreshMs = DEFAULT_LOCK_REFRESH_MS, + autoRefresh = true, + } = options; + + const [lockedIds, setLockedIds] = useState([]); + const [loading, setLoading] = useState(false); + const refreshTimerRef = useRef(null); + const projectIdRef = useRef(null); + + // ============ Lock Operations ============ + + /** + * Acquire locks for given image IDs + * @returns Array of successfully locked image IDs + */ + const acquire = useCallback( + async (projectId: string, imageIds: string[]): Promise => { + if (imageIds.length === 0) return []; + + setLoading(true); + projectIdRef.current = projectId; + + try { + const response = await acquireLocks(projectId, imageIds, lockDurationMs); + + const locked = (response.results || []) + .filter((result) => result.locked) + .map((result) => result.imageId); + + setLockedIds(locked); + return locked; + } catch (err) { + console.error('Failed to acquire locks:', err); + return []; + } finally { + setLoading(false); + } + }, + [lockDurationMs] + ); + + /** + * Release specific locks + */ + const release = useCallback( + async (projectId: string, imageIds: string[]): Promise => { + if (imageIds.length === 0) return; + + try { + await releaseLocks(projectId, imageIds); + setLockedIds((prev) => prev.filter((id) => !imageIds.includes(id))); + } catch (err) { + console.warn('Failed to release locks:', err); + } + }, + [] + ); + + /** + * Release all current locks + */ + const releaseAll = useCallback( + async (projectId: string): Promise => { + if (lockedIds.length === 0) return; + + try { + await releaseLocks(projectId, lockedIds); + setLockedIds([]); + } catch (err) { + console.warn('Failed to release all locks:', err); + } + }, + [lockedIds] + ); + + /** + * Refresh existing locks to extend their duration + */ + const refresh = useCallback( + async (projectId: string): Promise => { + if (lockedIds.length === 0) return; + + try { + await acquireLocks(projectId, lockedIds, lockDurationMs); + } catch (err) { + console.error('Failed to refresh locks:', err); + } + }, + [lockedIds, lockDurationMs] + ); + + /** + * Remove an image ID from locked list (local only) + */ + const removeLock = useCallback((imageId: string) => { + setLockedIds((prev) => prev.filter((id) => id !== imageId)); + }, []); + + /** + * Clear all locks (local only) + */ + const clearLocks = useCallback(() => { + setLockedIds([]); + }, []); + + // ============ Auto-Refresh Effect ============ + useEffect(() => { + // Clear existing timer + if (refreshTimerRef.current) { + window.clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + + // Don't set up refresh if disabled or no locks + if (!autoRefresh || lockedIds.length === 0 || !projectIdRef.current) { + return; + } + + // Set up periodic refresh + refreshTimerRef.current = window.setInterval(() => { + if (projectIdRef.current) { + refresh(projectIdRef.current); + } + }, lockRefreshMs); + + return () => { + if (refreshTimerRef.current) { + window.clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + }, [autoRefresh, lockedIds.length, lockRefreshMs, refresh]); + + return { + lockedIds, + loading, + acquire, + release, + releaseAll, + refresh, + removeLock, + clearLocks, + }; +} + +export default useLockManagement; diff --git a/src/labelling-app/frontend/src/hooks/useNotifications.ts b/src/labelling-app/frontend/src/hooks/useNotifications.ts new file mode 100644 index 00000000..4e9f5eef --- /dev/null +++ b/src/labelling-app/frontend/src/hooks/useNotifications.ts @@ -0,0 +1,114 @@ +/** + * useNotifications - Hook for managing notification messages + * Handles success/error banners with auto-dismiss functionality + */ + +import { useCallback, useState } from 'react'; + +// ============ Constants ============ +const DEFAULT_AUTO_DISMISS_MS = 3000; + +// ============ Types ============ +export interface UseNotificationsReturn { + /** Current notification message (success) */ + notification: string | null; + /** Current error message */ + error: string | null; + /** Show a success notification */ + showNotification: (message: string, autoDismiss?: boolean) => void; + /** Show an error message */ + showError: (message: string | Error | unknown, fallback?: string) => void; + /** Clear the notification */ + clearNotification: () => void; + /** Clear the error */ + clearError: () => void; + /** Clear all messages */ + clearAll: () => void; +} + +// ============ Helpers ============ + +/** + * Extract error message from various error types + */ +const getErrorMessage = (err: unknown, fallback: string): string => { + if (err instanceof Error && err.message) { + return err.message; + } + if (typeof err === 'string') { + return err; + } + return fallback; +}; + +// ============ Hook ============ + +/** + * Manages notification and error state + * @param autoDismissMs Time in ms before auto-dismissing notifications (default: 3000) + * @returns Notification state and handlers + */ +export function useNotifications( + autoDismissMs: number = DEFAULT_AUTO_DISMISS_MS +): UseNotificationsReturn { + const [notification, setNotification] = useState(null); + const [error, setError] = useState(null); + + /** + * Show a success notification with optional auto-dismiss + */ + const showNotification = useCallback( + (message: string, autoDismiss: boolean = true) => { + setNotification(message); + if (autoDismiss) { + setTimeout(() => setNotification(null), autoDismissMs); + } + }, + [autoDismissMs] + ); + + /** + * Show an error message + */ + const showError = useCallback( + (err: string | Error | unknown, fallback: string = 'An error occurred') => { + const message = typeof err === 'string' ? err : getErrorMessage(err, fallback); + setError(message); + }, + [] + ); + + /** + * Clear the notification + */ + const clearNotification = useCallback(() => { + setNotification(null); + }, []); + + /** + * Clear the error + */ + const clearError = useCallback(() => { + setError(null); + }, []); + + /** + * Clear all messages + */ + const clearAll = useCallback(() => { + setNotification(null); + setError(null); + }, []); + + return { + notification, + error, + showNotification, + showError, + clearNotification, + clearError, + clearAll, + }; +} + +export default useNotifications; diff --git a/src/labelling-app/frontend/src/hooks/useProjectState.ts b/src/labelling-app/frontend/src/hooks/useProjectState.ts new file mode 100644 index 00000000..f04b8caf --- /dev/null +++ b/src/labelling-app/frontend/src/hooks/useProjectState.ts @@ -0,0 +1,208 @@ +/** + * useProjectState - Hook for managing project selection and list + * Handles loading projects, selecting projects, and fetching project details + */ + +import { useCallback, useEffect, useState } from 'react'; +import type { Project } from '../types'; +import { listProjects, getProject, listImages, deleteProject as apiDeleteProject } from '../modules/API_Helps'; + +// ============ Types ============ +export interface UseProjectStateReturn { + /** List of all projects */ + projects: Project[]; + /** Currently selected project ID */ + selectedProjectId: string | null; + /** Detailed project data for selected project */ + projectDetails: Project | null; + /** Computed selected project (details + list counts) */ + selectedProject: Project | null; + /** Whether projects are loading */ + loading: boolean; + /** Refresh the projects list */ + refreshProjects: () => Promise; + /** Load details for a specific project */ + loadProjectDetails: (projectId: string) => Promise; + /** Select a project by ID */ + selectProject: (projectId: string | null) => void; + /** Delete a project */ + deleteProject: (projectId: string) => Promise; + /** Clear project details */ + clearProjectDetails: () => void; +} + +// ============ Helpers ============ + +/** + * Get count from API response + */ +const getResponseCount = (response: { total?: number; items: unknown[] }): number => + typeof response.total === 'number' ? response.total : response.items.length; + +// ============ Hook ============ + +/** + * Manages project list and selection state + * @returns Project state and handlers + */ +export function useProjectState(): UseProjectStateReturn { + const [projects, setProjects] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [projectDetails, setProjectDetails] = useState(null); + const [loading, setLoading] = useState(false); + + // ============ Computed State ============ + + // Merge project details with list counts + const projectFromList = projects.find((p) => p.projectId === selectedProjectId) || null; + const selectedProject = projectDetails + ? { + ...projectDetails, + imageCount: projectFromList?.imageCount ?? projectDetails.imageCount, + labeledCount: projectFromList?.labeledCount ?? projectDetails.labeledCount, + unlabeledCount: projectFromList?.unlabeledCount ?? projectDetails.unlabeledCount, + } + : projectFromList; + + // ============ API Operations ============ + + /** + * Refresh the projects list and fetch counts + */ + const refreshProjects = useCallback(async () => { + setLoading(true); + + try { + const response = await listProjects(); + + const items = (response.items || []).map((item) => ({ + projectId: item.projectId, + name: item.name, + description: item.description || null, + labels: item.labels || {}, + createdAt: item.createdAt || new Date().toISOString(), + imageCount: item.imageCount || 0, + labeledCount: item.labeledCount || 0, + })); + + // Fetch counts for each project in parallel + const itemsWithCounts = await Promise.all( + items.map(async (item) => { + try { + const [totalResponse, unlabeledResponse] = await Promise.all([ + listImages(item.projectId, { includeTotal: true, limit: 1 }), + listImages(item.projectId, { status: 'unlabeled', includeTotal: true, limit: 1 }), + ]); + + const total = getResponseCount(totalResponse); + const unlabeled = getResponseCount(unlabeledResponse); + const labeled = Math.max(total - unlabeled, 0); + + return { + ...item, + imageCount: total, + labeledCount: labeled, + unlabeledCount: unlabeled, + }; + } catch { + return item; + } + }) + ); + + setProjects(itemsWithCounts); + + // Auto-select first project if none selected + setSelectedProjectId((prev) => prev ?? itemsWithCounts[0]?.projectId ?? null); + } catch (err) { + console.error('Failed to load projects:', err); + throw err; + } finally { + setLoading(false); + } + }, []); + + /** + * Load details for a specific project + */ + const loadProjectDetails = useCallback(async (projectId: string) => { + try { + const details = await getProject(projectId); + + setProjectDetails({ + projectId: details.projectId, + name: details.name, + description: details.description || null, + labels: details.labels || {}, + createdAt: details.createdAt || new Date().toISOString(), + imageCount: details.imageCount || 0, + labeledCount: details.labeledCount || 0, + }); + } catch (err) { + console.error('Failed to load project details:', err); + throw err; + } + }, []); + + /** + * Select a project by ID + */ + const selectProject = useCallback((projectId: string | null) => { + setSelectedProjectId(projectId); + if (!projectId) { + setProjectDetails(null); + } + }, []); + + /** + * Delete a project + */ + const deleteProject = useCallback( + async (projectId: string) => { + await apiDeleteProject(projectId); + + // Remove from list + setProjects((prev) => prev.filter((p) => p.projectId !== projectId)); + + // Clear selection if deleted project was selected + if (selectedProjectId === projectId) { + setSelectedProjectId(null); + setProjectDetails(null); + } + }, + [selectedProjectId] + ); + + /** + * Clear project details + */ + const clearProjectDetails = useCallback(() => { + setProjectDetails(null); + }, []); + + // ============ Effects ============ + + // Load project details when selection changes + useEffect(() => { + if (!selectedProjectId) { + setProjectDetails(null); + return; + } + loadProjectDetails(selectedProjectId); + }, [loadProjectDetails, selectedProjectId]); + + return { + projects, + selectedProjectId, + projectDetails, + selectedProject, + loading, + refreshProjects, + loadProjectDetails, + selectProject, + deleteProject, + clearProjectDetails, + }; +} + +export default useProjectState; diff --git a/src/labelling-app/frontend/src/modules/API_Helps.ts b/src/labelling-app/frontend/src/modules/API_Helps.ts index b7013d44..6fa75f54 100644 --- a/src/labelling-app/frontend/src/modules/API_Helps.ts +++ b/src/labelling-app/frontend/src/modules/API_Helps.ts @@ -1,23 +1,28 @@ -import { signInAnonymously } from "firebase/auth"; import { auth } from "../firebaseconfig"; import type { LockResponse, MaskApiItem, + MaskImportRequest, + MaskImportResponse, MaskMapApiItem, MaskOverlay, ProjectApiItem, ProjectImageApiItem, ProjectImagesApiResponse, ProjectsApiResponse, + SamPoint, + SamSegmentResponse, SparseColorMap, UploadZipResponse, } from "../types"; -const apiBase = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, ""); +const rawApiBase = (import.meta.env.VITE_API_BASE_URL || "").trim(); +const apiBase = rawApiBase.replace(/\/$/, ""); +const apiRoot = apiBase.endsWith("/api") ? apiBase : `${apiBase}/api`; const ensureAuth = async () => { if (!auth.currentUser) { - await signInAnonymously(auth); + throw new Error("User is not authenticated"); } }; @@ -47,7 +52,7 @@ const apiFetch = async (path: string, options: RequestInit = {}) => { const headers = new Headers(options.headers || {}); headers.set("Authorization", `Bearer ${token}`); - const response = await fetch(`${apiBase}${path}`, { + const response = await fetch(`${apiRoot}${path}`, { ...options, headers, }); @@ -129,6 +134,11 @@ export const listImages = async ( export const getImage = async (projectId: string, imageId: string) => apiFetch(`/projects/${projectId}/images/${imageId}`, { method: "GET" }) as Promise; +export const deleteImage = async (projectId: string, imageId: string) => + apiFetch(`/projects/${projectId}/images/${imageId}`, { + method: "DELETE", + }) as Promise<{ success: boolean; deletedId: string }>; + export const getAvailableImages = async ( projectId: string, options: { limit?: number; status?: string; includeFileUrl?: boolean } = {} @@ -207,7 +217,7 @@ export const uploadZipToBackend = async ( form.append("zipData", file); form.append("meta", JSON.stringify(meta)); - const response = await fetch(`${apiBase}/projects/${projectId}/images/zip`, { + const response = await fetch(`${apiRoot}/projects/${projectId}/images/zip`, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -234,6 +244,53 @@ export const uploadZipToBackend = async ( return data as UploadZipResponse; }; +/** + * Download project images and masks as a ZIP (same format as upload). + * Triggers a file download in the browser. + */ +export const downloadProjectZip = async ( + projectId: string, + options?: { limit?: number; status?: string; ids?: string[] } +): Promise => { + const token = await getAuthToken(); + const params = new URLSearchParams(); + if (options?.limit != null) params.set("limit", String(options.limit)); + if (options?.status) params.set("status", options.status); + if (options?.ids?.length) params.set("ids", options.ids.join(",")); + const query = params.toString(); + const url = `${apiRoot}/projects/${projectId}/images/zip${query ? `?${query}` : ""}`; + + const response = await fetch(url, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + const text = await response.text(); + let message = "Download failed"; + try { + const data = text ? JSON.parse(text) : null; + if (data && typeof data === "object" && "message" in data) { + message = String((data as { message: string }).message); + } + } catch { + if (text) message = text; + } + throw new Error(message); + } + + const blob = await response.blob(); + const disposition = response.headers.get("Content-Disposition"); + const match = disposition?.match(/filename="?([^";\n]+)"?/); + const filename = match?.[1] ?? `project-${projectId}-export.zip`; + + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); +}; + // ============================================================================ // MASK API FUNCTIONS // ============================================================================ @@ -262,24 +319,60 @@ export const getColorMap = async (projectId: string, maskMapId: string) => method: "GET", }) as Promise; +// --------------------------------------------------------------------------- +// MaskOverlay decode helpers +// --------------------------------------------------------------------------- + +/** + * Raw shape returned by the API: data is a base64-encoded little-endian + * Int32Array. We decode it here so all consumers get an efficient Int32Array + * instead of a 2M-element plain number[] that would be allocated on the JS heap. + */ +interface RawMaskOverlay { + width: number; + height: number; + maskIds: string[]; + /** Base64-encoded little-endian Int32Array */ + data: string; +} + +function decodeMaskOverlay(raw: RawMaskOverlay | null): MaskOverlay | null { + if (!raw) return null; + const binaryStr = atob(raw.data); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + return { + width: raw.width, + height: raw.height, + maskIds: raw.maskIds, + data: new Int32Array(bytes.buffer), + }; +} + /** * Get maskOverlay from storage for a mask map (by maskMapId) * Returns the 2D array where each pixel contains the maskId of the smallest mask */ -export const getMaskOverlay = async (projectId: string, maskMapId: string) => - apiFetch(`/projects/${projectId}/maskmaps/${maskMapId}/maskoverlay`, { +export const getMaskOverlay = async (projectId: string, maskMapId: string): Promise => { + const raw = await apiFetch(`/projects/${projectId}/maskmaps/${maskMapId}/maskoverlay`, { method: "GET", - }) as Promise; + }) as RawMaskOverlay | null; + return decodeMaskOverlay(raw); +}; /** * Get maskOverlay for an image (by imageId) * This is the preferred method - simpler to use since you only need the imageId * Returns the 2D array where each pixel contains the maskId of the smallest mask */ -export const getImageMaskOverlay = async (projectId: string, imageId: string) => - apiFetch(`/projects/${projectId}/images/${imageId}/maskoverlay`, { +export const getImageMaskOverlay = async (projectId: string, imageId: string): Promise => { + const raw = await apiFetch(`/projects/${projectId}/images/${imageId}/maskoverlay`, { method: "GET", - }) as Promise; + }) as RawMaskOverlay | null; + return decodeMaskOverlay(raw); +}; /** * Update a single mask's label @@ -315,3 +408,68 @@ export const batchUpdateMaskLabels = async ( error?: string; }>; }>; + +// ============================================================================ +// SAM MASK GENERATION (direct to SAM backend, on-demand only) +// ============================================================================ + +const rawSamBase = (import.meta.env.VITE_SAM_BACKEND_URL || "").trim(); +const samBase = rawSamBase.replace(/\/$/, ""); + +/** + * Request point-based mask predictions from the SAM backend. + * Called ONLY when the user explicitly triggers the point tool. + */ +export const requestSamMasks = async ( + imageUrl: string, + points: SamPoint[] +): Promise => { + if (!samBase) { + throw new Error("VITE_SAM_BACKEND_URL is not configured"); + } + const token = await getAuthToken(); + const response = await fetch(`${samBase}/segment`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ mode: "click", imageUrl, points }), + }); + + const text = await response.text(); + let data: unknown = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + + if (!response.ok) { + const message = + typeof data === "object" && data && "message" in data + ? String((data as { message: string }).message) + : `SAM request failed (${response.status})`; + throw new Error(message); + } + + return data as SamSegmentResponse; +}; + +// ============================================================================ +// MASK IMPORT (persist SAM-generated masks into labelling backend) +// ============================================================================ + +/** + * Import SAM-generated masks into the labelling backend under a specific image. + */ +export const importSamMasks = async ( + projectId: string, + imageId: string, + payload: MaskImportRequest +): Promise => + apiFetch(`/projects/${projectId}/images/${imageId}/masks/import`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) as Promise; diff --git a/src/labelling-app/frontend/src/pages/CreateProject/CreateProject.css b/src/labelling-app/frontend/src/pages/CreateProject/CreateProject.css new file mode 100644 index 00000000..f4b3fa60 --- /dev/null +++ b/src/labelling-app/frontend/src/pages/CreateProject/CreateProject.css @@ -0,0 +1,191 @@ +/** + * CreateProject - Project creation page styles + */ + +/* ============ Container ============ */ +.create-project-container { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 24px; + align-items: start; +} + +.create-project-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* ============ Form Section ============ */ +.form-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-section-title { + margin: 0; + font-size: 1.1rem; + font-family: 'Fraunces', serif; +} + +.form-section-desc { + margin: 0; + font-size: 0.85rem; + color: var(--muted); +} + +.form-fields { + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ============ Labels Preview ============ */ +.classes-preview { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px; + background: var(--panel-strong); + border-radius: 12px; +} + +/* ============ Add Label Row ============ */ +.add-class-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 12px; + align-items: end; +} + +.class-name-input { + flex: 1; +} + +.form-error { + margin: 0; + font-size: 0.8rem; + color: #e74c3c; +} + +/* ============ Color Presets ============ */ +.color-presets { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.preset-label { + font-size: 0.75rem; + color: var(--muted); +} + +.color-preset { + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform 150ms ease, border-color 150ms ease; +} + +.color-preset:hover { + transform: scale(1.15); +} + +.color-preset.active { + border-color: var(--ink); +} + +/* ============ Form Actions ============ */ +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +/* ============ Preview ============ */ +.create-project-preview { + position: sticky; + top: 24px; +} + +.preview-title { + margin: 0 0 16px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); +} + +.preview-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.preview-name { + font-size: 1.2rem; + font-weight: 600; + font-family: 'Fraunces', serif; + color: var(--ink); +} + +.preview-desc { + font-size: 0.9rem; + color: var(--muted); + line-height: 1.5; +} + +.preview-classes { + display: flex; + flex-direction: column; + gap: 8px; +} + +.preview-label { + font-size: 0.75rem; + color: var(--muted); +} + +.preview-class-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.preview-class { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 8px; + font-size: 0.8rem; + border: 1px solid; + background: #fff; +} + +.preview-class-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +/* ============ Responsive ============ */ +@media (max-width: 1100px) { + .create-project-container { + grid-template-columns: 1fr; + } + + .create-project-preview { + position: static; + } +} + +@media (max-width: 720px) { + .add-class-row { + grid-template-columns: 1fr; + } +} diff --git a/src/labelling-app/frontend/src/pages/CreateProject/CreateProject.tsx b/src/labelling-app/frontend/src/pages/CreateProject/CreateProject.tsx new file mode 100644 index 00000000..1b7e0db3 --- /dev/null +++ b/src/labelling-app/frontend/src/pages/CreateProject/CreateProject.tsx @@ -0,0 +1,160 @@ +/** + * CreateProject - Project creation page + * Form for creating new projects with name, description, and labels + */ + +import { useState, type ChangeEvent } from 'react'; +import type { LabelsMap, ProjectFormData } from '../../types'; +import { Button, Input, TextArea, Card } from '../../components/ui'; +import { LabelManager } from './components/LabelManager'; +import { ProjectPreview } from './components/ProjectPreview'; +import './CreateProject.css'; + +// ============ Types ============ +export interface CreateProjectProps { + /** Callback when form is submitted */ + onSubmit: (data: ProjectFormData) => void; + /** Callback when cancel is clicked */ + onCancel: () => void; + /** Whether submission is in progress */ + loading?: boolean; +} + +// ============ Component ============ +export function CreateProject({ + onSubmit, + onCancel, + loading = false, +}: CreateProjectProps) { + // ============ Form State ============ + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [labels, setLabels] = useState({}); + const [errors, setErrors] = useState<{ name?: string; labels?: string }>({}); + + const labelsList = Object.values(labels); + + // ============ Handlers ============ + + /** + * Handle form submission + */ + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Validate + const newErrors: typeof errors = {}; + + if (!name.trim()) { + newErrors.name = 'Project name is required'; + } + + if (labelsList.length === 0) { + newErrors.labels = 'At least one label is required'; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + // Submit + onSubmit({ + name: name.trim(), + description: description.trim(), + labels, + }); + }; + + /** + * Handle name change + */ + const handleNameChange = (e: ChangeEvent) => { + setName(e.target.value); + if (errors.name) { + setErrors((prev) => ({ ...prev, name: undefined })); + } + }; + + /** + * Handle description change + */ + const handleDescriptionChange = (e: ChangeEvent) => { + setDescription(e.target.value); + }; + + /** + * Handle labels change + */ + const handleLabelsChange = (newLabels: LabelsMap) => { + setLabels(newLabels); + }; + + /** + * Clear labels error + */ + const handleClearLabelsError = () => { + if (errors.labels) { + setErrors((prev) => ({ ...prev, labels: undefined })); + } + }; + + // ============ Render ============ + return ( +
+ {/* Form */} +
+ + {/* Project Information Section */} +
+

Project Information

+

+ Give your project a clear name and description to help you organize your work. +

+ +
+ + +