# Code-Level Specification ## 1. Implementation Directive for Cursor Implement the repository exactly according to this document and `design-level-spec.md`. When the two documents conflict, **this code-level specification takes precedence**. Do not add a backend, Gradio UI, database, training pipeline, MedSAM, or report generation in this phase. Keep the implementation modular, explicit, and easy to extend. Target Python version: **Python 3.11+**. ## 2. Required Repository Structure ```text cxr-medgemma-finding-localization-demo/ ├── app.py ├── requirements.txt ├── README.md ├── design-level-spec.md ├── code-level-spec.md ├── data/ │ └── demo_cases.jsonl ├── outputs/ │ └── .gitkeep ├── tests/ │ └── test_multiview_pipeline.py └── src/ ├── __init__.py ├── config.py ├── dataset.py ├── inference.py ├── model.py ├── parsing.py ├── preprocessing.py ├── prompts.py ├── schemas.py └── visualization.py ``` Do not collapse the entire implementation into `app.py`. `app.py` is orchestration only. ## 3. Dependencies `requirements.txt`: ```text accelerate numpy Pillow pydantic>=2 python-dotenv torch transformers ``` Do not hardcode a Hugging Face access token. Authentication is expected to come from the user's Hugging Face environment or `HF_TOKEN`. ## 4. Configuration File: `src/config.py` Required constants: ```python MODEL_ID = os.getenv( "MEDGEMMA_MODEL_ID", "google/medgemma-1.5-4b-it", ) BBOX_NORMALIZATION_SCALE = 1000 DEFAULT_MAX_FINDINGS = 5 DISCOVERY_MAX_NEW_TOKENS = 512 LOCALIZATION_MAX_NEW_TOKENS = 768 ``` No secrets belong in this file. ## 5. Pydantic Schemas File: `src/schemas.py` ### `Finding` ```python class Finding(BaseModel): finding: str anatomical_location: str certainty: Literal[ "positive", "probable", "questionable", ] ``` ### `FindingDiscovery` ```python class FindingDiscovery(BaseModel): findings: list[Finding] ``` Default `findings` to an empty list. ### `LocalizationBox` ```python class LocalizationBox(BaseModel): box_2d: list[int] label: str ``` Validation requirements: - exactly four coordinates; - every coordinate between `0` and `1000`; - interpret order as `[y0, x0, y1, x1]`; - require `x0 < x1`; - require `y0 < y1`. ### `LocalizationStatus` ```python LocalizationStatus = Literal[ "localized", "abstained", "rejected_by_quality_gate", "parser_error", ] ``` ### `ViewLocalization` ```python class ViewLocalization(BaseModel): image_index: int image_path: str status: LocalizationStatus boxes: list[LocalizationBox] = [] candidate_boxes: list[LocalizationBox] = [] rejection_reasons: list[str] = [] ``` - `boxes` — accepted boxes only (MedSAM input); - `candidate_boxes` — raw MedGemma proposals before gating; - `rejection_reasons` — populated when status is `rejected_by_quality_gate` or `parser_error`. ### `LocalizedFinding` ```python class LocalizedFinding(BaseModel): finding: str anatomical_location: str certainty: Certainty localizations: list[ViewLocalization] ``` Default `localizations` to an empty list. Every finding must contain one `ViewLocalization` per study view. ### `PipelineResult` ```python class PipelineResult(BaseModel): case_id: str | None input_images: list[str] findings: list[LocalizedFinding] ``` ## 6. Study-Level Finding Discovery Prompt File: `src/prompts.py` Use a study-level multi-view prompt that: - treats all provided images as complementary views of one examination; - returns one consolidated study-level finding list as strict raw JSON only; - does not duplicate the same finding across views; - does not infer temporal change without prior-study images; - remains valid when only one image is provided; - forbids prose, headings, markdown fences, and top-level JSON lists in the model response. Do not include the IU X-Ray reference report in this prompt. ## 7. Localization Prompt Builder File: `src/prompts.py` Implement: ```python def build_localization_prompt(finding: Finding) -> str: ... ``` The prompt must inject: - `finding.finding` - `finding.anatomical_location` The prompt must retain the localization contract adapted from the uploaded MedGemma notebook: - bounding-box order `[y0, x0, y1, x1]`; - normalized coordinates `[0, 1000]`; - top-left and bottom-right semantics; - patient anatomical left/right convention. Additional required rules: 1. localize the abnormal finding itself; 2. do not merely box the entire normal anatomical region; 3. do not add a different finding; 4. allow multiple boxes for spatially separate regions; 5. return `[]` when the target cannot be localized in the current view; 6. include study-level context explaining that the finding was identified from a multi-view review but must only be localized if visible in the current image; 7. prefer abstention over speculative or anatomy-only boxes; 8. return strict raw JSON only — no prose, headings, or markdown fences. Required output shape: ```json [ { "box_2d": [120, 610, 290, 790], "label": "pulmonary nodule" } ] ``` ## 7.1 Localization Quality Gate File: `src/localization_quality.py` Implement geometry helpers in normalized 1000×1000 space: ```python def calculate_bbox_area_ratio(box: LocalizationBox) -> float: ... def calculate_bbox_width_ratio(box: LocalizationBox) -> float: ... def calculate_bbox_height_ratio(box: LocalizationBox) -> float: ... ``` Finding profiles (demo heuristics): ```python FOCAL_PROFILE = LocalizationProfile( name="focal", max_area_ratio=0.12, max_width_ratio=0.60, max_height_ratio=0.60, ) DIFFUSE_PROFILE = LocalizationProfile( name="diffuse", max_area_ratio=None, max_width_ratio=None, max_height_ratio=None, ) ``` Finding groups via exact `finding.strip().lower()` match: - Focal: `nodule`, `pulmonary nodule`, `nodular opacity`, `focal opacity`, `focal airspace opacity`, `fracture`, `rib fracture` - Diffuse: `opacity`, `pleural effusion`, `atelectasis`, `atelectatic opacity`, `consolidation` - Unknown → `DIFFUSE_PROFILE` Gate entry point: ```python def evaluate_localization_boxes( finding_name: str, boxes: list[LocalizationBox], ) -> LocalizationGateResult: ... ``` Reject a box when any non-`None` profile threshold is exceeded. Example reason: ```text bbox_area_ratio=0.2400 exceeds focal max_area_ratio=0.1200 ``` Do not use `reference_report` or `reference_findings` in the gate. ## 8. Image Preprocessing File: `src/preprocessing.py` Implement: ```python def pad_image_to_square(image: Image.Image) -> Image.Image: ... ``` Rules: 1. convert input to RGB; 2. convert to `numpy.uint8`; 3. calculate `max(height, width)`; 4. apply symmetric black padding; 5. return a PIL image. Do not resize the image during this stage. Do not multiply a `uint8` array by `255` after padding. The returned square image is the single coordinate space for: - finding discovery; - finding localization; - visualization. ## 9. MedGemma Client File: `src/model.py` Implement: ```python class MedGemmaClient: def __init__(self, model_id: str = MODEL_ID) -> None: ... def generate( self, *, images: Sequence[Image.Image], prompt: str, max_new_tokens: int, ) -> str: ... ``` ### Model Loading Use the notebook's Transformers pipeline pattern: ```python pipeline( "image-text-to-text", model=model_id, model_kwargs={ "dtype": selected_dtype, "device_map": "auto", }, ) ``` Load the pipeline exactly once in `MedGemmaClient.__init__`. ### Dtype Selection Implement: ```text CUDA + BF16 support -> torch.bfloat16 CUDA without BF16 -> torch.float16 CPU -> torch.float32 ``` ### Message Format Build content as: ```python [ {"type": "image", "image": image}, ..., {"type": "text", "text": prompt}, ] ``` Then: ```python messages = [ { "role": "user", "content": content, } ] ``` Inference settings: ```python do_sample=False ``` Use the stage-specific `max_new_tokens`. ### Thinking Trace Removal Preserve the uploaded notebook's behavior: ```python if "" in response: response = response.split("", 1)[1].lstrip() ``` Do not print or save the removed thinking trace. ## 10. JSON Parsing File: `src/parsing.py` Implement: ```python def strip_medgemma_thinking_trace(response: str) -> str: ... def extract_finding_discovery_payload(response: str) -> dict[str, Any]: ... def extract_json_payload( response: str, expected_type: type, ) -> Any: ... ``` ### Finding discovery parsing `extract_finding_discovery_payload` is used only for study-level finding discovery. It normalizes known MedGemma structural deviations into: ```json {"findings": [...]} ``` Accepted inputs include raw JSON objects, top-level finding lists, fenced JSON, and prose followed by `Consolidated list of findings:` plus a JSON array. Parser failure must raise `ValueError` with a response preview. It must not silently return `{"findings": []}`. ### Localization parsing `extract_json_payload` remains the generic parser for localization responses expecting a JSON list of bounding boxes. Parsing order for `extract_json_payload`: 1. strip MedGemma thinking trace; 2. search for a fenced ` ```json ... ``` ` payload; 3. if no code fence exists: - for `dict`, extract from first `{` to last `}`; - for `list`, extract from first `[` to last `]`; 4. parse the first JSON value with `JSONDecoder.raw_decode`; 5. validate the top-level Python type; 6. raise `ValueError` when parsing fails. Do not silently fabricate a fallback finding. ## 11. Demo Dataset Loader File: `src/dataset.py` Implement: ```python def load_demo_cases(path: Path) -> list[dict[str, Any]]: ... def get_demo_case( cases: list[dict[str, Any]], case_id: str, ) -> dict[str, Any]: ... def resolve_case_images( case: dict[str, Any], dataset_root: Path, ) -> list[Path]: ... ``` Path resolution: ```python dataset_root / raw_path.lstrip("/") ``` Example: ```text raw JSONL path: /iu_xray/image/CXR3281_IM-1562/0.png dataset_root: /data/iu-xray-dataset resolved: /data/iu-xray-dataset/iu_xray/image/CXR3281_IM-1562/0.png ``` The dataset loader may read: - `case_id` - `images` - `reference_report` - `reference_findings` However, `reference_report` and `reference_findings` may only be used by `app.py` after the MedGemma pipeline finishes. They must never be passed to `MedGemmaClient.generate`. ## 12. Inference Service File: `src/inference.py` Implement: ```python class CXRFindingsLocalizer: def __init__( self, medgemma: MedGemmaClient, max_findings: int = DEFAULT_MAX_FINDINGS, ) -> None: ... def discover_findings( self, images: Sequence[Image.Image], ) -> FindingDiscovery: ... def localize_finding( self, image: Image.Image, finding: Finding, image_index: int | None = None, ) -> list[LocalizationBox]: ... def run( self, *, images: Sequence[Image.Image], input_images: list[str], case_id: str | None = None, ) -> tuple[PipelineResult, list[Image.Image]]: ... ``` ### `discover_findings` Flow: ```text all square study images ↓ FINDING_DISCOVERY_PROMPT ↓ MedGemmaClient.generate (one call) ↓ extract_finding_discovery_payload ↓ FindingDiscovery.model_validate ``` ### `localize_finding` Flow: ```text square image + Finding ↓ build_localization_prompt(finding) ↓ MedGemmaClient.generate ↓ extract_json_payload(..., list) ↓ LocalizationBox.model_validate for each item ``` Print debug output including finding, location, image index, and raw response. ### `run` Required flow: ```python processed_images = [pad_image_to_square(image) for image in images] discovery = discover_findings(processed_images) for finding in discovery.findings[:max_findings]: for image_index, processed_image in enumerate(processed_images): try: candidate_boxes = localize_finding( processed_image, finding, image_index=image_index ) except (ValueError, ValidationError) as error: ViewLocalization(status="parser_error", ...) elif not candidate_boxes: ViewLocalization(status="abstained", ...) else: gate = evaluate_localization_boxes(finding.finding, candidate_boxes) if gate.accepted_boxes: ViewLocalization(status="localized", boxes=gate.accepted_boxes, ...) else: ViewLocalization(status="rejected_by_quality_gate", boxes=[], ...) ``` If one localization cannot be parsed or validated for one finding/view pair: - print a warning; - preserve the finding; - set `status="parser_error"` with empty `boxes` and `candidate_boxes`; - continue to the next finding/view pair. Do not catch finding-discovery JSON errors as an empty normal result. A malformed discovery response should fail visibly because otherwise the demo could present a false "no abnormality" state. ## 13. Visualization File: `src/visualization.py` Implement: ```python def draw_view_localizations( image: Image.Image, image_index: int, findings: list[LocalizedFinding], ) -> Image.Image: ... ``` For each finding, locate the `ViewLocalization` whose `image_index` matches the current view and draw only its accepted `boxes`. Never draw `candidate_boxes`. ```python y0, x0, y1, x1 = box_2d left = x0 / 1000 * width top = y0 / 1000 * height right = x1 / 1000 * width bottom = y1 / 1000 * height ``` Draw: - red rectangle; - readable text label; - black label background; - white label text. Draw on the square-padded image returned by the inference service. Do not transform boxes back to the original non-square coordinate space in this phase. ## 14. CLI Application File: `app.py` `app.py` must remain a thin orchestration layer. ### Required Input Modes Use an argparse mutually exclusive group. #### Direct Image Mode ```bash python app.py --image /path/to/chest_xray.png --show ``` #### Demo Case Mode ```bash python app.py --case-id CXR3281_IM-1562 --dataset-root /path/to/dataset/root --show ``` Demo-case mode loads all images listed in the case. ### Required Arguments ```text --image PATH --case-id STRING --demo-cases PATH --dataset-root PATH --output-dir PATH --max-findings INT --model-id STRING --show ``` Defaults: ```text --demo-cases data/demo_cases.jsonl --output-dir outputs --max-findings 5 --model-id google/medgemma-1.5-4b-it ``` `--dataset-root` is required only with `--case-id`. ## 15. Console Output Implement a readable text summary: ```text === Study Input === Case ID : CXR3281_IM-1562 Study views : 2 [0] /.../0.png [1] /.../1.png === MedGemma Findings and Localization === [1] pulmonary nodule Location : right upper lung Certainty : positive View [0] Image : /.../0.png Status : localized BBox 1 : [120, 610, 290, 790] (label=pulmonary nodule) Area : 0.0306 View [1] Image : /.../1.png Status : abstained BBox : not localized [2] fracture Location : right humerus Certainty : positive View [0] Image : /.../0.png Status : rejected_by_quality_gate Candidate : [100, 100, 700, 500] Reason : bbox_area_ratio=0.2400 exceeds focal max_area_ratio=0.1200 BBox : not accepted ``` Always print `Status` for each view. For `localized`, also print accepted bbox coordinates and area ratio. For `abstained`, print `BBox : not localized`. For `rejected_by_quality_gate`, print candidate boxes, reasons, and `BBox : not accepted`. For `parser_error`, print the reason. Then print: ```text === Structured JSON === ``` and pretty-print `PipelineResult.model_dump()`. In demo-case mode, print only after inference: ```text === Hidden IU X-Ray Reference === The following reference data was NOT sent to MedGemma. ``` Then display every `reference_findings` item in a concise text form. Do not calculate an automatic match score in this phase. ## 16. Saved Output Contract Use: ```text outputs// ├── result.json ├── annotated_0.png ├── annotated_1.png └── ... ``` `run_name`: - demo mode: `case_id` - direct-image mode: input image stem `result.json` example: ```json { "case_id": "CXR3281_IM-1562", "input_images": [ "/data/iu-xray-dataset/iu_xray/image/CXR3281_IM-1562/0.png", "/data/iu-xray-dataset/iu_xray/image/CXR3281_IM-1562/1.png" ], "findings": [ { "finding": "pulmonary nodule", "anatomical_location": "right upper lung", "certainty": "positive", "localizations": [ { "image_index": 0, "image_path": "/data/iu-xray-dataset/iu_xray/image/CXR3281_IM-1562/0.png", "boxes": [ { "box_2d": [120, 610, 290, 790], "label": "pulmonary nodule" } ] }, { "image_index": 1, "image_path": "/data/iu-xray-dataset/iu_xray/image/CXR3281_IM-1562/1.png", "boxes": [] } ] } ] } ``` `annotated_N.png` must contain only boxes whose `image_index == N`. ## 17. Error Handling Fail with a clear exception for: - missing direct image; - missing demo JSONL; - unknown case ID; - missing `--dataset-root` in demo mode; - case with no images; - resolved image path not found; - discovery JSON parsing failure; - discovery Pydantic validation failure. For localization-only parsing/validation failure: ```text [warning] Could not parse localization for '': ``` Continue processing remaining findings. ## 18. Acceptance Tests Run mocked tests in `tests/test_multiview_pipeline.py`, `tests/test_parsing.py`, and `tests/test_localization_quality.py`: ```bash python -m unittest discover -s tests -v ``` At minimum, verify: ### Finding discovery parser tests (`tests/test_parsing.py`) - strict `{"findings":[...]}` dict; - top-level finding list normalization; - real MedGemma prose plus `Consolidated list of findings:` array; - fenced JSON; - explicit empty findings; - malformed prose raises `ValueError`; - multiple standalone finding dicts raise `ValueError`. ### Inference normalization test `discover_findings` with the real MedGemma prose response preserves two findings after normalization. ### Test A: one multi-view discovery call For two study images, `MedGemmaClient.generate(images=[img0, img1], ...)` is called exactly once for discovery. ### Test B: localization call count For 2 findings and 2 images, localization is called 4 times. ### Test C: bbox/image association Every localization result contains `image_index`, `image_path`, `status`, and `boxes`. ### Test D: finding visible in one view only Mock one bbox on view 0 and `[]` on view 1. Final JSON must contain both `ViewLocalization` entries with statuses `localized` and `abstained`. ### Localization quality gate tests (`tests/test_localization_quality.py`) - Test A: small focal nodule box accepted; - Test B: broad fracture box rejected with area-ratio reason; - Test C: large diffuse opacity box not geometry-rejected. ### Localization gate state tests (`tests/test_multiview_pipeline.py`) - explicit MedGemma abstention (`[]`) → `abstained`; - broad focal candidate fully rejected → `rejected_by_quality_gate`, `candidate_boxes` preserved; - malformed localization response → `parser_error`; - visualizer draws accepted `boxes` only, not rejected `candidate_boxes`. ### Test E: annotated image consistency `annotated_0.png` contains only boxes from `image_index == 0`. ### Test F: direct image mode One image produces one discovery call, one localization call per finding, and `annotated_0.png`. ### Test G: hidden reference leakage Verify `reference_report` and `reference_findings` are not passed to `MedGemmaClient.generate`. ## 19. Implementation Order for Cursor Implement in this exact order: 1. create repository structure; 2. copy `data/demo_cases.jsonl`; 3. implement Pydantic schemas; 4. implement config; 5. implement prompts; 6. implement thinking-trace removal and JSON parser; 7. implement square padding; 8. implement MedGemma client; 9. implement dataset loader; 10. implement inference service; 11. implement visualization; 12. implement CLI orchestration; 13. add README commands; 14. run syntax checks; 15. run mocked parser/schema tests; 16. run one real MedGemma demo case. Do not start MedSAM integration until this phase passes the acceptance tests.