| from __future__ import annotations |
|
|
| import argparse |
| import json |
| from pathlib import Path |
| from typing import Any |
|
|
| from dotenv import load_dotenv |
| from PIL import Image |
|
|
| load_dotenv(Path(__file__).resolve().parent / ".env") |
|
|
| from src.clients.medgemma_client import MedGemmaClient, MedGemmaDetector, MedGemmaReporter |
| from src.clients.medsam_client import MedSAMClient |
| from src.config.endpoints import ( |
| DEFAULT_MAX_FINDINGS, |
| MEDSAM_CHECKPOINT, |
| MEDSAM_DEVICE, |
| MEDGEMMA_MODEL_ID, |
| ) |
| from src.dataset import get_demo_case, load_demo_cases, resolve_case_images |
| from src.pipeline import CXRPipeline |
| from src.schemas.detection import DetectionResult |
| from src.schemas.report import PipelineOutput |
| from src.localization_quality import calculate_bbox_area_ratio |
| from src.visualization import draw_view_localizations |
|
|
|
|
| |
| |
| |
|
|
| def build_parser() -> argparse.ArgumentParser: |
| parser = argparse.ArgumentParser( |
| description=( |
| "Three-stage chest X-ray analysis: " |
| "(1) detect findings, (2) segment them, (3) generate a report." |
| ) |
| ) |
|
|
| input_group = parser.add_mutually_exclusive_group(required=True) |
| input_group.add_argument("--image", type=Path, help="Path to one chest X-ray image.") |
| input_group.add_argument("--case-id", type=str, help="Case ID from demo_cases.jsonl.") |
|
|
| parser.add_argument( |
| "--demo-cases", |
| type=Path, |
| default=Path("data/demo_cases.jsonl"), |
| help="Path to the demo JSONL file.", |
| ) |
| parser.add_argument( |
| "--dataset-root", |
| type=Path, |
| help="Dataset root for resolving JSONL image paths. Required with --case-id.", |
| ) |
| parser.add_argument( |
| "--output-dir", |
| type=Path, |
| default=Path("outputs"), |
| help="Directory for outputs.", |
| ) |
| parser.add_argument( |
| "--max-findings", |
| type=int, |
| default=DEFAULT_MAX_FINDINGS, |
| help=f"Max findings to localize. Default: {DEFAULT_MAX_FINDINGS}.", |
| ) |
| parser.add_argument( |
| "--model-id", |
| type=str, |
| default=MEDGEMMA_MODEL_ID, |
| help=f"MedGemma model ID. Default: {MEDGEMMA_MODEL_ID}.", |
| ) |
| parser.add_argument( |
| "--segment", |
| action="store_true", |
| help="Run Stage 2: segment each localized finding with MedSAM.", |
| ) |
| parser.add_argument( |
| "--medsam-checkpoint", |
| type=str, |
| default=MEDSAM_CHECKPOINT, |
| help=f"MedSAM checkpoint path. Default: {MEDSAM_CHECKPOINT}.", |
| ) |
| parser.add_argument( |
| "--medsam-device", |
| type=str, |
| default=MEDSAM_DEVICE, |
| help=f"Device for MedSAM. Default: {MEDSAM_DEVICE}.", |
| ) |
| parser.add_argument( |
| "--report", |
| action="store_true", |
| help="Run Stage 3: generate a radiology report with MedGemma.", |
| ) |
| parser.add_argument( |
| "--show", |
| action="store_true", |
| help="Open each annotated view image after processing.", |
| ) |
| return parser |
|
|
|
|
| |
| |
| |
|
|
| def resolve_input( |
| args: argparse.Namespace, |
| ) -> tuple[list[Path], str | None, dict[str, Any] | None]: |
| if args.image is not None: |
| if not args.image.exists(): |
| raise FileNotFoundError(f"Input image not found: {args.image}") |
| return [args.image], None, None |
|
|
| if args.dataset_root is None: |
| raise ValueError("--dataset-root is required when using --case-id.") |
|
|
| cases = load_demo_cases(args.demo_cases) |
| case = get_demo_case(cases, args.case_id) |
| image_paths = resolve_case_images(case, args.dataset_root) |
| return image_paths, args.case_id, case |
|
|
|
|
| |
| |
| |
|
|
| def print_study_input(image_paths: list[Path], case_id: str | None) -> None: |
| print("\n=== Study Input ===") |
| if case_id is not None: |
| print(f"Case ID : {case_id}") |
| print(f"Views : {len(image_paths)}") |
| for i, p in enumerate(image_paths): |
| print(f" [{i}] {p}") |
|
|
|
|
| def print_detection_result(result: DetectionResult) -> None: |
| print("\n=== Stage 1 – Detection Results ===") |
| if not result.findings: |
| print("No positive abnormal radiographic findings returned.") |
| return |
|
|
| for i, finding in enumerate(result.findings, start=1): |
| print(f"\n[{i}] {finding.finding}") |
| print(f" Location : {finding.anatomical_location}") |
| print(f" Certainty : {finding.certainty}") |
|
|
| for view in finding.localizations: |
| print(f"\n View [{view.image_index}]") |
| print(f" Image : {view.image_path}") |
| print(f" Status : {view.status}") |
|
|
| if view.status == "localized": |
| for j, box in enumerate(view.boxes, start=1): |
| area = calculate_bbox_area_ratio(box) |
| print(f" BBox {j}: {box.box_2d} (label={box.label})") |
| print(f" Area : {area:.4f}") |
| elif view.status == "rejected_by_quality_gate": |
| for reason in view.rejection_reasons: |
| print(f" Reason : {reason}") |
| elif view.status == "parser_error": |
| for reason in view.rejection_reasons: |
| print(f" Reason : {reason}") |
|
|
|
|
| def print_segmentation_summary(output: PipelineOutput) -> None: |
| if not output.masks: |
| return |
| print("\n=== Stage 2 – Segmentation Summary ===") |
| for mask in output.masks: |
| shape = mask.mask.shape if mask.mask is not None else "N/A" |
| print(f" [{mask.image_index}] {mask.finding_label} | status={mask.status} | mask shape={shape}") |
|
|
|
|
| def print_report(output: PipelineOutput) -> None: |
| if output.report is None: |
| return |
| print("\n=== Stage 3 – Radiology Report ===") |
| if output.report.status == "failed": |
| print(f"Report generation failed: {output.report.error}") |
| else: |
| print(output.report.report_text) |
| print("===================================") |
|
|
|
|
| def print_hidden_reference(case: dict[str, Any] | None) -> None: |
| if case is None: |
| return |
| print("\n=== Hidden IU X-Ray Reference (not sent to model) ===") |
| for i, finding in enumerate(case.get("reference_findings", []), start=1): |
| print(f"[{i}] {finding['finding']} | {finding['location']} | {finding['certainty']}") |
|
|
|
|
| |
| |
| |
|
|
| def main() -> None: |
| parser = build_parser() |
| args = parser.parse_args() |
|
|
| image_paths, case_id, demo_case = resolve_input(args) |
| print_study_input(image_paths, case_id) |
|
|
| images = [Image.open(p).convert("RGB") for p in image_paths] |
| input_images = [str(p) for p in image_paths] |
|
|
| |
| print(f"\nLoading MedGemma: {args.model_id}") |
| medgemma = MedGemmaClient(model_id=args.model_id) |
| detector = MedGemmaDetector(client=medgemma, max_findings=args.max_findings) |
|
|
| segmenter = None |
| if args.segment: |
| print(f"Loading MedSAM : {args.medsam_checkpoint} on {args.medsam_device}") |
| segmenter = MedSAMClient( |
| checkpoint_path=args.medsam_checkpoint, |
| device=args.medsam_device, |
| ) |
|
|
| reporter = None |
| if args.report: |
| reporter = MedGemmaReporter(client=medgemma) |
|
|
| |
| pipeline = CXRPipeline(detector=detector, segmenter=segmenter, reporter=reporter) |
| output = pipeline.run(images=images, input_images=input_images, case_id=case_id) |
|
|
| |
| run_name = case_id or image_paths[0].stem |
| run_dir = args.output_dir / run_name |
| run_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| detection_json = output.detection.model_dump() |
| (run_dir / "result.json").write_text( |
| json.dumps(detection_json, indent=2, ensure_ascii=False), encoding="utf-8" |
| ) |
|
|
| |
| if output.report is not None: |
| report_json = output.report.model_dump() |
| (run_dir / "report.json").write_text( |
| json.dumps(report_json, indent=2, ensure_ascii=False), encoding="utf-8" |
| ) |
|
|
| |
| annotated_paths: list[Path] = [] |
| for image_index, processed_image in enumerate(output.processed_images): |
| annotated = draw_view_localizations( |
| image=processed_image, |
| image_index=image_index, |
| findings=output.detection.findings, |
| ) |
| annotated_path = run_dir / f"annotated_{image_index}.png" |
| annotated.save(annotated_path) |
| annotated_paths.append(annotated_path) |
| if args.show: |
| annotated.show() |
|
|
| |
| print_detection_result(output.detection) |
| print_segmentation_summary(output) |
| print_report(output) |
| print_hidden_reference(demo_case) |
|
|
| print("\n=== Saved Outputs ===") |
| print(f"Detection JSON : {run_dir / 'result.json'}") |
| if output.report is not None: |
| print(f"Report JSON : {run_dir / 'report.json'}") |
| for p in annotated_paths: |
| print(f"Annotated : {p}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|