File size: 15,988 Bytes
827f690
 
7d11de9
827f690
7d11de9
 
 
827f690
7d11de9
 
 
 
 
 
 
 
 
 
827f690
 
 
 
 
 
 
 
 
7d11de9
 
827f690
 
 
7d11de9
827f690
 
 
 
 
 
 
 
 
 
 
 
 
7d11de9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329abd1
 
7d11de9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329abd1
7d11de9
329abd1
7d11de9
 
329abd1
7d11de9
329abd1
 
7d11de9
 
329abd1
 
 
7d11de9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329abd1
 
 
7d11de9
329abd1
7d11de9
329abd1
 
 
7d11de9
 
329abd1
7d11de9
 
 
 
 
 
827f690
 
 
7d11de9
 
 
 
 
 
 
827f690
7d11de9
 
 
 
827f690
 
7d11de9
 
 
 
 
 
 
 
 
 
827f690
 
7d11de9
827f690
7d11de9
827f690
7d11de9
 
 
827f690
7d11de9
827f690
 
7d11de9
 
 
827f690
 
 
 
7d11de9
827f690
 
 
 
 
 
 
 
 
 
 
7d11de9
827f690
 
 
 
7d11de9
827f690
 
 
7d11de9
827f690
 
7d11de9
 
 
827f690
 
 
7d11de9
 
 
 
 
 
 
 
 
 
 
 
 
 
827f690
7d11de9
827f690
 
 
7d11de9
827f690
 
7d11de9
 
 
 
 
827f690
7d11de9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
827f690
 
 
 
 
 
7d11de9
827f690
 
7d11de9
 
 
827f690
 
7d11de9
 
 
 
 
 
 
827f690
7d11de9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
827f690
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
"""
preprocessing.py
Multi-gate fundus validation + preprocessing pipeline.

Gate 1 β€” Fundus Shape Gate:
  Retinal fundus photos have a distinct circular/oval bright region
  on a very dark (near-black) background. Non-fundus images fail this.

Gate 2 β€” Color Profile Gate:
  Fundus images are dominated by warm reddish-orange tones (blood vessels,
  retinal tissue). Screenshots, selfies, and random photos fail this.

Gate 3 β€” Quality Gate:
  Sharpness measured via Laplacian variance *inside* the fundus ROI.
  Low sharpness = blurry / out-of-focus scan β†’ reject.

Inference transform (no augmentation, exact test_transform from notebook):
  Resize 380Γ—380 β†’ ImageNet Normalize β†’ Tensor
"""

import cv2
import numpy as np
import base64
from PIL import Image
import albumentations as A
from albumentations.pytorch import ToTensorV2

IMG_SIZE = 380   # EfficientNet-B4 input size
CDR_SIZE = 512   # Notebook Step 4: CDR computed on 512Γ—512
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD  = [0.229, 0.224, 0.225]

# ─── Exact test_transform from notebook ──────────────────────────────────────
test_transform = A.Compose([
    A.Resize(IMG_SIZE, IMG_SIZE),
    A.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
    ToTensorV2()
])

# ─── Display transform: CLAHE applied, no normalization ──────────────────────
display_transform = A.Compose([
    A.Resize(IMG_SIZE, IMG_SIZE),
    A.CLAHE(clip_limit=2.0, p=1.0),
])


# ═══════════════════════════════════════════════════════════════════════════
# GATE 1 β€” Fundus Shape Gate
# ═══════════════════════════════════════════════════════════════════════════

def check_fundus_shape(img_rgb: np.ndarray) -> tuple:
    """
    Gate 1: Verify the image contains a circular/oval bright region
    on a dark background β€” the hallmark of a retinal fundus photo.

    Checks:
      a) Dark border ratio: β‰₯ 20% of pixels must be near-black (L < 30)
         (fundus images have large dark surround from the camera aperture)
      b) Bright circular region: largest bright contour must be
         roughly circular (aspect ratio 0.5–2.0) and cover 10–85% of image.

    Returns: (passed: bool, reason: str, score: float 0–1)
    """
    h, w = img_rgb.shape[:2]
    total_pixels = h * w

    img_lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB)
    L = img_lab[:, :, 0]

    # ── a) Dark border check ─────────────────────────────────────────────
    dark_pixels = np.sum(L < 30)
    dark_ratio = dark_pixels / total_pixels

    if dark_ratio < 0.20:
        # Fundus images must have at least ~20% pitch black borders from camera aperture
        return False, f"Tidak terdeteksi sebagai foto fundus retina (dark border terlalu kecil: {dark_ratio:.0%})", 0.0

    # ── b) Bright circular region check ──────────────────────────────────
    _, bright_mask = cv2.threshold(L, 30, 255, cv2.THRESH_BINARY)
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
    bright_mask = cv2.morphologyEx(bright_mask, cv2.MORPH_CLOSE, kernel)
    bright_mask = cv2.morphologyEx(bright_mask, cv2.MORPH_OPEN, kernel)

    contours, _ = cv2.findContours(bright_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        return False, "Tidak ditemukan region cerah (bukan foto fundus retina)", 0.0

    largest = max(contours, key=cv2.contourArea)
    x, y, bw, bh = cv2.boundingRect(largest)
    bright_area = cv2.contourArea(largest)
    bright_ratio = bright_area / total_pixels

    if bright_ratio < 0.15:
        return False, f"Region retina terlalu kecil (area: {bright_ratio:.0%} dari gambar)", 0.0
    if bright_ratio > 0.80:
        return False, f"Seluruh gambar terang β€” bukan foto fundus dengan bingkai hitam ({bright_ratio:.0%})", 0.0

    # Aspect ratio check: fundus ROI must be roughly a perfect circle
    aspect = bw / bh if bh > 0 else 0
    if aspect < 0.7 or aspect > 1.3:
        return False, f"Bentuk region tidak bulat sempurna (aspect ratio: {aspect:.2f}) β€” bukan fundus retina", 0.0

    # ── Score: combination of dark border + circularity ──────────────────
    dark_score = min(dark_ratio / 0.40, 1.0)
    circ_score = 1.0 - abs(aspect - 1.0) / 0.3  # closer to 1.0 = more circular
    shape_score = round(max((dark_score + circ_score) / 2, 0.0), 3)

    return True, "OK", shape_score


# ═══════════════════════════════════════════════════════════════════════════
# GATE 2 β€” Color Profile Gate
# ═══════════════════════════════════════════════════════════════════════════

def check_fundus_color(img_rgb: np.ndarray) -> tuple:
    """
    Gate 2: Verify the image has a warm reddish-orange color profile
    typical of retinal fundus photography.

    Fundus images: dominant hue in HSV is orange-red (Hue 0–30 or 160–180),
    medium-high saturation. Grayscale/blue-dominant images fail.

    Returns: (passed: bool, reason: str, score: float 0–1)
    """
    img_hsv = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2HSV)
    H = img_hsv[:, :, 0]
    S = img_hsv[:, :, 1]
    V = img_hsv[:, :, 2]

    # Only look at pixels that aren't near-black (part of the fundus ROI)
    roi_mask = V > 40
    if roi_mask.sum() < 1000:
        return False, "Gambar terlalu gelap untuk dianalisis warnanya", 0.0

    H_roi = H[roi_mask]
    S_roi = S[roi_mask]

    # ── Reddish-orange hue check ─────────────────────────────────────────
    # In OpenCV HSV: Hue 0–17 = red-orange, 17–35 = orange-yellow
    # Red wraps around: also 160–180
    red_orange_mask = ((H_roi <= 20) | (H_roi >= 160))
    orange_yellow_mask = ((H_roi > 10) & (H_roi <= 35))
    warm_ratio = (red_orange_mask.sum() + orange_yellow_mask.sum()) / len(H_roi)

    # ── Saturation check ─────────────────────────────────────────────────
    mean_saturation = S_roi.mean()

    # Grayscale images or weakly colored diagrams: saturation is low
    if mean_saturation < 35:
        return False, f"Gambar tidak memiliki ketajaman warna fundus (saturasi: {mean_saturation:.0f} < 35)", 0.0

    if warm_ratio < 0.35:
        return False, (
            f"Warna tidak dominan merah/oranye fundus "
            f"(warm hue: {warm_ratio:.0%}, harusnya β‰₯35%). "
            f"Bukan foto medis retina yang valid."
        ), 0.0

    color_score = round(min(warm_ratio / 0.60, 1.0) * min(mean_saturation / 90, 1.0), 3)
    return True, "OK", color_score


# ═══════════════════════════════════════════════════════════════════════════
# GATE 3 β€” Quality Gate (Sharpness within fundus ROI)
# ═══════════════════════════════════════════════════════════════════════════

def compute_quality_score(img_rgb: np.ndarray) -> float:
    """
    Gate 3: Sharpness-based Quality Score measured INSIDE the fundus ROI.
    Uses Laplacian variance on the bright region only.

    Maps to 1–5 scale matching dataset QS convention.
    QS < 3 β†’ reject.

    Thresholds calibrated for retinal fundus images (naturally smooth).
    """
    img_lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB)
    L = img_lab[:, :, 0]
    _, roi_mask = cv2.threshold(L, 30, 255, cv2.THRESH_BINARY)

    gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
    gray_resized = cv2.resize(gray, (300, 300))
    roi_resized  = cv2.resize(roi_mask, (300, 300))

    # Measure sharpness only inside the fundus ROI
    lap = cv2.Laplacian(gray_resized, cv2.CV_64F)
    if roi_resized.sum() > 5000:
        lap_var = lap[roi_resized > 0].var()
    else:
        lap_var = lap.var()  # fallback to full image

    print(f"[Preprocessing] Laplacian Variance (ROI): {lap_var:.2f}")

    if lap_var < 3.0:
        return 1.0   # Extremely blurry
    elif lap_var < 8.0:
        return 2.0   # Blurry β†’ reject
    elif lap_var < 15.0:
        return 3.0   # Acceptable
    elif lap_var < 30.0:
        return 4.0   # Good
    else:
        return 5.0   # Excellent


# ═══════════════════════════════════════════════════════════════════════════
# Auto-crop Fundus ROI
# ═══════════════════════════════════════════════════════════════════════════

def auto_crop_fundus(img_rgb: np.ndarray) -> np.ndarray:
    """
    Auto-detect and crop the circular fundus region.
    Falls back to full image if detection fails.
    """
    img_lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB)
    L = img_lab[:, :, 0]
    _, mask = cv2.threshold(L, 30, 255, cv2.THRESH_BINARY)

    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (20, 20))
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)

    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        return img_rgb

    largest = max(contours, key=cv2.contourArea)
    x, y, w, h = cv2.boundingRect(largest)
    pad = 10
    x1 = max(0, x - pad);  y1 = max(0, y - pad)
    x2 = min(img_rgb.shape[1], x + w + pad)
    y2 = min(img_rgb.shape[0], y + h + pad)
    cropped = img_rgb[y1:y2, x1:x2]
    return cropped if cropped.size > 0 else img_rgb


# ═══════════════════════════════════════════════════════════════════════════
# Main Preprocessing Pipeline
# ═══════════════════════════════════════════════════════════════════════════

def preprocess_image(img_bytes: bytes) -> dict:
    """
    Full multi-gate preprocessing pipeline:

    Gate 1 β€” Fundus Shape: circular bright region on dark background
    Gate 2 β€” Color Profile: reddish-orange dominant (retinal tissue)
    Gate 3 β€” Quality:       sharpness β‰₯ 3.0 inside fundus ROI

    Returns dict with:
        passed_gate      : bool
        gate_failed      : str | None  ('shape' | 'color' | 'quality' | None)
        rejection_reason : str | None
        quality_score    : float (1–5)
        shape_score      : float (0–1)
        color_score      : float (0–1)
        original_b64, preprocessed_b64, tensor, cdr_img_rgb
    """
    # ── Decode ───────────────────────────────────────────────────────────
    nparr = np.frombuffer(img_bytes, np.uint8)
    img_bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
    if img_bgr is None:
        raise ValueError("Tidak dapat membaca gambar. Upload file JPEG/PNG yang valid.")
    img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)

    # ── Gate 1: Fundus Shape ─────────────────────────────────────────────
    shape_ok, shape_reason, shape_score = check_fundus_shape(img_rgb)
    print(f"[Gate 1 - Shape]  passed={shape_ok}, score={shape_score}, reason={shape_reason}")
    if not shape_ok:
        return _rejection_response(img_rgb, 'shape', shape_reason, shape_score, 0.0, 0.0)

    # ── Gate 2: Color Profile ────────────────────────────────────────────
    color_ok, color_reason, color_score = check_fundus_color(img_rgb)
    print(f"[Gate 2 - Color]  passed={color_ok}, score={color_score}, reason={color_reason}")
    if not color_ok:
        return _rejection_response(img_rgb, 'color', color_reason, shape_score, color_score, 0.0)

    # ── Gate 3: Quality ──────────────────────────────────────────────────
    quality_score = compute_quality_score(img_rgb)
    quality_ok = quality_score >= 3.0
    print(f"[Gate 3 - Quality] passed={quality_ok}, score={quality_score}/5.0")
    if not quality_ok:
        reason = (
            f"Kualitas gambar terlalu rendah (skor {quality_score}/5.0). "
            f"Gambar terlalu blur atau tidak fokus. Gunakan hasil scan fundus yang jelas."
        )
        return _rejection_response(img_rgb, 'quality', reason, shape_score, color_score, quality_score)

    # ── All gates passed β†’ process ───────────────────────────────────────
    cropped = auto_crop_fundus(img_rgb)

    original_display = cv2.resize(cropped, (IMG_SIZE, IMG_SIZE))
    original_b64 = ndarray_to_b64(original_display)

    display_result = display_transform(image=cropped)
    preprocessed_b64 = ndarray_to_b64(display_result['image'])

    test_result = test_transform(image=cropped)
    tensor = test_result['image']

    cdr_img_rgb = cv2.resize(cropped, (CDR_SIZE, CDR_SIZE))

    return {
        'passed_gate':      True,
        'gate_failed':      None,
        'rejection_reason': None,
        'quality_score':    quality_score,
        'shape_score':      shape_score,
        'color_score':      color_score,
        'original_b64':     original_b64,
        'preprocessed_b64': preprocessed_b64,
        'tensor':           tensor,
        'cdr_img_rgb':      cdr_img_rgb,
    }


def _rejection_response(img_rgb: np.ndarray, gate: str, reason: str,
                         shape_score: float, color_score: float,
                         quality_score: float) -> dict:
    """Return a standardised rejection dict with a display thumbnail."""
    # Always give user a preview of what was uploaded
    thumb = cv2.resize(img_rgb, (IMG_SIZE, IMG_SIZE))
    original_b64 = ndarray_to_b64(thumb)
    return {
        'passed_gate':      False,
        'gate_failed':      gate,
        'rejection_reason': reason,
        'quality_score':    quality_score,
        'shape_score':      shape_score,
        'color_score':      color_score,
        'original_b64':     original_b64,
        'preprocessed_b64': original_b64,  # show same thumb
        'tensor':           None,
        'cdr_img_rgb':      None,
    }


def ndarray_to_b64(img_rgb: np.ndarray) -> str:
    """Convert RGB numpy array (uint8) to base64 JPEG string."""
    img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
    _, buffer = cv2.imencode('.jpg', img_bgr, [cv2.IMWRITE_JPEG_QUALITY, 90])
    return base64.b64encode(buffer).decode('utf-8')