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.
- annodesk/__init__.py +3 -0
- annodesk/__main__.py +3 -0
- annodesk/app.py +102 -0
- annodesk/assets/models/yolo11n/NOTICE.md +12 -0
- annodesk/assets/models/yolo11n/yolo11n.pt +0 -0
- annodesk/batch.py +388 -0
- annodesk/candidates.py +153 -0
- annodesk/classes.py +76 -0
- annodesk/cli.py +386 -0
- annodesk/colors.py +52 -0
- annodesk/detector.py +130 -0
- annodesk/devices.py +105 -0
- annodesk/docs/user-guide-cn.md +607 -0
- annodesk/docs/user-guide.md +615 -0
- annodesk/domain.py +56 -0
- annodesk/events.py +220 -0
- annodesk/execution.py +331 -0
- annodesk/exports.py +224 -0
- annodesk/images.py +44 -0
- annodesk/inference.py +425 -0
- annodesk/jobs.py +194 -0
- annodesk/lease.py +30 -0
- annodesk/lifecycle.py +331 -0
- annodesk/masks.py +69 -0
- annodesk/models.json +54 -0
- annodesk/models.py +249 -0
- annodesk/offline.py +46 -0
- annodesk/operations.py +256 -0
- annodesk/routes.py +511 -0
- annodesk/statistics.py +271 -0
- annodesk/storage.py +405 -0
- annodesk/web_dist/annodesk.svg +8 -0
- annodesk/web_dist/assets/BatchSetup-BZxRoLUw.js +1 -0
- annodesk/web_dist/assets/BatchSetup-DzyKsN-b.css +1 -0
- annodesk/web_dist/assets/ModelSettings-BTGm-2wz.js +1 -0
- annodesk/web_dist/assets/ModelSettings-BXpFkB0h.css +1 -0
- annodesk/web_dist/assets/index-CY6ssN86.js +1200 -0
- annodesk/web_dist/assets/index-wLDHp-w7.css +1 -0
- annodesk/web_dist/index.html +14 -0
- annodesk-0.3.1.dist-info/METADATA +636 -0
- annodesk-0.3.1.dist-info/RECORD +44 -0
- annodesk-0.3.1.dist-info/WHEEL +4 -0
- annodesk-0.3.1.dist-info/entry_points.txt +2 -0
- annodesk-0.3.1.dist-info/licenses/LICENSE +21 -0
annodesk/__init__.py
ADDED
annodesk/__main__.py
ADDED
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.
|
|
Binary file
|
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)
|