Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

blurdetector — a pixel-art eye in a focus frame, telling a blurry flower from a sharp one

blurdetector is a fast command-line tool that scores photo sharpness and classifies every image as sharp or BLURRY, so a whole shoot can be culled in one pass — directly from RAW files (CR3, NEF, ARW, DNG, …), no conversion step required.

$ blurdetector photos --sort

file                  sharpness      global  verdict
-----------------------------------------------------
photos/IMG_0003.CR3        18.4         6.2  BLURRY
photos/IMG_0002.CR3       154.9        40.3  sharp
photos/IMG_0001.CR3       512.3        88.1  sharp

3 file(s): 2 sharp, 1 blurry  [threshold = 100.0]

Highlights

  • RAW-friendly and fast — RAW files are scored from the JPEG preview the camera already embeds; nothing is ever demosaiced. CR3 files get a dedicated path that reads the full-resolution embedded JPEG straight out of the container and decodes only its luminance plane.
  • Bokeh-aware — the sharpness score is the mean of the best tiles of a grid, not of the whole frame: one sharp region is enough to call a photo sharp, so portraits on creamy backgrounds are not flagged as blurry.
  • Parallel — files are analyzed concurrently with rayon; --jobs caps the thread count.
  • Batch-safe — a corrupt or unreadable file becomes an error row in the report and never aborts the run.
  • Calibration-stable — scores are byte-for-byte reproducible and guarded by a golden-file test suite: a threshold calibrated today keeps meaning the same thing in every future version.

Next steps

  • Installation — prebuilt binaries or cargo build.
  • Usage — the command line, option by option.
  • Calibration — pick the right cut-off for your camera and style.
  • Pipeline — what the score actually measures.
  • Score Stability — why scores never drift between versions.

Installation

Prebuilt binaries

Every tagged version is built by the project’s CI and published on the GitLab Releases page with two downloads:

AssetPlatform
blurdetector-linux-x86_64Linux, x86-64 (dynamically linked against glibc)
blurdetector-windows-x86_64.exeWindows, x86-64

The binary is self-contained, with no runtime dependencies to install. On x86-64 the AVX2 fast paths are compiled in next to a baseline version and selected once at run time, so the same binary is safe on older machines and fast on recent ones.

Download the asset, make it executable (chmod +x on Linux), and put it somewhere on your PATH.

From source

A stable Rust toolchain is all that is required (the CI builds with Rust 1.97):

cargo build --release

The binary lands in target/release/blurdetector (blurdetector.exe on Windows).

Always use release builds for real runs. The pixel loops are far slower in debug mode. The release profile additionally enables fat LTO and a single codegen unit, which speeds up the hot loops by about 14% on a real CR3 batch.

To build and run in one step from a source checkout:

cargo run --release -- photos --sort

Checking

$ blurdetector --version
blurdetector 0.1.0

blurdetector --help lists every option described in Usage.

Usage

blurdetector <PATH>... [OPTIONS]

Inputs can be files, directories, or a mix. Directories are scanned for supported files; --recursive descends into subdirectories. The resulting list is deduplicated and sorted, so a path given twice is analyzed once and the report order is stable. Nonexistent paths print a warning on stderr and are skipped; if nothing is left to analyze, the program exits with code 1.

Options

OptionDefaultEffect
--threshold <SCORE>100Verdict cut-off: a photo is sharp when its sharpness score reaches this value
--grid-size <N>6The image is analyzed on an N×N grid of tiles
--top-fraction <F>0.10Fraction of the best tiles averaged into the sharpness score
--pre-blur <SIGMA>0 (off)Gaussian filter applied before the Laplacian, to tame sensor noise
--long-edge <PIXELS>1600Images are downscaled to this long edge before scoring
--jobs <N>one per CPU coreNumber of files analyzed in parallel
--csv <FILE>—Also write the report to FILE as CSV
--move-blurry-to <DIR>—Move blurry photos to DIR (created if missing)
--move-sharp-to <DIR>—Move sharp photos to DIR (created if missing)
--recursiveoffRecurse into subdirectories
--sortoffPrint results from blurriest to sharpest
--timingoffPrint cumulative per-stage CPU timings after the report

The analysis options (--threshold, --grid-size, --top-fraction, --pre-blur, --long-edge) all change the score scale or its meaning — see Calibration before touching them.

Examples

# Score a directory, blurriest photos first
blurdetector photos --sort

# Whole photo tree, results exported to CSV
blurdetector photos --recursive --csv report.csv

# Quarantine the rejects
blurdetector photos --move-blurry-to rejects

# Stricter verdicts plus light denoising for high-ISO shots
blurdetector photos --threshold 150 --pre-blur 0.8

# From a source checkout
cargo run --release -- photos --sort

Moving files

--move-blurry-to and --move-sharp-to sort the analyzed photos into directories after the report is printed. Both can be used in the same run. The destination directory (and its parents) is created if missing. Each file is moved with a plain rename first, falling back to copy-then-delete when the destination is on another file system.

error rows are never moved: a file the tool could not score stays where it is. A file that cannot be moved (already deleted, permission denied, …) prints a warning and the rest of the batch continues; only a destination directory that cannot be created aborts the move and turns the exit code into 1.

Parallelism

Files are analyzed concurrently on the rayon thread pool, one file per task. By default the pool has one thread per processor core; --jobs N caps it, which is useful to keep the machine responsive during a big batch or to benchmark single-thread performance. If the pool cannot be configured the run continues on the default pool after a warning.

Timing

--timing appends a per-stage breakdown (decode, grayscale, resize, gaussian, score) after the report. The values are CPU totals summed over all worker threads, so they measure the compute cost of each stage independently of the wall clock — see Performance.

Formats

Inputs are matched by extension, case-insensitively; anything else found in a scanned directory is ignored.

RAW

BrandExtensions
Canoncrw cr2 cr3
Fujifilmraf
Leicarwl (and natively dng)
Nikonnef nrw
Sonyarw srf sr2
Cross-branddng — Adobe’s open RAW format, shared by many brands

Standard images

jpg jpeg png tif tiff bmp webp

Decode paths

The tool never develops (demosaics) RAW data. RAW files are scored from the JPEG image the camera already embedded, which is faithful for sharpness purposes and orders of magnitude faster to read. Each input takes the cheapest decode path available:

CR3 (Canon). The camera stores a full-resolution JPEG rendition inside the CR3’s BMFF container. That JPEG is located directly — first sample of the first track, the same place rawler’s own preview extraction looks — and decoded by zune-jpeg straight to its luminance plane, skipping the color processing entirely. A malformed container index degrades to rawler’s generic preview extraction below instead of failing. A JPEG that then fails to decode is a real error: for example, CR3 files shot in HDR PQ embed an HEIF preview that no decoder in the tool reads, so they are reported as error rows.

Other RAW formats (crw, cr2, raf, rwl, dng, nef, nrw, arw, srf, sr2). rawler extracts the embedded preview image, falling back internally to the largest embedded image when no preview exists. The decoded RGB pixels then go through the BT.601 grayscale conversion.

Standalone JPEG. Decoded by zune-jpeg straight to the luminance plane, exactly like a CR3 preview. JPEGs without a luminance plane (CMYK, …) fall back to the generic path below.

Other standard images (png, tif, bmp, webp). Decoded by the image crate to RGB, then converted to grayscale with the BT.601 weights.

Why luminance only? Sharpness is measured on the luminance channel. For a YCbCr JPEG, the Y plane is the BT.601 luminance the encoder computed from the original pixels, so decoding it directly gives the same signal while skipping the chroma upsampling and the RGB round trip — see Score Stability for how this shortcut was validated.

No raw development

There is deliberately no raw-development fallback: a RAW file without a decodable JPEG preview becomes an error row rather than being demosaiced. This keeps the pipeline fast and its scores tied to one well-defined source image per file. See Reports for how error rows behave.

Reports

Console report

Every run prints a table with one row per analyzed file:

file                  sharpness      global  verdict
-----------------------------------------------------
photos/IMG_0003.CR3        18.4         6.2  BLURRY
photos/IMG_0002.CR3       154.9        40.3  sharp
photos/IMG_0004.CR3                          error  (BMFF parse failed: ...)
photos/IMG_0001.CR3       512.3        88.1  sharp

4 file(s): 2 sharp, 1 blurry, 1 error(s)  [threshold = 100.0]
  • sharpness is the score the verdict is based on; global is the whole-frame variance, informative only (see Pipeline). Both are rounded to one decimal.
  • The file column adapts to the longest path, capped at 55 characters; longer paths are truncated from the left with an ellipsis so the file name stays visible.
  • error rows show the reason in parentheses and leave the score columns empty.
  • With --sort, rows are ordered from blurriest to sharpest — handy for eyeballing where your threshold should sit — and error rows sink to the end.

CSV report

--csv FILE writes the same information as machine-readable CSV:

file,verdict,sharpness_score,global_score,error
photos/IMG_0001.CR3,sharp,512.3,88.1,
photos/IMG_0002.CR3,BLURRY,42.7,12.9,
photos/IMG_0004.CR3,error,,,unable to extract preview
  • The column set file,verdict,sharpness_score,global_score,error is a stable interface: the companion calibration script calibrate_blur.py (kept outside this repository) consumes it directly, and the golden parity tests byte-compare it across versions.
  • Scores are rounded to one decimal and left empty on error rows.
  • Fields containing commas, quotes or newlines are quoted, with inner quotes doubled (standard CSV escaping).
  • A CSV file that cannot be written prints an error but does not change the exit code — the console report already happened.

Exit status

CodeMeaning
0Run completed — even if some files ended up as error rows
1No files to analyze, or a --move-* destination directory could not be created

Scripts should treat error rows as the per-file failure signal and the exit code only as “did the batch run at all”.

Error handling

Batch analysis is designed to always finish:

  • A file that cannot be decoded or scored becomes an error row carrying the failure message; the rest of the batch is unaffected.
  • This includes panics inside the RAW parser: rawler can panic on malformed containers (for example a CR3 missing its co64 box), so each file’s analysis runs under a panic catcher that converts the panic into that file’s error message instead of letting it abort the whole run.
  • Unreadable directories or directory entries print a warning on stderr and are skipped.
  • error rows are never moved by --move-blurry-to / --move-sharp-to.

Calibration

The verdict rule is simple: a photo is sharp when its sharpness score is at least --threshold, BLURRY otherwise. Everything therefore hinges on choosing a threshold that matches your camera, lenses and idea of “sharp enough”.

The default (100, tuned for the default --long-edge 1600) is a reasonable starting point for modern cameras.

Procedure

  1. Take a folder you have already culled by hand — ideally a real shoot with both keepers and rejects.

  2. Score it and export the results:

    blurdetector my-culled-shoot --recursive --csv report.csv
    
  3. Open the CSV (or run with --sort and read the console) and look for the score that best separates your keepers from your rejects. That score is your threshold.

  4. Use it on future shoots: blurdetector new-shoot --threshold 137.

Raising the threshold rejects more photos; lowering it is more forgiving. There is no universally correct value — motion blur tolerance, subject matter and output size all play into it.

The companion script calibrate_blur.py (kept outside this repository) automates step 3 from the CSV report, which is why the CSV column set is a stable interface.

What invalidates a calibration

Any option that changes the score scale requires recalibrating:

  • --long-edge — scores depend on image scale; that is the very reason images are normalized.
  • --grid-size and --top-fraction — they define which tiles the score averages.
  • --pre-blur — smoothing lowers every score, blurry photos most of all.

Calibrate with the exact analysis options you plan to run with.

What does not invalidate a calibration is upgrading the tool: scores are byte-identical across versions by design, guarded by a golden test suite. In the exceptional case of a deliberate scoring change, the impact is measured and announced first — see Score Stability.

High-ISO noise and --pre-blur

Sensor noise is high-frequency and inflates the Laplacian variance, so a noisy-but-blurry night shot can outscore a clean-but-soft one. A light Gaussian pre-blur (--pre-blur 0.8 is a good starting value) evens the field by taming the noise before the measurement. If your shoots mix ISO ranges, calibrate with the same --pre-blur you will use day to day.

Pipeline

Every file goes through the same stages, top to bottom:

decode ─▶ grayscale ─▶ resize to long edge ─▶ (optional pre-blur)
       ─▶ Laplacian ─▶ per-tile variance ─▶ scores ─▶ verdict

The pipeline follows OpenCV’s exact numeric semantics — the OpenCV names below are conventions being matched, not libraries being called. See Score Stability for why that matters.

1. Decode and grayscale

Sharpness is measured on the luminance channel only.

  • JPEG sources (CR3 embedded previews, standalone JPEG files) decode straight to their luminance plane: for a YCbCr JPEG, the Y component is the BT.601 luminance the encoder computed from the original pixels.

  • Every other source decodes to RGB and is converted with the BT.601 weights, matching OpenCV’s cvtColor:

    Y = round(0.299 R + 0.587 G + 0.114 B)
    

Either way, the rest of the pipeline works on an 8-bit grayscale image. Formats details which file takes which decode path.

2. Normalization

The variance of the Laplacian grows with image resolution: the same photo scores differently at 6000 px and at 1600 px. To make scores comparable across cameras and crops, every image whose longest edge exceeds --long-edge (default 1600) is downscaled to it.

The downscale replicates OpenCV’s INTER_AREA: each destination pixel is the average of the source pixels it covers, with fractional coverage weights on the window edges. The target dimensions are truncated, not rounded. Images already at or below the target size are left untouched — never upscaled.

3. Pre-blur

With --pre-blur SIGMA (> 0), a separable Gaussian filter mirroring OpenCV’s GaussianBlur on 8-bit images runs before the measurement: kernel size round(6σ + 1) forced odd (OpenCV’s automatic size), BORDER_REFLECT_101 borders, full-precision intermediate rows, result rounded back to 8 bits.

Its purpose is noise, not aesthetics: high-ISO sensor noise is high-frequency and inflates the variance of genuinely blurry photos. See Calibration.

4. Laplacian

The classic focus measure: the 3×3 Laplacian

0  1  0
1 -4  1
0  1  0

is applied at every pixel with BORDER_REFLECT_101 borders, matching OpenCV’s Laplacian with its default kernel size. A sharp edge produces a strong positive/negative swing in the response; a blurred edge barely registers.

5. Tile variance

The Laplacian response is measured per tile on an N×N grid (--grid-size, default 6×6 = 36 tiles). Tile boundaries use integer arithmetic (start = index * size / N), so every pixel belongs to exactly one tile. The statistic is the population variance (dividing by the pixel count, like NumPy’s default var()), computed per tile. When the image is smaller than the grid, the empty tiles are simply skipped.

6. Scores

Two numbers come out of the grid:

  • sharpness score — the tile variances are sorted descending and the best --top-fraction of them (max(1, round(tile_count × fraction)) tiles; the default keeps the best 4 of 36) are averaged. This is the score the verdict uses.
  • global score — the variance over the whole frame, reported for information. Comparing it to the sharpness score tells you whether a photo is sharp everywhere or only somewhere.

Scoring only the best tiles is deliberate: a razor-sharp subject on a melted bokeh background would fail a whole-frame variance test, yet the photo is a keeper. One sharp region is enough evidence that the focus landed.

7. Verdict

A photo is sharp when its sharpness score reaches --threshold, BLURRY below. Both scores are rounded to one decimal in the reports.

Files that fail anywhere in the pipeline become error rows instead — see Reports.

Score Stability

The contract

Scores are byte-identical from version to version. Thresholds are calibrated against real shoots with calibrate_blur.py; a score that silently drifted by a few percent would invalidate every calibration in the wild without anyone noticing. Score stability is therefore treated as a hard interface: the golden CSV reports checked by Golden Tests must stay byte-identical across any refactor or optimization.

Hand-rolled by design

The pipeline implements the classic variance-of-Laplacian recipe with OpenCV’s exact numeric semantics, which the original scores were validated against. Because “roughly the same” is not good enough, the image operations replicate those semantics instead of calling off-the-shelf crates, which use different weights, rounding or border conventions:

  • BT.601 grayscale weights (0.299 / 0.587 / 0.114, rounded like OpenCV’s cvtColor) for non-JPEG sources.
  • Area-averaging resize replicating INTER_AREA, with truncated (not rounded) target dimensions.
  • BORDER_REFLECT_101 borders (gfedcb|abcdefgh|gfedcba) in both the Gaussian pre-blur and the Laplacian — OpenCV’s default border mode.
  • OpenCV’s automatic Gaussian kernel size for 8-bit images: round(6σ + 1), forced odd.
  • The same 3×3 Laplacian kernel as OpenCV’s Laplacian with its default aperture.
  • Population variance (not sample variance), with identical integer tile boundaries.

Replacing any of these with a library call, or changing a rounding or truncation, shifts every score. Do not “simplify” them. Optimizations are welcome, but they must reproduce the reference operations exactly: every fast path in the code is backed by an exact-equivalence unit test against a reference implementation kept in the tests module of src/lib.rs, and the whole pipeline is backed by the golden CSVs.

One deliberate departure

One scoring change was accepted, in July 2026: JPEG sources score on the JPEG’s own luminance plane instead of BT.601 recomputed over decoded RGB.

For a YCbCr JPEG, the Y plane is the BT.601 luminance the encoder computed from the original pixels; recomputing it after chroma upsampling and the RGB round trip only adds two lossy conversions and a double rounding. Decoding Y directly is both closer to the source signal and much faster.

Measured on the reference corpus before adoption:

MetricValue
Maximum score drift≤ 0.24 %
Spearman rank correlation≥ 0.99998
Verdict flips0
Thresholdsunchanged

Changing the scores on purpose

A deliberate scoring change is exceptional and follows a fixed procedure:

  1. Produce a candidate CSV of the reference corpus with the change applied.

  2. Measure the drift:

    cargo run --release --example drift_report -- tests/assets/golden/default.csv candidate.csv
    

    The report prints per-file deltas, the Spearman rank correlation (does the new score still order photos the same way?), verdict flips at the threshold, and the equivalent threshold suggested by the median score ratio.

  3. Announce the change with those numbers — users may need to adjust thresholds.

  4. Regenerate the golden references (Golden Tests) so the new behavior becomes the contract.

Performance

The golden rule of performance work here: an optimization must not change a single output byte (why). Every fast path below is proven equivalent to a reference implementation by unit tests, and the end-to-end result is locked by the golden CSVs. Within that constraint, a 108-file CR3 batch that took several seconds in the first working version runs in about 1.35 s today.

Parallel batches

Files are independent, so the batch is data-parallel: one rayon task per file, on a pool sized by --jobs (default: one thread per core). All stage timings are accumulated in atomic counters so --timing works across threads.

Lean decoding

Decoding is the most expensive stage, so the pipeline decodes as little as possible:

  • CR3 files skip generic RAW processing entirely: the full-resolution embedded JPEG is located through the BMFF index and read as raw bytes, and nothing is ever demosaiced.
  • JPEG sources are decoded straight to their luminance plane by zune-jpeg. Since the pipeline only scores luminance, the chroma upsampling, the YCbCr→RGB conversion and the BT.601 recomputation disappear from the hot path.

SIMD area resize

The INTER_AREA resize is the heaviest pure-compute stage. It is vectorized with f64x4: four destination columns accumulate in lockstep, one SIMD lane each, and the image is swept one source row at a time into a cache-resident row of accumulators, which hides the floating-point addition latency that throttles a naïve per-pixel loop.

The output stays byte-identical to the scalar reference because every destination pixel still receives the exact same multiplications and additions in the exact same order: IEEE 754 operations round the same whether they run one or four per instruction, and Rust never fuses a multiply-add on its own. multiversion compiles an AVX2 clone next to the baseline x86-64 one and dispatches once at run time, so a single distributed binary is fast on modern CPUs and safe on older ones.

The stage runs ~1.8× faster vectorized; on a real 108-file CR3 batch this took the total from 1.7 s to 1.35 s.

Fused tile statistics

Variance only needs Σx and Σx², so the scoring stage computes the Laplacian and the per-tile statistics in a single pass, without ever materializing the full Laplacian response buffer. The accumulators are i64: the Laplacian of an 8-bit image is an integer in [-1020, 1020], so the integer sums are exact — and equal to the f64 accumulation of the reference pipeline, which stays below 2⁵³ and is therefore exact too. Same integer tile boundaries, same skipping of empty tiles; the materializing implementation is kept in the code as the reference the tests compare against.

Interior/border split

The Gaussian and Laplacian kernels index the interior of the image directly; only the margin pixels pay for the BORDER_REFLECT_101 reflection arithmetic. On a 1600 px image the border is a fraction of a percent of the pixels.

Release profile

Cargo.toml enables fat LTO and a single codegen unit for release builds — about −14 % on a real CR3 batch, with no code change at all.

Measuring

Two built-in instruments, no external tooling:

  • --timing appends per-stage CPU totals to any run. The totals are summed over all worker threads, so they can exceed the wall clock; they measure where the compute actually goes. The last line gives the average cost per file.

    Stage CPU totals over all threads:
      decode        5.10 s   78.9%
      grayscale     0.00 s    0.0%
      resize        0.86 s   13.3%
      gaussian      0.00 s    0.0%
      score         0.50 s    7.8%
      total         6.46 s  (60 ms per file on average)
    
  • The resize microbenchmark times the SIMD and scalar resize kernels back to back, in-process, on a synthetic 24 Mpx image — immune to the thermal drift that plagues run-to-run comparisons:

    cargo test --release -- --ignored resize_microbenchmark --nocapture
    

Building

Layout

The crate follows the binary+library split recommended by the Rust Book: src/main.rs only parses the command line (clap) into a RunConfiguration and calls blurdetector::run; every piece of logic lives in src/lib.rs. The coding guidelines are in rules.md at the repository root.

Commands

cargo build --release   # use release builds for real runs; the pixel loops are slow in debug
cargo test              # unit tests live in the `tests` module at the bottom of src/lib.rs
cargo test area_resize_averages_pixel_blocks   # run a single test
cargo clippy            # lint (CI runs it with -D warnings on all targets)
cargo fmt               # format (CI checks with --check)

The unit tests need no test data: they exercise the image operations on synthetic pixels, notably the exact-equivalence tests that pin every fast path to its reference implementation (see Score Stability).

Test corpus

tests/assets/raw/ holds sample CR3 files for manual testing and for the golden tests. It is gitignored — the corpus lives only on development machines, and the golden tests that need it are #[ignore]d by default so cargo test stays green everywhere, including CI.

A quick manual run against the corpus:

cargo run --release -- tests/assets/raw --sort
cargo run --release -- tests/assets/raw --timing   # where does the time go?

CI

Every push runs, on rust:1.97.1:

  1. cargo fmt --check
  2. cargo clippy --all-targets -- -D warnings
  3. cargo test
  4. Release builds for Linux (x86_64-unknown-linux-gnu) and Windows (x86_64-pc-windows-gnu, cross-compiled with mingw-w64)

Tags additionally publish the binaries and a GitLab release — see Releasing.

Documentation

The book you are reading lives in docs/ and is built with mdBook:

mdbook serve docs --open   # live preview while editing
mdbook build docs          # output in docs/book/ (gitignored)

The pages CI job builds it with a pinned mdBook and deploys it to GitLab Pages on every push to the default branch.

Golden Tests

tests/golden.rs is the end-to-end lock on the score stability contract: it re-scores the local corpus in tests/assets/raw/ through the exact code path of the binary (blurdetector::run with the CLI defaults) and byte-compares the resulting CSV report with the golden references in tests/assets/golden/. Any difference — a score, a rounding, a column, a line ending — fails the test.

Two configurations are locked:

Golden fileConfiguration
tests/assets/golden/default.csvCLI defaults (--pre-blur 0)
tests/assets/golden/preblur.csvCLI defaults with --pre-blur 0.8

Running

cargo test --release --test golden -- --ignored

The tests are #[ignore]d by default because they need the gitignored CR3 corpus (absent in CI and on fresh clones) and a release build to run in reasonable time. Run the guard on any machine holding the corpus before merging a change that touches the pipeline — especially an optimization.

When it fails

The failure message pinpoints the first differing CSV line, keeps the candidate report in the temp directory for inspection, and prints the drift command ready to paste:

scores diverged from tests/assets/golden/default.csv
first difference at line 42:
  golden   : tests\assets\raw\IMG_6545.CR3,sharp,251.6,73.9,
  candidate: tests\assets\raw\IMG_6545.CR3,sharp,251.7,73.9,
candidate kept at /tmp/blurdetector-candidate-default-1234.csv

An unintended difference means the change broke parity: fix the code, not the goldens.

Measuring drift

If the difference is intended (exceptional — see the procedure), quantify it first:

cargo run --release --example drift_report -- tests/assets/golden/default.csv candidate.csv [threshold]

The report prints:

  • how many files were compared, and how many exist on only one side;
  • the median and maximum relative score deltas;
  • the Spearman rank correlation — 1.0 means the new scores order photos exactly like the old ones, which is what threshold-based culling actually depends on;
  • the verdict flips at the given threshold (default 100), listed file by file;
  • the median new/old score ratio and the equivalent threshold it suggests for existing calibrations.

Regenerating

Only once a scoring change is measured and announced:

# POSIX shell
BLURDETECTOR_UPDATE_GOLDEN=1 cargo test --release --test golden -- --ignored
# PowerShell
$env:BLURDETECTOR_UPDATE_GOLDEN = "1"; cargo test --release --test golden -- --ignored

The regenerated CSVs in tests/assets/golden/ are committed with the change; from that point on they are the contract every future refactor is held to.

Releasing

Releases are driven by git tags; the GitLab CI pipeline does the rest.

Cutting a release

  1. Update CHANGELOG.md: move the [Unreleased] notes into a new section headed

    ## [X.Y.Z] - YYYY-MM-DD
    

    The release job extracts exactly that section (matched against the tag) to use as the release notes — the pipeline fails if the tag has no matching, non-empty section, so the changelog cannot be forgotten.

  2. Bump the version in Cargo.toml and commit.

  3. Tag and push:

    git tag vX.Y.Z
    git push origin vX.Y.Z
    

On a tag

  1. The usual gates: cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test.
  2. Release builds for Linux (x86_64-unknown-linux-gnu) and Windows (x86_64-pc-windows-gnu, cross-compiled with mingw-w64).
  3. Both binaries are uploaded to the project’s generic package registry under the tag:
    • blurdetector-linux-x86_64
    • blurdetector-windows-x86_64.exe
  4. A GitLab Release is created for the tag, with the changelog section as description and direct download links to both binaries.

Docs deployment

This book deploys independently of releases: the pages CI job builds it with mdBook and publishes it to GitLab Pages on every push to the default branch, so the published documentation always tracks the latest merged state.