Repository: napari/napari

Issue:
ValueError assigning to `.affine` if image is tczyx with singleton tcz dimensions + TypeError in EventedList
## 🐛 Bug

This is potentially  a bug or two bugs. I want to put this issue in before I'll have to finish for the day, I don't have a minimum reproducible example yet. 

Part 1:

The first part of this might be related to #2973. Therefore I pulled the current master to make sure this isn't already fixed by  PR #2990. 
I have what is essentially a 2D image pyramid, as .ome.zarr in a single image layer. Due to the OME ZARR dimension convention this essentially 2D image has three singleton dimensions resulting in a  shape `(1, 1, 1, 69728, 27353)` .  

I have calculated a transformation (in 2D) that I want to apply by assigning to the layers `.affine` property. My transformation matrix `tr.params` has shape `(3,3)` as it is essentially a 2D operation in the yx dimensions.

```
imlayer.affine = imlayer.affine.affine_matrix @ np.linalg.inv(tr.params)
```

This gives a ValuError:
```
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-30-9474d02db3c4> in <module>
      1 imlayer = v.layers['whole_slide']
----> 2 imlayer.affine = imlayer.affine.affine_matrix @ np.linalg.inv(tr.params)
      3 

ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 3 is different from 6)
```

It is debatable whether this is a bug or whether the affine matrix dimensions should be matching for the overall dimensions (even if they are singleton). I'd hazard a guess that no-one would like to apply an affine over channels though.

Part 2:

I decided to press ahead regardless and look at the affine. So the layer has a 6x6 identiy matrix:
![image](https://user-images.githubusercontent.com/2210044/125666173-e3f44c92-878d-461a-9fa2-9c863336d566.png)

I augmented my 3x3 matrix to be compatible:

![image](https://user-images.githubusercontent.com/2210044/125666317-68a7454a-b304-4ba8-9f2d-9a683efd6266.png)

and proceeded to apply and assign this transform:

![image](https://user-images.githubusercontent.com/2210044/125666421-6b22e22d-b0d2-4660-acd0-e63552476515.png)

getting the above `TypeError` in the evented list. I  am not sure whether this is related to the fact that 6x6 affine matrices are not making a lot of sense, but I don't think a string should appear instead of an integer regardless of shape. So something fishy seems to be going on in the affine setter.



## To Reproduce

I  have't created a minimal reproducible example yet.

What I do  reproduce the behavior:

Part 1:
1. Load a 5D - OME.ZARR pyramid
2. Assign a 3x3 matrix to the layers `.affine`

Part2:
1. Load a 5D - OME.ZARR pyramid
2. assign a 6x6 matrix to the layers `.affine`



## Expected behavior

In the first instance I would have hoped the singleton dimensions are not a problem and would be squeezed away.

That not being the case I would have hoped that augmenting the number of dimensions would help. 


## Environment
napari: 0.4.11.dev43+g49a29395
Platform: Linux-5.8.0-59-generic-x86_64-with-glibc2.31
System: Ubuntu 20.04.2 LTS
Python: 3.9.5 (default, Jun 4 2021, 12:28:51) [GCC 7.5.0]
Qt: 5.15.2
PyQt5: 5.15.4
NumPy: 1.21.0
SciPy: 1.7.0
Dask: 2021.06.2
VisPy: 0.6.6

OpenGL:
- GL version: 4.6.0 NVIDIA 460.80
- MAX_TEXTURE_SIZE: 32768

Screens:
- screen 1: resolution 3840x2160, scale 1.0

Plugins:
- console: 0.0.3
- dzi-zarr: 0.1.2
- napari-ome-zarr: 0.1.2.dev1+g0b19835
- scikit-image: 0.4.11.dev43+g49a29395
- svg: 0.1.5
- zarpaint: 0.1.0




Discussion:
While trying to create a minimal reproducable example I find that if I use a regular 5D array (rather than reading an .ome.zarr pyramid) I get the TypeError directly:

```
v.add_image(np.ones((1,1,1,100,100)), name="5D")
imlayer=v.layers["5D"]
imlayer.affine = np.eye(3)
```
results directly  in 
```
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-39-8ab21e88f879> in <module>
----> 1 imlayer.affine = np.eye(3)

~/miniconda3/envs/napari_latest/lib/python3.9/site-packages/napari/layers/base/base.py in affine(self, affine)
    447     @affine.setter
    448     def affine(self, affine):
--> 449         self._transforms['physical2world'] = coerce_affine(
    450             affine, self.ndim, name='physical2world'
    451         )

~/miniconda3/envs/napari_latest/lib/python3.9/site-packages/napari/utils/events/containers/_evented_list.py in __setitem__(self, key, value)
    109 
    110     def __setitem__(self, key, value):
--> 111         old = self._list[key]
    112         if value is old:  # https://github.com/napari/napari/pull/2120
    113             return

TypeError: list indices must be integers or slices, not str
```
@VolkerH I think both are bugs and need to be fixed before the next release. The second one relates to `_transforms` which is a funky sequence class that ~~allows~~ *should allow* indexing with both integer position and name. It is the same as the layer list which lets you grab layers by either position or by name, as you know. So that should definitely work, not sure whether what's happening is that that key ('physical2world') is missing for some reason.

The first issue is a bit more debatable but since we broadcast on scale, translation, etc, we should also broadcast on affine. So :+1: on fixing.
Either way though, I suspect in this line:

```python
imlayer.affine = imlayer.affine.affine_matrix @ np.linalg.inv(tr.params)
```

you might have to make sure you limit the dimensionality of the affine_matrix:

```python
ndim1 = tr.params.shape[0]
imlayer.affine = imlayer.affine.affine_matrix[-ndim1:, -ndim1:] @ np.linalg.inv(tr.params)
```

in case we coerce/broadcast to the full dimensionality at assignment time. I'm not sure yet whether that is what we do or whether that is where we might go.
Hmmm... but assuming that some dimensions are channels, what does an affine across channels even mean ?
For timepoints I can see that one might be wanting to translate in time, but if a general affine is possible one can wildly interpolate between spatial, temporal and spectral dimensions, which is conceptually too challenging for my little brain. (I didn't take general relativity courses)
@VolkerH Ideally, channels would be represented as layers, rather than as a dimension. In a sense, channels appearing as a dimension within a single layer is a failure of metadata handling rather than a "happy" state. Otherwise, perhaps we will want to track "channel" axes in napari as special. But that is a longer-term goal.
The assignment issue was broken by [PR 2855](https://github.com/napari/napari/pull/2855/files#diff-ae7753430cbb6fe9e6dc532f409eed7d2ee7fe362347f8309cbdc4e349bd6063L462). It seems like reading from the `TransformChain` by transform name/key (and modifying the transform associated with it) works , but assigning to it does not (i.e. `transforms['physical2world'].affine_matrix = ...` works, but `transforms['physical2world'] = ...` does not). That seems to be the same for `LayerList`, so this need fixing or clarifying at the `EventedList` level.

@VolkerH: I think I have something small and suitable that should make your use case work again, and will hopefully write the PR shortly.
Also, agree with @jni on some of the longer term goals here around distinguishing channels and dimensions, which should hopefully help prevent these kinds of problems in the future and makes thing a little clearer.

Resolving patch:
diff --git a/napari/layers/base/base.py b/napari/layers/base/base.py
--- a/napari/layers/base/base.py
+++ b/napari/layers/base/base.py
@@ -229,7 +229,7 @@ def __init__(
                     ndim=ndim,
                     name='data2physical',
                 ),
-                coerce_affine(affine, ndim, name='physical2world'),
+                coerce_affine(affine, ndim=ndim, name='physical2world'),
                 Affine(np.ones(ndim), np.zeros(ndim), name='world2grid'),
             ]
         )
@@ -446,8 +446,11 @@ def affine(self):
 
     @affine.setter
     def affine(self, affine):
-        self._transforms['physical2world'] = coerce_affine(
-            affine, self.ndim, name='physical2world'
+        # Assignment by transform name is not supported by TransformChain and
+        # EventedList, so use the integer index instead. For more details, see:
+        # https://github.com/napari/napari/issues/3058
+        self._transforms[2] = coerce_affine(
+            affine, ndim=self.ndim, name='physical2world'
         )
         self._update_dims()
         self.events.affine()
diff --git a/napari/layers/utils/layer_utils.py b/napari/layers/utils/layer_utils.py
--- a/napari/layers/utils/layer_utils.py
+++ b/napari/layers/utils/layer_utils.py
@@ -461,19 +461,19 @@ def compute_multiscale_level_and_corners(
     return level, corners
 
 
-def coerce_affine(affine, ndim, name=None):
+def coerce_affine(affine, *, ndim, name=None):
     """Coerce a user input into an affine transform object.
 
     If the input is already an affine transform object, that same object is returned
     with a name change if the given name is not None. If the input is None, an identity
     affine transform object of the given dimensionality is returned.
 
-     Parameters
+    Parameters
     ----------
     affine : array-like or napari.utils.transforms.Affine
         An existing affine transform object or an array-like that is its transform matrix.
     ndim : int
-        The desired dimensionality of the transform. Ignored if implied by affine.
+        The desired dimensionality of the transform. Ignored is affine is an Affine transform object.
     name : str
         The desired name of the transform.
 
@@ -483,11 +483,11 @@ def coerce_affine(affine, ndim, name=None):
         The input coerced into an affine transform object.
     """
     if affine is None:
-        affine = Affine(affine_matrix=np.eye(ndim + 1))
+        affine = Affine(affine_matrix=np.eye(ndim + 1), ndim=ndim)
     elif isinstance(affine, np.ndarray):
-        affine = Affine(affine_matrix=affine)
+        affine = Affine(affine_matrix=affine, ndim=ndim)
     elif isinstance(affine, list):
-        affine = Affine(affine_matrix=np.array(affine))
+        affine = Affine(affine_matrix=np.array(affine), ndim=ndim)
     elif not isinstance(affine, Affine):
         raise TypeError(
             trans._(


Test patch:
diff --git a/napari/layers/image/_tests/test_image.py b/napari/layers/image/_tests/test_image.py
--- a/napari/layers/image/_tests/test_image.py
+++ b/napari/layers/image/_tests/test_image.py
@@ -670,3 +670,20 @@ def test_2d_image_with_channels_and_2d_scale_translate_then_scale_translate_padd
 
     np.testing.assert_array_equal(image.scale, (1, 1, 1))
     np.testing.assert_array_equal(image.translate, (0, 3, 4))
+
+
+@pytest.mark.parametrize('affine_size', range(3, 6))
+def test_2d_image_with_channels_and_affine_broadcasts(affine_size):
+    # For more details, see the GitHub issue:
+    # https://github.com/napari/napari/issues/3045
+    image = Image(np.ones((1, 1, 1, 100, 100)), affine=np.eye(affine_size))
+    np.testing.assert_array_equal(image.affine, np.eye(6))
+
+
+@pytest.mark.parametrize('affine_size', range(3, 6))
+def test_2d_image_with_channels_and_affine_assignment_broadcasts(affine_size):
+    # For more details, see the GitHub issue:
+    # https://github.com/napari/napari/issues/3045
+    image = Image(np.ones((1, 1, 1, 100, 100)))
+    image.affine = np.eye(affine_size)
+    np.testing.assert_array_equal(image.affine, np.eye(6))