copick-web 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. copick_web/__init__.py +3 -0
  2. copick_web/app/__init__.py +1 -0
  3. copick_web/app/config.py +52 -0
  4. copick_web/app/dependencies.py +38 -0
  5. copick_web/app/main.py +180 -0
  6. copick_web/app/models.py +370 -0
  7. copick_web/app/routes/__init__.py +1 -0
  8. copick_web/app/routes/config.py +162 -0
  9. copick_web/app/routes/filaments.py +169 -0
  10. copick_web/app/routes/projects.py +15 -0
  11. copick_web/app/routes/runs.py +392 -0
  12. copick_web/app/routes/zarr_proxy.py +190 -0
  13. copick_web/app/services/__init__.py +1 -0
  14. copick_web/app/services/cache.py +103 -0
  15. copick_web/app/services/compat.py +147 -0
  16. copick_web/app/services/config_file.py +133 -0
  17. copick_web/app/services/copick_service.py +757 -0
  18. copick_web/app/services/filament_picks.py +113 -0
  19. copick_web/app/services/object_types.py +245 -0
  20. copick_web/app/services/project_registry.py +465 -0
  21. copick_web/app/services/registry_client.py +93 -0
  22. copick_web/app/services/run_info.py +148 -0
  23. copick_web/app/services/thumbnails.py +132 -0
  24. copick_web/app/services/zarr_urls.py +47 -0
  25. copick_web/app/validation.py +46 -0
  26. copick_web/cli.py +100 -0
  27. copick_web/static/assets/blosc-D1xNXZJs-CRrdIaHK.js +17 -0
  28. copick_web/static/assets/chunk-INHXZS53-DiyuLb3Z-DAOLzMPX.js +1 -0
  29. copick_web/static/assets/index-CFHbVtPG.css +2 -0
  30. copick_web/static/assets/index-DGooRI34.js +4333 -0
  31. copick_web/static/assets/lz4-1Ws5oVWR-TBpHcg4u.js +15 -0
  32. copick_web/static/assets/zstd-C4EcZnjq-_uEBMK3Z.js +15 -0
  33. copick_web/static/copick-logo.png +0 -0
  34. copick_web/static/index.html +33 -0
  35. copick_web-0.1.5.dist-info/METADATA +19 -0
  36. copick_web-0.1.5.dist-info/RECORD +38 -0
  37. copick_web-0.1.5.dist-info/WHEEL +4 -0
  38. copick_web-0.1.5.dist-info/entry_points.txt +2 -0
copick_web/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Copick Web - Web visualization for copick datasets."""
2
+
3
+ __version__ = "0.1.5"
@@ -0,0 +1 @@
1
+ # copick-web server app
@@ -0,0 +1,52 @@
1
+ """Server configuration using pydantic-settings."""
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ """Application settings loaded from environment variables."""
8
+
9
+ copick_config_paths: list[str] = []
10
+ registry_url: str | None = None
11
+ registry_refresh_seconds: int = 60
12
+ service_cache_size: int = 8
13
+ # Hard ceiling on a single zarr-chunk read. If exceeded, the request
14
+ # returns 504 and the underlying CopickService is evicted so the next
15
+ # request rebuilds with a fresh SSH connection.
16
+ zarr_read_timeout_seconds: float = 60.0
17
+ # Max concurrent zarr chunk reads per project. Bounds peak memory
18
+ # (≈ value × max_chunk_size) and avoids saturating a single asyncssh
19
+ # SFTP channel — past ~16 in flight, parallel reads stop helping.
20
+ zarr_concurrency_per_project: int = 8
21
+ slurm_user: str | None = None
22
+ slurm_keyfile: str | None = None
23
+ slurm_ssh_port: int = 22
24
+ # cluster_id -> SSH hostname/IP. Defaults mirror the umbrella seed
25
+ # (umbrella/stores/migrations/0010_seed_clusters.py). Override via env:
26
+ # CLUSTER_HOSTS='{"bruno":"…","czii":"…"}'
27
+ cluster_hosts: dict[str, str] = {
28
+ "bruno": "192.168.98.229",
29
+ "czii": "10.50.120.90",
30
+ }
31
+ cors_origins: list[str] = [
32
+ "http://localhost:5173",
33
+ "http://127.0.0.1:5173",
34
+ "http://localhost:8000",
35
+ "http://127.0.0.1:8000",
36
+ ]
37
+ host: str = "0.0.0.0"
38
+ port: int = 8000
39
+ base_path: str = ""
40
+ # In-memory caches of derived data (env: THUMBNAIL_CACHE_MB, MEASUREMENT_CACHE_MB, CACHE_MAX_AGE_SECONDS,
41
+ # CACHE_SWEEP_SECONDS). Entries older than the max age are dropped by a sweep every CACHE_SWEEP_SECONDS.
42
+ thumbnail_cache_mb: int = 64
43
+ measurement_cache_mb: int = 512
44
+ cache_max_age_seconds: int = 3600
45
+ cache_sweep_seconds: int = 300
46
+
47
+ class Config:
48
+ env_file = ".env"
49
+ env_file_encoding = "utf-8"
50
+
51
+
52
+ settings = Settings()
@@ -0,0 +1,38 @@
1
+ """FastAPI dependency injection for project-scoped routes."""
2
+
3
+ from fastapi import HTTPException, Path
4
+
5
+ from .models import ProjectSummaryResponse
6
+ from .services.copick_service import CopickService
7
+ from .services.project_registry import ProjectRegistry, get_project_registry
8
+ from .services.registry_client import RegistryError
9
+
10
+
11
+ def get_registry() -> ProjectRegistry:
12
+ """Dependency: the application's ``ProjectRegistry``."""
13
+ return get_project_registry()
14
+
15
+
16
+ def get_copick_service(project_id: str = Path(...)) -> CopickService:
17
+ """Dependency: resolve the ``CopickService`` for the path's ``project_id``."""
18
+ registry = get_project_registry()
19
+ try:
20
+ return registry.get_service(project_id)
21
+ except KeyError:
22
+ raise HTTPException(status_code=404, detail=f"Project '{project_id}' not found") from None
23
+ except RegistryError as e:
24
+ raise HTTPException(status_code=502, detail=str(e)) from e
25
+
26
+
27
+ def get_project_meta(project_id: str = Path(...)) -> ProjectSummaryResponse:
28
+ """Dependency: resolve the ``ProjectSummaryResponse`` for the path's ``project_id``.
29
+
30
+ Used by routes that need to branch on ``source`` (local vs. registry) — e.g.
31
+ zarr URL construction, where registry projects skip the server-side proxy
32
+ and point the browser at ``data_url`` directly.
33
+ """
34
+ registry = get_project_registry()
35
+ try:
36
+ return registry.get_metadata(project_id)
37
+ except KeyError:
38
+ raise HTTPException(status_code=404, detail=f"Project '{project_id}' not found") from None
copick_web/app/main.py ADDED
@@ -0,0 +1,180 @@
1
+ """FastAPI application entry point."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+
8
+ from fastapi import FastAPI, HTTPException, Request
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import FileResponse, RedirectResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from .config import settings
14
+ from .routes import config, filaments, projects, runs, zarr_proxy
15
+ from .services.project_registry import ProjectRegistry, set_project_registry
16
+ from .services.registry_client import RegistryClient
17
+
18
+ logging.basicConfig(level=logging.DEBUG)
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ async def _refresh_loop(registry: ProjectRegistry, interval: int):
23
+ """Background loop that refreshes the registry list at a fixed cadence."""
24
+ while True:
25
+ try:
26
+ await asyncio.sleep(interval)
27
+ await registry.refresh_registry()
28
+ except asyncio.CancelledError:
29
+ raise
30
+ except Exception as e:
31
+ logger.exception("Registry refresh loop error: %s", e)
32
+
33
+
34
+ @asynccontextmanager
35
+ async def lifespan(app: FastAPI):
36
+ """Application lifespan handler."""
37
+ # Build registry
38
+ if not settings.copick_config_paths and not settings.registry_url:
39
+ raise RuntimeError(
40
+ "No project source configured. Provide local config paths via the CLI / "
41
+ "COPICK_CONFIG_PATHS, or set REGISTRY_URL."
42
+ )
43
+
44
+ client = RegistryClient(settings.registry_url) if settings.registry_url else None
45
+ registry = ProjectRegistry(registry_client=client, service_cache_size=settings.service_cache_size)
46
+
47
+ for path in settings.copick_config_paths:
48
+ try:
49
+ pid = registry.register_local(path)
50
+ logger.info(
51
+ "Registered local project '%s' (%s) from %s",
52
+ pid,
53
+ registry.get_metadata(pid).name or "unnamed",
54
+ path,
55
+ )
56
+ except FileNotFoundError as e:
57
+ logger.error("Local config not found, skipping: %s (%s)", path, e)
58
+
59
+ if client is not None:
60
+ try:
61
+ await registry.refresh_registry()
62
+ except Exception as e:
63
+ logger.warning("Initial registry refresh failed; continuing without registry projects: %s", e)
64
+
65
+ set_project_registry(registry)
66
+
67
+ tasks = [asyncio.create_task(_sweep_caches(registry, settings.cache_sweep_seconds))]
68
+ if client is not None:
69
+ tasks.append(asyncio.create_task(_refresh_loop(registry, settings.registry_refresh_seconds)))
70
+
71
+ logger.info("copick-web ready: %d project(s) listed.", len(registry.list_projects()))
72
+
73
+ try:
74
+ yield
75
+ finally:
76
+ for task in tasks:
77
+ task.cancel()
78
+ try:
79
+ await task
80
+ except asyncio.CancelledError:
81
+ pass
82
+ logger.info("Shutting down copick-web server")
83
+
84
+
85
+ async def _sweep_caches(registry: ProjectRegistry, every: float) -> None:
86
+ """Drop expired cache entries of every open project regularly, so memory is released even when nothing is
87
+ requested."""
88
+ while True:
89
+ await asyncio.sleep(max(every, 1))
90
+ try:
91
+ dropped = registry.sweep_caches()
92
+ if dropped:
93
+ logger.info(f"Dropped {dropped} expired cache entries")
94
+ except Exception as e: # never let the sweeper die
95
+ logger.warning(f"Cache sweep failed: {e}")
96
+
97
+
98
+ app = FastAPI(
99
+ title="Copick Web Server",
100
+ description="API server for copick web visualization",
101
+ version="0.1.0",
102
+ lifespan=lifespan,
103
+ # A prefix the proxy forwards (e.g. /node/<host>/<port>); requests with or without it are routed alike.
104
+ root_path=settings.base_path.rstrip("/"),
105
+ )
106
+
107
+
108
+ @app.middleware("http")
109
+ async def redirect_root_relatively(request: Request, call_next):
110
+ """Add the trailing slash to the app root under a forwarded prefix (``/node/<host>/<port>``) with a relative
111
+ redirect: Starlette's own is absolute and names the Host it was sent, which may be the backend behind the proxy."""
112
+ root = request.scope.get("root_path", "")
113
+ if root and request.url.path == root:
114
+ query = f"?{request.url.query}" if request.url.query else ""
115
+ return RedirectResponse(f"{root.rsplit('/', 1)[-1]}/{query}", status_code=307)
116
+ return await call_next(request)
117
+
118
+
119
+ # Configure CORS
120
+ app.add_middleware(
121
+ CORSMiddleware,
122
+ allow_origins=settings.cors_origins,
123
+ allow_credentials=True,
124
+ allow_methods=["*"],
125
+ allow_headers=["*"],
126
+ )
127
+
128
+ app.include_router(projects.router)
129
+ app.include_router(config.router)
130
+ app.include_router(runs.router)
131
+ app.include_router(filaments.router)
132
+ app.include_router(zarr_proxy.router)
133
+
134
+
135
+ @app.get("/health")
136
+ async def health_check():
137
+ """Liveness check. Async so a saturated threadpool can't make the server look dead."""
138
+ return {"status": "healthy"}
139
+
140
+
141
+ # Serve the built SPA. Order matters: SPA catch-all must come BEFORE the
142
+ # StaticFiles mount at "/" so it can intercept deep-link refreshes
143
+ # (e.g. /projects/abc) and return index.html instead of 404.
144
+ static_dir = Path(__file__).parent.parent / "static"
145
+
146
+
147
+ def _has_static_files() -> bool:
148
+ if not static_dir.exists():
149
+ return False
150
+ return any(f.name != ".gitkeep" for f in static_dir.iterdir())
151
+
152
+
153
+ if _has_static_files():
154
+ index_file = static_dir / "index.html"
155
+
156
+ @app.get("/projects/{full_path:path}", include_in_schema=False)
157
+ def spa_projects(full_path: str, request: Request):
158
+ """Serve the SPA index.html for client-side routes under /projects."""
159
+ if not index_file.exists():
160
+ raise HTTPException(status_code=404, detail="SPA index.html missing")
161
+ return FileResponse(index_file)
162
+
163
+ logger.info(f"Serving static files from: {static_dir}")
164
+ app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
165
+ elif static_dir.exists():
166
+ logger.warning(
167
+ f"Static directory exists but is empty: {static_dir}. "
168
+ "Run 'npm run build:server' in the client directory to build the client."
169
+ )
170
+
171
+
172
+ if __name__ == "__main__":
173
+ import uvicorn
174
+
175
+ uvicorn.run(
176
+ "copick_web.app.main:app",
177
+ host=settings.host,
178
+ port=settings.port,
179
+ reload=True,
180
+ )
@@ -0,0 +1,370 @@
1
+ """Pydantic response models for the API."""
2
+
3
+ from typing import Any, Literal, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+ SegmentationType = Literal["binary", "multilabel", "instance", "panoptic"]
8
+
9
+
10
+ class FeaturesResponse(BaseModel):
11
+ """Optional features of the installed copick (see services/compat.py)."""
12
+
13
+ filaments: bool = False
14
+ segmentation_types: bool = False
15
+ pick_identity: bool = False
16
+
17
+
18
+ class ProjectSummaryResponse(BaseModel):
19
+ """Summary of a project tracked by the project registry."""
20
+
21
+ id: str
22
+ source: Literal["registry", "local"]
23
+ # Registry-derived fields (all optional — locals don't have them).
24
+ session_name: Optional[str] = None
25
+ run_name: Optional[str] = None
26
+ cluster_id: Optional[str] = None
27
+ scope: Optional[str] = None
28
+ status: Optional[str] = None
29
+ created_at: Optional[str] = None
30
+ proc_run_id: Optional[int] = None
31
+ config_url: Optional[str] = None
32
+ data_url: Optional[str] = None
33
+ root_url: Optional[str] = None
34
+ # Pulled from copick config when service is materialized; nullable so listing stays cheap.
35
+ name: Optional[str] = None
36
+ description: Optional[str] = None
37
+
38
+
39
+ class ConfigResponse(BaseModel):
40
+ """Project configuration response."""
41
+
42
+ name: Optional[str]
43
+ description: Optional[str]
44
+ version: Optional[str]
45
+ user_id: Optional[str]
46
+ session_id: Optional[str]
47
+ features: FeaturesResponse = FeaturesResponse()
48
+
49
+
50
+ class PickableObjectResponse(BaseModel):
51
+ """Pickable object metadata."""
52
+
53
+ name: str
54
+ is_particle: bool
55
+ label: Optional[int]
56
+ color: tuple[int, int, int, int]
57
+ radius: Optional[float]
58
+ is_filament: bool = False
59
+ filament: Optional[dict[str, Any]] = None
60
+
61
+
62
+ class FilamentSpecModel(BaseModel):
63
+ """Filament declaration of an object (copick ``FilamentSpec``)."""
64
+
65
+ polar: Optional[bool] = None
66
+ helical_rise_a: Optional[float] = None
67
+ helical_twist_deg: Optional[float] = None
68
+
69
+
70
+ class ObjectTypeFields(BaseModel):
71
+ """The editable fields of a pickable object type."""
72
+
73
+ name: str
74
+ is_particle: bool = True
75
+ label: Optional[int] = None
76
+ color: tuple[int, int, int, int]
77
+ radius: Optional[float] = None
78
+ map_threshold: Optional[float] = None
79
+ emdb_id: Optional[str] = None
80
+ pdb_id: Optional[str] = None
81
+ identifier: Optional[str] = None
82
+ #: None: not a filament.
83
+ filament: Optional[FilamentSpecModel] = None
84
+
85
+
86
+ class ObjectTypeRequest(ObjectTypeFields):
87
+ """Create or update an object type; ``version`` is the object list version the change was made against."""
88
+
89
+ version: str
90
+
91
+
92
+ class ObjectTypesResponse(BaseModel):
93
+ """The configuration's object types, for editing."""
94
+
95
+ version: str
96
+ editable: bool
97
+ config_file: Optional[str]
98
+ suggested_label: int
99
+ objects: list[ObjectTypeFields]
100
+
101
+
102
+ class ReloadResponse(BaseModel):
103
+ """The project after it was re-opened from its configuration file."""
104
+
105
+ runs: int
106
+
107
+
108
+ class RunSummaryResponse(BaseModel):
109
+ """Summary of a run."""
110
+
111
+ name: str
112
+
113
+
114
+ class TomogramSummaryResponse(BaseModel):
115
+ """Summary of a tomogram."""
116
+
117
+ tomo_type: str
118
+
119
+
120
+ class VoxelSpacingSummaryResponse(BaseModel):
121
+ """Summary of a voxel spacing with its tomograms."""
122
+
123
+ voxel_size: float
124
+ tomograms: list[TomogramSummaryResponse]
125
+
126
+
127
+ class RunDetailResponse(BaseModel):
128
+ """Detailed run information including voxel spacings."""
129
+
130
+ name: str
131
+ voxel_spacings: list[VoxelSpacingSummaryResponse]
132
+
133
+
134
+ class ZarrInfo(BaseModel):
135
+ shape: list[int]
136
+ dtype: str
137
+ levels: int
138
+
139
+
140
+ class TomogramInfo(BaseModel):
141
+ tomo_type: str
142
+ #: Where the tomogram is read from (portal location, overlay or static copy).
143
+ path: Optional[str] = None
144
+ #: Level-0 shape (z, y, x), dtype and pyramid levels; None if the store could not be read.
145
+ zarr: Optional[ZarrInfo] = None
146
+ #: CryoET Data Portal metadata (id, url, name, reconstruction, deposition, authors, paths...), if any.
147
+ portal: Optional[dict[str, Any]] = None
148
+
149
+
150
+ class VoxelSpacingInfo(BaseModel):
151
+ voxel_size: float
152
+ static_path: Optional[str] = None
153
+ overlay_path: Optional[str] = None
154
+ portal_id: Optional[int] = None
155
+ tomograms: list[TomogramInfo]
156
+
157
+
158
+ class RunPortalInfo(BaseModel):
159
+ run_id: int
160
+ run_name: Optional[str] = None
161
+ run_url: str
162
+ dataset_id: Optional[int] = None
163
+ dataset_url: Optional[str] = None
164
+
165
+
166
+ class RunInfoResponse(BaseModel):
167
+ """Where a run's data lives and what it holds (the run info dialog)."""
168
+
169
+ name: str
170
+ backend: str
171
+ static_path: Optional[str] = None
172
+ overlay_path: Optional[str] = None
173
+ portal: Optional[RunPortalInfo] = None
174
+ counts: dict[str, Optional[int]]
175
+ voxel_spacings: list[VoxelSpacingInfo]
176
+
177
+
178
+ class TomogramResponse(BaseModel):
179
+ """Tomogram with zarr URL."""
180
+
181
+ tomo_type: str
182
+ zarr_url: str
183
+
184
+
185
+ class PointResponse(BaseModel):
186
+ """A single pick point.
187
+
188
+ ``x``, ``y``, ``z`` are the stored location in Angstrom. The particle centre is the location plus the translation
189
+ of ``transformation`` (a 4x4 object-to-tomogram matrix); clients draw the centre and send the location and
190
+ transformation back unchanged when saving.
191
+ """
192
+
193
+ x: float
194
+ y: float
195
+ z: float
196
+ instance_id: Optional[int]
197
+ score: Optional[float]
198
+ transformation: Optional[list[list[float]]] = None
199
+
200
+
201
+ class PicksSummaryResponse(BaseModel):
202
+ """Summary of a picks collection."""
203
+
204
+ object_name: str
205
+ user_id: str
206
+ session_id: str
207
+ point_count: int
208
+ color: tuple[int, int, int, int]
209
+ is_filament: bool = False
210
+ instance_count: int = 0
211
+
212
+
213
+ class PicksDetailResponse(BaseModel):
214
+ """Detailed picks with all points."""
215
+
216
+ object_name: str
217
+ user_id: str
218
+ session_id: str
219
+ color: tuple[int, int, int, int]
220
+ points: list[PointResponse]
221
+ is_filament: bool = False
222
+
223
+
224
+ class SegmentationSummaryResponse(BaseModel):
225
+ """Summary of a segmentation."""
226
+
227
+ name: str
228
+ user_id: str
229
+ session_id: str
230
+ voxel_size: float
231
+ is_multilabel: bool
232
+ zarr_url: str
233
+ color: Optional[tuple[int, int, int, int]]
234
+ segmentation_type: SegmentationType = "binary"
235
+ is_instance: bool = False
236
+ is_panoptic: bool = False
237
+ channels: Optional[list[str]] = None
238
+
239
+
240
+ class InstanceResponse(BaseModel):
241
+ """One instance (or panoptic segment) of a segmentation, measured on a pyramid level."""
242
+
243
+ instance_id: int
244
+ label: Optional[int] = None
245
+ voxel_count: int
246
+ centroid: tuple[float, float, float]
247
+
248
+
249
+ class InstancesResponse(BaseModel):
250
+ """The instances of an instance or panoptic segmentation."""
251
+
252
+ segmentation_type: SegmentationType
253
+ level: int
254
+ voxel_size: float
255
+ instances: list[InstanceResponse]
256
+
257
+
258
+ # --- Filaments ---
259
+
260
+
261
+ class FilamentCurveModel(BaseModel):
262
+ """An editable or fitted filament curve (copick ``CopickFilamentCurve``); ``points`` are regenerated from it."""
263
+
264
+ kind: str
265
+ control_points: list[tuple[float, float, float]]
266
+ step: float
267
+ alpha: Optional[float] = None
268
+ degree: Optional[int] = None
269
+ knots: Optional[list[float]] = None
270
+ smoothing: Optional[float] = None
271
+
272
+
273
+ class FilamentResponse(BaseModel):
274
+ """One traced filament: its ordered centreline in Angstrom."""
275
+
276
+ instance_id: int
277
+ points: list[tuple[float, float, float]]
278
+ polarity_known: bool = False
279
+ score: float = 1.0
280
+ radius: Optional[float] = None
281
+ curve_kind: Optional[str] = None
282
+ #: The stored curve, if any (it may be stale: editors check it against ``points``).
283
+ curve: Optional[FilamentCurveModel] = None
284
+ metadata: dict[str, Any] = {}
285
+
286
+
287
+ class FilamentsSummaryResponse(BaseModel):
288
+ """Summary of a set of filaments (one object, user and session)."""
289
+
290
+ object_name: str
291
+ user_id: str
292
+ session_id: str
293
+ filament_count: int
294
+ color: tuple[int, int, int, int]
295
+ instance_ids: list[int] = []
296
+
297
+
298
+ class FilamentsDetailResponse(BaseModel):
299
+ """A set of filaments with their centrelines."""
300
+
301
+ object_name: str
302
+ user_id: str
303
+ session_id: str
304
+ color: tuple[int, int, int, int]
305
+ filaments: list[FilamentResponse]
306
+ voxel_spacing: Optional[float] = None
307
+
308
+
309
+ class FilamentWriteModel(BaseModel):
310
+ """One filament to store: a ``curve`` (its points are regenerated by copick) or, for filaments without one,
311
+ the ``points`` as they are."""
312
+
313
+ instance_id: int
314
+ curve: Optional[FilamentCurveModel] = None
315
+ points: Optional[list[tuple[float, float, float]]] = None
316
+ polarity_known: bool = False
317
+ score: float = 1.0
318
+ radius: Optional[float] = None
319
+ metadata: dict[str, Any] = {}
320
+
321
+
322
+ class SaveFilamentsRequest(BaseModel):
323
+ """Replace a filament set (created if missing). With ``pick_spacing`` (Angstrom), picks sampled along the
324
+ filaments replace the picks set of the same object, user and session."""
325
+
326
+ filaments: list[FilamentWriteModel]
327
+ voxel_spacing: Optional[float] = None
328
+ pick_spacing: Optional[float] = None
329
+
330
+
331
+ class SaveFilamentsResponse(BaseModel):
332
+ filaments: FilamentsDetailResponse
333
+ n_picks: int = 0
334
+
335
+
336
+ # --- Request models for picks mutations ---
337
+
338
+
339
+ class CreatePicksRequest(BaseModel):
340
+ """Request to create a new picks collection."""
341
+
342
+ object_name: str
343
+ user_id: str
344
+ session_id: str
345
+
346
+
347
+ class PointRequest(BaseModel):
348
+ """Point for create/update operations."""
349
+
350
+ x: float
351
+ y: float
352
+ z: float
353
+ instance_id: Optional[int] = None
354
+ score: Optional[float] = 1.0
355
+ transformation: Optional[list[list[float]]] = None
356
+
357
+
358
+ class UpdatePicksRequest(BaseModel):
359
+ """Request to update picks points."""
360
+
361
+ points: list[PointRequest]
362
+
363
+
364
+ class CreatePicksResponse(BaseModel):
365
+ """Response after creating picks."""
366
+
367
+ object_name: str
368
+ user_id: str
369
+ session_id: str
370
+ color: tuple[int, int, int, int]
@@ -0,0 +1 @@
1
+ # API routes