Repository: open-mmlab/mmengine

Issue:
Proposal for Aim Visualizer Backend
### What is the feature?

Aim is a great experiment tracker. 

Currently no visualizer implemented for it. 

Not thinking the below is complete (I use the datetime to create the experiment name), but may be useful for other users. Happy to make changes to actually get it merged in. 

```
import aim
import datetime

@VISBACKENDS.register_module()
class AimVisBackend(BaseVisBackend):
    """
    Based on https://github.com/open-mmlab/mmengine/blob/main/mmengine/visualization/vis_backend.py 
    """

    def __init__(
            self,
            save_dir: str,  
        ):
        super().__init__(save_dir)
        self._init_env()

        
    def _init_env(self):
        """Setup env for MLflow."""

        try:
            import aim
        except ImportError:
            raise ImportError(
                'Please run "pip install aim" to install aim'
            )  # type: ignore
            
        clean_datetime = str(datetime.now()).replace(' ','_').replace(':','-')
        self._aim_run = aim.Run(
            experiment = clean_datetime,
        )
        
    @property  # type: ignore
    @force_init_env
    def experiment(self):
        """Return MLflow object."""
        return self._aim_run
    
    @force_init_env
    def add_config(self, config, **kwargs) -> None:
        """Record the config to mlflow.
        Args:
            config (Config): The Config object
        """
        # works perfectly
        self._aim_run['hparams'] = dict(config)
        
        # looks a bit clumsy in Aim UI
        aim_text = aim.Text(str(config))
        self._aim_run.track(aim_text, name='Config', step=0)
        
    @force_init_env
    def add_scalar(self,
                   name: str,
                   value: Union[int, float, torch.Tensor, np.ndarray],
                   step: int = 0,
                   **kwargs) -> None:
        """Record the scalar data to mlflow.
        Args:
            name (str): The scalar identifier.
            value (int, float, torch.Tensor, np.ndarray): Value to save.
            step (int): Global step value to record. Default to 0.
        """
        self._aim_run.track(name=name, value=value, step=step)
        
        
    @force_init_env
    def add_scalars(self,
                    scalar_dict: dict,
                    step: int = 0,
                    file_path: Optional[str] = None,
                    **kwargs) -> None:
        """Record the scalar's data to wandb.
        Args:
            scalar_dict (dict): Key-value pair storing the tag and
                corresponding values.
            step (int): Useless parameter. Wandb does not
                need this parameter. Defaults to 0.
            file_path (str, optional): Useless parameter. Just for
                interface unification. Defaults to None.
        """
        for k in scalar_dict:
            self._aim_run.track(name=k, value=scalar_dict[k], step=step)
```

### Any other context?

The use of 'save_dir' as the kwarg fed through to the VisualizerBackend seems a little odd, 
feels like it comes from the Local Visualizer but has no analogy for most other forms, e.g. MLFLow, Neptune, W and B. 

```
TypeError: class `DetLocalVisualizer` in mmdet/visualization/local_visualizer.py: class `AimVisBackend` in viz_backend.py: __init__() got an unexpected keyword argument 'save_dir'
```

Surely a better core would be experiment_name or experiment_id? 


Discussion:
Thank you very much for your suggestion, and I am really looking forward to your submission of a PR to implement this feature :laughing: !

As far as I know, MLFlow and W&B do indeed utilize this parameter, although it's true that it is not essential for some visualization backends. Regardless, it is recommended to maintain the `save_dir` for now. The **experiment_name** parameter is indeed more common, and I suggest adding it to each Visbackend, which will also make it convenient for the Runner to pass in.

Resolving patch:
diff --git a/mmengine/visualization/__init__.py b/mmengine/visualization/__init__.py
--- a/mmengine/visualization/__init__.py
+++ b/mmengine/visualization/__init__.py
@@ -1,11 +1,12 @@
 # Copyright (c) OpenMMLab. All rights reserved.
-from .vis_backend import (BaseVisBackend, ClearMLVisBackend, DVCLiveVisBackend,
-                          LocalVisBackend, MLflowVisBackend, NeptuneVisBackend,
-                          TensorboardVisBackend, WandbVisBackend)
+from .vis_backend import (AimVisBackend, BaseVisBackend, ClearMLVisBackend,
+                          DVCLiveVisBackend, LocalVisBackend, MLflowVisBackend,
+                          NeptuneVisBackend, TensorboardVisBackend,
+                          WandbVisBackend)
 from .visualizer import Visualizer
 
 __all__ = [
     'Visualizer', 'BaseVisBackend', 'LocalVisBackend', 'WandbVisBackend',
     'TensorboardVisBackend', 'MLflowVisBackend', 'ClearMLVisBackend',
-    'NeptuneVisBackend', 'DVCLiveVisBackend'
+    'NeptuneVisBackend', 'DVCLiveVisBackend', 'AimVisBackend'
 ]
diff --git a/mmengine/visualization/vis_backend.py b/mmengine/visualization/vis_backend.py
--- a/mmengine/visualization/vis_backend.py
+++ b/mmengine/visualization/vis_backend.py
@@ -1309,3 +1309,133 @@ def _to_dvc_paramlike(self,
             return value.item()
         else:
             return value
+
+
+@VISBACKENDS.register_module()
+class AimVisBackend(BaseVisBackend):
+    """Aim visualization backend class.
+
+    Examples:
+        >>> from mmengine.visualization import AimVisBackend
+        >>> import numpy as np
+        >>> aim_vis_backend = AimVisBackend()
+        >>> img=np.random.randint(0, 256, size=(10, 10, 3))
+        >>> aim_vis_backend.add_image('img', img)
+        >>> aim_vis_backend.add_scalar('mAP', 0.6)
+        >>> aim_vis_backend.add_scalars({'loss': 0.1, 'acc': 0.8})
+        >>> cfg = Config(dict(a=1, b=dict(b1=[0, 1])))
+        >>> aim_vis_backend.add_config(cfg)
+
+    Note:
+        1. `New in version 0.8.5.`
+        2. Refer to
+           `Github issue <https://github.com/aimhubio/aim/issues/2064>`_ ,
+           Aim is not unable to be install on Windows for now.
+
+    Args:
+        save_dir (str, optional): The root directory to save the files
+            produced by the visualizer.
+        init_kwargs (dict, optional): Aim initialization parameters. See
+            `Aim <https://aimstack.readthedocs.io/en/latest/refs/sdk.html>`_
+            for details. Defaults to None.
+    """
+
+    def __init__(self,
+                 save_dir: Optional[str] = None,
+                 init_kwargs: Optional[dict] = None):
+        super().__init__(save_dir)  # type:ignore
+        self._init_kwargs = init_kwargs
+
+    def _init_env(self):
+        """Setup env for Aim."""
+        try:
+            from aim import Run
+        except ImportError:
+            raise ImportError('Please run "pip install aim" to install aim')
+
+        from datetime import datetime
+
+        if self._save_dir is not None:
+            path_list = os.path.normpath(self._save_dir).split(os.sep)
+            exp_name = f'{path_list[-2]}_{path_list[-1]}'
+        else:
+            exp_name = datetime.now().strftime('%Y%m%d_%H%M%S')
+
+        if self._init_kwargs is None:
+            self._init_kwargs = {}
+        self._init_kwargs.setdefault('experiment', exp_name)
+        self._aim_run = Run(**self._init_kwargs)
+
+    @property  # type: ignore
+    @force_init_env
+    def experiment(self):
+        """Return Aim object."""
+        return self._aim_run
+
+    @force_init_env
+    def add_config(self, config, **kwargs) -> None:
+        """Record the config to Aim.
+
+        Args:
+            config (Config): The Config object
+        """
+        if isinstance(config, Config):
+            config = config.to_dict()
+        self._aim_run['hparams'] = config
+
+    @force_init_env
+    def add_image(self,
+                  name: str,
+                  image: np.ndarray,
+                  step: int = 0,
+                  **kwargs) -> None:
+        """Record the image.
+
+        Args:
+            name (str): The image identifier.
+            image (np.ndarray): The image to be saved. The format
+                should be RGB. Defaults to None.
+            step (int): Global step value to record. Defaults to 0.
+        """
+        from aim import Image
+        self._aim_run.track(name=name, value=Image(image), step=step)
+
+    @force_init_env
+    def add_scalar(self,
+                   name: str,
+                   value: Union[int, float, torch.Tensor, np.ndarray],
+                   step: int = 0,
+                   **kwargs) -> None:
+        """Record the scalar data to Aim.
+
+        Args:
+            name (str): The scalar identifier.
+            value (int, float, torch.Tensor, np.ndarray): Value to save.
+            step (int): Global step value to record. Default to 0.
+        """
+        self._aim_run.track(name=name, value=value, step=step)
+
+    @force_init_env
+    def add_scalars(self,
+                    scalar_dict: dict,
+                    step: int = 0,
+                    file_path: Optional[str] = None,
+                    **kwargs) -> None:
+        """Record the scalar's data to wandb.
+
+        Args:
+            scalar_dict (dict): Key-value pair storing the tag and
+                corresponding values.
+            step (int): Global step value to record. Default to 0.
+            file_path (str, optional): Useless parameter. Just for
+                interface unification. Defaults to None.
+        """
+        for key, value in scalar_dict.items():
+            self._aim_run.track(name=key, value=value, step=step)
+
+    def close(self) -> None:
+        """Close the Aim."""
+        if not hasattr(self, '_aim_run'):
+            return
+
+        self._aim_run.close()


Test patch:
diff --git a/requirements/tests.txt b/requirements/tests.txt
--- a/requirements/tests.txt
+++ b/requirements/tests.txt
@@ -1,3 +1,4 @@
+aim
 clearml
 coverage
 dadaptation
diff --git a/tests/test_visualizer/test_vis_backend.py b/tests/test_visualizer/test_vis_backend.py
--- a/tests/test_visualizer/test_vis_backend.py
+++ b/tests/test_visualizer/test_vis_backend.py
@@ -14,10 +14,10 @@
 from mmengine.fileio import load
 from mmengine.registry import VISBACKENDS
 from mmengine.utils import digit_version
-from mmengine.visualization import (ClearMLVisBackend, DVCLiveVisBackend,
-                                    LocalVisBackend, MLflowVisBackend,
-                                    NeptuneVisBackend, TensorboardVisBackend,
-                                    WandbVisBackend)
+from mmengine.visualization import (AimVisBackend, ClearMLVisBackend,
+                                    DVCLiveVisBackend, LocalVisBackend,
+                                    MLflowVisBackend, NeptuneVisBackend,
+                                    TensorboardVisBackend, WandbVisBackend)
 
 
 class TestLocalVisBackend:
@@ -445,3 +445,44 @@ def test_close(self):
         dvclive_vis_backend.add_config(cfg)
         dvclive_vis_backend.close()
         shutil.rmtree('temp_dir')
+
+
+@pytest.mark.skipif(
+    platform.system() == 'Windows',
+    reason='Aim does not support Windows for now.')
+class TestAimVisBackend:
+
+    def test_init(self):
+        AimVisBackend()
+        VISBACKENDS.build(dict(type='AimVisBackend'))
+
+    def test_experiment(self):
+        aim_vis_backend = AimVisBackend()
+        assert aim_vis_backend.experiment == aim_vis_backend._aim_run
+
+    def test_add_config(self):
+        cfg = Config(dict(a=1, b=dict(b1=[0, 1])))
+        aim_vis_backend = AimVisBackend()
+        aim_vis_backend.add_config(cfg)
+
+    def test_add_image(self):
+        image = np.random.randint(0, 256, size=(10, 10, 3)).astype(np.uint8)
+        aim_vis_backend = AimVisBackend()
+        aim_vis_backend.add_image('img', image)
+        aim_vis_backend.add_image('img', image, step=1)
+
+    def test_add_scalar(self):
+        aim_vis_backend = AimVisBackend()
+        aim_vis_backend.add_scalar('map', 0.9)
+        aim_vis_backend.add_scalar('map', 0.9, step=1)
+        aim_vis_backend.add_scalar('map', 0.95, step=2)
+
+    def test_add_scalars(self):
+        aim_vis_backend = AimVisBackend()
+        input_dict = {'map': 0.7, 'acc': 0.9}
+        aim_vis_backend.add_scalars(input_dict)
+
+    def test_close(self):
+        aim_vis_backend = AimVisBackend()
+        aim_vis_backend._init_env()
+        aim_vis_backend.close()