Repository: swisstopo/swissgeol-boreholes-dataextraction

Issue:
Layer class should allow any interval type
[Migrated from LGVISIUM-104.]

Currently, the Layer class only allows an AAboveBInterval for the depth_interval field. This is not correct, as a layer may have any interval type.

The current situation forces other intervals to be coerced into AAboveBInterval in extract.py, where the code already contains a TODO _"don't automatically convert any interval to an AAboveBInterval"_.

We should allow any interval type (not just AAboveBInterval) in the Layer class, and deal with any implications that this might have (in particular, concerning JSON serialization and deserialization).

Hints from issue comments:

Solution patch:
diff --git a/src/stratigraphy/annotations/draw.py b/src/stratigraphy/annotations/draw.py
index de513c69..dc5bddb0 100644
--- a/src/stratigraphy/annotations/draw.py
+++ b/src/stratigraphy/annotations/draw.py
@@ -314,41 +314,75 @@ def draw_layer(shape: fitz.Shape, derotation_matrix: fitz.Matrix, layer: Layer,
                     stroke_opacity=0.5,
                 )
 
-        if layer.depth_interval:
-            # background color for depth interval
-            background_rect = layer.depth_interval.background_rect
-            if background_rect is not None:
-                shape.draw_rect(
-                    background_rect * derotation_matrix,
-                )
-                shape.finish(
-                    color=fitz.utils.getColor(color),
-                    fill_opacity=0.2,
-                    fill=fitz.utils.getColor(color),
-                    width=0,
-                )
+        if layer.depths:
+            # line from depth interval to material description
+            depths_rect = fitz.Rect()
+            if layer.depths.start:
+                depths_rect.include_rect(layer.depths.start.rect)
+            if layer.depths.end:
+                depths_rect.include_rect(layer.depths.end.rect)
+
+            if not layer.material_description.rect.contains(depths_rect):
+                # Depths are separate from the material description: draw a line connecting them
+                line_anchor = layer.depths.line_anchor
+                if line_anchor:
+                    rect = layer.material_description.rect
+                    shape.draw_line(
+                        line_anchor * derotation_matrix,
+                        fitz.Point(rect.x0, (rect.y0 + rect.y1) / 2) * derotation_matrix,
+                    )
+                    shape.finish(
+                        color=fitz.utils.getColor(color),
+                    )
+
+                # background color for depth interval
+                background_rect = layer.depths.background_rect
+                if background_rect is not None:
+                    shape.draw_rect(
+                        background_rect * derotation_matrix,
+                    )
+                    shape.finish(
+                        color=fitz.utils.getColor(color),
+                        fill_opacity=0.2,
+                        fill=fitz.utils.getColor(color),
+                        width=0,
+                    )
+
+                    # draw green line if depth interval is correct else red line
+                    if layer.is_correct is not None:
+                        depth_is_correct_color = "green" if layer.is_correct else "red"
+                        shape.draw_line(
+                            background_rect.top_left * derotation_matrix,
+                            background_rect.bottom_left * derotation_matrix,
+                        )
+                        shape.finish(
+                            color=fitz.utils.getColor(depth_is_correct_color),
+                            width=6,
+                            stroke_opacity=0.5,
+                        )
+            else:
+                # Depths are part of the material description: only draw bounding boxes
+                if layer.depths.start:
+                    shape.draw_rect(fitz.Rect(layer.depths.start.rect) * derotation_matrix)
+                if layer.depths.end:
+                    shape.draw_rect(fitz.Rect(layer.depths.end.rect) * derotation_matrix)
+                shape.finish(color=fitz.utils.getColor("purple"))
 
                 # draw green line if depth interval is correct else red line
                 if layer.is_correct is not None:
                     depth_is_correct_color = "green" if layer.is_correct else "red"
-                    shape.draw_line(
-                        background_rect.top_left * derotation_matrix,
-                        background_rect.bottom_left * derotation_matrix,
-                    )
+                    if layer.depths.start:
+                        shape.draw_line(
+                            layer.depths.start.rect.bottom_left * derotation_matrix,
+                            layer.depths.start.rect.bottom_right * derotation_matrix,
+                        )
+                    if layer.depths.end:
+                        shape.draw_line(
+                            layer.depths.end.rect.bottom_left * derotation_matrix,
+                            layer.depths.end.rect.bottom_right * derotation_matrix,
+                        )
                     shape.finish(
                         color=fitz.utils.getColor(depth_is_correct_color),
                         width=6,
                         stroke_opacity=0.5,
                     )
-
-            # line from depth interval to material description
-            line_anchor = layer.depth_interval.line_anchor
-            if line_anchor:
-                rect = layer.material_description.rect
-                shape.draw_line(
-                    line_anchor * derotation_matrix,
-                    fitz.Point(rect.x0, (rect.y0 + rect.y1) / 2) * derotation_matrix,
-                )
-                shape.finish(
-                    color=fitz.utils.getColor(color),
-                )
diff --git a/src/stratigraphy/depth/__init__.py b/src/stratigraphy/depth/__init__.py
index 288242cd..9b0ba5fd 100644
--- a/src/stratigraphy/depth/__init__.py
+++ b/src/stratigraphy/depth/__init__.py
@@ -1,11 +1 @@
 """Modules for extracting values indicating some measured depth below the surface."""
-
-from .a_to_b_interval_extractor import AToBIntervalExtractor
-from .interval import AAboveBInterval, AToBInterval, Interval
-
-__all__ = [
-    "AAboveBInterval",
-    "AToBInterval",
-    "AToBIntervalExtractor",
-    "Interval",
-]
diff --git a/src/stratigraphy/depth/a_to_b_interval_extractor.py b/src/stratigraphy/depth/a_to_b_interval_extractor.py
index e0b635a9..b232e3cd 100644
--- a/src/stratigraphy/depth/a_to_b_interval_extractor.py
+++ b/src/stratigraphy/depth/a_to_b_interval_extractor.py
@@ -5,8 +5,8 @@
 import fitz
 
 from stratigraphy.lines.line import TextLine
+from stratigraphy.sidebar.sidebarentry import DepthColumnEntry
 
-from ..sidebar.sidebarentry import DepthColumnEntry
 from .interval import AToBInterval
 
 
@@ -40,9 +40,7 @@ def from_material_description_lines(cls, lines: list[TextLine]) -> AToBInterval
         depth_entries = []
         for line in lines:
             try:
-                a_to_b_depth_entry = AToBIntervalExtractor.from_text(
-                    line.text, line.rect, require_start_of_string=False
-                )
+                a_to_b_depth_entry = AToBIntervalExtractor.from_text(line, require_start_of_string=False)
                 # require_start_of_string = False because the depth interval may not always start at the beginning
                 # of the line e.g. "Remblais Heterogene: 0.00 - 0.5m"
                 if a_to_b_depth_entry:
@@ -59,30 +57,46 @@ def from_material_description_lines(cls, lines: list[TextLine]) -> AToBInterval
             return None
 
     @classmethod
-    def from_text(cls, text: str, rect: fitz.Rect, require_start_of_string: bool = True) -> AToBInterval | None:
+    def from_text(cls, text_line: TextLine, require_start_of_string: bool = True) -> AToBInterval | None:
         """Attempts to extract a AToBInterval from a string.
 
         Args:
-            text (str): The string to extract the depth interval from.
-            rect (fitz.Rect): The rectangle of the text.
+            text_line (TextLine): The text line to extract the depth interval from.
             require_start_of_string (bool, optional): Whether the number to extract needs to be
                                                       at the start of a string. Defaults to True.
 
         Returns:
             AToBInterval | None: The extracted AToBInterval or None if none is found.
         """
-        input_string = text.strip().replace(",", ".")
+        input_string = text_line.text.strip().replace(",", ".")
 
-        query = r"-?([0-9]+(\.[0-9]+)?)[müMN\]*[\s-]+([0-9]+(\.[0-9]+)?)[müMN\\.]*"
+        # for every character in input_string, list the index of the word this character originates from
+        char_index_to_word_index = []
+        for index, word in enumerate(text_line.words):
+            char_index_to_word_index.extend([index] * (len(word.text) + 1))  # +1 to include the space between words
+
+        query = r"-?([0-9]+(?:\.[0-9]+)?)[müMN\]*[\s-]+([0-9]+(?:\.[0-9]+)?)[müMN\\.]*"
         if not require_start_of_string:
             query = r".*?" + query
         regex = re.compile(query)
         match = regex.match(input_string)
+
+        def rect_from_group_index(index):
+            """Give the rect that covers all the words that intersect with the given regex group."""
+            rect = fitz.Rect()
+            start_word_index = char_index_to_word_index[match.start(index)]
+            # `match.end(index) - 1`, because match.end gives the index of the first character that is *not* matched,
+            # whereas we want the last character that *is* matched.
+            end_word_index = char_index_to_word_index[match.end(index) - 1]
+            # `end_word_index + 1` because the end of the range is exclusive by default, whereas we also want to
+            # include the word with this index
+            for word_index in range(start_word_index, end_word_index + 1):
+                rect.include_rect(text_line.words[word_index].rect)
+            return rect
+
         if match:
-            first_half_rect = fitz.Rect(rect.x0, rect.y0, rect.x1 - rect.width / 2, rect.y1)
-            second_half_rect = fitz.Rect(rect.x0 + rect.width / 2, rect.y0, rect.x1, rect.y1)
             return AToBInterval(
-                DepthColumnEntry.from_string_value(first_half_rect, match.group(1)),
-                DepthColumnEntry.from_string_value(second_half_rect, match.group(3)),
+                DepthColumnEntry.from_string_value(rect_from_group_index(1), match.group(1)),
+                DepthColumnEntry.from_string_value(rect_from_group_index(2), match.group(2)),
             )
         return None
diff --git a/src/stratigraphy/depth/interval.py b/src/stratigraphy/depth/interval.py
index aff6d286..fa7d41c2 100644
--- a/src/stratigraphy/depth/interval.py
+++ b/src/stratigraphy/depth/interval.py
@@ -2,8 +2,6 @@
 
 from __future__ import annotations
 
-import abc
-
 import fitz
 
 from stratigraphy.lines.line import TextLine
@@ -11,7 +9,7 @@
 from stratigraphy.text.textblock import TextBlock
 
 
-class Interval(metaclass=abc.ABCMeta):
+class Interval:
     """Abstract class for (depth) intervals."""
 
     def __init__(self, start: DepthColumnEntry | None, end: DepthColumnEntry | None):
@@ -19,59 +17,10 @@ def __init__(self, start: DepthColumnEntry | None, end: DepthColumnEntry | None)
         self.start = start
         self.end = end
 
-    @property
-    def start_value(self) -> float | None:
-        """Get the start value of the interval."""
-        if self.start:
-            return self.start.value
-        else:
-            return None
-
-    @property
-    def end_value(self) -> float | None:
-        """Get the end value of the interval."""
-        if self.end:
-            return self.end.value
-        else:
-            return None
-
-    @property
-    @abc.abstractmethod
-    def line_anchor(self) -> fitz.Point:
-        """Get the line anchor of the interval."""
-        pass
-
-    @property
-    @abc.abstractmethod
-    def background_rect(self) -> fitz.Rect | None:
-        """Get the background rectangle of the interval."""
-        pass
-
-    def to_json(self):
-        """Convert the interval to a JSON serializable format."""
-        return {
-            "start": self.start.to_json() if self.start else None,
-            "end": self.end.to_json() if self.end else None,
-        }
-
 
 class AAboveBInterval(Interval):
     """Class for depth intervals where the upper depth is located above the lower depth on the page."""
 
-    @property
-    def line_anchor(self) -> fitz.Point | None:
-        if self.start and self.end:
-            return fitz.Point(self.start.rect.x1, (self.start.rect.y0 + self.end.rect.y1) / 2)
-        elif self.start:
-            return fitz.Point(self.start.rect.x1, self.start.rect.y1)
-        elif self.end:
-            return fitz.Point(self.end.rect.x1, self.end.rect.y0)
-
-    @property
-    def background_rect(self) -> fitz.Rect | None:
-        if self.start and self.end:
-            return fitz.Rect(self.start.rect.x0, self.start.rect.y1, self.start.rect.x1, self.end.rect.y0)
-
     def matching_blocks(
         self, all_blocks: list[TextBlock], block_index: int
     ) -> tuple[list[TextBlock], list[TextBlock], list[TextBlock]]:
@@ -151,15 +100,6 @@ def rect(self) -> fitz.Rect:
         """Get the rectangle surrounding the interval."""
         return fitz.Rect(self.start.rect).include_rect(self.end.rect)
 
-    @property
-    def line_anchor(self) -> fitz.Point | None:
-        if self.end:
-            return fitz.Point(self.end.rect.x1, (self.end.rect.y0 + self.end.rect.y1) / 2)
-
-    @property
-    def background_rect(self) -> fitz.Rect | None:
-        return None
-
     def matching_blocks(
         self, all_lines: list[TextLine], line_index: int, next_interval: Interval | None
     ) -> list[TextBlock]:
diff --git a/src/stratigraphy/depths_materials_column_pairs/material_description_rect_with_sidebar.py b/src/stratigraphy/depths_materials_column_pairs/material_description_rect_with_sidebar.py
index 1a7f5e30..4d71c4a8 100644
--- a/src/stratigraphy/depths_materials_column_pairs/material_description_rect_with_sidebar.py
+++ b/src/stratigraphy/depths_materials_column_pairs/material_description_rect_with_sidebar.py
@@ -4,7 +4,7 @@
 from dataclasses import dataclass
 
 import fitz
-from stratigraphy.sidebar import Sidebar
+from stratigraphy.sidebar.sidebar import Sidebar
 
 
 @dataclass
diff --git a/src/stratigraphy/evaluation/layer_evaluator.py b/src/stratigraphy/evaluation/layer_evaluator.py
index f7e51d87..914edf7a 100644
--- a/src/stratigraphy/evaluation/layer_evaluator.py
+++ b/src/stratigraphy/evaluation/layer_evaluator.py
@@ -140,7 +140,7 @@ def find_matching_layer(layer: Layer, unmatched_layers: list[dict]) -> tuple[dic
             start = possible_match["depth_interval"]["start"]
             end = possible_match["depth_interval"]["end"]
 
-            if _is_valid_depth_interval(layer.depth_interval, start, end):
+            if _is_valid_depth_interval(layer.depths, start, end):
                 unmatched_layers.remove(possible_match)
                 return possible_match, True
 
diff --git a/src/stratigraphy/evaluation/utility.py b/src/stratigraphy/evaluation/utility.py
index 7fda85db..6080e9b8 100644
--- a/src/stratigraphy/evaluation/utility.py
+++ b/src/stratigraphy/evaluation/utility.py
@@ -2,8 +2,8 @@
 
 from collections import Counter
 
-from stratigraphy.depth import Interval
 from stratigraphy.evaluation.evaluation_dataclasses import Metrics
+from stratigraphy.layer.layer import LayerDepths
 
 
 def count_against_ground_truth(values: list[str], ground_truth: list[str]) -> Metrics:
@@ -29,24 +29,24 @@ def count_against_ground_truth(values: list[str], ground_truth: list[str]) -> Me
     return Metrics(tp=tp, fp=len(values) - tp, fn=len(ground_truth) - tp)
 
 
-def _is_valid_depth_interval(depth_interval: Interval, start: float, end: float) -> bool:
+def _is_valid_depth_interval(depths: LayerDepths, start: float, end: float) -> bool:
     """Validate if the depth intervals match.
 
     Args:
-        depth_interval (Interval): The depth interval to compare.
+        depths (LayerDepths): The layer depths to compare.
         start (float): The start value of the interval.
         end (float): The end value of the interval.
 
     Returns:
         bool: True if the depth intervals match, False otherwise.
     """
-    if depth_interval is None:
+    if depths is None:
         return False
 
-    if depth_interval.start is None:
-        return (start == 0) and (end == depth_interval.end.value)
+    if depths.start is None:
+        return (start == 0) and (end == depths.end.value)
 
-    if (depth_interval.start is not None) and (depth_interval.end is not None):
-        return start == depth_interval.start.value and end == depth_interval.end.value
+    if (depths.start is not None) and (depths.end is not None):
+        return start == depths.start.value and end == depths.end.value
 
     return False
diff --git a/src/stratigraphy/extract.py b/src/stratigraphy/extract.py
index 81b8311e..5f20e46f 100644
--- a/src/stratigraphy/extract.py
+++ b/src/stratigraphy/extract.py
@@ -6,20 +6,17 @@
 import rtree
 
 from stratigraphy.data_extractor.data_extractor import FeatureOnPage
-from stratigraphy.depth import AAboveBInterval, Interval
+from stratigraphy.depth.interval import AAboveBInterval, Interval
 from stratigraphy.depths_materials_column_pairs.bounding_boxes import BoundingBox, BoundingBoxes
 from stratigraphy.depths_materials_column_pairs.material_description_rect_with_sidebar import (
     MaterialDescriptionRectWithSidebar,
 )
-from stratigraphy.layer.layer import IntervalBlockPair, Layer
+from stratigraphy.layer.layer import IntervalBlockPair, Layer, LayerDepths
 from stratigraphy.lines.line import TextLine
-from stratigraphy.sidebar import (
-    AAboveBSidebarExtractor,
-    AToBSidebarExtractor,
-    LayerIdentifierSidebarExtractor,
-    Sidebar,
-)
-from stratigraphy.sidebar.sidebar import SidebarNoise, noise_count
+from stratigraphy.sidebar.a_above_b_sidebar_extractor import AAboveBSidebarExtractor
+from stratigraphy.sidebar.a_to_b_sidebar_extractor import AToBSidebarExtractor
+from stratigraphy.sidebar.layer_identifier_sidebar_extractor import LayerIdentifierSidebarExtractor
+from stratigraphy.sidebar.sidebar import Sidebar, SidebarNoise, noise_count
 from stratigraphy.text.find_description import (
     get_description_blocks,
     get_description_lines,
@@ -179,10 +176,7 @@ def process_page(
                 rect=pair.block.rect,
                 page=page_number,
             ),
-            # TODO LGVISIUM-104 don't automatically convert any interval to an AAboveBInterval
-            depth_interval=AAboveBInterval(start=pair.depth_interval.start, end=pair.depth_interval.end)
-            if pair.depth_interval
-            else None,
+            depths=LayerDepths.from_interval(pair.depth_interval) if pair.depth_interval else None,
         )
         for pair in pairs
     ]
diff --git a/src/stratigraphy/layer/duplicate_detection.py b/src/stratigraphy/layer/duplicate_detection.py
index 9d295608..76ea58da 100644
--- a/src/stratigraphy/layer/duplicate_detection.py
+++ b/src/stratigraphy/layer/duplicate_detection.py
@@ -50,15 +50,15 @@ def remove_duplicate_layers(
         # check if current layer has an overlapping layer on the previous page.
         # for that purpose compare depth interval as well as material description text.
         duplicate_condition = False
-        if layer.depth_interval is None:
+        if layer.depths is None:
             duplicate_condition = check_duplicate_layer_by_template_matching(
                 previous_page, current_page, layer, img_template_probability_threshold
             )
         else:  # in this case we compare the depth interval and material description
             current_material_description = layer.material_description
-            current_depth_interval = layer.depth_interval
+            current_depth_interval = layer.depths
             for previous_layer in previous_layers.layers:
-                if previous_layer.depth_interval is None:
+                if previous_layer.depths is None:
                     # It may happen, that a layer on the previous page does not have depth interval assigned.
                     # In this case we skip the comparison. This should only happen in some edge cases, as we
                     # assume that when the current page has a depth column, that the previous page also contains a
@@ -66,7 +66,7 @@ def remove_duplicate_layers(
                     continue
 
                 previous_material_description = previous_layer.material_description
-                previous_depth_interval = previous_layer.depth_interval
+                previous_depth_interval = previous_layer.depths
 
                 # start values for the depth intervals may be None. End values are always explicitly set.
                 current_depth_interval_start = (
diff --git a/src/stratigraphy/layer/layer.py b/src/stratigraphy/layer/layer.py
index 1cce7f8e..f831d495 100644
--- a/src/stratigraphy/layer/layer.py
+++ b/src/stratigraphy/layer/layer.py
@@ -4,18 +4,102 @@
 
 import fitz
 from stratigraphy.data_extractor.data_extractor import ExtractedFeature, FeatureOnPage
-from stratigraphy.depth import AAboveBInterval, Interval
-from stratigraphy.sidebar.sidebarentry import DepthColumnEntry
+from stratigraphy.depth.interval import Interval
 from stratigraphy.text.textblock import MaterialDescription, TextBlock
 from stratigraphy.util.util import parse_text
 
 
+@dataclass
+class LayerDepthsEntry:
+    """Class for the data about the upper or lower limit of a layer, as required for visualiation and evaluation."""
+
+    value: float
+    rect: fitz.Rect
+
+    def to_json(self):
+        """Convert the LayerDepthsEntry object to a JSON serializable format."""
+        return {
+            "value": self.value,
+            "rect": [self.rect.x0, self.rect.y0, self.rect.x1, self.rect.y1] if self.rect else None,
+        }
+
+    @classmethod
+    def from_json(cls, data: dict) -> "LayerDepthsEntry":
+        """Converts a dictionary to an object.
+
+        Args:
+            data (dict): A dictionary representing the layer depths entry.
+
+        Returns:
+            DepthColumnEntry: the corresponding LayerDepthsEntry object.
+        """
+        return cls(value=data["value"], rect=fitz.Rect(data["rect"]))
+
+
+@dataclass
+class LayerDepths:
+    """Class for the data about the depths of a layer that are necessary for visualiation and evaluation."""
+
+    start: LayerDepthsEntry | None
+    end: LayerDepthsEntry | None
+
+    @property
+    def line_anchor(self) -> fitz.Point | None:
+        if self.start and self.end:
+            return fitz.Point(max(self.start.rect.x1, self.end.rect.x1), (self.start.rect.y0 + self.end.rect.y1) / 2)
+        elif self.start:
+            return fitz.Point(self.start.rect.x1, self.start.rect.y1)
+        elif self.end:
+            return fitz.Point(self.end.rect.x1, self.end.rect.y0)
+
+    @property
+    def background_rect(self) -> fitz.Rect | None:
+        if self.start and self.end and self.start.rect.y1 < self.end.rect.y0:
+            return fitz.Rect(
+                self.start.rect.x0, self.start.rect.y1, max(self.start.rect.x1, self.end.rect.x1), self.end.rect.y0
+            )
+
+    def to_json(self):
+        """Convert the LayerDepths object to a JSON serializable format."""
+        return {"start": self.start.to_json() if self.start else None, "end": self.end.to_json() if self.end else None}
+
+    @classmethod
+    def from_json(cls, data: dict) -> "LayerDepths":
+        """Converts a dictionary to an object.
+
+        Args:
+            data (dict): A dictionary representing the layer depths.
+
+        Returns:
+            DepthColumnEntry: the corresponding LayerDepths object.
+        """
+        return cls(
+            start=LayerDepthsEntry.from_json(data["start"]) if data["start"] else None,
+            end=LayerDepthsEntry.from_json(data["end"]) if data["end"] else None,
+        )
+
+    @classmethod
+    def from_interval(cls, interval: Interval) -> "LayerDepths":
+        """Converts an Interval to a LayerDepths object.
+
+        Args:
+            interval (Interval): an AAboveBInterval or AToBInterval.
+
+        Returns:
+            LayerDepths: the corresponding LayerDepths object.
+        """
+        return cls(
+            start=LayerDepthsEntry(interval.start.value, interval.start.rect) if interval.start else None,
+            end=LayerDepthsEntry(interval.end.value, interval.end.rect) if interval.end else None,
+        )
+
+
 @dataclass
 class Layer(ExtractedFeature):
     """A class to represent predictions for a single layer."""
 
     material_description: FeatureOnPage[MaterialDescription]
-    depth_interval: AAboveBInterval | None
+    depths: LayerDepths | None
 
     def __str__(self) -> str:
         """Converts the object to a string.
@@ -23,7 +107,7 @@ def __str__(self) -> str:
         Returns:
             str: The object as a string.
         """
-        return f"Layer(material_description={self.material_description}, depth_interval={self.depth_interval})"
+        return f"Layer(material_description={self.material_description}, depths={self.depths})"
 
     def description_nonempty(self) -> bool:
         return parse_text(self.material_description.feature.text) != ""
@@ -36,7 +120,7 @@ def to_json(self) -> dict:
         """
         return {
             "material_description": self.material_description.to_json() if self.material_description else None,
-            "depth_interval": self.depth_interval.to_json() if self.depth_interval else None,
+            "depths": self.depths.to_json() if self.depths else None,
         }
 
     @classmethod
@@ -50,26 +134,9 @@ def from_json(cls, data: dict) -> "Layer":
             list[LayerPrediction]: A list of LayerPrediction objects.
         """
         material_prediction = FeatureOnPage.from_json(data["material_description"], MaterialDescription)
-        if "depth_interval" in data and data["depth_interval"] is not None:
-            depth_interval = data.get("depth_interval", {})
-            start_data = depth_interval.get("start")
-            end_data = depth_interval.get("end")
-            start = (
-                DepthColumnEntry(value=start_data["value"], rect=fitz.Rect(start_data["rect"]))
-                if start_data is not None
-                else None
-            )
-            end = (
-                DepthColumnEntry(value=end_data["value"], rect=fitz.Rect(end_data["rect"]))
-                if end_data is not None
-                else None
-            )
-
-            depth_interval_prediction = AAboveBInterval(start=start, end=end)
-        else:
-            depth_interval_prediction = None
+        depths = LayerDepths.from_json(data["depths"]) if ("depths" in data and data["depths"] is not None) else None
 
-        return Layer(material_description=material_prediction, depth_interval=depth_interval_prediction)
+        return Layer(material_description=material_prediction, depths=depths)
 
 
 @dataclass
diff --git a/src/stratigraphy/sidebar/__init__.py b/src/stratigraphy/sidebar/__init__.py
index 371aaaad..75903389 100644
--- a/src/stratigraphy/sidebar/__init__.py
+++ b/src/stratigraphy/sidebar/__init__.py
@@ -1,22 +1 @@
 """Modules for Sidebars, representing depths or other data displayed to the side of material descriptions."""
-
-from .a_above_b_sidebar import AAboveBSidebar
-from .a_above_b_sidebar_extractor import AAboveBSidebarExtractor
-from .a_above_b_sidebar_validator import AAboveBSidebarValidator
-from .a_to_b_sidebar import AToBSidebar
-from .a_to_b_sidebar_extractor import AToBSidebarExtractor
-from .layer_identifier_sidebar import LayerIdentifierSidebar
-from .layer_identifier_sidebar_extractor import LayerIdentifierSidebarExtractor
-from .sidebar import Sidebar, SidebarNoise
-
-__all__ = [
-    "Sidebar",
-    "SidebarNoise",
-    "AAboveBSidebar",
-    "AAboveBSidebarExtractor",
-    "AAboveBSidebarValidator",
-    "AToBSidebar",
-    "AToBSidebarExtractor",
-    "LayerIdentifierSidebar",
-    "LayerIdentifierSidebarExtractor",
-]
diff --git a/src/stratigraphy/sidebar/a_to_b_sidebar.py b/src/stratigraphy/sidebar/a_to_b_sidebar.py
index ec5334b2..5bfef98f 100644
--- a/src/stratigraphy/sidebar/a_to_b_sidebar.py
+++ b/src/stratigraphy/sidebar/a_to_b_sidebar.py
@@ -6,7 +6,7 @@
 
 import fitz
 
-from stratigraphy.depth import AToBInterval
+from stratigraphy.depth.interval import AToBInterval
 from stratigraphy.lines.line import TextLine
 from stratigraphy.util.dataclasses import Line
 
diff --git a/src/stratigraphy/sidebar/a_to_b_sidebar_extractor.py b/src/stratigraphy/sidebar/a_to_b_sidebar_extractor.py
index b8b195ff..5b18203d 100644
--- a/src/stratigraphy/sidebar/a_to_b_sidebar_extractor.py
+++ b/src/stratigraphy/sidebar/a_to_b_sidebar_extractor.py
@@ -2,9 +2,9 @@
 
 import re
 
-from stratigraphy.depth import AToBInterval
+from stratigraphy.depth.interval import AToBInterval
 from stratigraphy.lines.line import TextWord
-from stratigraphy.sidebar import AToBSidebar
+from stratigraphy.sidebar.a_to_b_sidebar import AToBSidebar
 from stratigraphy.sidebar.depthcolumnentry_extractor import DepthColumnEntryExtractor
 from stratigraphy.sidebar.sidebarentry import DepthColumnEntry
 
diff --git a/src/stratigraphy/sidebar/depthcolumnentry_extractor.py b/src/stratigraphy/sidebar/depthcolumnentry_extractor.py
index f4e79dd4..ad8953c5 100644
--- a/src/stratigraphy/sidebar/depthcolumnentry_extractor.py
+++ b/src/stratigraphy/sidebar/depthcolumnentry_extractor.py
@@ -3,7 +3,7 @@
 import re
 
 from stratigraphy.depth.a_to_b_interval_extractor import AToBIntervalExtractor
-from stratigraphy.lines.line import TextWord
+from stratigraphy.lines.line import TextLine, TextWord
 from stratigraphy.sidebar.sidebarentry import DepthColumnEntry
 
 
@@ -37,8 +37,9 @@ def find_in_words(cls, all_words: list[TextWord], include_splits: bool) -> list[
 
                 elif include_splits:
                     # support for e.g. "1.10-1.60m" extracted as a single word
-                    a_to_b_interval = AToBIntervalExtractor.from_text(input_string, word.rect)
-                    entries.extend([a_to_b_interval.start, a_to_b_interval.end] if a_to_b_interval else [])
+                    a_to_b_interval = AToBIntervalExtractor.from_text(TextLine([word]))
+                    if a_to_b_interval:
+                        entries.extend([a_to_b_interval.start, a_to_b_interval.end])
             except ValueError:
                 pass
         return entries
diff --git a/src/stratigraphy/sidebar/layer_identifier_sidebar.py b/src/stratigraphy/sidebar/layer_identifier_sidebar.py
index b934b1a1..1beae82d 100644
--- a/src/stratigraphy/sidebar/layer_identifier_sidebar.py
+++ b/src/stratigraphy/sidebar/layer_identifier_sidebar.py
@@ -4,7 +4,7 @@
 
 import fitz
 
-from stratigraphy.depth import AToBIntervalExtractor
+from stratigraphy.depth.a_to_b_interval_extractor import AToBIntervalExtractor
 from stratigraphy.lines.line import TextLine
 from stratigraphy.text.textblock import TextBlock
 from stratigraphy.util.dataclasses import Line
diff --git a/src/stratigraphy/sidebar/sidebarentry.py b/src/stratigraphy/sidebar/sidebarentry.py
index 2f2e9c7e..fd941288 100644
--- a/src/stratigraphy/sidebar/sidebarentry.py
+++ b/src/stratigraphy/sidebar/sidebarentry.py
@@ -4,7 +4,7 @@
 
 import abc
 from dataclasses import dataclass
-from typing import Any, Generic, TypeVar
+from typing import Generic, TypeVar
 
 import pymupdf
 
@@ -30,26 +30,6 @@ class DepthColumnEntry(SidebarEntry[float]):  # noqa: D101
     def __repr__(self) -> str:
         return str(self.value)
 
-    def to_json(self) -> dict[str, Any]:
-        """Convert the depth column entry to a JSON serializable format."""
-        return {
-            "value": self.value,
-            "rect": [self.rect.x0, self.rect.y0, self.rect.x1, self.rect.y1],
-            "has_decimal_point": self.has_decimal_point,
-        }
-
-    @classmethod
-    def from_json(cls, data: dict) -> DepthColumnEntry:
-        """Converts a dictionary to an object.
-
-        Args:
-            data (dict): A dictionary representing the depth column entry.
-
-        Returns:
-            DepthColumnEntry: The depth column entry object.
-        """
-        return cls(rect=pymupdf.Rect(data["rect"]), value=data["value"], has_decimal_point=data["has_decimal_point"])
-
     @classmethod
     def from_string_value(cls, rect: pymupdf.Rect, string_value: str) -> DepthColumnEntry:
         """Creates a DepthColumnEntry from a string representation of the value.


Test patch:
diff --git a/tests/test_aabovebsidebar.py b/tests/test_aabovebsidebar.py
index 58c94cc9..a31dbe51 100644
--- a/tests/test_aabovebsidebar.py
+++ b/tests/test_aabovebsidebar.py
@@ -1,8 +1,7 @@
 """Test suite for the find_depth_columns module."""
 
 import fitz
-from stratigraphy.sidebar import AAboveBSidebar
-from stratigraphy.sidebar.a_above_b_sidebar import generate_alternatives
+from stratigraphy.sidebar.a_above_b_sidebar import AAboveBSidebar, generate_alternatives
 from stratigraphy.sidebar.sidebarentry import DepthColumnEntry
 
 
diff --git a/tests/test_find_sidebar.py b/tests/test_find_sidebar.py
index 9a98420d..b8e1fc70 100644
--- a/tests/test_find_sidebar.py
+++ b/tests/test_find_sidebar.py
@@ -4,7 +4,8 @@
 import pytest
 import rtree
 from stratigraphy.lines.line import TextWord
-from stratigraphy.sidebar import AAboveBSidebarExtractor, AToBSidebarExtractor
+from stratigraphy.sidebar.a_above_b_sidebar_extractor import AAboveBSidebarExtractor
+from stratigraphy.sidebar.a_to_b_sidebar_extractor import AToBSidebarExtractor
 from stratigraphy.sidebar.depthcolumnentry_extractor import DepthColumnEntryExtractor
 
 PAGE_NUMBER = 1
diff --git a/tests/test_interval.py b/tests/test_interval.py
index ef883e0d..3986f7b5 100644
--- a/tests/test_interval.py
+++ b/tests/test_interval.py
@@ -1,47 +1,47 @@
 """Test suite for the interval module."""
 
-import fitz
-from stratigraphy.depth import AAboveBInterval, AToBInterval
-from stratigraphy.sidebar.sidebarentry import DepthColumnEntry
+import pymupdf
+from stratigraphy.depth.a_to_b_interval_extractor import AToBIntervalExtractor
+from stratigraphy.lines.line import TextLine, TextWord
 
-
-def test_line_anchor():  # noqa: D103
-    """Test the line anchor property of the AAboveBInterval and AToBInterval classes."""
-    start = DepthColumnEntry(fitz.Rect(0, 0, 1, 1), 5)
-    end = DepthColumnEntry(fitz.Rect(0, 2, 1, 3), 10)
-    a_above_b_interval = AAboveBInterval(start, end)
-    assert a_above_b_interval.line_anchor == fitz.Point(1, 1.5), (
-        "The 'line anchor' for an AAboveBInterval should be halfway between the bottom-right of the start depth and "
-        "the top-right of the end depth."
-    )
-
-    a_above_b_interval = AAboveBInterval(start, end=None)
-    assert a_above_b_interval.line_anchor == fitz.Point(
-        1, 1
-    ), "The 'line anchor' for an AAboveBInterval without end should be the bottom-right of the start depth."
-
-    a_above_b_interval = AAboveBInterval(start=None, end=end)
-    assert a_above_b_interval.line_anchor == fitz.Point(
-        1, 2
-    ), "The 'line anchor' for a AAboveBInterval without start should be the top-right of the end depth."
-
-    start = DepthColumnEntry(fitz.Rect(0, 0, 1, 1), 5)
-    end = DepthColumnEntry(fitz.Rect(2, 0, 3, 1), 10)
-    a_to_b_interval = AToBInterval(start, end)
-    assert a_to_b_interval.line_anchor == fitz.Point(
-        3, 0.5
-    ), "The 'line anchor' for an AToBInterval should be the midpoint of the right-hand-side of the end rect."
-
-
-def test_background_rect():  # noqa: D103
-    """Test the background_rect property of the AAboveBInterval class."""
-    start = DepthColumnEntry(fitz.Rect(0, 0, 1, 1), 5)
-    end = DepthColumnEntry(fitz.Rect(0, 2, 1, 3), 10)
-    a_above_b_interval = AAboveBInterval(start, end)
-    assert a_above_b_interval.background_rect == fitz.Rect(
-        start.rect.x0, start.rect.y1, start.rect.x1, end.rect.y0
-    ), "The background rect should be (0, 1, 1, 2)"
-    assert a_above_b_interval.background_rect == fitz.Rect(0, 1, 1, 2), "The background rect should be (0, 1, 1, 2)"
+# TODO: add tests for AAboveBInterval.matching_blocks
 
 
-# TODO: add tests for AAboveBInterval.matching_blocks
+def test_atobintervalextractor_fromtext():  # noqa: D103
+    """Test the from_text class-method of the AToBIntervalExtractor class."""
+    words = [
+        TextWord(pymupdf.Rect(0, 0, 2, 1), "COLLUVIONS:", page=1),
+        TextWord(pymupdf.Rect(2, 0, 3, 1), "1,70", page=1),
+        TextWord(pymupdf.Rect(3, 0, 4, 1), "-", page=1),
+        TextWord(pymupdf.Rect(4, 0, 5, 1), "5,80", page=1),
+        TextWord(pymupdf.Rect(5, 0, 6, 1), "m", page=1),
+    ]
+    interval = AToBIntervalExtractor.from_text(TextLine(words), require_start_of_string=False)
+    assert interval.start.value == 1.70, "The start value of the interval should be 1.70m"
+    assert interval.start.rect == pymupdf.Rect(2, 0, 3, 1), "The start rect of the interval should be correct"
+    assert interval.end.value == 5.80, "The start value of the interval should be 5.80m"
+    assert interval.end.rect == pymupdf.Rect(4, 0, 5, 1), "The end rect of the interval should be correct"
+
+    words = [
+        TextWord(pymupdf.Rect(0, 0, 2, 1), "COLLUVIONS:", page=1),
+        TextWord(pymupdf.Rect(2, 0, 6, 1), "1,70-5,80m", page=1),
+    ]
+    interval = AToBIntervalExtractor.from_text(TextLine(words), require_start_of_string=False)
+    assert interval.start.value == 1.70, "The start value of the interval should be 1.70m"
+    assert interval.start.rect == pymupdf.Rect(2, 0, 6, 1), "The start rect of the interval should be correct"
+    assert interval.end.value == 5.80, "The start value of the interval should be 5.80m"
+    assert interval.end.rect == pymupdf.Rect(2, 0, 6, 1), "The end rect of the interval should be correct"
+
+    words = [
+        TextWord(pymupdf.Rect(0, 0, 2, 1), "COLLUVIONS:", page=1),
+        TextWord(pymupdf.Rect(2, 0, 6, 1), "1,70-5,80m", page=1),
+    ]
+    interval = AToBIntervalExtractor.from_text(TextLine(words), require_start_of_string=True)
+    assert interval is None, "With require_start_of_string=True, matches that are not at the start are not allowed."
+
+    words = [
+        TextWord(pymupdf.Rect(0, 0, 4, 1), "1,70-5,80m:", page=1),
+        TextWord(pymupdf.Rect(4, 0, 6, 1), "COLLUVIONS", page=1),
+    ]
+    interval = AToBIntervalExtractor.from_text(TextLine(words), require_start_of_string=False)
+    assert interval is not None, "With require_start_of_string=False, matches that are at the start are allowed."
diff --git a/tests/test_layerdepths.py b/tests/test_layerdepths.py
new file mode 100644
index 00000000..40828931
--- /dev/null
+++ b/tests/test_layerdepths.py
@@ -0,0 +1,47 @@
+"""Test suite for the LayerDepths class."""
+
+import fitz
+from stratigraphy.layer.layer import LayerDepths, LayerDepthsEntry
+
+
+def test_line_anchor():  # noqa: D103
+    """Test the line anchor property of the LayerDepths class."""
+    start = LayerDepthsEntry(5, fitz.Rect(0, 0, 1, 1))
+    end = LayerDepthsEntry(10, fitz.Rect(0, 2, 1, 3))
+    assert LayerDepths(start, end).line_anchor == fitz.Point(1, 1.5), (
+        "When the depth values are below each other, the line anchor should be halfway between the bottom-right of "
+        "the start depth and the top-right of the end depth."
+    )
+
+    assert LayerDepths(start, end=None).line_anchor == fitz.Point(
+        1, 1
+    ), "When there is no end depth, the line anchor should be the bottom-right of the start depth."
+
+    assert LayerDepths(start=None, end=end).line_anchor == fitz.Point(
+        1, 2
+    ), "When there is no start depth, the line anchor should be the top-right of the end depth."
+
+    left = LayerDepthsEntry(5, fitz.Rect(0, 0, 1, 1))
+    right = LayerDepthsEntry(10, fitz.Rect(2, 0, 3, 1))
+    assert LayerDepths(left, right).line_anchor == fitz.Point(3, 0.5), (
+        "When the depth entries are next to each other, the line anchor should on the right-hand-side of the "
+        "right-most rect."
+    )
+
+
+def test_background_rect():  # noqa: D103
+    """Test the background_rect property of the LayerDepths class."""
+    start = LayerDepthsEntry(5, fitz.Rect(0, 0, 1, 1))
+    end = LayerDepthsEntry(10, fitz.Rect(0, 2, 1, 3))
+    depths = LayerDepths(start, end)
+    assert depths.background_rect == fitz.Rect(
+        start.rect.x0, start.rect.y1, start.rect.x1, end.rect.y0
+    ), "The background rect should be (0, 1, 1, 2)"
+    assert depths.background_rect == fitz.Rect(0, 1, 1, 2), "The background rect should be (0, 1, 1, 2)"
+
+    rect = fitz.Rect(0, 0, 1, 1)
+    start = LayerDepthsEntry(5, rect)
+    end = LayerDepthsEntry(10, rect)
+    assert (
+        LayerDepths(start, end).background_rect is None
+    ), "When start and end depths are overlapping, there should be no background rect."