Spaces:
Sleeping
Sleeping
| """ | |
| 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') | |