Add body envelope multi table - #42
Conversation
The find-air-invert steps can leak the air-density CT table into the body (air trapped between patient and table is a separate component from the exterior air, so it reads as "not air" -> body). Step 8 detects it as the largest connected component of body voxels that are actually air (CT < hu_threshold) and drops it when that component is >= table_frac_thresh (default 0.05) of the body. Since the seg labels the lungs, no legitimate air region is table-sized (a table is ~16-28% of body vs <0.3% clean). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hu_threshold) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eps 1&4) + log when table safety net fires Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tep-8 A CT table can split into multiple air-in-body connected components (side rails, pads, or a table broken by the patient silhouette). The previous step-8 removed only the single largest component, leaving the others labeled as body. Replace the largest-CC selection with scipy.ndimage.label over the full air-in-body mask and drop every component >= table_frac_thresh of the body. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Step 8 removes large air-density regions inside the body as CT table. The lungs are the only legitimate large air pocket, so they must already be labeled in the input seg (nv-segment-ct / VISTA everything_labels) — otherwise unsegmented lung air reads as a table-sized component and is wrongly removed. Add an .. important:: note to the docstring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Step 8 assumes the lungs are labeled (they're the only legitimate large air-in-body region). Add seg_has_lung (default True): True runs step 8; False skips it so a segmentation without lungs does not have its lung air mistaken for the table and removed. Also drop the pengfeig/3d_ldm_monai provenance line from the docstring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_has_lung arg doc) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…age import to top Wrap the entire step-8 block (air-mask computation + labeling) in 'if seg_has_lung' so nothing is computed when lungs are absent, instead of computing air_hu/air_body/ n_body and then discarding them. Move 'from scipy import ndimage' to the module-level imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….py (line fits in 150) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR extends the
Confidence Score: 4/5Safe to merge for the common case; the only concern is a silent change in connected-component connectivity that could cause a diagonally-bridged table blob to be under-detected. The multi-component table removal logic is correct and well-guarded. The connectivity change from 26 to 6 is the only notable concern, and in practice large air blobs are almost always face-connected. scripts/utils.py — specifically the ndimage.label call in step 8 and whether the default 6-connectivity is intentional. Important Files Changed
Reviews (1): Last reviewed commit: "ruff format: collapse a pre-split log st..." | Re-trigger Greptile |
| out[table] = 0 # remove the detected table from the body | ||
| print(f"[add_body_envelope] table detected ({100.0 * n_table / n_body:.1f}% of body) -> removed", flush=True) | ||
| if air_body.any() and n_body: | ||
| lbl, ncc = ndimage.label(air_body) # all air-in-body components |
There was a problem hiding this comment.
Connectivity mismatch with original code
scipy.ndimage.label uses 6-connectivity (face-adjacent only) by default in 3D, because structure=None resolves to generate_binary_structure(rank, 1). The original get_largest_connected_component_mask(connectivity=None) called skimage.measure.label(connectivity=None), which in 3D defaults to full 26-connectivity (all face + edge + corner neighbors). If a table blob is connected only diagonally — which can happen at typical CT voxel resolutions where the air pocket narrows to a single-voxel diagonal bridge — 6-connectivity will split it into separate components. Each piece could then fall below table_frac_thresh and escape removal.
There was a problem hiding this comment.
that means you need to tune table_frac_thresh?
There was a problem hiding this comment.
Not quite — tuning the threshold doesn't fully solve it. The problem is that a single physical table blob can be split into an unpredictable number of pieces (2, 3, or more depending on scan resolution and patient geometry). You don't know the split factor in advance, so there's no safe threshold to tune to.
Worse, lowering table_frac_thresh to catch split pieces risks false positives: bowel gas, stomach air, or other small legitimate air-in-body pockets could exceed a lower threshold and get incorrectly removed.
The direct fix is to match the original 26-connectivity behavior:
| lbl, ncc = ndimage.label(air_body) # all air-in-body components | |
| lbl, ncc = ndimage.label(air_body, structure=ndimage.generate_binary_structure(3, 3)) # all air-in-body components |
This makes ndimage.label use face+edge+corner neighbors (same as skimage.measure.label(connectivity=None) in the original code), so a diagonally-bridged table stays as one component and the existing table_frac_thresh logic works as intended.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
No description provided.