Repository: fepegar/torchio

Issue:
Cannot copy subclass of Subject with keyword arguments 
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Bug summary

A subclass of Subject with keyword arguments cannot be copied with `copy.copy()` which is used in transform.py. Raises `TypeError: A subject without images cannot be created`.

### Code for reproduction

```python
import copy
import torchio as tio


class Subject(tio.Subject):

    def __init__(self, name: str, **kwargs):
        kwargs['name'] = name
        super().__init__(**kwargs)


colin = tio.datasets.Colin27()
subject = Subject(name='colin', **colin)
copy.copy(subject)
```


### Actual outcome

Raises `TypeError: A subject without images cannot be created`.

The subclass cannot be copied as `_subject_copy_helper` in subject.py calls `new = new_subj_cls(result_dict)`.

In the above example, `result_dict = {'name:'colin', 'brain': ...}` which means that `name=result_dict` on subclass recreation. This is the only parameter the subclass can see and hence it contains no images.

This can be fixed by changing `_subject_copy_helper`, to unpack `result_dict` as below, although I'm unsure if this is the best way of implementing the fix.

```python
import copy
import torchio as tio
from typing import Any
from typing import Callable
from typing import Dict


class Subject(tio.Subject):

    def __init__(self, name: str, **kwargs):
        kwargs['name'] = name
        super().__init__(**kwargs)

    def __copy__(self):
        return _subject_copy_helper(self, type(self))


def _subject_copy_helper(
    old_obj: Subject,
    new_subj_cls: Callable[[Dict[str, Any]], Subject],
):
    result_dict = {}
    for key, value in old_obj.items():
        if isinstance(value, tio.Image):
            value = copy.copy(value)
        else:
            value = copy.deepcopy(value)
        result_dict[key] = value

    new = new_subj_cls(**result_dict)
    new.applied_transforms = old_obj.applied_transforms[:]
    return new


colin = tio.datasets.Colin27()
subject = Subject(name='colin', **colin)
copy.copy(subject)
```

### Error messages

```python-traceback
Traceback (most recent call last):
  File "D:\Programs\Anaconda\envs\body-comp\lib\site-packages\IPython\core\interactiveshell.py", line 3526, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-2-b4528fe5d1e0>", line 1, in <module>
    runfile('F:\\body-comp\\Playground\\test3.py', wdir='F:\\body-comp\\Playground')
  File "D:\Programs\PyCharm 2022.2\plugins\python\helpers\pydev\_pydev_bundle\pydev_umd.py", line 197, in runfile
    pydev_imports.execfile(filename, global_vars, local_vars)  # execute the script
  File "D:\Programs\PyCharm 2022.2\plugins\python\helpers\pydev\_pydev_imps\_pydev_execfile.py", line 18, in execfile
    exec(compile(contents+"\n", file, 'exec'), glob, loc)
  File "F:\body-comp\Playground\test3.py", line 17, in <module>
    copy.copy(broken_subject)
  File "D:\Programs\Anaconda\envs\body-comp\lib\copy.py", line 84, in copy
    return copier(x)
  File "D:\Programs\Anaconda\envs\body-comp\lib\site-packages\torchio\data\subject.py", line 75, in __copy__
    return _subject_copy_helper(self, type(self))
  File "D:\Programs\Anaconda\envs\body-comp\lib\site-packages\torchio\data\subject.py", line 445, in _subject_copy_helper
    new = new_subj_cls(result_dict)
  File "F:\body-comp\Playground\test3.py", line 11, in __init__
    super().__init__(**kwargs)
  File "D:\Programs\Anaconda\envs\body-comp\lib\site-packages\torchio\data\subject.py", line 62, in __init__
    self._parse_images(self.get_images(intensity_only=False))
  File "D:\Programs\Anaconda\envs\body-comp\lib\site-packages\torchio\data\subject.py", line 101, in _parse_images
    raise TypeError('A subject without images cannot be created')
TypeError: A subject without images cannot be created
```


### Expected outcome

Subclass should copy correctly.

### System info

```shell
Platform:   Windows-10-10.0.19045-SP0
TorchIO:    0.19.7
PyTorch:    2.0.1
SimpleITK:  2.3.1 (ITK 5.3)
NumPy:      1.26.3
Python:     3.10.13 | packaged by Anaconda, Inc. | (main, Sep 11 2023, 13:24:38) [MSC v.1916 64 bit (AMD64)]
```

Hints from issue comments:
@fepegar: Hi, @c-winder. Thanks for reporting. Would you be willing to contribute with a PR? We can start by adding your snippet in a unit test, and your fix would make the new test pass.@c-winder: Sorry for the delay @fepegar, I've raised a PR which fixes the bug and extends test_copy_subclass.

Solution patch:
diff --git a/src/torchio/data/subject.py b/src/torchio/data/subject.py
index ccffc6fe..508dd222 100644
--- a/src/torchio/data/subject.py
+++ b/src/torchio/data/subject.py
@@ -442,7 +442,7 @@ def _subject_copy_helper(
             value = copy.deepcopy(value)
         result_dict[key] = value
 
-    new = new_subj_cls(result_dict)
+    new = new_subj_cls(**result_dict)
     new.applied_transforms = old_obj.applied_transforms[:]
     return new
 


Test patch:
diff --git a/tests/data/test_subject.py b/tests/data/test_subject.py
index 1824e126..e94fbb98 100644
--- a/tests/data/test_subject.py
+++ b/tests/data/test_subject.py
@@ -159,9 +159,13 @@ def test_copy_subject(self):
 
     def test_copy_subclass(self):
         class DummySubjectSubClass(tio.data.Subject):
-            pass
+            def __init__(self, **kwargs):
+                super().__init__(**kwargs)
 
-        dummy_sub = DummySubjectSubClass(self.sample_subject)
+        dummy_sub = DummySubjectSubClass(
+            attr_1='abcd',
+            attr_2=tio.ScalarImage(tensor=torch.zeros(1, 1, 1, 1)),
+        )
         sub_copy = copy.copy(dummy_sub)
         assert isinstance(sub_copy, tio.data.Subject)
         assert isinstance(sub_copy, DummySubjectSubClass)