This commit is contained in:
DatTT127
2026-06-24 10:33:07 +07:00
parent 16a91bd17e
commit f705113711
77 changed files with 8999 additions and 14 deletions

View File

@@ -0,0 +1,26 @@
# Stage 1: Build stage
FROM python:3.12 AS builder
WORKDIR /app
# Install dependencies into a virtual environment to make copying easy
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: Final Runtime stage
FROM python:3.12-slim
WORKDIR /app
# Copy the pre-compiled Python packages from the builder stage
COPY --from=builder /opt/venv /opt/venv
COPY . .
# Ensure the virtual environment is used
ENV PATH="/opt/venv/bin:$PATH"
EXPOSE 8000
CMD ["python", "main.py"]

View File

@@ -0,0 +1,852 @@
from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Request, Response
from pydantic import BaseModel
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
import os
import uuid
import numpy as np
from PIL import Image, ImageDraw
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.models as models
from torchvision import transforms
import uvicorn
from pathlib import Path
import cv2
import io
import base64
from datetime import datetime
import re
# Import custom models
import sys
sys.path.append('.')
from arch.efficientfeedback import EfficientFeedbackNetwork
from arch.unet3plus_att import UNet3Plus_Attention
from pdf_service import generate_medical_report
# Configuration
UPLOAD_FOLDER = 'uploads'
RESULTS_FOLDER = 'results'
TEMPLATES_FOLDER = 'templates'
# for folder in [UPLOAD_FOLDER, RESULTS_FOLDER, TEMPLATES_FOLDER]:
# os.makedirs(folder, exist_ok=True)
os.makedirs(TEMPLATES_FOLDER, exist_ok=True)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Classes
ANGLE_CLASSES = ['med-lat', 'post-trans', 'sup-trans-flex', 'sup-up-long']
SEGMENT_CLASSES_SUPRAPAT = {0: "background", 1: "effusion", 2: "fat", 3: "fat-pat", 4: "femur", 5: "synovium", 6: "tendon"}
SEGMENT_CLASSES_POST = {0: "background", 1: "fat", 2: "tendon", 3: "muscle", 4: "femur", 5: "artery", 6: "baker's cyst"}
# Color map for Suprapat
COLOR_MAP_SUP = {
'background': {'color': [0, 0, 0], 'name': 'Nền'},
'effusion': {'color': [255, 0, 0], 'name': 'Dịch khớp'},
'fat': {'color': [255, 255, 0], 'name': 'Mỡ'},
'fat-pat': {'color': [0, 255, 255], 'name': 'Mỡ Hoffa'},
'femur': {'color': [0, 255, 0], 'name': 'Xương đùi'},
'synovium': {'color': [255, 0, 255], 'name': 'Màng hoạt dịch'},
'tendon': {'color': [0, 0, 255], 'name': 'Gân'}
}
# Color map for Post-trans
COLOR_MAP_POST = {
'background': {'color': [0, 0, 0], 'name': 'Nền'},
"baker's cyst": {'color': [255, 0, 0], 'name': "Baker's cyst"},
'fat': {'color': [255, 255, 0], 'name': 'Mỡ'},
'muscle': {'color': [0, 255, 255], 'name': 'Cơ bắp'},
'femur': {'color': [0, 255, 0], 'name': 'Xương đùi'},
'synovium': {'color': [255, 0, 255], 'name': 'Màng hoạt dịch'},
'tendon': {'color': [0, 0, 255], 'name': 'Gân'}
}
# Measurement configuration
DEFAULT_MEASURE_IDS = [1, 5]
PIXEL_TO_MM = 45.0 / 655.0
app = FastAPI(title="Medical Image Analysis API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# app.mount("/uploads", StaticFiles(directory=UPLOAD_FOLDER), name="uploads")
# app.mount("/results", StaticFiles(directory=RESULTS_FOLDER), name="results")
# ============ MODEL LOADING ============
def load_angle_model(model_name):
print(f"📄 Loading angle model: {model_name}")
if model_name == "convnext":
model = models.convnext_tiny(weights=None)
model.classifier[2] = nn.Linear(model.classifier[2].in_features, 4)
checkpoint = torch.load(f"models/best_convnext_tiny.pth", map_location=device, weights_only=False)
checkpoint = {k.replace('model.', ''): v for k, v in checkpoint.items()}
model.load_state_dict(checkpoint)
elif model_name == "densenet":
model = models.densenet121(weights=None)
model.classifier = nn.Linear(model.classifier.in_features, 4)
checkpoint = torch.load(f"models/best_densenet.pth", map_location=device, weights_only=False)
checkpoint = {k.replace('model.', ''): v for k, v in checkpoint.items()}
model.load_state_dict(checkpoint)
elif model_name == "resnet50":
model = models.resnet50(weights=None)
model.fc = nn.Linear(model.fc.in_features, 4)
checkpoint = torch.load(f"models/best_resnet50.pth", map_location=device, weights_only=False)
checkpoint = {k.replace('model.', ''): v for k, v in checkpoint.items()}
model.load_state_dict(checkpoint)
elif model_name == "efficientnet_b2":
model = models.efficientnet_b2(weights=None)
model.classifier[1] = nn.Linear(model.classifier[1].in_features, 4)
checkpoint = torch.load(f"models/best_efficientnet_b2.pth", map_location=device, weights_only=False)
checkpoint = {k.replace('model.', ''): v for k, v in checkpoint.items()}
model.load_state_dict(checkpoint)
elif model_name == "swin":
model = models.swin_v2_s(weights=None)
model.head = nn.Linear(model.head.in_features, 4)
checkpoint = torch.load(f"models/best_swin_v2_s.pth", map_location=device, weights_only=False)
checkpoint = {k.replace('model.', ''): v for k, v in checkpoint.items()}
model.load_state_dict(checkpoint)
else:
raise ValueError(f"Unknown angle model: {model_name}")
print(f"✅ Loaded: {model_name}")
return model.to(device).eval()
def load_inflammation_model():
print("📄 Loading inflammation model")
model = models.efficientnet_b0(weights=None)
model.classifier[1] = nn.Linear(model.classifier[1].in_features, 2)
model.load_state_dict(torch.load("models/efficientnet_b0_ultrasound_2_class.pth", map_location=device, weights_only=False))
print("✅ Loaded inflammation model")
return model.to(device).eval()
def load_segmentation_model_sup(model_name):
print(f"📄 Loading segmentation model SUP: {model_name}")
if model_name == "deeplabv3":
model = models.segmentation.deeplabv3_resnet50(weights=None)
in_ch = model.classifier[-1].in_channels
model.classifier = nn.Sequential(
model.classifier[0],
nn.Dropout(0.3),
nn.Conv2d(in_ch, 7, kernel_size=1)
)
model.load_state_dict(torch.load("models/best_model_Deeplav3.pth", map_location=device, weights_only=False), strict=False)
elif model_name == "unet_resnet101":
try:
from segmentation_models_pytorch import Unet
model = Unet(encoder_name="resnet101", encoder_weights=None, classes=7)
model.load_state_dict(torch.load("models/unet_resnet101.pth", map_location=device, weights_only=False))
except ImportError:
raise ValueError("segmentation_models_pytorch not installed")
elif model_name == "efficientfeedback":
model = EfficientFeedbackNetwork(in_channels=3, num_class=7)
model.load_state_dict(torch.load("models/efficientfeedback.pth", map_location=device, weights_only=False))
elif model_name == "unet3plus":
model = UNet3Plus_Attention(in_channels=3, num_classes=7)
model.load_state_dict(torch.load("models/unet3plus_att.pth", map_location=device, weights_only=False))
else:
raise ValueError(f"Unknown segmentation model: {model_name}")
print(f"✅ Loaded SUP: {model_name}")
return model.to(device).eval()
def load_segmentation_model_post(model_name):
print(f"📄 Loading segmentation model POST: {model_name}")
if model_name == "deeplabv3_resnet101":
model = models.segmentation.deeplabv3_resnet101(weights=None)
in_ch = model.classifier[-1].in_channels
model.classifier = nn.Sequential(
model.classifier[0],
nn.Dropout(0.3),
nn.Conv2d(in_ch, 7, kernel_size=1)
)
model.load_state_dict(torch.load("models/best_model_deeplabv3_resnet101_seed_16.pth", map_location=device, weights_only=False), strict=False)
else:
raise ValueError(f"Unknown post segmentation model: {model_name}")
print(f"✅ Loaded POST: {model_name}")
return model.to(device).eval()
# Transforms
angle_transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
inflammation_transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
segmentation_transform = transforms.Compose([
transforms.Resize((512, 512)),
transforms.ToTensor()
])
# ============ PREDICTION ============
@torch.no_grad()
def predict_angle(model, image_pil):
img_tensor = angle_transform(image_pil).unsqueeze(0).to(device)
output = model(img_tensor)
probs = torch.softmax(output, dim=1)
pred_class = torch.argmax(probs, dim=1).item()
confidence = probs[0][pred_class].item()
return ANGLE_CLASSES[pred_class], round(confidence * 100, 2)
@torch.no_grad()
def predict_inflammation(model, image_pil):
img_tensor = inflammation_transform(image_pil).unsqueeze(0).to(device)
output = model(img_tensor)
probs = torch.softmax(output, dim=1)
pred_class = torch.argmax(probs, dim=1).item()
confidence = probs[0][pred_class].item()
is_inflammation = (pred_class == 1)
return is_inflammation, round(confidence * 100, 2)
@torch.no_grad()
def segment_image(model, image_pil, model_type, angle_type):
original_size = image_pil.size
img_tensor = segmentation_transform(image_pil).unsqueeze(0).to(device)
if model_type.startswith("deeplabv3"):
outputs = model(img_tensor)['out']
else:
outputs = model(img_tensor)
upsampled = F.interpolate(outputs, size=original_size[::-1], mode='bilinear', align_corners=False)
preds = upsampled.argmax(dim=1)[0].cpu().numpy()
if angle_type == 'sup' and model_type in ["unet3plus", "efficientfeedback"]:
remap = {0: 0, 1: 2, 2: 6, 3: 1, 4: 4, 5: 5, 6: 3}
preds = np.vectorize(remap.get)(preds)
class_map = SEGMENT_CLASSES_SUPRAPAT if angle_type == 'sup' else SEGMENT_CLASSES_POST
masks = {}
for class_id, class_name in class_map.items():
mask = (preds == class_id).astype(np.uint8)
masks[class_name] = mask
return preds, masks
def get_mask_bounding_box(mask, dist_percent=0.01):
"""
Duyệt toàn bộ vùng được mask, loại bỏ nhiễu và trả về khung bao (Bounding Box).
Áp dụng quy tắc kết hợp:
1. Giữ lại khối có diện tích lớn nhất (vùng trung tâm).
2. Giữ lại các khối phụ nếu thỏa mãn một trong hai điều kiện:
- Diện tích >= 1/5 diện tích khối lớn nhất.
- Khoảng cách tới khối lớn nhất <= dist_percent * chiều rộng ảnh.
"""
if mask is None or np.sum(mask) == 0:
return None
# 1. Chuyển sang uint8
mask_uint8 = mask.astype(np.uint8)
if np.max(mask_uint8) == 1:
mask_uint8 *= 255
# Lấy chiều rộng ảnh để tính ngưỡng khoảng cách theo %
img_width = mask_uint8.shape[1]
dist_threshold = img_width * dist_percent
# 2. Làm sạch mask cơ bản (Morphological Opening)
kernel = np.ones((5, 5), np.uint8)
clean_mask = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, kernel)
# 3. Tìm các đường bao (các khối tách rời)
contours, _ = cv2.findContours(clean_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
# 4. Tính diện tích từng khối và tìm khối lớn nhất
contour_info = []
for cnt in contours:
contour_info.append({'cnt': cnt, 'area': cv2.contourArea(cnt)})
# Sắp xếp theo diện tích giảm dần
contour_info.sort(key=lambda x: x['area'], reverse=True)
main_block = contour_info[0]
max_area = main_block['area']
if max_area < 50:
return None
# 5. Chuẩn bị để tính khoảng cách (Distance Transform)
main_mask = np.zeros_like(mask_uint8)
cv2.drawContours(main_mask, [main_block['cnt']], -1, 255, -1)
# dist_map chứa khoảng cách từ mỗi điểm tới biên gần nhất của khối chính
dist_map = cv2.distanceTransform(255 - main_mask, cv2.DIST_L2, 3)
# 6. Lọc các khối
significant_contours = [main_block['cnt']]
area_threshold = max_area / 4.0
for i in range(1, len(contour_info)):
other = contour_info[i]
# Tạo mask cho khối đang xét để lấy giá trị khoảng cách
other_mask = np.zeros_like(mask_uint8)
cv2.drawContours(other_mask, [other['cnt']], -1, 255, -1)
# Khoảng cách nhỏ nhất từ khối này tới khối chính
min_dist = np.min(dist_map[other_mask > 0])
# Điều kiện giữ lại: (Diện tích đủ lớn) HOẶC (Ở gần khối chính theo %)
if other['area'] >= area_threshold or min_dist <= dist_threshold:
significant_contours.append(other['cnt'])
# 7. Tính toán bounding box bao quanh tất cả các vùng được chọn
all_points = np.concatenate(significant_contours)
x, y, w, h = cv2.boundingRect(all_points)
return x, y, w, h
def find_max_continuous_segment(col_array):
padded = np.concatenate(([0], col_array, [0]))
diffs = np.diff(padded)
starts = np.where(diffs == 1)[0]
ends = np.where(diffs == -1)[0]
if len(starts) == 0:
return 0, -1, -1
lengths = ends - starts
max_idx = np.argmax(lengths)
max_len = lengths[max_idx]
return max_len, starts[max_idx], ends[max_idx]
def measure_thickness_new(masks, image_size, measure_ids=None):
if measure_ids is None:
measure_ids = DEFAULT_MEASURE_IDS
width, height = image_size
mask_all_labels = np.zeros((height, width), dtype=np.uint8)
mask_measure = np.zeros((height, width), dtype=np.uint8)
has_any_label = False
if 'fat-pat' in masks:
class_map = SEGMENT_CLASSES_SUPRAPAT
else:
class_map = SEGMENT_CLASSES_POST
for class_id, class_name in class_map.items():
if class_name not in masks or class_name == 'background':
continue
mask = masks[class_name]
if np.sum(mask) > 0:
has_any_label = True
mask_all_labels = np.logical_or(mask_all_labels, mask).astype(np.uint8)
if class_id in measure_ids:
mask_measure = np.logical_or(mask_measure, mask).astype(np.uint8)
if not has_any_label or np.sum(mask_measure) == 0:
return None
# Đóng khung toàn bộ vùng được mask (xương, màng, dịch, mỡ...)
bbox_all = get_mask_bounding_box(mask_all_labels)
if bbox_all is None:
return None
x_all, y_all, w_all, h_all = bbox_all
# Từ khung này, xác định vùng quét là 1/3 ở giữa theo chiều ngang
roi_start = x_all + (w_all // 3)
roi_end = x_all + (2 * w_all // 3)
roi_strip = mask_measure[:, roi_start:roi_end]
global_max_len_px = 0
best_x_rel = 0
best_y_start = 0
best_y_end = 0
for x in range(roi_strip.shape[1]):
col = roi_strip[:, x]
if not np.any(col):
continue
length, y_s, y_e = find_max_continuous_segment(col)
if length > global_max_len_px:
global_max_len_px = length
best_x_rel = x
best_y_start = y_s
best_y_end = y_e
if global_max_len_px == 0:
return None
thickness_mm = global_max_len_px * PIXEL_TO_MM
real_x = roi_start + best_x_rel
print(f"📏 Measurement: {thickness_mm:.2f}mm ({global_max_len_px}px) at x={real_x}")
return {
'thickness_px': int(global_max_len_px),
'thickness_mm': float(round(thickness_mm, 2)),
'x': int(real_x),
'y_start': int(best_y_start),
'y_end': int(best_y_end),
'roi_start': int(roi_start),
'roi_end': int(roi_end),
'bbox': {'x': int(x_all), 'y': int(y_all), 'w': int(w_all), 'h': int(h_all)}
}
def analyze_inflammation_severity(masks, image_size):
if not masks:
return None
width, height = image_size
total_pixels = width * height
effusion_mask = masks.get('effusion', np.zeros((height, width), dtype=np.uint8))
effusion_pixels = int(np.sum(effusion_mask))
effusion_ratio = (effusion_pixels / total_pixels) * 100
effusion_thickness = 0
if effusion_pixels > 0:
rows_with_effusion = np.any(effusion_mask > 0, axis=1)
if np.any(rows_with_effusion):
effusion_thickness = int(np.sum(rows_with_effusion))
synovium_mask = masks.get('synovium', np.zeros((height, width), dtype=np.uint8))
synovium_pixels = int(np.sum(synovium_mask))
synovium_ratio = (synovium_pixels / total_pixels) * 100
effusion_score = min(effusion_thickness / height * 100, 100)
synovium_score = synovium_ratio
combined_score = (effusion_score * 0.6 + synovium_score * 0.4)
if combined_score > 15:
level, severity, color = 3, "Nặng", "#dc3545"
description = f"Dịch khớp dày ({effusion_thickness}px), màng hoạt dịch tăng sinh rõ"
elif combined_score >= 8:
level, severity, color = 2, "Trung bình", "#fd7e14"
description = f"Dịch khớp trung bình ({effusion_thickness}px), màng hoạt dịch tăng sinh vừa"
elif combined_score >= 3:
level, severity, color = 1, "Nhẹ", "#ffc107"
description = f"Dịch khớp mỏng ({effusion_thickness}px), màng hoạt dịch tăng sinh nhẹ"
else:
level, severity, color = 0, "Rất nhẹ", "#28a745"
description = "Lượng dịch và màng hoạt dịch trong giới hạn bình thường"
return {
'level': int(level),
'severity': severity,
'color': color,
'description': description,
'effusion': {'pixels': effusion_pixels, 'ratio': float(round(effusion_ratio, 2)), 'thickness': effusion_thickness},
'synovium': {'pixels': synovium_pixels, 'ratio': float(round(synovium_ratio, 2))},
'combined_score': float(round(combined_score, 2))
}
def create_segmentation_overlay(image_pil, masks, measurement=None, angle_type='sup'):
if masks is None:
return image_pil
color_map = COLOR_MAP_SUP if angle_type == 'sup' else COLOR_MAP_POST
img_array = np.array(image_pil)
overlay = img_array.copy()
for class_name, mask in masks.items():
if class_name in color_map and np.sum(mask) > 0:
color = color_map[class_name]['color']
for i in range(3):
overlay[:, :, i] = np.where(mask > 0,
(overlay[:, :, i] * 0.6 + color[i] * 0.4).astype(np.uint8),
overlay[:, :, i])
overlay_pil = Image.fromarray(overlay)
draw = ImageDraw.Draw(overlay_pil)
for class_name in ['effusion', 'synovium']:
mask = masks.get(class_name)
if mask is not None and np.sum(mask) > 0:
mask_uint8 = (mask * 255).astype(np.uint8)
contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
points = contour.reshape(-1, 2).tolist()
if len(points) > 2:
points = [(int(p[0]), int(p[1])) for p in points]
draw.line(points + [points[0]], fill=(255, 255, 255), width=3)
if measurement and angle_type == 'sup':
x = measurement['x']
y_start = measurement['y_start']
y_end = measurement['y_end']
thickness_mm = measurement['thickness_mm']
roi_start = measurement['roi_start']
roi_end = measurement['roi_end']
bbox = measurement['bbox']
draw.rectangle(
[bbox['x'], bbox['y'], bbox['x'] + bbox['w'], bbox['y'] + bbox['h']],
outline=(0, 255, 0), # Chuyển sang xanh lá cho dễ nhìn
width=3 # Tăng độ dày khung
)
h = image_pil.size[1]
draw.line([(roi_start, 0), (roi_start, h)], fill=(0, 255, 255), width=2)
draw.line([(roi_end, 0), (roi_end, h)], fill=(0, 255, 255), width=2)
draw.line([(x, y_start), (x, y_end)], fill=(255, 0, 0), width=4)
radius = 4
draw.ellipse([x-radius, y_start-radius, x+radius, y_start+radius],
fill=(0, 255, 0), outline=(255, 255, 255), width=2)
draw.ellipse([x-radius, y_end-radius, x+radius, y_end+radius],
fill=(0, 255, 0), outline=(255, 255, 255), width=2)
text = f"{thickness_mm:.2f} mm"
try:
from PIL import ImageFont
font = ImageFont.load_default()
bbox_text = draw.textbbox((0, 0), text, font=font)
text_w = bbox_text[2] - bbox_text[0]
text_h = bbox_text[3] - bbox_text[1]
except:
text_w, text_h = 100, 20
text_x = x + 8
text_y = y_start - text_h - 8
draw.rectangle(
[text_x - 2, text_y - 2, text_x + text_w + 2, text_y + text_h + 2],
fill=(0, 0, 0)
)
draw.text((text_x, text_y), text, fill=(255, 0, 0))
return overlay_pil
def apply_clahe(image_pil):
"""Áp dụng thuật toán CLAHE để tăng độ tương phản. Phục vụ cả hiển thị và làm đầu vào AI."""
# Chuyển từ PIL sang OpenCV (numpy array)
img_array = np.array(image_pil)
# Chuyển sang thang độ xám (Gray) để xử lý CLAHE
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
# Tạo đối tượng CLAHE
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
enhanced_gray = clahe.apply(gray)
# Chuyển ngược lại sang RGB (3 kênh) để tương thích với các models
enhanced_rgb = cv2.cvtColor(enhanced_gray, cv2.COLOR_GRAY2RGB)
return Image.fromarray(enhanced_rgb)
# Mount thư mục static (CSS, JS)
app.mount("/css", StaticFiles(directory="templates/css"), name="css")
app.mount("/js", StaticFiles(directory="templates/js"), name="js")
@app.get("/")
async def read_index():
html_file = Path(TEMPLATES_FOLDER) / "index.html"
if html_file.exists():
return FileResponse(html_file)
return JSONResponse({"error": "Template not found"})
@app.post("/api/analyze")
async def analyze_image(
image: UploadFile = File(...),
angle_model: str = Query("convnext"),
inflammation_model: str = Query("efficientnet_b0"),
segment_model_sup: str = Query("deeplabv3"),
segment_model_post: str = Query("deeplabv3_resnet101")
):
try:
print(f"\n{'='*60}")
print(f"📊 NEW REQUEST")
print(f"Models: angle={angle_model}, inflam={inflammation_model}")
print(f" seg_sup={segment_model_sup}, seg_post={segment_model_post}")
print(f"{'='*60}")
contents = await image.read()
image_pil = Image.open(io.BytesIO(contents)).convert('RGB')
# Tạo ảnh tăng cường độ tương phản (Enhanced) cho mục đích hiển thị
enhanced_pil = apply_clahe(image_pil)
buffered_en = io.BytesIO()
enhanced_pil.save(buffered_en, format="PNG")
enhanced_str = base64.b64encode(buffered_en.getvalue()).decode()
result = {
'success': True,
'filename': image.filename,
'images': {
'enhanced': f"data:image/png;base64,{enhanced_str}"
},
'models_used': {
'angle': angle_model,
'inflammation': inflammation_model,
'segmentation_sup': segment_model_sup,
'segmentation_post': segment_model_post
}
}
angle_clf = load_angle_model(angle_model)
angle, angle_conf = predict_angle(angle_clf, image_pil)
result['angle'] = {'class': angle, 'confidence': angle_conf}
print(f"✅ Angle: {angle} ({angle_conf}%)")
if 'post-trans' in angle.lower():
print(f"🔍 POST-TRANS pipeline")
inflam_model = load_inflammation_model()
has_inflammation, inflam_conf = predict_inflammation(inflam_model, image_pil)
result['inflammation'] = {'detected': has_inflammation, 'confidence': inflam_conf}
print(f"✅ Inflammation: {has_inflammation} ({inflam_conf}%)")
if has_inflammation:
seg_model = load_segmentation_model_post(segment_model_post)
preds, masks = segment_image(seg_model, image_pil, segment_model_post, 'post')
if masks:
segmented_img = create_segmentation_overlay(image_pil, masks, None, 'post')
# Convert to base64
buffered = io.BytesIO()
segmented_img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
result['images']['segmented'] = f"data:image/png;base64,{img_str}"
detected_classes = [k for k, v in masks.items() if np.sum(v) > 0]
color_legend = []
for class_name in detected_classes:
if class_name in COLOR_MAP_POST:
color_legend.append({
'name': COLOR_MAP_POST[class_name]['name'],
'color': COLOR_MAP_POST[class_name]['color'],
'key': class_name
})
result['segmentation'] = {
'performed': True,
'classes_detected': detected_classes,
'color_legend': color_legend,
'angle_type': 'post'
}
print(f"✅ Segmentation POST completed")
else:
print(f" No inflammation detected - skipping segmentation POST")
elif 'sup-up-long' in angle.lower():
print(f"🔍 SUPRAPAT pipeline")
inflam_model = load_inflammation_model()
has_inflammation, inflam_conf = predict_inflammation(inflam_model, image_pil)
result['inflammation'] = {'detected': has_inflammation, 'confidence': inflam_conf}
print(f"✅ Inflammation: {has_inflammation} ({inflam_conf}%)")
if has_inflammation:
seg_model = load_segmentation_model_sup(segment_model_sup)
preds, masks = segment_image(seg_model, image_pil, segment_model_sup, 'sup')
if masks:
measurement = measure_thickness_new(masks, image_pil.size)
segmented_img = create_segmentation_overlay(image_pil, masks, measurement, 'sup')
# Convert to base64
buffered = io.BytesIO()
segmented_img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
result['images']['segmented'] = f"data:image/png;base64,{img_str}"
if measurement:
result['measurement'] = {
'thickness_mm': measurement['thickness_mm'],
'thickness_px': measurement['thickness_px'],
'location_x': measurement['x'],
'y_start': measurement['y_start'],
'y_end': measurement['y_end']
}
print(f"✅ Measurement: {measurement['thickness_mm']:.2f}mm at x={measurement['x']}")
detected_classes = [k for k, v in masks.items() if np.sum(v) > 0]
color_legend = []
for class_name in detected_classes:
if class_name in COLOR_MAP_SUP:
color_legend.append({
'name': COLOR_MAP_SUP[class_name]['name'],
'color': COLOR_MAP_SUP[class_name]['color'],
'key': class_name
})
result['segmentation'] = {
'performed': True,
'classes_detected': detected_classes,
'color_legend': color_legend,
'angle_type': 'sup'
}
severity = analyze_inflammation_severity(masks, image_pil.size)
if severity:
result['severity'] = severity
print(f"✅ Severity: {severity['severity']}")
print(f"✅ Segmentation SUP completed")
else:
print(f" No inflammation detected - skipping segmentation SUP")
else:
print(f" Other angle - only angle classification")
print(f"{'='*60}\n")
return JSONResponse(result)
except Exception as e:
import traceback
print(f"❌ Error: {e}")
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/health")
async def health_check():
return JSONResponse({'status': 'healthy'})
def sanitize_name(name):
# Loại bỏ ký tự không hợp lệ cho folder trên Windows
if not name: return "unknown"
# Thay thế ký tự lạ bằng dấu gạch dưới
clean = re.sub(r'[\\/*?:"<>|]', "_", name)
# Loại bỏ dấu cách thừa
clean = clean.strip().replace(" ", "_")
return clean
class SaveDataRequest(BaseModel):
patient_info: dict
analysis_result: dict
images: dict
@app.post("/api/save")
async def save_patient_data(data: SaveDataRequest):
try:
p = data.patient_info
res = data.analysis_result
imgs = data.images
# 1. Tạo thư mục theo mã bệnh nhân (nhóm chính)
patient_id = sanitize_name(p.get('id', 'unknown'))
patient_name = sanitize_name(p.get('name', 'no_name'))
# Thư mục chính của bệnh nhân
patient_folder = f"{patient_id}_{patient_name}"
# Thư mục con theo thời gian hiện tại
timestamp_folder = datetime.now().strftime("%Y%m%d_%H%M%S")
# Tổng hợp đường dẫn: patients/ID_Name/TIMESTAMP/
target_dir = os.path.join("patients", patient_folder, timestamp_folder)
os.makedirs(target_dir, exist_ok=True)
# 2. Lưu info.txt
info_path = os.path.join(target_dir, "info.txt")
with open(info_path, "w", encoding="utf-8") as f:
f.write(f"--- THÔNG TIN BỆNH NHÂN ---\n")
f.write(f"Mã BN: {patient_id}\n")
f.write(f"Họ tên: {patient_name}\n")
f.write(f"Giới tính: {p.get('gender')}\n")
f.write(f"Tuổi: {p.get('age')}\n")
f.write(f"Chẩn đoán BS: {p.get('diagnosis')}\n\n")
f.write(f"--- KẾT QUẢ PHÂN TÍCH AI ---\n")
f.write(f"Góc chụp: {res.get('angle', {}).get('class')} ({res.get('angle', {}).get('confidence')}%)\n")
if 'inflammation' in res:
infl = res['inflammation']
f.write(f"Viêm nhiễm: {'' if infl['detected'] else 'Không'} ({infl['confidence']}%)\n")
if 'measurement' in res:
m = res['measurement']
f.write(f"Độ dày màng: {m['thickness_mm']} mm ({m['thickness_px']} px)\n")
f.write(f"Vị trí x: {m['location_x']}\n")
if 'severity' in res:
s = res['severity']
f.write(f"Mức độ: {s['severity']}\n")
f.write(f"Mô tả: {s['description']}\n")
# 3. Lưu ảnh
def save_base64_img(b64_str, filename):
if not b64_str: return
# Remove header if present
if "," in b64_str:
b64_str = b64_str.split(",")[1]
img_data = base64.b64decode(b64_str)
with open(os.path.join(target_dir, filename), "wb") as f:
f.write(img_data)
save_base64_img(imgs.get('original'), "original.png")
save_base64_img(imgs.get('segmented'), "segmented.png")
# 4. Tự động lưu PDF báo cáo
try:
pdf_bytes = generate_medical_report(p, res, imgs)
pdf_path = os.path.join(target_dir, "report.pdf")
with open(pdf_path, "wb") as f:
f.write(bytes(pdf_bytes))
print(f"📄 Report PDF saved to: {pdf_path}")
except Exception as pdf_err:
print(f"⚠️ Warning: Could not auto-save PDF: {pdf_err}")
print(f"✅ Data saved for patient: {patient_id}")
return {"success": True, "folder": target_dir}
except Exception as e:
print(f"❌ Save Error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/export-pdf")
async def export_patient_pdf(data: SaveDataRequest):
try:
pdf_bytes = generate_medical_report(
data.patient_info,
data.analysis_result,
data.images
)
filename = f"Phieu_Kham_{sanitize_name(data.patient_info.get('id', 'unknown'))}.pdf"
return Response(
content=bytes(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
except Exception as e:
import traceback
print(f"❌ PDF Export Error: {e}")
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
if __name__ == '__main__':
print("Medical Image Analysis Server")
print(f"URL: http://127.0.0.1:8000")
print(f"Device: {device}")
uvicorn.run(app, host="127.0.0.1", port=8000)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -0,0 +1,247 @@
from fpdf import FPDF
import io
import base64
import os
from datetime import datetime
from PIL import Image
class MedicalReportPDF(FPDF):
def __init__(self):
super().__init__()
self.main_font = 'helvetica' # Default fallback
self.set_margins(10, 10, 10)
def header(self):
# Logo support
logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'logo.png')
show_logo = False
if os.path.exists(logo_path):
logo_stream = get_clean_image_stream(logo_path)
if logo_stream:
try:
self.image(logo_stream, 10, 8, 25, type='PNG')
self.set_x(40)
show_logo = True
except:
pass
# Header with Unicode support
try:
self.set_font(self.main_font, 'B', 16)
title = 'TRUNG TÂM CHẨN ĐOÁN HÌNH ẢNH VKIST'
if show_logo:
self.cell(0, 10, title, 0, 1, 'L')
self.set_x(40)
self.set_font(self.main_font, '', 10)
self.cell(0, 5, 'Địa chỉ: Khu Công nghệ cao Hòa Lạc, Thạch Thất, Hà Nội', 0, 1, 'L')
else:
self.cell(0, 10, title, 0, 1, 'C')
self.set_font(self.main_font, '', 10)
self.cell(0, 10, 'Địa chỉ: Khu Công nghệ cao Hòa Lạc, Thạch Thất, Hà Nội', 0, 1, 'C')
self.ln(10)
except Exception:
pass
def footer(self):
self.set_y(-15)
try:
self.set_font(self.main_font, 'I', 8)
self.cell(0, 10, f'Trang {self.page_no()}/{{nb}}', 0, 0, 'C')
except Exception:
self.set_font('helvetica', 'I', 8)
self.cell(0, 10, f'Page {self.page_no()}/{{nb}}', 0, 0, 'C')
def get_clean_image_stream(image_source):
"""
Mở ảnh (file path hoặc base64), loại bỏ interlacing và trả về BytesIO stream.
Giúp tránh lỗi 'Interlacing not supported' trong FPDF2.
"""
if not image_source:
return None
try:
if isinstance(image_source, str) and image_source.startswith('data:image'):
# Xử lý base64
header, content = image_source.split(',') if ',' in image_source else (None, image_source)
img_data = base64.b64decode(content)
img_io = io.BytesIO(img_data)
elif isinstance(image_source, str) and os.path.exists(image_source):
# Xử lý file path
img_io = image_source
else:
return None
with Image.open(img_io) as img:
# Chuyển đổi sang RGB nếu cần và lưu lại không có interlacing
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
output = io.BytesIO()
img.save(output, format='PNG', optimize=False, interlaced=False)
output.seek(0)
return output
except Exception as e:
print(f"⚠️ Lỗi xử lý ảnh: {e}")
return None
def generate_medical_report(patient_info, analysis_result, images_base64):
pdf = MedicalReportPDF()
# Sử dụng đường dẫn tuyệt đối để ổn định
base_dir = os.path.dirname(os.path.abspath(__file__))
font_dir = os.path.join(base_dir, 'assets', 'fonts')
# 1. Đăng ký Font nội bộ (Arial đã sao chép)
try:
pdf.add_font('arial_local', '', os.path.join(font_dir, 'arial.ttf'))
pdf.add_font('arial_local', 'B', os.path.join(font_dir, 'arialbd.ttf'))
pdf.add_font('arial_local', 'I', os.path.join(font_dir, 'ariali.ttf'))
pdf.main_font = 'arial_local'
except Exception as e:
print(f"Warning: Could not load local Arial fonts: {e}")
# Dự phòng cuối cùng
pdf.main_font = 'helvetica'
font_main = pdf.main_font
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()
pdf.alias_nb_pages()
# Tiêu đề
pdf.set_font(font_main, 'B', 14)
pdf.cell(0, 10, 'PHIẾU KẾT QUẢ SIÊU ÂM KHỚP GỐI', 0, 1, 'C')
pdf.ln(5)
# Thông tin bệnh nhân
pdf.set_x(10)
pdf.set_font(font_main, 'B', 11)
pdf.cell(0, 8, 'I. THÔNG TIN BỆNH NHÂN', 0, 1, 'L')
pdf.set_font(font_main, '', 11)
# Độ rộng cột an toàn (Tổng 180mm < 190mm khả dụng cho A4)
col1 = 95
col2 = 85
pdf.cell(col1, 8, f"Họ tên: {patient_info.get('name', 'N/A')}", 0, 0)
pdf.cell(col2, 8, f"Mã BN: {patient_info.get('id', 'N/A')}", 0, 1)
pdf.cell(col1, 8, f"Giới tính: {patient_info.get('gender', 'N/A')}", 0, 0)
pdf.cell(col2, 8, f"Tuổi: {patient_info.get('age', 'N/A')}", 0, 1)
pdf.ln(5)
pdf.line(10, pdf.get_y(), 200, pdf.get_y())
pdf.ln(5)
# Images Section
pdf.set_x(10)
pdf.set_font(font_main, 'B', 11)
pdf.cell(0, 10, 'II. HÌNH ẢNH SIÊU ÂM', 0, 1, 'L')
y_before_images = pdf.get_y()
img_w = 90
margin = 5
# Process Images
has_orig = images_base64.get('original')
has_seg = images_base64.get('segmented')
max_img_h = 0
if has_orig:
try:
orig_stream = get_clean_image_stream(has_orig)
if orig_stream:
with Image.open(orig_stream) as img:
w, h = img.size
img_h = (img_w * h) / w
max_img_h = max(max_img_h, img_h)
orig_stream.seek(0)
pdf.image(orig_stream, x=10, y=y_before_images, w=img_w, type='PNG')
# Label
pdf.set_xy(10, y_before_images + img_h + 2)
pdf.set_font(font_main, 'I', 9)
pdf.cell(img_w, 5, 'Hình 1: Ảnh gốc / Tăng cường', 0, 0, 'C')
except Exception as e:
print(f"Error processing original image: {e}")
if has_seg:
try:
seg_stream = get_clean_image_stream(has_seg)
if seg_stream:
with Image.open(seg_stream) as img:
w, h = img.size
img_h = (img_w * h) / w
max_img_h = max(max_img_h, img_h)
seg_stream.seek(0)
pdf.image(seg_stream, x=110, y=y_before_images, w=img_w, type='PNG')
# Label
pdf.set_xy(110, y_before_images + img_h + 2)
pdf.set_font(font_main, 'I', 9)
pdf.cell(img_w, 5, 'Hình 2: Ảnh phân đoạn AI', 0, 1, 'C')
except Exception as e:
print(f"Error processing segmented image: {e}")
# Reset Y to after images
pdf.set_y(y_before_images + max_img_h + 10)
pdf.set_x(10)
# AI Results
pdf.set_font(font_main, 'B', 11)
pdf.cell(0, 10, 'III. KẾT QUẢ PHÂN TÍCH TỰ ĐỘNG (AI)', 0, 1, 'L')
pdf.set_font(font_main, '', 10)
angle = analysis_result.get('angle', {})
pdf.multi_cell(185, 6, f"• Góc chụp dự đoán: {angle.get('class', 'N/A')} (Độ tin cậy: {angle.get('confidence', 'N/A')}%)")
if 'inflammation' in analysis_result:
infl = analysis_result['inflammation']
status = "Có khả năng viêm / Theo dõi viêm" if infl.get('detected') else "Không thấy dấu hiệu viêm rõ rệt"
pdf.set_x(10)
pdf.multi_cell(185, 6, f"• Tình trạng viêm: {status} (Độ tin cậy: {infl.get('confidence', 'N/A')}%)")
if 'measurement' in analysis_result:
m = analysis_result['measurement']
pdf.set_x(10)
pdf.multi_cell(185, 6, f"• Đo đạc: Độ dày dịch & màng hoạt dịch đạt mức {m.get('thickness_mm', 'N/A')} mm")
if 'severity' in analysis_result:
s = analysis_result['severity']
pdf.set_x(10)
pdf.multi_cell(185, 6, f"• Mức độ viêm: {s.get('severity', 'N/A')}")
pdf.set_x(10)
pdf.set_font(font_main, 'I', 10)
pdf.multi_cell(185, 6, f" Chi tiết: {s.get('description', 'N/A')}")
pdf.set_font(font_main, '', 10)
pdf.ln(5)
pdf.set_x(10)
# Doctor Diagnosis
pdf.set_font(font_main, 'B', 11)
pdf.cell(0, 10, 'IV. CHẨN ĐOÁN VÀ KẾT LUẬN CỦA BÁC SĨ', 0, 1, 'L')
pdf.set_font(font_main, '', 11)
diagnosis = patient_info.get('diagnosis', 'Ghi chú chẩn đoán trống.')
pdf.set_x(10)
pdf.multi_cell(185, 7, diagnosis)
pdf.ln(15)
# Signature
current_date = datetime.now()
date_str = f"Ngày {current_date.day} tháng {current_date.month} năm {current_date.year}"
pdf.set_font(font_main, 'I', 11)
pdf.cell(0, 8, date_str, 0, 1, 'R')
pdf.set_font(font_main, 'B', 11)
pdf.cell(0, 8, 'BÁC SĨ CHẨN ĐOÁN', 0, 1, 'R')
pdf.ln(15)
pdf.set_font(font_main, '', 10)
pdf.cell(0, 8, '(Ký và ghi rõ họ tên)', 0, 1, 'R')
return pdf.output()

View File

@@ -0,0 +1,681 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1600px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 { font-size: 2rem; margin-bottom: 10px; }
.main-content {
display: grid;
grid-template-columns: 300px 1fr 1fr;
gap: 25px;
padding: 30px;
min-height: 600px;
}
.models-panel {
background: #f8f9fa;
padding: 20px;
border-radius: 15px;
height: fit-content;
}
.models-panel h3 {
margin-bottom: 15px;
color: #2c3e50;
font-size: 1.1rem;
}
.model-section {
background: white;
padding: 15px;
margin-bottom: 15px;
border-radius: 10px;
border-left: 4px solid #3498db;
}
.model-section h4 {
font-size: 0.9rem;
color: #2c3e50;
margin-bottom: 10px;
font-weight: 600;
}
.model-section select {
width: 100%;
padding: 8px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
.model-section select:focus {
outline: none;
border-color: #3498db;
}
.model-section small {
display: block;
margin-top: 6px;
color: #7f8c8d;
font-size: 0.75rem;
line-height: 1.3;
}
.upload-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.panel-title {
font-size: 1.3rem;
color: #2c3e50;
font-weight: 600;
margin-bottom: 10px;
}
.upload-area {
border: 3px dashed #bdc3c7;
border-radius: 15px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 250px;
}
.upload-area:hover {
border-color: #3498db;
background: #f8f9fa;
}
.upload-icon { font-size: 3rem; margin-bottom: 15px; }
.upload-btn {
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
padding: 12px 30px;
border: none;
border-radius: 25px;
font-size: 1rem;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.upload-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
}
.image-preview-box {
background: #f8f9fa;
border-radius: 15px;
padding: 15px;
margin-top: 15px;
}
.image-preview-box h3 {
margin-bottom: 12px;
color: #2c3e50;
font-size: 1.05rem;
font-weight: 600;
}
.preview-image {
width: 100%;
border-radius: 10px;
display: block;
}
.results-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.angle-result-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 15px;
text-align: center;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.angle-result-card h3 {
font-size: 0.9rem;
margin-bottom: 10px;
opacity: 0.9;
}
.angle-value {
font-size: 2rem;
font-weight: 900;
margin: 10px 0;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.angle-confidence {
font-size: 0.85rem;
opacity: 0.8;
}
.result-image-box {
background: #f8f9fa;
border-radius: 15px;
padding: 15px;
}
.result-image-box h3 {
margin-bottom: 12px;
color: #2c3e50;
font-size: 1.05rem;
font-weight: 600;
}
.result-image-box img {
width: 100%;
border-radius: 10px;
}
.color-legend {
background: white;
padding: 12px;
border-radius: 8px;
margin-top: 12px;
}
.color-legend h4 {
font-size: 0.9rem;
color: #2c3e50;
margin-bottom: 8px;
}
.legend-items {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #ddd;
}
.legend-highlight {
border: 2px solid #fff;
box-shadow: 0 0 0 2px #333;
}
.results-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.result-card {
background: #f8f9fa;
border-radius: 12px;
padding: 15px;
border-left: 5px solid #3498db;
}
.result-card.full-width {
grid-column: 1 / -1;
}
.result-card h4 {
margin-bottom: 10px;
color: #2c3e50;
font-size: 1rem;
}
.result-value {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
}
/* Patient Form Styles */
.patient-panel {
background: #f8f9fa;
padding: 20px;
border-radius: 15px;
margin-bottom: 20px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-top: 15px;
}
.full-width {
grid-column: 1 / -1;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-group label {
font-size: 0.85rem;
font-weight: 600;
color: #34495e;
}
.form-group label span {
color: #e74c3c;
}
.form-group input, .form-group select, .form-group textarea {
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 0.9rem;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none;
border-color: #3498db;
}
.save-section {
margin-top: 25px;
padding-top: 20px;
border-top: 2px solid #eee;
display: flex;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
.save-btn {
background: linear-gradient(135deg, #2ecc71, #27ae60);
color: white;
padding: 14px 40px;
border: none;
border-radius: 30px;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 10px;
}
.save-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(46, 204, 113, 0.4);
}
.save-btn:disabled {
background: #bdc3c7;
cursor: not-allowed;
opacity: 0.7;
}
.save-btn .icon { font-size: 1.2rem; }
.export-btn {
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
padding: 14px 40px;
border: none;
border-radius: 30px;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 10px;
}
.export-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
}
.export-btn:disabled {
background: #bdc3c7;
cursor: not-allowed;
opacity: 0.7;
}
.export-btn .icon { font-size: 1.2rem; }
.confidence {
font-size: 0.85rem;
color: #7f8c8d;
margin-top: 5px;
}
.badge {
display: inline-block;
padding: 6px 14px;
border-radius: 15px;
font-weight: 600;
font-size: 0.9rem;
}
.badge-success { background: #d4edda; color: #155724; }
.badge-danger { background: #f8d7da; color: #721c24; }
.severity-card { border-left-color: #e74c3c; }
.severity-badge {
padding: 8px 16px;
border-radius: 20px;
color: white;
font-weight: 700;
text-transform: uppercase;
font-size: 0.9rem;
}
.severity-details {
margin-top: 10px;
font-size: 0.9rem;
color: #555;
line-height: 1.5;
}
.severity-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
.stat-box {
background: white;
padding: 10px;
border-radius: 8px;
text-align: center;
}
.stat-label {
font-size: 0.75rem;
color: #7f8c8d;
margin-bottom: 4px;
}
.stat-value {
font-size: 0.95rem;
font-weight: 700;
color: #2c3e50;
}
.loading {
text-align: center;
padding: 40px;
display: none;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 10px;
margin: 15px 0;
display: none;
}
#fileInput { display: none; }
.no-results-message {
text-align: center;
color: #7f8c8d;
padding: 60px 20px;
font-size: 1rem;
}
.info-badge {
background: #e3f2fd;
color: #1976d2;
padding: 8px 12px;
border-radius: 8px;
font-size: 0.8rem;
margin-top: 8px;
display: block;
line-height: 1.4;
}
.measurement-main {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
text-align: center;
margin-bottom: 12px;
}
.measurement-value {
font-size: 2.5rem;
font-weight: 900;
margin: 10px 0;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.measurement-label {
font-size: 0.9rem;
opacity: 0.9;
}
.measurement-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.detail-box {
background: white;
padding: 12px;
border-radius: 8px;
text-align: center;
}
.detail-label {
font-size: 0.75rem;
color: #7f8c8d;
margin-bottom: 4px;
}
.detail-value {
font-size: 1.1rem;
font-weight: 700;
color: #2c3e50;
}
/* Modal Styles - Expanded */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(12px);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
padding: 20px;
}
.modal-overlay.active {
display: flex;
opacity: 1;
}
.modal-content {
background: white;
width: 100%;
max-width: 1000px;
max-height: 95vh;
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3);
overflow-y: auto;
transform: scale(0.9) translateY(30px);
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
display: flex;
flex-direction: column;
}
.modal-overlay.active .modal-content {
transform: scale(1) translateY(0);
}
.modal-header {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
color: white;
padding: 20px 30px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.modal-header h2 {
font-size: 1.4rem;
font-weight: 800;
}
.close-modal {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 1.5rem;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.close-modal:hover {
background: rgba(255, 255, 255, 0.3);
transform: rotate(90deg);
}
.modal-body {
padding: 30px;
display: flex;
flex-direction: column;
gap: 25px;
}
/* Image Grid in Modal */
.modal-images-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.modal-img-box {
background: #f1f3f5;
padding: 10px;
border-radius: 12px;
text-align: center;
}
.img-label {
display: block;
font-size: 0.85rem;
font-weight: 700;
color: #495057;
margin-bottom: 8px;
text-transform: uppercase;
}
.modal-img-box img {
width: 100%;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
/* Details Grid in Modal */
.modal-details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.modal-detail-card {
background: #f8f9fa;
padding: 15px;
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
border: 1px solid #e9ecef;
}
.modal-detail-card.highlight {
background: linear-gradient(135deg, #f0f7ff 0%, #e3f2fd 100%);
border-color: #bbdefb;
}
.modal-detail-card .label {
font-size: 0.8rem;
color: #6c757d;
font-weight: 600;
margin-bottom: 5px;
}
.modal-detail-card .value {
font-size: 1.25rem;
font-weight: 800;
color: #2c3e50;
}
.modal-legend {
background: white;
padding: 20px;
border-radius: 16px;
border: 1px solid #dee2e6;
}
.modal-legend h4 {
margin-bottom: 12px;
font-size: 0.95rem;
}
.modal-advice {
background: #fff9db;
padding: 20px;
border-radius: 16px;
border-left: 5px solid #fcc419;
color: #856404;
font-size: 1rem;
line-height: 1.6;
}
.modal-footer {
padding: 0 30px 40px;
display: flex;
justify-content: center;
}
.modal-btn-primary {
background: linear-gradient(135deg, #228be6, #1c7ed6);
color: white;
border: none;
padding: 16px 60px;
border-radius: 35px;
font-size: 1.2rem;
font-weight: 800;
cursor: pointer;
box-shadow: 0 10px 25px -5px rgba(34, 139, 230, 0.4);
transition: all 0.3s;
}
.modal-btn-primary:hover {
transform: translateY(-4px);
box-shadow: 0 15px 30px -5px rgba(34, 139, 230, 0.5);
}
/* Responsive Modal */
@media (max-width: 768px) {
.modal-images-grid {
grid-template-columns: 1fr;
}
.modal-content {
max-height: 100vh;
border-radius: 0;
}
.modal-overlay {
padding: 0;
}
}

View File

@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Phân tích ảnh siêu âm khớp gối</title>
<!-- Link CSS -->
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="container">
<div class="header">
<h1>🏥 PHÂN TÍCH ẢNH SIÊU ÂM KHỚP GỐI</h1>
<p>Hệ thống phân tích tự động với AI - Pipeline: Góc → Viêm → Phân đoạn → Đo đạc</p>
</div>
<div class="main-content">
<div class="models-panel">
<h3>⚙️ Chọn mô hình</h3>
<div class="model-section">
<h4>1⃣ Phân loại góc chụp</h4>
<select id="angleModel">
<option value="convnext">ConvNeXt-Tiny (100%)</option>
<option value="densenet">DenseNet121 (98%)</option>
<option value="resnet50">ResNet50 (98%)</option>
<option value="efficientnet_b2">EfficientNet-B2 (97.78%)</option>
<option value="swin">Swin Transformer (97.78%)</option>
</select>
<small>Luôn chạy đầu tiên cho mọi ảnh</small>
</div>
<div class="model-section">
<h4>2⃣ Phát hiện viêm</h4>
<select id="inflammationModel">
<option value="efficientnet_b0">EfficientNet-B0</option>
</select>
<small>Chỉ với góc post-trans và suprapat-long</small>
</div>
<div class="model-section">
<h4>3⃣ Phân đoạn Suprapat</h4>
<select id="segmentModelSup">
<option value="deeplabv3">DeepLabV3-ResNet50 (91.67%)</option>
<option value="efficientfeedback">EfficientFeedback (86%)</option>
<option value="unet3plus">UNet3+ Attention (80%)</option>
<option value="unet_resnet101">UNet-ResNet101 (73.62%)</option>
</select>
<small>Dùng cho góc suprapat-long</small>
</div>
<div class="model-section">
<h4>4⃣ Phân đoạn Post-trans</h4>
<select id="segmentModelPost">
<option value="deeplabv3_resnet101">DeepLabV3-ResNet101</option>
</select>
<small>Dùng cho góc post-trans</small>
</div>
<div class="info-badge">
💡 Đo độ dày (chỉ SUP): Quét vùng 1/3 giữa, tìm đoạn liên tục dài nhất
</div>
</div>
<div class="upload-panel">
<div id="uploadSection">
<h2 class="panel-title">📤 Tải ảnh lên</h2>
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📷</div>
<div style="margin-bottom: 15px; color: #7f8c8d;">
Kéo thả ảnh vào đây hoặc
</div>
<button class="upload-btn" onclick="document.getElementById('fileInput').click()">
Chọn ảnh
</button>
<input type="file" id="fileInput" accept="image/*">
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Đang phân tích ảnh...</p>
</div>
<div class="error" id="error"></div>
</div>
<div id="originalImageContainer" style="display: none;">
<h2 class="panel-title">📷 Ảnh gốc</h2>
<div class="image-preview-box">
<img id="originalImage" class="preview-image">
</div>
</div>
</div>
<div class="results-panel">
<h2 class="panel-title">📊 Kết quả phân tích</h2>
<div id="resultsContent">
<!-- Patient Information Form -->
<div class="patient-panel" id="patientInfoPanel" style="display: none;">
<h3 class="panel-title" style="font-size: 1.1rem;">👤 Thông tin bệnh nhân</h3>
<div class="form-grid">
<div class="form-group">
<label for="patientName">Họ và tên <span>*</span></label>
<input type="text" id="patientName" placeholder="Nhập tên bệnh nhân...">
</div>
<div class="form-group">
<label for="patientId">Mã bệnh nhân <span>*</span></label>
<input type="text" id="patientId" placeholder="VD: BN001...">
</div>
<div class="form-group">
<label for="patientGender">Giới tính</label>
<select id="patientGender">
<option value="Nam">Nam</option>
<option value="Nữ">Nữ</option>
<option value="Khác">Khác</option>
</select>
</div>
<div class="form-group">
<label for="patientAge">Tuổi</label>
<input type="number" id="patientAge" placeholder="Nhập tuổi...">
</div>
<div class="form-group full-width">
<label for="doctorDiagnosis">Chẩn đoán của bác sĩ</label>
<textarea id="doctorDiagnosis" rows="3" placeholder="Nhập chẩn đoán hoặc ghi chú thêm..."></textarea>
</div>
</div>
<div class="save-section">
<button id="saveDataBtn" class="save-btn" disabled>
<span class="icon">💾</span> Lưu dữ liệu
</button>
<button id="exportPdfBtn" class="export-btn" disabled>
<span class="icon">📄</span> Xuất phiếu khám (PDF)
</button>
<div id="saveStatus" style="margin-top: 10px; font-size: 0.85rem;"></div>
</div>
</div>
<div id="angleResultCard" style="display: none;">
<div class="angle-result-card">
<h3>🎯 GÓC CHỤP</h3>
<div class="angle-value" id="angleValue">-</div>
<div class="angle-confidence" id="angleConfidenceText">-</div>
</div>
</div>
<div id="segmentedImageContainer" style="display: none;">
<div class="result-image-box">
<h3>🎨 Ảnh phân đoạn</h3>
<img id="segmentedImage">
<div class="color-legend" id="colorLegend" style="display: none;">
<h4>📋 Chú thích màu sắc:</h4>
<div class="legend-items" id="legendItems"></div>
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #e0e0e0; font-size: 0.8rem; color: #666;">
⚠️ <strong>Viền trắng dày</strong>: Vùng viêm (dịch khớp & màng hoạt dịch)<br>
<span id="measurementNote" style="display: none;">📏 <strong>Đường đỏ</strong>: Đo độ dày tại vùng 1/3 giữa</span>
</div>
</div>
</div>
</div>
<div id="resultsGrid" style="display: none;">
<div class="results-grid">
<div class="result-card full-width" id="inflammationCard" style="display: none;">
<h4>🔬 Tình trạng viêm</h4>
<div id="inflammationResult"></div>
<div class="confidence" id="inflammationConfidence">-</div>
</div>
<div class="result-card full-width" id="measurementCard" style="display: none; border-left-color: #2ecc71; padding: 0; overflow: hidden;">
<div style="padding: 15px;">
<h4>📏 Đo độ dày (Dịch + Màng hoạt dịch)</h4>
</div>
<div class="measurement-main">
<div class="measurement-label">Độ dày tối đa</div>
<div class="measurement-value" id="thicknessMm">- mm</div>
<div class="measurement-label">
(<span id="thicknessPx">-</span> pixels)
</div>
</div>
<div style="padding: 0 15px 15px 15px;">
<div class="measurement-details">
<div class="detail-box">
<div class="detail-label">Vị trí X</div>
<div class="detail-value" id="measurementLocationX">-</div>
</div>
<div class="detail-box">
<div class="detail-label">Phạm vi Y</div>
<div class="detail-value" id="measurementRangeY">-</div>
</div>
</div>
<div style="margin-top: 8px; font-size: 0.75rem; color: #7f8c8d; text-align: center;">
Đo tại vùng 1/3 giữa bounding box - Tìm đoạn liên tục dài nhất
</div>
</div>
</div>
<div class="result-card severity-card full-width" id="severityCard" style="display: none;">
<h4>📈 Mức độ viêm</h4>
<div>
<span class="severity-badge" id="severityBadge">-</span>
</div>
<div class="severity-details" id="severityDescription">-</div>
<div class="severity-stats">
<div class="stat-box">
<div class="stat-label">Dịch khớp (Effusion)</div>
<div class="stat-value" id="effusionStat">-</div>
</div>
<div class="stat-box">
<div class="stat-label">Màng hoạt dịch (Synovium)</div>
<div class="stat-value" id="synoviumStat">-</div>
</div>
</div>
</div>
</div>
</div>
<div id="noResults" class="no-results-message">
Chưa có kết quả phân tích
</div>
</div>
</div>
</div>
</div>
<!-- Popup Kết quả chẩn đoán -->
<div id="resultModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>🩺 KẾT QUẢ CHẨN ĐOÁN AI</h2>
<button class="close-modal" id="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="modal-images-grid">
<div class="modal-img-box">
<span class="img-label">Ảnh tăng cường</span>
<img id="modalImgEnhanced" src="" alt="Enhanced">
</div>
<div class="modal-img-box" id="modalImgSegmentedBox">
<span class="img-label">Ảnh phân đoạn</span>
<img id="modalImgSegmented" src="" alt="Segmented">
</div>
</div>
<div class="modal-details-grid">
<div class="modal-detail-card">
<span class="label">📐 Góc chụp</span>
<span class="value" id="modalAngle">-</span>
</div>
<div class="modal-detail-card" id="modalInflammationRow">
<span class="label">🔥 Tình trạng</span>
<span class="value" id="modalInflammation">-</span>
</div>
<div class="modal-detail-card highlight" id="modalMeasurementRow" style="display: none;">
<span class="label">📏 Độ dày</span>
<span class="value" id="modalThickness">- mm</span>
</div>
<div class="modal-detail-card highlight" id="modalSeverityRow" style="display: none;">
<span class="label">📈 Mức độ</span>
<span class="severity-badge" id="modalSeverity">-</span>
</div>
</div>
<div id="modalLegendContainer" class="modal-legend" style="display: none;">
<h4>📋 Chú thích màu sắc:</h4>
<div id="modalLegendItems" class="legend-items"></div>
</div>
<div class="modal-advice">
<p id="modalAdviceText">Hệ thống AI đã hoàn tất phân tích. Vui lòng đối chiếu hình ảnh và kết quả đo đạc phía trên.</p>
</div>
</div>
<div class="modal-footer">
<button class="modal-btn-primary" id="modalViewDetail">Xem chi tiết</button>
</div>
</div>
</div>
<!-- Link JavaScript -->
<script src="js/script.js?v=1.1"></script>
</body>
</html>

View File

@@ -0,0 +1,440 @@
const API_BASE = 'http://127.0.0.1:8000';
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
let currentResult = null;
let originalImageBase64 = null;
const ANGLE_NAMES = {
'med-lat': 'Med-Lat Long',
'post-trans': 'Post Trans',
'sup-trans-flex': 'Suprapat Trans Flex',
'sup-up-long': 'Suprapat Up Long'
};
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#3498db';
uploadArea.style.background = '#f8f9fa';
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.style.borderColor = '#bdc3c7';
uploadArea.style.background = 'transparent';
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.style.borderColor = '#bdc3c7';
uploadArea.style.background = 'transparent';
const files = e.dataTransfer.files;
if (files.length > 0) handleFile(files[0]);
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) handleFile(e.target.files[0]);
});
function handleFile(file) {
if (!file.type.startsWith('image/')) {
showError('Vui lòng chọn file ảnh hợp lệ');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
originalImageBase64 = e.target.result;
document.getElementById('originalImage').src = originalImageBase64;
document.getElementById('originalImageContainer').style.display = 'block';
};
reader.readAsDataURL(file);
uploadAndAnalyze(file);
}
async function uploadAndAnalyze(file) {
showLoading(true);
hideError();
hideResults();
const angleModelValue = document.getElementById('angleModel').value;
const inflammationModelValue = document.getElementById('inflammationModel').value;
const segmentModelSupValue = document.getElementById('segmentModelSup').value;
const segmentModelPostValue = document.getElementById('segmentModelPost').value;
console.log('🎯 Selected models:', {
angle: angleModelValue,
inflammation: inflammationModelValue,
seg_sup: segmentModelSupValue,
seg_post: segmentModelPostValue
});
const formData = new FormData();
formData.append('image', file);
try {
const url = `${API_BASE}/api/analyze?angle_model=${angleModelValue}&inflammation_model=${inflammationModelValue}&segment_model_sup=${segmentModelSupValue}&segment_model_post=${segmentModelPostValue}`;
console.log('🌐 Request URL:', url);
const response = await fetch(url, {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Lỗi phân tích');
const result = await response.json();
console.log('✅ TRẢ VỀ TỪ API:', result);
if (result.success) {
currentResult = result;
displayResults(result);
}
else showError('Phân tích thất bại');
} catch (error) {
console.error('❌ Error:', error);
showError(`Lỗi: ${error.message}`);
} finally {
showLoading(false);
}
}
function displayResults(result) {
try {
console.log('--- ĐANG HIỂN THỊ KẾT QUẢ ---');
document.getElementById('noResults').style.display = 'none';
// Cập nhật ảnh gốc thành ảnh đã tăng cường tương phản (CLAHE)
if (result.images && result.images.enhanced) {
document.getElementById('originalImage').src = result.images.enhanced;
}
document.getElementById('angleResultCard').style.display = 'block';
document.getElementById('angleValue').textContent = ANGLE_NAMES[result.angle.class] || result.angle.class;
document.getElementById('angleConfidenceText').textContent = `Độ tin cậy: ${result.angle.confidence}%`;
if (result.inflammation) {
console.log('✅ Hiển thị Inflammation');
document.getElementById('resultsGrid').style.display = 'block';
document.getElementById('inflammationCard').style.display = 'block';
const inflDiv = document.getElementById('inflammationResult');
if (result.inflammation.detected) {
inflDiv.innerHTML = '<span class="badge badge-danger">CÓ KHẢ NĂNG VIÊM / THEO DÕI VIÊM</span>';
} else {
inflDiv.innerHTML = '<span class="badge badge-success">KHÔNG VIÊM</span>';
}
document.getElementById('inflammationConfidence').textContent = `Độ tin cậy: ${result.inflammation.confidence}%`;
}
if (result.segmentation && result.segmentation.performed) {
console.log('✅ Hiển thị Segmentation & Overlay');
document.getElementById('segmentedImageContainer').style.display = 'block';
document.getElementById('segmentedImage').src = result.images.segmented;
if (result.segmentation.color_legend) {
displayColorLegend(result.segmentation.color_legend, result.segmentation.angle_type);
}
if (result.segmentation.angle_type === 'sup') {
document.getElementById('measurementNote').style.display = 'inline';
} else {
document.getElementById('measurementNote').style.display = 'none';
}
}
if (result.measurement) {
console.log('✅ Hiển thị Đo đạc (Measurement)');
document.getElementById('measurementCard').style.display = 'block';
document.getElementById('thicknessMm').textContent = `${result.measurement.thickness_mm} mm`;
document.getElementById('thicknessPx').textContent = `${result.measurement.thickness_px}`;
document.getElementById('measurementLocationX').textContent = result.measurement.location_x;
document.getElementById('measurementRangeY').textContent =
`${result.measurement.y_start} - ${result.measurement.y_end}`;
}
if (result.severity) {
console.log('✅ Hiển thị Mức độ (Severity)');
document.getElementById('severityCard').style.display = 'block';
const badge = document.getElementById('severityBadge');
badge.textContent = result.severity.severity;
badge.style.background = result.severity.color;
document.getElementById('severityDescription').textContent = result.severity.description;
document.getElementById('effusionStat').textContent =
`${result.severity.effusion.ratio}% (${result.severity.effusion.thickness}px)`;
document.getElementById('synoviumStat').textContent = `${result.severity.synovium.ratio}%`;
}
// Hiện bảng nhập liệu bệnh nhân
document.getElementById('patientInfoPanel').style.display = 'block';
updateSaveButtonState();
// HIỂN THỊ POPUP KẾT QUẢ
showResultPopup(result);
} catch (err) {
console.error('❌ LỖI TRONG displayResults:', err);
showError(`Lỗi hiển thị: ${err.message}`);
}
}
// Kiểm tra tính hợp lệ của form
function updateSaveButtonState() {
const name = document.getElementById('patientName').value.trim();
const id = document.getElementById('patientId').value.trim();
const btn = document.getElementById('saveDataBtn');
const exportBtn = document.getElementById('exportPdfBtn');
const isValid = !!(currentResult && name && id);
btn.disabled = !isValid;
exportBtn.disabled = !isValid;
}
// Lắng nghe thay đổi trên form
['patientName', 'patientId', 'patientGender', 'patientAge', 'doctorDiagnosis'].forEach(id => {
document.getElementById(id).addEventListener('input', updateSaveButtonState);
});
// Hàm lưu dữ liệu
document.getElementById('saveDataBtn').addEventListener('click', async () => {
if (!currentResult) return;
const saveBtn = document.getElementById('saveDataBtn');
const statusDiv = document.getElementById('saveStatus');
const payload = {
patient_info: {
name: document.getElementById('patientName').value,
id: document.getElementById('patientId').value,
gender: document.getElementById('patientGender').value,
age: document.getElementById('patientAge').value,
diagnosis: document.getElementById('doctorDiagnosis').value
},
analysis_result: currentResult,
images: {
original: originalImageBase64,
segmented: currentResult.images.segmented
}
};
try {
saveBtn.disabled = true;
statusDiv.innerHTML = '<span style="color: blue;">⌛ Đang lưu...</span>';
const response = await fetch(`${API_BASE}/api/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const resData = await response.json();
if (resData.success) {
statusDiv.innerHTML = `<span style="color: green;">✅ Đã lưu vào thư mục: <strong>${resData.folder}</strong></span>`;
} else {
throw new Error(resData.detail || 'Lỗi không xác định');
}
} catch (error) {
console.error('❌ Save error:', error);
statusDiv.innerHTML = `<span style="color: red;">❌ Lỗi: ${error.message}</span>`;
} finally {
saveBtn.disabled = false;
}
});
document.getElementById('exportPdfBtn').addEventListener('click', async () => {
if (!currentResult) return;
const exportBtn = document.getElementById('exportPdfBtn');
const statusDiv = document.getElementById('saveStatus');
const payload = {
patient_info: {
name: document.getElementById('patientName').value,
id: document.getElementById('patientId').value,
gender: document.getElementById('patientGender').value,
age: document.getElementById('patientAge').value,
diagnosis: document.getElementById('doctorDiagnosis').value
},
analysis_result: currentResult,
images: {
original: originalImageBase64,
segmented: currentResult.images.segmented
}
};
try {
exportBtn.disabled = true;
statusDiv.innerHTML = '<span style="color: blue;">⌛ Đang khởi tạo PDF...</span>';
const response = await fetch(`${API_BASE}/api/export-pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Lỗi từ server khi tạo PDF');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Phieu_Kham_${payload.patient_info.id || 'BN'}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
statusDiv.innerHTML = `<span style="color: green;">✅ Đã xuất file PDF!</span>`;
} catch (error) {
console.error('❌ Export error:', error);
statusDiv.innerHTML = `<span style="color: red;">❌ Lỗi: ${error.message}</span>`;
} finally {
exportBtn.disabled = false;
}
});
function displayColorLegend(colorLegend, angleType) {
const legendContainer = document.getElementById('legendItems');
legendContainer.innerHTML = '';
colorLegend.forEach(item => {
const isHighlight = item.key === 'effusion' || item.key === 'synovium';
const legendItem = document.createElement('div');
legendItem.className = 'legend-item';
legendItem.innerHTML = `
<div class="legend-color ${isHighlight ? 'legend-highlight' : ''}"
style="background-color: rgb(${item.color.join(',')})"></div>
<span>${item.name}</span>
`;
legendContainer.appendChild(legendItem);
});
document.getElementById('colorLegend').style.display = 'block';
}
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'block' : 'none';
}
function showError(msg) {
const errorDiv = document.getElementById('error');
errorDiv.textContent = msg;
errorDiv.style.display = 'block';
}
function hideError() {
document.getElementById('error').style.display = 'none';
}
function hideResults() {
document.getElementById('angleResultCard').style.display = 'none';
document.getElementById('resultsGrid').style.display = 'none';
document.getElementById('segmentedImageContainer').style.display = 'none';
document.getElementById('inflammationCard').style.display = 'none';
document.getElementById('measurementCard').style.display = 'none';
document.getElementById('severityCard').style.display = 'none';
document.getElementById('noResults').style.display = 'block';
}
function showResultPopup(result) {
const modal = document.getElementById('resultModal');
// 1. Hình ảnh
if (result.images) {
document.getElementById('modalImgEnhanced').src = result.images.enhanced || '';
const segImg = document.getElementById('modalImgSegmented');
const segBox = document.getElementById('modalImgSegmentedBox');
if (result.images.segmented) {
segImg.src = result.images.segmented;
segBox.style.display = 'block';
} else {
segBox.style.display = 'none';
}
}
// 2. Chi tiết kết quả
document.getElementById('modalAngle').textContent = ANGLE_NAMES[result.angle.class] || result.angle.class;
const inflEl = document.getElementById('modalInflammation');
if (result.inflammation) {
document.getElementById('modalInflammationRow').style.display = 'flex';
if (result.inflammation.detected) {
inflEl.innerHTML = '<span class="badge badge-danger">CÓ VIÊM/THEO DÕI</span>';
} else {
inflEl.innerHTML = '<span class="badge badge-success">KHÔNG VIÊM</span>';
}
} else {
document.getElementById('modalInflammationRow').style.display = 'none';
}
if (result.measurement) {
document.getElementById('modalMeasurementRow').style.display = 'flex';
document.getElementById('modalThickness').textContent = `${result.measurement.thickness_mm} mm`;
} else {
document.getElementById('modalMeasurementRow').style.display = 'none';
}
if (result.severity) {
document.getElementById('modalSeverityRow').style.display = 'flex';
const badge = document.getElementById('modalSeverity');
badge.textContent = result.severity.severity;
badge.style.background = result.severity.color;
} else {
document.getElementById('modalSeverityRow').style.display = 'none';
}
// 3. Chú thích màu sắc (Legend) trong modal
const legendContainer = document.getElementById('modalLegendContainer');
if (result.segmentation && result.segmentation.performed && result.segmentation.color_legend) {
legendContainer.style.display = 'block';
renderModalLegend(result.segmentation.color_legend);
} else {
legendContainer.style.display = 'none';
}
// Hiển thị modal
modal.classList.add('active');
}
function renderModalLegend(colorLegend) {
const itemsContainer = document.getElementById('modalLegendItems');
itemsContainer.innerHTML = '';
colorLegend.forEach(item => {
const isHighlight = item.key === 'effusion' || item.key === 'synovium';
const legendItem = document.createElement('div');
legendItem.className = 'legend-item';
legendItem.innerHTML = `
<div class="legend-color ${isHighlight ? 'legend-highlight' : ''}"
style="background-color: rgb(${item.color.join(',')})"></div>
<span>${item.name}</span>
`;
itemsContainer.appendChild(legendItem);
});
}
function closeResultPopup() {
document.getElementById('resultModal').classList.remove('active');
}
// Event Listeners for Modal
document.getElementById('closeModal').addEventListener('click', closeResultPopup);
document.getElementById('modalViewDetail').addEventListener('click', closeResultPopup);
// Click outside to close
document.getElementById('resultModal').addEventListener('click', (e) => {
if (e.target.id === 'resultModal') closeResultPopup();
});
// Health check
window.addEventListener('load', async () => {
try {
const response = await fetch(`${API_BASE}/api/health`);
const health = await response.json();
console.log('✅ Server ready:', health);
} catch (error) {
showError('Không thể kết nối server. Vui lòng khởi động FastAPI backend.');
}
});