Skip to content

Commit c9a9b42

Browse files
committed
Enhance pipeline generator functionality and improve bundle organization
- Updated the CLI output to reflect "Verified" models instead of "Tested" for better clarity. - Added new model configurations for pancreas segmentation and spleen segmentation in the config file. - Implemented a method to organize downloaded bundle structures into the standard MONAI format, improving file management. - Enhanced dependency handling in the AppGenerator to resolve conflicts between configuration and metadata. - Added unit tests to verify the new bundle organization functionality and ensure correct behavior under various scenarios. Signed-off-by: Victor Chang <[email protected]>
1 parent 18eea51 commit c9a9b42

File tree

7 files changed

+389
-19
lines changed

7 files changed

+389
-19
lines changed

tools/pipeline-generator/pipeline_generator/cli/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def list(ctx: click.Context, format: str, bundles_only: bool, tested_only: bool)
118118
bundle_count = sum(1 for m in models if m.is_monai_bundle)
119119
tested_count = sum(1 for m in models if m.model_id in tested_models)
120120
console.print(
121-
f"\n[green]Total models: {len(models)} (MONAI Bundles: {bundle_count}, Tested: {tested_count})[/green]"
121+
f"\n[green]Total models: {len(models)} (MONAI Bundles: {bundle_count}, Verified: {tested_count})[/green]"
122122
)
123123

124124

tools/pipeline-generator/pipeline_generator/config/config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ endpoints:
4040
- model_id: "MONAI/swin_unetr_btcv_segmentation"
4141
input_type: "nifti"
4242
output_type: "nifti"
43+
- model_id: "MONAI/pancreas_ct_dints_segmentation"
44+
input_type: "nifti"
45+
output_type: "nifti"
4346
- model_id: "MONAI/Llama3-VILA-M3-3B"
4447
input_type: "custom"
4548
output_type: "custom"
@@ -64,6 +67,14 @@ endpoints:
6467
- torch>=2.0.0
6568
- Pillow>=8.0.0
6669
- PyYAML>=6.0
70+
- model_id: "MONAI/example_spleen_segmentation"
71+
input_type: "nifti"
72+
output_type: "nifti"
73+
dependencies:
74+
- torch>=1.11.0,<3.0.0
75+
- numpy>=1.21.2,<2.0.0
76+
- monai>=1.3.0
77+
- nibabel>=3.0.0
6778

6879
additional_models:
6980
- model_id: "LGAI-EXAONE/EXAONEPath"

tools/pipeline-generator/pipeline_generator/generator/app_generator.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ def generate_app(
112112
# Download the bundle
113113
logger.info(f"Downloading bundle: {model_id}")
114114
bundle_path = self.downloader.download_bundle(model_id, output_dir)
115+
116+
# Organize bundle into proper structure if needed
117+
self.downloader.organize_bundle_structure(bundle_path)
115118

116119
# Read bundle metadata and config
117120
metadata = self.downloader.get_bundle_metadata(bundle_path)
@@ -275,10 +278,35 @@ def _prepare_context(
275278
# Collect dependency hints from metadata.json
276279
required_packages_version = metadata.get("required_packages_version", {}) if metadata else {}
277280
extra_dependencies = getattr(model_config, "dependencies", []) if model_config else []
278-
if metadata and "numpy_version" in metadata:
281+
282+
# Handle dependency conflicts between config and metadata
283+
config_deps = []
284+
if extra_dependencies:
285+
# Extract dependency names from config overrides
286+
config_deps = [dep.split(">=")[0].split("==")[0].split("<")[0] for dep in extra_dependencies]
287+
288+
# Add metadata dependencies only if not overridden by config
289+
if metadata and "numpy_version" in metadata and "numpy" not in config_deps:
279290
extra_dependencies.append(f"numpy=={metadata['numpy_version']}")
280-
if metadata and "pytorch_version" in metadata:
291+
if metadata and "pytorch_version" in metadata and "torch" not in config_deps:
281292
extra_dependencies.append(f"torch=={metadata['pytorch_version']}")
293+
294+
# Handle MONAI version - move logic from template to Python for better maintainability
295+
has_monai_config = any(dep.startswith("monai") for dep in extra_dependencies)
296+
if has_monai_config and metadata:
297+
# Remove monai_version from metadata since we have config override
298+
metadata = dict(metadata) # Make a copy
299+
metadata.pop("monai_version", None)
300+
elif not has_monai_config:
301+
# No config MONAI dependency - add one based on metadata or fallback
302+
if metadata and "monai_version" in metadata:
303+
extra_dependencies.append(f"monai=={metadata['monai_version']}")
304+
# Remove from metadata since it's now in extra_dependencies
305+
metadata = dict(metadata) if metadata else {}
306+
metadata.pop("monai_version", None)
307+
else:
308+
# No metadata version, use fallback
309+
extra_dependencies.append("monai>=1.5.0")
282310

283311
return {
284312
"model_id": model_id,

tools/pipeline-generator/pipeline_generator/generator/bundle_downloader.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,57 @@ def detect_model_file(self, bundle_path: Path) -> Optional[Path]:
144144

145145
logger.warning(f"No model file found in bundle: {bundle_path}")
146146
return None
147+
148+
def organize_bundle_structure(self, bundle_path: Path) -> None:
149+
"""Organize bundle files into the expected MONAI Bundle structure.
150+
151+
Creates the standard structure if files are in the root directory:
152+
bundle_root/
153+
configs/
154+
metadata.json
155+
inference.json
156+
models/
157+
model.pt
158+
model.ts
159+
160+
Args:
161+
bundle_path: Path to the downloaded bundle
162+
"""
163+
configs_dir = bundle_path / "configs"
164+
models_dir = bundle_path / "models"
165+
166+
# Check if structure already exists
167+
has_configs_structure = (
168+
configs_dir.exists() and
169+
(configs_dir / "metadata.json").exists()
170+
)
171+
has_models_structure = (
172+
models_dir.exists() and
173+
any(models_dir.glob("model.*"))
174+
)
175+
176+
if has_configs_structure and has_models_structure:
177+
logger.debug("Bundle already has proper structure")
178+
return
179+
180+
logger.info("Organizing bundle into standard structure")
181+
182+
# Create directories
183+
configs_dir.mkdir(exist_ok=True)
184+
models_dir.mkdir(exist_ok=True)
185+
186+
# Move config files to configs/
187+
config_files = ["metadata.json", "inference.json"]
188+
for config_file in config_files:
189+
src_path = bundle_path / config_file
190+
if src_path.exists() and not (configs_dir / config_file).exists():
191+
src_path.rename(configs_dir / config_file)
192+
logger.debug(f"Moved {config_file} to configs/")
193+
194+
# Move model files to models/
195+
model_extensions = [".pt", ".ts", ".onnx"]
196+
for ext in model_extensions:
197+
for model_file in bundle_path.glob(f"*{ext}"):
198+
if model_file.is_file() and not (models_dir / model_file.name).exists():
199+
model_file.rename(models_dir / model_file.name)
200+
logger.debug(f"Moved {model_file.name} to models/")

tools/pipeline-generator/pipeline_generator/templates/requirements.txt.j2

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,6 @@
33

44
# MONAI Deploy App SDK and dependencies
55
monai-deploy-app-sdk>=3.0.0
6-
{% if metadata.monai_version is defined %}
7-
monai=={{ metadata.monai_version }}
8-
{% else %}
9-
monai>=1.5.0
10-
{% endif %}
116

127

138
# Required by MONAI Deploy SDK (always needed)

tools/pipeline-generator/tests/test_bundle_downloader.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,78 @@ def test_detect_model_file_not_found(self, tmp_path):
220220

221221
assert result is None
222222

223+
def test_organize_bundle_structure_flat_to_structured(self, tmp_path):
224+
"""Test organizing flat bundle structure into standard format."""
225+
bundle_path = tmp_path / "bundle"
226+
bundle_path.mkdir()
227+
228+
# Create files in flat structure
229+
metadata_file = bundle_path / "metadata.json"
230+
inference_file = bundle_path / "inference.json"
231+
model_pt_file = bundle_path / "model.pt"
232+
model_ts_file = bundle_path / "model.ts"
233+
234+
metadata_file.write_text('{"name": "Test"}')
235+
inference_file.write_text('{"config": "test"}')
236+
model_pt_file.touch()
237+
model_ts_file.touch()
238+
239+
# Organize structure
240+
self.downloader.organize_bundle_structure(bundle_path)
241+
242+
# Check that files were moved to proper locations
243+
assert (bundle_path / "configs" / "metadata.json").exists()
244+
assert (bundle_path / "configs" / "inference.json").exists()
245+
assert (bundle_path / "models" / "model.pt").exists()
246+
assert (bundle_path / "models" / "model.ts").exists()
247+
248+
# Check that original files were moved (not copied)
249+
assert not metadata_file.exists()
250+
assert not inference_file.exists()
251+
assert not model_pt_file.exists()
252+
assert not model_ts_file.exists()
253+
254+
def test_organize_bundle_structure_already_structured(self, tmp_path):
255+
"""Test organizing bundle that already has proper structure."""
256+
bundle_path = tmp_path / "bundle"
257+
configs_dir = bundle_path / "configs"
258+
models_dir = bundle_path / "models"
259+
configs_dir.mkdir(parents=True)
260+
models_dir.mkdir(parents=True)
261+
262+
# Create files in proper structure
263+
metadata_file = configs_dir / "metadata.json"
264+
model_file = models_dir / "model.pt"
265+
metadata_file.write_text('{"name": "Test"}')
266+
model_file.touch()
267+
268+
# Should not change anything
269+
self.downloader.organize_bundle_structure(bundle_path)
270+
271+
# Files should remain in place
272+
assert metadata_file.exists()
273+
assert model_file.exists()
274+
275+
def test_organize_bundle_structure_partial_structure(self, tmp_path):
276+
"""Test organizing bundle with partial structure."""
277+
bundle_path = tmp_path / "bundle"
278+
configs_dir = bundle_path / "configs"
279+
configs_dir.mkdir(parents=True)
280+
281+
# Create metadata in configs but model in root
282+
metadata_file = configs_dir / "metadata.json"
283+
model_file = bundle_path / "model.pt"
284+
metadata_file.write_text('{"name": "Test"}')
285+
model_file.touch()
286+
287+
# Organize structure
288+
self.downloader.organize_bundle_structure(bundle_path)
289+
290+
# Metadata should stay, model should move
291+
assert metadata_file.exists()
292+
assert (bundle_path / "models" / "model.pt").exists()
293+
assert not model_file.exists()
294+
223295
def test_detect_model_file_multiple_models(self, tmp_path):
224296
"""Test detecting model file with multiple model files (returns first found)."""
225297
bundle_path = tmp_path / "bundle"

0 commit comments

Comments
 (0)