diff --git a/app/backend/db_manager.py b/app/backend/db_manager.py index a22b5b1..b4d02fa 100644 --- a/app/backend/db_manager.py +++ b/app/backend/db_manager.py @@ -13,8 +13,41 @@ import sqlite3 import os import json +import ast from datetime import datetime -from typing import List, Dict, Any, Optional, Tuple +from typing import List, Dict, Any, Optional, Tuple, Set + + +def _parse_label_field(value: Any) -> List[str]: + """Parse stored class_label field into a list of strings.""" + if not value: + return [] + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + if isinstance(value, str): + v = value.strip() + if not v: + return [] + try: + arr = json.loads(v) + if isinstance(arr, list): + return [str(x).strip() for x in arr if str(x).strip()] + except json.JSONDecodeError: + try: + arr = ast.literal_eval(v) + if isinstance(arr, list): + return [str(x).strip() for x in arr if str(x).strip()] + except Exception: + pass + return [s.strip() for s in v.split(",") if s.strip()] + return [] + + +def _serialize_label_field(labels: Optional[List[str]]) -> Optional[str]: + if labels is None: + return None + return json.dumps([str(v).strip() for v in labels if str(v).strip()]) + # Assuming config.py is in the project_root, one level above app/backend/ # Adjust path if your structure is different or use absolute imports if app is a package @@ -656,7 +689,7 @@ def save_mask_layer( status: str, mask_data_rle: Any, name: Optional[str] = None, - class_label: Optional[str] = None, + class_label: Optional[List[str]] = None, display_color: Optional[str] = None, visible: bool = True, source_metadata: Optional[Dict] = None, @@ -694,7 +727,7 @@ def save_mask_layer( else mask_data_rle ), name, - class_label, + _serialize_label_field(class_label), display_color, 1 if visible else 0, json.dumps(source_metadata) if source_metadata else None, @@ -743,10 +776,9 @@ def get_mask_layers_for_image( pass if layer.get("metadata"): layer["metadata"] = json.loads(layer["metadata"]) - if "class_label" in layer["metadata"] and not layer.get("class_label"): - layer["class_label"] = layer["metadata"].get("class_label") if "display_color" in layer["metadata"] and not layer.get("display_color"): layer["display_color"] = layer["metadata"].get("display_color") + layer["class_label"] = _parse_label_field(layer.get("class_label")) layer["visible"] = bool(layer.get("visible", 1)) layers.append(layer) conn.close() @@ -774,7 +806,12 @@ def get_all_class_labels(project_id: str) -> List[str]: ) rows = cursor.fetchall() conn.close() - return [row["class_label"] for row in rows] + + labels: Set[str] = set() + for row in rows: + labels.update(_parse_label_field(row["class_label"])) + + return sorted(labels) def delete_mask_layer(project_id: str, layer_id: str) -> None: @@ -790,7 +827,7 @@ def update_mask_layer_basic( project_id: str, layer_id: str, name: Optional[str] = None, - class_label: Optional[str] = None, + class_label: Optional[List[str]] = None, display_color: Optional[str] = None, visible: Optional[bool] = None, mask_data_rle: Optional[Any] = None, @@ -806,7 +843,7 @@ def update_mask_layer_basic( params.append(name) if class_label is not None: updates.append("class_label = ?") - params.append(class_label) + params.append(_serialize_label_field(class_label)) if display_color is not None: updates.append("display_color = ?") params.append(display_color) diff --git a/app/backend/project_logic.py b/app/backend/project_logic.py index 66fe891..f0d1deb 100644 --- a/app/backend/project_logic.py +++ b/app/backend/project_logic.py @@ -30,6 +30,7 @@ try: from .... import config # For running from within app/backend from . import db_manager + from .db_manager import _parse_label_field from .sam_backend import SAMInference # Assuming SAMInference is accessible from . import mask_utils except ImportError: @@ -40,6 +41,7 @@ ) # Add project_root to path import config import app.backend.db_manager as db_manager + from app.backend.db_manager import _parse_label_field from app.backend.sam_backend import SAMInference import app.backend.mask_utils as mask_utils @@ -799,13 +801,15 @@ def update_mask_layer_basic( image_hash: str, layer_id: str, name: Optional[str] = None, - class_label: Optional[str] = None, + class_label: Optional[List[str]] = None, display_color: Optional[str] = None, visible: Optional[bool] = None, mask_data_rle: Optional[Any] = None, status: Optional[str] = None, ) -> Dict[str, Any]: """Update editable attributes of a layer and return success.""" + if class_label is not None and not isinstance(class_label, list): + class_label = _parse_label_field(class_label) db_manager.update_mask_layer_basic( project_id, layer_id, @@ -852,7 +856,9 @@ def get_image_state(project_id: str, image_hash: str) -> Dict[str, Any]: { "layerId": m["layer_id"], "name": m.get("name"), - "classLabel": m.get("class_label") or meta.get("class_label"), + "classLabel": _parse_label_field( + m.get("class_label") or meta.get("class_label") + ), "status": m.get("status") or m.get("layer_type"), "visible": bool(m.get("visible", True)), "displayColor": m.get("display_color") or meta.get("display_color"), @@ -888,6 +894,8 @@ def update_image_state( continue name = layer.get("name") class_label = layer.get("classLabel") + if class_label is not None and not isinstance(class_label, list): + class_label = _parse_label_field(class_label) display_color = layer.get("displayColor") visible = layer.get("visible") if any(v is not None for v in (name, class_label, display_color, visible)): diff --git a/app/frontend/static/css/style.css b/app/frontend/static/css/style.css index c53253d..92fd7c7 100644 --- a/app/frontend/static/css/style.css +++ b/app/frontend/static/css/style.css @@ -1110,12 +1110,16 @@ input[type="file"]#image-upload { padding: 2px 4px; font-size: 13px; } +.layer-class-input.tagify, .layer-class-input { flex: 2 1 25px; min-width: 30px; padding: 2px 4px; font-size: 11px; } +.layer-tag-dropdown .tagify__dropdown__item { + font-size: 11px; +} .layer-status-tag { font-size: 11px; padding: 2px 4px; diff --git a/app/frontend/static/js/layerViewController.js b/app/frontend/static/js/layerViewController.js index 8f8f57b..585a9f5 100644 --- a/app/frontend/static/js/layerViewController.js +++ b/app/frontend/static/js/layerViewController.js @@ -11,6 +11,28 @@ class LayerViewController { this.layers = []; this.selectedLayerIds = []; this.Utils = window.Utils || { dispatchCustomEvent: (n,d)=>document.dispatchEvent(new CustomEvent(n,{detail:d})) }; + this.labelWhitelist = []; + this._setupLabelListeners(); + } + + _setupLabelListeners() { + document.addEventListener('project-loaded', () => this._fetchLabelWhitelist()); + document.addEventListener('state-changed-activeProjectId', () => this._fetchLabelWhitelist()); + this._fetchLabelWhitelist(); + } + + async _fetchLabelWhitelist() { + const pid = this.stateManager && this.stateManager.getActiveProjectId ? this.stateManager.getActiveProjectId() : null; + if (!pid || !window.apiClient || typeof window.apiClient.getProjectLabels !== 'function') return; + try { + const res = await window.apiClient.getProjectLabels(pid); + this.labelWhitelist = Array.isArray(res.labels) ? res.labels : []; + } catch (err) { + console.error('Failed to fetch project labels', err); + this.labelWhitelist = []; + } + // re-render to apply updated whitelist to Tagify inputs + this.render(); } setLayers(layers) { @@ -142,22 +164,28 @@ class LayerViewController { classInput.className = 'layer-class-input'; classInput.type = 'text'; classInput.placeholder = 'label'; - classInput.value = layer.classLabel || ''; classInput.title = 'Class label'; + + const tagifyOptions = { + whitelist: this.labelWhitelist, + dropdown: { + maxItems: 20, + classname: 'tags-look layer-tag-dropdown', + enabled: 0, + closeOnSelect: false, + fuzzySearch: true, + }, + pattern: /[^,]+/, // disallow comma in tags + originalInputValueFormat: (valuesArr) => JSON.stringify(valuesArr.map(v => v.value)) + }; + + li.appendChild(visBtn); + li.appendChild(colorSwatch); + li.appendChild(colorInput); + li.appendChild(nameInput); + li.appendChild(classInput); classInput.addEventListener('mousedown', (e) => e.stopPropagation()); classInput.addEventListener('click', (e) => e.stopPropagation()); - classInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - classInput.blur(); - classInput.dispatchEvent(new Event('change', { bubbles: true })); - } - }); - classInput.addEventListener('change', (e) => { - e.stopPropagation(); - layer.classLabel = classInput.value.trim(); - this.Utils.dispatchCustomEvent('layer-class-changed', { layerId: layer.layerId, classLabel: layer.classLabel }); - }); const statusTag = document.createElement('span'); statusTag.className = `layer-status-tag ${layer.status || ''}`; @@ -175,18 +203,31 @@ class LayerViewController { }); li.addEventListener('click', (e) => { + if (e.target.closest('.tagify')) return; const additive = e.shiftKey; this.selectLayer(layer.layerId, additive); }); - li.appendChild(visBtn); - li.appendChild(colorSwatch); - li.appendChild(colorInput); - li.appendChild(nameInput); - li.appendChild(classInput); li.appendChild(statusTag); li.appendChild(deleteBtn); listEl.appendChild(li); + + const tagify = new Tagify(classInput, tagifyOptions); + if (Array.isArray(layer.classLabel)) { + tagify.addTags(layer.classLabel); + } + if (tagify.DOM && tagify.DOM.scope) { + ['mousedown', 'click', 'touchstart', 'pointerdown'].forEach((evt) => { + tagify.DOM.scope.addEventListener(evt, (e) => e.stopPropagation()); + }); + } + const updateClassLabel = () => { + layer.classLabel = tagify.value.map((v) => v.value); + this.Utils.dispatchCustomEvent('layer-class-changed', { layerId: layer.layerId, classLabel: layer.classLabel }); + }; + tagify.on('add', updateClassLabel); + tagify.on('remove', updateClassLabel); + tagify.on('blur', updateClassLabel); }); this.containerEl.appendChild(listEl); diff --git a/app/frontend/static/js/main.js b/app/frontend/static/js/main.js index 7fabd9e..1024e6a 100644 --- a/app/frontend/static/js/main.js +++ b/app/frontend/static/js/main.js @@ -42,6 +42,29 @@ document.addEventListener("DOMContentLoaded", () => { const canvasStateCache = {}; let imageLayerCache = {}; + const layerUpdateTimers = {}; + const layerUpdatePayloads = {}; + + function scheduleLayerUpdate(layerId, payload) { + const pid = stateManager.getActiveProjectId(); + const ih = activeImageState && activeImageState.imageHash; + if (!pid || !ih) return; + if (!layerUpdatePayloads[layerId]) layerUpdatePayloads[layerId] = {}; + Object.assign(layerUpdatePayloads[layerId], payload); + clearTimeout(layerUpdateTimers[layerId]); + layerUpdateTimers[layerId] = setTimeout(() => { + const p = layerUpdatePayloads[layerId]; + delete layerUpdatePayloads[layerId]; + delete layerUpdateTimers[layerId]; + apiClient.updateMaskLayer(pid, ih, layerId, p).catch((err) => { + uiManager.showGlobalStatus( + `Layer update failed: ${utils.escapeHTML(err.message)}`, + "error", + ); + }); + }, 400); + } + // modelHandler.js is a script that self-initializes its DOM listeners. // We don't instantiate it as a class here, but we will need its functions if we were to call them. // For now, it primarily dispatches events that main.js listens to. @@ -462,6 +485,9 @@ document.addEventListener("DOMContentLoaded", () => { // == ImagePoolHandler Events == document.addEventListener("active-image-set", async (event) => { + Object.keys(layerUpdateTimers).forEach((k) => clearTimeout(layerUpdateTimers[k])); + for (const k in layerUpdateTimers) delete layerUpdateTimers[k]; + for (const k in layerUpdatePayloads) delete layerUpdatePayloads[k]; const { imageHash, filename, @@ -534,7 +560,7 @@ document.addEventListener("DOMContentLoaded", () => { return { layerId: m.layerId, name: m.name || `Mask ${idx + 1}`, - classLabel: m.classLabel || "", + classLabel: utils.parseLabels(m.classLabel), status: m.status || "prediction", visible: m.visible !== false, displayColor: m.displayColor || utils.getRandomHexColor(), @@ -573,7 +599,7 @@ document.addEventListener("DOMContentLoaded", () => { return { layerId: m.layer_id || `layer_${idx}`, name: m.name || `Mask ${idx + 1}`, - classLabel: m.class_label || "", + classLabel: utils.parseLabels(m.class_label), status: m.status || "prediction", visible: m.visible !== false, displayColor: m.display_color || utils.getRandomHexColor(), @@ -704,7 +730,7 @@ document.addEventListener("DOMContentLoaded", () => { const newLayer = { layerId: crypto.randomUUID(), name: `Mask ${activeImageState.layers.length + 1}`, - classLabel: "", + classLabel: [], status: "edited", visible: true, displayColor: utils.getRandomHexColor(), @@ -1066,7 +1092,7 @@ document.addEventListener("DOMContentLoaded", () => { const newLayers = selected.map((mask, idx) => ({ layerId: ids[idx] || crypto.randomUUID(), name: masksToCommit[idx].name, - classLabel: "", + classLabel: [], status: "edited", visible: true, displayColor: masksToCommit[idx].display_color, @@ -1168,18 +1194,7 @@ document.addEventListener("DOMContentLoaded", () => { ); if (layer) { layer.name = event.detail.name || ""; - const pid = stateManager.getActiveProjectId(); - const ih = activeImageState.imageHash; - if (pid && ih) { - apiClient - .updateMaskLayer(pid, ih, layer.layerId, { name: layer.name }) - .catch((err) => { - uiManager.showGlobalStatus( - `Layer update failed: ${utils.escapeHTML(err.message)}`, - "error", - ); - }); - } + scheduleLayerUpdate(layer.layerId, { name: layer.name }); onImageDataChange( "layer-modified", { layerId: layer.layerId }, @@ -1194,21 +1209,10 @@ document.addEventListener("DOMContentLoaded", () => { (l) => l.layerId === event.detail.layerId, ); if (layer) { - layer.classLabel = event.detail.classLabel || ""; - const pid = stateManager.getActiveProjectId(); - const ih = activeImageState.imageHash; - if (pid && ih) { - apiClient - .updateMaskLayer(pid, ih, layer.layerId, { - class_label: layer.classLabel, - }) - .catch((err) => { - uiManager.showGlobalStatus( - `Layer update failed: ${utils.escapeHTML(err.message)}`, - "error", - ); - }); - } + layer.classLabel = Array.isArray(event.detail.classLabel) + ? event.detail.classLabel + : utils.parseLabels(event.detail.classLabel); + scheduleLayerUpdate(layer.layerId, { class_label: layer.classLabel }); onImageDataChange("layer-modified", { layerId: layer.layerId }); } }); @@ -1221,13 +1225,7 @@ document.addEventListener("DOMContentLoaded", () => { if (layer) { layer.visible = event.detail.visible; canvasManager.setLayers(activeImageState.layers); - const pid = stateManager.getActiveProjectId(); - const ih = activeImageState.imageHash; - if (pid && ih) { - apiClient - .updateMaskLayer(pid, ih, layer.layerId, { visible: layer.visible }) - .catch(() => {}); - } + scheduleLayerUpdate(layer.layerId, { visible: layer.visible }); } }); @@ -1238,20 +1236,9 @@ document.addEventListener("DOMContentLoaded", () => { ); if (layer) { layer.displayColor = event.detail.displayColor || "#888888"; - const pid = stateManager.getActiveProjectId(); - const ih = activeImageState.imageHash; - if (pid && ih) { - apiClient - .updateMaskLayer(pid, ih, layer.layerId, { - display_color: layer.displayColor, - }) - .catch((err) => { - uiManager.showGlobalStatus( - `Layer update failed: ${utils.escapeHTML(err.message)}`, - "error", - ); - }); - } + scheduleLayerUpdate(layer.layerId, { + display_color: layer.displayColor, + }); onImageDataChange( "layer-modified", { layerId: layer.layerId }, @@ -1269,22 +1256,11 @@ document.addEventListener("DOMContentLoaded", () => { 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", - ); - }); - } + const rle = utils.binaryMaskToRLE(layer.maskData); + scheduleLayerUpdate(layer.layerId, { + mask_data_rle: rle, + status: "edited", + }); onImageDataChange("layer-modified", { layerId: layer.layerId }); } if (layerViewController) layerViewController.setSelectedLayers([]); @@ -1324,6 +1300,9 @@ document.addEventListener("DOMContentLoaded", () => { }); document.addEventListener("active-image-cleared", () => { + Object.keys(layerUpdateTimers).forEach((k) => clearTimeout(layerUpdateTimers[k])); + for (const k in layerUpdateTimers) delete layerUpdateTimers[k]; + for (const k in layerUpdatePayloads) delete layerUpdatePayloads[k]; activeImageState = null; if (editModeController) editModeController.endEdit(); canvasManager.setMode("creation"); diff --git a/app/frontend/static/js/utils.js b/app/frontend/static/js/utils.js index 6e1be31..4691b3d 100644 --- a/app/frontend/static/js/utils.js +++ b/app/frontend/static/js/utils.js @@ -141,6 +141,42 @@ const Utils = { .replace(/'/g, "'"); // Or ' }, + /** + * Parses various label formats into an array of strings. + * Accepts JSON arrays, comma separated strings, or arrays. + * @param {string|string[]|null} val - Raw label data. + * @returns {string[]} Array of cleaned label strings. + */ + parseLabels: (val) => { + if (!val) return []; + if (Array.isArray(val)) { + return val.map((v) => String(v).trim()).filter(Boolean); + } + if (typeof val === 'string') { + const trimmed = val.trim(); + if (!trimmed) return []; + if (trimmed.startsWith('[')) { + try { + const arr = JSON.parse(trimmed); + if (Array.isArray(arr)) { + return arr.map((v) => String(v).trim()).filter(Boolean); + } + } catch (e) { + try { + const arr = JSON.parse(trimmed.replace(/'/g, '"')); + if (Array.isArray(arr)) { + return arr.map((v) => String(v).trim()).filter(Boolean); + } + } catch (_) { + // ignore + } + } + } + return trimmed.split(',').map((s) => s.trim()).filter(Boolean); + } + return []; + }, + /** * Dispatches a custom event. * @param {string} eventName - The name of the event. @@ -249,5 +285,9 @@ const Utils = { // Make Utils globally available if not using modules. if (typeof window !== 'undefined') { window.Utils = Utils; + // maintain legacy reference if some modules expect `window.utils` + if (!window.utils) { + window.utils = Utils; + } } // export default Utils; // For ES module system \ No newline at end of file diff --git a/docs/annotation_workflow_specification.md b/docs/annotation_workflow_specification.md index 1d0b41d..d50585c 100644 --- a/docs/annotation_workflow_specification.md +++ b/docs/annotation_workflow_specification.md @@ -176,7 +176,7 @@ const ActiveImageState = { { layerId: "uuid-1a2b-3c4d", // PK from DB name: "Top Connector", // User-editable name - classLabel: "connector", // User-defined class/category + classLabel: ["connector"], // One or more user-defined labels status: "prediction", // 'prediction', 'edited', 'approved', 'rejected' visible: true, // UI toggle state displayColor: "hsla(120, 80%, 50%, 0.7)", // Color swatch in UI and for mask display in canvas when this layer is selected