diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9798785 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Build artifacts & deps (rebuilt inside the image) +**/node_modules +**/dist +**/.vite +frontend/dist +backend/static + +# Python envs / caches +.venv +.conda-py312 +**/__pycache__ +**/*.pyc +.pytest_cache +htmlcov +.coverage + +# Tiled server config, data & catalog (external in production — never bundled) +.tiled +tiled/ + +# Local data, backups, runtime files +data +.drafts +.reset-backups +*.pid +.run + +# VCS / editor / OS +.git +.gitignore +.idea +.vscode +.DS_Store + +# Plans / docs not needed in the image +*.md + +# peter's version of the repo +peter/ + +.reset-backups/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 54e9b46..d7e46d2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,11 +14,15 @@ build/ .env # Tiled catalog database (binary, regenerated on first run via start_all.sh) -# The zarr data under .tiled/data/ IS committed so clones get demo data. .tiled/catalog.db .tiled/catalog.db-shm .tiled/catalog.db-wal +# Tiled data written at runtime (drag-and-drop ingest, etc.). +# Demo datasets already committed under .tiled/data/ stay tracked; new arrays +# ingested locally are ignored. To commit a new demo dataset, use `git add -f`. +.tiled/data/ + # Runtime Tiled thumbnail containers created on save **/*__v_thumbs/ @@ -27,6 +31,9 @@ node_modules/ dist/ .vite/ +# Production SPA build served by FastAPI (generated by `PROD=1 ./start_all.sh`) +backend/static/ + # OS .DS_Store Thumbs.db @@ -45,3 +52,14 @@ Thumbs.db htmlcov/ .coverage coverage.xml + +# Vendored SAM model (large, fetched via frontend/scripts/fetch-sam-model.mjs) +frontend/public/models/ + +# MkDocs build output (regenerate with `mkdocs build`) +site/ + +# peter's version of the repo +peter/ + +.reset-backups/ diff --git a/.tiled/README.md b/.tiled/README.md index 720cdf9..a315096 100644 --- a/.tiled/README.md +++ b/.tiled/README.md @@ -6,9 +6,21 @@ SQLite catalog and sample array data (~1 MB). - **`catalog.db`** — Tiled catalog metadata (paths, structure, metadata keys). - **`data/`** — Writable storage (Zarr chunks) for `browse/generated_data/*` sample datasets. -After clone, run `./start_all.sh` from the repo root. The script still creates -`backend/.env` from `.env.example` and may generate a `TILED_API_KEY` if missing; -that key is only for authenticating to the local server, not stored inside these files. +After clone, run `./start_all.sh` from the repo root. The script creates +`backend/.env` from `.env.example` and **generates a strong `TILED_API_KEY`** into it +when the key is missing (or still the old committed value, which it auto-rotates). +`tiled/config.yml` holds no literal key — the script passes the generated key to +Tiled at launch via `--api-key` (robust across Tiled versions). The key lives only +in the gitignored `backend/.env`, never inside these catalog files and never sent +to the frontend. To replace this with an empty catalog, delete `.tiled/` and run `tiled catalog init` (see `start_all.sh`). To add more datasets, use `backend/scripts/seed_generated_data_to_tiled.py`. + +## Security note +Earlier commits of `tiled/config.yml` contained a hardcoded `single_user_api_key` +(`3b1d23cd…`). It has been removed from the working tree and is auto-rotated on the +next `./start_all.sh`, but it **remains in git history**. It only guards a +local-only (127.0.0.1) server with anonymous read enabled, so exposure is low. To +purge it from history, use `git filter-repo` (or BFG) to replace the blob, then +force-push — destructive; coordinate with anyone who has cloned the repo. diff --git a/.tiled/data/browse/generated_data/gen_010005/c/0/0/0 b/.tiled/data/browse/generated_data/gen_010005/c/0/0/0 deleted file mode 100644 index 5394893..0000000 Binary files a/.tiled/data/browse/generated_data/gen_010005/c/0/0/0 and /dev/null differ diff --git a/.tiled/data/browse/generated_data/gen_010005/zarr.json b/.tiled/data/browse/generated_data/gen_010005/zarr.json deleted file mode 100644 index 4e1f63f..0000000 --- a/.tiled/data/browse/generated_data/gen_010005/zarr.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "shape": [ - 512, - 512, - 3 - ], - "data_type": "uint8", - "chunk_grid": { - "name": "regular", - "configuration": { - "chunk_shape": [ - 512, - 512, - 3 - ] - } - }, - "chunk_key_encoding": { - "name": "default", - "configuration": { - "separator": "/" - } - }, - "fill_value": 0, - "codecs": [ - { - "name": "bytes" - }, - { - "name": "zstd", - "configuration": { - "level": 0, - "checksum": false - } - } - ], - "attributes": {}, - "zarr_format": 3, - "node_type": "array", - "storage_transformers": [] -} \ No newline at end of file diff --git a/.tiled/data/browse/generated_data/gen_010006/c/0/0/0 b/.tiled/data/browse/generated_data/gen_010006/c/0/0/0 deleted file mode 100644 index c4b0fb4..0000000 Binary files a/.tiled/data/browse/generated_data/gen_010006/c/0/0/0 and /dev/null differ diff --git a/.tiled/data/browse/generated_data/gen_010006/zarr.json b/.tiled/data/browse/generated_data/gen_010006/zarr.json deleted file mode 100644 index 4e1f63f..0000000 --- a/.tiled/data/browse/generated_data/gen_010006/zarr.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "shape": [ - 512, - 512, - 3 - ], - "data_type": "uint8", - "chunk_grid": { - "name": "regular", - "configuration": { - "chunk_shape": [ - 512, - 512, - 3 - ] - } - }, - "chunk_key_encoding": { - "name": "default", - "configuration": { - "separator": "/" - } - }, - "fill_value": 0, - "codecs": [ - { - "name": "bytes" - }, - { - "name": "zstd", - "configuration": { - "level": 0, - "checksum": false - } - } - ], - "attributes": {}, - "zarr_format": 3, - "node_type": "array", - "storage_transformers": [] -} \ No newline at end of file diff --git a/.tiled/data/browse/generated_data/gen_010007/c/0/0/0 b/.tiled/data/browse/generated_data/gen_010007/c/0/0/0 deleted file mode 100644 index 676851d..0000000 Binary files a/.tiled/data/browse/generated_data/gen_010007/c/0/0/0 and /dev/null differ diff --git a/.tiled/data/browse/generated_data/gen_010007/zarr.json b/.tiled/data/browse/generated_data/gen_010007/zarr.json deleted file mode 100644 index 4e1f63f..0000000 --- a/.tiled/data/browse/generated_data/gen_010007/zarr.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "shape": [ - 512, - 512, - 3 - ], - "data_type": "uint8", - "chunk_grid": { - "name": "regular", - "configuration": { - "chunk_shape": [ - 512, - 512, - 3 - ] - } - }, - "chunk_key_encoding": { - "name": "default", - "configuration": { - "separator": "/" - } - }, - "fill_value": 0, - "codecs": [ - { - "name": "bytes" - }, - { - "name": "zstd", - "configuration": { - "level": 0, - "checksum": false - } - } - ], - "attributes": {}, - "zarr_format": 3, - "node_type": "array", - "storage_transformers": [] -} \ No newline at end of file diff --git a/.tiled/data/browse/generated_data/gen_010011/c/0/0/0 b/.tiled/data/browse/generated_data/gen_010011/c/0/0/0 deleted file mode 100644 index 954a60d..0000000 Binary files a/.tiled/data/browse/generated_data/gen_010011/c/0/0/0 and /dev/null differ diff --git a/.tiled/data/browse/generated_data/gen_010011/zarr.json b/.tiled/data/browse/generated_data/gen_010011/zarr.json deleted file mode 100644 index 4e1f63f..0000000 --- a/.tiled/data/browse/generated_data/gen_010011/zarr.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "shape": [ - 512, - 512, - 3 - ], - "data_type": "uint8", - "chunk_grid": { - "name": "regular", - "configuration": { - "chunk_shape": [ - 512, - 512, - 3 - ] - } - }, - "chunk_key_encoding": { - "name": "default", - "configuration": { - "separator": "/" - } - }, - "fill_value": 0, - "codecs": [ - { - "name": "bytes" - }, - { - "name": "zstd", - "configuration": { - "level": 0, - "checksum": false - } - } - ], - "attributes": {}, - "zarr_format": 3, - "node_type": "array", - "storage_transformers": [] -} \ No newline at end of file diff --git a/.tiled/data/browse/generated_data/gen_010021/c/0/0/0 b/.tiled/data/browse/generated_data/gen_010021/c/0/0/0 deleted file mode 100644 index 4283229..0000000 Binary files a/.tiled/data/browse/generated_data/gen_010021/c/0/0/0 and /dev/null differ diff --git a/.tiled/data/browse/generated_data/gen_010021/zarr.json b/.tiled/data/browse/generated_data/gen_010021/zarr.json deleted file mode 100644 index 4e1f63f..0000000 --- a/.tiled/data/browse/generated_data/gen_010021/zarr.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "shape": [ - 512, - 512, - 3 - ], - "data_type": "uint8", - "chunk_grid": { - "name": "regular", - "configuration": { - "chunk_shape": [ - 512, - 512, - 3 - ] - } - }, - "chunk_key_encoding": { - "name": "default", - "configuration": { - "separator": "/" - } - }, - "fill_value": 0, - "codecs": [ - { - "name": "bytes" - }, - { - "name": "zstd", - "configuration": { - "level": 0, - "checksum": false - } - } - ], - "attributes": {}, - "zarr_format": 3, - "node_type": "array", - "storage_transformers": [] -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 225cceb..f51e5b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# AGENTS.md — SAM3 Annotation Studio +# AGENTS.md — Segmentation Annotation Studio ## Stack - Backend: FastAPI, Python ≥ 3.11, pyproject.toml, pytest diff --git a/COPYRIGHT.txt b/COPYRIGHT.txt new file mode 100644 index 0000000..e0d596e --- /dev/null +++ b/COPYRIGHT.txt @@ -0,0 +1,14 @@ +MLExchange Copyright (c) 2023, The Regents of the University of California, +through Lawrence Berkeley National Laboratory (subject to receipt of +any required approvals from the U.S. Dept. of Energy). All rights reserved. + +If you have questions about your rights to use or distribute this software, +please contact Berkeley Lab's Intellectual Property Office at +IPO@lbl.gov. + +NOTICE. This Software was developed under funding from the U.S. Department +of Energy and the U.S. Government consequently retains certain rights. As +such, the U.S. Government has been granted for itself and others acting on +its behalf a paid-up, nonexclusive, irrevocable, worldwide license in the +Software to reproduce, distribute copies to the public, prepare derivative +works, and perform publicly and display publicly, and to permit others to do so. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..12ff00c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 +# Lightweight production image: builds the SPA and serves it from the FastAPI +# backend (single container, one port). Tiled is NOT included — point the app at +# an external Tiled via TILED_URI/TILED_API_KEY. Local dev still uses start_all.sh. + +# --- Stage 1: build the frontend (same-origin: VITE_API_BASE left empty) --- +FROM node:22-alpine AS web +WORKDIR /web +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build # → /web/dist + +# --- Stage 2: backend + built SPA --- +FROM python:3.12-slim AS app +WORKDIR /app + +# Install Python deps first (cached until pyproject changes). py-modules=[] means +# this installs dependencies only; the app code runs from the copied source below. +# pyproject pins tiled[client] (not [all]/[server]), so the Tiled server is NOT +# installed into this image — the app only connects to an external Tiled. +# If a wheel is unavailable for pycocotools/imagecodecs on this platform, add: +# RUN apt-get update && apt-get install -y --no-install-recommends build-essential +COPY backend/pyproject.toml ./pyproject.toml +RUN pip install --no-cache-dir . + +# App source + the built SPA (served from ./static by annotation_server.py). +COPY backend/ ./ +COPY --from=web /web/dist ./static + +# Drafts/versions/exports persist here — mount a volume in production. +ENV LOCAL_DATA_ROOT=/data +VOLUME ["/data"] + +EXPOSE 8002 +CMD ["uvicorn", "annotation_server:app", "--host", "0.0.0.0", "--port", "8002"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..1f8d05a --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,42 @@ +MLExchange Copyright (c) 2023, The Regents of the University of California, +through Lawrence Berkeley National Laboratory (subject to receipt of +any required approvals from the U.S. Dept. of Energy). All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +(1) Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +(2) Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +(3) Neither the name of the University of California, Lawrence Berkeley +National Laboratory, U.S. Dept. of Energy nor the names of its contributors +may be used to endorse or promote products derived from this software +without specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +You are under no obligation whatsoever to provide any bug fixes, patches, +or upgrades to the features, functionality or performance of the source +code ("Enhancements") to anyone; however, if you choose to make your +Enhancements available either publicly, or directly to Lawrence Berkeley +National Laboratory, without imposing a separate written license agreement +for such Enhancements, then you hereby grant the following license: a +non-exclusive, royalty-free perpetual license to install, use, modify, +prepare derivative works, incorporate into other computer software, +distribute, and sublicense such enhancements or derivative works thereof, +in binary and source code form. \ No newline at end of file diff --git a/README.md b/README.md index 5fd76b9..6d19044 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# SAM3 Annotation Studio +# Segmentation Annotation Studio Manual image segmentation tool for producing COCO datasets for SAM3 fine-tuning. ## Quick start ```bash +chmod +x start_all.sh ./start_all.sh ``` diff --git a/backend/.env.example b/backend/.env.example index cace58b..c26e815 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,16 @@ TILED_URI=http://127.0.0.1:8010 +# Leave blank for local dev: start_all.sh generates a strong TILED_API_KEY here on +# first run and passes it to Tiled via --api-key (anonymous +# access is read-only, the key authenticates writes/ingest). A blank value is safe — +# it is never sent as an (invalid) empty auth header. TILED_API_KEY= BROWSE_CACHE_TTL_SECONDS=300 LOCAL_DATA_ROOT=~/data EXPORT_ROOT= + +# Production / Docker notes: +# - The container serves the SPA same-origin, so BROWSE_ALLOWED_ORIGINS can stay +# empty. Set it (comma-separated) only for split frontend/backend origins. +# - Point TILED_URI/TILED_API_KEY at your external Tiled; mount LOCAL_DATA_ROOT +# as a volume so annotation drafts/versions/exports survive restarts. +BROWSE_ALLOWED_ORIGINS= diff --git a/backend/annotation_server.py b/backend/annotation_server.py index 8145fa9..44970c1 100644 --- a/backend/annotation_server.py +++ b/backend/annotation_server.py @@ -1,4 +1,4 @@ -"""FastAPI entry point for the SAM3 Annotation Studio API. +"""FastAPI entry point for the Segmentation Annotation Studio API. Endpoints --------- @@ -20,30 +20,69 @@ import json import logging import os +import shutil +import tempfile +import threading from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone from pathlib import Path from typing import Optional -from fastapi import FastAPI, HTTPException, Query, Response +import numpy as np +from fastapi import FastAPI, File, Form, HTTPException, Query, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse from pydantic import BaseModel +from starlette.middleware.gzip import GZipMiddleware +import annotation_thumbnails import arrays as arrays_mod import drafts as drafts_mod +import export_jobs +import guides as guides_mod import images as images_mod +import ingest as ingest_mod import local_fs -from browse_helpers import FieldMapping, build_field_mapping, tiled_distinct_values, tiled_search_items, _STUDIO_RAW_KEYS +from browse_helpers import ( + _SINGLE_VALUE_FACET_RAW_KEYS, + FieldMapping, + build_field_mapping, + distinct_from_rows, + scoped_metadata_rows, + tiled_distinct_values, + tiled_search_items, +) from cache import TTLCache -from schemas import DraftPayload, ExportRequest, ExportSourceItem, ImageMeta, RenderOpts, SaveVersionRequest +from coco_export import ( + build_export_plan, + lightly_classes_map, + shape_to_mask, + write_coco_split, + write_lightly_split, +) +from schemas import ( + DraftPayload, + ExportRequest, + ExportSourceItem, + GuidePayload, + ImageMeta, + MeasureRequest, + SaveVersionRequest, +) +from source_keys import parse_source_key from thumbnails import render_thumbnail -from tiled_clients import api_key_for_uri, get_browse_container, get_tiled_client +from tiled_clients import ( + api_key_for_uri, + get_browse_container_for, + get_tiled_client, +) from tiled_config import get_tiled_api_key, get_tiled_servers logging.basicConfig(level=logging.INFO) logger = logging.getLogger("annotation-server") app = FastAPI( - title="SAM3 Annotation Studio API", + title="Segmentation Annotation Studio API", description="Annotation API for SAM3 fine-tuning dataset generation", version="0.1.0", ) @@ -67,6 +106,11 @@ allow_headers=["*"], ) +# Compress text responses (SPA JS/CSS, JSON). Matters for the production path where +# FastAPI serves the built SPA + API from one origin; PNGs are already compressed so +# the ~500-byte floor skips tiny/binary payloads. (Dev uses the Vite server instead.) +app.add_middleware(GZipMiddleware, minimum_size=500) + # --------------------------------------------------------------------------- # Response models @@ -92,9 +136,11 @@ class ServerConfig(BaseModel): _field_mapping_cache: TTLCache = TTLCache(ttl_seconds=_FIELD_MAPPING_TTL, max_entries=32) -def _resolve_field_mapping(container: object, server_uri: str, technique: str) -> FieldMapping: +def _resolve_field_mapping( + container: object, server_uri: str, technique: str, container_path: str = "" +) -> FieldMapping: """Return a cached :class:`FieldMapping` for the given container.""" - key = (server_uri, technique) + key = (server_uri, technique, container_path or "") cached = _field_mapping_cache.get(key) if cached is not None: return cached @@ -127,6 +173,7 @@ async def browse_facets( server_uri: Optional[str] = None, server_api_key: Optional[str] = None, technique: str = Query("GIWAXS"), + container_path: Optional[str] = Query(None, description="Tiled container to browse"), refresh: bool = Query(False), # noqa: ARG001 — kept for client API compat ) -> dict[str, list[str]]: """Return ordered list of browsable metadata fields, discovered live. @@ -137,17 +184,27 @@ async def browse_facets( """ def _discover() -> dict[str, list[str]]: client = get_tiled_client(server_uri, server_api_key) - container, _ = get_browse_container(client) - mapping = _resolve_field_mapping(container, server_uri or "", technique) + container, _ = get_browse_container_for(client, container_path) + mapping = _resolve_field_mapping(container, server_uri or "", technique, container_path or "") + + # `container.distinct()` is catalog-global; when browsing a specific + # container, read its children once and compute values scoped to it. + # A sample is enough to detect which fields have >=2 distinct values; + # exact value lists are computed per-field (scoped) by /api/browse/column. + scoped = bool(container_path) + scoped_rows = scoped_metadata_rows(container, limit=300) if scoped else [] def _facet_for_key(disp_key: str) -> tuple[list[str], list[str]]: raw_key = mapping.display_to_raw.get(disp_key, disp_key) - min_values = 1 if raw_key in _STUDIO_RAW_KEYS else 2 - try: - result = container.distinct(raw_key, counts=True) - except Exception: - return [], [] - raw_values = result.get("metadata", {}).get(raw_key, []) + min_values = 1 if raw_key in _SINGLE_VALUE_FACET_RAW_KEYS else 2 + if scoped: + raw_values = distinct_from_rows(scoped_rows, raw_key) + else: + try: + result = container.distinct(raw_key, counts=True) + except Exception: + return [], [] + raw_values = result.get("metadata", {}).get(raw_key, []) non_null = [ v for v in raw_values if v.get("value") is not None @@ -193,13 +250,14 @@ async def browse_column( technique: str = Query("GIWAXS"), field: str = Query(..., description="Display-key metadata field to group by"), filters: str = Query("{}", description="JSON dict of upstream display_key=value selections"), + container_path: Optional[str] = Query(None, description="Tiled container to browse"), limit: int = Query(500, ge=1, le=5000), refresh: bool = Query(False), ) -> dict: """Return distinct values (+ counts) for *field* via Tiled ``distinct()``.""" filter_dict = _parse_json_filters(filters) - cache_key = ("column", server_uri or "", technique, field, filters, limit) + cache_key = ("column", server_uri or "", technique, container_path or "", field, filters, limit) if not refresh: cached = _column_cache.get(cache_key) if cached is not None: @@ -207,8 +265,8 @@ async def browse_column( def _build() -> dict: client = get_tiled_client(server_uri, server_api_key) - container, _ = get_browse_container(client) - mapping = _resolve_field_mapping(container, server_uri or "", technique) + container, _ = get_browse_container_for(client, container_path) + mapping = _resolve_field_mapping(container, server_uri or "", technique, container_path or "") raw_key = mapping.display_to_raw.get(field, field) return tiled_distinct_values( container, @@ -216,6 +274,7 @@ def _build() -> dict: filters=filter_dict, field_mapping=mapping, limit=limit, + scoped=bool(container_path), ) try: @@ -233,13 +292,14 @@ async def browse_items( server_api_key: Optional[str] = None, technique: str = Query("GIWAXS"), filters: str = Query("{}", description="JSON dict of display_key=value selections"), + container_path: Optional[str] = Query(None, description="Tiled container to browse"), limit: int = Query(500, ge=1, le=2000), refresh: bool = Query(False), ) -> dict: """Return sample records (path + metadata) matching *filters* via ``search()``.""" filter_dict = _parse_json_filters(filters) - cache_key = ("items", server_uri or "", technique, filters, limit) + cache_key = ("items", server_uri or "", technique, container_path or "", filters, limit) if not refresh: cached = _items_cache.get(cache_key) if cached is not None: @@ -247,8 +307,8 @@ async def browse_items( def _build() -> dict: client = get_tiled_client(server_uri, server_api_key) - container, prefix = get_browse_container(client) - mapping = _resolve_field_mapping(container, server_uri or "", technique) + container, prefix = get_browse_container_for(client, container_path) + mapping = _resolve_field_mapping(container, server_uri or "", technique, container_path or "") return tiled_search_items( container, filters=filter_dict, @@ -266,6 +326,40 @@ def _build() -> dict: return result +@app.get("/api/browse/slices") +async def browse_slices( + path: str = Query(..., description="Tiled container path of a multi-slice dataset"), + server_uri: Optional[str] = None, + server_api_key: Optional[str] = None, + limit: int = Query(2000, ge=1, le=10000), +) -> dict: + """List a dataset container's array children as individually-openable slices. + + Used by Browse drill-in: each returned record is ``{path, sample, metadata}`` + where ``path`` points at a single array node that opens as a 2-D image. + """ + cache_key = ("slices", server_uri or "", path, limit) + cached = _items_cache.get(cache_key) + if cached is not None: + return cached + + def _build() -> dict: + client = get_tiled_client(server_uri, server_api_key) + container, prefix = get_browse_container_for(client, path) + result = tiled_search_items(container, limit=limit, container_path_prefix=prefix) + # Order slices by key (ingest zero-pads, so lexical == slice order). + result["items"].sort(key=lambda it: it["sample"]) + return result + + try: + result = await asyncio.to_thread(_build) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Failed to list slices: {exc}") from exc + + _items_cache.set(cache_key, result) + return result + + @app.get("/api/browse/thumbnail") async def browse_thumbnail( tiled_path: str = Query(..., description="Slash-separated Tiled path"), @@ -300,25 +394,28 @@ def _build() -> bytes | None: @app.get("/api/local/list") async def local_list( - rel: str = Query("", description="Relative path under LOCAL_DATA_ROOT"), + rel: str = Query("", description="Relative path under the granted root"), + root: Optional[str] = Query(None, description="Granted absolute browse root"), ) -> list[dict]: - """List directory entries under LOCAL_DATA_ROOT.""" - return await asyncio.to_thread(local_fs.list_dir, rel) + """List directory entries under the granted local root.""" + return await asyncio.to_thread(local_fs.list_dir, rel, root) @app.get("/api/local/samples") async def local_samples( - rel: str = Query(..., description="Relative path to a folder under LOCAL_DATA_ROOT"), + rel: str = Query(..., description="Relative path to a folder under the granted root"), + root: Optional[str] = Query(None, description="Granted absolute browse root"), ) -> dict: """Return all image files under a local folder (used by the Browse tab). Args: - rel: Relative folder path under ``LOCAL_DATA_ROOT``. + rel: Relative folder path under the granted root. + root: Granted absolute browse root (defaults to ``LOCAL_DATA_ROOT``). Returns: ``{"items": [{"name", "path"}], "total": int}`` """ - items = await asyncio.to_thread(local_fs.list_image_files, rel) + items = await asyncio.to_thread(local_fs.list_image_files, rel, root) return {"items": items, "total": len(items)} @@ -327,6 +424,8 @@ async def connect_summary( kind: str = Query(..., description="'tiled' or 'local'"), server_uri: Optional[str] = None, rel: str = Query("", description="Local folder path (kind=local only)"), + root: Optional[str] = Query(None, description="Granted absolute browse root (kind=local)"), + container_path: Optional[str] = Query(None, description="Tiled container to browse (kind=tiled)"), ) -> dict: """Return a connection summary: sample count and display label. @@ -337,16 +436,28 @@ async def connect_summary( ``{"kind", "label", "sample_count", "server_uri"}`` """ if kind == "local": - count = await asyncio.to_thread(local_fs.count_image_files, rel) - label = rel or "Local Data Root" - return {"kind": "local", "label": label, "sample_count": count, "server_uri": None} + count = await asyncio.to_thread(local_fs.count_image_files, rel, root) + label = (root or "Local Data Root") + (f"/{rel}" if rel else "") + return { + "kind": "local", + "label": label, + "sample_count": count, + "server_uri": None, + "local_root": root, + } if kind == "tiled": def _count() -> int: client = get_tiled_client(server_uri) - container, _ = get_browse_container(client) - result = tiled_search_items(container, filters={}, limit=10_000) - return int(result.get("total", 0)) + container, _ = get_browse_container_for(client, container_path) + # An unfiltered count is just the container size — a single request. + # Avoid iterating every child and building per-item metadata dicts, + # which is O(N) HTTP round trips and stalls the connect UI. + try: + return int(len(container)) + except Exception: + result = tiled_search_items(container, filters={}, limit=10_000) + return int(result.get("total", 0)) try: count = await asyncio.to_thread(_count) @@ -360,7 +471,15 @@ def _count() -> int: if (cfg.get("uri") or "").rstrip("/") == (server_uri or "").rstrip("/")), server_uri or "Tiled Server", ) - return {"kind": "tiled", "label": label, "sample_count": count, "server_uri": server_uri} + if container_path: + label = f"{label} · {container_path}" + return { + "kind": "tiled", + "label": label, + "sample_count": count, + "server_uri": server_uri, + "container_path": container_path, + } raise HTTPException(400, f"Unknown kind: {kind!r}; must be 'tiled' or 'local'") @@ -430,10 +549,11 @@ async def image_meta( source: str = Query(...), kind: str = Query(...), server_uri: Optional[str] = None, + root: Optional[str] = Query(None, description="Granted absolute root (kind=local)"), ) -> ImageMeta: """Return shape / dtype metadata for an image source.""" def _run() -> ImageMeta: - node = arrays_mod.resolve_array(source, kind, server_uri) + node = arrays_mod.resolve_array(source, kind, server_uri, root) meta = arrays_mod.array_shape_meta(node) sl = arrays_mod.read_slice(node, meta, 0) flat = sl.ravel().astype(float) @@ -444,6 +564,7 @@ def _run() -> ImageMeta: dtype=meta["dtype"], is_rgb=meta["is_rgb"], value_range=[float(flat.min()), float(flat.max())], + keywords=arrays_mod.node_keywords(node), ) try: @@ -461,6 +582,7 @@ async def image_slice( kind: str = Query(...), slice_index: int = Query(0), server_uri: Optional[str] = None, + root: Optional[str] = Query(None, description="Granted absolute root (kind=local)"), norm: str = Query("global"), scale: str = Query("linear"), vmin_pct: float = Query(1.0), @@ -477,7 +599,7 @@ async def image_slice( } def _run() -> bytes: - node = arrays_mod.resolve_array(source, kind, server_uri) + node = arrays_mod.resolve_array(source, kind, server_uri, root) meta = arrays_mod.array_shape_meta(node) sl = arrays_mod.read_slice(node, meta, slice_index) global_range = None @@ -529,6 +651,84 @@ async def list_drafts_route() -> list[dict]: return await asyncio.to_thread(drafts_mod.list_drafts) +@app.get("/api/guide") +async def get_guide(source_key: str = Query(...)) -> dict: + """Return the annotation guide for source_key, or 404 if none exists.""" + result = await asyncio.to_thread(guides_mod.load_guide, source_key) + if result is None: + raise HTTPException(404, "No guide found") + return result + + +@app.put("/api/guide") +async def put_guide( + source_key: str = Query(...), + guide: GuidePayload = ..., +) -> dict: + """Persist the annotation guide for source_key (dataset-scoped).""" + return await asyncio.to_thread(guides_mod.save_guide, source_key, guide.model_dump()) + + +@app.post("/api/measure") +async def measure_region( + source_key: str = Query(...), + body: MeasureRequest = ..., +) -> dict: + """Return raw-intensity statistics inside the union of the given shapes on a + slice: min/max/mean/std and pixel count. Uses the raw array values (not the + display-rendered image), so results are meaningful for scientific data. + """ + def _run() -> dict: + parsed = parse_source_key(source_key) + kind = parsed["kind"] or "local" + node = arrays_mod.resolve_array(parsed["path"] or "", kind, parsed["server_uri"]) + meta = arrays_mod.array_shape_meta(node) + sidx = max(0, min(int(body.slice_index), meta["n_slices"] - 1)) + arr = np.asarray(arrays_mod.read_slice(node, meta, sidx)) + # Collapse RGB to luminance so intensity stats are single-channel. + if arr.ndim == 3 and arr.shape[2] in (3, 4): + arr = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]) + h, w = arr.shape[:2] + + union = np.zeros((h, w), dtype=bool) + for shape in body.shapes: + try: + union |= shape_to_mask(shape, h, w) + except Exception: + continue + + vals = arr[union] + if vals.size == 0: + return {"pixel_count": 0, "min": None, "max": None, "mean": None, "std": None} + return { + "pixel_count": int(vals.size), + "min": float(np.min(vals)), + "max": float(np.max(vals)), + "mean": float(np.mean(vals)), + "std": float(np.std(vals)), + } + + return await asyncio.to_thread(_run) + + +@app.post("/api/guide/generate") +async def generate_guide_route( + source_key: str = Query(...), + payload: DraftPayload = ..., +) -> dict: + """Build a guide skeleton (per-class label/color + example crops) from an + annotation payload (the current draft or a fetched version). + + Descriptions are returned blank for the lead to fill in. Does not persist — + the client merges the result into the guide and saves via PUT /api/guide. + """ + def _run() -> dict: + import guide_gen + return guide_gen.generate_guide(source_key, payload.model_dump()) + + return await asyncio.to_thread(_run) + + @app.post("/api/annotations/preview-thumbnail") async def preview_annotation_thumbnail( source_key: str = Query(...), @@ -569,9 +769,6 @@ async def save_annotation_version( thumbnail was generated. """ def _run() -> dict: - import annotation_thumbnails - import threading - payload = body.payload.model_dump() # Persist version JSON first so the critical data lands quickly. result = drafts_mod.save_version( @@ -680,6 +877,12 @@ async def export_coco(payload: ExportRequest) -> dict: stem = first_source.replace("/", "_").replace("\\", "_")[-30:].strip("_") or "dataset" folder_name = f"{stem}_{ts}" + # Stamp the annotator into the folder so gathered downloads are self-identifying. + annotator = (payload.annotator or "").strip() + if annotator: + from coco_export import _safe_name + folder_name = f"{_safe_name(annotator)}__{folder_name}" + out_root = (export_root / folder_name).resolve() if not str(out_root).startswith(str(export_root)): raise HTTPException(403, "Derived output path escapes EXPORT_ROOT") @@ -697,20 +900,64 @@ async def export_coco(payload: ExportRequest) -> dict: negative_slices=payload.negative_slices, )] - def _run() -> dict: - from coco_export import build_export_plan, write_coco_split - import images as images_mod_local - import arrays as arrays_mod_local + # Dry run = counts only. Resolve splits from the payload without touching + # Tiled, reading slices, sampling stats, or rasterizing — so the preview is + # instant. Returned synchronously (no job). + if payload.dry_run: + from coco_export import _resolve_split + summary: dict = {"skipped_zero_area": 0, "splits": {}} + for item in source_items: + neg_keys = {str(k) for k in item.negative_slices} + all_keys = list(set(item.slices.keys()) | neg_keys) + resolved = _resolve_split( + all_keys, + {str(k): v for k, v in item.split_by_slice.items()}, + payload.auto_split, + ) + for k in all_keys: + split = resolved.get(k, "train") + bucket = summary["splits"].setdefault(split, {"n_images": 0, "n_annotations": 0}) + bucket["n_images"] += 1 + bucket["n_annotations"] += len(item.slices.get(k, [])) + return summary + + # Real export runs on a background thread; the UI polls /api/export/status + # for phase/progress/log lines and downloads the .zip when done. + jid = export_jobs.new_job(str(out_root)) + threading.Thread( + target=_run_export_job, + args=(jid, source_items, payload, out_root), + daemon=True, + ).start() + return {"job_id": jid, "dataset_path": str(out_root)} + + +def _run_export_job( + jid: str, + source_items: "list[ExportSourceItem]", + payload: "ExportRequest", + out_root: Path, +) -> None: + """Background worker: render+rasterize all sources, write the dataset tree + (images + masks + COCO), zip it for download, then sync Tiled metadata.""" + lightly = getattr(payload, "format", "coco_sam3") == "lightly_dinov3" + + try: + export_jobs.update(jid, state="running", phase="reading") + total = sum( + len(set(item.slices.keys()) | {str(k) for k in item.negative_slices}) + for item in source_items + ) + export_jobs.set_total(jid, total) - # Merged splits accumulator across all sources. merged_splits: dict[str, dict] = {} skipped_total = 0 merged_categories: list[dict] = [] merged_info: dict = {} for item in source_items: - node = arrays_mod_local.resolve_array(item.source, item.kind, item.server_uri) - # Build a temporary single-source payload object for reuse of build_export_plan. + export_jobs.log(jid, f"Reading {item.source} …") + node = arrays_mod.resolve_array(item.source, item.kind, item.server_uri) tmp = ExportRequest( kind=item.kind, source=item.source, @@ -722,76 +969,174 @@ def _run() -> dict: render=payload.render, auto_split=payload.auto_split, ) + + def _cb(message: str, _jid: str = jid) -> None: + export_jobs.bump(_jid, 1) + export_jobs.log(_jid, message) + plan = build_export_plan( node, tmp, - render_slice_fn=images_mod_local.render_slice, - array_shape_meta_fn=arrays_mod_local.array_shape_meta, - read_slice_fn=arrays_mod_local.read_slice, - sample_global_stats_fn=images_mod_local._sample_global_stats, + render_slice_fn=images_mod.render_slice, + array_shape_meta_fn=arrays_mod.array_shape_meta, + read_slice_fn=arrays_mod.read_slice, + sample_global_stats_fn=images_mod._sample_global_stats, + progress_cb=_cb, + include_polygons=payload.include_polygons, ) skipped_total += plan["skipped_zero_area"] if not merged_categories: merged_categories = plan["categories"] merged_info = plan["info"] for split_name, split_data in plan["splits"].items(): - if split_name not in merged_splits: - merged_splits[split_name] = {"images": [], "annotations": []} - merged_splits[split_name]["images"].extend(split_data["images"]) - merged_splits[split_name]["annotations"].extend(split_data["annotations"]) - - summary: dict = {"skipped_zero_area": skipped_total, "splits": {}} - if payload.dry_run: - for split_name, split_data in merged_splits.items(): - summary["splits"][split_name] = { - "n_images": len(split_data["images"]), - "n_annotations": len(split_data["annotations"]), - } - return summary - + bucket = merged_splits.setdefault(split_name, {"images": [], "annotations": []}) + bucket["images"].extend(split_data["images"]) + bucket["annotations"].extend(split_data["annotations"]) + + # Stamp the annotator so downloads are self-identifying for external + # inter-annotator-agreement analysis (folder name + COCO info + manifest). + annotator = (payload.annotator or "").strip() + exported_at = datetime.now(timezone.utc).isoformat() + if annotator: + merged_info = {**(merged_info or {}), "annotator": annotator} + source_keys = [ + (f"tiled:{item.server_uri or ''}:{item.source}" if item.kind == "tiled" + else f"local:{item.source}") + for item in source_items + ] + manifest = { + "annotator": annotator, + "exported_at": exported_at, + "source_keys": source_keys, + "classes": [ + (c.model_dump() if hasattr(c, "model_dump") else dict(c)) for c in payload.classes + ], + } + + # Write files AND build the download .zip in one pass. ZIP_STORED: the + # PNGs are already compressed, so re-deflating them is wasted CPU. + import zipfile + export_jobs.update(jid, phase="writing") + zip_path = f"{out_root}.zip" written: dict = {} - for split_name, split_data in merged_splits.items(): - split_dir = out_root / split_name - result = write_coco_split( - split_dir, - images=split_data["images"], - categories=merged_categories, - annotations=split_data["annotations"], - mode=payload.mode, - info=merged_info, - ) - written[split_name] = result - summary["written"] = written - summary["dataset_path"] = str(out_root) - - # Sync annotation flags back onto source Tiled nodes for Browse discovery. + # Create the dataset dir (and its parent EXPORT_ROOT, e.g. ~/data/exports) + # BEFORE opening the zip — the zip lives at {out_root}.zip, so its parent + # must exist or ZipFile("w") raises FileNotFoundError on a fresh install. + out_root.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zf: + manifest_json = json.dumps(manifest, indent=2) + (out_root / "manifest.json").write_text(manifest_json) + zf.writestr("manifest.json", manifest_json) + + if lightly: + # DINOv3 / Lightly: classes.json (index→name, 0=bg) at the dataset + # root; each split as images/ + masks/ with matching stems. + classes_json = json.dumps(lightly_classes_map(merged_categories), indent=2) + (out_root / "classes.json").write_text(classes_json) + zf.writestr("classes.json", classes_json.encode("utf-8")) + for split_name, split_data in merged_splits.items(): + # Lightly convention: 'valid' → 'val'; 'train'/'test' unchanged. + dir_name = "val" if split_name == "valid" else split_name + export_jobs.log(jid, f"Writing split '{dir_name}' ({len(split_data['images'])} images + masks)…") + written[dir_name] = write_lightly_split( + out_root / dir_name, + images=split_data["images"], + zf=zf, + arc_prefix=f"{dir_name}/", + ) + else: + for split_name, split_data in merged_splits.items(): + export_jobs.log(jid, f"Writing split '{split_name}' ({len(split_data['images'])} images + masks)…") + written[split_name] = write_coco_split( + out_root / split_name, + images=split_data["images"], + categories=merged_categories, + annotations=split_data["annotations"], + mode=payload.mode, + info=merged_info, + zf=zf, + arc_prefix=f"{split_name}/", + ) + + export_jobs.update(jid, phase="syncing") import tiled_annotation_sync - from source_keys import parse_source_key - for item in source_items: if item.kind != "tiled": continue sk = f"tiled:{item.server_uri or ''}:{item.source}" try: tiled_annotation_sync.sync_annotation_metadata( - sk, - { - "classes": payload.classes, - "slices": item.slices, - }, + sk, {"classes": payload.classes, "slices": item.slices}, ) except Exception as sync_exc: logger.warning("Export Tiled sync failed for %s: %s", sk, sync_exc) - return summary - - try: - result = await asyncio.to_thread(_run) - return result + result = { + "skipped_zero_area": skipped_total, + "written": written, + "dataset_path": str(out_root), + "zip_available": True, + "splits": { + k: {"n_images": len(v["images"]), "n_annotations": len(v["annotations"])} + for k, v in merged_splits.items() + }, + } + export_jobs.update(jid, zip_path=zip_path, result=result, phase="done", state="done") + export_jobs.log(jid, "Export complete.") except FileExistsError as exc: - raise HTTPException(409, str(exc)) from exc - except Exception as exc: - logger.error("Export failed: %s", exc) - raise HTTPException(500, f"Export failed: {exc}") from exc + export_jobs.update(jid, state="error", phase="error", error=f"{exc} (use overwrite or merge)") + except Exception as exc: # noqa: BLE001 + logger.error("Export job failed: %s", exc) + export_jobs.update(jid, state="error", phase="error", error=str(exc)) + + +@app.get("/api/export/status/{job_id}") +async def export_status(job_id: str) -> dict: + """Poll an export job's progress (state, phase, done/total, log, result).""" + job = export_jobs.get_job(job_id) + if job is None: + raise HTTPException(404, "Unknown job_id") + return job + + +@app.get("/api/export/download/{job_id}") +async def export_download(job_id: str) -> Response: + """Stream the finished export .zip (browser save dialog picks the location).""" + zp = export_jobs.zip_path(job_id) + if not zp or not Path(zp).exists(): + raise HTTPException(404, "Export zip not ready") + return FileResponse(zp, media_type="application/zip", filename=Path(zp).name) + + +@app.post("/api/masks/to-tiled") +async def masks_to_tiled(payload: ExportRequest) -> dict: + """Write rasterized masks into Tiled as stacked volumes (standalone action). + + Reuses the export payload (classes/slices/negative_slices per source) but, + instead of building a training zip, rasterizes each Tiled source's shapes and + writes a ``__masks`` sibling container. Runs on a background + thread; poll ``/api/export/status/{job_id}`` for progress. + """ + import tiled_mask_sync + + if payload.sources: + source_items: list[ExportSourceItem] = payload.sources + else: + source_items = [ExportSourceItem( + kind=payload.kind, # type: ignore[arg-type] + source=payload.source, + server_uri=payload.server_uri, + slices=payload.slices, + split_by_slice=payload.split_by_slice, + negative_slices=payload.negative_slices, + )] + + jid = export_jobs.new_job("") + threading.Thread( + target=tiled_mask_sync.run_mask_sync_job, + args=(jid, source_items, payload), + daemon=True, + ).start() + return {"job_id": jid} @app.post("/api/import/coco") @@ -810,6 +1155,55 @@ def _run() -> dict: raise HTTPException(500, f"Import failed: {exc}") from exc +@app.post("/api/ingest/upload") +async def ingest_upload( + server_uri: Optional[str] = Query(None, description="Target Tiled server URI"), + container_path: str = Form(..., description="Target container, e.g. 'browse/myset'"), + description: str = Form("", description="Optional keyword(s) stored on every ingested node"), + files: list[UploadFile] = File(..., description="Image files to copy into Tiled"), +) -> dict: + """Stream uploaded files to temp storage and start a background ingest job. + + Each supported image becomes its own browsable node in *container_path* on + the connected Tiled server. Returns a ``job_id`` to poll for progress. + """ + tmp_dir = Path(tempfile.mkdtemp(prefix="ingest_")) + saved: list[tuple[str, Path]] = [] + for index, upload in enumerate(files): + ext = Path(upload.filename or "").suffix.lower() + if ext not in ingest_mod.IMAGE_EXTS: + await upload.close() + continue + dest = tmp_dir / f"{index:06d}{ext}" + # Stream in 1MB chunks — files can be 26MB+, never read() whole into memory. + with dest.open("wb") as out: + while chunk := await upload.read(1024 * 1024): + out.write(chunk) + await upload.close() + saved.append((upload.filename or dest.name, dest)) + + if not saved: + shutil.rmtree(tmp_dir, ignore_errors=True) + raise HTTPException(400, "No supported image files in upload") + + jid = ingest_mod.new_job(len(saved), server_uri, container_path) + threading.Thread( + target=ingest_mod.run_ingest_job, + args=(jid, server_uri, container_path, saved, description), + daemon=True, + ).start() + return {"job_id": jid, "total": len(saved), "container_path": container_path} + + +@app.get("/api/ingest/status/{job_id}") +async def ingest_status(job_id: str) -> dict: + """Return progress for an ingest job started by ``/api/ingest/upload``.""" + job = ingest_mod.get_job(job_id) + if job is None: + raise HTTPException(404, "Unknown job_id") + return job + + @app.get("/health") async def health() -> dict[str, str]: return {"status": "ok"} @@ -827,6 +1221,28 @@ def _parse_json_filters(raw: str) -> dict: return value if isinstance(value, dict) else {} +# --------------------------------------------------------------------------- +# Static SPA (production container only) +# --------------------------------------------------------------------------- +# In the Docker image the built frontend is copied to backend/static/, and +# FastAPI serves it so the app is a single same-origin service. In local dev +# this directory is absent (Vite serves the SPA), so the mount is skipped. +# Registered AFTER all /api routes so the catch-all never shadows them. +_STATIC_DIR = Path(__file__).parent / "static" +if _STATIC_DIR.is_dir(): + from fastapi.staticfiles import StaticFiles + + app.mount("/assets", StaticFiles(directory=str(_STATIC_DIR / "assets")), name="assets") + + @app.get("/{full_path:path}") + async def spa_fallback(full_path: str) -> FileResponse: + """Serve a real static file when it exists, else index.html (SPA routing).""" + candidate = _STATIC_DIR / full_path + if full_path and candidate.is_file() and _STATIC_DIR in candidate.resolve().parents: + return FileResponse(str(candidate)) + return FileResponse(str(_STATIC_DIR / "index.html")) + + if __name__ == "__main__": # pragma: no cover — convenience entry point import uvicorn diff --git a/backend/annotation_thumbnails.py b/backend/annotation_thumbnails.py index 6eecffb..b671006 100644 --- a/backend/annotation_thumbnails.py +++ b/backend/annotation_thumbnails.py @@ -12,7 +12,8 @@ from typing import Any import numpy as np -from PIL import Image as PILImage, ImageDraw +from PIL import Image as PILImage +from PIL import ImageDraw logger = logging.getLogger(__name__) @@ -138,7 +139,7 @@ def render_annotated_thumbnail( """ try: import arrays as arrays_mod - from thumbnails import _prepare_rgb, _prepare_intensity + from thumbnails import _prepare_intensity, _prepare_rgb except ImportError as exc: logger.warning("annotation_thumbnails: missing module: %s", exc) return None diff --git a/backend/arrays.py b/backend/arrays.py index db2360d..ac821ad 100644 --- a/backend/arrays.py +++ b/backend/arrays.py @@ -34,23 +34,29 @@ import numpy as np from fastapi import HTTPException -from cache import TTLCache import local_fs +from cache import TTLCache from tiled_clients import api_key_for_uri, get_tiled_client logger = logging.getLogger(__name__) _node_cache: TTLCache = TTLCache(ttl_seconds=300.0, max_entries=32) -def resolve_array(source: str, kind: str, server_uri: str | None = None) -> Any: +def resolve_array( + source: str, + kind: str, + server_uri: str | None = None, + root: str | None = None, +) -> Any: """Return a lazily-sliceable array node for *source*. - Results are cached by ``(kind, source, server_uri)`` for 5 minutes. + Results are cached by ``(kind, source, server_uri, root)`` for 5 minutes. Args: source: Tiled path (slash-separated) or local relative path. kind: ``"tiled"`` or ``"local"``. server_uri: Tiled server URI; only used when ``kind == "tiled"``. + root: Granted absolute local root; only used when ``kind == "local"``. Returns: A Tiled array node or a NumPy-compatible array. @@ -58,7 +64,7 @@ def resolve_array(source: str, kind: str, server_uri: str | None = None) -> Any: Raises: HTTPException: 404 if the path does not exist; 422 for unknown kind. """ - key = (kind, source, server_uri or "") + key = (kind, source, server_uri or "", root or "") cached = _node_cache.get(key) if cached is not None: return cached @@ -72,8 +78,12 @@ def resolve_array(source: str, kind: str, server_uri: str | None = None) -> Any: node = node[part] except KeyError as exc: raise HTTPException(404, f"Tiled path not found: {source!r}") from exc + # Drag-and-drop ingest nests datasets as browse//, so + # a Browse selection resolves to the wrapping container — descend to the + # array or slice-stack it represents. Direct array paths are unchanged. + node = _descend_to_stack(node) elif kind == "local": - node = local_fs.open_array(source) + node = local_fs.open_array(source, root) else: raise HTTPException(422, f"Unknown source kind: {kind!r}") @@ -81,6 +91,80 @@ def resolve_array(source: str, kind: str, server_uri: str | None = None) -> Any: return node +def _is_container_node(node: Any) -> bool: + """True if *node* is a Tiled container (vs. an array/leaf) node.""" + sf = getattr(node, "structure_family", None) + return str(getattr(sf, "value", sf)) == "container" + + +def _descend_to_stack(node: Any, max_depth: int = 8) -> Any: + """Resolve a Browse selection to the array/stack it should open. + + Drag-and-drop ingest nests datasets as ``browse//``. This + descends through *wrapper* containers (a container whose only/first child is + itself a container) but STOPS at a container whose children are arrays — + returning that container so it can be treated as a slice stack (one array + node per slice). Array nodes (and non-Tiled inputs) are returned unchanged. + """ + depth = 0 + while _is_container_node(node) and depth < max_depth: + try: + first = next(iter(node)) + except StopIteration: + return node # empty container — nothing to descend into + child = node[first] + if not _is_container_node(child): + return node # container of arrays → the stack itself + node = child + depth += 1 + return node + + +def _stack_keys(node: Any) -> list[str]: + """Sorted child keys of a container-stack (ingest zero-pads, so lexical + order == slice order).""" + return sorted(node) + + +def node_keywords(node: Any) -> list[str]: + """Return the ``keywords`` tag list stored on *node* (or its first child). + + Ingest writes ``keywords`` on both the dataset container and each array + child. For a stack container we read the container metadata first, then fall + back to the first slice. Non-Tiled inputs have no metadata → empty list. + + Args: + node: A resolved Tiled node (array or container-stack) or NumPy array. + + Returns: + List of tag strings; empty when none are present. + """ + def _from_meta(meta: Any) -> list[str] | None: + try: + value = (meta or {}).get("keywords") + except AttributeError: + return None + if isinstance(value, (list, tuple)): + return [str(v) for v in value if str(v).strip()] + if isinstance(value, str) and value.strip(): + return [value.strip()] + return None + + tags = _from_meta(getattr(node, "metadata", None)) + if tags: + return tags + if _is_container_node(node): + try: + keys = _stack_keys(node) + if keys: + child_tags = _from_meta(getattr(node[keys[0]], "metadata", None)) + if child_tags: + return child_tags + except Exception: # noqa: BLE001 — metadata is best-effort + pass + return [] + + def array_shape_meta(node: Any) -> dict[str, Any]: """Return shape-dispatch metadata for *node*. @@ -94,6 +178,9 @@ def array_shape_meta(node: Any) -> dict[str, Any]: Raises: HTTPException: 422 for unsupported array shapes (e.g. 1-D or 5-D). """ + if _is_container_node(node): + return _stack_shape_meta(node) + raw = ( np.asarray(node) if hasattr(node, "__array__") and not hasattr(node, "shape") @@ -145,6 +232,41 @@ def array_shape_meta(node: Any) -> dict[str, Any]: raise HTTPException(422, f"Unsupported array shape: {shape}") +def _stack_shape_meta(node: Any) -> dict[str, Any]: + """Shape-dispatch metadata for a container-of-arrays treated as a stack. + + Each child is one slice; ``n_slices`` is the child count and the per-slice + H/W/dtype/is_rgb come from the first child. The sorted child keys are stored + under ``"keys"`` so :func:`read_slice` maps a slice index to its node. + + Raises: + HTTPException: 422 for an empty container or unsupported slice shape. + """ + keys = _stack_keys(node) + if not keys: + raise HTTPException(422, "Container has no array slices") + first = node[keys[0]] + fshape = tuple(first.shape) + dtype = str(first.dtype) + + if len(fshape) == 2: + h, w, is_rgb = fshape[0], fshape[1], False + elif len(fshape) == 3 and fshape[2] in (3, 4): + h, w, is_rgb = fshape[0], fshape[1], True + else: + raise HTTPException(422, f"Unsupported slice shape in stack: {fshape}") + + return { + "n_slices": len(keys), + "height": h, + "width": w, + "dtype": dtype, + "is_rgb": is_rgb, + "shape_kind": "STACK", + "keys": keys, + } + + def read_slice(node: Any, meta: dict[str, Any], idx: int) -> np.ndarray: """Read one slice from *node* and return it as a NumPy array. @@ -168,4 +290,10 @@ def read_slice(node: Any, meta: dict[str, Any], idx: int) -> np.ndarray: return np.asarray(node[idx]) if kind == "NHWC": return np.asarray(node[idx]) + if kind == "STACK": + keys = meta.get("keys") or _stack_keys(node) + if not keys: + raise HTTPException(422, "Container has no array slices") + key = keys[idx] if 0 <= idx < len(keys) else keys[0] + return np.asarray(node[key]) raise HTTPException(422, f"Cannot slice shape kind: {kind}") diff --git a/backend/browse_helpers.py b/backend/browse_helpers.py index c897c86..5b72ecb 100644 --- a/backend/browse_helpers.py +++ b/backend/browse_helpers.py @@ -36,6 +36,26 @@ "studio_updated_at", ) +# Ingest-time keys worth showing as Browse facets even when only ONE distinct +# value exists (e.g. a single dropped file, or a whole batch sharing one +# user-supplied description). Without this they'd be hidden by the >=2 rule. +_INGEST_FACET_RAW_KEYS: tuple[str, ...] = ( + "description", + "keywords", + "sample_name", + "original_filename", +) + +# Keys that qualify as a facet with a single distinct value (vs. the default >=2). +_SINGLE_VALUE_FACET_RAW_KEYS: frozenset[str] = frozenset( + (*_STUDIO_RAW_KEYS, *_INGEST_FACET_RAW_KEYS), +) + +# Metadata keys stored as a LIST of values (rather than a scalar). Each element +# is treated as its own distinct Browse value, and filtering matches membership +# (via Tiled's ``Contains`` query) rather than equality. +_LIST_VALUED_RAW_KEYS: frozenset[str] = frozenset({"keywords"}) + # Raw keys that are stored at the array-node level rather than on the parent # sample container. When a filter references one of these, we switch to a # per-sample search path. @@ -116,12 +136,17 @@ def build_field_mapping(container_node: Any) -> FieldMapping: ) +# Ingest writes these at the dataset-container level, so they're always worth +# offering as facets even when the scanned sample metadata didn't surface them. +_INJECTED_INGEST_RAW_KEYS: tuple[str, ...] = ("description", "keywords", "sample_name") + + def _inject_studio_keys( display_to_raw: dict[str, str], raw_to_display: dict[str, str], ) -> None: - """Ensure studio annotation fields are always available in Browse.""" - for raw_key in _STUDIO_RAW_KEYS: + """Ensure studio annotation + ingest description fields are always in Browse.""" + for raw_key in (*_STUDIO_RAW_KEYS, *_INJECTED_INGEST_RAW_KEYS): display_key = _display_name(raw_key) display_to_raw.setdefault(display_key, raw_key) raw_to_display.setdefault(raw_key, display_key) @@ -157,14 +182,60 @@ def _display_name(raw_key: str) -> str: # Distinct values # --------------------------------------------------------------------------- +def scoped_metadata_rows(node: Any, limit: int = 5000) -> list[dict]: + """Read each child's metadata once for a *specific* container. + + Tiled's ``container.distinct()`` aggregates across the whole catalog, not + just the node it's called on, so it can't be used to compute values scoped + to a chosen sub-container. Iterating children gives correctly-scoped data + at the cost of one metadata read per child. + """ + rows: list[dict] = [] + try: + for key in list(node)[:limit]: + try: + meta = node[key].metadata + rows.append(dict(meta) if meta else {}) + except Exception: # noqa: BLE001 — skip children that fail to open + continue + except Exception as exc: # noqa: BLE001 + logger.warning("scoped_metadata_rows iteration failed: %s", exc) + return rows + + +def distinct_from_rows(rows: list[dict], raw_key: str) -> list[dict]: + """Tally distinct values of *raw_key* across pre-read metadata *rows*. + + List-valued metadata (e.g. ``keywords``) is exploded so each element is + counted as its own distinct value, making every tag individually filterable. + """ + counts: dict[Any, int] = {} + for meta in rows: + val = meta.get(raw_key) + if val is None: + continue + values = val if isinstance(val, (list, tuple)) else [val] + for item in values: + if item is None: + continue + counts[item] = counts.get(item, 0) + 1 + return [{"value": v, "count": c} for v, c in counts.items()] + + def tiled_distinct_values( container_node: Any, raw_key: str, filters: Optional[dict] = None, field_mapping: Optional[FieldMapping] = None, limit: int = 500, + scoped: bool = False, ) -> dict: - """Call ``container.distinct(raw_key)`` with optional upstream filters. + """Return distinct values (+ counts) for *raw_key* under optional filters. + + When *scoped* is True, values are computed by iterating the (filtered) + container's children so they reflect only that container — use this when + browsing a specific ``container_path``. Otherwise the faster but + catalog-global ``container.distinct()`` is used. Returns:: @@ -189,12 +260,15 @@ def tiled_distinct_values( except Exception: # noqa: BLE001 — Tiled raises a range of errors here pass - try: - result = node.distinct(raw_key, counts=True) - raw_values = result.get("metadata", {}).get(raw_key, []) - except Exception as exc: - logger.warning("distinct() failed for key %r: %s", raw_key, exc) - raw_values = [] + if scoped: + raw_values = distinct_from_rows(scoped_metadata_rows(node), raw_key) + else: + try: + result = node.distinct(raw_key, counts=True) + raw_values = result.get("metadata", {}).get(raw_key, []) + except Exception as exc: + logger.warning("distinct() failed for key %r: %s", raw_key, exc) + raw_values = [] non_null = [entry for entry in raw_values if _is_valid_value(entry.get("value"))] sorted_vals = sorted(non_null, key=lambda e: (-e.get("count", 0), str(e["value"])))[:limit] @@ -262,8 +336,26 @@ def _search_container_only( for k in list(node)[:limit]: try: entry = node[k] - meta = dict(entry.metadata) if hasattr(entry, "metadata") else {} - items.append({"path": _join(prefix, k), "sample": k, "metadata": meta}) + meta = dict(entry.metadata) if hasattr(entry, "metadata") and entry.metadata else {} + n_slices = 1 + # Drag-and-drop ingest nests as browse//: the + # dataset is a container of array slices, with the descriptive + # metadata on the children. Report the slice count (so the UI can + # offer drill-in) and, if the container itself is metadata-less, + # borrow the first child's metadata (cheap metadata-only read). + if _is_container(entry): + child_keys = list(entry) + if child_keys and not _is_container(entry[child_keys[0]]): + n_slices = len(child_keys) # flat stack of array slices + if not meta: + cmeta = entry[child_keys[0]].metadata + meta = dict(cmeta) if cmeta else {} + items.append({ + "path": _join(prefix, k), + "sample": k, + "metadata": meta, + "n_slices": n_slices, + }) except Exception: # noqa: BLE001 continue except Exception as exc: # noqa: BLE001 @@ -272,6 +364,12 @@ def _search_container_only( return {"items": items, "total": len(items)} +def _is_container(entry: Any) -> bool: + """True if *entry* is a Tiled container (vs. an array/leaf) node.""" + sf = getattr(entry, "structure_family", None) + return str(getattr(sf, "value", sf)) == "container" + + def _search_array_only( container_node: Any, raw_filters: list[tuple[str, str]], @@ -363,24 +461,44 @@ def _raw_filters( def _apply_filters(node: Any, raw_filters: Iterable[tuple[str, str]]) -> Any: - """Apply each ``Key(...) == value`` filter to *node*, skipping failures.""" + """Apply each filter to *node*, skipping failures. + + List-valued keys (e.g. ``keywords``) match membership via ``Contains``; + scalar keys match equality via ``Key(...) == value``. + """ from tiled.queries import Key + try: + from tiled.queries import Contains + except ImportError: # pragma: no cover — older Tiled without Contains + Contains = None + for r_key, val in raw_filters: try: - node = node.search(Key(r_key) == _typed_query_value(val)) + if r_key in _LIST_VALUED_RAW_KEYS and Contains is not None: + node = node.search(Contains(r_key, val)) + else: + node = node.search(Key(r_key) == _typed_query_value(val)) except Exception: # noqa: BLE001 pass return node def _matches_container_filters(meta: dict, container_filters: list[tuple[str, str]]) -> bool: - """Case-insensitive exact match over container-level metadata.""" + """Case-insensitive match over container-level metadata. + + Scalar values match by equality; list-valued metadata matches if the filter + value is one of the list's members. + """ for r_key, val in container_filters: sample_val = meta.get(r_key) if sample_val is None: return False - if str(sample_val).strip().lower() != val.strip().lower(): + target = val.strip().lower() + if isinstance(sample_val, (list, tuple)): + if target not in {str(item).strip().lower() for item in sample_val}: + return False + elif str(sample_val).strip().lower() != target: return False return True @@ -398,7 +516,9 @@ def _typed_query_value(raw: str) -> Any: except (ValueError, TypeError): pass try: - return float(raw) + fv = float(raw) + if str(fv) == raw: + return fv except (ValueError, TypeError): pass return raw diff --git a/backend/coco_export.py b/backend/coco_export.py index 7fbd14f..75aeb3c 100644 --- a/backend/coco_export.py +++ b/backend/coco_export.py @@ -5,16 +5,19 @@ """ from __future__ import annotations +import io import json import logging import math import random +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from pathlib import Path from typing import Any import numpy as np import pycocotools.mask as mask_utils +from PIL import Image as PILImage from skimage import draw, measure logger = logging.getLogger(__name__) @@ -98,24 +101,58 @@ def _brush_mask(strokes: list[dict[str, Any]], h: int, w: int) -> np.ndarray: return mask +def _apply_erased(mask: np.ndarray, erased: list[dict[str, Any]] | None) -> np.ndarray: + """Subtract erase carve-outs from a vector shape's mask (in-place-safe).""" + if not erased: + return mask + for stroke in erased: + stamp = np.zeros(mask.shape, dtype=bool) + _stamp_stroke(stamp, stroke["points"], stroke["radius"]) + mask &= ~stamp + return mask + + def shape_to_mask(shape: dict[str, Any], h: int, w: int) -> np.ndarray: """Rasterize one Shape (image-pixel coords) to an (h, w) boolean mask.""" kind = shape["kind"] if kind == "polygon": - return _polygon_mask(shape["points"], h, w) - if kind == "rectangle": - return _rect_mask(shape["x"], shape["y"], shape["w"], shape["h"], h, w) - if kind == "ellipse": - return _ellipse_mask(shape["cx"], shape["cy"], shape["rx"], shape["ry"], h, w) - if kind == "brush": + mask = _polygon_mask(shape["points"], h, w) + # Carve inner rings (holes), e.g. from "invert shape", so exports match. + for hole in shape.get("holes") or []: + mask &= ~_polygon_mask(hole, h, w) + elif kind == "rectangle": + mask = _rect_mask(shape["x"], shape["y"], shape["w"], shape["h"], h, w) + elif kind == "ellipse": + mask = _ellipse_mask(shape["cx"], shape["cy"], shape["rx"], shape["ry"], h, w) + elif kind == "brush": + # Brush erase strokes are part of its own stroke list, not `erased`. return _brush_mask(shape["strokes"], h, w) - raise ValueError(f"Unknown shape kind: {kind!r}") + else: + raise ValueError(f"Unknown shape kind: {kind!r}") + # Vector shapes can carry eraser carve-outs applied after rasterization. + return _apply_erased(mask, shape.get("erased")) # --------------------------------------------------------------------------- # Mask -> COCO annotation # --------------------------------------------------------------------------- +def _encode_png(arr: np.ndarray, compress_level: int = 0) -> bytes: + """Encode a single-channel uint8 array as a grayscale PNG. + + Masks are tiny and pre-binarized; ``compress_level=0`` (store) is the fastest + and the size cost is negligible. + """ + buf = io.BytesIO() + PILImage.fromarray(arr, mode="L").save(buf, format="PNG", compress_level=compress_level) + return buf.getvalue() + + +def _safe_name(name: str) -> str: + """Filesystem-safe folder name for a class label.""" + return "".join(c if c.isalnum() or c in "-_" else "_" for c in name) or "class" + + def _mask_to_polygons(mask: np.ndarray, min_pts: int = 6) -> list[list[float]]: """Outer+inner contours as COCO-style flat polygons (holes NOT encoded -- see RLE).""" polys: list[list[float]] = [] @@ -131,14 +168,26 @@ def mask_to_coco_ann( ann_id: int, image_id: int, category_id: int, + *, + include_polygons: bool = False, + poly_override: list[list[float]] | None = None, ) -> dict[str, Any]: - """Build a COCO annotation: RLE in segmentation, polygons in segmentation_poly. + """Build a COCO annotation: RLE in segmentation, optional polygon copy. RLE is exact (holes, multiple components) and is what SAM3's pycocotools - segm path consumes. The polygon copy is a convenience for external viewers. + segm path consumes — always present. The polygon copy (``segmentation_poly``) + is a convenience for external viewers; generating it via ``find_contours`` is + costly, so it is opt-in. ``poly_override`` lets a polygon-kind shape reuse its + own points instead of re-tracing the mask. """ rle = mask_utils.encode(np.asfortranarray(mask.astype(np.uint8))) rle["counts"] = rle["counts"].decode("ascii") + if not include_polygons: + seg_poly: list[list[float]] = [] + elif poly_override is not None: + seg_poly = poly_override + else: + seg_poly = _mask_to_polygons(mask) return { "id": ann_id, "image_id": image_id, @@ -147,7 +196,7 @@ def mask_to_coco_ann( "area": float(mask_utils.area(rle)), "bbox": [float(v) for v in mask_utils.toBbox(rle)], "segmentation": rle, - "segmentation_poly": _mask_to_polygons(mask), + "segmentation_poly": seg_poly, } @@ -189,9 +238,15 @@ def write_coco_split( *, mode: str = "fail", info: dict[str, Any] | None = None, + zf: Any = None, + arc_prefix: str = "", ) -> dict[str, Any]: """Write/merge one split directory. + If ``zf`` (an open ``zipfile.ZipFile``) is given, every file is also added to + it under ``arc_prefix`` from the same in-memory bytes — a single pass with no + disk re-read (used to build the download .zip cheaply). + In merge mode existing image entries are matched by file_name: matched images are replaced (their old annotations dropped), new images get ids above the existing max; annotation ids likewise @@ -215,7 +270,11 @@ def write_coco_split( """ split_dir.mkdir(parents=True, exist_ok=True) coco_path = split_dir / "_annotations.coco.json" - sidecar_path = split_dir / "_studio_shapes.json" + + def _emit(rel: str, data: bytes) -> None: + """Mirror a just-written file into the download zip (if building one).""" + if zf is not None: + zf.writestr(arc_prefix + rel, data) existing_coco: dict[str, Any] = {"images": [], "annotations": [], "categories": []} if coco_path.exists(): @@ -269,12 +328,30 @@ def write_coco_split( max_img_id += 1 img_id = max_img_id png_bytes: bytes | None = img.pop("png_bytes", None) + # Pop mask payloads so they don't leak into the COCO json. + label_png: bytes | None = img.pop("label_png_bytes", None) + class_masks: dict[str, bytes] = img.pop("class_masks", {}) or {} out_img = {**img, "id": img_id} out_images.append(out_img) - # Write PNG + fname = img["file_name"] + # Write PNG (+ mirror into zip) if png_bytes is not None: - (split_dir / img["file_name"]).write_bytes(png_bytes) + (split_dir / fname).write_bytes(png_bytes) + _emit(fname, png_bytes) + + # Write masks: masks/semantic/ (label map) + masks//. + if label_png is not None: + sem_dir = split_dir / "masks" / "semantic" + sem_dir.mkdir(parents=True, exist_ok=True) + (sem_dir / fname).write_bytes(label_png) + _emit(f"masks/semantic/{fname}", label_png) + for cname, cbytes in class_masks.items(): + safe = _safe_name(cname) + cls_dir = split_dir / "masks" / safe + cls_dir.mkdir(parents=True, exist_ok=True) + (cls_dir / fname).write_bytes(cbytes) + _emit(f"masks/{safe}/{fname}", cbytes) # Write annotations for this image img_anns = [a for a in annotations if a.get("_image_file_name") == img["file_name"]] @@ -289,7 +366,7 @@ def write_coco_split( coco_doc = { "info": info or { - "description": "SAM3 fine-tune dataset -- SAM3 Annotation Studio", + "description": "SAM3 fine-tune dataset -- Segmentation Annotation Studio", "date_created": datetime.now(timezone.utc).isoformat(), }, "licenses": [], @@ -297,7 +374,17 @@ def write_coco_split( "categories": final_cats, "annotations": out_anns, } - coco_path.write_text(json.dumps(coco_doc, indent=2)) + coco_json = json.dumps(coco_doc, indent=2) + coco_path.write_text(coco_json) + _emit("_annotations.coco.json", coco_json.encode("utf-8")) + + # Legend mapping semantic label index -> class name/color (for mask viewers). + masks_dir = split_dir / "masks" + if masks_dir.exists(): + legend = [{"id": c["id"], "name": c["name"], "color": c.get("color")} for c in final_cats] + legend_json = json.dumps(legend, indent=2) + (masks_dir / "legend.json").write_text(legend_json) + _emit("masks/legend.json", legend_json.encode("utf-8")) return { "n_images": len(images), @@ -306,6 +393,55 @@ def write_coco_split( } +def write_lightly_split( + split_dir: Path, + images: list[dict[str, Any]], + *, + zf: Any = None, + arc_prefix: str = "", +) -> dict[str, Any]: + """Write one split in the DINOv3 / Lightly semantic-segmentation layout: + ``/images/.png`` (rendered frame) + ``/masks/.png`` + (single-channel label map, pixel = class index, 0 = background) with MATCHING + filename stems. Reuses the ``png_bytes`` / ``label_png_bytes`` already built by + ``build_export_plan`` (the same rasterization as the COCO/semantic export). + + If ``zf`` is given, each file is mirrored into the download zip under + ``arc_prefix`` from the same bytes. + """ + img_dir = split_dir / "images" + mask_dir = split_dir / "masks" + img_dir.mkdir(parents=True, exist_ok=True) + mask_dir.mkdir(parents=True, exist_ok=True) + + def _emit(rel: str, data: bytes) -> None: + if zf is not None: + zf.writestr(arc_prefix + rel, data) + + n = 0 + for img in images: + fname = img["file_name"] # e.g. "sample_0003.png" + png_bytes = img.get("png_bytes") + label_png = img.get("label_png_bytes") + if png_bytes is not None: + (img_dir / fname).write_bytes(png_bytes) + _emit(f"images/{fname}", png_bytes) + if label_png is not None: + (mask_dir / fname).write_bytes(label_png) + _emit(f"masks/{fname}", label_png) + n += 1 + return {"n_images": n, "n_annotations": 0, "path": str(img_dir)} + + +def lightly_classes_map(categories: list[dict[str, Any]]) -> dict[str, str]: + """Build the Lightly ``classes`` mapping (index → name), 0 = background. + Category ids start at 1 and are contiguous (see build_export_plan).""" + out: dict[str, str] = {"0": "background"} + for c in categories: + out[str(int(c["id"]))] = str(c["name"]) + return out + + def build_export_plan( node: Any, payload: Any, @@ -313,6 +449,8 @@ def build_export_plan( array_shape_meta_fn: Any, read_slice_fn: Any, sample_global_stats_fn: Any, + progress_cb: Any = None, + include_polygons: bool = False, ) -> dict[str, Any]: """Build the full export plan (rasterize all shapes, render PNGs). @@ -327,9 +465,6 @@ def build_export_plan( Returns: Dict with splits (each has images, categories, annotations, info). """ - import io - from PIL import Image as PILImage - meta = array_shape_meta_fn(node) h, w = meta["height"], meta["width"] render_opts = payload.render.model_dump() if hasattr(payload.render, "model_dump") else dict(payload.render) @@ -345,66 +480,116 @@ def build_export_plan( if render_opts_mapped["norm"] == "global": global_range = sample_global_stats_fn(node, meta) - classes_by_id = {c.classId: c for c in payload.classes} cat_id_map: dict[int, int] = {} + cat_id_to_name: dict[int, str] = {} categories: list[dict[str, Any]] = [] for i, cls in enumerate(payload.classes, 1): - categories.append({"id": i, "name": cls.label, "supercategory": "object"}) + # `color` is an extra key (COCO ignores it) used for the mask legend. + categories.append({ + "id": i, "name": cls.label, "supercategory": "object", + "color": getattr(cls, "color", None), + }) cat_id_map[cls.classId] = i + cat_id_to_name[i] = cls.label all_slice_keys = list(payload.slices.keys()) neg_keys = set(str(k) for k in payload.negative_slices) - all_keys = list(set(all_slice_keys) | neg_keys) + all_keys = sorted(set(all_slice_keys) | neg_keys, key=int) resolved_splits = _resolve_split(all_keys, {str(k): v for k, v in payload.split_by_slice.items()}, payload.auto_split) - splits_data: dict[str, dict[str, Any]] = {} - skipped_zero_area = 0 - - for slice_key in all_keys: - split = resolved_splits.get(slice_key, "train") - if split not in splits_data: - splits_data[split] = {"images": [], "annotations": []} + source_stem = str(payload.source).replace("/", "_").replace("\\", "_")[-30:] + def _process_slice(slice_key: str) -> dict[str, Any]: + """Read → render → PNG-encode → rasterize one slice. Pure & independent, + so slices run concurrently — the read/encode I/O dominates wall time.""" slice_idx = int(slice_key) arr = read_slice_fn(node, meta, slice_idx) rgb = render_slice_fn(arr, render_opts_mapped, global_range) buf = io.BytesIO() - PILImage.fromarray(rgb).save(buf, format="PNG") + # compress_level=1: PNG encode of a large frame is a big chunk of export + # time; level 1 is ~3x faster than the default for a small size cost. + PILImage.fromarray(rgb).save(buf, format="PNG", compress_level=1) png_bytes = buf.getvalue() - source_stem = str(payload.source).replace("/", "_").replace("\\", "_")[-30:] file_name = f"{source_stem}_{slice_idx:04d}.png" - - shapes = payload.slices.get(slice_key, []) anns: list[dict[str, Any]] = [] - for shape in shapes: + skipped = 0 + # Semantic label map (class index per pixel, 0 = bg) + per-class binary + # masks, built from the same rasterization used for the COCO annotations. + label = np.zeros((h, w), dtype=np.uint8) + class_acc: dict[str, np.ndarray] = {} + for shape in payload.slices.get(slice_key, []): shape_dict = shape if isinstance(shape, dict) else shape.model_dump() mask = shape_to_mask(shape_dict, h, w) - area = float(mask.sum()) - if area < 1: - skipped_zero_area += 1 + if float(mask.sum()) < 1: + skipped += 1 logger.warning("Zero-area shape %r skipped", shape_dict.get("id")) continue - class_id = shape_dict.get("classId", 1) - cat_id = cat_id_map.get(class_id, 1) - ann = mask_to_coco_ann(mask, ann_id=0, image_id=0, category_id=cat_id) + cat_id = cat_id_map.get(shape_dict.get("classId", 1), 1) + # A polygon shape can reuse its own points instead of re-tracing. + poly_override = ( + [shape_dict["points"]] + if include_polygons and shape_dict.get("kind") == "polygon" and shape_dict.get("points") + else None + ) + ann = mask_to_coco_ann( + mask, ann_id=0, image_id=0, category_id=cat_id, + include_polygons=include_polygons, poly_override=poly_override, + ) ann["_image_file_name"] = file_name anns.append(ann) + # Paint label map (last shape wins on overlap) + accumulate per class. + label[mask] = cat_id + cname = cat_id_to_name.get(cat_id, str(cat_id)) + if cname not in class_acc: + class_acc[cname] = np.zeros((h, w), dtype=bool) + class_acc[cname] |= mask + + # Semantic map is always emitted (zeros for negative slices) so every + # exported image has a matching label; per-class only where present. + class_masks = {name: _encode_png((m * 255).astype(np.uint8)) for name, m in class_acc.items()} + + result = { + "split": resolved_splits.get(slice_key, "train"), + "image": { + "file_name": file_name, + "height": h, + "width": w, + "source_key": str(payload.source), + "slice_index": slice_idx, + "png_bytes": png_bytes, + "label_png_bytes": _encode_png(label), + "class_masks": class_masks, + }, + "annotations": anns, + "skipped": skipped, + } + if progress_cb is not None: + progress_cb(f"slice {slice_idx}: {len(anns)} object(s)") + return result - splits_data[split]["images"].append({ - "file_name": file_name, - "height": h, - "width": w, - "source_key": str(payload.source), - "slice_index": slice_idx, - "png_bytes": png_bytes, - }) - splits_data[split]["annotations"].extend(anns) + splits_data: dict[str, dict[str, Any]] = {} + skipped_zero_area = 0 + + # I/O-bound (Tiled reads) + partially GIL-releasing (numpy/PIL/pycocotools) + # → thread pool. EXPORT_WORKERS tunes parallelism. ex.map preserves order. + import os + workers = min(int(os.getenv("EXPORT_WORKERS", "16")), max(1, len(all_keys))) + with ThreadPoolExecutor(max_workers=workers) as ex: + results = list(ex.map(_process_slice, all_keys)) + + for r in results: + split = r["split"] + if split not in splits_data: + splits_data[split] = {"images": [], "annotations": []} + splits_data[split]["images"].append(r["image"]) + splits_data[split]["annotations"].extend(r["annotations"]) + skipped_zero_area += r["skipped"] info = { - "description": "SAM3 fine-tune dataset -- SAM3 Annotation Studio", + "description": "SAM3 fine-tune dataset -- Segmentation Annotation Studio", "date_created": datetime.now(timezone.utc).isoformat(), "render": render_opts_mapped, } diff --git a/backend/coco_import.py b/backend/coco_import.py index 9fde5b3..bc19404 100644 --- a/backend/coco_import.py +++ b/backend/coco_import.py @@ -80,7 +80,6 @@ def _import_from_coco(coco: dict[str, Any]) -> dict[str, Any]: from skimage import measure images_by_id = {img["id"]: img for img in coco.get("images", [])} - cats_by_id = {c["id"]: c for c in coco.get("categories", [])} classes = [ {"classId": c["id"], "label": c["name"], "color": "#1f77b4", "isVisible": True} diff --git a/backend/drafts.py b/backend/drafts.py index d188dfc..973e89c 100644 --- a/backend/drafts.py +++ b/backend/drafts.py @@ -1,4 +1,4 @@ -"""Session-draft persistence and version history for the SAM3 Annotation Studio. +"""Session-draft persistence and version history for the Segmentation Annotation Studio. Drafts are stored as JSON files under ``$LOCAL_DATA_ROOT/.drafts/``. Each draft is keyed by an arbitrary *source_key* string (typically the Tiled diff --git a/backend/export_jobs.py b/backend/export_jobs.py new file mode 100644 index 0000000..315fe13 --- /dev/null +++ b/backend/export_jobs.py @@ -0,0 +1,79 @@ +"""In-memory job registry for COCO/mask exports. + +Mirrors the ingest job pattern (ingest.py): export runs on a background thread +and reports phase/progress/log lines that the UI polls via +``/api/export/status/{job_id}``. Kept deliberately simple — a process-local dict +guarded by a lock; jobs are ephemeral and fine to lose on restart. +""" +from __future__ import annotations + +import threading +import uuid +from typing import Any + +_jobs: dict[str, dict[str, Any]] = {} +_lock = threading.Lock() +_MAX_LOG = 200 + + +def new_job(dataset_path: str) -> str: + """Register a new export job and return its id.""" + jid = uuid.uuid4().hex + with _lock: + _jobs[jid] = { + "state": "pending", # pending | running | done | error + "phase": "queued", + "done": 0, + "total": 0, + "log": [], + "result": None, + "error": None, + "dataset_path": dataset_path, + "zip_path": None, # internal; surfaced as result.zip_available + } + return jid + + +def get_job(jid: str) -> dict | None: + """Return a snapshot copy of the job (without the internal zip_path).""" + with _lock: + job = _jobs.get(jid) + if not job: + return None + snap = dict(job) + snap.pop("zip_path", None) + return snap + + +def zip_path(jid: str) -> str | None: + with _lock: + job = _jobs.get(jid) + return job.get("zip_path") if job else None + + +def update(jid: str, **kw: Any) -> None: + with _lock: + if jid in _jobs: + _jobs[jid].update(kw) + + +def set_total(jid: str, total: int) -> None: + with _lock: + if jid in _jobs: + _jobs[jid]["total"] = total + + +def bump(jid: str, n: int = 1) -> None: + with _lock: + if jid in _jobs: + _jobs[jid]["done"] += n + + +def log(jid: str, message: str) -> None: + with _lock: + job = _jobs.get(jid) + if not job: + return + job["log"].append(message) + if len(job["log"]) > _MAX_LOG: + del job["log"][: len(job["log"]) - _MAX_LOG] diff --git a/backend/guide_gen.py b/backend/guide_gen.py new file mode 100644 index 0000000..444c535 --- /dev/null +++ b/backend/guide_gen.py @@ -0,0 +1,319 @@ +"""Generate an annotation guide from an existing annotation. + +Given a set of annotated shapes (a draft or a saved version's payload), this +picks a few representative example crops per class — the largest instances, +cropped to their bounding box with a small margin, with the class color overlaid +on the labeled region — so a project lead gets a guide skeleton (label, color, +example images) with one click, then fills in the descriptions. + +Performance: instances are ranked by *analytic* area and bounding box (no +rasterization), only the slices actually used are read, those are downsampled, +and only the selected top-N shapes are rasterized (at reduced resolution) for the +color overlay. Reuses the mask rasterizer from :mod:`coco_export` and the slice +reading / colour-mapping helpers from :mod:`arrays` / :mod:`thumbnails`. +""" + +from __future__ import annotations + +import base64 +import logging +import math +from io import BytesIO +from typing import Any + +import numpy as np +from PIL import Image as PILImage + +logger = logging.getLogger(__name__) + +_DEFAULT_PER_CLASS = 4 +_CROP_MAX_PX = 160 +_MARGIN_FRAC = 0.15 # bbox padding as a fraction of the larger bbox side +_RENDER_MAX_DIM = 1024 # cap the working slice resolution for speed +_OVERLAY_ALPHA = 0.45 # class-color tint strength on the labeled region + + +def _parse_source_key(source_key: str) -> tuple[str, str, str | None]: + from source_keys import parse_source_key + + parsed = parse_source_key(source_key) + return parsed["kind"] or "local", parsed["path"] or "", parsed["server_uri"] + + +def _hex_rgb(hex_color: str) -> tuple[int, int, int]: + h = (hex_color or "#1f77b4").lstrip("#") + if len(h) < 6: + h = h.ljust(6, "0") + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +# --- analytic area + bbox (image px), no rasterization ---------------------- + +def _shoelace(pts: list[float]) -> float: + n = len(pts) // 2 + if n < 3: + return 0.0 + s = 0.0 + j = n - 1 + for i in range(n): + s += pts[j * 2] * pts[i * 2 + 1] - pts[i * 2] * pts[j * 2 + 1] + j = i + return abs(s) / 2.0 + + +def _analytic_area(shape: dict[str, Any]) -> float: + kind = shape.get("kind") + if kind == "rectangle": + return abs(float(shape.get("w", 0)) * float(shape.get("h", 0))) + if kind == "ellipse": + return math.pi * abs(float(shape.get("rx", 0)) * float(shape.get("ry", 0))) + if kind == "polygon": + a = _shoelace(shape.get("points") or []) + for hole in shape.get("holes") or []: + a -= _shoelace(hole) + return max(0.0, a) + if kind == "brush": + a = 0.0 + for st in shape.get("strokes") or []: + if st.get("mode") != "paint": + continue + p = st.get("points") or [] + r = float(st.get("radius", 1)) + length = 0.0 + for i in range(0, len(p) - 3, 2): + length += math.hypot(p[i + 2] - p[i], p[i + 3] - p[i + 1]) + a += length * 2 * r + math.pi * r * r + return a + return 0.0 + + +def _analytic_bbox(shape: dict[str, Any]) -> tuple[float, float, float, float] | None: + """(x0, y0, x1, y1) in image px, or None.""" + kind = shape.get("kind") + if kind == "rectangle": + x, y = float(shape.get("x", 0)), float(shape.get("y", 0)) + w, h = float(shape.get("w", 0)), float(shape.get("h", 0)) + return min(x, x + w), min(y, y + h), max(x, x + w), max(y, y + h) + if kind == "ellipse": + cx, cy = float(shape.get("cx", 0)), float(shape.get("cy", 0)) + rx, ry = abs(float(shape.get("rx", 0))), abs(float(shape.get("ry", 0))) + return cx - rx, cy - ry, cx + rx, cy + ry + xs: list[float] = [] + ys: list[float] = [] + if kind == "polygon": + p = shape.get("points") or [] + xs = p[0::2] + ys = p[1::2] + elif kind == "brush": + for st in shape.get("strokes") or []: + p = st.get("points") or [] + r = float(st.get("radius", 1)) + for i in range(0, len(p) - 1, 2): + xs.extend([p[i] - r, p[i] + r]) + ys.extend([p[i + 1] - r, p[i + 1] + r]) + if not xs or not ys: + return None + return min(xs), min(ys), max(xs), max(ys) + + +def _scale_shape(shape: dict[str, Any], f: float) -> dict[str, Any]: + """Return a copy of *shape* with all coordinates scaled by *f* (for rasterizing + at a reduced resolution).""" + s = dict(shape) + k = s.get("kind") + if k == "polygon": + s["points"] = [v * f for v in s.get("points", [])] + if s.get("holes"): + s["holes"] = [[v * f for v in ring] for ring in s["holes"]] + elif k == "rectangle": + for key in ("x", "y", "w", "h"): + s[key] = float(s.get(key, 0)) * f + elif k == "ellipse": + for key in ("cx", "cy", "rx", "ry"): + s[key] = float(s.get(key, 0)) * f + elif k == "brush": + s["strokes"] = [ + {**st, "points": [v * f for v in st.get("points", [])], "radius": float(st.get("radius", 1)) * f} + for st in s.get("strokes", []) + ] + if s.get("erased"): + s["erased"] = [ + {**er, "points": [v * f for v in er.get("points", [])], "radius": float(er.get("radius", 1)) * f} + for er in s["erased"] + ] + return s + + +def _boundary(mask: np.ndarray, thickness: int = 1) -> np.ndarray: + """Boolean boundary ring of *mask* (mask minus its 4-neighbour erosion), + dilated to ~*thickness* pixels so it stays visible after downscaling.""" + m = mask + eroded = m.copy() + eroded[1:, :] &= m[:-1, :] + eroded[:-1, :] &= m[1:, :] + eroded[:, 1:] &= m[:, :-1] + eroded[:, :-1] &= m[:, 1:] + edge = m & ~eroded + for _ in range(max(0, thickness - 1)): + d = edge.copy() + d[1:, :] |= edge[:-1, :] + d[:-1, :] |= edge[1:, :] + d[:, 1:] |= edge[:, :-1] + d[:, :-1] |= edge[:, 1:] + edge = d & m # keep the thickened outline inside the region + return edge + + +def _crop_with_overlay( + rgb: np.ndarray, + mask: np.ndarray, + color: tuple[int, int, int], + max_px: int, +) -> str | None: + """Crop *rgb* to the mask's bbox (+margin), tint the masked region with *color*, + resize, and return a PNG data URL. Coordinates are in the (reduced) grid of both + arrays. Returns None if the mask is empty.""" + ys, xs = np.where(mask) + if xs.size == 0: + return None + h, w = rgb.shape[:2] + x0, x1 = int(xs.min()), int(xs.max()) + 1 + y0, y1 = int(ys.min()), int(ys.max()) + 1 + margin = int(round(max(x1 - x0, y1 - y0) * _MARGIN_FRAC)) + 1 + x0 = max(0, x0 - margin) + y0 = max(0, y0 - margin) + x1 = min(w, x1 + margin) + y1 = min(h, y1 + margin) + if x1 <= x0 or y1 <= y0: + return None + + crop = rgb[y0:y1, x0:x1].astype(np.float32) + cmask = mask[y0:y1, x0:x1] + tint = np.array(color, dtype=np.float32) + # Fill: blend the class color over the labeled region. + crop[cmask] = crop[cmask] * (1.0 - _OVERLAY_ALPHA) + tint * _OVERLAY_ALPHA + # Outline: draw a solid class-color boundary (thickened) so the region reads + # clearly regardless of the underlying intensity. + outline = _boundary(cmask, thickness=2) + crop[outline] = tint + img = PILImage.fromarray(np.clip(crop, 0, 255).astype(np.uint8)).convert("RGB") + + scale = min(max_px / img.width, max_px / img.height, 1.0) + if scale < 1.0: + img = img.resize((max(1, int(img.width * scale)), max(1, int(img.height * scale))), PILImage.Resampling.BILINEAR) + buf = BytesIO() + img.save(buf, format="PNG") + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii") + + +def generate_guide( + source_key: str, + payload: dict[str, Any], + *, + per_class: int = _DEFAULT_PER_CLASS, + crop_px: int = _CROP_MAX_PX, +) -> dict[str, Any]: + """Build a guide skeleton (per-class label/color + color-coded example crops). + + Descriptions are left blank for the lead to fill in. + """ + import arrays as arrays_mod + from coco_export import shape_to_mask + from thumbnails import _prepare_intensity, _prepare_rgb + + classes = payload.get("classes") or [] + slices: dict[str, list[dict[str, Any]]] = payload.get("slices") or {} + + # Index shapes per class with their analytic area + slice, ranked largest-first. + by_class: dict[int, list[tuple[float, int, dict[str, Any]]]] = {} + for skey, shapes in slices.items(): + if not isinstance(shapes, list): + continue + try: + sidx = int(skey) + except (TypeError, ValueError): + continue + for shape in shapes: + cid = int(shape.get("classId", -1)) + by_class.setdefault(cid, []).append((_analytic_area(shape), sidx, shape)) + for cid in by_class: + by_class[cid].sort(key=lambda t: t[0], reverse=True) + + # Which slices do the selected top-N instances live on? Only render those. + selected: dict[int, list[tuple[int, dict[str, Any]]]] = {} + needed_slices: set[int] = set() + for cid, items in by_class.items(): + picks = [(sidx, shape) for area, sidx, shape in items[:per_class] if area > 0] + selected[cid] = picks + needed_slices.update(sidx for sidx, _ in picks) + + # Render each needed slice once, downsampled, recording the downsample step. + node = meta = None + if needed_slices: + try: + kind, source, server_uri = _parse_source_key(source_key) + node = arrays_mod.resolve_array(source, kind, server_uri) + meta = arrays_mod.array_shape_meta(node) + except Exception as exc: + logger.warning("guide_gen: cannot resolve array %s: %s", source_key, exc) + node = meta = None + + rgb_cache: dict[int, tuple[np.ndarray, int] | None] = {} + + def _slice_rgb(sidx: int) -> tuple[np.ndarray, int] | None: + """Downsampled RGB slice + integer downsample step, or None.""" + if sidx in rgb_cache: + return rgb_cache[sidx] + result: tuple[np.ndarray, int] | None = None + if node is not None and meta is not None: + try: + sc = max(0, min(sidx, meta["n_slices"] - 1)) + arr = np.asarray(arrays_mod.read_slice(node, meta, sc)) + h, w = arr.shape[:2] + step = max(1, int(math.ceil(max(h, w) / _RENDER_MAX_DIM))) + arr = arr[::step, ::step, ...] if arr.ndim == 3 else arr[::step, ::step] + if arr.ndim == 3 and arr.shape[2] in (3, 4): + rgb = _prepare_rgb(arr[:, :, :3]) + elif arr.ndim == 2: + rgb = _prepare_intensity(arr) + else: + rgb = None + if rgb is not None: + result = (rgb, step) + except Exception as exc: + logger.warning("guide_gen: cannot render slice %d: %s", sidx, exc) + rgb_cache[sidx] = result + return result + + color_by_class = {int(c.get("classId", -1)): _hex_rgb(str(c.get("color", "#1f77b4"))) for c in classes} + + out_classes: list[dict[str, Any]] = [] + for cls in classes: + cid = int(cls.get("classId", -1)) + color = color_by_class.get(cid, (31, 119, 180)) + crops: list[str] = [] + for sidx, shape in selected.get(cid, []): + got = _slice_rgb(sidx) + if got is None: + continue + rgb, step = got + gh, gw = rgb.shape[:2] + # Rasterize this one shape at the reduced resolution for the overlay. + try: + mask = shape_to_mask(_scale_shape(shape, 1.0 / step), gh, gw).astype(bool) + except Exception: + continue + url = _crop_with_overlay(rgb, mask, color, crop_px) + if url: + crops.append(url) + + out_classes.append( + { + "label": str(cls.get("label", "")), + "color": str(cls.get("color", "#1f77b4")), + "description": "", + "exampleCrops": crops, + } + ) + + return {"classes": out_classes, "notes": ""} diff --git a/backend/guides.py b/backend/guides.py new file mode 100644 index 0000000..9f998a6 --- /dev/null +++ b/backend/guides.py @@ -0,0 +1,70 @@ +"""Annotation-guide persistence for the Segmentation Annotation Studio. + +A *guide* is a project lead's curated description of each class — label, color, +a free-text description of what the class is and how it looks, and a few example +image crops. Annotators see the guide in the Reference tab and get its classes +offered as one-click suggestions, so everyone labels the same thing the same way. + +Guides are dataset-scoped: stored as JSON under ``$LOCAL_DATA_ROOT/.drafts/`` +keyed by the same *source_key* used for drafts, so opening a dataset surfaces its +guide automatically. A guide can additionally be exported as a shareable bundle +(see ``coco_export``) for hand-off to another machine. + +Writes are atomic (write-to-temp + rename), matching :mod:`drafts`. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) +_DRAFT_DIR = ( + Path(os.getenv("LOCAL_DATA_ROOT", "~/data")).expanduser().resolve() / ".drafts" +) + + +def _guide_path(source_key: str) -> Path: + """Return the JSON file path for *source_key*'s guide.""" + digest = hashlib.sha1(source_key.encode("utf-8")).hexdigest()[:16] + return _DRAFT_DIR / f"{digest}.guide.json" + + +def save_guide(source_key: str, guide: dict[str, Any]) -> dict[str, Any]: + """Persist the annotation guide for *source_key*. + + Args: + source_key: Arbitrary string identifying the image source. + guide: Guide document (``{classes: [...], notes?}``). + + Returns: + Dict with ``saved_at`` (ISO-8601 timestamp) and ``path`` (str). + """ + _DRAFT_DIR.mkdir(parents=True, exist_ok=True) + doc: dict[str, Any] = { + "source_key": source_key, + "saved_at": datetime.now(timezone.utc).isoformat(), + "guide": guide, + } + path = _guide_path(source_key) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(doc)) + tmp.replace(path) + return {"saved_at": doc["saved_at"], "path": str(path)} + + +def load_guide(source_key: str) -> dict[str, Any] | None: + """Return the saved guide document for *source_key*, or ``None``.""" + path = _guide_path(source_key) + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except json.JSONDecodeError as exc: + logger.warning("Corrupt guide %s: %s", path, exc) + return None diff --git a/backend/images.py b/backend/images.py index 8e1d8e4..1ee0723 100644 --- a/backend/images.py +++ b/backend/images.py @@ -43,6 +43,8 @@ def _sample_global_stats(node: Any, meta: dict[str, Any]) -> tuple[float, float] Returns: ``(vmin, vmax)`` floats across all sampled slices. """ + from concurrent.futures import ThreadPoolExecutor + from arrays import read_slice cache_key = ("global_stats", id(node)) @@ -50,14 +52,21 @@ def _sample_global_stats(node: Any, meta: dict[str, Any]) -> tuple[float, float] if cached is not None: return cached + # Sample fewer slices, read them concurrently, and spatially subsample each + # (every 4th pixel) — min/max are robust to this and the cost drops sharply. + # Result still spans the whole volume, so it matches the viewer's contrast. n = meta["n_slices"] - indices = list(range(0, n, max(1, n // 64)))[:64] - values: list[float] = [] - for i in indices: - sl = read_slice(node, meta, i).astype(np.float64) - values.extend([float(np.nanmin(sl)), float(np.nanmax(sl))]) + samples = 24 + indices = list(range(0, n, max(1, n // samples)))[:samples] or [0] + + def _minmax(i: int) -> tuple[float, float]: + sl = np.asarray(read_slice(node, meta, i))[::4, ::4].astype(np.float64) + return float(np.nanmin(sl)), float(np.nanmax(sl)) + + with ThreadPoolExecutor(max_workers=min(8, len(indices))) as ex: + pairs = list(ex.map(_minmax, indices)) - result: tuple[float, float] = (min(values), max(values)) + result: tuple[float, float] = (min(p[0] for p in pairs), max(p[1] for p in pairs)) _stats_cache.set(cache_key, result) return result diff --git a/backend/ingest.py b/backend/ingest.py new file mode 100644 index 0000000..3609e57 --- /dev/null +++ b/backend/ingest.py @@ -0,0 +1,256 @@ +"""Background ingest of uploaded image files into a Tiled container. + +The Connect page lets the user drag-and-drop a folder of images; those bytes +are streamed to temp files by the API layer, then this module's worker copies +each file into the connected Tiled server via ``write_array`` (works for remote +servers too — Tiled uploads the array over HTTP). + +Each file becomes its OWN node with metadata so the result is browsable in the +metadata-driven Browse UI (which needs fields with >=2 distinct values): + +* ``image_number`` — zero-padded STRING index parsed from the filename, so it + stays a string filter (see ``browse_helpers._typed_query_value``). +* ``size`` — ``"H x W"`` from the array shape. +* ``original_filename`` — the uploaded filename. +* ``sample_name`` — the filename stem (the node key); a friendlier label. +* ``description`` — optional user-supplied keyword(s) entered on Connect; the + same value is written to every node in the batch so a single drop is filterable + (these keys are made facet-eligible with one distinct value, see + ``browse_helpers._SINGLE_VALUE_FACET_RAW_KEYS``). +* ``keywords`` — the ``description`` split on commas into a LIST of tags. + Each tag becomes an individually-searchable Browse value (see + ``browse_helpers``) and is pre-created as an annotation class for the dataset + in the Annotate tab. + +Job state is held in-memory (lost on restart) — acceptable for a localhost tool. +""" + +from __future__ import annotations + +import logging +import re +import threading +import uuid +from pathlib import Path +from typing import Any + +import numpy as np + +from tiled_clients import api_key_for_uri, get_tiled_client + +logger = logging.getLogger("ingest") + +IMAGE_EXTS: frozenset[str] = frozenset({".tif", ".tiff", ".npy", ".png", ".jpg", ".jpeg"}) +# Trailing digits of the filename stem (e.g. "..._petiole22_00042" -> "00042"). +# Anchored at the end so a leading date like "20260221_..." is not mistaken for +# the frame index. +_FRAME_RE = re.compile(r"(\d+)$") +_MIN_PAD = 5 + +# job_id -> {state, total, done, failed, errors[], container_path, server_uri} +_jobs: dict[str, dict[str, Any]] = {} +_jobs_lock = threading.Lock() + + +def new_job(total: int, server_uri: str | None, container_path: str) -> str: + """Register a new ingest job and return its id.""" + jid = uuid.uuid4().hex + with _jobs_lock: + _jobs[jid] = { + "state": "pending", + "total": total, + "done": 0, + "failed": 0, + "errors": [], + "container_path": container_path, + "server_uri": server_uri, + } + return jid + + +def get_job(jid: str) -> dict | None: + """Return a snapshot copy of the job, or ``None`` if unknown.""" + with _jobs_lock: + job = _jobs.get(jid) + return dict(job) if job else None + + +def _update(jid: str, **kw: Any) -> None: + with _jobs_lock: + if jid in _jobs: + _jobs[jid].update(kw) + + +def _bump(jid: str, *, done: int = 0, failed: int = 0, error: str | None = None) -> None: + with _jobs_lock: + job = _jobs.get(jid) + if not job: + return + job["done"] += done + job["failed"] += failed + if error: + job["errors"].append(error) + + +def _read_array(path: Path) -> np.ndarray: + """Read a supported image file into a NumPy array.""" + import tifffile + from PIL import Image as PILImage + + suffix = path.suffix.lower() + if suffix in (".tif", ".tiff"): + return tifffile.imread(str(path)) + if suffix == ".npy": + return np.load(str(path), allow_pickle=False) + if suffix in (".png", ".jpg", ".jpeg"): + return np.asarray(PILImage.open(str(path))) + raise ValueError(f"unsupported extension {suffix!r}") + + +def _ensure_container(client: Any, parts: list[str]) -> Any: + """Navigate to ``client[parts...]``, creating containers as needed.""" + node = client + for key in parts: + try: + node = node[key] + except Exception: # noqa: BLE001 — KeyError or transport error → create + node = node.create_container(key=key, metadata={}) + return node + + +def _size_str(arr: np.ndarray) -> str: + """Return pixel dimensions as ``"H x W"`` (first two dims).""" + return " x ".join(str(d) for d in arr.shape[:2]) + + +def _pad_width(filenames: list[str]) -> int: + """Return a consistent zero-pad width for image_number across the batch.""" + widths = [ + len(m.group(1)) + for m in (_FRAME_RE.search(Path(f).stem) for f in filenames) + if m + ] + return max([*widths, _MIN_PAD]) if widths else _MIN_PAD + + +def _image_number(stem: str, width: int, fallback_index: int) -> str: + m = _FRAME_RE.search(stem) + if m: + return m.group(1).zfill(width) + return str(fallback_index).zfill(width) + + +def parse_keywords(description: str) -> list[str]: + """Split a comma-separated ``description`` into a de-duplicated tag list. + + Each tag doubles as (a) an annotation class pre-created for the dataset in + the Annotate tab and (b) an individually-searchable value in Browse. Order + is preserved and case-insensitive duplicates are dropped. + + Args: + description: Raw comma-separated string entered on the Connect page. + + Returns: + Ordered list of non-empty, de-duplicated tag strings. + """ + tags: list[str] = [] + seen: set[str] = set() + for part in (description or "").split(","): + tag = part.strip() + if not tag: + continue + key = tag.lower() + if key in seen: + continue + seen.add(key) + tags.append(tag) + return tags + + +def run_ingest_job( + jid: str, + server_uri: str | None, + container_path: str, + temp_files: list[tuple[str, Path]], + description: str = "", +) -> None: + """Copy each temp file into the target Tiled container. + + Args: + jid: Job id from :func:`new_job`. + server_uri: Connected Tiled server URI. + container_path: Slash-separated target container (e.g. ``browse/testset``). + temp_files: list of ``(original_filename, temp_path)``. + description: Optional user-supplied keyword(s) stored on every node so the + batch is identifiable/filterable in Browse (empty string → omitted). + """ + description = (description or "").strip() + keywords = parse_keywords(description) + _update(jid, state="running") + try: + api_key = api_key_for_uri(server_uri) + client = get_tiled_client(server_uri, api_key) + parts = [p for p in container_path.strip("/").split("/") if p] + target = _ensure_container(client, parts) + width = _pad_width([orig for orig, _ in temp_files]) + + # Describe the dataset at the CONTAINER level too. Browse treats each + # child of the browse root as a "sample" and reads its container + # metadata (not the per-array metadata written below), so this is what + # makes the upload identifiable/filterable there. + container_meta: dict[str, Any] = { + "sample_name": parts[-1] if parts else "", + "n_images": len(temp_files), + } + if description: + container_meta["description"] = description + if keywords: + # A LIST so Browse can offer each tag as its own filter value and + # Annotate can pre-create one class per tag. + container_meta["keywords"] = keywords + try: + target.update_metadata(metadata=container_meta) + except Exception as exc: # noqa: BLE001 — best-effort; per-array meta still set + logger.warning("could not set container metadata on %s: %s", container_path, exc) + + for idx, (orig_name, tmp) in enumerate(temp_files): + try: + arr = _read_array(tmp) + stem = Path(orig_name).stem + meta = { + "image_number": _image_number(stem, width, idx), + "size": _size_str(arr), + "original_filename": orig_name, + "sample_name": stem, + } + if description: + meta["description"] = description + if keywords: + meta["keywords"] = keywords + if arr.ndim == 3 and arr.shape[2] in (3, 4): + dims = ["y", "x", "channel"] + elif arr.ndim == 2: + dims = ["y", "x"] + else: + dims = None + target.write_array(arr, key=stem, metadata=meta, dims=dims) + _bump(jid, done=1) + except Exception as exc: # noqa: BLE001 — isolate per-file failures + logger.warning("ingest %s failed: %s", orig_name, exc) + _bump(jid, failed=1, error=f"{orig_name}: {exc}") + finally: + try: + tmp.unlink(missing_ok=True) + except Exception: # noqa: BLE001 + pass + _update(jid, state="done") + except Exception as exc: # noqa: BLE001 — fatal (e.g. cannot reach server) + logger.error("ingest job %s fatal: %s", jid, exc) + _bump(jid, error=str(exc)) + _update(jid, state="error") + finally: + for _, tmp in temp_files: + try: + tmp.unlink(missing_ok=True) + except Exception: # noqa: BLE001 + pass diff --git a/backend/local_fs.py b/backend/local_fs.py index 79a2939..4845287 100644 --- a/backend/local_fs.py +++ b/backend/local_fs.py @@ -1,4 +1,4 @@ -"""Sandboxed local filesystem access for the SAM3 Annotation Studio. +"""Sandboxed local filesystem access for the Segmentation Annotation Studio. All paths are resolved relative to ``LOCAL_DATA_ROOT`` (environment variable). Any attempt to escape the root via path traversal (e.g. ``../../etc/passwd``) @@ -13,8 +13,6 @@ from __future__ import annotations -IMAGE_EXTS: frozenset[str] = frozenset({".tif", ".tiff", ".npy", ".png", ".jpg", ".jpeg"}) - import logging import os from pathlib import Path @@ -23,35 +21,79 @@ import numpy as np from fastapi import HTTPException +IMAGE_EXTS: frozenset[str] = frozenset({".tif", ".tiff", ".npy", ".png", ".jpg", ".jpeg"}) + logger = logging.getLogger(__name__) -_ROOT: Path = Path(os.getenv("LOCAL_DATA_ROOT", "~/data")).expanduser().resolve() +_DEFAULT_ROOT: Path = Path(os.getenv("LOCAL_DATA_ROOT", "~/data")).expanduser().resolve() -def _safe(rel: str) -> Path: - """Resolve *rel* under ``LOCAL_DATA_ROOT``; raise 403 on traversal. +def _resolve_root(root: str | None) -> Path: + """Return the granted browse root as an absolute, resolved Path. Args: - rel: Relative path string supplied by the caller. + root: Absolute path the user granted access to, or ``None`` to fall + back to ``LOCAL_DATA_ROOT``. Returns: - Absolute :class:`pathlib.Path` guaranteed to be inside ``_ROOT``. + Absolute :class:`pathlib.Path`. + """ + if root: + return Path(root).expanduser().resolve() + return _DEFAULT_ROOT + + +def _within(base: Path, resolved: Path) -> bool: + """Return True if *resolved* is *base* itself or a descendant of it. + + Parent containment avoids the string-prefix footgun where ``/data2`` would + slip past a ``/data`` root. + """ + return base == resolved or base in resolved.parents + + +def _safe(rel: str, root: str | None = None) -> Path: + """Resolve *rel* to an absolute path with traversal protection. + + Three cases: + + * *root* given → join under the granted root and enforce containment + (the sandboxed Browse flow). + * *root* omitted and *rel* absolute → an explicit file identity (used when + re-opening an already-chosen file by its absolute path, e.g. annotate / + thumbnail rendering); returned as-is with no sandbox. + * *root* omitted and *rel* relative → legacy behaviour under + ``LOCAL_DATA_ROOT`` with containment enforced. Raises: - HTTPException: 403 if the resolved path escapes ``_ROOT``. + HTTPException: 403 if a sandboxed path escapes its root. """ - resolved = (_ROOT / rel).resolve() - if not str(resolved).startswith(str(_ROOT)): - logger.warning("Path traversal attempt: %r", rel) + if root: + base = _resolve_root(root) + resolved = (base / rel).resolve() + if not _within(base, resolved): + logger.warning("Path traversal attempt: rel=%r root=%r", rel, root) + raise HTTPException(403, "Path traversal not allowed") + return resolved + + candidate = Path(rel).expanduser() + if candidate.is_absolute(): + return candidate.resolve() + + base = _DEFAULT_ROOT + resolved = (base / rel).resolve() + if not _within(base, resolved): + logger.warning("Path traversal attempt: rel=%r", rel) raise HTTPException(403, "Path traversal not allowed") return resolved -def list_dir(rel: str = "") -> list[dict[str, Any]]: - """List directory entries under ``LOCAL_DATA_ROOT``. +def list_dir(rel: str = "", root: str | None = None) -> list[dict[str, Any]]: + """List directory entries under the granted *root*. Args: rel: Relative path to the directory to list (empty → root). + root: Granted absolute root (defaults to ``LOCAL_DATA_ROOT``). Returns: List of dicts with keys ``name``, ``path``, ``is_dir``, ``size``. @@ -59,13 +101,14 @@ def list_dir(rel: str = "") -> list[dict[str, Any]]: Raises: HTTPException: 404 if path does not exist; 400 if not a directory. """ - path = _safe(rel) + base = _resolve_root(root) + path = _safe(rel, root) if not path.exists(): # A missing root directory is treated as empty rather than an error, # so the file browser can still render (and the user can fix the - # LOCAL_DATA_ROOT configuration) instead of seeing a 404. + # granted path) instead of seeing a 404. if rel in ("", "."): - logger.warning("LOCAL_DATA_ROOT does not exist: %s", _ROOT) + logger.warning("Browse root does not exist: %s", base) return [] raise HTTPException(404, f"Path not found: {rel!r}") if not path.is_dir(): @@ -75,7 +118,7 @@ def list_dir(rel: str = "") -> list[dict[str, Any]]: entries.append( { "name": child.name, - "path": str(child.relative_to(_ROOT)), + "path": str(child.relative_to(base)), "is_dir": child.is_dir(), "size": child.stat().st_size if child.is_file() else None, } @@ -83,26 +126,28 @@ def list_dir(rel: str = "") -> list[dict[str, Any]]: return entries -def count_image_files(rel: str) -> int: - """Return a recursive count of image files under ``LOCAL_DATA_ROOT/rel``. +def count_image_files(rel: str, root: str | None = None) -> int: + """Return a recursive count of image files under ``/rel``. Args: - rel: Relative path to a directory under ``LOCAL_DATA_ROOT``. + rel: Relative path to a directory under the granted root. + root: Granted absolute root (defaults to ``LOCAL_DATA_ROOT``). Returns: Number of files with a supported image extension. """ - path = _safe(rel) + path = _safe(rel, root) if not path.exists() or not path.is_dir(): return 0 return sum(1 for f in path.rglob("*") if f.is_file() and f.suffix.lower() in IMAGE_EXTS) -def list_image_files(rel: str) -> list[dict[str, Any]]: - """Return a flat, sorted list of image files under ``LOCAL_DATA_ROOT/rel``. +def list_image_files(rel: str, root: str | None = None) -> list[dict[str, Any]]: + """Return a flat, sorted list of image files under ``/rel``. Args: - rel: Relative path to a directory under ``LOCAL_DATA_ROOT``. + rel: Relative path to a directory under the granted root. + root: Granted absolute root (defaults to ``LOCAL_DATA_ROOT``). Returns: List of ``{"name", "path"}`` dicts sorted by path. @@ -110,7 +155,8 @@ def list_image_files(rel: str) -> list[dict[str, Any]]: Raises: HTTPException: 404 if path does not exist; 400 if not a directory. """ - path = _safe(rel) + base = _resolve_root(root) + path = _safe(rel, root) if not path.exists(): raise HTTPException(404, f"Path not found: {rel!r}") if not path.is_dir(): @@ -119,7 +165,7 @@ def list_image_files(rel: str) -> list[dict[str, Any]]: ( { "name": f.name, - "path": str(f.relative_to(_ROOT)), + "path": str(f.relative_to(base)), } for f in path.rglob("*") if f.is_file() and f.suffix.lower() in IMAGE_EXTS @@ -129,14 +175,15 @@ def list_image_files(rel: str) -> list[dict[str, Any]]: return entries -def open_array(rel: str) -> Any: +def open_array(rel: str, root: str | None = None) -> Any: """Open a local array file and return a lazily-sliceable array. Supported extensions: ``.tif``, ``.tiff``, ``.npy``, ``.png``, ``.jpg``, ``.jpeg``. Args: - rel: Relative path to the file under ``LOCAL_DATA_ROOT``. + rel: Relative path to the file under the granted root. + root: Granted absolute root (defaults to ``LOCAL_DATA_ROOT``). Returns: A memory-mapped or fully-loaded NumPy array. @@ -148,7 +195,7 @@ def open_array(rel: str) -> Any: import tifffile from PIL import Image as PILImage - path = _safe(rel) + path = _safe(rel, root) if not path.exists(): raise HTTPException(404, f"File not found: {rel!r}") suffix = path.suffix.lower() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5658ed0..b3ffeed 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,8 +8,15 @@ version = "0.1.0" requires-python = ">=3.11" dependencies = [ "fastapi>=0.115", + # Required by FastAPI for multipart form data (the /api/ingest/upload route uses + # Form/UploadFile). Not pulled in transitively, so it must be declared explicitly. + "python-multipart>=0.0.9", "uvicorn[standard]>=0.30", - "tiled[all]>=0.1", + # Client only — the app talks to an EXTERNAL Tiled (tiled.client / tiled.queries). + # The Tiled *server* is not a runtime dependency and is intentionally not shipped + # in the Docker image. Local dev runs its own Tiled server via start_all.sh, which + # installs tiled[all] into .venv independently of this file. + "tiled[client]>=0.1", "numpy>=1.26", "pillow>=10.3", "python-dotenv>=1.0", @@ -27,6 +34,12 @@ test = ["pytest>=8", "pytest-asyncio>=0.23", "httpx>=0.27"] [tool.setuptools] py-modules = [] +[tool.isort] +# Align isort's wrap width with flake8's --max-line-length=120 so import ordering +# doesn't rewrap everything to 79 columns. flake8-isort reads this config. +profile = "black" +line_length = 120 + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/backend/schemas.py b/backend/schemas.py index 5a5d099..a41d403 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,4 +1,4 @@ -"""Pydantic models for the SAM3 Annotation Studio API. +"""Pydantic models for the Segmentation Annotation Studio API. Shapes ------ @@ -196,8 +196,18 @@ class ExportRequest(BaseModel): source: str = "" server_uri: str | None = None sources: list[ExportSourceItem] = Field(default_factory=list) + # Who produced this annotation. Stamped into the output folder name, COCO + # info, and manifest.json so downloads are self-identifying for external + # inter-annotator-agreement analysis. + annotator: str = "" mode: Literal["fail", "overwrite", "merge"] = "merge" + # Export target format: COCO-for-SAM3 (default) or DINOv3/Lightly semantic-seg + # (per-split images/ + masks/ with matching filename stems + classes.json). + format: Literal["coco_sam3", "lightly_dinov3"] = "coco_sam3" dry_run: bool = False + # Include the (costly) polygon copy in COCO segmentation_poly. RLE is always + # written and is exact; polygons are opt-in for external viewers. + include_polygons: bool = False render: RenderOpts = Field(default_factory=RenderOpts) classes: list[AnnotationClass] = Field(default_factory=list) slices: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) @@ -240,6 +250,46 @@ class SaveVersionRequest(BaseModel): thumbnail_base64: str | None = None +class MeasureRequest(BaseModel): + """Request body for per-region intensity measurement. + + Attributes: + slice_index: Zero-based slice to sample. + shapes: Serialised shape dicts; their union defines the measured region. + """ + + slice_index: int = 0 + shapes: list[dict[str, Any]] = Field(default_factory=list) + + +class GuideClass(BaseModel): + """One class entry in an annotation guide. + + Attributes: + label: Human-readable class name (matches an annotation class label). + color: CSS colour string used for this class. + description: Free-text guidance on what the class is and how it looks. + exampleCrops: Base64 data-URL PNG crops illustrating the class. + """ + + label: str + color: str + description: str = "" + exampleCrops: list[str] = Field(default_factory=list) + + +class GuidePayload(BaseModel): + """A project lead's annotation guide for a dataset. + + Attributes: + classes: Ordered guide entries, one per class. + notes: Optional overall notes for the annotation task. + """ + + classes: list[GuideClass] = Field(default_factory=list) + notes: str = "" + + class ImageMeta(BaseModel): """Shape and dtype metadata for an opened image source. @@ -250,6 +300,8 @@ class ImageMeta(BaseModel): dtype: NumPy dtype string (e.g. ``"float32"``). is_rgb: ``True`` if the array has a colour channel dimension. value_range: ``[min, max]`` of the first slice. + keywords: Dataset tags stored at ingest; each is pre-created as an + annotation class in the Annotate tab. """ n_slices: int @@ -258,3 +310,4 @@ class ImageMeta(BaseModel): dtype: str is_rgb: bool value_range: list[float] + keywords: list[str] = Field(default_factory=list) diff --git a/backend/scripts/add_sample_metadata.py b/backend/scripts/add_sample_metadata.py new file mode 100644 index 0000000..00cfe6e --- /dev/null +++ b/backend/scripts/add_sample_metadata.py @@ -0,0 +1,113 @@ +"""Add browsable metadata to each TIFF sample node. + +The Browse UI is metadata-driven: it can only list/filter samples that have a +metadata field with >=2 distinct values. Freshly-registered TIFF nodes have +empty metadata ({}), so the browser shows nothing to add as a column. + +This script writes per node: + image_number : frame index parsed from the trailing digits of the key, + zero-padded to match the filenames (e.g. "00000".."00689"). + size : pixel dimensions from the node's array structure, + e.g. "2560 x 2560". + +Padding keeps the browser column in natural order (lexical sort == numeric). + +Run from the REPO ROOT: + python backend/scripts/add_sample_metadata.py +Refresh the Browse UI (or restart Tiled) afterwards. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +CATALOG_DB = REPO_ROOT / ".tiled" / "catalog.db" + +# Trailing digits of the node key, e.g. "..._petiole22_00042" -> "00042". +_FRAME_RE = re.compile(r"(\d+)$") + + +def main(dry_run: bool = False) -> None: + if not CATALOG_DB.exists(): + print(f"ERROR: catalog.db not found at {CATALOG_DB}") + sys.exit(1) + + conn = sqlite3.connect(str(CATALOG_DB)) + conn.row_factory = sqlite3.Row + c = conn.cursor() + + # Sample nodes live directly under the root (parent=0). Join through the + # data_source to the structure so we can read each node's pixel shape. + rows = c.execute( + """ + SELECT n.id, n.key, n.metadata, s.structure + FROM nodes n + LEFT JOIN data_sources ds ON ds.node_id = n.id + LEFT JOIN structures s ON s.id = ds.structure_id + WHERE n.structure_family = 'array' AND n.parent = 0 + """ + ).fetchall() + + print(f"Found {len(rows)} sample nodes.") + if not rows: + print("Nothing to update.") + conn.close() + return + + # Use a consistent pad width across the whole set. + width = max( + (len(m.group(1)) for m in (_FRAME_RE.search(r["key"]) for r in rows) if m), + default=1, + ) + + updated = 0 + for row in rows: + m = _FRAME_RE.search(row["key"]) + if not m: + continue + new_fields = {"image_number": m.group(1).zfill(width)} + + # Pixel dimensions from the array structure, e.g. "2560 x 2560". + if row["structure"]: + try: + shape = json.loads(row["structure"]).get("shape") or [] + if shape: + new_fields["size"] = " x ".join(str(d) for d in shape) + except (TypeError, json.JSONDecodeError): + pass + + try: + existing = json.loads(row["metadata"]) if row["metadata"] else {} + except (TypeError, json.JSONDecodeError): + existing = {} + + merged = {**existing, **new_fields} + if merged == existing: + continue + + if dry_run: + if updated < 3: + print(f" {row['key']} -> {new_fields}") + else: + c.execute( + "UPDATE nodes SET metadata = ? WHERE id = ?", + (json.dumps(merged), row["id"]), + ) + updated += 1 + + if dry_run: + print(f"Would update {updated} node(s) (dry run).") + else: + conn.commit() + print(f"Set image_number on {updated} node(s).") + print("Refresh the Browse UI (or restart Tiled) to see the 'image_number' column.") + conn.close() + + +if __name__ == "__main__": + main(dry_run="--dry-run" in sys.argv) diff --git a/backend/scripts/reingest_tiffs_as_individual.py b/backend/scripts/reingest_tiffs_as_individual.py new file mode 100644 index 0000000..c191cf2 --- /dev/null +++ b/backend/scripts/reingest_tiffs_as_individual.py @@ -0,0 +1,165 @@ +"""Re-ingest stacked TIFF array as 690 individual sample nodes. + +The 690 TIFFs were previously registered as one stacked multi-chunk array. +This script splits them back into individual nodes so the Browse UI can +treat each TIFF as a separate sample. + +Run from the REPO ROOT (not from backend/scripts): + python backend/scripts/reingest_tiffs_as_individual.py + +Tiled must NOT be running when you run this (it modifies the catalog DB +directly). Restart Tiled after running. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import sys +from pathlib import Path + +try: + import canonicaljson +except ImportError: + print("ERROR: canonicaljson not installed. Run: pip install canonicaljson") + sys.exit(1) + +REPO_ROOT = Path(__file__).resolve().parents[2] +CATALOG_DB = REPO_ROOT / ".tiled" / "catalog.db" + +STACKED_KEY = "20260221_135217_petiole22_" + +# Structure for a single 2560×2560 float32 TIFF frame. +SINGLE_TIFF_STRUCTURE = { + "data_type": {"endianness": "little", "itemsize": 4, "kind": "f", "dt_units": None}, + "chunks": [[2560], [2560]], + "shape": [2560, 2560], + "dims": None, + "resizable": False, +} + + +def compute_structure_id(structure: dict) -> str: + canonical = canonicaljson.encode_canonical_json(structure) + return hashlib.md5(canonical).hexdigest() + + +def main(dry_run: bool = False) -> None: + if not CATALOG_DB.exists(): + print(f"ERROR: catalog.db not found at {CATALOG_DB}") + sys.exit(1) + + conn = sqlite3.connect(str(CATALOG_DB)) + conn.execute("PRAGMA foreign_keys = ON") + conn.row_factory = sqlite3.Row + c = conn.cursor() + + # --- find the stacked node --- + c.execute("SELECT id, parent FROM nodes WHERE key = ?", (STACKED_KEY,)) + stacked_row = c.fetchone() + if stacked_row is None: + print(f"No node with key '{STACKED_KEY}' found — nothing to do.") + conn.close() + return + + stacked_node_id = stacked_row["id"] + root_node_id = stacked_row["parent"] + print(f"Found stacked node id={stacked_node_id}, parent={root_node_id}") + + # --- collect assets (TIFFs) in order --- + c.execute( + """ + SELECT a.id AS asset_id, a.data_uri, dsaa.num + FROM data_source_asset_association dsaa + JOIN data_sources ds ON dsaa.data_source_id = ds.id + JOIN assets a ON dsaa.asset_id = a.id + WHERE ds.node_id = ? + ORDER BY dsaa.num + """, + (stacked_node_id,), + ) + tiff_rows = c.fetchall() + print(f"Found {len(tiff_rows)} TIFF assets to re-register.") + + if len(tiff_rows) == 0: + print("No assets found for the stacked node. Aborting.") + conn.close() + return + + if dry_run: + for r in tiff_rows[:5]: + print(f" [{r['num']}] {r['data_uri']}") + print(" ... (dry run, not modifying DB)") + conn.close() + return + + struct_id = compute_structure_id(SINGLE_TIFF_STRUCTURE) + struct_json = json.dumps(SINGLE_TIFF_STRUCTURE) + + # --- delete stacked node (cascades to data_sources and associations) --- + print(f"Deleting stacked node id={stacked_node_id} ...") + c.execute("DELETE FROM nodes WHERE id = ?", (stacked_node_id,)) + + # --- ensure single-TIFF structure exists --- + c.execute("SELECT id FROM structures WHERE id = ?", (struct_id,)) + if c.fetchone() is None: + c.execute("INSERT INTO structures (id, structure) VALUES (?, ?)", (struct_id, struct_json)) + print(f"Inserted structure id={struct_id}") + + # --- insert 690 individual nodes --- + print("Inserting individual nodes ...") + inserted = 0 + for row in tiff_rows: + uri: str = row["data_uri"] + asset_id: int = row["asset_id"] + + # Derive key from the filename stem + fname = uri.split("/")[-1] + stem = fname.rsplit(".", 1)[0] if "." in fname else fname + node_key = stem + + # Insert node + c.execute( + """ + INSERT INTO nodes (parent, key, structure_family, metadata, specs, access_blob) + VALUES (?, ?, 'array', '{}', '[]', '{}') + """, + (root_node_id, node_key), + ) + node_id = c.lastrowid + + # Insert data_source + c.execute( + """ + INSERT INTO data_sources + (node_id, structure_id, mimetype, parameters, properties, management, structure_family) + VALUES (?, ?, 'image/tiff', '{}', '{}', 'external', 'array') + """, + (node_id, struct_id), + ) + ds_id = c.lastrowid + + # Insert association (single file → parameter=data_uri, num=NULL) + c.execute( + """ + INSERT INTO data_source_asset_association + (data_source_id, asset_id, parameter, num) + VALUES (?, ?, 'data_uri', NULL) + """, + (ds_id, asset_id), + ) + + inserted += 1 + if inserted % 100 == 0: + print(f" {inserted}/{len(tiff_rows)} ...") + + conn.commit() + print(f"Done. Inserted {inserted} individual sample nodes.") + print("Restart Tiled to pick up the changes.") + conn.close() + + +if __name__ == "__main__": + dry_run = "--dry-run" in sys.argv + main(dry_run=dry_run) diff --git a/backend/scripts/repair_catalog_paths.py b/backend/scripts/repair_catalog_paths.py index 08ebbd9..14a9905 100644 --- a/backend/scripts/repair_catalog_paths.py +++ b/backend/scripts/repair_catalog_paths.py @@ -29,19 +29,33 @@ def repair(dry_run: bool = False) -> int: conn = sqlite3.connect(str(CATALOG_DB)) c = conn.cursor() + + # Only repair managed assets (those stored under .tiled/data/). + # External assets (management='external') live at user-supplied paths and + # should never be rewritten here — they're intentionally outside the repo. + managed_asset_ids: set[int] = set( + row[0] + for row in c.execute( + """ + SELECT DISTINCT a.id + FROM assets a + JOIN data_source_asset_association dsaa ON dsaa.asset_id = a.id + JOIN data_sources ds ON ds.id = dsaa.data_source_id + WHERE ds.management != 'external' + """ + ).fetchall() + ) + rows = c.execute("SELECT id, data_uri FROM assets").fetchall() updated = 0 for row_id, uri in rows: + if row_id not in managed_asset_ids: + continue # external asset — leave it alone if not uri.startswith("file://localhost"): continue # Extract the path portion after the scheme+host file_path = Path(uri[len("file://localhost"):]) - # The part we care about is everything from .tiled/data/ onward - try: - rel = file_path.relative_to(file_path.parts[0] + "/.tiled/data" if False else "") - except ValueError: - rel = None # Find .tiled/data/ anchor anywhere in the path parts = file_path.parts @@ -52,7 +66,7 @@ def repair(dry_run: bool = False) -> int: ) rel_parts = parts[idx + 1:] except StopIteration: - print(f" [{row_id}] cannot parse URI, skipping: {uri}") + print(f" [{row_id}] cannot parse managed URI, skipping: {uri}") continue new_path = DATA_DIR.joinpath(*rel_parts) diff --git a/backend/tests/test_coco_export.py b/backend/tests/test_coco_export.py index 9b644c3..72c4c05 100644 --- a/backend/tests/test_coco_export.py +++ b/backend/tests/test_coco_export.py @@ -2,8 +2,9 @@ from __future__ import annotations import math -import pytest + import numpy as np +import pytest def test_brush_width_parity() -> None: @@ -42,9 +43,10 @@ def test_brush_width_parity() -> None: def test_brush_erase_ring() -> None: """Paint a disk, erase its center -> ring. Area should match expected annulus.""" - from coco_export import _brush_mask import pycocotools.mask as mask_utils + from coco_export import _brush_mask + h, w = 100, 100 cx, cy = 50.0, 50.0 paint_r = 20.0 @@ -103,9 +105,10 @@ def test_rect_normalization_all_directions() -> None: def test_rle_json_serializable() -> None: """RLE counts must be ascii-decoded so the annotation is JSON-serializable.""" - from coco_export import _rect_mask, mask_to_coco_ann import json + from coco_export import _rect_mask, mask_to_coco_ann + h, w = 50, 50 mask = _rect_mask(5, 5, 10, 10, h, w) ann = mask_to_coco_ann(mask, ann_id=1, image_id=1, category_id=1) @@ -115,13 +118,120 @@ def test_rle_json_serializable() -> None: assert isinstance(restored["segmentation"]["counts"], str) +def test_mask_export_writes_semantic_and_per_class(tmp_path) -> None: + """build_export_plan emits label-map + per-class masks; write_coco_split lays them out.""" + import numpy as np + from PIL import Image + + from coco_export import build_export_plan, write_coco_split + from schemas import AnnotationClass, ExportRequest, RenderOpts + + h, w = 40, 40 + poly = {"id": "s1", "kind": "polygon", "classId": 7, "points": [5, 5, 25, 5, 25, 25, 5, 25]} + payload = ExportRequest( + kind="local", source="vol", server_uri=None, + slices={"0": [poly]}, split_by_slice={"0": "train"}, negative_slices=[], + classes=[AnnotationClass(classId=7, label="air", color="#ff0000")], + render=RenderOpts(norm="slice"), + auto_split={"ratios": [1, 0, 0], "seed": 1}, + ) + meta = {"height": h, "width": w, "n_slices": 1} + plan = build_export_plan( + object(), payload, + render_slice_fn=lambda arr, opts, gr: np.zeros((h, w, 3), dtype=np.uint8), + array_shape_meta_fn=lambda n: meta, + read_slice_fn=lambda n, m, i: np.zeros((h, w), dtype=np.uint8), + sample_global_stats_fn=lambda n, m: (0.0, 1.0), + ) + img = plan["splits"]["train"]["images"][0] + fname = img["file_name"] + assert img.get("label_png_bytes") + assert "air" in img["class_masks"] + + # Polygons are opt-in: default off → segmentation_poly empty, RLE always set. + ann0 = plan["splits"]["train"]["annotations"][0] + assert ann0["segmentation_poly"] == [] + assert isinstance(ann0["segmentation"]["counts"], str) and ann0["segmentation"]["counts"] + + import zipfile + zip_path = tmp_path / "ds.zip" + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zf: + write_coco_split( + tmp_path / "train", + images=plan["splits"]["train"]["images"], + categories=plan["categories"], + annotations=plan["splits"]["train"]["annotations"], + mode="overwrite", info=plan["info"], + zf=zf, arc_prefix="train/", + ) + assert (tmp_path / "train" / fname).exists() + assert (tmp_path / "train" / "masks" / "semantic" / fname).exists() + assert (tmp_path / "train" / "masks" / "air" / fname).exists() + assert (tmp_path / "train" / "masks" / "legend.json").exists() + sem = np.array(Image.open(tmp_path / "train" / "masks" / "semantic" / fname)) + assert int(sem.max()) == 1 # single class → category id 1 in the label map + + # The zip mirrors the tree (STORED) and carries images, masks, and COCO. + with zipfile.ZipFile(zip_path) as zf: + names = set(zf.namelist()) + assert f"train/{fname}" in names + assert f"train/masks/semantic/{fname}" in names + assert f"train/masks/air/{fname}" in names + assert "train/_annotations.coco.json" in names + assert all(zi.compress_type == zipfile.ZIP_STORED for zi in zf.infolist()) + + +def test_export_polygons_opt_in() -> None: + """include_polygons=True populates segmentation_poly (reusing polygon points).""" + import numpy as np + + from coco_export import build_export_plan + from schemas import AnnotationClass, ExportRequest, RenderOpts + + h, w = 30, 30 + poly = {"id": "s1", "kind": "polygon", "classId": 1, "points": [4, 4, 20, 4, 20, 20, 4, 20]} + payload = ExportRequest( + kind="local", source="vol", slices={"0": [poly]}, split_by_slice={"0": "train"}, + classes=[AnnotationClass(classId=1, label="air", color="#fff")], + render=RenderOpts(norm="slice"), include_polygons=True, + ) + plan = build_export_plan( + object(), payload, + render_slice_fn=lambda a, o, g: np.zeros((h, w, 3), np.uint8), + array_shape_meta_fn=lambda n: {"height": h, "width": w, "n_slices": 1}, + read_slice_fn=lambda n, m, i: np.zeros((h, w), np.uint8), + sample_global_stats_fn=lambda n, m: (0.0, 1.0), + include_polygons=True, + ) + ann0 = plan["splits"]["train"]["annotations"][0] + assert ann0["segmentation_poly"] == [poly["points"]] # reused, not re-traced + + def test_zero_area_polygon_skipped() -> None: """A 1-px degenerate polygon produces zero area.""" - from coco_export import _polygon_mask import pycocotools.mask as mask_utils + from coco_export import _polygon_mask + h, w = 50, 50 mask = _polygon_mask([10.0, 10.0, 10.0, 10.0], h, w) rle = mask_utils.encode(np.asfortranarray(mask.astype(np.uint8))) area = float(mask_utils.area(rle)) assert area < 2, "Degenerate polygon should produce near-zero area" + + +def test_polygon_hole_carved_out() -> None: + """A polygon with an inner ring (invert-shape) leaves the hole empty.""" + from coco_export import shape_to_mask + + h, w = 40, 40 + shape = { + "id": "inv", "classId": 1, "kind": "polygon", + "points": [0, 0, 40, 0, 40, 40, 0, 40], + "holes": [[10, 10, 30, 10, 30, 30, 10, 30]], + } + mask = shape_to_mask(shape, h, w) + assert not mask[20, 20], "hole interior must be empty" + assert mask[2, 2], "frame corner must be filled" + # Area ~= 40*40 - 20*20 = 1200, allow boundary slack. + assert 1050 < int(mask.sum()) < 1350 diff --git a/backend/tests/test_lightly_export.py b/backend/tests/test_lightly_export.py new file mode 100644 index 0000000..c317cb4 --- /dev/null +++ b/backend/tests/test_lightly_export.py @@ -0,0 +1,73 @@ +"""Tests for the DINOv3 / Lightly semantic-segmentation export writer.""" +from __future__ import annotations + +import io +import json + +import numpy as np +from PIL import Image as PILImage + + +def _mk_image(file_name: str, label: np.ndarray) -> dict: + """A build_export_plan-style image dict with png + label-map payloads.""" + from coco_export import _encode_png + + h, w = label.shape + rgb = np.zeros((h, w, 3), dtype=np.uint8) + buf = io.BytesIO() + PILImage.fromarray(rgb).save(buf, format="PNG") + return { + "file_name": file_name, + "height": h, + "width": w, + "png_bytes": buf.getvalue(), + "label_png_bytes": _encode_png(label.astype(np.uint8)), + } + + +def test_write_lightly_split_matching_stems(tmp_path) -> None: + """images/.png and masks/.png share the exact filename.""" + from coco_export import write_lightly_split + + label = np.zeros((16, 16), dtype=np.uint8) + label[4:8, 4:8] = 1 + label[10:14, 10:14] = 2 + images = [_mk_image("sample_0000.png", label), _mk_image("sample_0001.png", label)] + + summary = write_lightly_split(tmp_path / "train", images) + assert summary["n_images"] == 2 + + img_dir = tmp_path / "train" / "images" + mask_dir = tmp_path / "train" / "masks" + imgs = sorted(p.name for p in img_dir.iterdir()) + masks = sorted(p.name for p in mask_dir.iterdir()) + assert imgs == masks == ["sample_0000.png", "sample_0001.png"] + + +def test_write_lightly_split_mask_is_index_labelmap(tmp_path) -> None: + """Mask PNGs are single-channel with pixel value == class index.""" + from coco_export import write_lightly_split + + label = np.zeros((16, 16), dtype=np.uint8) + label[4:8, 4:8] = 1 + label[10:14, 10:14] = 2 + write_lightly_split(tmp_path / "val", [_mk_image("s_0000.png", label)]) + + m = PILImage.open(tmp_path / "val" / "masks" / "s_0000.png") + assert m.mode == "L" # single-channel integer + arr = np.array(m) + assert arr[0, 0] == 0 # background + assert arr[5, 5] == 1 # class 1 + assert arr[12, 12] == 2 # class 2 + assert set(np.unique(arr)).issubset({0, 1, 2}) + + +def test_lightly_classes_map(tmp_path) -> None: + """classes.json maps index→name with 0=background and contiguous ids.""" + from coco_export import lightly_classes_map + + cats = [{"id": 1, "name": "cell"}, {"id": 2, "name": "wall"}] + m = lightly_classes_map(cats) + assert m == {"0": "background", "1": "cell", "2": "wall"} + # round-trips as JSON (Lightly accepts a path to this) + assert json.loads(json.dumps(m))["1"] == "cell" diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index ed6db98..8dbf913 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -2,8 +2,6 @@ from __future__ import annotations -import pytest - from schemas import BrushShape, BrushStroke, EllipseShape, PolygonShape, RectShape, RenderOpts diff --git a/backend/tests/test_tiled_annotation_sync.py b/backend/tests/test_tiled_annotation_sync.py index 2c7a5ad..f207608 100644 --- a/backend/tests/test_tiled_annotation_sync.py +++ b/backend/tests/test_tiled_annotation_sync.py @@ -3,7 +3,7 @@ from __future__ import annotations from source_keys import parse_source_key -from tiled_annotation_sync import annotation_metadata, STUDIO_ANNOTATED +from tiled_annotation_sync import STUDIO_ANNOTATED, annotation_metadata def test_parse_local_source_key() -> None: diff --git a/backend/tests/test_tiled_config.py b/backend/tests/test_tiled_config.py new file mode 100644 index 0000000..b0d500c --- /dev/null +++ b/backend/tests/test_tiled_config.py @@ -0,0 +1,81 @@ +"""Tests for Tiled key hardening (empty-key clearing + config-key fallback).""" + +from __future__ import annotations + +import importlib +import os +from unittest import mock + +import tiled_config + + +def test_clear_empty_tiled_api_key_env_removes_blank_vars() -> None: + """Blank/whitespace key env vars are deleted; real ones are kept.""" + with mock.patch.dict( + os.environ, + {"TILED_API_KEY": " ", "TILED_LOCAL_API_KEY": ""}, + clear=False, + ): + tiled_config.clear_empty_tiled_api_key_env() + assert "TILED_API_KEY" not in os.environ + assert "TILED_LOCAL_API_KEY" not in os.environ + + with mock.patch.dict(os.environ, {"TILED_API_KEY": "realkey"}, clear=False): + tiled_config.clear_empty_tiled_api_key_env() + assert os.environ["TILED_API_KEY"] == "realkey" + + +def test_single_user_api_key_from_tiled_config(tmp_path) -> None: + """Reads authentication.single_user_api_key from a config.yml.""" + cfg = tmp_path / "config.yml" + cfg.write_text( + "authentication:\n" + " allow_anonymous_access: true\n" + ' single_user_api_key: "abc123"\n' + ) + assert tiled_config.single_user_api_key_from_tiled_config(cfg) == "abc123" + + # Missing key / missing file → None (not an error). + empty = tmp_path / "empty.yml" + empty.write_text("allow_origins: []\n") + assert tiled_config.single_user_api_key_from_tiled_config(empty) is None + assert tiled_config.single_user_api_key_from_tiled_config(tmp_path / "nope.yml") is None + + +def test_config_key_env_interpolation(tmp_path) -> None: + """A ${TILED_API_KEY} placeholder in config.yml expands from the env; unset → None.""" + cfg = tmp_path / "config.yml" + cfg.write_text('authentication:\n single_user_api_key: "${TILED_API_KEY}"\n') + + with mock.patch.dict(os.environ, {"TILED_API_KEY": "envexpanded"}, clear=False): + assert tiled_config.single_user_api_key_from_tiled_config(cfg) == "envexpanded" + + # Unset var → placeholder stays literal → treated as "no key" (not the literal). + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("TILED_API_KEY", None) + assert tiled_config.single_user_api_key_from_tiled_config(cfg) is None + + +def test_local_api_key_falls_back_to_config(tmp_path) -> None: + """_local_api_key prefers env, else falls back to the config single-user key.""" + cfg = tmp_path / "config.yml" + cfg.write_text('authentication:\n single_user_api_key: "cfgkey"\n') + + # Env blank → falls back to config key. + with mock.patch.dict(os.environ, {}, clear=False), \ + mock.patch.object(tiled_config, "_TILED_CONFIG_PATH", cfg): + os.environ.pop("TILED_API_KEY", None) + os.environ.pop("TILED_LOCAL_API_KEY", None) + assert tiled_config._local_api_key() == "cfgkey" + + # Env set → env wins over config. + with mock.patch.dict(os.environ, {"TILED_API_KEY": "envkey"}, clear=False), \ + mock.patch.object(tiled_config, "_TILED_CONFIG_PATH", cfg): + assert tiled_config._local_api_key() == "envkey" + + +def test_module_reload_clears_blank_key() -> None: + """Importing the module clears a blank TILED_API_KEY from the env.""" + with mock.patch.dict(os.environ, {"TILED_API_KEY": ""}, clear=False): + importlib.reload(tiled_config) + assert "TILED_API_KEY" not in os.environ diff --git a/backend/tests/test_tiled_mask_sync.py b/backend/tests/test_tiled_mask_sync.py new file mode 100644 index 0000000..2a6e8bd --- /dev/null +++ b/backend/tests/test_tiled_mask_sync.py @@ -0,0 +1,110 @@ +"""Unit tests for tiled_mask_sync.build_mask_volumes (pure rasterization → volumes).""" +import numpy as np + +from schemas import AnnotationClass, ExportSourceItem +from tiled_mask_sync import build_mask_volumes, merge_mask_volumes + +H = W = 32 + + +def _classes(): + return [ + AnnotationClass(classId=10, label="Cell", color="#ff0000"), + AnnotationClass(classId=20, label="Wall", color="#00ff00"), + ] + + +def test_build_mask_volumes_stacks_and_labels(): + slices = { + "5": [{"id": "b", "kind": "polygon", "classId": 20, + "points": [10, 10, 20, 10, 20, 20, 10, 20]}], + "2": [{"id": "a", "kind": "rectangle", "classId": 10, + "x": 2, "y": 2, "w": 6, "h": 6}], + } + item = ExportSourceItem(kind="tiled", source="browse/ds/img", server_uri=None, slices=slices) + vols = build_mask_volumes(item, _classes(), {"height": H, "width": W}) + + assert vols is not None + # Sorted numeric slice order. + assert vols["slice_indices"] == [2, 5] + assert vols["semantic"].shape == (2, H, W) + assert vols["semantic"].dtype == np.uint8 + + # Slice 0 (key "2") = the rectangle → class id 1 (Cell); slice 1 = polygon → id 2 (Wall). + assert vols["semantic"][0].max() == 1 + assert vols["semantic"][1].max() == 2 + assert vols["semantic"][0, 4, 4] == 1 # inside the rect + assert vols["semantic"][1, 15, 15] == 2 # inside the polygon + + # One binary volume per class, 0/255, present only on its slice. + assert set(vols["class_vols"]) == {"Cell", "Wall"} + cell, wall = vols["class_vols"]["Cell"], vols["class_vols"]["Wall"] + assert cell.shape == (2, H, W) and wall.shape == (2, H, W) + assert set(np.unique(cell)).issubset({0, 255}) + assert cell[0].sum() > 0 and cell[1].sum() == 0 # Cell only on slice 0 + assert wall[1].sum() > 0 and wall[0].sum() == 0 # Wall only on slice 1 + + # Legend mirrors the zip's legend shape. + assert vols["legend"] == [ + {"id": 1, "name": "Cell", "color": "#ff0000"}, + {"id": 2, "name": "Wall", "color": "#00ff00"}, + ] + + +def test_negative_slices_emitted_as_zero_frames(): + slices = {"1": [{"id": "a", "kind": "rectangle", "classId": 10, "x": 2, "y": 2, "w": 4, "h": 4}]} + item = ExportSourceItem(kind="tiled", source="browse/ds/img", slices=slices, negative_slices=["3"]) + vols = build_mask_volumes(item, _classes(), {"height": H, "width": W}) + + assert vols is not None + assert vols["slice_indices"] == [1, 3] + # Negative slice 3 (index 1 in the stack) is all background. + assert vols["semantic"][1].sum() == 0 + + +def test_returns_none_without_slices(): + item = ExportSourceItem(kind="tiled", source="browse/ds/img", slices={}) + assert build_mask_volumes(item, _classes(), {"height": H, "width": W}) is None + + +def _volumes(slices, negatives=None): + item = ExportSourceItem( + kind="tiled", source="browse/ds/img", slices=slices, negative_slices=negatives or [], + ) + return build_mask_volumes(item, _classes(), {"height": H, "width": W}) + + +def test_merge_updates_pushed_slice_and_keeps_others(): + # Existing container holds slices 2 (Cell) and 5 (Wall). + existing_vols = _volumes({ + "2": [{"id": "a", "kind": "rectangle", "classId": 10, "x": 2, "y": 2, "w": 6, "h": 6}], + "5": [{"id": "b", "kind": "rectangle", "classId": 20, "x": 2, "y": 2, "w": 6, "h": 6}], + }) + existing = { + "slice_indices": existing_vols["slice_indices"], + "semantic": existing_vols["semantic"], + "class_arrays": existing_vols["class_vols"], + "legend": existing_vols["legend"], + } + + # Re-push ONLY slice 2, now as Wall (class changed on that slice). + new = _volumes({"2": [{"id": "c", "kind": "rectangle", "classId": 20, "x": 2, "y": 2, "w": 6, "h": 6}]}) + merged = merge_mask_volumes(existing, new) + + # Both slices survive; only slice 2 reported as updated. + assert merged["slice_indices"] == [2, 5] + assert merged["updated_indices"] == [2] + # Slice 2 (index 0) is now Wall (id 2), slice 5 (index 1) still Wall. + assert merged["semantic"][0, 4, 4] == 2 # updated slice → Wall + assert merged["semantic"][1, 4, 4] == 2 # untouched slice preserved + # Slice 2's Cell mask was replaced (now empty there); Wall present on both. + assert merged["class_vols"]["Cell"][0].sum() == 0 + assert merged["class_vols"]["Wall"][0].sum() > 0 + + +def test_merge_fresh_when_no_existing(): + new = _volumes({"3": [{"id": "a", "kind": "rectangle", "classId": 10, "x": 1, "y": 1, "w": 4, "h": 4}]}) + merged = merge_mask_volumes(None, new) + assert merged["slice_indices"] == [3] + assert merged["updated_indices"] == [3] + assert merged["semantic"].shape == (1, H, W) diff --git a/backend/tiled_clients.py b/backend/tiled_clients.py index 9c19879..0c34ea2 100644 --- a/backend/tiled_clients.py +++ b/backend/tiled_clients.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os from typing import Any from tiled_config import get_tiled_api_key, get_tiled_base, get_tiled_servers @@ -53,15 +54,31 @@ def get_tiled_client(server_uri: str | None = None, server_api_key: str | None = (("beamlines", "bl733", "projects", "10k"), "beamlines/bl733/projects/10k"), (("beamlines", "bl733"), "beamlines/bl733"), (("beamlines", "bl901"), "beamlines/bl901"), + # Plain drag-and-drop ingest writes samples directly under `browse/`; catch + # this before falling back to the root (which would list `browse` itself as + # a single sample instead of its contents). + (("browse",), "browse"), ) def get_browse_container(client: Any) -> tuple[Any, str]: """Return ``(container_node, path_prefix)`` for the Metadata Browser root. - Walks known beamline-ish paths in priority order and returns the first - container that exists and is non-empty. Falls back to the client's root. + Checks ``TILED_BROWSE_PATH`` env var first (slash-separated path into the + Tiled tree, e.g. ``20260221_135217_petiole22_``). Then walks known + beamline-ish paths in priority order. Falls back to the client's root. """ + browse_path = (os.getenv("TILED_BROWSE_PATH") or "").strip().strip("/") + if browse_path: + try: + node: Any = client + for k in browse_path.split("/"): + node = node[k] + if len(node) > 0: + return node, browse_path + except (KeyError, TypeError): + pass + for keys, prefix in _BROWSE_CANDIDATES: try: node: Any = client @@ -72,3 +89,19 @@ def get_browse_container(client: Any) -> tuple[Any, str]: except (KeyError, TypeError): continue return client, "" + + +def get_browse_container_for(client: Any, container_path: str | None) -> tuple[Any, str]: + """Return ``(container_node, path_prefix)`` for a specific browse target. + + When *container_path* is given (slash-separated, e.g. ``browse/testset``), + navigate directly to that node. Otherwise fall back to the heuristic + discovery in :func:`get_browse_container`. + """ + path = (container_path or "").strip().strip("/") + if not path: + return get_browse_container(client) + node: Any = client + for k in path.split("/"): + node = node[k] + return node, path diff --git a/backend/tiled_config.py b/backend/tiled_config.py index 336b0f2..fbb2a1f 100644 --- a/backend/tiled_config.py +++ b/backend/tiled_config.py @@ -15,21 +15,86 @@ from __future__ import annotations +import logging import os +from pathlib import Path from dotenv import load_dotenv -load_dotenv() +logger = logging.getLogger(__name__) _LOCAL_URI = "http://127.0.0.1:8010" +# Repo-root tiled/config.yml (backend/ -> repo root -> tiled/config.yml). +_TILED_CONFIG_PATH = Path(__file__).resolve().parent.parent / "tiled" / "config.yml" + + +def clear_empty_tiled_api_key_env() -> None: + """Delete blank ``TILED_API_KEY`` / ``TILED_LOCAL_API_KEY`` from the process env. + + A fresh install ships ``.env`` with an empty ``TILED_API_KEY``; ``start_all.sh`` + exports it via ``set -a``. The Tiled client library reads that env var directly + and builds an ``Authorization: Apikey `` (trailing space) header, which httpx + rejects (``Illegal header value b'Apikey '``). Removing blank key vars entirely + means neither our code nor the Tiled library ever sees an empty key. + """ + for var in ("TILED_API_KEY", "TILED_LOCAL_API_KEY"): + if var in os.environ and not os.environ[var].strip(): + del os.environ[var] + + +load_dotenv() +clear_empty_tiled_api_key_env() + def _stripped(name: str) -> str: return (os.getenv(name) or "").strip() +def single_user_api_key_from_tiled_config(path: Path | None = None) -> str | None: + """Return ``authentication.single_user_api_key`` from ``tiled/config.yml``, or None. + + Anonymous access is read-only; writes (ingest) need this key. Used as a fallback + when no API key is set in the environment so the backend can still authenticate + for writes without the operator duplicating the key into ``.env``. + """ + cfg_path = path or _TILED_CONFIG_PATH + try: + import yaml + + with open(cfg_path) as f: + doc = yaml.safe_load(f) or {} + key = ((doc.get("authentication") or {}).get("single_user_api_key")) or None + if not key: + return None + # config.yml may hold a ${TILED_API_KEY} placeholder (Tiled expands it the + # same way at load time); expand from the env and reject an unset placeholder. + key = os.path.expandvars(str(key)).strip() + if not key or "$" in key: + return None + return key + except Exception as exc: + logger.debug("tiled_config: could not read single_user_api_key from %s: %s", cfg_path, exc) + return None + + +def local_uri() -> str: + """Local Tiled base URI. + + ``TILED_URI`` (set by ``start_all.sh`` to whatever port Tiled actually bound — + it may fall back off the default 8010 if that port is busy) takes precedence; + otherwise the built-in default. + """ + return _stripped("TILED_URI") or _LOCAL_URI + + def _local_api_key() -> str | None: - return _stripped("TILED_LOCAL_API_KEY") or _stripped("TILED_API_KEY") or None + return ( + _stripped("TILED_LOCAL_API_KEY") + or _stripped("TILED_API_KEY") + or single_user_api_key_from_tiled_config() + or None + ) def get_tiled_servers() -> dict[str, dict[str, str | None]]: @@ -54,18 +119,18 @@ def get_tiled_servers() -> dict[str, dict[str, str | None]]: } i += 1 - if not any(cfg["uri"] == _LOCAL_URI for cfg in servers.values()): - servers.setdefault( - "Local Data (port 8010)", - {"uri": _LOCAL_URI, "api_key": _local_api_key()}, - ) + lu = local_uri() + if not any(cfg["uri"] == lu for cfg in servers.values()): + port = lu.rsplit(":", 1)[-1] + name = f"Local Data (port {port})" if port.isdigit() else "Local Data" + servers.setdefault(name, {"uri": lu, "api_key": _local_api_key()}) return servers def get_tiled_base() -> str: """Return the default Tiled base URI.""" - return _stripped("TILED_URI") or _LOCAL_URI + return local_uri() def get_tiled_api_key() -> str | None: diff --git a/backend/tiled_mask_sync.py b/backend/tiled_mask_sync.py new file mode 100644 index 0000000..35c71b2 --- /dev/null +++ b/backend/tiled_mask_sync.py @@ -0,0 +1,347 @@ +"""Write rasterized annotation masks into Tiled as stacked volumes. + +Standalone counterpart to the COCO .zip export: instead of writing per-slice PNG +files to disk, this rasterizes the same shapes (via ``coco_export.shape_to_mask``) +and stores them as compact **stacked uint8 arrays** in a sibling Tiled container +``__masks`` next to the source dataset — so a downstream app +(SAM3 / DINOv3 fine-tuning) can grab every mask in one request. + +Layout (all annotated + explicit negative slices, stacked in sorted order): + + __masks/ (container; metadata carries legend + slice_indices) + semantic uint8 (n, H, W) class-index per pixel, 0 = background + uint8 (n, H, W) 0/255 binary volume, one per class + +Tiled serves these back as PNG/TIFF via format negotiation. Only Tiled sources +are handled; local sources are skipped (nothing to write back to). +""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +import numpy as np + +import arrays as arrays_mod +import export_jobs +from coco_export import _safe_name, shape_to_mask +from tiled_clients import api_key_for_uri, get_tiled_client + +logger = logging.getLogger(__name__) + + +def _category_maps(classes: list[Any]) -> tuple[dict[int, int], dict[int, str], list[dict[str, Any]]]: + """1-based COCO ids + legend, matching ``build_export_plan``'s convention.""" + cat_id_map: dict[int, int] = {} + cat_id_to_name: dict[int, str] = {} + legend: list[dict[str, Any]] = [] + for i, cls in enumerate(classes, 1): + d = cls if isinstance(cls, dict) else cls.model_dump() + cat_id_map[int(d["classId"])] = i + cat_id_to_name[i] = d["label"] + legend.append({"id": i, "name": d["label"], "color": d.get("color")}) + return cat_id_map, cat_id_to_name, legend + + +def build_mask_volumes( + item: Any, + classes: list[Any], + meta: dict[str, Any], + progress_cb: Any = None, +) -> dict[str, Any] | None: + """Rasterize an item's shapes into stacked mask volumes. + + Returns ``{semantic, class_vols, slice_indices, legend}`` or ``None`` when + there are no slices to write. ``semantic`` is ``(n,H,W)`` uint8 (class index, + 0=bg); ``class_vols`` maps class label → ``(n,H,W)`` uint8 (0/255). Slices are + every annotated key plus any ``negative_slices`` (emitted as all-zero frames + for hard negatives), in sorted numeric order. + """ + h, w = int(meta["height"]), int(meta["width"]) + cat_id_map, cat_id_to_name, legend = _category_maps(classes) + + slices: dict[str, list[dict[str, Any]]] = item.slices or {} + neg = {str(k) for k in (item.negative_slices or [])} + keys = sorted( + {k for k, shapes in slices.items() if shapes} | neg, + key=lambda k: int(k), + ) + if not keys: + return None + + class_names = [cat_id_to_name[i] for i in sorted(cat_id_to_name)] + sem_list: list[np.ndarray] = [] + class_lists: dict[str, list[np.ndarray]] = {name: [] for name in class_names} + + for key in keys: + label = np.zeros((h, w), dtype=np.uint8) + acc: dict[str, np.ndarray] = {name: np.zeros((h, w), dtype=bool) for name in class_names} + for shape in slices.get(key, []): + shape_dict = shape if isinstance(shape, dict) else shape.model_dump() + mask = shape_to_mask(shape_dict, h, w) + if float(mask.sum()) < 1: + continue + cat_id = cat_id_map.get(int(shape_dict.get("classId", 1)), 1) + label[mask] = cat_id + acc[cat_id_to_name.get(cat_id, "")] |= mask + sem_list.append(label) + for name in class_names: + class_lists[name].append((acc[name] * 255).astype(np.uint8)) + if progress_cb is not None: + progress_cb(f"slice {key}: rasterized") + + return { + "semantic": np.stack(sem_list, axis=0), + "class_vols": {name: np.stack(lst, axis=0) for name, lst in class_lists.items()}, + "slice_indices": [int(k) for k in keys], + "legend": legend, + } + + +def _legend_id_to_name(legend: list[dict[str, Any]]) -> dict[int, str]: + return {int(e["id"]): e["name"] for e in legend} + + +def _remap_semantic(frame: np.ndarray, id_to_name: dict[int, str], name_to_uid: dict[str, int]) -> np.ndarray: + """Recode a semantic frame from one legend's class ids to unified ids (by name).""" + out = np.zeros_like(frame) + for old_id in np.unique(frame): + if old_id == 0: + continue + name = id_to_name.get(int(old_id)) + if name is None: + continue + uid = name_to_uid.get(name) + if uid: + out[frame == old_id] = uid + return out + + +def merge_mask_volumes(existing: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]: + """Merge freshly-rasterized ``new`` volumes onto ``existing`` ones, per slice. + + Slices present in ``new`` overwrite the same index; other existing slices are + kept. Classes are unioned by NAME and given a single unified id scheme, so the + stored ``semantic`` label map stays consistent even if the class set changed + between pushes. Pure (no I/O) so it can be unit-tested. + + ``existing`` is ``None`` (fresh) or ``{slice_indices, semantic (k,H,W), + class_arrays {name:(k,H,W)}, legend}``. Returns + ``{semantic, class_vols, slice_indices, legend, updated_indices}``. + """ + old_legend = (existing or {}).get("legend") or [] + old_indices = [int(i) for i in (existing or {}).get("slice_indices", [])] + old_sem = (existing or {}).get("semantic") + old_cls = (existing or {}).get("class_arrays", {}) or {} + new_indices = [int(i) for i in new["slice_indices"]] + + # Unified class list: existing names first (stable ids), then new-only names. + unified_names: list[str] = [e["name"] for e in old_legend] + for e in new["legend"]: + if e["name"] not in unified_names: + unified_names.append(e["name"]) + name_to_uid = {name: i + 1 for i, name in enumerate(unified_names)} + color_by_name: dict[str, Any] = {} + for e in [*old_legend, *new["legend"]]: # new overrides old for colour + color_by_name[e["name"]] = e.get("color") + + old_id_to_name = _legend_id_to_name(old_legend) + new_id_to_name = _legend_id_to_name(new["legend"]) + + merged_sem: dict[int, np.ndarray] = {} + merged_cls: dict[str, dict[int, np.ndarray]] = {name: {} for name in unified_names} + + if old_sem is not None: + for pos, idx in enumerate(old_indices): + merged_sem[idx] = _remap_semantic(old_sem[pos], old_id_to_name, name_to_uid) + for name, arr in old_cls.items(): + for pos, idx in enumerate(old_indices): + merged_cls.setdefault(name, {})[idx] = arr[pos] + + for i, idx in enumerate(new_indices): # new overrides same index + merged_sem[idx] = _remap_semantic(new["semantic"][i], new_id_to_name, name_to_uid) + for name, vol in new["class_vols"].items(): + merged_cls.setdefault(name, {})[idx] = vol[i] + + all_indices = sorted(merged_sem) + h, w = new["semantic"].shape[1], new["semantic"].shape[2] + zero = np.zeros((h, w), dtype=np.uint8) + semantic = np.stack([merged_sem[i] for i in all_indices], axis=0).astype(np.uint8) + class_vols = { + name: np.stack([merged_cls.get(name, {}).get(i, zero) for i in all_indices], axis=0).astype(np.uint8) + for name in unified_names + } + legend = [{"id": name_to_uid[n], "name": n, "color": color_by_name.get(n)} for n in unified_names] + return { + "semantic": semantic, + "class_vols": class_vols, + "slice_indices": all_indices, + "legend": legend, + "updated_indices": sorted(new_indices), + } + + +def _read_existing_masks(container: Any) -> dict[str, Any] | None: + """Read a prior masks container into ``merge_mask_volumes`` shape, or None.""" + try: + meta = dict(container.metadata) + legend = meta.get("legend") or meta.get("classes") or [] + indices = [int(i) for i in (meta.get("slice_indices") or [])] + semantic = np.asarray(container["semantic"][...]) + safe_to_name = {_safe_name(e["name"]): e["name"] for e in legend} + class_arrays: dict[str, np.ndarray] = {} + for key in list(container): + if key == "semantic": + continue + name = safe_to_name.get(key) + if name is not None: + class_arrays[name] = np.asarray(container[key][...]) + return { + "slice_indices": indices, + "semantic": semantic, + "class_arrays": class_arrays, + "legend": legend, + "slice_updated_at": dict(meta.get("slice_updated_at") or {}), + } + except Exception as exc: # noqa: BLE001 — unreadable/legacy → treat as fresh + logger.warning("mask merge: could not read existing masks (%s) — replacing", exc) + return None + + +def write_masks_to_tiled( + source: str, + server_uri: str | None, + volumes: dict[str, Any], + classes: list[Any], +) -> dict[str, Any]: + """Merge stacked mask volumes into a ``__masks`` sibling container. + + Slices in this push overwrite the same index; previously-pushed slices are + kept (merge). Metadata records ``updated_at`` and a per-slice + ``slice_updated_at`` map plus ``last_updated_slices`` so the latest version of + each slice is explicit. Returns ``{path, n_slices, updated, n_classes}``. + """ + api_key = api_key_for_uri(server_uri) + client = get_tiled_client(server_uri, api_key) + + parts = [p for p in source.strip("/").split("/") if p] + stem = parts[-1] + parent: Any = client + for part in parts[:-1]: + parent = parent[part] + + container_key = f"{stem}__masks" + try: + container: Any = parent[container_key] + except KeyError: + container = None + + existing = _read_existing_masks(container) if container is not None else None + # H/W mismatch → can't merge; replace instead. + if existing is not None and existing["semantic"].shape[1:] != volumes["semantic"].shape[1:]: + logger.warning("mask merge: shape changed for %s — replacing existing masks", source) + existing = None + + merged = merge_mask_volumes(existing, volumes) + + now_iso = datetime.now(timezone.utc).isoformat() + slice_ts: dict[str, str] = dict((existing or {}).get("slice_updated_at", {})) if existing else {} + for idx in merged["updated_indices"]: + slice_ts[str(idx)] = now_iso + + container_meta = { + "studio_type": "segmentation_masks", + "source": source, + "updated_at": now_iso, + "n_slices": len(merged["slice_indices"]), + "slice_indices": merged["slice_indices"], + "slice_updated_at": slice_ts, + "last_updated_slices": merged["updated_indices"], + "classes": merged["legend"], + "legend": merged["legend"], + } + + if container is not None: + # Clear prior arrays (external_only=False → also internally-managed data). + container.delete_contents(recursive=True, external_only=False) + container.update_metadata(container_meta) + else: + container = parent.create_container(key=container_key, metadata=container_meta) + + dims = ["slice", "y", "x"] + container.write_array( + merged["semantic"], key="semantic", dims=dims, + metadata={"studio_type": "segmentation_semantic"}, + ) + for name, vol in merged["class_vols"].items(): + container.write_array( + vol, key=_safe_name(name), dims=dims, + metadata={"studio_type": "segmentation_class", "class_name": name}, + ) + + path = "/".join(parts[:-1] + [container_key]) + logger.info( + "tiled_mask_sync: merged masks into %s (%d total, %d updated)", + path, len(merged["slice_indices"]), len(merged["updated_indices"]), + ) + return { + "path": path, + "n_slices": len(merged["slice_indices"]), + "updated": len(merged["updated_indices"]), + "n_classes": len(merged["class_vols"]), + } + + +def run_mask_sync_job(jid: str, source_items: list[Any], payload: Any) -> None: + """Background worker: rasterize each Tiled source's masks and write them back.""" + try: + export_jobs.update(jid, state="running", phase="reading") + tiled_items = [it for it in source_items if it.kind == "tiled"] + total = 0 + for it in tiled_items: + total += len({k for k, s in (it.slices or {}).items() if s} | {str(k) for k in (it.negative_slices or [])}) + export_jobs.set_total(jid, total) + + if not tiled_items: + export_jobs.update( + jid, state="done", phase="done", + result={"written": [], "note": "skipped — no Tiled sources (masks only write back to Tiled)"}, + ) + export_jobs.log(jid, "No Tiled sources — nothing to write.") + return + + written: list[dict[str, Any]] = [] + for item in tiled_items: + export_jobs.log(jid, f"Rasterizing {item.source} …") + node = arrays_mod.resolve_array(item.source, item.kind, item.server_uri) + meta = arrays_mod.array_shape_meta(node) + + def _cb(message: str, _jid: str = jid) -> None: + export_jobs.bump(_jid, 1) + export_jobs.log(_jid, message) + + volumes = build_mask_volumes(item, payload.classes, meta, progress_cb=_cb) + if volumes is None: + export_jobs.log(jid, f"{item.source}: no annotated slices — skipped.") + continue + + export_jobs.update(jid, phase="writing") + info = write_masks_to_tiled(item.source, item.server_uri, volumes, payload.classes) + written.append({ + "source": item.source, + "container": info["path"], + "n_slices": info["n_slices"], + "updated": info["updated"], + "n_classes": info["n_classes"], + }) + export_jobs.log( + jid, + f"Wrote {info['path']} ({info['n_slices']} slices total, {info['updated']} updated).", + ) + + export_jobs.update(jid, state="done", phase="done", result={"written": written}) + export_jobs.log(jid, "Mask sync complete.") + except Exception as exc: # noqa: BLE001 + logger.error("Mask sync job failed: %s", exc) + export_jobs.update(jid, state="error", phase="error", error=str(exc)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c1fa1b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +# Production app container (frontend + backend). Tiled runs separately — point +# TILED_URI/TILED_API_KEY at your external Tiled server. +# +# docker compose up --build +# +services: + app: + build: . + ports: + - "8002:8002" + environment: + # External Tiled server (required): + TILED_URI: "${TILED_URI:-http://host.docker.internal:8010}" + TILED_API_KEY: "${TILED_API_KEY:-}" + # Persisted annotation drafts/versions/exports live under this path: + LOCAL_DATA_ROOT: "/data" + # Same-origin SPA → CORS can be empty; set if you split origins: + BROWSE_ALLOWED_ORIGINS: "${BROWSE_ALLOWED_ORIGINS:-}" + volumes: + - annotation-data:/data + # host.docker.internal lets the container reach a Tiled on the host (Linux): + extra_hosts: + - "host.docker.internal:host-gateway" + +volumes: + annotation-data: diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000..ce26371 Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..1f0470d --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,167 @@ +# Installation + +There are two ways to run Segmentation Annotation Studio: + +- **Local development** — one command starts everything (recommended for annotators and evaluation). +- **Docker** — a single production container that serves the app against an external Tiled server. + +--- + +## Prerequisites + +| Requirement | Version | Notes | +| --- | --- | --- | +| **Python** | 3.11 or newer | 3.12 is used by the local launcher. | +| **Node.js / npm** | 18 or newer | Needed to build and run the frontend. | +| **git** | any recent | To clone the repository. | +| `curl`, `openssl`, `lsof` | system tools | Used by the launcher for health checks, key generation, and port cleanup. | + +The launcher will automatically install [`uv`](https://github.com/astral-sh/uv) +(a fast Python package manager) if it is not already on your `PATH`. + +!!! note "macOS / Linux" + The one-command launcher (`start_all.sh`) is a bash script and targets + macOS and Linux. On Windows, use WSL or follow the + [manual setup](#manual-setup) below. + +--- + +## Option 1 — One-command local start (recommended) + +From the repository root: + +```bash +./start_all.sh +``` + +That's it. The script bootstraps the entire stack and opens three services: + +| Service | URL | +| --- | --- | +| **Frontend** (open this in your browser) | | +| **Backend API** | | +| **Tiled** (data catalog) | | + +Press ++ctrl+c++ in the terminal to stop all three services. + +??? info "What `start_all.sh` does for you" + On first run the launcher: + + 1. Creates a Python 3.12 virtual environment in `.venv` (via `uv venv`). + 2. Installs the backend dependencies, including the full Tiled server (`tiled[all]`). + 3. Copies `backend/.env.example` → `backend/.env` if it doesn't exist yet. + 4. Generates a strong `TILED_API_KEY` and writes it into `backend/.env`. + 5. Initializes/repairs the local Tiled catalog at `.tiled/catalog.db`. + 6. Starts Tiled, the backend (`uvicorn annotation_server:app`), and the frontend (`npm run dev`). + 7. Runs `npm install` if `frontend/node_modules` is missing. + 8. Downloads the SlimSAM model in the background so the **Smart (AI)** tool works offline. + +### Changing the ports + +If a default port is busy, the launcher automatically scans upward for a free +one. To pin specific ports, set environment variables before launching: + +```bash +FRONTEND_PORT=5200 BACKEND_PORT=8100 TILED_PORT=8110 ./start_all.sh +``` + +--- + +## Option 2 — Docker (production) + +Docker runs a **single container** that serves the built frontend and the API +together on port **8002**. Tiled is **not** included — you point the container +at an existing Tiled server. + +```bash +docker compose up --build +``` + +Then open . + +Configure the connection to your external Tiled through environment variables +(see [Environment variables](#environment-variables)): + +```bash +TILED_URI=https://tiled.example.com \ +TILED_API_KEY=your-key \ +docker compose up --build +``` + +!!! warning "Persisting your data" + Mount `LOCAL_DATA_ROOT` as a volume so annotation drafts, versions, and + exports survive container restarts. + +--- + +## Manual setup + +If you prefer to run each service yourself (for example on Windows, or in +separate terminals), install and start the backend and frontend independently. +Tiled must be running and reachable at `TILED_URI` first — the easiest way to +get a local Tiled instance is still `./start_all.sh`. + +### Backend + +```bash +cd backend +pip install -e ".[dev,test]" +uvicorn annotation_server:app --host 127.0.0.1 --port 8002 +``` + +Run the backend test suite with: + +```bash +pytest +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +The Vite dev server runs on and proxies all `/api` +requests to the backend on port 8002. Other useful scripts: + +```bash +npm run build # type-check and build the production bundle +npm run typecheck # type-check only +npm test # run the Vitest unit tests +``` + +--- + +## Environment variables + +Backend configuration lives in `backend/.env` (created from +`backend/.env.example` on first launch). The most relevant settings: + +| Variable | Purpose | Default | +| --- | --- | --- | +| `TILED_URI` | Address of the Tiled server | `http://127.0.0.1:8010` | +| `TILED_API_KEY` | Auth key for Tiled writes/ingest | *(generated locally)* | +| `LOCAL_DATA_ROOT` | Where drafts, versions, and exports are stored | `~/data` | +| `EXPORT_ROOT` | Override output folder for exports | `~/data/exports` | +| `BROWSE_CACHE_TTL_SECONDS` | Cache lifetime for Tiled browse listings | `300` | +| `BROWSE_ALLOWED_ORIGINS` | CORS origins (only needed for split frontend/backend hosting) | *(empty)* | + +!!! danger "Never commit secrets" + `backend/.env` is git-ignored. Never commit it, and never expose + `TILED_API_KEY` to the frontend — all Tiled access goes through the backend. + +--- + +## Verify it's working + +1. Open the frontend at . +2. You should land on the **Connect** tab with the ALS logo in the header. +3. Check the backend health endpoint: + + ```bash + curl http://127.0.0.1:8002/health + ``` + +Once the app loads, continue to the [Quick start](quick-start.md). diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md new file mode 100644 index 0000000..381a0ff --- /dev/null +++ b/docs/getting-started/quick-start.md @@ -0,0 +1,117 @@ +# Quick start + +This is the fast path: from a running app to a downloaded, annotated dataset in +a few minutes. Each step links to the in-depth guide if you want more detail. + +!!! note "Before you begin" + Make sure the app is running and open at . If not, + see [Installation](installation.md). + +--- + +## 1. Connect to your data + +You land on the **Connect** tab. Choose one of the two modes at the top: + +=== "Tiled Server" + + 1. Pick a server from the **Server** dropdown. + 2. *(Optional)* expand **Dataset to view (optional)** to point Browse at a specific collection. + 3. Click **Connect**. On success you'll see *"Connected — N sample(s) found"*. + 4. Click **Go to Browse**. + +=== "Local Folder" + + 1. Enter an absolute path (e.g. `/absolute/path/to/data`) and click **Grant**. + 2. Navigate to the folder you want and choose **Use "…" as dataset folder**. + 3. Click **Connect** — this takes you straight to Browse. + +→ More detail: [Connect to data](../guide/connect.md) + +--- + +## 2. Open an image to annotate + +On the **Browse** tab, find a sample and open it: + +- **Tiled**: use the metadata columns to filter, or click **All samples**, then click the pencil icon (or **Open in Annotate**). +- **Local**: hover a row and click **Annotate**. + +→ More detail: [Browse & select](../guide/browse.md) + +--- + +## 3. Add a class + +In the **Annotate** tab's left sidebar, find the **CLASSES** panel: + +1. Click **+** to add a class. +2. Type a **Class label** (e.g. `pore`), pick a **Color**, and click **Add**. +3. Click the class row to make it active (highlighted). + +!!! tip + If you ingested data with keyword tags, those appear as one-click **quick add** chips. + +--- + +## 4. Draw a mask + +Pick a tool from the **TOOLS** panel and draw on the image: + +| Tool | Key | How | +| --- | --- | --- | +| **Brush** | ++b++ | Paint freehand strokes. | +| **Polygon** | ++p++ | Click vertices, double-click to finish. | +| **Magic → Smart (AI)** | ++g++ | Drag a box around an object; press ++enter++ to commit. | + +Zoom with the mouse wheel and press ++t++ to fit the image to the screen. Undo +with ++cmd+z++ (or ++ctrl+z++). + +→ More detail: [Annotate](../guide/annotate.md) + +--- + +## 5. Save your work + +Click **Save** in the sidebar to open the **Save version** modal, add your name +and optional notes, then click **Save version**. + +!!! note "Autosave vs. Save version" + The app autosaves a local draft every ~1.5 seconds for crash recovery. + **Save version** creates an explicit, restorable version on the server. + +--- + +## 6. Export a COCO dataset + +1. Click **Export** in the Annotate sidebar. +2. In the **Download Dataset** modal, choose a **Format** (COCO or DINOv3 / Lightly) + and an **Export scope** (e.g. *Current sample only*). +3. Enter your **Annotator** name. +4. Click **Export**, wait for the progress bar, then click **Download .zip**. + +You'll get a `.zip` with rendered images plus masks in the chosen layout. + +→ More detail: [Export & download](../guide/export.md) + +--- + +## Where to go next + +
+ +
+### :material-draw: Master annotation +Every tool, class management, slices, versions, and QA insights. + +[Annotate guide →](../guide/annotate.md){ .md-button } +
+ +
+### :material-keyboard: Keyboard shortcuts +Work faster with the full shortcut reference. + +[Shortcuts →](../reference/shortcuts.md){ .md-button } +
+ +
diff --git a/docs/guide/annotate.md b/docs/guide/annotate.md new file mode 100644 index 0000000..4f568ab --- /dev/null +++ b/docs/guide/annotate.md @@ -0,0 +1,276 @@ +# 3. Annotate + +The **Annotate** tab is the heart of the tool. Its left sidebar stacks a series +of panels; the right side is the image canvas. + +!!! note "Nothing loaded?" + If you open Annotate without a sample, you'll see *"No sample loaded. Pick a + sample to annotate."* and a **Go to Browse** button. Open a sample from + [Browse](browse.md) first. + +The sidebar panels, top to bottom: + +**Classes → Tools → Display → Slice → Cross-slice → Measure → Save / Insights / Export** + +--- + +## Classes + +Everything you draw belongs to a **class**. The **CLASSES** panel manages them. + +- **Add a class**: click **+**, enter a **Class label**, pick a **Color**, click **Add**. +- **Quick add** chips let you add common classes in one click (from your + annotation guide, or defaults like `air`, `sample`, `void`, `pore`, + `background`, `substrate`). +- **Activate** a class by clicking its row — new shapes use the active class. + +Each class row has actions: + +| Action | Effect | +| --- | --- | +| Click row | Make it the active class. | +| :material-information: Info | Show its guide description and example crops. | +| :material-eye: / :material-eye-off: | **Show class** / **Hide class** on the canvas. | +| :material-pencil: Pencil | Edit the class: rename it inline **and** change its color via the swatch (now a color picker). Click the :material-check: check to finish. | +| :material-delete: Trash | Delete the class (asks to confirm — this removes its annotations). | + +!!! tip + Press ++1++–++9++ to activate the first nine classes by their order in the list. + +If you have no classes, the Tools panel warns *"Add a class above to start +annotating."* + +### Colorblind-safe colors + +The **CLASSES** panel has a **Colorblind-safe colors** checkbox. When checked, +**newly added** classes are colored from a colorblind-safe palette (the +Okabe–Ito scheme, extended with Paul Tol's muted colors). Uncheck it to color +new classes from the standard palette. + +!!! note + This is a display/authoring preference stored in your browser. To avoid + silently changing already-annotated datasets, it only affects the colors + auto-assigned to **new** classes — your existing classes keep their colors. + You can always recolor a class yourself when adding it, and class colors are + recorded per class in saved versions and exports as usual. + +--- + +## Tools + +The **TOOLS** panel is your drawing palette. Each tool has a keyboard shortcut. + +| Tool | Key | What it does | +| --- | --- | --- | +| **Pan** | hold ++space++ | Drag the canvas around. | +| **Select** | ++s++ | Click or marquee-select shapes; move, edit vertices, run region ops. | +| **Polygon** | ++p++ | Click to place vertices; double-click to finish. | +| **Magnetic** | ++m++ | Click along an edge and it snaps to it (livewire); double-click to finish. | +| **Magic** | ++g++ | AI (SAM) or classic intensity region selection (see below). | +| **Rect** | ++e++ | Drag a rectangle. | +| **Ellipse** | ++l++ | Drag an ellipse. | +| **Brush** | ++b++ | Paint freehand; disconnected dabs become separate shapes. | +| **Fill** | ++f++ | Flood-fill a region by intensity similarity. | +| **Eraser** | ++r++ | Carve pixels out of any shape (see below). | + +**Undo** (++cmd+z++) and **Redo** (++cmd+shift+z++) buttons sit in the toolbar, +with up to 200 steps of history. + +### How each tool works + +All shapes are stored in **image-pixel coordinates**, independent of zoom/pan, so +they stay pixel-accurate at any magnification. + +- **Polygon** — each click drops a vertex; the dashed rubber-band line follows the + cursor. Double-click (or ++enter++ is not used here) closes the ring. While + drafting, ++cmd+z++/++ctrl+z++ removes the **last vertex** (not the whole shape); + if you close too early, one ++cmd+z++/++ctrl+z++ reopens the polygon in edit mode + with its last node removed so you can continue. +- **Magnetic (livewire)** — computes an edge-cost map of the current view and traces + the **least-cost path** from your last click to the cursor, so the line hugs + contrast edges. Click to lock each segment; double-click to finish. ++cmd+z++ + pops the last locked node, and (like Polygon) reopens the trace if you just closed + it. +- **Rectangle / Ellipse** — press-drag to size; released as a shape. +- **Brush** — freehand round-capped strokes. Strokes that touch build up **one** + shape; a stroke drawn in a **disconnected** area starts a **new** shape, so each + blob is independently selectable. Press ++n++ to force a new brush instance. +- **Fill** — flood-fills the connected region around your click whose intensity is + within the **Fill threshold** of the clicked pixel (a paint-bucket by brightness + similarity). +- **Eraser** — carves pixels out of **any** shape kind (polygon, rectangle, ellipse, + brush), not just brushes. It is **radius-aware**: the brush disk erases as soon as + it grazes a shape's edge — the cursor center need not be inside. Erasing a polygon + **rebuilds its vertices** to match the carved outline (no stray invisible nodes), + can **split** one shape into several, and can open **holes**. Undo restores the + shape in one step. + +### The Magic tool (Smart AI + Classic) + +The **Magic** tool has two engines, chosen with a toggle: + +=== "Smart (AI)" + + Uses a SAM (Segment Anything) model running in your browser. + + - On first use you'll see *"Loading model… (first time only)"*, then + *"Encoding slice…"*, then a readiness message like *"SAM ready · WebGPU"* + (or *"CPU"*). + - **Drag a box** around an object, or **click** it, then commit with the + **Add** button or ++enter++. + - ++shift+click++ adds to the selection; ++alt+click++ (++opt+click++ on Mac) + excludes a region. + - Tune it with the **Detail** buttons (*auto*, *fine*, *medium*, *coarse*), + the **Tightness** and **Edge smoothing** sliders, and the **Avoid + other-class regions** checkbox. + + !!! note + Changing **Display** brightness/contrast re-encodes the slice for SAM, + so adjust the display first if you plan to use Smart mode heavily. + +=== "Classic" + + A classic intensity-based magic wand — no model required. + + - Choose **Connected** (contiguous region) or **All similar** (every matching pixel). + - Tune **Tolerance**, **Edge stop** (contiguous only), and **Edge smoothing**. + +### Tool-specific and global options + +- **Brush / Eraser**: a **Brush radius (px)** slider (1–500) with a matching + number box for exact values. +- **Eraser scope**: radios — **Erase selected class** (default) or **Erase all + classes** (carve whatever visible shape the stroke crosses). +- **Select**: **Select this class** / **Select all classes** radios; ++cmd+a++/++ctrl+a++ + selects every shape on the slice per that scope. +- **Fill**: a **Fill threshold** slider (0–100%). +- **Global** (below the class list / in Tools): **Annotation opacity**, **Clip to + other classes** (on by default), and **Merge overlapping same class**. + +!!! info "Clip and Merge use exact geometry" + **Clip to other classes** subtracts neighbouring classes from a new shape so + regions tile **flush with no gap**; **Merge overlapping same class** unions a + new shape with overlapping same-class shapes. Both use true polygon boolean + operations at full resolution, so **existing vertices are preserved** — only + the seam/cut changes, and repeated edits don't erode a region. + +--- + +## Working on the canvas + +- **Zoom**: mouse wheel zooms toward the cursor; the current zoom shows bottom-right. +- **Fit**: press ++t++ to fit the image to the screen. +- **Pan**: hold ++space++ and drag (releases back to your previous tool). + +### Selecting and editing shapes + +With the **Select** tool active, the canvas hint reads *"Click a shape · drag a +box to select many · Shift-drag to add more · Shift-click to toggle"*. When +shapes are selected, a selection toolbar appears: + +| Control | Action | +| --- | --- | +| **Class:** dropdown | Reassign selected shapes to another class. | +| **Copy** / **Paste** | ++cmd+c++ / ++cmd+v++. | +| **Invert** | ++i++ — invert a single selected shape. | +| **Delete** | ++delete++ or ++backspace++. | +| **Thickness** | Adjust a selected brush shape's stroke width. | +| **Region:** ops | **Merge**, **Grow**, **Shrink**, **Remove islands** — then **Apply** (or **Cancel**). | + +To add a vertex to a polygon, double-click its edge with the Select tool; +double-click a vertex to delete it. Drag the white outer vertices or the amber +hole vertices to reshape. ++cmd+a++/++ctrl+a++ selects every shape on the slice +(scoped by the **Select this class / all classes** radios). Press ++n++ to start +a new brush instance. + +--- + +## Display (view-only) + +The **DISPLAY** panel changes how the image *looks* while you work — it does +**not** change exported pixels (except that it defines the render used for +export images). Controls include **Brightness**, **Contrast**, a histogram +levels window, **Colormap** (gray, viridis, magma, inferno), **Gamma**, +**CLAHE**, and **Sharpen**. Use the reset button to restore defaults. + +- **CLAHE** is *adaptive* (local) contrast: it equalizes each image tile's + histogram with a clip limit and blends the tiles, so faint local features pop + without blowing out the whole frame. When on, it is applied **before** the + brightness/contrast/levels chain, and the levels histogram updates to reflect + it. (It replaced the older global "Auto-contrast" stretch.) +- The display preprocessors also change what the **tools see** — SAM, the magic + wand, and the magnetic edge map all operate on the enhanced view — but never + affect the exported pixels. + +--- + +## Navigating slices + +For multi-slice volumes, the **Slice** panel shows *"Slice N / total"* with: + +- A slider and **Previous slice** / **Next slice** buttons (++left++ / ++right++, or ++x++ for next). +- A **Jump to annotated…** dropdown listing slices that already have shapes. +- **Mark as negative** — flags a slice as a deliberate negative example + (exported as an image with zero annotations). + +### Cross-slice + +For volumes, the **CROSS-SLICE** panel acts on the active class. Its main action +is **Copy '{class}' → next slice**, handy for propagating a mask through a stack. + +--- + +## Measure + +Select one or more regions and the **MEASURE** panel reports **Regions, Area, +Perimeter, Centroid, Bounds**, and **Intensity (raw)** (mean ± SD, min/max, +pixel count). If you set a **Pixel size** and unit (default **µm**), +measurements convert to physical units. + +--- + +## Saving your work + +The tool saves at two levels: + +- **Autosave** — a local draft is saved roughly every 1.5 seconds for crash + recovery. This is *not* a versioned save. +- **Save version** — an explicit, restorable snapshot stored on the server. + +Click **Save** (the button reads **Saved** or **Saving…** depending on state) to +open the **Save version** modal: + +1. Review the **Preview** thumbnail. +2. Fill in **Who annotated this** (placeholder *"Your name or initials"*). +3. Add optional **Notes**. +4. Click **Save version**. + +### Version history + +The version-count button opens **Version History**. For each version you can +**Preview** (scrub versions on the canvas without changing your work) or +**Restore** (load it into the editor to save as a new version). A preview bar +across the canvas shows *"Previewing v{N} (latest)"* with **Restore this +version** / **Exit** and a version slider. + +--- + +## Insights (quality checks) + +Click **Insights** to open **Dataset Insights**, a QA dashboard showing: + +- Counts: **Annotated slices, Negative slices, Empty unmarked, Samples this session**. +- A **Class balance** bar chart. +- **Quality checks** flags: *Tiny regions, Self-intersecting polygons, + Cross-class overlaps, Empty, not marked negative*. + +Click any flag to jump straight to the offending slice or region on the canvas. + +--- + +## Export + +When your annotations are ready, click **Export** to open the download +dialog. That's covered in full on the next page. + +Next: [Annotation guide →](reference-guide.md) or jump to [Export & download →](export.md) diff --git a/docs/guide/browse.md b/docs/guide/browse.md new file mode 100644 index 0000000..c13414b --- /dev/null +++ b/docs/guide/browse.md @@ -0,0 +1,75 @@ +# 2. Browse & select + +The **Browse** tab is where you find a specific image (a *sample*) and open it +for annotation. A banner at the top shows the connected dataset and sample +count, with a **Change** link back to [Connect](connect.md). + +!!! note "Not connected?" + If you haven't connected yet, Browse shows *"No dataset connected."* with a + **Go to Connect** button. + +The layout depends on your data source. + +--- + +## Tiled: the metadata browser + +Tiled data uses a Miller-column browser (labelled **Metadata Browser**) — a set +of columns you read left to right. The toolbar controls: + +| Control | Purpose | +| --- | --- | +| **Server** | Switch the active Tiled server. | +| **Annotation** | Filter by annotation status: *All samples*, *With annotations*, *Without annotations*. | +| **Refresh** | Reload the current listing. | +| **All samples** | Show every dataset regardless of filters. | +| **Add column** | Add a metadata facet column to filter by. | +| **Open in Annotate** | Open the currently selected sample. | + +The columns flow: **facet filters → Samples → (Slices, for volumes) → detail panel**. + +### The Samples column + +Each sample row can show: + +- **Star ratings** (click to rate 1–3 stars). Use the **Min rating** filter + (**All**, ★, ★★, ★★★) to narrow the list. +- An **annotated** badge if it already has annotations. +- A slice-count badge for multi-slice volumes. +- A pencil icon to open it directly in Annotate. + +!!! tip "Ratings drive export scopes" + Star ratings you set here power the *"★ and above"* export scopes later. + Rate your best annotations so you can export only those. + +### The detail panel + +Selecting a sample shows a preview (*"Loading preview…"* while it loads) plus +metadata grouped into sections: **Annotation, Identity, Experiment, Geometry, +Thin Film, Chemistry, Other**. A footer **Open in Annotate** button opens it. + +If there's nothing to filter by yet, you'll see hints like *"Click 'Add column' +to filter, or 'All samples' to view everything."* + +--- + +## Local: the flat file list + +Local folders show a simple list (**Local Samples**) with the folder path. It +offers the same **Annotation** and **Min rating** filters. Hover a row to reveal +its **Annotate** button. + +--- + +## Opening a sample + +When you open a sample (from either browser, or straight from ingest), the app: + +1. Loads the image metadata. +2. Restores any local draft autosave for that sample. +3. Seeds the class list from ingest keywords if you have no saved classes yet. +4. Switches to the [Annotate](annotate.md) tab. + +--- + +Next: [Annotate →](annotate.md) diff --git a/docs/guide/connect.md b/docs/guide/connect.md new file mode 100644 index 0000000..9684894 --- /dev/null +++ b/docs/guide/connect.md @@ -0,0 +1,74 @@ +# 1. Connect to data + +The **Connect** tab (page title *"Connect to Dataset"*) is where you tell the +app which images to work with. It's the first screen you see on launch. + +At the top, a toggle chooses the data source: + +- **Tiled Server** — data managed by a Tiled catalog (supports metadata browsing and ingest). +- **Local Folder** — a folder of image files on the machine running the backend. + +--- + +## Tiled Server mode + +Tiled mode has two sections. + +### Connect to Tiled + +1. **Server** — pick a server from the dropdown (it starts on *"— select server —"*). +2. **Dataset to view (optional)** — expand this to point Browse at a specific + collection. Use the breadcrumb (starting at **root**) and the + **Browse "…"** button to drill into containers. Leave it blank to let the + app auto-detect samples. +3. Click **Connect**. While it checks the connection you'll see *"Verifying…"*. +4. On success the status reads *"Connected — N sample(s) found"* and a + **Go to Browse** button appears. + +!!! tip "Re-verify" + If a connection was interrupted, the button becomes **Re-verify** — click it + to re-check without changing your selection. + +### Load / Ingest Datasets + +Below the connection section, a drop zone lets you upload images into Tiled. See +[Ingesting data](#ingesting-data-tiled-only) below. + +--- + +## Local Folder mode + +1. Under **Grant access to a folder**, type an absolute path + (placeholder: `/absolute/path/to/data`) and click **Grant**. +2. Browse into subfolders and choose **Use "…" as dataset folder**. +3. Click **Connect** — this navigates straight to [Browse](browse.md). + +Local mode supports the same annotation-status and star-rating filters in +Browse, but has no metadata columns or ingest. + +--- + +## Ingesting data (Tiled only) + +The **Ingest data into this server** drop zone uploads new images into the +connected Tiled server. + +| Field | What it does | +| --- | --- | +| **Classes / keywords (comma-separated, optional)** | Placeholder `e.g. air, sample, void, pore`. Each entry becomes a pre-created annotation class **and** a searchable tag in Browse. | +| Drop zone | *"Drag an image file or folder of images here"* — or use **choose files** / **choose a folder**. | +| **Save uploaded images to (optional)** | Expandable; placeholder `browse/my_dataset` sets the destination container. | + +Supported formats: **TIFF, PNG, JPG, NPY**. + +When ingest finishes, you get shortcut buttons such as **Browse this dataset**, +**Open in Annotate**, or **Annotate first image**. + +!!! note "Why set keywords at ingest time?" + Keywords do double duty: they seed your class list in the Annotate tab (so + you can start drawing immediately) and they become metadata tags you can + filter on in Browse. + +--- + +Next: [Browse & select a sample →](browse.md) diff --git a/docs/guide/export.md b/docs/guide/export.md new file mode 100644 index 0000000..87f730e --- /dev/null +++ b/docs/guide/export.md @@ -0,0 +1,189 @@ +# 5. Export & download + +This is the payoff: turning your annotations into a downloadable dataset. You can +export as a **COCO dataset** (for SAM3 fine-tuning) or in the **DINOv3 / Lightly** +semantic-segmentation layout. Either way the primary path produces a `.zip` file +with images and masks. + +There are two related actions in the same dialog: + +- **Export** → build a COCO dataset and **download a `.zip`** to your computer. +- **Push masks to Tiled** → write masks back into Tiled (server-side, **no** local download). + +--- + +## Downloading a COCO dataset + +### Step 1 — Prepare (optional but recommended) + +Before exporting, you can shape *what* gets included: + +- **Mark negative slices** in the [Slice panel](annotate.md#navigating-slices) + with **Mark as negative** — these export as images with zero annotations. +- **Rate samples** with stars in [Browse](browse.md#the-samples-column) — ratings + drive the *"★ and above"* export scopes. + +### Step 2 — Open the export dialog + +In the Annotate sidebar, click **Export**. This opens the **Download +Dataset** modal (where you also choose the export format). + +### Step 3 — Choose a scope + +Under **Export scope**, pick which samples to include: + +| Scope | Includes | +| --- | --- | +| **Current slice only** | Just the slice open in the viewer. | +| **Current sample only** | All annotations for the open sample *(default)*. | +| **All annotated samples** | Every sample with at least one annotation this session. | +| **★ and above** | Annotated samples rated 1 star or higher. | +| **★★ and above** | Annotated samples rated 2 stars or higher. | +| **★★★ only** | Only samples rated 3 stars. | + +The dialog previews how many samples match, e.g. *"3 sample(s) will be +exported."* (or *"No samples match."*). + +### Step 4 — Set options + +- **Format** — choose the dataset layout: + - **COCO (SAM3)** *(default)* — RLE masks + images, for SAM3 fine-tuning. + - **DINOv3 / Lightly** — `images/` + `masks/` label PNGs with matching + filenames + a `classes.json`, for [LightlyTrain semantic + segmentation](https://docs.lightly.ai/train/stable/semantic_segmentation.html). + See [the layout below](#dinov3-lightly-format). +- **Annotator** — your name (placeholder *"Your name (recorded in the export)"*). + It's stamped into the export folder name, the COCO `info` block, and + `manifest.json`. +- **Include polygon copy in COCO** — optional checkbox (COCO format only). RLE + masks are always exact; enable this only if an external viewer needs polygon + geometry (it's slower). + +### Step 5 — Export and download + +1. Click **Export**. The button shows **Exporting…** and a progress bar tracks + *"{done}/{total} slices"* with a live log. +2. On success you'll see a message like *"Saved to {path} (Tiled). Use Download + .zip to save images + masks to your computer."* +3. Click **Download .zip** — your browser's save dialog picks where to store it. + +!!! tip "Filename" + The zip is named after the annotator and source, e.g. + `yourname__samplename_20260714T....zip`. + +--- + +## What's inside the `.zip` + +The export is split into `train/`, `valid/`, and `test/` folders (an 80/10/10 +split is applied automatically to slices marked `auto`, using a fixed seed for +reproducibility). Each split contains: + +```text +manifest.json +train/ + _annotations.coco.json # COCO JSON: info, images, categories, annotations + _0001.png # rendered slice image (uses your Display settings) + masks/ + semantic/_0001.png # label map: 0 = background, 1..N = class index + /_0001.png # per-class binary mask (0 / 255) + legend.json # [{ id, name, color }, ...] +valid/ + ... +test/ + ... +``` + +### The COCO JSON + +Each annotation record contains: + +| Field | Notes | +| --- | --- | +| `segmentation` | **Always** present — compressed RLE (pycocotools), exact including holes. | +| `segmentation_poly` | Only if you checked **Include polygon copy in COCO**. | +| `bbox`, `area`, `category_id` | Standard COCO fields. | +| `iscrowd` | Always `0`. | + +The `info` block also records the **render** settings used to produce the PNG +images, so exports are reproducible. `categories[].name` is the SAM3 concept +phrase (the class label). + +## DINOv3 / Lightly format + +Choosing **DINOv3 / Lightly** in Step 4 writes the layout expected by +LightlyTrain's semantic-segmentation trainer instead of COCO: + +```text +classes.json # { "0": "background", "1": "", ... } +manifest.json +train/ + images/_0001.png # rendered slice (uses your Display settings) + masks/_0001.png # single-channel label map, pixel = class index (0 = bg) +val/ # our "valid" split → Lightly's "val" + images/... masks/... +test/ # kept as a held-out split (optional to use) + images/... masks/... +``` + +- **Matching filenames**: each `masks/.png` shares the exact stem of its + `images/.png`, which is how Lightly pairs them. +- **Masks are index label maps** (grayscale PNG, `0` = background, `1..N` = class + index — the same indices as `classes.json`), not colorized. +- Point Lightly's config at the folders, e.g. + `data = { "train": {"images": ".../train/images", "masks": ".../train/masks"}, + "val": {...}, "classes": ".../classes.json" }` (add `"ignore_classes": [0]` to + skip background). + +!!! note "Where the files are written on the server" + Before you download, the backend writes the dataset under `EXPORT_ROOT` + (or `~/data/exports`). The **Download .zip** button streams that folder to + your browser as a zip. + +--- + +## Alternative: Push masks to Tiled + +If your source is a **Tiled** dataset, you can write masks back into Tiled +instead of (or in addition to) downloading. + +1. In the same **Download Dataset** modal, click **Push masks to Tiled**. +2. The backend rasterizes the selected scope and writes stacked mask volumes + into a sibling Tiled container named `__masks`: + - `semantic` — a `uint8 (n, H, W)` class-index volume. + - `` — a `uint8 (n, H, W)` binary (0/255) volume per class. +3. Success message: *"Masks merged into Tiled: {container} ({n} slices total, + {updated} updated)."* + +!!! warning + **Push masks to Tiled** is disabled for local-folder sources (its tooltip + says *"Only available for Tiled sources"*). It produces **no** downloadable + file — the result lives in Tiled. + +--- + +## What is *not* a data export + +A couple of other buttons say "export" but do something different: + +| Button | Location | What it produces | +| --- | --- | --- | +| **Save version** | Annotate sidebar | A server-side version snapshot (not a dataset). See [Saving](annotate.md#saving-your-work). | +| **Export** | Reference tab | `annotation-guide.json` — class descriptions only. See [Annotation guide](reference-guide.md). | + +The **only** way to download annotated segmentation data is the COCO **Export → +Download .zip** flow described above. + +--- + +## Importing a dataset back + +The backend can also re-import a COCO dataset directory into the editor (via its +import endpoint), useful for reviewing or continuing a previous export. This is +a backend capability rather than a prominent UI button. + +--- + +Done! You've installed the tool, annotated images, and exported a COCO dataset. +For a faster refresher, see the [Quick start](../getting-started/quick-start.md), +or speed up your workflow with the [keyboard shortcuts](../reference/shortcuts.md). diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 0000000..1c86815 --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,35 @@ +# Using the tool + +This section is a complete walkthrough of the Segmentation Annotation Studio interface, +tab by tab. If you just want the fastest path to a dataset, read the +[Quick start](../getting-started/quick-start.md) instead. + +## The Finch shell + +Every screen shares the same layout: + +- **Left sidebar** — the icon rail that switches between tabs. +- **Header** — the ALS logo and the app title, *"Segmentation Annotation Tool"*. +- **Main area** — the current tab's content. + +The sidebar contains four tabs by default: + +| Tab | Icon | What it's for | +| --- | --- | --- | +| **Connect** | plug | Choose a Tiled dataset or local folder. | +| **Browse** | magnifier | Filter and pick a sample to annotate. | +| **Reference** | book | Write class descriptions (the annotation guide). | +| **Annotate** | pencil | Draw masks, manage versions, and export. | + +!!! tip "Customize which tabs you see" + Click the floating **Customize Layout** button (top-right) to open + *"Customize Your Layout"* and hide or show tabs. Your choice is saved in + the browser. Click **Apply Changes** to confirm. + +## Recommended reading order + +1. [Connect to data](connect.md) — get the app pointed at your images. +2. [Browse & select](browse.md) — find and open a sample. +3. [Annotate](annotate.md) — the core drawing workflow. +4. [Annotation guide](reference-guide.md) — keep multi-annotator projects consistent. +5. [Export & download](export.md) — produce a COCO dataset. diff --git a/docs/guide/reference-guide.md b/docs/guide/reference-guide.md new file mode 100644 index 0000000..40fe0ec --- /dev/null +++ b/docs/guide/reference-guide.md @@ -0,0 +1,54 @@ +# 4. Annotation guide + +The **Reference** tab (title *"Annotation Guide"*) is an optional but valuable +step for consistency, especially when several people annotate the same dataset. +It lets you describe each class in plain language so everyone labels the same +way. + +!!! note "Requires an open sample" + Reference works on the currently open dataset. If none is open, you'll see a + **Go to Browse** button. + +The tab explains its purpose at the top: + +> *"Describe each class so annotators label consistently. These classes appear +> as one-click suggestions in the Annotate tab. Saved automatically with this +> dataset."* + +--- + +## What you can do here + +| Feature | Description | +| --- | --- | +| **Task notes** | A free-text area for overall labeling instructions. | +| **Per-class description** | A **Class label**, color, and a description (placeholder: *"What is this class? How does it look? When should it (not) be used?"*). | +| **Example crops** | Generate labelled example images per class. | +| **Generate example crops from** | Choose **Current annotation** or a saved version, then click **Generate**. | +| **Add class** | Add a new class to the guide. | +| **Import current classes** | Pull in the classes you already created in Annotate. | +| **Import** / **Export** | Load or save the guide as an `annotation-guide.json` file. | + +--- + +## How it connects to Annotate + +- Classes defined here appear as **quick add** chips in the Annotate + [Classes panel](annotate.md#classes). +- Their descriptions and example crops show up behind the :material-information: + **Info** icon on each class row. +- The guide is saved automatically with the dataset — no manual save needed. + +!!! tip "Sharing across a team" + Use **Export** to download `annotation-guide.json` and share it, then have + teammates use **Import** to load the same class definitions. This keeps a + multi-annotator project aligned. + +!!! warning "This is not a data export" + The **Export** button here downloads only the *guide* (class descriptions and + example crops) — not segmentation masks or a training dataset. For that, see + [Export & download](export.md). + +--- + +Next: [Export & download →](export.md) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..48ccf2c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,90 @@ +--- +title: Segmentation Annotation Studio +hide: + - navigation +--- + +
+ +# Segmentation Annotation Studio + +Draw segmentation masks on scientific images, manage classes and versions, and +export clean datasets — **COCO** for SAM3, or the **DINOv3 / Lightly** layout — +all from your browser. + +
+ +This guide walks you through everything you need to go from a fresh checkout to a +downloadable, annotated dataset: + +
+ +
+### :material-download-box: Install +Get the tool running locally with a single command, or deploy it with Docker. + +[Installation →](getting-started/installation.md){ .md-button } +
+ +
+### :material-rocket-launch: Quick start +The five-minute path: connect, annotate one image, and export a dataset. + +[Quick start →](getting-started/quick-start.md){ .md-button } +
+ +
+### :material-draw: Annotate +Learn every tool — polygon, brush, smart AI (SAM), classes, slices, and versions. + +[Annotate →](guide/annotate.md){ .md-button } +
+ +
+### :material-export: Export +Produce a COCO `.zip` (images, RLE masks, per-class PNGs) or a DINOv3 / Lightly +`images/` + `masks/` layout. + +[Export & download →](guide/export.md){ .md-button } +
+ +
+ +## What is this tool? + +Segmentation Annotation Studio is a manual image-segmentation workspace. It runs as a +web app with three parts working together: + +| Component | Role | Default address | +| --- | --- | --- | +| **Frontend** | The React app you interact with in the browser | | +| **Backend** | FastAPI service that renders images, rasterizes masks, and builds exports | | +| **Tiled** | Data catalog for your source images (plus optional mask / metadata write-back) | | + +You can point the tool at data stored in a **Tiled server** or at a **local +folder** of images (TIFF, PNG, JPG, or NPY). + +## The workflow at a glance + +The app is organized into tabs in the left sidebar. A typical session moves +through them in order: + +```mermaid +graph LR + A[Connect] --> B[Browse]; + B --> C[Annotate]; + C -.optional.-> D[Reference guide]; + C --> E[Export]; + E --> F[Download .zip]; +``` + +1. **Connect** — choose a Tiled dataset or grant access to a local folder. +2. **Browse** — filter samples by metadata, annotation status, or star rating, then open one. +3. **Annotate** — add classes and draw masks with polygon, brush, fill, or the Smart (AI) tool. +4. **Reference** *(optional)* — write class descriptions so annotators stay consistent. +5. **Export** — pick a scope, generate the COCO dataset, and download the `.zip`. + +!!! note "New here?" + Start with [Installation](getting-started/installation.md), then follow the + [Quick start](getting-started/quick-start.md). Each **Using the tool** page + covers one tab in depth. diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md new file mode 100644 index 0000000..e637e47 --- /dev/null +++ b/docs/reference/architecture.md @@ -0,0 +1,512 @@ +# Software architecture + +This page is a developer-oriented tour of how Segmentation Annotation Studio is put +together: the major processes, the modules inside each one, and how a request +flows from a click in the browser all the way to Tiled and back. + +If you only want to *use* the tool, the [Using the tool](../guide/index.md) +section is the place to start. This page is for people who want to extend, +debug, or deploy it. + +## System context + +The application is three cooperating processes. The browser only ever talks to +the **backend**; all catalog and array access is proxied server-side so that +Tiled credentials never reach the client. + +```mermaid +graph LR + User([You in a browser]) + + subgraph Frontend["Frontend · React SPA"] + FE["Vite dev server :5173
(prod: served by backend)"] + end + + subgraph Backend["Backend · FastAPI :8002"] + API["annotation_server.py"] + end + + subgraph Data["Data services"] + Tiled["Tiled server :8010
SQLite catalog + storage"] + Disk[("LOCAL_DATA_ROOT
drafts · versions · exports")] + end + + User --> FE + FE -->|"/api/* (fetch)"| API + API -->|"tiled.client (HTTP)"| Tiled + API --> Disk + User -. never direct .-> Tiled +``` + +| Component | Role | Default address | +| --- | --- | --- | +| **Frontend** | React app the annotator interacts with | | +| **Backend** | FastAPI: renders images, rasterizes masks, builds exports | | +| **Tiled** | Data catalog for source images and mask write-back | | + +## Technology stack + +=== "Frontend" + + | Layer | Technology | Used for | + | --- | --- | --- | + | Framework | **React 18** + **TypeScript** | UI components | + | Build/dev | **Vite 6** | Dev server, `/api` proxy, production bundle | + | Routing | **react-router 7** | `BrowserRouter`, tab navigation | + | Server state | **TanStack Query 5** | Caching image slices, server lists, drafts | + | Client state | **Zustand 5** | Nine stores (see [State management](#state-management)) | + | Undo/redo | **zundo** | Temporal middleware on the annotation store | + | Canvas | **react-konva** / **konva** | Drawing shapes on the image | + | Styling | **Tailwind CSS** | Utility-first styling of the Finch shell | + | Icons | **@phosphor-icons/react** | All UI icons | + | AI | **@huggingface/transformers** | Segment Anything (SAM) in a Web Worker | + +=== "Backend" + + | Concern | Package | Used for | + | --- | --- | --- | + | API | **fastapi** + **uvicorn** | HTTP framework and ASGI server | + | Catalog | **tiled[client]** | Reading/writing the Tiled catalog | + | Arrays | **numpy** | Mask math, statistics, downsampling | + | Images | **pillow**, **matplotlib** | PNG encode/decode, colormaps | + | Rasterize | **scikit-image** | Polygon/ellipse/disk rasterization, contours | + | Export | **pycocotools** | RLE encoding, bbox/area for COCO | + | Files | **tifffile**, **imagecodecs** | Scientific TIFF reads | + | Config | **python-dotenv** | Loading `backend/.env` | + +## Deployment topology + +The dev and production layouts differ mainly in **who serves the SPA** and +**where Tiled lives**. + +=== "Development" + + ```mermaid + flowchart LR + Browser["Browser"] + Vite["Vite :5173"] + Backend["FastAPI :8002"] + Tiled["Tiled :8010"] + Disk[("~/data")] + + Browser -->|localhost:5173| Vite + Vite -->|"/api/* proxy"| Backend + Backend -->|tiled.client| Tiled + Backend --> Disk + ``` + + `start_all.sh` launches all three: Tiled (8010), backend (8002), and the + Vite dev server (5173). Vite proxies every `/api` request to the backend, + so the frontend uses an empty `API_BASE` and stays same-origin. + +=== "Production (Docker)" + + ```mermaid + flowchart LR + Browser["Browser"] + Container["Single container :8002
API + static SPA"] + TiledExt["External Tiled
(TILED_URI env)"] + Volume[("/data volume")] + + Browser -->|same origin| Container + Container --> TiledExt + Container --> Volume + ``` + + The build copies the compiled SPA into `backend/static/`, and FastAPI serves + both the API and the static files from one origin on port 8002. Tiled is an + external service referenced by `TILED_URI`; only `tiled[client]` ships in the + image. + +## Frontend architecture + +### Component shell + +The UI follows the ALS **Finch** hub pattern: a fixed icon sidebar, a header, +and a routed main area. Four tabs map to four page components. + +```mermaid +graph TB + HTML["index.html"] --> MAIN["main.tsx"] + MAIN --> BR["BrowserRouter"] + BR --> QCP["QueryClientProvider"] + QCP --> APP["App.tsx
(route registry, tab persistence)"] + + APP --> HUB["HubAppLayout"] + HUB --> SIDEBAR["HubSidebar"] + HUB --> HEADER["HubHeader"] + HUB --> MAINC["HubMainContent"] + + MAINC --> CONNECT["ConnectPage
/connect"] + MAINC --> BROWSE["BrowsePage
/browse"] + MAINC --> REF["ReferencePage
/reference"] + MAINC --> ANNOT["AnnotatePage
/annotate"] +``` + +| Tab | Route | Page component | Key children | +| --- | --- | --- | --- | +| **Connect** | `/connect` | `ConnectPage` | `IngestDropzone`, server/folder pickers | +| **Browse** | `/browse` | `BrowsePage` | `ColumnBrowser`, `LocalSampleBrowser` | +| **Reference** | `/reference` | `ReferencePage` | inline guide editors | +| **Annotate** | `/annotate` | `AnnotatePage` | `AnnotationCanvas`, `Toolbar`, `ClassManager` | + +!!! note "Export is a modal, not a tab" + Dataset export (COCO or DINOv3/Lightly) lives in `DownloadModal`, opened from + the Annotate sidebar — there is no dedicated Export tab in the current navigation. + +### State management + +State is split across nine **Zustand** stores. Only the annotation store carries +undo/redo history (via **zundo**), and a couple of stores persist to +`localStorage`. + +```mermaid +flowchart TD + subgraph UI["Annotate tab UI"] + AP["AnnotatePage"] + AC["AnnotationCanvas"] + TB["Toolbar"] + CM["ClassManager"] + end + + subgraph Stores["Zustand stores"] + DS["datasetStore
active sample + render opts"] + AS["annotationStore
shapes per image/slice
· zundo temporal"] + TS["toolStore
active tool + brush settings"] + CLS["classStore
annotation classes"] + RG["referenceGuideStore
guide entries"] + CB["clipboardStore
copied shapes"] + end + + AP --> AC & TB & CM + AC --> DS & AS & TS & CLS & CB + TB --> TS & AS + CM --> CLS & AS & RG +``` + +| Store | Holds | Persistence | Undo? | +| --- | --- | --- | --- | +| `annotationStore` | `byImage[sourceKey][slice] → Shape[]`, splits, negatives | draft autosave to backend | **zundo** | +| `toolStore` | active tool, brush size, fill/threshold, selection | memory | — | +| `datasetStore` | active sample, `meta`, `currentSlice`, `renderOpts` | memory | — | +| `classStore` | `AnnotationClass[]` (id, label, color, visibility) | in draft/save payloads | — | +| `connectionStore` | tiled/local URIs, paths, sample count | memory | — | +| `referenceGuideStore` | guide entries, notes, `loadedFor` | backend via `useGuideSync` | — | +| `clipboardStore` | copied shapes | memory | — | +| `settingsStore` | `annotatorName`, `colorblindMode`, anonymous `sessionId` | `localStorage` | — | +| `ratingStore` | per-sample star ratings | `localStorage` | — | + +`toolStore` also carries the eraser/select scope (`eraseAllClasses`, `selectScope`), +the `clipToOtherClasses` (default **on**) and `mergeOverlappingSameClass` toggles, +and `panReturnTool` (so the brush/eraser cursor stays visible while hold-Space +panning). `settingsStore.sessionId` is an anonymous per-install id stamped into +the "Feedback" bug-report context. + +### Canvas rendering + +`AnnotationCanvas` stacks several **react-konva** layers. The in-progress brush +stroke is drawn imperatively through a ref to avoid a Zustand write on every +pointer move; it is committed to the store only on mouse-up. + +```mermaid +graph TB + subgraph Stage["Konva Stage"] + L0["Layer 0 · KonvaImage
preprocessed base (CLAHE/Sharpen baked)
+ client tone/levels via SVG filter"] + L1["Layer 1 · committed shapes
(cached, non-listening)"] + L2["Layer 2 · dimmed drag preview (fill opacity)"] + L2b["Layer 2b · polygon/lasso guide lines
(full opacity, so they stay crisp)"] + L3["Layer 3 · live brush stroke (ref)"] + L4["Layer 4 · cursor ring (ref)"] + end + + DS["datasetStore"] --> UIS["useImageSlice"] + UIS -->|"GET /api/image/slice"| L0 + AS["annotationStore"] --> L1 + TS["toolStore.tool"] --> HANDLERS["pointer handlers"] + HANDLERS --> AS +``` + +Tools resolve to different interactions: `polygon`, `rectangle`, `ellipse`, +`brush`/`eraser`, `fill`, `select`, `pan`, plus AI-assisted `magic` (Segment +Anything or classic magic-wand) and `magnetic` (livewire). Pure geometry, +rasterization, region ops, and the SAM worker live under `src/lib/`. + +The in-progress brush/erase stroke is drawn imperatively on Layer 3 (no store +write per pointer move); polygon and magnetic **guide lines** get their own +full-opacity layer (2b) so they read clearly even when the shape fill opacity is +turned down. Layer 0 shows a **preprocessed base** — CLAHE/Sharpen are baked into +an offscreen canvas so the tools (SAM encode, magic wand, magnetic edge map) +operate on the same enhanced image the user sees, while brightness/contrast/ +levels/gamma/colormap stay on the GPU as an SVG filter over the top. + +### Client-side geometry & tools (`src/lib/`) + +The drawing tools are backed by small, pure, unit-tested modules — no backend +round-trip for editing: + +| Module | Responsibility | +| --- | --- | +| `magicwand.ts` | Classic wand + mask→polygon vectorization (`maskToPolygons`, `maskToPolygonsWithHoles`) | +| `livewire.ts` | Magnetic-lasso edge-cost map, Dijkstra, least-cost `tracePath` | +| `rasterize.ts` | Shapes → binary mask (`rasterizeShapes`, `gridFor`, `fullResGridFor`) | +| `polybool.ts` | True polygon boolean ops (union/difference) via `polygon-clipping` — powers clip, merge, and eraser while **preserving existing vertices** | +| `clipToClasses.ts` / `mergeSameClass.ts` | Clip a new shape against other classes / union with overlapping same-class shapes | +| `regionOps.ts` / `morphology.ts` | Select-tool region ops: merge, grow, shrink, remove islands | +| `clahe.ts` / `sharpen.ts` / `stretch.ts` / `colormaps.ts` | Display-only preprocessors and LUTs | +| `geometry.ts` / `measure.ts` / `datasetStats.ts` | Hit-testing/util, measurement, and the Insights QA metrics | +| `sam/samClient.ts` · `sam/samWorker.ts` · `sam/adjust.ts` | SAM main-thread singleton, the Web Worker, and the display-bake used by tools | + +## Backend architecture + +The backend is a **flat module layout**: one FastAPI app (`annotation_server.py`) +declares every route directly, delegating to focused helper modules. There are no +sub-routers. + +```mermaid +flowchart TB + subgraph API["annotation_server.py · :8002"] + Config["/api/config/*"] + Browse["/api/browse/*"] + Connect["/api/connect · /api/tiled · /api/local"] + Image["/api/image/*"] + Annot["/api/annotations/* · /api/guide* · /api/measure"] + Export["/api/export/* · /api/masks/* · /api/import/*"] + Ingest["/api/ingest/*"] + end + + subgraph Modules["Helper modules"] + TC["tiled_clients
tiled_config"] + BH["browse_helpers"] + AR["arrays · local_fs"] + IM["images · thumbnails"] + DR["drafts · guides"] + CE["coco_export · coco_import"] + TS["tiled_annotation_sync
tiled_mask_sync"] + IG["ingest"] + end + + subgraph Ext["External"] + Tiled["Tiled :8010"] + Disk[("LOCAL_DATA_ROOT")] + end + + Browse --> BH --> Tiled + Browse --> TC --> Tiled + Connect --> TC + Image --> AR --> Tiled + Image --> AR --> Disk + Image --> IM + Annot --> DR --> Disk + Annot --> TS --> Tiled + Export --> CE --> Disk + Export --> TS --> Tiled + Ingest --> IG --> Tiled +``` + +### Module responsibilities + +| Module | Responsibility | +| --- | --- | +| `annotation_server.py` | FastAPI app, all HTTP routes, CORS, static SPA, caching | +| `tiled_config.py` / `tiled_clients.py` | Server config, cached clients, `api_key_for_uri`, browse-root resolution | +| `browse_helpers.py` | Metadata facets and filtered search (`tiled.queries.Key`, `distinct()`) | +| `arrays.py` / `local_fs.py` | Resolve and slice arrays from Tiled or the local filesystem | +| `images.py` / `thumbnails.py` | Slice → PNG rendering (normalize, colormap, scale) | +| `drafts.py` / `guides.py` | Autosave drafts, immutable version history, annotation guides | +| `coco_export.py` / `coco_import.py` | Shape rasterization, COCO build/write, dataset import | +| `export_jobs.py` / `ingest.py` | In-memory background-job registries | +| `tiled_annotation_sync.py` / `tiled_mask_sync.py` | Write `studio_*` metadata and rasterized mask volumes back to Tiled | + +### Data model + +All shape coordinates are **image pixels** — the Konva stage transform is +display-only. Shapes are shared conceptually between the frontend store and the +backend `schemas.py`. + +```mermaid +classDiagram + class Shape { + <> + id + classId + kind + } + class PolygonShape { + points + holes + erased + } + class RectShape { + x, y, w, h + } + class EllipseShape { + cx, cy, rx, ry + } + class BrushShape { + strokes + } + class BrushStroke { + points + radius + mode: paint|erase + } + class AnnotationClass { + classId + label + color + isVisible + } + + Shape <|-- PolygonShape + Shape <|-- RectShape + Shape <|-- EllipseShape + Shape <|-- BrushShape + BrushShape "1" o-- "*" BrushStroke + Shape ..> AnnotationClass : classId +``` + +Samples are identified by a canonical **source key**: + +- Tiled — `tiled::` +- Local — `local:` + +The same key is used by the stores, draft autosave, versioned saves, the +annotation guide, and export, so every artifact for a sample lines up. + +## Key request flows + +### Open a sample for annotation + +Selecting a sample in Browse loads its metadata and any existing draft, then +navigates to the Annotate tab. + +```mermaid +sequenceDiagram + actor User + participant Browse as ColumnBrowser + participant Open as useOpenInAnnotate + participant API as Backend + participant DS as datasetStore + participant AS as annotationStore + participant CS as classStore + + User->>Browse: Select sample → Open in Annotate + Browse->>Open: openTiledArray(path, serverUri) + Open->>API: GET /api/image/meta + API-->>Open: slices, width, height, dtype + Open->>DS: setDataset(...) + Open->>API: GET /api/annotations/draft + API-->>Open: classes, slices, splits + Open->>CS: setClasses (if present) + Open->>AS: mergeSourceDraft(sourceKey, ...) + Open->>Open: navigate('/annotate') +``` + +### Draw, autosave, and save a version + +Drawing mutates the annotation store (recorded by zundo). A debounced autosave +writes a crash-recovery **draft**; an explicit **save** creates an immutable +version and syncs metadata to Tiled. + +```mermaid +sequenceDiagram + actor User + participant Canvas as AnnotationCanvas + participant AS as annotationStore + participant Draft as useDraftSync + participant API as Backend + participant DR as drafts + participant TS as tiled_annotation_sync + participant T as Tiled + + User->>Canvas: Draw a shape + Canvas->>AS: addShape(sourceKey, slice, shape) + Note over AS: zundo records a snapshot + AS-->>Draft: change (debounce ~1.5s) + Draft->>API: PUT /api/annotations/draft + API->>DR: save_draft (atomic write to disk) + + User->>API: POST /api/annotations/save + API->>DR: save_version + thumbnail + API->>TS: sync_annotation_metadata + TS->>T: node.update_metadata(studio_*) + API-->>User: version, saved_at +``` + +### Export a dataset + +Export runs as a background job. The client polls for status and downloads the +finished `.zip`. The `format` field selects the writer: **COCO (SAM3)** +(`write_coco_split` — RLE + images + semantic/per-class masks) or **DINOv3 / +Lightly** (`write_lightly_split` — `images/` + `masks/` label PNGs with matching +stems + `classes.json`). Both share the same rasterization and zip plumbing. + +```mermaid +sequenceDiagram + actor User + participant Modal as DownloadModal + participant Job as useExportJob + participant API as Backend + participant CE as coco_export + participant Disk as LOCAL_DATA_ROOT + + User->>Modal: Choose scope → Export + Modal->>Job: start(request) + Job->>API: POST /api/export/coco + API->>CE: build_export_plan + shape_to_mask + CE->>Disk: write_coco_split + zip + API-->>Job: { job_id } + loop poll ~500ms + Job->>API: GET /api/export/status/{job_id} + API-->>Job: progress + end + Job->>API: GET /api/export/download/{job_id} + API-->>User: dataset.zip +``` + +## Key design decisions + +The choices below explain *why* the code looks the way it does — useful before +extending it. + +- **Backend renders pixels; the browser never sees raw arrays.** `/api/image/slice` + returns an 8-bit PNG (normalize → scale → colormap). This keeps scientific dtypes + and Tiled credentials server-side, makes the frontend format-agnostic, and lets + the backend expose raw intensities only where needed (e.g. `/api/measure`). +- **Shapes are stored in image-pixel coordinates.** The Konva stage transform is + display-only, so annotations are resolution-independent and line up exactly with + the array — no zoom-dependent rounding. +- **Display enhancement is baked for the tools, filtered for the eye.** Nonlinear + preprocessors (CLAHE, Sharpen) are baked into an offscreen base so SAM/wand/livewire + act on what you see; the cheap linear chain (brightness/contrast/levels/gamma/ + colormap) stays on the GPU as an SVG filter. None of it changes exported pixels. +- **Editing uses true polygon boolean geometry at full resolution.** Clip, merge, and + erase go through `polygon-clipping` (`polybool.ts`), not a mask round-trip, so + **untouched vertices are preserved** and repeated edits don't erode a region; the + eraser rebuilds a polygon's vertices to match the carved outline and can split + shapes or open holes. Rasterization uses `fullResGridFor` to avoid downsampling + drift. +- **Brush blobs are connected components.** Overlapping strokes grow one shape; a + disconnected stroke starts a new shape, so each blob is independently selectable. +- **AI runs in the browser, offline-first.** SAM (SlimSAM via `@huggingface/transformers`) + runs in a Web Worker, WebGPU with a WASM fallback, loading vendored local weights + first and only falling back to the HF CDN. No server GPU or model calls. +- **Two-tier persistence.** A debounced **draft** autosaves for crash recovery + (`/api/annotations/draft`, disk only); an explicit **save** writes an immutable + version + thumbnail and syncs summary metadata to Tiled. Everything is keyed by the + canonical **source key** so drafts, versions, guide, and exports line up per sample. +- **Long work is a background job + poll.** Export, mask write-back, and ingest use an + in-memory job registry with a `/status/{id}` poller and (for export) a streamed + `.zip`, rather than blocking the request. + +## Security boundaries + +These invariants are enforced across the codebase (see `AGENTS.md`): + +- **The frontend never calls Tiled directly.** Every catalog read, array slice, + and mask write goes through the backend API. +- **Tiled API keys never reach the browser.** `/api/config/servers` returns only + `has_api_key: bool`; keys are resolved server-side via `api_key_for_uri()`. +- **Local services bind to `127.0.0.1`**, never `0.0.0.0`. +- **Tiled writes require intent** — metadata sync, mask write-back, and ingest are + explicit actions, not side effects of browsing. diff --git a/docs/reference/shortcuts.md b/docs/reference/shortcuts.md new file mode 100644 index 0000000..b9795f8 --- /dev/null +++ b/docs/reference/shortcuts.md @@ -0,0 +1,58 @@ +# Keyboard shortcuts + +Shortcuts work on the Annotate canvas. They're ignored while you're typing in a +text input, text area, or dropdown. + +!!! note "Mac vs. Windows/Linux" + ++cmd++ on macOS is ++ctrl++ on Windows/Linux. Both are shown as + ++cmd++/++ctrl++ below. + +## Tools + +| Key | Tool | +| --- | --- | +| ++s++ | Select | +| ++p++ | Polygon | +| ++m++ | Magnetic | +| ++g++ | Magic | +| ++e++ | Rect | +| ++l++ | Ellipse | +| ++b++ | Brush | +| ++f++ | Fill | +| ++r++ | Eraser | +| hold ++space++ | Pan (reverts to previous tool on release) | + +## Canvas & navigation + +| Key | Action | +| --- | --- | +| ++t++ | Fit image to screen | +| Mouse wheel | Zoom toward cursor | +| ++x++ | Next slice | +| ++left++ / ++right++ | Previous / next slice | +| ++1++–++9++ | Activate class 1–9 | +| ++n++ | New brush instance | + +## Editing + +| Key | Action | +| --- | --- | +| ++cmd+z++ | Undo. **While drafting a polygon/magnetic shape**, removes the last node instead; right after an accidental close, reopens the shape to edit mode. | +| ++cmd+shift+z++ / ++ctrl+y++ | Redo | +| ++cmd+a++ / ++ctrl+a++ | Select all shapes on the slice (Select tool; scoped by the this-class / all-classes radios) | +| ++cmd+c++ | Copy selected shapes (Select tool) | +| ++cmd+v++ | Paste shapes (Select tool) | +| ++i++ | Invert a single selected shape | +| ++delete++ / ++backspace++ | Delete selected shapes | +| ++esc++ | Cancel an in-progress polygon / magic / fill draft | +| ++enter++ | Commit a Magic/Fill selection, or apply an active region op | + +## Mouse modifiers (Magic / Fill / SAM) + +| Action | Effect | +| --- | --- | +| ++shift++ + click | Add to the selection | +| ++alt++ + click (++opt++ on Mac) | Exclude a region ("not" point) | +| Double-click | Finish a Polygon or Magnetic shape | +| Double-click an edge | Add a vertex (Select tool, on a polygon) | +| Double-click a vertex | Delete that vertex (Select tool, on a polygon) | diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md new file mode 100644 index 0000000..d47ba21 --- /dev/null +++ b/docs/reference/troubleshooting.md @@ -0,0 +1,80 @@ +# Troubleshooting + +Common issues and how to resolve them. + +## The app won't start + +??? question "`npm / Node.js 18+ was not found on PATH`" + Install Node.js 18 or newer and make sure `node` and `npm` are on your + `PATH`, then re-run `./start_all.sh`. See [Installation](../getting-started/installation.md#prerequisites). + +??? question "A port is already in use" + The launcher scans upward for a free port automatically. To pin ports, + launch with overrides: + + ```bash + FRONTEND_PORT=5200 BACKEND_PORT=8100 TILED_PORT=8110 ./start_all.sh + ``` + +??? question "The frontend loads but nothing connects" + Check the backend is healthy: + + ```bash + curl http://127.0.0.1:8002/health + ``` + + The Vite dev server proxies `/api` to `http://127.0.0.1:8002`. If you + changed the backend port, set `API_PROXY_TARGET` accordingly. + +## Connecting to data + +??? question "Tiled connection fails or shows 0 samples" + - Confirm `TILED_URI` in `backend/.env` points at a running Tiled server. + - Make sure `TILED_API_KEY` is set (the launcher generates one on first run). + - Use **Re-verify** on the Connect tab after fixing settings. + +??? question "Local folder shows no images" + Only **TIFF, PNG, JPG, and NPY** files are recognized. Confirm you granted an + **absolute** path and selected the folder that directly contains the images. + +## The Smart (AI) tool + +??? question "'Smart (AI)' is disabled or stuck loading" + - The SAM model downloads in the background on first launch; give it a moment. + - If your browser lacks WebGPU it falls back to CPU (slower) — the status + line shows which backend is active. + - You can pre-fetch the model manually: + + ```bash + cd frontend && node scripts/fetch-sam-model.mjs + ``` + +??? question "SAM results look stale after changing brightness" + Adjusting **Display** brightness/contrast re-encodes the slice for SAM. Set + your display first, then use Smart mode. + +## Exporting + +??? question "'Push masks to Tiled' is greyed out" + It only works for **Tiled** sources — it's disabled for local folders. Use + **Export → Download .zip** to get data from a local source. + +??? question "No 'Download .zip' button appears" + The download button only shows for COCO **Export** jobs, not for **Push masks + to Tiled** jobs (which write to Tiled, not to a file). + +??? question "Exported images look different from the canvas" + Exported PNGs bake in your **Display** render settings (brightness, contrast, + colormap, gamma). Adjust those before exporting if needed. + +## Saving + +??? question "Did I lose my work?" + The app autosaves a local draft roughly every 1.5 seconds for crash recovery. + For a durable, restorable snapshot, use **Save → Save version**. Recover + earlier states from **Version History**. + +--- + +Still stuck? Check the terminal running `start_all.sh` for backend and Tiled +logs — most connection and export errors surface there. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..3b08604 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +# Documentation build dependencies for Segmentation Annotation Studio. +# Install with: pip install -r docs/requirements.txt +mkdocs>=1.6 +mkdocs-material>=9.6 +pymdown-extensions>=10.7 diff --git a/docs/stylesheets/finch.css b/docs/stylesheets/finch.css new file mode 100644 index 0000000..500623f --- /dev/null +++ b/docs/stylesheets/finch.css @@ -0,0 +1,248 @@ +/* ------------------------------------------------------------------ * + * Finch theme for MkDocs Material + * Mirrors the Segmentation Annotation Studio "Finch" shell (Tailwind sky/slate). + * sidebar sky-950 #082f49 main sky-900 #0c4a6e + * header sky-100 #e0f2fe active sky-300 #7dd3fc + * primary sky-600 #0284c7 accent sky-400 #38bdf8 + * ------------------------------------------------------------------ */ + +:root { + --radius: 0.5rem; + + /* Finch sky scale */ + --finch-sky-50: #f0f9ff; + --finch-sky-100: #e0f2fe; + --finch-sky-200: #bae6fd; + --finch-sky-300: #7dd3fc; + --finch-sky-400: #38bdf8; + --finch-sky-500: #0ea5e9; + --finch-sky-600: #0284c7; + --finch-sky-700: #0369a1; + --finch-sky-800: #075985; + --finch-sky-900: #0c4a6e; + --finch-sky-950: #082f49; +} + +/* ---------- Light mode (default scheme) ------------------------------ */ +[data-md-color-scheme="default"] { + --md-primary-fg-color: var(--finch-sky-950); + --md-primary-fg-color--light: var(--finch-sky-900); + --md-primary-fg-color--dark: #041f31; + --md-primary-bg-color: #ffffff; + --md-primary-bg-color--light: rgba(255, 255, 255, 0.7); + + --md-accent-fg-color: var(--finch-sky-600); + --md-accent-fg-color--transparent: rgba(2, 132, 199, 0.1); + + --md-typeset-a-color: var(--finch-sky-700); + --md-footer-bg-color: var(--finch-sky-950); + --md-footer-bg-color--dark: #041f31; +} + +/* ---------- Dark mode (slate scheme) --------------------------------- */ +[data-md-color-scheme="slate"] { + --md-hue: 210; + --md-primary-fg-color: var(--finch-sky-950); + --md-primary-fg-color--light: var(--finch-sky-800); + --md-primary-fg-color--dark: #04151f; + --md-primary-bg-color: #e0f2fe; + + --md-accent-fg-color: var(--finch-sky-400); + --md-accent-fg-color--transparent: rgba(56, 189, 248, 0.1); + + --md-default-bg-color: #0c1a26; + --md-typeset-a-color: var(--finch-sky-300); + --md-footer-bg-color: #041f31; +} + +/* ---------- Typography ---------------------------------------------- */ +.md-typeset h1, +.md-typeset h2 { + font-weight: 600; + letter-spacing: -0.01em; + color: var(--finch-sky-900); +} +[data-md-color-scheme="slate"] .md-typeset h1, +[data-md-color-scheme="slate"] .md-typeset h2 { + color: var(--finch-sky-200); +} + +.md-typeset h2 { + margin-top: 2.4rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--md-default-fg-color--lightest); +} + +/* ---------- Header / logo ------------------------------------------- */ +.md-header { + box-shadow: 0 2px 6px rgba(8, 47, 73, 0.25); +} +.md-header__button.md-logo img { + height: 2rem; + width: 2rem; + border-radius: var(--radius); +} + +/* ---------- Navigation: active item = Finch sky-300 ----------------- */ +.md-nav__link--active, +.md-nav__item .md-nav__link--active { + color: var(--finch-sky-700); + font-weight: 600; +} +[data-md-color-scheme="slate"] .md-nav__link--active { + color: var(--finch-sky-300); +} + +.md-nav__item--section > .md-nav__link { + color: var(--finch-sky-800); +} +[data-md-color-scheme="slate"] .md-nav__item--section > .md-nav__link { + color: var(--finch-sky-300); +} + +/* ---------- Rounded corners everywhere (Finch --radius) ------------- */ +.md-typeset .admonition, +.md-typeset details, +.md-typeset pre > code, +.md-typeset .tabbed-set > .tabbed-content, +.md-typeset table:not([class]), +.md-typeset img { + border-radius: var(--radius); +} + +/* ---------- Code blocks --------------------------------------------- */ +.md-typeset pre > code { + border: 1px solid var(--md-default-fg-color--lightest); +} +.md-typeset code { + border-radius: calc(var(--radius) - 2px); +} + +/* ---------- Buttons (.md-button) — Finch primary -------------------- */ +.md-typeset .md-button { + border-radius: var(--radius); + border-width: 2px; + transition: background-color 150ms, color 150ms, border-color 150ms; +} +.md-typeset .md-button--primary { + background-color: var(--finch-sky-600); + border-color: var(--finch-sky-600); + color: #ffffff; +} +.md-typeset .md-button--primary:hover { + background-color: var(--finch-sky-700); + border-color: var(--finch-sky-700); + color: #ffffff; +} +.md-typeset .md-button:not(.md-button--primary):hover { + background-color: var(--finch-sky-600); + border-color: var(--finch-sky-600); + color: #ffffff; +} + +/* ---------- Tables --------------------------------------------------- */ +.md-typeset table:not([class]) { + border: 1px solid var(--md-default-fg-color--lightest); + overflow: hidden; +} +.md-typeset table:not([class]) th { + background-color: var(--finch-sky-900); + color: #ffffff; + font-weight: 600; +} + +/* ---------- Admonitions: recolor "note" to Finch sky ---------------- */ +.md-typeset .admonition.note, +.md-typeset details.note { + border-color: var(--finch-sky-500); +} +.md-typeset .note > .admonition-title, +.md-typeset .note > summary { + background-color: rgba(14, 165, 233, 0.1); +} +.md-typeset .note > .admonition-title::before, +.md-typeset .note > summary::before { + background-color: var(--finch-sky-500); +} + +/* ---------- Keyboard keys ------------------------------------------- */ +.md-typeset kbd { + background-color: var(--finch-sky-50); + border-radius: calc(var(--radius) - 2px); + box-shadow: + 0 0 0 1px var(--finch-sky-200), + 0 2px 0 var(--finch-sky-200); + color: var(--finch-sky-900); + padding: 0 0.4em; +} +[data-md-color-scheme="slate"] .md-typeset kbd { + background-color: #10263a; + color: var(--finch-sky-100); + box-shadow: + 0 0 0 1px var(--finch-sky-800), + 0 2px 0 var(--finch-sky-800); +} + +/* ---------- Home hero ----------------------------------------------- */ +.finch-hero { + background: linear-gradient(135deg, var(--finch-sky-950) 0%, var(--finch-sky-800) 100%); + color: #ffffff; + border-radius: calc(var(--radius) * 1.5); + padding: 2.5rem 2rem; + margin: 1rem 0 2rem; + box-shadow: 0 10px 15px -3px rgba(8, 47, 73, 0.35); +} +.finch-hero h1 { + color: #ffffff !important; + margin: 0 0 0.5rem; + font-size: 2rem; +} +.finch-hero p { + color: var(--finch-sky-100); + font-size: 1.05rem; + margin: 0.25rem 0 0; + max-width: 42rem; +} + +/* ---------- Feature grid on home ------------------------------------ */ +.finch-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} +.finch-card { + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: var(--radius); + padding: 1.25rem; + background-color: var(--md-default-bg-color); + transition: border-color 150ms, box-shadow 150ms, transform 150ms; +} +.finch-card:hover { + border-color: var(--finch-sky-400); + box-shadow: 0 4px 12px rgba(2, 132, 199, 0.15); + transform: translateY(-2px); +} +.finch-card h3 { + margin-top: 0.25rem; + color: var(--finch-sky-700); +} +[data-md-color-scheme="slate"] .finch-card h3 { + color: var(--finch-sky-300); +} + +/* ---------- Colored "chip" for UI labels in prose ------------------- */ +.finch-chip { + display: inline-block; + padding: 0.05em 0.5em; + border-radius: 9999px; + background-color: var(--finch-sky-100); + color: var(--finch-sky-800); + font-size: 0.85em; + font-weight: 600; + white-space: nowrap; +} +[data-md-color-scheme="slate"] .finch-chip { + background-color: #10344d; + color: var(--finch-sky-200); +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..d3c4d89 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,14 @@ +# Frontend build-time config (Vite). Copy to `.env` and set as needed. +# Only VITE_-prefixed vars are exposed to the client. `.env.example` is NOT loaded. + +# Backend API base. Leave empty for same-origin (dev uses the /api proxy). +# VITE_API_BASE= + +# Documentation site URL (the sidebar "Docs" button). +# VITE_DOCS_URL=http://127.0.0.1:8000 + +# "Bugs & Feature Requests" Google Form. Set both to show the sidebar "Feedback" +# button. FORM_URL is the form's .../viewform link; ENTRY_ID is the numeric +# entry. of a single long-answer "context" question to prefill. +# VITE_FEEDBACK_FORM_URL=https://docs.google.com/forms/d/e/XXXXXXXX/viewform +# VITE_FEEDBACK_ENTRY_ID=123456789 diff --git a/frontend/index.html b/frontend/index.html index 0bdc307..ef45032 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,11 @@ - Tiled Browse Hub + + Segmentation Annotation Studio
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 119d67c..0b22e09 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,12 @@ "version": "0.1.0", "dependencies": { "@blueskyproject/finch": "^0.1.7", + "@huggingface/transformers": "^4.2.0", "@phosphor-icons/react": "^2.1.7", "@tanstack/react-query": "^5.99.0", "clsx": "^2.1.1", "konva": "^9.3.20", + "polygon-clipping": "^0.15.7", "react": "^18.3.1", "react-dom": "^18.3.1", "react-konva": "^18.2.10", @@ -545,6 +547,16 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", @@ -938,76 +950,569 @@ "node": ">=18" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "license": "Apache-2.0" + }, + "node_modules/@huggingface/transformers": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", + "sharp": "^0.34.5" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1102,6 +1607,63 @@ "react-dom": ">= 16.8" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/number": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", @@ -3392,6 +3954,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3594,6 +4165,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -4102,7 +4680,6 @@ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", - "peer": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -4125,6 +4702,23 @@ "node": ">=8" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", @@ -4143,6 +4737,21 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -4240,6 +4849,12 @@ "node": ">= 0.4" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4304,6 +4919,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -4397,6 +5024,12 @@ "node": ">=8" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4542,6 +5175,51 @@ "node": ">=10.13.0" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globrex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", @@ -4567,12 +5245,17 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", - "peer": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -4968,6 +5651,12 @@ "node": ">=6" } }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5037,6 +5726,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5102,6 +5797,18 @@ "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==", "license": "MIT" }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-expression-evaluator": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz", @@ -5255,6 +5962,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.26.0-dev.20260416-b7804b056c", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", + "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", + "license": "MIT" + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -5345,6 +6104,21 @@ "node": ">= 6" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/polygon-clipping": { + "version": "0.15.7", + "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz", + "integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==", + "dependencies": { + "robust-predicates": "^3.0.2", + "splaytree": "^3.1.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5554,6 +6328,29 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5946,6 +6743,23 @@ "node": ">=0.10.0" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -6084,6 +6898,39 @@ "semver": "bin/semver.js" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -6108,6 +6955,62 @@ "node": ">= 0.4" } }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -6206,6 +7109,20 @@ "node": ">=0.10.0" } }, + "node_modules/splaytree": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.2.3.tgz", + "integrity": "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==", + "engines": { + "node": ">=18.20 || >=20" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 43e1ea2..2750e66 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,18 +5,20 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc -b && vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -b", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { "@blueskyproject/finch": "^0.1.7", + "@huggingface/transformers": "^4.2.0", "@phosphor-icons/react": "^2.1.7", "@tanstack/react-query": "^5.99.0", "clsx": "^2.1.1", "konva": "^9.3.20", + "polygon-clipping": "^0.15.7", "react": "^18.3.1", "react-dom": "^18.3.1", "react-konva": "^18.2.10", diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/frontend/scripts/fetch-sam-model.mjs b/frontend/scripts/fetch-sam-model.mjs new file mode 100644 index 0000000..c9af71e --- /dev/null +++ b/frontend/scripts/fetch-sam-model.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Vendor the SlimSAM model files for local/offline use of the Magic tool's + * "Smart (AI)" (SAM) engine. + * + * The browser cannot stream the model from the Hugging Face CDN on many machines + * (403 Forbidden via the Xet CDN), which greys out "Smart (AI)". This script + * downloads the model into frontend/public/models/slimsam-77-uniform/ so the app + * loads it locally (samWorker.ts is local-first with a remote fallback). + * + * Plain HTTP fetch also 403s, so we vendor via Python `huggingface_hub` + * (snapshot_download). It uses the repo venv python: set PYTHON to override, + * else falls back to ../.venv/bin/python then `python3`. + * + * node scripts/fetch-sam-model.mjs + * + * start_all.sh runs this automatically (best-effort, backgrounded) on startup. + */ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO = 'Xenova/slimsam-77-uniform'; +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = join(HERE, '..', 'public', 'models', 'slimsam-77-uniform'); + +/** Resolve a python interpreter: $PYTHON, then repo ../.venv, then python3. */ +function resolvePython() { + if (process.env.PYTHON) return process.env.PYTHON; + const venv = join(HERE, '..', '..', '.venv', 'bin', 'python'); + if (existsSync(venv)) return venv; + return 'python3'; +} + +const python = resolvePython(); +console.log(`Vendoring ${REPO} → ${OUT}\n using python: ${python}`); + +// Ensure huggingface_hub is available (no-op if already installed). +spawnSync(python, ['-m', 'pip', 'install', '-q', 'huggingface_hub'], { stdio: 'inherit' }); + +const code = ` +import sys +from huggingface_hub import snapshot_download +p = snapshot_download(${JSON.stringify(REPO)}, local_dir=${JSON.stringify(OUT)}) +print("SAM model vendored to", p) +`; +const res = spawnSync(python, ['-c', code], { stdio: 'inherit' }); + +if (res.status !== 0) { + console.error('\nFetch failed. Ensure the machine has network access to huggingface.co'); + console.error('and that huggingface_hub is installed in the venv. Smart (AI) will fall'); + console.error('back to the remote model until the files are vendored.'); + process.exit(res.status ?? 1); +} +console.log('Done. Hard-refresh the app to load SAM from /models/.'); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index e47ee8a..f2968eb 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,14 +1,20 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, lazy } from 'react'; import { useNavigate, useLocation, Navigate } from 'react-router'; import './App.css'; import { RouteItem } from '@/types/navigationRouterTypes'; import HubAppLayout from '@/components/HubAppLayout'; import { useHubSelectedTabs } from '@/hooks/useHubSelectedTabs'; -import { PlugsConnected, PencilSimple, MagnifyingGlass } from '@phosphor-icons/react'; -import ConnectPage from './pages/ConnectPage'; -import AnnotatePage from './pages/AnnotatePage'; -import BrowsePage from './pages/BrowsePage'; +import { DOCS_URL, FEEDBACK_FORM_URL, FEEDBACK_ENTRY_ID } from '@/config'; +import { buildFeedbackContext, buildFeedbackUrl } from '@/lib/feedbackContext'; +import { PlugsConnected, PencilSimple, MagnifyingGlass, BookOpen } from '@phosphor-icons/react'; +// Lazy-loaded pages: keeps the heavy Annotate stack (konva, polygon-clipping, +// magicwand, canvas) out of the initial /connect bundle — each page is its own chunk. +const ConnectPage = lazy(() => import('./pages/ConnectPage')); +const AnnotatePage = lazy(() => import('./pages/AnnotatePage')); +const BrowsePage = lazy(() => import('./pages/BrowsePage')); +const ReferencePage = lazy(() => import('./pages/ReferencePage')); import CustomizePages from '@/components/CustomizePages'; +import IframeModal from '@/components/IframeModal'; const allRoutes: RouteItem[] = [ { @@ -24,6 +30,12 @@ const allRoutes: RouteItem[] = [ element: , isBackgroundTransparent: true, }, + { + path: '/reference', + label: 'Reference', + icon: , + element: , + }, { path: '/annotate', label: 'Annotate', @@ -35,11 +47,17 @@ const allRoutes: RouteItem[] = [ const DEFAULT_PATHS = allRoutes.map((r) => r.path); +/** + * App — root component: validates persisted tab selection against known routes, redirects on + * first load, and renders the hub layout plus the tab-customisation modal. + */ function App() { const { selectedPaths, setSelectedPaths } = useHubSelectedTabs(); const navigate = useNavigate(); const location = useLocation(); const [showTabSelector, setShowTabSelector] = useState(false); + // Docs / Feedback open in an in-app iframe modal rather than a new tab. + const [iframeModal, setIframeModal] = useState<{ title: string; url: string } | null>(null); // Validate stored paths — discard unknown paths and merge in any newly added tabs. const storedValid = selectedPaths?.filter((p) => DEFAULT_PATHS.includes(p)) ?? null; @@ -80,13 +98,26 @@ function App() { <> setIframeModal({ title: 'Documentation', url: DOCS_URL }) : undefined} + onFeedback={ + FEEDBACK_FORM_URL + ? () => setIframeModal({ + title: 'Bugs & Feature Requests', + url: buildFeedbackUrl(FEEDBACK_FORM_URL, FEEDBACK_ENTRY_ID, buildFeedbackContext(), true), + }) + : undefined + } /> + {iframeModal && ( + setIframeModal(null)} /> + )} ); } diff --git a/frontend/src/app/pages/AnnotatePage.tsx b/frontend/src/app/pages/AnnotatePage.tsx index 6363346..b11098c 100644 --- a/frontend/src/app/pages/AnnotatePage.tsx +++ b/frontend/src/app/pages/AnnotatePage.tsx @@ -2,40 +2,51 @@ * AnnotatePage — react-konva canvas workspace with sidebar tools. */ import { useState, useEffect, useCallback } from 'react'; -import { DownloadSimple, FloppyDisk, ClockCounterClockwise, CircleDashed } from '@phosphor-icons/react'; +import { useNavigate } from 'react-router'; +import { DownloadSimple, FloppyDisk, ClockCounterClockwise, CircleDashed, ChartBar } from '@phosphor-icons/react'; import { useDatasetStore } from '@/stores/datasetStore'; import { useAnnotationStore } from '@/stores/annotationStore'; import { useToolStore } from '@/stores/toolStore'; import { useClassStore } from '@/stores/classStore'; import { useDraftSync } from '@/hooks/useDraftSync'; +import { useGuideLoad } from '@/hooks/useGuideSync'; import { useSave, type VersionPayload } from '@/hooks/useSave'; import { buildSourceKey } from '@/lib/sourceKey'; import { useKeybinds } from '@/hooks/useKeybinds'; +import type { ColormapName } from '@/lib/colormaps'; import Toolbar from '@/components/annotate/Toolbar'; import ClassManager from '@/components/annotate/ClassManager'; import DisplayControls from '@/components/annotate/DisplayControls'; import SliceNavigator from '@/components/annotate/SliceNavigator'; +import MaskToolsPanel from '@/components/annotate/MaskToolsPanel'; +import MeasurementPanel from '@/components/annotate/MeasurementPanel'; import AnnotationCanvas from '@/components/annotate/AnnotationCanvas'; +import DebouncedSlider from '@/components/common/DebouncedSlider'; import DownloadModal from '@/components/annotate/DownloadModal'; +import InsightsModal from '@/components/annotate/InsightsModal'; import VersionHistoryModal from '@/components/annotate/VersionHistoryModal'; import VersionPreviewBar from '@/components/annotate/VersionPreviewBar'; import SaveModal from '@/components/annotate/SaveModal'; import type { SaveDraftPayload } from '@/hooks/useSave'; +/** Renders the annotation workspace: tool sidebar, canvas, and save/version/export flows. */ export default function AnnotatePage() { + const navigate = useNavigate(); const { source, kind, serverUri, meta } = useDatasetStore(); - const { removeShape } = useAnnotationStore(); - const { selectedShapeId, setSelectedShapeId } = useToolStore(); + const { removeShapes } = useAnnotationStore(); + const { selectedShapeIds, setSelectedShapeId, fillOpacity, setFillOpacity } = useToolStore(); const { classes } = useClassStore(); const [activeClassId, setActiveClassId] = useState(null); const [activeBrushShapeId, setActiveBrushShapeId] = useState(null); + /** Sets the active class and clears any in-progress brush instance. */ const handleActivateClass = useCallback((classId: number) => { setActiveClassId(classId); setActiveBrushShapeId(null); }, []); + /** Resets the active brush instance after a class is deleted. */ const handleClassDeleted = useCallback((_deletedClassId: number) => { setActiveBrushShapeId(null); }, []); @@ -47,7 +58,20 @@ export default function AnnotatePage() { const [brightness, setBrightness] = useState(0); const [contrast, setContrast] = useState(0); + // Min/max levels window (0–255) + histogram of the current slice (client-side). + const [levelsLo, setLevelsLo] = useState(0); + const [levelsHi, setLevelsHi] = useState(255); + const [histogramBins, setHistogramBins] = useState(null); + // Display-only false-color map + gamma. + const [colormap, setColormap] = useState('gray'); + const [gamma, setGamma] = useState(1); + // Display-only nonlinear preprocessors (adaptive CLAHE / Sharpen). + const [clahe, setClahe] = useState(false); + const [sharpen, setSharpen] = useState(false); const [showDownload, setShowDownload] = useState(false); + const [showInsights, setShowInsights] = useState(false); + // Region to zoom to + highlight on the canvas (from an Insights QA flag). + const [focusRegion, setFocusRegion] = useState<{ x: number; y: number; w: number; h: number; nonce: number } | null>(null); const [showVersionHistory, setShowVersionHistory] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false); const [saveModalPayload, setSaveModalPayload] = useState(null); @@ -63,11 +87,19 @@ export default function AnnotatePage() { // Crash-recovery autosave (local draft only, no Tiled sync) useDraftSync(sourceKey); + // Load the dataset's annotation guide (read-only) for class suggestions/examples. + useGuideLoad(sourceKey); // Explicit versioned save const { isDirty, isSaving, lastSavedAt, save, buildSavePayload, saveSummary, versions, fetchVersionPayload, restoreVersion } = useSave(sourceKey); - const { currentSlice } = useDatasetStore(); + const { currentSlice, setSlice } = useDatasetStore(); + + /** From an Insights QA flag: jump to its slice and zoom/highlight its region. */ + const handleInsightFocus = useCallback((slice: number, bbox?: { x: number; y: number; w: number; h: number }) => { + setSlice(slice); + setFocusRegion(bbox ? { ...bbox, nonce: Date.now() } : null); + }, [setSlice]); // Load the previewed version's payload (cached) whenever the slider moves. useEffect(() => { @@ -90,6 +122,7 @@ export default function AnnotatePage() { setPreviewVersion(null); }, [sourceKey]); + /** Restores the given version into the editor and exits preview mode. */ const handleRestoreFromPreview = useCallback((version: number) => { restoreVersion(version); setPreviewVersion(null); @@ -100,12 +133,14 @@ export default function AnnotatePage() { ? (previewPayload.slices[String(currentSlice)] ?? []) : null; + /** Removes the currently selected shapes from the active slice and clears the selection. */ const handleDeleteSelected = () => { - if (!sourceKey || !selectedShapeId) return; - removeShape(sourceKey, currentSlice, selectedShapeId); + if (!sourceKey || selectedShapeIds.length === 0) return; + removeShapes(sourceKey, currentSlice, selectedShapeIds); setSelectedShapeId(null); }; + /** Builds the save payload and opens the save modal (no-op if nothing to save). */ const handleOpenSaveModal = () => { const payload = buildSavePayload(); if (!payload) return; @@ -113,6 +148,7 @@ export default function AnnotatePage() { setShowSaveModal(true); }; + /** Saves the version and closes the modal on success. */ const handleConfirmSave = async (opts: { annotatedBy: string; notes: string; thumbnailBase64?: string }) => { const ok = await save(opts); if (ok) { @@ -121,6 +157,7 @@ export default function AnnotatePage() { } }; + /** Cancels the in-progress draft by clearing the active brush instance. */ const handleCancelDraft = () => { setActiveBrushShapeId(null); }; @@ -135,8 +172,15 @@ export default function AnnotatePage() { if (!meta) { return ( -
-

No sample loaded. Go to Browse to pick a sample.

+
+

No sample loaded. Pick a sample to annotate.

+
); } @@ -151,25 +195,50 @@ export default function AnnotatePage() { <>
{/* Sidebar */} -
- +
+ + `${v}%`} + min={0} + max={100} + value={Math.round(fillOpacity * 100)} + onChange={(v) => setFillOpacity(v / 100)} + /> +
+
{ setBrightness(0); setContrast(0); }} - /> -
- { setBrightness(0); setContrast(0); setLevelsLo(0); setLevelsHi(255); setColormap('gray'); setGamma(1); setClahe(false); setSharpen(false); }} + histogramBins={histogramBins} + levelsLo={levelsLo} + levelsHi={levelsHi} + onLevelsChange={(lo, hi) => { setLevelsLo(lo); setLevelsHi(hi); }} + onLevelsReset={() => { setLevelsLo(0); setLevelsHi(255); }} + colormap={colormap} + gamma={gamma} + onColormapChange={setColormap} + onGammaChange={setGamma} + clahe={clahe} + sharpen={sharpen} + onClaheChange={setClahe} + onSharpenChange={setSharpen} />

+ +
+ +
{/* Save button + status */}
@@ -194,7 +263,7 @@ export default function AnnotatePage() { {/* Dirty / saved status line */} -
+
{isDirty ? 'Unsaved changes' : (savedLabel ?? 'No saves yet')} {versions.length > 0 && (
+ {/* Insights */} + + {/* Download / export */}
@@ -226,11 +305,19 @@ export default function AnnotatePage() { {previewVersion !== null && ( {showDownload && setShowDownload(false)} />} + {showInsights && ( + setShowInsights(false)} + onFocus={handleInsightFocus} + /> + )} {showSaveModal && saveModalPayload && sourceKey && ( ('all'); @@ -32,6 +33,7 @@ export default function BrowsePage() { enabled: kind === 'tiled', }); + /** Switch the active Tiled connection to the chosen server URI. */ const handleServerChange = (uri: string) => { setConnection({ kind: 'tiled', @@ -64,7 +66,7 @@ export default function BrowsePage() { {label} {sampleCount !== null && ( - + · {sampleCount} sample{sampleCount !== 1 ? 's' : ''} )} @@ -77,26 +79,31 @@ export default function BrowsePage() {
- {/* Main browser */} - {kind === 'tiled' && serverUri && ( - - )} - {kind === 'local' && ( - - )} + {/* Main browser — flex-1 so it fills the space under the banner and its + own internal scroll areas are bounded (otherwise the bottom is clipped). */} +
+ {kind === 'tiled' && serverUri && ( + + )} + {kind === 'local' && ( + + )} +
); } diff --git a/frontend/src/app/pages/ConnectPage.tsx b/frontend/src/app/pages/ConnectPage.tsx index 070edd3..92407fb 100644 --- a/frontend/src/app/pages/ConnectPage.tsx +++ b/frontend/src/app/pages/ConnectPage.tsx @@ -1,13 +1,32 @@ /** - * ConnectPage — choose a Tiled server or local folder, establish the connection, - * see the sample count, then navigate to Browse to pick individual samples. + * ConnectPage — split into two stacked sections: + * + * 1. "Connect to Tiled" — pick a server (and, optionally, a browse container), + * then verify the connection. Verifying stores the connection but does NOT + * navigate; a "Go to Browse" action appears on success. + * 2. "Load / Ingest Datasets" — drag-and-drop new data into the server, tagging + * it with classes/keywords. + * + * Local mode lets the user grant access to any absolute folder on the backend + * machine and connect to it directly. */ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; -import { PlugsConnected, Folder, HardDrives } from '@phosphor-icons/react'; +import { + PlugsConnected, + Folder, + HardDrives, + Stack, + CaretRight, + CaretDown, + ArrowRight, + UploadSimple, +} from '@phosphor-icons/react'; import { API_BASE } from '@/config'; import { useConnectionStore } from '@/stores/connectionStore'; +import { useOpenInAnnotate } from '@/hooks/useOpenInAnnotate'; +import IngestDropzone from '@/components/Ingest/IngestDropzone'; interface ServerInfo { name: string; @@ -22,16 +41,32 @@ interface LocalEntry { size: number | null; } +interface TiledEntry { + name: string; + path: string; + is_dir: boolean; + is_array: boolean; +} + export default function ConnectPage() { const navigate = useNavigate(); const { setConnection } = useConnectionStore(); + const { openTiledArray } = useOpenInAnnotate(); const [mode, setMode] = useState<'tiled' | 'local'>('tiled'); - // Tiled: just pick the server + // Tiled: pick the server + optionally a browse container const [selectedServerUri, setSelectedServerUri] = useState(''); + const [containerDir, setContainerDir] = useState(''); // currently-browsed container node + const [selectedContainer, setSelectedContainer] = useState(''); // chosen browse target + // "Dataset to view" is optional (auto-detected), so keep it collapsed by default. + const [showDatasetPicker, setShowDatasetPicker] = useState(false); + // True once a connection has been verified — reveals the "Go to Browse" action. + const [connected, setConnected] = useState(false); - // Local: browse directories until you pick a folder + // Local: grant an absolute root, then browse subfolders until you pick a folder + const [grantedRoot, setGrantedRoot] = useState(''); + const [rootInput, setRootInput] = useState(''); const [browseDir, setBrowseDir] = useState(''); const [selectedFolder, setSelectedFolder] = useState(''); @@ -48,50 +83,92 @@ export default function ConnectPage() { }, }); - // Default to the first server useEffect(() => { if (!selectedServerUri && servers.length > 0) setSelectedServerUri(servers[0].uri); }, [servers, selectedServerUri]); + // --- Tiled container listing (for the optional browse-target picker) --- + const { data: tiledEntries = [] } = useQuery({ + queryKey: ['tiledList', selectedServerUri, containerDir], + queryFn: async () => { + const res = await fetch( + `${API_BASE}/api/tiled/list?server_uri=${encodeURIComponent(selectedServerUri)}&path=${encodeURIComponent(containerDir)}`, + ); + if (!res.ok) throw new Error(await res.text()); + return res.json(); + }, + enabled: mode === 'tiled' && !!selectedServerUri, + }); + // --- Local directory listing --- const { data: localEntries = [], isLoading: localLoading } = useQuery({ - queryKey: ['localList', browseDir], + queryKey: ['localList', grantedRoot, browseDir], queryFn: async () => { - const res = await fetch(`${API_BASE}/api/local/list?rel=${encodeURIComponent(browseDir)}`); + const res = await fetch( + `${API_BASE}/api/local/list?root=${encodeURIComponent(grantedRoot)}&rel=${encodeURIComponent(browseDir)}`, + ); if (!res.ok) throw new Error(await res.text()); return res.json(); }, - enabled: mode === 'local', + enabled: mode === 'local' && !!grantedRoot, }); - const canConnect = - mode === 'tiled' ? !!selectedServerUri : !!selectedFolder; + const canConnect = mode === 'tiled' ? !!selectedServerUri : !!grantedRoot && !!selectedFolder; + + /** Set the Tiled connection (optional browse container) and, by default, navigate to Browse. */ + const connectTiled = (containerPath: string | null, gotoBrowse = true) => { + setConnection({ + kind: 'tiled', + serverUri: selectedServerUri, + browseContainerPath: containerPath, + label: servers.find((s) => s.uri === selectedServerUri)?.name ?? selectedServerUri, + sampleCount: 0, + }); + if (gotoBrowse) navigate('/browse'); + }; + + // Jump straight from ingest to the Annotate tab for the first uploaded sample. + const annotateIngested = (containerPath: string, firstKey: string) => { + connectTiled(containerPath, false); // set connection context, don't navigate to Browse + void openTiledArray(`${containerPath}/${firstKey}`, selectedServerUri); // navigates to /annotate + }; - const handleConnect = async () => { - setStatus('Connecting…'); + /** + * Fetch a connection summary for the chosen Tiled server or local folder and + * store it. When *navigateAfter* is true (local mode), jump to Browse; when + * false (Tiled "Verify"), stay put and reveal the "Go to Browse" action. + */ + const handleConnect = async (navigateAfter: boolean) => { + setStatus(navigateAfter ? 'Connecting…' : 'Verifying…'); setConnecting(true); try { - let summaryUrl: string; + const params = new URLSearchParams({ kind: mode }); if (mode === 'tiled') { - summaryUrl = `${API_BASE}/api/connect/summary?kind=tiled&server_uri=${encodeURIComponent(selectedServerUri)}`; + params.set('server_uri', selectedServerUri); + if (selectedContainer) params.set('container_path', selectedContainer); } else { - summaryUrl = `${API_BASE}/api/connect/summary?kind=local&rel=${encodeURIComponent(selectedFolder)}`; + params.set('root', grantedRoot); + params.set('rel', selectedFolder); } - const res = await fetch(summaryUrl); + const res = await fetch(`${API_BASE}/api/connect/summary?${params}`); if (!res.ok) throw new Error(await res.text()); const summary = await res.json(); setConnection({ kind: summary.kind, serverUri: summary.server_uri ?? null, - localRoot: mode === 'local' ? selectedFolder : null, + browseContainerPath: mode === 'tiled' ? selectedContainer || null : null, + localRoot: mode === 'local' ? grantedRoot : null, + localRel: mode === 'local' ? selectedFolder : null, label: summary.label, sampleCount: summary.sample_count, }); + setConnected(true); setStatus(`Connected — ${summary.sample_count} sample${summary.sample_count === 1 ? '' : 's'} found`); - setTimeout(() => navigate('/browse'), 600); + if (navigateAfter) setTimeout(() => navigate('/browse'), 600); } catch (e) { + setConnected(false); setStatus(`Failed: ${e}`); } finally { setConnecting(false); @@ -99,8 +176,8 @@ export default function ConnectPage() { }; return ( -
-
+
+

Connect to Dataset

@@ -111,7 +188,7 @@ export default function ConnectPage() { {(['tiled', 'local'] as const).map((m) => (
- {/* Tiled: server dropdown only */} + {/* ── Section 1: Connect to Tiled (verify only) ── */} + {mode === 'tiled' && ( +
+
+ +

Connect to Tiled

+
+ +
+ + {servers.length === 0 ? ( +

Loading servers…

+ ) : ( + + )} +
+ + {/* Optional browse-target container picker (collapsed by default) */} + {selectedServerUri && ( +
+ + {showDatasetPicker && ( +
+

+ Which collection on the server the Browse tab will show. + Most people can leave this blank — it auto-detects, and ingesting below fills it in for you. +

+
+ + {containerDir.split('/').filter(Boolean).map((seg, i, arr) => { + const target = arr.slice(0, i + 1).join('/'); + return ( + + / + + + ); + })} +
+
+ {containerDir && ( + + )} + {tiledEntries.filter((e) => e.is_dir).map((e) => ( + + ))} + {tiledEntries.filter((e) => e.is_dir).length === 0 && ( +
+ No sub-containers. Leave unset to auto-discover, or pick a parent. +
+ )} +
+ {selectedContainer && ( +

+ Browse will show {selectedContainer} +

+ )} +
+ )} +
+ )} + + {status && ( +

+ {status} +

+ )} + + {selectedServerUri && ( +
+ + {connected && ( + + )} +
+ )} +
+ )} + + {/* ── Section 2: Load / Ingest Datasets ── */} {mode === 'tiled' && ( -
- - {servers.length === 0 ? ( -

Loading servers…

+
+
+ +

Load / Ingest Datasets

+
+ {selectedServerUri ? ( + connectTiled(containerPath)} + onAnnotate={annotateIngested} + /> ) : ( - +

Select a server above to ingest data.

)} -

- After connecting, use Browse to filter and select individual samples. -

-
+ )} - {/* Local: directory browser — pick a folder */} + {/* Local: grant a root, then browse subfolders */} {mode === 'local' && ( -
+
- - {/* Breadcrumbs */} -
+
+ setRootInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { setGrantedRoot(rootInput.trim()); setBrowseDir(''); setSelectedFolder(''); } }} + placeholder="/absolute/path/to/data" + className="flex-1 border border-white/20 rounded-md px-3 py-2 text-sm font-mono bg-white/10 text-white focus:outline-none focus:ring-2 focus:ring-sky-500" + /> - {browseDir.split('/').filter(Boolean).map((seg, i, arr) => { - const target = arr.slice(0, i + 1).join('/'); - return ( - - / - - - ); - })}
- {/* Folder listing */} -
- {/* "Use this folder" button */} - + {grantedRoot && ( + <> +
+ + {browseDir.split('/').filter(Boolean).map((seg, i, arr) => { + const target = arr.slice(0, i + 1).join('/'); + return ( + + / + + + ); + })} +
- {browseDir && ( - - )} +
+ - {localLoading &&
Loading…
} - {!localLoading && - localEntries - .filter((e) => e.is_dir) - .map((e) => ( + {browseDir && ( - ))} + )} - {!localLoading && localEntries.filter((e) => e.is_dir).length === 0 && ( -
No sub-folders here.
- )} -
+ {localLoading &&
Loading…
} + {!localLoading && + localEntries + .filter((e) => e.is_dir) + .map((e) => ( + + ))} - {selectedFolder !== '' && ( -

- Selected folder:{' '} - {selectedFolder || 'root'} -

+ {!localLoading && localEntries.filter((e) => e.is_dir).length === 0 && ( +
No sub-folders here.
+ )} +
+ + {selectedFolder !== '' && ( +

+ Selected folder:{' '} + {selectedFolder || 'root'} +

+ )} + )}
)} - {status && ( -

- {status} -

+ {/* Local: status + connect (navigates straight to Browse) */} + {mode === 'local' && ( + <> + {status && ( +

+ {status} +

+ )} + + )} - -
); diff --git a/frontend/src/app/pages/ExportPage.tsx b/frontend/src/app/pages/ExportPage.tsx index ef60f3a..3058f4b 100644 --- a/frontend/src/app/pages/ExportPage.tsx +++ b/frontend/src/app/pages/ExportPage.tsx @@ -5,10 +5,12 @@ import { useState } from 'react'; import { useDatasetStore } from '@/stores/datasetStore'; import { useAnnotationStore } from '@/stores/annotationStore'; import { useClassStore } from '@/stores/classStore'; +import { useExportJob } from '@/hooks/useExportJob'; import { API_BASE } from '@/config'; type Split = 'train' | 'valid' | 'test' | 'auto'; +/** Renders the COCO export page: split table, dry-run preview, and write/download actions. */ export default function ExportPage() { const { source, kind, serverUri, meta, renderOpts } = useDatasetStore(); const { byImage, splitBySlice, negativeSlices, setSplitForSlice } = useAnnotationStore(); @@ -16,9 +18,10 @@ export default function ExportPage() { const [outDir, setOutDir] = useState(''); const [mode, setMode] = useState<'fail' | 'overwrite' | 'merge'>('fail'); + const [includePolygons, setIncludePolygons] = useState(false); const [preview, setPreview] = useState | null>(null); - const [result, setResult] = useState | null>(null); const [status, setStatus] = useState(''); + const { state: job, start, startMaskSync, downloadUrl } = useExportJob(); if (!source || !meta) { return ( @@ -41,6 +44,7 @@ export default function ExportPage() { ); } + /** Assembles the COCO export request body (render opts, classes, slices, splits) for the current sample. */ const buildPayload = (dryRun: boolean) => ({ out_dir: outDir, kind, @@ -48,6 +52,7 @@ export default function ExportPage() { server_uri: serverUri, mode, dry_run: dryRun, + include_polygons: includePolygons, render: { norm: renderOpts.norm, scale: renderOpts.scale, @@ -62,6 +67,7 @@ export default function ExportPage() { negative_slices: negSlices, }); + /** POSTs a dry-run export to the backend and stores the preview/status. */ const handleDryRun = async () => { if (!outDir) { setStatus('Please enter an output directory.'); return; } setStatus('Running dry run…'); @@ -78,21 +84,20 @@ export default function ExportPage() { } catch (e) { setStatus(`Failed: ${e}`); } }; - const handleWrite = async () => { - setStatus('Writing dataset…'); - try { - const res = await fetch(`${API_BASE}/api/export/coco`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(buildPayload(false)), - }); - const data = await res.json(); - if (!res.ok) { setStatus(`Error: ${JSON.stringify(data)}`); return; } - setResult(data); - setStatus(''); - } catch (e) { setStatus(`Failed: ${e}`); } + /** Starts the real export job (writes the dataset to the backend). */ + const handleWrite = () => { + setStatus(''); + start(buildPayload(false)); + }; + + /** Writes rasterized masks straight into Tiled as stacked volumes (no zip). */ + const handleMaskSync = () => { + setStatus(''); + startMaskSync(buildPayload(false)); }; + const pct = job.total > 0 ? Math.round((job.done / job.total) * 100) : 0; + return (

Export COCO Dataset

@@ -172,6 +177,18 @@ export default function ExportPage() {
+
{status &&

{status}

} @@ -184,10 +201,43 @@ export default function ExportPage() {
)} - {result && ( -
-

Export complete

-
{JSON.stringify(result, null, 2)}
+ {/* Live export progress (phase + bar + backend log) */} + {(job.status === 'running' || job.status === 'done' || job.status === 'error') && ( +
+
+ + {job.status === 'error' ? 'Failed' : job.status === 'done' ? 'Export complete' : `${job.phase || 'working'}…`} + + {job.total > 0 && {job.done}/{job.total} slices} +
+ {job.status !== 'error' && ( +
+
+
+ )} + {job.error &&

{job.error}

} + {job.log.length > 0 && ( +
+ {job.log.slice(-15).map((line, i) =>
{line}
)} +
+ )} + {job.status === 'done' && Array.isArray(job.result?.written) ? ( +

+ Wrote masks into Tiled:{' '} + {(job.result.written as Array<{ container?: string; n_slices?: number }>).length === 0 + ? 'nothing (no Tiled sources / no annotated slices).' + : (job.result.written as Array<{ container?: string; n_slices?: number }>) + .map((w) => `${w.container} (${w.n_slices} slices)`) + .join(', ')} +

+ ) : job.status === 'done' && ( +

+ Saved to {String(job.result?.dataset_path ?? 'server')} (Tiled). Download includes images + masks (semantic + per-class) + COCO. +

+ )}
)} @@ -198,14 +248,36 @@ export default function ExportPage() { > Dry run preview - {preview && ( + {preview && job.status !== 'done' && ( )} + {job.status === 'done' && downloadUrl && ( + + Download .zip + + )} +
); diff --git a/frontend/src/app/pages/ReferencePage.tsx b/frontend/src/app/pages/ReferencePage.tsx new file mode 100644 index 0000000..1cad24a --- /dev/null +++ b/frontend/src/app/pages/ReferencePage.tsx @@ -0,0 +1,338 @@ +/** + * ReferencePage — authors the per-dataset annotation guide: for each class a + * label, color, a written description of what it is / how it looks, and example + * image crops. The guide is dataset-scoped (persisted by sourceKey via + * useGuideSync) and its classes surface as one-click suggestions in the Annotate + * tab, keeping annotators consistent with the lead's intended labels and colors. + */ +import { useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { Plus, Trash, Images, DownloadSimple, Sparkle, CircleDashed, Export, UploadSimple } from '@phosphor-icons/react'; +import { useDatasetStore } from '@/stores/datasetStore'; +import { useClassStore } from '@/stores/classStore'; +import { useReferenceGuideStore, type GuideClass } from '@/stores/referenceGuideStore'; +import { useGuideSync, generateGuide } from '@/hooks/useGuideSync'; +import { useSave } from '@/hooks/useSave'; +import { buildSourceKey } from '@/lib/sourceKey'; +import { getClassPalette } from '@/lib/classColors'; + +/** Reads a File as a base64 data URL. */ +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +/** One editable guide entry: color, label, description, and example crops. */ +function GuideEntryRow({ index, entry }: { index: number; entry: GuideClass }) { + const { updateEntry, removeEntry } = useReferenceGuideStore(); + const fileRef = useRef(null); + + const addCrops = async (files: FileList | null) => { + if (!files || files.length === 0) return; + const urls = await Promise.all(Array.from(files).map(fileToDataUrl)); + updateEntry(index, { exampleCrops: [...entry.exampleCrops, ...urls] }); + }; + + const removeCrop = (cropIdx: number) => + updateEntry(index, { exampleCrops: entry.exampleCrops.filter((_, i) => i !== cropIdx) }); + + return ( +
+
+ updateEntry(index, { color: e.target.value })} + className="h-7 w-8 cursor-pointer rounded border border-gray-200" + /> + updateEntry(index, { label: e.target.value })} + className="flex-1 rounded border border-gray-200 px-2 py-1 text-sm font-medium" + /> + +
+ +