From 2186016f23e399796eecf9ef70072a186e296f5b Mon Sep 17 00:00:00 2001 From: Flook Date: Fri, 7 Nov 2025 05:26:00 +0700 Subject: [PATCH] =?UTF-8?q?Spleen=20Segmentation=20(=E0=B8=A1=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 8 ++ .idea/inspectionProfiles/Project_Default.xml | 6 + .idea/misc.xml | 9 ++ .idea/modules.xml | 8 ++ .idea/monorepo-starter-template.iml | 9 ++ .idea/vcs.xml | 6 + README.md | 45 +++++++ ai-medical/app.py | 135 ++++++++++++++++++- 8 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/monorepo-starter-template.iml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0b8763d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5f3a278 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/monorepo-starter-template.iml b/.idea/monorepo-starter-template.iml new file mode 100644 index 0000000..67954c8 --- /dev/null +++ b/.idea/monorepo-starter-template.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 1d84219..06c01ec 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,48 @@ docker compose up -d cockroach-1 cockroach-2 cockroach-3 init-cluster redis mini docker compose up -d ``` คำสั่ง docker compose down -v จะลบ Volume และฐานข้อมูลทั้งหมด ใช้เฉพาะตอนต้องการเริ่มต้นฐานข้อมูลใหม่ + + +# บริการโมเดล AI ทางการแพทย์ (Medical AI Models) + +โครงการนี้มุ่งมั่นที่จะนำเสนอโมเดล AI คุณภาพสูงเพื่อช่วยในการวิเคราะห์ภาพทางการแพทย์ + +➡️ **สถานะ:** กำลังพัฒนาโมเดลใหม่ ๆ เพิ่มเติมอย่างต่อเนื่อง + +--- + +## 1. ⚙️ Spleen Segmentation (ม้าม) + +### ภาพรวม + +API นี้ให้บริการ **Segmentation** (การระบุขอบเขต) ของอวัยวะ **ม้าม** จากไฟล์ภาพ CT (NIfTI format) โดยเฉพาะ โดยใช้โมเดล **MONAI UNet** ที่ผ่านการฝึกฝนมาเพื่อตอบโจทย์ทางคลินิก + +### ตอบคำถาม: "ม้ามอยู่ที่ไหนและมีปริมาตรเท่าไหร่ในภาพนี้" + +บริการนี้ถูกออกแบบมาเพื่อส่งคืนข้อมูลที่สำคัญสำหรับรายงานทางการแพทย์โดยอัตโนมัติ: + +1. **การระบุตำแหน่ง:** ตำแหน่งของม้ามถูกระบุผ่าน **Segmentation Map** ซึ่งแสดงตำแหน่งของม้ามในระบบพิกัด RAS ที่ได้มาตรฐาน +2. **การคำนวณปริมาตร:** คำนวณปริมาตรของม้ามในหน่วยลูกบาศก์เซนติเมตร ($\text{cm}^3$) โดยใช้ข้อมูล Voxel Spacing +3. **การวิเคราะห์ม้ามโต (Splenomegaly):** ประเมินขนาดม้ามตามเกณฑ์มาตรฐาน และส่งคืนผลการวินิจฉัยเบื้องต้น (เช่น Normal Spleen Size, Borderline Enlarged, หรือ Splenomegaly Detected) + +### Endpoint + +| Method | Path | Description | +| :--- | :--- | :--- | +| `POST` | `/inference/spleen` | รับไฟล์ NIfTI และส่งคืนผลลัพธ์ Segmentation และ Volume Analysis | + +### 🛠️ เทคโนโลยีโมเดล + +* **เฟรมเวิร์ก:** MONAI / PyTorch +* **ไฟล์โมเดล:** `spleen_ct_spleen_model.ts` (โหลดจาก MinIO) +* **Input:** 3D CT Scan (NIfTI) +* **Output:** JSON ที่มีปริมาตร ($\text{cm}^3$) และการวินิจฉัยม้ามโต + +--- + +## 2. ⏳ โมเดลที่กำลังพัฒนา + +เราวางแผนที่จะขยายบริการไปยังอวัยวะและพยาธิสภาพอื่น ๆ ในอนาคตอันใกล้ เช่น: +* Liver and Tumor Segmentation +* Kidney Segmentation +* Lung Nodule Detection \ No newline at end of file diff --git a/ai-medical/app.py b/ai-medical/app.py index 7d1d656..e44e566 100644 --- a/ai-medical/app.py +++ b/ai-medical/app.py @@ -4,10 +4,15 @@ import torch import logging import boto3 from botocore.client import Config -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, UploadFile, File from contextlib import asynccontextmanager from pydantic_settings import BaseSettings +import numpy as np +import torch.nn.functional as F +# MONAI Dependencies ที่จำเป็นสำหรับการประมวลผล +from monai.inferers import sliding_window_inference +from monai.transforms import Compose, LoadImage, EnsureChannelFirst, ScaleIntensityRange, SpatialPad, Spacing,Resize,CenterSpatialCrop, Orientation, NormalizeIntensity # --- Logging setup --- logger = logging.getLogger("uvicorn") @@ -101,3 +106,131 @@ async def reload_model(): return {"message": f"Model '{settings.MODEL_FILE}' reloaded successfully"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + +# --- 7. MONAI Inference Endpoint --- +@app.post("/inference/spleen") +async def spleen_segmentation(file: UploadFile = File(...)): + """ + รับไฟล์ภาพทางการแพทย์ (NIfTI) และดำเนินการ Segmentation ม้าม + พร้อมคำนวณปริมาตรจริงและวิเคราะห์ภาวะม้ามโต + """ + if model is None: + raise HTTPException(status_code=503, detail="Model is not loaded. Please wait or check logs.") + + # เกณฑ์การวิเคราะห์ม้ามโต (Splenomegaly Thresholds) + SPLENOMEGALY_THRESHOLD_CM3 = 450.0 + + # 1. บันทึกไฟล์ที่ได้รับชั่วคราว + with tempfile.TemporaryDirectory() as temp_dir: + input_path = os.path.join(temp_dir, file.filename) + + try: + content = await file.read() + with open(input_path, "wb") as f: + f.write(content) + logger.info(f"Received file: {file.filename} saved to {input_path}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to read/save uploaded file: {e}") + + # 2. Pre-processing, Load Image และดึง Voxel Spacing (NEW) + try: + target_spacing = (1.5, 1.5, 2.0) + target_size = (96, 96, 96) # roi_size ที่โมเดลคาดหวัง + + # --- ดึง Original Spacing ก่อน Transform --- + try: + nifti_img = nib.load(input_path) + # Dims [1, 2, 3] คือ Spacing สำหรับ x, y, z (มักเป็น mm) + original_spacing_mm = tuple(nifti_img.header['pixdim'][1:4].tolist()) + logger.info(f"Original Voxel Spacing (mm): {original_spacing_mm}") + + # คำนวณปริมาตรของ 1 Voxel ในภาพเดิม (mm³) + original_voxel_volume_mm3 = float(np.prod(original_spacing_mm)) + except Exception as e: + logger.warning(f"Failed to load NIfTI Header/Spacing via nibabel. Falling back to target_spacing for volume calculation. Error: {e}") + # หากดึง Spacing เดิมไม่ได้ ให้ใช้ Spacing ของภาพที่ Resample แล้วเป็นค่าประมาณ + original_voxel_volume_mm3 = float(np.prod(target_spacing)) + + # --- MONAI Transforms (Resampling to target_spacing เกิดขึ้นที่นี่) --- + transform = Compose([ + LoadImage(image_only=True, ensure_channel_first=True, reader='NibabelReader'), + Orientation(axcodes='RAS'), + Spacing(pixdim=target_spacing, mode='bilinear'), # <--- Resampling ที่นี่! + + NormalizeIntensity(subtrahend=None, divisor=None, channel_wise=False), + SpatialPad(spatial_size=target_size), + CenterSpatialCrop(roi_size=target_size), + ]) + + img_data = transform(input_path) + + logger.info(f"Input shape after transform: {img_data.shape}, dtype: {img_data.dtype}, min={img_data.min().item():.4f}, max={img_data.max().item():.4f}") + input_tensor = torch.as_tensor(img_data, dtype=torch.float32, device=settings.DEVICE).unsqueeze(0) + + except Exception as e: + logger.error(f"Pre-processing failed: {e}") + raise HTTPException(status_code=500, detail=f"Image Pre-processing Error: {e}") + + # 3. Inference (Same) + # 4. Post-processing (Same) + model.eval() + with torch.no_grad(): + roi_size = (96, 96, 96) + sw_batch_size = 4 + prediction_raw = sliding_window_inference( + inputs=input_tensor, roi_size=roi_size, sw_batch_size=sw_batch_size, + predictor=model, overlap=0.5, mode="gaussian" + ) + + # ... (Softmax/Sigmoid/Argmax Logic - เหมือนเดิม) ... + if prediction_raw.shape[1] > 1: + prediction_prob = torch.softmax(prediction_raw, dim=1) + segmentation_map = torch.argmax(prediction_prob, dim=1).cpu().numpy()[0] + elif prediction_raw.shape[1] == 1: + prediction_prob = torch.sigmoid(prediction_raw) + segmentation_map = (prediction_prob > 0.5).cpu().numpy()[0, 0] + else: + raise RuntimeError(f"Unexpected model output channel count: {prediction_raw.shape[1]}") + + unique_labels = np.unique(segmentation_map) + logger.info(f"Unique labels in segmentation map: {unique_labels}") + + # 5. Post-processing (คำนวณสถิติและปริมาตรจริง) (MODIFIED) + if not isinstance(segmentation_map, np.ndarray) or segmentation_map.ndim != 3: + logger.error("Segmentation map is not a 3D numpy array.") + raise RuntimeError("Post-processing failed to produce a valid 3D map.") + + spleen_voxels = int(np.sum(segmentation_map == 1)) + + # ต้อง Inverse Transform Segmentation Map กลับไปยัง Spacing เดิม + # อย่างไรก็ตาม ในการใช้งานจริง มักใช้ Voxel Volume ของ Resampled Image (ซึ่งมี Spacing คงที่) + # เนื่องจาก Monai/Inferer มักจะทำการ Resample ก่อน และ Volume Calculation ในงานวิจัย + # ส่วนใหญ่จะใช้น้ำหนัก Spacing หลัง Resample แล้ว (target_spacing) เพื่อรักษาความสม่ำเสมอ + + # เราจะใช้ target_spacing เพื่อความสอดคล้องกับ Segmentation Map ที่ได้ + resampled_voxel_volume_mm3 = float(np.prod(target_spacing)) + + # ปริมาตรม้าม (cm³) คำนวณจาก Voxel ที่ Segmented และ Spacing หลัง Resample + spleen_volume_cm3 = (spleen_voxels * resampled_voxel_volume_mm3) / 1000.0 # mm³ → cm³ + + # วินิจฉัย Splenomegaly + if spleen_volume_cm3 < 350: + diagnosis = "Normal Spleen Size" + elif 350 <= spleen_volume_cm3 < SPLENOMEGALY_THRESHOLD_CM3: + diagnosis = "Borderline Enlarged" + else: + diagnosis = "Splenomegaly Detected" + + logger.info(f"Spleen volume: {spleen_volume_cm3:.2f} cm³ → {diagnosis}") + + # 6. ส่งผลลัพธ์กลับ + return { + "filename": file.filename, + "status": "Success", + "spleen_voxels_count": spleen_voxels, + "resampled_voxel_volume_cm3": round(resampled_voxel_volume_mm3 / 1000.0, 6), + "estimated_spleen_volume_cm3": round(spleen_volume_cm3, 2), + "diagnosis": diagnosis, + "splenomegaly_threshold_cm3": SPLENOMEGALY_THRESHOLD_CM3, + "message": "Segmentation, volume calculation using resampled spacing, and splenomegaly analysis complete." + } \ No newline at end of file