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.
@@ -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