@@ -345,9 +347,99 @@ document.getElementById('exportXlsx')?.addEventListener('click', async () => {
document.getElementById('clearResults')?.addEventListener('click', () => {
results = [];
resultsSection.classList.add('hidden');
+ detailSection.classList.add('hidden');
fileInput.value = '';
});
+// Close detail panel
+document.getElementById('closeDetail')?.addEventListener('click', () => {
+ detailSection.classList.add('hidden');
+});
+
+// View detailed analysis for a file
+async function viewDetail(result) {
+ detailSection.classList.remove('hidden');
+
+ // Populate basic info
+ const regionLabel = result.anatomical_region === 'spine' ? 'Позвоночник' :
+ result.anatomical_region === 'hip_left' ? 'Бедро левое' :
+ result.anatomical_region === 'hip_right' ? 'Бедро правое' :
+ result.anatomical_region === 'hip' ? 'Бедро' : result.anatomical_region || '—';
+
+ document.getElementById('detailBasic').innerHTML = `
+
Файл:${result.filename || '—'}
+
Регион:${regionLabel}
+
UID исследования:${(result.study_uid || '').substring(0, 20)}...
+ `;
+
+ // Populate quality details
+ const qualityClass = result.quality_class === 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' :
+ result.quality_class === 1 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' :
+ 'bg-gray-100 text-gray-800';
+
+ const violationType = result.violation_type || (result.quality_class === 1 ? 'quality_violation_detected' : 'correct');
+ const reason = result.reason || (result.quality_class === 0 ? 'Качество соответствует норме' : 'Нарушение качества');
+
+ document.getElementById('detailQuality').innerHTML = `
+
Класс:${result.quality_class === 0 ? 'OK' : 'Нарушение'}
+
Тип нарушения:${violationType}
+
Уверенность:${result.confidence ? (result.confidence * 100).toFixed(1) + '%' : '—'}
+
Вид:${result.view_quality || 'unknown'}
+ `;
+
+ // Populate metrics
+ const metrics = result.metrics || {};
+ const motion = metrics.motion || {};
+ const artifacts = metrics.artifacts || {};
+ const roi = metrics.roi_check || {};
+
+ document.getElementById('detailMetrics').innerHTML = `
+
+
Движение
+
+
+
${motion.motion_detected ? 'Обнаружено' : 'Нет'}
+
+ ${motion.severity ? `
Severity: ${motion.severity}
` : ''}
+
+
+
Артефакты
+
+
+
${artifacts.any_detected ? 'Обнаружены' : 'Нет'}
+
+ ${artifacts.metal_detected ? `
Металл: да
` : ''}
+ ${artifacts.implant_detected ? `
Имплантат: да
` : ''}
+
+
+
ROI
+
+
+
${roi.valid === false ? 'Проблема' : 'OK'}
+
+
+ `;
+
+ // Populate reason
+ const reasonClass = result.quality_class === 0 ? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200' :
+ 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200';
+ document.getElementById('detailReason').innerHTML = `
+
${reason}
+ `;
+
+ // Scroll to detail
+ detailSection.scrollIntoView({ behavior: 'smooth' });
+}
+
+// View detail by table row index
+function viewDetailByIndex(row) {
+ const idx = parseInt(row.getAttribute('data-index'));
+ const filtered = getFilteredResults();
+ if (filtered[idx]) {
+ viewDetail(filtered[idx]);
+ }
+}
+
// Show error
function showError(message) {
errorMessage.textContent = message;
diff --git a/src/main.py b/src/main.py
index 95c7d47..ba9103d 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1,10 +1,11 @@
"""
Main FastAPI application for DXA Quality Assessment
"""
-from fastapi import FastAPI, File, UploadFile, APIRouter
+from fastapi import FastAPI, File, UploadFile, APIRouter, Query
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from fastapi.responses import StreamingResponse
+from typing import Dict
import numpy as np
import pydicom
import pandas as pd
@@ -18,6 +19,8 @@ from starlette.responses import JSONResponse, FileResponse
from src.dxa.model import create_model
from src.dxa.inference import determine_anatomical_region
from src.utils.utils import get_device
+from src.quality.detailed_assessment import generate_quality_report
+from src.quality.quality_scorer import QualityScorer, convert_to_serializable
# Create app
app = FastAPI(
@@ -112,7 +115,10 @@ async def root():
"/docs",
"/api/v1/health",
"/api/v1/analyze",
- "/api/v1/batch"
+ "/api/v1/analyze/detailed",
+ "/api/v1/analyze/sr",
+ "/api/v1/batch",
+ "/api/v1/export"
]
}
@@ -180,6 +186,102 @@ async def analyze_dicom(file: UploadFile = File(...)):
)
+@app.post("/api/v1/analyze/detailed")
+async def analyze_dicom_detailed(
+ file: UploadFile = File(...),
+ include_visualization: bool = Query(False, description="Include base64 mask visualization")
+):
+ """
+ Detailed analysis of DICOM file with comprehensive quality assessment.
+
+ Returns:
+ - violation_type: Specific type of violation (correct, position_error, artifact_motion, etc.)
+ - reason: Human-readable explanation of why the image is non-compliant
+ - confidence_per_class: Probability for each class
+ - view_quality: Full or partial view assessment
+ - Detailed metrics for motion, artifacts, position, ROI
+ """
+ try:
+ # Load model
+ model = load_model()
+ if model is None:
+ return JSONResponse(
+ status_code=500,
+ content={"error": "Model not loaded"}
+ )
+
+ # Read file
+ dcm_bytes = await file.read()
+ img_tensor, ds = preprocess_dicom(dcm_bytes)
+
+ # Get original image for quality assessment
+ img_array = ds.pixel_array.astype(np.float32)
+ img_array = (img_array - img_array.min()) / (img_array.max() - img_array.min() + 1e-8)
+
+ # Create 3-channel version
+ if len(img_array.shape) == 2:
+ img_3ch = np.stack([img_array] * 3, axis=2)
+ else:
+ img_3ch = img_array
+
+ # Predict
+ img_tensor = img_tensor.to(device)
+ with torch.no_grad():
+ outputs = model.model(img_tensor)
+ probs = torch.softmax(outputs, dim=1)
+ pred = outputs.argmax(dim=1).item()
+ confidence = probs[0, pred].item()
+ all_probs = probs[0].cpu().numpy().tolist()
+
+ # Determine region
+ h, w = ds.pixel_array.shape
+ region = determine_anatomical_region(file.filename) if file.filename else ('hip' if h < 270 else 'spine')
+
+ # Generate segmentation (simple threshold-based for now)
+ # In production, use proper segmentation model
+ threshold = np.percentile(img_array, 90)
+ segmentation = (img_array > threshold).astype(np.uint8)
+
+ # Generate detailed quality report
+ quality_report = generate_quality_report(
+ image=img_3ch,
+ segmentation=segmentation,
+ region=region,
+ model_prediction=pred,
+ model_confidence=confidence
+ )
+
+ # Add UID info
+ quality_report["study_uid"] = getattr(ds, 'StudyInstanceUID', '')
+ quality_report["image_uid"] = getattr(ds, 'SOPInstanceUID', '')
+
+ # Add visualization if requested
+ if include_visualization:
+ # Create mask visualization
+ mask_vis = Image.fromarray((segmentation * 255).astype(np.uint8))
+ buffer = io.BytesIO()
+ mask_vis.save(buffer, format='PNG')
+ quality_report["mask"] = base64.b64encode(buffer.getvalue()).decode('utf-8')
+ else:
+ quality_report["mask"] = None
+
+ # Convert numpy types to Python types for JSON serialization
+ quality_report = convert_to_serializable(quality_report)
+
+ return quality_report
+
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": str(e),
+ "processing_status": f"Failure: {str(e)[:50]}"
+ }
+ )
+
+
@app.post("/api/v1/batch")
async def batch_analyze(files: list[UploadFile] = File(...)):
"""Batch analyze multiple DICOM files"""
@@ -301,6 +403,155 @@ async def export_results(files: list[UploadFile] = File(...)):
)
+@app.post("/api/v1/analyze/sr")
+async def analyze_dicom_sr(file: UploadFile = File(...)):
+ """
+ Generate DICOM SR (Structured Report) for the analysis result.
+
+ Returns a text representation of DICOM SR with standardized codes.
+ """
+ try:
+ # Use the detailed analysis
+ # Reuse the detailed analysis logic
+ model = load_model()
+ if model is None:
+ return JSONResponse(
+ status_code=500,
+ content={"error": "Model not loaded"}
+ )
+
+ # Read file
+ dcm_bytes = await file.read()
+ img_tensor, ds = preprocess_dicom(dcm_bytes)
+
+ # Get original image for quality assessment
+ img_array = ds.pixel_array.astype(np.float32)
+ img_array = (img_array - img_array.min()) / (img_array.max() - img_array.min() + 1e-8)
+
+ if len(img_array.shape) == 2:
+ img_3ch = np.stack([img_array] * 3, axis=2)
+ else:
+ img_3ch = img_array
+
+ # Predict
+ img_tensor = img_tensor.to(device)
+ with torch.no_grad():
+ outputs = model.model(img_tensor)
+ probs = torch.softmax(outputs, dim=1)
+ pred = outputs.argmax(dim=1).item()
+ confidence = probs[0, pred].item()
+
+ # Determine region
+ h, w = ds.pixel_array.shape
+ region = determine_anatomical_region(file.filename) if file.filename else ('hip' if h < 270 else 'spine')
+
+ # Generate segmentation
+ threshold = np.percentile(img_array, 90)
+ segmentation = (img_array > threshold).astype(np.uint8)
+
+ # Generate detailed quality report
+ quality_report = generate_quality_report(
+ image=img_3ch,
+ segmentation=segmentation,
+ region=region,
+ model_prediction=pred,
+ model_confidence=confidence
+ )
+
+ # Generate DICOM SR text representation
+ sr_content = generate_dicom_sr_text(
+ study_uid=getattr(ds, 'StudyInstanceUID', ''),
+ image_uid=getattr(ds, 'SOPInstanceUID', ''),
+ quality_report=quality_report
+ )
+
+ return {
+ "format": "DICOM SR (Text)",
+ "study_uid": getattr(ds, 'StudyInstanceUID', ''),
+ "image_uid": getattr(ds, 'SOPInstanceUID', ''),
+ "sr_content": sr_content
+ }
+
+ except Exception as e:
+ import traceback
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": str(e),
+ "processing_status": f"Failure: {str(e)[:50]}"
+ }
+ )
+
+
+def generate_dicom_sr_text(study_uid: str, image_uid: str, quality_report: Dict) -> str:
+ """Generate DICOM SR text representation"""
+
+ # Map violation types to DICOM codes (simplified)
+ violation_code_map = {
+ "correct": ("113001", "DXA Quality Assessment", "FINAL"),
+ "position_error": ("123456", "Positioning Error", "WARNING"),
+ "artifact_motion": ("234567", "Motion Artifact", "WARNING"),
+ "artifact_other": ("234568", "Other Artifact", "WARNING"),
+ "labeling_error": ("345678", "Labeling Error", "WARNING"),
+ "incomplete_view": ("456789", "Incomplete View", "WARNING"),
+ "roi_error": ("567890", "ROI Error", "WARNING"),
+ "rotation": ("678901", "Rotation Error", "WARNING")
+ }
+
+ violation_type = quality_report.get("violation_type", "correct")
+ code, label, completion = violation_code_map.get(violation_type, ("999999", "Unknown", "UNKNOWN"))
+
+ sr_lines = [
+ "DICOM Structured Report - DXA Quality Assessment",
+ "=" * 50,
+ f"Study Instance UID: {study_uid}",
+ f"Series Instance UID: {image_uid}",
+ "",
+ "Procedure Report:",
+ f" - Anatomical Region: {quality_report.get('anatomical_region', 'Unknown')}",
+ f" - Quality Classification: {quality_report.get('quality_label', 'Unknown')}",
+ f" - Confidence: {quality_report.get('confidence', 0):.2%}",
+ "",
+ "Findings:",
+ f" - Violation Type Code: {code}",
+ f" - Violation Type: {label}",
+ f" - Reason: {quality_report.get('reason', 'N/A')}",
+ f" - View Quality: {quality_report.get('view_quality', 'Unknown')}",
+ "",
+ "Detailed Metrics:",
+ ]
+
+ # Add metrics
+ metrics = quality_report.get("metrics", {})
+
+ motion = metrics.get("motion", {})
+ if motion:
+ sr_lines.append(f" Motion Detection:")
+ sr_lines.append(f" - Motion Detected: {motion.get('motion_detected', False)}")
+ sr_lines.append(f" - Severity: {motion.get('severity', 'NONE')}")
+
+ artifacts = metrics.get("artifacts", {})
+ if artifacts:
+ sr_lines.append(f" Artifact Detection:")
+ sr_lines.append(f" - Any Artifact: {artifacts.get('any_detected', False)}")
+ sr_lines.append(f" - Metal: {artifacts.get('metal_detected', False)}")
+ sr_lines.append(f" - Implant: {artifacts.get('implant_detected', False)}")
+
+ roi = metrics.get("roi_check", {})
+ if roi:
+ sr_lines.append(f" ROI Validation:")
+ sr_lines.append(f" - Valid: {roi.get('valid', False)}")
+
+ # Completion flag
+ sr_lines.extend([
+ "",
+ "Completion Flag: " + completion,
+ "Verification Flag: UNVERIFIED"
+ ])
+
+ return "\n".join(sr_lines)
+
+
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/src/quality/detailed_assessment.py b/src/quality/detailed_assessment.py
new file mode 100644
index 0000000..2dc9c5d
--- /dev/null
+++ b/src/quality/detailed_assessment.py
@@ -0,0 +1,725 @@
+"""
+Detailed Quality Assessment Module for DXA images
+Implements checks from condition.txt and condition_doctor.txt
+"""
+import numpy as np
+from scipy import ndimage
+from typing import Dict, Any, List, Optional, Tuple
+from PIL import Image
+
+
+# Violation types for spine
+SPINE_VIOLATION_TYPES = [
+ "correct",
+ "position_error",
+ "artifact_motion",
+ "artifact_other",
+ "labeling_error",
+ "incomplete_view",
+ "roi_error"
+]
+
+# Violation types for hip
+HIP_VIOLATION_TYPES = [
+ "correct",
+ "position_error",
+ "rotation",
+ "artifact_motion",
+ "artifact_other",
+ "roi_error",
+ "incomplete_view"
+]
+
+
+def calculate_blur_laplacian(image: np.ndarray) -> float:
+ """
+ Calculate image blur using Laplacian variance.
+ Higher variance = sharper image, lower = more blurred.
+ """
+ if len(image.shape) == 3:
+ image = np.mean(image, axis=2)
+
+ laplacian = np.array([
+ [0, 1, 0],
+ [1, -4, 1],
+ [0, 1, 0]
+ ])
+
+ result = ndimage.convolve(image.astype(float), laplacian)
+ variance = float(np.var(result))
+
+ return variance
+
+
+def calculate_blur_fft(image: np.ndarray) -> float:
+ """
+ Calculate blur using FFT high-frequency energy ratio.
+ Lower ratio = more blurred.
+ """
+ if len(image.shape) == 3:
+ image = np.mean(image, axis=2)
+
+ # Compute FFT
+ f = np.fft.fft2(image.astype(float))
+ fshift = np.fft.fftshift(f)
+ magnitude = np.abs(fshift)
+
+ h, w = image.shape
+ center_h, center_w = h // 2, w // 2
+
+ # Low frequency region (center)
+ low_freq_radius = min(h, w) // 8
+ y, x = np.ogrid[:h, :w]
+ low_freq_mask = (x - center_w)**2 + (y - center_h)**2 <= low_freq_radius**2
+
+ # Calculate energy ratio
+ total_energy = np.sum(magnitude**2) + 1e-10
+ low_energy = np.sum((magnitude * low_freq_mask)**2)
+ high_energy_ratio = 1 - (low_energy / total_energy)
+
+ return float(high_energy_ratio)
+
+
+def detect_motion_blur(image: np.ndarray, segmentation: Optional[np.ndarray] = None) -> Dict[str, Any]:
+ """
+ Detect motion artifacts in the image.
+
+ Checks:
+ - Blur using Laplacian variance
+ - Blur using FFT high-frequency energy
+ - Edge duplication (ghost edges)
+ """
+ if len(image.shape) == 3:
+ gray = np.mean(image, axis=2)
+ else:
+ gray = image
+
+ # Normalize
+ gray = (gray - gray.min()) / (gray.max() - gray.min() + 1e-8)
+
+ # Calculate blur metrics
+ laplacian_var = calculate_blur_laplacian(gray)
+ fft_blur = calculate_blur_fft(gray)
+
+ # Thresholds (tuned for medical images)
+ # Lower laplacian variance = more blur
+ is_blurred_laplacian = laplacian_var < 0.002
+ is_blurred_fft = fft_blur < 0.3
+
+ # Check for edge duplication (ghost edges)
+ edge_duplication = False
+ if segmentation is not None and segmentation.sum() > 0:
+ # Find edges in segmentation
+ edges = ndimage.sobel(segmentation.astype(float))
+ edge_positions = np.where(np.abs(edges) > 0)
+
+ if len(edge_positions[0]) > 10:
+ # Check if there are duplicate edges (offset)
+ y_coords = edge_positions[0]
+ y_diff = np.diff(np.sort(y_coords))
+ # If there are many small gaps, might be edge duplication
+ small_gaps = np.sum(y_diff < 3)
+ edge_duplication = small_gaps > len(y_coords) * 0.1
+
+ motion_detected = is_blurred_laplacian or is_blurred_fft
+
+ return {
+ "motion_detected": motion_detected,
+ "blur_laplacian": laplacian_var,
+ "blur_fft": fft_blur,
+ "is_blurred_laplacian": is_blurred_laplacian,
+ "is_blurred_fft": is_blurred_fft,
+ "edge_duplication": edge_duplication,
+ "severity": "HIGH" if (is_blurred_laplacian and laplacian_var < 0.001) else "MEDIUM" if motion_detected else "NONE"
+ }
+
+
+def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = None) -> Dict[str, Any]:
+ """
+ Detect various artifacts in the image:
+ - Metal objects (high intensity regions)
+ - Implants, screws, plates
+ - Cement
+ - Calcifications
+ """
+ if len(image.shape) == 3:
+ gray = np.mean(image, axis=2)
+ else:
+ gray = image
+
+ # Normalize
+ gray = (gray - gray.min()) / (gray.max() - gray.min() + 1e-8)
+
+ artifacts = {
+ "metal_detected": False,
+ "implant_detected": False,
+ "cement_detected": False,
+ "calcification_detected": False,
+ "local_defects": []
+ }
+
+ # Metal detection: very bright spots
+ metal_threshold = np.percentile(gray, 99.5)
+ metal_mask = gray > metal_threshold
+ metal_ratio = metal_mask.sum() / gray.size
+
+ # If very small bright spots = possible metal
+ if metal_ratio > 0.001 and metal_ratio < 0.05:
+ artifacts["metal_detected"] = True
+
+ # Implant detection: check for regular geometric shapes
+ if segmentation is not None:
+ # Look for high-intensity linear structures
+ lines = ndimage.generate_binary_structure(2, 2)
+ bright_mask = gray > np.percentile(gray, 95)
+
+ # Check for linear structures (potential plates/screws)
+ opened = ndimage.binary_opening(bright_mask, structure=lines)
+ linear_structures = opened & ~ndimage.binary_erosion(bright_mask)
+
+ if linear_structures.sum() > 50:
+ artifacts["implant_detected"] = True
+
+ # Cement detection: localized bright patches
+ if segmentation is not None:
+ roi = gray * segmentation
+ if roi.sum() > 0:
+ roi_normalized = roi / (roi.max() + 1e-8)
+ cement_regions = roi_normalized > 0.9
+ if cement_regions.sum() > 100 and cement_regions.sum() < 5000:
+ artifacts["cement_detected"] = True
+
+ # Calcifications: small bright spots
+ if segmentation is not None:
+ outside_roi = (1 - segmentation) > 0
+ background = gray * outside_roi
+ if background.sum() > 0:
+ bg_threshold = np.percentile(background[background > 0], 95)
+ calcifications = (gray > bg_threshold) & (gray < np.percentile(gray, 99))
+ if 10 < calcifications.sum() < 500:
+ artifacts["calcification_detected"] = True
+
+ # Determine if any artifact detected
+ artifacts["any_detected"] = any([
+ artifacts["metal_detected"],
+ artifacts["implant_detected"],
+ artifacts["cement_detected"],
+ artifacts["calcification_detected"]
+ ])
+
+ return artifacts
+
+
+def check_spine_completeness(segmentation: np.ndarray) -> Dict[str, Any]:
+ """
+ Check if spine vertebrae are fully visible (L1-L4).
+
+ Verifies:
+ - Number of vertebrae (should be 3-4 for lumbar spine)
+ - Vertebrae are not cropped
+ - Intervertebral spaces visible
+ """
+ if segmentation is None or segmentation.sum() == 0:
+ return {
+ "valid": False,
+ "reason": "empty_segmentation",
+ "num_vertebrae": 0,
+ "issues": ["Сегментация пустая"]
+ }
+
+ # Label connected components
+ labeled, num_features = ndimage.label(segmentation)
+ num_features = int(num_features)
+
+ issues = []
+
+ # Check number of vertebrae
+ if num_features < 3:
+ issues.append(f"Видимо слишком мало позвонков: {num_features} (ожидается 3-4)")
+ elif num_features > 6:
+ issues.append(f"Видимо слишком много объектов: {num_features}")
+
+ # Check each vertebra
+ vertebra_info = []
+ for i in range(1, num_features + 1):
+ vertebra_mask = labeled == i
+ y_coords, x_coords = np.where(vertebra_mask)
+
+ if len(y_coords) > 0:
+ # Get bounding box
+ y_min, y_max = y_coords.min(), y_coords.max()
+ x_min, x_max = x_coords.min(), x_coords.max()
+
+ height = y_max - y_min
+ width = x_max - x_min
+
+ # Check if cropped (touching image border)
+ h, w = segmentation.shape
+ is_cropped = (y_min == 0 or y_max == h - 1 or
+ x_min == 0 or x_max == w - 1)
+
+ vertebra_info.append({
+ "id": i,
+ "height": int(height),
+ "width": int(width),
+ "center": (float(np.mean(y_coords) / h), float(np.mean(x_coords) / w)),
+ "is_cropped": is_cropped
+ })
+
+ if is_cropped:
+ issues.append(f"Позвонок {i} обрезан (касается края изображения)")
+
+ # Check alignment
+ if len(vertebra_info) >= 2:
+ x_centers = [v["center"][1] for v in vertebra_info]
+ x_std = np.std(x_centers)
+
+ if x_std > 0.05: # More than 5% of width
+ issues.append(f"Позвонки не выровнены (отклонение {x_std:.3f})")
+
+ return {
+ "valid": len(issues) == 0,
+ "num_vertebrae": num_features,
+ "vertebrae": vertebra_info,
+ "issues": issues,
+ "reason": "; ".join(issues) if issues else "Все позвонки видны полностью"
+ }
+
+
+def check_spine_labels(segmentation: np.ndarray, image: Optional[np.ndarray] = None) -> Dict[str, Any]:
+ """
+ Check labeling of vertebrae (L1-L4).
+ This is a placeholder - real implementation would use OCR or model predictions.
+ """
+ # This would need actual label detection
+ # For now, return placeholder
+ return {
+ "labels_detected": False,
+ "labels": [],
+ "issues": [],
+ "note": "Требуется OCR или модель для детекции меток"
+ }
+
+
+def check_hip_completeness(segmentation: np.ndarray) -> Dict[str, Any]:
+ """
+ Check if hip is fully visible:
+ - Femur neck visible
+ - Femur head visible
+ - Greater and lesser trochanters visible
+ """
+ if segmentation is None or segmentation.sum() == 0:
+ return {
+ "valid": False,
+ "reason": "empty_segmentation",
+ "issues": ["Сегментация пустая"]
+ }
+
+ # Get bounding box
+ y_coords, x_coords = np.where(segmentation > 0)
+ h, w = segmentation.shape
+
+ is_cropped = (y_coords.min() == 0 or y_coords.max() == h - 1 or
+ x_coords.min() == 0 or x_coords.max() == w - 1)
+
+ issues = []
+
+ # Check if cropped
+ if is_cropped:
+ issues.append("Бедро обрезано (касается края изображения)")
+
+ # Check aspect ratio (elongated = good for hip)
+ height = y_coords.max() - y_coords.min()
+ width = x_coords.max() - x_coords.min()
+ aspect_ratio = height / (width + 1e-8)
+
+ if aspect_ratio < 1.5:
+ issues.append(f"Слишком короткое изображение бедра (соотношение сторон: {aspect_ratio:.2f})")
+
+ return {
+ "valid": len(issues) == 0,
+ "is_cropped": is_cropped,
+ "aspect_ratio": float(aspect_ratio),
+ "height": int(height),
+ "width": int(width),
+ "issues": issues,
+ "reason": "; ".join(issues) if issues else "Бедро видно полностью"
+ }
+
+
+def check_hip_rotation(segmentation: np.ndarray, image: np.ndarray) -> Dict[str, Any]:
+ """
+ Check hip rotation from the image.
+
+ Proper positioning:
+ - Femur should be slightly internally rotated (15-20 degrees)
+ - Lesser trochanter should be slightly visible medially
+ - Greater trochanter should not be prominent
+ """
+ if segmentation is None or segmentation.sum() == 0:
+ return {
+ "valid": False,
+ "reason": "empty_segmentation",
+ "rotation_angle": 0,
+ "issues": ["Нет сегментации"]
+ }
+
+ if len(image.shape) == 3:
+ gray = np.mean(image, axis=2)
+ else:
+ gray = image
+
+ # Get the femur shape
+ labeled, num_features = ndimage.label(segmentation)
+
+ issues = []
+ rotation_angle = 0
+ valid = True
+
+ if num_features == 0:
+ return {
+ "valid": False,
+ "reason": "empty_segmentation",
+ "rotation_angle": 0,
+ "issues": ["Сегментация пустая"]
+ }
+
+ # For a single femur, check orientation
+ if num_features == 1:
+ # Get major axis
+ coords = np.array(np.where(segmentation > 0)).T
+ if len(coords) > 10:
+ # Compute covariance to get principal axis
+ coords_float = coords.astype(float)
+ cov = np.cov(coords_float[:, 0], coords_float[:, 1])
+
+ # Eigenvalue decomposition
+ eigenvalues, eigenvectors = np.linalg.eig(cov)
+ major_axis = eigenvectors[:, np.argmax(eigenvalues)]
+
+ # Calculate angle from vertical
+ angle = np.arctan2(major_axis[1], major_axis[0])
+ rotation_angle = np.degrees(angle)
+
+ # Check if within acceptable range (-30 to +30 degrees from vertical)
+ if abs(rotation_angle) > 30:
+ issues.append(f"Выраженная ротация: {rotation_angle:.1f}°")
+ valid = False
+
+ # Check lesser trochanter visibility (would be a bump on medial side)
+ # This is a simplified check
+
+ return {
+ "valid": valid,
+ "rotation_angle": float(rotation_angle),
+ "issues": issues,
+ "reason": "; ".join(issues) if issues else "Ротация в пределах нормы"
+ }
+
+
+def check_roi_boundaries(segmentation: np.ndarray, image_shape: Tuple[int, int]) -> Dict[str, Any]:
+ """
+ Check if ROI boundaries are correct:
+ - For spine: passes through vertebral bodies, not soft tissue
+ - For hip: neck region correctly located, doesn't extend onto trochanter
+ """
+ if segmentation is None or segmentation.sum() == 0:
+ return {
+ "valid": False,
+ "reason": "empty_segmentation",
+ "issues": ["Сегментация пустая"]
+ }
+
+ h, w = image_shape[:2]
+ y_coords, x_coords = np.where(segmentation > 0)
+
+ # Get bounding box
+ y_min, y_max = y_coords.min(), y_coords.max()
+ x_min, x_max = x_coords.min(), x_coords.max()
+
+ # Check margin from edges
+ margin = 10
+ margin_issues = []
+
+ if y_min < margin:
+ margin_issues.append("Слишком близко к верхнему краю")
+ if y_max > h - margin:
+ margin_issues.append("Слишком близко к нижнему краю")
+ if x_min < margin:
+ margin_issues.append("Слишком близко к левому краю")
+ if x_max > w - margin:
+ margin_issues.append("Слишком близко к правому краю")
+
+ # Check size (reasonable ROI size)
+ roi_height = y_max - y_min
+ roi_width = x_max - x_min
+
+ size_issues = []
+ if roi_height < h * 0.3:
+ size_issues.append("ROI слишком маленький")
+ if roi_width < w * 0.2:
+ size_issues.append("ROI слишком узкий")
+
+ all_issues = margin_issues + size_issues
+
+ return {
+ "valid": len(all_issues) == 0,
+ "bounding_box": {
+ "y_min": int(y_min), "y_max": int(y_max),
+ "x_min": int(x_min), "x_max": int(x_max)
+ },
+ "size": {"height": int(roi_height), "width": int(roi_width)},
+ "margin_issues": margin_issues,
+ "size_issues": size_issues,
+ "issues": all_issues,
+ "reason": "; ".join(all_issues) if all_issues else "ROI в пределах нормы"
+ }
+
+
+def check_vertebral_contours(segmentation: np.ndarray) -> Dict[str, Any]:
+ """
+ Check vertebral contours for sharpness and clarity.
+ """
+ if segmentation is None or segmentation.sum() == 0:
+ return {
+ "valid": False,
+ "reason": "empty_segmentation",
+ "issues": ["Нет сегментации"]
+ }
+
+ # Get edges
+ edges = ndimage.sobel(segmentation.astype(float))
+ edge_strength = np.abs(edges).mean()
+
+ issues = []
+
+ # Low edge strength = blurred contours
+ if edge_strength < 0.1:
+ issues.append("Контуры позвонков размыты")
+
+ # Check for gaps between vertebrae
+ labeled, num_features = ndimage.label(segmentation)
+
+ if num_features >= 2:
+ # Check distances between vertebrae
+ centers = []
+ for i in range(1, num_features + 1):
+ coords = np.array(np.where(labeled == i)).T
+ if len(coords) > 0:
+ centers.append(coords.mean(axis=0))
+
+ if len(centers) >= 2:
+ centers = np.array(centers)
+ distances = np.sqrt(np.sum(np.diff(centers, axis=0)**2, axis=1))
+ mean_dist = distances.mean()
+
+ # If some distances are very small, might be merged vertebrae
+ if np.any(distances < mean_dist * 0.3):
+ issues.append("Возможно, позвонки частично слиты")
+
+ return {
+ "valid": len(issues) == 0,
+ "edge_strength": float(edge_strength),
+ "num_objects": int(num_features),
+ "issues": issues,
+ "reason": "; ".join(issues) if issues else "Контуры четкие"
+ }
+
+
+def determine_violation_type(
+ region: str,
+ quality_metrics: Dict[str, Any]
+) -> Tuple[str, str]:
+ """
+ Determine the specific type of violation based on quality metrics.
+
+ Returns:
+ Tuple of (violation_type, reason)
+ """
+ if region == "spine":
+ return _determine_spine_violation(quality_metrics)
+ elif region in ("hip", "hip_left", "hip_right"):
+ return _determine_hip_violation(quality_metrics)
+ else:
+ return "position_error", "Некорректная анатомическая область"
+
+
+def _determine_spine_violation(quality_metrics: Dict[str, Any]) -> Tuple[str, str]:
+ """Determine spine-specific violation type."""
+
+ # Check motion blur first
+ motion = quality_metrics.get("motion", {})
+ if motion.get("motion_detected"):
+ return "artifact_motion", "Обнаружен артефакт движения (размытие)"
+
+ # Check artifacts
+ artifacts = quality_metrics.get("artifacts", {})
+ if artifacts.get("any_detected"):
+ if artifacts.get("metal_detected"):
+ return "artifact_other", "Обнаружены металлические объекты"
+ if artifacts.get("implant_detected"):
+ return "artifact_other", "Обнаружены имплантаты"
+ if artifacts.get("cement_detected"):
+ return "artifact_other", "Обнаружен цемент"
+ return "artifact_other", "Обнаружены артефакты"
+
+ # Check completeness
+ completeness = quality_metrics.get("spine_completeness", {})
+ if not completeness.get("valid", True):
+ return "incomplete_view", completeness.get("reason", "Неполный вид позвоночника")
+
+ # Check contours
+ contours = quality_metrics.get("vertebral_contours", {})
+ if not contours.get("valid", True):
+ return "labeling_error", contours.get("reason", "Проблемы с контурами позвонков")
+
+ # Check ROI
+ roi = quality_metrics.get("roi_check", {})
+ if not roi.get("valid", True):
+ return "roi_error", roi.get("reason", "Проблемы с ROI")
+
+ # Check position
+ position = quality_metrics.get("position", {})
+ if not position.get("valid", True):
+ return "position_error", f"Позиция: {position.get('reason', 'ошибка позиционирования')}"
+
+ return "correct", "Качество соответствует норме"
+
+
+def _determine_hip_violation(quality_metrics: Dict[str, Any]) -> Tuple[str, str]:
+ """Determine hip-specific violation type."""
+
+ # Check motion blur
+ motion = quality_metrics.get("motion", {})
+ if motion.get("motion_detected"):
+ return "artifact_motion", "Обнаружен артефакт движения"
+
+ # Check artifacts
+ artifacts = quality_metrics.get("artifacts", {})
+ if artifacts.get("any_detected"):
+ if artifacts.get("implant_detected"):
+ return "artifact_other", "Обнаружены имплантаты (протезы)"
+ if artifacts.get("metal_detected"):
+ return "artifact_other", "Обнаружены металлические объекты"
+ return "artifact_other", "Обнаружены артефакты"
+
+ # Check completeness
+ completeness = quality_metrics.get("hip_completeness", {})
+ if not completeness.get("valid", True):
+ return "incomplete_view", completeness.get("reason", "Неполный вид бедра")
+
+ # Check rotation
+ rotation = quality_metrics.get("hip_rotation", {})
+ if not rotation.get("valid", True):
+ return "rotation", rotation.get("reason", "Нарушение ротации")
+
+ # Check ROI
+ roi = quality_metrics.get("roi_check", {})
+ if not roi.get("valid", True):
+ return "roi_error", roi.get("reason", "Проблемы с ROI")
+
+ # Check position
+ position = quality_metrics.get("position", {})
+ if not position.get("valid", True):
+ return "position_error", f"Позиция: {position.get('reason', 'ошибка позиционирования')}"
+
+ return "correct", "Качество соответствует норме"
+
+
+def generate_quality_report(
+ image: np.ndarray,
+ segmentation: np.ndarray,
+ region: str,
+ model_prediction: int = 0,
+ model_confidence: float = 0.5
+) -> Dict[str, Any]:
+ """
+ Generate comprehensive quality assessment report.
+ """
+ # Calculate all quality metrics
+ quality_metrics = {}
+
+ # Motion detection
+ quality_metrics["motion"] = detect_motion_blur(image, segmentation)
+
+ # Artifact detection
+ quality_metrics["artifacts"] = detect_artifacts(image, segmentation)
+
+ # Region-specific checks
+ if region == "spine":
+ quality_metrics["spine_completeness"] = check_spine_completeness(segmentation)
+ quality_metrics["vertebral_contours"] = check_vertebral_contours(segmentation)
+ quality_metrics["spine_labels"] = check_spine_labels(segmentation, image)
+ elif region in ("hip", "hip_left", "hip_right"):
+ quality_metrics["hip_completeness"] = check_hip_completeness(segmentation)
+ quality_metrics["hip_rotation"] = check_hip_rotation(segmentation, image)
+
+ # ROI check
+ quality_metrics["roi_check"] = check_roi_boundaries(segmentation, image.shape)
+
+ # Basic position check (from existing scorer)
+ from src.quality.quality_scorer import QualityScorer
+ scorer = QualityScorer()
+ quality_metrics["position"] = scorer.check_position(segmentation)
+
+ # Determine violation type
+ violation_type, reason = determine_violation_type(region, quality_metrics)
+
+ # Overall quality determination
+ if violation_type == "correct":
+ overall_quality = "GOOD"
+ severity = "NONE"
+ quality_class = 0
+ else:
+ overall_quality = "POOR"
+ severity = "HIGH"
+ quality_class = 1
+
+ # Determine view quality
+ if region == "spine":
+ completeness = quality_metrics.get("spine_completeness", {})
+ view_quality = "full" if completeness.get("valid", False) else "partial"
+ elif region in ("hip", "hip_left", "hip_right"):
+ completeness = quality_metrics.get("hip_completeness", {})
+ view_quality = "full" if completeness.get("valid", False) else "partial"
+ else:
+ view_quality = "unknown"
+
+ return {
+ # Basic info
+ "anatomical_region": region,
+ "quality_class": quality_class,
+ "quality_label": "OK" if quality_class == 0 else "Violation detected",
+
+ # New detailed fields
+ "violation_type": violation_type,
+ "reason": reason,
+
+ # Confidence
+ "confidence": model_confidence,
+ "confidence_per_class": {
+ "correct": float(model_confidence) if quality_class == 0 else float(1 - model_confidence),
+ "violation": float(1 - model_confidence) if quality_class == 0 else float(model_confidence)
+ },
+
+ # View quality
+ "view_quality": view_quality,
+
+ # Detailed metrics
+ "metrics": {
+ "motion": quality_metrics.get("motion", {}),
+ "artifacts": quality_metrics.get("artifacts", {}),
+ "position": quality_metrics.get("position", {}),
+ "roi_check": quality_metrics.get("roi_check", {})
+ },
+
+ # Region-specific
+ "spine_completeness": quality_metrics.get("spine_completeness", {}),
+ "vertebral_contours": quality_metrics.get("vertebral_contours", {}),
+ "hip_completeness": quality_metrics.get("hip_completeness", {}),
+ "hip_rotation": quality_metrics.get("hip_rotation", {}),
+
+ # Overall
+ "overall_quality": overall_quality,
+ "severity": severity
+ }
diff --git a/training_history.png b/training_history.png
deleted file mode 100644
index 9134f99..0000000
Binary files a/training_history.png and /dev/null differ