Repository: scalableminds/webknossos-libs

Issue:
Add struct for view configuration
It would be great if there was a typed model for the view-configuration of a layer. 

```python
ds = Dataset("...")
l = ds.layers["color"]
l.set_view_configuration(ViewConfiguration(color=(255,255,0), alpha=1.0, intensity_range=(-12.3e+1,123), is_inverted=True))
assert l.get_view_configuration().color == (255, 255, 0)
```



Discussion:

Resolving patch:
diff --git a/wkcuber/api/dataset.py b/wkcuber/api/dataset.py
--- a/wkcuber/api/dataset.py
+++ b/wkcuber/api/dataset.py
@@ -19,7 +19,7 @@
     LayerProperties,
 )
 from wkcuber.api.bounding_box import BoundingBox
-from wkcuber.utils import get_executor_for_args
+from wkcuber.utils import get_executor_for_args, _snake_to_camel_case
 
 from wkcuber.api.properties.dataset_properties import Properties
 from wkcuber.api.layer import Layer, LayerCategories, SegmentationLayer
@@ -655,3 +655,67 @@ def _create_layer(
             Layer if category == LayerCategories.COLOR_TYPE else SegmentationLayer
         )
         return layer_type(layer_name, self, dtype_per_channel, num_channels)
+
+    def set_view_configuration(
+        self, view_configuration: "DatasetViewConfiguration"
+    ) -> None:
+        self.properties._default_view_configuration = {
+            _snake_to_camel_case(k): v
+            for k, v in vars(view_configuration).items()
+            if v is not None
+        }
+        self.properties._export_as_json()  # update properties on disk
+
+    def get_view_configuration(self) -> Optional["DatasetViewConfiguration"]:
+        view_configuration_dict = self.properties.default_view_configuration
+        if view_configuration_dict is None:
+            return None
+
+        return DatasetViewConfiguration(
+            four_bit=view_configuration_dict.get("fourBit"),
+            interpolation=view_configuration_dict.get("interpolation"),
+            render_missing_data_black=view_configuration_dict.get(
+                "renderMissingDataBlack"
+            ),
+            loading_strategy=view_configuration_dict.get("loadingStrategy"),
+            segmentation_pattern_opacity=view_configuration_dict.get(
+                "segmentationPatternOpacity"
+            ),
+            zoom=view_configuration_dict.get("zoom"),
+            position=cast(
+                Tuple[int, int, int], tuple(view_configuration_dict["position"])
+            )
+            if "position" in view_configuration_dict
+            else None,
+            rotation=cast(
+                Tuple[int, int, int], tuple(view_configuration_dict["rotation"])
+            )
+            if "rotation" in view_configuration_dict
+            else None,
+        )
+
+
+class DatasetViewConfiguration:
+    """
+    Stores information on how the dataset is shown in webknossos by default.
+    """
+
+    def __init__(
+        self,
+        four_bit: Optional[bool] = None,
+        interpolation: Optional[bool] = None,
+        render_missing_data_black: Optional[bool] = None,
+        loading_strategy: Optional[str] = None,
+        segmentation_pattern_opacity: Optional[int] = None,
+        zoom: Optional[float] = None,
+        position: Optional[Tuple[int, int, int]] = None,
+        rotation: Optional[Tuple[int, int, int]] = None,
+    ):
+        self.four_bit = four_bit
+        self.interpolation = interpolation
+        self.render_missing_data_black = render_missing_data_black
+        self.loading_strategy = loading_strategy
+        self.segmentation_pattern_opacity = segmentation_pattern_opacity
+        self.zoom = zoom
+        self.position = position
+        self.rotation = rotation
diff --git a/wkcuber/api/layer.py b/wkcuber/api/layer.py
--- a/wkcuber/api/layer.py
+++ b/wkcuber/api/layer.py
@@ -45,6 +45,7 @@
     DEFAULT_WKW_FILE_LEN,
     get_executor_for_args,
     named_partial,
+    _snake_to_camel_case,
 )
 
 
@@ -607,6 +608,38 @@ def _initialize_mag_from_other_mag(
             compress=compress,
         )
 
+    def set_view_configuration(
+        self, view_configuration: "LayerViewConfiguration"
+    ) -> None:
+        self.dataset.properties._data_layers[self.name]._default_view_configuration = {
+            _snake_to_camel_case(k): v
+            for k, v in vars(view_configuration).items()
+            if v is not None
+        }
+        self.dataset.properties._export_as_json()  # update properties on disk
+
+    def get_view_configuration(self) -> Optional["LayerViewConfiguration"]:
+        view_configuration_dict = self.dataset.properties.data_layers[
+            self.name
+        ].default_view_configuration
+        if view_configuration_dict is None:
+            return None
+
+        return LayerViewConfiguration(
+            color=cast(Tuple[int, int, int], tuple(view_configuration_dict["color"])),
+            alpha=view_configuration_dict.get("alpha"),
+            intensity_range=cast(
+                Tuple[float, float], tuple(view_configuration_dict["intensityRange"])
+            )
+            if "intensityRange" in view_configuration_dict.keys()
+            else None,
+            min=view_configuration_dict.get("min"),
+            max=view_configuration_dict.get("max"),
+            is_disabled=view_configuration_dict.get("isDisabled"),
+            is_inverted=view_configuration_dict.get("isInverted"),
+            is_in_edit_mode=view_configuration_dict.get("isInEditMode"),
+        )
+
 
 class SegmentationLayer(Layer):
     @property
@@ -638,3 +671,29 @@ class LayerCategories:
 
     COLOR_TYPE = "color"
     SEGMENTATION_TYPE = "segmentation"
+
+
+class LayerViewConfiguration:
+    """
+    Stores information on how the dataset is shown in webknossos by default.
+    """
+
+    def __init__(
+        self,
+        color: Optional[Tuple[int, int, int]] = None,
+        alpha: Optional[float] = None,
+        intensity_range: Optional[Tuple[float, float]] = None,
+        min: Optional[float] = None,  # pylint: disable=redefined-builtin
+        max: Optional[float] = None,  # pylint: disable=redefined-builtin
+        is_disabled: Optional[bool] = None,
+        is_inverted: Optional[bool] = None,
+        is_in_edit_mode: Optional[bool] = None,
+    ):
+        self.color = color
+        self.alpha = alpha
+        self.intensity_range = intensity_range
+        self.min = min
+        self.max = max
+        self.is_disabled = is_disabled
+        self.is_inverted = is_inverted
+        self.is_in_edit_mode = is_in_edit_mode
diff --git a/wkcuber/utils.py b/wkcuber/utils.py
--- a/wkcuber/utils.py
+++ b/wkcuber/utils.py
@@ -514,3 +514,8 @@ def get_executor_args(global_args: argparse.Namespace) -> argparse.Namespace:
     executor_args.distribution_strategy = global_args.distribution_strategy
     executor_args.job_resources = global_args.job_resources
     return executor_args
+
+
+def _snake_to_camel_case(snake_case_name: str) -> str:
+    parts = snake_case_name.split("_")
+    return parts[0] + "".join(part.title() for part in parts[1:])


Test patch:
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
--- a/tests/test_dataset.py
+++ b/tests/test_dataset.py
@@ -14,11 +14,16 @@
 from wkw import wkw
 from wkw.wkw import WKWException
 
+from wkcuber.api.dataset import Dataset, DatasetViewConfiguration
 from wkcuber.api.bounding_box import BoundingBox
-from wkcuber.api.dataset import Dataset
 from os import makedirs
 
-from wkcuber.api.layer import Layer, LayerCategories, SegmentationLayer
+from wkcuber.api.layer import (
+    Layer,
+    LayerCategories,
+    SegmentationLayer,
+    LayerViewConfiguration,
+)
 from wkcuber.api.mag_view import MagView
 from wkcuber.api.properties.dataset_properties import Properties
 from wkcuber.api.properties.layer_properties import SegmentationLayerProperties
@@ -26,7 +31,7 @@
 from wkcuber.api.view import View
 from wkcuber.compress import compress_mag_inplace
 from wkcuber.mag import Mag
-from wkcuber.utils import get_executor_for_args, named_partial
+from wkcuber.utils import get_executor_for_args, named_partial, _snake_to_camel_case
 
 TESTDATA_DIR = Path("testdata")
 TESTOUTPUT_DIR = Path("testoutput")
@@ -1398,6 +1403,123 @@ def test_compression(tmp_path: Path) -> None:
     )
 
 
+def test_dataset_view_configuration(tmp_path: Path) -> None:
+    ds1 = Dataset.create(tmp_path, scale=(2, 2, 1))
+    default_view_configuration = ds1.get_view_configuration()
+    assert default_view_configuration is None
+
+    ds1.set_view_configuration(DatasetViewConfiguration(four_bit=True))
+    default_view_configuration = ds1.get_view_configuration()
+    assert default_view_configuration is not None
+    assert default_view_configuration.four_bit == True
+    assert default_view_configuration.interpolation == None
+    assert default_view_configuration.render_missing_data_black == None
+    assert default_view_configuration.loading_strategy == None
+    assert default_view_configuration.segmentation_pattern_opacity == None
+    assert default_view_configuration.zoom == None
+    assert default_view_configuration.position == None
+    assert default_view_configuration.rotation == None
+
+    # Test if only the set parameters are stored in the properties
+    assert ds1.properties.default_view_configuration == {"fourBit": True}
+
+    ds1.set_view_configuration(
+        DatasetViewConfiguration(
+            four_bit=True,
+            interpolation=False,
+            render_missing_data_black=True,
+            loading_strategy="PROGRESSIVE_QUALITY",
+            segmentation_pattern_opacity=40,
+            zoom=0.1,
+            position=(12, 12, 12),
+            rotation=(1, 2, 3),
+        )
+    )
+    default_view_configuration = ds1.get_view_configuration()
+    assert default_view_configuration is not None
+    assert default_view_configuration.four_bit == True
+    assert default_view_configuration.interpolation == False
+    assert default_view_configuration.render_missing_data_black == True
+    assert default_view_configuration.loading_strategy == "PROGRESSIVE_QUALITY"
+    assert default_view_configuration.segmentation_pattern_opacity == 40
+    assert default_view_configuration.zoom == 0.1
+    assert default_view_configuration.position == (12, 12, 12)
+    assert default_view_configuration.rotation == (1, 2, 3)
+
+    # Test if the data is persisted to disk
+    ds2 = Dataset(tmp_path)
+    default_view_configuration = ds2.get_view_configuration()
+    assert default_view_configuration is not None
+    assert default_view_configuration.four_bit == True
+    assert default_view_configuration.interpolation == False
+    assert default_view_configuration.render_missing_data_black == True
+    assert default_view_configuration.loading_strategy == "PROGRESSIVE_QUALITY"
+    assert default_view_configuration.segmentation_pattern_opacity == 40
+    assert default_view_configuration.zoom == 0.1
+    assert default_view_configuration.position == (12, 12, 12)
+    assert default_view_configuration.rotation == (1, 2, 3)
+
+    # Test camel case
+    view_configuration_dict = ds2.properties.default_view_configuration
+    assert view_configuration_dict is not None
+    for k in view_configuration_dict.keys():
+        assert _snake_to_camel_case(k) == k
+
+
+def test_layer_view_configuration(tmp_path: Path) -> None:
+    ds1 = Dataset.create(tmp_path, scale=(2, 2, 1))
+    layer1 = ds1.add_layer("color", LayerCategories.COLOR_TYPE)
+    default_view_configuration = layer1.get_view_configuration()
+    assert default_view_configuration is None
+
+    layer1.set_view_configuration(LayerViewConfiguration(color=(255, 0, 0)))
+    default_view_configuration = layer1.get_view_configuration()
+    assert default_view_configuration is not None
+    assert default_view_configuration.color == (255, 0, 0)
+    assert default_view_configuration.alpha is None
+    assert default_view_configuration.intensity_range is None
+    assert default_view_configuration.is_inverted is None
+    # Test if only the set parameters are stored in the properties
+    assert ds1.properties.data_layers["color"].default_view_configuration == {
+        "color": (255, 0, 0)
+    }
+
+    layer1.set_view_configuration(
+        LayerViewConfiguration(
+            color=(255, 0, 0),
+            alpha=1.0,
+            min=55.0,
+            intensity_range=(-12.3e1, 123),
+            is_inverted=True,
+        )
+    )
+    default_view_configuration = layer1.get_view_configuration()
+    assert default_view_configuration is not None
+    assert default_view_configuration.color == (255, 0, 0)
+    assert default_view_configuration.alpha == 1.0
+    assert default_view_configuration.intensity_range == (-12.3e1, 123)
+    assert default_view_configuration.is_inverted == True
+    assert default_view_configuration.min == 55.0
+
+    # Test if the data is persisted to disk
+    ds2 = Dataset(tmp_path)
+    default_view_configuration = ds2.get_layer("color").get_view_configuration()
+    assert default_view_configuration is not None
+    assert default_view_configuration.color == (255, 0, 0)
+    assert default_view_configuration.alpha == 1.0
+    assert default_view_configuration.intensity_range == (-12.3e1, 123)
+    assert default_view_configuration.is_inverted == True
+    assert default_view_configuration.min == 55.0
+
+    # Test camel case
+    view_configuration_dict = ds2.properties.data_layers[
+        "color"
+    ].default_view_configuration
+    assert view_configuration_dict is not None
+    for k in view_configuration_dict.keys():
+        assert _snake_to_camel_case(k) == k
+
+
 def test_get_largest_segment_id(tmp_path: Path) -> None:
     ds = Dataset.create(tmp_path, scale=(1, 1, 1))