Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions app/backend/db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions app/backend/project_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)):
Expand Down
4 changes: 4 additions & 0 deletions app/frontend/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
77 changes: 59 additions & 18 deletions app/frontend/static/js/layerViewController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 || ''}`;
Expand All @@ -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);
Expand Down
Loading