Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .zenodo.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ identifiers:
repository-code: 'https://github.com/rfonod/stabilo'
url: 'https://github.com/rfonod/stabilo'
license: MIT
version: 1.4.1
date-released: '2026-08-25'
version: 1.4.2
date-released: '2026-09-17'
preferred-citation:
type: article
title: "Advanced computer vision for extracting georeferenced vehicle trajectories from drone imagery"
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Three matchers are available: `bf` (brute force) and `flann` work with every det
- **Exclusion masks**: suppress keypoints on dynamic objects, in five geometries fed by seven input encodings (`--mask-enc yolo|pascal|coco|xywha|four|polygon|circle`), with an adjustable safety margin (`--mask-margin-ratio 0.15`). Disable with `--no-mask`. See [Masking behaviour](docs/usage.md#5-masking-behaviour).
- **Classical detectors**: ORB, SIFT, RootSIFT, BRISK, KAZE, AKAZE (`--detector-name rsift`), with BF or FLANN matching (`--matcher-name flann`) and cross-check, Lowe's ratio, or distance filtering (`--filter-type ratio`).
- **Learning-based detectors**: XFeat, DISK, DeDoDe, KeyNet, and the detector-free LoFTR via [kornia](https://kornia.readthedocs.io/) (`--detector-name disk`), optionally paired with the learned LightGlue matcher (`--matcher-name lightglue`). Pretrained weights download once into torch's cache and are reused offline. Mind the rotation and memory caveats above; pair them with a lower `--downsample-ratio`.
- **Robust estimation**: MAGSAC++ by default, plus GC-RANSAC, three LO-RANSAC (USAC) variants, PROSAC, RHO, LMEDS, RANSAC (`--ransac-method 36`), with tunable threshold, iterations, and confidence (`--ransac-epipolar-threshold 1.5`). See [RANSAC methods](docs/usage.md#11-transformation-types-and-ransac-methods).
- **Robust estimation**: MAGSAC++ by default, plus GC-RANSAC, three LO-RANSAC (USAC) variants, PROSAC, RHO, LMEDS, RANSAC (`--ransac-method 36`), with tunable threshold, iterations, and confidence (`--ransac-epipolar-threshold 1.5`). The threshold is measured in full-resolution pixels by default, or in downsampled-image pixels with `--ransac-threshold-space processed`. See [RANSAC methods](docs/usage.md#11-transformation-types-and-ransac-methods).
- **Pre-processing**: CLAHE contrast enhancement (`--clahe`) and detection-time downsampling for speed (`--downsample-ratio 0.25`), with keypoints rescaled back to full resolution.
- **Acceleration**: OpenCV CUDA for the classical pipeline (`--gpu`, see [`docs/cuda.md`](docs/cuda.md)) and CUDA/MPS/CPU for the learned models (`--device mps`).
- **Visualization**: live or recorded side-by-side rendering of matches, inliers, masks, and box trajectories (`--viz`, `--save-viz`, `--no-lines`, `--tail-length 60`). See [Visualisation mode](docs/usage.md#12-visualisation-mode).
Expand Down Expand Up @@ -272,11 +272,11 @@ If you use **Stabilo** in your research, software, or product, please cite the f
@software{fonod2026stabilo,
author = {Fonod, Robert},
license = {MIT},
month = aug,
month = sep,
title = {Stabilo: A Comprehensive Python Library for Video and Trajectory Stabilization with User-Defined Masks},
url = {https://github.com/rfonod/stabilo},
doi = {10.5281/zenodo.12117092},
version = {1.4.1},
version = {1.4.2},
year = {2026}
}
```
Expand Down
19 changes: 18 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- [11. Transformation Types and RANSAC Methods](#11-transformation-types-and-ransac-methods)
- [Transformation types](#transformation-types)
- [RANSAC methods (integer codes)](#ransac-methods-integer-codes)
- [The epipolar threshold and the downsample ratio](#the-epipolar-threshold-and-the-downsample-ratio)
- [Method and transformation compatibility](#method-and-transformation-compatibility)
- [12. Visualisation Mode](#12-visualisation-mode)
- [13. Benchmarking Mode](#13-benchmarking-mode)
Expand Down Expand Up @@ -284,6 +285,8 @@ ref_point = ref_point_h[:2] / ref_point_h[2] # divide by homogeneous coordinat

All parameters can be passed as keyword arguments to `Stabilizer(...)` or set via a YAML file (see `stabilo/cfg/default.yaml`). Parameters not supplied fall back to their defaults.

`stabilo/cfg/default.yaml` is the complete list. A keyword argument whose name is not one of its keys is ignored, so the parameter it was meant to set keeps its default; stabilo logs a warning naming the unrecognized arguments and, where there is one, the nearest valid name. `Stabilizer.configurable_keys()` returns the valid names, for a caller that wants to check or filter a configuration itself.

| Parameter | Default | Valid values | Description |
|-----------|---------|--------------|-------------|
| `detector_name` | `'orb'` | `orb`, `sift`, `rsift`, `brisk`, `kaze`, `akaze`, `xfeat`, `disk`, `dedode`, `keynet`, `loftr` | Feature detector (see section 9) |
Expand All @@ -301,7 +304,8 @@ All parameters can be passed as keyword arguments to `Stabilizer(...)` or set vi
| `mask_use` | `true` | `true`, `false` | Enable exclusion masking |
| `mask_margin_ratio` | `0.15` | `[0, 1]` | Fractional margin added to exclusion regions |
| `ransac_method` | `38` | see section 11 | RANSAC algorithm (`affine` supports only `4` and `8`) |
| `ransac_epipolar_threshold` | `2.0` | `> 0` | Reprojection-error threshold (pixels) |
| `ransac_epipolar_threshold` | `2.0` | `> 0` | Reprojection-error threshold, in pixels of `ransac_threshold_space` |
| `ransac_threshold_space` | `'full'` | `full`, `processed` | Pixels the threshold is measured in: full-resolution, or the downsampled image (see section 11) |
| `ransac_max_iter` | `5000` | `> 0` (int) | Maximum RANSAC iterations |
| `ransac_confidence` | `0.999999` | `(0, 1]` | Required confidence level |
| `brisk_threshold` | `130` | `(0, 255]` | BRISK detector threshold (fallback) |
Expand Down Expand Up @@ -430,6 +434,19 @@ Use `projective` (default) when the camera undergoes any motion (pan, tilt, zoom

Codes 32, 33, and 35 are three LO-RANSAC configurations that differ in how local optimization is scheduled, per OpenCV's [USAC tutorial](https://docs.opencv.org/4.x/de/d3f/tutorial_usac.html). None of them is DEGENSAC: that is a degeneracy check used for fundamental-matrix estimation, which OpenCV does not apply to homographies.

### The epipolar threshold and the downsample ratio

Keypoints detected on a downsampled frame are rescaled to full resolution before the estimator sees them, so `ransac_epipolar_threshold` is a **full-resolution** pixel distance by default (`ransac_threshold_space: 'full'`). The keypoint localization error it has to separate from real outliers is not: that arises in the processed image and is magnified by `1 / downsample_ratio` when the points are rescaled. A fixed threshold is therefore a stricter filter at a low ratio than at a high one, by exactly the ratio between them, and a value tuned at one `downsample_ratio` does not transfer to another.

Set `ransac_threshold_space: 'processed'` to express the threshold in processed-image pixels instead, which keeps it fixed in the units the noise is generated in; stabilo then passes `ransac_epipolar_threshold / downsample_ratio` to the estimator.

```python
Stabilizer(downsample_ratio=0.25, ransac_epipolar_threshold=2.0) # 2.0 px at full resolution
Stabilizer(downsample_ratio=0.25, ransac_epipolar_threshold=2.0, ransac_threshold_space='processed') # 8.0 px at full resolution
```

Neither setting is right in general: the residuals a threshold must separate are part localization noise (which scales with `1 / downsample_ratio`) and part model error (which does not). Which to use, and at what value, is a tuning question; `get_ransac_reproj_threshold()` reports the value that is actually handed to the estimator.

### Method and transformation compatibility

`affine` maps to `cv2.estimateAffinePartial2D`, which implements only LMEDS and RANSAC; every other code raises inside OpenCV. Stabilo rejects the combination when the `Stabilizer` is constructed, rather than failing on every frame:
Expand Down
2 changes: 1 addition & 1 deletion stabilo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .stabilo import Stabilizer

__version__ = '1.4.1'
__version__ = '1.4.2'
__all__ = ['Stabilizer', '__version__']
14 changes: 12 additions & 2 deletions stabilo/cfg/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,25 @@ match_query_frame: 'reference' # Which frame's descriptors are the knnMatch que
# Transformation estimation
# -----------------------------------------------------------------------------
# The estimated matrix maps the current frame onto the reference frame, so the
# RANSAC reprojection threshold is expressed in reference-frame pixels.
# RANSAC reprojection threshold is evaluated in reference-frame pixels.
#
# Which pixels depends on 'ransac_threshold_space'. Keypoints are rescaled to full
# resolution before the estimator sees them, so under 'full' the threshold is a
# full-resolution distance at any 'downsample_ratio', while the localization error
# it separates arises in the processed image and is magnified by 1/downsample_ratio.
# A fixed threshold is therefore stricter at a low ratio, and a value tuned at one
# ratio does not transfer to another. 'processed' measures it in processed-image
# pixels instead, passing ransac_epipolar_threshold / downsample_ratio to the
# estimator. The two agree at downsample_ratio: 1.0. See docs/usage.md section 11.
#
# Not every ransac_method works with every transformation_type. 'projective'
# (cv2.findHomography) accepts all of them, whereas 'affine'
# (cv2.estimateAffinePartial2D) accepts ONLY 4 (LMEDS) and 8 (RANSAC); any other
# code, including the default 38, is rejected when the Stabilizer is created.
transformation_type: 'projective' # ['projective' (3x3 homography, any ransac_method), 'affine' (2x3, ransac_method 4 or 8 only)]
ransac_method: 38 # [4: LMEDS, 8: RANSAC, 16: RHO, 32: USAC-Default (LO-RANSAC), 33: USAC-Parallel (LO-RANSAC), 35: USAC-Fast (LO-RANSAC), 36: USAC-Accurate (GC-RANSAC), 37: PROSAC, 38: MAGSAC++]
ransac_epipolar_threshold: 2.0 # Reprojection-error threshold in pixels; (0.0, inf)
ransac_epipolar_threshold: 2.0 # Reprojection-error threshold, in pixels of ransac_threshold_space; (0.0, inf)
ransac_threshold_space: 'full' # Pixels ransac_epipolar_threshold is measured in; ['full' (full resolution), 'processed' (downsampled image)]
ransac_max_iter: 5000 # Max. RANSAC iterations; (0, inf)
ransac_confidence: 0.999999 # Required RANSAC confidence level; (0.0, 1.0]

Expand Down
21 changes: 19 additions & 2 deletions stabilo/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ def add_stabilo_config_arguments(parser):
group = parser.add_argument_group(
"stabilo: transformation estimation",
"'projective' works with every --ransac-method; 'affine' (cv2.estimateAffinePartial2D) accepts only "
f"{affine_methods} and is rejected with any other method.",
f"{affine_methods} and is rejected with any other method. The epipolar threshold is measured in "
"full-resolution pixels unless --ransac-threshold-space says otherwise, so a value tuned at one "
"--downsample-ratio does not transfer to another.",
)
group.add_argument(
"--transformation-type",
Expand All @@ -124,6 +126,14 @@ def add_stabilo_config_arguments(parser):
group.add_argument(
"--ransac-epipolar-threshold", "-ret", type=float, help="RANSAC epipolar threshold [default: 2.0]"
)
group.add_argument(
"--ransac-threshold-space",
"-rts",
type=str,
choices=Stabilizer.VALID_RANSAC_THRESHOLD_SPACES,
help="pixels the epipolar threshold is measured in: 'full' (full resolution) or 'processed' "
"(downsampled image, i.e. divided by the downsample ratio before estimation) [default: full]",
)
group.add_argument("--ransac-max-iter", "-rmi", type=int, help="RANSAC maximum iterations [default: 5000]")
group.add_argument("--ransac-confidence", "-rc", type=float, help="RANSAC confidence [default: 0.999999]")

Expand Down Expand Up @@ -173,9 +183,16 @@ def separate_cli_arguments(cli_args):
Get the command-line arguments and the corresponding keyword arguments for Stabilizer.

Precedence (lowest to highest): stabilo/cfg/default.yaml < --custom-config file < explicit CLI flags.

The namespace also holds the subcommand's own options (the input path, --save, the
visualization flags, argparse's own `func`), which are not Stabilizer parameters. They are
filtered out here against `Stabilizer.configurable_keys()`; forwarded, they would be
reported as unrecognized arguments on every invocation. A `--custom-config` file is not
filtered, since a typo in a user's configuration is what that report exists to surface.
"""
args = argparse.Namespace(**vars(cli_args))
explicit_kwargs = drop_none_values(vars(cli_args))
configurable = Stabilizer.configurable_keys()
explicit_kwargs = {k: v for k, v in drop_none_values(vars(cli_args)).items() if k in configurable}

kwargs = {}
if args.custom_config:
Expand Down
68 changes: 66 additions & 2 deletions stabilo/stabilo.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
Use a classical detector or KeyNet for footage with large in-plane rotation.
- The learning-based detectors are memory hungry at high resolution. Reduce downsample_ratio for
large inputs; LoFTR in particular scales quadratically with the pixel count.
- ransac_epipolar_threshold does not transfer across values of downsample_ratio. Keypoints are
rescaled to full resolution before estimation, so the threshold is a full-resolution distance
by default, while the localization error it separates arises in the processed image and is
magnified by 1 / downsample_ratio. Re-tune the threshold when the ratio changes, or set
ransac_threshold_space='processed' to express it in processed-image pixels instead.

Usage:
1. Create an instance of the 'Stabilizer' class with desired parameter configurations.
Expand Down Expand Up @@ -88,6 +93,7 @@ class Stabilizer:
VALID_FILTER_TYPES = ['none', 'ratio', 'distance']
VALID_TRANSFORMATION_TYPES = ['projective', 'affine']
VALID_MATCH_QUERY_FRAMES = ['reference', 'current']
VALID_RANSAC_THRESHOLD_SPACES = ['full', 'processed']
VALID_RANSAC_METHODS_DICT = {
'cv2.LMEDS': cv2.LMEDS, # 4
'cv2.RANSAC': cv2.RANSAC, # 8
Expand All @@ -112,6 +118,19 @@ class Stabilizer:
}
VALID_AFFINE_RANSAC_METHODS = {cv2.LMEDS, cv2.RANSAC}

@classmethod
def configurable_keys(cls) -> frozenset:
"""
Every key that can be passed to Stabilizer(...) or set in a configuration file.

These are exactly the keys of cfg/default.yaml, which is the single source of truth for
the options. `logger` is not among them: it is a constructor-only argument, popped
before the configuration is loaded. Exposed so a caller that forwards a user's
configuration can filter or check it against stabilo rather than keeping its own copy
of the list, which drifts as soon as stabilo gains an option.
"""
return frozenset(cfg)

@classmethod
def describe_ransac_methods(cls, methods=None) -> list:
"""
Expand Down Expand Up @@ -140,7 +159,11 @@ def __init__(self, **kwargs):
- match_query_frame: str - which descriptors are the knnMatch query: 'reference' (default) or 'current'
- ransac_method: int - method for RANSAC algorithm (see above for options); with
transformation_type='affine' only cv2.LMEDS (4) and cv2.RANSAC (8) are supported
- ransac_epipolar_threshold: float - threshold for RANSAC (e.g., 1.0)
- ransac_epipolar_threshold: float - reprojection-error threshold for RANSAC (e.g., 1.0), in the
pixel units named by ransac_threshold_space
- ransac_threshold_space: str - coordinate system ransac_epipolar_threshold is expressed in:
'full' (full-resolution pixels, the default) or 'processed' (downsampled-image pixels, which
keeps the threshold fixed relative to the keypoint localization noise as downsample_ratio varies)
- ransac_max_iter: int - max iterations for RANSAC (e.g., 2000)
- ransac_confidence: float - confidence for RANSAC (e.g., 0.999)
- brisk_threshold: int - threshold for BRISK detector (used only if 'max_features -> threshold' model is unavailable)
Expand Down Expand Up @@ -184,10 +207,32 @@ def __init__(self, **kwargs):
def _load_configuration(self, kwargs):
"""
Load configuration parameters, using defaults if not provided.

Only the keys of cfg/default.yaml are honoured, so a keyword argument that is not one of
them has no effect and a misspelled parameter runs with the default. Unknown keys are
warned about, with the nearest valid name, rather than raising: callers that forward a
user's configuration block verbatim may carry keys stabilo does not know, and an
exception here would turn a typo in a downstream config into a crash inside this
constructor. A caller that wants strictness can check `Stabilizer.configurable_keys()`
before constructing.
"""
for key, value in cfg.items():
setattr(self, key, kwargs.get(key, value))

unknown = [key for key in kwargs if key not in cfg]
if unknown:
import difflib # noqa: PLC0415 - only needed on the error path

described = []
for key in sorted(unknown):
close = difflib.get_close_matches(key, cfg, n=1, cutoff=0.7)
described.append(f"'{key}'" + (f" (did you mean '{close[0]}'?)" if close else ''))
self.logger.warning(
f"Ignoring {len(described)} unknown Stabilizer argument(s): {', '.join(described)}. "
"Only the keys of stabilo/cfg/default.yaml are configurable; the defaults are used "
"for anything else. Run 'stabilo config show' to list them."
)

def _initialize_variables(self):
"""
Initialize internal variables for the Stabilizer.
Expand Down Expand Up @@ -773,7 +818,7 @@ def calculate_transformation_matrix(self) -> None:
maxIters=self.ransac_max_iter,
method=self.ransac_method,
confidence=self.ransac_confidence,
ransacReprojThreshold=self.ransac_epipolar_threshold,
ransacReprojThreshold=self.get_ransac_reproj_threshold(),
)
except cv2.error as e:
self.logger.exception(f"Transformation matrix couldn't be calculated.\n Error: {e}")
Expand Down Expand Up @@ -1072,6 +1117,20 @@ def get_cur_num_keypoints(self) -> tuple:
len(self.cur_kpts) if self.cur_kpts is not None else None,
)

def get_ransac_reproj_threshold(self) -> float:
"""
Get the reprojection threshold actually handed to the estimator, in full-resolution pixels.

Correspondences are rescaled to full resolution before estimation, so the estimator's
threshold is always a full-resolution distance. With ransac_threshold_space='full' that is
ransac_epipolar_threshold itself. With 'processed' the configured value is a
processed-image distance, and rescaling multiplies it by 1 / downsample_ratio, exactly as it
multiplies the residuals the threshold has to separate. The two agree at downsample_ratio 1.0.
"""
if self.ransac_threshold_space == 'processed':
return self.ransac_epipolar_threshold / self.downsample_ratio
return self.ransac_epipolar_threshold

def get_basic_info(self) -> dict:
"""
Get basic information about the Stabilizer.
Expand Down Expand Up @@ -1186,6 +1245,11 @@ def _validate_arguments(self):
raise ValueError("Invalid ransac_max_iter. It should be greater than 0 and an integer")
if not (0.0 < self.ransac_epipolar_threshold):
raise ValueError("Invalid ransac_epipolar_threshold. It should be greater than 0")
if self.ransac_threshold_space not in self.VALID_RANSAC_THRESHOLD_SPACES:
raise ValueError(
f"Invalid ransac_threshold_space: {self.ransac_threshold_space}. "
f"Choose from {self.VALID_RANSAC_THRESHOLD_SPACES}"
)
if not (0.0 < self.ransac_confidence <= 1.0):
raise ValueError("Invalid ransac_confidence. It should be in the range (0.0, 1.0]")
if self.gpu:
Expand Down
Loading
Loading