Felix Kleinsteuber 2 years ago
parent
commit
4e25520634

BIN
Cache_NoBackup/approach3_cluster10.npy


File diff suppressed because it is too large
+ 74 - 7
approach4_boxplot.ipynb


+ 0 - 37
results.ipynb → deprecated/results.ipynb

@@ -101,43 +101,6 @@
     "| | Deep +Noise +Sparse KDE (lr=1e-4) | 0.9684 | 0.9579 | 0.9041 | 0.4964 | 5:00 min | < 0:30 min |"
    ]
   },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "gaia5: python train_bow.py ResizedSessions_NoBackup beaver_01 --clusters 4096 --include_motion && python eval_bow.py ResizedSessions_NoBackup beaver_01 --clusters 4096 --include_motion\n",
-    "\n",
-    "gaia4: python train_bow.py ResizedSessions_NoBackup beaver_01 --clusters 4096 && python eval_bow.py ResizedSessions_NoBackup beaver_01 --clusters 4096\n",
-    "\n",
-    "gaia3: python train_bow.py ResizedSessions_NoBackup beaver_01 --clusters 2048 --step_size 40 && python eval_bow.py ResizedSessions_NoBackup beaver_01 --clusters 2048 --step_size 40\n",
-    "\n",
-    "herkules: python train_bow.py ResizedSessions_NoBackup beaver_01 --clusters 2048 --step_size 40 --include_motion && python eval_bow.py ResizedSessions_NoBackup beaver_01 --clusters 2048 --step_size 40 --include_motion"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 2,
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/plain": [
-       "array([[4.687806e-310, 0.000000e+000],\n",
-       "       [0.000000e+000, 0.000000e+000]])"
-      ]
-     },
-     "execution_count": 2,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "import numpy as np\n",
-    "\n",
-    "a = np.empty((3, 2))\n",
-    "a[np.random.choice(len(a), size=2, replace=False)]"
-   ]
-  },
   {
    "cell_type": "code",
    "execution_count": null,

File diff suppressed because it is too large
+ 0 - 96
frame_differencing.ipynb


+ 16 - 22
generate_lapseless_session.ipynb

@@ -44,23 +44,10 @@
    ]
   },
   {
-   "cell_type": "code",
-   "execution_count": 37,
+   "cell_type": "markdown",
    "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/plain": [
-       "3"
-      ]
-     },
-     "execution_count": 37,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
    "source": [
-    "sum([0, 1, 2])"
+    "We will pick capture sets where every image is labeled as normal, until we have selected at least pick_for_lapse images."
    ]
   },
   {
@@ -121,6 +108,13 @@
     "len(lapse_img_nrs)"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We will now copy the Motion images to either Lapse or Motion, depending on whether they are in lapse_img_nrs."
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": 40,
@@ -167,6 +161,13 @@
     "print(f\"Copied {lapse} files to Lapse, {motion} to Motion\")"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The full folder is copied without changes."
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": 41,
@@ -188,13 +189,6 @@
     "full_folder = os.path.join(TARGET_DIR, os.path.basename(target_session.folder), \"Full\")\n",
     "shutil.copytree(target_session.get_full_folder(), full_folder)"
    ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
   }
  ],
  "metadata": {

+ 7 - 7
index.ipynb

@@ -13,6 +13,8 @@
    "metadata": {},
    "source": [
     "## Approach 1: Lapse Frame Differencing\n",
+    "**Note:** Before this approach can be used on a new session, lapse maps have to be generated for this session using *scan_sessions.ipynb*.\n",
+    "\n",
     " - *approach1a_basic_frame_differencing.ipynb*: Implementation.\n",
     " - *approach1b_histograms.ipynb*: Discarded similar approach using histogram distribution to compare Lapse and Motion images.\n",
     "\n",
@@ -41,17 +43,15 @@
     " - *analyze_dataset.ipynb*: Dataset statistics, check for duplicates\n",
     " - *analyze_labels.ipynb*: Annotation statistics (number of normal/anomalous motion samples)\n",
     " - *check_csv.ipynb*: Loads annotations from *Kadaverbilder_leer.csv*\n",
+    " - *generate_lapseless_session.ipynb*: Generate a session with artificial lapse data from a lapseless session (e.g., Fox_03 -> GFox_03)\n",
+    " - *quick_label.py*: Minimal quick labeling script using OpenCV\n",
+    " - *read_csv_annotations.ipynb*: Loads annotations from *observations.csv* and *media.csv*\n",
+    " - *resize_session.ipynb*: Session preprocessing (crop and resize images)\n",
+    " - *scan_sessions.ipynb*: Creates lapse maps (map between lapse images and their EXIF dates), statistics of inconsistencies in sessions\n",
     "\n",
     "## Early experiments\n",
     " - *deprecated/experiments.ipynb*: Early experiments with lapse images and frame differencing"
    ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
   }
  ],
  "metadata": {

BIN
plots/approach4/boxplot_kde_sessions.pdf


BIN
plots/approach4/boxplot_kde_sessions_loss.pdf


BIN
plots/approach4/boxplot_kde_sessions_loss_tnr95.pdf


BIN
plots/approach4/boxplot_kde_sessions_tnr95.pdf


+ 5 - 0
py/Autoencoder.py

@@ -1,3 +1,8 @@
+# This is the initial autoencoder architecture.
+# Convolutional with 5 conv layers + 1 dense layer per encoder and decoder.
+# relu on hidden layers, tanh on output layer
+# Number of latent features: 512
+
 from torch import nn
 
 class Autoencoder(nn.Module):

+ 5 - 0
py/Autoencoder2.py

@@ -1,3 +1,8 @@
+# This is the preferred autoencoder architecture.
+# Fully convolutional with 7 layer encoder and decoder.
+# Dropout, relu on hidden layers, tanh on output layer
+# Allows multiples of 16 as number of latent features
+
 from torch import nn
 
 class Autoencoder(nn.Module):

+ 6 - 0
py/Autoencoder3.py

@@ -1,3 +1,9 @@
+# Experimental architecture; not used for paper results.
+# Convolutional with 6 conv layers + 1 dense layer per encoder and decoder.
+# Dropout, relu on hidden layers, tanh on output layer
+# Allows any number of latent features
+# More parameters than Autoencoder2
+
 from torch import nn
 
 class Autoencoder(nn.Module):

+ 28 - 1
py/Dataset.py

@@ -4,10 +4,16 @@ from py.DatasetStatistics import DatasetStatistics
 from py.FileUtils import list_folders, list_jpegs_recursive, expected_subfolders, verify_expected_subfolders
 from py.Session import Session
 
-
+# Represents the whole dataset consisting of multiple sessions. Can be used to get
+# session instances or to get an statistics instance.
 class Dataset:
 
     def __init__(self, base_path: str):
+        """Create a new dataset instance.
+
+        Args:
+            base_path (str): Path to dataset, should contain subfolders for sessions.
+        """
         self.base_path = base_path
         self.raw_sessions = []
         self.__parse_subdirectories()
@@ -23,10 +29,20 @@ class Dataset:
 
 
     def get_sessions(self) -> list:
+        """Get names of all sessions (without prefixes).
+
+        Returns:
+            list of str: session names
+        """
         # cut off the first 33 characters (redundant)
         return [name[33:] for name in self.raw_sessions]
     
     def create_statistics(self) -> DatasetStatistics:
+        """Accumulate statistics over the dataset and return a new statistics instance.
+
+        Returns:
+            DatasetStatistics: statistics instance
+        """
         counts = {}
         for folder in tqdm(self.raw_sessions):
             counts[folder[33:]] = {}
@@ -39,6 +55,17 @@ class Dataset:
         return DatasetStatistics(counts)
 
     def create_session(self, session_name: str) -> Session:
+        """Return a new session instance from the session name.
+
+        Args:
+            session_name (str): Session name, e.g. beaver_01. Not case-sensitive.
+
+        Raises:
+            ValueError: No or multiple sessions matching session name
+
+        Returns:
+            Session: Session instance
+        """
         if session_name in self.raw_sessions:
             return Session(os.path.join(self.base_path, session_name))
         filtered = [s for s in self.raw_sessions if session_name.lower() in s.lower()]

+ 38 - 0
py/DatasetStatistics.py

@@ -4,6 +4,7 @@ from warnings import warn
 import numpy as np
 import pandas as pd
 
+# helper for accumulating, saving, loading, and displaying dataset statistics
 class DatasetStatistics:
 
     def __init__(self, stats_dict: dict = None, load_from_file: str = None):
@@ -30,6 +31,14 @@ class DatasetStatistics:
         self.df = pd.DataFrame.from_dict(self.stats).transpose()
 
     def add_total_row(self, row_name = "Z_Total") -> "DatasetStatistics":
+        """Add a row to the pandas dataframe with totals of all columns. Should only be called once.
+
+        Args:
+            row_name (str, optional): Name of the new row. Defaults to "Z_Total".
+
+        Returns:
+            DatasetStatistics: self
+        """
         if row_name in self.stats:
             warn(f"{row_name} is already a defined row")
             return self
@@ -47,18 +56,47 @@ class DatasetStatistics:
         return self
     
     def save(self, filename = "dataset_stats.npy"):
+        """Save statistics stored in this instance to a file using numpy.
+
+        Args:
+            filename (str, optional): Target file name. Defaults to "dataset_stats.npy".
+        """
         np.save(filename, self.stats)
         print(f"Saved to {filename}.")
     
     def load(self, filename = "dataset_stats.npy"):
+        """Load statistics from a file using numpy.
+
+        Args:
+            filename (str, optional): Target file name. Defaults to "dataset_stats.npy".
+        """
         self.stats = np.load(filename, allow_pickle=True).tolist()
         self.__update_dataframe()
         print(f"Loaded from {filename}.")
 
     def view(self, col_order = ["Lapse", "Motion", "Full", "Total"]) -> pd.DataFrame:
+        """Display the statistics dataframe.
+
+        Args:
+            col_order (list, optional): Order of columns. Defaults to ["Lapse", "Motion", "Full", "Total"].
+
+        Returns:
+            pd.DataFrame: data frame
+        """
         return self.df.sort_index()[col_order]
     
     def plot_sessions(self, cols = ["Lapse", "Motion", "Full"], figsize = (20, 10), style = {"width": 2}, exclude_last_row = False):
+        """Plot the statistics dataframe as a bar plot.
+
+        Args:
+            cols (list, optional): Columns to include. Defaults to ["Lapse", "Motion", "Full"].
+            figsize (tuple, optional): Plot size. Defaults to (20, 10).
+            style (dict, optional): Additional style arguments. Defaults to {"width": 2}.
+            exclude_last_row (bool, optional): If True, the last row will not be plotted. Defaults to False.
+
+        Returns:
+            _type_: _description_
+        """
         df = self.df[cols]
         # Plot lapse, motion, full columns without the last row (Z_Total)
         if exclude_last_row:

+ 15 - 0
py/FileUtils.py

@@ -1,3 +1,4 @@
+# This file defines helper functions for processing files.
 from glob import glob
 import os
 import pickle
@@ -40,9 +41,23 @@ def verify_expected_subfolders(session_path: str):
 # Pickle helpers
 
 def dump(filename: str, data):
+    """Dumps data using pickle.
+
+    Args:
+        filename (str): Target file name.
+        data (any): Data.
+    """
     with open(filename, "wb") as f:
         pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
 
 def load(filename: str):
+    """Loads data using pickle.
+
+    Args:
+        filename (str): Target file name.
+
+    Returns:
+        any: loaded data
+    """
     with open(filename, "rb") as f:
         return pickle.load(f)

+ 6 - 0
py/ImageAnnotator.py

@@ -4,6 +4,8 @@ from py.Session import Session
 from py.ImageClassifier import AbstractImageClassifier
 import numpy as np
 
+# Old annotator script using IPython widgets, which are very slow and sometimes buggy.
+# It is preferred to use quick_label.py.
 class ImageAnnotator():
     def __init__(self, classifier: AbstractImageClassifier, session: Session, initial_scores = [], initial_annotations = [], load_from = None):
         self.scores = initial_scores
@@ -26,6 +28,7 @@ class ImageAnnotator():
         anomalous_btn.on_click(self.mark_as_anomalous)
         self.next_image()
     
+    # Click on normal button
     def mark_as_normal(self, _):
         with self.output:
             print("Marking as normal...")
@@ -33,6 +36,7 @@ class ImageAnnotator():
         self.scores.append(self.score)
         self.next_image()
     
+    # Click on anomalous button
     def mark_as_anomalous(self, _):
         with self.output:
             print("Marking as anomalous...")
@@ -40,6 +44,7 @@ class ImageAnnotator():
         self.scores.append(self.score)
         self.next_image()
     
+    # Show next image
     def next_image(self):
         img = self.session.get_random_motion_image(day_only=True)
         self.score = self.classifier.evaluate(img)
@@ -48,5 +53,6 @@ class ImageAnnotator():
             display(img.to_ipython_image())
             print(f"score = {self.score}")
 
+    # Save annotation data to file
     def save(self, filename: str):
         np.save(filename, [self.annotations, self.scores])

+ 2 - 0
py/ImageClassifier.py

@@ -1,5 +1,7 @@
 from py.Session import MotionImage
 
+# Abstract class which represents an image classifier.
+# Returns a real number for any image which can then be thresholded.
 class AbstractImageClassifier():
     def evaluate(self, motion_img: MotionImage, display=False) -> int:
         raise NotImplementedError("Please implement evaluate(motion_img)!")

+ 11 - 0
py/ImageUtils.py

@@ -1,3 +1,4 @@
+# This file defines helper functions for processing images.
 from datetime import datetime
 from PIL import Image
 import numpy as np
@@ -59,4 +60,14 @@ def save_image(image, filename: str, title: str, colorbar=False, size=(8, 5), **
     plt.savefig(filename, bbox_inches="tight")
 
 def is_daytime(img, threshold=50) -> bool:
+    """Returns true when the image is taken at daytime.
+    This is evaluated based on the image statistics.
+
+    Args:
+        img (2d np.array): image
+        threshold (int, optional): Threshold which controls the bias towards predicting night or day. Defaults to 50.
+
+    Returns:
+        bool: True if day
+    """
     return np.mean([abs(img[:,:,0] - img[:,:,1]), abs(img[:,:,1] - img[:,:,2]), abs(img[:,:,2] - img[:,:,0])]) > threshold

+ 6 - 0
py/Labels.py

@@ -1,3 +1,8 @@
+# Annotations for all sessions that were evaluated in the paper.
+# Annotations generated using quick_label.py can be pasted here.
+# Each session is labeled using the "normal", "anomalous", "not_annotated", and "max" keys.
+# Every motion image is referenced by a number which can be found in its filename.
+# Beaver_01 contains the deprecated "small" key which holds a list of anomalous images with small animals such as birds.
 
 LABELS = {
     "Beaver_01": {
@@ -21,4 +26,5 @@ LABELS = {
     }
 }
 
+# GFox_03 is generated from Fox_03, so the labels stay the same
 LABELS["GFox_03"] = LABELS["Fox_03"]

+ 26 - 0
py/PlotUtils.py

@@ -1,7 +1,21 @@
+# This file defines helper functions for plotting.
 import matplotlib.pyplot as plt
 from sklearn.metrics import roc_curve, auc
 
 def plot_roc_curve(test_labels: list, test_df: list, title: str, figsize=(8, 8), savefile = None, show: bool = True):
+    """Plots the roc curve of a classifier.
+
+    Args:
+        test_labels (list): Labels for the test examples.
+        test_df (list): Decision function values for the test examples.
+        title (str): Title of the plot.
+        figsize (tuple, optional): Size of the plot. Defaults to (8, 8).
+        savefile (_type_, optional): Output file without ending. Will be saved as pdf and png. If None, the plot is not saved. Defaults to None.
+        show (bool, optional): If False, do not show the plot. Defaults to True.
+
+    Returns:
+        fpr (list of float), tpr (list of float), thresholds (list of float), auc_score (float): Points on roc curves, their thresholds, and the area under ROC curve.
+    """
     fpr, tpr, thresholds = roc_curve(test_labels, test_df)
     auc_score = auc(fpr, tpr)
 
@@ -25,6 +39,18 @@ def plot_roc_curve(test_labels: list, test_df: list, title: str, figsize=(8, 8),
     return fpr, tpr, thresholds, auc_score
 
 def get_percentiles(fpr, tpr, thresholds, percentiles=[0.9, 0.95, 0.98, 0.99], verbose = True):
+    """Returns the maximum possible TNR (elimination rate) for given minimum TPR.
+
+    Args:
+        fpr (list of float): FPR values from ROC curve.
+        tpr (list of float): TPR values from ROC curve.
+        thresholds (list of float): Thresholds from ROC curve.
+        percentiles (list of float, optional): List of minimum TPR values to use as input. Defaults to [0.9, 0.95, 0.98, 0.99].
+        verbose (bool, optional): If True, print the results. Defaults to True.
+
+    Returns:
+        list of float: TNR values aka elimination rates.
+    """
     assert percentiles == sorted(percentiles)
     tnrs = []
     for percentile in percentiles:

+ 31 - 0
py/PyTorchData.py

@@ -1,10 +1,21 @@
+# Functions related to approach 4 (autoencoder).
+# For training and evaluation scripts, see ./train_autoencoder.py and ./eval_autoencoder.py.
 import os
 import matplotlib.pyplot as plt
 from torchvision import io, transforms
 from torch.utils.data import DataLoader, Dataset
 
+# PyTorch dataset instance which loads images from a directory
 class ImageDataset(Dataset):
     def __init__(self, img_dir: str, transform = None, labeler = None, filter = lambda filename: True):
+        """Create a new PyTorch dataset from images in a directory.
+
+        Args:
+            img_dir (str): Source directory which contains the images.
+            transform (lambda img: transformed_img, optional): Input transform function. Defaults to None.
+            labeler (lambda str: int, optional): Labeling function. Input is the filename, output the label. Defaults to None.
+            filter (lambda str: bool, optional): Input filter function. Input is the filename. Images where filter returns False are skipped. Defaults to no filtering.
+        """
         self.img_dir = img_dir
         self.transform = transform
         self.labeler = labeler
@@ -18,9 +29,11 @@ class ImageDataset(Dataset):
     def __getitem__(self, idx):
         img_path = os.path.join(self.img_dir, self.files[idx])
         img = io.read_image(img_path)
+        # apply transform function
         if self.transform:
             img = self.transform(img)
         label = 0
+        # get label
         if self.labeler:
             label = self.labeler(self.files[idx])
         return img, label
@@ -63,12 +76,30 @@ def create_dataloader(img_folder: str, target_size: tuple = (256, 256), batch_si
     return DataLoader(data, batch_size=batch_size, shuffle=shuffle)
 
 def model_output_to_image(y):
+    """Converts the raw model output back to an image by normalizing and clamping it to [0, 1] and reshaping it.
+
+    Args:
+        y (PyTorch tensor): Autoencoder output.
+
+    Returns:
+        PyTorch tensor: Image from autoencoder output.
+    """
     y = 0.5 * (y + 1) # normalize back to [0, 1]
     y = y.clamp(0, 1) # clamp to [0, 1]
     y = y.view(y.size(0), 3, 256, 256)
     return y
 
 def get_log(name: str, display: bool = False, figsize: tuple = (12, 6)):
+    """Parses a training log file and returns the iteration and loss values.
+
+    Args:
+        name (str): Name of training session.
+        display (bool, optional): If True, plot the training curve. Defaults to False.
+        figsize (tuple, optional): Plot size if display is True. Defaults to (12, 6).
+
+    Returns:
+        iterations (list of int), losses (list of float): Training curve values
+    """
     its = []
     losses = []
     with open(f"./ae_train_NoBackup/{name}/log.csv", "r") as f:

+ 112 - 4
py/Session.py

@@ -6,13 +6,15 @@ import subprocess
 from warnings import warn
 import os
 from tqdm import tqdm
-import matplotlib.image as mpimg
 from skimage import transform, io
 import IPython.display as display
 
 from py.FileUtils import list_folders, list_jpegs_recursive, verify_expected_subfolders
 from py.ImageUtils import display_images, get_image_date
 
+# A session represents the images taken from a single camera trap at a single position.
+# Each session has a subfolder in the dataset directory specifying the session name.
+# Each session has Lapse, Motion, and Full images, which can be accessed via this class.
 class Session:
     def __init__(self, folder: str):
         self.folder = folder
@@ -35,6 +37,9 @@ class Session:
             print("Session not scanned. Run session.scan() to create scan files")
     
     def load_scans(self):
+        """ Loads scan results (lapse dates, motion dates, lapse map) from files.
+            Use save_scans() or scan(auto_save=True) to save scan results.
+        """
         lapse_dates_file = os.path.join("session_scans", self.name, "lapse_dates.pickle")
         motion_dates_file = os.path.join("session_scans", self.name, "motion_dates.pickle")
         lapse_map_file = os.path.join("session_scans", self.name, "lapse_map.pickle")
@@ -56,6 +61,10 @@ class Session:
             self.scanned = False
     
     def save_scans(self):
+        """ Saves scan results (lapse dates, motion dates, lapse map) to files using pickle.
+            Use load_scans() to load scan results.
+            The output directory is ./session_scans/{session.name}
+        """
         os.makedirs(os.path.join("session_scans", self.name), exist_ok=True)
         lapse_dates_file = os.path.join("session_scans", self.name, "lapse_dates.pickle")
         motion_dates_file = os.path.join("session_scans", self.name, "motion_dates.pickle")
@@ -71,15 +80,28 @@ class Session:
             print(f"Saved {lapse_map_file}")
     
     def get_lapse_folder(self) -> str:
+        """Returns the path of the Lapse folder."""
         return os.path.join(self.folder, "Lapse")
     
     def get_motion_folder(self) -> str:
+        """Returns the path of the Motion folder."""
         return os.path.join(self.folder, "Motion")
     
     def get_full_folder(self) -> str:
+        """Returns the path of the Full folder."""
         return os.path.join(self.folder, "Full")
     
     def scan(self, force=False, auto_save=True):
+        """Scans Motion and Lapse images for their EXIF dates. This populates the fields
+        motion_dates, lapse_dates and motion_map.
+
+        Args:
+            force (bool, optional): Scan even if this session was already scanned. Defaults to False.
+            auto_save (bool, optional): Save scan results after scan. Defaults to True.
+
+        Raises:
+            ValueError: Session was already scanned and force=False.
+        """
         if self.scanned and not force:
             raise ValueError("Session is already scanned. Use force=True to scan anyway and override scan progress.")
         # Scan motion dates
@@ -109,6 +131,14 @@ class Session:
             self.save_scans()
     
     def check_lapse_duplicates(self):
+        """Checks the Lapse images for duplicates and prints the results.
+        A duplicate means there are two or more Lapse images with the same EXIF date.
+        A multiple means there are three or more such images (includes duplicates).
+        Deviant duplicate means there are two or more images which have the same EXIF date but are not identical (have different file sizes).
+
+        Returns:
+            total (int), total_duplicates (int), total_multiples (int), deviant_duplicates (int)
+        """
         total = 0
         total_duplicates = 0
         total_multiples = 0
@@ -135,6 +165,11 @@ class Session:
         return total, total_duplicates, total_multiples, deviant_duplicates
     
     def open_images_for_date(self, date: datetime):
+        """Open all lapse images with the specified EXIF date using the system image viewer.
+
+        Args:
+            date (datetime): Lapse date.
+        """
         img_names = self.lapse_map.get(date, [])
         if len(img_names) == 0:
             warn("No images for this date!")
@@ -144,14 +179,24 @@ class Session:
             subprocess.call(("xdg-open", full_path))
 
     def get_motion_image_from_filename(self, filename: str) -> "MotionImage":
+        """Returns a MotionImage instance from the filename of a motion image.
+
+        Args:
+            filename (str): File name of motion image.
+
+        Raises:
+            ValueError: Unknown motion file name.
+
+        Returns:
+            MotionImage: MotionImage instance.
+        """
         if filename in self.motion_dates:
             return MotionImage(self, filename, self.motion_dates[filename])
         else:
             raise ValueError(f"Unknown motion file name: {filename}")
     
     def __generate_motion_map(self):
-        """Populates self.motion_map which maps dates to motion images
-        """
+        """Populates self.motion_map which maps dates to motion images"""
         if self.motion_map is not None:
             return
         print("Generating motion map...")
@@ -163,11 +208,28 @@ class Session:
                 self.motion_map[date] = [filename]
     
     def get_motion_images_from_date(self, date: datetime):
+        """Returns MotionImage instances for all motion images with the specified EXIF date.
+
+        Args:
+            date (datetime): Motion date.
+        """
         self.__generate_motion_map()
         filenames = self.motion_map.get(date, [])
         return [MotionImage(self, filename, date) for filename in filenames]
     
     def get_random_motion_image(self, day_only=False, night_only=False) -> "MotionImage":
+        """Returns a MotionImage instance of a random Motion image.
+
+        Args:
+            day_only (bool, optional): Only return daytime images. Defaults to False.
+            night_only (bool, optional): Only return nighttime images. Defaults to False.
+
+        Raises:
+            ValueError: No motion images in this session.
+
+        Returns:
+            MotionImage: Random MotionImage or None if not found
+        """
         if len(self.motion_dates) == 0:
             raise ValueError("No motion images in session!")
         img = None
@@ -208,6 +270,17 @@ class Session:
         return imgs
     
     def generate_motion_image_sets(self) -> list:
+        """Generator function which yields consecutively taken motion image sets.
+
+        Raises:
+            ValueError: No motion images in this session.
+
+        Returns:
+            list: _description_
+
+        Yields:
+            Iterator[list of MotionImage]: consecutive motion image set
+        """
         self.__generate_motion_map()
         if len(self.motion_map) == 0:
             raise ValueError("No motion images in session!")
@@ -283,6 +356,7 @@ class Session:
         next_img = None if next_date is None else LapseImage(self, self.lapse_map[next_date][0], next_date)
         return previous_img, next_img
 
+# Abstract class which represents an image in a session (either Motion or Lapse).
 class SessionImage:
     def __init__(self, session: Session, subfolder: str, filename: str, date: datetime):
         self.session = session
@@ -293,14 +367,26 @@ class SessionImage:
             raise ValueError(f"File {subfolder}/{filename} in session folder {session.folder} not found!")
     
     def get_full_path(self) -> str:
+        """Returns the full path of this image. """
         return os.path.join(self.session.folder, self.subfolder, self.filename)
     
     def open(self):
+        """Open this image using the system image viewer. """
         full_path = self.get_full_path()
         print(f"Opening {full_path}...")
         subprocess.call(("xdg-open", full_path))
 
     def read(self, truncate_y = (40, 40), scale=1, gray=True):
+        """Read this image into a numpy array.
+
+        Args:
+            truncate_y (tuple, optional): Crop of the image at the top and bottom, respectively. Defaults to (40, 40).
+            scale (int, optional): Scale factor for rescaling. Defaults to 1.
+            gray (bool, optional): If True, read the image as grayscale. Defaults to True.
+
+        Returns:
+            np.array: image
+        """
         full_path = self.get_full_path()
         img = io.imread(full_path, as_gray=gray)
         # truncate
@@ -317,6 +403,16 @@ class SessionImage:
         return img
     
     def read_opencv(self, truncate_y = (40, 40), scale=1, gray=True):
+        """Read this image into an OpenCV Mat.
+
+        Args:
+            truncate_y (tuple, optional): Crop of the image at the top and bottom, respectively. Defaults to (40, 40).
+            scale (int, optional): Scale factor for rescaling. Defaults to 1.
+            gray (bool, optional): If True, read the image as grayscale. Defaults to True.
+
+        Returns:
+            OpenCV Mat: image
+        """
         full_path = self.get_full_path()
         img = cv.imread(full_path)
         # grayscale
@@ -337,14 +433,18 @@ class SessionImage:
         
 
     def is_daytime(self):
+        """Returns True if this image was taken at daytime based on the EXIF date. """
         return 6 <= self.date.hour <= 18
     
     def is_nighttime(self):
+        """Returns True if this image was taken at nighttime based on the EXIF date. """
         return not self.is_daytime()
     
     def to_ipython_image(self, width=500, height=None):
+        """Return an IPython image displaying this image. """
         return display.Image(filename=self.get_full_path(), width=width, height=height)
 
+# Represents a single Motion image. Should only be instantiated by Session.
 class MotionImage(SessionImage):
     def __init__(self, session: Session, filename: str, date: datetime):
         super().__init__(session, "Motion", filename, date)
@@ -352,6 +452,13 @@ class MotionImage(SessionImage):
             raise ValueError(f"File name {filename} not in session!")
 
     def get_closest_lapse_images(self):
+        """ Returns the closest lapse images before and after and the rel-value.
+            rel is a value between 0 and 1. The close rel is to 0 (1), the closer the motion image is too
+            the before (after) lapse image. If no lapse images were found, rel is -1.
+
+        Returns:
+            before (LapseImage or None), after (LapseImage or None), rel (float)
+        """
         before, after = self.session.get_closest_lapse_images(self.filename)
         rel = -1
         # rel = 0 if motion image was taken at before lapse image, rel = 1 if motion image was taken at after lapse image
@@ -364,7 +471,8 @@ class MotionImage(SessionImage):
         else:
             warn("No before and no after image!")
         return before, after, rel
-        
+
+# Represents a single Lapse image. Should only be instantiated by Session. 
 class LapseImage(SessionImage):
     def __init__(self, session: Session, filename: str, date: datetime):
         super().__init__(session, "Lapse", filename, date)

Some files were not shown because too many files changed in this diff