From e47671c5d2a0ced66fd1f7a909ba102f7459edae Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Mon, 9 Dec 2024 21:08:39 +0100 Subject: [PATCH 1/9] chore: add script to examine BMZ README --- .pre-commit-config.yaml | 6 +++--- scripts/export_bmz_readme.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 scripts/export_bmz_readme.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 055feb89..73d2bf3c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: rev: v0.7.2 hooks: - id: ruff - exclude: "^src/careamics/lvae_training/.*|^src/careamics/models/lvae/.*" + exclude: "^src/careamics/lvae_training/.*|^src/careamics/models/lvae/.*|^scripts/.*" args: [--fix, --target-version, py38] - repo: https://github.com/psf/black @@ -31,7 +31,7 @@ repos: - id: mypy files: "^src/" exclude: "^src/careamics/lvae_training/.*|^src/careamics/models/lvae/.*|^src/careamics/config/likelihood_model.py|^src/careamics/losses/loss_factory.py|^src/careamics/losses/lvae/losses.py" - args: ['--config-file', 'mypy.ini'] + args: ["--config-file", "mypy.ini"] additional_dependencies: - numpy - types-PyYAML @@ -42,7 +42,7 @@ repos: rev: v1.8.0 hooks: - id: numpydoc-validation - exclude: "^src/careamics/lvae_training/.*|^src/careamics/models/lvae/.*|^src/careamics/losses/lvae/.*" + exclude: "^src/careamics/lvae_training/.*|^src/careamics/models/lvae/.*|^src/careamics/losses/lvae/.*|^scripts/.*" # # jupyter linting and formatting # - repo: https://github.com/nbQA-dev/nbQA diff --git a/scripts/export_bmz_readme.py b/scripts/export_bmz_readme.py new file mode 100644 index 00000000..96dae78b --- /dev/null +++ b/scripts/export_bmz_readme.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +from pathlib import Path + +from careamics.config import create_n2v_configuration +from careamics.model_io.bioimage._readme_factory import readme_factory + + +def main(): + # create configuration + config = create_n2v_configuration( + experiment_name="export_bmz_readme", + data_type="array", + axes="YX", + patch_size=(64, 64), + batch_size=2, + num_epochs=10, + ) + # export README + readme_path = readme_factory( + config=config, + careamics_version="0.1.0", + ) + + # copy file to __file__ + readme_path.rename(Path(__file__).parent / "README.md") + + +if __name__ == "__main__": + main() From 551370d669e2a17389b24ab4b35b7c865bbb8377 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Mon, 9 Dec 2024 21:40:13 +0100 Subject: [PATCH 2/9] feat: Datadescription mandatory, reorganize readme --- scripts/export_bmz_readme.py | 4 +- src/careamics/careamist.py | 12 ++--- .../model_io/bioimage/_readme_factory.py | 49 ++++++------------- .../model_io/bioimage/model_description.py | 6 +-- src/careamics/model_io/bmz_io.py | 8 +-- tests/conftest.py | 1 + tests/model_io/test_bmz_io.py | 1 + 7 files changed, 31 insertions(+), 50 deletions(-) diff --git a/scripts/export_bmz_readme.py b/scripts/export_bmz_readme.py index 96dae78b..9be2fcc6 100644 --- a/scripts/export_bmz_readme.py +++ b/scripts/export_bmz_readme.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +"""Export a README file for the bioimage model zoo.""" from pathlib import Path from careamics.config import create_n2v_configuration @@ -17,8 +18,7 @@ def main(): ) # export README readme_path = readme_factory( - config=config, - careamics_version="0.1.0", + config=config, careamics_version="0.1.0", data_description="Mydata" ) # copy file to __file__ diff --git a/src/careamics/careamist.py b/src/careamics/careamist.py index 4a580009..7675abf7 100644 --- a/src/careamics/careamist.py +++ b/src/careamics/careamist.py @@ -866,9 +866,9 @@ def export_to_bmz( friendly_model_name: str, input_array: NDArray, authors: list[dict], - general_description: str = "", + general_description: str, + data_description: str, channel_names: Optional[list[str]] = None, - data_description: Optional[str] = None, ) -> None: """Export the model to the BioImage Model Zoo format. @@ -898,11 +898,11 @@ def export_to_bmz( authors : list of dict List of authors of the model. general_description : str - General description of the model, used in the metadata of the BMZ archive. + General description of the model used in the BMZ metadata. + data_description : str + Description of the data the model was trained on. channel_names : list of str, optional Channel names, by default None. - data_description : str, optional - Description of the data, by default None. """ # TODO: add in docs that it is expected that input_array dimensions match # those in data_config @@ -921,11 +921,11 @@ def export_to_bmz( path_to_archive=path_to_archive, model_name=friendly_model_name, general_description=general_description, + data_description=data_description, authors=authors, input_array=input_array, output_array=output, channel_names=channel_names, - data_description=data_description, ) def get_losses(self) -> dict[str, list]: diff --git a/src/careamics/model_io/bioimage/_readme_factory.py b/src/careamics/model_io/bioimage/_readme_factory.py index acfc0a61..800c6667 100644 --- a/src/careamics/model_io/bioimage/_readme_factory.py +++ b/src/careamics/model_io/bioimage/_readme_factory.py @@ -1,7 +1,6 @@ """Functions used to create a README.md file for BMZ export.""" from pathlib import Path -from typing import Optional import yaml @@ -28,7 +27,7 @@ def _yaml_block(yaml_str: str) -> str: def readme_factory( config: Configuration, careamics_version: str, - data_description: Optional[str] = None, + data_description: str, ) -> Path: """Create a README file for the model. @@ -41,18 +40,14 @@ def readme_factory( CAREamics configuration. careamics_version : str CAREamics version. - data_description : Optional[str], optional - Description of the data, by default None. + data_description : str + Description of the data. Returns ------- Path Path to the README file. """ - algorithm = config.algorithm_config - training = config.training_config - data = config.data_config - # create file # TODO use tempfile as in the bmz_io module with cwd(get_careamics_home()): @@ -65,41 +60,25 @@ def readme_factory( description = [f"# {algorithm_pretty_name}\n\n"] - # algorithm description - description.append("Algorithm description:\n\n") - description.append(config.get_algorithm_description()) - description.append("\n\n") - - # algorithm details - description.append( - f"{algorithm_flavour} was trained using CAREamics (version " - f"{careamics_version}) with the following algorithm " - f"parameters:\n\n" - ) - description.append( - _yaml_block(yaml.dump(algorithm.model_dump(exclude_none=True))) - ) - description.append("\n\n") - # data description description.append("## Data description\n\n") - if data_description is not None: - description.append(data_description) - description.append("\n\n") - - description.append("The data was processed using the following parameters:\n\n") + description.append(data_description) + description.append("\n\n") - description.append(_yaml_block(yaml.dump(data.model_dump(exclude_none=True)))) + # algorithm description + description.append("## Algorithm description:\n\n") + description.append(config.get_algorithm_description()) description.append("\n\n") # training description - description.append("## Training description\n\n") - - description.append("The model was trained using the following parameters:\n\n") + description.append("## Configuration\n\n") description.append( - _yaml_block(yaml.dump(training.model_dump(exclude_none=True))) + f"{algorithm_flavour} was trained using CAREamics (version " + f"{careamics_version}) using the following configuration:\n\n" ) + + description.append(_yaml_block(yaml.dump(config.model_dump(exclude_none=True)))) description.append("\n\n") # references @@ -113,7 +92,7 @@ def readme_factory( description.append( "## Links\n\n" "- [CAREamics repository](https://github.com/CAREamics/careamics)\n" - "- [CAREamics documentation](https://careamics.github.io/latest/)\n" + "- [CAREamics documentation](https://careamics.github.io/)\n" ) readme.write_text("".join(description)) diff --git a/src/careamics/model_io/bioimage/model_description.py b/src/careamics/model_io/bioimage/model_description.py index dbd1dfe0..a910fa09 100644 --- a/src/careamics/model_io/bioimage/model_description.py +++ b/src/careamics/model_io/bioimage/model_description.py @@ -187,6 +187,7 @@ def create_model_description( config: Configuration, name: str, general_description: str, + data_description: str, authors: List[Author], inputs: Union[Path, str], outputs: Union[Path, str], @@ -196,7 +197,6 @@ def create_model_description( config_path: Union[Path, str], env_path: Union[Path, str], channel_names: Optional[List[str]] = None, - data_description: Optional[str] = None, ) -> ModelDescr: """Create model description. @@ -208,6 +208,8 @@ def create_model_description( Name of the model. general_description : str General description of the model. + data_description : str + Description of the data the model was trained on. authors : List[Author] Authors of the model. inputs : Union[Path, str] @@ -226,8 +228,6 @@ def create_model_description( Path to environment file. channel_names : Optional[List[str]], optional Channel names, by default None. - data_description : Optional[str], optional - Description of the data, by default None. Returns ------- diff --git a/src/careamics/model_io/bmz_io.py b/src/careamics/model_io/bmz_io.py index 65a3ea99..7484c79f 100644 --- a/src/careamics/model_io/bmz_io.py +++ b/src/careamics/model_io/bmz_io.py @@ -85,11 +85,11 @@ def export_to_bmz( path_to_archive: Union[Path, str], model_name: str, general_description: str, + data_description: str, authors: List[dict], input_array: np.ndarray, output_array: np.ndarray, channel_names: Optional[List[str]] = None, - data_description: Optional[str] = None, ) -> None: """Export the model to BioImage Model Zoo format. @@ -110,6 +110,8 @@ def export_to_bmz( Model name. general_description : str General description of the model. + data_description : str + Description of the data the model was trained on. authors : List[dict] Authors of the model. input_array : np.ndarray @@ -118,8 +120,6 @@ def export_to_bmz( Output array, should have been denormalized. channel_names : Optional[List[str]], optional Channel names, by default None. - data_description : Optional[str], optional - Description of the data, by default None. Raises ------ @@ -171,6 +171,7 @@ def export_to_bmz( config=config, name=model_name, general_description=general_description, + data_description=data_description, authors=authors, inputs=inputs, outputs=outputs, @@ -180,7 +181,6 @@ def export_to_bmz( config_path=config_path, env_path=env_path, channel_names=channel_names, - data_description=data_description, ) # test model description diff --git a/tests/conftest.py b/tests/conftest.py index 0e40eb65..1f965c0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -374,6 +374,7 @@ def pre_trained_bmz(tmp_path, pre_trained) -> Path: path_to_archive=path, model_name="TopModel", general_description="A model that just walked in.", + data_description="My data.", authors=[{"name": "Amod", "affiliation": "El"}], input_array=train_array[np.newaxis, np.newaxis, ...], output_array=predicted, diff --git a/tests/model_io/test_bmz_io.py b/tests/model_io/test_bmz_io.py index 4eac1dcf..5f6b20e8 100644 --- a/tests/model_io/test_bmz_io.py +++ b/tests/model_io/test_bmz_io.py @@ -51,6 +51,7 @@ def test_bmz_io(tmp_path, ordered_array, pre_trained): path_to_archive=path, model_name="TopModel", general_description="A model that just walked in.", + data_description="My data.", authors=[{"name": "Amod", "affiliation": "El"}], input_array=train_array[np.newaxis, np.newaxis, ...], output_array=predicted, From 792b93eaa854287c4a957daf55539fd0b286616c Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Mon, 9 Dec 2024 23:33:10 +0100 Subject: [PATCH 3/9] fix: Fix tests --- tests/test_careamist.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_careamist.py b/tests/test_careamist.py index ce5c0e7e..29d5ce37 100644 --- a/tests/test_careamist.py +++ b/tests/test_careamist.py @@ -113,6 +113,7 @@ def test_train_single_array_no_val(tmp_path: Path, minimum_configuration: dict): input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -148,6 +149,7 @@ def test_train_array(tmp_path: Path, minimum_configuration: dict): input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -189,6 +191,7 @@ def test_train_array_channel( input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", channel_names=["red", "green", "blue"], ) assert (tmp_path / "model.zip").exists() @@ -225,6 +228,7 @@ def test_train_array_3d(tmp_path: Path, minimum_configuration: dict): input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -263,6 +267,7 @@ def test_train_tiff_files_in_memory_no_val(tmp_path: Path, minimum_configuration input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -305,6 +310,7 @@ def test_train_tiff_files_in_memory(tmp_path: Path, minimum_configuration: dict) input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -348,6 +354,7 @@ def test_train_tiff_files(tmp_path: Path, minimum_configuration: dict): input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -390,6 +397,7 @@ def test_train_array_supervised(tmp_path: Path, supervised_configuration: dict): input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -451,6 +459,7 @@ def test_train_tiff_files_in_memory_supervised( input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -512,6 +521,7 @@ def test_train_tiff_files_supervised(tmp_path: Path, supervised_configuration: d input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -557,6 +567,7 @@ def test_predict_on_array_tiled( input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -600,6 +611,7 @@ def test_predict_arrays_no_tiling( input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -742,6 +754,7 @@ def test_predict_path( input_array=train_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -807,6 +820,7 @@ def test_export_bmz_pretrained_prediction(tmp_path: Path, pre_trained: Path): input_array=source_array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model.zip").exists() @@ -828,6 +842,7 @@ def test_export_bmz_pretrained_with_array(tmp_path: Path, pre_trained: Path): input_array=array, authors=[{"name": "Amod", "affiliation": "El"}], general_description="A model that just walked in.", + data_description="A random array.", ) assert (tmp_path / "model2.zip").exists() From 3a1e91f380dac16221e90a03b688c87d9513eeec Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Mon, 9 Dec 2024 23:40:11 +0100 Subject: [PATCH 4/9] feat: Pass model version --- src/careamics/careamist.py | 4 ++++ src/careamics/model_io/bioimage/model_description.py | 3 +++ src/careamics/model_io/bmz_io.py | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/src/careamics/careamist.py b/src/careamics/careamist.py index 7675abf7..eff7b6d7 100644 --- a/src/careamics/careamist.py +++ b/src/careamics/careamist.py @@ -869,6 +869,7 @@ def export_to_bmz( general_description: str, data_description: str, channel_names: Optional[list[str]] = None, + model_version: str = "0.1.0", ) -> None: """Export the model to the BioImage Model Zoo format. @@ -903,6 +904,8 @@ def export_to_bmz( Description of the data the model was trained on. channel_names : list of str, optional Channel names, by default None. + model_version : str, default="0.1.0" + Version of the model. """ # TODO: add in docs that it is expected that input_array dimensions match # those in data_config @@ -926,6 +929,7 @@ def export_to_bmz( input_array=input_array, output_array=output, channel_names=channel_names, + model_version=model_version, ) def get_losses(self) -> dict[str, list]: diff --git a/src/careamics/model_io/bioimage/model_description.py b/src/careamics/model_io/bioimage/model_description.py index a910fa09..91fb5788 100644 --- a/src/careamics/model_io/bioimage/model_description.py +++ b/src/careamics/model_io/bioimage/model_description.py @@ -197,6 +197,7 @@ def create_model_description( config_path: Union[Path, str], env_path: Union[Path, str], channel_names: Optional[List[str]] = None, + model_version: str = "0.1.0", ) -> ModelDescr: """Create model description. @@ -228,6 +229,8 @@ def create_model_description( Path to environment file. channel_names : Optional[List[str]], optional Channel names, by default None. + model_version : str, default "0.1.0" + Model version. Returns ------- diff --git a/src/careamics/model_io/bmz_io.py b/src/careamics/model_io/bmz_io.py index 7484c79f..245ddc7b 100644 --- a/src/careamics/model_io/bmz_io.py +++ b/src/careamics/model_io/bmz_io.py @@ -90,6 +90,7 @@ def export_to_bmz( input_array: np.ndarray, output_array: np.ndarray, channel_names: Optional[List[str]] = None, + model_version: str = "0.1.0", ) -> None: """Export the model to BioImage Model Zoo format. @@ -120,6 +121,8 @@ def export_to_bmz( Output array, should have been denormalized. channel_names : Optional[List[str]], optional Channel names, by default None. + model_version : str, default="0.1.0" + Model version. Raises ------ @@ -181,6 +184,7 @@ def export_to_bmz( config_path=config_path, env_path=env_path, channel_names=channel_names, + model_version=model_version, ) # test model description From e8039607a4ed9210d72915144475c13c1a64732f Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Mon, 9 Dec 2024 23:50:55 +0100 Subject: [PATCH 5/9] feat: Add generic validation text --- src/careamics/model_io/bioimage/_readme_factory.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/careamics/model_io/bioimage/_readme_factory.py b/src/careamics/model_io/bioimage/_readme_factory.py index 800c6667..4e879089 100644 --- a/src/careamics/model_io/bioimage/_readme_factory.py +++ b/src/careamics/model_io/bioimage/_readme_factory.py @@ -70,7 +70,7 @@ def readme_factory( description.append(config.get_algorithm_description()) description.append("\n\n") - # training description + # configuration description description.append("## Configuration\n\n") description.append( @@ -81,6 +81,18 @@ def readme_factory( description.append(_yaml_block(yaml.dump(config.model_dump(exclude_none=True)))) description.append("\n\n") + # validation + description.append("## Validation\n\n") + + description.append( + "In order to validate the model, we encourage users to acquire a " + "test dataset with ground-truth data. Comparing the ground-truth data " + "with the prediction allows unbiased evaluation of the model performances. " + "In the absence of ground-truth, inspecting the residual image (difference " + "between input and predicted image) can be helpful to identify " + "whether real signal is removed from the input image.\n\n" + ) + # references reference = config.get_algorithm_references() if reference != "": From 9eb3cb12e1273d7a2968e05dcba900e8e1e30c04 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:29:43 +0100 Subject: [PATCH 6/9] test: Add test for model version, and fix error --- src/careamics/model_io/bioimage/model_description.py | 2 +- tests/model_io/test_bmz_io.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/careamics/model_io/bioimage/model_description.py b/src/careamics/model_io/bioimage/model_description.py index 91fb5788..c7812fda 100644 --- a/src/careamics/model_io/bioimage/model_description.py +++ b/src/careamics/model_io/bioimage/model_description.py @@ -294,7 +294,7 @@ def create_model_description( } } }, - version="0.1.0", + version=model_version, weights=weights_descr, attachments=[FileDescr(source=config_path)], cite=config.get_algorithm_citations(), diff --git a/tests/model_io/test_bmz_io.py b/tests/model_io/test_bmz_io.py index 5f6b20e8..b2d73fb8 100644 --- a/tests/model_io/test_bmz_io.py +++ b/tests/model_io/test_bmz_io.py @@ -1,4 +1,5 @@ import numpy as np +from bioimageio.spec import load_description from torch import Tensor from careamics import CAREamist @@ -55,9 +56,14 @@ def test_bmz_io(tmp_path, ordered_array, pre_trained): authors=[{"name": "Amod", "affiliation": "El"}], input_array=train_array[np.newaxis, np.newaxis, ...], output_array=predicted, + model_version="0.0.15", ) assert path.exists() + # load description + description = load_description(path) + assert str(description.version) == "0.0.15" + # load model model, config = load_pretrained(path) assert config == careamist.cfg From 8147776ae2e0428bab2b8917773c28a9192cffe2 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Thu, 12 Dec 2024 20:07:10 +0100 Subject: [PATCH 7/9] feat: Add cover from input in BMZ --- pyproject.toml | 1 + scripts/export_covers.py | 0 src/careamics/careamist.py | 8 +- .../model_io/bioimage/cover_factory.py | 171 ++++++++++++++++++ .../model_io/bioimage/model_description.py | 4 + src/careamics/model_io/bmz_io.py | 9 + 6 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 scripts/export_covers.py create mode 100644 src/careamics/model_io/bioimage/cover_factory.py diff --git a/pyproject.toml b/pyproject.toml index 55b011a6..09181226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dependencies = [ 'typer==0.12.3', 'scikit-image<=0.23.2', 'zarr<3.0.0', + 'pillow<=10.3.0', ] [project.optional-dependencies] diff --git a/scripts/export_covers.py b/scripts/export_covers.py new file mode 100644 index 00000000..e69de29b diff --git a/src/careamics/careamist.py b/src/careamics/careamist.py index eff7b6d7..315f3a60 100644 --- a/src/careamics/careamist.py +++ b/src/careamics/careamist.py @@ -868,6 +868,7 @@ def export_to_bmz( authors: list[dict], general_description: str, data_description: str, + covers: Optional[list[Union[Path, str]]] = None, channel_names: Optional[list[str]] = None, model_version: str = "0.1.0", ) -> None: @@ -902,8 +903,10 @@ def export_to_bmz( General description of the model used in the BMZ metadata. data_description : str Description of the data the model was trained on. - channel_names : list of str, optional - Channel names, by default None. + covers : list of pathlib.Path or str, default=None + Paths to the cover images. + channel_names : list of str, default=None + Channel names. model_version : str, default="0.1.0" Version of the model. """ @@ -928,6 +931,7 @@ def export_to_bmz( authors=authors, input_array=input_array, output_array=output, + covers=covers, channel_names=channel_names, model_version=model_version, ) diff --git a/src/careamics/model_io/bioimage/cover_factory.py b/src/careamics/model_io/bioimage/cover_factory.py new file mode 100644 index 00000000..de84d3cb --- /dev/null +++ b/src/careamics/model_io/bioimage/cover_factory.py @@ -0,0 +1,171 @@ +"""Convenience function to create covers for the BMZ.""" + +from pathlib import Path + +import numpy as np +from numpy.typing import NDArray +from PIL import Image + +color_palette = np.array( + [ + np.array([255, 195, 0]), # grey + np.array([189, 226, 240]), + np.array([96, 60, 76]), + np.array([193, 225, 193]), + ] +) + + +def _get_norm_slice(array: NDArray) -> NDArray: + """Get the normalized middle slice of a 4D or 5D array (SC(Z)YX). + + Parameters + ---------- + array : NDArray + Array from which to get the middle slice. + + Returns + ------- + NDArray + Normalized middle slice of the input array. + """ + if array.ndim not in (4, 5): + raise ValueError("Array must be 4D or 5D.") + + channels = array.shape[1] > 1 + z_stack = array.ndim == 5 + + # get slice + if z_stack: + array_slice = array[0, :, array.shape[2] // 2, ...] + else: + array_slice = array[0, ...] + + # channels + if channels: + array_slice = np.moveaxis(array_slice, 0, -1) + else: + array_slice = array_slice[0, ...] + + # normalize + array_slice = ( + 255 + * (array_slice - array_slice.min()) + / (array_slice.max() - array_slice.min()) + ) + + return array_slice.astype(np.uint8) + + +def _four_channel_image(array: NDArray) -> Image: + """Convert 4-channel array to Image. + + Parameters + ---------- + array : NDArray + Normalized array to convert. + + Returns + ------- + Image + Converted array. + """ + colors = color_palette[np.newaxis, np.newaxis, :, :] + four_c_array = np.sum(array[..., :4, np.newaxis] * colors, axis=-2).astype(np.uint8) + + return Image.fromarray(four_c_array).convert("RGB") + + +def _convert_to_image(original_shape: tuple[int, ...], array: NDArray) -> Image: + """Convert to Image. + + Parameters + ---------- + original_shape : tuple + Original shape of the array. + array : NDArray + Normalized array to convert. + + Returns + ------- + Image + Converted array. + """ + n_channels = original_shape[1] + + if n_channels > 1: + if n_channels == 3: + return Image.fromarray(array).convert("RGB") + elif n_channels == 2: + # add an empty channel to the numpy array + array = np.concatenate([array, np.zeros_like(array[..., 0:1])], axis=-1) + + return Image.fromarray(array).convert("RGB") + else: # more than 4 + return _four_channel_image(array[..., :4]) + else: + return Image.fromarray(array).convert("L").convert("RGB") + + +def create_cover(directory: Path, array_in: NDArray, array_out: NDArray) -> Path: + """Create a cover image from input and output arrays. + + Input and output arrays are expected to be SC(Z)YX. For images with a Z + dimension, the middle slice is taken. + + Parameters + ---------- + directory : Path + Directory in which to save the cover. + array_in : numpy.ndarray + Array from which to create the cover image. + array_out : numpy.ndarray + Array from which to create the cover image. + + Returns + ------- + Path + Path to the saved cover image. + """ + # extract slice and normalize arrays + slice_in = _get_norm_slice(array_in) + slice_out = _get_norm_slice(array_out) + + horizontal_split = slice_in.shape[-1] == slice_out.shape[-1] + if not horizontal_split: + if slice_in.shape[-2] != slice_out.shape[-2]: + raise ValueError("Input and output arrays have different shapes.") + + # convert to Image + image_in = _convert_to_image(array_in.shape, slice_in) + image_out = _convert_to_image(array_out.shape, slice_out) + + # split horizontally or vertically + if horizontal_split: + width = image_in.width // 2 + + cover = Image.new("RGB", (image_in.width, image_in.height)) + cover.paste(image_in.crop((0, 0, width, image_in.height)), (0, 0)) + cover.paste( + image_out.crop( + (image_in.width - width, 0, image_in.width, image_in.height) + ), + (width, 0), + ) + else: + height = image_in.height // 2 + + cover = Image.new("RGB", (image_in.width, image_in.height)) + cover.paste(image_in.crop((0, 0, image_in.width, height)), (0, 0)) + cover.paste( + image_out.crop( + (0, image_in.height - height, image_in.width, image_in.height) + ), + (0, height), + ) + + # save + cover_path = directory / "cover.png" + cover.save(cover_path) + + return cover_path diff --git a/src/careamics/model_io/bioimage/model_description.py b/src/careamics/model_io/bioimage/model_description.py index c7812fda..23c9f7ba 100644 --- a/src/careamics/model_io/bioimage/model_description.py +++ b/src/careamics/model_io/bioimage/model_description.py @@ -196,6 +196,7 @@ def create_model_description( careamics_version: str, config_path: Union[Path, str], env_path: Union[Path, str], + covers: list[Union[Path, str]], channel_names: Optional[List[str]] = None, model_version: str = "0.1.0", ) -> ModelDescr: @@ -227,6 +228,8 @@ def create_model_description( Path to model configuration. env_path : Union[Path, str] Path to environment file. + covers : list of pathlib.Path or str + Paths to cover images. channel_names : Optional[List[str]], optional Channel names, by default None. model_version : str, default "0.1.0" @@ -298,6 +301,7 @@ def create_model_description( weights=weights_descr, attachments=[FileDescr(source=config_path)], cite=config.get_algorithm_citations(), + covers=covers, ) return model diff --git a/src/careamics/model_io/bmz_io.py b/src/careamics/model_io/bmz_io.py index 245ddc7b..ce907514 100644 --- a/src/careamics/model_io/bmz_io.py +++ b/src/careamics/model_io/bmz_io.py @@ -22,6 +22,7 @@ create_model_description, extract_model_path, ) +from .bioimage.cover_factory import create_cover def _export_state_dict( @@ -89,6 +90,7 @@ def export_to_bmz( authors: List[dict], input_array: np.ndarray, output_array: np.ndarray, + covers: Optional[list[Union[Path, str]]] = None, channel_names: Optional[List[str]] = None, model_version: str = "0.1.0", ) -> None: @@ -119,6 +121,8 @@ def export_to_bmz( Input array, should not have been normalized. output_array : np.ndarray Output array, should have been denormalized. + covers : list of pathlib.Path or str, default=None + Paths to the cover images. channel_names : Optional[List[str]], optional Channel names, by default None. model_version : str, default="0.1.0" @@ -169,6 +173,10 @@ def export_to_bmz( # export model state dictionary weight_path = _export_state_dict(model, temp_path / "weights.pth") + # export cover if necesary + if covers is None: + covers = [create_cover(temp_path, input_array, output_array)] + # create model description model_description = create_model_description( config=config, @@ -183,6 +191,7 @@ def export_to_bmz( careamics_version=careamics_version, config_path=config_path, env_path=env_path, + covers=covers, channel_names=channel_names, model_version=model_version, ) From 4e57f872a1bf1e8d42f93b16887f452d8a52a482 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Thu, 12 Dec 2024 20:23:24 +0100 Subject: [PATCH 8/9] fix: Ignore red channel for 2 color --- src/careamics/model_io/bioimage/cover_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/careamics/model_io/bioimage/cover_factory.py b/src/careamics/model_io/bioimage/cover_factory.py index de84d3cb..a2cd5322 100644 --- a/src/careamics/model_io/bioimage/cover_factory.py +++ b/src/careamics/model_io/bioimage/cover_factory.py @@ -98,7 +98,7 @@ def _convert_to_image(original_shape: tuple[int, ...], array: NDArray) -> Image: return Image.fromarray(array).convert("RGB") elif n_channels == 2: # add an empty channel to the numpy array - array = np.concatenate([array, np.zeros_like(array[..., 0:1])], axis=-1) + array = np.concatenate([np.zeros_like(array[..., 0:1]), array], axis=-1) return Image.fromarray(array).convert("RGB") else: # more than 4 From af3080f375367832948e1d06fea82ca4deb94244 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:58:59 +0100 Subject: [PATCH 9/9] feat: Add metrics to validation description --- src/careamics/model_io/bioimage/_readme_factory.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/careamics/model_io/bioimage/_readme_factory.py b/src/careamics/model_io/bioimage/_readme_factory.py index 4e879089..7db30c6f 100644 --- a/src/careamics/model_io/bioimage/_readme_factory.py +++ b/src/careamics/model_io/bioimage/_readme_factory.py @@ -82,14 +82,15 @@ def readme_factory( description.append("\n\n") # validation - description.append("## Validation\n\n") + description.append("# Validation\n\n") description.append( "In order to validate the model, we encourage users to acquire a " "test dataset with ground-truth data. Comparing the ground-truth data " "with the prediction allows unbiased evaluation of the model performances. " - "In the absence of ground-truth, inspecting the residual image (difference " - "between input and predicted image) can be helpful to identify " + "This can be done for instance by using metrics such as PSNR, SSIM, or" + "MicroSSIM. In the absence of ground-truth, inspecting the residual image " + "(difference between input and predicted image) can be helpful to identify " "whether real signal is removed from the input image.\n\n" ) @@ -102,7 +103,7 @@ def readme_factory( # links description.append( - "## Links\n\n" + "# Links\n\n" "- [CAREamics repository](https://github.com/CAREamics/careamics)\n" "- [CAREamics documentation](https://careamics.github.io/)\n" )