Repository: HakaiInstitute/kelp-o-matic (language: python)

Issue:
Move preprocessing transforms into model definition
Transforms are often specific to the model the data is being fed into, so it makes sense to define them as part of the model.

This opens the door for releasing additional models that may have different transforms.  


Discussion / hints:

Fix patch:
diff --git a/docs/beginner_guide/setup.md b/docs/beginner_guide/setup.md
index 6958b09..76e0b4f 100644
--- a/docs/beginner_guide/setup.md
+++ b/docs/beginner_guide/setup.md
@@ -70,7 +70,7 @@ At this stage, you can directly reference the [installation instructions](../ins
 
 ### Install PyTorch
 
-Kelp-O-Matic relies on PyTorch to run. PyTorch is a machine learning library for Python. 
+Kelp-O-Matic relies on PyTorch to run. PyTorch is a machine learning library for Python.
 PyTorch can be installed to use your CPU only, or can make use of an NVIDIA GPUs you may have in your machine to accelerate processing.
 
 The most up-to-date and reliable instructions for installing PyTorch can be found on the [PyTorch website](https://pytorch.org).
diff --git a/docs/cli.md b/docs/cli.md
index fab55c1..17c00e5 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -3,76 +3,52 @@
 The `kelp-o-matic` package includes one command line tool, `kom`. It will be registered in the same Conda environment
 that the `kelp-o-matic` package is installed to.
 
-```bash
+```
 $ kom --help
 
-Usage: kom [OPTIONS] COMMAND [ARGS]...
+ Usage: python -m kelp_o_matic.cli [OPTIONS] COMMAND [ARGS]...
 
-Options:
-  --install-completion  Install completion for the current shell.
-  --show-completion     Show completion for the current shell, to copy it or
-                        customize the installation.
-  --help                Show this message and exit.
+   Options
+  --version             -v
+  --install-completion          [bash|zsh|fish|powershell|pwsh]  Install completion for the specified shell. [default: None]
+  --show-completion             [bash|zsh|fish|powershell|pwsh]  Show completion for the specified shell, to copy it or customize the installation. [default: None]
+  --help                -h                                       Show this message and exit.
 
-Commands:
-  find-kelp     Detect kelp in image at path `source` and output the...
-  find-mussels  Detect mussels in image at path `source` and output the...
+   Commands
+  find-kelp           Detect kelp in image at path SOURCE and output the resulting classification raster to file at path DEST.
+  find-mussels        Detect mussels in image at path SOURCE and output the resulting classification raster to file at path DEST.
 ```
 
 ## find-kelp
 
-```bash
-$ kom find-kelp --help
-
-Usage: kom find-kelp [OPTIONS] SOURCE DEST
-
-  Detect kelp in image at path SOURCE and output the resulting
-  classification raster to file at path DEST.
-
-Arguments:
-  SOURCE  Input image with Byte data type.  [required]
-  DEST    File path location to save output to.  [required]
-
-Options:
-  --species / --presence  Segment to species or presence/absence level.
-                          [default: presence]
-  --crop-size INTEGER     The size for the cropped image squares run through
-                          the segmentation model.  [default: 512]
-  --padding INTEGER       The number of context pixels added to each side of
-                          the image crops.  [default: 256]
-  --batch-size INTEGER    The batch size of cropped image sections to process
-                          together.  [default: 1]
-  --gpu / --no-gpu        Enable or disable GPU, if available.  [default: gpu]
-  --help                  Show this message and exit.
 ```
+$ kom find-kelp --help
 
-!!! Example
-
-    ```bash
-    kom find-kelp --species --crop-size=1024 ./some/image_with_kelp.tif ./some/place_to_write_output.tif
-    ```
+ Usage: python -m kelp_o_matic.cli find-kelp [OPTIONS] SOURCE DEST
 
-???+ tip "Tip: Reduce windowed processing artifacts"
+ Detect kelp in image at path SOURCE and output the resulting classification raster to file at path DEST.
 
-    To reduce artifacts caused by Kelp-O-Matic's moving window classification, use the largest `crop_size` that you can.
-    Try starting with a crop_size around 3200 pixels and reduce it if your computer is unable to load that much data at once and the application crashes.
+   Arguments
+  *    source      TEXT  Input image with Byte data type. [default: None] [required]
+  *    dest        TEXT  File path location to save output to. [default: None] [required]
 
-    We hope to improve Kelp-O-Matic in the future such that the maximum crop size for your computer can be determined automatically.
+   Options
+  --species        --presence             Segment to species or presence/absence level. [default: presence]
+  --crop-size                    INTEGER  The data window size to run through the segmentation model. [default: 1024]
+  --gpu            --no-gpu               Enable or disable GPU, if available. [default: gpu]
+  --help       -h                         Show this message and exit.
+```
 
-    CLI Example:
+!!! Example
 
     ```bash
-    kom --crop-size=3200 your-input-image.tif output-image.tif
+    kom find-kelp --species --crop-size=1024 ./some/image_with_kelp.tif ./some/place_to_write_output.tif
     ```
 
-    <div style="display:flex;">
-        <img alt="Small window output" src="../images/kom-small-window.png" width="50%"/>
-        <img alt="Big window output" src="../images/kom-big-window.png" width="50%"/>
-    </div>
-
 [//]: # (??? info "Info: Misclassifications over land")
 
 [//]: # ()
+
 [//]: # (    Currently, Kelp-O-Matic is mostly optimized to differentiate between canopy-forming kelp, water, and)
 
 [//]: # (    near-shore land. It is a known issue that inland vegetation is sometimes misclassified as)
@@ -80,33 +56,28 @@ Options:
 [//]: # (    kelp. )
 
 [//]: # ()
+
 [//]: # (    Please check out our [post-process documentation]&#40;post_process.md&#41; for our recommendations on)
 
 [//]: # (    cleaning up the output classification mask.)
 
 ## find-mussels
 
-```bash
+```
 $ kom find-mussels --help
 
-Usage: kom find-mussels [OPTIONS] SOURCE DEST
+ Usage: python -m kelp_o_matic.cli find-mussels [OPTIONS] SOURCE DEST
 
-  Detect mussels in image at path SOURCE and output the resulting
-  classification raster to file at path DEST.
+ Detect mussels in image at path SOURCE and output the resulting classification raster to file at path DEST.
 
-Arguments:
-  SOURCE  Input image with Byte data type.  [required]
-  DEST    File path location to save output to.  [required]
+   Arguments
+  *    source      TEXT  Input image with Byte data type. [default: None] [required]
+  *    dest        TEXT  File path location to save output to. [default: None] [required]
 
-Options:
-  --crop-size INTEGER   The size for the cropped image squares run through the
-                        segmentation model.  [default: 512]
-  --padding INTEGER     The number of context pixels added to each side of the
-                        image crops.  [default: 256]
-  --batch-size INTEGER  The batch size of cropped image sections to process
-                        together.  [default: 1]
-  --gpu / --no-gpu      Enable or disable GPU, if available.  [default: gpu]
-  --help                Show this message and exit.
+   Options
+  --crop-size                  INTEGER  The data window size to run through the segmentation model. [default: 1024]
+  --gpu            --no-gpu             Enable or disable GPU, if available. [default: gpu]
+  --help       -h                       Show this message and exit.
 ```
 
 !!! example
diff --git a/docs/installation.md b/docs/installation.md
index 97ac8bd..4f0a498 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -18,15 +18,15 @@ that you're using the most up-to-date version of `kelp-o-matic`.
 
 ### Pre-requisites
 
-Install `pytorch` and `torchvision` for your operating system using the 
-[official installation instructions here](https://pytorch.org/). 
+Install `pytorch` and `torchvision` for your operating system using the
+[official installation instructions here](https://pytorch.org/).
 Make sure you select "CUDA" as the compute platform if you have an NVIDIA GPU you'd like to use to improve performance.
 
 
 === "Conda"
 
     ### Install
-    
+
     ```bash
     conda install -c conda-forge kelp-o-matic
     ```
diff --git a/kelp_o_matic/__init__.py b/kelp_o_matic/__init__.py
index 5d3cb58..d1065bc 100644
--- a/kelp_o_matic/__init__.py
+++ b/kelp_o_matic/__init__.py
@@ -1,13 +1,7 @@
-from kelp_o_matic import geotiff_io, models
 from kelp_o_matic.lib import find_kelp, find_mussels
-from kelp_o_matic.managers import GeotiffSegmentationManager, RichSegmentationManager
 
 __all__ = [
     "find_kelp",
     "find_mussels",
-    "geotiff_io",
-    "models",
-    "GeotiffSegmentationManager",
-    "RichSegmentationManager",
 ]
 __version__ = "0.0.0"
diff --git a/kelp_o_matic/cli.py b/kelp_o_matic/cli.py
index 031faf4..e776c1e 100644
--- a/kelp_o_matic/cli.py
+++ b/kelp_o_matic/cli.py
@@ -21,15 +21,9 @@ def find_kelp(
         help="Segment to species or presence/absence level.",
     ),
     crop_size: int = typer.Option(
-        512,
+        1024,
         help="The data window size to run through the segmentation model.",
     ),
-    padding: int = typer.Option(
-        256, help="The number of context pixels added to each side of the image crops."
-    ),
-    batch_size: int = typer.Option(
-        1, help="The batch size of cropped image sections to process together."
-    ),
     use_gpu: bool = typer.Option(
         True, "--gpu/--no-gpu", help="Enable or disable GPU, if available."
     ),
@@ -43,9 +37,7 @@ def find_kelp(
         if species
         else KelpPresenceSegmentationModel(use_gpu=use_gpu)
     )
-    manager = RichSegmentationManager(
-        model, source, dest, crop_size=crop_size, padding=padding, batch_size=batch_size
-    )
+    manager = RichSegmentationManager(model, source, dest, crop_size=crop_size)
     manager()
 
 
@@ -54,15 +46,9 @@ def find_mussels(
     source: str = typer.Argument(..., help="Input image with Byte data type."),
     dest: str = typer.Argument(..., help="File path location to save output to."),
     crop_size: int = typer.Option(
-        512,
+        1024,
         help="The data window size to run through the segmentation model.",
     ),
-    padding: int = typer.Option(
-        256, help="The number of context pixels added to each side of the image crops."
-    ),
-    batch_size: int = typer.Option(
-        1, help="The batch size of cropped image sections to process together."
-    ),
     use_gpu: bool = typer.Option(
         True, "--gpu/--no-gpu", help="Enable or disable GPU, if available."
     ),
@@ -72,9 +58,7 @@ def find_mussels(
     raster to file at path DEST.
     """
     model = MusselPresenceSegmentationModel(use_gpu=use_gpu)
-    manager = RichSegmentationManager(
-        model, source, dest, crop_size=crop_size, padding=padding, batch_size=batch_size
-    )
+    manager = RichSegmentationManager(model, source, dest, crop_size=crop_size)
     manager()
 
 
diff --git a/kelp_o_matic/geotiff_io/geotiff_reader.py b/kelp_o_matic/geotiff_io/geotiff_reader.py
index 324e8e2..3294294 100644
--- a/kelp_o_matic/geotiff_io/geotiff_reader.py
+++ b/kelp_o_matic/geotiff_io/geotiff_reader.py
@@ -6,10 +6,11 @@
 """
 import itertools
 from pathlib import Path
-from typing import Callable, List, Optional, Tuple, Union
+from typing import List, Optional, Union
 
 import numpy as np
 import rasterio
+from rasterio.windows import Window
 from torch.utils.data import IterableDataset
 
 
@@ -18,10 +19,7 @@ def __init__(
         self,
         img_path: Union[str, "Path"],
         crop_size: int,
-        padding: Optional[int] = 0,
-        fill_value: Optional[Union[int, float]] = None,
-        transform: Optional[Callable] = None,
-        filter_: Optional[Callable] = None,
+        stride: Optional[int] = None,
     ):
         """A Pytorch dataset that returns cropped segments of a tif image file.
 
@@ -29,20 +27,13 @@ def __init__(
             img_path: The path to the image file to make the dataset from.
             crop_size: The desired edge length for each cropped section.
                 Returned images will be square.
-            padding: The amount of padding to add around each crop section from adjacent
-                image areas. Defaults to 0.
-            fill_value: The value to fill in border regions of nodata areas of the
-                image. Defaults to image nodata value.
-            transform: Optional Pytorch style data transform to apply to each cropped
-                section.
-            filter_: Optional function to filter a chip out from the iterator.
-                Must have signature `(img: np.array) -> bool`
+            stride: The stride to use when cropping the image. Defaults to `crop_size`.
         """
         super().__init__()
 
         self.img_path = img_path
         self.crop_size = crop_size
-        self.padding = padding
+        self.stride = stride if stride is not None else crop_size
 
         with rasterio.open(img_path, "r") as src:
             self.height = src.height
@@ -52,89 +43,51 @@ def __init__(
             self.profile = src.profile
             self.block_shapes = src.block_shapes
 
-        if fill_value is not None:
-            self.fill_value = fill_value
-        elif self.nodata is not None:
-            self.fill_value = self.nodata
-        else:
-            self.fill_value = 0
-
-        self.transform = transform
-        self.filter = filter_
-
-        _y0s = range(0, self.height, self.crop_size)
-        _x0s = range(0, self.width, self.crop_size)
-        self.y0x0 = list(itertools.product(_y0s, _x0s))
-
-        self.idx = 0
+        self._y0s = list(range(0, self.height, self.stride))
+        self._x0s = list(range(0, self.width, self.stride))
+        self.y0x0 = list(itertools.product(self._y0s, self._x0s))
 
     def __len__(self) -> int:
         return len(self.y0x0)
 
-    def _get_crop(self, idx: int) -> np.ndarray:
+    def get_window(self, idx: int) -> Window:
         y0, x0 = self.y0x0[idx]
-
-        # Read the image section
-        window = (
-            (y0 - self.padding, y0 + self.crop_size + self.padding),
-            (x0 - self.padding, x0 + self.crop_size + self.padding),
+        return Window.from_slices(
+            (y0, min(y0 + self.crop_size, self.height)),
+            (x0, min(x0 + self.crop_size, self.width)),
         )
 
-        with rasterio.open(self.img_path, "r") as src:
-            crop = src.read(window=window, boundless=True, fill_value=self.fill_value)
+    def is_top_window(self, window: Window):
+        return window.row_off == 0
 
-        if len(crop.shape) == 3:
-            crop = np.moveaxis(crop, 0, 2)  # (c, h, w) => (h, w, c)
-            if crop.shape[2] == 1:
-                crop = np.squeeze(crop, axis=2)  # (h, w, 1) => (h, w)
+    def is_bottom_window(self, window: Window):
+        return window.row_off == self._y0s[-1]
+
+    def is_left_window(self, window: Window):
+        return window.col_off == 0
+
+    def is_right_window(self, window: Window):
+        return window.col_off == self._x0s[-1]
 
-        return crop
+    def __getitem__(self, idx: int) -> ("np.ndarray", Window):
+        window = self.get_window(idx)
 
-    def __getitem__(self, idx: int) -> np.ndarray:
-        crop = self._get_crop(idx)
+        with rasterio.open(self.img_path, "r") as src:
+            crop = src.read(window=window)
 
-        if self.transform:
-            crop = self.transform(crop)
+        if len(crop.shape) == 3:
+            crop = np.moveaxis(crop, 0, 2)  # (c, h, w) => (h, w, c)
 
-        return crop
+        return crop, window
 
     def __iter__(self):
-        self.idx = 0
-        return self
-
-    def __next__(self) -> Tuple[np.ndarray, int]:
-        # Assumes single worker process
-        if self.idx < len(self):
-            crop = self._get_crop(self.idx)
-            self.idx += 1
-        else:
-            raise StopIteration
-
-        if self.filter is not None:
-            # Get crops until one meets the filter criteria
-            while True:
-                if self.filter(
-                    crop[
-                        self.padding : self.crop_size + self.padding,
-                        self.padding : self.crop_size + self.padding,
-                    ]
-                ):
-                    break
-                elif self.idx < len(self):
-                    crop = self._get_crop(self.idx)
-                    self.idx += 1
-                else:
-                    raise StopIteration
-
-        if self.transform:
-            crop = self.transform(crop)
-
-        return crop, self.idx - 1
+        for i in range(len(self)):
+            yield self[i]
 
     @property
     def y0(self) -> List[int]:
-        return [a[0] for a in self.y0x0]
+        return self._y0s
 
     @property
     def x0(self) -> List[int]:
-        return [a[1] for a in self.y0x0]
+        return self._x0s
diff --git a/kelp_o_matic/geotiff_io/geotiff_writer.py b/kelp_o_matic/geotiff_io/geotiff_writer.py
index ec85c2c..5d39cbb 100644
--- a/kelp_o_matic/geotiff_io/geotiff_writer.py
+++ b/kelp_o_matic/geotiff_io/geotiff_writer.py
@@ -4,24 +4,19 @@
 Date: 2021-02-22
 Description: A Pytorch Dataset for geotiff images that dynamically crops the image.
 """
-import itertools
 from pathlib import Path
 from typing import Union
 
 import numpy as np
 import rasterio
+from rasterio.windows import Window
 
 from kelp_o_matic.geotiff_io import GeotiffReader
 
 
 class GeotiffWriter:
     def __init__(
-        self,
-        img_path: Union[str, "Path"],
-        profile: dict,
-        crop_size: int,
-        padding: int = 0,
-        **kwargs
+        self, img_path: Union[str, "Path"], profile: dict, crop_size: int, **kwargs
     ):
         """Write a tif file in small sections.
 
@@ -29,13 +24,11 @@ def __init__(
             img_path: The path to save the output file to.
             profile: Profile to pass to rasterio with crs and geo-transform information.
             crop_size: The size of each section being written.
-            padding: Padding data to remove from each write data section.
             **kwargs: All other kwargs are passed to the geotiff rasterio profile.
         """
         super().__init__()
         self.img_path = img_path
         self.crop_size = crop_size
-        self.padding = padding
 
         profile.update(blockxsize=crop_size, blockysize=crop_size, tiled=True, **kwargs)
 
@@ -45,10 +38,6 @@ def __init__(
             self.width = dst.width
             self.profile = dst.profile
 
-        _y0s = range(0, self.height, self.crop_size)
-        _x0s = range(0, self.width, self.crop_size)
-        self.y0x0 = list(itertools.product(_y0s, _x0s))
-
     @classmethod
     def from_reader(
         cls, img_path: Union[str, "Path"], reader: "GeotiffReader", **kwargs
@@ -66,35 +55,16 @@ def from_reader(
         Returns:
             CropDatasetWriter
         """
-        self = cls(
-            img_path,
-            profile=reader.profile,
-            crop_size=reader.crop_size,
-            padding=reader.padding,
-            **kwargs
-        )
-        self.y0x0 = reader.y0x0
-        return self
-
-    def write_index(self, write_data: np.ndarray, idx: int):
-        y0, x0 = self.y0x0[idx]
-
-        # Read the image section
-        window = (
-            (y0, min(y0 + self.crop_size, self.height)),
-            (x0, min(x0 + self.crop_size, self.width)),
+        return cls(
+            img_path, profile=reader.profile, crop_size=reader.crop_size, **kwargs
         )
 
-        # Remove padding information
-        write_data = write_data[
-            self.padding : -self.padding, self.padding : -self.padding
-        ]
-
+    def write_window(self, write_data: np.ndarray, window: Window):
         # Remove data that goes past the boundaries
-        dh = window[0][1] - window[0][0]
-        dw = window[1][1] - window[1][0]
+        write_data = write_data[: window.height, : window.width].astype(
+            self.profile["dtype"]
+        )
 
         # Write the data
-        write_data = write_data[:dh, :dw].astype(self.profile["dtype"])
         with rasterio.open(self.img_path, "r+") as dst:
             dst.write(write_data, 1, window=window)
diff --git a/kelp_o_matic/hann.py b/kelp_o_matic/hann.py
new file mode 100644
index 0000000..badec5c
--- /dev/null
+++ b/kelp_o_matic/hann.py
@@ -0,0 +1,168 @@
+import math
+from abc import ABCMeta, abstractmethod
+
+import numpy as np
+import rasterio
+import torch
+from rasterio.windows import Window
+
+
+# Implementation of paper:
+# https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0229839#pone.0229839.ref007
+
+
+class Kernel(torch.nn.Module, metaclass=ABCMeta):
+    def __init__(
+        self, size: int = 512, device: torch.device.type = torch.device("cpu")
+    ):
+        super().__init__()
+        self.size = size
+        self.wi = self._init_wi(size, device)
+        self.wj = self.wi.clone()
+
+    @staticmethod
+    @abstractmethod
+    def _init_wi(size: int, device: torch.device.type) -> torch.Tensor:
+        raise NotImplementedError
+
+    def get_kernel(
+        self,
+        top: bool = False,
+        bottom: bool = False,
+        left: bool = False,
+        right: bool = False,
+    ) -> torch.Tensor:
+        wi, wj = self.wi.clone(), self.wj.clone()
+
+        if top:
+            wi[: self.size // 2] = 1
+        if bottom:
+            wi[self.size // 2 :] = 1
+
+        if left:
+            wj[: self.size // 2] = 1
+        if right:
+            wj[self.size // 2 :] = 1
+
+        return wi.unsqueeze(1) @ wj.unsqueeze(0)
+
+    def forward(
+        self,
+        x: torch.Tensor,
+        top: bool = False,
+        bottom: bool = False,
+        left: bool = False,
+        right: bool = False,
+    ) -> torch.Tensor:
+        kernel = self.get_kernel(top=top, bottom=bottom, left=left, right=right)
+        return torch.mul(x, kernel)
+
+
+class HannKernel(Kernel):
+    @staticmethod
+    def _init_wi(size: int, device: torch.device.type) -> torch.Tensor:
+        i = torch.arange(0, size, device=device)
+        return (1 - ((2 * np.pi * i) / (size - 1)).cos()) / 2
+
+
+class BartlettHannKernel(Kernel):
+    @staticmethod
+    def _init_wi(size: int, device: torch.device.type) -> torch.Tensor:
+        # Follows original paper:
+        # Ha YH, Pearce JA. A new window and comparison to standard windows.
+        # IEEE Transactions on Acoustics, Speech, and Signal Processing.
+        # 1989;37(2):298–301.
+        i = torch.arange(0, size, device=device)
+        return (
+            0.62
+            - 0.48 * (i / size - 1 / 2).abs()
+            + 0.38 * (2 * np.pi * (i / size - 1 / 2).abs()).cos()
+        )
+
+
+class TriangularKernel(Kernel):
+    @staticmethod
+    def _init_wi(size: int, device: torch.device.type) -> torch.Tensor:
+        i = torch.arange(0, size, device=device)
+        return 1 - (2 * i / size - 1).abs()
+
+
+class BlackmanKernel(Kernel):
+    @staticmethod
+    def _init_wi(size: int, device: torch.device.type) -> torch.Tensor:
+        i = torch.arange(0, size, device=device)
+        return (
+            0.42
+            - 0.5 * (2 * np.pi * i / size).cos()
+            + 0.08 * (4 * np.pi * i / size).cos()
+        )
+
+
+class TorchMemoryRegister(object):
+    def __init__(
+        self,
+        image_path: str,
+        num_classes: int,
+        window_size: int,
+        device: torch.device.type,
+    ):
+        super().__init__()
+        self.image_path = image_path
+        self.n = num_classes
+        self.ws = window_size
+        self.hws = window_size // 2
+        self.device = device
+
+        # Copy metadata from img
+        with rasterio.open(image_path, "r") as src:
+            src_width = src.width
+
+        self.height = self.ws
+        self.width = (math.ceil(src_width / self.ws) * self.ws) + self.hws
+        self.register = torch.zeros(
+            (self.n, self.height, self.width), device=self.device
+        )
+
+    @property
+    def _zero_chip(self):
+        return torch.zeros(
+            (self.n, self.hws, self.hws), dtype=torch.float, device=self.device
+        )
+
+    def step(self, new_logits: torch.Tensor, img_window: Window):
+        # 1. Read data from the registry to update with the new logits
+        # |a|b| |
+        # |c|d| |
+        with torch.no_grad():
+            logits_abcd = self.register[
+                :, :, img_window.col_off : img_window.col_off + self.ws
+            ].clone()
+            logits_abcd += new_logits
+
+        # Update the registry and pop information-complete data
+        # |c|b| | + pop a
+        # |0|d| |
+        logits_a = logits_abcd[:, : self.hws, : self.hws]
+        logits_c = logits_abcd[:, self.hws :, : self.hws]
+        logits_c0 = torch.concat([logits_c, self._zero_chip], dim=1)
+        logits_bd = logits_abcd[:, :, self.hws :]
+
+        # write c0
+        self.register[
+            :, :, img_window.col_off : img_window.col_off + self.hws
+        ] = logits_c0
+
+        # write bd
+        col_off_bd = img_window.col_off + self.hws
+        self.register[:, :, col_off_bd : col_off_bd + self.hws] = logits_bd
+
+        # Return the information-complete predictions
+        preds_win = Window(
+            col_off=img_window.col_off,
+            row_off=img_window.row_off,
+            height=min(self.hws, img_window.height),
+            width=min(self.hws, img_window.width),
+        )
+        preds = logits_a[:, : img_window.height, : img_window.width].softmax(axis=0)
+
+        return preds, preds_win
diff --git a/kelp_o_matic/lib.py b/kelp_o_matic/lib.py
index f2ef80c..5e9f010 100644
--- a/kelp_o_matic/lib.py
+++ b/kelp_o_matic/lib.py
@@ -10,9 +10,7 @@ def find_kelp(
     source: str,
     dest: str,
     species: bool = False,
-    crop_size: int = 512,
-    padding: int = 256,
-    batch_size: int = 1,
+    crop_size: int = 1024,
     use_gpu: bool = True,
 ):
     """
@@ -24,25 +22,19 @@ def find_kelp(
         dest: File path location to save output to.
         species: Do species classification instead of presence/absence.
         crop_size: The size of cropped image square run through the segmentation model.
-        padding: The number of context pixels added to each side of the cropped image.
-        batch_size: The batch size of cropped image sections to process together.
         use_gpu: Disable Cuda GPU usage and run on CPU only.
     """
     if species:
         model = KelpSpeciesSegmentationModel(use_gpu=use_gpu)
     else:
         model = KelpPresenceSegmentationModel(use_gpu=use_gpu)
-    RichSegmentationManager(
-        model, source, dest, crop_size=crop_size, padding=padding, batch_size=batch_size
-    )()
+    RichSegmentationManager(model, source, dest, crop_size=crop_size)()
 
 
 def find_mussels(
     source: str,
     dest: str,
-    crop_size: int = 512,
-    padding: int = 256,
-    batch_size: int = 1,
+    crop_size: int = 1024,
     use_gpu: bool = True,
 ):
     """
@@ -53,11 +45,7 @@ def find_mussels(
         source: Input image with Byte data type.
         dest: File path location to save output to.
         crop_size: The size of cropped image square run through the segmentation model.
-        padding: The number of context pixels added to each side of the cropped image.
-        batch_size: The batch size of cropped image sections to process together.
         use_gpu: Disable Cuda GPU usage and run on CPU only.
     """
     model = MusselPresenceSegmentationModel(use_gpu=use_gpu)
-    RichSegmentationManager(
-        model, source, dest, crop_size=crop_size, padding=padding, batch_size=batch_size
-    )()
+    RichSegmentationManager(model, source, dest, crop_size=crop_size)()
diff --git a/kelp_o_matic/managers.py b/kelp_o_matic/managers.py
index c4b2cdb..2c50376 100644
--- a/kelp_o_matic/managers.py
+++ b/kelp_o_matic/managers.py
@@ -2,14 +2,13 @@
 from pathlib import Path
 from typing import Union
 
-import numpy as np
 import rasterio
+import torch
 from rich import print
 from rich.progress import Progress, SpinnerColumn, TimeElapsedColumn
-from torch.utils.data import DataLoader
-from torchvision import transforms
 
 from kelp_o_matic.geotiff_io import GeotiffReader, GeotiffWriter
+from kelp_o_matic.hann import TorchMemoryRegister, BartlettHannKernel
 from kelp_o_matic.models import _Model
 from kelp_o_matic.utils import all_same
 
@@ -22,9 +21,7 @@ def __init__(
         model: "_Model",
         input_path: Union[str, "Path"],
         output_path: Union[str, "Path"],
-        crop_size: int = 512,
-        padding: int = 256,
-        batch_size: int = 1,
+        crop_size: int = 1024,
     ):
         """Create the segmentation object.
 
@@ -35,56 +32,28 @@ def __init__(
             output_path: The destination file path for the output segmentation data.
             crop_size: The size of image crop to classify iteratively until the entire
                 image is classified.
-            padding: The number of context pixels to add to each side of an image crop
-                to improve outputs.
-            batch_size: The number of crops to classify at a time using the model.
         """
         self.model = model
+        self.crop_size = crop_size
+        self.input_path = str(Path(input_path).expanduser().resolve())
+        self.output_path = str(Path(output_path).expanduser().resolve())
 
-        tran = transforms.Compose(
-            [
-                transforms.ToTensor(),
-                transforms.Lambda(lambda img: img[:3, :, :]),
-                transforms.Normalize(
-                    mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]
-                ),
-            ]
-        )
         self.reader = GeotiffReader(
-            Path(input_path).expanduser().resolve(),
-            transform=tran,
+            self.input_path,
             crop_size=crop_size,
-            padding=padding,
-            filter_=self._should_keep,
-        )
-        self._dataloader = DataLoader(
-            self.reader,
-            shuffle=False,
-            batch_size=batch_size,
-            pin_memory=True,
-            num_workers=0,
+            stride=crop_size // 2,
         )
-
         self.writer = GeotiffWriter.from_reader(
-            Path(output_path).expanduser().resolve(),
+            self.output_path,
             self.reader,
             count=1,
             dtype="uint8",
             nodata=0,
         )
-
-    @staticmethod
-    def _should_keep(img: "np.ndarray") -> bool:
-        """Determines if an image crop should be classified or discarded.
-
-        Args:
-            img: The image crop, with padding removed.
-
-        Returns:
-             Flag to indicate if crop should be discarded.
-        """
-        _img = np.clip(img, 0, 255)
-        return bool(np.any(_img % 255))
+        self.kernel = BartlettHannKernel(crop_size, self.model.device)
+        self.register = TorchMemoryRegister(
+            self.input_path, self.model.register_depth, crop_size, self.model.device
+        )
 
     def __call__(self):
         """Run the segmentation task."""
@@ -92,20 +61,35 @@ def __call__(self):
         self._run_checks()
 
         with rasterio.Env():
-            for batch_idx, batch in enumerate(self._dataloader):
-                self.on_batch_start(batch_idx)
-
-                crops, indices = batch
-                labels = self.model(crops).detach().cpu().numpy()
+            for index, batch in enumerate(self.reader):
+                crop, read_window = batch
+
+                if self.model.transform:
+                    crop = self.model.transform(crop)
+
+                if torch.all(crop == 0):
+                    logits = self.model.shortcut(self.reader.crop_size)
+                else:
+                    # Zero pad to correct shape
+                    _, h, w = crop.shape
+                    crop = torch.nn.functional.pad(
+                        crop, (0, self.crop_size - w, 0, self.crop_size - h), value=0
+                    )
+                    logits = self.model(crop.unsqueeze(0))[0]
+
+                logits = self.kernel(
+                    logits,
+                    top=self.reader.is_top_window(read_window),
+                    bottom=self.reader.is_bottom_window(read_window),
+                    left=self.reader.is_left_window(read_window),
+                    right=self.reader.is_right_window(read_window),
+                )
+                write_logits, write_window = self.register.step(logits, read_window)
+                labels = self.model.post_process(write_logits)
 
                 # Write outputs
-                for label, idx in zip(labels, indices):
-                    self.writer.write_index(label, int(idx))
-                    self.on_chip_write_end(int(idx))
-
-                del crops, indices, labels, batch
-
-                self.on_batch_end(batch_idx)
+                self.writer.write_window(labels, write_window)
+                self.on_tile_write(index)
         self.on_end()
 
     def _no_data_check(self):
@@ -135,7 +119,7 @@ def _block_tiles_check(self):
         if not all_same(self.reader.block_shapes):
             warnings.warn("Input image bands have different sized blocks.", UserWarning)
 
-        crop_shape = self.reader.crop_size + 2 * self.reader.padding
+        crop_shape = self.reader.crop_size
         y_shape, x_shape = self.reader.block_shapes[0]
         if y_shape == 1:
             warnings.warn(
@@ -168,16 +152,7 @@ def on_end(self):
         """Hook that runs after image processing."""
         pass
 
-    def on_batch_start(self, batch_idx: int):
-        """
-        Hook that runs for each batch of data, immediately before classification.
-
-        Args:
-            batch_idx: The batch index being processed.
-        """
-        pass
-
-    def on_batch_end(self, batch_idx: int):
+    def on_tile_write(self, batch_idx: int):
         """
         Hook that runs for each batch of data, immediately after classification.
 
@@ -186,15 +161,6 @@ def on_batch_end(self, batch_idx: int):
         """
         pass
 
-    def on_chip_write_end(self, index: int):
-        """
-        Hook that runs for each crop of data, immediately after classification.
-
-        Args:
-            index: The index of the image crop that was processed.
-        """
-        pass
-
 
 class RichSegmentationManager(GeotiffSegmentationManager):
     """Run the segmentation with Rich progress bars and logging."""
@@ -217,7 +183,7 @@ def on_start(self):
         device_emoji = ":rocket:" if self.model.device.type == "cuda" else ":snail:"
         print(f"Running with [magenta]{self.model.device} {device_emoji}")
 
-    def on_chip_write_end(self, index: int):
+    def on_tile_write(self, index: int):
         self.progress.update(self.processing_task, completed=index)
 
     def on_end(self):
diff --git a/kelp_o_matic/models.py b/kelp_o_matic/models.py
index 02f6bce..dc0165e 100644
--- a/kelp_o_matic/models.py
+++ b/kelp_o_matic/models.py
@@ -1,8 +1,12 @@
 import gc
 import importlib.resources as importlib_resources
 from abc import ABC, abstractmethod
+from typing import Union
 
+import numpy as np
 import torch
+import torchvision.transforms.functional as f
+from PIL.Image import Image
 
 from kelp_o_matic.data import (
     lraspp_kelp_presence_torchscript_path,
@@ -12,6 +16,14 @@
 
 
 class _Model(ABC):
+    register_depth = 2
+
+    @staticmethod
+    def transform(x: Union[np.ndarray, Image]) -> torch.Tensor:
+        x = f.to_tensor(x)[:3, :, :]
+        x = f.normalize(x, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
+        return x
+
     def __init__(self, use_gpu: bool = True):
         is_cuda = torch.cuda.is_available() and use_gpu
         self.device = torch.device("cuda") if is_cuda else torch.device("cpu")
@@ -33,9 +45,21 @@ def reload(self):
         gc.collect()
         self.model = self.load_model()
 
-    def __call__(self, batch: "torch.Tensor") -> "torch.Tensor":
+    def __call__(self, x: "torch.Tensor") -> "torch.Tensor":
         with torch.no_grad():
-            return torch.argmax(self.model.forward(batch.to(self.device)), dim=1)
+            return self.model.forward(x.to(self.device))
+
+    def post_process(self, x: "torch.Tensor") -> "np.ndarray":
+        return x.argmax(dim=0).detach().cpu().numpy()
+
+    def shortcut(self, crop_size: int):
+        """Shortcut prediction for when we know a cropped section is background.
+        Prevent unnecessary forward passes through model."""
+        logits = torch.zeros(
+            (self.register_depth, crop_size, crop_size), device=self.device
+        )
+        logits[0] = 1
+        return logits
 
 
 class KelpPresenceSegmentationModel(_Model):
@@ -44,20 +68,28 @@ class KelpPresenceSegmentationModel(_Model):
 
 class KelpSpeciesSegmentationModel(_Model):
     torchscript_path = lraspp_kelp_species_torchscript_path
+    register_depth = 4
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.presence_model = KelpPresenceSegmentationModel(*args, **kwargs)
 
-    def __call__(self, batch: "torch.Tensor") -> "torch.Tensor":
+    def __call__(self, x: "torch.Tensor") -> "torch.Tensor":
         with torch.no_grad():
-            batch = batch.to(self.device)
-            presence = self.presence_model(batch)  # 0: bg, 1: kelp
+            x = x.to(self.device)
+            presence_logits = self.presence_model(x)  # 0: bg, 1: kelp
+            species_logits = self.model.forward(x)  # 0: macro, 1: nerea
+            logits = torch.concat((presence_logits, species_logits), dim=1)
+
+        return logits  # 0: bg, 1: kelp, 2: macro, 3: nereo
 
-            logits = self.model.forward(batch)
-            species = torch.add(torch.argmax(logits, dim=1), 2)  # 2: macro, 3: nereo
+    def post_process(self, x: "torch.Tensor") -> "np.ndarray":
+        with torch.no_grad():
+            presence = torch.argmax(x[:2], dim=0)  # 0: bg, 1: kelp
+            species = torch.argmax(x[2:], dim=0) + 2  # 2: macro, 3: nereo
+            label = torch.mul(presence, species)  # 0: bg, 2: macro, 3: nereo
 
-            return torch.mul(presence, species)  # 0: bg, 2: macro, 3: nereo
+        return label.detach().cpu().numpy()
 
 
 class MusselPresenceSegmentationModel(_Model):
diff --git a/poetry.lock b/poetry.lock
index 1739704..843feef 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -331,15 +331,46 @@ smb = ["smbprotocol"]
 ssh = ["paramiko"]
 tqdm = ["tqdm"]
 
+[[package]]
+name = "ghp-import"
+version = "2.1.0"
+description = "Copy your docs directly to the gh-pages branch."
+optional = false
+python-versions = "*"
+files = [
+    {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"},
+    {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
+]
+
+[package.dependencies]
+python-dateutil = ">=2.8.1"
+
+[package.extras]
+dev = ["flake8", "markdown", "twine", "wheel"]
+
+[[package]]
+name = "griffe"
+version = "0.38.1"
+description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "griffe-0.38.1-py3-none-any.whl", hash = "sha256:334c79d3b5964ade65c05dfcaf53518c576dedd387aaba5c9fd71212f34f1483"},
+    {file = "griffe-0.38.1.tar.gz", hash = "sha256:bd68d7da7f3d87bc57eb9962b250db123efd9bbcc06c11c1a91b6e583b2a9361"},
+]
+
+[package.dependencies]
+colorama = ">=0.4"
+
 [[package]]
 name = "identify"
-version = "2.5.32"
+version = "2.5.33"
 description = "File identification library for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "identify-2.5.32-py2.py3-none-any.whl", hash = "sha256:0b7656ef6cba81664b783352c73f8c24b39cf82f926f78f4550eda928e5e0545"},
-    {file = "identify-2.5.32.tar.gz", hash = "sha256:5d9979348ec1a21c768ae07e0a652924538e8bce67313a73cb0f681cf08ba407"},
+    {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"},
+    {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"},
 ]
 
 [package.extras]
@@ -356,6 +387,25 @@ files = [
     {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
 ]
 
+[[package]]
+name = "importlib-metadata"
+version = "7.0.0"
+description = "Read metadata from Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "importlib_metadata-7.0.0-py3-none-any.whl", hash = "sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67"},
+    {file = "importlib_metadata-7.0.0.tar.gz", hash = "sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7"},
+]
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
+perf = ["ipython"]
+testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
+
 [[package]]
 name = "iniconfig"
 version = "2.0.0"
@@ -384,6 +434,24 @@ MarkupSafe = ">=2.0"
 [package.extras]
 i18n = ["Babel (>=2.7)"]
 
+[[package]]
+name = "markdown"
+version = "3.5.1"
+description = "Python implementation of John Gruber's Markdown."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "Markdown-3.5.1-py3-none-any.whl", hash = "sha256:5874b47d4ee3f0b14d764324d2c94c03ea66bee56f2d929da9f2508d65e722dc"},
+    {file = "Markdown-3.5.1.tar.gz", hash = "sha256:b65d7beb248dc22f2e8a31fb706d93798093c308dc1aba295aedeb9d41a813bd"},
+]
+
+[package.dependencies]
+importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
+
+[package.extras]
+docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
+testing = ["coverage", "pyyaml"]
+
 [[package]]
 name = "markdown-it-py"
 version = "3.0.0"
@@ -488,6 +556,139 @@ files = [
     {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
 ]
 
+[[package]]
+name = "mergedeep"
+version = "1.3.4"
+description = "A deep merge function for 🐍."
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"},
+    {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"},
+]
+
+[[package]]
+name = "mkdocs"
+version = "1.5.3"
+description = "Project documentation with Markdown."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "mkdocs-1.5.3-py3-none-any.whl", hash = "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1"},
+    {file = "mkdocs-1.5.3.tar.gz", hash = "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2"},
+]
+
+[package.dependencies]
+click = ">=7.0"
+colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""}
+ghp-import = ">=1.0"
+importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""}
+jinja2 = ">=2.11.1"
+markdown = ">=3.2.1"
+markupsafe = ">=2.0.1"
+mergedeep = ">=1.3.4"
+packaging = ">=20.5"
+pathspec = ">=0.11.1"
+platformdirs = ">=2.2.0"
+pyyaml = ">=5.1"
+pyyaml-env-tag = ">=0.1"
+watchdog = ">=2.0"
+
+[package.extras]
+i18n = ["babel (>=2.9.0)"]
+min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pathspec (==0.11.1)", "platformdirs (==2.2.0)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"]
+
+[[package]]
+name = "mkdocs-autorefs"
+version = "0.5.0"
+description = "Automatically link across pages in MkDocs."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "mkdocs_autorefs-0.5.0-py3-none-any.whl", hash = "sha256:7930fcb8ac1249f10e683967aeaddc0af49d90702af111a5e390e8b20b3d97ff"},
+    {file = "mkdocs_autorefs-0.5.0.tar.gz", hash = "sha256:9a5054a94c08d28855cfab967ada10ed5be76e2bfad642302a610b252c3274c0"},
+]
+
+[package.dependencies]
+Markdown = ">=3.3"
+mkdocs = ">=1.1"
+
+[[package]]
+name = "mkdocs-material"
+version = "9.0.12"
+description = "Documentation that simply works"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "mkdocs_material-9.0.12-py3-none-any.whl", hash = "sha256:ff4233e4f4da0c879db0dbcb532a690a3f86f5a66a0cfcce99a124e82a462afb"},
+    {file = "mkdocs_material-9.0.12.tar.gz", hash = "sha256:4da07b1390c6b78844f1566d723dd045572e645f31b108a1b5062fa7d11aa241"},
+]
+
+[package.dependencies]
+colorama = ">=0.4"
+jinja2 = ">=3.0"
+markdown = ">=3.2"
+mkdocs = ">=1.4.2"
+mkdocs-material-extensions = ">=1.1"
+pygments = ">=2.14"
+pymdown-extensions = ">=9.9.1"
+regex = ">=2022.4.24"
+requests = ">=2.26"
+
+[[package]]
+name = "mkdocs-material-extensions"
+version = "1.3.1"
+description = "Extension pack for Python Markdown and MkDocs Material."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"},
+    {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"},
+]
+
+[[package]]
+name = "mkdocstrings"
+version = "0.24.0"
+description = "Automatic documentation from sources, for MkDocs."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "mkdocstrings-0.24.0-py3-none-any.whl", hash = "sha256:f4908560c10f587326d8f5165d1908817b2e280bbf707607f601c996366a2264"},
+    {file = "mkdocstrings-0.24.0.tar.gz", hash = "sha256:222b1165be41257b494a9d29b14135d2b7ca43f38161d5b10caae03b87bd4f7e"},
+]
+
+[package.dependencies]
+click = ">=7.0"
+importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""}
+Jinja2 = ">=2.11.1"
+Markdown = ">=3.3"
+MarkupSafe = ">=1.1"
+mkdocs = ">=1.4"
+mkdocs-autorefs = ">=0.3.1"
+platformdirs = ">=2.2.0"
+pymdown-extensions = ">=6.3"
+typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""}
+
+[package.extras]
+crystal = ["mkdocstrings-crystal (>=0.3.4)"]
+python = ["mkdocstrings-python (>=0.5.2)"]
+python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
+
+[[package]]
+name = "mkdocstrings-python"
+version = "1.7.5"
+description = "A Python handler for mkdocstrings."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "mkdocstrings_python-1.7.5-py3-none-any.whl", hash = "sha256:5f6246026353f0c0785135db70c3fe9a5d9318990fc7ceb11d62097b8ffdd704"},
+    {file = "mkdocstrings_python-1.7.5.tar.gz", hash = "sha256:c7d143728257dbf1aa550446555a554b760dcd40a763f077189d298502b800be"},
+]
+
+[package.dependencies]
+griffe = ">=0.37"
+mkdocstrings = ">=0.20"
+
 [[package]]
 name = "mpmath"
 version = "1.3.0"
@@ -886,6 +1087,24 @@ files = [
 plugins = ["importlib-metadata"]
 windows-terminal = ["colorama (>=0.4.6)"]
 
+[[package]]
+name = "pymdown-extensions"
+version = "10.5"
+description = "Extension pack for Python Markdown."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:1f0ca8bb5beff091315f793ee17683bc1390731f6ac4c5eb01e27464b80fe879"},
+    {file = "pymdown_extensions-10.5.tar.gz", hash = "sha256:1b60f1e462adbec5a1ed79dac91f666c9c0d241fa294de1989f29d20096cfd0b"},
+]
+
+[package.dependencies]
+markdown = ">=3.5"
+pyyaml = "*"
+
+[package.extras]
+extra = ["pygments (>=2.12)"]
+
 [[package]]
 name = "pyparsing"
 version = "3.1.1"
@@ -922,6 +1141,20 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
 [package.extras]
 testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
 
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+    {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+    {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
 [[package]]
 name = "pyyaml"
 version = "6.0.1"
@@ -981,6 +1214,20 @@ files = [
     {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
 ]
 
+[[package]]
+name = "pyyaml-env-tag"
+version = "0.1"
+description = "A custom YAML tag for referencing environment variables in YAML files. "
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"},
+    {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
+]
+
+[package.dependencies]
+pyyaml = "*"
+
 [[package]]
 name = "rasterio"
 version = "1.3.9"
@@ -1030,6 +1277,103 @@ plot = ["matplotlib"]
 s3 = ["boto3 (>=1.2.4)"]
 test = ["boto3 (>=1.2.4)", "hypothesis", "packaging", "pytest (>=2.8.2)", "pytest-cov (>=2.2.0)", "shapely"]
 
+[[package]]
+name = "regex"
+version = "2023.10.3"
+description = "Alternative regular expression module, to replace re."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "regex-2023.10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c34d4f73ea738223a094d8e0ffd6d2c1a1b4c175da34d6b0de3d8d69bee6bcc"},
+    {file = "regex-2023.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8f4e49fc3ce020f65411432183e6775f24e02dff617281094ba6ab079ef0915"},
+    {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cd1bccf99d3ef1ab6ba835308ad85be040e6a11b0977ef7ea8c8005f01a3c29"},
+    {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:81dce2ddc9f6e8f543d94b05d56e70d03a0774d32f6cca53e978dc01e4fc75b8"},
+    {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c6b4d23c04831e3ab61717a707a5d763b300213db49ca680edf8bf13ab5d91b"},
+    {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c15ad0aee158a15e17e0495e1e18741573d04eb6da06d8b84af726cfc1ed02ee"},
+    {file = "regex-2023.10.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6239d4e2e0b52c8bd38c51b760cd870069f0bdf99700a62cd509d7a031749a55"},
+    {file = "regex-2023.10.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4a8bf76e3182797c6b1afa5b822d1d5802ff30284abe4599e1247be4fd6b03be"},
+    {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9c727bbcf0065cbb20f39d2b4f932f8fa1631c3e01fcedc979bd4f51fe051c5"},
+    {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3ccf2716add72f80714b9a63899b67fa711b654be3fcdd34fa391d2d274ce767"},
+    {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:107ac60d1bfdc3edb53be75e2a52aff7481b92817cfdddd9b4519ccf0e54a6ff"},
+    {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:00ba3c9818e33f1fa974693fb55d24cdc8ebafcb2e4207680669d8f8d7cca79a"},
+    {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f0a47efb1dbef13af9c9a54a94a0b814902e547b7f21acb29434504d18f36e3a"},
+    {file = "regex-2023.10.3-cp310-cp310-win32.whl", hash = "sha256:36362386b813fa6c9146da6149a001b7bd063dabc4d49522a1f7aa65b725c7ec"},
+    {file = "regex-2023.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:c65a3b5330b54103e7d21cac3f6bf3900d46f6d50138d73343d9e5b2900b2353"},
+    {file = "regex-2023.10.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90a79bce019c442604662d17bf69df99090e24cdc6ad95b18b6725c2988a490e"},
+    {file = "regex-2023.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c7964c2183c3e6cce3f497e3a9f49d182e969f2dc3aeeadfa18945ff7bdd7051"},
+    {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ef80829117a8061f974b2fda8ec799717242353bff55f8a29411794d635d964"},
+    {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5addc9d0209a9afca5fc070f93b726bf7003bd63a427f65ef797a931782e7edc"},
+    {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c148bec483cc4b421562b4bcedb8e28a3b84fcc8f0aa4418e10898f3c2c0eb9b"},
+    {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d1f21af4c1539051049796a0f50aa342f9a27cde57318f2fc41ed50b0dbc4ac"},
+    {file = "regex-2023.10.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b9ac09853b2a3e0d0082104036579809679e7715671cfbf89d83c1cb2a30f58"},
+    {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ebedc192abbc7fd13c5ee800e83a6df252bec691eb2c4bedc9f8b2e2903f5e2a"},
+    {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d8a993c0a0ffd5f2d3bda23d0cd75e7086736f8f8268de8a82fbc4bd0ac6791e"},
+    {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:be6b7b8d42d3090b6c80793524fa66c57ad7ee3fe9722b258aec6d0672543fd0"},
+    {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4023e2efc35a30e66e938de5aef42b520c20e7eda7bb5fb12c35e5d09a4c43f6"},
+    {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d47840dc05e0ba04fe2e26f15126de7c755496d5a8aae4a08bda4dd8d646c54"},
+    {file = "regex-2023.10.3-cp311-cp311-win32.whl", hash = "sha256:9145f092b5d1977ec8c0ab46e7b3381b2fd069957b9862a43bd383e5c01d18c2"},
+    {file = "regex-2023.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:b6104f9a46bd8743e4f738afef69b153c4b8b592d35ae46db07fc28ae3d5fb7c"},
+    {file = "regex-2023.10.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bff507ae210371d4b1fe316d03433ac099f184d570a1a611e541923f78f05037"},
+    {file = "regex-2023.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be5e22bbb67924dea15039c3282fa4cc6cdfbe0cbbd1c0515f9223186fc2ec5f"},
+    {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a992f702c9be9c72fa46f01ca6e18d131906a7180950958f766c2aa294d4b41"},
+    {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7434a61b158be563c1362d9071358f8ab91b8d928728cd2882af060481244c9e"},
+    {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2169b2dcabf4e608416f7f9468737583ce5f0a6e8677c4efbf795ce81109d7c"},
+    {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9e908ef5889cda4de038892b9accc36d33d72fb3e12c747e2799a0e806ec841"},
+    {file = "regex-2023.10.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12bd4bc2c632742c7ce20db48e0d99afdc05e03f0b4c1af90542e05b809a03d9"},
+    {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bc72c231f5449d86d6c7d9cc7cd819b6eb30134bb770b8cfdc0765e48ef9c420"},
+    {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bce8814b076f0ce5766dc87d5a056b0e9437b8e0cd351b9a6c4e1134a7dfbda9"},
+    {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:ba7cd6dc4d585ea544c1412019921570ebd8a597fabf475acc4528210d7c4a6f"},
+    {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b0c7d2f698e83f15228ba41c135501cfe7d5740181d5903e250e47f617eb4292"},
+    {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5a8f91c64f390ecee09ff793319f30a0f32492e99f5dc1c72bc361f23ccd0a9a"},
+    {file = "regex-2023.10.3-cp312-cp312-win32.whl", hash = "sha256:ad08a69728ff3c79866d729b095872afe1e0557251da4abb2c5faff15a91d19a"},
+    {file = "regex-2023.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:39cdf8d141d6d44e8d5a12a8569d5a227f645c87df4f92179bd06e2e2705e76b"},
+    {file = "regex-2023.10.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4a3ee019a9befe84fa3e917a2dd378807e423d013377a884c1970a3c2792d293"},
+    {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76066d7ff61ba6bf3cb5efe2428fc82aac91802844c022d849a1f0f53820502d"},
+    {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe50b61bab1b1ec260fa7cd91106fa9fece57e6beba05630afe27c71259c59b"},
+    {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fd88f373cb71e6b59b7fa597e47e518282455c2734fd4306a05ca219a1991b0"},
+    {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ab05a182c7937fb374f7e946f04fb23a0c0699c0450e9fb02ef567412d2fa3"},
+    {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dac37cf08fcf2094159922edc7a2784cfcc5c70f8354469f79ed085f0328ebdf"},
+    {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e54ddd0bb8fb626aa1f9ba7b36629564544954fff9669b15da3610c22b9a0991"},
+    {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3367007ad1951fde612bf65b0dffc8fd681a4ab98ac86957d16491400d661302"},
+    {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:16f8740eb6dbacc7113e3097b0a36065a02e37b47c936b551805d40340fb9971"},
+    {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:f4f2ca6df64cbdd27f27b34f35adb640b5d2d77264228554e68deda54456eb11"},
+    {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:39807cbcbe406efca2a233884e169d056c35aa7e9f343d4e78665246a332f597"},
+    {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7eece6fbd3eae4a92d7c748ae825cbc1ee41a89bb1c3db05b5578ed3cfcfd7cb"},
+    {file = "regex-2023.10.3-cp37-cp37m-win32.whl", hash = "sha256:ce615c92d90df8373d9e13acddd154152645c0dc060871abf6bd43809673d20a"},
+    {file = "regex-2023.10.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0f649fa32fe734c4abdfd4edbb8381c74abf5f34bc0b3271ce687b23729299ed"},
+    {file = "regex-2023.10.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b98b7681a9437262947f41c7fac567c7e1f6eddd94b0483596d320092004533"},
+    {file = "regex-2023.10.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:91dc1d531f80c862441d7b66c4505cd6ea9d312f01fb2f4654f40c6fdf5cc37a"},
+    {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82fcc1f1cc3ff1ab8a57ba619b149b907072e750815c5ba63e7aa2e1163384a4"},
+    {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7979b834ec7a33aafae34a90aad9f914c41fd6eaa8474e66953f3f6f7cbd4368"},
+    {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef71561f82a89af6cfcbee47f0fabfdb6e63788a9258e913955d89fdd96902ab"},
+    {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd829712de97753367153ed84f2de752b86cd1f7a88b55a3a775eb52eafe8a94"},
+    {file = "regex-2023.10.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00e871d83a45eee2f8688d7e6849609c2ca2a04a6d48fba3dff4deef35d14f07"},
+    {file = "regex-2023.10.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:706e7b739fdd17cb89e1fbf712d9dc21311fc2333f6d435eac2d4ee81985098c"},
+    {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cc3f1c053b73f20c7ad88b0d1d23be7e7b3901229ce89f5000a8399746a6e039"},
+    {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6f85739e80d13644b981a88f529d79c5bdf646b460ba190bffcaf6d57b2a9863"},
+    {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:741ba2f511cc9626b7561a440f87d658aabb3d6b744a86a3c025f866b4d19e7f"},
+    {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e77c90ab5997e85901da85131fd36acd0ed2221368199b65f0d11bca44549711"},
+    {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:979c24cbefaf2420c4e377ecd1f165ea08cc3d1fbb44bdc51bccbbf7c66a2cb4"},
+    {file = "regex-2023.10.3-cp38-cp38-win32.whl", hash = "sha256:58837f9d221744d4c92d2cf7201c6acd19623b50c643b56992cbd2b745485d3d"},
+    {file = "regex-2023.10.3-cp38-cp38-win_amd64.whl", hash = "sha256:c55853684fe08d4897c37dfc5faeff70607a5f1806c8be148f1695be4a63414b"},
+    {file = "regex-2023.10.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2c54e23836650bdf2c18222c87f6f840d4943944146ca479858404fedeb9f9af"},
+    {file = "regex-2023.10.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69c0771ca5653c7d4b65203cbfc5e66db9375f1078689459fe196fe08b7b4930"},
+    {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ac965a998e1388e6ff2e9781f499ad1eaa41e962a40d11c7823c9952c77123e"},
+    {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c0e8fae5b27caa34177bdfa5a960c46ff2f78ee2d45c6db15ae3f64ecadde14"},
+    {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c56c3d47da04f921b73ff9415fbaa939f684d47293f071aa9cbb13c94afc17d"},
+    {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ef1e014eed78ab650bef9a6a9cbe50b052c0aebe553fb2881e0453717573f52"},
+    {file = "regex-2023.10.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d29338556a59423d9ff7b6eb0cb89ead2b0875e08fe522f3e068b955c3e7b59b"},
+    {file = "regex-2023.10.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9c6d0ced3c06d0f183b73d3c5920727268d2201aa0fe6d55c60d68c792ff3588"},
+    {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:994645a46c6a740ee8ce8df7911d4aee458d9b1bc5639bc968226763d07f00fa"},
+    {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:66e2fe786ef28da2b28e222c89502b2af984858091675044d93cb50e6f46d7af"},
+    {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:11175910f62b2b8c055f2b089e0fedd694fe2be3941b3e2633653bc51064c528"},
+    {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:06e9abc0e4c9ab4779c74ad99c3fc10d3967d03114449acc2c2762ad4472b8ca"},
+    {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fb02e4257376ae25c6dd95a5aec377f9b18c09be6ebdefa7ad209b9137b73d48"},
+    {file = "regex-2023.10.3-cp39-cp39-win32.whl", hash = "sha256:3b2c3502603fab52d7619b882c25a6850b766ebd1b18de3df23b2f939360e1bd"},
+    {file = "regex-2023.10.3-cp39-cp39-win_amd64.whl", hash = "sha256:adbccd17dcaff65704c856bd29951c58a1bd4b2b0f8ad6b826dbd543fe740988"},
+    {file = "regex-2023.10.3.tar.gz", hash = "sha256:3fef4f844d2290ee0ba57addcec17eec9e3df73f10a2748485dfd6a3a188cc0f"},
+]
+
 [[package]]
 name = "requests"
 version = "2.31.0"
@@ -1111,6 +1455,17 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments
 testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
 testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
 
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
 [[package]]
 name = "snuggs"
 version = "1.4.7"
@@ -1338,7 +1693,61 @@ platformdirs = ">=3.9.1,<5"
 docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
 test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
 
+[[package]]
+name = "watchdog"
+version = "3.0.0"
+description = "Filesystem events monitoring"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"},
+    {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"},
+    {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"},
+    {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"},
+    {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"},
+    {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"},
+    {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"},
+    {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"},
+    {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"},
+    {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"},
+    {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"},
+    {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"},
+    {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"},
+    {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"},
+    {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"},
+    {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"},
+    {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"},
+    {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"},
+    {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"},
+    {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"},
+    {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"},
+    {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"},
+    {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"},
+    {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"},
+    {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"},
+    {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"},
+    {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"},
+]
+
+[package.extras]
+watchmedo = ["PyYAML (>=3.10)"]
+
+[[package]]
+name = "zipp"
+version = "3.17.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"},
+    {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
+
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.9, <3.12"
-content-hash = "6474efd2e4bee3747d8eb27cf4ae75d6dc3ba9abe020049160a6d8d393160068"
+content-hash = "a572de8ceb19bce44a2bb05c2441331e0401eff8d12b5f6ee9f1df50afc314aa"
diff --git a/pyproject.toml b/pyproject.toml
index 69eddf7..1fff113 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,10 +24,13 @@ typer = "^0.9.0"
 black = "^23.3.0"
 pre-commit = "^3.3.3"
 ruff = "^0.0.272"
-
-[tool.poetry.group.test.dependencies]
 pytest = "^7.3.2"
 
+[tool.poetry.group.docs.dependencies]
+mkdocs = "^1.5.3"
+mkdocs-material = "9.0.12"
+mkdocstrings-python = "^1.7.5"
+
 [build-system]
 requires = ["poetry-core"]
 build-backend = "poetry.core.masonry.api"


Test patch:
diff --git a/tests/test_geotiff_reader.py b/tests/test_geotiff_reader.py
index efd6733..b0a9fbd 100644
--- a/tests/test_geotiff_reader.py
+++ b/tests/test_geotiff_reader.py
@@ -1,8 +1,7 @@
 import numpy as np
 import rasterio
-import torch
 from rasterio.profiles import DefaultGTiffProfile
-from torchvision import transforms
+from rasterio.windows import Window
 
 from kelp_o_matic.geotiff_io import GeotiffReader
 
@@ -58,138 +57,36 @@ def test_simple_crop(tmpdir):
     p = _create_simple_1band_img(tmpdir)
 
     ds = GeotiffReader(p, crop_size=1)
-    assert ds[0] == np.array([[1]])
-    assert ds[1] == np.array([[2]])
-    assert ds[2] == np.array([[3]])
-    assert ds[3] == np.array([[4]])
-
-    ds = GeotiffReader(p, crop_size=2)
-    assert np.all(ds[0] == np.array([[1, 2], [3, 4]]))
+    c, w = ds[0]
+    assert c == np.array([[1]])
+    assert w == Window(row_off=0, col_off=0, height=1, width=1)
 
+    c, w = ds[1]
+    assert c == np.array([[2]])
+    assert w == Window(row_off=0, col_off=1, height=1, width=1)
 
-def test_padding(tmpdir):
-    p = _create_simple_1band_img(tmpdir)
-    ds = GeotiffReader(p, crop_size=1, padding=1, fill_value=0)
-    assert np.all(ds[0] == np.array([[0, 0, 0], [0, 1, 2], [0, 3, 4]]))
-    assert np.all(ds[1] == np.array([[0, 0, 0], [1, 2, 0], [3, 4, 0]]))
-    assert np.all(ds[2] == np.array([[0, 1, 2], [0, 3, 4], [0, 0, 0]]))
-    assert np.all(ds[3] == np.array([[1, 2, 0], [3, 4, 0], [0, 0, 0]]))
-
-    ds = GeotiffReader(p, crop_size=2, padding=1, fill_value=0)
-    assert np.all(
-        ds[0] == np.array([[0, 0, 0, 0], [0, 1, 2, 0], [0, 3, 4, 0], [0, 0, 0, 0]])
-    )
-
-    ds = GeotiffReader(p, crop_size=2, padding=2, fill_value=8)
-    assert np.all(
-        ds[0]
-        == np.array(
-            [
-                [8, 8, 8, 8, 8, 8],
-                [8, 8, 8, 8, 8, 8],
-                [8, 8, 1, 2, 8, 8],
-                [8, 8, 3, 4, 8, 8],
-                [8, 8, 8, 8, 8, 8],
-                [8, 8, 8, 8, 8, 8],
-            ]
-        )
-    )
+    c, w = ds[2]
+    assert c == np.array([[3]])
+    assert w == Window(row_off=1, col_off=0, height=1, width=1)
 
+    c, w = ds[3]
+    assert c == np.array([[4]])
+    assert w == Window(row_off=1, col_off=1, height=1, width=1)
 
-def test_fill_values(tmpdir):
-    p = _create_simple_1band_img(tmpdir)
-    ds = GeotiffReader(p, crop_size=2, padding=2)
-    # Should default to 0
-    assert ds.nodata == 0.0
-    assert np.all(
-        ds[0]
-        == np.array(
-            [
-                [0, 0, 0, 0, 0, 0],
-                [0, 0, 0, 0, 0, 0],
-                [0, 0, 1, 2, 0, 0],
-                [0, 0, 3, 4, 0, 0],
-                [0, 0, 0, 0, 0, 0],
-                [0, 0, 0, 0, 0, 0],
-            ]
-        )
-    )
-
-    # Should use raster nodata if available
-    simple_img = np.array([[1, 2], [3, 4]])
-
-    p = str(tmpdir.mkdir("nodata").join("simple_img.tif"))
-    height, width = simple_img.shape
-
-    with rasterio.open(
-        p,
-        "w",
-        **DefaultGTiffProfile(
-            count=1,
-            height=height,
-            width=width,
-            nodata=9,
-            crs="+proj=latlong",
-            transform=rasterio.Affine.identity(),
-        )
-    ) as dst:
-        dst.write(simple_img.astype(rasterio.uint8), 1)
-
-    ds = GeotiffReader(p, crop_size=2, padding=2)
-    assert ds.nodata == 9
-    assert np.all(
-        ds[0]
-        == np.array(
-            [
-                [9, 9, 9, 9, 9, 9],
-                [9, 9, 9, 9, 9, 9],
-                [9, 9, 1, 2, 9, 9],
-                [9, 9, 3, 4, 9, 9],
-                [9, 9, 9, 9, 9, 9],
-                [9, 9, 9, 9, 9, 9],
-            ]
-        )
-    )
-
-    ds = GeotiffReader(p, crop_size=2, padding=2, fill_value=7)
-    # Should use value defined at class instantiation
-    assert ds.nodata == 9
-    assert np.all(
-        ds[0]
-        == np.array(
-            [
-                [7, 7, 7, 7, 7, 7],
-                [7, 7, 7, 7, 7, 7],
-                [7, 7, 1, 2, 7, 7],
-                [7, 7, 3, 4, 7, 7],
-                [7, 7, 7, 7, 7, 7],
-                [7, 7, 7, 7, 7, 7],
-            ]
-        )
-    )
+    ds = GeotiffReader(p, crop_size=2)
+    assert np.all(ds[0][0] == np.expand_dims(np.array([[1, 2], [3, 4]]), 2))
 
 
-def test_transforms(tmpdir):
+def test_stride(tmpdir):
     p = _create_simple_1band_img(tmpdir)
-    ds = GeotiffReader(p, crop_size=2, transform=transforms.ToTensor())
-    it = (ds[0] * 255).to(torch.long)
-    gt = torch.tensor([[1, 2], [3, 4]])
-
-    assert torch.allclose(it, gt)
-
-
-def test_band_ordering(tmpdir):
-    p = _create_simple_3band_img(tmpdir)
-    ds = GeotiffReader(p, crop_size=2, transform=transforms.ToTensor())
-    it = (ds[0] * 255).to(torch.long)
-    gt = torch.tensor(
-        [[[11, 112, 213], [221, 22, 123]], [[131, 232, 33], [41, 142, 243]]]
-    ).permute(
-        2, 0, 1
-    )  # Rearrange to (C, H, W)
+    ds = GeotiffReader(p, crop_size=2, stride=1)
+    c, w = ds[0]
+    assert np.all(c == np.expand_dims(np.array([[1, 2], [3, 4]]), 2))
+    assert w == Window(row_off=0, col_off=0, height=2, width=2)
 
-    assert it.shape == torch.Size([3, 2, 2])
-    assert torch.allclose(it, gt)
+    c, w = ds[1]
+    assert np.all(c == np.expand_dims(np.array([[2], [4]]), 2))
+    assert w == Window(row_off=0, col_off=1, height=2, width=1)
 
 
 def test_len(tmpdir):
@@ -204,40 +101,5 @@ def test_len(tmpdir):
 def test_y0x0_getters(tmpdir):
     p = _create_simple_3band_img(tmpdir)
     ds = GeotiffReader(p, crop_size=1)
-    assert ds.y0 == [0, 0, 1, 1]
-    assert ds.x0 == [0, 1, 0, 1]
-
-
-def test_filter(tmpdir):
-    p = _create_simple_1band_img(tmpdir)
-    ds = GeotiffReader(
-        p, crop_size=1, padding=0, filter_=lambda img: np.all((img == 2))
-    )
-    crop, idx = next(iter(ds))
-    assert crop == np.array([[2]])
-    assert idx == 1
-
-    q = _create_simple_3band_img(tmpdir)
-    ds = GeotiffReader(
-        q, crop_size=1, padding=0, filter_=lambda img: np.any((img == 22))
-    )
-    crop, idx = next(iter(ds))
-    assert np.all(crop == np.array([[[221, 22, 123]]]))
-    assert idx == 1
-
-    ds = GeotiffReader(
-        q, crop_size=1, padding=1, filter_=lambda img: np.any((img == 33))
-    )
-    crop, idx = next(iter(ds))
-
-    assert np.all(
-        crop
-        == np.array(
-            [
-                [[0, 0, 0], [11, 112, 213], [221, 22, 123]],
-                [[0, 0, 0], [131, 232, 33], [41, 142, 243]],
-                [[0, 0, 0], [0, 0, 0], [0, 0, 0]],
-            ]
-        )
-    )
-    assert idx == 2
+    assert ds.y0 == [0, 1]
+    assert ds.x0 == [0, 1]