annodesk 0.3.1__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 (44) hide show
  1. annodesk/__init__.py +3 -0
  2. annodesk/__main__.py +3 -0
  3. annodesk/app.py +102 -0
  4. annodesk/assets/models/yolo11n/NOTICE.md +12 -0
  5. annodesk/assets/models/yolo11n/yolo11n.pt +0 -0
  6. annodesk/batch.py +388 -0
  7. annodesk/candidates.py +153 -0
  8. annodesk/classes.py +76 -0
  9. annodesk/cli.py +386 -0
  10. annodesk/colors.py +52 -0
  11. annodesk/detector.py +130 -0
  12. annodesk/devices.py +105 -0
  13. annodesk/docs/user-guide-cn.md +607 -0
  14. annodesk/docs/user-guide.md +615 -0
  15. annodesk/domain.py +56 -0
  16. annodesk/events.py +220 -0
  17. annodesk/execution.py +331 -0
  18. annodesk/exports.py +224 -0
  19. annodesk/images.py +44 -0
  20. annodesk/inference.py +425 -0
  21. annodesk/jobs.py +194 -0
  22. annodesk/lease.py +30 -0
  23. annodesk/lifecycle.py +331 -0
  24. annodesk/masks.py +69 -0
  25. annodesk/models.json +54 -0
  26. annodesk/models.py +249 -0
  27. annodesk/offline.py +46 -0
  28. annodesk/operations.py +256 -0
  29. annodesk/routes.py +511 -0
  30. annodesk/statistics.py +271 -0
  31. annodesk/storage.py +405 -0
  32. annodesk/web_dist/annodesk.svg +8 -0
  33. annodesk/web_dist/assets/BatchSetup-BZxRoLUw.js +1 -0
  34. annodesk/web_dist/assets/BatchSetup-DzyKsN-b.css +1 -0
  35. annodesk/web_dist/assets/ModelSettings-BTGm-2wz.js +1 -0
  36. annodesk/web_dist/assets/ModelSettings-BXpFkB0h.css +1 -0
  37. annodesk/web_dist/assets/index-CY6ssN86.js +1200 -0
  38. annodesk/web_dist/assets/index-wLDHp-w7.css +1 -0
  39. annodesk/web_dist/index.html +14 -0
  40. annodesk-0.3.1.dist-info/METADATA +636 -0
  41. annodesk-0.3.1.dist-info/RECORD +44 -0
  42. annodesk-0.3.1.dist-info/WHEEL +4 -0
  43. annodesk-0.3.1.dist-info/entry_points.txt +2 -0
  44. annodesk-0.3.1.dist-info/licenses/LICENSE +21 -0
annodesk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AnnoDesk image annotation workbench."""
2
+
3
+ __version__ = "0.3.1"
annodesk/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from annodesk.cli import main
2
+
3
+ main()
annodesk/app.py ADDED
@@ -0,0 +1,102 @@
1
+ """Application factory for combined, API-only and UI-only deployment."""
2
+
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import FileResponse, JSONResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+
12
+ from annodesk import __version__
13
+ from annodesk.events import EventLog, log_routes
14
+ from annodesk.storage import RevisionConflict
15
+
16
+
17
+ class WorkbenchFiles(StaticFiles):
18
+ """Never reuse entry HTML across builds, including conditional requests."""
19
+
20
+ def file_response(self, full_path, stat_result, scope, status_code=200):
21
+ if Path(full_path).suffix.lower() == ".html":
22
+ response = FileResponse(
23
+ full_path,
24
+ status_code=status_code,
25
+ stat_result=stat_result,
26
+ headers={"Cache-Control": "no-store"},
27
+ )
28
+ # Avoid 304 responses that keep a browser's old asset manifest alive.
29
+ for name in ("etag", "last-modified"):
30
+ if name in response.headers:
31
+ del response.headers[name]
32
+ return response
33
+ return super().file_response(full_path, stat_result, scope, status_code)
34
+
35
+
36
+ def create_app(
37
+ mode: Literal["serve", "api", "ui"] = "serve",
38
+ api_url: str = "",
39
+ project: Path | None = None,
40
+ models_config: Path | None = None,
41
+ events: EventLog | None = None,
42
+ ) -> FastAPI:
43
+ events = events or EventLog()
44
+ router = None
45
+
46
+ @asynccontextmanager
47
+ async def lifespan(_app):
48
+ try:
49
+ yield
50
+ finally:
51
+ if router:
52
+ router.close()
53
+ events.close()
54
+
55
+ app = FastAPI(title="AnnoDesk", version=__version__, lifespan=lifespan)
56
+ app.state.events = events
57
+ app.include_router(log_routes(events))
58
+ app.add_middleware(
59
+ CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
60
+ )
61
+
62
+ @app.middleware("http")
63
+ async def fresh_json(request: Request, call_next):
64
+ response = await call_next(request)
65
+ if response.headers.get("content-type", "").startswith("application/json"):
66
+ response.headers["Cache-Control"] = "no-store"
67
+ return response
68
+
69
+ @app.exception_handler(ValueError)
70
+ async def invalid(_request: Request, error: ValueError):
71
+ events.emit("WARNING", str(error), event_type="request_error")
72
+ return JSONResponse(
73
+ {"detail": str(error)}, status_code=409 if isinstance(error, RevisionConflict) else 400
74
+ )
75
+
76
+ @app.exception_handler(KeyError)
77
+ async def missing(_request: Request, error: KeyError):
78
+ return JSONResponse({"detail": str(error)}, status_code=404)
79
+
80
+ if mode != "ui":
81
+ from annodesk.routes import project_routes
82
+
83
+ router = project_routes(project, models_config, events)
84
+ app.include_router(router)
85
+
86
+ @app.get("/api/v1/info")
87
+ def info():
88
+ return {"version": __version__, "protocol": 1, "name": "AnnoDesk"}
89
+
90
+ if mode != "api":
91
+
92
+ @app.get("/ui-config.json")
93
+ def ui_config():
94
+ base = api_url.rstrip("/")
95
+ return JSONResponse(
96
+ {"apiBase": f"{base}/api/v1"}, headers={"Cache-Control": "no-store"}
97
+ )
98
+
99
+ app.mount(
100
+ "/", WorkbenchFiles(directory=Path(__file__).parent / "web_dist", html=True), name="web"
101
+ )
102
+ return app
@@ -0,0 +1,12 @@
1
+ # YOLO11n pretrained weights
2
+
3
+ - Publisher: Ultralytics
4
+ - Model: YOLO11n, COCO detection (80 classes)
5
+ - Source: https://huggingface.co/Ultralytics/YOLO11
6
+ - Upstream download: https://github.com/ultralytics/assets/releases/download/v8.3.0/yolo11n.pt
7
+ - SHA256: `0ebbc80d4a7680d14987a577cd21342b65ecfd94632bd9a8da63ae6417644ee1`
8
+ - Original license: AGPL-3.0; see https://www.ultralytics.com/license.
9
+
10
+ This is the unmodified local checkpoint used in AnnoDesk's real-model validation.
11
+ AnnoDesk's MIT declaration applies to its original source code, not to this
12
+ third-party checkpoint or third-party dependencies. No model is downloaded at runtime.
annodesk/batch.py ADDED
@@ -0,0 +1,388 @@
1
+ """Shared CLI/API batch coordinator; only this parent process commits labels."""
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import threading
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from annodesk.detector import inspect_classes
11
+ from annodesk.devices import DevicePlan, ExecutionOptions, resolve_plan
12
+ from annodesk.events import EventLog
13
+ from annodesk.execution import ImageExecutor, file_stamp
14
+ from annodesk.inference import SegmentRequest, compute_annotation
15
+ from annodesk.lease import ProjectLease
16
+ from annodesk.models import ModelConfig, ModelRegistry
17
+ from annodesk.operations import commit_result, eligible, prepare_detection, target_task
18
+ from annodesk.storage import Project, atomic_write
19
+
20
+
21
+ def new_report(dataset: Project, body: SegmentRequest) -> dict:
22
+ with dataset.lock:
23
+ ids = (
24
+ list(dict.fromkeys(body.image_ids))
25
+ if body.image_ids is not None
26
+ else list(dataset.entries)
27
+ )
28
+ if body.image_ids is not None and (not ids or any(i not in dataset.entries for i in ids)):
29
+ raise ValueError("Choose a non-empty image range belonging to this project")
30
+ paths = {image_id: dataset.entries[image_id]["path"] for image_id in ids}
31
+ return {
32
+ "operation": body.operation,
33
+ "image_paths": paths,
34
+ "skip_reasons": {"excluded": 0, "review_state": 0, "no_content": 0},
35
+ "boxes_added": 0,
36
+ "boxes_updated": 0,
37
+ "detections_discarded": 0,
38
+ "classes_added": 0,
39
+ "model": body.model,
40
+ "processed": 0,
41
+ "completed": 0,
42
+ "skipped": 0,
43
+ "failed": [],
44
+ "input_total": len(ids),
45
+ "total": len(ids),
46
+ "remaining": ids,
47
+ "request": body.model_dump(),
48
+ "prepared": False,
49
+ "status": "running",
50
+ "phase": "preflight",
51
+ "cancel": False,
52
+ "active_workers": [],
53
+ "results": [],
54
+ "started_at": time.time(),
55
+ }
56
+
57
+
58
+ def run_batch(
59
+ dataset,
60
+ registry,
61
+ body,
62
+ report,
63
+ events,
64
+ persist,
65
+ *,
66
+ executor_factory=ImageExecutor,
67
+ available=None,
68
+ class_probe=None,
69
+ ):
70
+ project_id = dataset.metadata["id"]
71
+ context = {"project_id": project_id, "job_id": report.get("id")}
72
+ with dataset.lock:
73
+ # Older persisted reports lack paths. Never depend on mutable entries
74
+ # while recording a result (the image may have been removed externally).
75
+ if "image_paths" not in report:
76
+ report["image_paths"] = {
77
+ image_id: entry["path"] for image_id, entry in dataset.entries.items()
78
+ }
79
+ paths = report["image_paths"]
80
+
81
+ def class_snapshot():
82
+ with dataset.lock:
83
+ return sorted(
84
+ [
85
+ {key: c[key] for key in ("id", "index", "name")}
86
+ for c in dataset.metadata["classes"]
87
+ ],
88
+ key=lambda c: c["index"],
89
+ )
90
+
91
+ def failed(image_id, annotation, error, metrics=None):
92
+ path = paths.get(image_id, image_id)
93
+ if error:
94
+ report["failed"].append({"image": path, "error": error})
95
+ events.emit(
96
+ "ERROR",
97
+ f"{path}: {error}",
98
+ event_type="image_failed",
99
+ error_code=error,
100
+ **context,
101
+ )
102
+ else:
103
+ report["processed"] += 1
104
+ if body.operation == "detection":
105
+ report["project_classes"] = class_snapshot()
106
+ report.setdefault("results", []).append(
107
+ {
108
+ "image": path,
109
+ "status": "failed" if error else "saved",
110
+ "error": error,
111
+ **(metrics or {"elapsed_seconds": 0, "attempts": 0}),
112
+ }
113
+ )
114
+ for key in ("boxes_added", "boxes_updated", "detections_discarded", "classes_added"):
115
+ report[key] = report.get(key, 0) + (metrics or {}).get(key, 0)
116
+ report["completed"] += 1
117
+ report["remaining"].remove(image_id)
118
+ persist()
119
+
120
+ try:
121
+ if body.operation == "segmentation" and body.text:
122
+ raise ValueError("Batch segmentation only supports single masks from existing boxes")
123
+ # Resolve before preflight so invalid configuration never mutates labels.
124
+ geometry = body.operation == "boxes_from_masks"
125
+ if geometry:
126
+ if (
127
+ body.device_type not in ("auto", "cpu")
128
+ or body.devices != "auto"
129
+ or str(body.num_workers) not in ("auto", "1")
130
+ ):
131
+ raise ValueError(
132
+ "Boxes from masks is CPU geometry; GPU and multiple workers are not used"
133
+ )
134
+ model, weights = "", None
135
+ plan = DevicePlan("cpu", ["cpu"], ["cpu"])
136
+ report.update(model="", plan=plan.to_dict())
137
+ elif report.get("frozen_config"):
138
+ if report.get("cuda_visible_devices") != os.environ.get("CUDA_VISIBLE_DEVICES"):
139
+ raise ValueError(
140
+ "CUDA_VISIBLE_DEVICES changed; start a new job to use a different device mapping"
141
+ )
142
+ frozen = ModelRegistry.__new__(ModelRegistry)
143
+ frozen.path = Path(report["config_path"])
144
+ frozen.config = ModelConfig.model_validate(report["frozen_config"])
145
+ registry = frozen
146
+ model = report["model"]
147
+ weights = registry.weights(model)
148
+ if [weights.stat().st_mtime_ns, weights.stat().st_size] != report["weights_stamp"]:
149
+ raise ValueError("Checkpoint changed; start a new batch")
150
+ plan = DevicePlan(**report["plan"])
151
+ else:
152
+ initial_registry = ModelRegistry.__new__(ModelRegistry)
153
+ initial_registry.path = registry.path
154
+ initial_registry.config = registry.config.model_copy(deep=True)
155
+ registry = initial_registry.freeze()
156
+ model = registry.entry(body.model, body.operation).id
157
+ weights = registry.weights(model)
158
+ plan = resolve_plan(
159
+ ExecutionOptions(body.device_type, body.devices, str(body.num_workers)), available
160
+ )
161
+ config = registry.config.model_dump()
162
+ for entry in config["models"]:
163
+ if entry["id"] == model:
164
+ entry["source"] = str(registry.resolve(model))
165
+ report.update(
166
+ plan=plan.to_dict(),
167
+ model=model,
168
+ weights=str(weights),
169
+ weights_stamp=[weights.stat().st_mtime_ns, weights.stat().st_size],
170
+ frozen_config=config,
171
+ config_path=str(registry.path),
172
+ cuda_visible_devices=os.environ.get("CUDA_VISIBLE_DEVICES"),
173
+ )
174
+ if not geometry:
175
+ frozen = ModelRegistry.__new__(ModelRegistry)
176
+ frozen.path = Path(report["config_path"])
177
+ frozen.config = ModelConfig.model_validate(report["frozen_config"])
178
+ registry = frozen
179
+ body = body.model_copy(update={"model": model})
180
+ if body.operation == "detection":
181
+ current_classes = class_snapshot()
182
+ if "project_classes" in report and report["project_classes"] != current_classes:
183
+ raise ValueError("Project class definitions changed; start a new batch")
184
+ report["project_classes"] = current_classes
185
+ names = report.get("class_names") or (class_probe or inspect_classes)(registry, model)
186
+ body = prepare_detection(dataset, body, {int(k): v for k, v in names.items()})
187
+ report["class_names"] = body.class_names
188
+ report["request"] = body.model_dump()
189
+ events.emit(
190
+ "INFO",
191
+ f"Model {model}: {weights}; devices={plan.devices}; workers={plan.workers}",
192
+ event_type="device_plan",
193
+ **context,
194
+ )
195
+ if len(plan.workers) < len(plan.devices):
196
+ events.emit(
197
+ "WARNING",
198
+ f"Unused devices: {plan.devices[len(plan.workers) :]}",
199
+ event_type="device_plan",
200
+ **context,
201
+ )
202
+ if not report["prepared"]:
203
+ last_checkpoint = time.monotonic()
204
+ for image_id in list(report["remaining"]):
205
+ if report["cancel"]:
206
+ break
207
+ try:
208
+ with dataset.lock:
209
+ if image_id not in dataset.entries:
210
+ raise ValueError("Image removed from project during processing")
211
+ annotation = dataset.annotation(image_id, materialize=False)
212
+ if not eligible(annotation, body):
213
+ reason = (
214
+ "excluded"
215
+ if annotation.excluded
216
+ else "review_state"
217
+ if getattr(annotation, target_task(body) + "_review")
218
+ not in body.review_states
219
+ else "no_content"
220
+ )
221
+ report.setdefault(
222
+ "skip_reasons", {"excluded": 0, "review_state": 0, "no_content": 0}
223
+ )[reason] += 1
224
+ report["skipped"] += 1
225
+ report["remaining"].remove(image_id)
226
+ except Exception as error:
227
+ failed(image_id, None, str(error))
228
+ report["total"] = report["input_total"] - report["skipped"]
229
+ if time.monotonic() - last_checkpoint >= 1:
230
+ persist()
231
+ last_checkpoint = time.monotonic()
232
+ persist()
233
+ if not report["cancel"]:
234
+ report["prepared"] = True
235
+ if geometry:
236
+ report["phase"] = "running"
237
+ persist()
238
+ for image_id in list(report["remaining"]):
239
+ if report["cancel"]:
240
+ break
241
+ start = time.monotonic()
242
+ try:
243
+ with dataset.lock:
244
+ if image_id not in dataset.entries:
245
+ raise ValueError("Image removed from project during processing")
246
+ annotation = dataset.annotation(image_id)
247
+ if not eligible(annotation, body):
248
+ raise ValueError(
249
+ "Image no longer matches the batch eligibility; start a new batch"
250
+ )
251
+ size = dataset.image_size(image_id)
252
+ stamp = file_stamp(dataset.image_path(image_id))
253
+ result = compute_annotation(
254
+ dataset.image_path(image_id), size, annotation, body, None
255
+ )
256
+ if file_stamp(dataset.image_path(image_id)) != stamp:
257
+ raise ValueError("Image changed during processing")
258
+ result = commit_result(dataset, image_id, result, body)
259
+ failed(
260
+ image_id,
261
+ result,
262
+ None,
263
+ {
264
+ **result.operation_stats,
265
+ "elapsed_seconds": round(time.monotonic() - start, 3),
266
+ "attempts": 1,
267
+ },
268
+ )
269
+ except Exception as error:
270
+ failed(image_id, None, str(error))
271
+ if not geometry and report["remaining"] and not report["cancel"]:
272
+ report["phase"] = "loading"
273
+ persist()
274
+ events.emit(
275
+ "INFO",
276
+ f"Batch loading {len(plan.workers)} workers; {report['total']} images require processing",
277
+ event_type="batch_start",
278
+ **context,
279
+ )
280
+ if [weights.stat().st_mtime_ns, weights.stat().st_size] != report["weights_stamp"]:
281
+ raise ValueError("Checkpoint changed while preparing the batch")
282
+ executor = executor_factory(registry, model, plan, events.bind(**context))
283
+ executor.expected_names = body.class_names if body.operation == "detection" else None
284
+ executor.start()
285
+ report["phase"] = "running"
286
+ persist()
287
+
288
+ def activity(workers):
289
+ report["active_workers"] = workers
290
+
291
+ executor.run(
292
+ dataset, list(report["remaining"]), body, failed, lambda: report["cancel"], activity
293
+ )
294
+ report["status"] = "cancelled" if report["cancel"] else "complete"
295
+ report["phase"] = report["status"]
296
+ events.emit(
297
+ "WARNING" if report["cancel"] else "SUCCESS",
298
+ f"Batch {report['status']}: {report['completed']}/{report['total']} finished · {report['processed']} succeeded · {len(report['failed'])} failed · {report['skipped']} skipped",
299
+ event_type="batch_complete",
300
+ **context,
301
+ )
302
+ except BaseException as error:
303
+ report.update(
304
+ status="interrupted" if isinstance(error, KeyboardInterrupt) else "failed",
305
+ phase="stopped",
306
+ error=str(error),
307
+ )
308
+ events.emit("CRITICAL", f"Batch stopped: {error}", event_type="batch_fatal", **context)
309
+ raise
310
+ finally:
311
+ report["active_workers"] = []
312
+ report["elapsed_seconds"] = round(time.time() - report["started_at"], 2)
313
+ persist()
314
+
315
+
316
+ def segment_dataset(
317
+ project: Path,
318
+ model: str,
319
+ registry: ModelRegistry | None,
320
+ options: ExecutionOptions | None = None,
321
+ events: EventLog | None = None,
322
+ *,
323
+ executor_factory=ImageExecutor,
324
+ available=None,
325
+ request: SegmentRequest | None = None,
326
+ class_probe=None,
327
+ ) -> dict:
328
+ if request is None or request.operation != "boxes_from_masks":
329
+ registry.weights(
330
+ model or registry.entry("", request.operation if request else "segmentation").id
331
+ )
332
+ events = events or EventLog()
333
+ options = options or ExecutionOptions()
334
+ with ProjectLease(project):
335
+ dataset = Project(project)
336
+ events.emit(
337
+ "INFO",
338
+ f"Project write lease acquired: {dataset.root}",
339
+ event_type="project_lock",
340
+ project_id=dataset.metadata["id"],
341
+ )
342
+ body = request or SegmentRequest(model=model, **options.__dict__)
343
+ report = new_report(dataset, body)
344
+ report_path = (
345
+ dataset.private
346
+ / {
347
+ "segmentation": "segment-report.json",
348
+ "detection": "detect-report.json",
349
+ "boxes_from_masks": "boxes-from-masks-report.json",
350
+ }[body.operation]
351
+ )
352
+
353
+ def persist():
354
+ atomic_write(report_path, json.dumps(report, indent=2))
355
+
356
+ previous_handler = None
357
+ interrupts = 0
358
+
359
+ def interrupt(_signal, _frame):
360
+ nonlocal interrupts
361
+ interrupts += 1
362
+ if interrupts > 1:
363
+ raise KeyboardInterrupt
364
+ report["cancel"] = True
365
+ events.emit(
366
+ "WARNING",
367
+ "Cancellation requested; finishing active images. Press Ctrl+C again to terminate.",
368
+ event_type="batch_cancel",
369
+ )
370
+
371
+ if threading.current_thread() is threading.main_thread():
372
+ previous_handler = signal.signal(signal.SIGINT, interrupt)
373
+ try:
374
+ run_batch(
375
+ dataset,
376
+ registry,
377
+ body,
378
+ report,
379
+ events,
380
+ persist,
381
+ executor_factory=executor_factory,
382
+ available=available,
383
+ class_probe=class_probe,
384
+ )
385
+ finally:
386
+ if previous_handler is not None:
387
+ signal.signal(signal.SIGINT, previous_handler)
388
+ return report
annodesk/candidates.py ADDED
@@ -0,0 +1,153 @@
1
+ """Stateless, read-only mask previews and revision-checked explicit application."""
2
+
3
+ import base64
4
+ import io
5
+ import math
6
+
7
+ import numpy as np
8
+ from PIL import Image
9
+ from pydantic import BaseModel, Field
10
+
11
+ from annodesk.inference import SegmentRequest
12
+ from annodesk.masks import contours, decode_mask, encode_mask
13
+ from annodesk.storage import RevisionConflict
14
+
15
+
16
+ class CandidateRequest(SegmentRequest):
17
+ expected_revision: int = Field(ge=0)
18
+ project_id: str
19
+ multimask_output: bool = True
20
+
21
+
22
+ class CandidateApply(BaseModel):
23
+ project_id: str
24
+ instance_id: str
25
+ revision: int = Field(ge=0)
26
+ image_stamp: str
27
+ model: str
28
+ mask: dict
29
+
30
+
31
+ def image_stamp(project, image_id):
32
+ stat = project.image_path(image_id).stat()
33
+ # Preserve nanosecond precision across JavaScript JSON round trips.
34
+ return f"{stat.st_mtime_ns}:{stat.st_size}"
35
+
36
+
37
+ def target(annotation, instance_id):
38
+ obj = next((o for o in annotation.instances if o.id == instance_id), None)
39
+ if annotation.excluded:
40
+ raise ValueError("Image is excluded")
41
+ if obj is None:
42
+ raise ValueError("Select an object with points or a box prompt")
43
+ if obj.locked:
44
+ raise ValueError("Object is locked")
45
+ if obj.hidden:
46
+ raise ValueError("Unhide this object before generating candidates")
47
+ if not (obj.box or obj.prompt_box or obj.points):
48
+ raise ValueError("Select an object with points or a box prompt")
49
+ return obj
50
+
51
+
52
+ def overlay(mask, color):
53
+ rgba = np.zeros((*mask.shape, 4), dtype=np.uint8)
54
+ rgba[:, :, :3] = [int(color[i : i + 2], 16) for i in (1, 3, 5)]
55
+ rgba[:, :, 3] = mask * 110
56
+ output = io.BytesIO()
57
+ Image.fromarray(rgba).save(output, format="PNG")
58
+ return "data:image/png;base64," + base64.b64encode(output.getvalue()).decode("ascii")
59
+
60
+
61
+ def preview_candidates(project, image_id, body, adapter):
62
+ if body.operation != "segmentation" or body.text:
63
+ raise ValueError("Mask candidates require a point/box segmentation model, not text")
64
+ with project.lock:
65
+ if body.project_id != project.metadata["id"]:
66
+ raise RevisionConflict("Project changed; generate candidates again")
67
+ annotation = project.annotation(image_id, materialize=False)
68
+ if annotation.revision != body.expected_revision:
69
+ raise RevisionConflict("Annotation changed; generate candidates again")
70
+ obj = target(annotation, body.instance_id)
71
+ width, height = project.image_size(image_id)
72
+ stamp = image_stamp(project, image_id)
73
+ color = next(
74
+ (c["color"] for c in project.metadata["classes"] if c["id"] == obj.class_id), "#657286"
75
+ )
76
+ if body.multimask_output:
77
+ if not callable(getattr(adapter, "predict_candidates", None)):
78
+ raise ValueError("This model adapter does not support mask candidates")
79
+ predictions = adapter.predict_candidates(
80
+ project.image_path(image_id), body.model, obj.prompt_box or obj.box, obj.points
81
+ )
82
+ else:
83
+ predictions = [
84
+ (mask, None)
85
+ for mask in adapter.predict(
86
+ project.image_path(image_id), body.model, obj.prompt_box or obj.box, obj.points, ""
87
+ )[:1]
88
+ ]
89
+ valid = []
90
+ for mask, score in predictions:
91
+ if mask.shape != (height, width):
92
+ raise ValueError("Model mask size does not match the original image")
93
+ binary = (mask != 0).astype(np.uint8)
94
+ if not binary.any():
95
+ continue
96
+ score = float(score) if score is not None and math.isfinite(float(score)) else None
97
+ valid.append((binary, score))
98
+ if not valid:
99
+ raise ValueError("No mask returned; existing annotations were preserved")
100
+ valid.sort(
101
+ key=lambda item: (item[1] is not None, item[1] if item[1] is not None else 0), reverse=True
102
+ )
103
+ with project.lock:
104
+ if (
105
+ image_stamp(project, image_id) != stamp
106
+ or project.annotation(image_id, materialize=False).revision != annotation.revision
107
+ ):
108
+ raise RevisionConflict("Image or annotation changed; generate candidates again")
109
+ return {
110
+ "project_id": project.metadata["id"],
111
+ "image_id": image_id,
112
+ "instance_id": obj.id,
113
+ "revision": annotation.revision,
114
+ "image_stamp": stamp,
115
+ "model": body.model,
116
+ "items": [
117
+ {
118
+ "mask": encode_mask(mask),
119
+ "polygons": contours(mask),
120
+ "quality": score,
121
+ "overlay": overlay(mask, color),
122
+ }
123
+ for mask, score in valid[:3]
124
+ ],
125
+ }
126
+
127
+
128
+ def apply_candidate(project, image_id, body):
129
+ with project.lock:
130
+ if project.metadata["id"] != body.project_id:
131
+ raise RevisionConflict("Project changed; generate candidates again")
132
+ annotation = project.annotation(image_id)
133
+ if (
134
+ annotation.revision != body.revision
135
+ or image_stamp(project, image_id) != body.image_stamp
136
+ ):
137
+ raise RevisionConflict("Image or annotation changed; generate candidates again")
138
+ obj = target(annotation, body.instance_id)
139
+ width, height = project.image_size(image_id)
140
+ if body.mask.get("size") != [height, width] or not isinstance(body.mask.get("counts"), str):
141
+ raise ValueError("Candidate mask size does not match the image")
142
+ try:
143
+ binary = decode_mask(body.mask)
144
+ except (ValueError, TypeError, OverflowError) as error:
145
+ raise ValueError("Invalid candidate mask") from error
146
+ if binary.shape != (height, width) or not binary.any():
147
+ raise ValueError("Candidate mask is empty or has invalid dimensions")
148
+ obj.mask, obj.polygons, obj.source = encode_mask(binary), contours(binary), body.model
149
+ annotation.segmentation_review = "pending"
150
+ annotation.explicit_empty["segmentation"] = False
151
+ if image_stamp(project, image_id) != body.image_stamp:
152
+ raise RevisionConflict("Image changed; generate candidates again")
153
+ return project.save(image_id, annotation)