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).

Discussion:

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."