Changelog
All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Changed
Section titled “Changed”- The benchmark
--assertspeed gate is now self-referential and host-scoped. It required sceptre’s warm/batch wall time to beat EasyOCR’s by 2.0×; it now requires sceptre’s own warm throughput to stay within 10% of the figure the committed baseline published for the same host. The old floor had both defects ADR 0042 found in the peak-RSS floor it deliberately left open: it was host-dependent, because on this corpus the two engines do near-identical CPU work (core-seconds ratio 1.064×) and essentially all of sceptre’s advantage is parallel scaling (5.92 effective cores against 3.23), which an 8-core runner caps; and it was coupled to EasyOCR, so a faster reference release tripped a gate whose message said sceptre regressed.warm_speedupremains a reported figure. See ADR 0043. - The published benchmark baseline was re-measured on
runner-mediumwith 0.6.0, replacing a 0.4.0 Darwin/arm64 one. Headline figures fall (warm speedup 2.28× → 1.95×, peak-RSS ratio 3.8× → 1.4×) because the CI host is smaller and slower, not because sceptre regressed; quality is unchanged and still ahead of EasyOCR. The rendered tables now name the runner class.
- GitHub releases carry their changelog section as the release body.
publish.yamlpassed a literalRelease <tag>placeholder and the finalize step has no notes input, so every release through 0.6.0 published with an empty body.
0.6.0 - 2026-08-08
Section titled “0.6.0 - 2026-08-08”- An opt-in megapixel budget for detection.
DetectionConfig::max_megapixels(defaultNone, plus--max-megapixels) bounds the padded CRAFT input’s area, composing withcanvas_sizeandmag_ratioas a minimum. Peak memory tracks area, not the longest side: fitted over 91 (image, canvas) points,RSS_MB ≈ 1063 × megapixels + 326at R² 0.984 against 0.847 for a longest-side fit, and two images sharing a longest side can differ 2× in area. On a 3630×2777 page peak RSS goes 6230 MB unset → 2234 MB at2.0→ 838 MB at0.5. Thecanvas_sizedefault stays at 2560 deliberately — see ADR 0041. - Opt-in whole-page orientation detection.
DetectionConfig::detect_orientation(defaultfalse, plus a CLI flag and an MCP parameter) probes a page at 0°/90°/180°/270° by running a reduced-canvas CRAFT pass in each rotation and scoring region- and link-head activation, then detects and recognizes the winning rotation and maps the output quads back to the caller’s frame. On the six known-rotated corpus images this takes token-F1 from ~0.00 to 0.911 and CER from 0.858 to 0.134. The default staysfalsebecause the scorer false-positives on five upright images (four dense tables/receipts pluskannada.png), where a wrong rotation is a total loss rather than a degradation — see ADR 0037 for the measured before/after tables and the false-positive analysis. - The orientation pre-pass no longer rotates upright pages. A rotation is applied only when the
combined region+link score and the link-only score independently select the same one, each
clearing
orientation_margin. The two CRAFT heads fail in opposite directions — the region head responds to stroke density rather than glyph orientation and drifts on dense tables and receipts, while the link head discriminates orientation but is noisy on photographed scenes — so a rotation one proposes and the other refuses is the signature of a false positive. Over the 23 orientation-labeled corpus images this goes from 18/23 to 23/23: all five wrong rotations are dropped, all six correcting rotations are kept, and no new false positive appears. No extra CRAFT pass and no new config field.detect_orientationstill defaults tofalse. See ADR 0038. - Opt-in CTC beam-search decoding.
RecognitionConfig::decoderacceptsDecoder::BeamSearch, a faithful port of EasyOCR’sctcBeamSearch(recognize/beam.rs), reusing the greedy path’s probability matrix andcustom_meanconfidence so confidence stays comparable across decoders. Greedy remains the default: measured against the tier-2 golden corpus, beam search is a mixed, net-negative change (one win, two regressions, Σdelta −0.138 over 8 images), and the pattern — dropping a character mid-word rather than substituting one — does not improve with a wider beam.Decoder::WordBeamSearchremains a config error; it needs per-language dictionaries and word segmentation sceptre does not have. See ADR 0036. - Benchmark quality metrics gained a CJK bigram tokenizer (word-level F1 now scores CJK text on
overlapping character pairs instead of treating a whole line as one token), line-level
detection precision/recall/F1, and a reading-order score (anchor longest-increasing-subsequence
over exactly-once tokens), ported from xberg’s
benchmark-harnessquality module. - The benchmark harness reports R-7 percentiles (p95, p99) with sample-count suppression — a
percentile computed from fewer than 20 (p95) or 100 (p99) samples is reported as absent rather
than as the maximum wearing a statistical label — plus CPU core-seconds per run (summed
user+system time from
/usr/bin/time, not sampled) and, per corpus image, thecorpus.lock.jsonsha256 that image was fetched against, so a report cites exactly which corpus snapshot it measured. - The benchmark comparison report carries a
schema_version, andvalidate_reportrejects a malformed report before it is written to disk instead of failing whoever reads it back later. - The corpus-mean quality gate was replaced with per-image guardrails:
derive_guardrailsbuilds a floor per labeled image from a baseline run (ported from xberg’ssplit_benchmark.rsboundary guardrails), and--assert --guardrailsfails on the specific image that regressed instead of on a bimodal corpus average that can hide a large per-image regression behind an unrelated improvement. - A PDF rasterization dev script,
task python:rasterize, for turning a PDF page into a raster image ahead of benchmarking or fixture generation. - A backend × accelerator benchmark matrix:
crates/sceptre/tests/backend_matrix.rsruns one#[ignore]d test per backend/accelerator pairing at a fixedcanvas_sizeso legs are comparable, aggregated intobenchmarks/published/backends.jsonand a newBenchmarksCI workflow that also re-homes the criterion microbenchmarks and the EasyOCR head-to-head jobs. This is a liveness/throughput measurement, not a correctness bar —backend_agreement.rsremains the only correctness gate for every backend/accelerator pairing. See ADR 0035. - The
candlebackend runs.--backend candle(featurecandle) executes CRAFT and the gen2 recognizers with no ONNX Runtime at all. It does not interpret the ONNX graph: the two networks are written out againstcandle_nnand their weights are read from the initializers of the same ONNX files the other backends load, so there is no new model artifact, no change to the registry or its sha256 pins, and noprotocbuild dependency. Validated againstortat the tensor level (within1e-4over the shapes the export pipeline checks) and end to end (exact sorted word-multiset equality onenglish.pngandcyrillic.png). See ADR 0031. - GPU execution on the
candlebackend, via thecandle-metalandcandle-cudafeatures and--accelerator metal/--accelerator cuda. This reaches a GPU without provisioning a provider-specific ONNX Runtime build. Metal is validated againstorton real hardware; neither path is exercised in CI, which has no GPU runner. Accelerator::Metal, andBackend::hardware_accelerators()/Backend::supports()reporting which accelerators each backend can run on.run --timingsnow reports the stage breakdown in the--format jsonpayload, not only as a human line on stderr. A single image gains atimingskey alongsidelines; a batch, whose payload is an array, is wrapped in a{ "images", "timings" }envelope. Times are milliseconds:setup_ms(model load + decode),detect_ms,recognize_ms,total_ms.
Changed
Section titled “Changed”- The benchmark’s peak-RSS gate now measures sceptre, not a ratio against EasyOCR. The old
floor divided EasyOCR’s max-over-batches by sceptre’s, each taken independently, so it could
publish a quotient of two measurements that never co-occurred — and because peak RSS is
dominated by the CRAFT detector both engines run, the ratio tends toward 1 as pages grow,
making it a gate on corpus image sizes. It is replaced by an absolute ceiling on sceptre’s own
warm peak RSS against the committed baseline for the same
(os, arch, runner_label); every way of failing to evaluate it is a breach that names the host, because “unmeasurable” must not read as “fine”. The cross-engine ratio survives as a reported figure, now a per-image median over correctly paired records rather than a quotient of two corpus maxima, and the published table names the statistic. Published artifacts move toschema_version2. See ADR 0042. - Models are hosted under the
xberg-ioHugging Face org. The nine ONNX repos moved fromsceptre-ocr/<model>toxberg-io/sceptre-<model>, consolidating them with the rest of the stack’s model artifacts. The exports are byte-identical and every sha256 pin is unchanged, so download verification is unaffected; the on-disk hub cache directory changes name with the repo id, so the first run after upgrading re-downloads once. Hugging Face serves redirects from the old ids, so 0.2.0–0.4.0 keep resolving models. Thesceptre-prefix keeps ADR 0011’sregistry_owneroverride a pure owner-segment swap. See ADR 0040. - Accelerator validation is a per-backend table instead of a test for
ort.orttakescoreml,directml,cuda;candletakesmetal,cuda;tractremains CPU-only.cudais shared because it names hardware rather than an execution provider, whilecoremlandmetalstay distinct — same Apple GPU, different frameworks and different numerics — and a wrong pairing is rejected with the equivalent for your backend named in the error. Amends ADR 0029; see ADR 0032. runtime_info_for(andsceptre env) no longer reports a fixedcpufor thecandlebackend. It resolves the device that would actually be opened, and reports the accelerator as undetermined when the backend is not compiled in, rather than naming a run that cannot happen.- The project moved to the
xberg-ioorganisation. The repository is nowgithub.com/xberg-io/sceptreand the documentation is served fromhttps://docs.sceptre.xberg.ioon the shared Xberg theme, replacing thegoldziher.github.io/sceptreproject path. The old repository URL redirects; the old documentation URLs do not. Crate names, the CLI binary name and the public API are unchanged. See ADR 0033. - The test fixture corpus moved to the
test_documentsgit submodule, matching thexberg/xberg-enterpriseidiom (a plain submodule plus aTEST_DOCUMENTS_DIRenv var with a repo-relative fallback), replacing the images and transcripts vendored directly intocrates/sceptre/tests/data/.task setupnow fetches the corpus (python3 test_documents/scripts/fetch_corpus.py --include 'images/**'); a missing or unfetched corpus skips the tests that need it rather than substituting a different image. See ADR 0034.
- The backend agreement tests now actually run in CI.
backend_agreement.rsno-ops withoutSCEPTRE_REQUIRE_MODELS, and that variable was set only on the parity job — so the test that ADR 0035 calls the only correctness bar for every backend/accelerator pairing was compiled, never executed, and enforced on developer machines alone.ort,tractandcandledo agree. - Benchmark reports were being discarded by their own validator.
validate_reportrange-checked every aggregate statistic against [0, 1] while passing the metric’s name, socountfailed for any metric with more than one sample; because the harness validates before writing, a complete 40-image run produced no artifact at all. Peak RSS and CPU core-seconds were also silentlynullon Linux CI, where GNUtimeis absent and the fallback said nothing. Ratio asides rendered with no decimals, so a 1.08× ratio published as “~1× lower”. - Rotated text boxes now match OpenCV’s geometry.
imageproc’smin_area_rectsnapped every rectangle corner outward with a per-cornerfloor/ceil; OpenCV’sminAreaRect/boxPoints, which EasyOCR uses, never does. Axis-aligned boxes were unaffected — they already matched bit-for-bit — but a rotated box inflated by up to ~1px per corner in heat-map space and ~2px after thex2scale-up, enough to flip borderline line merges:french.jpg’sLOUVREbox came out at slope 0.129 against cv2’s 0.096, just overslope_ths, splitting one reference line into two. Replaced with in-crate rotating calipers overimageproc’s (unaffected) convex hull, keeping corners inf64with a single final cast, and testing every hull edge including the closing one thatimageproc’swindows(2)scan omits. Line recall, precision and F1 are now 1.000 on all eight parity images (french.jpgfrom 0.833/0.625,english.pngfrom 1.000/0.923), withword_f1unchanged everywhere. See ADR 0039. widemoved off the yanked 1.6.0.- Beam-search decoding was nondeterministic: the same crop could recognize to different text
across runs of the same binary. Beam pruning and the final labeling-selection step both broke
ties over a
HashMap, whose per-instance random hash seed made iteration order — and so which tied entry asort_by/max_bylanded on — vary run to run for identical input. Fixed by a total order over(total mass, labeling)that breaks every float tie on the labeling itself, used by both the pruning sort and the final selection.
candleis not the fast backend. Measured on a local Apple Silicon machine at the default canvas (ADR 0031), its CPU path is ~10× slower thanorton the CPU and Metal ~3–4× slower. That ratio is host-dependent, not a property of the backend: the same comparison on amacos-latestrunner, where the whole machine is roughly an order of magnitude slower, narrows to ~2× and ~1× becauseort’s multithreading has fewer cores to exploit whilecandle’s kernels do not. Either way the ordering holds — choosecandlefor what it removes, the ONNX Runtime dependency and its provisioning matrix, not for speed. It is excluded from the published benchmark drift gate.candleis not a pure-Rust backend:candle-core0.10+ pullstokenizers, which compiles oniguruma from C.tractremains the pure-Rust path. The 0.11 pin is deliberate — 0.9.x miscomputes one of CRAFT’s convolutions.
Breaking
Section titled “Breaking”Acceleratorgains aMetalvariant, so exhaustive matches on it need a new arm.
0.4.0 - 2026-08-07
Section titled “0.4.0 - 2026-08-07”Changed
Section titled “Changed”cargo install sceptre-clinow produces a self-contained binary. The CLI’s default features are["ort-bundled", "download"]instead of["ort-dynamic", "download"], so the documented install path no longer requires a systemlibonnxruntime. To keep loading ONNX Runtime at runtime, install withcargo install sceptre-cli --features ort-dynamic— that override is additive and needs no--no-default-features. On targets with no prebuilt ONNX Runtime (Intel macOS, musl, armv7, riscv64, FreeBSD) the default now fails at build time; use--features ort-dynamicor--no-default-features --features tract,download.deny.tomlaudits every feature (all-features = true); previously only the default graph was checked, so theort-bundleddependency tree had never been reviewed.- The benchmark corpus is vendored into the repository instead of resolved from a Git-LFS submodule,
so tests and benchmarks no longer need a submodule checkout.
bench::load_corpus_imagenow fails loudly on a missing fixture instead of silently substituting a stand-in image, which could make a benchmark measure the wrong picture and still look healthy. The images and their transcripts are excluded from the published crate — only the Python benchmark tooling reads them.
model.accelerator/--acceleratorselects the execution provider (cpu,auto,coreml,directml,cuda), with theort-coreml,ort-directml, andort-cudafeatures. An explicitly requested provider that cannot register is a hard error rather than a silent fall back to CPU.sceptre envreports the runtime behind a result — sceptre and ONNX Runtime versions, provisioning strategy, requested and registered accelerator, architecture, and the model digests.sceptre models download --all(andmodels list --all) covers every language, so pre-seeding a cache no longer needs eight--langflags.- A
tractfeature on the CLI.--backend tractwas previously accepted but never compiled in. - CI verifies the real
cargo installpaths end to end, runs parity per execution provider, and uploads the reports it used to discard. - Published benchmark numbers are now generated, not transcribed.
task python:publishdistils a measured run into the committedbenchmarks/published/latest.json— headline figures plus the sceptre, ONNX Runtime, EasyOCR and torch versions that produced them — and regenerates the tables in the README and on the docs site from it. A report carrying no provenance block is refused rather than published, andtask python:publish:checkfails CI when the committed tables drift from the artifact. See ADR 0030.
- The cache fast path no longer returns unreadable or zero-length artifacts, and a SHA-256 mismatch now evicts the artifact and its backing blob so a corrupt download self-heals instead of being served from cache forever.
- The published speed figures were measured on an unrecorded machine with an older harness and overstated the advantage: re-measured with full provenance, sceptre is ~2.3× faster warm and ~2.4× faster cold than EasyOCR, not the ~2.8× and ~4.4× previously claimed. The peak-RSS advantage (~3×) is unchanged. Numeric claims now live only in the generated table, so the surrounding prose can no longer drift away from a measurement.
- The golden parity fixtures were regenerated and now carry the
metadataprovenance block that the format has always documented; the committed ones predated it and recorded neither the EasyOCR/torch version nor the sceptre commit behind them. Every EasyOCR reference side reproduced byte-identically.example.jsonlost five low-confidence lines and gained two, because it predated the 0.1.1 change that maderecognition.filter_thsactually filter. task python:benchmark’s flags (--group,--limit,--repeats,--baseline,--assert) reach the module instead of being silently dropped.
0.3.0 - 2026-08-04
Section titled “0.3.0 - 2026-08-04”- Filesystem-free model provisioning through
ModelDescriptor,model_descriptors,ModelArtifact::Bytes, and the SHA-256-verifyingVerifiedModelProvider. - Paired
model.detector_pathandmodel.recognizer_pathconfiguration for mobile or other host-managed local model assets. - Eager, reusable model initialization through
Reader::warm_upandReaderBuilder::build_warmed.
Changed
Section titled “Changed”ModelProvidernow resolvesModelArtifact::{Path, Bytes}instead of paths only; initialized detector and recognizer plans are serialized, cached, and release their source provider.- Browser WASM uses sequential crop and decoding loops without a Rayon worker pool, and the pure-Rust backend is aligned to tract 0.23.4.
0.2.0 - 2026-08-03
Section titled “0.2.0 - 2026-08-03”- Telugu (
telugu_g2) and Kannada (kannada_g2) gen2 recognizers, completing EasyOCR’s full eight-model gen2 recognizer family. Select them with--lang telugu/--lang kannada. - A first-party
.pth -> ONNXmodel export pipeline (sceptre_rs_tools.export) that converts EasyOCR’s CRAFT and gen2 CRNN weights to the ONNX artifacts the library loads. - Full pure-Rust
tractpipeline: the recognizers run undertract(the export replaces gen2’sAdaptiveAvgPoolwith an equivalentReduceMean, which upstream exports lack), and CRAFT runs on a fixed square canvas undertract, with a cross-backend test assertingtractrecognizes the same words asort.
Changed
Section titled “Changed”- Model source is now first-party: the registry points at the
sceptre-ocrHugging Face org instead ofitextresearch, with fresh sha256 pins for our own exports.
0.1.1 - 2026-08-03
Section titled “0.1.1 - 2026-08-03”- Exposed the crate version as
sceptre::VERSIONfor adapters and diagnostics.
- Detection and recognition preprocessing now borrow their source pixels, copy axis-aligned crops by row, reuse immutable recognizer/CTC state, and bypass parallel dispatch for singleton batches, reducing warm-path allocations and scheduling overhead without changing OCR output.
- Each
Readernow owns its configured Rayon worker pool instead of attempting to initialize the process-global pool, so readers with different thread budgets remain isolated and embedding Sceptre cannot override an application’s Rayon configuration. recognition.filter_thsnow validates its range and removes recognition results below the configured confidence threshold instead of being accepted without affecting output; its default is now0.1, which improves the measured DocLayNet and TextOCR quality without regressing the receipt and scanned-PDF fixtures.- The Hugging Face cache root now falls back to
%USERPROFILE%when$HOMEis unset, sosceptre modelsworks on Windows instead of failing to locate the cache.
0.1.0 - 2026-08-01
Section titled “0.1.0 - 2026-08-01”- CRAFT text detection and gen2 CRNN recognition with CTC decoding, running over ONNX — a from-scratch Rust reimplementation of EasyOCR’s pipeline.
- Validated EasyOCR parity across six scripts: English, Latin, Chinese (simplified), Japanese, Korean, and Cyrillic.
- Two inference backends behind one seam: native ONNX Runtime (
ort) and pure-Rusttractfor WASM / Android targets. - Rust library API centered on a single
Readerhandle, configured through a backend-agnosticOcrConfig. sceptreCLI withrun,detect,models,mcp, andcompletionssubcommands, including multi-image batch mode (models load once) and multi-language recognition.- MCP server exposing a
readtexttool for agent integrations. - Offline-first model provisioning: models download from Hugging Face on first use, cache locally, and are sha256-verified on download — every run thereafter reads the cache with no network.