Expand description
Image content analyzers for adaptive codec decisions.
Computes the numeric features used by oracle-trained decision trees
(originally coefficient::scripts/fit_oracle_tree.py) to drive
per-image encoder configuration. Every shipped feature is wired
into at least one fitted tree’s splits per the 2026-04-25 audit.
§Public API
All public types are opaque. Construction goes through the
feature module:
feature::AnalysisFeature: stable identifier per feature (#[non_exhaustive],#[repr(u16)], sequential discriminants — ids are immutable once shipped).feature::FeatureValue:F32/U32/Booltagged value withto_f32lossless coercion.feature::FeatureSet: opaque bitset with fullconst fnset math. The only “all features” entry isfeature::FeatureSet::SUPPORTED— there is intentionally noall(). Production callers enumerate what they need.feature::AnalysisQuery: opaque request handle. Sampling budgets are crate invariants, not per-call knobs.feature::ImageGeometry: opaquewidth/height/pixels/megapixels/aspect_ratioaccessors.feature::AnalysisResults: opaque queryable container.
§Entry points
analyze_features— preferred. Takes anyPixelSliceand anfeature::AnalysisQuery. Native zero-copy on RGB8/RGB8_SRGB inputs; one-row scratch +RowConverteron anything else (everyzenpixelsdescriptor: RGBA/BGRA u8/u16, GRAY u8/u16, RGB/RGBA f32 linear, PQ/HLG/Bt709, BT.709/DisplayP3/BT.2020/AdobeRGB).analyze_features_rgb8— convenience for packed RGB8 buffers.
§Composition pattern
use zenanalyze::feature::{AnalysisFeature, AnalysisQuery, FeatureSet};
const JPEG_FEATURES: FeatureSet = FeatureSet::new()
.with(AnalysisFeature::Variance)
.with(AnalysisFeature::EdgeDensity)
.with(AnalysisFeature::DctCompressibilityY);
const WEBP_FEATURES: FeatureSet = FeatureSet::new()
.with(AnalysisFeature::Variance)
.with(AnalysisFeature::AlphaPresent);
// Orchestrator unions, runs once, hands results down.
let needed = JPEG_FEATURES.union(WEBP_FEATURES);
let r = zenanalyze::analyze_features(slice, &AnalysisQuery::new(needed))?;
let var = r.get_f32(AnalysisFeature::Variance);§Threshold contract — iterating during 0.1.x
Numeric thresholds and normalization scales in this crate are converging during the 0.1.x line. Downstream consumers that compile-in fitted models (oracle decision trees, content selectors) must pin to a specific zenanalyze patch version and re-validate / retrain whenever they bump it — feature outputs change between patches whenever a real bug is fixed.
Every breaking numeric change ships with a CHANGELOG entry under the version it landed in. When the algorithm stabilizes (1.0), the contract will freeze. Sampling budgets (Tier 1/2 stripe step, Tier 3 block cap) are part of that contract — not exposed as per-call knobs.
§Tier architecture
Five passes, each gated by what the requested feature::FeatureSet
actually needs. None of them materialize a full RGB8 buffer:
| Pass | Iterates over | Reads | Cost (4 MP) | Drives |
|---|---|---|---|---|
| Tier 1 | Stripe-sampled rows | RGB8 (via RowStream) | ~1 ms | luma stats, edges, chroma, uniformity, grayscale_score |
| Tier 2 | 3-row sliding window | RGB8 | ~2 ms | per-axis Cb/Cr sharpness |
| Tier 3 | Sampled 8×8 DCT blocks | RGB8 | ~3 ms | DCT energy, entropy, AQ map, noise floor, line-art, patch fraction |
| Palette | Full-image (every pixel) | RGB8 | ~1 ms | distinct_color_bins |
| Alpha | Stride-sampled rows | Source bytes | ~0.3 ms | alpha presence / used / bimodal |
| tier_depth | Stride-sampled rows | Source bytes | ~0.5 ms HDR / ~0 ms SDR | peak nits, headroom, bit depth, HDR-present |
Tier 1/2/3 + Palette read RGB8, going through RowStream’s Native
zero-copy path on RGB8 layouts and RowConverter row-by-row on
everything else. Alpha and tier_depth read source samples
directly — that’s load-bearing for HDR (RowConverter doesn’t
tonemap PQ/HLG; its narrowing clips to [0, 1] sRGB-display) and
for the alpha pass (avoids a precision-losing pre-multiply round-
trip on u16/f32 alpha).
§Why a separate tier_depth
The standard tiers’ threshold contract is calibrated on display- space RGB8 bytes. HDR content’s source samples encode dynamic range that the RGB8 narrowing destroys; a 4000-nit PQ source and a 100-nit-clipped SDR source produce byte-identical RGB8 streams. Routing the depth tier through RowStream would have made it literally impossible to surface HDR-aware features.
Adding tier_depth as a fifth const bool axis to the dispatch
table would have doubled it from 16 to 32 monomorphizations for
near-zero benefit — the depth tier reads source bytes, not RGB8
rows, and shares no inner loop with T1/T2/T3 to specialize. So
it’s wired as a runtime branch outside the const-bool dispatch.
§Empirical calibration (corpus-eval 2026-04-27)
Pre-0.1.0-ship calibration baseline measured on a 219-image
labeled corpus from coefficient/benchmarks/classifier-eval/labels.tsv
(174 photo, 36 screen, 9 illustration, 44 marked synthetic;
pooled from cid22-train/val, clic2025-1024, gb82, gb82-sc,
imageflow, kadid10k, qoi-benchmark). Full per-class
distributions, ROC-AUC ranking for every feature, recommended
operating thresholds, and the recalibration candidates that
were considered and rejected are recorded in
docs/calibration-corpus-2026-04-27.md.
Top-line empirical findings:
- Strongest single screen-vs-photo discriminator:
[
feature::AnalysisFeature::PatchFraction] (AUC = 0.880, F1 = 0.769 at>= 0.27). - Strongest photo classifier:
[
feature::AnalysisFeature::NaturalLikelihood] (F1 = 0.924 at>= 0.06). - Near-deterministic line-art signal:
[
feature::AnalysisFeature::LineArtScore]> 0(F1 = 0.978). - Derived likelihoods empirically saturate at ~0.70, not 1.0 — operating thresholds live in the 0.3–0.6 band, not 0.8+.
Spearman ρ |≥ 0.85| pairs (mostly structural, all kept):
distinct_color_bins↔palette_density: ρ = +1.00 — derived.flat_color_block_ratio↔screen_content_likelihood: ρ = +0.99 — structural, the former is the latter’s primary input.uniformity↔aq_map_mean: ρ = −0.97. Both measure block flatness; drive different knobs (T1 fast-path vs T3 AQ-driven trellis-λ).chroma_complexity↔colourfulness: ρ = +0.97. Both quantify chroma spread; one is normalised, the other is the raw Hasler-Süsstrunk M3 published scale — keep both for ergonomics.aq_map_mean↔noise_floor_y: ρ = +0.91. Both fall when blocks are flat. Different downstream knobs (zenjpeg trellis-λ vspre_blur), keep both.noise_floor_y↔screen_content_likelihood: ρ = −0.89. Screen content is clean-by-construction; photos carry sensor noise. Different passes, different knobs.cb_sharpness↔cr_sharpness: ρ = +0.89. Co-vary by construction in natural content.
No deletion candidates were found. Numeric drift in 0.1.x
patches is permitted by the threshold contract above; the
committed docs/calibration-corpus-2026-04-27.md is the
pre-ship baseline against which patch drift can be compared.
Modules§
- feature
- Public API surface: a stable, opaque, set-composable feature interface that lets multiple codecs share one analysis pass.
Enums§
- Analyze
Error - Errors returned from the public analyzer entries.
Functions§
- analyze_
features - Run the analyzer over any
PixelSlicefor the requested feature set. Returns an opaquefeature::AnalysisResultsholding only the features the query asked for. - analyze_
features_ rgb8 - Convenience entry for callers holding a packed RGB8 buffer plus a query. Panics on length mismatch or stride-construction failure.
- try_
analyze_ features_ rgb8 - Fallible parallel of
analyze_features_rgb8. ReturnsAnalyzeError::InvalidInputwhen the buffer length doesn’t matchwidth * height * 3or the resulting stride is invalid; returnsAnalyzeError::OutOfMemorywhen (in a future fallible- allocation build) the analyzer’s working buffers can’t be reserved.