Stabilo — Detailed Usage Guide¶
Command-line interface: installing stabilo provides a
stabiloconsole command withvideo,tracks, andconfigsubcommands. See 15. Command-Line Interface and thescripts/README.mdfor the threshold-analysis tooling.CUDA GPU acceleration (
gpu=True) requires a CUDA-enabled OpenCV build; seedocs/cuda.mdfor build instructions. Learning-based detectors instead run on any torch device via thedeviceoption.
Table of Contents¶
- Stabilo — Detailed Usage Guide
- Table of Contents
- 1. Core Functionality
- 2. Installation
- 3. Main Workflow
- 4. Supported Mask and Box Formats
- 5. Masking Behaviour
- 6. Frame and Box Transformation
- 7. Point Transformation
- 8. Configuration Parameters
- 9. Feature Detectors
- 10. Feature Matching and Filtering
- 11. Transformation Types and RANSAC Methods
- 12. Visualisation Mode
- 13. Benchmarking Mode
- 14. Testing and Development
- 15. Command-Line Interface
1. Core Functionality¶
Stabilo aligns a current frame to a reference frame using a feature-point pipeline:
- Pre-processing — optionally apply CLAHE for contrast enhancement and downsample the frame.
- Masking — build a binary mask from user-supplied exclusion regions so that dynamic objects (e.g., vehicles) are excluded from feature extraction.
- Feature detection and description — detect and describe keypoints using a chosen classical detector (ORB, SIFT, rSIFT, BRISK, KAZE, AKAZE) or learning-based detector (XFeat, DISK, DeDoDe, KeyNet), or match densely with the detector-free LoFTR.
- Matching — match descriptors between the current and reference frame using a brute-force (BF), FLANN, or learned LightGlue matcher.
- Match filtering — filter matches by cross-check, Lowe's ratio test, or distance threshold.
- Transformation estimation — robustly estimate a 3x3 homography (projective) or 2x3 affine matrix using a RANSAC-type algorithm (MAGSAC++ by default).
- Stabilisation — warp the current frame, or transform tracked bounding boxes, into the reference coordinate system.
This pipeline makes Stabilo suitable for:
- video stabilisation (align all frames to a chosen anchor frame),
- trajectory stabilisation (transform per-frame detections/tracks to a fixed reference),
- any downstream analysis requiring geometric frame-to-frame consistency.
2. Installation¶
It is recommended to create and activate a Python virtual environment (Python >= 3.11 and <= 3.13) first:
Alternatives: conda or uv
uv (fastest; use uv pip install in the step below):
Then, install the stabilo package using one of the following options:
Option 1 — from PyPI (recommended):
Option 2 — from local source (for development):
git clone https://github.com/rfonod/stabilo.git
cd stabilo
pip install -e '.[dev]' # editable install with dev dependencies
3. Main Workflow¶
import numpy as np
from stabilo import Stabilizer
# 1. Create a stabilizer (all arguments are optional; defaults shown below)
stabilizer = Stabilizer(
detector_name='orb',
matcher_name='bf',
filter_type='ratio',
transformation_type='projective',
max_features=2000,
)
# 2. Set the reference frame (once)
# Optionally supply bounding-box masks to exclude dynamic regions.
stabilizer.set_ref_frame(ref_frame, ref_boxes, box_format='xywh')
# 3. Stabilize each subsequent frame
stabilizer.stabilize(cur_frame, cur_boxes, box_format='xywh')
# 4. Retrieve results
stabilized_frame = stabilizer.warp_cur_frame() # warped frame
stabilized_boxes = stabilizer.transform_cur_boxes() # transformed boxes (xywh)
trans_matrix = stabilizer.get_cur_trans_matrix() # 3x3 or 2x3 matrix
Key constraints¶
set_ref_frame(...)must be called beforestabilize(...).- All frames passed to
stabilize(...)are aligned to the single reference frame; callset_ref_frame(...)again to change the anchor. - If a transformation cannot be estimated (too few matches), Stabilo falls back to the last known transformation matrix (or
Noneif no previous matrix exists).
4. Supported Mask and Box Formats¶
The box_format argument is accepted by set_ref_frame, stabilize, and create_binary_mask. It controls how the boxes array is interpreted.
The transform_boxes(...) method uses in_box_format and out_box_format to specify the input and output box formats.
xywh (default)¶
Axis-aligned bounding box, one row per box:
xywha¶
Oriented bounding box (OBB) with an explicit rotation angle:
angle_degrees is the counter-clockwise rotation from the positive x-axis.
four¶
Four corner points (clockwise or arbitrary order), one row per box:
Stabilo automatically detects whether the box is axis-aligned or rotated. For axis-aligned boxes fast rectangular masking is used; rotated boxes use polygon filling.
polygon¶
Arbitrary convex or non-convex polygons with N >= 3 vertices. Two input shapes are accepted:
- Flattened row — one polygon per row:
[x1, y1, x2, y2, ..., xN, yN](all rows must have the same even length >= 6) - Vertex-pair array — shape
(N, 2)for a single polygon, or shape(P, N, 2)for P polygons
# Flattened rows (two quadrilaterals)
polygon_masks = np.array([
[60, 60, 120, 60, 120, 120, 60, 120],
[200, 200, 260, 210, 240, 270, 190, 260],
])
stabilizer.set_ref_frame(ref_frame, polygon_masks, box_format='polygon')
circle¶
Circular exclusion regions, one row per circle:
circle_masks = np.array([[120, 120, 25], [360, 240, 40]])
stabilizer.stabilize(cur_frame, circle_masks, box_format='circle')
Note —
polygonandcircleformats are supported only for masking (i.e., feature-extraction exclusion). They cannot be passed totransform_cur_boxes()/transform_boxes(), which requirexywh,xywha, orfour.
5. Masking Behaviour¶
When boxes are provided, create_binary_mask builds a single-channel uint8 image (same height x width as the reference frame) where:
| Pixel value | Meaning |
|---|---|
255 |
Included in feature extraction |
0 |
Excluded from feature extraction |
The mask_margin_ratio parameter (default 0.15) expands each exclusion region by 15% to add a safety margin:
| Format | How margin is applied |
|---|---|
xywh |
Width and height each multiplied by (1 + margin_ratio) |
xywha |
Width and height expanded, then the OBB is converted to four corner points |
four — aligned |
Same as xywh after bounding-box extraction |
four — rotated |
Corner points scaled away from their centroid |
polygon |
Vertices scaled away from their centroid |
circle |
Radius multiplied by (1 + margin_ratio) |
Masks for the reference and current frame are independent — each frame may have its own set of exclusion boxes.
Regions are clipped to the frame: one that is partly outside excludes only its visible part, and one wholly outside the frame excludes nothing. A region with a NaN or infinite coordinate is skipped with a warning instead of stopping the run.
If mask_use=False, no mask is created even if boxes are supplied.
6. Frame and Box Transformation¶
Warp a frame¶
stabilized = stabilizer.warp_cur_frame()
# or warp an arbitrary frame with the current matrix:
stabilized = stabilizer.warp_frame(some_frame)
For projective transformations cv2.warpPerspective is used; for affine cv2.warpAffine is used.
Transform bounding boxes¶
# transform boxes that were passed to stabilize(...)
out_boxes = stabilizer.transform_cur_boxes(out_box_format='xywh')
# transform arbitrary boxes
out_boxes = stabilizer.transform_boxes(
boxes,
trans_matrix,
in_box_format='xywha',
out_box_format='four',
)
Internally, every box format is first converted to four corner points (four), those points are transformed with the matrix, then converted back to the requested output format.
Supported in_box_format / out_box_format values for transformation: 'xywh', 'xywha', 'four'.
7. Point Transformation¶
To transform an arbitrary pixel coordinate from the current frame to the reference frame:
# projective (homography)
cur_point = np.array([x, y, 1.0])
ref_point_h = stabilizer.get_cur_trans_matrix() @ cur_point
ref_point = ref_point_h[:2] / ref_point_h[2] # divide by homogeneous coordinate
# affine (no perspective division needed — last row is [0, 0, 1])
8. Configuration Parameters¶
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) |
matcher_name |
'bf' |
bf, flann, lightglue |
Feature matcher (see section 10) |
filter_type |
'ratio' |
none, ratio, distance |
Match filtering strategy |
filter_ratio |
0.9 |
(0, 1] |
Lowe's ratio (for ratio) or distance threshold ratio (for distance) |
match_query_frame |
'reference' |
reference, current |
Which frame's descriptors form the knnMatch query (matching is asymmetric) |
transformation_type |
'projective' |
projective, affine |
Geometric model |
clahe |
false |
true, false |
Apply CLAHE contrast enhancement |
downsample_ratio |
0.5 |
(0, 1] |
Resize factor before feature extraction |
max_features |
2000 |
> 0 (int) |
Maximum keypoints kept in the current frame, the strongest by response |
ref_multiplier |
2.0 |
>= 1.0 |
Scale factor for keypoints in reference frame (ref_multiplier x max_features) |
sift_enable_precise_upscale |
false |
true, false |
SIFT/rSIFT only: enable precise sub-pixel upscaling at octave -1 |
rsift_eps |
1e-8 |
> 0 |
RootSIFT (rsift) L1-normalization epsilon |
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, 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 |
30 |
(0, 255] |
BRISK detection threshold (AGAST score) |
kaze_threshold |
0.001 |
> 0 |
KAZE detection threshold (detector response) |
akaze_threshold |
0.001 |
> 0 |
AKAZE detection threshold (detector response) |
gpu |
false |
true, false |
Use CUDA GPU acceleration for detection/matching/warping; requires a CUDA-enabled OpenCV build, see docs/cuda.md |
gpu_device_id |
0 |
>= 0 (int) |
CUDA device index to use when gpu is true |
device |
'auto' |
auto, cpu, cuda, mps |
Torch device for learning-based detectors/matchers only (auto picks cuda > mps > cpu) |
loftr_weights |
'outdoor' |
outdoor, indoor |
LoFTR pretrained weights |
loftr_confidence |
0.0 |
[0, 1] |
Minimum LoFTR correspondence confidence to keep (0.0 keeps all) |
disk_weights |
'depth' |
depth, epipolar |
DISK pretrained weights |
dedode_detector_weights |
'L-C4-v2' |
L-upright, L-C4, L-SO2, L-C4-v2 |
DeDoDe detector weights |
dedode_descriptor_weights |
'B-upright' |
B-upright, G-upright, B-C4, B-SO2, G-C4, G-SO2 |
DeDoDe descriptor weights. The G variants pull a 1.2 GB DINOv2 backbone and roughly double the memory for no rotation benefit, so B is the default |
logger |
None |
logging.Logger |
Optional external logger (constructor-only; not a YAML key) |
viz |
false |
true, false |
Retain intermediate data for visualisation |
benchmark |
false |
true, false |
Benchmarking mode (see section 13) |
min_good_match_count_warning |
20 |
>= 0 |
Warn if fewer than N good matches found |
min_inliers_match_count_warning |
10 |
>= 0 |
Warn if fewer than N inliers found |
9. Feature Detectors¶
Classical detectors (OpenCV):
| Name | Type | Notes |
|---|---|---|
orb |
Binary | Fast; uses Hamming distance. Default. |
sift |
Float | Scale and rotation invariant; uses L2 distance. |
rsift |
Float | RootSIFT variant of SIFT with improved matching quality. |
brisk |
Binary | Detection threshold brisk_threshold. |
kaze |
Float | Non-linear scale-space detector; detection threshold kaze_threshold. |
akaze |
Binary | Accelerated KAZE; detection threshold akaze_threshold. |
Every classical detector keeps at most max_features keypoints in the current frame and ref_multiplier x max_features in the reference frame, the strongest by detector response, counted in the processed (downsampled) image. ORB and SIFT do this natively. BRISK, KAZE, and AKAZE detect every keypoint whose response exceeds their threshold (brisk_threshold, kaze_threshold, akaze_threshold, OpenCV's defaults) and stabilo keeps the strongest. The cap therefore holds at any resolution and downsample_ratio, but it is a maximum, not a target: a small or weakly textured frame can yield fewer keypoints, in which case lowering the threshold yields more. Before v1.4.5, BRISK, KAZE, and AKAZE converted max_features into a threshold with linear models fitted at native resolution, which ignored downsample_ratio and, for large feature counts, went negative and kept every keypoint.
Learning-based detectors (kornia, core dependency; pretrained weights download on first use and run on the device):
| Name | Type | Rotation invariant | Notes |
|---|---|---|---|
xfeat |
Float (64-d) | ❌ | Fast learned sparse detector/descriptor, and by far the lightest of the learned models. Works with bf/flann. |
disk |
Float (128-d) | ❌ | Robust learned sparse features. Works with bf/flann/lightglue. |
dedode |
Float (256-d) | ❌ | Decoupled detect-and-describe; strong but slower. Works with bf/flann/lightglue. Weights via dedode_detector_weights / dedode_descriptor_weights. |
keynet |
Float (128-d) | ✅ | KeyNet detector + HardNet descriptor, with an OriNet orientation estimator. The only rotation-invariant learned option. Works with bf/flann/lightglue. |
loftr |
Detector-free | ❌ | Semi-dense image-pair matching; produces correspondences directly, so filter_type and the bf/flann choice of matcher_name are ignored (lightglue is rejected). Weights via loftr_weights; filter correspondences with loftr_confidence. |
Learning-based detectors receive the same CLAHE, downsampling, and exclusion-mask handling as the classical ones: masks are applied by discarding keypoints (or LoFTR correspondences) that fall in excluded regions, and keypoint coordinates are rescaled back to full resolution after downsampled detection. max_features caps the number of detected keypoints; the brisk_threshold/kaze_threshold/akaze_threshold options do not apply. LoFTR matches on a coarse grid of stride 8, so its input is zero-padded at the bottom and right to a multiple of 8 and correspondences falling in the padding are dropped. Before v1.4.5 it was not padded, and a processed frame whose sides are not multiples of 8 (for example 691×389, a 4K frame at downsample_ratio: 0.18) was registered with a systematic error that grows toward the far edges, several to tens of pixels at 4K. Select the compute device with device (auto picks cuda > mps > cpu). Note that gpu=True (OpenCV CUDA) is incompatible with learning-based detectors; use device='cuda' instead.
Pretrained weights are downloaded once, on first use, into torch's hub cache (~/.cache/torch/hub/checkpoints on Linux and macOS, %USERPROFILE%\.cache\torch\hub\checkpoints on Windows). Set the TORCH_HOME environment variable to relocate it. Stabilo logs whether the weights were downloaded or reused from that cache, so a run that stalls on a first-use download is easy to recognise.
Rotation invariance¶
xfeat, disk, dedode, and loftr are trained on upright imagery and do not estimate a keypoint orientation. Matching against the reference frame degrades as the current frame rotates, and collapses beyond roughly 30°. Measured on a synthetically rotated frame pair, the mean reprojection error of the recovered homography is:
| Detector | 0° | 10° | 30° | 90° |
|---|---|---|---|---|
orb, sift |
0.0 px | 0.4 px | 0.6 px | 1.2 px |
keynet |
0.0 px | 0.7 px | 1.3 px | 2.7 px |
xfeat |
0.0 px | 0.9 px | 1.8 px | fails |
disk |
0.0 px | 0.4 px | 11.8 px | fails |
dedode |
0.0 px | 0.5 px | 1.1 px | fails |
loftr |
0.1 px | 0.5 px | 1.3 px | fails |
For footage with large in-plane rotation, such as a drone orbiting or yawing about its own axis, use a classical detector or keynet. Note that DeDoDe's C4/SO2 weight variants are not a workaround: they require a steerer to be applied at match time, which kornia does not do.
Choosing a downsample ratio¶
The learned models allocate memory in proportion to the processed frame size, which is downsample_ratio² times the input resolution. They are much hungrier than the classical detectors: at roughly 0.5 MP, disk, dedode, keynet, and loftr each peak at several GB of RAM on CPU, while xfeat stays a few hundred MB. loftr is the extreme case because its coarse matching builds an N×N confidence matrix over N = (H/8)·(W/8) tokens, so its cost grows quadratically: a 4K frame at the default downsample_ratio: 0.5 yields a 3.9 GB matrix (before intermediates), and at full resolution over 60 GB.
Stabilo emits a warning, but does not cap the resolution, when the processed frame exceeds the guideline below:
| Detector | Guideline (processed frame) | Suggested for a 4K input (3840×2160) |
|---|---|---|
xfeat |
2.0 MP | --downsample-ratio 0.49 |
disk, dedode, keynet |
0.5 MP | --downsample-ratio 0.24 |
loftr |
0.3 MP | --downsample-ratio 0.19 |
These are guidelines for a machine with a few GB of free RAM, not hard limits; a large GPU tolerates more. Lower downsample_ratio further if the process is killed or the machine begins to swap.
Running out of device memory is raised, not treated as an unregistrable frame. A torch.OutOfMemoryError, or a RuntimeError whose message says the device or host allocator ran out of memory (including MPS's Invalid buffer size), propagates out of set_ref_frame(...) and stabilize(...) so the caller can tell an infeasible configuration apart from an estimation failure. Before v1.4.3, the sparse detectors (xfeat, disk, dedode, keynet) caught it, logged that no features were found, and fell back like any other failed frame; loftr and lightglue always raised it.
10. Feature Matching and Filtering¶
Three matchers are supported:
bf— OpenCVBFMatcher. UsescrossCheck=Trueforfilter_type='none'and'distance';crossCheck=Falseforfilter_type='ratio'.flann— OpenCVFlannBasedMatcher. Uses LSH index for binary descriptors and KD-Tree for float descriptors.lightglue— learned korniaLightGlueMatcher, compatible with thedisk,dedode, andkeynetdetectors only. It performs its own filtering, sofilter_typeis ignored. (The detector-freeloftrneeds no matcher.)
Three filtering strategies:
filter_type |
Behaviour |
|---|---|
none |
Keep all matches returned by the matcher |
ratio |
Lowe's ratio test: keep match m where m.distance < filter_ratio x n.distance |
distance |
Distance threshold: keep matches below min_dist + (max_dist - min_dist) x filter_ratio |
knnMatch is asymmetric, so the set of surviving matches depends on which descriptors are used as the query. By default (match_query_frame='reference') the reference frame's descriptors are the query; set match_query_frame='current' to query with the current frame's descriptors instead. The estimated transform still maps current → reference in both cases.
11. Transformation Types and RANSAC Methods¶
Transformation types¶
transformation_type |
Matrix shape | Function |
|---|---|---|
projective |
3 x 3 | cv2.findHomography |
affine |
2 x 3 | cv2.estimateAffinePartial2D (4-DOF similarity) |
Use projective (default) when the camera undergoes any motion (pan, tilt, zoom, rotation). Use affine to restrict the estimate to a 4-DOF similarity transform: rotation, uniform scale, and translation. Despite the name, cv2.estimateAffinePartial2D does not estimate shear or non-uniform scale; a general 6-DOF affine would need cv2.estimateAffine2D, which stabilo does not wire up.
RANSAC methods (integer codes)¶
| Code | Method | projective |
affine |
|---|---|---|---|
| 4 | LMEDS (cv2.LMEDS) |
yes | yes |
| 8 | RANSAC (cv2.RANSAC) |
yes | yes |
| 16 | RHO (cv2.RHO) |
yes | no |
| 32 | USAC-Default, LO-RANSAC (cv2.USAC_DEFAULT) |
yes | no |
| 33 | USAC-Parallel, LO-RANSAC run in parallel (cv2.USAC_PARALLEL) |
yes | no |
| 35 | USAC-Fast, LO-RANSAC with fewer local-optimization iterations (cv2.USAC_FAST) |
yes | no |
| 36 | USAC-Accurate, GC-RANSAC (cv2.USAC_ACCURATE) |
yes | no |
| 37 | PROSAC (cv2.USAC_PROSAC) |
yes | no |
| 38 | MAGSAC++ (cv2.USAC_MAGSAC), default |
yes | no |
Codes 32, 33, and 35 are three LO-RANSAC configurations that differ in how local optimization is scheduled, per OpenCV's USAC tutorial. 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.
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:
Stabilizer(transformation_type='affine') # ValueError: the default ransac_method 38 is projective-only
Stabilizer(transformation_type='affine', ransac_method=8) # OK
12. Visualisation Mode¶
Set viz=True to have Stabilo retain intermediate data on the instance after each stabilize(...) call:
| Attribute | Contents |
|---|---|
ref_mask |
Binary mask used for the reference frame |
cur_mask |
Binary mask used for the current frame |
ref_frame_gray |
Grayscale reference frame |
cur_frame_gray |
Grayscale current frame |
ref_kpts |
Reference keypoints |
cur_kpts |
Current keypoints |
ref_pts |
Matched reference keypoint coordinates |
cur_pts |
Matched current keypoint coordinates |
cur_inliers |
Boolean inlier mask for matched points |
cur_inliers_count |
Number of inlier matches |
These attributes are used by the stabilo video --viz command to render side-by-side stabilisation visualisations.
Match-quality statistics are also exposed through dedicated getters:
- get_cur_inliers_count() -> int | None — number of inliers (or None if estimation failed or has not run yet).
- get_cur_num_matches() -> int | None — total number of good matches fed to the estimator (denominator of inliers/total), or None if stabilize() has not yet been called.
- get_cur_num_keypoints() -> tuple — (num_reference_keypoints, num_current_keypoints).
13. Benchmarking Mode¶
Set benchmark=True when running systematic parameter evaluation:
- If transformation estimation fails or produces
None, the identity matrix (np.eye(3)) is used instead of the last known matrix. - Log warnings about missing matches and fallback matrices are suppressed for cleaner batch output.
See Stabilo-Optimize for a dedicated benchmarking and hyperparameter-tuning framework built on top of Stabilo.
14. Testing and Development¶
# run the full test suite
pytest
# run lint checks
ruff check .
# install in editable mode with development dependencies
pip install -e '.[dev]'
Run pytest from the repository root: the fixtures load images through relative paths such as tests/ND_before.jpg, so the suite fails from anywhere else.
Tests that are skipped by default¶
Three groups of tests stay skipped unless their prerequisites are met:
| Group | Runs when | Why it is gated |
|---|---|---|
Learning-based end-to-end tests (tests/test_dl.py) |
kornia is importable and STABILO_DL_TESTS=1 is set |
Each detector downloads pretrained weights on first run (hundreds of MB into torch's hub cache) and needs far more time and memory than the classical path. Gating them keeps a plain pytest offline, fast, and safe to run in CI. |
CUDA tests (tests/test_stabilizer.py) |
OpenCV reports a CUDA-enabled device | Stock opencv-python wheels ship no CUDA algorithms, see docs/cuda.md. |
Codec-dependent CLI tests (tests/test_cli.py) |
The OpenCV build can actually encode a video file | The end-to-end render tests skip themselves when no VideoWriter codec is available, or when the writer produces no file, which happens on minimal or headless OpenCV builds. |
The learning-based gate is an explicit environment variable rather than an "enable if kornia is installed" check because kornia and torch became core dependencies in v1.4.0: they are always present, so an importability check would download weights for everyone running the suite. Opt in with:
Once the weights are cached, later runs work offline. The validation-only tests in tests/test_dl.py (invalid device, incompatible matcher, and so on) always run, because argument validation raises before kornia is ever imported.
Core-dependency smoke test¶
.github/scripts/smoke_core.py synthesizes a small video plus a matching bounding-box file and drives both stabilo video and stabilo tracks over them. It needs no development dependency, so it can validate an install made with a plain pip install .:
--core-only additionally asserts that the extras dependency (matplotlib) is absent. CI runs this as the core-install job on every push and pull request.
Documentation site¶
The documentation at rfonod.github.io/stabilo is built with MkDocs and the Material theme from mkdocs.yml and the docs/ folder. The CLI reference is generated from the installed stabilo parsers and the API reference from the Stabilizer docstrings, so both follow the code. Build it locally with:
pip install -e '.[docs]'
mkdocs serve # live preview at http://127.0.0.1:8000
mkdocs build --strict # the check CI runs; fails on any warning
The docs workflow builds the site on every pull request and deploys it to GitHub Pages on every push to main.
15. Command-Line Interface¶
Installing stabilo provides a stabilo console command:
stabilo video <input> [options] # stabilize a video relative to a reference frame
stabilo tracks <input> [options] # stabilize per-frame object annotations (tracks)
stabilo config show # print the default configuration
stabilo config copy [--output PATH] # write an editable copy (default: ./custom.yaml)
Run stabilo video --help or stabilo tracks --help for the full list of options; both expose every stabilo parameter (detector, matcher, RANSAC, masking, device, etc.).
To customize parameters, run stabilo config copy, edit the generated custom.yaml, and pass it with --custom-config custom.yaml:
stabilo config copy
# edit custom.yaml ...
stabilo video data/video.mp4 --save --output data/out/ --custom-config custom.yaml
Resolution order is explicit CLI flags > custom config file > built-in defaults: a key set in the file passed to --custom-config fills in anything not explicitly passed as a CLI flag, and both override stabilo/cfg/default.yaml.
Each CLI invocation performs a best-effort check for a newer stabilo release on PyPI and prints a one-line notice if one is available. Set STABILO_DISABLE_UPDATE_CHECK=1 to disable it.
Using an external logger¶
Stabilizer accepts an optional logger so an embedding application can route stabilo's log records through its own logging setup:
import logging
from stabilo import Stabilizer
my_logger = logging.getLogger("my_app.stabilo")
stabilizer = Stabilizer(logger=my_logger)
If omitted, stabilo uses its own module logger.