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 5523727..e6dfae5 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); }); } @@ -737,6 +738,14 @@ class CanvasManager { _handleWheel(e) { if (!this.currentImage) return; + + if (this.editingMask && e.ctrlKey) { + e.preventDefault(); + const delta = e.deltaY < 0 ? 1 : -1; + this._dispatchEvent('brushSizeScroll', { delta }); + return; + } + e.preventDefault(); const rect = this.userInputCanvas.getBoundingClientRect(); @@ -893,7 +902,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 +918,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 +935,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; @@ -1128,10 +1137,10 @@ class CanvasManager { 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) { + if (i * i + j * j <= radius * radius) { const nx = cx + i; const ny = cy + j; - if (nx >=0 && ny >=0 && nx < w && ny < h) { + if (nx >= 0 && ny >= 0 && nx < w && ny < h) { this.editingMask[ny][nx] = add ? 1 : 0; } } @@ -1310,6 +1319,8 @@ class CanvasManager { finishMaskEdit() { 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 6e71d92..fe92be9 100644 --- a/app/frontend/static/js/editModeController.js +++ b/app/frontend/static/js/editModeController.js @@ -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)); @@ -62,6 +60,9 @@ class EditModeController { canvas.addEventListener('contextmenu', (e) => e.preventDefault()); } this.canvasManager.addEventListener('zoom-pan-changed', () => this.updatePreviewSize()); + document.addEventListener('canvas-brushSizeScroll', (e) => { + this.adjustBrushSize(e.detail.delta); + }); } updatePreviewSize() { @@ -71,6 +72,15 @@ class EditModeController { this.previewEl.style.height = `${r}px`; } + adjustBrushSize(delta) { + this.brushSize += delta; + if (this.brushSize < 1) this.brushSize = 1; + const max = this.brushSizeInput ? parseInt(this.brushSizeInput.max, 10) : 50; + if (this.brushSize > max) this.brushSize = max; + if (this.brushSizeInput) this.brushSizeInput.value = this.brushSize; + this.updatePreviewSize(); + } + beginEdit(layer) { if (!layer) return; this.activeLayer = layer; @@ -188,15 +198,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/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 b26fc44..0ac70b4 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,6 +166,45 @@ document.addEventListener("DOMContentLoaded", () => { let reviewHistoryIndex = -1; let navigatingHistory = false; 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(); + 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; + 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"; @@ -190,6 +231,7 @@ document.addEventListener("DOMContentLoaded", () => { } updateStatusToggleUI("unprocessed", false); + updateLayerUtilityButtons(); updateHelpTooltipForMode("creation"); function enterReviewMode() { @@ -482,7 +524,7 @@ document.addEventListener("DOMContentLoaded", () => { // == ImagePoolHandler Events == document.addEventListener("active-image-set", async (event) => { - const { + const { imageHash, filename, width, @@ -491,6 +533,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", @@ -746,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); @@ -753,6 +799,108 @@ 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"); + } + updateLayerUtilityButtons(); + }); + } + + 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) { + if (activeLayer.maskData) { + utils.unionBinaryMasks(activeLayer.maskData, o.maskData); + } else { + 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", + }); + } + onImageDataChange("layer-modified", { layerId: activeLayer.layerId }); + 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 (editModeController) { + const layer = activeImageState.layers.find( + (l) => l.layerId === activeLayerId, + ); + if (layer) editModeController.beginEdit(layer); + } + utils.dispatchCustomEvent("layers-selected", { + layerIds: [activeLayerId], + }); + updateLayerUtilityButtons(); + canvasManager.setLayers(activeImageState.layers); + canvasManager.drawPredictionMaskLayer(); + }); + } + if (imageUploadInput) { imageUploadInput.addEventListener("change", handleImageFileUpload); } @@ -1125,7 +1273,7 @@ document.addEventListener("DOMContentLoaded", () => { canvasManager.clearAllCanvasInputs(false); canvasManager.setManualPredictions(null); canvasManager.setAutomaskPredictions(null); - canvasManager.setMode("edit"); + canvasManager.setMode("creation"); } if (commitMasksBtn && !commitMasksBtn.dataset.listenerAttached) { @@ -1140,11 +1288,16 @@ 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 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(); + editModeController.endEdit(); + } canvasManager.clearAllCanvasInputs(false); if (ids.length === 0) { canvasManager.setMode("creation"); @@ -1153,12 +1306,14 @@ 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); - 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"); @@ -1166,6 +1321,7 @@ document.addEventListener("DOMContentLoaded", () => { if (!reviewMode) updateHelpTooltipForMode("creation"); } } + updateLayerUtilityButtons(); }); document.addEventListener("layer-deleted", async (event) => { @@ -1328,40 +1484,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); } @@ -1378,7 +1502,7 @@ document.addEventListener("DOMContentLoaded", () => { } }); - document.addEventListener("active-image-set", (event) => { + document.addEventListener("active-image-set", async (event) => { if ( activeImageState && activeImageState.imageHash === event.detail.imageHash @@ -1386,15 +1510,23 @@ document.addEventListener("DOMContentLoaded", () => { activeImageState.status = event.detail.status || "unprocessed"; } onImageDataChange("image-loaded", { imageHash: event.detail.imageHash }); - if (editModeController) editModeController.endEdit(); + if (editModeController && editModeController.activeLayer) { + await autoSaveCurrentEdits(); + editModeController.endEdit(); + } canvasManager.setMode("creation"); + updateLayerUtilityButtons(); }); - document.addEventListener("active-image-cleared", () => { + document.addEventListener("active-image-cleared", async () => { + if (editModeController && editModeController.activeLayer) { + await autoSaveCurrentEdits(); + editModeController.endEdit(); + } activeImageState = null; - if (editModeController) editModeController.endEdit(); canvasManager.setMode("creation"); updateStatusToggleUI("unprocessed", false); + updateLayerUtilityButtons(); }); document.addEventListener("image-status-updated", (event) => { diff --git a/app/frontend/static/js/utils.js b/app/frontend/static/js/utils.js index 6e1be31..fbd6709 100644 --- a/app/frontend/static/js/utils.js +++ b/app/frontend/static/js/utils.js @@ -243,6 +243,24 @@ const Utils = { } counts.push(count); return { counts, size: [height, width] }; + }, + + /** + * Combine two binary masks by union. Modifies and returns `base`. + * @param {Array>} base - 2D binary mask array to merge into. + * @param {Array>} other - 2D binary mask array to merge from. + * @returns {Array>} base mask after union. + */ + unionBinaryMasks: (base, other) => { + if (!base || !other) return base; + const h = Math.min(base.length, other.length); + const w = Math.min(base[0].length, other[0].length); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + base[y][x] = base[y][x] || other[y][x] ? 1 : 0; + } + } + return base; } }; diff --git a/app/frontend/templates/index.html b/app/frontend/templates/index.html index b6f3fe3..267586e 100644 --- a/app/frontend/templates/index.html +++ b/app/frontend/templates/index.html @@ -297,8 +297,7 @@

Export Annotations