cxr-report-generation / specs /2_code-level-spec.md
adhisetiawan's picture
Deploy CXR report generation demo
16d6749
|
Raw
History Blame Contribute Delete
21 kB
# 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 "<unused95>" in response:
response = response.split("<unused95>", 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/<run_name>/
├── 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 '<finding>': <error>
```
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.