diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 4882e03..41d9856 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -1,4 +1,4 @@ -name: Deploy MkDocs and Doorstop to GitHub Pages +name: Deploy MkDocs to GitHub Pages on: push: @@ -7,7 +7,6 @@ on: paths: - 'src/**' - 'docs/mkdocs/**' - - 'docs/doorstop/**' workflow_dispatch: jobs: @@ -25,11 +24,7 @@ jobs: - name: Install dependencies run: | - pip install mkdocs mkdocs-material mkdocstrings[python] doorstop - - - name: Generate Doorstop HTML - run: | - doorstop publish all docs/mkdocs/doorstop + pip install mkdocs mkdocs-material mkdocstrings[python] - name: Build and deploy MkDocs run: | diff --git a/.gitignore b/.gitignore index 3272ec0..eee00a3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ __pycache__ .env wandb .wandb -results \ No newline at end of file +results +wireguard \ No newline at end of file diff --git a/Justfile b/Justfile index 29e25b3..0aaa1e8 100644 --- a/Justfile +++ b/Justfile @@ -6,6 +6,25 @@ default: install: uv sync +# Connect to VPN +vpn_connect: + sudo wg-quick up wg0 + +# Disconnect from VPN +vpn_disconnect: + sudo wg-quick down wg0 + +# Download shape predictor model weights +download_shape_predictor: + mkdir -p models/dlib + wget -O models/dlib/shape_predictor_68_face_landmarks.dat.bz2 https://raw.githubusercontent.com/davisking/dlib-models/master/shape_predictor_68_face_landmarks.dat.bz2 + bzip2 -d models/dlib/shape_predictor_68_face_landmarks.dat.bz2 + +# Download opencv face detector model weights +download_opencv_face_detector: + mkdir -p models/opencv + wget -O models/opencv/haarcascade_frontalface_default.xml https://raw.githubusercontent.com/opencv/opencv/4.x/data/haarcascades/haarcascade_frontalface_default.xml + # === Scripts from src.scripts package === # Run benchmark @@ -36,7 +55,7 @@ test: # Run tests with coverage cov: - uv run pytest --cov=src + uv run pytest --cov=src --cov-report=term-missing # === Static Analysis === @@ -93,10 +112,6 @@ metrics_all: metrics_raw_src metrics_raw_tests metrics_raw_notebooks metrics_cyc # === Documentation === -# Build doorstop documentation -doorstop_build: - uv run doorstop publish all site/doorstop - # Serve documentation locally docs: uv run mkdocs serve diff --git a/docs/doorstop/WF/.doorstop.yml b/docs/doorstop/WF/.doorstop.yml deleted file mode 100644 index 0b81268..0000000 --- a/docs/doorstop/WF/.doorstop.yml +++ /dev/null @@ -1,5 +0,0 @@ -settings: - digits: 3 - itemformat: yaml - prefix: WF - sep: '' diff --git a/docs/doorstop/WF/WF001.yml b/docs/doorstop/WF/WF001.yml deleted file mode 100644 index 465005a..0000000 --- a/docs/doorstop/WF/WF001.yml +++ /dev/null @@ -1,10 +0,0 @@ -active: true -derived: false -header: '' -level: 1.0 -links: [] -normative: true -ref: '' -reviewed: KkjTuFFAY9xLIk27cikSjKfv939TdA42tyEL7Xm0ez8= -text: | - Model jest walidowany na zbiorze Rococo diff --git a/docs/doorstop/WT/.doorstop.yml b/docs/doorstop/WT/.doorstop.yml deleted file mode 100644 index 3a87d47..0000000 --- a/docs/doorstop/WT/.doorstop.yml +++ /dev/null @@ -1,6 +0,0 @@ -settings: - digits: 3 - itemformat: yaml - parent: WF - prefix: WT - sep: '' diff --git a/docs/doorstop/WT/WT001.yml b/docs/doorstop/WT/WT001.yml deleted file mode 100644 index 816ad01..0000000 --- a/docs/doorstop/WT/WT001.yml +++ /dev/null @@ -1,12 +0,0 @@ -active: true -derived: false -header: '' -level: 1.0 -links: -- WF001: KkjTuFFAY9xLIk27cikSjKfv939TdA42tyEL7Xm0ez8= -normative: true -ref: '' -reviewed: K6mIjvT6I4-7imX8YIB3nguXp06pW4hVjjLaYSnTYy8= -test: untested -text: | - Jakiƛ test diff --git a/docs/mkdocs/api/augmentation.md b/docs/mkdocs/api/augmentation.md deleted file mode 100644 index 76a79fe..0000000 --- a/docs/mkdocs/api/augmentation.md +++ /dev/null @@ -1,3 +0,0 @@ -# Augmentation - -::: src.augmentation \ No newline at end of file diff --git a/docs/mkdocs/api/common.md b/docs/mkdocs/api/common.md new file mode 100644 index 0000000..c45b997 --- /dev/null +++ b/docs/mkdocs/api/common.md @@ -0,0 +1,3 @@ +# Common + +::: src.common \ No newline at end of file diff --git a/docs/mkdocs/api/evaluation.md b/docs/mkdocs/api/evaluation.md new file mode 100644 index 0000000..94e1aee --- /dev/null +++ b/docs/mkdocs/api/evaluation.md @@ -0,0 +1,3 @@ +# Evaluation + +::: src.evaluation \ No newline at end of file diff --git a/docs/mkdocs/api/loss.md b/docs/mkdocs/api/loss.md deleted file mode 100644 index 61107bb..0000000 --- a/docs/mkdocs/api/loss.md +++ /dev/null @@ -1,3 +0,0 @@ -# Loss - -::: src.loss \ No newline at end of file diff --git a/docs/mkdocs/api/plots.md b/docs/mkdocs/api/plots.md new file mode 100644 index 0000000..c89ee07 --- /dev/null +++ b/docs/mkdocs/api/plots.md @@ -0,0 +1,3 @@ +# Plots + +::: src.plots \ No newline at end of file diff --git a/docs/mkdocs/api/scripts.md b/docs/mkdocs/api/scripts.md new file mode 100644 index 0000000..f6d3490 --- /dev/null +++ b/docs/mkdocs/api/scripts.md @@ -0,0 +1,3 @@ +# Scripts + +::: src.scripts \ No newline at end of file diff --git a/docs/mkdocs/api/training.md b/docs/mkdocs/api/training.md new file mode 100644 index 0000000..f51d772 --- /dev/null +++ b/docs/mkdocs/api/training.md @@ -0,0 +1,5 @@ +# Training + +::: src.training + options: + show_submodules: true diff --git a/docs/mkdocs/api/validation.md b/docs/mkdocs/api/validation.md deleted file mode 100644 index ab1a6cb..0000000 --- a/docs/mkdocs/api/validation.md +++ /dev/null @@ -1,3 +0,0 @@ -# Validation - -::: src.validation \ No newline at end of file diff --git a/docs/mkdocs/api/video_processing.md b/docs/mkdocs/api/video_processing.md new file mode 100644 index 0000000..179fdf4 --- /dev/null +++ b/docs/mkdocs/api/video_processing.md @@ -0,0 +1,3 @@ +# Video Processing + +::: src.video_processing \ No newline at end of file diff --git a/docs/mkdocs/index.md b/docs/mkdocs/index.md index 6fd3c2a..b6c9962 100644 --- a/docs/mkdocs/index.md +++ b/docs/mkdocs/index.md @@ -1,3 +1,3 @@ -# Dokumentacja projektu +# Project documentation -Wygenerowana przy pomocy MkDocs \ No newline at end of file +Generated with MkDocs. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 08f117c..5b7f500 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,4 @@ -site_name: "Dokumentacja projektu" +site_name: "Project documentation" docs_dir: "docs/mkdocs" theme: name: "material" @@ -21,9 +21,11 @@ plugins: show_function_docstring: true nav: - Home: index.md - - API: - - Augmentation: api/augmentation.md + - API Reference: + - Common: api/common.md - Dataset: api/dataset.md - - Loss: api/loss.md - - Validation: api/validation.md - - Wymagania: doorstop/index.html \ No newline at end of file + - Evaluation: api/evaluation.md + - Plots: api/plots.md + - Scripts: api/scripts.md + - Training: api/training.md + - Video Processing: api/video_processing.md \ No newline at end of file diff --git a/notebooks/04-fine_tuning.ipynb b/notebooks/04-fine_tuning.ipynb index f3b4355..168c95e 100644 --- a/notebooks/04-fine_tuning.ipynb +++ b/notebooks/04-fine_tuning.ipynb @@ -44,15 +44,15 @@ "from torchvision import transforms\n", "from torchvision.utils import make_grid\n", "\n", - "from src.augmentation.mask import GenerateMask\n", - "from src.augmentation.random_rectangle import (\n", + "from src.training.augmentation import GenerateMask\n", + "from src.training.augmentation.random_rectangle import (\n", " AddRandomRectangleAverageColor,\n", " AddRandomRectangleRandomColor,\n", " AddRandomBlackRectangle,\n", ")\n", "from src.dataset.lfw import LFWDataset\n", "from src.dataset.rof import ROFDataset\n", - "from src.loss.contrastive_loss import ContrastiveLoss" + "from src.training.loss import ContrastiveLoss" ], "id": "8d8ee930d7320f79", "outputs": [], diff --git a/notebooks/07-face_landmark_based_augmentation.ipynb b/notebooks/07-face_landmark_based_augmentation.ipynb index 02fd3e4..36c7042 100644 --- a/notebooks/07-face_landmark_based_augmentation.ipynb +++ b/notebooks/07-face_landmark_based_augmentation.ipynb @@ -43,7 +43,7 @@ "\n", "os.chdir(\"..\")\n", "\n", - "from src.augmentation import AddMaskAugmentation\n", + "from src.training.augmentation import AddMaskAugmentation\n", "\n", "os.getcwd()" ] diff --git a/notebooks/08-glasses-augmentation.ipynb b/notebooks/08-glasses-augmentation.ipynb index 109ab7f..4b189f0 100644 --- a/notebooks/08-glasses-augmentation.ipynb +++ b/notebooks/08-glasses-augmentation.ipynb @@ -26,8 +26,8 @@ "\n", "os.chdir(\"..\")\n", "\n", - "from src.training.data_loaders import get_lfw_loaders\n", - "from src.augmentation import AddGlassesAugmentation\n", + "from src.dataset.data_loaders import get_lfw_loaders\n", + "from src.training.augmentation import AddGlassesAugmentation\n", "\n", "os.getcwd()" ] diff --git a/notebooks/09-all-augmentations-demo.ipynb b/notebooks/09-all-augmentations-demo.ipynb index 1b7c80f..bd69a5a 100644 --- a/notebooks/09-all-augmentations-demo.ipynb +++ b/notebooks/09-all-augmentations-demo.ipynb @@ -31,7 +31,7 @@ "\n", "os.chdir(\"..\")\n", "\n", - "from src.augmentation import get_augmentation_transform\n", + "from src.training.augmentation import get_augmentation_transform\n", "\n", "os.getcwd()" ] diff --git a/pyproject.toml b/pyproject.toml index 3d94f87..8b676fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,12 @@ profile = "black" [tool.pytest.ini_options] pythonpath = ["src"] +markers = [ + "integration: integration tests that may require external resources (e.g. model weights)", +] +filterwarnings = [ + "ignore::pydantic._internal._generate_schema.UnsupportedFieldAttributeWarning", # Ignore warning from libraries +] [dependency-groups] dev = [ @@ -43,7 +49,6 @@ dev = [ "mkdocs>=1.6.1", "mkdocstrings[python]>=0.29.1", "mkdocs-material>=9.6.13", - "doorstop>=3.0", "pytest-cov>=6.1.1", ] diff --git a/src/benchmark/__init__.py b/src/benchmark/__init__.py deleted file mode 100644 index 6b491cb..0000000 --- a/src/benchmark/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from src.benchmark.face_pairs import ( - FacePairsBenchmark, - FacePairsBenchmarkConfig, - FacePairsBenchmarkResult, -) - -__all__ = [ - "FacePairsBenchmarkConfig", - "FacePairsBenchmarkResult", - "FacePairsBenchmark", -] diff --git a/src/common/__init__.py b/src/common/__init__.py new file mode 100644 index 0000000..06ee77e --- /dev/null +++ b/src/common/__init__.py @@ -0,0 +1,15 @@ +from .config import default_device_str +from .models import ( + get_dlib_frontal_face_detector, + get_dlib_shape_predictor, + get_facenet_pytorch_inception_resnet_v1, + get_facenet_pytorch_mtcnn_detector, +) + +__all__ = [ + "default_device_str", + "get_facenet_pytorch_inception_resnet_v1", + "get_facenet_pytorch_mtcnn_detector", + "get_dlib_frontal_face_detector", + "get_dlib_shape_predictor", +] diff --git a/src/utils/config.py b/src/common/config.py similarity index 100% rename from src/utils/config.py rename to src/common/config.py diff --git a/src/training/models.py b/src/common/models.py similarity index 100% rename from src/training/models.py rename to src/common/models.py diff --git a/src/dataset/__init__.py b/src/dataset/__init__.py index e69de29..ddd5d68 100644 --- a/src/dataset/__init__.py +++ b/src/dataset/__init__.py @@ -0,0 +1,21 @@ +from .data_loaders import ( + get_dummy_loaders, + get_lfw_loaders, + get_rococo_loaders, +) +from .dummy import DummyFacePairsDataset +from .face_pairs import FacePairsDataset +from .lfw import LFWDataset +from .rococo_training import RococoTrainingDataset +from .rof import ROFDataset + +__all__ = [ + "get_lfw_loaders", + "get_rococo_loaders", + "get_dummy_loaders", + "DummyFacePairsDataset", + "FacePairsDataset", + "LFWDataset", + "RococoTrainingDataset", + "ROFDataset", +] diff --git a/src/training/data_loaders.py b/src/dataset/data_loaders.py similarity index 77% rename from src/training/data_loaders.py rename to src/dataset/data_loaders.py index 28cbce7..d5ab0d4 100644 --- a/src/training/data_loaders.py +++ b/src/dataset/data_loaders.py @@ -1,7 +1,8 @@ from torch.utils.data import DataLoader -from src.dataset.lfw import LFWDataset -from src.dataset.rococo_training import RococoTrainingDataset +from .dummy import DummyFacePairsDataset +from .lfw import LFWDataset +from .rococo_training import RococoTrainingDataset def get_lfw_loaders( @@ -58,7 +59,6 @@ def get_rococo_loaders( num_workers=4, pin_memory=True, ) -> dict[str, DataLoader]: - train_set = RococoTrainingDataset.from_match_and_mismatch_pairs( root_dir=rococo_root, match_pairs_file="data/rococo2v3-dev/train_match_pairs.csv", @@ -99,3 +99,34 @@ def get_rococo_loaders( "train": train_loader, "val": val_loader, } + + +def get_dummy_loaders( + transform, + batch_size=32, + num_workers=0, + pin_memory=False, +) -> dict[str, DataLoader]: + train_set = DummyFacePairsDataset(transform) + val_set = DummyFacePairsDataset(transform) + + train_loader = DataLoader( + train_set, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + pin_memory=pin_memory, + ) + + val_loader = DataLoader( + val_set, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + pin_memory=pin_memory, + ) + + return { + "train": train_loader, + "val": val_loader, + } diff --git a/src/dataset/dummy.py b/src/dataset/dummy.py new file mode 100644 index 0000000..14356ef --- /dev/null +++ b/src/dataset/dummy.py @@ -0,0 +1,29 @@ +from PIL import Image + +from .face_pairs import FacePairsDataset + + +class DummyFacePairsDataset(FacePairsDataset): + """Dummy in-memory dataset for testing""" + + def __init__(self, transform): + self.transform = transform + + # 60 pairs: 30 match (same color), 30 mismatch (different colors) + self.pairs = [] + for i in range(30): + color = (i * 8, 128, 200) + img = Image.new("RGB", (160, 160), color) + self.pairs.append((img, img.copy(), 1)) # match + + for i in range(30): + img1 = Image.new("RGB", (160, 160), (i * 8, 100, 150)) + img2 = Image.new("RGB", (160, 160), (255 - i * 8, 200, 50)) + self.pairs.append((img1, img2, -1)) # mismatch + + def __len__(self): + return len(self.pairs) + + def __getitem__(self, idx): + img1, img2, label = self.pairs[idx] + return self.transform(img1), self.transform(img2), label diff --git a/src/dataset/lfw.py b/src/dataset/lfw.py index 150adc7..856866d 100644 --- a/src/dataset/lfw.py +++ b/src/dataset/lfw.py @@ -2,7 +2,7 @@ from PIL import Image -from src.dataset.face_pairs import FacePairsDataset +from .face_pairs import FacePairsDataset class LFWDataset(FacePairsDataset): diff --git a/src/dataset/rococo_training.py b/src/dataset/rococo_training.py index d2789b5..ba5dac7 100644 --- a/src/dataset/rococo_training.py +++ b/src/dataset/rococo_training.py @@ -2,7 +2,7 @@ from PIL import Image -from src.dataset.face_pairs import FacePairsDataset +from .face_pairs import FacePairsDataset class RococoTrainingDataset(FacePairsDataset): diff --git a/src/dataset/rof.py b/src/dataset/rof.py index 6c4f827..caf1183 100644 --- a/src/dataset/rof.py +++ b/src/dataset/rof.py @@ -6,7 +6,7 @@ from PIL import Image -from src.dataset.face_pairs import FacePairsDataset +from .face_pairs import FacePairsDataset class RofVariant(Enum): diff --git a/src/evaluation/__init__.py b/src/evaluation/__init__.py index e69de29..7b8b651 100644 --- a/src/evaluation/__init__.py +++ b/src/evaluation/__init__.py @@ -0,0 +1,17 @@ +from .face_pairs import ( + FacePairsBenchmark, + FacePairsBenchmarkConfig, + FacePairsBenchmarkResult, +) +from .rococo import ( + FaceRecognitionSystemFacenetPytorchAdapter, + log_results_to_wandb, +) + +__all__ = [ + "FacePairsBenchmarkConfig", + "FacePairsBenchmarkResult", + "FacePairsBenchmark", + "FaceRecognitionSystemFacenetPytorchAdapter", + "log_results_to_wandb", +] diff --git a/src/benchmark/face_pairs.py b/src/evaluation/face_pairs.py similarity index 95% rename from src/benchmark/face_pairs.py rename to src/evaluation/face_pairs.py index 736973d..e4decc1 100644 --- a/src/benchmark/face_pairs.py +++ b/src/evaluation/face_pairs.py @@ -7,11 +7,8 @@ from torch.utils.data import DataLoader from torchvision import transforms -from src.dataset.face_pairs import FacePairsDataset -from src.dataset.lfw import LFWDataset -from src.dataset.rof import ROFDataset -from src.training.models import get_facenet_pytorch_inception_resnet_v1 -from src.utils.config import default_device_str +from src.common import default_device_str, get_facenet_pytorch_inception_resnet_v1 +from src.dataset import FacePairsDataset, LFWDataset, ROFDataset @dataclass(frozen=True) @@ -45,8 +42,6 @@ class FacePairsBenchmark: Computes accuracy: (TP + TN) / TOTAL """ - N_ELEMENTS = 6000 # Expected number of elements in LFW and ROF datasets - def __init__( self, model: nn.Module, @@ -112,7 +107,6 @@ def _get_data_loader(self): def _compute_embeddings(self) -> tuple[Tensor, Tensor, Tensor]: n_examples = len(self.dataset) - assert n_examples == self.N_ELEMENTS pair_classes = [] embeddings_1 = [] embeddings_2 = [] diff --git a/src/evaluation/rococo.py b/src/evaluation/rococo.py index e95d24e..22cf858 100644 --- a/src/evaluation/rococo.py +++ b/src/evaluation/rococo.py @@ -3,6 +3,7 @@ import cv2 import torch import torch.nn as nn +import wandb from numpy import ndarray from PIL import Image from rococo_evaluation_lib import ( @@ -11,8 +12,6 @@ ValidationResults, ) -import wandb - class FaceRecognitionSystemFacenetPytorchAdapter(FaceRecognitionSystem): """Adapter for compatibility with the rococo evaluation library. diff --git a/src/plots/__init__.py b/src/plots/__init__.py index c3b760d..25ca985 100644 --- a/src/plots/__init__.py +++ b/src/plots/__init__.py @@ -2,3 +2,8 @@ Visualizing experiment data downloaded from Weights & Biases API. """ + +from .plot_groups import PlotGroups +from .wandb_client import WandbClient + +__all__ = ["PlotGroups", "WandbClient"] diff --git a/src/plots/plot_groups.py b/src/plots/plot_groups.py index 4d790c6..518ba1f 100644 --- a/src/plots/plot_groups.py +++ b/src/plots/plot_groups.py @@ -7,8 +7,8 @@ from pathlib import Path from typing import Any -from src.plots.individual_plots import PlotMaker -from src.plots.sweep_data import SweepData +from .individual_plots import PlotMaker +from .sweep_data import SweepData class PlotGroups: diff --git a/src/plots/wandb_client.py b/src/plots/wandb_client.py index cc21d22..e0b5679 100644 --- a/src/plots/wandb_client.py +++ b/src/plots/wandb_client.py @@ -1,10 +1,10 @@ import json from pathlib import Path +import wandb from pandas import DataFrame -import wandb -from src.plots.sweep_data import SweepData +from .sweep_data import SweepData class WandbClient: diff --git a/src/scripts/benchmark.py b/src/scripts/benchmark.py index 121e344..32ff8c6 100644 --- a/src/scripts/benchmark.py +++ b/src/scripts/benchmark.py @@ -1,8 +1,9 @@ import argparse import wandb -from src.benchmark import FacePairsBenchmark, FacePairsBenchmarkConfig -from src.training.models import get_facenet_pytorch_inception_resnet_v1 + +from src.common import get_facenet_pytorch_inception_resnet_v1 +from src.evaluation import FacePairsBenchmark, FacePairsBenchmarkConfig def main(): diff --git a/src/scripts/detector_evaluation.py b/src/scripts/detector_evaluation.py index a7662e9..fe61c6d 100644 --- a/src/scripts/detector_evaluation.py +++ b/src/scripts/detector_evaluation.py @@ -5,8 +5,7 @@ from PIL import Image from rococo_evaluation_lib import RococoDataset -from src.training.models import get_facenet_pytorch_mtcnn_detector -from src.utils.config import default_device_str +from src.common import default_device_str, get_facenet_pytorch_mtcnn_detector @dataclass diff --git a/src/scripts/experiment_sweep.py b/src/scripts/experiment_sweep.py index 65deeec..aaf5d9f 100644 --- a/src/scripts/experiment_sweep.py +++ b/src/scripts/experiment_sweep.py @@ -6,21 +6,21 @@ from datetime import datetime import torch +import wandb from rococo_evaluation_lib import RococoDataset, RococoValidation -import wandb -from src.benchmark import FacePairsBenchmark, FacePairsBenchmarkConfig -from src.evaluation.rococo import ( - FaceRecognitionSystemFacenetPytorchAdapter, - log_results_to_wandb, -) -from src.training.fine_tuning import FineTuningTrainer -from src.training.fine_tuning_config import FineTuningConfig -from src.training.models import ( +from src.common import ( + default_device_str, get_facenet_pytorch_inception_resnet_v1, get_facenet_pytorch_mtcnn_detector, ) -from src.utils.config import default_device_str +from src.evaluation import ( + FacePairsBenchmark, + FacePairsBenchmarkConfig, + FaceRecognitionSystemFacenetPytorchAdapter, + log_results_to_wandb, +) +from src.training import FineTuningConfig, FineTuningTrainer def main(): diff --git a/src/scripts/plots_for_publication.py b/src/scripts/plots_for_publication.py index 74018a7..163d3d0 100644 --- a/src/scripts/plots_for_publication.py +++ b/src/scripts/plots_for_publication.py @@ -1,12 +1,11 @@ from dataclasses import dataclass from pathlib import Path +from src.plots import PlotGroups, WandbClient from src.plots.parameter_importance import ( experiment_05_parameter_importance, experiment_06_parameter_importance, ) -from src.plots.plot_groups import PlotGroups -from src.plots.wandb_client import WandbClient @dataclass diff --git a/src/scripts/process_video.py b/src/scripts/process_video.py index 4caf6a8..5223ca1 100644 --- a/src/scripts/process_video.py +++ b/src/scripts/process_video.py @@ -70,39 +70,7 @@ from datetime import datetime from time import perf_counter -from src.video_processing.configuration import Configuration -from src.video_processing.processor import VideoProcessor - - -def naive_strategy(config: Configuration): - """Naive sampling strategy that samples frames from each sequence.""" - processor = VideoProcessor(config) - processor.find_face_sequences() - processor.print_sequence_analysis() - - # Sample frames from sequences - sequence_mapping = processor.sample_frames_from_sequences_naive( - config.samples_per_sequence - ) - processor.save_sampled_camera_regions_from_sequences(sequence_mapping) - - -def offset_strategy(config: Configuration): - """Offset sampling strategy that samples frames based on sequence descriptors.""" - processor = VideoProcessor(config) - processor.find_face_sequences() - processor.print_sequence_analysis() - - # Offset sequence sampling - assert config.offset_before_marker is not None - assert config.offset_after_marker is not None - seq_descriptors = processor.get_offset_sequence_bounds( - config.offset_before_marker, config.offset_after_marker - ) - samples = processor.sample_frames_from_sequence_offset_based( - seq_descriptors, config.samples_per_sequence - ) - processor.save_sampled_camera_regions_from_sequences(samples) +from src.video_processing import Configuration, naive_strategy, offset_strategy def main(): diff --git a/src/scripts/rococo_evaluation.py b/src/scripts/rococo_evaluation.py index ae9a34e..484828c 100644 --- a/src/scripts/rococo_evaluation.py +++ b/src/scripts/rococo_evaluation.py @@ -3,21 +3,21 @@ from dataclasses import asdict import torch +import wandb from rococo_evaluation_lib import ( RococoDataset, RococoValidation, ) -import wandb -from src.evaluation.rococo import ( - FaceRecognitionSystemFacenetPytorchAdapter, - log_results_to_wandb, -) -from src.training.models import ( +from src.common import ( + default_device_str, get_facenet_pytorch_inception_resnet_v1, get_facenet_pytorch_mtcnn_detector, ) -from src.utils.config import default_device_str +from src.evaluation import ( + FaceRecognitionSystemFacenetPytorchAdapter, + log_results_to_wandb, +) def main(): diff --git a/src/training/__init__.py b/src/training/__init__.py index e69de29..4600f6e 100644 --- a/src/training/__init__.py +++ b/src/training/__init__.py @@ -0,0 +1,4 @@ +from .fine_tuning import FineTuningTrainer +from .fine_tuning_config import FineTuningConfig + +__all__ = ["FineTuningTrainer", "FineTuningConfig"] diff --git a/src/augmentation/__init__.py b/src/training/augmentation/__init__.py similarity index 52% rename from src/augmentation/__init__.py rename to src/training/augmentation/__init__.py index 5f47911..626a825 100644 --- a/src/augmentation/__init__.py +++ b/src/training/augmentation/__init__.py @@ -1,8 +1,8 @@ -from src.augmentation.gaussian_noise_pil import GaussianNoisePIL -from src.augmentation.get_augmentation_transform import get_augmentation_transform -from src.augmentation.glasses import AddGlassesAugmentation -from src.augmentation.mask import AddMaskAugmentation -from src.augmentation.random_rectangle import ( +from .gaussian_noise_pil import GaussianNoisePIL +from .get_augmentation_transform import get_augmentation_transform +from .glasses import AddGlassesAugmentation +from .mask import AddMaskAugmentation +from .random_rectangle import ( AddRandomBlackRectangle, AddRandomRectangleAverageColor, AddRandomRectangleRandomColor, diff --git a/src/augmentation/gaussian_noise_pil.py b/src/training/augmentation/gaussian_noise_pil.py similarity index 59% rename from src/augmentation/gaussian_noise_pil.py rename to src/training/augmentation/gaussian_noise_pil.py index 3ec5ee0..1854076 100644 --- a/src/augmentation/gaussian_noise_pil.py +++ b/src/training/augmentation/gaussian_noise_pil.py @@ -1,5 +1,11 @@ +import torch from PIL import Image -from torchvision.transforms.v2.functional import gaussian_noise, to_pil_image, to_tensor +from torchvision.transforms.v2.functional import ( + gaussian_noise, + to_dtype, + to_image, + to_pil_image, +) class GaussianNoisePIL: @@ -11,6 +17,10 @@ def __init__(self, mean=0.0, sigma=0.1): def __call__(self, img: Image.Image) -> Image.Image: """Apply Gaussian noise to the input PIL image.""" - img_tensor = to_tensor(img) + img_tensor = self.pil_to_tensor(img) noisy_tensor = gaussian_noise(img_tensor, mean=self.mean, sigma=self.sigma) return to_pil_image(noisy_tensor) + + @staticmethod + def pil_to_tensor(img: Image.Image): + return to_dtype(to_image(img), dtype=torch.float32, scale=True) diff --git a/src/augmentation/get_augmentation_transform.py b/src/training/augmentation/get_augmentation_transform.py similarity index 88% rename from src/augmentation/get_augmentation_transform.py rename to src/training/augmentation/get_augmentation_transform.py index 153ef8b..c727f36 100644 --- a/src/augmentation/get_augmentation_transform.py +++ b/src/training/augmentation/get_augmentation_transform.py @@ -2,10 +2,10 @@ from torchvision.transforms.v2 import ColorJitter, GaussianBlur, Lambda, RandomChoice -from src.augmentation.gaussian_noise_pil import GaussianNoisePIL -from src.augmentation.glasses import AddGlassesAugmentation -from src.augmentation.mask import AddMaskAugmentation -from src.augmentation.random_rectangle import ( +from .gaussian_noise_pil import GaussianNoisePIL +from .glasses import AddGlassesAugmentation +from .mask import AddMaskAugmentation +from .random_rectangle import ( AddRandomBlackRectangle, AddRandomRectangleAverageColor, AddRandomRectangleRandomColor, diff --git a/src/augmentation/glasses.py b/src/training/augmentation/glasses.py similarity index 97% rename from src/augmentation/glasses.py rename to src/training/augmentation/glasses.py index e1f8ed7..36b5264 100644 --- a/src/augmentation/glasses.py +++ b/src/training/augmentation/glasses.py @@ -4,7 +4,7 @@ import numpy as np from PIL import Image, ImageDraw, ImageFilter -from src.training.models import get_dlib_frontal_face_detector, get_dlib_shape_predictor +from src.common import get_dlib_frontal_face_detector, get_dlib_shape_predictor class AddGlassesAugmentation: @@ -16,7 +16,7 @@ class AddGlassesAugmentation: The glasses are composed of 3 line segments and 2 ellipses, inside the ellipses a Gaussian blur is applied. Example usage: - >>> from src.augmentation import AddGlassesAugmentation + >>> from src.training.augmentation import AddGlassesAugmentation >>> from PIL import Image >>> img = Image.open("image.jpg") >>> add_glasses = AddGlassesAugmentation.with_default_models() diff --git a/src/augmentation/mask.py b/src/training/augmentation/mask.py similarity index 95% rename from src/augmentation/mask.py rename to src/training/augmentation/mask.py index 3fec8fc..4194a97 100644 --- a/src/augmentation/mask.py +++ b/src/training/augmentation/mask.py @@ -2,7 +2,7 @@ import numpy as np from PIL import Image, ImageDraw -from src.training.models import get_dlib_frontal_face_detector, get_dlib_shape_predictor +from src.common import get_dlib_frontal_face_detector, get_dlib_shape_predictor class AddMaskAugmentation: diff --git a/src/augmentation/random_rectangle.py b/src/training/augmentation/random_rectangle.py similarity index 100% rename from src/augmentation/random_rectangle.py rename to src/training/augmentation/random_rectangle.py diff --git a/src/training/fine_tuning.py b/src/training/fine_tuning.py index df4650e..ea0c4ef 100644 --- a/src/training/fine_tuning.py +++ b/src/training/fine_tuning.py @@ -4,17 +4,22 @@ import torch import torch.nn.functional as F +import wandb from torch import Tensor, nn from torch.optim import Optimizer from torch.utils.data import DataLoader from torchvision import transforms -import wandb -from src.augmentation.get_augmentation_transform import get_augmentation_transform -from src.loss.get_loss_fn import get_loss_fn -from src.training.data_loaders import get_lfw_loaders, get_rococo_loaders -from src.training.fine_tuning_config import FineTuningConfig -from src.training.models import get_facenet_pytorch_inception_resnet_v1 +from src.common import get_facenet_pytorch_inception_resnet_v1 +from src.dataset import ( + get_dummy_loaders, + get_lfw_loaders, + get_rococo_loaders, +) + +from .augmentation import get_augmentation_transform +from .fine_tuning_config import FineTuningConfig +from .loss import get_loss_fn def save_model_to_file(model: nn.Module, file_path: str): @@ -56,6 +61,8 @@ def get_data_loaders(config: FineTuningConfig) -> dict[str, DataLoader]: transform_frame=transform, batch_size=config.batch_size, ) + elif config.dataset == "dummy": + return get_dummy_loaders(transform, batch_size=config.batch_size) else: raise ValueError(f"Unknown dataset: {config.dataset}") diff --git a/src/training/fine_tuning_config.py b/src/training/fine_tuning_config.py index 1815351..baef077 100644 --- a/src/training/fine_tuning_config.py +++ b/src/training/fine_tuning_config.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from typing import Literal, Optional -from src.utils.config import default_device_str +from src.common import default_device_str @dataclass @@ -17,4 +17,4 @@ class FineTuningConfig: device: str = field(default_factory=default_device_str) augmentation: Optional[str] = None only_head: bool = True - dataset: Literal["lfw", "rococo"] = "lfw" + dataset: Literal["lfw", "rococo", "dummy"] = "lfw" diff --git a/src/training/loss/__init__.py b/src/training/loss/__init__.py new file mode 100644 index 0000000..5336533 --- /dev/null +++ b/src/training/loss/__init__.py @@ -0,0 +1,7 @@ +from .contrastive_loss import ContrastiveLoss +from .get_loss_fn import get_loss_fn + +__all__ = [ + "get_loss_fn", + "ContrastiveLoss", +] diff --git a/src/loss/contrastive_loss.py b/src/training/loss/contrastive_loss.py similarity index 100% rename from src/loss/contrastive_loss.py rename to src/training/loss/contrastive_loss.py diff --git a/src/loss/get_loss_fn.py b/src/training/loss/get_loss_fn.py similarity index 88% rename from src/loss/get_loss_fn.py rename to src/training/loss/get_loss_fn.py index d9ea9bd..97e7b15 100644 --- a/src/loss/get_loss_fn.py +++ b/src/training/loss/get_loss_fn.py @@ -1,8 +1,9 @@ from torch import nn -from src.loss.contrastive_loss import ContrastiveLoss from src.training.fine_tuning_config import FineTuningConfig +from .contrastive_loss import ContrastiveLoss + def get_loss_fn(config: FineTuningConfig) -> nn.Module: if config.loss_type == "ContrastiveLoss": diff --git a/src/video_processing/__init__.py b/src/video_processing/__init__.py index e69de29..1cab542 100644 --- a/src/video_processing/__init__.py +++ b/src/video_processing/__init__.py @@ -0,0 +1,9 @@ +from .configuration import Configuration +from .processor import VideoProcessor, naive_strategy, offset_strategy + +__all__ = [ + "Configuration", + "VideoProcessor", + "naive_strategy", + "offset_strategy", +] diff --git a/src/video_processing/configuration.py b/src/video_processing/configuration.py index 53e80b8..562c896 100644 --- a/src/video_processing/configuration.py +++ b/src/video_processing/configuration.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Self -from src.video_processing.rect import Rect +from .rect import Rect @dataclass diff --git a/src/video_processing/processor.py b/src/video_processing/processor.py index b1b8c24..35dc2ea 100644 --- a/src/video_processing/processor.py +++ b/src/video_processing/processor.py @@ -3,16 +3,16 @@ import cv2 import numpy as np -from src.video_processing.configuration import Configuration -from src.video_processing.filters import ( +from .configuration import Configuration +from .filters import ( FaceDetectionFilter, FilterType, SimilarityFilter, StatisticsFilter, ) -from src.video_processing.rect import Rect -from src.video_processing.sequence_marker import SequenceDescriptor, SequenceMarker -from src.video_processing.video_capture import VideoProperties, video_capture +from .rect import Rect +from .sequence_marker import SequenceDescriptor, SequenceMarker +from .video_capture import VideoProperties, video_capture class VideoProcessor: @@ -421,3 +421,34 @@ def print_sequence_analysis(self): print( f"Icon sequences: {len(icon_sequences)} total, avg length: {sum(icon_sequences)/len(icon_sequences):.1f} frames" ) + + +def naive_strategy(config: Configuration): + """Naive sampling strategy that samples frames from each sequence.""" + processor = VideoProcessor(config) + processor.find_face_sequences() + processor.print_sequence_analysis() + + # Sample frames from sequences + sequence_mapping = processor.sample_frames_from_sequences_naive( + config.samples_per_sequence + ) + processor.save_sampled_camera_regions_from_sequences(sequence_mapping) + + +def offset_strategy(config: Configuration): + """Offset sampling strategy that samples frames based on sequence descriptors.""" + processor = VideoProcessor(config) + processor.find_face_sequences() + processor.print_sequence_analysis() + + # Offset sequence sampling + assert config.offset_before_marker is not None + assert config.offset_after_marker is not None + seq_descriptors = processor.get_offset_sequence_bounds( + config.offset_before_marker, config.offset_after_marker + ) + samples = processor.sample_frames_from_sequence_offset_based( + seq_descriptors, config.samples_per_sequence + ) + processor.save_sampled_camera_regions_from_sequences(samples) diff --git a/test/assets/face.jpg b/test/assets/face.jpg new file mode 100644 index 0000000..140a226 Binary files /dev/null and b/test/assets/face.jpg differ diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..2bf1dc6 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,58 @@ +from pathlib import Path + +import cv2 +import pytest +import torch +from PIL import Image +from torchvision.transforms.v2.functional import to_dtype, to_image + + +@pytest.fixture +def example_image(size=(64, 48)): + r = torch.linspace(0, 1, steps=size[0]).repeat(size[1], 1).T + g = torch.linspace(0, 1, steps=size[1]).repeat(size[0], 1) + b = torch.zeros(size[0], size[1]) + img = torch.stack([r, g, b], dim=0) + return Image.fromarray( + (img.permute(1, 2, 0).numpy() * 255).astype("uint8"), mode="RGB" + ) + + +@pytest.fixture +def face_image(): + return Image.open("test/assets/face.jpg") + + +@pytest.fixture +def face_image_cv2(): + return cv2.imread("test/assets/face.jpg", cv2.IMREAD_COLOR) + + +@pytest.fixture +def video_fragment_path(): + path = Path("data/test_video_2_people.mp4") + if not path.exists(): + pytest.skip("Test video file is missing") + + return path + + +@pytest.fixture +def opencv_face_detector_weights_path(): + path = Path("models/opencv/haarcascade_frontalface_default.xml") + if not path.exists(): + pytest.skip("OpenCV face detector weights file is missing") + + return path + + +def img_diff(img1: Image.Image, img2: Image.Image): + return pil_to_tensor(img1) - pil_to_tensor(img2) + + +def images_equal(img1: Image.Image, img2: Image.Image): + return pil_to_tensor(img1).equal(pil_to_tensor(img2)) + + +def pil_to_tensor(img): + return to_dtype(to_image(img), dtype=torch.float32, scale=True) diff --git a/test/dataset/test_rococo.py b/test/dataset/test_rococo.py index 30088d9..82a48d3 100644 --- a/test/dataset/test_rococo.py +++ b/test/dataset/test_rococo.py @@ -1,4 +1,4 @@ -from dataset.rococo import Frame +from src.dataset.rococo import Frame class TestFrame: diff --git a/src/loss/__init__.py b/test/evaluation/__init__.py similarity index 100% rename from src/loss/__init__.py rename to test/evaluation/__init__.py diff --git a/test/evaluation/test_face_pairs.py b/test/evaluation/test_face_pairs.py new file mode 100644 index 0000000..597bef7 --- /dev/null +++ b/test/evaluation/test_face_pairs.py @@ -0,0 +1,75 @@ +import pytest + +from src.common import get_facenet_pytorch_inception_resnet_v1 +from src.dataset import DummyFacePairsDataset, LFWDataset, ROFDataset +from src.evaluation import FacePairsBenchmark, FacePairsBenchmarkConfig + + +@pytest.fixture +def face_pairs_config(): + return FacePairsBenchmarkConfig( + n_folds=10, + batch_size=10, + embedding_size=512, + device="cpu", + num_workers=0, + ) + + +@pytest.fixture +def model(): + return get_facenet_pytorch_inception_resnet_v1() + + +class TestFacePairsBenchmark: + """Tests for FacePairsBenchmark.""" + + @pytest.mark.integration + def test_run_benchmark_on_tiny_dataset(self, model, face_pairs_config): + """Run full benchmark on a tiny real dataset.""" + dataset = DummyFacePairsDataset( + transform=FacePairsBenchmark._get_default_transform() + ) + benchmark = FacePairsBenchmark(model, dataset, face_pairs_config) + result = benchmark.evaluate() + + assert 0.0 <= result.mean_accuracy <= 1.0 + assert result.std_accuracy >= 0.0 + + @pytest.mark.integration + def test_lfw_benchmark_initialization(self, model, face_pairs_config): + """Test LFW benchmark initialization.""" + + try: + benchmark = FacePairsBenchmark.lfw(model, face_pairs_config) + except FileNotFoundError: + pytest.skip("LFW dataset files missing") + return + + assert isinstance( + benchmark.dataset, LFWDataset + ), f"Dataset should be LFWDataset, got {type(benchmark.dataset)}" + + @pytest.mark.integration + def test_rof_sunglasses_benchmark_initialization(self, model, face_pairs_config): + """Test LFW benchmark initialization.""" + + try: + benchmark = FacePairsBenchmark.rof_sunglasses(model, face_pairs_config) + except FileNotFoundError: + pytest.skip("ROF-s dataset files missing") + return + + assert isinstance(benchmark.dataset, ROFDataset) + + @pytest.mark.integration + def test_rof_masked_initialization(self, model, face_pairs_config): + """Test LFW benchmark initialization.""" + + try: + benchmark = FacePairsBenchmark.rof_masked(model, face_pairs_config) + except FileNotFoundError: + pytest.skip("ROF-m dataset files missing") + return + + assert isinstance(benchmark.dataset, ROFDataset) diff --git a/test/evaluation/test_rococo.py b/test/evaluation/test_rococo.py new file mode 100644 index 0000000..b305013 --- /dev/null +++ b/test/evaluation/test_rococo.py @@ -0,0 +1,41 @@ +import numpy as np +import pytest +import torch +from rococo_evaluation_lib import NoFaceDetectedException + +from src.common import ( + get_facenet_pytorch_inception_resnet_v1, + get_facenet_pytorch_mtcnn_detector, +) +from src.evaluation import FaceRecognitionSystemFacenetPytorchAdapter + + +@pytest.fixture +def adapter(): + facenet = get_facenet_pytorch_inception_resnet_v1() + mtcnn = get_facenet_pytorch_mtcnn_detector() + device = torch.device("cpu") + return FaceRecognitionSystemFacenetPytorchAdapter( + facenet=facenet, + detector=mtcnn, + device=device, + ) + + +class TestFaceRecognitionSystemFacenetPytorchAdapter: + + @pytest.mark.integration + def test_compute_feature_vector_with_facenet_pytorch_models( + self, adapter, face_image_cv2 + ): + feature_vector = adapter.compute_feature_vector(face_image_cv2) + assert feature_vector.shape == (512,), "Feature vector should have length 512" + assert ( + adapter.feature_vector_length() == 512 + ), "Feature vector length should be 512" + + @pytest.mark.integration + def test_compute_feature_vector_no_face_detected(self, adapter, example_image): + cv2_img = np.asarray(example_image) + with pytest.raises(NoFaceDetectedException): + adapter.compute_feature_vector(cv2_img) diff --git a/src/utils/__init__.py b/test/training/__init__.py similarity index 100% rename from src/utils/__init__.py rename to test/training/__init__.py diff --git a/test/loss/__init__.py b/test/training/augmentation/__init__.py similarity index 100% rename from test/loss/__init__.py rename to test/training/augmentation/__init__.py diff --git a/test/training/augmentation/test_gaussain_noise.py b/test/training/augmentation/test_gaussain_noise.py new file mode 100644 index 0000000..d47d13f --- /dev/null +++ b/test/training/augmentation/test_gaussain_noise.py @@ -0,0 +1,68 @@ +from test.conftest import images_equal, img_diff, pil_to_tensor + +import torch +from pytest import approx + +from src.training.augmentation.gaussian_noise_pil import GaussianNoisePIL + + +class TestGaussianNoisePIL: + + def test_shape_and_mode_preserved(self, example_image): + aug = GaussianNoisePIL(mean=0.0, sigma=0.1) + out = aug(example_image) + assert out.size == example_image.size + assert out.mode == example_image.mode + + def test_original_not_mutated(self, example_image): + before = example_image.tobytes() + aug = GaussianNoisePIL() + _ = aug(example_image) + assert example_image.tobytes() == before + + def test_sigma_zero_is_identity(self, example_image): + aug = GaussianNoisePIL(mean=0.0, sigma=0.0) + out = aug(example_image) + assert images_equal(example_image, out) + + def test_statistical_properties_approximate(self, example_image): + mean, sigma = 0.05, 0.1 + aug = GaussianNoisePIL(mean=mean, sigma=sigma) + torch.manual_seed(123) + out = aug(example_image) + diff = img_diff(out, example_image) + diff_mean = diff.mean().item() + diff_std = diff.std().item() + assert approx(diff_mean, abs=0.03) == mean + assert approx(diff_std, abs=0.03) == sigma + + def test_reproducibility_with_seed(self, example_image): + aug = GaussianNoisePIL(mean=0.0, sigma=0.1) + torch.manual_seed(42) + out1 = aug(example_image) + torch.manual_seed(42) + out2 = aug(example_image) + assert images_equal(out1, out2) + + def test_variation_across_calls(self, example_image): + aug = GaussianNoisePIL(mean=0.0, sigma=0.2) + torch.manual_seed(1) + out1 = aug(example_image) + torch.manual_seed(2) + out2 = aug(example_image) + diff = img_diff(out1, out2).abs().mean().item() + assert diff > 0.005 + + def test_output_range(self, example_image): + aug = GaussianNoisePIL(mean=0.0, sigma=0.3) + out = aug(example_image) + tensor = pil_to_tensor(out) + assert 0.0 <= tensor.min().item() <= 1.0 + assert 0.0 <= tensor.max().item() <= 1.0 + + def test_output_range_large_sigma(self, example_image): + aug = GaussianNoisePIL(mean=0.0, sigma=5.0) + out = aug(example_image) + t = pil_to_tensor(out) + assert t.min().item() >= 0.0 + assert t.max().item() <= 1.0 diff --git a/test/training/augmentation/test_glasses.py b/test/training/augmentation/test_glasses.py new file mode 100644 index 0000000..823dd59 --- /dev/null +++ b/test/training/augmentation/test_glasses.py @@ -0,0 +1,22 @@ +from test.conftest import images_equal + +import pytest + +from src.training.augmentation.glasses import AddGlassesAugmentation + + +class TestAddGlassesAugmentation: + + @pytest.mark.integration + def test_add_glasses_to_face(self, face_image): + try: + add_glasses = AddGlassesAugmentation.with_default_models() + except RuntimeError as e: + pytest.skip(f"Model weights not available: {e}") + return + + img_with_glasses = add_glasses(face_image) + + assert img_with_glasses.size == face_image.size + assert img_with_glasses.mode == face_image.mode + assert not images_equal(img_with_glasses, face_image) diff --git a/test/training/augmentation/test_mask.py b/test/training/augmentation/test_mask.py new file mode 100644 index 0000000..dcc3b3c --- /dev/null +++ b/test/training/augmentation/test_mask.py @@ -0,0 +1,23 @@ +from test.conftest import images_equal + +import pytest + +from src.training.augmentation import AddMaskAugmentation + + +class TestAddMaskAugmentation: + + @pytest.mark.integration + def test_add_mask_to_face(self, face_image): + try: + add_mask = AddMaskAugmentation.with_default_models() + except RuntimeError as e: + pytest.skip(f"Model weights not available: {e}") + return + + original_copy = face_image.copy() + img_with_mask = add_mask(face_image) + + assert img_with_mask.size == original_copy.size + assert img_with_mask.mode == original_copy.mode + assert not images_equal(img_with_mask, original_copy), "May mutate the original" diff --git a/test/training/augmentation/test_random_rectangle.py b/test/training/augmentation/test_random_rectangle.py new file mode 100644 index 0000000..c8ab429 --- /dev/null +++ b/test/training/augmentation/test_random_rectangle.py @@ -0,0 +1,27 @@ +from test.conftest import images_equal + +import pytest + +from src.training.augmentation import ( + AddRandomBlackRectangle, + AddRandomRectangleAverageColor, + AddRandomRectangleRandomColor, +) + + +class TestAddRandomRectangle: + + @pytest.mark.parametrize( + "aug_cls", + [ + AddRandomRectangleRandomColor, + AddRandomBlackRectangle, + AddRandomRectangleAverageColor, + ], + ) + def test_add_random_rectangle(self, aug_cls, example_image): + aug = aug_cls(min_size=5, max_size=15) + out = aug(example_image.copy()) + assert out.size == example_image.size, "Image size changed" + assert out.mode == example_image.mode, "Image mode changed" + assert not images_equal(out, example_image), "Image should be modified" diff --git a/test/training/loss/__init__.py b/test/training/loss/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/loss/test_contrastive_loss.py b/test/training/loss/test_contrastive_loss.py similarity index 96% rename from test/loss/test_contrastive_loss.py rename to test/training/loss/test_contrastive_loss.py index bf4d2a6..a9f105f 100644 --- a/test/loss/test_contrastive_loss.py +++ b/test/training/loss/test_contrastive_loss.py @@ -1,7 +1,7 @@ import torch from pytest import approx -from src.loss.contrastive_loss import ContrastiveLoss +from src.training.loss import ContrastiveLoss def test_equal_vectors_same_person(): diff --git a/test/training/test_fine_tuning.py b/test/training/test_fine_tuning.py new file mode 100644 index 0000000..1bf1009 --- /dev/null +++ b/test/training/test_fine_tuning.py @@ -0,0 +1,19 @@ +import pytest + +from src.training.fine_tuning import FineTuningTrainer +from src.training.fine_tuning_config import FineTuningConfig + + +class TestFineTuningTrainer: + + @pytest.mark.integration + def test_training_on_dummy_dataset(self, tmp_path): + config = FineTuningConfig( + epochs=1, + dataset="dummy", + device="cpu", + model_save_path=str(tmp_path / "model.pth"), + ) + trainer = FineTuningTrainer(config, use_wandb=False) + results = trainer.run() + print(results) diff --git a/test/video_processing/__init__.py b/test/video_processing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/video_processing/test_processor.py b/test/video_processing/test_processor.py new file mode 100644 index 0000000..9badf54 --- /dev/null +++ b/test/video_processing/test_processor.py @@ -0,0 +1,48 @@ +import pytest + +from src.video_processing.configuration import Configuration +from src.video_processing.processor import offset_strategy +from src.video_processing.rect import Rect + + +class TestVideoProcessor: + + @pytest.mark.integration + def test_process_video( + self, video_fragment_path, opencv_face_detector_weights_path, tmp_path + ): + """Process a short real video""" + + expected_people = 2 + faces_output_dir = tmp_path / "faces" + camera_output_dir = tmp_path / "camera" + config = Configuration( + mode="offset", + video_path=str(video_fragment_path), + faces_output_dir=str(faces_output_dir), + face_rectangle=Rect(x=1415, y=930, width=500, height=350), + camera_rectangle=Rect(x=100, y=0, width=1280, height=1300), + camera_output_dir=str(camera_output_dir), + pixel_diff_threshold=100.0, + variance_filter_value=3085.0, + mean_filter_value=214.0, + variance_filter_tolerance=5.0, + mean_filter_tolerance=2.0, + face_detection_model_path=str(opencv_face_detector_weights_path), + face_detection_min_size=(30, 30), + samples_per_sequence=3, + offset_before_marker=1, + offset_after_marker=1, + ) + + offset_strategy(config) + + face_files = list(faces_output_dir.glob("*.jpg")) + frame_files = list(camera_output_dir.glob("*.jpg")) + + assert ( + len(face_files) == expected_people + ), f"Expected {expected_people} people in the video" + assert ( + len(frame_files) == expected_people * config.samples_per_sequence + ), f"Expected {config.samples_per_sequence} frames per person" diff --git a/uv.lock b/uv.lock index 9709d1f..b04d63e 100644 --- a/uv.lock +++ b/uv.lock @@ -191,15 +191,6 @@ css = [ { name = "tinycss2" }, ] -[[package]] -name = "bottle" -version = "0.13.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, -] - [[package]] name = "certifi" version = "2025.10.5" @@ -377,27 +368,6 @@ version = "20.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/28/f4/f8949b18ec1df2ef05fc2ea1d1dd82ff2d050b8704b7d0d088017315c221/dlib-20.0.0.tar.gz", hash = "sha256:9ab6a6fe113cc36a20c3f611c57fa6a07f18d1169bd04efb85c32e21b23b7d2b", size = 3310015, upload-time = "2025-05-28T01:22:16.141Z" } -[[package]] -name = "doorstop" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bottle" }, - { name = "markdown" }, - { name = "openpyxl" }, - { name = "plantuml-markdown" }, - { name = "python-frontmatter" }, - { name = "python-markdown-math" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "six" }, - { name = "verchew" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/52/758c5c2955b64c32fb029a537ff307eca8940ee22ec165ae5d671c55590a/doorstop-3.0.2.tar.gz", hash = "sha256:b444fd4bafe67a7f265bee714e1c0ade844b9490f79947946f91efd343f74772", size = 1376290, upload-time = "2025-06-16T19:34:25.451Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/65/337211babe5bd4d2660abf0e594f4e7fe1af2a8848154509442ed5310746/doorstop-3.0.2-py3-none-any.whl", hash = "sha256:366054bef70c5d2f27f5d4b0eea056eb3244cc6fdbcb434e0b1ba83f324f4ca4", size = 1409948, upload-time = "2025-06-16T19:34:22.104Z" }, -] - [[package]] name = "dotenv" version = "0.9.9" @@ -409,15 +379,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, ] -[[package]] -name = "et-xmlfile" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, -] - [[package]] name = "executing" version = "2.2.1" @@ -1524,18 +1485,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/7d/f1c30a92854540bf789e9cd5dde7ef49bbe63f855b85a2e6b3db8135c591/opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec", size = 39488044, upload-time = "2025-01-16T13:52:21.928Z" }, ] -[[package]] -name = "openpyxl" -version = "3.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "et-xmlfile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1633,20 +1582,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, ] -[[package]] -name = "plantuml-markdown" -version = "3.11.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "requests" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f7/c5/1749bae9c838a78d9f6bb0c628fd47251572d5ad1502e33b51fb72582838/plantuml_markdown-3.11.1.tar.gz", hash = "sha256:0ac39c5d13d3ea8b84ec88f57369a85d058ff15c0c225591dd649b327586142c", size = 32120, upload-time = "2025-02-07T17:30:57.131Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/06/fed3b67b5488854eb7934bfbbb580d0651d8af243ffb39937cb00838bc3d/plantuml_markdown-3.11.1-py3-none-any.whl", hash = "sha256:52c8507be5b4e5b57e3c4c1cfbe8894b6204f4b6bac2279896efb03d3b79bacf", size = 20116, upload-time = "2025-02-07T17:30:54.88Z" }, -] - [[package]] name = "platformdirs" version = "4.5.0" @@ -1883,18 +1818,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] -[[package]] -name = "python-frontmatter" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" }, -] - [[package]] name = "python-json-logger" version = "4.0.0" @@ -1904,15 +1827,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, ] -[[package]] -name = "python-markdown-math" -version = "0.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/e1/24021b30466ca2985352ec1f15b3cb38e0b62ba742c46820c33ddf7114a8/python-markdown-math-0.6.tar.gz", hash = "sha256:c68d8cb9695cb7b435484403dc18941d1bad0ff148e4166d9417046a0d5d3022", size = 6406, upload-time = "2018-06-15T12:52:47.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/81/2c86570437821d77b90a6d939d54e11b507b71785850840a5e56d8febeca/python_markdown_math-0.6-py2.py3-none-any.whl", hash = "sha256:d443e264cf063623a5f02b0c9730867e5172b31d49967da424ed3457c25b2848", size = 3819, upload-time = "2018-06-15T12:53:21.726Z" }, -] - [[package]] name = "pytokens" version = "0.2.0" @@ -2281,7 +2195,6 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "black", extra = ["jupyter"] }, - { name = "doorstop" }, { name = "isort" }, { name = "mkdocs" }, { name = "mkdocs-material" }, @@ -2315,7 +2228,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "black", extras = ["jupyter"], specifier = ">=25.1.0" }, - { name = "doorstop", specifier = ">=3.0" }, { name = "isort", specifier = ">=6.0.1" }, { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-material", specifier = ">=9.6.13" }, @@ -2491,15 +2403,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] -[[package]] -name = "verchew" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/69/543e3a2fb6a8fb4dcc0d18bc34ebd54d789dc844c8f83af0926ba0b8bcf6/verchew-3.4.2.tar.gz", hash = "sha256:98f3f8e4894528afe316879ac848d1a6c8b47168a3b3de2e44fc2f5b83b29149", size = 9528, upload-time = "2023-09-15T17:02:05.577Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/8a/697eb1daee017b9313336c4899c897542d8c011cbbfbb1a6e87a8370fd89/verchew-3.4.2-py3-none-any.whl", hash = "sha256:d000ec54ccc87240feae05cf9e37b29016ca73406b6f39af69114a32773dfb7e", size = 10629, upload-time = "2023-09-15T17:02:02.166Z" }, -] - [[package]] name = "wandb" version = "0.22.2"