Repository: spatial-image/spatial-image

Issue:
Assigning the `c` Coordinate for `axis_units` in `SpatialImageCYXDataClass` raises a TypeError
Hello, thanks for making `spatial-image`!

I've run into a small error with when initializing a `SpatialImageCYXDataClass` as setting an axis unit for "Channel" / "c" errors.



```python
# imports, example data
import numpy as np
import spatialdata as sd
from spatialdata.models import Image2DModel, C, Y, X
from spatial_image import to_spatial_image

rng = np.random.default_rng()

_data = rng.random(size=(3, 10, 10))
channel_names = ["chan_0", "chan_1", "chan_2"]

```
Here is a small example demonstrating the error using `spatialdata`.

```python
fov_sd = sd.SpatialData(
    images={
        "test": Image2DModel.parse(
            data=_data,
            dims=(C, Y, X),
            axis_names={C: "channel", Y: "y", X: "x"},
            axis_units={C: "channel", Y: "pixel", X: "pixel"},
            c_coords=channel_names,
        )
    }
)
```

In addition, here is one just using `spatial-image`
```python
fov_si = to_spatial_image(
    array_like=_data,
    dims=(C, Y, X),
    axis_names={C: "channel", Y: "y", X: "x"},
    axis_units={C: "channel", Y: "pixel", X: "pixel"},
    c_coords=channel_names,
)
```

The error happens here at https://github.com/spatial-image/spatial-image/blob/43b2faddce73cb17c2a5bdef5998e76e75870463/spatial_image.py#L827-L829

I'd love to contribute to the repo with a small fix!

<details><summary>Traceback</summary>

```python
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[2], line 12
      7 _data = rng.random(size=(3, 10, 10))
      8 channel_names = ["chan_0", "chan_1", "chan_2"]
     10 fov_sd = sd.SpatialData(
     11     images={
---> 12         "test": Image2DModel.parse(
     13             data=_data,
     14             dims=(C, Y, X),
     15             axis_names={C: "channel", Y: "y", X: "x"},
     16             axis_units={C: "channel", Y: "pixel", X: "pixel"},
     17             c_coords=channel_names,
     18         )
     19     }
     20 )

File ~/.pyenv/versions/3.11.4/envs/spatial_data/lib/python3.11/site-packages/spatialdata/models/models.py:176, in RasterSchema.parse(cls, data, dims, transformations, scale_factors, method, chunks, **kwargs)
    170         raise ValueError(
    171             f"Cannot transpose arrays to match `dims`: {dims}.",
    172             "Try to reshape `data` or `dims`.",
    173         ) from e
    175 # finally convert to spatial image
--> 176 data = to_spatial_image(array_like=data, dims=cls.dims.dims, **kwargs)
    177 # parse transformations
    178 _parse_transformations(data, transformations)

File ~/.pyenv/versions/3.11.4/envs/spatial_data/lib/python3.11/site-packages/spatial_image.py:1228, in to_spatial_image(array_like, dims, scale, translation, name, axis_names, axis_units, t_coords, c_coords)
   1225 if "t" in dims:
   1226     si_kwargs["t_coords"] = t_coords
-> 1228 image = SIDataClass.new(array_like, **si_kwargs)
   1230 return image

File ~/.pyenv/versions/3.11.4/envs/spatial_data/lib/python3.11/site-packages/xarray_dataclasses/dataarray.py:148, in AsDataArray.new..new(cls, *args, **kwargs)
    147 def new(cls: Any, *args: Any, **kwargs: Any) -> Any:
--> 148     return asdataarray(cls(*args, **kwargs))

File ~/.pyenv/versions/3.11.4/envs/spatial_data/lib/python3.11/site-packages/spatial_image.py:829, in SpatialImageCYXDataClass.__init__(self, data, scale, translation, name, axis_names, axis_units, c_coords)
    827 c_axis_units = ""
    828 if axis_units and "c" in axis_units:
--> 829     c_axis_units["c"] = ""
    831 self.c = CAxis(c_coords, c_axis_name, c_axis_units)

TypeError: 'str' object does not support item assignment
```
</details>




Discussion:

Patch:
diff --git a/spatial_image.py b/spatial_image.py
index 68248db..de9d3e8 100644
--- a/spatial_image.py
+++ b/spatial_image.py
@@ -176,7 +176,7 @@ class SpatialImageXCDataClass(SpatialImageXDataClass):
             c_axis_name = axis_names["c"]
         c_axis_units = ""
         if axis_units and "c" in axis_units:
-            c_axis_units["c"] = ""
+            c_axis_units = axis_units["c"]
 
         self.c = CAxis(c_coords, c_axis_name, c_axis_units)
 
@@ -343,7 +343,7 @@ class SpatialImageYXCDataClass(SpatialImageYXDataClass):
             c_axis_name = axis_names["c"]
         c_axis_units = ""
         if axis_units and "c" in axis_units:
-            c_axis_units["c"] = ""
+            c_axis_units = axis_units["c"]
         self.c = CAxis(c_coords, c_axis_name, c_axis_units)
 
 
@@ -535,7 +535,7 @@ class SpatialImageZYXCDataClass(SpatialImageZYXDataClass):
             c_axis_name = axis_names["c"]
         c_axis_units = ""
         if axis_units and "c" in axis_units:
-            c_axis_units["c"] = ""
+            c_axis_units = axis_units["c"]
         self.c = CAxis(c_coords, c_axis_name, c_axis_units)
 
 
@@ -697,7 +697,7 @@ class SpatialImageCXDataClass(SpatialImageDataClass):
             c_axis_name = axis_names["c"]
         c_axis_units = ""
         if axis_units and "c" in axis_units:
-            c_axis_units["c"] = ""
+            c_axis_units = axis_units["c"]
 
         self.c = CAxis(c_coords, c_axis_name, c_axis_units)
 
@@ -761,7 +761,7 @@ class SpatialImageTCXDataClass(SpatialImageDataClass):
             c_axis_name = axis_names["c"]
         c_axis_units = ""
         if axis_units and "c" in axis_units:
-            c_axis_units["c"] = ""
+            c_axis_units = axis_units["c"]
 
         self.c = CAxis(c_coords, c_axis_name, c_axis_units)
 
@@ -826,7 +826,7 @@ class SpatialImageCYXDataClass(SpatialImageDataClass):
             c_axis_name = axis_names["c"]
         c_axis_units = ""
         if axis_units and "c" in axis_units:
-            c_axis_units["c"] = ""
+            c_axis_units = axis_units["c"]
 
         self.c = CAxis(c_coords, c_axis_name, c_axis_units)
 
@@ -903,7 +903,7 @@ class SpatialImageTCYXDataClass(SpatialImageDataClass):
             c_axis_name = axis_names["c"]
         c_axis_units = ""
         if axis_units and "c" in axis_units:
-            c_axis_units["c"] = ""
+            c_axis_units = axis_units["c"]
 
         self.c = CAxis(c_coords, c_axis_name, c_axis_units)
 
@@ -980,7 +980,7 @@ class SpatialImageCZYXDataClass(SpatialImageDataClass):
             c_axis_name = axis_names["c"]
         c_axis_units = ""
         if axis_units and "c" in axis_units:
-            c_axis_units["c"] = ""
+            c_axis_units = axis_units["c"]
 
         self.c = CAxis(c_coords, c_axis_name, c_axis_units)
 
@@ -1070,7 +1070,7 @@ class SpatialImageTCZYXDataClass(SpatialImageDataClass):
             c_axis_name = axis_names["c"]
         c_axis_units = ""
         if axis_units and "c" in axis_units:
-            c_axis_units["c"] = ""
+            c_axis_units = axis_units["c"]
 
         self.c = CAxis(c_coords, c_axis_name, c_axis_units)
 


Test patch:
diff --git a/test_spatial_image.py b/test_spatial_image.py
index 914135c..e04d94e 100644
--- a/test_spatial_image.py
+++ b/test_spatial_image.py
@@ -83,6 +83,7 @@ def test_4D_default_coords_channels_last():
     assert np.array_equal(image.coords["x"], np.arange(6, dtype=np.float64))
     assert np.array_equal(image.coords["c"], np.arange(6, dtype=np.float64))
 
+
 def test_4D_default_coords_channels_first():
     array = np.random.random((3, 4, 6, 6))
     image = si.to_spatial_image(array, dims=("c", "z", "y", "x"))
@@ -91,6 +92,7 @@ def test_4D_default_coords_channels_first():
     assert np.array_equal(image.coords["y"], np.arange(6, dtype=np.float64))
     assert np.array_equal(image.coords["x"], np.arange(6, dtype=np.float64))
 
+
 def test_5D_default_coords():
     array = np.random.random((3, 4, 6, 6, 5))
     image = si.to_spatial_image(array)
@@ -179,17 +181,17 @@ def test_SpatialImageXCDataClass():
         scale={"x": 2.0},
         translation={"x": 3.5},
         name="img",
-        axis_names={"x": "left-right", "c": "features"},
-        c_coords=["fa", "fb"],
+        axis_names={"x": "left-right", "c": "channel-wavelength"},
+        c_coords=["c1", "c2"],
     )
     assert np.array_equal(image.data, array)
     assert np.array_equal(
         image.coords["x"].data, np.arange(3, dtype=np.float64) * 2.0 + 3.5
     )
-    assert np.array_equal(image.coords["c"].data, ["fa", "fb"])
+    assert np.array_equal(image.coords["c"].data, ["c1", "c2"])
     assert image.name == "img"
     assert image.x.long_name == "left-right"
-    assert image.c.long_name == "features"
+    assert image.c.long_name == "channel-wavelength"
 
     assert si.SpatialImageDataClasses[("x", "c")] is si.SpatialImageXCDataClass
 
@@ -246,22 +248,24 @@ def test_SpatialImageTXCDataClass():
         scale={"x": 2.0},
         translation={"x": 3.5},
         name="img",
-        axis_names={"x": "left-right", "t": "time"},
-        axis_units={"x": "millimeters", "t": "seconds"},
+        axis_names={"x": "left-right", "t": "time", "c": "channel-wavelength"},
+        axis_units={"x": "millimeters", "t": "seconds", "c": "nanometers"},
         t_coords=["ta", "tb", "tc"],
-        c_coords=["fa", "fb", "fc", "fd"],
+        c_coords=["c1", "c2", "c3", "c4"],
     )
     assert np.array_equal(image.data, array)
     assert np.array_equal(
         image.coords["x"].data, np.arange(2, dtype=np.float64) * 2.0 + 3.5
     )
     assert np.array_equal(image.coords["t"].data, ["ta", "tb", "tc"])
-    assert np.array_equal(image.coords["c"].data, ["fa", "fb", "fc", "fd"])
+    assert np.array_equal(image.coords["c"].data, ["c1", "c2", "c3", "c4"])
     assert image.name == "img"
     assert image.x.long_name == "left-right"
     assert image.t.long_name == "time"
+    assert image.c.long_name == "channel-wavelength"
     assert image.x.units == "millimeters"
     assert image.t.units == "seconds"
+    assert image.c.units == "nanometers"
 
     assert si.SpatialImageDataClasses[("t", "x", "c")] is si.SpatialImageTXCDataClass
 
@@ -324,8 +328,12 @@ def test_SpatialImageYXCDataClass():
         scale={"y": 3.4, "x": 2.0},
         translation={"y": 1.2, "x": 3.5},
         name="img",
-        axis_names={"x": "left-right", "y": "anterior-posterior"},
-        axis_units={"x": "millimeters", "y": "micrometers"},
+        axis_names={
+            "x": "left-right",
+            "y": "anterior-posterior",
+            "c": "channel-wavelength",
+        },
+        axis_units={"x": "millimeters", "y": "micrometers", "c": "nanometers"},
         c_coords=[
             40,
         ],
@@ -346,8 +354,10 @@ def test_SpatialImageYXCDataClass():
     assert image.name == "img"
     assert image.x.long_name == "left-right"
     assert image.y.long_name == "anterior-posterior"
+    assert image.c.long_name == "channel-wavelength"
     assert image.x.units == "millimeters"
     assert image.y.units == "micrometers"
+    assert image.c.units == "nanometers"
 
     assert si.SpatialImageDataClasses[("y", "x", "c")] is si.SpatialImageYXCDataClass
 
@@ -427,8 +437,18 @@ def test_SpatialImageTYXCDataClass():
         scale={"y": 3.4, "x": 2.0},
         translation={"y": 1.2, "x": 3.5},
         name="img",
-        axis_names={"x": "left-right", "y": "anterior-posterior"},
-        axis_units={"x": "millimeters", "y": "micrometers"},
+        axis_names={
+            "t": "time",
+            "x": "left-right",
+            "y": "anterior-posterior",
+            "c": "channel-wavelength",
+        },
+        axis_units={
+            "t": "milliseconds",
+            "x": "millimeters",
+            "y": "micrometers",
+            "c": "nanometers",
+        },
         t_coords=[
             20,
             40,
@@ -458,10 +478,14 @@ def test_SpatialImageTYXCDataClass():
         ],
     )
     assert image.name == "img"
+    assert image.t.long_name == "time"
     assert image.x.long_name == "left-right"
     assert image.y.long_name == "anterior-posterior"
+    assert image.c.long_name == "channel-wavelength"
+    assert image.t.units == "milliseconds"
     assert image.x.units == "millimeters"
     assert image.y.units == "micrometers"
+    assert image.c.units == "nanometers"
 
     assert (
         si.SpatialImageDataClasses[("t", "y", "x", "c")] is si.SpatialImageTYXCDataClass
@@ -545,8 +569,14 @@ def test_SpatialImageZYXCDataClass():
             "z": "inferior-superior",
             "x": "left-right",
             "y": "anterior-posterior",
+            "c": "channel-wavelength",
+        },
+        axis_units={
+            "z": "millimeters",
+            "x": "millimeters",
+            "y": "micrometers",
+            "c": "nanometers",
         },
-        axis_units={"z": "millimeters", "x": "millimeters", "y": "micrometers"},
         c_coords=[
             3,
         ],
@@ -571,9 +601,11 @@ def test_SpatialImageZYXCDataClass():
     assert image.x.long_name == "left-right"
     assert image.y.long_name == "anterior-posterior"
     assert image.z.long_name == "inferior-superior"
+    assert image.c.long_name == "channel-wavelength"
     assert image.x.units == "millimeters"
     assert image.y.units == "micrometers"
     assert image.z.units == "millimeters"
+    assert image.c.units == "nanometers"
 
     assert (
         si.SpatialImageDataClasses[("z", "y", "x", "c")] is si.SpatialImageZYXCDataClass
@@ -671,11 +703,19 @@ def test_SpatialImageTZYXCDataClass():
         translation={"z": 0.9, "y": 1.2, "x": 3.5},
         name="img",
         axis_names={
+            "t": "time",
             "z": "inferior-superior",
             "x": "left-right",
             "y": "anterior-posterior",
+            "c": "channel-wavelength",
+        },
+        axis_units={
+            "t": "time",
+            "z": "millimeters",
+            "x": "millimeters",
+            "y": "micrometers",
+            "c": "nanometers",
         },
-        axis_units={"z": "millimeters", "x": "millimeters", "y": "micrometers"},
         t_coords=[
             20,
         ],
@@ -709,9 +749,11 @@ def test_SpatialImageTZYXCDataClass():
     assert image.x.long_name == "left-right"
     assert image.y.long_name == "anterior-posterior"
     assert image.z.long_name == "inferior-superior"
+    assert image.c.long_name == "channel-wavelength"
     assert image.x.units == "millimeters"
     assert image.y.units == "micrometers"
     assert image.z.units == "millimeters"
+    assert image.c.units == "nanometers"
 
     assert (
         si.SpatialImageDataClasses[("t", "z", "y", "x", "c")]
@@ -735,17 +777,20 @@ def test_SpatialImageCXDataClass():
         scale={"x": 2.0},
         translation={"x": 3.5},
         name="img",
-        axis_names={"x": "left-right", "c": "features"},
-        c_coords=["fa", "fb"],
+        axis_names={"c": "channel-wavelength", "x": "left-right"},
+        axis_units={"c": "nanometers", "x": "micrometers"},
+        c_coords=["c1", "c2"],
     )
     assert np.array_equal(image.data, array)
     assert np.array_equal(
         image.coords["x"].data, np.arange(3, dtype=np.float64) * 2.0 + 3.5
     )
-    assert np.array_equal(image.coords["c"].data, ["fa", "fb"])
+    assert np.array_equal(image.coords["c"].data, ["c1", "c2"])
     assert image.name == "img"
     assert image.x.long_name == "left-right"
-    assert image.c.long_name == "features"
+    assert image.c.long_name == "channel-wavelength"
+    assert image.x.units == "micrometers"
+    assert image.c.units == "nanometers"
 
     assert si.SpatialImageDataClasses[("c", "x")] is si.SpatialImageCXDataClass
 
@@ -771,20 +816,22 @@ def test_SpatialImageTCXDataClass():
         scale={"x": 2.0},
         translation={"x": 3.5},
         name="img",
-        axis_names={"x": "left-right", "t": "time"},
-        axis_units={"x": "millimeters", "t": "seconds"},
+        axis_names={"t": "time", "c": "channel-wavelength", "x": "left-right"},
+        axis_units={"t": "seconds", "c": "nanometers", "x": "millimeters"},
         t_coords=["ta", "tb", "tc"],
-        c_coords=["fa", "fb", "fc", "fd"],
+        c_coords=["c1", "c2", "c3", "c4"],
     )
     assert np.array_equal(image.data, array)
     assert np.array_equal(
         image.coords["x"].data, np.arange(2, dtype=np.float64) * 2.0 + 3.5
     )
     assert np.array_equal(image.coords["t"].data, ["ta", "tb", "tc"])
-    assert np.array_equal(image.coords["c"].data, ["fa", "fb", "fc", "fd"])
+    assert np.array_equal(image.coords["c"].data, ["c1", "c2", "c3", "c4"])
     assert image.name == "img"
+    assert image.t.long_name == "time"
     assert image.x.long_name == "left-right"
     assert image.t.long_name == "time"
+    assert image.t.units == "seconds"
     assert image.x.units == "millimeters"
     assert image.t.units == "seconds"
 
@@ -812,8 +859,12 @@ def test_SpatialImageCYXDataClass():
         scale={"y": 3.4, "x": 2.0},
         translation={"y": 1.2, "x": 3.5},
         name="img",
-        axis_names={"x": "left-right", "y": "anterior-posterior"},
-        axis_units={"x": "millimeters", "y": "micrometers"},
+        axis_names={
+            "c": "channel-wavelength",
+            "x": "left-right",
+            "y": "anterior-posterior",
+        },
+        axis_units={"c": "nanometers", "x": "millimeters", "y": "micrometers"},
         c_coords=[
             40,
         ],
@@ -832,10 +883,12 @@ def test_SpatialImageCYXDataClass():
         ],
     )
     assert image.name == "img"
+    assert image.c.long_name == "channel-wavelength"
     assert image.x.long_name == "left-right"
     assert image.y.long_name == "anterior-posterior"
     assert image.x.units == "millimeters"
     assert image.y.units == "micrometers"
+    assert image.c.units == "nanometers"
 
     assert si.SpatialImageDataClasses[("c", "y", "x")] is si.SpatialImageCYXDataClass
 
@@ -864,8 +917,18 @@ def test_SpatialImageTCYXDataClass():
         scale={"y": 3.4, "x": 2.0},
         translation={"y": 1.2, "x": 3.5},
         name="img",
-        axis_names={"x": "left-right", "y": "anterior-posterior"},
-        axis_units={"x": "millimeters", "y": "micrometers"},
+        axis_names={
+            "t": "time",
+            "c": "channel-wavelength",
+            "x": "left-right",
+            "y": "anterior-posterior",
+        },
+        axis_units={
+            "t": "seconds",
+            "c": "nanometers",
+            "x": "millimeters",
+            "y": "micrometers",
+        },
         t_coords=[
             20,
             40,
@@ -895,8 +958,12 @@ def test_SpatialImageTCYXDataClass():
         ],
     )
     assert image.name == "img"
+    assert image.t.long_name == "time"
+    assert image.c.long_name == "channel-wavelength"
     assert image.x.long_name == "left-right"
     assert image.y.long_name == "anterior-posterior"
+    assert image.t.units == "seconds"
+    assert image.c.units == "nanometers"
     assert image.x.units == "millimeters"
     assert image.y.units == "micrometers"
 
@@ -905,6 +972,77 @@ def test_SpatialImageTCYXDataClass():
     )
 
 
+def test_SpatialImageCZYXDataClass():
+    array = np.random.random((1, 2, 3, 2))
+
+    image = si.SpatialImageCZYXDataClass.new(array)
+    assert np.array_equal(image.data, array)
+    assert np.array_equal(image.coords["c"].data, np.arange(1))
+    assert np.array_equal(image.coords["z"].data, np.arange(2, dtype=np.float64))
+    assert np.array_equal(image.coords["y"].data, np.arange(3, dtype=np.float64))
+    assert np.array_equal(image.coords["x"].data, np.arange(2, dtype=np.float64))
+    assert image.name == "image"
+    assert image.x.long_name == "x"
+    assert image.y.long_name == "y"
+    assert image.z.long_name == "z"
+    assert image.c.long_name == "c"
+    assert image.x.units == ""
+    assert image.y.units == ""
+    assert image.z.units == ""
+    assert image.c.units == ""
+
+    image = si.SpatialImageCZYXDataClass.new(
+        array,
+        scale={"z": 1.8, "y": 3.4, "x": 2.0},
+        translation={"z": 0.9, "y": 1.2, "x": 3.5},
+        name="img",
+        axis_names={
+            "c": "channel-wavelength",
+            "z": "inferior-superior",
+            "x": "left-right",
+            "y": "anterior-posterior",
+        },
+        axis_units={
+            "c": "nanometers",
+            "z": "millimeters",
+            "x": "millimeters",
+            "y": "micrometers",
+        },
+        c_coords=[
+            4,
+        ],
+    )
+    assert np.array_equal(image.data, array)
+    assert np.array_equal(
+        image.coords["z"].data, np.arange(2, dtype=np.float64) * 1.8 + 0.9
+    )
+    assert np.array_equal(
+        image.coords["y"].data, np.arange(3, dtype=np.float64) * 3.4 + 1.2
+    )
+    assert np.array_equal(
+        image.coords["x"].data, np.arange(2, dtype=np.float64) * 2.0 + 3.5
+    )
+    assert np.array_equal(
+        image.coords["c"].data,
+        [
+            4,
+        ],
+    )
+    assert image.name == "img"
+    assert image.c.long_name == "channel-wavelength"
+    assert image.x.long_name == "left-right"
+    assert image.y.long_name == "anterior-posterior"
+    assert image.z.long_name == "inferior-superior"
+    assert image.c.units == "nanometers"
+    assert image.x.units == "millimeters"
+    assert image.y.units == "micrometers"
+    assert image.z.units == "millimeters"
+
+    assert (
+        si.SpatialImageDataClasses[("c", "z", "y", "x")] is si.SpatialImageCZYXDataClass
+    )
+
+
 def test_SpatialImageTCZYXDataClass():
     array = np.random.random((1, 1, 2, 3, 2))
 
@@ -933,11 +1071,19 @@ def test_SpatialImageTCZYXDataClass():
         translation={"z": 0.9, "y": 1.2, "x": 3.5},
         name="img",
         axis_names={
+            "t": "time",
+            "c": "channel-wavelength",
             "z": "inferior-superior",
             "x": "left-right",
             "y": "anterior-posterior",
         },
-        axis_units={"z": "millimeters", "x": "millimeters", "y": "micrometers"},
+        axis_units={
+            "t": "seconds",
+            "c": "nanometers",
+            "z": "millimeters",
+            "x": "millimeters",
+            "y": "micrometers",
+        },
         t_coords=[
             20,
         ],
@@ -968,9 +1114,13 @@ def test_SpatialImageTCZYXDataClass():
         ],
     )
     assert image.name == "img"
+    assert image.t.long_name == "time"
+    assert image.c.long_name == "channel-wavelength"
     assert image.x.long_name == "left-right"
     assert image.y.long_name == "anterior-posterior"
     assert image.z.long_name == "inferior-superior"
+    assert image.t.units == "seconds"
+    assert image.c.units == "nanometers"
     assert image.x.units == "millimeters"
     assert image.y.units == "micrometers"
     assert image.z.units == "millimeters"