Repository: scverse/spatialdata-io

Issue:
Add CLI for conversions from and to standard types (`.png`, `.tiff`, `.geojson`, ...)
If we have a single image, say a `.tiff` file, it would be convenient to be able to:
- [x] convert it to a `SpatialData` Zarr stored object with a single image
- [x] add the image to an existing `SpatialData` object
All using the CLI.

Hints from issue comments:
@LucaMarconato: Note, we have a already a parser for `.geojson` data into a shapes object in `ShapesModel.parse()`. It could be a good starting point to move that into a CLI function.

Instead, for say `.png` -> `.zarr` conversion, the pseudocode would look something like this:
1. use `skimage.imread` to read the image lazily
2. use `Image2DModel.parse` to parse the image
3. construct a `SpatialData` object and write it to disk.@LucaMarconato: In particular a very convenient use case would be to quick extract an image in, say, `.png`, from a Zarr store.

Solution patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 46c41f2f..8c1835d4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -10,8 +10,8 @@ repos:
       rev: 24.10.0
       hooks:
           - id: black
-    - repo: https://github.com/pre-commit/mirrors-prettier
-      rev: v4.0.0-alpha.8
+    - repo: https://github.com/rbubley/mirrors-prettier
+      rev: v3.4.2
       hooks:
           - id: prettier
     - repo: https://github.com/asottile/blacken-docs
diff --git a/README.md b/README.md
index f3dd6fca..41beda62 100644
--- a/README.md
+++ b/README.md
@@ -86,15 +86,18 @@ Technologies that can be read into `SpatialData` objects using third-party libra
 This library is community maintained and is not officially endorsed by the aforementioned spatial technology companies. As such, we cannot offer any warranty of the correctness of the representation. Furthermore, we cannot ensure the correctness of the readers for every data version as the technologies evolve and update their formats. If you find a bug or notice a misrepresentation of the data please report it via our [Bug Tracking System](https://github.com/scverse/spatialdata-io/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) so that it can be addressed either by the maintainers of this library or by the community.
 
 ## Solutions to common problems
+
 ### Problem: I cannot visualize the data, everything is slow
-Solution: after parsing the data with `spatialdata-io` readers, you need to write it to Zarr and read it again. Otherwise the performance advantage given by the SpatialData Zarr format will not available. 
+
+Solution: after parsing the data with `spatialdata-io` readers, you need to write it to Zarr and read it again. Otherwise the performance advantage given by the SpatialData Zarr format will not available.
+
 ```python
 from spatialdata_io import xenium
 from spatialdata import read_zarr
 
-sdata = xenium('raw_data')
-sdata.write('data.zarr')
-sdata = read_zarr('sdata.zarr')
+sdata = xenium("raw_data")
+sdata.write("data.zarr")
+sdata = read_zarr("sdata.zarr")
 ```
 
 ## Citation
diff --git a/docs/api.md b/docs/api.md
index d82f535a..95ff1650 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -1,5 +1,7 @@
 # API
 
+This section documents the Application Programming Interface (API) for the `spatialdata_io` package.
+
 ```{eval-rst}
 .. module:: spatialdata_io
 ```
@@ -10,6 +12,8 @@ I/O for the `spatialdata` project.
 
 ### Readers
 
+#### Spatial technologies
+
 ```{eval-rst}
 .. autosummary::
     :toctree: generated
@@ -18,6 +22,7 @@ I/O for the `spatialdata` project.
     cosmx
     curio
     dbit
+    experimental.iss
     mcmicro
     merscope
     seqfish
@@ -28,15 +33,15 @@ I/O for the `spatialdata` project.
     xenium
 ```
 
-### Experimental readers
+#### Data types
 
 ```{eval-rst}
-.. currentmodule:: spatialdata_io.experimental
-
 .. autosummary::
     :toctree: generated
 
-    iss
+    generic
+    image
+    geojson
 ```
 
 ### Conversion functions
@@ -47,18 +52,8 @@ I/O for the `spatialdata` project.
 .. autosummary::
     :toctree: generated
 
-```
-
-### Experimental conversion functions
-
-```{eval-rst}
-.. currentmodule:: spatialdata_io.experimental
-
-.. autosummary::
-    :toctree: generated
-
-    from_legacy_anndata
-    to_legacy_anndata
+    experimental.from_legacy_anndata
+    experimental.to_legacy_anndata
 ```
 
 ### Utility functions
diff --git a/docs/cli.md b/docs/cli.md
new file mode 100644
index 00000000..44130120
--- /dev/null
+++ b/docs/cli.md
@@ -0,0 +1,9 @@
+# CLI
+
+This section documents the Command Line Interface (CLI) for the `spatialdata_io` package.
+
+```{eval-rst}
+.. click:: spatialdata_io.__main__:cli
+    :prog: spatialdata_io
+    :nested: full
+```
diff --git a/docs/conf.py b/docs/conf.py
index 15bf1458..24e42ba9 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -57,6 +57,7 @@
     "sphinx_autodoc_typehints",
     "sphinx.ext.mathjax",
     "IPython.sphinxext.ipython_console_highlighting",
+    "sphinx_click",
     *[p.stem for p in (HERE / "extensions").glob("*.py")],
 ]
 
diff --git a/docs/index.md b/docs/index.md
index 46aa14d4..6d698add 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -7,6 +7,7 @@
 :maxdepth: 1
 
 api.md
+cli.md
 changelog.md
 template_usage.md
 contributing.md
diff --git a/pyproject.toml b/pyproject.toml
index a922782c..81f0ae28 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,7 @@ urls.Source = "https://github.com/scverse/spatialdata-io"
 urls.Home-page = "https://github.com/scverse/spatialdata-io"
 dependencies = [
     "anndata",
+    "click",
     "numpy",
     "scanpy",
     "spatialdata>=0.2.6",
@@ -50,6 +51,7 @@ doc = [
     "sphinxcontrib-bibtex>=1.0.0",
     "sphinx-autodoc-typehints",
     "sphinx-design",
+    "sphinx-click",
     # For notebooks
     "ipython>=8.6.0",
     "sphinx-copybutton",
diff --git a/src/spatialdata_io/__init__.py b/src/spatialdata_io/__init__.py
index 618855a5..0648c09e 100644
--- a/src/spatialdata_io/__init__.py
+++ b/src/spatialdata_io/__init__.py
@@ -1,9 +1,11 @@
 from importlib.metadata import version
 
+from spatialdata_io.converters.generic_to_zarr import generic_to_zarr
 from spatialdata_io.readers.codex import codex
 from spatialdata_io.readers.cosmx import cosmx
 from spatialdata_io.readers.curio import curio
 from spatialdata_io.readers.dbit import dbit
+from spatialdata_io.readers.generic import generic, geojson, image
 from spatialdata_io.readers.macsima import macsima
 from spatialdata_io.readers.mcmicro import mcmicro
 from spatialdata_io.readers.merscope import merscope
@@ -18,22 +20,42 @@
     xenium_explorer_selection,
 )
 
-__all__ = [
-    "curio",
-    "seqfish",
-    "visium",
-    "xenium",
+_readers_technologies = [
     "codex",
     "cosmx",
+    "curio",
+    "dbit",
+    "macsima",
     "mcmicro",
+    "merscope",
+    "seqfish",
     "steinbock",
     "stereoseq",
-    "merscope",
-    "xenium_aligned_image",
-    "xenium_explorer_selection",
-    "dbit",
+    "visium",
     "visium_hd",
-    "macsima",
+    "xenium",
 ]
 
+_readers_file_types = [
+    "generic",
+    "image",
+    "geojson",
+]
+
+_converters = [
+    "generic_to_zarr",
+]
+
+
+__all__ = (
+    [
+        "xenium_aligned_image",
+        "xenium_explorer_selection",
+    ]
+    + _readers_technologies
+    + _readers_file_types
+    + _converters
+)
+
+
 __version__ = version("spatialdata-io")
diff --git a/src/spatialdata_io/__main__.py b/src/spatialdata_io/__main__.py
new file mode 100644
index 00000000..15d212f1
--- /dev/null
+++ b/src/spatialdata_io/__main__.py
@@ -0,0 +1,645 @@
+import importlib
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any, Literal
+
+import click
+
+# dynamically import all readers and converters (also the experimental ones)
+from spatialdata_io import _converters, _readers_file_types, _readers_technologies
+from spatialdata_io._constants._constants import VisiumKeys
+from spatialdata_io.converters.generic_to_zarr import generic_to_zarr
+from spatialdata_io.experimental import _converters as _experimental_converters
+from spatialdata_io.experimental import (
+    _readers_file_types as _experimental_readers_file_types,
+)
+from spatialdata_io.experimental import (
+    _readers_technologies as _experimental_readers_technologies,
+)
+from spatialdata_io.readers.generic import VALID_IMAGE_TYPES, VALID_SHAPE_TYPES
+
+for func in _readers_technologies + _readers_file_types + _converters:
+    module = importlib.import_module("spatialdata_io")
+    globals()[func] = getattr(module, func)
+
+for func in _experimental_readers_technologies + _experimental_readers_file_types + _experimental_converters:
+    module = importlib.import_module("spatialdata_io.experimental")
+    globals()[func] = getattr(module, func)
+
+
+@click.group()
+def cli() -> None:
+    """
+    Convert standard technology data formats to SpatialData object.
+
+    Usage:
+
+    python -m spatialdata_io <Command> -i <input> -o <output>
+
+    For help on how to use a specific command, run:
+
+    python -m spatialdata_io <Command> --help
+    """
+
+
+def _input_output_click_options(func: Callable[..., None]) -> Callable[..., None]:
+    """Decorator to add input and output options to a Click command."""
+    func = click.option(
+        "--input",
+        "-i",
+        type=click.Path(exists=True, file_okay=False, dir_okay=True),
+        help="Path to the input file.",
+        required=True,
+    )(func)
+    func = click.option(
+        "--output", "-o", type=click.Path(exists=False), help="Path to the output file.", required=True
+    )(func)
+    return func
+
+
+@cli.command(name="codex")
+@_input_output_click_options
+@click.option(
+    "--fcs",
+    type=bool,
+    default=True,
+    help="Whether the .fcs file is provided if False a .csv file is expected. [default: True]",
+)
+def codex_wrapper(input: str, output: str, fcs: bool = True) -> None:
+    """Codex conversion to SpatialData"""
+    sdata = codex(input, fcs=fcs)  # type: ignore[name-defined] # noqa: F821
+    sdata.write(output)
+
+
+@cli.command(name="cosmx")
+@_input_output_click_options
+@click.option("--dataset-id", type=str, default=None, help="Name of the dataset [default: None]")
+@click.option("--transcripts", type=bool, default=True, help="Whether to load transcript information. [default: True]")
+def cosmx_wrapper(input: str, output: str, dataset_id: str | None = None, transcripts: bool = True) -> None:
+    """Cosmic conversion to SpatialData"""
+    sdata = cosmx(input, dataset_id=dataset_id, transcripts=transcripts)  # type: ignore[name-defined] # noqa: F821
+    sdata.write(output)
+
+
+@cli.command(name="curio")
+@_input_output_click_options
+def curio_wrapper(input: str, output: str) -> None:
+    """Curio conversion to SpatialData"""
+    sdata = curio(input)  # type: ignore[name-defined] # noqa: F821
+    sdata.write(output)
+
+
+@cli.command(name="dbit")
+@_input_output_click_options
+@click.option(
+    "--anndata-path",
+    type=click.Path(exists=True),
+    default=None,
+    help="Path to the counts and metadata file. [default: None]",
+)
+@click.option(
+    "--barcode-position",
+    type=click.Path(exists=True),
+    default=None,
+    help="Path to the barcode coordinates file. [default: None]",
+)
+@click.option("--image-path", type=str, default=None, help="Path to the low resolution image file. [default: None]")
+@click.option("--dataset-id", type=str, default=None, help="Dataset ID. [default: None]")
+@click.option("--border", type=bool, default=True, help="Value pass internally to _xy2edges. [default: True]")
+@click.option("--border-scale", type=float, default=1, help="The factor by which the border is scaled. [default: 1]")
+def dbit_wrapper(
+    input: str,
+    output: str,
+    anndata_path: str | None = None,
+    barcode_position: str | None = None,
+    image_path: str | None = None,
+    dataset_id: str | None = None,
+    border: bool = True,
+    border_scale: float = 1,
+) -> None:
+    """Conversion of DBit-seq to SpatialData"""
+    sdata = dbit(  # type: ignore[name-defined] # noqa: F821
+        input,
+        anndata_path=anndata_path,
+        barcode_position=barcode_position,
+        image_path=image_path,
+        dataset_id=dataset_id,
+        border=border,
+        border_scale=border_scale,
+    )
+    sdata.write(output)
+
+
+@cli.command(name="iss")
+@_input_output_click_options
+@click.option(
+    "--raw-relative-path", type=click.Path(exists=True), required=True, help="Relative path to raw raster image file."
+)
+@click.option(
+    "--labels-relative-path", type=click.Path(exists=True), required=True, help="Relative path to label image file."
+)
+@click.option(
+    "--h5ad-relative-path",
+    type=click.Path(exists=True),
+    required=True,
+    help="Relative path to counts and metadata file.",
+)
+@click.option(
+    "--instance-key",
+    type=str,
+    default=None,
+    help="Which column of the AnnData table contains the CellID. [default: None]",
+)
+@click.option("--dataset-id", type=str, default="region", help="Dataset ID [default: region]")
+@click.option(
+    "--multiscale-image",
+    type=bool,
+    default=True,
+    help="Whether to process the image into a multiscale image [default: True]",
+)
+@click.option(
+    "--multiscale-labels",
+    type=bool,
+    default=True,
+    help="Whether to process the label image into a multiscale image [default: True]",
+)
+def iss_wrapper(
+    input: str,
+    output: str,
+    raw_relative_path: Path,
+    labels_relative_path: Path,
+    h5ad_relative_path: Path,
+    instance_key: str | None = None,
+    dataset_id: str = "region",
+    multiscale_image: bool = True,
+    multiscale_labels: bool = True,
+) -> None:
+    """ISS conversion to SpatialData"""
+    sdata = iss(  # type: ignore[name-defined] # noqa: F821
+        input,
+        raw_relative_path,
+        labels_relative_path,
+        h5ad_relative_path,
+        instance_key=instance_key,
+        dataset_id=dataset_id,
+        multiscale_image=multiscale_image,
+        multiscale_labels=multiscale_labels,
+    )
+    sdata.write(output)
+
+
+@cli.command(name="mcmicro")
+@click.option(
+    "--input", "-i", type=click.Path(exists=True), help="Path to the mcmicro project directory.", required=True
+)
+@click.option("--output", "-o", type=click.Path(), help="Path to the output.zarr file.", required=True)
+def mcmicro_wrapper(input: str, output: str) -> None:
+    """Conversion of MCMicro to SpatialData"""
+    sdata = mcmicro(input)  # type: ignore[name-defined] # noqa: F821
+    sdata.write(output)
+
+
+@cli.command(name="merscope")
+@_input_output_click_options
+@click.option(
+    "--vpt-outputs",
+    type=click.Path(exists=True),
+    default=None,
+    help="Optional argument to specify the path to the Vizgen postprocessing tool. [default: None]",
+)
+@click.option("--z-layers", type=int, default=3, help="Indices of the z-layers to consider. [default: 3]")
+@click.option("--region-name", type=str, default=None, help="Name of the ROI. [default: None]")
+@click.option("--slide-name", type=str, default=None, help="Name of the slide/run [default: None]")
+@click.option(
+    "--backend",
+    type=click.Choice(["dask_image", "rioxarray"]),
+    default=None,
+    help="Either 'dask_image' or 'rioxarray'. [default: None]",
+)
+@click.option("--transcripts", type=bool, default=True, help="Whether to read transcripts.  [default: True]")
+@click.option("--cells-boundaries", type=bool, default=True, help="Whether to read cells boundaries. [default: True]")
+@click.option("--cells-table", type=bool, default=True, help="Whether to read cells table.  [default: True]")
+@click.option("--mosaic-images", type=bool, default=True, help="Whether to read the mosaic images.  [default: True]")
+def merscope_wrapper(
+    input: str,
+    output: str,
+    vpt_outputs: Path | str | dict[str, Any] | None = None,
+    z_layers: int | list[int] | None = 3,
+    region_name: str | None = None,
+    slide_name: str | None = None,
+    backend: Literal["dask_image", "rioxarray"] | None = None,
+    transcripts: bool = True,
+    cells_boundaries: bool = True,
+    cells_table: bool = True,
+    mosaic_images: bool = True,
+) -> None:
+    """Merscope conversion to SpatialData"""
+    sdata = merscope(  # type: ignore[name-defined] # noqa: F821
+        input,
+        vpt_outputs=vpt_outputs,
+        z_layers=z_layers,
+        region_name=region_name,
+        slide_name=slide_name,
+        backend=backend,
+        transcripts=transcripts,
+        cells_boundaries=cells_boundaries,
+        cells_table=cells_table,
+        mosaic_images=mosaic_images,
+    )
+    sdata.write(output)
+
+
+@cli.command(name="seqfish")
+@_input_output_click_options
+@click.option("--load-images", type=bool, default=True, help="Whether to load images. [default: True]")
+@click.option("--load-labels", type=bool, default=True, help="Whether to load labels. [default: True]")
+@click.option("--load-points", type=bool, default=True, help="Whether to load points. [default: True]")
+@click.option("--load-shapes", type=bool, default=True, help="Whether to load shapes. [default: True]")
+@click.option("--cells-as-circles", type=bool, default=False, help="Whether to read cells as circles. [default: False]")
+@click.option(
+    "--rois",
+    type=click.IntRange(min=0),
+    multiple=True,
+    default=None,
+    help="Which sections to load. Provide one or more section indices. [default: All sections are loaded]",
+)
+def seqfish_wrapper(
+    input: str,
+    output: str,
+    load_images: bool = True,
+    load_labels: bool = True,
+    load_points: bool = True,
+    load_shapes: bool = True,
+    cells_as_circles: bool = False,
+    rois: list[int] | None = None,
+) -> None:
+    """Seqfish conversion to SpatialData"""
+    rois = list(rois) if rois else None
+    sdata = seqfish(  # type: ignore[name-defined] # noqa: F821
+        input,
+        load_images=load_images,
+        load_labels=load_labels,
+        load_points=load_points,
+        load_shapes=load_shapes,
+        cells_as_circles=cells_as_circles,
+        rois=rois,
+    )
+    sdata.write(output)
+
+
+@cli.command(name="steinbock")
+@_input_output_click_options
+@click.option(
+    "--labels-kind",
+    type=click.Choice(["deepcell", "ilastik"]),
+    default="deepcell",
+    help="What kind of labels to use. [default: 'deepcell']",
+)
+def steinbock_wrapper(input: str, output: str, labels_kind: Literal["deepcell", "ilastik"] = "deepcell") -> None:
+    """Steinbock conversion to SpatialData"""
+    sdata = steinbock(input, labels_kind=labels_kind)  # type: ignore[name-defined] # noqa: F821
+    sdata.write(output)
+
+
+@cli.command(name="stereoseq")
+@_input_output_click_options
+@click.option("--dataset-id", type=str, default=None, help="Dataset ID. [default: None]")
+@click.option(
+    "--read-square-bin",
+    type=bool,
+    default=True,
+    help="If True, will read the square bin ``{xx.GEF_FILE!r}`` file and build corresponding points element. [default: True]",
+)
+@click.option(
+    "--optional-tif", type=bool, default=False, help="If True, will read ``{xx.TISSUE_TIF!r}`` files. [default: False]"
+)
+def stereoseq_wrapper(
+    input: str,
+    output: str,
+    dataset_id: str | None = None,
+    read_square_bin: bool = True,
+    optional_tif: bool = False,
+) -> None:
+    """Stereoseq conversion to SpatialData"""
+    sdata = stereoseq(input, dataset_id=dataset_id, read_square_bin=read_square_bin, optional_tif=optional_tif)  # type: ignore[name-defined] # noqa: F821
+    sdata.write(output)
+
+
+@cli.command(name="visium")
+@_input_output_click_options
+@click.option("--dataset-id", type=str, default=None, help="Dataset ID. [default: None]")
+@click.option(
+    "--counts-file",
+    type=str,
+    default=VisiumKeys.FILTERED_COUNTS_FILE,
+    help="Name of the counts file, defaults to ``{vx.FILTERED_COUNTS_FILE!r}``. [default: None]",
+)
+@click.option(
+    "--fullres-image-file",
+    type=click.Path(exists=True),
+    default=None,
+    help="Path to the full resolution image. [default: None]",
+)
+@click.option(
+    "--tissue-positions-file",
+    type=click.Path(exists=True),
+    default=None,
+    help="Path to the tissue positions file. [default: None]",
+)
+@click.option(
+    "--scalefactors-file",
+    type=click.Path(exists=True),
+    default=None,
+    help="Path to the scalefactors file. [default: None]",
+)
+def visium_wrapper(
+    input: str,
+    output: str,
+    dataset_id: str | None = None,
+    counts_file: str = VisiumKeys.FILTERED_COUNTS_FILE,
+    fullres_image_file: str | Path | None = None,
+    tissue_positions_file: str | Path | None = None,
+    scalefactors_file: str | Path | None = None,
+) -> None:
+    """Visium conversion to SpatialData"""
+    sdata = visium(  # type: ignore[name-defined] # noqa: F821
+        input,
+        dataset_id=dataset_id,
+        counts_file=counts_file,
+        fullres_image_file=fullres_image_file,
+        tissue_positions_file=tissue_positions_file,
+        scalefactors_file=scalefactors_file,
+    )
+    sdata.write(output)
+
+
+@cli.command(name="visium-hd")
+@_input_output_click_options
+@click.option("--dataset-id", type=str, default=None, help="Dataset ID. [default: None]")
+@click.option(
+    "--filtered-counts-file",
+    type=bool,
+    default=True,
+    help="It sets the value of `counts_file` to ``{vx.FILTERED_COUNTS_FILE!r}`` (when `True`) or to``{vx.RAW_COUNTS_FILE!r}`` (when `False`). [default: True]",
+)
+@click.option(
+    "--bin-size",
+    type=int,
+    multiple=True,
+    default=None,
+    help="When specified, load the data of a specific bin size, or a list of bin sizes. By default, it loads all the available bin sizes. [default: None]",
+)
+@click.option(
+    "--bins-as-squares",
+    type=bool,
+    default=True,
+    help="If true, bins are represented as squares otherwise as circles. [default: True]",
+)
+@click.option(
+    "--fullres-image-file",
+    type=click.Path(exists=True),
+    default=None,
+    help="Path to the full resolution image. [default: None]",
+)
+@click.option(
+    "--load-all-images",
+    type=bool,
+    default=False,
+    help="If `False`, load only the full resolution, high resolution, and low resolution images. If `True`, also the following images: ``{vx.IMAGE_CYTASSIST!r}``. [default: False]",
+)
+@click.option(
+    "--annotate-table-by-labels",
+    type=bool,
+    default=False,
+    help="If true, annotates the table by labels. [default: False]",
+)
+def visium_hd_wrapper(
+    input: str,
+    output: str,
+    dataset_id: str | None = None,
+    filtered_counts_file: bool = True,
+    bin_size: int | list[int] | None = None,
+    bins_as_squares: bool = True,
+    fullres_image_file: str | Path | None = None,
+    load_all_images: bool = False,
+    annotate_table_by_labels: bool = False,
+) -> None:
+    """Visium HD conversion to SpatialData"""
+    sdata = visium_hd(  # type: ignore[name-defined] # noqa: F821
+        path=input,
+        dataset_id=dataset_id,
+        filtered_counts_file=filtered_counts_file,
+        bin_size=bin_size,
+        bins_as_squares=bins_as_squares,
+        fullres_image_file=fullres_image_file,
+        load_all_images=load_all_images,
+        annotate_table_by_labels=annotate_table_by_labels,
+    )
+    sdata.write(output)
+
+
+@cli.command(name="xenium")
+@_input_output_click_options
+@click.option("--cells-boundaries", type=bool, default=True, help="Whether to read cells boundaries. [default: True]")
+@click.option(
+    "--nucleus-boundaries", type=bool, default=True, help="Whether to read Nucleus boundaries. [default: True]"
+)
+@click.option("--cells-as-circles", type=bool, default=None, help="Whether to read cells as circles. [default: None]")
+@click.option("--cells-labels", type=bool, default=True, help="Whether to read cells labels (raster). [default: True]")
+@click.option(
+    "--nucleus-labels", type=bool, default=True, help="Whether to read nucleus labels (raster). [default: True]"
+)
+@click.option("--transcripts", type=bool, default=True, help="Whether to read transcripts. [default: True]")
+@click.option("--morphology-mip", type=bool, default=True, help="Whether to read morphology mip image. [default: True]")
+@click.option(
+    "--morphology-focus", type=bool, default=True, help="Whether to read morphology focus image. [default: True]"
+)
+@click.option(
+    "--aligned-images",
+    type=bool,
+    default=True,
+    help="Whether to parse additional H&E or IF aligned images. [default: True]",
+)
+@click.option(
+    "--cells-table",
+    type=bool,
+    default=True,
+    help="Whether to read cells annotations in the AnnData table. [default: True]",
+)
+@click.option("--n-jobs", type=int, default=1, help="Number of jobs. [default: 1]")
+def xenium_wrapper(
+    input: str,
+    output: str,
+    *,
+    cells_boundaries: bool = True,
+    nucleus_boundaries: bool = True,
+    cells_as_circles: bool | None = None,
+    cells_labels: bool = True,
+    nucleus_labels: bool = True,
+    transcripts: bool = True,
+    morphology_mip: bool = True,
+    morphology_focus: bool = True,
+    aligned_images: bool = True,
+    cells_table: bool = True,
+    n_jobs: int = 1,
+) -> None:
+    """Xenium conversion to SpatialData"""
+    sdata = xenium(  # type: ignore[name-defined] # noqa: F821
+        input,
+        cells_boundaries=cells_boundaries,
+        nucleus_boundaries=nucleus_boundaries,
+        cells_as_circles=cells_as_circles,
+        cells_labels=cells_labels,
+        nucleus_labels=nucleus_labels,
+        transcripts=transcripts,
+        morphology_mip=morphology_mip,
+        morphology_focus=morphology_focus,
+        aligned_images=aligned_images,
+        cells_table=cells_table,
+        n_jobs=n_jobs,
+    )
+    sdata.write(output)
+
+
+@cli.command(name="macsima")
+@_input_output_click_options
+@click.option(
+    "--filter-folder-names",
+    type=str,
+    multiple=True,
+    default=None,
+    help="List of folder names to filter out when parsing multiple folders. [default: None]",
+)
+@click.option(
+    "--subset",
+    type=int,
+    default=None,
+    help="Subset the image to the first 'subset' pixels in x and y dimensions. [default: None]",
+)
+@click.option(
+    "--c-subset", type=int, default=None, help="Subset the image to the first 'c-subset' channels. [default: None]"
+)
+@click.option(
+    "--max-chunk-size", type=int, default=1024, help="Maximum chunk size for x and y dimensions. [default: 1024]"
+)
+@click.option("--c-chunks-size", type=int, default=1, help="Chunk size for c dimension. [default: 1]")
+@click.option("--multiscale", type=bool, default=True, help="Whether to create a multiscale image. [default: True]")
+@click.option(
+    "--transformations",
+    type=bool,
+    default=True,
+    help="Whether to add a transformation from pixels to microns to the image. [default: True]",
+)
+@click.option(
+    "--scale-factors",
+    type=int,
+    multiple=True,
+    default=None,
+    help="Scale factors to use for downsampling. If None, scale factors are calculated based on image size. [default: None]",
+)
+@click.option(
+    "--default-scale-factor", type=int, default=2, help="Default scale factor to use for downsampling. [default: 2]"
+)
+@click.option(
+    "--nuclei-channel-name",
+    type=str,
+    default="DAPI",
+    help="Common string of the nuclei channel to separate nuclei from other channels. [default: 'DAPI']",
+)
+@click.option(
+    "--split-threshold-nuclei-channel",
+    type=int,
+    default=2,
+    help="Threshold for splitting nuclei channels. [default: 2]",
+)
+@click.option(
+    "--skip-rounds",
+    type=int,
+    multiple=True,
+    default=None,
+    help="List of round numbers to skip when parsing the data. [default: None]",
+)
+@click.option(
+    "--include-cycle-in-channel-name",
+    type=bool,
+    default=False,
+    help="Whether to include the cycle number in the channel name. [default: False]",
+)
+def macsima_wrapper(
+    input: str,
+    output: str,
+    *,
+    filter_folder_names: list[str] | None = None,
+    subset: int | None = None,
+    c_subset: int | None = None,
+    max_chunk_size: int = 1024,
+    c_chunks_size: int = 1,
+    multiscale: bool = True,
+    transformations: bool = True,
+    scale_factors: list[int] | None = None,
+    default_scale_factor: int = 2,
+    nuclei_channel_name: str = "DAPI",
+    split_threshold_nuclei_channel: int | None = 2,
+    skip_rounds: list[int] | None = None,
+    include_cycle_in_channel_name: bool = False,
+) -> None:
+    """Read MACSima formatted dataset and convert to SpatialData."""
+    sdata = macsima(  # type: ignore[name-defined] # noqa: F821
+        path=input,
+        filter_folder_names=filter_folder_names,
+        subset=subset,
+        c_subset=c_subset,
+        max_chunk_size=max_chunk_size,
+        c_chunks_size=c_chunks_size,
+        multiscale=multiscale,
+        transformations=transformations,
+        scale_factors=scale_factors,
+        default_scale_factor=default_scale_factor,
+        nuclei_channel_name=nuclei_channel_name,
+        split_threshold_nuclei_channel=split_threshold_nuclei_channel,
+        skip_rounds=skip_rounds,
+        include_cycle_in_channel_name=include_cycle_in_channel_name,
+    )
+    sdata.write(output)
+
+
+@cli.command(name="generic")
+@click.option(
+    "--input",
+    "-i",
+    type=click.Path(exists=True, file_okay=True, dir_okay=False),
+    required=True,
+    help=f"Path to the image/shapes input file. Supported extensions: {VALID_IMAGE_TYPES + VALID_SHAPE_TYPES}",
+)
+@click.option(
+    "--output",
+    "-o",
+    type=click.Path(file_okay=False),
+    required=True,
+    help="Path to zarr store to write to. If it does not exist yet, create new zarr store from input",
+)
+@click.option("--name", "-n", type=str, help="name of the element to be stored")
+@click.option(
+    "--data-axes",
+    type=click.Choice(["cyx", "czyx"], case_sensitive=False),
+    help="Axes of the data for image files. Valid values are 'cyx' and 'czyx'.",
+)
+@click.option(
+    "--coordinate-system",
+    "-c",
+    type=str,
+    help="Coordinate system in spatialdata object to which an element should belong",
+)
+def read_generic_wrapper(
+    input: str,
+    output: str,
+    name: str | None = None,
+    data_axes: str | None = None,
+    coordinate_system: str | None = None,
+) -> None:
+    """Read generic data to SpatialData"""
+    generic_to_zarr(input=input, output=output, name=name, data_axes=data_axes, coordinate_system=coordinate_system)
+
+
+if __name__ == "__main__":
+    cli()
diff --git a/src/spatialdata_io/converters/generic_to_zarr.py b/src/spatialdata_io/converters/generic_to_zarr.py
new file mode 100644
index 00000000..61272139
--- /dev/null
+++ b/src/spatialdata_io/converters/generic_to_zarr.py
@@ -0,0 +1,84 @@
+from pathlib import Path
+
+from spatialdata import SpatialData
+from spatialdata._docs import docstring_parameter
+from spatialdata.models.models import DEFAULT_COORDINATE_SYSTEM
+
+from spatialdata_io.readers.generic import VALID_IMAGE_TYPES, VALID_SHAPE_TYPES
+
+__all__ = ["generic_to_zarr"]
+
+
+@docstring_parameter(
+    valid_image_types=", ".join(VALID_IMAGE_TYPES),
+    valid_shape_types=", ".join(VALID_SHAPE_TYPES),
+    default_coordinate_system=DEFAULT_COORDINATE_SYSTEM,
+)
+def generic_to_zarr(
+    input: str | Path,
+    output: str | Path,
+    name: str | None = None,
+    data_axes: str | None = None,
+    coordinate_system: str | None = None,
+) -> None:
+    """
+    Read generic data from an input file and save it as a SpatialData zarr store.
+
+    Parameters
+    ----------
+    input
+        Path to the image/shapes input file. The file must exist and have a supported extension.
+        Supported image extensions: {valid_image_types}.
+        Supported shapes extensions: {valid_shape_types}.
+    output
+        Path to the zarr store to write to. If the zarr store does not exist, it will be created from the input.
+    name
+        Name of the element to be stored. If not provided, the name will default to the stem of the input file.
+    data_axes
+        Axes of the data for image files. Valid values are 'cyx' and 'czyx'. If not provided, it defaults to None.
+    coordinate_system
+        Coordinate system in the spatialdata object to which an element should belong. If not provided, it defaults
+        to {default_coordinate_system}.
+
+    Raises
+    ------
+    ValueError
+        If the name already exists in the output zarr store, a ValueError is raised, prompting the user to provide
+        a different name or delete the existing element.
+
+    Notes
+    -----
+    This function reads data using the `generic()` method from `spatialdata_io` and writes it to a zarr store
+    using the `SpatialData` class. It handles both existing and new zarr stores, ensuring that data is appropriately
+    appended or initialized.
+    """
+    from spatialdata_io.readers.generic import generic
+
+    input = Path(input)
+    output = Path(output)
+
+    if name is None:
+        name = input.stem
+    if not data_axes:
+        data_axes = None
+    if not coordinate_system:
+        coordinate_system = "global"
+
+    element = generic(
+        input=input, data_axes=list(data_axes) if data_axes is not None else None, coordinate_system=coordinate_system
+    )
+
+    if output.exists():
+        sdata = SpatialData.read_zarr(output)
+        if name in sdata:
+            raise ValueError(
+                f"Name {name} already exists in {output}; please provide a different name or delete the "
+                f"existing element."
+            )
+        sdata[name] = element
+        sdata.write_element(name, element_name=name)
+        print(f"Element {name} written to {output}")
+    else:
+        sdata = SpatialData.init_from_elements(elements={name: element})
+        sdata.write(output)
+        print(f"Data written to {output}")
diff --git a/src/spatialdata_io/experimental/__init__.py b/src/spatialdata_io/experimental/__init__.py
index 7771cb35..36dd4d8b 100644
--- a/src/spatialdata_io/experimental/__init__.py
+++ b/src/spatialdata_io/experimental/__init__.py
@@ -4,4 +4,15 @@
 )
 from spatialdata_io.readers.iss import iss
 
-__all__ = ["from_legacy_anndata", "to_legacy_anndata", "iss"]
+_readers_technologies = [
+    "iss",
+]
+_readers_file_types: list[str] = [
+    # add experimental readers for new file types here
+]
+_converters = [
+    "from_legacy_anndata",
+    "to_legacy_anndata",
+]
+
+__all__ = _readers_technologies + _readers_file_types + _converters
diff --git a/src/spatialdata_io/readers/generic.py b/src/spatialdata_io/readers/generic.py
new file mode 100644
index 00000000..24ee7071
--- /dev/null
+++ b/src/spatialdata_io/readers/generic.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import warnings
+from collections.abc import Sequence
+from pathlib import Path
+
+import numpy as np
+from dask_image.imread import imread
+from geopandas import GeoDataFrame
+from spatialdata._docs import docstring_parameter
+from spatialdata.models import Image2DModel, ShapesModel
+from spatialdata.models._utils import DEFAULT_COORDINATE_SYSTEM
+from spatialdata.transformations import Identity
+from xarray import DataArray
+
+VALID_IMAGE_TYPES = [".tif", ".tiff", ".png", ".jpg", ".jpeg"]
+VALID_SHAPE_TYPES = [".geojson"]
+
+__all__ = ["generic", "geojson", "image", "VALID_IMAGE_TYPES", "VALID_SHAPE_TYPES"]
+
+
+@docstring_parameter(
+    valid_image_types=", ".join(VALID_IMAGE_TYPES),
+    valid_shape_types=", ".join(VALID_SHAPE_TYPES),
+    default_coordinate_system=DEFAULT_COORDINATE_SYSTEM,
+)
+def generic(
+    input: Path,
+    data_axes: Sequence[str] | None = None,
+    coordinate_system: str | None = None,
+) -> DataArray | GeoDataFrame:
+    """
+    Read a generic shapes or image file and save it as SpatialData zarr.
+
+    Supported image types: {valid_image_types}.
+    Supported shape types: {valid_shape_types} (only Polygons and MultiPolygons are supported).
+
+    Parameters
+    ----------
+    input
+        Path to the input file.
+    data_axes
+        Axes of the data for image files, required for image files.
+    coordinate_system
+        Coordinate system of the spatial element; if None, the default coordinate system ({default_coordinate_system})
+        is used.
+
+    Returns
+    -------
+    Parsed spatial element.
+    """
+    if coordinate_system is None:
+        coordinate_system = DEFAULT_COORDINATE_SYSTEM
+    if input.suffix in VALID_SHAPE_TYPES:
+        if data_axes is not None:
+            warnings.warn("data_axes is not used for geojson files", UserWarning, stacklevel=2)
+        return geojson(input, coordinate_system=coordinate_system)
+    elif input.suffix in VALID_IMAGE_TYPES:
+        if data_axes is None:
+            raise ValueError("data_axes must be provided for image files")
+        return image(input, data_axes=data_axes, coordinate_system=coordinate_system)
+    else:
+        raise ValueError(f"Invalid file type. Must be one of {VALID_SHAPE_TYPES + VALID_IMAGE_TYPES}")
+
+
+def geojson(input: Path, coordinate_system: str) -> GeoDataFrame:
+    """Reads a GeoJSON file and returns a parsed GeoDataFrame spatial element"""
+    return ShapesModel.parse(input, transformations={coordinate_system: Identity()})
+
+
+def image(input: Path, data_axes: Sequence[str], coordinate_system: str) -> DataArray:
+    """Reads an image file and returns a parsed Image2D spatial element"""
+    # this function is just a draft, the more general one will be available when
+    # https://github.com/scverse/spatialdata-io/pull/234 is merged
+    image = imread(input)
+    if len(image.shape) == len(data_axes) + 1 and image.shape[0] == 1:
+        image = np.squeeze(image, axis=0)
+    return Image2DModel.parse(image, dims=data_axes, transformations={coordinate_system: Identity()})


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..5e4c26ad
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,7 @@
+import pytest
+from click.testing import CliRunner
+
+
+@pytest.fixture
+def runner() -> CliRunner:
+    return CliRunner()
diff --git a/tests/converters/test_legacy_anndata.py b/tests/converters/test_legacy_anndata.py
index 1f18151a..10c23704 100644
--- a/tests/converters/test_legacy_anndata.py
+++ b/tests/converters/test_legacy_anndata.py
@@ -9,7 +9,10 @@
 from spatialdata.testing import assert_spatial_data_objects_are_identical
 from spatialdata.transformations import get_transformation, set_transformation
 
-from spatialdata_io.experimental import from_legacy_anndata, to_legacy_anndata
+from spatialdata_io.converters.legacy_anndata import (
+    from_legacy_anndata,
+    to_legacy_anndata,
+)
 
 BlobsTypes = Literal["blobs_labels", "blobs_circles", "blobs_polygons", "blobs_multipolygons"]
 
diff --git a/tests/test_generic.py b/tests/test_generic.py
new file mode 100644
index 00000000..d4dda1a7
--- /dev/null
+++ b/tests/test_generic.py
@@ -0,0 +1,116 @@
+import tempfile
+from collections.abc import Generator
+from contextlib import contextmanager
+from pathlib import Path
+
+import numpy as np
+import pytest
+from click.testing import CliRunner
+from PIL import Image
+from spatialdata import SpatialData
+from spatialdata.datasets import blobs
+
+from spatialdata_io.__main__ import read_generic_wrapper
+from spatialdata_io.converters.generic_to_zarr import generic_to_zarr
+
+
+@contextmanager
+def save_temp_files() -> Generator[tuple[Path, Path, Path], None, None]:
+    with tempfile.TemporaryDirectory() as tmpdir:
+        sdata = blobs()
+        # save the image as jpg
+        x = sdata["blobs_image"].data.compute()
+        x = np.clip(x * 255, 0, 255).astype(np.uint8).transpose(1, 2, 0)
+        im = Image.fromarray(x)
+        jpg_path = Path(tmpdir) / "blobs_image.jpg"
+        im.save(jpg_path)
+
+        # save the shapes as geojson
+        gdf = sdata["blobs_multipolygons"]
+        geojson_path = Path(tmpdir) / "blobs_multipolygons.geojson"
+        gdf.to_file(geojson_path, driver="GeoJSON")
+
+        yield jpg_path, geojson_path, Path(tmpdir)
+
+
+@pytest.mark.parametrize("cli", [True, False])
+@pytest.mark.parametrize("element_name", [None, "test_element"])
+def test_read_generic_image(runner: CliRunner, cli: bool, element_name: str | None) -> None:
+    with save_temp_files() as (image_path, geojson_path, tmpdir):
+        output_zarr_path = tmpdir / "output.zarr"
+        if cli:
+            result = runner.invoke(
+                read_generic_wrapper,
+                [
+                    "--input",
+                    image_path,
+                    "--output",
+                    output_zarr_path,
+                    "--name",
+                    element_name,
+                    "--data-axes",
+                    "cyx",
+                    "--coordinate-system",
+                    "global",
+                ],
+            )
+            assert result.exit_code == 0, result.output
+            assert f"Data written to {tmpdir}" in result.output
+        else:
+            generic_to_zarr(
+                input=image_path,
+                output=output_zarr_path,
+                name=element_name,
+                data_axes="cyx",
+                coordinate_system="global",
+            )
+        sdata = SpatialData.read(output_zarr_path)
+        if element_name is None:
+            assert "blobs_image" in sdata
+        else:
+            assert element_name in sdata
+
+
+def test_cli_read_generic_image_invalid_data_axes(runner: CliRunner) -> None:
+    with save_temp_files() as (image_path, geojson_path, tmpdir):
+        output_zarr_path = tmpdir / "output.zarr"
+
+        result = runner.invoke(
+            read_generic_wrapper,
+            [
+                "--input",
+                image_path,
+                "--output",
+                output_zarr_path,
+                "--data-axes",
+                "invalid_axes",
+            ],
+        )
+        assert result.exit_code != 0, result.output
+        assert "'invalid_axes' is not one of 'cyx', 'czyx'." in result.output
+
+
+@pytest.mark.parametrize("cli", [True, False])
+def test_read_generic_geojson(runner: CliRunner, cli: bool) -> None:
+    with save_temp_files() as (image_path, geojson_path, tmpdir):
+        output_zarr_path = tmpdir / "output.zarr"
+
+        if cli:
+            result = runner.invoke(
+                read_generic_wrapper,
+                [
+                    "--input",
+                    geojson_path,
+                    "--output",
+                    output_zarr_path,
+                    "--coordinate-system",
+                    "global",
+                ],
+            )
+            assert result.exit_code == 0, result.output
+            assert f"Data written to {tmpdir}" in result.output
+        else:
+            generic_to_zarr(input=geojson_path, output=output_zarr_path, coordinate_system="global")
+
+        sdata = SpatialData.read(output_zarr_path)
+        assert "blobs_multipolygons" in sdata
diff --git a/tests/test_macsima.py b/tests/test_macsima.py
index 784e4dff..cf7ab2f7 100644
--- a/tests/test_macsima.py
+++ b/tests/test_macsima.py
@@ -1,12 +1,16 @@
 import math
 from copy import deepcopy
 from pathlib import Path
+from tempfile import TemporaryDirectory
 from typing import Any
 
 import dask.array as da
 import pytest
+from click.testing import CliRunner
+from spatialdata import read_zarr
 from spatialdata.models import get_channel_names
 
+from spatialdata_io.__main__ import macsima_wrapper
 from spatialdata_io.readers.macsima import (
     ChannelMetadata,
     MultiChannelImage,
@@ -199,3 +203,29 @@ def test_mci_array_reference() -> None:
     assert not da.all(deepcopy_mci.data[0] == orig_arr1)
     # test that the original mci is not changed
     assert da.all(mci.data[0] == orig_arr1)
+
+
+@skip_if_below_python_version()
+@pytest.mark.parametrize("dataset", ["Lung_adc_demo", "MACSimaData_HCA/HumanLiverH35"])
+def test_cli_macimsa(runner: CliRunner, dataset: str) -> None:
+    f = Path("./data") / dataset
+    assert f.is_dir()
+    with TemporaryDirectory() as tmpdir:
+        output_zarr = Path(tmpdir) / "data.zarr"
+        result = runner.invoke(
+            macsima_wrapper,
+            [
+                "--input",
+                f,
+                "--output",
+                output_zarr,
+                "--subset",
+                500,
+                "--c-subset",
+                1,
+                "--multiscale",
+                False,
+            ],
+        )
+        assert result.exit_code == 0, result.output
+        _ = read_zarr(output_zarr)
diff --git a/tests/test_seqfish.py b/tests/test_seqfish.py
index bdbf2a60..47d4fbd7 100644
--- a/tests/test_seqfish.py
+++ b/tests/test_seqfish.py
@@ -1,8 +1,12 @@
 import math
 from pathlib import Path
+from tempfile import TemporaryDirectory
 
 import pytest
+from click.testing import CliRunner
+from spatialdata import read_zarr
 
+from spatialdata_io.__main__ import seqfish_wrapper
 from spatialdata_io.readers.seqfish import seqfish
 from tests._utils import skip_if_below_python_version
 
@@ -27,3 +31,23 @@ def test_example_data(dataset: str, expected: str, rois: list[int] | None, cells
         # manual correction required to take into account for the circle radii
         expected = "{'y': (-2, 109), 'x': (-2, 109)}"
     assert str(extent) == expected
+
+
+@skip_if_below_python_version()
+@pytest.mark.parametrize("dataset", ["seqfish-2-test-dataset/instrument 2 official"])
+def test_cli_seqfish(runner: CliRunner, dataset: str) -> None:
+    f = Path("./data") / dataset
+    assert f.is_dir()
+    with TemporaryDirectory() as tmpdir:
+        output_zarr = Path(tmpdir) / "data.zarr"
+        result = runner.invoke(
+            seqfish_wrapper,
+            [
+                "--input",
+                f,
+                "--output",
+                output_zarr,
+            ],
+        )
+        assert result.exit_code == 0, result.output
+        _ = read_zarr(output_zarr)
diff --git a/tests/test_xenium.py b/tests/test_xenium.py
index 32408323..c4a39afb 100644
--- a/tests/test_xenium.py
+++ b/tests/test_xenium.py
@@ -1,9 +1,13 @@
 import math
 from pathlib import Path
+from tempfile import TemporaryDirectory
 
 import numpy as np
 import pytest
+from click.testing import CliRunner
+from spatialdata import read_zarr
 
+from spatialdata_io.__main__ import xenium_wrapper
 from spatialdata_io.readers.xenium import (
     cell_id_str_from_prefix_suffix_uint32,
     prefix_suffix_uint32_from_cell_id_str,
@@ -42,6 +46,7 @@ def test_roundtrip_with_data_limits() -> None:
 
 # See https://github.com/scverse/spatialdata-io/blob/main/.github/workflows/prepare_test_data.yaml for instructions on
 # how to download and place the data on disk
+# TODO: add tests for Xenium 3.0.0
 @skip_if_below_python_version()
 @pytest.mark.parametrize(
     "dataset,expected",
@@ -62,3 +67,21 @@ def test_example_data(dataset: str, expected: str) -> None:
 
 
 # TODO: add tests for Xenium 3.0.0
+@skip_if_below_python_version()
+@pytest.mark.parametrize("dataset", ["Xenium_V1_human_Breast_2fov_outs", "Xenium_V1_human_Lung_2fov_outs"])
+def test_cli_xenium(runner: CliRunner, dataset: str) -> None:
+    f = Path("./data") / dataset
+    assert f.is_dir()
+    with TemporaryDirectory() as tmpdir:
+        output_zarr = Path(tmpdir) / "data.zarr"
+        result = runner.invoke(
+            xenium_wrapper,
+            [
+                "--input",
+                f,
+                "--output",
+                output_zarr,
+            ],
+        )
+        assert result.exit_code == 0, result.output
+        _ = read_zarr(output_zarr)