Felix Kleinsteuber %!s(int64=2) %!d(string=hai) anos
pai
achega
4e25520634

BIN=BIN
Cache_NoBackup/approach3_cluster10.npy


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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 |"
     "| | 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",
    "cell_type": "code",
    "execution_count": null,
    "execution_count": null,

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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": {},
    "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/plain": [
-       "3"
-      ]
-     },
-     "execution_count": 37,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
    "source": [
    "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)"
     "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",
    "cell_type": "code",
    "execution_count": 40,
    "execution_count": 40,
@@ -167,6 +161,13 @@
     "print(f\"Copied {lapse} files to Lapse, {motion} to Motion\")"
     "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",
    "cell_type": "code",
    "execution_count": 41,
    "execution_count": 41,
@@ -188,13 +189,6 @@
     "full_folder = os.path.join(TARGET_DIR, os.path.basename(target_session.folder), \"Full\")\n",
     "full_folder = os.path.join(TARGET_DIR, os.path.basename(target_session.folder), \"Full\")\n",
     "shutil.copytree(target_session.get_full_folder(), full_folder)"
     "shutil.copytree(target_session.get_full_folder(), full_folder)"
    ]
    ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
   }
   }
  ],
  ],
  "metadata": {
  "metadata": {

+ 7 - 7
index.ipynb

@@ -13,6 +13,8 @@
    "metadata": {},
    "metadata": {},
    "source": [
    "source": [
     "## Approach 1: Lapse Frame Differencing\n",
     "## 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",
     " - *approach1a_basic_frame_differencing.ipynb*: Implementation.\n",
     " - *approach1b_histograms.ipynb*: Discarded similar approach using histogram distribution to compare Lapse and Motion images.\n",
     " - *approach1b_histograms.ipynb*: Discarded similar approach using histogram distribution to compare Lapse and Motion images.\n",
     "\n",
     "\n",
@@ -41,17 +43,15 @@
     " - *analyze_dataset.ipynb*: Dataset statistics, check for duplicates\n",
     " - *analyze_dataset.ipynb*: Dataset statistics, check for duplicates\n",
     " - *analyze_labels.ipynb*: Annotation statistics (number of normal/anomalous motion samples)\n",
     " - *analyze_labels.ipynb*: Annotation statistics (number of normal/anomalous motion samples)\n",
     " - *check_csv.ipynb*: Loads annotations from *Kadaverbilder_leer.csv*\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",
     "\n",
     "## Early experiments\n",
     "## Early experiments\n",
     " - *deprecated/experiments.ipynb*: Early experiments with lapse images and frame differencing"
     " - *deprecated/experiments.ipynb*: Early experiments with lapse images and frame differencing"
    ]
    ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
   }
   }
  ],
  ],
  "metadata": {
  "metadata": {

BIN=BIN
plots/approach4/boxplot_kde_sessions.pdf


BIN=BIN
plots/approach4/boxplot_kde_sessions_loss.pdf


BIN=BIN
plots/approach4/boxplot_kde_sessions_loss_tnr95.pdf


BIN=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
 from torch import nn
 
 
 class Autoencoder(nn.Module):
 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
 from torch import nn
 
 
 class Autoencoder(nn.Module):
 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
 from torch import nn
 
 
 class Autoencoder(nn.Module):
 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.FileUtils import list_folders, list_jpegs_recursive, expected_subfolders, verify_expected_subfolders
 from py.Session import Session
 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:
 class Dataset:
 
 
     def __init__(self, base_path: str):
     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.base_path = base_path
         self.raw_sessions = []
         self.raw_sessions = []
         self.__parse_subdirectories()
         self.__parse_subdirectories()
@@ -23,10 +29,20 @@ class Dataset:
 
 
 
 
     def get_sessions(self) -> list:
     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)
         # cut off the first 33 characters (redundant)
         return [name[33:] for name in self.raw_sessions]
         return [name[33:] for name in self.raw_sessions]
     
     
     def create_statistics(self) -> DatasetStatistics:
     def create_statistics(self) -> DatasetStatistics:
+        """Accumulate statistics over the dataset and return a new statistics instance.
+
+        Returns:
+            DatasetStatistics: statistics instance
+        """
         counts = {}
         counts = {}
         for folder in tqdm(self.raw_sessions):
         for folder in tqdm(self.raw_sessions):
             counts[folder[33:]] = {}
             counts[folder[33:]] = {}
@@ -39,6 +55,17 @@ class Dataset:
         return DatasetStatistics(counts)
         return DatasetStatistics(counts)
 
 
     def create_session(self, session_name: str) -> Session:
     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:
         if session_name in self.raw_sessions:
             return Session(os.path.join(self.base_path, session_name))
             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()]
         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 numpy as np
 import pandas as pd
 import pandas as pd
 
 
+# helper for accumulating, saving, loading, and displaying dataset statistics
 class DatasetStatistics:
 class DatasetStatistics:
 
 
     def __init__(self, stats_dict: dict = None, load_from_file: str = None):
     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()
         self.df = pd.DataFrame.from_dict(self.stats).transpose()
 
 
     def add_total_row(self, row_name = "Z_Total") -> "DatasetStatistics":
     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:
         if row_name in self.stats:
             warn(f"{row_name} is already a defined row")
             warn(f"{row_name} is already a defined row")
             return self
             return self
@@ -47,18 +56,47 @@ class DatasetStatistics:
         return self
         return self
     
     
     def save(self, filename = "dataset_stats.npy"):
     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)
         np.save(filename, self.stats)
         print(f"Saved to {filename}.")
         print(f"Saved to {filename}.")
     
     
     def load(self, filename = "dataset_stats.npy"):
     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.stats = np.load(filename, allow_pickle=True).tolist()
         self.__update_dataframe()
         self.__update_dataframe()
         print(f"Loaded from {filename}.")
         print(f"Loaded from {filename}.")
 
 
     def view(self, col_order = ["Lapse", "Motion", "Full", "Total"]) -> pd.DataFrame:
     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]
         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):
     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]
         df = self.df[cols]
         # Plot lapse, motion, full columns without the last row (Z_Total)
         # Plot lapse, motion, full columns without the last row (Z_Total)
         if exclude_last_row:
         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
 from glob import glob
 import os
 import os
 import pickle
 import pickle
@@ -40,9 +41,23 @@ def verify_expected_subfolders(session_path: str):
 # Pickle helpers
 # Pickle helpers
 
 
 def dump(filename: str, data):
 def dump(filename: str, data):
+    """Dumps data using pickle.
+
+    Args:
+        filename (str): Target file name.
+        data (any): Data.
+    """
     with open(filename, "wb") as f:
     with open(filename, "wb") as f:
         pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
         pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
 
 
 def load(filename: str):
 def load(filename: str):
+    """Loads data using pickle.
+
+    Args:
+        filename (str): Target file name.
+
+    Returns:
+        any: loaded data
+    """
     with open(filename, "rb") as f:
     with open(filename, "rb") as f:
         return pickle.load(f)
         return pickle.load(f)

+ 6 - 0
py/ImageAnnotator.py

@@ -4,6 +4,8 @@ from py.Session import Session
 from py.ImageClassifier import AbstractImageClassifier
 from py.ImageClassifier import AbstractImageClassifier
 import numpy as np
 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():
 class ImageAnnotator():
     def __init__(self, classifier: AbstractImageClassifier, session: Session, initial_scores = [], initial_annotations = [], load_from = None):
     def __init__(self, classifier: AbstractImageClassifier, session: Session, initial_scores = [], initial_annotations = [], load_from = None):
         self.scores = initial_scores
         self.scores = initial_scores
@@ -26,6 +28,7 @@ class ImageAnnotator():
         anomalous_btn.on_click(self.mark_as_anomalous)
         anomalous_btn.on_click(self.mark_as_anomalous)
         self.next_image()
         self.next_image()
     
     
+    # Click on normal button
     def mark_as_normal(self, _):
     def mark_as_normal(self, _):
         with self.output:
         with self.output:
             print("Marking as normal...")
             print("Marking as normal...")
@@ -33,6 +36,7 @@ class ImageAnnotator():
         self.scores.append(self.score)
         self.scores.append(self.score)
         self.next_image()
         self.next_image()
     
     
+    # Click on anomalous button
     def mark_as_anomalous(self, _):
     def mark_as_anomalous(self, _):
         with self.output:
         with self.output:
             print("Marking as anomalous...")
             print("Marking as anomalous...")
@@ -40,6 +44,7 @@ class ImageAnnotator():
         self.scores.append(self.score)
         self.scores.append(self.score)
         self.next_image()
         self.next_image()
     
     
+    # Show next image
     def next_image(self):
     def next_image(self):
         img = self.session.get_random_motion_image(day_only=True)
         img = self.session.get_random_motion_image(day_only=True)
         self.score = self.classifier.evaluate(img)
         self.score = self.classifier.evaluate(img)
@@ -48,5 +53,6 @@ class ImageAnnotator():
             display(img.to_ipython_image())
             display(img.to_ipython_image())
             print(f"score = {self.score}")
             print(f"score = {self.score}")
 
 
+    # Save annotation data to file
     def save(self, filename: str):
     def save(self, filename: str):
         np.save(filename, [self.annotations, self.scores])
         np.save(filename, [self.annotations, self.scores])

+ 2 - 0
py/ImageClassifier.py

@@ -1,5 +1,7 @@
 from py.Session import MotionImage
 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():
 class AbstractImageClassifier():
     def evaluate(self, motion_img: MotionImage, display=False) -> int:
     def evaluate(self, motion_img: MotionImage, display=False) -> int:
         raise NotImplementedError("Please implement evaluate(motion_img)!")
         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 datetime import datetime
 from PIL import Image
 from PIL import Image
 import numpy as np
 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")
     plt.savefig(filename, bbox_inches="tight")
 
 
 def is_daytime(img, threshold=50) -> bool:
 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
     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 = {
 LABELS = {
     "Beaver_01": {
     "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"]
 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
 import matplotlib.pyplot as plt
 from sklearn.metrics import roc_curve, auc
 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):
 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)
     fpr, tpr, thresholds = roc_curve(test_labels, test_df)
     auc_score = auc(fpr, tpr)
     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
     return fpr, tpr, thresholds, auc_score
 
 
 def get_percentiles(fpr, tpr, thresholds, percentiles=[0.9, 0.95, 0.98, 0.99], verbose = True):
 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)
     assert percentiles == sorted(percentiles)
     tnrs = []
     tnrs = []
     for percentile in percentiles:
     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 os
 import matplotlib.pyplot as plt
 import matplotlib.pyplot as plt
 from torchvision import io, transforms
 from torchvision import io, transforms
 from torch.utils.data import DataLoader, Dataset
 from torch.utils.data import DataLoader, Dataset
 
 
+# PyTorch dataset instance which loads images from a directory
 class ImageDataset(Dataset):
 class ImageDataset(Dataset):
     def __init__(self, img_dir: str, transform = None, labeler = None, filter = lambda filename: True):
     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.img_dir = img_dir
         self.transform = transform
         self.transform = transform
         self.labeler = labeler
         self.labeler = labeler
@@ -18,9 +29,11 @@ class ImageDataset(Dataset):
     def __getitem__(self, idx):
     def __getitem__(self, idx):
         img_path = os.path.join(self.img_dir, self.files[idx])
         img_path = os.path.join(self.img_dir, self.files[idx])
         img = io.read_image(img_path)
         img = io.read_image(img_path)
+        # apply transform function
         if self.transform:
         if self.transform:
             img = self.transform(img)
             img = self.transform(img)
         label = 0
         label = 0
+        # get label
         if self.labeler:
         if self.labeler:
             label = self.labeler(self.files[idx])
             label = self.labeler(self.files[idx])
         return img, label
         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)
     return DataLoader(data, batch_size=batch_size, shuffle=shuffle)
 
 
 def model_output_to_image(y):
 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 = 0.5 * (y + 1) # normalize back to [0, 1]
     y = y.clamp(0, 1) # clamp to [0, 1]
     y = y.clamp(0, 1) # clamp to [0, 1]
     y = y.view(y.size(0), 3, 256, 256)
     y = y.view(y.size(0), 3, 256, 256)
     return y
     return y
 
 
 def get_log(name: str, display: bool = False, figsize: tuple = (12, 6)):
 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 = []
     its = []
     losses = []
     losses = []
     with open(f"./ae_train_NoBackup/{name}/log.csv", "r") as f:
     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
 from warnings import warn
 import os
 import os
 from tqdm import tqdm
 from tqdm import tqdm
-import matplotlib.image as mpimg
 from skimage import transform, io
 from skimage import transform, io
 import IPython.display as display
 import IPython.display as display
 
 
 from py.FileUtils import list_folders, list_jpegs_recursive, verify_expected_subfolders
 from py.FileUtils import list_folders, list_jpegs_recursive, verify_expected_subfolders
 from py.ImageUtils import display_images, get_image_date
 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:
 class Session:
     def __init__(self, folder: str):
     def __init__(self, folder: str):
         self.folder = folder
         self.folder = folder
@@ -35,6 +37,9 @@ class Session:
             print("Session not scanned. Run session.scan() to create scan files")
             print("Session not scanned. Run session.scan() to create scan files")
     
     
     def load_scans(self):
     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")
         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")
         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")
         lapse_map_file = os.path.join("session_scans", self.name, "lapse_map.pickle")
@@ -56,6 +61,10 @@ class Session:
             self.scanned = False
             self.scanned = False
     
     
     def save_scans(self):
     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)
         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")
         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")
         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}")
             print(f"Saved {lapse_map_file}")
     
     
     def get_lapse_folder(self) -> str:
     def get_lapse_folder(self) -> str:
+        """Returns the path of the Lapse folder."""
         return os.path.join(self.folder, "Lapse")
         return os.path.join(self.folder, "Lapse")
     
     
     def get_motion_folder(self) -> str:
     def get_motion_folder(self) -> str:
+        """Returns the path of the Motion folder."""
         return os.path.join(self.folder, "Motion")
         return os.path.join(self.folder, "Motion")
     
     
     def get_full_folder(self) -> str:
     def get_full_folder(self) -> str:
+        """Returns the path of the Full folder."""
         return os.path.join(self.folder, "Full")
         return os.path.join(self.folder, "Full")
     
     
     def scan(self, force=False, auto_save=True):
     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:
         if self.scanned and not force:
             raise ValueError("Session is already scanned. Use force=True to scan anyway and override scan progress.")
             raise ValueError("Session is already scanned. Use force=True to scan anyway and override scan progress.")
         # Scan motion dates
         # Scan motion dates
@@ -109,6 +131,14 @@ class Session:
             self.save_scans()
             self.save_scans()
     
     
     def check_lapse_duplicates(self):
     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 = 0
         total_duplicates = 0
         total_duplicates = 0
         total_multiples = 0
         total_multiples = 0
@@ -135,6 +165,11 @@ class Session:
         return total, total_duplicates, total_multiples, deviant_duplicates
         return total, total_duplicates, total_multiples, deviant_duplicates
     
     
     def open_images_for_date(self, date: datetime):
     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, [])
         img_names = self.lapse_map.get(date, [])
         if len(img_names) == 0:
         if len(img_names) == 0:
             warn("No images for this date!")
             warn("No images for this date!")
@@ -144,14 +179,24 @@ class Session:
             subprocess.call(("xdg-open", full_path))
             subprocess.call(("xdg-open", full_path))
 
 
     def get_motion_image_from_filename(self, filename: str) -> "MotionImage":
     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:
         if filename in self.motion_dates:
             return MotionImage(self, filename, self.motion_dates[filename])
             return MotionImage(self, filename, self.motion_dates[filename])
         else:
         else:
             raise ValueError(f"Unknown motion file name: {filename}")
             raise ValueError(f"Unknown motion file name: {filename}")
     
     
     def __generate_motion_map(self):
     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:
         if self.motion_map is not None:
             return
             return
         print("Generating motion map...")
         print("Generating motion map...")
@@ -163,11 +208,28 @@ class Session:
                 self.motion_map[date] = [filename]
                 self.motion_map[date] = [filename]
     
     
     def get_motion_images_from_date(self, date: datetime):
     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()
         self.__generate_motion_map()
         filenames = self.motion_map.get(date, [])
         filenames = self.motion_map.get(date, [])
         return [MotionImage(self, filename, date) for filename in filenames]
         return [MotionImage(self, filename, date) for filename in filenames]
     
     
     def get_random_motion_image(self, day_only=False, night_only=False) -> "MotionImage":
     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:
         if len(self.motion_dates) == 0:
             raise ValueError("No motion images in session!")
             raise ValueError("No motion images in session!")
         img = None
         img = None
@@ -208,6 +270,17 @@ class Session:
         return imgs
         return imgs
     
     
     def generate_motion_image_sets(self) -> list:
     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()
         self.__generate_motion_map()
         if len(self.motion_map) == 0:
         if len(self.motion_map) == 0:
             raise ValueError("No motion images in session!")
             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)
         next_img = None if next_date is None else LapseImage(self, self.lapse_map[next_date][0], next_date)
         return previous_img, next_img
         return previous_img, next_img
 
 
+# Abstract class which represents an image in a session (either Motion or Lapse).
 class SessionImage:
 class SessionImage:
     def __init__(self, session: Session, subfolder: str, filename: str, date: datetime):
     def __init__(self, session: Session, subfolder: str, filename: str, date: datetime):
         self.session = session
         self.session = session
@@ -293,14 +367,26 @@ class SessionImage:
             raise ValueError(f"File {subfolder}/{filename} in session folder {session.folder} not found!")
             raise ValueError(f"File {subfolder}/{filename} in session folder {session.folder} not found!")
     
     
     def get_full_path(self) -> str:
     def get_full_path(self) -> str:
+        """Returns the full path of this image. """
         return os.path.join(self.session.folder, self.subfolder, self.filename)
         return os.path.join(self.session.folder, self.subfolder, self.filename)
     
     
     def open(self):
     def open(self):
+        """Open this image using the system image viewer. """
         full_path = self.get_full_path()
         full_path = self.get_full_path()
         print(f"Opening {full_path}...")
         print(f"Opening {full_path}...")
         subprocess.call(("xdg-open", full_path))
         subprocess.call(("xdg-open", full_path))
 
 
     def read(self, truncate_y = (40, 40), scale=1, gray=True):
     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()
         full_path = self.get_full_path()
         img = io.imread(full_path, as_gray=gray)
         img = io.imread(full_path, as_gray=gray)
         # truncate
         # truncate
@@ -317,6 +403,16 @@ class SessionImage:
         return img
         return img
     
     
     def read_opencv(self, truncate_y = (40, 40), scale=1, gray=True):
     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()
         full_path = self.get_full_path()
         img = cv.imread(full_path)
         img = cv.imread(full_path)
         # grayscale
         # grayscale
@@ -337,14 +433,18 @@ class SessionImage:
         
         
 
 
     def is_daytime(self):
     def is_daytime(self):
+        """Returns True if this image was taken at daytime based on the EXIF date. """
         return 6 <= self.date.hour <= 18
         return 6 <= self.date.hour <= 18
     
     
     def is_nighttime(self):
     def is_nighttime(self):
+        """Returns True if this image was taken at nighttime based on the EXIF date. """
         return not self.is_daytime()
         return not self.is_daytime()
     
     
     def to_ipython_image(self, width=500, height=None):
     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)
         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):
 class MotionImage(SessionImage):
     def __init__(self, session: Session, filename: str, date: datetime):
     def __init__(self, session: Session, filename: str, date: datetime):
         super().__init__(session, "Motion", filename, date)
         super().__init__(session, "Motion", filename, date)
@@ -352,6 +452,13 @@ class MotionImage(SessionImage):
             raise ValueError(f"File name {filename} not in session!")
             raise ValueError(f"File name {filename} not in session!")
 
 
     def get_closest_lapse_images(self):
     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)
         before, after = self.session.get_closest_lapse_images(self.filename)
         rel = -1
         rel = -1
         # rel = 0 if motion image was taken at before lapse image, rel = 1 if motion image was taken at after lapse image
         # 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:
         else:
             warn("No before and no after image!")
             warn("No before and no after image!")
         return before, after, rel
         return before, after, rel
-        
+
+# Represents a single Lapse image. Should only be instantiated by Session. 
 class LapseImage(SessionImage):
 class LapseImage(SessionImage):
     def __init__(self, session: Session, filename: str, date: datetime):
     def __init__(self, session: Session, filename: str, date: datetime):
         super().__init__(session, "Lapse", filename, date)
         super().__init__(session, "Lapse", filename, date)

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio