Spaces:
Running on Zero
Running on Zero
Download inference.py from Lnadeem/FeatureX-Clinical-AI: direct link, hf CLI and curl.
- Browser
- Download file 45.9 kB
-
https://huggingface.co/spaces/Lnadeem/FeatureX-Clinical-AI/resolve/main/inference.py
- Command line
-
hf download hf://spaces/Lnadeem/FeatureX-Clinical-AI/inference.py
-
curl -L -o inference.py https://huggingface.co/spaces/Lnadeem/FeatureX-Clinical-AI/resolve/main/inference.py
45.9 kB
| import os | |
| import io | |
| import gc | |
| import logging | |
| import re | |
| import threading | |
| from datetime import datetime | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torchvision import models, transforms | |
| from PIL import Image, ImageOps, UnidentifiedImageError | |
| import numpy as np | |
| import cv2 | |
| from janus_engine import generate_reports as janus_generate_reports, parse_radiology_text | |
| from arabic_translation import translate_report_en_to_ar | |
| from report_quality import check_bilingual_reports | |
| # ============================================================ | |
| # Clinical AI X-Ray - Inference Core | |
| # EfficientNet-B4 + Grad-CAM | |
| # ============================================================ | |
| os.environ.setdefault("OMP_NUM_THREADS", "4") | |
| os.environ.setdefault("MKL_NUM_THREADS", "4") | |
| try: | |
| torch.set_num_threads(4) | |
| except Exception: | |
| pass | |
| try: | |
| if hasattr(torch, "set_num_interop_threads"): | |
| torch.set_num_interop_threads(1) | |
| except Exception: | |
| pass | |
| logger = logging.getLogger("clinical-ai") | |
| MODEL_PATH = os.environ.get( | |
| "MODEL_PATH", | |
| os.path.join( | |
| os.path.dirname(os.path.abspath(__file__)), | |
| "best_efficientnet.pth", | |
| ), | |
| ) | |
| IMG_SIZE = 380 | |
| MAX_FILE_SIZE = 15 * 1024 * 1024 | |
| MIN_WIDTH = 200 | |
| MIN_HEIGHT = 200 | |
| MAX_WIDTH = 4000 | |
| MAX_HEIGHT = 4000 | |
| Image.MAX_IMAGE_PIXELS = 100_000_000 | |
| CLASSES = [ | |
| "COVID-19", | |
| "Cardiomegaly", | |
| "Normal", | |
| "Pleural Effusion", | |
| "Pneumonia", | |
| "Tuberculosis", | |
| ] | |
| REPORT_TEMPLATES_AR = { | |
| "COVID-19": { | |
| "title": "اشتباه إصابة بكوفيد-19", | |
| "findings": "توجد مظاهر رئوية قد تتوافق مع التهاب رئوي فيروسي.", | |
| "impression": "النتيجة الآلية تشير إلى احتمال وجود إصابة بكوفيد-19.", | |
| "recommendations": [ | |
| "مراجعة الطبيب المختص.", | |
| "النظر في إجراء فحص PCR حسب التقييم السريري.", | |
| "تقييم الأعراض والعلامات الحيوية.", | |
| ], | |
| "differential_diagnosis": [ | |
| "التهاب رئوي فيروسي آخر", | |
| "التهاب رئوي جرثومي", | |
| "وذمة رئوية", | |
| ], | |
| "urgency": "high", | |
| "follow_up": "حسب الحالة السريرية وتقييم الطبيب.", | |
| }, | |
| "Cardiomegaly": { | |
| "title": "اشتباه تضخم القلب", | |
| "findings": "توجد مؤشرات شعاعية قد تتوافق مع زيادة حجم ظل القلب.", | |
| "impression": "النتيجة الآلية تشير إلى احتمال وجود تضخم في القلب.", | |
| "recommendations": [ | |
| "مراجعة طبيب القلب.", | |
| "النظر في إجراء Echocardiography.", | |
| "ربط النتيجة بالفحص السريري.", | |
| ], | |
| "differential_diagnosis": [ | |
| "انصباب تاموري", | |
| "اعتلال عضلة القلب", | |
| "تضخم بطيني", | |
| ], | |
| "urgency": "medium", | |
| "follow_up": "حسب تقييم الطبيب.", | |
| }, | |
| "Normal": { | |
| "title": "صورة صدر تبدو طبيعية", | |
| "findings": "لم يحدد النموذج علامات واضحة تتوافق مع الفئات المرضية المدربة عليها.", | |
| "impression": "لم تظهر علامات مرضية واضحة وفق تصنيف النموذج.", | |
| "recommendations": [ | |
| "ربط النتيجة بالحالة السريرية.", | |
| "مراجعة أخصائي الأشعة عند الحاجة.", | |
| ], | |
| "differential_diagnosis": [], | |
| "urgency": "low", | |
| "follow_up": "حسب الحاجة السريرية.", | |
| }, | |
| "Pleural Effusion": { | |
| "title": "اشتباه انصباب جنبي", | |
| "findings": "توجد مؤشرات قد تتوافق مع وجود تجمع سائل في الحيز الجنبي.", | |
| "impression": "النتيجة الآلية تشير إلى احتمال وجود انصباب جنبي.", | |
| "recommendations": [ | |
| "مراجعة طبيب صدرية أو باطنية.", | |
| "ربط النتيجة بالفحص السريري.", | |
| "النظر في التصوير الإضافي حسب قرار الطبيب.", | |
| ], | |
| "differential_diagnosis": [ | |
| "قصور القلب", | |
| "التهاب رئوي", | |
| "أمراض جنبية أخرى", | |
| ], | |
| "urgency": "medium", | |
| "follow_up": "حسب الأعراض والتقييم السريري.", | |
| }, | |
| "Pneumonia": { | |
| "title": "اشتباه التهاب رئوي", | |
| "findings": "توجد مؤشرات شعاعية قد تتوافق مع التهاب رئوي.", | |
| "impression": "النتيجة الآلية تشير إلى احتمال وجود التهاب رئوي.", | |
| "recommendations": [ | |
| "مراجعة الطبيب.", | |
| "ربط الصورة بالأعراض والفحوصات المخبرية.", | |
| "تقييم مستوى الأكسجين عند الحاجة.", | |
| ], | |
| "differential_diagnosis": [ | |
| "التهاب رئوي فيروسي", | |
| "التهاب رئوي جرثومي", | |
| "وذمة رئوية", | |
| ], | |
| "urgency": "high", | |
| "follow_up": "حسب شدة الأعراض وتقييم الطبيب.", | |
| }, | |
| "Tuberculosis": { | |
| "title": "اشتباه سل رئوي", | |
| "findings": "توجد مؤشرات شعاعية قد تتوافق مع الإصابة بالسل الرئوي.", | |
| "impression": "النتيجة الآلية تشير إلى احتمال وجود سل رئوي.", | |
| "recommendations": [ | |
| "مراجعة طبيب صدرية أو أمراض معدية.", | |
| "إجراء الفحوصات المناسبة لتأكيد التشخيص.", | |
| "عدم الاعتماد على نتيجة النموذج وحدها.", | |
| ], | |
| "differential_diagnosis": [ | |
| "التهاب رئوي", | |
| "أمراض رئوية أخرى", | |
| "أورام رئوية", | |
| ], | |
| "urgency": "high", | |
| "follow_up": "متابعة عاجلة مع الطبيب المختص.", | |
| }, | |
| } | |
| REPORT_TEMPLATES_EN = { | |
| "COVID-19": { | |
| "title": "Suspected COVID-19 Infection", | |
| "findings": "There are pulmonary findings that may be consistent with viral pneumonia.", | |
| "impression": "The automated result suggests a possible COVID-19 infection.", | |
| "recommendations": [ | |
| "Consult a specialist physician.", | |
| "Consider PCR testing based on clinical evaluation.", | |
| "Assess symptoms and vital signs.", | |
| ], | |
| "differential_diagnosis": [ | |
| "Other viral pneumonia", | |
| "Bacterial pneumonia", | |
| "Pulmonary edema", | |
| ], | |
| "urgency": "high", | |
| "follow_up": "Based on clinical condition and physician assessment.", | |
| }, | |
| "Cardiomegaly": { | |
| "title": "Suspected Cardiomegaly", | |
| "findings": "Radiographic signs may be consistent with increased cardiac shadow size.", | |
| "impression": "The automated result suggests possible cardiomegaly.", | |
| "recommendations": [ | |
| "Consult a cardiologist.", | |
| "Consider echocardiography.", | |
| "Correlate with clinical examination.", | |
| ], | |
| "differential_diagnosis": [ | |
| "Pericardial effusion", | |
| "Cardiomyopathy", | |
| "Ventricular hypertrophy", | |
| ], | |
| "urgency": "medium", | |
| "follow_up": "Based on physician evaluation.", | |
| }, | |
| "Normal": { | |
| "title": "Chest X-ray Appears Normal", | |
| "findings": "The model did not identify clear signs matching the trained disease categories.", | |
| "impression": "No obvious pathological signs were detected according to the model classification.", | |
| "recommendations": [ | |
| "Correlate with clinical condition.", | |
| "Consult a radiologist if needed.", | |
| ], | |
| "differential_diagnosis": [], | |
| "urgency": "low", | |
| "follow_up": "As clinically indicated.", | |
| }, | |
| "Pleural Effusion": { | |
| "title": "Suspected Pleural Effusion", | |
| "findings": "There are signs that may be consistent with fluid accumulation in the pleural space.", | |
| "impression": "The automated result suggests possible pleural effusion.", | |
| "recommendations": [ | |
| "Consult a pulmonologist or internist.", | |
| "Correlate with clinical examination.", | |
| "Consider additional imaging as per physician's decision.", | |
| ], | |
| "differential_diagnosis": [ | |
| "Heart failure", | |
| "Pneumonia", | |
| "Other pleural diseases", | |
| ], | |
| "urgency": "medium", | |
| "follow_up": "Based on symptoms and clinical evaluation.", | |
| }, | |
| "Pneumonia": { | |
| "title": "Suspected Pneumonia", | |
| "findings": "Radiographic signs may be consistent with pneumonia.", | |
| "impression": "The automated result suggests possible pneumonia.", | |
| "recommendations": [ | |
| "Consult a physician.", | |
| "Correlate imaging with symptoms and laboratory tests.", | |
| "Assess oxygen saturation if needed.", | |
| ], | |
| "differential_diagnosis": [ | |
| "Viral pneumonia", | |
| "Bacterial pneumonia", | |
| "Pulmonary edema", | |
| ], | |
| "urgency": "high", | |
| "follow_up": "Based on symptom severity and physician assessment.", | |
| }, | |
| "Tuberculosis": { | |
| "title": "Suspected Pulmonary Tuberculosis", | |
| "findings": "Radiographic signs may be consistent with pulmonary tuberculosis.", | |
| "impression": "The automated result suggests possible pulmonary tuberculosis.", | |
| "recommendations": [ | |
| "Consult a pulmonologist or infectious disease specialist.", | |
| "Perform appropriate tests to confirm the diagnosis.", | |
| "Do not rely solely on the model result.", | |
| ], | |
| "differential_diagnosis": [ | |
| "Pneumonia", | |
| "Other pulmonary diseases", | |
| "Lung tumors", | |
| ], | |
| "urgency": "high", | |
| "follow_up": "Urgent follow-up with a specialist.", | |
| }, | |
| } | |
| class XRayModel: | |
| def __init__(self, model_path: str): | |
| logger.info("Loading EfficientNet-B4...") | |
| self.model = models.efficientnet_b4(weights=None) | |
| num_features = self.model.classifier[1].in_features | |
| self.model.classifier = nn.Sequential( | |
| nn.Dropout(0.4), | |
| nn.Linear(num_features, len(CLASSES)), | |
| ) | |
| logger.info("Loading checkpoint: %s", model_path) | |
| if not os.path.exists(model_path): | |
| raise FileNotFoundError( | |
| f"Model checkpoint not found: {model_path}" | |
| ) | |
| try: | |
| state_dict = torch.load( | |
| model_path, | |
| map_location="cpu", | |
| weights_only=True, | |
| ) | |
| except TypeError: | |
| state_dict = torch.load( | |
| model_path, | |
| map_location="cpu", | |
| ) | |
| self.model.load_state_dict(state_dict, strict=True) | |
| del state_dict | |
| self.model.eval() | |
| for parameter in self.model.parameters(): | |
| parameter.requires_grad_(False) | |
| self.transform = transforms.Compose([ | |
| transforms.Resize((IMG_SIZE, IMG_SIZE)), | |
| transforms.ToTensor(), | |
| transforms.Normalize( | |
| [0.485, 0.456, 0.406], | |
| [0.229, 0.224, 0.225], | |
| ), | |
| ]) | |
| logger.info( | |
| "Model loaded. Parameters: %s", | |
| sum(p.numel() for p in self.model.parameters()), | |
| ) | |
| def preprocess(self, image: Image.Image, device: torch.device): | |
| if image.mode != "RGB": | |
| image = image.convert("RGB") | |
| tensor = self.transform(image).unsqueeze(0) | |
| return tensor.to(device) | |
| class GradCAM: | |
| def __init__(self, model: nn.Module): | |
| self.model = model | |
| self.feature_maps = None | |
| self.gradients = None | |
| self.target_layer = self.find_target_layer() | |
| self.target_layer.register_forward_hook(self.forward_hook) | |
| self.target_layer.register_full_backward_hook(self.backward_hook) | |
| def find_target_layer(self): | |
| for module in reversed(list(self.model.features.modules())): | |
| if isinstance(module, nn.Conv2d): | |
| return module | |
| raise RuntimeError("No Conv2d layer found.") | |
| def get_layer_name(self): | |
| for name, module in self.model.named_modules(): | |
| if module is self.target_layer: | |
| return name | |
| return "unknown" | |
| def forward_hook(self, module, inputs, output): | |
| self.feature_maps = output | |
| def backward_hook(self, module, grad_input, grad_output): | |
| if grad_output and grad_output[0] is not None: | |
| self.gradients = grad_output[0] | |
| def clear(self): | |
| self.feature_maps = None | |
| self.gradients = None | |
| def generate(self, image_tensor, class_idx): | |
| self.clear() | |
| self.model.zero_grad(set_to_none=True) | |
| if not image_tensor.requires_grad: | |
| image_tensor.requires_grad_(True) | |
| output = self.model(image_tensor) | |
| score = output[0, class_idx] | |
| score.backward() | |
| if self.gradients is None or self.feature_maps is None: | |
| raise RuntimeError( | |
| "Grad-CAM hooks did not capture activations." | |
| ) | |
| gradients = self.gradients[0] | |
| feature_maps = self.feature_maps[0] | |
| weights = gradients.mean(dim=(1, 2)) | |
| cam = (weights[:, None, None] * feature_maps).sum(dim=0) | |
| cam = torch.relu(cam) | |
| cam = cam.detach().float().cpu().numpy() | |
| max_value = float(cam.max()) if cam.size else 0.0 | |
| if max_value > 0: | |
| cam = cam / max_value | |
| return cam.astype(np.float32) | |
| logger.info("Initializing inference engine...") | |
| xray_model = XRayModel(MODEL_PATH) | |
| grad_cam = GradCAM(xray_model.model) | |
| # Grad-CAM stores request-specific activations in shared hooks. | |
| # Serialize inference to prevent cross-request activation mixing. | |
| INFERENCE_LOCK = threading.Lock() | |
| def validate_image(file_bytes: bytes, filename: str) -> Image.Image: | |
| if len(file_bytes) > MAX_FILE_SIZE: | |
| raise ValueError("حجم الصورة يتجاوز 15MB.") | |
| extension = "" | |
| if "." in filename: | |
| extension = filename.rsplit(".", 1)[1].lower() | |
| allowed = {"jpg", "jpeg", "png", "bmp", "webp"} | |
| if extension not in allowed: | |
| raise ValueError("امتداد الصورة غير مدعوم.") | |
| try: | |
| image = Image.open(io.BytesIO(file_bytes)) | |
| image.verify() | |
| image = Image.open(io.BytesIO(file_bytes)) | |
| except UnidentifiedImageError: | |
| raise ValueError("الملف ليس صورة صالحة.") | |
| except Exception as exc: | |
| raise ValueError(f"خطأ في قراءة الصورة: {exc}") | |
| width, height = image.size | |
| if width < MIN_WIDTH or height < MIN_HEIGHT: | |
| raise ValueError( | |
| f"دقة الصورة منخفضة جدًا. الحد الأدنى {MIN_WIDTH}x{MIN_HEIGHT}." | |
| ) | |
| if width > MAX_WIDTH or height > MAX_HEIGHT: | |
| raise ValueError( | |
| f"دقة الصورة مرتفعة جدًا. الحد الأقصى {MAX_WIDTH}x{MAX_HEIGHT}." | |
| ) | |
| image = ImageOps.exif_transpose(image) | |
| return image.convert("RGB") | |
| def compute_quality_metrics(image: Image.Image): | |
| gray = np.asarray(image.convert("L")) | |
| brightness = float(gray.mean()) | |
| contrast = float(gray.std()) | |
| sharpness = float( | |
| cv2.Laplacian(gray, cv2.CV_64F).var() | |
| ) | |
| black_ratio = float((gray < 10).mean()) | |
| white_ratio = float((gray > 245).mean()) | |
| quality_score = 1.0 | |
| issues = [] | |
| if brightness < 20 or brightness > 220: | |
| issues.append("سطوع غير طبيعي") | |
| quality_score *= 0.7 | |
| if contrast < 15: | |
| issues.append("تباين منخفض جدًا") | |
| quality_score *= 0.6 | |
| if sharpness < 30: | |
| issues.append("الصورة قد تكون غير حادة") | |
| quality_score *= 0.7 | |
| if black_ratio > 0.95 or white_ratio > 0.95: | |
| issues.append("مناطق فارغة كبيرة جدًا") | |
| quality_score *= 0.5 | |
| return { | |
| "brightness": round(brightness, 2), | |
| "contrast": round(contrast, 2), | |
| "sharpness": round(sharpness, 2), | |
| "black_pixel_ratio": round(black_ratio, 4), | |
| "white_pixel_ratio": round(white_ratio, 4), | |
| "quality_score": round(quality_score, 2), | |
| "issues": issues, | |
| "is_acceptable": quality_score >= 0.5, | |
| } | |
| def check_confidence(probs, threshold=0.60): | |
| sorted_probs, _ = torch.sort(probs, descending=True) | |
| top1 = float(sorted_probs[0].item()) | |
| top2 = float(sorted_probs[1].item()) | |
| margin = top1 - top2 | |
| flags = [] | |
| if top1 < 0.40: | |
| level = "very_low" | |
| flags.append("very_low_confidence") | |
| elif top1 < 0.60: | |
| level = "low" | |
| flags.append("low_confidence") | |
| elif top1 < 0.85: | |
| level = "medium" | |
| else: | |
| level = "high" | |
| if margin < 0.10: | |
| flags.append("ambiguous_prediction") | |
| level = "ambiguous" | |
| reliable = top1 >= threshold and margin >= 0.10 | |
| return { | |
| "confidence_level": level, | |
| "flags": flags, | |
| "top1_probability": top1, | |
| "top2_probability": top2, | |
| "margin": margin, | |
| "is_reliable": reliable, | |
| } | |
| def _encode_visualization_bytes(image_array, fmt="png", jpeg_quality=88): | |
| """Encode a visualization to binary bytes for Storage upload only. | |
| This is intentionally bytes, never Base64. The bytes stay server-side and | |
| are uploaded directly to private Cloudflare R2 by database.save_analysis(). | |
| """ | |
| array = np.asarray(image_array) | |
| if array.dtype != np.uint8: | |
| if array.size and float(array.max()) <= 1.0: | |
| array = (array * 255).clip(0, 255).astype(np.uint8) | |
| else: | |
| array = array.clip(0, 255).astype(np.uint8) | |
| fmt = (fmt or "png").lower().lstrip(".") | |
| if fmt == "png": | |
| encode_image = array if array.ndim == 2 else cv2.cvtColor(array, cv2.COLOR_RGB2BGR) | |
| ext = ".png" | |
| params = [cv2.IMWRITE_PNG_COMPRESSION, 3] | |
| else: | |
| encode_image = array if array.ndim == 2 else cv2.cvtColor(array, cv2.COLOR_RGB2BGR) | |
| ext = ".jpg" | |
| params = [cv2.IMWRITE_JPEG_QUALITY, int(jpeg_quality)] | |
| ok, encoded = cv2.imencode(ext, encode_image, params) | |
| if not ok: | |
| raise RuntimeError("Failed to encode visualization.") | |
| return encoded.tobytes() | |
| def enhance_heatmap( | |
| heatmap, | |
| percentile_low=5, | |
| percentile_high=95, | |
| gamma=0.8, | |
| ): | |
| heatmap = np.asarray(heatmap, dtype=np.float32) | |
| low = np.percentile(heatmap, percentile_low) | |
| high = np.percentile(heatmap, percentile_high) | |
| enhanced = heatmap.copy() | |
| enhanced[enhanced < low] = 0 | |
| enhanced = np.clip(enhanced, 0, high) | |
| minimum = float(enhanced.min()) | |
| maximum = float(enhanced.max()) | |
| if maximum > minimum: | |
| enhanced = (enhanced - minimum) / (maximum - minimum) | |
| enhanced = np.power(enhanced, gamma) | |
| return enhanced.astype(np.float32) | |
| def create_overlay(image_rgb, heatmap, alpha=0.6): | |
| if heatmap.shape != image_rgb.shape[:2]: | |
| heatmap = cv2.resize( | |
| heatmap, | |
| (image_rgb.shape[1], image_rgb.shape[0]), | |
| interpolation=cv2.INTER_LINEAR, | |
| ) | |
| colored = cv2.applyColorMap( | |
| (heatmap * 255).astype(np.uint8), | |
| cv2.COLORMAP_INFERNO, | |
| ) | |
| colored = cv2.cvtColor(colored, cv2.COLOR_BGR2RGB).astype( | |
| np.float32 | |
| ) / 255.0 | |
| overlay = colored * alpha + image_rgb * (1 - alpha) | |
| return np.clip(overlay, 0, 1) | |
| def generate_visualizations(image, input_tensor, pred_class): | |
| original_np = np.asarray(image).astype(np.float32) / 255.0 | |
| with torch.enable_grad(): | |
| try: | |
| heatmap_raw = grad_cam.generate(input_tensor, pred_class) | |
| except Exception as exc: | |
| logger.warning("Grad-CAM failed: %s", exc) | |
| heatmap_raw = np.zeros( | |
| (IMG_SIZE, IMG_SIZE), | |
| dtype=np.float32, | |
| ) | |
| active_ratio = float((heatmap_raw > 0.1).mean()) | |
| mean_activation = float(heatmap_raw.mean()) | |
| std_activation = float(heatmap_raw.std()) | |
| heatmap = enhance_heatmap( | |
| heatmap_raw, | |
| percentile_low=5, | |
| percentile_high=95, | |
| gamma=0.8, | |
| ) | |
| overlay = create_overlay( | |
| original_np, | |
| heatmap, | |
| alpha=0.6, | |
| ) | |
| heatmap_resized_full = cv2.resize( | |
| heatmap, | |
| (original_np.shape[1], original_np.shape[0]), | |
| interpolation=cv2.INTER_LINEAR, | |
| ) | |
| heatmap_color = cv2.applyColorMap( | |
| (heatmap_resized_full * 255).astype(np.uint8), | |
| cv2.COLORMAP_INFERNO, | |
| ) | |
| heatmap_color = cv2.cvtColor( | |
| heatmap_color, | |
| cv2.COLOR_BGR2RGB, | |
| ) | |
| # Keep the original contour behavior: threshold the 380x380 CAM, | |
| # then draw contours on the original-resolution image. | |
| heatmap_resized = cv2.resize( | |
| heatmap, | |
| (IMG_SIZE, IMG_SIZE), | |
| interpolation=cv2.INTER_LINEAR, | |
| ) | |
| _, threshold = cv2.threshold( | |
| (heatmap_resized * 255).astype(np.uint8), | |
| 50, | |
| 255, | |
| cv2.THRESH_BINARY, | |
| ) | |
| contours, _ = cv2.findContours( | |
| threshold, | |
| cv2.RETR_EXTERNAL, | |
| cv2.CHAIN_APPROX_SIMPLE, | |
| ) | |
| # Scale contours to the original image dimensions. | |
| scale_x = original_np.shape[1] / IMG_SIZE | |
| scale_y = original_np.shape[0] / IMG_SIZE | |
| contour_image = (original_np * 255).astype(np.uint8) | |
| if contours: | |
| scaled_contours = [] | |
| for contour in contours: | |
| c = contour.astype(np.float32) | |
| c[:, :, 0] *= scale_x | |
| c[:, :, 1] *= scale_y | |
| scaled_contours.append(np.round(c).astype(np.int32)) | |
| cv2.drawContours( | |
| contour_image, | |
| scaled_contours, | |
| -1, | |
| (0, 255, 255), | |
| 3, | |
| ) | |
| contour_image = contour_image.astype(np.float32) / 255.0 | |
| threshold_overlay = original_np.copy() | |
| mask = cv2.resize( | |
| (heatmap > 0.5).astype(np.float32), | |
| (original_np.shape[1], original_np.shape[0]), | |
| interpolation=cv2.INTER_NEAREST, | |
| ) | |
| threshold_overlay[mask > 0.5] = (1.0, 0.0, 0.0) | |
| comparison = np.hstack([original_np, overlay]) | |
| images = { | |
| # Binary bytes are used only for server-side Storage upload. | |
| "heatmap_enhanced": _encode_visualization_bytes(heatmap_color, "png"), | |
| "overlay": _encode_visualization_bytes(overlay, "jpg"), | |
| "contour": _encode_visualization_bytes(contour_image, "jpg"), | |
| "threshold": _encode_visualization_bytes(threshold_overlay, "jpg"), | |
| "comparison": _encode_visualization_bytes(comparison, "jpg"), | |
| } | |
| statistics = { | |
| "active_ratio": round(active_ratio, 6), | |
| "mean_activation": round(mean_activation, 6), | |
| "std_activation": round(std_activation, 6), | |
| } | |
| grad_cam.clear() | |
| return images, statistics | |
| def generate_medical_report( | |
| pred_class, | |
| confidence, | |
| probs, | |
| quality, | |
| confidence_check, | |
| language="ar", | |
| ): | |
| templates = REPORT_TEMPLATES_EN if language == "en" else REPORT_TEMPLATES_AR | |
| class_name = CLASSES[pred_class] | |
| if class_name not in templates: | |
| templates = REPORT_TEMPLATES_AR | |
| template = templates[class_name] | |
| sorted_probs, sorted_indices = torch.sort( | |
| probs, | |
| descending=True, | |
| ) | |
| top3 = [] | |
| for i in range(min(3, len(sorted_probs))): | |
| idx = int(sorted_indices[i].item()) | |
| probability = float(sorted_probs[i].item()) | |
| top3.append({ | |
| "disease": CLASSES[idx], | |
| "probability": round(probability, 6), | |
| "percentage": f"{probability * 100:.2f}%", | |
| }) | |
| now = datetime.utcnow() | |
| report_id = "XR-" + now.strftime("%Y%m%d%H%M%S%f")[:-3] | |
| return { | |
| "report_id": report_id, | |
| "generated_at": now.isoformat() + "Z", | |
| "language": language, | |
| "title": template["title"], | |
| "prediction": { | |
| "primary_diagnosis": class_name, | |
| "confidence": round(confidence, 6), | |
| "confidence_percentage": f"{confidence * 100:.2f}%", | |
| "confidence_level": confidence_check["confidence_level"], | |
| }, | |
| "findings": template["findings"], | |
| "impression": template["impression"], | |
| "recommendations": template["recommendations"], | |
| "differential_diagnosis": template["differential_diagnosis"], | |
| "urgency": template["urgency"], | |
| "follow_up": template["follow_up"], | |
| "quality_metrics": quality, | |
| "confidence_flags": confidence_check["flags"], | |
| "reliability": { | |
| "is_reliable": confidence_check["is_reliable"], | |
| "reasons": ( | |
| confidence_check["flags"] | |
| if confidence_check["flags"] | |
| else ( | |
| ["No clear issues"] | |
| if language == "en" | |
| else ["لا توجد مشاكل واضحة"] | |
| ) | |
| ), | |
| }, | |
| "top_3_predictions": top3, | |
| "disclaimer": ( | |
| "This report was automatically generated by an AI system for assistance only, " | |
| "and does not replace evaluation by a physician or radiologist." | |
| if language == "en" | |
| else | |
| "هذا التقرير تم إنشاؤه آليًا بواسطة نظام ذكاء اصطناعي للمساعدة فقط، " | |
| "ولا يغني عن تقييم الطبيب أو أخصائي الأشعة." | |
| ), | |
| } | |
| def generate_bilingual_reports(pred_class, confidence, probs, quality, confidence_check): | |
| report_ar = generate_medical_report(pred_class, confidence, probs, quality, confidence_check, language="ar") | |
| report_en = generate_medical_report(pred_class, confidence, probs, quality, confidence_check, language="en") | |
| report_en["report_id"] = report_ar["report_id"] | |
| report_ar["language"] = "ar" | |
| report_en["language"] = "en" | |
| return report_ar, report_en | |
| def predict_bytes(file_bytes: bytes, filename="xray.jpg", language="ar"): | |
| """Run EfficientNet-B4 + Grad-CAM inference and build bilingual reports.""" | |
| request_tag = f"{filename}|{len(file_bytes)}bytes" | |
| if language not in {"ar", "en"}: | |
| language = "ar" | |
| logger.info("INFERENCE START %s language=%s", request_tag, language) | |
| # ------------------------------------------------------------ | |
| # Image validation / quality | |
| # ------------------------------------------------------------ | |
| logger.info("INFERENCE STEP image validation START %s", request_tag) | |
| image = validate_image(file_bytes, filename) | |
| logger.info( | |
| "INFERENCE STEP image validation OK %s size=%sx%s mode=%s", | |
| request_tag, | |
| image.width, | |
| image.height, | |
| image.mode, | |
| ) | |
| quality = compute_quality_metrics(image) | |
| logger.info( | |
| "INFERENCE STEP quality OK %s score=%s acceptable=%s", | |
| request_tag, | |
| quality.get("quality_score"), | |
| quality.get("is_acceptable"), | |
| ) | |
| if not torch.cuda.is_available(): | |
| raise RuntimeError( | |
| "CUDA/ZeroGPU is not available. Create this Space with ZeroGPU hardware." | |
| ) | |
| gpu = torch.device("cuda") | |
| input_tensor = None | |
| outputs = None | |
| probabilities = None | |
| # Grad-CAM uses shared hooks; serialize requests. | |
| with INFERENCE_LOCK: | |
| try: | |
| # -------------------------------------------------------- | |
| # Model / prediction | |
| # -------------------------------------------------------- | |
| logger.info("INFERENCE STEP model-to-GPU START %s", request_tag) | |
| xray_model.model.to(gpu) | |
| xray_model.model.eval() | |
| input_tensor = xray_model.preprocess(image, gpu) | |
| start_time = datetime.utcnow() | |
| logger.info("INFERENCE STEP forward START %s", request_tag) | |
| with torch.inference_mode(): | |
| outputs = xray_model.model(input_tensor) | |
| probabilities = F.softmax(outputs, dim=1)[0] | |
| confidence_tensor, pred_tensor = torch.max( | |
| probabilities, | |
| dim=0, | |
| ) | |
| pred_class = int(pred_tensor.item()) | |
| confidence = float(confidence_tensor.item()) | |
| logger.info( | |
| "INFERENCE STEP forward OK %s class=%s confidence=%.6f", | |
| request_tag, | |
| CLASSES[pred_class], | |
| confidence, | |
| ) | |
| confidence_check = check_confidence(probabilities) | |
| # -------------------------------------------------------- | |
| # Grad-CAM / visualizations | |
| # -------------------------------------------------------- | |
| logger.info("INFERENCE STEP Grad-CAM START %s", request_tag) | |
| try: | |
| visualizations, heatmap_stats = generate_visualizations( | |
| image, | |
| input_tensor, | |
| pred_class, | |
| ) | |
| logger.info("INFERENCE STEP Grad-CAM OK %s", request_tag) | |
| except Exception as exc: | |
| logger.exception( | |
| "INFERENCE STEP Grad-CAM FAILED %s: %s", | |
| request_tag, | |
| exc, | |
| ) | |
| visualizations = { | |
| "heatmap_enhanced": "", | |
| "overlay": "", | |
| "contour": "", | |
| "threshold": "", | |
| "comparison": "", | |
| } | |
| heatmap_stats = { | |
| "active_ratio": 0.0, | |
| "mean_activation": 0.0, | |
| "std_activation": 0.0, | |
| } | |
| # -------------------------------------------------------- | |
| # Janus-Pro-CXR report + deterministic parser | |
| # -------------------------------------------------------- | |
| logger.info("INFERENCE STEP Janus reports START %s", request_tag) | |
| janus_payload = {"enabled": False, "report_ar": "", "report_en": "", "requested_raw_text": ""} | |
| try: | |
| janus_payload = janus_generate_reports(image, requested_language=language) | |
| except Exception as janus_exc: | |
| logger.exception("JANUS REPORT FAILED %s: %s", request_tag, janus_exc) | |
| template_ar, template_en = generate_bilingual_reports( | |
| pred_class, confidence, probabilities, quality, confidence_check | |
| ) | |
| def merge_janus(template, raw_text, lang): | |
| parsed = parse_radiology_text(raw_text) if raw_text else {} | |
| if not raw_text: | |
| template["report_source"] = "EFFICIENTNET_TEMPLATE_FALLBACK" | |
| template["janus_raw_text"] = "" | |
| return template | |
| if parsed.get("findings"): | |
| template["findings"] = parsed["findings"] | |
| if parsed.get("impression"): | |
| template["impression"] = parsed["impression"] | |
| if parsed.get("recommendations"): | |
| template["recommendations"] = parsed["recommendations"] | |
| template["report_source"] = "JANUS_PRO_CXR" | |
| template["report_model"] = "ZrH42/Janus-Pro-CXR-Final" | |
| template["janus_raw_text"] = raw_text | |
| template["janus_parsed"] = parsed | |
| return template | |
| arabic_raw = janus_payload.get("report_ar", "") or "" | |
| english_raw = janus_payload.get("report_en", "") or "" | |
| arabic_verified = bool(janus_payload.get("arabic_verified")) | |
| english_verified = bool(janus_payload.get("english_verified")) | |
| # Never allow an unverified language payload into the corresponding report. | |
| if not arabic_verified or not re.search(r"[\u0600-\u06FF]", arabic_raw): | |
| if arabic_raw: | |
| logger.warning("JANUS Arabic output failed language validation; using Arabic template fallback") | |
| arabic_raw = "" | |
| arabic_verified = False | |
| if not english_verified or not re.search(r"[A-Za-z]", english_raw): | |
| if english_raw: | |
| logger.warning("JANUS English output failed language validation; using English template fallback") | |
| english_raw = "" | |
| english_verified = False | |
| # Merge verified native Janus outputs into their matching language. | |
| # Never cross-copy English into Arabic. | |
| report_ar = merge_janus(template_ar, arabic_raw, "ar") | |
| report_en = merge_janus(template_en, english_raw, "en") | |
| # Arabic strategy: | |
| # 1) Prefer a genuinely Arabic Janus report if one exists. | |
| # 2) Otherwise translate the VERIFIED Janus English structured report. | |
| # 3) Accept the translation only after conservative script/number checks. | |
| # 4) Otherwise use the existing Arabic EfficientNet template. | |
| arabic_translation = { | |
| "enabled": False, | |
| "verified": False, | |
| "model": None, | |
| "findings": "", | |
| "impression": "", | |
| "recommendations": [], | |
| } | |
| if not arabic_verified and english_verified: | |
| try: | |
| english_parsed_for_translation = parse_radiology_text(english_raw) | |
| arabic_translation = translate_report_en_to_ar(english_parsed_for_translation) | |
| if arabic_translation.get("verified"): | |
| report_ar["findings"] = arabic_translation.get("findings", "") or report_ar.get("findings", "") | |
| report_ar["impression"] = arabic_translation.get("impression", "") or report_ar.get("impression", "") | |
| if arabic_translation.get("recommendations"): | |
| report_ar["recommendations"] = arabic_translation["recommendations"] | |
| report_ar["report_source"] = "JANUS_EN_TRANSLATED_TO_ARABIC" | |
| report_ar["report_model"] = arabic_translation.get("model") or "Helsinki-NLP/opus-mt-en-ar" | |
| report_ar["janus_raw_text"] = "" | |
| report_ar["translation_source"] = "JANUS_PRO_CXR_ENGLISH" | |
| report_ar["translation_verified"] = True | |
| else: | |
| logger.warning( | |
| "Arabic controlled translation rejected: script_valid=%s numbers_preserved=%s gibberish_free=%s terms=%s", | |
| arabic_translation.get("script_valid"), | |
| arabic_translation.get("numbers_preserved"), | |
| arabic_translation.get("gibberish_free"), | |
| arabic_translation.get("required_terms", {}).get("all_present"), | |
| ) | |
| except Exception as translation_exc: | |
| logger.exception("Arabic translation failed: %s", translation_exc) | |
| arabic_translation = { | |
| "enabled": True, | |
| "verified": False, | |
| "model": "Helsinki-NLP/opus-mt-en-ar", | |
| "error": str(translation_exc), | |
| } | |
| # If Janus English was verified but Arabic conversion failed, do not | |
| # silently present an unrelated EfficientNet Arabic report as if it | |
| # were a translation of the same analysis. Mark the result for review. | |
| arabic_translation_failed = bool( | |
| (language == "ar") and english_verified and not report_ar.get("translation_verified") | |
| ) | |
| if arabic_translation_failed: | |
| # Do NOT expose an unrelated EfficientNet Arabic template when a | |
| # verified Janus English report exists. Keep the Janus source | |
| # traceable and mark the Arabic presentation as review-required. | |
| report_ar["findings"] = "" | |
| report_ar["impression"] = "" | |
| report_ar["recommendations"] = [] | |
| report_ar["report_source"] = "ARABIC_TRANSLATION_REQUIRES_REVIEW" | |
| report_ar["translation_verified"] = False | |
| report_ar["translation_source"] = "JANUS_PRO_CXR_ENGLISH" | |
| report_ar["janus_source_report_en"] = english_raw | |
| report = report_en if language == "en" else report_ar | |
| logger.info("INFERENCE STEP reports OK %s source_ar=%s source_en=%s", request_tag, report_ar.get("report_source"), report_en.get("report_source")) | |
| all_predictions = [] | |
| for index, probability in enumerate( | |
| probabilities.detach().float().cpu().tolist() | |
| ): | |
| all_predictions.append({ | |
| "class": CLASSES[index], | |
| "probability": round(probability, 6), | |
| "percentage": round(probability * 100, 2), | |
| }) | |
| elapsed_ms = ( | |
| datetime.utcnow() - start_time | |
| ).total_seconds() * 1000.0 | |
| report_integrity = { | |
| "requested_language": language, | |
| "arabic": { | |
| "language": "ar", | |
| # This is the verification state of the FINAL Arabic report, | |
| # not merely native Janus Arabic generation. | |
| "verified": bool( | |
| arabic_verified or report_ar.get("translation_verified") is True | |
| ), | |
| "script_valid": bool(re.search(r"[\u0600-\u06FF]", report_ar.get("findings", "") + " " + report_ar.get("impression", ""))), | |
| "source": report_ar.get("report_source", "EFFICIENTNET_TEMPLATE_FALLBACK"), | |
| "native_janus_verified": bool(arabic_verified), | |
| "translation_verified": bool(report_ar.get("translation_verified")), | |
| "requires_manual_review": bool(arabic_translation_failed), | |
| }, | |
| "english": { | |
| "language": "en", | |
| "verified": bool(english_verified), | |
| "script_valid": bool(re.search(r"[A-Za-z]", report_en.get("findings", "") + " " + report_en.get("impression", ""))), | |
| "source": report_en.get("report_source", "EFFICIENTNET_TEMPLATE_FALLBACK"), | |
| }, | |
| "cross_language_fallback": False, | |
| "arabic_translation": { | |
| "attempted": bool(not arabic_verified and english_verified), | |
| "verified": bool(arabic_translation.get("verified")), | |
| "model": arabic_translation.get("model"), | |
| "script_valid": bool(arabic_translation.get("script_valid", False)), | |
| "numbers_preserved": bool(arabic_translation.get("numbers_preserved", False)), | |
| "item_count_preserved": bool(arabic_translation.get("item_count_preserved", False)), | |
| "negation_preserved": bool(arabic_translation.get("negation_preserved", False)), | |
| "required_terms": arabic_translation.get("required_terms", {}), | |
| "gibberish_free": bool(arabic_translation.get("gibberish_free", False)), | |
| "translation_methods": arabic_translation.get("translation_methods", []), | |
| "translated_text_preview": arabic_translation.get("translated_text_preview", ""), | |
| "warning": arabic_translation.get("warning"), | |
| }, | |
| "consistency": { | |
| "english_source_available": bool(english_verified), | |
| "english_source": "JANUS_PRO_CXR" if english_verified else "UNAVAILABLE", | |
| "same_analysis_source": bool(english_verified and (arabic_translation.get("verified") or arabic_verified)), | |
| "arabic_source": ( | |
| "JANUS_PRO_CXR_AR" if arabic_verified | |
| else "JANUS_EN_TRANSLATED_TO_ARABIC" if arabic_translation.get("verified") | |
| else "ARABIC_TRANSLATION_REQUIRES_REVIEW" if english_verified | |
| else "EFFICIENTNET_TEMPLATE_FALLBACK" | |
| ), | |
| "translation_verified": bool(arabic_translation.get("verified")), | |
| "requires_manual_review": bool(arabic_translation_failed), | |
| }, | |
| } | |
| # Independent conservative checks across the final Arabic/English reports. | |
| # These checks flag review risks; they do not establish clinical correctness. | |
| bilingual_checks = check_bilingual_reports( | |
| report_ar=report_ar, | |
| report_en=report_en, | |
| classifier=CLASSES[pred_class], | |
| confidence=confidence, | |
| quality=quality, | |
| translation_meta=report_integrity.get("arabic_translation", {}), | |
| ) | |
| report_integrity["bilingual_quality"] = bilingual_checks | |
| report_integrity["manual_review_required"] = bool(bilingual_checks["manual_review_required"]) | |
| report_integrity["review_status"] = bilingual_checks["status"] | |
| response = { | |
| "status": "success", | |
| "language": language, | |
| "analysis_id": report_ar["report_id"], | |
| "medical_report": report, | |
| "medical_report_ar": report_ar, | |
| "medical_report_en": report_en, | |
| "report_languages": ["ar", "en"], | |
| "report_integrity": report_integrity, | |
| "predictions": all_predictions, | |
| "visualizations": visualizations, | |
| "heatmap_stats": heatmap_stats, | |
| "quality_metrics": quality, | |
| "model_info": { | |
| "architecture": "EfficientNet-B4", | |
| "input_size": IMG_SIZE, | |
| "classes": len(CLASSES), | |
| "device": "cuda", | |
| "version": "2.0.0-kaggle-flask", | |
| "inference_time_ms": round(elapsed_ms, 2), | |
| "report_model": "ZrH42/Janus-Pro-CXR-Final" if janus_payload.get("enabled") else "template_fallback", | |
| "report_source": report.get("report_source", "EFFICIENTNET_TEMPLATE_FALLBACK"), | |
| }, | |
| "janus": { | |
| "enabled": bool(janus_payload.get("enabled")), | |
| "model": "ZrH42/Janus-Pro-CXR-Final" if janus_payload.get("enabled") else None, | |
| "raw_text": janus_payload.get("requested_raw_text", ""), | |
| "report_ar_raw": janus_payload.get("report_ar", ""), | |
| "report_en_raw": janus_payload.get("report_en", ""), | |
| "arabic_verified": arabic_verified, | |
| "english_verified": english_verified, | |
| "arabic_translation": arabic_translation, | |
| }, | |
| } | |
| logger.info( | |
| "INFERENCE SUCCESS %s class=%s confidence=%.6f time_ms=%.2f", | |
| request_tag, | |
| CLASSES[pred_class], | |
| confidence, | |
| elapsed_ms, | |
| ) | |
| return response | |
| except Exception as exc: | |
| logger.exception( | |
| "INFERENCE FAILED %s type=%s message=%s", | |
| request_tag, | |
| type(exc).__name__, | |
| exc, | |
| ) | |
| raise | |
| finally: | |
| # Always release request-specific tensors and return weights to CPU. | |
| try: | |
| if input_tensor is not None: | |
| del input_tensor | |
| if outputs is not None: | |
| del outputs | |
| if probabilities is not None: | |
| del probabilities | |
| except Exception: | |
| pass | |
| try: | |
| xray_model.model.to("cpu") | |
| except Exception: | |
| logger.exception("INFERENCE CLEANUP: failed moving model to CPU") | |
| try: | |
| grad_cam.clear() | |
| except Exception: | |
| pass | |
| gc.collect() | |
| try: | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| except Exception: | |
| pass | |
| logger.info("INFERENCE CLEANUP COMPLETE %s", request_tag) | |
| def health_info(): | |
| return { | |
| "status": "healthy", | |
| "service": "Clinical AI X-Ray API", | |
| "model": "EfficientNet-B4", | |
| "device": "cuda/ZeroGPU", | |
| "classes": len(CLASSES), | |
| "gradcam": True, | |
| "version": "1.0.0-zerogpu", | |
| } | |
| def classes_info(): | |
| return { | |
| "classes": CLASSES, | |
| "count": len(CLASSES), | |
| } | |
| def root_info(): | |
| return { | |
| "name": "Clinical AI X-Ray API", | |
| "version": "1.0.0-zerogpu", | |
| "status": "online", | |
| "endpoints": { | |
| "health": "Gradio/API health endpoint", | |
| "classes": "Gradio/API classes endpoint", | |
| "predict": "Gradio/API predict endpoint", | |
| }, | |
| } | |