Repository: fractal-analytics-platform/fractal-tasks-core (language: python)

Issue:
Mismatch between table specs and implementation, re: `instance_key` column
What we wrote in the table specs is that for tables of type `masking_roi_table` and `feature_table` the following condition is needed

>  table must include the column which is defined in its `instance_key` attribute (e.g. the `label` column

This is a very natural way of storing the information that links to another table, but it does not correspond to the current fractal-tasks-core implementation, where the list of labels is stored within the `obs` attribute of the AnnData object - see for instance these snippet where we write/read the table:
```python
# Write table (and labels)
            # Extract labels and drop them from df_well
            labels = pd.DataFrame(df_well["label"].astype(str))
            df_well.drop(labels=["label"], axis=1, inplace=True)
            # Convert all to float (warning: some would be int, in principle)
            measurement_dtype = np.float32
            df_well = df_well.astype(measurement_dtype)
            df_well.index = df_well.index.map(str)
            # Convert to anndata
            measurement_table = ad.AnnData(df_well, dtype=measurement_dtype)
            measurement_table.obs = labels

# Read labels
    column_name = attrs["instance_key"]
    # Check that ROI_table.obs has the right column and extract label_value
    if column_name not in ROI_table.obs.columns:
        raise ValueError(
            'In _preprocess_input, "{column_name}" '
            f" missing in {ROI_table.obs.columns=}"
        )
    label_value = int(ROI_table.obs[column_name][ROI_positional_index])
```

I'm not 100% sure of why we made this choice in the past, but my vague memory is that it was related to the fact that we wanted labels to be string and maybe it was hard to place them in the AnnData `X` attribute.


---

What should we do? (cc @jluethi)

Two options:

1. Keep current implementation and update the specs, saying the labels (or whatever is defined as `instance_key`) go in the `obs` attribute. This is the one that takes least effort.
2. Update the implementation and store labels as an additional column. This may or may not be possible, if we want to guarantee that labels are string - TBD. The most annoying part would obviously be that we must be backwards compatible, and also accept labels which are stored in the `obs` attribute.
`prepare_label_group` should handle mismatches of label_name & the zattrs name of the label
Issue originally reported by @nrepina 

If we copy a label image and give it a new name, the zattrs we get from an old label image also contain a `name` attribute => potential mismatches.

Example code
```
# Get the label_attrs correctly
label_attrs = get_zattrs(zarr_url=f"{rx_zarr_path}/labels/{label_name}")

rx_zarr_out = Path(output_path) / component
new_label_name = label_name + '_consensus'

# useful check for overwriting, adds metadata to labels group
_ = prepare_label_group(
    image_group=zarr.group(rx_zarr_out),
    label_name=new_label_name,
    overwrite=True,
    label_attrs=label_attrs,
    logger=logger,
)
```

Now the label image will be named label_name + '_consensus' in the folder, but the metadata still contains only label_name.

We can debate who's responsibility this should be. The easy fix is to add a line like this:
```
label_attrs['multiscales'][0]['name'] = new_label_name
```

But we tend to want to remove more of this metadata handling from tasks and put it into the helper functions.
At a minimum, `prepare_label_group` should give a warning if there is this mismatch, but maybe even just fix it and give priority to the explicit `label_name` parameter.
`prepare_label_group` should handle mismatches of label_name & the zattrs name of the label
Issue originally reported by @nrepina 

If we copy a label image and give it a new name, the zattrs we get from an old label image also contain a `name` attribute => potential mismatches.

Example code
```
# Get the label_attrs correctly
label_attrs = get_zattrs(zarr_url=f"{rx_zarr_path}/labels/{label_name}")

rx_zarr_out = Path(output_path) / component
new_label_name = label_name + '_consensus'

# useful check for overwriting, adds metadata to labels group
_ = prepare_label_group(
    image_group=zarr.group(rx_zarr_out),
    label_name=new_label_name,
    overwrite=True,
    label_attrs=label_attrs,
    logger=logger,
)
```

Now the label image will be named label_name + '_consensus' in the folder, but the metadata still contains only label_name.

We can debate who's responsibility this should be. The easy fix is to add a line like this:
```
label_attrs['multiscales'][0]['name'] = new_label_name
```

But we tend to want to remove more of this metadata handling from tasks and put it into the helper functions.
At a minimum, `prepare_label_group` should give a warning if there is this mismatch, but maybe even just fix it and give priority to the explicit `label_name` parameter.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci_pip.yml b/.github/workflows/ci_pip.yml
index e46369067..f23fecfd5 100644
--- a/.github/workflows/ci_pip.yml
+++ b/.github/workflows/ci_pip.yml
@@ -36,7 +36,7 @@ jobs:
         run: python -m pip install pytest devtools jsonschema requests wget pooch
 
       - name: Test core library with pytest
-        run: python -m pytest tests --ignore tests/tasks -s --log-cli-level info
+        run: python -m pytest tests --ignore tests/tasks
 
   tests_tasks:
     if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
diff --git a/.github/workflows/ci_poetry.yml b/.github/workflows/ci_poetry.yml
index db1f9cc3a..0dd2c0205 100644
--- a/.github/workflows/ci_poetry.yml
+++ b/.github/workflows/ci_poetry.yml
@@ -43,7 +43,7 @@ jobs:
         run: poetry install --with dev --without docs --no-interaction
 
       - name: Test core library with pytest
-        run: poetry run coverage run -m pytest tests --ignore tests/tasks -s --log-cli-level info
+        run: poetry run coverage run -m pytest tests --ignore tests/tasks
 
       - name: Upload coverage data
         uses: actions/upload-artifact@v3
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e67a5f15..26e356b9f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,30 @@
 **Note**: Numbers like (\#123) point to closed Pull Requests on the fractal-tasks-core repository.
 
-# Unreleased
 
-* Remove `has_args_schema` obsolete property from manifest (\#603).
-* Fix table selection in calculate registration image-based (\#615).
+# 0.14.0 (unreleased)
+
+* Breaking changes in tasks:
+    * Make `NapariWorkflowsOutput.label_name` attribute required, and use it to fill the `region["path"]` table attribute (\#613).
+* Breaking changes in core library:
+    * In `prepare_label_group` helper function:
+        * Make `label_attrs` function argument required (\#613).
+        * Validate `label_attrs` with `NgffImageMeta` model (\#613).
+        * Override multiscale name in `label_attrs` with `label_name` (\#613).
+    * In `write_table` helper function:
+        * Drop `logger` function argument (\#613).
+        * Add `table_name` function argument, taking priority over `table_attrs` (\#613).
+        * Raise an error if no table type is not provided (\#613).
+        * Raise an error if table attributes do not comply with table specs (\#613).
+    * Changes import of `lib_write.write_table` into `lib_tables.write_table` (\#613).
+    * Change imports from `lib_ROI_overlaps` into imports from `lib_regions_of_interest` (\#613).
+* Other internal changes:
+    * Comply with table specs V1, by writing all required Zarr attributes (\#613).
+    * Refactor modules related to tables and ROIs (\#613).
+    * Remove `has_args_schema` obsolete property from manifest (\#603).
+* Bug fixes:
+    * Fix table selection in calculate registration image-based (\#615).
+* Documentation
+    * Clarify table specs V1 (\#613).
 * Testing:
     * Use more recent Zenodo datasets, created with `fractal-tasks-core>=0.12` (\#623).
     * Use poetry 1.7.1 in GitHub actions (\#620).
diff --git a/README.md b/README.md
index 18cc9c10c..4bed4e3bf 100644
--- a/README.md
+++ b/README.md
@@ -35,4 +35,4 @@ Some additional tasks are currently being worked on and some older tasks are sti
 
 Unless otherwise stated in each individual module, all Fractal components are released according to a BSD 3-Clause License, and Copyright is with Friedrich Miescher Institute for Biomedical Research and University of Zurich.
 
-Fractal was conceived in the Liberali Lab at the Friedrich Miescher Institute for Biomedical Research and in the Pelkmans Lab at the University of Zurich (both in Switzerland). The project lead is with [@gusqgm](https://github.com/gusqgm) & [@jluethi](https://github.com/jluethi). The core development is done under contract by [eXact lab S.r.l.](https://exact-lab.it).
+Fractal was conceived in the Liberali Lab at the Friedrich Miescher Institute for Biomedical Research and in the Pelkmans Lab at the University of Zurich by [@jluethi](https://github.com/jluethi) and [@gusqgm](https://github.com/gusqgm). The Fractal project is now developed at the [BioVisionCenter](https://www.biovisioncenter.uzh.ch/en.html) at the University of Zurich and the project lead is with [@jluethi](https://github.com/jluethi). The core development is done under contract by [eXact lab S.r.l.](https://www.exact-lab.it/).
diff --git a/docs/index.md b/docs/index.md
index 5c1c8cccb..ec1df0b18 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -38,7 +38,7 @@ Some additional tasks are currently being worked on and some older tasks are sti
 
 ## Contributors
 
-Fractal was conceived in the Liberali Lab at the Friedrich Miescher Institute for Biomedical Research and in the Pelkmans Lab at the University of Zurich (both in Switzerland). The project lead is with [@gusqgm](https://github.com/gusqgm) & [@jluethi](https://github.com/jluethi). The core development is done under contract by [eXact lab S.r.l.](https://exact-lab.it).
+Fractal was conceived in the Liberali Lab at the Friedrich Miescher Institute for Biomedical Research and in the Pelkmans Lab at the University of Zurich by [@jluethi](https://github.com/jluethi) and [@gusqgm](https://github.com/gusqgm). The Fractal project is now developed at the [BioVisionCenter](https://www.biovisioncenter.uzh.ch/en.html) at the University of Zurich and the project lead is with [@jluethi](https://github.com/jluethi). The core development is done under contract by [eXact lab S.r.l.](https://www.exact-lab.it/).
 
 ## License
 
diff --git a/docs/tables.md b/docs/tables.md
index b2e4d6f35..d397945bd 100644
--- a/docs/tables.md
+++ b/docs/tables.md
@@ -20,7 +20,10 @@ tables we use, and it includes:
 > on hold, and `fractal-tasks-core` will evolve as soon as an official NGFF
 > table specs is adopted - see also the [Outlook](#outlook) section.
 
-## Specifications
+## Specifications (V1)
+
+In this section we describe version 1 (V1) of the Fractal table specifications;
+for the moment, only V1 exists.
 
 ### Core tables
 
@@ -192,11 +195,14 @@ Here is an example of valid Zarr attributes
 }
 ```
 
-**Table columns**
+**`AnnData` table attributes**
+
+On top of the required ROI-table colums, the masking-ROI-table `AnnData` object
+must have an attribute `obs` with a key matching to the `instance_key` zarr
+attribute. For instance if `instance_key="label"` then `table.obs["label"]`
+must exist, with its items matching the labels in the image in
+`"../labels/label_DAPI"`.
 
-On top of the required ROI-table colums, a masking ROI table must include the
-column which is defined in its `instance_key` attribute (e.g. the `label`
-column, for the example above).
 
 ### Feature tables
 
@@ -237,10 +243,13 @@ Here is an example of valid Zarr attributes
 }
 ```
 
-**Table columns**
 
-A feature table must include the column which is defined in its `instance_key`
-attribute (e.g. the `label` column, for the example above).
+**`AnnData` table attributes**
+
+The feature-table `AnnData` object must have an attribute `obs` with a key
+matching to the `instance_key` zarr attribute. For instance if
+`instance_key="label"` then `table.obs["label"]` must exist, with its items
+matching the labels in the image in `"../labels/label_DAPI"`.
 
 ## Examples
 
@@ -373,13 +382,13 @@ Here is an example of how to use `write_table`:
 import numpy as np
 import zarr
 import anndata as ad
-from fractal_tasks_core.lib_write import write_table
+from fractal_tasks_core.lib_tables import write_table
 
 table = ad.AnnData(X=np.ones((10, 10)))  # Generate a dummy `AnnData` object
 image_group = zarr.open_group("/tmp/image.zarr")
 table_name = "MyTable"
 table_attrs = {
-    "type": "ngff:region_table",
+    "type": "feature_table",
     "region": {"path": "../labels/MyLabel"},
     "instance_key": "label",
 }
@@ -430,7 +439,7 @@ $ cat /tmp/image.zarr/tables/MyTable/.zattrs    # View single-table attributes
     "region": {
         "path": "../labels/MyLabel"
     },
-    "type": "ngff:region_table"
+    "type": "feature_table"
 }
 ```
 
diff --git a/fractal_tasks_core/__FRACTAL_MANIFEST__.json b/fractal_tasks_core/__FRACTAL_MANIFEST__.json
index b7d8e397a..add1596ac 100644
--- a/fractal_tasks_core/__FRACTAL_MANIFEST__.json
+++ b/fractal_tasks_core/__FRACTAL_MANIFEST__.json
@@ -772,7 +772,7 @@
               "label_name": {
                 "title": "Label Name",
                 "type": "string",
-                "description": "Label name (for label outputs only)."
+                "description": "Label name (for label outputs, it is used as the name of the label; for dataframe outputs, it is used to fill the `region[\"path\"]` field)."
               },
               "table_name": {
                 "title": "Table Name",
@@ -781,7 +781,8 @@
               }
             },
             "required": [
-              "type"
+              "type",
+              "label_name"
             ]
           }
         }
diff --git a/fractal_tasks_core/__init__.py b/fractal_tasks_core/__init__.py
index 3c8692eef..a236d4d4d 100644
--- a/fractal_tasks_core/__init__.py
+++ b/fractal_tasks_core/__init__.py
@@ -5,6 +5,6 @@
 )
 
 
-__VERSION__ = "0.13.1"
+__VERSION__ = "0.14.0a0"
 __OME_NGFF_VERSION__ = "0.4"
 __FRACTAL_TABLE_VERSION__ = "1"
diff --git a/fractal_tasks_core/lib_input_models.py b/fractal_tasks_core/lib_input_models.py
index 0e69a03b1..e2cd89d32 100644
--- a/fractal_tasks_core/lib_input_models.py
+++ b/fractal_tasks_core/lib_input_models.py
@@ -92,26 +92,16 @@ class NapariWorkflowsOutput(BaseModel):
 
     Attributes:
         type: Output type (either `label` or `dataframe`).
-        label_name: Label name (for label outputs only).
+        label_name: Label name (for label outputs, it is used as the name of
+            the label; for dataframe outputs, it is used to fill the
+            `region["path"]` field).
         table_name: Table name (for dataframe outputs only).
     """
 
     type: Literal["label", "dataframe"]
-    label_name: Optional[str] = None
+    label_name: str
     table_name: Optional[str] = None
 
-    @validator("label_name", always=True)
-    def label_name_only_for_label_type(cls, v, values):
-        """
-        Check that label_name is set only for label outputs.
-        """
-        _type = values.get("type")
-        if (_type == "label" and (not v)) or (_type != "label" and v):
-            raise ValueError(
-                f"Output item has type={_type} but label_name={v}."
-            )
-        return v
-
     @validator("table_name", always=True)
     def table_name_only_for_dataframe_type(cls, v, values):
         """
diff --git a/fractal_tasks_core/lib_masked_loading.py b/fractal_tasks_core/lib_masked_loading.py
index 88fd14ca6..10d4367b5 100644
--- a/fractal_tasks_core/lib_masked_loading.py
+++ b/fractal_tasks_core/lib_masked_loading.py
@@ -21,6 +21,7 @@
 import numpy as np
 import zarr
 
+from fractal_tasks_core.lib_tables.v1 import MaskingROITableAttrs
 from fractal_tasks_core.lib_upscale_array import convert_region_to_low_res
 from fractal_tasks_core.lib_upscale_array import upscale_array
 
@@ -49,8 +50,9 @@ def _preprocess_input(
     - Loading the array which will be needed in postprocessing to restore
       background.
 
-    **NOTE 1**: This function relies on a change to OME-NGFF table specs
-    (https://github.com/ome/ngff/pull/64) which is still in-progress.
+    **NOTE 1**: This function relies on V1 of the Fractal table specifications,
+    see
+    https://fractal-analytics-platform.github.io/fractal-tasks-core/tables/.
 
     **NOTE 2**: The pre/post-processing functions and the
     masked_loading_wrapper are currently meant to work as part of the
@@ -97,8 +99,7 @@ def _preprocess_input(
     attrs = zarr.group(ROI_table_path).attrs
     logger.info(f"[_preprocess_input] {ROI_table_path=}")
     logger.info(f"[_preprocess_input] {attrs.asdict()=}")
-    if not attrs["type"] == "ngff:region_table":
-        raise ValueError("Wrong attributes for {ROI_table_path}:\n{attrs}")
+    MaskingROITableAttrs(**attrs.asdict())
     label_relative_path = attrs["region"]["path"]
     column_name = attrs["instance_key"]
 
diff --git a/fractal_tasks_core/lib_regions_of_interest/__init__.py b/fractal_tasks_core/lib_regions_of_interest/__init__.py
new file mode 100644
index 000000000..0ee40a0bf
--- /dev/null
+++ b/fractal_tasks_core/lib_regions_of_interest/__init__.py
@@ -0,0 +1,7 @@
+"""
+Subpackage for ROI-related functions.
+"""
+from .load_region import load_region  # noqa
+from .v1 import *  # noqa
+from .v1_checks import *  # noqa
+from .v1_overlaps import *  # noqa
diff --git a/fractal_tasks_core/lib_regions_of_interest/_overlaps_common.py b/fractal_tasks_core/lib_regions_of_interest/_overlaps_common.py
new file mode 100644
index 000000000..b3398af56
--- /dev/null
+++ b/fractal_tasks_core/lib_regions_of_interest/_overlaps_common.py
@@ -0,0 +1,131 @@
+# Copyright 2022 (C) Friedrich Miescher Institute for Biomedical Research and
+# University of Zurich
+#
+# Original authors:
+# Tommaso Comparin <tommaso.comparin@exact-lab.it>
+# Joel Lüthi  <joel.luethi@fmi.ch>
+#
+# This file is part of Fractal and was originally developed by eXact lab S.r.l.
+# <exact-lab.it> under contract with Liberali Lab from the Friedrich Miescher
+# Institute for Biomedical Research and Pelkmans Lab from the University of
+# Zurich.
+"""
+Functions to identify overlaps between regions, not related to table specs.
+"""
+import logging
+from typing import Sequence
+
+logger = logging.getLogger(__name__)
+
+
+def is_overlapping_1D(
+    line1: Sequence[float], line2: Sequence[float], tol: float = 1e-10
+) -> bool:
+    """
+    Given two intervals, finds whether they overlap.
+
+    This is based on https://stackoverflow.com/a/70023212/19085332, and we
+    additionally use a finite tolerance for floating-point comparisons.
+
+    Args:
+        line1: The boundaries of the first interval, written as
+            `[x_min, x_max]`.
+        line2: The boundaries of the second interval, written as
+            `[x_min, x_max]`.
+        tol: Finite tolerance for floating-point comparisons.
+    """
+    return line1[0] <= line2[1] - tol and line2[0] <= line1[1] - tol
+
+
+def is_overlapping_2D(
+    box1: Sequence[float], box2: Sequence[float], tol: float = 1e-10
+) -> bool:
+    """
+    Given two rectangular boxes, finds whether they overlap.
+
+    This is based on https://stackoverflow.com/a/70023212/19085332, and we
+    additionally use a finite tolerance for floating-point comparisons.
+
+    Args:
+        box1: The boundaries of the first rectangle, written as
+            `[x_min, y_min, x_max, y_max]`.
+        box2: The boundaries of the second rectangle, written as
+            `[x_min, y_min, x_max, y_max]`.
+        tol: Finite tolerance for floating-point comparisons.
+    """
+    overlap_x = is_overlapping_1D(
+        [box1[0], box1[2]], [box2[0], box2[2]], tol=tol
+    )
+    overlap_y = is_overlapping_1D(
+        [box1[1], box1[3]], [box2[1], box2[3]], tol=tol
+    )
+    return overlap_x and overlap_y
+
+
+def is_overlapping_3D(
+    box1: Sequence[float], box2: Sequence[float], tol: float = 1e-10
+) -> bool:
+    """
+    Given two three-dimensional boxes, finds whether they overlap.
+
+    This is based on https://stackoverflow.com/a/70023212/19085332, and we
+    additionally use a finite tolerance for floating-point comparisons.
+
+    Args:
+        box1: The boundaries of the first box, written as
+            `[x_min, y_min, z_min, x_max, y_max, z_max]`.
+        box2: The boundaries of the second box, written as
+            `[x_min, y_min, z_min, x_max, y_max, z_max]`.
+        tol: Finite tolerance for floating-point comparisons.
+    """
+
+    overlap_x = is_overlapping_1D(
+        [box1[0], box1[3]], [box2[0], box2[3]], tol=tol
+    )
+    overlap_y = is_overlapping_1D(
+        [box1[1], box1[4]], [box2[1], box2[4]], tol=tol
+    )
+    overlap_z = is_overlapping_1D(
+        [box1[2], box1[5]], [box2[2], box2[5]], tol=tol
+    )
+    return overlap_x and overlap_y and overlap_z
+
+
+def _is_overlapping_1D_int(
+    line1: Sequence[int],
+    line2: Sequence[int],
+) -> bool:
+    """
+    Given two integer intervals, find whether they overlap
+
+    This is the same as `is_overlapping_1D` (based on
+    https://stackoverflow.com/a/70023212/19085332), for integer-valued
+    intervals.
+
+    Args:
+        line1: The boundaries of the first interval , written as
+            `[x_min, x_max]`.
+        line2: The boundaries of the second interval , written as
+            `[x_min, x_max]`.
+    """
+    return line1[0] < line2[1] and line2[0] < line1[1]
+
+
+def _is_overlapping_3D_int(box1: list[int], box2: list[int]) -> bool:
+    """
+    Given two three-dimensional integer boxes, find whether they overlap.
+
+    This is the same as is_overlapping_3D (based on
+    https://stackoverflow.com/a/70023212/19085332), for integer-valued
+    boxes.
+
+    Args:
+        box1: The boundaries of the first box, written as
+            `[x_min, y_min, z_min, x_max, y_max, z_max]`.
+        box2: The boundaries of the second box, written as
+            `[x_min, y_min, z_min, x_max, y_max, z_max]`.
+    """
+    overlap_x = _is_overlapping_1D_int([box1[0], box1[3]], [box2[0], box2[3]])
+    overlap_y = _is_overlapping_1D_int([box1[1], box1[4]], [box2[1], box2[4]])
+    overlap_z = _is_overlapping_1D_int([box1[2], box1[5]], [box2[2], box2[5]])
+    return overlap_x and overlap_y and overlap_z
diff --git a/fractal_tasks_core/lib_regions_of_interest/load_region.py b/fractal_tasks_core/lib_regions_of_interest/load_region.py
new file mode 100644
index 000000000..51c98c3f0
--- /dev/null
+++ b/fractal_tasks_core/lib_regions_of_interest/load_region.py
@@ -0,0 +1,49 @@
+from typing import Union
+
+import dask.array as da
+import numpy as np
+
+
+def load_region(
+    data_zyx: da.Array,
+    region: tuple[slice, slice, slice],
+    compute: bool = True,
+    return_as_3D: bool = False,
+) -> Union[da.Array, np.ndarray]:
+    """
+    Load a region from a dask array.
+
+    Can handle both 2D and 3D dask arrays as input and return them as is or
+    always as a 3D array.
+
+    Args:
+        data_zyx: Dask array (2D or 3D).
+        region: Region to load, tuple of three slices (ZYX).
+        compute: Whether to compute the result. If `True`, returns a numpy
+            array. If `False`, returns a dask array.
+        return_as_3D: Whether to return a 3D array, even if the input is 2D.
+
+    Returns:
+        3D array.
+    """
+
+    if len(region) != 3:
+        raise ValueError(
+            f"In `load_region`, `region` must have three elements "
+            f"(given: {len(region)})."
+        )
+
+    if len(data_zyx.shape) == 3:
+        img = data_zyx[region]
+    elif len(data_zyx.shape) == 2:
+        img = data_zyx[(region[1], region[2])]
+        if return_as_3D:
+            img = np.expand_dims(img, axis=0)
+    else:
+        raise ValueError(
+            f"Shape {data_zyx.shape} not supported for `load_region`"
+        )
+    if compute:
+        return img.compute()
+    else:
+        return img
diff --git a/fractal_tasks_core/lib_regions_of_interest.py b/fractal_tasks_core/lib_regions_of_interest/v1.py
similarity index 75%
rename from fractal_tasks_core/lib_regions_of_interest.py
rename to fractal_tasks_core/lib_regions_of_interest/v1.py
index 0bcf7ee82..57b1aad5e 100644
--- a/fractal_tasks_core/lib_regions_of_interest.py
+++ b/fractal_tasks_core/lib_regions_of_interest/v1.py
@@ -10,19 +10,15 @@
 # Institute for Biomedical Research and Pelkmans Lab from the University of
 # Zurich.
 """
-Functions to handle regions of interests (via pandas and AnnData).
+Functions to produce/process ROI tables.
 """
 import logging
 import math
-from typing import Optional
 from typing import Sequence
-from typing import Union
 
 import anndata as ad
-import dask.array as da
 import numpy as np
 import pandas as pd
-import zarr
 
 
 logger = logging.getLogger(__name__)
@@ -265,7 +261,6 @@ def convert_ROI_table_to_indices(
 
     list_indices = []
     for ROI_name in ROI.obs_names:
-
         # Extract data from anndata table
         x_micrometer = ROI[ROI_name, x_pos].X[0, 0]
         y_micrometer = ROI[ROI_name, y_pos].X[0, 0]
@@ -305,59 +300,6 @@ def convert_ROI_table_to_indices(
     return list_indices
 
 
-def check_valid_ROI_indices(
-    list_indices: list[list[int]],
-    ROI_table_name: str,
-) -> None:
-    """
-    Check that list of indices has zero origin on each axis.
-
-    See fractal-tasks-core issues #530 and #554.
-
-    This helper function is meant to provide informative error messages when
-    ROI tables created with fractal-tasks-core up to v0.11 are used in v0.12.
-    This function will be deprecated and removed as soon as the v0.11/v0.12
-    transition advances.
-
-    Note that only `FOV_ROI_table` and `well_ROI_table` have to fulfill this
-    constraint, while ROI tables obtained through segmentation may have
-    arbitrary (non-negative) indices.
-
-    Args:
-        list_indices:
-            Output of `convert_ROI_table_to_indices`; each item is like
-            `[start_z, end_z, start_y, end_y, start_x, end_x]`.
-        ROI_table_name: Name of the ROI table.
-
-    Raises:
-        ValueError:
-            If the table name is `FOV_ROI_table` or `well_ROI_table` and the
-                minimum value of `start_x`, `start_y` and `start_z` are not all
-                zero.
-    """
-    if ROI_table_name not in ["FOV_ROI_table", "well_ROI_table"]:
-        # This validation function only applies to the FOV/well ROI tables
-        # generated with fractal-tasks-core
-        return
-
-    # Find minimum index along ZYX
-    min_start_z = min(item[0] for item in list_indices)
-    min_start_y = min(item[2] for item in list_indices)
-    min_start_x = min(item[4] for item in list_indices)
-
-    # Check that minimum indices are all zero
-    for (ind, min_index) in enumerate((min_start_z, min_start_y, min_start_x)):
-        if min_index != 0:
-            axis = ["Z", "Y", "X"][ind]
-            raise ValueError(
-                f"{axis} component of ROI indices for table `{ROI_table_name}`"
-                f" do not start with 0, but with {min_index}.\n"
-                "Hint: As of fractal-tasks-core v0.12, FOV/well ROI "
-                "tables with non-zero origins (e.g. the ones created with "
-                "v0.11) are not supported."
-            )
-
-
 def empty_bounding_box_table() -> pd.DataFrame:
     """
     Construct an empty bounding-box ROI table of given shape.
@@ -416,7 +358,6 @@ def array_to_bounding_box_table(
     labels = labels[labels > 0]
     elem_list = []
     for label in labels:
-
         # Compute bounding box
         label_match = np.where(mask_array == label)
         zmin, ymin, xmin = np.min(label_match, axis=1) * pxl_sizes_zyx_array
@@ -454,117 +395,6 @@ def array_to_bounding_box_table(
     return df
 
 
-def is_ROI_table_valid(*, table_path: str, use_masks: bool) -> Optional[bool]:
-    """
-    Verify some validity assumptions on a ROI table.
-
-    This function reflects our current working assumptions (e.g. the presence
-    of some specific columns); this may change in future versions.
-
-    If `use_masks=True`, we verify that the table is suitable to be used as
-    part of our masked-loading functions (see `lib_masked_loading.py`); if
-    these checks fail, `use_masks` should be set to `False` upstream in the
-    parent function.
-
-    Args:
-        table_path: Path of the AnnData ROI table to be checked.
-        use_masks: If `True`, perform some additional checks related to
-            masked loading.
-
-    Returns:
-        Always `None` if `use_masks=False`, otherwise return whether the table
-            is valid for masked loading.
-    """
-
-    table = ad.read_zarr(table_path)
-    are_ROI_table_columns_valid(table=table)
-    if not use_masks:
-        return None
-
-    # Soft constraint: the table can be used for masked loading (if not, return
-    # False)
-    attrs = zarr.group(table_path).attrs
-    logger.info(f"ROI table at {table_path} has attrs: {attrs.asdict()}")
-    valid = set(("type", "region", "instance_key")).issubset(attrs.keys())
-    if valid:
-        valid = valid and attrs["type"] == "ngff:region_table"
-        valid = valid and "path" in attrs["region"].keys()
-    if valid:
-        return True
-    else:
-        return False
-
-
-def are_ROI_table_columns_valid(*, table: ad.AnnData) -> None:
-    """
-    Verify some validity assumptions on a ROI table.
-
-    This function reflects our current working assumptions (e.g. the presence
-    of some specific columns); this may change in future versions.
-
-    Args:
-        table: AnnData table to be checked
-    """
-
-    # Hard constraint: table columns must include some expected ones
-    columns = [
-        "x_micrometer",
-        "y_micrometer",
-        "z_micrometer",
-        "len_x_micrometer",
-        "len_y_micrometer",
-        "len_z_micrometer",
-    ]
-    for column in columns:
-        if column not in table.var_names:
-            raise ValueError(f"Column {column} is not present in ROI table")
-
-
-def load_region(
-    data_zyx: da.Array,
-    region: tuple[slice, slice, slice],
-    compute: bool = True,
-    return_as_3D: bool = False,
-) -> Union[da.Array, np.ndarray]:
-    """
-    Load a region from a dask array.
-
-    Can handle both 2D and 3D dask arrays as input and return them as is or
-    always as a 3D array.
-
-    Args:
-        data_zyx: Dask array (2D or 3D).
-        region: Region to load, tuple of three slices (ZYX).
-        compute: Whether to compute the result. If `True`, returns a numpy
-            array. If `False`, returns a dask array.
-        return_as_3D: Whether to return a 3D array, even if the input is 2D.
-
-    Returns:
-        3D array.
-    """
-
-    if len(region) != 3:
-        raise ValueError(
-            f"In `load_region`, `region` must have three elements "
-            f"(given: {len(region)})."
-        )
-
-    if len(data_zyx.shape) == 3:
-        img = data_zyx[region]
-    elif len(data_zyx.shape) == 2:
-        img = data_zyx[(region[1], region[2])]
-        if return_as_3D:
-            img = np.expand_dims(img, axis=0)
-    else:
-        raise ValueError(
-            f"Shape {data_zyx.shape} not supported for `load_region`"
-        )
-    if compute:
-        return img.compute()
-    else:
-        return img
-
-
 def convert_indices_to_regions(
     index: list[int],
 ) -> tuple[slice, slice, slice]:
@@ -668,8 +498,6 @@ def get_single_image_ROI(
                     shape_x * pixels_ZYX[2],
                     shape_y * pixels_ZYX[1],
                     shape_z * pixels_ZYX[0],
-                    0.0,
-                    0.0,
                 ],
             ],
             dtype=np.float32,
@@ -683,8 +511,6 @@ def get_single_image_ROI(
         "len_x_micrometer",
         "len_y_micrometer",
         "len_z_micrometer",
-        "x_micrometer_original",
-        "y_micrometer_original",
     ]
     return ROI_table
 
@@ -739,8 +565,6 @@ def get_image_grid_ROIs(
                     tmp_len_x * pixels_ZYX[2],
                     tmp_len_y * pixels_ZYX[1],
                     len_z * pixels_ZYX[0],
-                    start_x * pixels_ZYX[2],
-                    start_y * pixels_ZYX[1],
                 ]
             )
             counter += 1
@@ -754,7 +578,5 @@ def get_image_grid_ROIs(
         "len_x_micrometer",
         "len_y_micrometer",
         "len_z_micrometer",
-        "x_micrometer_original",
-        "y_micrometer_original",
     ]
     return ROI_table
diff --git a/fractal_tasks_core/lib_regions_of_interest/v1_checks.py b/fractal_tasks_core/lib_regions_of_interest/v1_checks.py
new file mode 100644
index 000000000..a3dcdc0e3
--- /dev/null
+++ b/fractal_tasks_core/lib_regions_of_interest/v1_checks.py
@@ -0,0 +1,141 @@
+# Copyright 2022 (C) Friedrich Miescher Institute for Biomedical Research and
+# University of Zurich
+#
+# Original authors:
+# Tommaso Comparin <tommaso.comparin@exact-lab.it>
+# Joel Lüthi <joel.luethi@uzh.ch>
+#
+# This file is part of Fractal and was originally developed by eXact lab S.r.l.
+# <exact-lab.it> under contract with Liberali Lab from the Friedrich Miescher
+# Institute for Biomedical Research and Pelkmans Lab from the University of
+# Zurich.
+"""
+Functions to check content of ROI tables.
+"""
+import logging
+from typing import Optional
+
+import anndata as ad
+import zarr
+from pydantic.error_wrappers import ValidationError
+
+from ..lib_tables.v1 import MaskingROITableAttrs
+
+
+logger = logging.getLogger(__name__)
+
+
+def check_valid_ROI_indices(
+    list_indices: list[list[int]],
+    ROI_table_name: str,
+) -> None:
+    """
+    Check that list of indices has zero origin on each axis.
+
+    See fractal-tasks-core issues #530 and #554.
+
+    This helper function is meant to provide informative error messages when
+    ROI tables created with fractal-tasks-core up to v0.11 are used in v0.12.
+    This function will be deprecated and removed as soon as the v0.11/v0.12
+    transition advances.
+
+    Note that only `FOV_ROI_table` and `well_ROI_table` have to fulfill this
+    constraint, while ROI tables obtained through segmentation may have
+    arbitrary (non-negative) indices.
+
+    Args:
+        list_indices:
+            Output of `convert_ROI_table_to_indices`; each item is like
+            `[start_z, end_z, start_y, end_y, start_x, end_x]`.
+        ROI_table_name: Name of the ROI table.
+
+    Raises:
+        ValueError:
+            If the table name is `FOV_ROI_table` or `well_ROI_table` and the
+                minimum value of `start_x`, `start_y` and `start_z` are not all
+                zero.
+    """
+    if ROI_table_name not in ["FOV_ROI_table", "well_ROI_table"]:
+        # This validation function only applies to the FOV/well ROI tables
+        # generated with fractal-tasks-core
+        return
+
+    # Find minimum index along ZYX
+    min_start_z = min(item[0] for item in list_indices)
+    min_start_y = min(item[2] for item in list_indices)
+    min_start_x = min(item[4] for item in list_indices)
+
+    # Check that minimum indices are all zero
+    for ind, min_index in enumerate((min_start_z, min_start_y, min_start_x)):
+        if min_index != 0:
+            axis = ["Z", "Y", "X"][ind]
+            raise ValueError(
+                f"{axis} component of ROI indices for table `{ROI_table_name}`"
+                f" do not start with 0, but with {min_index}.\n"
+                "Hint: As of fractal-tasks-core v0.12, FOV/well ROI "
+                "tables with non-zero origins (e.g. the ones created with "
+                "v0.11) are not supported."
+            )
+
+
+def is_ROI_table_valid(*, table_path: str, use_masks: bool) -> Optional[bool]:
+    """
+    Verify some validity assumptions on a ROI table.
+
+    This function reflects our current working assumptions (e.g. the presence
+    of some specific columns); this may change in future versions.
+
+    If `use_masks=True`, we verify that the table is a valid
+    `masking_roi_table` as of table specifications V1; if this check fails,
+    `use_masks` should be set to `False` upstream in the parent function.
+
+    Args:
+        table_path: Path of the AnnData ROI table to be checked.
+        use_masks: If `True`, perform some additional checks related to
+            masked loading.
+
+    Returns:
+        Always `None` if `use_masks=False`, otherwise return whether the table
+            is valid for masked loading.
+    """
+
+    table = ad.read_zarr(table_path)
+    are_ROI_table_columns_valid(table=table)
+    if not use_masks:
+        return None
+
+    # Check whether the table can be used for masked loading
+    attrs = zarr.group(table_path).attrs.asdict()
+    logger.info(f"ROI table at {table_path} has attrs: {attrs}")
+    try:
+        MaskingROITableAttrs(**attrs)
+        logging.info("ROI table can be used for masked loading")
+        return True
+    except ValidationError:
+        logging.info("ROI table cannot be used for masked loading")
+        return False
+
+
+def are_ROI_table_columns_valid(*, table: ad.AnnData) -> None:
+    """
+    Verify some validity assumptions on a ROI table.
+
+    This function reflects our current working assumptions (e.g. the presence
+    of some specific columns); this may change in future versions.
+
+    Args:
+        table: AnnData table to be checked
+    """
+
+    # Hard constraint: table columns must include some expected ones
+    columns = [
+        "x_micrometer",
+        "y_micrometer",
+        "z_micrometer",
+        "len_x_micrometer",
+        "len_y_micrometer",
+        "len_z_micrometer",
+    ]
+    for column in columns:
+        if column not in table.var_names:
+            raise ValueError(f"Column {column} is not present in ROI table")
diff --git a/fractal_tasks_core/lib_ROI_overlaps.py b/fractal_tasks_core/lib_regions_of_interest/v1_overlaps.py
similarity index 76%
rename from fractal_tasks_core/lib_ROI_overlaps.py
rename to fractal_tasks_core/lib_regions_of_interest/v1_overlaps.py
index cd8ce288b..92b6b4246 100644
--- a/fractal_tasks_core/lib_ROI_overlaps.py
+++ b/fractal_tasks_core/lib_regions_of_interest/v1_overlaps.py
@@ -10,7 +10,7 @@
 # Institute for Biomedical Research and Pelkmans Lab from the University of
 # Zurich.
 """
-Functions to identify and remove overlaps between regions of interest.
+Functions to identify and remove ROI overlaps, based on V1 table specs.
 """
 import logging
 from typing import Callable
@@ -20,80 +20,13 @@
 
 import pandas as pd
 
-logger = logging.getLogger(__name__)
-
-
-def is_overlapping_1D(
-    line1: Sequence[float], line2: Sequence[float], tol: float = 1e-10
-) -> bool:
-    """
-    Given two intervals, finds whether they overlap.
-
-    This is based on https://stackoverflow.com/a/70023212/19085332, and we
-    additionally use a finite tolerance for floating-point comparisons.
-
-    Args:
-        line1: The boundaries of the first interval, written as
-            `[x_min, x_max]`.
-        line2: The boundaries of the second interval, written as
-            `[x_min, x_max]`.
-        tol: Finite tolerance for floating-point comparisons.
-    """
-    return line1[0] <= line2[1] - tol and line2[0] <= line1[1] - tol
-
-
-def is_overlapping_2D(
-    box1: Sequence[float], box2: Sequence[float], tol: float = 1e-10
-) -> bool:
-    """
-    Given two rectangular boxes, finds whether they overlap.
-
-    This is based on https://stackoverflow.com/a/70023212/19085332, and we
-    additionally use a finite tolerance for floating-point comparisons.
-
-    Args:
-        box1: The boundaries of the first rectangle, written as
-            `[x_min, y_min, x_max, y_max]`.
-        box2: The boundaries of the second rectangle, written as
-            `[x_min, y_min, x_max, y_max]`.
-        tol: Finite tolerance for floating-point comparisons.
-    """
-    overlap_x = is_overlapping_1D(
-        [box1[0], box1[2]], [box2[0], box2[2]], tol=tol
-    )
-    overlap_y = is_overlapping_1D(
-        [box1[1], box1[3]], [box2[1], box2[3]], tol=tol
-    )
-    return overlap_x and overlap_y
-
-
-def is_overlapping_3D(
-    box1: Sequence[float], box2: Sequence[float], tol: float = 1e-10
-) -> bool:
-    """
-    Given two three-dimensional boxes, finds whether they overlap.
+from ._overlaps_common import _is_overlapping_3D_int
+from ._overlaps_common import is_overlapping_1D
+from ._overlaps_common import is_overlapping_2D
+from ._overlaps_common import is_overlapping_3D
 
-    This is based on https://stackoverflow.com/a/70023212/19085332, and we
-    additionally use a finite tolerance for floating-point comparisons.
 
-    Args:
-        box1: The boundaries of the first box, written as
-            `[x_min, y_min, z_min, x_max, y_max, z_max]`.
-        box2: The boundaries of the second box, written as
-            `[x_min, y_min, z_min, x_max, y_max, z_max]`.
-        tol: Finite tolerance for floating-point comparisons.
-    """
-
-    overlap_x = is_overlapping_1D(
-        [box1[0], box1[3]], [box2[0], box2[3]], tol=tol
-    )
-    overlap_y = is_overlapping_1D(
-        [box1[1], box1[4]], [box2[1], box2[4]], tol=tol
-    )
-    overlap_z = is_overlapping_1D(
-        [box1[2], box1[5]], [box2[2], box2[5]], tol=tol
-    )
-    return overlap_x and overlap_y and overlap_z
+logger = logging.getLogger(__name__)
 
 
 def get_overlapping_pair(
@@ -342,46 +275,6 @@ def remove_FOV_overlaps(df: pd.DataFrame):
     return df
 
 
-def _is_overlapping_1D_int(
-    line1: Sequence[int],
-    line2: Sequence[int],
-) -> bool:
-    """
-    Given two integer intervals, find whether they overlap
-
-    This is the same as `is_overlapping_1D` (based on
-    https://stackoverflow.com/a/70023212/19085332), for integer-valued
-    intervals.
-
-    Args:
-        line1: The boundaries of the first interval , written as
-            `[x_min, x_max]`.
-        line2: The boundaries of the second interval , written as
-            `[x_min, x_max]`.
-    """
-    return line1[0] < line2[1] and line2[0] < line1[1]
-
-
-def _is_overlapping_3D_int(box1: list[int], box2: list[int]) -> bool:
-    """
-    Given two three-dimensional integer boxes, find whether they overlap.
-
-    This is the same as is_overlapping_3D (based on
-    https://stackoverflow.com/a/70023212/19085332), for integer-valued
-    boxes.
-
-    Args:
-        box1: The boundaries of the first box, written as
-            `[x_min, y_min, z_min, x_max, y_max, z_max]`.
-        box2: The boundaries of the second box, written as
-            `[x_min, y_min, z_min, x_max, y_max, z_max]`.
-    """
-    overlap_x = _is_overlapping_1D_int([box1[0], box1[3]], [box2[0], box2[3]])
-    overlap_y = _is_overlapping_1D_int([box1[1], box1[4]], [box2[1], box2[4]])
-    overlap_z = _is_overlapping_1D_int([box1[2], box1[5]], [box2[2], box2[5]])
-    return overlap_x and overlap_y and overlap_z
-
-
 def find_overlaps_in_ROI_indices(
     list_indices: list[list[int]],
 ) -> Optional[tuple[int, int]]:
diff --git a/fractal_tasks_core/lib_tables/__init__.py b/fractal_tasks_core/lib_tables/__init__.py
new file mode 100644
index 000000000..aaa1d4244
--- /dev/null
+++ b/fractal_tasks_core/lib_tables/__init__.py
@@ -0,0 +1,91 @@
+# Copyright 2022 (C) Friedrich Miescher Institute for Biomedical Research and
+# University of Zurich
+#
+# Original authors:
+# Tommaso Comparin <tommaso.comparin@exact-lab.it>
+#
+# This file is part of Fractal and was originally developed by eXact lab S.r.l.
+# <exact-lab.it> under contract with Liberali Lab from the Friedrich Miescher
+# Institute for Biomedical Research and Pelkmans Lab from the University of
+# Zurich.
+"""
+Functions and classes related to table specifications (see
+https://fractal-analytics-platform.github.io/fractal-tasks-core/tables)."""
+from typing import Any
+from typing import Optional
+
+import anndata as ad
+import zarr.hierarchy
+
+from .v1 import _write_table_v1
+from fractal_tasks_core import __FRACTAL_TABLE_VERSION__
+
+
+def write_table(
+    image_group: zarr.hierarchy.Group,
+    table_name: str,
+    table: ad.AnnData,
+    overwrite: bool = False,
+    table_type: Optional[str] = None,
+    table_attrs: Optional[dict[str, Any]] = None,
+) -> zarr.group:
+    """
+    Write a table to a Zarr group.
+
+    This is the general interface that should allow for a smooth coexistence of
+    tables with different `fractal_table_version` values. Currently only V1 is
+    defined and implemented. The assumption is that V2 should only change:
+    1. The lower-level writing function (that is, `_write_table_v2`).
+    2. The type of the table (which would also reflect into a more general type
+        hint for `table`, in the current funciton);
+    3. A different definition of what values of `table_attrs` are valid or
+       invalid, to be implemented in `_write_table_v2`.
+    4. Possibly, additional parameters for `_write_table_v2`, which will be
+       optional parameters of `write_table` (so that `write_table` remains
+       valid for both V1 and V2).
+
+    Args:
+        image_group:
+            The image Zarr group where the table will be written.
+        table_name:
+            The name of the table.
+        table:
+            The table object (currently an AnnData object, for V1).
+        overwrite:
+            If `False`, check that the new table does not exist (either as a
+            zarr sub-group or as part of the zarr-group attributes). In all
+            cases, propagate parameter to `_write_elem_with_overwrite`, to
+            determine the behavior in case of an existing sub-group named as
+            `table_name`.
+        table_type: `type` attribute for the table; in case `type` is also
+            present in `table_attrs`, this function argument takes priority.
+        table_attrs:
+            If set, overwrite table_group attributes with table_attrs key/value
+            pairs. If `table_type` is not provided, then `table_attrs` must
+            include the `type` key.
+
+    Returns:
+        Zarr group of the table.
+    """
+    # Choose which version to use, giving priority to a value that is present
+    # in table_attrs
+    version = __FRACTAL_TABLE_VERSION__
+    if table_attrs is not None:
+        try:
+            version = table_attrs["fractal_table_version"]
+        except KeyError:
+            pass
+
+    if version == "1":
+        return _write_table_v1(
+            image_group,
+            table_name,
+            table,
+            overwrite,
+            table_type,
+            table_attrs,
+        )
+    else:
+        raise NotImplementedError(
+            f"fractal_table_version='{version}' is not supported"
+        )
diff --git a/fractal_tasks_core/lib_tables/v1.py b/fractal_tasks_core/lib_tables/v1.py
new file mode 100644
index 000000000..f99442ff6
--- /dev/null
+++ b/fractal_tasks_core/lib_tables/v1.py
@@ -0,0 +1,204 @@
+"""
+Functions and classes related to table specifications V1 (see
+https://fractal-analytics-platform.github.io/fractal-tasks-core/tables).
+"""
+import logging
+import warnings
+from typing import Any
+from typing import Literal
+from typing import Optional
+
+import anndata as ad
+import zarr.hierarchy
+from pydantic import BaseModel
+from pydantic import validator
+from pydantic.error_wrappers import ValidationError
+
+from ..lib_write import _write_elem_with_overwrite
+from ..lib_write import OverwriteNotAllowedError
+
+logger = logging.getLogger(__name__)
+
+
+class _RegionType(BaseModel):
+    path: str
+
+
+class MaskingROITableAttrs(BaseModel):
+    type: Literal["masking_roi_table", "ngff:region_table"]
+    region: _RegionType
+    instance_key: str
+
+    @validator("type", always=True)
+    def warning_for_old_table_type(cls, v):
+        if v == "ngff:region_table":
+            warning_msg = (
+                "Table type `ngff:region_table` is currently accepted instead "
+                "of `masking_roi_table`, but it will be deprecated in the "
+                "future. Please switch to `type='masking_roi_table'`."
+            )
+
+            warnings.warn(warning_msg, FutureWarning)
+        return v
+
+
+class FeatureTableAttrs(BaseModel):
+    type: Literal["feature_table", "ngff:region_table"]
+    region: _RegionType
+    instance_key: str
+
+    @validator("type", always=True)
+    def warning_for_old_table_type(cls, v):
+        if v == "ngff:region_table":
+            warning_msg = (
+                "Table type `ngff:region_table` is currently accepted instead "
+                "of `feature_table`, but it will be deprecated in the "
+                "future. Please switch to `type='feature_table'`."
+            )
+
+            warnings.warn(warning_msg, FutureWarning)
+        return v
+
+
+def _write_table_v1(
+    image_group: zarr.hierarchy.Group,
+    table_name: str,
+    table: ad.AnnData,
+    overwrite: bool = False,
+    table_type: Optional[str] = None,
+    table_attrs: Optional[dict[str, Any]] = None,
+) -> zarr.group:
+    """
+    Handle multiple options for writing an AnnData table to a zarr group.
+
+    1. Create the `tables` group, if needed.
+    2. If `overwrite=False`, check that the new table does not exist (either in
+       zarr attributes or as a zarr sub-group).
+    3. Call the `_write_elem_with_overwrite` wrapper with the appropriate
+       `overwrite` parameter.
+    4. Update the `tables` attribute of the image group.
+    5. Validate `table_type` and `table_attrs` according to Fractal table
+       specifications, and raise errors/warnings if needed; then set the
+       appropriate attributes in the new-table Zarr group.
+
+
+    Args:
+        image_group:
+            The group to write to.
+        table_name:
+            The name of the new table.
+        table:
+            The AnnData table to write.
+        overwrite:
+            If `False`, check that the new table does not exist (either as a
+            zarr sub-group or as part of the zarr-group attributes). In all
+            cases, propagate parameter to `_write_elem_with_overwrite`, to
+            determine the behavior in case of an existing sub-group named as
+            `table_name`.
+        table_type: `type` attribute for the table; in case `type` is also
+            present in `table_attrs`, this function argument takes priority.
+        table_attrs:
+            If set, overwrite table_group attributes with table_attrs key/value
+            pairs. If `table_type` is not provided, then `table_attrs` must
+            include the `type` key.
+
+    Returns:
+        Zarr group of the new table.
+    """
+
+    # Create tables group (if needed) and extract current_tables
+    if "tables" not in set(image_group.group_keys()):
+        tables_group = image_group.create_group("tables", overwrite=False)
+    else:
+        tables_group = image_group["tables"]
+    current_tables = tables_group.attrs.asdict().get("tables", [])
+
+    # If overwrite=False, check that the new table does not exist (either as a
+    # zarr sub-group or as part of the zarr-group attributes)
+    if not overwrite:
+        if table_name in set(tables_group.group_keys()):
+            error_msg = (
+                f"Sub-group '{table_name}' of group {image_group.store.path} "
+                f"already exists, but `{overwrite=}`.\n"
+                "Hint: try setting `overwrite=True`."
+            )
+            logger.error(error_msg)
+            raise OverwriteNotAllowedError(error_msg)
+        if table_name in current_tables:
+            error_msg = (
+                f"Item '{table_name}' already exists in `tables` attribute of "
+                f"group {image_group.store.path}, but `{overwrite=}`.\n"
+                "Hint: try setting `overwrite=True`."
+            )
+            logger.error(error_msg)
+            raise OverwriteNotAllowedError(error_msg)
+
+    # Always include fractal-roi-table version in table attributes
+    if table_attrs is None:
+        table_attrs = dict(fractal_table_version="1")
+    elif table_attrs.get("fractal_table_version", None) is None:
+        table_attrs["fractal_table_version"] = "1"
+
+    # Set type attribute for the table
+    table_type_from_attrs = table_attrs.get("type", None)
+    if table_type is not None:
+        if table_type_from_attrs is not None:
+            logger.warning(
+                f"Setting table type to '{table_type}' (and overriding "
+                f"'{table_type_from_attrs}' attribute)."
+            )
+        table_attrs["type"] = table_type
+    else:
+        if table_type_from_attrs is None:
+            raise ValueError(
+                "Missing attribute `type` for table; this must be provided"
+                " either via `table_type` or within `table_attrs`."
+            )
+
+    # Prepare/validate attributes for the table
+    table_type = table_attrs.get("type", None)
+    if table_type == "roi_table":
+        pass
+    elif table_type == "masking_roi_table":
+        try:
+            MaskingROITableAttrs(**table_attrs)
+        except ValidationError as e:
+            error_msg = (
+                "Table attributes do not comply with Fractal "
+                "`masking_roi_table` specifications V1.\nOriginal error:\n"
+                f"ValidationError: {str(e)}"
+            )
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+    elif table_type == "feature_table":
+        try:
+            FeatureTableAttrs(**table_attrs)
+        except ValidationError as e:
+            error_msg = (
+                "Table attributes do not comply with Fractal "
+                "`feature_table` specifications V1.\nOriginal error:\n"
+                f"ValidationError: {str(e)}"
+            )
+            logger.error(error_msg)
+            raise ValueError(error_msg)
+    else:
+        logger.warning(f"Unknown table type `{table_type}`.")
+
+    # If it's all OK, proceed and write the table
+    _write_elem_with_overwrite(
+        tables_group,
+        table_name,
+        table,
+        overwrite=overwrite,
+    )
+    table_group = tables_group[table_name]
+
+    # Update the `tables` metadata of the image group, if needed
+    if table_name not in current_tables:
+        new_tables = current_tables + [table_name]
+        tables_group.attrs["tables"] = new_tables
+
+    # Update table_group attributes with table_attrs key/value pairs
+    table_group.attrs.update(**table_attrs)
+
+    return table_group
diff --git a/fractal_tasks_core/lib_write.py b/fractal_tasks_core/lib_write.py
index 2e15ac178..ec19d2e1d 100644
--- a/fractal_tasks_core/lib_write.py
+++ b/fractal_tasks_core/lib_write.py
@@ -17,13 +17,13 @@
 from typing import Optional
 from typing import Union
 
-import anndata as ad
 import zarr.hierarchy
 from anndata.experimental import write_elem
+from pydantic.error_wrappers import ValidationError
 from zarr.errors import ContainsGroupError
 from zarr.errors import GroupNotFoundError
 
-from fractal_tasks_core import __FRACTAL_TABLE_VERSION__
+from .lib_ngff import NgffImageMeta
 
 
 class OverwriteNotAllowedError(RuntimeError):
@@ -185,125 +185,11 @@ def _write_elem_with_overwrite(
     write_elem(group, key, elem)
 
 
-def write_table(
-    image_group: zarr.hierarchy.Group,
-    table_name: str,
-    table: ad.AnnData,
-    overwrite: bool = False,
-    table_attrs: Optional[dict[str, Any]] = None,
-    logger: Optional[logging.Logger] = None,
-) -> zarr.group:
-    """
-    Handle multiple options for writing an AnnData table to a zarr group.
-
-    1. Create the `tables` group, if needed.
-    2. If `overwrite=False`, check that the new table does not exist (either in
-       zarr attributes or as a zarr sub-group).
-    3. Call the `_write_elem_with_overwrite` wrapper with the appropriate
-       `overwrite` parameter.
-    4. Update the `tables` attribute of the image group.
-    5. If `table_attrs` is set, include this set of attributes in the
-       new-table zarr group. One intended usage, within fractal-tasks-core, is
-       to comply with a proposed change to the OME-NGFF specs
-       (https://github.com/ome/ngff/pull/64).
-
-    Args:
-        image_group:
-            The group to write to.
-        table_name:
-            The name of the new table.
-        table:
-            The AnnData table to write.
-        overwrite:
-            If `False`, check that the new table does not exist (either as a
-            zarr sub-group or as part of the zarr-group attributes). In all
-            cases, propagate parameter to `_write_elem_with_overwrite`, to
-            determine the behavior in case of an existing sub-group named as
-            `table_name`.
-        table_attrs:
-            If set, overwrite table_group attributes with table_attrs key/value
-            pairs.
-        logger:
-            The logger to use (if unset, use `logging.getLogger(None)`).
-
-    Returns:
-        Zarr group of the new table.
-    """
-
-    # Set logger
-    if logger is None:
-        logger = logging.getLogger(None)
-
-    # Create tables group (if needed) and extract current_tables
-    if "tables" not in set(image_group.group_keys()):
-        tables_group = image_group.create_group("tables", overwrite=False)
-    else:
-        tables_group = image_group["tables"]
-    current_tables = tables_group.attrs.asdict().get("tables", [])
-
-    # If overwrite=False, check that the new table does not exist (either as a
-    # zarr sub-group or as part of the zarr-group attributes)
-    if not overwrite:
-        if table_name in set(tables_group.group_keys()):
-            error_msg = (
-                f"Sub-group '{table_name}' of group {image_group.store.path} "
-                f"already exists, but `{overwrite=}`.\n"
-                "Hint: try setting `overwrite=True`."
-            )
-            logger.error(error_msg)
-            raise OverwriteNotAllowedError(error_msg)
-        if table_name in current_tables:
-            error_msg = (
-                f"Item '{table_name}' already exists in `tables` attribute of "
-                f"group {image_group.store.path}, but `{overwrite=}`.\n"
-                "Hint: try setting `overwrite=True`."
-            )
-            logger.error(error_msg)
-            raise OverwriteNotAllowedError(error_msg)
-
-    # If it's all OK, proceed and write the table
-    _write_elem_with_overwrite(
-        tables_group,
-        table_name,
-        table,
-        overwrite=overwrite,
-    )
-    table_group = tables_group[table_name]
-
-    # Update the `tables` metadata of the image group, if needed
-    if table_name not in current_tables:
-        new_tables = current_tables + [table_name]
-        tables_group.attrs["tables"] = new_tables
-
-    # Optionally update attributes of the new-table zarr group
-    if table_attrs is not None:
-        if table_attrs.get("type") == "ngff:region_table":
-            # Verify whether we comply with a proposed change to the OME-NGFF
-            # table specs (https://github.com/ome/ngff/pull/64)
-            try:
-                table_attrs["instance_key"]
-                table_attrs["region"]["path"]
-            except KeyError as e:
-                logger.warning(
-                    f"The table_attrs parameter of write_elem has "
-                    "type='ngff:region_table' but does not comply with the "
-                    "proposed table specs. "
-                    f"Original error: KeyError: {str(e)}"
-                )
-        # Update table_group attributes with table_attrs key/value pairs
-        table_group.attrs.update(**table_attrs)
-
-    # Always add information about the fractal-roi-table version
-    table_group.attrs.update(fractal_table_version=__FRACTAL_TABLE_VERSION__)
-
-    return table_group
-
-
 def prepare_label_group(
     image_group: zarr.hierarchy.Group,
     label_name: str,
+    label_attrs: dict[str, Any],
     overwrite: bool = False,
-    label_attrs: Optional[dict[str, Any]] = None,
     logger: Optional[logging.Logger] = None,
 ) -> zarr.group:
     """
@@ -330,15 +216,15 @@ def prepare_label_group(
         image_group:
             The group to write to.
         label_name:
-            The name of the new label.
+            The name of the new label; this name also overrides the multiscale
+            name in NGFF-image Zarr attributes, if needed.
         overwrite:
             If `False`, check that the new label does not exist (either in zarr
             attributes or as a zarr sub-group); if `True` propagate parameter
             to `create_group` method, making it overwrite any existing
             sub-group with the given name.
         label_attrs:
-            If set, overwrite label_group attributes with label_attrs key/value
-            pairs.
+            Zarr attributes of the label-image group.
         logger:
             The logger to use (if unset, use `logging.getLogger(None)`).
 
@@ -385,9 +271,27 @@ def prepare_label_group(
     # Define new-label group
     label_group = labels_group.create_group(label_name, overwrite=overwrite)
 
-    # Optionally update attributes of the new-table zarr group
-    if label_attrs is not None:
-        # Overwrite label_group attributes with label_attrs key/value pairs
-        label_group.attrs.put(label_attrs)
+    # Validate attrs against NGFF specs 0.4
+    try:
+        meta = NgffImageMeta(**label_attrs)
+    except ValidationError as e:
+        error_msg = (
+            "Label attributes do not comply with NGFF image "
+            "specifications, as encoded in fractal-tasks-core.\n"
+            f"Original error:\nValidationError: {str(e)}"
+        )
+        logger.error(error_msg)
+        raise ValueError(error_msg)
+    # Replace multiscale name with label_name, if needed
+    current_multiscale_name = meta.multiscale.name
+    if current_multiscale_name != label_name:
+        logger.warning(
+            f"Setting multiscale name to '{label_name}' (old value: "
+            f"'{current_multiscale_name}') in label-image NGFF "
+            "attributes."
+        )
+        label_attrs["multiscales"][0]["name"] = label_name
+    # Overwrite label_group attributes with label_attrs key/value pairs
+    label_group.attrs.put(label_attrs)
 
     return label_group
diff --git a/fractal_tasks_core/tasks/apply_registration_to_ROI_tables.py b/fractal_tasks_core/tasks/apply_registration_to_ROI_tables.py
index 1d0a02004..ed05e1d5f 100644
--- a/fractal_tasks_core/tasks/apply_registration_to_ROI_tables.py
+++ b/fractal_tasks_core/tasks/apply_registration_to_ROI_tables.py
@@ -28,7 +28,7 @@
 from fractal_tasks_core.lib_regions_of_interest import (
     are_ROI_table_columns_valid,
 )
-from fractal_tasks_core.lib_write import write_table
+from fractal_tasks_core.lib_tables import write_table
 
 logger = logging.getLogger(__name__)
 
@@ -98,11 +98,16 @@ def apply_registration_to_ROI_tables(
 
     # Collect all the ROI tables
     roi_tables = {}
+    roi_tables_attrs = {}
     for acq in acquisition_dict.keys():
         acq_path = acquisition_dict[acq]
         curr_ROI_table = ad.read_zarr(
             f"{well_zarr}/{acq_path}/tables/{roi_table}"
         )
+        curr_ROI_table_group = zarr.open_group(
+            f"{well_zarr}/{acq_path}/tables/{roi_table}", mode="r"
+        )
+        curr_ROI_table_attrs = curr_ROI_table_group.attrs.asdict()
 
         # For reference_cycle acquisition, handle the fact that it doesn't
         # have the shifts
@@ -122,6 +127,7 @@ def apply_registration_to_ROI_tables(
                 "this task."
             )
         roi_tables[acq] = curr_ROI_table
+        roi_tables_attrs[acq] = curr_ROI_table_attrs
 
     # Check that all acquisitions have the same ROIs
     rois = roi_tables[reference_cycle].obs.index
@@ -158,7 +164,7 @@ def apply_registration_to_ROI_tables(
             image_group,
             new_roi_table,
             shifted_rois[acq],
-            table_attrs=dict(type="ngff:region_table"),
+            table_attrs=roi_tables_attrs[acq],
         )
 
     # TODO: Optionally apply registration to other tables as well?
diff --git a/fractal_tasks_core/tasks/apply_registration_to_image.py b/fractal_tasks_core/tasks/apply_registration_to_image.py
index e89ecb1a4..174f01e69 100644
--- a/fractal_tasks_core/tasks/apply_registration_to_image.py
+++ b/fractal_tasks_core/tasks/apply_registration_to_image.py
@@ -36,7 +36,7 @@
 )
 from fractal_tasks_core.lib_regions_of_interest import is_standard_roi_table
 from fractal_tasks_core.lib_regions_of_interest import load_region
-from fractal_tasks_core.lib_write import write_table
+from fractal_tasks_core.lib_tables import write_table
 from fractal_tasks_core.lib_zattrs_utils import get_table_path_dict
 
 logger = logging.getLogger(__name__)
diff --git a/fractal_tasks_core/tasks/calculate_registration_image_based.py b/fractal_tasks_core/tasks/calculate_registration_image_based.py
index 11d53bb6e..6d61919ed 100644
--- a/fractal_tasks_core/tasks/calculate_registration_image_based.py
+++ b/fractal_tasks_core/tasks/calculate_registration_image_based.py
@@ -36,7 +36,7 @@
     convert_ROI_table_to_indices,
 )
 from fractal_tasks_core.lib_regions_of_interest import load_region
-from fractal_tasks_core.lib_write import write_table
+from fractal_tasks_core.lib_tables import write_table
 
 logger = logging.getLogger(__name__)
 
@@ -152,6 +152,28 @@ def calculate_registration_image_based(
         f"Found {len(ROI_table_x)} ROIs in {roi_table=} to be processed."
     )
 
+    # Check that table type of ROI_table_ref is valid. Note that
+    # "ngff:region_table" and None are accepted for backwards compatibility
+    valid_table_types = [
+        "roi_table",
+        "masking_roi_table",
+        "ngff:region_table",
+        None,
+    ]
+    ROI_table_ref_group = zarr.open_group(
+        f"{zarr_img_ref_cycle}/tables/{roi_table}",
+        mode="r",
+    )
+    ref_table_attrs = ROI_table_ref_group.attrs.asdict()
+    ref_table_type = ref_table_attrs.get("type")
+    if ref_table_type not in valid_table_types:
+        raise ValueError(
+            (
+                f"Table '{roi_table}' (with type '{ref_table_type}') is "
+                "not a valid ROI table."
+            )
+        )
+
     # For each cycle, get the relevant info
     # TODO: Add additional checks on ROIs?
     if (ROI_table_ref.obs.index != ROI_table_x.obs.index).all():
@@ -259,7 +281,7 @@ def calculate_registration_image_based(
         roi_table,
         new_ROI_table,
         overwrite=True,
-        table_attrs=dict(type="ngff:region_table"),
+        table_attrs=ref_table_attrs,
     )
 
     return {}
diff --git a/fractal_tasks_core/tasks/cellpose_segmentation.py b/fractal_tasks_core/tasks/cellpose_segmentation.py
index 9ecaa3c2c..3e1f86651 100644
--- a/fractal_tasks_core/tasks/cellpose_segmentation.py
+++ b/fractal_tasks_core/tasks/cellpose_segmentation.py
@@ -46,12 +46,14 @@
     convert_ROI_table_to_indices,
 )
 from fractal_tasks_core.lib_regions_of_interest import empty_bounding_box_table
+from fractal_tasks_core.lib_regions_of_interest import (
+    find_overlaps_in_ROI_indices,
+)
+from fractal_tasks_core.lib_regions_of_interest import get_overlapping_pairs_3D
 from fractal_tasks_core.lib_regions_of_interest import is_ROI_table_valid
 from fractal_tasks_core.lib_regions_of_interest import load_region
-from fractal_tasks_core.lib_ROI_overlaps import find_overlaps_in_ROI_indices
-from fractal_tasks_core.lib_ROI_overlaps import get_overlapping_pairs_3D
+from fractal_tasks_core.lib_tables import write_table
 from fractal_tasks_core.lib_write import prepare_label_group
-from fractal_tasks_core.lib_write import write_table
 from fractal_tasks_core.lib_zattrs_utils import rescale_datasets
 
 logger = logging.getLogger(__name__)
@@ -638,7 +640,7 @@ def cellpose_segmentation(
             f"{in_path}/{component}/tables/{output_ROI_table}"
         )
         table_attrs = {
-            "type": "ngff:region_table",
+            "type": "masking_roi_table",
             "region": {"path": f"../labels/{output_label_name}"},
             "instance_key": "label",
         }
@@ -647,7 +649,6 @@ def cellpose_segmentation(
             output_ROI_table,
             bbox_table,
             overwrite=overwrite,
-            logger=logger,
             table_attrs=table_attrs,
         )
 
diff --git a/fractal_tasks_core/tasks/copy_ome_zarr.py b/fractal_tasks_core/tasks/copy_ome_zarr.py
index e30a2f39d..ff7cccbc5 100644
--- a/fractal_tasks_core/tasks/copy_ome_zarr.py
+++ b/fractal_tasks_core/tasks/copy_ome_zarr.py
@@ -26,8 +26,8 @@
 from fractal_tasks_core.lib_regions_of_interest import (
     convert_ROIs_from_3D_to_2D,
 )
+from fractal_tasks_core.lib_tables import write_table
 from fractal_tasks_core.lib_write import open_zarr_group_with_overwrite
-from fractal_tasks_core.lib_write import write_table
 
 logger = logging.getLogger(__name__)
 
@@ -190,6 +190,10 @@ def copy_ome_zarr(
                             f"{zarrurl_old}/{well_path}/{image_path}/"
                             f"tables/{ROI_table_name}"
                         )
+                        old_ROI_table_attrs = zarr.open_group(
+                            f"{zarrurl_old}/{well_path}/{image_path}/"
+                            f"tables/{ROI_table_name}"
+                        ).attrs.asdict()
                         # Convert 3D ROIs to 2D
                         if project_to_2D:
                             new_ROI_table = convert_ROIs_from_3D_to_2D(
@@ -197,7 +201,10 @@ def copy_ome_zarr(
                             )
                         # Write new table
                         write_table(
-                            new_image_group, ROI_table_name, new_ROI_table
+                            new_image_group,
+                            ROI_table_name,
+                            new_ROI_table,
+                            table_attrs=old_ROI_table_attrs,
                         )
 
     for key in ["plate", "well", "image"]:
diff --git a/fractal_tasks_core/tasks/create_ome_zarr.py b/fractal_tasks_core/tasks/create_ome_zarr.py
index ea8aef11a..5a7cab9d2 100644
--- a/fractal_tasks_core/tasks/create_ome_zarr.py
+++ b/fractal_tasks_core/tasks/create_ome_zarr.py
@@ -30,9 +30,9 @@
 from fractal_tasks_core.lib_parse_filename_metadata import parse_filename
 from fractal_tasks_core.lib_regions_of_interest import prepare_FOV_ROI_table
 from fractal_tasks_core.lib_regions_of_interest import prepare_well_ROI_table
-from fractal_tasks_core.lib_ROI_overlaps import remove_FOV_overlaps
+from fractal_tasks_core.lib_regions_of_interest import remove_FOV_overlaps
+from fractal_tasks_core.lib_tables import write_table
 from fractal_tasks_core.lib_write import open_zarr_group_with_overwrite
-from fractal_tasks_core.lib_write import write_table
 
 
 __OME_NGFF_VERSION__ = fractal_tasks_core.__OME_NGFF_VERSION__
@@ -428,14 +428,14 @@ def create_ome_zarr(
                 "FOV_ROI_table",
                 FOV_ROIs_table,
                 overwrite=overwrite,
-                logger=logger,
+                table_attrs={"type": "roi_table"},
             )
             write_table(
                 group_image,
                 "well_ROI_table",
                 well_ROIs_table,
                 overwrite=overwrite,
-                logger=logger,
+                table_attrs={"type": "roi_table"},
             )
 
     # Check that the different images in each well have unique channel labels.
diff --git a/fractal_tasks_core/tasks/create_ome_zarr_multiplex.py b/fractal_tasks_core/tasks/create_ome_zarr_multiplex.py
index 332fe8996..91d0d3b6b 100644
--- a/fractal_tasks_core/tasks/create_ome_zarr_multiplex.py
+++ b/fractal_tasks_core/tasks/create_ome_zarr_multiplex.py
@@ -32,9 +32,9 @@
 from fractal_tasks_core.lib_parse_filename_metadata import parse_filename
 from fractal_tasks_core.lib_regions_of_interest import prepare_FOV_ROI_table
 from fractal_tasks_core.lib_regions_of_interest import prepare_well_ROI_table
-from fractal_tasks_core.lib_ROI_overlaps import remove_FOV_overlaps
+from fractal_tasks_core.lib_regions_of_interest import remove_FOV_overlaps
+from fractal_tasks_core.lib_tables import write_table
 from fractal_tasks_core.lib_write import open_zarr_group_with_overwrite
-from fractal_tasks_core.lib_write import write_table
 
 
 __OME_NGFF_VERSION__ = fractal_tasks_core.__OME_NGFF_VERSION__
@@ -470,14 +470,14 @@ def create_ome_zarr_multiplex(
                 "FOV_ROI_table",
                 FOV_ROIs_table,
                 overwrite=overwrite,
-                logger=logger,
+                table_attrs={"type": "roi_table"},
             )
             write_table(
                 group_image,
                 "well_ROI_table",
                 well_ROIs_table,
                 overwrite=overwrite,
-                logger=logger,
+                table_attrs={"type": "roi_table"},
             )
 
     # Check that the different images (e.g. different cycles) in the each well
diff --git a/fractal_tasks_core/tasks/import_ome_zarr.py b/fractal_tasks_core/tasks/import_ome_zarr.py
index 033ffffd3..1e5093ac3 100644
--- a/fractal_tasks_core/tasks/import_ome_zarr.py
+++ b/fractal_tasks_core/tasks/import_ome_zarr.py
@@ -26,7 +26,7 @@
 from fractal_tasks_core.lib_ngff import NgffImageMeta
 from fractal_tasks_core.lib_regions_of_interest import get_image_grid_ROIs
 from fractal_tasks_core.lib_regions_of_interest import get_single_image_ROI
-from fractal_tasks_core.lib_write import write_table
+from fractal_tasks_core.lib_tables import write_table
 
 logger = logging.getLogger(__name__)
 
@@ -88,7 +88,7 @@ def _process_single_image(
             "image_ROI_table",
             image_ROI_table,
             overwrite=overwrite,
-            logger=logger,
+            table_attrs={"type": "roi_table"},
         )
 
     # Prepare grid_ROI_table and write it into the zarr group
@@ -103,7 +103,7 @@ def _process_single_image(
             "grid_ROI_table",
             grid_ROI_table,
             overwrite=overwrite,
-            logger=logger,
+            table_attrs={"type": "roi_table"},
         )
 
     # Update Omero-channels metadata
diff --git a/fractal_tasks_core/tasks/napari_workflows_wrapper.py b/fractal_tasks_core/tasks/napari_workflows_wrapper.py
index a17d7e7c5..eef47cbdf 100644
--- a/fractal_tasks_core/tasks/napari_workflows_wrapper.py
+++ b/fractal_tasks_core/tasks/napari_workflows_wrapper.py
@@ -37,9 +37,9 @@
     convert_ROI_table_to_indices,
 )
 from fractal_tasks_core.lib_regions_of_interest import load_region
+from fractal_tasks_core.lib_tables import write_table
 from fractal_tasks_core.lib_upscale_array import upscale_array
 from fractal_tasks_core.lib_write import prepare_label_group
-from fractal_tasks_core.lib_write import write_table
 from fractal_tasks_core.lib_zattrs_utils import rescale_datasets
 
 
@@ -624,13 +624,17 @@ def napari_workflows_wrapper(
 
         # Write to zarr group
         image_group = zarr.group(f"{in_path}/{component}")
-        # TODO: should we include any table_attrs? (ref issue #333)
+        table_attrs = dict(
+            type="feature_table",
+            region=dict(path=f"../labels/{out_params.label_name}"),
+            instance_key="label",
+        )
         write_table(
             image_group,
             table_name,
             measurement_table,
             overwrite=overwrite,
-            logger=logger,
+            table_attrs=table_attrs,
         )
 
     # Output handling: "label" type (for each output, build and write to disk
diff --git a/pyproject.toml b/pyproject.toml
index 9c738786a..ebc3c363a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "fractal-tasks-core"
-version = "0.13.1"
+version = "0.14.0a0"
 description = "Core bioimage-analysis library and tasks of the Fractal analytics platform"
 authors = [
     "Joel Lüthi  <joel.luethi@fmi.ch>",
@@ -89,7 +89,7 @@ source = ["fractal_tasks_core"]
 omit = ["tests/*", "examples/*", "fractal_tasks_core/dev/*", "fractal_tasks_core/tasks/compress_tif.py"]
 
 [tool.bumpver]
-current_version = "0.13.1"
+current_version = "0.14.0a0"
 version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]"
 commit_message = "bump version {old_version} -> {new_version}"
 commit = true


Test patch:
diff --git a/tests/tasks/conftest.py b/tests/tasks/conftest.py
index 53bced15b..adf31d3ca 100644
--- a/tests/tasks/conftest.py
+++ b/tests/tasks/conftest.py
@@ -117,7 +117,6 @@ def zenodo_zarr(testdata_path: Path) -> list[str]:
         if os.path.isdir(str(folder)):
             shutil.rmtree(str(folder))
         shutil.copytree(Path(zarr_full_path) / file_name, folder)
-
     return [str(f) for f in folders]
 
 
diff --git a/tests/tasks/test_unit_napari_workflows_wrapper.py b/tests/tasks/test_unit_napari_workflows_wrapper.py
index cec2a433c..2ee075cd1 100644
--- a/tests/tasks/test_unit_napari_workflows_wrapper.py
+++ b/tests/tasks/test_unit_napari_workflows_wrapper.py
@@ -103,7 +103,11 @@ def test_level_setting_in_non_labeling_worfklow(tmp_path, testdata_path):
             "type": "label",
             "label_name": "label_DAPI",
         },
-        "regionprops_DAPI": {"type": "dataframe", "table_name": "test"},
+        "regionprops_DAPI": {
+            "type": "dataframe",
+            "table_name": "test",
+            "label_name": "label_DAPI",
+        },
     }
 
     with pytest.raises(NotImplementedError):
diff --git a/tests/tasks/test_workflows.py b/tests/tasks/test_workflows.py
index b1b7fa8de..e983b0e63 100644
--- a/tests/tasks/test_workflows.py
+++ b/tests/tasks/test_workflows.py
@@ -17,6 +17,7 @@
 from typing import Any
 
 import pytest
+import zarr
 from devtools import debug
 
 from ._validation import check_file_number
@@ -230,6 +231,14 @@ def test_yokogawa_to_ome_zarr(
 
     check_file_number(zarr_path=image_zarr)
 
+    # Test presence and attributes of FOV/well ROI tables
+    for table_name in ["FOV_ROI_table", "well_ROI_table"]:
+        table_attrs = zarr.open_group(
+            image_zarr / f"tables/{table_name}", mode="r"
+        ).attrs.asdict()
+        assert table_attrs["type"] == "roi_table"
+        assert table_attrs["fractal_table_version"] == "1"
+
 
 def test_MIP(
     tmp_path: Path,
@@ -320,6 +329,14 @@ def test_MIP(
     validate_schema(path=str(well_zarr), type="well")
     validate_schema(path=str(plate_zarr), type="plate")
 
+    # Test presence and attributes of FOV/well ROI tables
+    for table_name in ["FOV_ROI_table", "well_ROI_table"]:
+        table_attrs = zarr.open_group(
+            image_zarr / f"tables/{table_name}", mode="r"
+        ).attrs.asdict()
+        assert table_attrs["type"] == "roi_table"
+        assert table_attrs["fractal_table_version"] == "1"
+
 
 def test_MIP_subset_of_images(
     tmp_path: Path,
diff --git a/tests/tasks/test_workflows_napari_workflows.py b/tests/tasks/test_workflows_napari_workflows.py
index 43b6d2dea..b9bb90eb5 100644
--- a/tests/tasks/test_workflows_napari_workflows.py
+++ b/tests/tasks/test_workflows_napari_workflows.py
@@ -92,6 +92,7 @@ def test_napari_workflow(
     output_specs = {
         "regionprops_DAPI": {  # type: ignore # noqa
             "type": "dataframe",
+            "label_name": "label_DAPI",
             "table_name": "regionprops_DAPI",
         },
     }
@@ -233,7 +234,11 @@ def test_napari_worfklow_label_input_only(
     input_label={"type": "label", "label_name": LABEL_NAME},
 )
 output_specs = dict(
-    output_dataframe={"type": "dataframe", "table_name": TABLE_NAME}
+    output_dataframe={
+        "type": "dataframe",
+        "table_name": TABLE_NAME,
+        "label_name": LABEL_NAME,
+    }
 )
 RELABELING_CASE_2: list = [workflow_file_name, input_specs, output_specs]
 # 3. Mixed labeling/measurement workflow.
@@ -243,7 +248,11 @@ def test_napari_worfklow_label_input_only(
 )
 output_specs = dict(
     output_label={"type": "label", "label_name": LABEL_NAME},
-    output_dataframe={"type": "dataframe", "table_name": TABLE_NAME},
+    output_dataframe={
+        "type": "dataframe",
+        "table_name": TABLE_NAME,
+        "label_name": LABEL_NAME,
+    },
 )
 RELABELING_CASE_3: list = [workflow_file_name, input_specs, output_specs]
 # Assemble three cases
@@ -516,6 +525,7 @@ def test_napari_workflow_empty_input_ROI_table(
         "regionprops_DAPI": {  # type: ignore # noqa
             "type": "dataframe",
             "table_name": "regionprops_DAPI",
+            "label_name": "label_DAPI",
         },
     }
     for component in metadata["image"]:
@@ -609,6 +619,7 @@ def test_napari_workflow_CYX(
         "regionprops_DAPI": {  # type: ignore # noqa
             "type": "dataframe",
             "table_name": "regionprops_DAPI",
+            "label_name": "label_DAPI",
         },
     }
     for component in metadata["image"]:
@@ -789,6 +800,7 @@ def test_napari_workflow_mock(
         "regionprops_DAPI": {  # type: ignore # noqa
             "type": "dataframe",
             "table_name": "regionprops_DAPI",
+            "label_name": "label_DAPI",
         },
     }
 
diff --git a/tests/test_unit_ROIs.py b/tests/test_unit_ROIs.py
index f95f4f8b7..4f117f290 100644
--- a/tests/test_unit_ROIs.py
+++ b/tests/test_unit_ROIs.py
@@ -26,6 +26,9 @@
     convert_ROIs_from_3D_to_2D,
 )
 from fractal_tasks_core.lib_regions_of_interest import empty_bounding_box_table
+from fractal_tasks_core.lib_regions_of_interest import (
+    find_overlaps_in_ROI_indices,
+)
 from fractal_tasks_core.lib_regions_of_interest import get_image_grid_ROIs
 from fractal_tasks_core.lib_regions_of_interest import get_single_image_ROI
 from fractal_tasks_core.lib_regions_of_interest import is_ROI_table_valid
@@ -36,7 +39,6 @@
 from fractal_tasks_core.lib_regions_of_interest import prepare_FOV_ROI_table
 from fractal_tasks_core.lib_regions_of_interest import prepare_well_ROI_table
 from fractal_tasks_core.lib_regions_of_interest import reset_origin
-from fractal_tasks_core.lib_ROI_overlaps import find_overlaps_in_ROI_indices
 
 
 PIXEL_SIZE_X = 0.1625
@@ -494,7 +496,7 @@ def test_is_ROI_table_valid(tmp_path):
     assert not is_valid
 
     # Case 3: use_masks=True, valid attrs
-    group[table_name].attrs["type"] = "ngff:region_table"
+    group[table_name].attrs["type"] = "masking_roi_table"
     group[table_name].attrs["instance_key"] = "label"
     group[table_name].attrs["region"] = {"path": "/tmp/"}
     is_valid = is_ROI_table_valid(table_path=table_path, use_masks=True)
@@ -614,8 +616,6 @@ def test_get_single_image_ROI():
             array_shape[2] * pixels_ZYX[2],
             array_shape[1] * pixels_ZYX[1],
             array_shape[0] * pixels_ZYX[0],
-            0.0,
-            0.0,
         )
     )
     assert np.allclose(EXPECTED_DATA, ROI.X)
@@ -642,8 +642,6 @@ def test_get_image_grid_ROIs():
             array_shape[2] * pixels_ZYX[2] / grid_shape_X,
             array_shape[1] * pixels_ZYX[1] / grid_shape_Y,
             array_shape[0] * pixels_ZYX[0],
-            0.0,
-            0.0,
         )
     )
     assert np.allclose(EXPECTED_DATA, ROI.X[0])
@@ -673,9 +671,9 @@ def test_get_image_grid_ROIs():
     # Check data
     EXPECTED_DATA = np.array(
         [
-            [0.0, 0.0, 0.0, 0.8, 5.0, 3.0, 0.0, 0.0],
-            [0.8, 0.0, 0.0, 0.8, 5.0, 3.0, 0.8, 0.0],
-            [1.6, 0.0, 0.0, 0.4, 5.0, 3.0, 1.6, 0.0],
+            [0.0, 0.0, 0.0, 0.8, 5.0, 3.0],
+            [0.8, 0.0, 0.0, 0.8, 5.0, 3.0],
+            [1.6, 0.0, 0.0, 0.4, 5.0, 3.0],
         ]
     )
     assert np.allclose(EXPECTED_DATA, ROI.X)
diff --git a/tests/test_unit_input_models.py b/tests/test_unit_input_models.py
index d9abb7ae4..b9d11d997 100644
--- a/tests/test_unit_input_models.py
+++ b/tests/test_unit_input_models.py
@@ -98,6 +98,13 @@ def test_NapariWorkflowsOutput():
         )
     debug(e.value)
 
+    with pytest.raises(ValueError) as e:
+        NapariWorkflowsOutput(
+            type="dataframe",
+            table_name="something",
+        )
+    debug(e.value)
+
     # Valid
 
     specs = NapariWorkflowsOutput(
@@ -110,6 +117,7 @@ def test_NapariWorkflowsOutput():
 
     specs = NapariWorkflowsOutput(
         type="dataframe",
+        label_name="label_DAPI",
         table_name="table_DAPI",
     )
     debug(specs)
diff --git a/tests/test_unit_parse_yokogawa_metadata.py b/tests/test_unit_parse_yokogawa_metadata.py
index 6f1d7f832..0fa0066b0 100644
--- a/tests/test_unit_parse_yokogawa_metadata.py
+++ b/tests/test_unit_parse_yokogawa_metadata.py
@@ -20,8 +20,8 @@
 from pandas import Timestamp
 
 from fractal_tasks_core.lib_metadata_parsing import parse_yokogawa_metadata
-from fractal_tasks_core.lib_ROI_overlaps import remove_FOV_overlaps
-from fractal_tasks_core.lib_ROI_overlaps import run_overlap_check
+from fractal_tasks_core.lib_regions_of_interest import remove_FOV_overlaps
+from fractal_tasks_core.lib_regions_of_interest import run_overlap_check
 
 # General variables and paths (relative to the test folder)
 testdir = os.path.dirname(__file__)
diff --git a/tests/test_unit_tables.py b/tests/test_unit_tables.py
new file mode 100644
index 000000000..e2429836c
--- /dev/null
+++ b/tests/test_unit_tables.py
@@ -0,0 +1,203 @@
+import anndata as ad
+import numpy as np
+import pytest
+import zarr
+from devtools import debug
+
+from fractal_tasks_core import __FRACTAL_TABLE_VERSION__
+from fractal_tasks_core.lib_tables import write_table
+from fractal_tasks_core.lib_tables.v1 import FeatureTableAttrs
+from fractal_tasks_core.lib_tables.v1 import MaskingROITableAttrs
+from fractal_tasks_core.lib_write import OverwriteNotAllowedError
+
+
+TYPE = "some-arbitrary-type"
+
+
+def test_write_table(tmp_path):
+    """
+    Test some specific behaviors of `write_table`, especially the logic which
+    is not part of `_write_elem_with_overwrite`.
+    """
+
+    # Generate fake ROI tables
+    ROI_table_1 = ad.AnnData(np.ones((1, 1)))
+    ROI_table_2 = ad.AnnData(np.ones((2, 2)))
+
+    # Create zarr groups for image and tables
+    zarr_path = str(tmp_path / "my_image.zarr")
+    image_group = zarr.open(zarr_path, mode="w")
+
+    # Run write_table
+    table_a_group = write_table(
+        image_group, "table_a", ROI_table_1, table_type=TYPE
+    )
+    assert set(image_group.group_keys()) == {"tables"}
+    assert image_group["tables"].attrs.asdict() == dict(tables=["table_a"])
+    assert "region" not in table_a_group.attrs.keys()
+    assert "instance_key" not in table_a_group.attrs.keys()
+    assert table_a_group.attrs["type"] == TYPE
+    assert (
+        table_a_group.attrs["fractal_table_version"]
+        == __FRACTAL_TABLE_VERSION__
+    )  # noqa
+
+    # Run write_table again, with overwrite=True
+    table_a_group = write_table(
+        image_group, "table_a", ROI_table_2, overwrite=True, table_type=TYPE
+    )
+    assert set(image_group.group_keys()) == {"tables"}
+    assert image_group["tables"].attrs.asdict() == dict(tables=["table_a"])
+    assert "region" not in table_a_group.attrs.keys()
+    assert "instance_key" not in table_a_group.attrs.keys()
+    assert table_a_group.attrs["type"] == TYPE
+    assert table_a_group.X.shape == (2, 2)  # Verify that it was overwritten
+
+    # Run write_table, with both table_type and table_attrs parameters
+    table_b_group = write_table(
+        image_group,
+        "table_b",
+        ROI_table_2,
+        table_type=TYPE,
+        table_attrs={"type": "wrong"},
+    )
+    assert image_group["tables"].attrs.asdict() == dict(
+        tables=["table_a", "table_b"]
+    )
+    assert table_b_group.attrs["type"] == TYPE
+
+    # Run write_table, without specifying type
+    with pytest.raises(ValueError) as e:
+        table_b_group = write_table(
+            image_group,
+            "table_b",
+            ROI_table_2,
+            overwrite=True,
+            table_attrs={"something": "else"},
+        )
+    assert "Missing attribute `type`" in str(e.value)
+
+    # Verify the overwrite=False failure if sub-group already exists
+    image_group["tables"].create_group("table_c")
+    with pytest.raises(OverwriteNotAllowedError) as e:
+        write_table(image_group, "table_c", ROI_table_2)
+    assert str(e.value).startswith("Sub-group ")
+
+    # Verify the overwrite=False failure if item already exists in tables
+    # attribute
+    image_group["tables"].attrs["tables"] = ["table_a", "table_b", "table_d"]
+    with pytest.raises(OverwriteNotAllowedError) as e:
+        write_table(image_group, "table_d", ROI_table_2)
+    assert str(e.value).startswith("Item ")
+
+
+def test_write_table_validation_errors(tmp_path, caplog):
+    """
+    Test that the appropriate errors are raised when not complying with the
+    table specs.
+    """
+
+    table = ad.AnnData(np.ones((2, 2)))
+    zarr_path = str(tmp_path / "my_image.zarr")
+    img_group = zarr.open(zarr_path, mode="w")
+
+    # Valid custom table
+    ATTRS = dict(
+        type="some_custom_table",
+        something="else",
+    )
+    write_table(img_group, "table", table, overwrite=True, table_attrs=ATTRS)
+
+    # Valid roi_table
+    ATTRS = dict(
+        type="roi_table",
+        something="else",
+    )
+    write_table(img_group, "table", table, overwrite=True, table_attrs=ATTRS)
+
+    # Valid masking_roi_table
+    ATTRS = dict(
+        type="masking_roi_table",
+        region=dict(path="../labels/something"),
+        instance_key="label",
+    )
+    write_table(img_group, "table", table, overwrite=True, table_attrs=ATTRS)
+
+    # Invalid masking_roi_table
+    ATTRS = dict(
+        type="masking_roi_table",
+        region=dict(path="../labels/something"),
+    )
+    with pytest.raises(ValueError):
+        write_table(
+            img_group, "table", table, overwrite=True, table_attrs=ATTRS
+        )
+
+    # Valid feature_table
+    ATTRS = dict(
+        type="feature_table",
+        region=dict(path="../labels/something"),
+        instance_key="label",
+    )
+    write_table(img_group, "table", table, overwrite=True, table_attrs=ATTRS)
+
+    # Invalid feature_table
+    ATTRS = dict(
+        type="feature_table",
+        region=dict(path="../labels/something"),
+    )
+    with pytest.raises(ValueError):
+        write_table(
+            img_group, "table", table, overwrite=True, table_attrs=ATTRS
+        )
+
+
+def test_write_table_V2_not_implemented(tmp_path):
+
+    zarr_path = str(tmp_path / "my_image.zarr")
+    img_group = zarr.open(zarr_path, mode="w")
+    table = ad.AnnData(np.ones((2, 2)))
+
+    with pytest.raises(NotImplementedError) as e:
+        write_table(
+            img_group,
+            "table",
+            table,
+            table_attrs={"fractal_table_version": "2"},
+        )
+    debug(e.value)
+    assert "not supported" in str(e.value)
+
+
+@pytest.mark.filterwarnings("error::FutureWarning")
+def test_MaskingROITableAttrs():
+    # Valid instance
+    MaskingROITableAttrs(
+        type="masking_roi_table",
+        region={"path": "../labels/something"},
+        instance_key="label",
+    )
+    # FutureWarning (transformed into error, in this test)
+    with pytest.raises(FutureWarning):
+        MaskingROITableAttrs(
+            type="ngff:region_table",
+            region={"path": "../labels/something"},
+            instance_key="label",
+        )
+
+
+@pytest.mark.filterwarnings("error::FutureWarning")
+def test_FeatureTableAttrs():
+    # Valid instance
+    FeatureTableAttrs(
+        type="feature_table",
+        region={"path": "../labels/something"},
+        instance_key="label",
+    )
+    # FutureWarning (transformed into error, in this test)
+    with pytest.raises(FutureWarning):
+        FeatureTableAttrs(
+            type="ngff:region_table",
+            region={"path": "../labels/something"},
+            instance_key="label",
+        )
diff --git a/tests/test_unit_zarr.py b/tests/test_unit_write.py
similarity index 51%
rename from tests/test_unit_zarr.py
rename to tests/test_unit_write.py
index 3f55fb922..7876cd6c8 100644
--- a/tests/test_unit_zarr.py
+++ b/tests/test_unit_write.py
@@ -1,15 +1,15 @@
+import logging
+
 import anndata as ad
 import numpy as np
 import pytest
 import zarr
 from devtools import debug
 
-from fractal_tasks_core import __FRACTAL_TABLE_VERSION__
 from fractal_tasks_core.lib_write import _write_elem_with_overwrite
 from fractal_tasks_core.lib_write import open_zarr_group_with_overwrite
 from fractal_tasks_core.lib_write import OverwriteNotAllowedError
 from fractal_tasks_core.lib_write import prepare_label_group
-from fractal_tasks_core.lib_write import write_table
 
 
 def test_open_zarr_group_with_overwrite(tmp_path, caplog):
@@ -108,158 +108,103 @@ def test_write_elem_with_overwrite(tmp_path):
     assert subgroup.X.shape == (3, 3)  # Verify that it was not overwritten
 
 
-def test_write_table(tmp_path):
-    """
-    Test some specific behaviors of `write_table`, especially the logic which
-    is not part of `_write_elem_with_overwrite`.
-    """
-
-    # Generate fake ROI tables
-    ROI_table_1 = ad.AnnData(np.ones((1, 1)))
-    ROI_table_2 = ad.AnnData(np.ones((2, 2)))
-
-    # Create zarr groups for image and tables
-    zarr_path = str(tmp_path / "my_image.zarr")
-    image_group = zarr.open(zarr_path, mode="w")
-
-    # Run write_table
-    table_a_group = write_table(image_group, "table_a", ROI_table_1)
-    assert set(image_group.group_keys()) == {"tables"}
-    assert image_group["tables"].attrs.asdict() == dict(tables=["table_a"])
-    for key in ["region", "instance_key", "type"]:
-        assert key not in table_a_group.attrs.keys()
-    assert (
-        table_a_group.attrs["fractal_table_version"]
-        == __FRACTAL_TABLE_VERSION__
-    )
-
-    # Run write_table again, with overwrite=True
-    table_a_group = write_table(
-        image_group, "table_a", ROI_table_2, overwrite=True
-    )
-    assert set(image_group.group_keys()) == {"tables"}
-    assert image_group["tables"].attrs.asdict() == dict(tables=["table_a"])
-    for key in ["region", "instance_key", "type"]:
-        assert key not in table_a_group.attrs.keys()
-    assert table_a_group.X.shape == (2, 2)  # Verify that it was overwritten
-
-    # Run write_table, with table_attrs parameters
-    KEY = "KEY"
-    VALUE = "VALUE"
-    table_b_group = write_table(
-        image_group, "table_b", ROI_table_2, table_attrs=dict(KEY=VALUE)
-    )
-    assert image_group["tables"].attrs.asdict() == dict(
-        tables=["table_a", "table_b"]
-    )
-    assert table_b_group.attrs[KEY] == VALUE
-
-    # Verify the overwrite=False failure if sub-group already exists
-    image_group["tables"].create_group("table_c")
-    with pytest.raises(OverwriteNotAllowedError) as e:
-        write_table(image_group, "table_c", ROI_table_2)
-    assert str(e.value).startswith("Sub-group ")
-
-    # Verify the overwrite=False failure if item already exists in tables
-    # attribute
-    image_group["tables"].attrs["tables"] = ["table_a", "table_b", "table_d"]
-    with pytest.raises(OverwriteNotAllowedError) as e:
-        write_table(image_group, "table_d", ROI_table_2)
-    assert str(e.value).startswith("Item ")
-
-
-def test_write_table_warnings(tmp_path, caplog):
-    """
-    Test that the appropriate warnings are raised when not complying with the
-    new proposed table specs.
-    """
-
-    table = ad.AnnData(np.ones((2, 2)))
-    zarr_path = str(tmp_path / "my_image.zarr")
-    img_group = zarr.open(zarr_path, mode="w")
-
-    def _check_warnings(_ATTRS, expect_warning=True):
-        caplog.clear()
-        write_table(
-            img_group, "table", table, table_attrs=_ATTRS, overwrite=True
-        )
-        debug(caplog.text)
-        WARNING_MSG = "does not comply with the proposed table specs"
-        if expect_warning:
-            assert WARNING_MSG in caplog.text
-        else:
-            assert WARNING_MSG not in caplog.text
-
-    # Run without warnings
-    ATTRS = dict(
-        type="ngff:region_table",
-        region=dict(path="../labels/something"),
-        instance_key="label",
-    )
-    _check_warnings(ATTRS, expect_warning=False)
-
-    # Run with warnings, case 1
-    ATTRS = dict(
-        type="ngff:region_table",
-        region=dict(path="../labels/something"),
-    )
-    _check_warnings(ATTRS)
-
-    # Run with warnings, case 2
-    ATTRS = dict(
-        type="ngff:region_table",
-        instance_key="label",
-        region=dict(key="value"),
-    )
-    _check_warnings(ATTRS)
-
-    # Run with warnings, case 3
-    ATTRS = dict(
-        type="ngff:region_table",
-        instance_key="label",
-    )
-    _check_warnings(ATTRS)
-
-
-def test_prepare_label_group(tmp_path):
+def test_prepare_label_group(tmp_path, caplog):
     """
     Test some specific behaviors of `prepare_label_group`.
     """
 
+    # Prepare label attributes with name OLD_NAME
+    ATTRS = {
+        "image-label": {
+            "version": "0.4",
+            "source": dict(image="../../"),
+        },
+        "multiscales": [
+            {
+                "name": "OLD_NAME",
+                "version": "0.4",
+                "axes": [
+                    {
+                        "name": "z",
+                        "type": "space",
+                        "unit": "micrometer",
+                    },
+                    {
+                        "name": "y",
+                        "type": "space",
+                        "unit": "micrometer",
+                    },
+                    {
+                        "name": "x",
+                        "type": "space",
+                        "unit": "micrometer",
+                    },
+                ],
+                "datasets": [
+                    {
+                        "path": "0",
+                        "coordinateTransformations": [
+                            {
+                                "type": "scale",
+                                "scale": [
+                                    1.0,
+                                    1.3,
+                                    1.3,
+                                ],
+                            },
+                        ],
+                    },
+                ],
+            },
+        ],
+    }
+
     # Create zarr groups for image and labels
     zarr_path = str(tmp_path / "my_image.zarr")
     image_group = zarr.open(zarr_path, mode="w")
 
     # Run prepare_label_group
-    prepare_label_group(image_group, "label_a")
+    label_name = "label_A"
+    caplog.clear()
+    caplog.set_level(logging.WARNING)
+    prepare_label_group(image_group, label_name, label_attrs=ATTRS)
     assert set(image_group.group_keys()) == {"labels"}
-    assert image_group["labels"].attrs.asdict() == dict(labels=["label_a"])
+    assert image_group["labels"].attrs.asdict() == dict(labels=[label_name])
+    # Check that name was overwritten in Zarr attributes
+    label_group = zarr.open(f"{zarr_path}/labels/{label_name}", mode="r")
+    assert label_group.attrs["multiscales"][0]["name"] == label_name
+    assert "Setting multiscale name to" in caplog.text
 
     # Run prepare_label_group again, with overwrite=True
-    prepare_label_group(image_group, "label_a", overwrite=True)
-    assert set(image_group.group_keys()) == {"labels"}
-    assert image_group["labels"].attrs.asdict() == dict(labels=["label_a"])
-
-    # Run prepare_label_group, with label_attrs parameters
-    KEY = "KEY"
-    VALUE = "VALUE"
-    label_b_group = prepare_label_group(
-        image_group, "label_b", label_attrs=dict(KEY=VALUE)
-    )
-    assert image_group["labels"].attrs.asdict() == dict(
-        labels=["label_a", "label_b"]
+    prepare_label_group(
+        image_group, label_name, overwrite=True, label_attrs=ATTRS
     )
-    assert label_b_group.attrs[KEY] == VALUE
+    assert set(image_group.group_keys()) == {"labels"}
+    assert image_group["labels"].attrs.asdict() == dict(labels=[label_name])
+    label_group = zarr.open(f"{zarr_path}/labels/{label_name}", mode="r")
+    assert label_group.attrs["multiscales"][0]["name"] == label_name
 
     # Verify the overwrite=False failure if sub-group already exists
-    image_group["labels"].create_group("label_c")
+    label_name = "label_B"
+    image_group["labels"].create_group(label_name)
     with pytest.raises(OverwriteNotAllowedError) as e:
-        prepare_label_group(image_group, "label_c")
+        prepare_label_group(image_group, label_name, label_attrs=ATTRS)
     assert str(e.value).startswith("Sub-group ")
 
     # Verify the overwrite=False failure if item already exists in labels
     # attribute
-    image_group["labels"].attrs["labels"] = ["label_a", "label_b", "label_d"]
+    label_name = "label_C"
+    image_group["labels"].attrs["labels"] = ["something", label_name]
     with pytest.raises(OverwriteNotAllowedError) as e:
-        prepare_label_group(image_group, "label_d")
+        prepare_label_group(image_group, label_name, label_attrs=ATTRS)
     assert str(e.value).startswith("Item ")
+
+    # Verify failures in case of missing or invalid label-group attributes
+    label_name = "label_D"
+    with pytest.raises(TypeError):
+        prepare_label_group(image_group, label_name)
+    with pytest.raises(ValueError) as e:
+        prepare_label_group(
+            image_group, label_name, label_attrs={"something": "invalid"}
+        )
+    assert "do not comply" in str(e.value)