bioimageio.core.cli

bioimageio CLI

Note: Some docstrings use a hair space ' ' to place the added '(default: ...)' on a new line.

  1"""bioimageio CLI
  2
  3Note: Some docstrings use a hair space ' '
  4      to place the added '(default: ...)' on a new line.
  5"""
  6
  7import json
  8import shutil
  9import subprocess
 10import sys
 11from abc import ABC
 12from argparse import RawTextHelpFormatter
 13from difflib import SequenceMatcher
 14from functools import cached_property
 15from io import StringIO
 16from pathlib import Path
 17from pprint import pformat, pprint
 18from typing import (
 19    Any,
 20    Dict,
 21    Iterable,
 22    List,
 23    Literal,
 24    Mapping,
 25    Optional,
 26    Sequence,
 27    Set,
 28    Tuple,
 29    Type,
 30    Union,
 31)
 32
 33import rich.markdown
 34from loguru import logger
 35from pydantic import AliasChoices, BaseModel, Field, model_validator
 36from pydantic_settings import (
 37    BaseSettings,
 38    CliPositionalArg,
 39    CliSettingsSource,
 40    CliSubCommand,
 41    JsonConfigSettingsSource,
 42    PydanticBaseSettingsSource,
 43    SettingsConfigDict,
 44    YamlConfigSettingsSource,
 45)
 46from tqdm import tqdm
 47from typing_extensions import assert_never
 48
 49from bioimageio.spec import (
 50    AnyModelDescr,
 51    InvalidDescr,
 52    ResourceDescr,
 53    load_description,
 54    save_bioimageio_yaml_only,
 55    settings,
 56    update_format,
 57    update_hashes,
 58)
 59from bioimageio.spec._internal.io import is_yaml_value
 60from bioimageio.spec._internal.io_basics import ZipPath
 61from bioimageio.spec._internal.io_utils import open_bioimageio_yaml
 62from bioimageio.spec._internal.types import NotEmpty
 63from bioimageio.spec.dataset import DatasetDescr
 64from bioimageio.spec.model import ModelDescr, v0_4, v0_5
 65from bioimageio.spec.notebook import NotebookDescr
 66from bioimageio.spec.utils import download, ensure_description_is_model, write_yaml
 67
 68from .commands import WeightFormatArgAll, WeightFormatArgAny, package, test
 69from .common import MemberId, SampleId, SupportedWeightsFormat
 70from .digest_spec import get_member_ids, load_sample_for_model
 71from .io import load_dataset_stat, save_dataset_stat, save_sample
 72from .prediction import create_prediction_pipeline
 73from .proc_setup import (
 74    DatasetMeasure,
 75    Measure,
 76    MeasureValue,
 77    StatsCalculator,
 78    get_required_dataset_measures,
 79)
 80from .sample import Sample
 81from .stat_measures import Stat
 82from .utils import VERSION, compare
 83from .weight_converters._add_weights import add_weights
 84
 85WEIGHT_FORMAT_ALIASES = AliasChoices(
 86    "weight-format",
 87    "weights-format",
 88    "weight_format",
 89    "weights_format",
 90)
 91
 92
 93class CmdBase(BaseModel, use_attribute_docstrings=True, cli_implicit_flags=True):
 94    pass
 95
 96
 97class ArgMixin(BaseModel, use_attribute_docstrings=True, cli_implicit_flags=True):
 98    pass
 99
100
101class WithSummaryLogging(ArgMixin):
102    summary: Union[
103        Literal["display"], Path, Sequence[Union[Literal["display"], Path]]
104    ] = Field(
105        "display",
106        examples=[
107            "display",
108            Path("summary.md"),
109            Path("bioimageio_summaries/"),
110            ["display", Path("summary.md")],
111        ],
112    )
113    """Display the validation summary or save it as JSON, Markdown or HTML.
114    The format is chosen based on the suffix: `.json`, `.md`, `.html`.
115    If a folder is given (path w/o suffix) the summary is saved in all formats.
116    Choose/add `"display"` to render the validation summary to the terminal.
117    """
118
119    def log(self, descr: Union[ResourceDescr, InvalidDescr]):
120        _ = descr.validation_summary.log(self.summary)
121
122
123class WithSource(ArgMixin):
124    source: CliPositionalArg[str]
125    """Url/path to a (folder with a) bioimageio.yaml/rdf.yaml file
126    or a bioimage.io resource identifier, e.g. 'affable-shark'"""
127
128    @cached_property
129    def descr(self):
130        return load_description(self.source)
131
132    @property
133    def descr_id(self) -> str:
134        """a more user-friendly description id
135        (replacing legacy ids with their nicknames)
136        """
137        if isinstance(self.descr, InvalidDescr):
138            return str(getattr(self.descr, "id", getattr(self.descr, "name")))
139
140        nickname = None
141        if (
142            isinstance(self.descr.config, v0_5.Config)
143            and (bio_config := self.descr.config.bioimageio)
144            and bio_config.model_extra is not None
145        ):
146            nickname = bio_config.model_extra.get("nickname")
147
148        return str(nickname or self.descr.id or self.descr.name)
149
150
151class ValidateFormatCmd(CmdBase, WithSource, WithSummaryLogging):
152    """Validate the meta data format of a bioimageio resource."""
153
154    perform_io_checks: bool = Field(
155        settings.perform_io_checks, alias="perform-io-checks"
156    )
157    """Wether or not to perform validations that requires downloading remote files.
158    Note: Default value is set by `BIOIMAGEIO_PERFORM_IO_CHECKS` environment variable.
159    """
160
161    @cached_property
162    def descr(self):
163        return load_description(self.source, perform_io_checks=self.perform_io_checks)
164
165    def run(self):
166        self.log(self.descr)
167        sys.exit(
168            0
169            if self.descr.validation_summary.status in ("valid-format", "passed")
170            else 1
171        )
172
173
174class TestCmd(CmdBase, WithSource, WithSummaryLogging):
175    """Test a bioimageio resource (beyond meta data formatting)."""
176
177    weight_format: WeightFormatArgAll = Field(
178        "all",
179        alias="weight-format",
180        validation_alias=WEIGHT_FORMAT_ALIASES,
181    )
182    """The weight format to limit testing to.
183
184    (only relevant for model resources)"""
185
186    devices: Optional[Union[str, Sequence[str]]] = None
187    """Device(s) to use for testing"""
188
189    runtime_env: Union[Literal["currently-active", "as-described"], Path] = Field(
190        "currently-active", alias="runtime-env"
191    )
192    """The python environment to run the tests in
193        - `"currently-active"`: use active Python interpreter
194        - `"as-described"`: generate a conda environment YAML file based on the model
195            weights description.
196        - A path to a conda environment YAML.
197          Note: The `bioimageio.core` dependency will be added automatically if not present.
198    """
199
200    determinism: Literal["seed_only", "full"] = "seed_only"
201    """Modes to improve reproducibility of test outputs."""
202
203    stop_early: bool = Field(
204        False, alias="stop-early", validation_alias=AliasChoices("stop-early", "x")
205    )
206    """Do not run further subtests after a failed one."""
207
208    def run(self):
209        sys.exit(
210            test(
211                self.descr,
212                weight_format=self.weight_format,
213                devices=self.devices,
214                summary=self.summary,
215                runtime_env=self.runtime_env,
216                determinism=self.determinism,
217            )
218        )
219
220
221class PackageCmd(CmdBase, WithSource, WithSummaryLogging):
222    """Save a resource's metadata with its associated files."""
223
224    path: CliPositionalArg[Path]
225    """The path to write the (zipped) package to.
226    If it does not have a `.zip` suffix
227    this command will save the package as an unzipped folder instead."""
228
229    weight_format: WeightFormatArgAll = Field(
230        "all",
231        alias="weight-format",
232        validation_alias=WEIGHT_FORMAT_ALIASES,
233    )
234    """The weight format to include in the package (for model descriptions only)."""
235
236    def run(self):
237        if isinstance(self.descr, InvalidDescr):
238            self.log(self.descr)
239            raise ValueError(f"Invalid {self.descr.type} description.")
240
241        sys.exit(
242            package(
243                self.descr,
244                self.path,
245                weight_format=self.weight_format,
246            )
247        )
248
249
250def _get_stat(
251    model_descr: AnyModelDescr,
252    dataset: Iterable[Sample],
253    dataset_length: int,
254    stats_path: Path,
255) -> Mapping[DatasetMeasure, MeasureValue]:
256    req_dataset_meas, _ = get_required_dataset_measures(model_descr)
257    if not req_dataset_meas:
258        return {}
259
260    req_dataset_meas, _ = get_required_dataset_measures(model_descr)
261
262    if stats_path.exists():
263        logger.info("loading precomputed dataset measures from {}", stats_path)
264        stat = load_dataset_stat(stats_path)
265        for m in req_dataset_meas:
266            if m not in stat:
267                raise ValueError(f"Missing {m} in {stats_path}")
268
269        return stat
270
271    stats_calc = StatsCalculator(req_dataset_meas)
272
273    for sample in tqdm(
274        dataset, total=dataset_length, desc="precomputing dataset stats", unit="sample"
275    ):
276        stats_calc.update(sample)
277
278    stat = stats_calc.finalize()
279    save_dataset_stat(stat, stats_path)
280
281    return stat
282
283
284class UpdateCmdBase(CmdBase, WithSource, ABC):
285    output: Union[Literal["display", "stdout"], Path] = "display"
286    """Output updated bioimageio.yaml to the terminal or write to a file.
287    Notes:
288    - `"display"`: Render to the terminal with syntax highlighting.
289    - `"stdout"`: Write to sys.stdout without syntax highligthing.
290      (More convenient for copying the updated bioimageio.yaml from the terminal.)
291    """
292
293    diff: Union[bool, Path] = Field(True, alias="diff")
294    """Output a diff of original and updated bioimageio.yaml.
295    If a given path has an `.html` extension, a standalone HTML file is written,
296    otherwise the diff is saved in unified diff format (pure text).
297    """
298
299    exclude_unset: bool = Field(True, alias="exclude-unset")
300    """Exclude fields that have not explicitly be set."""
301
302    exclude_defaults: bool = Field(False, alias="exclude-defaults")
303    """Exclude fields that have the default value (even if set explicitly)."""
304
305    @cached_property
306    def updated(self) -> Union[ResourceDescr, InvalidDescr]:
307        raise NotImplementedError
308
309    def run(self):
310        original_yaml = open_bioimageio_yaml(self.source).unparsed_content
311        assert isinstance(original_yaml, str)
312        stream = StringIO()
313
314        save_bioimageio_yaml_only(
315            self.updated,
316            stream,
317            exclude_unset=self.exclude_unset,
318            exclude_defaults=self.exclude_defaults,
319        )
320        updated_yaml = stream.getvalue()
321
322        diff = compare(
323            original_yaml.split("\n"),
324            updated_yaml.split("\n"),
325            diff_format=(
326                "html"
327                if isinstance(self.diff, Path) and self.diff.suffix == ".html"
328                else "unified"
329            ),
330        )
331
332        if isinstance(self.diff, Path):
333            _ = self.diff.write_text(diff, encoding="utf-8")
334        elif self.diff:
335            console = rich.console.Console()
336            diff_md = f"## Diff\n\n````````diff\n{diff}\n````````"
337            console.print(rich.markdown.Markdown(diff_md))
338
339        if isinstance(self.output, Path):
340            _ = self.output.write_text(updated_yaml, encoding="utf-8")
341            logger.info(f"written updated description to {self.output}")
342        elif self.output == "display":
343            updated_md = f"## Updated bioimageio.yaml\n\n```yaml\n{updated_yaml}\n```"
344            rich.console.Console().print(rich.markdown.Markdown(updated_md))
345        elif self.output == "stdout":
346            print(updated_yaml)
347        else:
348            assert_never(self.output)
349
350        if isinstance(self.updated, InvalidDescr):
351            logger.warning("Update resulted in invalid description")
352            _ = self.updated.validation_summary.display()
353
354
355class UpdateFormatCmd(UpdateCmdBase):
356    """Update the metadata format to the latest format version."""
357
358    exclude_defaults: bool = Field(True, alias="exclude-defaults")
359    """Exclude fields that have the default value (even if set explicitly).
360
361    Note:
362        The update process sets most unset fields explicitly with their default value.
363    """
364
365    perform_io_checks: bool = Field(
366        settings.perform_io_checks, alias="perform-io-checks"
367    )
368    """Wether or not to attempt validation that may require file download.
369    If `True` file hash values are added if not present."""
370
371    @cached_property
372    def updated(self):
373        return update_format(
374            self.source,
375            exclude_defaults=self.exclude_defaults,
376            perform_io_checks=self.perform_io_checks,
377        )
378
379
380class UpdateHashesCmd(UpdateCmdBase):
381    """Create a bioimageio.yaml description with updated file hashes."""
382
383    @cached_property
384    def updated(self):
385        return update_hashes(self.source)
386
387
388class PredictCmd(CmdBase, WithSource):
389    """Run inference on your data with a bioimage.io model."""
390
391    inputs: NotEmpty[Sequence[Union[str, NotEmpty[Tuple[str, ...]]]]] = (
392        "{input_id}/001.tif",
393    )
394    """Model input sample paths (for each input tensor)
395
396    The input paths are expected to have shape...
397     - (n_samples,) or (n_samples,1) for models expecting a single input tensor
398     - (n_samples,) containing the substring '{input_id}', or
399     - (n_samples, n_model_inputs) to provide each input tensor path explicitly.
400
401    All substrings that are replaced by metadata from the model description:
402    - '{model_id}'
403    - '{input_id}'
404
405    Example inputs to process sample 'a' and 'b'
406    for a model expecting a 'raw' and a 'mask' input tensor:
407    --inputs="[[\\"a_raw.tif\\",\\"a_mask.tif\\"],[\\"b_raw.tif\\",\\"b_mask.tif\\"]]"
408    (Note that JSON double quotes need to be escaped.)
409
410    Alternatively a `bioimageio-cli.yaml` (or `bioimageio-cli.json`) file
411    may provide the arguments, e.g.:
412    ```yaml
413    inputs:
414    - [a_raw.tif, a_mask.tif]
415    - [b_raw.tif, b_mask.tif]
416    ```
417
418    `.npy` and any file extension supported by imageio are supported.
419     Aavailable formats are listed at
420    https://imageio.readthedocs.io/en/stable/formats/index.html#all-formats.
421    Some formats have additional dependencies.
422
423
424    """
425
426    outputs: Union[str, NotEmpty[Tuple[str, ...]]] = (
427        "outputs_{model_id}/{output_id}/{sample_id}.tif"
428    )
429    """Model output path pattern (per output tensor)
430
431    All substrings that are replaced:
432    - '{model_id}' (from model description)
433    - '{output_id}' (from model description)
434    - '{sample_id}' (extracted from input paths)
435
436
437    """
438
439    overwrite: bool = False
440    """allow overwriting existing output files"""
441
442    blockwise: bool = False
443    """process inputs blockwise"""
444
445    stats: Path = Path("dataset_statistics.json")
446    """path to dataset statistics
447    (will be written if it does not exist,
448    but the model requires statistical dataset measures)
449     """
450
451    preview: bool = False
452    """preview which files would be processed
453    and what outputs would be generated."""
454
455    weight_format: WeightFormatArgAny = Field(
456        "any",
457        alias="weight-format",
458        validation_alias=WEIGHT_FORMAT_ALIASES,
459    )
460    """The weight format to use."""
461
462    example: bool = False
463    """generate and run an example
464
465    1. downloads example model inputs
466    2. creates a `{model_id}_example` folder
467    3. writes input arguments to `{model_id}_example/bioimageio-cli.yaml`
468    4. executes a preview dry-run
469    5. executes prediction with example input
470
471
472    """
473
474    def _example(self):
475        model_descr = ensure_description_is_model(self.descr)
476        input_ids = get_member_ids(model_descr.inputs)
477        example_inputs = (
478            model_descr.sample_inputs
479            if isinstance(model_descr, v0_4.ModelDescr)
480            else [ipt.sample_tensor or ipt.test_tensor for ipt in model_descr.inputs]
481        )
482        if not example_inputs:
483            raise ValueError(f"{self.descr_id} does not specify any example inputs.")
484
485        inputs001: List[str] = []
486        example_path = Path(f"{self.descr_id}_example")
487        example_path.mkdir(exist_ok=True)
488
489        for t, src in zip(input_ids, example_inputs):
490            local = download(src).path
491            dst = Path(f"{example_path}/{t}/001{''.join(local.suffixes)}")
492            dst.parent.mkdir(parents=True, exist_ok=True)
493            inputs001.append(dst.as_posix())
494            if isinstance(local, Path):
495                shutil.copy(local, dst)
496            elif isinstance(local, ZipPath):
497                _ = local.root.extract(local.at, path=dst)
498            else:
499                assert_never(local)
500
501        inputs = [tuple(inputs001)]
502        output_pattern = f"{example_path}/outputs/{{output_id}}/{{sample_id}}.tif"
503
504        bioimageio_cli_path = example_path / YAML_FILE
505        stats_file = "dataset_statistics.json"
506        stats = (example_path / stats_file).as_posix()
507        cli_example_args = dict(
508            inputs=inputs,
509            outputs=output_pattern,
510            stats=stats_file,
511            blockwise=self.blockwise,
512        )
513        assert is_yaml_value(cli_example_args)
514        write_yaml(
515            cli_example_args,
516            bioimageio_cli_path,
517        )
518
519        yaml_file_content = None
520
521        # escaped double quotes
522        inputs_json = json.dumps(inputs)
523        inputs_escaped = inputs_json.replace('"', r"\"")
524        source_escaped = self.source.replace('"', r"\"")
525
526        def get_example_command(preview: bool, escape: bool = False):
527            q: str = '"' if escape else ""
528
529            return [
530                "bioimageio",
531                "predict",
532                # --no-preview not supported for py=3.8
533                *(["--preview"] if preview else []),
534                "--overwrite",
535                *(["--blockwise"] if self.blockwise else []),
536                f"--stats={q}{stats}{q}",
537                f"--inputs={q}{inputs_escaped if escape else inputs_json}{q}",
538                f"--outputs={q}{output_pattern}{q}",
539                f"{q}{source_escaped if escape else self.source}{q}",
540            ]
541
542        if Path(YAML_FILE).exists():
543            logger.info(
544                "temporarily removing '{}' to execute example prediction", YAML_FILE
545            )
546            yaml_file_content = Path(YAML_FILE).read_bytes()
547            Path(YAML_FILE).unlink()
548
549        try:
550            _ = subprocess.run(get_example_command(True), check=True)
551            _ = subprocess.run(get_example_command(False), check=True)
552        finally:
553            if yaml_file_content is not None:
554                _ = Path(YAML_FILE).write_bytes(yaml_file_content)
555                logger.debug("restored '{}'", YAML_FILE)
556
557        print(
558            "🎉 Sucessfully ran example prediction!\n"
559            + "To predict the example input using the CLI example config file"
560            + f" {example_path/YAML_FILE}, execute `bioimageio predict` from {example_path}:\n"
561            + f"$ cd {str(example_path)}\n"
562            + f'$ bioimageio predict "{source_escaped}"\n\n'
563            + "Alternatively run the following command"
564            + " in the current workind directory, not the example folder:\n$ "
565            + " ".join(get_example_command(False, escape=True))
566            + f"\n(note that a local '{JSON_FILE}' or '{YAML_FILE}' may interfere with this)"
567        )
568
569    def run(self):
570        if self.example:
571            return self._example()
572
573        model_descr = ensure_description_is_model(self.descr)
574
575        input_ids = get_member_ids(model_descr.inputs)
576        output_ids = get_member_ids(model_descr.outputs)
577
578        minimum_input_ids = tuple(
579            str(ipt.id) if isinstance(ipt, v0_5.InputTensorDescr) else str(ipt.name)
580            for ipt in model_descr.inputs
581            if not isinstance(ipt, v0_5.InputTensorDescr) or not ipt.optional
582        )
583        maximum_input_ids = tuple(
584            str(ipt.id) if isinstance(ipt, v0_5.InputTensorDescr) else str(ipt.name)
585            for ipt in model_descr.inputs
586        )
587
588        def expand_inputs(i: int, ipt: Union[str, Tuple[str, ...]]) -> Tuple[str, ...]:
589            if isinstance(ipt, str):
590                ipts = tuple(
591                    ipt.format(model_id=self.descr_id, input_id=t) for t in input_ids
592                )
593            else:
594                ipts = tuple(
595                    p.format(model_id=self.descr_id, input_id=t)
596                    for t, p in zip(input_ids, ipt)
597                )
598
599            if len(set(ipts)) < len(ipts):
600                if len(minimum_input_ids) == len(maximum_input_ids):
601                    n = len(minimum_input_ids)
602                else:
603                    n = f"{len(minimum_input_ids)}-{len(maximum_input_ids)}"
604
605                raise ValueError(
606                    f"[input sample #{i}] Include '{{input_id}}' in path pattern or explicitly specify {n} distinct input paths (got {ipt})"
607                )
608
609            if len(ipts) < len(minimum_input_ids):
610                raise ValueError(
611                    f"[input sample #{i}] Expected at least {len(minimum_input_ids)} inputs {minimum_input_ids}, got {ipts}"
612                )
613
614            if len(ipts) > len(maximum_input_ids):
615                raise ValueError(
616                    f"Expected at most {len(maximum_input_ids)} inputs {maximum_input_ids}, got {ipts}"
617                )
618
619            return ipts
620
621        inputs = [expand_inputs(i, ipt) for i, ipt in enumerate(self.inputs, start=1)]
622
623        sample_paths_in = [
624            {t: Path(p) for t, p in zip(input_ids, ipts)} for ipts in inputs
625        ]
626
627        sample_ids = _get_sample_ids(sample_paths_in)
628
629        def expand_outputs():
630            if isinstance(self.outputs, str):
631                outputs = [
632                    tuple(
633                        Path(
634                            self.outputs.format(
635                                model_id=self.descr_id, output_id=t, sample_id=s
636                            )
637                        )
638                        for t in output_ids
639                    )
640                    for s in sample_ids
641                ]
642            else:
643                outputs = [
644                    tuple(
645                        Path(p.format(model_id=self.descr_id, output_id=t, sample_id=s))
646                        for t, p in zip(output_ids, self.outputs)
647                    )
648                    for s in sample_ids
649                ]
650
651            for i, out in enumerate(outputs, start=1):
652                if len(set(out)) < len(out):
653                    raise ValueError(
654                        f"[output sample #{i}] Include '{{output_id}}' in path pattern or explicitly specify {len(output_ids)} distinct output paths (got {out})"
655                    )
656
657                if len(out) != len(output_ids):
658                    raise ValueError(
659                        f"[output sample #{i}] Expected {len(output_ids)} outputs {output_ids}, got {out}"
660                    )
661
662            return outputs
663
664        outputs = expand_outputs()
665
666        sample_paths_out = [
667            {MemberId(t): Path(p) for t, p in zip(output_ids, out)} for out in outputs
668        ]
669
670        if not self.overwrite:
671            for sample_paths in sample_paths_out:
672                for p in sample_paths.values():
673                    if p.exists():
674                        raise FileExistsError(
675                            f"{p} already exists. use --overwrite to (re-)write outputs anyway."
676                        )
677        if self.preview:
678            print("🛈 bioimageio prediction preview structure:")
679            pprint(
680                {
681                    "{sample_id}": dict(
682                        inputs={"{input_id}": "<input path>"},
683                        outputs={"{output_id}": "<output path>"},
684                    )
685                }
686            )
687            print("🔎 bioimageio prediction preview output:")
688            pprint(
689                {
690                    s: dict(
691                        inputs={t: p.as_posix() for t, p in sp_in.items()},
692                        outputs={t: p.as_posix() for t, p in sp_out.items()},
693                    )
694                    for s, sp_in, sp_out in zip(
695                        sample_ids, sample_paths_in, sample_paths_out
696                    )
697                }
698            )
699            return
700
701        def input_dataset(stat: Stat):
702            for s, sp_in in zip(sample_ids, sample_paths_in):
703                yield load_sample_for_model(
704                    model=model_descr,
705                    paths=sp_in,
706                    stat=stat,
707                    sample_id=s,
708                )
709
710        stat: Dict[Measure, MeasureValue] = dict(
711            _get_stat(
712                model_descr, input_dataset({}), len(sample_ids), self.stats
713            ).items()
714        )
715
716        pp = create_prediction_pipeline(
717            model_descr,
718            weight_format=None if self.weight_format == "any" else self.weight_format,
719        )
720        predict_method = (
721            pp.predict_sample_with_blocking
722            if self.blockwise
723            else pp.predict_sample_without_blocking
724        )
725
726        for sample_in, sp_out in tqdm(
727            zip(input_dataset(dict(stat)), sample_paths_out),
728            total=len(inputs),
729            desc=f"predict with {self.descr_id}",
730            unit="sample",
731        ):
732            sample_out = predict_method(sample_in)
733            save_sample(sp_out, sample_out)
734
735
736class AddWeightsCmd(CmdBase, WithSource, WithSummaryLogging):
737    output: CliPositionalArg[Path]
738    """The path to write the updated model package to."""
739
740    source_format: Optional[SupportedWeightsFormat] = Field(None, alias="source-format")
741    """Exclusively use these weights to convert to other formats."""
742
743    target_format: Optional[SupportedWeightsFormat] = Field(None, alias="target-format")
744    """Exclusively add this weight format."""
745
746    verbose: bool = False
747    """Log more (error) output."""
748
749    def run(self):
750        model_descr = ensure_description_is_model(self.descr)
751        if isinstance(model_descr, v0_4.ModelDescr):
752            raise TypeError(
753                f"model format {model_descr.format_version} not supported."
754                + " Please update the model first."
755            )
756        updated_model_descr = add_weights(
757            model_descr,
758            output_path=self.output,
759            source_format=self.source_format,
760            target_format=self.target_format,
761            verbose=self.verbose,
762        )
763        if updated_model_descr is None:
764            return
765
766        self.log(updated_model_descr)
767
768
769JSON_FILE = "bioimageio-cli.json"
770YAML_FILE = "bioimageio-cli.yaml"
771
772
773class Bioimageio(
774    BaseSettings,
775    cli_implicit_flags=True,
776    cli_parse_args=True,
777    cli_prog_name="bioimageio",
778    cli_use_class_docs_for_groups=True,
779    use_attribute_docstrings=True,
780):
781    """bioimageio - CLI for bioimage.io resources 🦒"""
782
783    model_config = SettingsConfigDict(
784        json_file=JSON_FILE,
785        yaml_file=YAML_FILE,
786    )
787
788    validate_format: CliSubCommand[ValidateFormatCmd] = Field(alias="validate-format")
789    "Check a resource's metadata format"
790
791    test: CliSubCommand[TestCmd]
792    "Test a bioimageio resource (beyond meta data formatting)"
793
794    package: CliSubCommand[PackageCmd]
795    "Package a resource"
796
797    predict: CliSubCommand[PredictCmd]
798    "Predict with a model resource"
799
800    update_format: CliSubCommand[UpdateFormatCmd] = Field(alias="update-format")
801    """Update the metadata format"""
802
803    update_hashes: CliSubCommand[UpdateHashesCmd] = Field(alias="update-hashes")
804    """Create a bioimageio.yaml description with updated file hashes."""
805
806    add_weights: CliSubCommand[AddWeightsCmd] = Field(alias="add-weights")
807    """Add additional weights to the model descriptions converted from available
808    formats to improve deployability."""
809
810    @classmethod
811    def settings_customise_sources(
812        cls,
813        settings_cls: Type[BaseSettings],
814        init_settings: PydanticBaseSettingsSource,
815        env_settings: PydanticBaseSettingsSource,
816        dotenv_settings: PydanticBaseSettingsSource,
817        file_secret_settings: PydanticBaseSettingsSource,
818    ) -> Tuple[PydanticBaseSettingsSource, ...]:
819        cli: CliSettingsSource[BaseSettings] = CliSettingsSource(
820            settings_cls,
821            cli_parse_args=True,
822            formatter_class=RawTextHelpFormatter,
823        )
824        sys_args = pformat(sys.argv)
825        logger.info("starting CLI with arguments:\n{}", sys_args)
826        return (
827            cli,
828            init_settings,
829            YamlConfigSettingsSource(settings_cls),
830            JsonConfigSettingsSource(settings_cls),
831        )
832
833    @model_validator(mode="before")
834    @classmethod
835    def _log(cls, data: Any):
836        logger.info(
837            "loaded CLI input:\n{}",
838            pformat({k: v for k, v in data.items() if v is not None}),
839        )
840        return data
841
842    def run(self):
843        logger.info(
844            "executing CLI command:\n{}",
845            pformat({k: v for k, v in self.model_dump().items() if v is not None}),
846        )
847        cmd = (
848            self.add_weights
849            or self.package
850            or self.predict
851            or self.test
852            or self.update_format
853            or self.update_hashes
854            or self.validate_format
855        )
856        assert cmd is not None
857        cmd.run()
858
859
860assert isinstance(Bioimageio.__doc__, str)
861Bioimageio.__doc__ += f"""
862
863library versions:
864  bioimageio.core {VERSION}
865  bioimageio.spec {VERSION}
866
867spec format versions:
868        model RDF {ModelDescr.implemented_format_version}
869      dataset RDF {DatasetDescr.implemented_format_version}
870     notebook RDF {NotebookDescr.implemented_format_version}
871
872"""
873
874
875def _get_sample_ids(
876    input_paths: Sequence[Mapping[MemberId, Path]],
877) -> Sequence[SampleId]:
878    """Get sample ids for given input paths, based on the common path per sample.
879
880    Falls back to sample01, samle02, etc..."""
881
882    matcher = SequenceMatcher()
883
884    def get_common_seq(seqs: Sequence[Sequence[str]]) -> Sequence[str]:
885        """extract a common sequence from multiple sequences
886        (order sensitive; strips whitespace and slashes)
887        """
888        common = seqs[0]
889
890        for seq in seqs[1:]:
891            if not seq:
892                continue
893            matcher.set_seqs(common, seq)
894            i, _, size = matcher.find_longest_match()
895            common = common[i : i + size]
896
897        if isinstance(common, str):
898            common = common.strip().strip("/")
899        else:
900            common = [cs for c in common if (cs := c.strip().strip("/"))]
901
902        if not common:
903            raise ValueError(f"failed to find common sequence for {seqs}")
904
905        return common
906
907    def get_shorter_diff(seqs: Sequence[Sequence[str]]) -> List[Sequence[str]]:
908        """get a shorter sequence whose entries are still unique
909        (order sensitive, not minimal sequence)
910        """
911        min_seq_len = min(len(s) for s in seqs)
912        # cut from the start
913        for start in range(min_seq_len - 1, -1, -1):
914            shortened = [s[start:] for s in seqs]
915            if len(set(shortened)) == len(seqs):
916                min_seq_len -= start
917                break
918        else:
919            seen: Set[Sequence[str]] = set()
920            dupes = [s for s in seqs if s in seen or seen.add(s)]
921            raise ValueError(f"Found duplicate entries {dupes}")
922
923        # cut from the end
924        for end in range(min_seq_len - 1, 1, -1):
925            shortened = [s[:end] for s in shortened]
926            if len(set(shortened)) == len(seqs):
927                break
928
929        return shortened
930
931    full_tensor_ids = [
932        sorted(
933            p.resolve().with_suffix("").as_posix() for p in input_sample_paths.values()
934        )
935        for input_sample_paths in input_paths
936    ]
937    try:
938        long_sample_ids = [get_common_seq(t) for t in full_tensor_ids]
939        sample_ids = get_shorter_diff(long_sample_ids)
940    except ValueError as e:
941        raise ValueError(f"failed to extract sample ids: {e}")
942
943    return sample_ids
WEIGHT_FORMAT_ALIASES = AliasChoices(choices=['weight-format', 'weights-format', 'weight_format', 'weights_format'])
class CmdBase(pydantic.main.BaseModel):
94class CmdBase(BaseModel, use_attribute_docstrings=True, cli_implicit_flags=True):
95    pass

Usage docs: https://docs.pydantic.dev/2.9/concepts/models/

A base class for creating Pydantic models.

Attributes:
  • __class_vars__: The names of the class variables defined on the model.
  • __private_attributes__: Metadata about the private attributes of the model.
  • __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.
  • __pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
  • __pydantic_core_schema__: The core schema of the model.
  • __pydantic_custom_init__: Whether the model has a custom __init__ function.
  • __pydantic_decorators__: Metadata containing the decorators defined on the model. This replaces Model.__validators__ and Model.__root_validators__ from Pydantic V1.
  • __pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to __args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
  • __pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
  • __pydantic_post_init__: The name of the post-init method for the model, if defined.
  • __pydantic_root_model__: Whether the model is a [RootModel][pydantic.root_model.RootModel].
  • __pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
  • __pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
  • __pydantic_extra__: A dictionary containing extra values, if [extra][pydantic.config.ConfigDict.extra] is set to 'allow'.
  • __pydantic_fields_set__: The names of fields explicitly set during instantiation.
  • __pydantic_private__: Values of private attributes set on the model instance.
class ArgMixin(pydantic.main.BaseModel):
98class ArgMixin(BaseModel, use_attribute_docstrings=True, cli_implicit_flags=True):
99    pass

Usage docs: https://docs.pydantic.dev/2.9/concepts/models/

A base class for creating Pydantic models.

Attributes:
  • __class_vars__: The names of the class variables defined on the model.
  • __private_attributes__: Metadata about the private attributes of the model.
  • __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.
  • __pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
  • __pydantic_core_schema__: The core schema of the model.
  • __pydantic_custom_init__: Whether the model has a custom __init__ function.
  • __pydantic_decorators__: Metadata containing the decorators defined on the model. This replaces Model.__validators__ and Model.__root_validators__ from Pydantic V1.
  • __pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to __args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
  • __pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
  • __pydantic_post_init__: The name of the post-init method for the model, if defined.
  • __pydantic_root_model__: Whether the model is a [RootModel][pydantic.root_model.RootModel].
  • __pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
  • __pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
  • __pydantic_extra__: A dictionary containing extra values, if [extra][pydantic.config.ConfigDict.extra] is set to 'allow'.
  • __pydantic_fields_set__: The names of fields explicitly set during instantiation.
  • __pydantic_private__: Values of private attributes set on the model instance.
class WithSummaryLogging(ArgMixin):
102class WithSummaryLogging(ArgMixin):
103    summary: Union[
104        Literal["display"], Path, Sequence[Union[Literal["display"], Path]]
105    ] = Field(
106        "display",
107        examples=[
108            "display",
109            Path("summary.md"),
110            Path("bioimageio_summaries/"),
111            ["display", Path("summary.md")],
112        ],
113    )
114    """Display the validation summary or save it as JSON, Markdown or HTML.
115    The format is chosen based on the suffix: `.json`, `.md`, `.html`.
116    If a folder is given (path w/o suffix) the summary is saved in all formats.
117    Choose/add `"display"` to render the validation summary to the terminal.
118    """
119
120    def log(self, descr: Union[ResourceDescr, InvalidDescr]):
121        _ = descr.validation_summary.log(self.summary)

Usage docs: https://docs.pydantic.dev/2.9/concepts/models/

A base class for creating Pydantic models.

Attributes:
  • __class_vars__: The names of the class variables defined on the model.
  • __private_attributes__: Metadata about the private attributes of the model.
  • __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.
  • __pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
  • __pydantic_core_schema__: The core schema of the model.
  • __pydantic_custom_init__: Whether the model has a custom __init__ function.
  • __pydantic_decorators__: Metadata containing the decorators defined on the model. This replaces Model.__validators__ and Model.__root_validators__ from Pydantic V1.
  • __pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to __args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
  • __pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
  • __pydantic_post_init__: The name of the post-init method for the model, if defined.
  • __pydantic_root_model__: Whether the model is a [RootModel][pydantic.root_model.RootModel].
  • __pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
  • __pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
  • __pydantic_extra__: A dictionary containing extra values, if [extra][pydantic.config.ConfigDict.extra] is set to 'allow'.
  • __pydantic_fields_set__: The names of fields explicitly set during instantiation.
  • __pydantic_private__: Values of private attributes set on the model instance.
summary: Union[Literal['display'], pathlib.Path, Sequence[Union[Literal['display'], pathlib.Path]]]

Display the validation summary or save it as JSON, Markdown or HTML. The format is chosen based on the suffix: .json, .md, .html. If a folder is given (path w/o suffix) the summary is saved in all formats. Choose/add "display" to render the validation summary to the terminal.

def log( self, descr: Union[Annotated[Union[Annotated[Union[Annotated[bioimageio.spec.application.v0_2.ApplicationDescr, FieldInfo(annotation=NoneType, required=True, title='application 0.2')], Annotated[bioimageio.spec.ApplicationDescr, FieldInfo(annotation=NoneType, required=True, title='application 0.3')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='application')], Annotated[Union[Annotated[bioimageio.spec.dataset.v0_2.DatasetDescr, FieldInfo(annotation=NoneType, required=True, title='dataset 0.2')], Annotated[bioimageio.spec.DatasetDescr, FieldInfo(annotation=NoneType, required=True, title='dataset 0.3')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='dataset')], Annotated[Union[Annotated[bioimageio.spec.model.v0_4.ModelDescr, FieldInfo(annotation=NoneType, required=True, title='model 0.4')], Annotated[bioimageio.spec.ModelDescr, FieldInfo(annotation=NoneType, required=True, title='model 0.5')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='model')], Annotated[Union[Annotated[bioimageio.spec.NotebookDescr, FieldInfo(annotation=NoneType, required=True, title='notebook 0.2')], Annotated[bioimageio.spec.NotebookDescr, FieldInfo(annotation=NoneType, required=True, title='notebook 0.3')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='notebook')]], Discriminator(discriminator='type', custom_error_type=None, custom_error_message=None, custom_error_context=None)], Annotated[Union[Annotated[bioimageio.spec.generic.v0_2.GenericDescr, FieldInfo(annotation=NoneType, required=True, title='generic 0.2')], Annotated[bioimageio.spec.GenericDescr, FieldInfo(annotation=NoneType, required=True, title='generic 0.3')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='generic')], bioimageio.spec.InvalidDescr]):
120    def log(self, descr: Union[ResourceDescr, InvalidDescr]):
121        _ = descr.validation_summary.log(self.summary)
class WithSource(ArgMixin):
124class WithSource(ArgMixin):
125    source: CliPositionalArg[str]
126    """Url/path to a (folder with a) bioimageio.yaml/rdf.yaml file
127    or a bioimage.io resource identifier, e.g. 'affable-shark'"""
128
129    @cached_property
130    def descr(self):
131        return load_description(self.source)
132
133    @property
134    def descr_id(self) -> str:
135        """a more user-friendly description id
136        (replacing legacy ids with their nicknames)
137        """
138        if isinstance(self.descr, InvalidDescr):
139            return str(getattr(self.descr, "id", getattr(self.descr, "name")))
140
141        nickname = None
142        if (
143            isinstance(self.descr.config, v0_5.Config)
144            and (bio_config := self.descr.config.bioimageio)
145            and bio_config.model_extra is not None
146        ):
147            nickname = bio_config.model_extra.get("nickname")
148
149        return str(nickname or self.descr.id or self.descr.name)

Usage docs: https://docs.pydantic.dev/2.9/concepts/models/

A base class for creating Pydantic models.

Attributes:
  • __class_vars__: The names of the class variables defined on the model.
  • __private_attributes__: Metadata about the private attributes of the model.
  • __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.
  • __pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
  • __pydantic_core_schema__: The core schema of the model.
  • __pydantic_custom_init__: Whether the model has a custom __init__ function.
  • __pydantic_decorators__: Metadata containing the decorators defined on the model. This replaces Model.__validators__ and Model.__root_validators__ from Pydantic V1.
  • __pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to __args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
  • __pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
  • __pydantic_post_init__: The name of the post-init method for the model, if defined.
  • __pydantic_root_model__: Whether the model is a [RootModel][pydantic.root_model.RootModel].
  • __pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
  • __pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
  • __pydantic_extra__: A dictionary containing extra values, if [extra][pydantic.config.ConfigDict.extra] is set to 'allow'.
  • __pydantic_fields_set__: The names of fields explicitly set during instantiation.
  • __pydantic_private__: Values of private attributes set on the model instance.
source: Annotated[str, <class 'pydantic_settings.sources._CliPositionalArg'>]

Url/path to a (folder with a) bioimageio.yaml/rdf.yaml file or a bioimage.io resource identifier, e.g. 'affable-shark'

descr
129    @cached_property
130    def descr(self):
131        return load_description(self.source)
descr_id: str
133    @property
134    def descr_id(self) -> str:
135        """a more user-friendly description id
136        (replacing legacy ids with their nicknames)
137        """
138        if isinstance(self.descr, InvalidDescr):
139            return str(getattr(self.descr, "id", getattr(self.descr, "name")))
140
141        nickname = None
142        if (
143            isinstance(self.descr.config, v0_5.Config)
144            and (bio_config := self.descr.config.bioimageio)
145            and bio_config.model_extra is not None
146        ):
147            nickname = bio_config.model_extra.get("nickname")
148
149        return str(nickname or self.descr.id or self.descr.name)

a more user-friendly description id (replacing legacy ids with their nicknames)

class ValidateFormatCmd(CmdBase, WithSource, WithSummaryLogging):
152class ValidateFormatCmd(CmdBase, WithSource, WithSummaryLogging):
153    """Validate the meta data format of a bioimageio resource."""
154
155    perform_io_checks: bool = Field(
156        settings.perform_io_checks, alias="perform-io-checks"
157    )
158    """Wether or not to perform validations that requires downloading remote files.
159    Note: Default value is set by `BIOIMAGEIO_PERFORM_IO_CHECKS` environment variable.
160    """
161
162    @cached_property
163    def descr(self):
164        return load_description(self.source, perform_io_checks=self.perform_io_checks)
165
166    def run(self):
167        self.log(self.descr)
168        sys.exit(
169            0
170            if self.descr.validation_summary.status in ("valid-format", "passed")
171            else 1
172        )

Validate the meta data format of a bioimageio resource.

perform_io_checks: bool

Wether or not to perform validations that requires downloading remote files. Note: Default value is set by BIOIMAGEIO_PERFORM_IO_CHECKS environment variable.

descr
162    @cached_property
163    def descr(self):
164        return load_description(self.source, perform_io_checks=self.perform_io_checks)
def run(self):
166    def run(self):
167        self.log(self.descr)
168        sys.exit(
169            0
170            if self.descr.validation_summary.status in ("valid-format", "passed")
171            else 1
172        )
class TestCmd(CmdBase, WithSource, WithSummaryLogging):
175class TestCmd(CmdBase, WithSource, WithSummaryLogging):
176    """Test a bioimageio resource (beyond meta data formatting)."""
177
178    weight_format: WeightFormatArgAll = Field(
179        "all",
180        alias="weight-format",
181        validation_alias=WEIGHT_FORMAT_ALIASES,
182    )
183    """The weight format to limit testing to.
184
185    (only relevant for model resources)"""
186
187    devices: Optional[Union[str, Sequence[str]]] = None
188    """Device(s) to use for testing"""
189
190    runtime_env: Union[Literal["currently-active", "as-described"], Path] = Field(
191        "currently-active", alias="runtime-env"
192    )
193    """The python environment to run the tests in
194        - `"currently-active"`: use active Python interpreter
195        - `"as-described"`: generate a conda environment YAML file based on the model
196            weights description.
197        - A path to a conda environment YAML.
198          Note: The `bioimageio.core` dependency will be added automatically if not present.
199    """
200
201    determinism: Literal["seed_only", "full"] = "seed_only"
202    """Modes to improve reproducibility of test outputs."""
203
204    stop_early: bool = Field(
205        False, alias="stop-early", validation_alias=AliasChoices("stop-early", "x")
206    )
207    """Do not run further subtests after a failed one."""
208
209    def run(self):
210        sys.exit(
211            test(
212                self.descr,
213                weight_format=self.weight_format,
214                devices=self.devices,
215                summary=self.summary,
216                runtime_env=self.runtime_env,
217                determinism=self.determinism,
218            )
219        )

Test a bioimageio resource (beyond meta data formatting).

weight_format: Literal['keras_hdf5', 'onnx', 'pytorch_state_dict', 'tensorflow_saved_model_bundle', 'torchscript', 'all']

The weight format to limit testing to.

(only relevant for model resources)

devices: Union[str, Sequence[str], NoneType]

Device(s) to use for testing

runtime_env: Union[Literal['currently-active', 'as-described'], pathlib.Path]

The python environment to run the tests in

  • "currently-active": use active Python interpreter
  • "as-described": generate a conda environment YAML file based on the model weights description.
  • A path to a conda environment YAML. Note: The bioimageio.core dependency will be added automatically if not present.
determinism: Literal['seed_only', 'full']

Modes to improve reproducibility of test outputs.

stop_early: bool

Do not run further subtests after a failed one.

def run(self):
209    def run(self):
210        sys.exit(
211            test(
212                self.descr,
213                weight_format=self.weight_format,
214                devices=self.devices,
215                summary=self.summary,
216                runtime_env=self.runtime_env,
217                determinism=self.determinism,
218            )
219        )
class PackageCmd(CmdBase, WithSource, WithSummaryLogging):
222class PackageCmd(CmdBase, WithSource, WithSummaryLogging):
223    """Save a resource's metadata with its associated files."""
224
225    path: CliPositionalArg[Path]
226    """The path to write the (zipped) package to.
227    If it does not have a `.zip` suffix
228    this command will save the package as an unzipped folder instead."""
229
230    weight_format: WeightFormatArgAll = Field(
231        "all",
232        alias="weight-format",
233        validation_alias=WEIGHT_FORMAT_ALIASES,
234    )
235    """The weight format to include in the package (for model descriptions only)."""
236
237    def run(self):
238        if isinstance(self.descr, InvalidDescr):
239            self.log(self.descr)
240            raise ValueError(f"Invalid {self.descr.type} description.")
241
242        sys.exit(
243            package(
244                self.descr,
245                self.path,
246                weight_format=self.weight_format,
247            )
248        )

Save a resource's metadata with its associated files.

path: Annotated[pathlib.Path, <class 'pydantic_settings.sources._CliPositionalArg'>]

The path to write the (zipped) package to. If it does not have a .zip suffix this command will save the package as an unzipped folder instead.

weight_format: Literal['keras_hdf5', 'onnx', 'pytorch_state_dict', 'tensorflow_saved_model_bundle', 'torchscript', 'all']

The weight format to include in the package (for model descriptions only).

def run(self):
237    def run(self):
238        if isinstance(self.descr, InvalidDescr):
239            self.log(self.descr)
240            raise ValueError(f"Invalid {self.descr.type} description.")
241
242        sys.exit(
243            package(
244                self.descr,
245                self.path,
246                weight_format=self.weight_format,
247            )
248        )
class UpdateCmdBase(CmdBase, WithSource, abc.ABC):
285class UpdateCmdBase(CmdBase, WithSource, ABC):
286    output: Union[Literal["display", "stdout"], Path] = "display"
287    """Output updated bioimageio.yaml to the terminal or write to a file.
288    Notes:
289    - `"display"`: Render to the terminal with syntax highlighting.
290    - `"stdout"`: Write to sys.stdout without syntax highligthing.
291      (More convenient for copying the updated bioimageio.yaml from the terminal.)
292    """
293
294    diff: Union[bool, Path] = Field(True, alias="diff")
295    """Output a diff of original and updated bioimageio.yaml.
296    If a given path has an `.html` extension, a standalone HTML file is written,
297    otherwise the diff is saved in unified diff format (pure text).
298    """
299
300    exclude_unset: bool = Field(True, alias="exclude-unset")
301    """Exclude fields that have not explicitly be set."""
302
303    exclude_defaults: bool = Field(False, alias="exclude-defaults")
304    """Exclude fields that have the default value (even if set explicitly)."""
305
306    @cached_property
307    def updated(self) -> Union[ResourceDescr, InvalidDescr]:
308        raise NotImplementedError
309
310    def run(self):
311        original_yaml = open_bioimageio_yaml(self.source).unparsed_content
312        assert isinstance(original_yaml, str)
313        stream = StringIO()
314
315        save_bioimageio_yaml_only(
316            self.updated,
317            stream,
318            exclude_unset=self.exclude_unset,
319            exclude_defaults=self.exclude_defaults,
320        )
321        updated_yaml = stream.getvalue()
322
323        diff = compare(
324            original_yaml.split("\n"),
325            updated_yaml.split("\n"),
326            diff_format=(
327                "html"
328                if isinstance(self.diff, Path) and self.diff.suffix == ".html"
329                else "unified"
330            ),
331        )
332
333        if isinstance(self.diff, Path):
334            _ = self.diff.write_text(diff, encoding="utf-8")
335        elif self.diff:
336            console = rich.console.Console()
337            diff_md = f"## Diff\n\n````````diff\n{diff}\n````````"
338            console.print(rich.markdown.Markdown(diff_md))
339
340        if isinstance(self.output, Path):
341            _ = self.output.write_text(updated_yaml, encoding="utf-8")
342            logger.info(f"written updated description to {self.output}")
343        elif self.output == "display":
344            updated_md = f"## Updated bioimageio.yaml\n\n```yaml\n{updated_yaml}\n```"
345            rich.console.Console().print(rich.markdown.Markdown(updated_md))
346        elif self.output == "stdout":
347            print(updated_yaml)
348        else:
349            assert_never(self.output)
350
351        if isinstance(self.updated, InvalidDescr):
352            logger.warning("Update resulted in invalid description")
353            _ = self.updated.validation_summary.display()

Usage docs: https://docs.pydantic.dev/2.9/concepts/models/

A base class for creating Pydantic models.

Attributes:
  • __class_vars__: The names of the class variables defined on the model.
  • __private_attributes__: Metadata about the private attributes of the model.
  • __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.
  • __pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
  • __pydantic_core_schema__: The core schema of the model.
  • __pydantic_custom_init__: Whether the model has a custom __init__ function.
  • __pydantic_decorators__: Metadata containing the decorators defined on the model. This replaces Model.__validators__ and Model.__root_validators__ from Pydantic V1.
  • __pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to __args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
  • __pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
  • __pydantic_post_init__: The name of the post-init method for the model, if defined.
  • __pydantic_root_model__: Whether the model is a [RootModel][pydantic.root_model.RootModel].
  • __pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
  • __pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
  • __pydantic_extra__: A dictionary containing extra values, if [extra][pydantic.config.ConfigDict.extra] is set to 'allow'.
  • __pydantic_fields_set__: The names of fields explicitly set during instantiation.
  • __pydantic_private__: Values of private attributes set on the model instance.
output: Union[Literal['display', 'stdout'], pathlib.Path]

Output updated bioimageio.yaml to the terminal or write to a file. Notes:

  • "display": Render to the terminal with syntax highlighting.
  • "stdout": Write to sys.stdout without syntax highligthing. (More convenient for copying the updated bioimageio.yaml from the terminal.)
diff: Union[bool, pathlib.Path]

Output a diff of original and updated bioimageio.yaml. If a given path has an .html extension, a standalone HTML file is written, otherwise the diff is saved in unified diff format (pure text).

exclude_unset: bool

Exclude fields that have not explicitly be set.

exclude_defaults: bool

Exclude fields that have the default value (even if set explicitly).

updated: Union[Annotated[Union[Annotated[Union[Annotated[bioimageio.spec.application.v0_2.ApplicationDescr, FieldInfo(annotation=NoneType, required=True, title='application 0.2')], Annotated[bioimageio.spec.ApplicationDescr, FieldInfo(annotation=NoneType, required=True, title='application 0.3')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='application')], Annotated[Union[Annotated[bioimageio.spec.dataset.v0_2.DatasetDescr, FieldInfo(annotation=NoneType, required=True, title='dataset 0.2')], Annotated[bioimageio.spec.DatasetDescr, FieldInfo(annotation=NoneType, required=True, title='dataset 0.3')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='dataset')], Annotated[Union[Annotated[bioimageio.spec.model.v0_4.ModelDescr, FieldInfo(annotation=NoneType, required=True, title='model 0.4')], Annotated[bioimageio.spec.ModelDescr, FieldInfo(annotation=NoneType, required=True, title='model 0.5')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='model')], Annotated[Union[Annotated[bioimageio.spec.NotebookDescr, FieldInfo(annotation=NoneType, required=True, title='notebook 0.2')], Annotated[bioimageio.spec.NotebookDescr, FieldInfo(annotation=NoneType, required=True, title='notebook 0.3')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='notebook')]], Discriminator(discriminator='type', custom_error_type=None, custom_error_message=None, custom_error_context=None)], Annotated[Union[Annotated[bioimageio.spec.generic.v0_2.GenericDescr, FieldInfo(annotation=NoneType, required=True, title='generic 0.2')], Annotated[bioimageio.spec.GenericDescr, FieldInfo(annotation=NoneType, required=True, title='generic 0.3')]], Discriminator(discriminator='format_version', custom_error_type=None, custom_error_message=None, custom_error_context=None), FieldInfo(annotation=NoneType, required=True, title='generic')], bioimageio.spec.InvalidDescr]
306    @cached_property
307    def updated(self) -> Union[ResourceDescr, InvalidDescr]:
308        raise NotImplementedError
def run(self):
310    def run(self):
311        original_yaml = open_bioimageio_yaml(self.source).unparsed_content
312        assert isinstance(original_yaml, str)
313        stream = StringIO()
314
315        save_bioimageio_yaml_only(
316            self.updated,
317            stream,
318            exclude_unset=self.exclude_unset,
319            exclude_defaults=self.exclude_defaults,
320        )
321        updated_yaml = stream.getvalue()
322
323        diff = compare(
324            original_yaml.split("\n"),
325            updated_yaml.split("\n"),
326            diff_format=(
327                "html"
328                if isinstance(self.diff, Path) and self.diff.suffix == ".html"
329                else "unified"
330            ),
331        )
332
333        if isinstance(self.diff, Path):
334            _ = self.diff.write_text(diff, encoding="utf-8")
335        elif self.diff:
336            console = rich.console.Console()
337            diff_md = f"## Diff\n\n````````diff\n{diff}\n````````"
338            console.print(rich.markdown.Markdown(diff_md))
339
340        if isinstance(self.output, Path):
341            _ = self.output.write_text(updated_yaml, encoding="utf-8")
342            logger.info(f"written updated description to {self.output}")
343        elif self.output == "display":
344            updated_md = f"## Updated bioimageio.yaml\n\n```yaml\n{updated_yaml}\n```"
345            rich.console.Console().print(rich.markdown.Markdown(updated_md))
346        elif self.output == "stdout":
347            print(updated_yaml)
348        else:
349            assert_never(self.output)
350
351        if isinstance(self.updated, InvalidDescr):
352            logger.warning("Update resulted in invalid description")
353            _ = self.updated.validation_summary.display()
Inherited Members
WithSource
source
descr
descr_id
class UpdateFormatCmd(UpdateCmdBase):
356class UpdateFormatCmd(UpdateCmdBase):
357    """Update the metadata format to the latest format version."""
358
359    exclude_defaults: bool = Field(True, alias="exclude-defaults")
360    """Exclude fields that have the default value (even if set explicitly).
361
362    Note:
363        The update process sets most unset fields explicitly with their default value.
364    """
365
366    perform_io_checks: bool = Field(
367        settings.perform_io_checks, alias="perform-io-checks"
368    )
369    """Wether or not to attempt validation that may require file download.
370    If `True` file hash values are added if not present."""
371
372    @cached_property
373    def updated(self):
374        return update_format(
375            self.source,
376            exclude_defaults=self.exclude_defaults,
377            perform_io_checks=self.perform_io_checks,
378        )

Update the metadata format to the latest format version.

exclude_defaults: bool

Exclude fields that have the default value (even if set explicitly).

Note:

The update process sets most unset fields explicitly with their default value.

perform_io_checks: bool

Wether or not to attempt validation that may require file download. If True file hash values are added if not present.

updated
372    @cached_property
373    def updated(self):
374        return update_format(
375            self.source,
376            exclude_defaults=self.exclude_defaults,
377            perform_io_checks=self.perform_io_checks,
378        )
class UpdateHashesCmd(UpdateCmdBase):
381class UpdateHashesCmd(UpdateCmdBase):
382    """Create a bioimageio.yaml description with updated file hashes."""
383
384    @cached_property
385    def updated(self):
386        return update_hashes(self.source)

Create a bioimageio.yaml description with updated file hashes.

updated
384    @cached_property
385    def updated(self):
386        return update_hashes(self.source)
class PredictCmd(CmdBase, WithSource):
389class PredictCmd(CmdBase, WithSource):
390    """Run inference on your data with a bioimage.io model."""
391
392    inputs: NotEmpty[Sequence[Union[str, NotEmpty[Tuple[str, ...]]]]] = (
393        "{input_id}/001.tif",
394    )
395    """Model input sample paths (for each input tensor)
396
397    The input paths are expected to have shape...
398     - (n_samples,) or (n_samples,1) for models expecting a single input tensor
399     - (n_samples,) containing the substring '{input_id}', or
400     - (n_samples, n_model_inputs) to provide each input tensor path explicitly.
401
402    All substrings that are replaced by metadata from the model description:
403    - '{model_id}'
404    - '{input_id}'
405
406    Example inputs to process sample 'a' and 'b'
407    for a model expecting a 'raw' and a 'mask' input tensor:
408    --inputs="[[\\"a_raw.tif\\",\\"a_mask.tif\\"],[\\"b_raw.tif\\",\\"b_mask.tif\\"]]"
409    (Note that JSON double quotes need to be escaped.)
410
411    Alternatively a `bioimageio-cli.yaml` (or `bioimageio-cli.json`) file
412    may provide the arguments, e.g.:
413    ```yaml
414    inputs:
415    - [a_raw.tif, a_mask.tif]
416    - [b_raw.tif, b_mask.tif]
417    ```
418
419    `.npy` and any file extension supported by imageio are supported.
420     Aavailable formats are listed at
421    https://imageio.readthedocs.io/en/stable/formats/index.html#all-formats.
422    Some formats have additional dependencies.
423
424
425    """
426
427    outputs: Union[str, NotEmpty[Tuple[str, ...]]] = (
428        "outputs_{model_id}/{output_id}/{sample_id}.tif"
429    )
430    """Model output path pattern (per output tensor)
431
432    All substrings that are replaced:
433    - '{model_id}' (from model description)
434    - '{output_id}' (from model description)
435    - '{sample_id}' (extracted from input paths)
436
437
438    """
439
440    overwrite: bool = False
441    """allow overwriting existing output files"""
442
443    blockwise: bool = False
444    """process inputs blockwise"""
445
446    stats: Path = Path("dataset_statistics.json")
447    """path to dataset statistics
448    (will be written if it does not exist,
449    but the model requires statistical dataset measures)
450     """
451
452    preview: bool = False
453    """preview which files would be processed
454    and what outputs would be generated."""
455
456    weight_format: WeightFormatArgAny = Field(
457        "any",
458        alias="weight-format",
459        validation_alias=WEIGHT_FORMAT_ALIASES,
460    )
461    """The weight format to use."""
462
463    example: bool = False
464    """generate and run an example
465
466    1. downloads example model inputs
467    2. creates a `{model_id}_example` folder
468    3. writes input arguments to `{model_id}_example/bioimageio-cli.yaml`
469    4. executes a preview dry-run
470    5. executes prediction with example input
471
472
473    """
474
475    def _example(self):
476        model_descr = ensure_description_is_model(self.descr)
477        input_ids = get_member_ids(model_descr.inputs)
478        example_inputs = (
479            model_descr.sample_inputs
480            if isinstance(model_descr, v0_4.ModelDescr)
481            else [ipt.sample_tensor or ipt.test_tensor for ipt in model_descr.inputs]
482        )
483        if not example_inputs:
484            raise ValueError(f"{self.descr_id} does not specify any example inputs.")
485
486        inputs001: List[str] = []
487        example_path = Path(f"{self.descr_id}_example")
488        example_path.mkdir(exist_ok=True)
489
490        for t, src in zip(input_ids, example_inputs):
491            local = download(src).path
492            dst = Path(f"{example_path}/{t}/001{''.join(local.suffixes)}")
493            dst.parent.mkdir(parents=True, exist_ok=True)
494            inputs001.append(dst.as_posix())
495            if isinstance(local, Path):
496                shutil.copy(local, dst)
497            elif isinstance(local, ZipPath):
498                _ = local.root.extract(local.at, path=dst)
499            else:
500                assert_never(local)
501
502        inputs = [tuple(inputs001)]
503        output_pattern = f"{example_path}/outputs/{{output_id}}/{{sample_id}}.tif"
504
505        bioimageio_cli_path = example_path / YAML_FILE
506        stats_file = "dataset_statistics.json"
507        stats = (example_path / stats_file).as_posix()
508        cli_example_args = dict(
509            inputs=inputs,
510            outputs=output_pattern,
511            stats=stats_file,
512            blockwise=self.blockwise,
513        )
514        assert is_yaml_value(cli_example_args)
515        write_yaml(
516            cli_example_args,
517            bioimageio_cli_path,
518        )
519
520        yaml_file_content = None
521
522        # escaped double quotes
523        inputs_json = json.dumps(inputs)
524        inputs_escaped = inputs_json.replace('"', r"\"")
525        source_escaped = self.source.replace('"', r"\"")
526
527        def get_example_command(preview: bool, escape: bool = False):
528            q: str = '"' if escape else ""
529
530            return [
531                "bioimageio",
532                "predict",
533                # --no-preview not supported for py=3.8
534                *(["--preview"] if preview else []),
535                "--overwrite",
536                *(["--blockwise"] if self.blockwise else []),
537                f"--stats={q}{stats}{q}",
538                f"--inputs={q}{inputs_escaped if escape else inputs_json}{q}",
539                f"--outputs={q}{output_pattern}{q}",
540                f"{q}{source_escaped if escape else self.source}{q}",
541            ]
542
543        if Path(YAML_FILE).exists():
544            logger.info(
545                "temporarily removing '{}' to execute example prediction", YAML_FILE
546            )
547            yaml_file_content = Path(YAML_FILE).read_bytes()
548            Path(YAML_FILE).unlink()
549
550        try:
551            _ = subprocess.run(get_example_command(True), check=True)
552            _ = subprocess.run(get_example_command(False), check=True)
553        finally:
554            if yaml_file_content is not None:
555                _ = Path(YAML_FILE).write_bytes(yaml_file_content)
556                logger.debug("restored '{}'", YAML_FILE)
557
558        print(
559            "🎉 Sucessfully ran example prediction!\n"
560            + "To predict the example input using the CLI example config file"
561            + f" {example_path/YAML_FILE}, execute `bioimageio predict` from {example_path}:\n"
562            + f"$ cd {str(example_path)}\n"
563            + f'$ bioimageio predict "{source_escaped}"\n\n'
564            + "Alternatively run the following command"
565            + " in the current workind directory, not the example folder:\n$ "
566            + " ".join(get_example_command(False, escape=True))
567            + f"\n(note that a local '{JSON_FILE}' or '{YAML_FILE}' may interfere with this)"
568        )
569
570    def run(self):
571        if self.example:
572            return self._example()
573
574        model_descr = ensure_description_is_model(self.descr)
575
576        input_ids = get_member_ids(model_descr.inputs)
577        output_ids = get_member_ids(model_descr.outputs)
578
579        minimum_input_ids = tuple(
580            str(ipt.id) if isinstance(ipt, v0_5.InputTensorDescr) else str(ipt.name)
581            for ipt in model_descr.inputs
582            if not isinstance(ipt, v0_5.InputTensorDescr) or not ipt.optional
583        )
584        maximum_input_ids = tuple(
585            str(ipt.id) if isinstance(ipt, v0_5.InputTensorDescr) else str(ipt.name)
586            for ipt in model_descr.inputs
587        )
588
589        def expand_inputs(i: int, ipt: Union[str, Tuple[str, ...]]) -> Tuple[str, ...]:
590            if isinstance(ipt, str):
591                ipts = tuple(
592                    ipt.format(model_id=self.descr_id, input_id=t) for t in input_ids
593                )
594            else:
595                ipts = tuple(
596                    p.format(model_id=self.descr_id, input_id=t)
597                    for t, p in zip(input_ids, ipt)
598                )
599
600            if len(set(ipts)) < len(ipts):
601                if len(minimum_input_ids) == len(maximum_input_ids):
602                    n = len(minimum_input_ids)
603                else:
604                    n = f"{len(minimum_input_ids)}-{len(maximum_input_ids)}"
605
606                raise ValueError(
607                    f"[input sample #{i}] Include '{{input_id}}' in path pattern or explicitly specify {n} distinct input paths (got {ipt})"
608                )
609
610            if len(ipts) < len(minimum_input_ids):
611                raise ValueError(
612                    f"[input sample #{i}] Expected at least {len(minimum_input_ids)} inputs {minimum_input_ids}, got {ipts}"
613                )
614
615            if len(ipts) > len(maximum_input_ids):
616                raise ValueError(
617                    f"Expected at most {len(maximum_input_ids)} inputs {maximum_input_ids}, got {ipts}"
618                )
619
620            return ipts
621
622        inputs = [expand_inputs(i, ipt) for i, ipt in enumerate(self.inputs, start=1)]
623
624        sample_paths_in = [
625            {t: Path(p) for t, p in zip(input_ids, ipts)} for ipts in inputs
626        ]
627
628        sample_ids = _get_sample_ids(sample_paths_in)
629
630        def expand_outputs():
631            if isinstance(self.outputs, str):
632                outputs = [
633                    tuple(
634                        Path(
635                            self.outputs.format(
636                                model_id=self.descr_id, output_id=t, sample_id=s
637                            )
638                        )
639                        for t in output_ids
640                    )
641                    for s in sample_ids
642                ]
643            else:
644                outputs = [
645                    tuple(
646                        Path(p.format(model_id=self.descr_id, output_id=t, sample_id=s))
647                        for t, p in zip(output_ids, self.outputs)
648                    )
649                    for s in sample_ids
650                ]
651
652            for i, out in enumerate(outputs, start=1):
653                if len(set(out)) < len(out):
654                    raise ValueError(
655                        f"[output sample #{i}] Include '{{output_id}}' in path pattern or explicitly specify {len(output_ids)} distinct output paths (got {out})"
656                    )
657
658                if len(out) != len(output_ids):
659                    raise ValueError(
660                        f"[output sample #{i}] Expected {len(output_ids)} outputs {output_ids}, got {out}"
661                    )
662
663            return outputs
664
665        outputs = expand_outputs()
666
667        sample_paths_out = [
668            {MemberId(t): Path(p) for t, p in zip(output_ids, out)} for out in outputs
669        ]
670
671        if not self.overwrite:
672            for sample_paths in sample_paths_out:
673                for p in sample_paths.values():
674                    if p.exists():
675                        raise FileExistsError(
676                            f"{p} already exists. use --overwrite to (re-)write outputs anyway."
677                        )
678        if self.preview:
679            print("🛈 bioimageio prediction preview structure:")
680            pprint(
681                {
682                    "{sample_id}": dict(
683                        inputs={"{input_id}": "<input path>"},
684                        outputs={"{output_id}": "<output path>"},
685                    )
686                }
687            )
688            print("🔎 bioimageio prediction preview output:")
689            pprint(
690                {
691                    s: dict(
692                        inputs={t: p.as_posix() for t, p in sp_in.items()},
693                        outputs={t: p.as_posix() for t, p in sp_out.items()},
694                    )
695                    for s, sp_in, sp_out in zip(
696                        sample_ids, sample_paths_in, sample_paths_out
697                    )
698                }
699            )
700            return
701
702        def input_dataset(stat: Stat):
703            for s, sp_in in zip(sample_ids, sample_paths_in):
704                yield load_sample_for_model(
705                    model=model_descr,
706                    paths=sp_in,
707                    stat=stat,
708                    sample_id=s,
709                )
710
711        stat: Dict[Measure, MeasureValue] = dict(
712            _get_stat(
713                model_descr, input_dataset({}), len(sample_ids), self.stats
714            ).items()
715        )
716
717        pp = create_prediction_pipeline(
718            model_descr,
719            weight_format=None if self.weight_format == "any" else self.weight_format,
720        )
721        predict_method = (
722            pp.predict_sample_with_blocking
723            if self.blockwise
724            else pp.predict_sample_without_blocking
725        )
726
727        for sample_in, sp_out in tqdm(
728            zip(input_dataset(dict(stat)), sample_paths_out),
729            total=len(inputs),
730            desc=f"predict with {self.descr_id}",
731            unit="sample",
732        ):
733            sample_out = predict_method(sample_in)
734            save_sample(sp_out, sample_out)

Run inference on your data with a bioimage.io model.

inputs: Annotated[Sequence[Union[str, Annotated[Tuple[str, ...], MinLen(min_length=1)]]], MinLen(min_length=1)]

Model input sample paths (for each input tensor)

The input paths are expected to have shape...

  • (n_samples,) or (n_samples,1) for models expecting a single input tensor
  • (n_samples,) containing the substring '{input_id}', or
  • (n_samples, n_model_inputs) to provide each input tensor path explicitly.

All substrings that are replaced by metadata from the model description:

  • '{model_id}'
  • '{input_id}'

Example inputs to process sample 'a' and 'b' for a model expecting a 'raw' and a 'mask' input tensor: --inputs="[[\"a_raw.tif\",\"a_mask.tif\"],[\"b_raw.tif\",\"b_mask.tif\"]]" (Note that JSON double quotes need to be escaped.)

Alternatively a bioimageio-cli.yaml (or bioimageio-cli.json) file may provide the arguments, e.g.:

inputs:
- [a_raw.tif, a_mask.tif]
- [b_raw.tif, b_mask.tif]

.npy and any file extension supported by imageio are supported. Aavailable formats are listed at https://imageio.readthedocs.io/en/stable/formats/index.html#all-formats. Some formats have additional dependencies.

outputs: Union[str, Annotated[Tuple[str, ...], MinLen(min_length=1)]]

Model output path pattern (per output tensor)

All substrings that are replaced:

  • '{model_id}' (from model description)
  • '{output_id}' (from model description)
  • '{sample_id}' (extracted from input paths)
overwrite: bool

allow overwriting existing output files

blockwise: bool

process inputs blockwise

stats: pathlib.Path

path to dataset statistics (will be written if it does not exist, but the model requires statistical dataset measures)

preview: bool

preview which files would be processed and what outputs would be generated.

weight_format: Literal['keras_hdf5', 'onnx', 'pytorch_state_dict', 'tensorflow_saved_model_bundle', 'torchscript', 'any']

The weight format to use.

example: bool

generate and run an example

  1. downloads example model inputs
  2. creates a {model_id}_example folder
  3. writes input arguments to {model_id}_example/bioimageio-cli.yaml
  4. executes a preview dry-run
  5. executes prediction with example input
def run(self):
570    def run(self):
571        if self.example:
572            return self._example()
573
574        model_descr = ensure_description_is_model(self.descr)
575
576        input_ids = get_member_ids(model_descr.inputs)
577        output_ids = get_member_ids(model_descr.outputs)
578
579        minimum_input_ids = tuple(
580            str(ipt.id) if isinstance(ipt, v0_5.InputTensorDescr) else str(ipt.name)
581            for ipt in model_descr.inputs
582            if not isinstance(ipt, v0_5.InputTensorDescr) or not ipt.optional
583        )
584        maximum_input_ids = tuple(
585            str(ipt.id) if isinstance(ipt, v0_5.InputTensorDescr) else str(ipt.name)
586            for ipt in model_descr.inputs
587        )
588
589        def expand_inputs(i: int, ipt: Union[str, Tuple[str, ...]]) -> Tuple[str, ...]:
590            if isinstance(ipt, str):
591                ipts = tuple(
592                    ipt.format(model_id=self.descr_id, input_id=t) for t in input_ids
593                )
594            else:
595                ipts = tuple(
596                    p.format(model_id=self.descr_id, input_id=t)
597                    for t, p in zip(input_ids, ipt)
598                )
599
600            if len(set(ipts)) < len(ipts):
601                if len(minimum_input_ids) == len(maximum_input_ids):
602                    n = len(minimum_input_ids)
603                else:
604                    n = f"{len(minimum_input_ids)}-{len(maximum_input_ids)}"
605
606                raise ValueError(
607                    f"[input sample #{i}] Include '{{input_id}}' in path pattern or explicitly specify {n} distinct input paths (got {ipt})"
608                )
609
610            if len(ipts) < len(minimum_input_ids):
611                raise ValueError(
612                    f"[input sample #{i}] Expected at least {len(minimum_input_ids)} inputs {minimum_input_ids}, got {ipts}"
613                )
614
615            if len(ipts) > len(maximum_input_ids):
616                raise ValueError(
617                    f"Expected at most {len(maximum_input_ids)} inputs {maximum_input_ids}, got {ipts}"
618                )
619
620            return ipts
621
622        inputs = [expand_inputs(i, ipt) for i, ipt in enumerate(self.inputs, start=1)]
623
624        sample_paths_in = [
625            {t: Path(p) for t, p in zip(input_ids, ipts)} for ipts in inputs
626        ]
627
628        sample_ids = _get_sample_ids(sample_paths_in)
629
630        def expand_outputs():
631            if isinstance(self.outputs, str):
632                outputs = [
633                    tuple(
634                        Path(
635                            self.outputs.format(
636                                model_id=self.descr_id, output_id=t, sample_id=s
637                            )
638                        )
639                        for t in output_ids
640                    )
641                    for s in sample_ids
642                ]
643            else:
644                outputs = [
645                    tuple(
646                        Path(p.format(model_id=self.descr_id, output_id=t, sample_id=s))
647                        for t, p in zip(output_ids, self.outputs)
648                    )
649                    for s in sample_ids
650                ]
651
652            for i, out in enumerate(outputs, start=1):
653                if len(set(out)) < len(out):
654                    raise ValueError(
655                        f"[output sample #{i}] Include '{{output_id}}' in path pattern or explicitly specify {len(output_ids)} distinct output paths (got {out})"
656                    )
657
658                if len(out) != len(output_ids):
659                    raise ValueError(
660                        f"[output sample #{i}] Expected {len(output_ids)} outputs {output_ids}, got {out}"
661                    )
662
663            return outputs
664
665        outputs = expand_outputs()
666
667        sample_paths_out = [
668            {MemberId(t): Path(p) for t, p in zip(output_ids, out)} for out in outputs
669        ]
670
671        if not self.overwrite:
672            for sample_paths in sample_paths_out:
673                for p in sample_paths.values():
674                    if p.exists():
675                        raise FileExistsError(
676                            f"{p} already exists. use --overwrite to (re-)write outputs anyway."
677                        )
678        if self.preview:
679            print("🛈 bioimageio prediction preview structure:")
680            pprint(
681                {
682                    "{sample_id}": dict(
683                        inputs={"{input_id}": "<input path>"},
684                        outputs={"{output_id}": "<output path>"},
685                    )
686                }
687            )
688            print("🔎 bioimageio prediction preview output:")
689            pprint(
690                {
691                    s: dict(
692                        inputs={t: p.as_posix() for t, p in sp_in.items()},
693                        outputs={t: p.as_posix() for t, p in sp_out.items()},
694                    )
695                    for s, sp_in, sp_out in zip(
696                        sample_ids, sample_paths_in, sample_paths_out
697                    )
698                }
699            )
700            return
701
702        def input_dataset(stat: Stat):
703            for s, sp_in in zip(sample_ids, sample_paths_in):
704                yield load_sample_for_model(
705                    model=model_descr,
706                    paths=sp_in,
707                    stat=stat,
708                    sample_id=s,
709                )
710
711        stat: Dict[Measure, MeasureValue] = dict(
712            _get_stat(
713                model_descr, input_dataset({}), len(sample_ids), self.stats
714            ).items()
715        )
716
717        pp = create_prediction_pipeline(
718            model_descr,
719            weight_format=None if self.weight_format == "any" else self.weight_format,
720        )
721        predict_method = (
722            pp.predict_sample_with_blocking
723            if self.blockwise
724            else pp.predict_sample_without_blocking
725        )
726
727        for sample_in, sp_out in tqdm(
728            zip(input_dataset(dict(stat)), sample_paths_out),
729            total=len(inputs),
730            desc=f"predict with {self.descr_id}",
731            unit="sample",
732        ):
733            sample_out = predict_method(sample_in)
734            save_sample(sp_out, sample_out)
Inherited Members
WithSource
source
descr
descr_id
class AddWeightsCmd(CmdBase, WithSource, WithSummaryLogging):
737class AddWeightsCmd(CmdBase, WithSource, WithSummaryLogging):
738    output: CliPositionalArg[Path]
739    """The path to write the updated model package to."""
740
741    source_format: Optional[SupportedWeightsFormat] = Field(None, alias="source-format")
742    """Exclusively use these weights to convert to other formats."""
743
744    target_format: Optional[SupportedWeightsFormat] = Field(None, alias="target-format")
745    """Exclusively add this weight format."""
746
747    verbose: bool = False
748    """Log more (error) output."""
749
750    def run(self):
751        model_descr = ensure_description_is_model(self.descr)
752        if isinstance(model_descr, v0_4.ModelDescr):
753            raise TypeError(
754                f"model format {model_descr.format_version} not supported."
755                + " Please update the model first."
756            )
757        updated_model_descr = add_weights(
758            model_descr,
759            output_path=self.output,
760            source_format=self.source_format,
761            target_format=self.target_format,
762            verbose=self.verbose,
763        )
764        if updated_model_descr is None:
765            return
766
767        self.log(updated_model_descr)

Usage docs: https://docs.pydantic.dev/2.9/concepts/models/

A base class for creating Pydantic models.

Attributes:
  • __class_vars__: The names of the class variables defined on the model.
  • __private_attributes__: Metadata about the private attributes of the model.
  • __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.
  • __pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
  • __pydantic_core_schema__: The core schema of the model.
  • __pydantic_custom_init__: Whether the model has a custom __init__ function.
  • __pydantic_decorators__: Metadata containing the decorators defined on the model. This replaces Model.__validators__ and Model.__root_validators__ from Pydantic V1.
  • __pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to __args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
  • __pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
  • __pydantic_post_init__: The name of the post-init method for the model, if defined.
  • __pydantic_root_model__: Whether the model is a [RootModel][pydantic.root_model.RootModel].
  • __pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
  • __pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
  • __pydantic_extra__: A dictionary containing extra values, if [extra][pydantic.config.ConfigDict.extra] is set to 'allow'.
  • __pydantic_fields_set__: The names of fields explicitly set during instantiation.
  • __pydantic_private__: Values of private attributes set on the model instance.
output: Annotated[pathlib.Path, <class 'pydantic_settings.sources._CliPositionalArg'>]

The path to write the updated model package to.

source_format: Optional[Literal['keras_hdf5', 'onnx', 'pytorch_state_dict', 'tensorflow_saved_model_bundle', 'torchscript']]

Exclusively use these weights to convert to other formats.

target_format: Optional[Literal['keras_hdf5', 'onnx', 'pytorch_state_dict', 'tensorflow_saved_model_bundle', 'torchscript']]

Exclusively add this weight format.

verbose: bool

Log more (error) output.

def run(self):
750    def run(self):
751        model_descr = ensure_description_is_model(self.descr)
752        if isinstance(model_descr, v0_4.ModelDescr):
753            raise TypeError(
754                f"model format {model_descr.format_version} not supported."
755                + " Please update the model first."
756            )
757        updated_model_descr = add_weights(
758            model_descr,
759            output_path=self.output,
760            source_format=self.source_format,
761            target_format=self.target_format,
762            verbose=self.verbose,
763        )
764        if updated_model_descr is None:
765            return
766
767        self.log(updated_model_descr)
JSON_FILE = 'bioimageio-cli.json'
YAML_FILE = 'bioimageio-cli.yaml'
class Bioimageio(pydantic_settings.main.BaseSettings):
774class Bioimageio(
775    BaseSettings,
776    cli_implicit_flags=True,
777    cli_parse_args=True,
778    cli_prog_name="bioimageio",
779    cli_use_class_docs_for_groups=True,
780    use_attribute_docstrings=True,
781):
782    """bioimageio - CLI for bioimage.io resources 🦒"""
783
784    model_config = SettingsConfigDict(
785        json_file=JSON_FILE,
786        yaml_file=YAML_FILE,
787    )
788
789    validate_format: CliSubCommand[ValidateFormatCmd] = Field(alias="validate-format")
790    "Check a resource's metadata format"
791
792    test: CliSubCommand[TestCmd]
793    "Test a bioimageio resource (beyond meta data formatting)"
794
795    package: CliSubCommand[PackageCmd]
796    "Package a resource"
797
798    predict: CliSubCommand[PredictCmd]
799    "Predict with a model resource"
800
801    update_format: CliSubCommand[UpdateFormatCmd] = Field(alias="update-format")
802    """Update the metadata format"""
803
804    update_hashes: CliSubCommand[UpdateHashesCmd] = Field(alias="update-hashes")
805    """Create a bioimageio.yaml description with updated file hashes."""
806
807    add_weights: CliSubCommand[AddWeightsCmd] = Field(alias="add-weights")
808    """Add additional weights to the model descriptions converted from available
809    formats to improve deployability."""
810
811    @classmethod
812    def settings_customise_sources(
813        cls,
814        settings_cls: Type[BaseSettings],
815        init_settings: PydanticBaseSettingsSource,
816        env_settings: PydanticBaseSettingsSource,
817        dotenv_settings: PydanticBaseSettingsSource,
818        file_secret_settings: PydanticBaseSettingsSource,
819    ) -> Tuple[PydanticBaseSettingsSource, ...]:
820        cli: CliSettingsSource[BaseSettings] = CliSettingsSource(
821            settings_cls,
822            cli_parse_args=True,
823            formatter_class=RawTextHelpFormatter,
824        )
825        sys_args = pformat(sys.argv)
826        logger.info("starting CLI with arguments:\n{}", sys_args)
827        return (
828            cli,
829            init_settings,
830            YamlConfigSettingsSource(settings_cls),
831            JsonConfigSettingsSource(settings_cls),
832        )
833
834    @model_validator(mode="before")
835    @classmethod
836    def _log(cls, data: Any):
837        logger.info(
838            "loaded CLI input:\n{}",
839            pformat({k: v for k, v in data.items() if v is not None}),
840        )
841        return data
842
843    def run(self):
844        logger.info(
845            "executing CLI command:\n{}",
846            pformat({k: v for k, v in self.model_dump().items() if v is not None}),
847        )
848        cmd = (
849            self.add_weights
850            or self.package
851            or self.predict
852            or self.test
853            or self.update_format
854            or self.update_hashes
855            or self.validate_format
856        )
857        assert cmd is not None
858        cmd.run()

bioimageio - CLI for bioimage.io resources 🦒

library versions: bioimageio.core 0.8.0 bioimageio.spec 0.8.0

spec format versions: model RDF 0.5.4 dataset RDF 0.3.0 notebook RDF 0.3.0

validate_format: Annotated[Optional[ValidateFormatCmd], <class 'pydantic_settings.sources._CliSubCommand'>]

Check a resource's metadata format

test: Annotated[Optional[TestCmd], <class 'pydantic_settings.sources._CliSubCommand'>]

Test a bioimageio resource (beyond meta data formatting)

package: Annotated[Optional[PackageCmd], <class 'pydantic_settings.sources._CliSubCommand'>]

Package a resource

predict: Annotated[Optional[PredictCmd], <class 'pydantic_settings.sources._CliSubCommand'>]

Predict with a model resource

update_format: Annotated[Optional[UpdateFormatCmd], <class 'pydantic_settings.sources._CliSubCommand'>]

Update the metadata format

update_hashes: Annotated[Optional[UpdateHashesCmd], <class 'pydantic_settings.sources._CliSubCommand'>]

Create a bioimageio.yaml description with updated file hashes.

add_weights: Annotated[Optional[AddWeightsCmd], <class 'pydantic_settings.sources._CliSubCommand'>]

Add additional weights to the model descriptions converted from available formats to improve deployability.

@classmethod
def settings_customise_sources( cls, settings_cls: Type[pydantic_settings.main.BaseSettings], init_settings: pydantic_settings.sources.PydanticBaseSettingsSource, env_settings: pydantic_settings.sources.PydanticBaseSettingsSource, dotenv_settings: pydantic_settings.sources.PydanticBaseSettingsSource, file_secret_settings: pydantic_settings.sources.PydanticBaseSettingsSource) -> Tuple[pydantic_settings.sources.PydanticBaseSettingsSource, ...]:
811    @classmethod
812    def settings_customise_sources(
813        cls,
814        settings_cls: Type[BaseSettings],
815        init_settings: PydanticBaseSettingsSource,
816        env_settings: PydanticBaseSettingsSource,
817        dotenv_settings: PydanticBaseSettingsSource,
818        file_secret_settings: PydanticBaseSettingsSource,
819    ) -> Tuple[PydanticBaseSettingsSource, ...]:
820        cli: CliSettingsSource[BaseSettings] = CliSettingsSource(
821            settings_cls,
822            cli_parse_args=True,
823            formatter_class=RawTextHelpFormatter,
824        )
825        sys_args = pformat(sys.argv)
826        logger.info("starting CLI with arguments:\n{}", sys_args)
827        return (
828            cli,
829            init_settings,
830            YamlConfigSettingsSource(settings_cls),
831            JsonConfigSettingsSource(settings_cls),
832        )

Define the sources and their order for loading the settings values.

Arguments:
  • settings_cls: The Settings class.
  • init_settings: The InitSettingsSource instance.
  • env_settings: The EnvSettingsSource instance.
  • dotenv_settings: The DotEnvSettingsSource instance.
  • file_secret_settings: The SecretsSettingsSource instance.
Returns:

A tuple containing the sources and their order for loading the settings values.

def run(self):
843    def run(self):
844        logger.info(
845            "executing CLI command:\n{}",
846            pformat({k: v for k, v in self.model_dump().items() if v is not None}),
847        )
848        cmd = (
849            self.add_weights
850            or self.package
851            or self.predict
852            or self.test
853            or self.update_format
854            or self.update_hashes
855            or self.validate_format
856        )
857        assert cmd is not None
858        cmd.run()