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}
- setNotification(null)}>
+
Dismiss
@@ -638,7 +371,7 @@ function App() {
{error && (
{error}
- setError(null)}>
+
Dismiss
@@ -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 */}
-
-
-
- handleNavigate('prev')}
- disabled={currentIndex === 0}
- >
- Previous
-
-
- {currentIndex + 1} / {images.length}
-
- handleNavigate('next')}
- disabled={currentIndex === images.length - 1}
- >
- Next
-
-
-
- {maskLoading && Loading masks...}
- {highlightedMask && (
-
- Hovering: Mask ({highlightedMask.size.toLocaleString()}px)
- {highlightedMask.labelId ? ' - Labeled' : ' - Click to label'}
-
- )}
-
- Mark as Labeled
-
-
-
-
-
- {currentImage?.fileUrl ? (
-
-

- {/* 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 (
- handleMaskClick(mask, e)}
- >
-
-
- Mask {index + 1}
- {label && - {label.name}}
-
- {mask.size.toLocaleString()}px
-
- );
- })}
-
- )}
-
-
- {/* 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 (
- handleAssignLabel(label.labelId)}
- disabled={labelAssigning}
- >
-
- {label.name}
- {isActive && }
-
- );
- })}
-
- {selectedMask?.labelId && (
-
-
-
- Remove Label
-
-
- )}
-
- )}
-
- {/* 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}
+
+ Try again
+
+
+
+ )}
+ >
+ ({ 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 && (
+
+ )}
+ {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 ? 'Cancel Point Tool' : 'Add Masks by Point'}
+
+ {samToolActive && (
+ <>
+ {samPoints.length} point{samPoints.length !== 1 ? 's' : ''}
+
+ Clear
+
+
+ Generate
+
+ >
+ )}
+ {samError && (
+ {samError}
+ )}
+
+
+
+
+
+
+
+
Tags
+
+ setTagInput(event.target.value)}
+ onKeyDown={handleTagKeyDown}
+ />
+
+ Add
+
+
+ {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 (
+ handleMaskClick(mask, event)}
+ >
+
+
+ Mask {index + 1}
+ {label && - {label.name}}
+
+ {mask.size.toLocaleString()}px
+
+ );
+ })}
+
+
+ )}
+
+
+
+
Labels
+ {!hasLabels ? (
+
No labels configured for this project.
+ ) : (
+
+ {Object.values(project.labels).map((label) => (
+
+
+ {label.name}
+
+ ))}
+
+ )}
+
+
+
+
+ {(saveError || labelError) && (
+
+ {saveError || labelError}
+ {saveError && (
+
+ Retry
+
+ )}
+ {
+ setSaveError(null);
+ setLabelError(null);
+ }}
+ >
+ Dismiss
+
+
+ )}
+
+
+
+ Close
+
+
+
+ {labelPopup && hasLabels && (
+
+
+ Assign Label
+
+
+
+
+
+ {Object.values(project.labels).map((label) => {
+ const isActive = selectedMask?.labelId === label.labelId;
+ return (
+ handleAssignLabel(label.labelId)}
+ disabled={labelAssigning}
+ >
+
+ {label.name}
+ {isActive && }
+
+ );
+ })}
+
+ {selectedMask?.labelId && (
+
+
+
+ Remove Label
+
+
+ )}
+
+ )}
+
+ {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