sectra-image-analysis-api 1.5.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.
- sectra_client/__init__.py +3 -0
- sectra_client/client.py +310 -0
- sectra_client/mock_server.py +468 -0
- sectra_client/schemas/__init__.py +96 -0
- sectra_client/schemas/common.py +46 -0
- sectra_client/schemas/image.py +88 -0
- sectra_client/schemas/info.py +8 -0
- sectra_client/schemas/invocation.py +92 -0
- sectra_client/schemas/quality_control.py +26 -0
- sectra_client/schemas/registration.py +29 -0
- sectra_client/schemas/results.py +111 -0
- sectra_client/utils/__init__.py +0 -0
- sectra_client/utils/decode_images.py +17 -0
- sectra_client/utils/errors.py +10 -0
- sectra_client/utils/helpers.py +32 -0
- sectra_image_analysis_api-1.5.1.dist-info/METADATA +143 -0
- sectra_image_analysis_api-1.5.1.dist-info/RECORD +19 -0
- sectra_image_analysis_api-1.5.1.dist-info/WHEEL +4 -0
- sectra_image_analysis_api-1.5.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import pathlib
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import requests as _requests
|
|
13
|
+
import uvicorn
|
|
14
|
+
from fastapi import FastAPI, Header, HTTPException, Response
|
|
15
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
16
|
+
from PIL import Image
|
|
17
|
+
|
|
18
|
+
from sectra_client.schemas.common import DisplayedName, Size
|
|
19
|
+
from sectra_client.schemas.image import (
|
|
20
|
+
CaseImageInfo,
|
|
21
|
+
FocalPlane,
|
|
22
|
+
ImageMetadata,
|
|
23
|
+
OpticalPath,
|
|
24
|
+
SlideFormat,
|
|
25
|
+
TileFormat,
|
|
26
|
+
)
|
|
27
|
+
from sectra_client.schemas.info import ApplicationInfo
|
|
28
|
+
from sectra_client.schemas.invocation import Invocation
|
|
29
|
+
from sectra_client.schemas.quality_control import QualityControl, QualityControlData
|
|
30
|
+
from sectra_client.schemas.results import AdaptedResult, Result, ResultResponse
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Helpers for generating fake slide data
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _make_fake_metadata(slide_id: str) -> ImageMetadata:
|
|
38
|
+
"""Return plausible ImageMetadata for an unknown slide ID."""
|
|
39
|
+
return ImageMetadata(
|
|
40
|
+
id=slide_id,
|
|
41
|
+
isStreamable=True,
|
|
42
|
+
imageSize=Size(width=100_000, height=80_000),
|
|
43
|
+
tileSize=Size(width=256, height=256),
|
|
44
|
+
micronsPerPixel=0.25,
|
|
45
|
+
focalPlanes=[FocalPlane(id="0", offsetUm=0.0)],
|
|
46
|
+
opticalPaths=[OpticalPath(id="0", description="Brightfield")],
|
|
47
|
+
storedTileFormat=TileFormat(mimeType="image/jpeg", extension="jpg"),
|
|
48
|
+
availableTileFormats=[TileFormat(mimeType="image/jpeg", extension="jpg")],
|
|
49
|
+
fileFormat=SlideFormat(mimeType="image/tiff"),
|
|
50
|
+
staining=DisplayedName(displayName="HE"),
|
|
51
|
+
block=DisplayedName(displayName="A1"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _make_1x1_jpeg() -> bytes:
|
|
56
|
+
"""Return a minimal 1×1 white JPEG."""
|
|
57
|
+
buf = io.BytesIO()
|
|
58
|
+
Image.new("RGB", (1, 1), color=(255, 255, 255)).save(buf, format="JPEG")
|
|
59
|
+
return buf.getvalue()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_LABEL_JPEG: bytes = _make_1x1_jpeg()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _iter_multipart(boundary: str, paths: list[pathlib.Path]):
|
|
66
|
+
"""Yield multipart body chunks for a list of file paths without loading them into memory."""
|
|
67
|
+
for path in paths:
|
|
68
|
+
file_size = path.stat().st_size
|
|
69
|
+
sent = 0
|
|
70
|
+
start = time.monotonic()
|
|
71
|
+
header = (
|
|
72
|
+
f"--{boundary}\r\n"
|
|
73
|
+
f'Content-Disposition: attachment; filename="{path.name}"\r\n'
|
|
74
|
+
"Content-Type: application/octet-stream\r\n\r\n"
|
|
75
|
+
).encode()
|
|
76
|
+
yield header
|
|
77
|
+
with path.open("rb") as f:
|
|
78
|
+
while chunk := f.read(8 * 1024 * 1024): # 8 MB chunks
|
|
79
|
+
yield chunk
|
|
80
|
+
sent += len(chunk)
|
|
81
|
+
pct = sent * 100 // file_size if file_size else 100
|
|
82
|
+
elapsed = time.monotonic() - start
|
|
83
|
+
print(
|
|
84
|
+
f"\r[mock] Sending {path.name}: "
|
|
85
|
+
f"{sent / 1_048_576:.1f} / {file_size / 1_048_576:.1f} MB ({pct}%) "
|
|
86
|
+
f"[{elapsed:.1f}s]",
|
|
87
|
+
end="",
|
|
88
|
+
flush=True,
|
|
89
|
+
)
|
|
90
|
+
elapsed = time.monotonic() - start
|
|
91
|
+
print(f" done in {elapsed:.1f}s") # newline after each file completes
|
|
92
|
+
yield b"\r\n"
|
|
93
|
+
yield f"--{boundary}--\r\n".encode()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# MockSectraServer
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MockSectraServer:
|
|
102
|
+
"""In-memory mock of the Sectra PACS Image Analysis API.
|
|
103
|
+
|
|
104
|
+
Holds all state (slides, results, quality-control data) in Python dicts so
|
|
105
|
+
tests and dev scripts can read back what was stored without going through
|
|
106
|
+
HTTP.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
api_version:
|
|
111
|
+
Reported as ``ApplicationInfo.apiVersion`` on ``GET /info``.
|
|
112
|
+
software_version:
|
|
113
|
+
Reported as ``ApplicationInfo.softwareVersion`` on ``GET /info``.
|
|
114
|
+
token:
|
|
115
|
+
Bearer token that clients must supply. Defaults to ``"dev-token"``.
|
|
116
|
+
slides:
|
|
117
|
+
Optional mapping of slide_id → ImageMetadata to pre-register. Any
|
|
118
|
+
slide_id not in this mapping will receive auto-generated fake metadata.
|
|
119
|
+
slide_files:
|
|
120
|
+
Optional mapping of slide_id → file path(s) to pre-register for
|
|
121
|
+
``GET /slides/{id}/files``. Equivalent to calling
|
|
122
|
+
:meth:`add_slide_files` for each entry after construction.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
api_version: str = "1.10",
|
|
128
|
+
software_version: str = "4.2.0.0",
|
|
129
|
+
token: str = "dev-token",
|
|
130
|
+
slides: Optional[dict[str, ImageMetadata]] = None,
|
|
131
|
+
slide_files: Optional[dict[str, pathlib.Path | list[pathlib.Path]]] = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.api_version = api_version
|
|
134
|
+
self.software_version = software_version
|
|
135
|
+
self.token = token
|
|
136
|
+
|
|
137
|
+
self._slides: dict[str, ImageMetadata] = dict(slides) if slides else {}
|
|
138
|
+
# slide_id → list of file paths served by GET /slides/{id}/files
|
|
139
|
+
self._slide_files: dict[str, list[pathlib.Path]] = {}
|
|
140
|
+
if slide_files:
|
|
141
|
+
for sid, files in slide_files.items():
|
|
142
|
+
self.add_slide_files(sid, files)
|
|
143
|
+
# (app_id, result_id) → ResultResponse
|
|
144
|
+
self._results: dict[tuple[str, int], ResultResponse] = {}
|
|
145
|
+
self._result_counters: dict[str, int] = defaultdict(int)
|
|
146
|
+
self._quality_controls: dict[str, QualityControlData] = {}
|
|
147
|
+
|
|
148
|
+
self._host: str = "localhost"
|
|
149
|
+
self._port: int = 8001
|
|
150
|
+
self._server: Optional[uvicorn.Server] = None
|
|
151
|
+
self._thread: Optional[threading.Thread] = None
|
|
152
|
+
self._sock: Optional[socket.socket] = None
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# In-process state helpers (no HTTP needed)
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def add_slide(self, metadata: ImageMetadata) -> None:
|
|
159
|
+
"""Pre-register a slide so the mock returns your custom metadata."""
|
|
160
|
+
self._slides[metadata.id] = metadata
|
|
161
|
+
|
|
162
|
+
def add_slide_files(
|
|
163
|
+
self,
|
|
164
|
+
slide_id: str,
|
|
165
|
+
files: pathlib.Path | list[pathlib.Path],
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Register real files to be served by ``GET /slides/{slide_id}/files``.
|
|
168
|
+
|
|
169
|
+
When not set, the endpoint returns a minimal dummy TIFF.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
slide_id:
|
|
174
|
+
Slide to associate the files with.
|
|
175
|
+
files:
|
|
176
|
+
One or more file paths. Each file is served as a separate
|
|
177
|
+
multipart part, with the filename taken from ``path.name``.
|
|
178
|
+
"""
|
|
179
|
+
if isinstance(files, pathlib.Path):
|
|
180
|
+
files = [files]
|
|
181
|
+
self._slide_files[slide_id] = list(files)
|
|
182
|
+
|
|
183
|
+
def get_results(self, app_id: str, slide_id: str) -> list[ResultResponse]:
|
|
184
|
+
"""Return all stored results for *app_id* + *slide_id* (in-process).
|
|
185
|
+
|
|
186
|
+
Reads directly from internal state — no HTTP round-trip required.
|
|
187
|
+
"""
|
|
188
|
+
return [r for (aid, _), r in self._results.items() if aid == app_id and r.slideId == slide_id]
|
|
189
|
+
|
|
190
|
+
def trigger(
|
|
191
|
+
self,
|
|
192
|
+
webhook_url: str,
|
|
193
|
+
invocation: Invocation,
|
|
194
|
+
) -> dict:
|
|
195
|
+
"""Fire an invocation at *webhook_url*, simulating Sectra.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
webhook_url:
|
|
200
|
+
Full URL of the analysis app's invocation endpoint, e.g.
|
|
201
|
+
``"http://localhost:8000/sectra/hook"``.
|
|
202
|
+
invocation:
|
|
203
|
+
The invocation to fire.
|
|
204
|
+
|
|
205
|
+
Returns
|
|
206
|
+
-------
|
|
207
|
+
dict
|
|
208
|
+
JSON body returned by the webhook.
|
|
209
|
+
"""
|
|
210
|
+
self._get_or_create_slide(invocation.slideId) # ensure slide metadata exists for the invocation
|
|
211
|
+
resp = _requests.post(webhook_url, json=invocation.model_dump(), timeout=30)
|
|
212
|
+
resp.raise_for_status()
|
|
213
|
+
return resp.json()
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# Server lifecycle
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def run(self, port: int = 8001, host: str = "localhost") -> "MockSectraServer":
|
|
220
|
+
"""Start the HTTP server in a background thread and return *self*.
|
|
221
|
+
|
|
222
|
+
Designed for use as a context manager::
|
|
223
|
+
|
|
224
|
+
with server.run(port=8001):
|
|
225
|
+
... # server running here
|
|
226
|
+
# server stopped on exit
|
|
227
|
+
|
|
228
|
+
Or call :meth:`start` / :meth:`stop` directly for manual control.
|
|
229
|
+
"""
|
|
230
|
+
self._host = host
|
|
231
|
+
self._port = port
|
|
232
|
+
self.start()
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
def start(self) -> None:
|
|
236
|
+
"""Start the uvicorn server in a background daemon thread."""
|
|
237
|
+
if self._server is not None:
|
|
238
|
+
raise RuntimeError("Server is already running")
|
|
239
|
+
|
|
240
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
241
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
242
|
+
try:
|
|
243
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_MAXSEG, 1440)
|
|
244
|
+
except OSError as e:
|
|
245
|
+
print(f"[mock] Warning: could not set TCP_MAXSEG: {e}")
|
|
246
|
+
sock.bind((self._host, self._port))
|
|
247
|
+
self._sock = sock # keep reference so GC doesn't close it
|
|
248
|
+
|
|
249
|
+
config = uvicorn.Config(
|
|
250
|
+
self.create_app(),
|
|
251
|
+
fd=sock.fileno(),
|
|
252
|
+
log_level="warning",
|
|
253
|
+
)
|
|
254
|
+
self._server = uvicorn.Server(config)
|
|
255
|
+
self._thread = threading.Thread(target=self._server.run, daemon=True)
|
|
256
|
+
self._thread.start()
|
|
257
|
+
|
|
258
|
+
deadline = time.monotonic() + 10.0
|
|
259
|
+
while not self._server.started:
|
|
260
|
+
if time.monotonic() > deadline:
|
|
261
|
+
raise RuntimeError("Mock server did not start within 10 seconds")
|
|
262
|
+
time.sleep(0.05)
|
|
263
|
+
|
|
264
|
+
def stop(self) -> None:
|
|
265
|
+
"""Shut down the background server."""
|
|
266
|
+
if self._server is not None:
|
|
267
|
+
self._server.should_exit = True
|
|
268
|
+
if self._thread is not None:
|
|
269
|
+
self._thread.join(timeout=5)
|
|
270
|
+
self._server = None
|
|
271
|
+
self._thread = None
|
|
272
|
+
if self._sock is not None:
|
|
273
|
+
self._sock.close()
|
|
274
|
+
self._sock = None
|
|
275
|
+
|
|
276
|
+
def __enter__(self) -> "MockSectraServer":
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
def __exit__(self, *_: object) -> None:
|
|
280
|
+
self.stop()
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------------
|
|
283
|
+
# FastAPI application factory
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
def _get_or_create_slide(self, slide_id: str) -> ImageMetadata:
|
|
287
|
+
if slide_id not in self._slides:
|
|
288
|
+
self._slides[slide_id] = _make_fake_metadata(slide_id)
|
|
289
|
+
return self._slides[slide_id]
|
|
290
|
+
|
|
291
|
+
def _verify_token(self, authorization: Optional[str]) -> None:
|
|
292
|
+
if authorization != f"Bearer {self.token}":
|
|
293
|
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
294
|
+
|
|
295
|
+
def create_app(self) -> FastAPI:
|
|
296
|
+
"""Build and return the FastAPI application.
|
|
297
|
+
|
|
298
|
+
All Sectra endpoints plus a ``GET /dev/slides`` diagnostic route are
|
|
299
|
+
registered. The app captures ``self`` via closure so multiple server
|
|
300
|
+
instances are independent.
|
|
301
|
+
"""
|
|
302
|
+
app = FastAPI(
|
|
303
|
+
title="Mock Sectra PACS Server",
|
|
304
|
+
description="Local-dev mock of the Sectra Image Analysis API.",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# ---- /info --------------------------------------------------------
|
|
308
|
+
@app.get("/info")
|
|
309
|
+
def get_info(authorization: Optional[str] = Header(None)):
|
|
310
|
+
self._verify_token(authorization)
|
|
311
|
+
return ApplicationInfo(
|
|
312
|
+
apiVersion=self.api_version,
|
|
313
|
+
softwareVersion=self.software_version,
|
|
314
|
+
).model_dump()
|
|
315
|
+
|
|
316
|
+
# ---- /slides/{slide_id}/info --------------------------------------
|
|
317
|
+
@app.get("/slides/{slide_id}/info")
|
|
318
|
+
def get_slide_info(slide_id: str, authorization: Optional[str] = Header(None)):
|
|
319
|
+
self._verify_token(authorization)
|
|
320
|
+
return self._get_or_create_slide(slide_id).model_dump()
|
|
321
|
+
|
|
322
|
+
# ---- /slides/{slide_id}/label -------------------------------------
|
|
323
|
+
@app.get("/slides/{slide_id}/label")
|
|
324
|
+
def get_slide_label(slide_id: str, authorization: Optional[str] = Header(None)):
|
|
325
|
+
self._verify_token(authorization)
|
|
326
|
+
return Response(content=_LABEL_JPEG, media_type="image/jpeg")
|
|
327
|
+
|
|
328
|
+
# ---- /slides/{slide_id}/files -------------------------------------
|
|
329
|
+
@app.get("/slides/{slide_id}/files")
|
|
330
|
+
def get_slide_files(slide_id: str, authorization: Optional[str] = Header(None)):
|
|
331
|
+
self._verify_token(authorization)
|
|
332
|
+
boundary = "mock_boundary"
|
|
333
|
+
|
|
334
|
+
if slide_id in self._slide_files:
|
|
335
|
+
# Stream the registered real files as multipart parts so large
|
|
336
|
+
# WSI files are never loaded into memory all at once.
|
|
337
|
+
return StreamingResponse(
|
|
338
|
+
_iter_multipart(boundary, self._slide_files[slide_id]),
|
|
339
|
+
media_type=f"multipart/form-data; boundary={boundary}",
|
|
340
|
+
headers={"X-Sectra-ApiVersion": self.api_version},
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
# Fall back to a minimal dummy TIFF (little-endian magic bytes)
|
|
344
|
+
dummy_content = b"II\x2a\x00\x08\x00\x00\x00"
|
|
345
|
+
body = (
|
|
346
|
+
f"--{boundary}\r\n"
|
|
347
|
+
f'Content-Disposition: attachment; filename="{slide_id}.tiff"\r\n'
|
|
348
|
+
"Content-Type: image/tiff\r\n\r\n"
|
|
349
|
+
).encode() + dummy_content + f"\r\n--{boundary}--\r\n".encode()
|
|
350
|
+
return Response(
|
|
351
|
+
content=body,
|
|
352
|
+
media_type=f"multipart/form-data; boundary={boundary}",
|
|
353
|
+
headers={"X-Sectra-ApiVersion": self.api_version},
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# ---- /requests/{accession_number}/images/info --------------------
|
|
357
|
+
@app.get("/requests/{accession_number}/images/info")
|
|
358
|
+
def get_case_images_by_accession(accession_number: str, authorization: Optional[str] = Header(None)):
|
|
359
|
+
self._verify_token(authorization)
|
|
360
|
+
matching = [
|
|
361
|
+
CaseImageInfo(
|
|
362
|
+
id=s.id,
|
|
363
|
+
staining=s.staining,
|
|
364
|
+
block=s.block,
|
|
365
|
+
specimen=s.specimen,
|
|
366
|
+
seriesInstanceUid=s.seriesInstanceUid,
|
|
367
|
+
lisSlideId=s.lisSlideId,
|
|
368
|
+
)
|
|
369
|
+
for s in self._slides.values()
|
|
370
|
+
if s.accessionNumber == accession_number
|
|
371
|
+
]
|
|
372
|
+
return [m.model_dump() for m in matching]
|
|
373
|
+
|
|
374
|
+
# ---- /slides/{slide_id}/request/images/info ----------------------
|
|
375
|
+
@app.get("/slides/{slide_id}/request/images/info")
|
|
376
|
+
def get_case_images_by_slide(slide_id: str, authorization: Optional[str] = Header(None)):
|
|
377
|
+
self._verify_token(authorization)
|
|
378
|
+
meta = self._get_or_create_slide(slide_id)
|
|
379
|
+
if meta.accessionNumber:
|
|
380
|
+
siblings = [s for s in self._slides.values() if s.accessionNumber == meta.accessionNumber]
|
|
381
|
+
else:
|
|
382
|
+
siblings = [meta]
|
|
383
|
+
result = [
|
|
384
|
+
CaseImageInfo(
|
|
385
|
+
id=s.id,
|
|
386
|
+
staining=s.staining,
|
|
387
|
+
block=s.block,
|
|
388
|
+
specimen=s.specimen,
|
|
389
|
+
seriesInstanceUid=s.seriesInstanceUid,
|
|
390
|
+
lisSlideId=s.lisSlideId,
|
|
391
|
+
)
|
|
392
|
+
for s in siblings
|
|
393
|
+
]
|
|
394
|
+
return [r.model_dump() for r in result]
|
|
395
|
+
|
|
396
|
+
# ---- POST /applications/{app_id}/results -------------------------
|
|
397
|
+
@app.post("/applications/{app_id}/results", status_code=201)
|
|
398
|
+
def create_result(app_id: str, result: Result, authorization: Optional[str] = Header(None)):
|
|
399
|
+
self._verify_token(authorization)
|
|
400
|
+
self._result_counters[app_id] += 1
|
|
401
|
+
result_id = self._result_counters[app_id]
|
|
402
|
+
response = ResultResponse(**result.model_dump(), id=result_id, versionId=str(uuid.uuid4()))
|
|
403
|
+
self._results[(app_id, result_id)] = response
|
|
404
|
+
return JSONResponse(response.model_dump(), status_code=201)
|
|
405
|
+
|
|
406
|
+
# ---- GET /applications/{app_id}/results/slide/{wsi_id} -----------
|
|
407
|
+
# Registered before /{result_id} so the literal "slide" segment is
|
|
408
|
+
# not mistakenly captured as an integer result_id.
|
|
409
|
+
@app.get("/applications/{app_id}/results/slide/{wsi_id}")
|
|
410
|
+
def get_results_for_slide(app_id: str, wsi_id: str, authorization: Optional[str] = Header(None)):
|
|
411
|
+
self._verify_token(authorization)
|
|
412
|
+
results = [r for (aid, _), r in self._results.items() if aid == app_id and r.slideId == wsi_id]
|
|
413
|
+
return [r.model_dump() for r in results]
|
|
414
|
+
|
|
415
|
+
# ---- GET /applications/{app_id}/results/{result_id} --------------
|
|
416
|
+
@app.get("/applications/{app_id}/results/{result_id}")
|
|
417
|
+
def get_result(app_id: str, result_id: int, authorization: Optional[str] = Header(None)):
|
|
418
|
+
self._verify_token(authorization)
|
|
419
|
+
key = (app_id, result_id)
|
|
420
|
+
if key not in self._results:
|
|
421
|
+
raise HTTPException(status_code=404, detail=f"Result {result_id} not found for app {app_id!r}")
|
|
422
|
+
return self._results[key].model_dump()
|
|
423
|
+
|
|
424
|
+
# ---- PUT /applications/{app_id}/results/{result_id} --------------
|
|
425
|
+
@app.put("/applications/{app_id}/results/{result_id}")
|
|
426
|
+
def update_result(
|
|
427
|
+
app_id: str,
|
|
428
|
+
result_id: int,
|
|
429
|
+
result: AdaptedResult,
|
|
430
|
+
authorization: Optional[str] = Header(None),
|
|
431
|
+
):
|
|
432
|
+
self._verify_token(authorization)
|
|
433
|
+
key = (app_id, result_id)
|
|
434
|
+
if key not in self._results:
|
|
435
|
+
raise HTTPException(status_code=404, detail=f"Result {result_id} not found for app {app_id!r}")
|
|
436
|
+
data = result.model_dump()
|
|
437
|
+
data["id"] = result_id
|
|
438
|
+
data["versionId"] = str(uuid.uuid4())
|
|
439
|
+
updated = ResultResponse(**data)
|
|
440
|
+
self._results[key] = updated
|
|
441
|
+
return updated.model_dump()
|
|
442
|
+
|
|
443
|
+
# ---- PUT /slides/{slide_id}/qualityControl -----------------------
|
|
444
|
+
@app.put("/slides/{slide_id}/qualityControl")
|
|
445
|
+
def set_quality_control(
|
|
446
|
+
slide_id: str,
|
|
447
|
+
qc: QualityControl,
|
|
448
|
+
authorization: Optional[str] = Header(None),
|
|
449
|
+
):
|
|
450
|
+
self._verify_token(authorization)
|
|
451
|
+
self._get_or_create_slide(slide_id)
|
|
452
|
+
self._quality_controls[slide_id] = qc.qualityControl
|
|
453
|
+
return Response(status_code=200)
|
|
454
|
+
|
|
455
|
+
# ---- GET /dev/slides — diagnostic, no auth -----------------------
|
|
456
|
+
@app.get("/dev/slides")
|
|
457
|
+
def dev_list_slides():
|
|
458
|
+
"""List all registered slide IDs (for debugging)."""
|
|
459
|
+
return list(self._slides.keys())
|
|
460
|
+
|
|
461
|
+
return app
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ---------------------------------------------------------------------------
|
|
465
|
+
# Module-level default instance — used by: uvicorn sectra_client.mock_server:app
|
|
466
|
+
# ---------------------------------------------------------------------------
|
|
467
|
+
_default_server = MockSectraServer()
|
|
468
|
+
app = _default_server.create_app()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from .common import CallbackInfo, DisplayedName, InputType, Point, Polygon, Size
|
|
2
|
+
from .image import CaseImageInfo, FocalPlane, ImageMetadata, OpticalPath, SlideFormat, Specimen, TileFormat
|
|
3
|
+
from .info import ApplicationInfo
|
|
4
|
+
from .invocation import (
|
|
5
|
+
Action,
|
|
6
|
+
CancelInvocation,
|
|
7
|
+
CreateInput,
|
|
8
|
+
CreateInvocation,
|
|
9
|
+
DeleteInvocation,
|
|
10
|
+
ImageNotification,
|
|
11
|
+
Invocation,
|
|
12
|
+
InvocationBase,
|
|
13
|
+
ModifyInvocation,
|
|
14
|
+
MultiAreaContent,
|
|
15
|
+
MultiAreaInput,
|
|
16
|
+
TaggedPolygonContent,
|
|
17
|
+
TaggedPolygonInput,
|
|
18
|
+
WholeSlideInput,
|
|
19
|
+
)
|
|
20
|
+
from .quality_control import QualityControl, QualityControlData, QualityControlStatus
|
|
21
|
+
from .registration import InputTemplate, Registration, TaggedPolygonInputContent
|
|
22
|
+
from .results import (
|
|
23
|
+
AdaptedResult,
|
|
24
|
+
Attachment,
|
|
25
|
+
AttachmentState,
|
|
26
|
+
DisplayProperties,
|
|
27
|
+
Label,
|
|
28
|
+
Patch,
|
|
29
|
+
PatchContent,
|
|
30
|
+
PatchResultContent,
|
|
31
|
+
Polyline,
|
|
32
|
+
PrimitiveItem,
|
|
33
|
+
PrimitiveResultContent,
|
|
34
|
+
Result,
|
|
35
|
+
ResultContent,
|
|
36
|
+
ResultData,
|
|
37
|
+
ResultResponse,
|
|
38
|
+
ResultType,
|
|
39
|
+
Status,
|
|
40
|
+
Style,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"CallbackInfo",
|
|
45
|
+
"DisplayedName",
|
|
46
|
+
"InputType",
|
|
47
|
+
"Point",
|
|
48
|
+
"Polygon",
|
|
49
|
+
"Size",
|
|
50
|
+
"CaseImageInfo",
|
|
51
|
+
"FocalPlane",
|
|
52
|
+
"ImageMetadata",
|
|
53
|
+
"OpticalPath",
|
|
54
|
+
"SlideFormat",
|
|
55
|
+
"Specimen",
|
|
56
|
+
"TileFormat",
|
|
57
|
+
"ApplicationInfo",
|
|
58
|
+
"Action",
|
|
59
|
+
"CancelInvocation",
|
|
60
|
+
"CreateInput",
|
|
61
|
+
"CreateInvocation",
|
|
62
|
+
"DeleteInvocation",
|
|
63
|
+
"ImageNotification",
|
|
64
|
+
"Invocation",
|
|
65
|
+
"InvocationBase",
|
|
66
|
+
"ModifyInvocation",
|
|
67
|
+
"MultiAreaContent",
|
|
68
|
+
"MultiAreaInput",
|
|
69
|
+
"TaggedPolygonContent",
|
|
70
|
+
"TaggedPolygonInput",
|
|
71
|
+
"WholeSlideInput",
|
|
72
|
+
"QualityControl",
|
|
73
|
+
"QualityControlData",
|
|
74
|
+
"QualityControlStatus",
|
|
75
|
+
"InputTemplate",
|
|
76
|
+
"Registration",
|
|
77
|
+
"TaggedPolygonInputContent",
|
|
78
|
+
"AdaptedResult",
|
|
79
|
+
"Attachment",
|
|
80
|
+
"AttachmentState",
|
|
81
|
+
"DisplayProperties",
|
|
82
|
+
"Label",
|
|
83
|
+
"Patch",
|
|
84
|
+
"PatchContent",
|
|
85
|
+
"PatchResultContent",
|
|
86
|
+
"Polyline",
|
|
87
|
+
"PrimitiveItem",
|
|
88
|
+
"PrimitiveResultContent",
|
|
89
|
+
"Result",
|
|
90
|
+
"ResultContent",
|
|
91
|
+
"ResultData",
|
|
92
|
+
"ResultResponse",
|
|
93
|
+
"ResultType",
|
|
94
|
+
"Status",
|
|
95
|
+
"Style",
|
|
96
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from enum import Enum, unique
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CallbackInfo(BaseModel):
|
|
8
|
+
"""Model for callbacks info."""
|
|
9
|
+
|
|
10
|
+
url: str
|
|
11
|
+
token: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@unique
|
|
15
|
+
class InputType(str, Enum):
|
|
16
|
+
"""Enum for input template types."""
|
|
17
|
+
|
|
18
|
+
MULTI_AREA = "multiArea"
|
|
19
|
+
TAGGED_POLYGON = "taggedPolygon"
|
|
20
|
+
WHOLE_SLIDE = "wholeSlide"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Point(BaseModel):
|
|
24
|
+
"""Model for points."""
|
|
25
|
+
|
|
26
|
+
x: float
|
|
27
|
+
y: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Polygon(BaseModel):
|
|
31
|
+
"""Model for polygons."""
|
|
32
|
+
|
|
33
|
+
points: List[Point]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Size(BaseModel):
|
|
37
|
+
"""Model for size."""
|
|
38
|
+
|
|
39
|
+
width: int
|
|
40
|
+
height: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DisplayedName(BaseModel):
|
|
44
|
+
"""Model for displayed names."""
|
|
45
|
+
|
|
46
|
+
displayName: str
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from sectra_client.schemas.common import DisplayedName, Size
|
|
6
|
+
from sectra_client.schemas.quality_control import QualityControlData
|
|
7
|
+
from sectra_client.utils.decode_images import JPEGImage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FocalPlane(BaseModel):
|
|
11
|
+
"""Model for focal planes."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
offsetUm: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpticalPath(BaseModel):
|
|
18
|
+
"""Model for optical paths."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
description: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TileFormat(BaseModel):
|
|
25
|
+
"""Model for tiles format."""
|
|
26
|
+
|
|
27
|
+
mimeType: str
|
|
28
|
+
extension: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SlideFormat(BaseModel):
|
|
32
|
+
"""Model for slide format."""
|
|
33
|
+
|
|
34
|
+
mimeType: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Specimen(BaseModel):
|
|
38
|
+
"""Model for specimen fields."""
|
|
39
|
+
|
|
40
|
+
anatomy: Optional[str] = None
|
|
41
|
+
description: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ImageMetadata(BaseModel):
|
|
45
|
+
"""Model for images information."""
|
|
46
|
+
|
|
47
|
+
id: str
|
|
48
|
+
isStreamable: bool
|
|
49
|
+
imageSize: Size
|
|
50
|
+
tileSize: Size
|
|
51
|
+
micronsPerPixel: float
|
|
52
|
+
focalPlanes: List[FocalPlane]
|
|
53
|
+
opticalPaths: List[OpticalPath]
|
|
54
|
+
storedTileFormat: TileFormat
|
|
55
|
+
availableTileFormats: List[TileFormat]
|
|
56
|
+
fileFormat: SlideFormat
|
|
57
|
+
staining: DisplayedName
|
|
58
|
+
block: DisplayedName
|
|
59
|
+
specimen: Optional[Specimen] = None
|
|
60
|
+
bodyPart: Optional[str] = None
|
|
61
|
+
examCode: Optional[str] = None
|
|
62
|
+
examDescription: Optional[str] = None
|
|
63
|
+
stationName: Optional[str] = None
|
|
64
|
+
priority: Optional[int] = None
|
|
65
|
+
qualityControl: Optional[QualityControlData] = None
|
|
66
|
+
seriesInstanceUid: Optional[str] = None
|
|
67
|
+
lisSlideId: Optional[str] = None
|
|
68
|
+
accessionNumberIssuer: Optional[str] = None
|
|
69
|
+
accessionNumber: Optional[str] = None
|
|
70
|
+
studyInstanceUid: Optional[str] = None
|
|
71
|
+
examId: Optional[str] = None
|
|
72
|
+
examDateTime: Optional[str] = None
|
|
73
|
+
examFreeFields: List[Dict[str, str]] = Field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CaseImageInfo(BaseModel):
|
|
77
|
+
id: str
|
|
78
|
+
staining: DisplayedName
|
|
79
|
+
block: DisplayedName
|
|
80
|
+
specimen: Optional[Specimen] = None
|
|
81
|
+
seriesInstanceUid: Optional[str] = None
|
|
82
|
+
lisSlideId: Optional[str] = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LabelImage(BaseModel, JPEGImage):
|
|
86
|
+
"""Model for label images."""
|
|
87
|
+
|
|
88
|
+
image: bytes
|