From 5329784f7bbe4b85f53cadd132df10c0df0dfa1a Mon Sep 17 00:00:00 2001
From: Udhul <126940798+Udhul@users.noreply.github.com>
Date: Fri, 27 Jun 2025 18:02:04 +0200
Subject: [PATCH 1/8] Improve layer mask workflow
---
app/frontend/static/js/canvasController.js | 9 +++++----
app/frontend/static/js/main.js | 7 +++++--
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js
index 5523727..631ba40 100644
--- a/app/frontend/static/js/canvasController.js
+++ b/app/frontend/static/js/canvasController.js
@@ -521,7 +521,8 @@ class CanvasManager {
const color = (this.editingLayerId && l.layerId === this.editingLayerId)
? this.editingColor
: l.color;
- if (mask) this._drawBinaryMask(mask, color, op);
+ const hatch = !(this.editingLayerId && l.layerId === this.editingLayerId);
+ if (mask) this._drawBinaryMask(mask, color, op, hatch);
});
}
@@ -893,7 +894,7 @@ class CanvasManager {
}
}
- _drawBinaryMask(maskData, colorStr, opacity = 1.0) {
+ _drawBinaryMask(maskData, colorStr, opacity = 1.0, hatch = true) {
if (!maskData || !maskData.length || !maskData[0].length) return;
const maskHeight = maskData.length;
const maskWidth = maskData[0].length;
@@ -909,7 +910,7 @@ class CanvasManager {
const [r, g, b, a_int] = this._parseRgbaFromString(colorStr);
const finalAlpha = Math.round(Math.min(1, Math.max(0, opacity)) * a_int);
- const spacing = 6; // pixel spacing between hatch lines
+ const spacing = 4; // pixel spacing between hatch lines (tighter pattern)
const lineWidth = 2; // hatch line thickness
const isBorder = (mx, my) => {
@@ -926,7 +927,7 @@ class CanvasManager {
if (!maskData[y][x]) continue;
const idx = (y * maskWidth + x) * 4;
const border = isBorder(x, y);
- const drawPixel = border || ((x + y) % spacing < lineWidth);
+ const drawPixel = border || !hatch || ((x + y) % spacing < lineWidth);
if (drawPixel) {
pixelData[idx] = r;
pixelData[idx + 1] = g;
diff --git a/app/frontend/static/js/main.js b/app/frontend/static/js/main.js
index b26fc44..e7a2f31 100644
--- a/app/frontend/static/js/main.js
+++ b/app/frontend/static/js/main.js
@@ -482,7 +482,7 @@ document.addEventListener("DOMContentLoaded", () => {
// == ImagePoolHandler Events ==
document.addEventListener("active-image-set", async (event) => {
- const {
+ const {
imageHash,
filename,
width,
@@ -491,6 +491,9 @@ document.addEventListener("DOMContentLoaded", () => {
existingMasks,
status,
} = event.detail;
+ canvasManager.clearAllCanvasInputs(true);
+ canvasManager.setManualPredictions(null);
+ canvasManager.setAutomaskPredictions(null);
uiManager.showGlobalStatus(
`Loading image '${utils.escapeHTML(filename)}' for annotation...`,
"loading",
@@ -1125,7 +1128,7 @@ document.addEventListener("DOMContentLoaded", () => {
canvasManager.clearAllCanvasInputs(false);
canvasManager.setManualPredictions(null);
canvasManager.setAutomaskPredictions(null);
- canvasManager.setMode("edit");
+ canvasManager.setMode("creation");
}
if (commitMasksBtn && !commitMasksBtn.dataset.listenerAttached) {
From 7e07350815b0be0231049d5a989ca207fb344320 Mon Sep 17 00:00:00 2001
From: Udhul <126940798+Udhul@users.noreply.github.com>
Date: Fri, 27 Jun 2025 18:24:31 +0200
Subject: [PATCH 2/8] feat: auto-save mask edits
---
app/frontend/static/css/canvas.css | 12 +-
app/frontend/static/js/canvasController.js | 181 +++++++++++--------
app/frontend/static/js/editModeController.js | 35 ++--
app/frontend/static/js/main.js | 92 +++++-----
app/frontend/templates/index.html | 3 +-
docs/annotation_workflow_progress.md | 9 +-
docs/annotation_workflow_specification.md | 5 +-
7 files changed, 189 insertions(+), 148 deletions(-)
diff --git a/app/frontend/static/css/canvas.css b/app/frontend/static/css/canvas.css
index 963887b..69ec3a6 100644
--- a/app/frontend/static/css/canvas.css
+++ b/app/frontend/static/css/canvas.css
@@ -266,12 +266,12 @@
#save-canvas-png-btn:hover:not(:disabled) {
background-color: #0056b3;
}
-#commit-masks-btn, #edit-save-btn{
+#commit-masks-btn{
background-color: #28a745;
padding: 8px 16px;
font-size: 15px;
} /* Green for commit */
-#commit-masks-btn:hover:not(:disabled), #edit-save-btn:hover:not(:disabled) {
+#commit-masks-btn:hover:not(:disabled) {
background-color: #218838;
}
#edit-undo-btn, #edit-redo-btn {
@@ -282,7 +282,7 @@
}
/* Clear Inputs Button */
-#clear-inputs-btn, #edit-cancel-btn {
+#clear-inputs-btn, #edit-discard-btn {
background: linear-gradient(135deg, #ff7300, #ffa500);
border: none;
padding: 8px 16px;
@@ -293,12 +293,12 @@
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(255, 140, 0, 0.2);
}
-#clear-inputs-btn:hover:not(:disabled), #edit-cancel-btn:hover:not(:disabled) {
+#clear-inputs-btn:hover:not(:disabled), #edit-discard-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #e67e00, #cb7600);
box-shadow: 0 4px 8px rgba(255, 140, 0, 0.3);
transform: translateY(-1px);
}
-#clear-inputs-btn:disabled, #edit-cancel-btn:disabled {
+#clear-inputs-btn:disabled, #edit-discard-btn:disabled {
background: #ccc;
color: #888;
box-shadow: none;
@@ -306,7 +306,7 @@
cursor: not-allowed;
}
-#clear-inputs-btn #edit-cancel-btn{
+#clear-inputs-btn #edit-discard-btn{
width: 100%;
margin-top: 10px;
}
diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js
index 631ba40..2b53859 100644
--- a/app/frontend/static/js/canvasController.js
+++ b/app/frontend/static/js/canvasController.js
@@ -116,9 +116,7 @@ class CanvasManager {
this.selectedLayerIds = [];
this.mode = 'edit'; // 'creation', 'edit', 'review'
- this.editingLayerId = null;
- this.editingMask = null;
- this.editingColor = '#ff0000';
+ this.editingLayers = {}; // { layerId: { mask, color } }
this.editHistory = [];
this.editHistoryIndex = -1;
@@ -515,13 +513,10 @@ class CanvasManager {
if (this.mode === 'edit' && this.selectedLayerIds.length > 0) {
op = this.selectedLayerIds.includes(l.layerId) ? 1.0 : FADED_MASK_OPACITY;
}
- const mask = (this.editingLayerId && l.layerId === this.editingLayerId && this.editingMask)
- ? this.editingMask
- : l.maskData;
- const color = (this.editingLayerId && l.layerId === this.editingLayerId)
- ? this.editingColor
- : l.color;
- const hatch = !(this.editingLayerId && l.layerId === this.editingLayerId);
+ const isEditing = this.editingLayers.hasOwnProperty(l.layerId);
+ const mask = isEditing ? this.editingLayers[l.layerId].mask : l.maskData;
+ const color = isEditing ? this.editingLayers[l.layerId].color : l.color;
+ const hatch = !isEditing;
if (mask) this._drawBinaryMask(mask, color, op, hatch);
});
}
@@ -1101,51 +1096,62 @@ class CanvasManager {
this.drawPredictionMaskLayer();
}
- startMaskEdit(layerId, maskData, color) {
- this.editingLayerId = layerId;
- this.editingColor = color || '#ff0000';
- if (Array.isArray(maskData) && Array.isArray(maskData[0])) {
- this.editingMask = maskData.map(r => Array.from(r));
- } else if (maskData && maskData.counts && maskData.size) {
- const converted = this.Utils.rleToBinaryMask(
- maskData,
- this.originalImageHeight,
- this.originalImageWidth
- );
- this.editingMask = converted || this._createEmptyMask();
- } else {
- this.editingMask = this._createEmptyMask();
- }
- this.editHistory = [this.getEditedMask()];
+ startMaskEdit(layers) {
+ this.editingLayers = {};
+ if (!Array.isArray(layers)) layers = [];
+ layers.forEach(layer => {
+ let mask;
+ if (Array.isArray(layer.maskData) && Array.isArray(layer.maskData[0])) {
+ mask = layer.maskData.map(r => Array.from(r));
+ } else if (layer.maskData && layer.maskData.counts && layer.maskData.size) {
+ mask = this.Utils.rleToBinaryMask(
+ layer.maskData,
+ this.originalImageHeight,
+ this.originalImageWidth
+ );
+ mask = mask || this._createEmptyMask();
+ } else {
+ mask = this._createEmptyMask();
+ }
+ this.editingLayers[layer.layerId] = { mask, color: layer.displayColor || layer.color || '#ff0000' };
+ });
+ this.editHistory = [this.getEditedMasks()];
this.editHistoryIndex = 0;
this.drawPredictionMaskLayer();
}
applyBrush(x, y, radius, add = true) {
- if (!this.editingMask) return;
- const h = this.editingMask.length;
- const w = this.editingMask[0].length;
+ const layerIds = Object.keys(this.editingLayers);
+ if (layerIds.length === 0) return;
const cx = Math.round(x);
const cy = Math.round(y);
- for (let j = -radius; j <= radius; j++) {
- for (let i = -radius; i <= radius; i++) {
- if (i*i + j*j <= radius*radius) {
- const nx = cx + i;
- const ny = cy + j;
- if (nx >=0 && ny >=0 && nx < w && ny < h) {
- this.editingMask[ny][nx] = add ? 1 : 0;
+ layerIds.forEach(id => {
+ const mask = this.editingLayers[id].mask;
+ const h = mask.length;
+ const w = mask[0].length;
+ for (let j = -radius; j <= radius; j++) {
+ for (let i = -radius; i <= radius; i++) {
+ if (i * i + j * j <= radius * radius) {
+ const nx = cx + i;
+ const ny = cy + j;
+ if (nx >= 0 && ny >= 0 && nx < w && ny < h) {
+ mask[ny][nx] = add ? 1 : 0;
+ }
}
}
}
- }
+ });
this.drawPredictionMaskLayer();
}
applyLasso(points, add = true) {
- if (!this.editingMask || !points || points.length < 3) return;
- const h = this.editingMask.length;
- const w = this.editingMask[0].length;
- let minX = w, minY = h, maxX = 0, maxY = 0;
+ const layerIds = Object.keys(this.editingLayers);
+ if (layerIds.length === 0 || !points || points.length < 3) return;
+ let minX, minY, maxX, maxY;
+ const anyMask = this.editingLayers[layerIds[0]].mask;
+ const h = anyMask.length;
+ const w = anyMask[0].length;
+ minX = w; minY = h; maxX = 0; maxY = 0;
points.forEach(p => {
if (p.x < minX) minX = Math.floor(p.x);
if (p.x > maxX) maxX = Math.ceil(p.x);
@@ -1154,13 +1160,16 @@ class CanvasManager {
});
minX = Math.max(0, minX); minY = Math.max(0, minY);
maxX = Math.min(w - 1, maxX); maxY = Math.min(h - 1, maxY);
- for (let y = minY; y <= maxY; y++) {
- for (let x = minX; x <= maxX; x++) {
- if (this._isPointInPolygon({x, y}, points)) {
- this.editingMask[y][x] = add ? 1 : 0;
+ layerIds.forEach(id => {
+ const mask = this.editingLayers[id].mask;
+ for (let y = minY; y <= maxY; y++) {
+ for (let x = minX; x <= maxX; x++) {
+ if (this._isPointInPolygon({x, y}, points)) {
+ mask[y][x] = add ? 1 : 0;
+ }
}
}
- }
+ });
this.drawPredictionMaskLayer();
}
@@ -1189,19 +1198,25 @@ class CanvasManager {
}
commitHistoryStep() {
- if (!this.editingMask) return;
+ const layerIds = Object.keys(this.editingLayers);
+ if (layerIds.length === 0) return;
if (this.editHistoryIndex < this.editHistory.length - 1) {
this.editHistory = this.editHistory.slice(0, this.editHistoryIndex + 1);
}
- this.editHistory.push(this.getEditedMask());
+ this.editHistory.push(this.getEditedMasks());
this.editHistoryIndex = this.editHistory.length - 1;
}
undoEdit() {
if (this.editHistoryIndex > 0) {
this.editHistoryIndex -= 1;
- const mask = this.editHistory[this.editHistoryIndex];
- this.editingMask = mask.map(r => [...r]);
+ const snapshot = this.editHistory[this.editHistoryIndex];
+ for (const id of Object.keys(snapshot)) {
+ if (this.editingLayers[id]) {
+ const mask = snapshot[id];
+ this.editingLayers[id].mask = mask.map(r => [...r]);
+ }
+ }
this.drawPredictionMaskLayer();
}
}
@@ -1209,41 +1224,60 @@ class CanvasManager {
redoEdit() {
if (this.editHistoryIndex < this.editHistory.length - 1) {
this.editHistoryIndex += 1;
- const mask = this.editHistory[this.editHistoryIndex];
- this.editingMask = mask.map(r => [...r]);
+ const snapshot = this.editHistory[this.editHistoryIndex];
+ for (const id of Object.keys(snapshot)) {
+ if (this.editingLayers[id]) {
+ const mask = snapshot[id];
+ this.editingLayers[id].mask = mask.map(r => [...r]);
+ }
+ }
this.drawPredictionMaskLayer();
}
}
growEditingMask() {
- if (!this.editingMask) return;
- this.editingMask = this._dilateMask(this.editingMask);
+ const layerIds = Object.keys(this.editingLayers);
+ if (layerIds.length === 0) return;
+ layerIds.forEach(id => {
+ this.editingLayers[id].mask = this._dilateMask(this.editingLayers[id].mask);
+ });
this.drawPredictionMaskLayer();
}
shrinkEditingMask() {
- if (!this.editingMask) return;
- this.editingMask = this._erodeMask(this.editingMask);
+ const layerIds = Object.keys(this.editingLayers);
+ if (layerIds.length === 0) return;
+ layerIds.forEach(id => {
+ this.editingLayers[id].mask = this._erodeMask(this.editingLayers[id].mask);
+ });
this.drawPredictionMaskLayer();
}
smoothEditingMask() {
- if (!this.editingMask) return;
- let mask = this._dilateMask(this._erodeMask(this.editingMask));
- mask = this._erodeMask(this._dilateMask(mask));
- mask = this._smoothMask(mask);
- this.editingMask = mask;
+ const layerIds = Object.keys(this.editingLayers);
+ if (layerIds.length === 0) return;
+ layerIds.forEach(id => {
+ let mask = this.editingLayers[id].mask;
+ mask = this._dilateMask(this._erodeMask(mask));
+ mask = this._erodeMask(this._dilateMask(mask));
+ mask = this._smoothMask(mask);
+ this.editingLayers[id].mask = mask;
+ });
this.drawPredictionMaskLayer();
}
invertEditingMask() {
- if (!this.editingMask) return;
- const h = this.editingMask.length; const w = this.editingMask[0].length;
- for (let y = 0; y < h; y++) {
- for (let x = 0; x < w; x++) {
- this.editingMask[y][x] = this.editingMask[y][x] ? 0 : 1;
+ const layerIds = Object.keys(this.editingLayers);
+ if (layerIds.length === 0) return;
+ layerIds.forEach(id => {
+ const mask = this.editingLayers[id].mask;
+ const h = mask.length; const w = mask[0].length;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ mask[y][x] = mask[y][x] ? 0 : 1;
+ }
}
- }
+ });
this.drawPredictionMaskLayer();
}
@@ -1304,13 +1338,18 @@ class CanvasManager {
return out;
}
- getEditedMask() {
- return this.editingMask ? this.editingMask.map(r => [...r]) : null;
+ getEditedMasks() {
+ const out = {};
+ for (const [id, obj] of Object.entries(this.editingLayers)) {
+ out[id] = obj.mask.map(r => [...r]);
+ }
+ return out;
}
finishMaskEdit() {
- this.editingLayerId = null;
- this.editingMask = null;
+ this.editingLayers = {};
+ this.editHistory = [];
+ this.editHistoryIndex = -1;
this.drawPredictionMaskLayer();
}
diff --git a/app/frontend/static/js/editModeController.js b/app/frontend/static/js/editModeController.js
index 6e71d92..4c10f19 100644
--- a/app/frontend/static/js/editModeController.js
+++ b/app/frontend/static/js/editModeController.js
@@ -11,7 +11,7 @@ class EditModeController {
this.stateManager = stateManager;
this.apiClient = apiClient;
this.utils = utils;
- this.activeLayer = null;
+ this.activeLayers = [];
this.brushSize = 10;
this.isDrawing = false;
this.currentTool = 'brush'; // 'brush' or 'lasso'
@@ -33,8 +33,7 @@ class EditModeController {
this.invertBtn = document.getElementById('edit-invert-btn');
this.undoBtn = document.getElementById('edit-undo-btn');
this.redoBtn = document.getElementById('edit-redo-btn');
- this.saveBtn = document.getElementById('edit-save-btn');
- this.cancelBtn = document.getElementById('edit-cancel-btn');
+ this.discardBtn = document.getElementById('edit-discard-btn');
this.previewEl = document.getElementById('brush-preview');
}
@@ -51,8 +50,7 @@ class EditModeController {
if (this.invertBtn) this.invertBtn.addEventListener('click', () => this.actionInvert());
if (this.undoBtn) this.undoBtn.addEventListener('click', () => this.actionUndo());
if (this.redoBtn) this.redoBtn.addEventListener('click', () => this.actionRedo());
- if (this.saveBtn) this.saveBtn.addEventListener('click', () => this.save());
- if (this.cancelBtn) this.cancelBtn.addEventListener('click', () => this.cancel());
+ if (this.discardBtn) this.discardBtn.addEventListener('click', () => this.discard());
const canvas = this.canvasManager.userInputCanvas;
if (canvas) {
canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
@@ -71,17 +69,17 @@ class EditModeController {
this.previewEl.style.height = `${r}px`;
}
- beginEdit(layer) {
- if (!layer) return;
- this.activeLayer = layer;
- this.canvasManager.startMaskEdit(layer.layerId, layer.maskData, layer.displayColor);
+ beginEdit(layers) {
+ if (!Array.isArray(layers) || layers.length === 0) return;
+ this.activeLayers = layers;
+ this.canvasManager.startMaskEdit(layers);
this.showControls(true);
this.selectTool('brush');
this.updatePreviewSize();
}
endEdit() {
- this.activeLayer = null;
+ this.activeLayers = [];
this.showControls(false);
this.canvasManager.finishMaskEdit();
if (this.previewEl) this.previewEl.style.display = 'none';
@@ -95,7 +93,7 @@ class EditModeController {
}
onMouseDown(e) {
- if (!this.activeLayer) return;
+ if (!this.activeLayers || this.activeLayers.length === 0) return;
this.isDrawing = true;
if (this.currentTool === 'brush' && this.previewEl) {
this.previewEl.style.position = 'fixed';
@@ -116,7 +114,7 @@ class EditModeController {
onMouseMove(e) {
- if (!this.activeLayer) return;
+ if (!this.activeLayers || this.activeLayers.length === 0) return;
if (this.currentTool === 'brush' && this.previewEl) {
this.previewEl.style.position = 'fixed';
this.previewEl.style.left = `${e.clientX}px`;
@@ -156,7 +154,7 @@ class EditModeController {
this.currentTool = tool;
if (this.brushBtn) this.brushBtn.classList.toggle('active', tool === 'brush');
if (this.lassoBtn) this.lassoBtn.classList.toggle('active', tool === 'lasso');
- if (this.previewEl) this.previewEl.style.display = tool === 'brush' && this.activeLayer ? 'block' : 'none';
+ if (this.previewEl) this.previewEl.style.display = tool === 'brush' && this.activeLayers && this.activeLayers.length > 0 ? 'block' : 'none';
this.canvasManager.clearLassoPreview();
}
@@ -188,15 +186,8 @@ class EditModeController {
this.canvasManager.redoEdit();
}
- save() {
- if (!this.activeLayer) return;
- const edited = this.canvasManager.getEditedMask();
- this.utils.dispatchCustomEvent('edit-save', { layerId: this.activeLayer.layerId, maskData: edited });
- this.endEdit();
- }
-
- cancel() {
- this.utils.dispatchCustomEvent('edit-cancel', {});
+ discard() {
+ this.utils.dispatchCustomEvent('edit-discard', {});
this.endEdit();
}
}
diff --git a/app/frontend/static/js/main.js b/app/frontend/static/js/main.js
index e7a2f31..a3f705f 100644
--- a/app/frontend/static/js/main.js
+++ b/app/frontend/static/js/main.js
@@ -164,6 +164,33 @@ document.addEventListener("DOMContentLoaded", () => {
let reviewHistoryIndex = -1;
let navigatingHistory = false;
let suppressStatusChangeEvents = false;
+ async function autoSaveCurrentEdits() {
+ if (!activeImageState || !editModeController) return;
+ const masks = canvasManager.getEditedMasks();
+ const projectId = stateManager.getActiveProjectId();
+ const ih = activeImageState.imageHash;
+ for (const [layerId, mask] of Object.entries(masks)) {
+ const layer = activeImageState.layers.find((l) => l.layerId === layerId);
+ if (!layer || !mask) continue;
+ layer.maskData = mask;
+ layer.status = "edited";
+ if (projectId && ih) {
+ const rle = utils.binaryMaskToRLE(mask);
+ try {
+ await apiClient.updateMaskLayer(projectId, ih, layerId, {
+ mask_data_rle: rle,
+ status: "edited",
+ });
+ } catch (err) {
+ uiManager.showGlobalStatus(
+ `Save edit failed: ${utils.escapeHTML(err.message)}`,
+ "error",
+ );
+ }
+ }
+ onImageDataChange("layer-modified", { layerId });
+ }
+ }
function deriveStatusFromLayers() {
if (!activeImageState) return "unprocessed";
@@ -1143,11 +1170,18 @@ document.addEventListener("DOMContentLoaded", () => {
// Export button now handled by ExportDialog
- document.addEventListener("layers-selected", (event) => {
+ document.addEventListener("layers-selected", async (event) => {
if (!activeImageState) return;
const ids = Array.isArray(event.detail.layerIds)
? event.detail.layerIds
: [];
+ const prevIds = editModeController && editModeController.activeLayers
+ ? editModeController.activeLayers.map((l) => l.layerId)
+ : [];
+ if (prevIds.length > 0 && JSON.stringify(prevIds) !== JSON.stringify(ids)) {
+ await autoSaveCurrentEdits();
+ if (editModeController) editModeController.endEdit();
+ }
canvasManager.clearAllCanvasInputs(false);
if (ids.length === 0) {
canvasManager.setMode("creation");
@@ -1156,9 +1190,11 @@ document.addEventListener("DOMContentLoaded", () => {
}
canvasManager.setLayers(activeImageState.layers);
if (editModeController) {
- if (ids.length === 1) {
- const layer = activeImageState.layers.find((l) => l.layerId === ids[0]);
- editModeController.beginEdit(layer);
+ if (ids.length > 0) {
+ const layers = ids
+ .map((id) => activeImageState.layers.find((l) => l.layerId === id))
+ .filter(Boolean);
+ editModeController.beginEdit(layers);
utils.hideElement(creationActions);
utils.showElement(editActions, "flex");
updateHelpTooltipForMode("edit");
@@ -1331,40 +1367,8 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
- document.addEventListener("edit-save", (event) => {
- if (!activeImageState) return;
- const layer = activeImageState.layers.find(
- (l) => l.layerId === event.detail.layerId,
- );
- if (layer && event.detail.maskData) {
- layer.maskData = event.detail.maskData;
- layer.status = "edited";
- const pid = stateManager.getActiveProjectId();
- const ih = activeImageState.imageHash;
- if (pid && ih) {
- const rle = utils.binaryMaskToRLE(layer.maskData);
- apiClient
- .updateMaskLayer(pid, ih, layer.layerId, {
- mask_data_rle: rle,
- status: "edited",
- })
- .catch((err) => {
- uiManager.showGlobalStatus(
- `Save edit failed: ${utils.escapeHTML(err.message)}`,
- "error",
- );
- });
- }
- onImageDataChange("layer-modified", { layerId: layer.layerId });
- }
- if (layerViewController) layerViewController.setSelectedLayers([]);
- canvasManager.setMode("creation");
- utils.showElement(creationActions, "flex");
- utils.hideElement(editActions);
- if (!reviewMode) updateHelpTooltipForMode("creation");
- });
- document.addEventListener("edit-cancel", () => {
+ document.addEventListener("edit-discard", () => {
if (activeImageState) {
canvasManager.setLayers(activeImageState.layers);
}
@@ -1381,7 +1385,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
- document.addEventListener("active-image-set", (event) => {
+ document.addEventListener("active-image-set", async (event) => {
if (
activeImageState &&
activeImageState.imageHash === event.detail.imageHash
@@ -1389,13 +1393,19 @@ document.addEventListener("DOMContentLoaded", () => {
activeImageState.status = event.detail.status || "unprocessed";
}
onImageDataChange("image-loaded", { imageHash: event.detail.imageHash });
- if (editModeController) editModeController.endEdit();
+ if (editModeController && editModeController.activeLayers.length > 0) {
+ await autoSaveCurrentEdits();
+ editModeController.endEdit();
+ }
canvasManager.setMode("creation");
});
- document.addEventListener("active-image-cleared", () => {
+ document.addEventListener("active-image-cleared", async () => {
+ if (editModeController && editModeController.activeLayers.length > 0) {
+ await autoSaveCurrentEdits();
+ editModeController.endEdit();
+ }
activeImageState = null;
- if (editModeController) editModeController.endEdit();
canvasManager.setMode("creation");
updateStatusToggleUI("unprocessed", false);
});
diff --git a/app/frontend/templates/index.html b/app/frontend/templates/index.html
index b6f3fe3..93039e5 100644
--- a/app/frontend/templates/index.html
+++ b/app/frontend/templates/index.html
@@ -297,8 +297,7 @@
Export Annotations
-
-
+
diff --git a/docs/annotation_workflow_progress.md b/docs/annotation_workflow_progress.md
index 371c972..7abd4fd 100644
--- a/docs/annotation_workflow_progress.md
+++ b/docs/annotation_workflow_progress.md
@@ -99,7 +99,10 @@ It will be updated as new sprints add functionality.
- ~~Add update-status dropdown in the annotation view.~~ Implemented as Ready/Skip toggle switches.
- Improve error handling and autosave of `ActiveImageState` to prevent data
loss.
-- **Tagify Layer Tags**: Layer tags are edited using the Tagify widget with
- auto-suggestions from existing project tags. Suggestions update when tags
- change and appear when focusing the tag field.
+ - **Tagify Layer Tags**: Layer tags are edited using the Tagify widget with
+ auto-suggestions from existing project tags. Suggestions update when tags
+ change and appear when focusing the tag field.
+ - **Auto-Saving Edits**: Mask edits are saved automatically when changing
+ layer selection or images. The edit toolbar now features a single
+ `Discard` button to revert changes.
diff --git a/docs/annotation_workflow_specification.md b/docs/annotation_workflow_specification.md
index f4687a6..217c719 100644
--- a/docs/annotation_workflow_specification.md
+++ b/docs/annotation_workflow_specification.md
@@ -124,10 +124,9 @@ The canvas interaction will be modal, determined by whether a layer is selected
* **Invert:** Inverts the mask.
* **Undo:** Undo last action (Step backward in edit memory).
* **Redo:** Redo (Step forward in edit memory).
- * **Save:** Save the edits and exit edit mode.
- * **Cancel:** Discard edits and exit edit mode.
+ * **Discard:** Revert changes and exit edit mode.
- A `Save Edit` and `Cancel`: `Save Edit` finalizes the changes to the mask data and sets the layer status to `Edited`. Cancel discards the edits. Both returns the user to Creation Mode (no layer/ empty new layer selected).
+ Edits are automatically saved when leaving edit mode or changing the selected layers. The `Discard` button reverts the masks to their original state without saving.
#### 3.1.4. Image Status & Pool
From 3e38d39d7bf322893ce2c6ad77a409894f3e5723 Mon Sep 17 00:00:00 2001
From: Udhul <126940798+Udhul@users.noreply.github.com>
Date: Fri, 27 Jun 2025 18:44:06 +0200
Subject: [PATCH 3/8] feat: add layer merge and duplication
---
app/frontend/static/js/canvasController.js | 179 +++++++-----------
app/frontend/static/js/editModeController.js | 18 +-
app/frontend/static/js/layerViewController.js | 12 ++
app/frontend/static/js/main.js | 169 +++++++++++++----
app/frontend/templates/index.html | 2 +
docs/annotation_workflow_progress.md | 1 +
docs/annotation_workflow_specification.md | 2 +
7 files changed, 227 insertions(+), 156 deletions(-)
diff --git a/app/frontend/static/js/canvasController.js b/app/frontend/static/js/canvasController.js
index 2b53859..79d710f 100644
--- a/app/frontend/static/js/canvasController.js
+++ b/app/frontend/static/js/canvasController.js
@@ -116,7 +116,9 @@ class CanvasManager {
this.selectedLayerIds = [];
this.mode = 'edit'; // 'creation', 'edit', 'review'
- this.editingLayers = {}; // { layerId: { mask, color } }
+ this.editingLayerId = null;
+ this.editingMask = null;
+ this.editingColor = '#ff0000';
this.editHistory = [];
this.editHistoryIndex = -1;
@@ -513,10 +515,13 @@ class CanvasManager {
if (this.mode === 'edit' && this.selectedLayerIds.length > 0) {
op = this.selectedLayerIds.includes(l.layerId) ? 1.0 : FADED_MASK_OPACITY;
}
- const isEditing = this.editingLayers.hasOwnProperty(l.layerId);
- const mask = isEditing ? this.editingLayers[l.layerId].mask : l.maskData;
- const color = isEditing ? this.editingLayers[l.layerId].color : l.color;
- const hatch = !isEditing;
+ const mask = (this.editingLayerId && l.layerId === this.editingLayerId && this.editingMask)
+ ? this.editingMask
+ : l.maskData;
+ const color = (this.editingLayerId && l.layerId === this.editingLayerId)
+ ? this.editingColor
+ : l.color;
+ const hatch = !(this.editingLayerId && l.layerId === this.editingLayerId);
if (mask) this._drawBinaryMask(mask, color, op, hatch);
});
}
@@ -1096,62 +1101,51 @@ class CanvasManager {
this.drawPredictionMaskLayer();
}
- startMaskEdit(layers) {
- this.editingLayers = {};
- if (!Array.isArray(layers)) layers = [];
- layers.forEach(layer => {
- let mask;
- if (Array.isArray(layer.maskData) && Array.isArray(layer.maskData[0])) {
- mask = layer.maskData.map(r => Array.from(r));
- } else if (layer.maskData && layer.maskData.counts && layer.maskData.size) {
- mask = this.Utils.rleToBinaryMask(
- layer.maskData,
- this.originalImageHeight,
- this.originalImageWidth
- );
- mask = mask || this._createEmptyMask();
- } else {
- mask = this._createEmptyMask();
- }
- this.editingLayers[layer.layerId] = { mask, color: layer.displayColor || layer.color || '#ff0000' };
- });
- this.editHistory = [this.getEditedMasks()];
+ startMaskEdit(layerId, maskData, color) {
+ this.editingLayerId = layerId;
+ this.editingColor = color || '#ff0000';
+ if (Array.isArray(maskData) && Array.isArray(maskData[0])) {
+ this.editingMask = maskData.map(r => Array.from(r));
+ } else if (maskData && maskData.counts && maskData.size) {
+ const converted = this.Utils.rleToBinaryMask(
+ maskData,
+ this.originalImageHeight,
+ this.originalImageWidth
+ );
+ this.editingMask = converted || this._createEmptyMask();
+ } else {
+ this.editingMask = this._createEmptyMask();
+ }
+ this.editHistory = [this.getEditedMask()];
this.editHistoryIndex = 0;
this.drawPredictionMaskLayer();
}
applyBrush(x, y, radius, add = true) {
- const layerIds = Object.keys(this.editingLayers);
- if (layerIds.length === 0) return;
+ if (!this.editingMask) return;
+ const h = this.editingMask.length;
+ const w = this.editingMask[0].length;
const cx = Math.round(x);
const cy = Math.round(y);
- layerIds.forEach(id => {
- const mask = this.editingLayers[id].mask;
- const h = mask.length;
- const w = mask[0].length;
- for (let j = -radius; j <= radius; j++) {
- for (let i = -radius; i <= radius; i++) {
- if (i * i + j * j <= radius * radius) {
- const nx = cx + i;
- const ny = cy + j;
- if (nx >= 0 && ny >= 0 && nx < w && ny < h) {
- mask[ny][nx] = add ? 1 : 0;
- }
+ for (let j = -radius; j <= radius; j++) {
+ for (let i = -radius; i <= radius; i++) {
+ if (i * i + j * j <= radius * radius) {
+ const nx = cx + i;
+ const ny = cy + j;
+ if (nx >= 0 && ny >= 0 && nx < w && ny < h) {
+ this.editingMask[ny][nx] = add ? 1 : 0;
}
}
}
- });
+ }
this.drawPredictionMaskLayer();
}
applyLasso(points, add = true) {
- const layerIds = Object.keys(this.editingLayers);
- if (layerIds.length === 0 || !points || points.length < 3) return;
- let minX, minY, maxX, maxY;
- const anyMask = this.editingLayers[layerIds[0]].mask;
- const h = anyMask.length;
- const w = anyMask[0].length;
- minX = w; minY = h; maxX = 0; maxY = 0;
+ if (!this.editingMask || !points || points.length < 3) return;
+ const h = this.editingMask.length;
+ const w = this.editingMask[0].length;
+ let minX = w, minY = h, maxX = 0, maxY = 0;
points.forEach(p => {
if (p.x < minX) minX = Math.floor(p.x);
if (p.x > maxX) maxX = Math.ceil(p.x);
@@ -1160,16 +1154,13 @@ class CanvasManager {
});
minX = Math.max(0, minX); minY = Math.max(0, minY);
maxX = Math.min(w - 1, maxX); maxY = Math.min(h - 1, maxY);
- layerIds.forEach(id => {
- const mask = this.editingLayers[id].mask;
- for (let y = minY; y <= maxY; y++) {
- for (let x = minX; x <= maxX; x++) {
- if (this._isPointInPolygon({x, y}, points)) {
- mask[y][x] = add ? 1 : 0;
- }
+ for (let y = minY; y <= maxY; y++) {
+ for (let x = minX; x <= maxX; x++) {
+ if (this._isPointInPolygon({x, y}, points)) {
+ this.editingMask[y][x] = add ? 1 : 0;
}
}
- });
+ }
this.drawPredictionMaskLayer();
}
@@ -1198,25 +1189,19 @@ class CanvasManager {
}
commitHistoryStep() {
- const layerIds = Object.keys(this.editingLayers);
- if (layerIds.length === 0) return;
+ if (!this.editingMask) return;
if (this.editHistoryIndex < this.editHistory.length - 1) {
this.editHistory = this.editHistory.slice(0, this.editHistoryIndex + 1);
}
- this.editHistory.push(this.getEditedMasks());
+ this.editHistory.push(this.getEditedMask());
this.editHistoryIndex = this.editHistory.length - 1;
}
undoEdit() {
if (this.editHistoryIndex > 0) {
this.editHistoryIndex -= 1;
- const snapshot = this.editHistory[this.editHistoryIndex];
- for (const id of Object.keys(snapshot)) {
- if (this.editingLayers[id]) {
- const mask = snapshot[id];
- this.editingLayers[id].mask = mask.map(r => [...r]);
- }
- }
+ const mask = this.editHistory[this.editHistoryIndex];
+ this.editingMask = mask.map(r => [...r]);
this.drawPredictionMaskLayer();
}
}
@@ -1224,60 +1209,41 @@ class CanvasManager {
redoEdit() {
if (this.editHistoryIndex < this.editHistory.length - 1) {
this.editHistoryIndex += 1;
- const snapshot = this.editHistory[this.editHistoryIndex];
- for (const id of Object.keys(snapshot)) {
- if (this.editingLayers[id]) {
- const mask = snapshot[id];
- this.editingLayers[id].mask = mask.map(r => [...r]);
- }
- }
+ const mask = this.editHistory[this.editHistoryIndex];
+ this.editingMask = mask.map(r => [...r]);
this.drawPredictionMaskLayer();
}
}
growEditingMask() {
- const layerIds = Object.keys(this.editingLayers);
- if (layerIds.length === 0) return;
- layerIds.forEach(id => {
- this.editingLayers[id].mask = this._dilateMask(this.editingLayers[id].mask);
- });
+ if (!this.editingMask) return;
+ this.editingMask = this._dilateMask(this.editingMask);
this.drawPredictionMaskLayer();
}
shrinkEditingMask() {
- const layerIds = Object.keys(this.editingLayers);
- if (layerIds.length === 0) return;
- layerIds.forEach(id => {
- this.editingLayers[id].mask = this._erodeMask(this.editingLayers[id].mask);
- });
+ if (!this.editingMask) return;
+ this.editingMask = this._erodeMask(this.editingMask);
this.drawPredictionMaskLayer();
}
smoothEditingMask() {
- const layerIds = Object.keys(this.editingLayers);
- if (layerIds.length === 0) return;
- layerIds.forEach(id => {
- let mask = this.editingLayers[id].mask;
- mask = this._dilateMask(this._erodeMask(mask));
- mask = this._erodeMask(this._dilateMask(mask));
- mask = this._smoothMask(mask);
- this.editingLayers[id].mask = mask;
- });
+ if (!this.editingMask) return;
+ let mask = this._dilateMask(this._erodeMask(this.editingMask));
+ mask = this._erodeMask(this._dilateMask(mask));
+ mask = this._smoothMask(mask);
+ this.editingMask = mask;
this.drawPredictionMaskLayer();
}
invertEditingMask() {
- const layerIds = Object.keys(this.editingLayers);
- if (layerIds.length === 0) return;
- layerIds.forEach(id => {
- const mask = this.editingLayers[id].mask;
- const h = mask.length; const w = mask[0].length;
- for (let y = 0; y < h; y++) {
- for (let x = 0; x < w; x++) {
- mask[y][x] = mask[y][x] ? 0 : 1;
- }
+ if (!this.editingMask) return;
+ const h = this.editingMask.length; const w = this.editingMask[0].length;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ this.editingMask[y][x] = this.editingMask[y][x] ? 0 : 1;
}
- });
+ }
this.drawPredictionMaskLayer();
}
@@ -1338,16 +1304,13 @@ class CanvasManager {
return out;
}
- getEditedMasks() {
- const out = {};
- for (const [id, obj] of Object.entries(this.editingLayers)) {
- out[id] = obj.mask.map(r => [...r]);
- }
- return out;
+ getEditedMask() {
+ return this.editingMask ? this.editingMask.map(r => [...r]) : null;
}
finishMaskEdit() {
- this.editingLayers = {};
+ this.editingLayerId = null;
+ this.editingMask = null;
this.editHistory = [];
this.editHistoryIndex = -1;
this.drawPredictionMaskLayer();
diff --git a/app/frontend/static/js/editModeController.js b/app/frontend/static/js/editModeController.js
index 4c10f19..b45f908 100644
--- a/app/frontend/static/js/editModeController.js
+++ b/app/frontend/static/js/editModeController.js
@@ -11,7 +11,7 @@ class EditModeController {
this.stateManager = stateManager;
this.apiClient = apiClient;
this.utils = utils;
- this.activeLayers = [];
+ this.activeLayer = null;
this.brushSize = 10;
this.isDrawing = false;
this.currentTool = 'brush'; // 'brush' or 'lasso'
@@ -69,17 +69,17 @@ class EditModeController {
this.previewEl.style.height = `${r}px`;
}
- beginEdit(layers) {
- if (!Array.isArray(layers) || layers.length === 0) return;
- this.activeLayers = layers;
- this.canvasManager.startMaskEdit(layers);
+ beginEdit(layer) {
+ if (!layer) return;
+ this.activeLayer = layer;
+ this.canvasManager.startMaskEdit(layer.layerId, layer.maskData, layer.displayColor);
this.showControls(true);
this.selectTool('brush');
this.updatePreviewSize();
}
endEdit() {
- this.activeLayers = [];
+ this.activeLayer = null;
this.showControls(false);
this.canvasManager.finishMaskEdit();
if (this.previewEl) this.previewEl.style.display = 'none';
@@ -93,7 +93,7 @@ class EditModeController {
}
onMouseDown(e) {
- if (!this.activeLayers || this.activeLayers.length === 0) return;
+ if (!this.activeLayer) return;
this.isDrawing = true;
if (this.currentTool === 'brush' && this.previewEl) {
this.previewEl.style.position = 'fixed';
@@ -114,7 +114,7 @@ class EditModeController {
onMouseMove(e) {
- if (!this.activeLayers || this.activeLayers.length === 0) return;
+ if (!this.activeLayer) return;
if (this.currentTool === 'brush' && this.previewEl) {
this.previewEl.style.position = 'fixed';
this.previewEl.style.left = `${e.clientX}px`;
@@ -154,7 +154,7 @@ class EditModeController {
this.currentTool = tool;
if (this.brushBtn) this.brushBtn.classList.toggle('active', tool === 'brush');
if (this.lassoBtn) this.lassoBtn.classList.toggle('active', tool === 'lasso');
- if (this.previewEl) this.previewEl.style.display = tool === 'brush' && this.activeLayers && this.activeLayers.length > 0 ? 'block' : 'none';
+ if (this.previewEl) this.previewEl.style.display = tool === 'brush' && this.activeLayer ? 'block' : 'none';
this.canvasManager.clearLassoPreview();
}
diff --git a/app/frontend/static/js/layerViewController.js b/app/frontend/static/js/layerViewController.js
index 5d3a60e..1dd6a41 100644
--- a/app/frontend/static/js/layerViewController.js
+++ b/app/frontend/static/js/layerViewController.js
@@ -10,6 +10,7 @@ class LayerViewController {
this.stateManager = stateManager;
this.layers = [];
this.selectedLayerIds = [];
+ this.lastSelectedLayerId = null;
this.allProjectTags = [];
this.tagifyInstances = {};
this.Utils = window.Utils || { dispatchCustomEvent: (n,d)=>document.dispatchEvent(new CustomEvent(n,{detail:d})) };
@@ -51,12 +52,14 @@ class LayerViewController {
setSelectedLayers(layerIds) {
this.selectedLayerIds = Array.isArray(layerIds) ? [...layerIds] : [];
+ this.lastSelectedLayerId = this.selectedLayerIds.length > 0 ? this.selectedLayerIds[this.selectedLayerIds.length - 1] : null;
this.render();
}
clearSelection() {
if (this.selectedLayerIds.length > 0) {
this.selectedLayerIds = [];
+ this.lastSelectedLayerId = null;
this.Utils.dispatchCustomEvent('layers-selected', { layerIds: [] });
this.render();
}
@@ -67,14 +70,20 @@ class LayerViewController {
const idx = this.selectedLayerIds.indexOf(layerId);
if (idx !== -1) {
this.selectedLayerIds.splice(idx, 1);
+ if (layerId === this.lastSelectedLayerId) {
+ this.lastSelectedLayerId = this.selectedLayerIds[this.selectedLayerIds.length - 1] || null;
+ }
} else {
this.selectedLayerIds.push(layerId);
+ this.lastSelectedLayerId = layerId;
}
} else {
if (this.selectedLayerIds.length === 1 && this.selectedLayerIds[0] === layerId) {
this.selectedLayerIds = [];
+ this.lastSelectedLayerId = null;
} else {
this.selectedLayerIds = [layerId];
+ this.lastSelectedLayerId = layerId;
}
}
this.Utils.dispatchCustomEvent('layers-selected', { layerIds: [...this.selectedLayerIds] });
@@ -88,6 +97,9 @@ class LayerViewController {
const selIdx = this.selectedLayerIds.indexOf(layerId);
if (selIdx !== -1) {
this.selectedLayerIds.splice(selIdx, 1);
+ if (layerId === this.lastSelectedLayerId) {
+ this.lastSelectedLayerId = this.selectedLayerIds[this.selectedLayerIds.length - 1] || null;
+ }
this.Utils.dispatchCustomEvent('layers-selected', { layerIds: [...this.selectedLayerIds] });
}
this.Utils.dispatchCustomEvent('layer-deleted', { layerId });
diff --git a/app/frontend/static/js/main.js b/app/frontend/static/js/main.js
index a3f705f..cbdc863 100644
--- a/app/frontend/static/js/main.js
+++ b/app/frontend/static/js/main.js
@@ -118,6 +118,8 @@ document.addEventListener("DOMContentLoaded", () => {
const commitMasksBtn = document.getElementById("commit-masks-btn");
const openExportBtn = document.getElementById("open-export-btn");
const addEmptyLayerBtn = document.getElementById("add-empty-layer-btn");
+ const duplicateLayerBtn = document.getElementById("duplicate-layer-btn");
+ const mergeLayersBtn = document.getElementById("merge-layers-btn");
const creationActions = document.getElementById("creation-actions");
const editActions = document.getElementById("edit-actions");
const helpTooltipContent = document.querySelector("#help-icon .tooltip-content");
@@ -164,32 +166,33 @@ document.addEventListener("DOMContentLoaded", () => {
let reviewHistoryIndex = -1;
let navigatingHistory = false;
let suppressStatusChangeEvents = false;
+ let selectedLayerIds = [];
+ let activeLayerId = null;
async function autoSaveCurrentEdits() {
- if (!activeImageState || !editModeController) return;
- const masks = canvasManager.getEditedMasks();
+ if (!activeImageState || !editModeController || !editModeController.activeLayer) return;
+ const mask = canvasManager.getEditedMask();
+ const layerId = editModeController.activeLayer.layerId;
+ const layer = activeImageState.layers.find((l) => l.layerId === layerId);
+ if (!layer || !mask) return;
+ layer.maskData = mask;
+ layer.status = "edited";
const projectId = stateManager.getActiveProjectId();
const ih = activeImageState.imageHash;
- for (const [layerId, mask] of Object.entries(masks)) {
- const layer = activeImageState.layers.find((l) => l.layerId === layerId);
- if (!layer || !mask) continue;
- layer.maskData = mask;
- layer.status = "edited";
- if (projectId && ih) {
- const rle = utils.binaryMaskToRLE(mask);
- try {
- await apiClient.updateMaskLayer(projectId, ih, layerId, {
- mask_data_rle: rle,
- status: "edited",
- });
- } catch (err) {
- uiManager.showGlobalStatus(
- `Save edit failed: ${utils.escapeHTML(err.message)}`,
- "error",
- );
- }
+ if (projectId && ih) {
+ const rle = utils.binaryMaskToRLE(mask);
+ try {
+ await apiClient.updateMaskLayer(projectId, ih, layerId, {
+ mask_data_rle: rle,
+ status: "edited",
+ });
+ } catch (err) {
+ uiManager.showGlobalStatus(
+ `Save edit failed: ${utils.escapeHTML(err.message)}`,
+ "error",
+ );
}
- onImageDataChange("layer-modified", { layerId });
}
+ onImageDataChange("layer-modified", { layerId });
}
function deriveStatusFromLayers() {
@@ -783,6 +786,96 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
+ if (duplicateLayerBtn) {
+ duplicateLayerBtn.addEventListener("click", async () => {
+ if (!activeImageState || !activeLayerId) return;
+ const original = activeImageState.layers.find((l) => l.layerId === activeLayerId);
+ if (!original) return;
+ const projectId = stateManager.getActiveProjectId();
+ if (!projectId) return;
+ const name = `${original.name} (copy)`;
+ const color = utils.getRandomHexColor();
+ try {
+ const res = await apiClient.createEmptyLayer(projectId, activeImageState.imageHash, {
+ name,
+ class_labels: original.classLabels || [],
+ display_color: color,
+ });
+ if (res.success) {
+ const newLayer = {
+ layerId: res.layer_id,
+ name,
+ classLabels: [...(original.classLabels || [])],
+ status: "edited",
+ visible: true,
+ displayColor: color,
+ maskData: original.maskData ? JSON.parse(JSON.stringify(original.maskData)) : null,
+ };
+ if (newLayer.maskData) {
+ const rle = utils.binaryMaskToRLE(newLayer.maskData);
+ await apiClient.updateMaskLayer(projectId, activeImageState.imageHash, newLayer.layerId, {
+ mask_data_rle: rle,
+ status: "edited",
+ });
+ }
+ activeImageState.layers.unshift(newLayer);
+ onImageDataChange("layer-added", { layerIds: [newLayer.layerId] });
+ layerViewController.setLayers(activeImageState.layers);
+ }
+ } catch (err) {
+ uiManager.showGlobalStatus(`Duplicate failed: ${utils.escapeHTML(err.message)}`, "error");
+ }
+ });
+ }
+
+ if (mergeLayersBtn) {
+ mergeLayersBtn.addEventListener("click", async () => {
+ if (!activeImageState || !activeLayerId || selectedLayerIds.length <= 1) return;
+ const projectId = stateManager.getActiveProjectId();
+ if (!projectId) return;
+ const ih = activeImageState.imageHash;
+ const activeLayer = activeImageState.layers.find((l) => l.layerId === activeLayerId);
+ if (!activeLayer) return;
+ const others = selectedLayerIds.filter((id) => id !== activeLayerId)
+ .map((id) => activeImageState.layers.find((l) => l.layerId === id))
+ .filter(Boolean);
+ if (others.length === 0) return;
+ others.forEach((o) => {
+ if (o.maskData && activeLayer.maskData) {
+ for (let y = 0; y < activeLayer.maskData.length; y++) {
+ for (let x = 0; x < activeLayer.maskData[0].length; x++) {
+ activeLayer.maskData[y][x] = activeLayer.maskData[y][x] || o.maskData[y][x];
+ }
+ }
+ } else if (o.maskData && !activeLayer.maskData) {
+ activeLayer.maskData = JSON.parse(JSON.stringify(o.maskData));
+ }
+ (o.classLabels || []).forEach((t) => {
+ if (!activeLayer.classLabels.includes(t)) activeLayer.classLabels.push(t);
+ });
+ });
+ if (activeLayer.maskData) {
+ const rle = utils.binaryMaskToRLE(activeLayer.maskData);
+ await apiClient.updateMaskLayer(projectId, ih, activeLayer.layerId, {
+ mask_data_rle: rle,
+ class_labels: activeLayer.classLabels,
+ status: "edited",
+ });
+ }
+ for (const o of others) {
+ activeImageState.layers = activeImageState.layers.filter((l) => l.layerId !== o.layerId);
+ try {
+ await apiClient.deleteMaskLayer(projectId, ih, o.layerId);
+ } catch (e) {}
+ onImageDataChange("layer-deleted", { layerId: o.layerId });
+ }
+ layerViewController.setLayers(activeImageState.layers);
+ layerViewController.setSelectedLayers([activeLayerId]);
+ canvasManager.setLayers(activeImageState.layers);
+ canvasManager.setMode("edit", [activeLayerId]);
+ });
+ }
+
if (imageUploadInput) {
imageUploadInput.addEventListener("change", handleImageFileUpload);
}
@@ -1172,15 +1265,13 @@ document.addEventListener("DOMContentLoaded", () => {
document.addEventListener("layers-selected", async (event) => {
if (!activeImageState) return;
- const ids = Array.isArray(event.detail.layerIds)
- ? event.detail.layerIds
- : [];
- const prevIds = editModeController && editModeController.activeLayers
- ? editModeController.activeLayers.map((l) => l.layerId)
- : [];
- if (prevIds.length > 0 && JSON.stringify(prevIds) !== JSON.stringify(ids)) {
+ const ids = Array.isArray(event.detail.layerIds) ? event.detail.layerIds : [];
+ const prevActive = activeLayerId;
+ selectedLayerIds = ids;
+ activeLayerId = layerViewController ? layerViewController.lastSelectedLayerId : null;
+ if (prevActive && prevActive !== activeLayerId && editModeController && editModeController.activeLayer) {
await autoSaveCurrentEdits();
- if (editModeController) editModeController.endEdit();
+ editModeController.endEdit();
}
canvasManager.clearAllCanvasInputs(false);
if (ids.length === 0) {
@@ -1190,14 +1281,14 @@ document.addEventListener("DOMContentLoaded", () => {
}
canvasManager.setLayers(activeImageState.layers);
if (editModeController) {
- if (ids.length > 0) {
- const layers = ids
- .map((id) => activeImageState.layers.find((l) => l.layerId === id))
- .filter(Boolean);
- editModeController.beginEdit(layers);
- utils.hideElement(creationActions);
- utils.showElement(editActions, "flex");
- updateHelpTooltipForMode("edit");
+ if (activeLayerId) {
+ const layer = activeImageState.layers.find((l) => l.layerId === activeLayerId);
+ if (layer) {
+ editModeController.beginEdit(layer);
+ utils.hideElement(creationActions);
+ utils.showElement(editActions, "flex");
+ updateHelpTooltipForMode("edit");
+ }
} else {
editModeController.endEdit();
utils.showElement(creationActions, "flex");
@@ -1393,7 +1484,7 @@ document.addEventListener("DOMContentLoaded", () => {
activeImageState.status = event.detail.status || "unprocessed";
}
onImageDataChange("image-loaded", { imageHash: event.detail.imageHash });
- if (editModeController && editModeController.activeLayers.length > 0) {
+ if (editModeController && editModeController.activeLayer) {
await autoSaveCurrentEdits();
editModeController.endEdit();
}
@@ -1401,7 +1492,7 @@ document.addEventListener("DOMContentLoaded", () => {
});
document.addEventListener("active-image-cleared", async () => {
- if (editModeController && editModeController.activeLayers.length > 0) {
+ if (editModeController && editModeController.activeLayer) {
await autoSaveCurrentEdits();
editModeController.endEdit();
}
diff --git a/app/frontend/templates/index.html b/app/frontend/templates/index.html
index 93039e5..0b3ba00 100644
--- a/app/frontend/templates/index.html
+++ b/app/frontend/templates/index.html
@@ -352,6 +352,8 @@
Export Annotations
+
+
No layers yet. Use "Add to Layers".
diff --git a/docs/annotation_workflow_progress.md b/docs/annotation_workflow_progress.md
index 7abd4fd..55987da 100644
--- a/docs/annotation_workflow_progress.md
+++ b/docs/annotation_workflow_progress.md
@@ -105,4 +105,5 @@ It will be updated as new sprints add functionality.
- **Auto-Saving Edits**: Mask edits are saved automatically when changing
layer selection or images. The edit toolbar now features a single
`Discard` button to revert changes.
+ - **Layer Utilities**: Added Duplicate and Merge controls in the Layer View.
diff --git a/docs/annotation_workflow_specification.md b/docs/annotation_workflow_specification.md
index 217c719..89440ee 100644
--- a/docs/annotation_workflow_specification.md
+++ b/docs/annotation_workflow_specification.md
@@ -93,6 +93,8 @@ This panel sits to the right of the canvas and always reflects the layers for th
* **Controls:**
* **Select for Edit:** Clicking anywhere on the layer item (except controls) selects it, putting the canvas into **Edit Mode** for this layer. The selected layer should be highlighted.
* **Delete Button (Trash Icon):** Permanently removes the layer. Requires confirmation.
+ * **Duplicate Button:** Creates a copy of the active layer.
+ * **Merge Button:** Merges all selected layers into the active one, combining masks and labels.
At the bottom of the Layer View, compact buttons allow the user to save an overlay preview of the current image and export the project's annotations to COCO JSON.
From a7fc0db408d86b069ba93426d6b8c27ab8618064 Mon Sep 17 00:00:00 2001
From: Udhul <126940798+Udhul@users.noreply.github.com>
Date: Fri, 27 Jun 2025 19:09:04 +0200
Subject: [PATCH 4/8] Move layer utilities to status bar
---
app/frontend/static/js/main.js | 19 +++++++++++++++++++
app/frontend/templates/index.html | 4 ++--
docs/annotation_workflow_progress.md | 2 +-
docs/annotation_workflow_specification.md | 2 +-
4 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/app/frontend/static/js/main.js b/app/frontend/static/js/main.js
index cbdc863..24d52a2 100644
--- a/app/frontend/static/js/main.js
+++ b/app/frontend/static/js/main.js
@@ -168,6 +168,17 @@ document.addEventListener("DOMContentLoaded", () => {
let suppressStatusChangeEvents = false;
let selectedLayerIds = [];
let activeLayerId = null;
+
+ function updateLayerUtilityButtons() {
+ if (duplicateLayerBtn) {
+ duplicateLayerBtn.disabled = !activeLayerId;
+ }
+ if (mergeLayersBtn) {
+ mergeLayersBtn.disabled = !(
+ activeLayerId && Array.isArray(selectedLayerIds) && selectedLayerIds.length > 1
+ );
+ }
+ }
async function autoSaveCurrentEdits() {
if (!activeImageState || !editModeController || !editModeController.activeLayer) return;
const mask = canvasManager.getEditedMask();
@@ -220,6 +231,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
updateStatusToggleUI("unprocessed", false);
+ updateLayerUtilityButtons();
updateHelpTooltipForMode("creation");
function enterReviewMode() {
@@ -779,6 +791,7 @@ document.addEventListener("DOMContentLoaded", () => {
onImageDataChange("layer-added", { layerIds: [newLayer.layerId] });
canvasManager.clearAllCanvasInputs(false);
canvasManager.setMode("edit");
+ updateLayerUtilityButtons();
}
} catch (err) {
console.error("Failed to create empty layer", err);
@@ -825,6 +838,7 @@ document.addEventListener("DOMContentLoaded", () => {
} catch (err) {
uiManager.showGlobalStatus(`Duplicate failed: ${utils.escapeHTML(err.message)}`, "error");
}
+ updateLayerUtilityButtons();
});
}
@@ -862,6 +876,7 @@ document.addEventListener("DOMContentLoaded", () => {
status: "edited",
});
}
+ onImageDataChange("layer-modified", { layerId: activeLayer.layerId });
for (const o of others) {
activeImageState.layers = activeImageState.layers.filter((l) => l.layerId !== o.layerId);
try {
@@ -873,6 +888,7 @@ document.addEventListener("DOMContentLoaded", () => {
layerViewController.setSelectedLayers([activeLayerId]);
canvasManager.setLayers(activeImageState.layers);
canvasManager.setMode("edit", [activeLayerId]);
+ updateLayerUtilityButtons();
});
}
@@ -1296,6 +1312,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (!reviewMode) updateHelpTooltipForMode("creation");
}
}
+ updateLayerUtilityButtons();
});
document.addEventListener("layer-deleted", async (event) => {
@@ -1489,6 +1506,7 @@ document.addEventListener("DOMContentLoaded", () => {
editModeController.endEdit();
}
canvasManager.setMode("creation");
+ updateLayerUtilityButtons();
});
document.addEventListener("active-image-cleared", async () => {
@@ -1499,6 +1517,7 @@ document.addEventListener("DOMContentLoaded", () => {
activeImageState = null;
canvasManager.setMode("creation");
updateStatusToggleUI("unprocessed", false);
+ updateLayerUtilityButtons();
});
document.addEventListener("image-status-updated", (event) => {
diff --git a/app/frontend/templates/index.html b/app/frontend/templates/index.html
index 0b3ba00..267586e 100644
--- a/app/frontend/templates/index.html
+++ b/app/frontend/templates/index.html
@@ -352,12 +352,12 @@
Export Annotations
-
-
No layers yet. Use "Add to Layers".
+
+