NMundhra commited on
Commit
3777dcd
·
verified ·
1 Parent(s): 104453c

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies (for OpenCV)
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ libgl1 \
8
+ libglib2.0-0 \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Install python dependencies
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Copy all application files
16
+ COPY . .
17
+
18
+ # Hugging Face Spaces expose port 7860
19
+ EXPOSE 7860
20
+
21
+ # Run the FastAPI server
22
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import cv2
3
+ import numpy as np
4
+ from pathlib import Path
5
+ from fastapi import FastAPI, UploadFile, File, HTTPException
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ import tempfile
8
+ import os
9
+ import json
10
+
11
+ from models.unet import HierarchicalUNet
12
+ from src.inference.analyzer import SegmentationAnalyzer
13
+
14
+ app = FastAPI(title="OCT Segmentation API")
15
+
16
+ # Setup CORS to allow requests from the frontend
17
+ app.add_middleware(
18
+ CORSMiddleware,
19
+ allow_origins=["*"],
20
+ allow_methods=["*"],
21
+ allow_headers=["*"],
22
+ )
23
+
24
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
25
+ checkpoint_path = Path(__file__).parent / "unet_hierarchical_best.pth"
26
+ model = None
27
+
28
+ @app.on_event("startup")
29
+ def load_model():
30
+ global model
31
+ if checkpoint_path.exists():
32
+ print(f"Loading model from {checkpoint_path}...")
33
+ model = HierarchicalUNet(n_channels=1, n_coarse_classes=3, n_granular_classes=15)
34
+ checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=True)
35
+ model.load_state_dict(checkpoint['model_state_dict'])
36
+ model.to(device)
37
+ model.eval()
38
+ else:
39
+ print(f"Warning: Checkpoint {checkpoint_path} not found.")
40
+
41
+ @app.post("/predict")
42
+ async def predict_endpoint(file: UploadFile = File(...)):
43
+ if model is None:
44
+ raise HTTPException(status_code=500, detail="Model not loaded")
45
+
46
+ suffix = Path(file.filename or "").suffix.lower()
47
+
48
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
49
+ content = await file.read()
50
+ tmp.write(content)
51
+ tmp_path = tmp.name
52
+
53
+ try:
54
+ img = cv2.imread(tmp_path, cv2.IMREAD_GRAYSCALE)
55
+ if img is None:
56
+ raise HTTPException(status_code=400, detail="Invalid image file")
57
+
58
+ img_resized = cv2.resize(img, (512, 512))
59
+ img_normalized = img_resized.astype(np.float32) / 255.0
60
+ img_tensor = torch.from_numpy(img_normalized).unsqueeze(0).unsqueeze(0).to(device)
61
+
62
+ with torch.no_grad():
63
+ coarse_logits, granular_logits = model(img_tensor)
64
+
65
+ granular_preds = torch.argmax(granular_logits, dim=1).squeeze(0).cpu().numpy()
66
+
67
+ analyzer = SegmentationAnalyzer()
68
+ analysis = analyzer.analyze(granular_preds)
69
+
70
+ # Convert to dictionary matching the JSON structure
71
+ return json.loads(analysis.to_json())
72
+ except Exception as e:
73
+ raise HTTPException(status_code=500, detail=str(e))
74
+ finally:
75
+ if os.path.exists(tmp_path):
76
+ os.remove(tmp_path)
models/__pycache__/unet.cpython-314.pyc ADDED
Binary file (19.9 kB). View file
 
models/unet.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+
5
+
6
+ class AttentionGate(nn.Module):
7
+ """
8
+ Attention Gate from 'Attention U-Net' (Oktay et al., 2018).
9
+
10
+ Learns a soft spatial attention mask that suppresses irrelevant background
11
+ activations in each skip connection, directing the decoder to focus on
12
+ clinically meaningful structures (thin retinal layers, small fluid pockets).
13
+
14
+ Applied on every skip connection in the decoder, so the model explicitly
15
+ learns *where* in the scan each layer/lesion should appear — addressing
16
+ the vanilla U-Net's inability to model long-range spatial dependencies.
17
+
18
+ Args:
19
+ F_g : Channels in the gating signal (upsampled decoder feature, x1).
20
+ F_l : Channels in the skip connection (encoder feature, x2).
21
+ F_int: Intermediate channels for the attention computation (typically F_g // 2).
22
+ """
23
+
24
+ def __init__(self, F_g: int, F_l: int, F_int: int):
25
+ super().__init__()
26
+ # Project gating signal to F_int
27
+ self.W_g = nn.Sequential(
28
+ nn.Conv2d(F_g, F_int, kernel_size=1, bias=True),
29
+ nn.BatchNorm2d(F_int),
30
+ )
31
+ # Project skip connection to F_int
32
+ self.W_x = nn.Sequential(
33
+ nn.Conv2d(F_l, F_int, kernel_size=1, bias=True),
34
+ nn.BatchNorm2d(F_int),
35
+ )
36
+ # Scalar attention coefficient per spatial location
37
+ self.psi = nn.Sequential(
38
+ nn.Conv2d(F_int, 1, kernel_size=1, bias=True),
39
+ nn.BatchNorm2d(1),
40
+ nn.Sigmoid(),
41
+ )
42
+ self.relu = nn.ReLU(inplace=True)
43
+
44
+ def forward(self, g: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
45
+ """
46
+ Args:
47
+ g : Gating signal from the decoder path (upsampled to match x spatially).
48
+ x : Skip connection from the encoder path.
49
+ Returns:
50
+ Attention-weighted skip connection: x * attention_map.
51
+ """
52
+ g1 = self.W_g(g)
53
+ x1 = self.W_x(x)
54
+ psi = self.relu(g1 + x1)
55
+ psi = self.psi(psi) # (B, 1, H, W) attention map in [0, 1]
56
+ return x * psi # broadcast multiply across all channels
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Standard U-Net building blocks
61
+ # ---------------------------------------------------------------------------
62
+
63
+ class DoubleConv(nn.Module):
64
+ """(convolution => [BN] => ReLU) * 2"""
65
+
66
+ def __init__(self, in_channels: int, out_channels: int, mid_channels: int = None):
67
+ super().__init__()
68
+ if not mid_channels:
69
+ mid_channels = out_channels
70
+ self.double_conv = nn.Sequential(
71
+ nn.Conv2d(in_channels, mid_channels, kernel_size=3, padding=1, bias=False),
72
+ nn.BatchNorm2d(mid_channels),
73
+ nn.ReLU(inplace=True),
74
+ nn.Conv2d(mid_channels, out_channels, kernel_size=3, padding=1, bias=False),
75
+ nn.BatchNorm2d(out_channels),
76
+ nn.ReLU(inplace=True),
77
+ )
78
+
79
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
80
+ return self.double_conv(x)
81
+
82
+
83
+ class Down(nn.Module):
84
+ """Downscaling with maxpool then double conv"""
85
+
86
+ def __init__(self, in_channels: int, out_channels: int):
87
+ super().__init__()
88
+ self.maxpool_conv = nn.Sequential(
89
+ nn.MaxPool2d(2),
90
+ DoubleConv(in_channels, out_channels),
91
+ )
92
+
93
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
94
+ return self.maxpool_conv(x)
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # ASPP Bottleneck (Fix #4 — multi-scale receptive field)
99
+ # ---------------------------------------------------------------------------
100
+
101
+ class _ASPPConv(nn.Module):
102
+ """Single dilated convolution branch inside ASPP."""
103
+
104
+ def __init__(self, in_channels: int, out_channels: int, dilation: int):
105
+ super().__init__()
106
+ self.block = nn.Sequential(
107
+ nn.Conv2d(in_channels, out_channels, kernel_size=3,
108
+ padding=dilation, dilation=dilation, bias=False),
109
+ nn.BatchNorm2d(out_channels),
110
+ nn.ReLU(inplace=True),
111
+ )
112
+
113
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
114
+ return self.block(x)
115
+
116
+
117
+ class _ASPPPooling(nn.Module):
118
+ """Global average pooling branch inside ASPP."""
119
+
120
+ def __init__(self, in_channels: int, out_channels: int):
121
+ super().__init__()
122
+ self.pool = nn.Sequential(
123
+ nn.AdaptiveAvgPool2d(1),
124
+ nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False),
125
+ nn.BatchNorm2d(out_channels),
126
+ nn.ReLU(inplace=True),
127
+ )
128
+
129
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
130
+ size = x.shape[-2:]
131
+ pooled = self.pool(x) # (B, C, 1, 1)
132
+ return F.interpolate(pooled, size=size,
133
+ mode='bilinear', align_corners=False) # upsample back
134
+
135
+
136
+ class ASPP(nn.Module):
137
+ """
138
+ Atrous Spatial Pyramid Pooling (from DeepLab v3).
139
+
140
+ Runs five parallel branches over the bottleneck feature map:
141
+ 1. 1×1 convolution — captures pixel-level features
142
+ 2–4. 3×3 dilated convs — dilation rates 6, 12, 18 cover
143
+ fields of ~13, ~25, ~37 pixels at 32×32
144
+ 5. Global average pooling — captures the full-image context
145
+
146
+ The five outputs are concatenated and projected to `out_channels`.
147
+ This gives the decoder access to multi-scale context without any
148
+ additional spatial compression — addressing the bottleneck's tendency
149
+ to lose thin-layer detail through aggressive downsampling.
150
+
151
+ Args:
152
+ in_channels : Input channel count (512 for down4 output).
153
+ out_channels : Output channel count (1024 for the decoder).
154
+ dilations : Dilation rates for the three parallel atrous convs.
155
+ """
156
+
157
+ _MID = 256 # channels per branch; 5 × 256 = 1280 → projected to out_channels
158
+
159
+ def __init__(self, in_channels: int, out_channels: int,
160
+ dilations: tuple = (6, 12, 18)):
161
+ super().__init__()
162
+ m = self._MID
163
+ self.branch_1x1 = nn.Sequential(
164
+ nn.Conv2d(in_channels, m, kernel_size=1, bias=False),
165
+ nn.BatchNorm2d(m),
166
+ nn.ReLU(inplace=True),
167
+ )
168
+ self.branch_d1 = _ASPPConv(in_channels, m, dilations[0])
169
+ self.branch_d2 = _ASPPConv(in_channels, m, dilations[1])
170
+ self.branch_d3 = _ASPPConv(in_channels, m, dilations[2])
171
+ self.branch_pool = _ASPPPooling(in_channels, m)
172
+
173
+ self.project = nn.Sequential(
174
+ nn.Conv2d(5 * m, out_channels, kernel_size=1, bias=False),
175
+ nn.BatchNorm2d(out_channels),
176
+ nn.ReLU(inplace=True),
177
+ nn.Dropout2d(0.1), # light regularisation on bottleneck features
178
+ )
179
+
180
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
181
+ branches = [
182
+ self.branch_1x1(x),
183
+ self.branch_d1(x),
184
+ self.branch_d2(x),
185
+ self.branch_d3(x),
186
+ self.branch_pool(x),
187
+ ]
188
+ return self.project(torch.cat(branches, dim=1))
189
+
190
+
191
+ class BottleneckASPP(nn.Module):
192
+ """
193
+ Drop-in replacement for the deepest `Down` block.
194
+ Applies MaxPool2d then ASPP (instead of MaxPool2d then DoubleConv),
195
+ so the interface is identical: `forward(x) -> tensor`.
196
+ """
197
+
198
+ def __init__(self, in_channels: int, out_channels: int,
199
+ dilations: tuple = (6, 12, 18)):
200
+ super().__init__()
201
+ self.maxpool = nn.MaxPool2d(2)
202
+ self.aspp = ASPP(in_channels, out_channels, dilations)
203
+
204
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
205
+ return self.aspp(self.maxpool(x))
206
+
207
+
208
+ class Up(nn.Module):
209
+ """
210
+ Upscaling block with an Attention Gate applied to the skip connection
211
+ before concatenation with the upsampled decoder feature.
212
+
213
+ Channel layout after ConvTranspose2d:
214
+ x1 (decoder, upsampled) : in_channels // 2
215
+ x2 (encoder skip) : in_channels // 2
216
+ The attention gate uses x1 as the gating signal and attends x2.
217
+ """
218
+
219
+ def __init__(self, in_channels: int, out_channels: int):
220
+ super().__init__()
221
+ self.up = nn.ConvTranspose2d(in_channels, in_channels // 2, kernel_size=2, stride=2)
222
+ self.attn = AttentionGate(
223
+ F_g=in_channels // 2,
224
+ F_l=in_channels // 2,
225
+ F_int=in_channels // 4,
226
+ )
227
+ self.conv = DoubleConv(in_channels, out_channels)
228
+
229
+ def forward(self, x1: torch.Tensor, x2: torch.Tensor) -> torch.Tensor:
230
+ x1 = self.up(x1)
231
+ # Pad x1 if spatial sizes don't divide evenly
232
+ diffY = x2.size()[2] - x1.size()[2]
233
+ diffX = x2.size()[3] - x1.size()[3]
234
+ x1 = F.pad(x1, [diffX // 2, diffX - diffX // 2,
235
+ diffY // 2, diffY - diffY // 2])
236
+ # Attend the skip connection before concatenation
237
+ x2 = self.attn(g=x1, x=x2)
238
+ x = torch.cat([x2, x1], dim=1)
239
+ return self.conv(x)
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Main Model
244
+ # ---------------------------------------------------------------------------
245
+
246
+ class HierarchicalUNet(nn.Module):
247
+ """
248
+ Hierarchical Multi-Head U-Net for retinal layer and lesion segmentation.
249
+
250
+ Architecture:
251
+ - Shared U-Net encoder/decoder backbone.
252
+ - ASPP bottleneck (Fix #4): replaces the flat DoubleConv at 32×32 with
253
+ Atrous Spatial Pyramid Pooling to capture multi-scale receptive fields
254
+ without additional spatial compression.
255
+ - Attention Gates on every decoder skip connection (Fix #3).
256
+ - Coarse Head: predicts 3 broad categories (Background, Retina, Fluid/Lesion).
257
+ - Granular Head: predicts 15 fine-grained classes, conditioned on the
258
+ coarse head's softmax probabilities — not raw logits (Fix #2).
259
+ """
260
+
261
+ def __init__(self, n_channels: int = 1, n_coarse_classes: int = 3, n_granular_classes: int = 15):
262
+ super(HierarchicalUNet, self).__init__()
263
+ self.n_channels = n_channels
264
+ self.n_coarse_classes = n_coarse_classes
265
+ self.n_granular_classes = n_granular_classes
266
+
267
+ # Shared Encoder
268
+ self.inc = DoubleConv(n_channels, 64)
269
+ self.down1 = Down(64, 128)
270
+ self.down2 = Down(128, 256)
271
+ self.down3 = Down(256, 512)
272
+ # Bottleneck: ASPP replaces plain DoubleConv for multi-scale context
273
+ self.down4 = BottleneckASPP(512, 1024, dilations=(6, 12, 18))
274
+
275
+ # Shared Decoder — each Up block includes an AttentionGate on its skip connection
276
+ self.up1 = Up(1024, 512)
277
+ self.up2 = Up(512, 256)
278
+ self.up3 = Up(256, 128)
279
+ self.up4 = Up(128, 64)
280
+
281
+ # Coarse Head → 3-channel output
282
+ self.coarse_conv = nn.Sequential(
283
+ DoubleConv(64, 64),
284
+ nn.Conv2d(64, n_coarse_classes, kernel_size=1),
285
+ )
286
+
287
+ # Granular Head → 15-channel output
288
+ # Input: shared decoder features (64) + coarse softmax probabilities (n_coarse_classes)
289
+ self.granular_conv = nn.Sequential(
290
+ DoubleConv(64 + n_coarse_classes, 64),
291
+ nn.Conv2d(64, n_granular_classes, kernel_size=1),
292
+ )
293
+
294
+ def forward(self, x: torch.Tensor):
295
+ # ---- Encoder ----
296
+ x1 = self.inc(x)
297
+ x2 = self.down1(x1)
298
+ x3 = self.down2(x2)
299
+ x4 = self.down3(x3)
300
+ x5 = self.down4(x4)
301
+
302
+ # ---- Decoder (attention-gated skip connections) ----
303
+ x = self.up1(x5, x4)
304
+ x = self.up2(x, x3)
305
+ x = self.up3(x, x2)
306
+ shared_features = self.up4(x, x1)
307
+
308
+ # ---- Coarse Head ----
309
+ coarse_logits = self.coarse_conv(shared_features)
310
+
311
+ # ---- Granular Head ----
312
+ # FIX (Issue #2): pass softmax PROBABILITIES, not raw logits.
313
+ # Raw logits have arbitrary, growing scale during training — the granular
314
+ # DoubleConv cannot reliably interpret them as a coarse prior.
315
+ # Softmax maps them to a stable [0, 1] probability distribution.
316
+ coarse_probs = torch.softmax(coarse_logits, dim=1)
317
+ granular_input = torch.cat([shared_features, coarse_probs], dim=1)
318
+ granular_logits = self.granular_conv(granular_input)
319
+
320
+ return coarse_logits, granular_logits
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ torch
5
+ torchvision
6
+ numpy
7
+ opencv-python-headless
8
+ pydantic
src/inference/__pycache__/analyzer.cpython-314.pyc ADDED
Binary file (7.68 kB). View file
 
src/inference/__pycache__/data_models.cpython-314.pyc ADDED
Binary file (4.8 kB). View file
 
src/inference/__pycache__/export.cpython-314.pyc ADDED
Binary file (1.97 kB). View file
 
src/inference/analyzer.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ from typing import List, Dict
4
+ from src.inference.data_models import Point, BoundingBox, RetinalLayer, LesionInstance, ClinicalMetrics, OCTScanAnalysis
5
+
6
+ class SegmentationAnalyzer:
7
+ """
8
+ Parses a dense (H, W) granular segmentation mask into an object-oriented
9
+ OCTScanAnalysis containing discrete vector instances and metrics.
10
+ """
11
+
12
+ # Example mapping (should match your dataset's exact class labels)
13
+ CLASS_MAP = {
14
+ 0: "Background",
15
+ 1: "ILM",
16
+ 2: "NFL-IPL",
17
+ 3: "INL",
18
+ 4: "OPL",
19
+ 5: "ONL-ISM",
20
+ 6: "ISE",
21
+ 7: "OS-RPE",
22
+ 8: "RPE",
23
+ 9: "Fluid",
24
+ 10: "Hard Drusen",
25
+ 11: "Soft Drusen",
26
+ 12: "PED",
27
+ 13: "Geographic Atrophy",
28
+ 14: "Hyper-reflective Foci"
29
+ }
30
+
31
+ def __init__(self, layer_classes=list(range(1, 9)), lesion_classes=list(range(9, 15))):
32
+ self.layer_classes = layer_classes
33
+ self.lesion_classes = lesion_classes
34
+
35
+ def analyze(self, mask: np.ndarray) -> OCTScanAnalysis:
36
+ height, width = mask.shape
37
+
38
+ layers = self._extract_layers(mask)
39
+ lesions = self._extract_lesions(mask)
40
+ metrics = self._calculate_metrics(layers, lesions, width)
41
+
42
+ return OCTScanAnalysis(
43
+ image_width=width,
44
+ image_height=height,
45
+ layers=layers,
46
+ lesions=lesions,
47
+ clinical_metrics=metrics,
48
+ model_version="unet_hierarchical_v1.0"
49
+ )
50
+
51
+ def _extract_layers(self, mask: np.ndarray) -> List[RetinalLayer]:
52
+ layers = []
53
+ for class_id in self.layer_classes:
54
+ binary_mask = (mask == class_id).astype(np.uint8) * 255
55
+
56
+ # For continuous layers, we could extract the top boundary.
57
+ # A simple approach is taking the argmax along the y-axis for each x.
58
+ # But since layers might have gaps, contours are safer.
59
+ contours, _ = cv2.findContours(binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
60
+
61
+ # To keep it as a simple "layer" concept for the frontend, we just take the largest contour
62
+ # and format it as a boundary. (Alternatively, return all contours as polygons).
63
+ if not contours:
64
+ continue
65
+
66
+ largest_contour = max(contours, key=cv2.contourArea)
67
+ # Simplify contour to reduce payload size (Ramer-Douglas-Peucker)
68
+ epsilon = 0.001 * cv2.arcLength(largest_contour, True)
69
+ approx = cv2.approxPolyDP(largest_contour, epsilon, True)
70
+
71
+ points = [Point(x=int(pt[0][0]), y=int(pt[0][1])) for pt in approx]
72
+
73
+ # Compute average depth (mean y)
74
+ if points:
75
+ avg_depth = float(np.mean([pt.y for pt in points]))
76
+ else:
77
+ avg_depth = 0.0
78
+
79
+ layers.append(RetinalLayer(
80
+ class_id=class_id,
81
+ class_name=self.CLASS_MAP.get(class_id, f"Layer_{class_id}"),
82
+ boundary_points=points,
83
+ avg_depth=avg_depth
84
+ ))
85
+
86
+ return layers
87
+
88
+ def _extract_lesions(self, mask: np.ndarray) -> List[LesionInstance]:
89
+ lesions = []
90
+ for class_id in self.lesion_classes:
91
+ binary_mask = (mask == class_id).astype(np.uint8) * 255
92
+ contours, _ = cv2.findContours(binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
93
+
94
+ for cnt in contours:
95
+ area = cv2.contourArea(cnt)
96
+ # Filter out microscopic noise
97
+ if area < 5.0:
98
+ continue
99
+
100
+ x, y, w, h = cv2.boundingRect(cnt)
101
+ bbox = BoundingBox(xmin=int(x), ymin=int(y), xmax=int(x+w), ymax=int(y+h))
102
+
103
+ # Simplify polygon
104
+ epsilon = 0.005 * cv2.arcLength(cnt, True)
105
+ approx = cv2.approxPolyDP(cnt, epsilon, True)
106
+ polygon = [Point(x=int(pt[0][0]), y=int(pt[0][1])) for pt in approx]
107
+
108
+ lesions.append(LesionInstance(
109
+ class_id=class_id,
110
+ class_name=self.CLASS_MAP.get(class_id, f"Lesion_{class_id}"),
111
+ polygon=polygon,
112
+ bounding_box=bbox,
113
+ area_pixels=float(area),
114
+ max_width=float(w),
115
+ max_height=float(h)
116
+ ))
117
+ return lesions
118
+
119
+ def _calculate_metrics(self, layers: List[RetinalLayer], lesions: List[LesionInstance], width: int) -> ClinicalMetrics:
120
+ # Example Metric: Total fluid area
121
+ fluid_area = sum([L.area_pixels for L in lesions if L.class_name == "Fluid"])
122
+
123
+ # Example Metric: Max fluid height
124
+ fluid_heights = [L.max_height for L in lesions if L.class_name == "Fluid"]
125
+ max_fluid_h = max(fluid_heights) if fluid_heights else 0.0
126
+
127
+ # Example Metric: Average Retinal Thickness
128
+ # Estimated as distance between topmost layer (ILM) and bottommost layer (RPE)
129
+ layer_depths = [layer.avg_depth for layer in layers]
130
+ if len(layer_depths) >= 2:
131
+ thickness = float(max(layer_depths) - min(layer_depths))
132
+ else:
133
+ thickness = 0.0
134
+
135
+ return ClinicalMetrics(
136
+ average_retinal_thickness=thickness,
137
+ total_fluid_area=float(fluid_area),
138
+ max_fluid_height=float(max_fluid_h)
139
+ )
src/inference/data_models.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from dataclasses import dataclass, asdict
3
+ from typing import List, Dict, Optional, Tuple
4
+
5
+ @dataclass
6
+ class Point:
7
+ x: int
8
+ y: int
9
+
10
+ @dataclass
11
+ class BoundingBox:
12
+ xmin: int
13
+ ymin: int
14
+ xmax: int
15
+ ymax: int
16
+
17
+ @dataclass
18
+ class RetinalLayer:
19
+ """
20
+ Represents a continuous anatomical boundary layer (e.g. ILM, RPE).
21
+ Stored as a sequence of points (x, y) forming a 1D spline across the image width.
22
+ """
23
+ class_id: int
24
+ class_name: str
25
+ boundary_points: List[Point]
26
+ avg_depth: float
27
+
28
+ @dataclass
29
+ class LesionInstance:
30
+ """
31
+ Represents a discrete pathological finding (e.g. Fluid, Drusen).
32
+ Stored as a closed polygon contour.
33
+ """
34
+ class_id: int
35
+ class_name: str
36
+ polygon: List[Point]
37
+ bounding_box: BoundingBox
38
+ area_pixels: float
39
+ # Optional clinical metrics based on geometry
40
+ max_height: Optional[float] = None
41
+ max_width: Optional[float] = None
42
+
43
+ @dataclass
44
+ class ClinicalMetrics:
45
+ """
46
+ Global clinical metrics calculated for the entire scan.
47
+ """
48
+ average_retinal_thickness: float
49
+ total_fluid_area: float
50
+ max_fluid_height: float
51
+
52
+ @dataclass
53
+ class OCTScanAnalysis:
54
+ """
55
+ The root object representing the full analysis of a single OCT B-scan.
56
+ """
57
+ image_width: int
58
+ image_height: int
59
+ layers: List[RetinalLayer]
60
+ lesions: List[LesionInstance]
61
+ clinical_metrics: ClinicalMetrics
62
+ model_version: str = "v1.0"
63
+
64
+ def to_json(self) -> str:
65
+ """Serializes the analysis object to a JSON string."""
66
+ return json.dumps(asdict(self), indent=2)
src/inference/export.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+ from src.inference.data_models import OCTScanAnalysis
4
+
5
+ class InferenceExporter:
6
+ @staticmethod
7
+ def to_json_file(analysis: OCTScanAnalysis, filepath: str) -> None:
8
+ """
9
+ Exports the OCTScanAnalysis to a JSON file.
10
+ """
11
+ out_path = Path(filepath)
12
+ out_path.parent.mkdir(parents=True, exist_ok=True)
13
+
14
+ with open(out_path, 'w') as f:
15
+ f.write(analysis.to_json())
16
+
17
+ @staticmethod
18
+ def to_json_string(analysis: OCTScanAnalysis) -> str:
19
+ """
20
+ Exports the OCTScanAnalysis to a JSON string (for direct API responses).
21
+ """
22
+ return analysis.to_json()
unet_hierarchical_best.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e37bdbacdb422cb3bc1ce789b8f523b79220b5758cd4ae04c5086238623e501a
3
+ size 270210923