Perception with no visible light, built on CPU-verifiable math and one hard number we will not soften: today's real-data pilot ran on 13x fewer frames than the repository's own floor for calling a pretraining run meaningful.
0.40s)thermalcore.cli selfcheck --seed 0 and
thermalcore.cli demo --seed 0.99 passed in 0.40s; selfcheck --seed 0 printed
"passed": true with probe.separable_accuracy: 0.94;
demo --outdir ... --seed 0 printed hotspot_vs_ground_truth precision
1.0, recall 1.0 (tp 4, fp 0), and walker track path length 4.246 m at 1.062 m/s, all matching
the repository's own documented numbers exactly.ssl.random_mask) over the one sample frame, in your browser, with no model
involved. It does not reconstruct anything and is not a demonstration of a trained model,
because none exists yet.Picture a fixed camera watching a loading dock at 2 a.m., or a corridor filling with smoke during an incident, or a yard sitting under thick fog. A visible-light camera is nearly useless in all three cases: there is not enough reflected light for it to form an image, and smoke scatters what little light exists. A thermal (infrared) sensor keeps working, because it is not imaging reflected light at all, it is imaging emitted heat. A person, a running engine, a fire, all keep radiating in the long-wave infrared band regardless of ambient light. That is the entire case for thermal perception: it is the only sensor modality that stays useful exactly when darkness, smoke, or fog take the others offline.
The cost is that thermal sensors are not "RGB but darker." A microbolometer's raw output is a 16-bit radiometric count array, and a real scene at night typically fills only a narrow slice of that sensor's full dynamic range, our own synthetic generator's default scene spans roughly 14,000 to 43,000 counts out of a 16-bit sensor's full 0 to 65,535 span. Which normalization squeezes that slice into a model's expected 0 to 1 input, naive global min-max stretch, a percentile clip, plateau-limited histogram equalization, tiled CLAHE, is not a cosmetic preprocessing step. It decides how much real scene contrast a downstream model ever sees, and whether two different camera units even produce visually comparable frames for the same scene. The section below, "How it works," shows the actual pixel-level difference this choice makes on one frame.
Layered on top of that is a data problem, not just a preprocessing problem: there is vastly less labeled thermal training data in the world than labeled RGB training data, by orders of magnitude. The two largest thermal-specific public annotation sets we could locate without an interactive registration gate, FLIR ADAS v2 and KAIST Multispectral Pedestrian, together offer roughly 110,000 to 120,000 usable real thermal frames, itself still short of what our own pretraining recipe's floor requires (see "Measured results" below). The practical consequence for a buyer: a team under deadline pressure often takes an RGB-pretrained perception model and simply runs it on thermal frames after some ad hoc contrast stretch. That model was never shown thermal statistics during pretraining, has no reason to have learned what a genuine thermal hot spot looks like versus a normalization artifact, and can produce confident, wrong detections that look plausible to an operator who has no easy way to tell the difference. This product exists to close that specific gap honestly, by building the normalization-aware, thermal-native pretraining groundwork first, and by refusing to claim a trained model exists before one actually does.
The failure mode is not exotic, it is mundane and therefore easy to miss in a demo. An RGB-pretrained backbone has never seen a frame where absolute pixel brightness correlates with which physical camera captured it rather than with scene content, because RGB cameras are broadly white-balanced and exposure-corrected before a model ever sees them. Thermal sensors are not: two cameras of the same model, on the same wall, at the same moment, can report meaningfully different absolute counts for the same physical temperature because of gain drift, ambient calibration offset, and column fixed-pattern noise. A model that was never trained to be indifferent to that will quietly fold "which camera" into what it thinks is "how hot," and there is no way for an operator watching a dashboard to see that failure mode from the outside, it looks exactly like a normal detection until the numbers are checked against ground truth. That is precisely why this product's research half spends its effort on per-patch target normalization and cross-sensor quantile matching before it spends a single GPU-hour on a backbone: the fix has to happen in the data and pretraining objective, not be patched in afterward.
thermalcore is two halves of one product. The perception engine is
the shipped half: a standalone, market-agnostic engine that takes any detector's outputs (boxes,
tracks, classes, as JSON, schema tp/1) and returns thermal geometry metrics, native
thermal detections, and plain-language explanations, no GPU and no training required to run it.
The research half is the complete non-GPU groundwork for pretraining a
thermal-native backbone: the radiometric data engine, the self-supervised (masked autoencoding)
pretraining harness with its math CPU-verified, and the evaluation protocols the eventual backbone
and depth models will be judged by. It is explicitly not a trained model: there is no checkpoint
in this repository and no claim that one exists. The GPU run is emitted as a costed recipe
(thermalcore emit-recipe), the exact training configuration a torch loop gets written
against once compute is connected, not shipped as weights.
Input is one JSON envelope, schema tp/1, from any detector: image dimensions,
camera mount height and pitch, a radiometry mode, one or more frames, and a list of detections
(box, class, confidence, track id, timestamp). Output is one tp/1 artifact with five
channels, each degrading gracefully when its inputs are missing:
--explainer ollama mode rewrites the
same facts with a local qwen3.5:2b-q4_K_M model via Ollama's native
/api/chat endpoint with think: false, falling back to the template text
on any failure, the artifact's own engine field discloses which one produced a given
explanation.minmax, percentile,
plateau (the FLIR-style default), and tile_clahe, all pure numpy, all
returning NaN-free float32 in [0, 1]. The corpus manifest records which normalizer built each
frame, because the choice is a corpus-level hyperparameter, not a preprocessing detail that can
be swapped after the fact.round(mask_ratio *
n_patches) patches by construction (with an optional activity-weighted variant that biases
which patches stay visible toward warm structure), per-patch target normalization stops the
backbone from learning absolute sensor brightness as content, and a masked-reconstruction loss
scores only the hidden patches. PretrainRecipe.min_corpus_frames = 200,000 is a
hard-coded floor: below it, the documented, honest move is distilling from an RGB teacher instead
of pretraining a thermal backbone from scratch.build-corpus (index raw frames into a
deduped, bucketed corpus), emit-recipe (write the GPU-phase pretraining recipe JSON
for a corpus), and selfcheck (the "is this install sane" command used throughout
this page).The engine has exactly one input coupling: the tp/1 JSON envelope. If a detector
can emit boxes, it can feed this engine, no SDK, no imports, no platform dependency. The most
common integration is an Ultralytics-style (YOLO family) detector: anything that yields pixel
boxes plus class names and confidences converts directly.
import json
def yolo_to_tp1(results, camera, out_path, fps=30.0):
"""results: iterable of per-frame ultralytics Results objects."""
detections = []
for frame_idx, r in enumerate(results):
for i, box in enumerate(r.boxes):
detections.append({
"id": f"{frame_idx}-{i}",
"bbox": [float(v) for v in box.xyxy[0]],
"class_name": r.names[int(box.cls)],
"confidence": float(box.conf),
"frame_id": f"f{frame_idx}",
"timestamp": frame_idx / fps,
"track_id": str(int(box.id)) if box.id is not None else None,
})
envelope = {"schema": "tp/1", "source": "yolo", "camera": camera,
"detections": detections}
with open(out_path, "w") as f:
json.dump(envelope, f)
The camera dict is the one piece of information a detector does not have on its own, it
describes the mount, not the model: image width and height, horizontal field of view from the
lens datasheet, mount height in meters from a tape measure, and pitch in degrees from an
inclinometer or an installer app. If a deployment also captures raw thermal matrices (16-bit
counts, kelvin, or celsius, in .npy, .csv, or .pgm), the same
envelope references them per frame and the engine additionally returns per-box temperature stats
and its own native hotspot detections. With no detector at all, the native hotspot pass needs
nothing but a frame:
thermalcore detect --frame f0.npy --radiometry counts_linear --percentile 99
The output is a single JSON document (schema tp/1) with six
keys that matter to integrators: geometry, tracks, thermal,
native_detections, explanations, warnings. Absent or null
fields mean "the engine could not solve this," each carries a human-readable entry in
warnings explaining why (above-horizon foot, missing timestamps, bad calibration,
unreadable frame); the engine never fails an entire envelope because one channel lacked inputs.
Source: docs/INTEGRATION.md.
The diagram below traces one frame from raw sensor output to a disclosed result on this page. Scroll horizontally on narrow screens; the boxes and arrows do not reflow, they stay legible and you pan across them.
Two hands-on pieces below. The first shows the exact pixel-level effect of the AGC choice described above on one real synthetic frame from this session. The second is a browser-only toy that visualizes the masking algorithm the pretraining harness uses, with no model behind it.
The self-check confirms all four AGC methods in agc.py run correctly; this section
shows what they actually do to a frame and why the choice is not cosmetic. Same synthetic scene
(seed 2, 4 warm bodies, 1 vehicle, ambient 285 K, the repository's default 4 hot pixels), run this
session through two of the four normalizers with no other change:
agc.minmax (naive global stretch)
Output standard deviation , only pixel above the 0.95 brightness level. The single coldest and single hottest raw pixel define the whole output range, so ordinary scene contrast gets compressed into a narrow band.
agc.plateau (FLIR-style default)
Output standard deviation , pixels above the 0.95 brightness level. Capping how much any one histogram bin can dominate the output range recovers scene contrast, at the honest cost of also making the column fixed-pattern noise more visible.
Frame index 2 of the 30-frame corpus (seed 2): 4 warm bodies and 1 vehicle, ambient 285 K, NETD 0.06 K. Rendered here with a false-color thermal colormap for display; the underlying data is a 240x320 uint16 radiometric array. Green boxes are the committed ground-truth warm-body boxes.
agc.minmax normalizationagc.percentile normalizationagc.plateau normalizationagc.tile_clahe normalizationssl.zero_loss_on_perfect_reconstructionprobe.separable_accuracy (linear-probe sanity check on synthetic classes)selfcheck, CPU-only, 99-test suite greenReproduce with
PYTHONPATH=src .venv/bin/python -m thermalcore.cli selfcheck --seed 0.
ssl.random_mask concrete,
not to demonstrate model quality. No checkpoint exists for this product yet.
Algorithm mirrored from src/thermalcore/ssl.py's
random_mask: exactly round(ratio * n_patches) patches chosen uniformly
at random without replacement, seeded. The real harness additionally supports activity-weighted
masking (biasing which patches stay visible toward busier regions); this toy uses uniform
masking for clarity.
"99 tests, all green, no GPU required" is easy to say and easy to leave vague. Here is the
actual breakdown by file, collected fresh this session via
pytest tests/ --collect-only -q, so the number is not just a total to trust, it is a
map of what is actually under test:
| test file | tests | covers |
|---|---|---|
test_schema_robustness.py | 18 | malformed / partial tp/1 envelopes degrade gracefully instead of crashing |
test_thermal_core.py | 16 | AGC normalizers, radiometry spec conversion, synthetic scene generation |
test_geometry_tracks.py | 16 | pinhole/flat-ground geometry, true-projection round trips, track kinematics |
test_engine_explain.py | 15 | plain-language explanation templates and the Ollama explainer fallback path |
test_thermal_detection.py | 14 | the native hotspot detector: thresholding, connected components, core-area filtering |
test_corpus_ssl.py | 10 | corpus dedup/bucketing, MAE masking math, PretrainRecipe JSON emission |
test_evalx_cli.py | 10 | the closed-form linear probe, depth metrics, COCO-thermal adapter, CLI entry points |
| total | 99 | all CPU-only, no GPU or network required |
thermalcore.cli demo --outdir demo_out --seed 0 is the product demo and its own
referee: it synthesizes ground truth, plays "any detector" by serializing those ground-truth boxes
as a tp/1 envelope, adds a true-projected walking track (foot and head each projected
through the full camera model, so the recovered numbers are a genuine round trip, not
hand-authored pixels), runs the full engine, then scores the engine's own hotspot pass against
ground truth. Rerun fresh on 2026-07-09, this session, on a plain Mac CPU:
| metric | value |
|---|---|
| hotspot detector precision / recall | 1.0 / 1.0 (tp 4, fp 0, 4 ground-truth warm bodies) |
| walker track path length | 4.246 m |
| walker track average speed | 1.062 m/s |
| walker track heading | 90.0 degrees |
| on-axis geometry round trip | 0.500 m wide x 1.750 m tall at (0.0, 7.0), exact to the millimeter |
| off-axis residual (+-2.0 m ground truth) | reads +-2.123 m, height 1.712 m (explainable image-shift residual) |
| track speed vs. true speed | reads 1.062 m/s against a true 1.0 m/s |
| sample plain-language explanation | "A person moved 4 m over 4 s, averaging 3.8 km/h. It was heading to the right." |
passed | true |
Zero-training native detector (hotspot.py): threshold plus
union-find connected-component labeling, not a learned model. This is not a claim about a trained
foundation model's accuracy, only that the shipped, non-learned detector and geometry pipeline
work correctly against synthetic ground truth. Height uses a box-top-ray x vertical-line
estimator; the naive foot-depth similar-triangle shortcut was measurably biased (-24% at 5 m to
+14% at 40 m on a 6 m / 35 degree mount) and was replaced after a true-projection sweep exposed it.
Source: README.md "Proof run" section and demo_out/report.json.
No GPU was available in this session, so the numbers in this subsection are reported as
committed document statements from TRAINING_RESULTS.md and
REAL_DATA_RESULTS.md in the private codebase, not reproduced here. Two short pilots of
scripts/train_mae.py and scripts/train_mae_real.py ran on a rented RTX
5060 Ti: one on the repository's own synthetic generator, one on a real public infrared dataset
(LLVIP). Both are explicitly labeled training-mechanics checks, not evidence of learned-feature
quality, and both sit far below the pretraining corpus floor.
| pilot | frames | wall-clock | steps | throughput | peak VRAM | loss (start to end) |
|---|---|---|---|---|---|---|
| synthetic data | 2,503 (kept, deduped from 4,096 generated) | 32 min (1,920.0 s) | 9,815 | ~1,306 img/s (peak 1,424) | 4,875 MB | 0.9226 to 0.6902 (~25% reduction) |
| LLVIP real infrared | 15,488 (all kept, dedup off, see caveat below) | 40 min (2,400.2 s) | 12,974 | ~1,381.7 img/s (peak 1,440.8) | 4,874.6 MB | 1.0022 to 0.6438 (~33% reduction) |
PretrainRecipe.min_corpus_frames = 200,000 floor: the synthetic pilot is
about 80x below that floor, the real LLVIP pilot is about 13x
below it. Neither pilot is evidence of learned-feature quality; both are trainer-mechanics
checks, exactly as labeled in the source documents. This is the single most important caveat on
this entire page and it is not softened anywhere below.
| hyperparameter | value |
|---|---|
| mask_ratio | 0.75 |
| target normalization | per-patch z-score, ddof=0 |
| activity-weighted masking | on |
| optimizer | AdamW, betas (0.9, 0.95), weight_decay 0.05 |
| batch size | 256 (recipe default is 1,024; batch 1,024 OOMs the MAE decoder's full self-attention over 197 tokens) |
| base_lr to effective LR | 1.5e-4 to 1.5e-4 (linear batch scaling, effective batch 256) |
| warmup | 5% of planned steps (scheduler-probed) |
| LR schedule | cosine decay to 0 after warmup |
| drop_path | 0.1, linear across 12 encoder blocks (above recipe default, regularization biased up for a small corpus) |
| patch embed | Conv2d(in_chans=1), 224x224 input, patch 16 |
| augmentation | RandomResizedCrop(scale 0.2 to 1.0) plus horizontal flip only |
| architecture | ViT-S/16 encoder (embed 384, depth 12, heads 6) plus decoder (dim 256, depth 4, heads 8), 24.7M params |
Source: TRAINING_RESULTS.md and REAL_DATA_RESULTS.md,
"Final hyperparameters" / "Hyperparameters" tables, both explicitly cross-referenced against the
repository's own PretrainRecipe dataclass defaults in src/thermalcore/ssl.py.
The LLVIP pilot used huggingface_hub.hf_hub_download to fetch a public, ungated
mirror of the dataset's infrared half only (12,025 train plus 3,463 test JPEGs, 15,488 total,
1280x1024, near-grayscale LWIR frames), after three other candidate sources were checked and
rejected (one turned out to be visible-light images only, one was a 551-pair sample far too small,
one at ~4,200 pairs was still under the requested band). FLIR ADAS v2 and KAIST Multispectral were
both confirmed login/registration gated and excluded per the task's own instruction to avoid them.
| step | value |
|---|---|
| frames extracted from LLVIP.zip's infrared/ folder | 15,488 JPEGs, ~1.4 GB on disk before resize |
| corpus build (dedup off, resized to 256x256) | 15,488 seen, 15,488 kept, 0 dropped, in 158.1 s |
| activity buckets | empty 12,165 / sparse 3,285 / busy 38 |
| final corpus size on disk | 2.0 GB |
| input-normalization stats, real (8-bit source) | mean 74.67, std 47.40 (sampled 512 frames) |
| input-normalization stats, synthetic (16-bit source) | mean approximately 16,389, std approximately 5,023 (sampled 512 frames) |
Source: REAL_DATA_RESULTS.md, "Corpus build (measured)"
section, and TRAINING_RESULTS.md, "Data source: synthetic" section.
The repository's perceptual-hash dedup exists to drop literal duplicate frames from a continuous fixed-camera stream. On a 600-frame LLVIP sample, it was measured directly before the decision was made:
| hamming threshold | kept | kept % |
|---|---|---|
| <=4 (repository default) | 29 / 600 | 4.8% |
| <=2 | 92 / 600 | 15.3% |
| <=1 | 161 / 600 | 26.8% |
| <=0 (exact hash match only) | 262 / 600 | 43.7% |
Even exact hash collisions discarded 56% of a real, visually diverse
sample: the 8x8 average-hash's 64-bit resolution is too coarse for near-static night thermal
scenes, where a walking pedestrian barely moves the average of an already low-contrast frame. This
was judged a hash-resolution artifact, not genuine frame redundancy, and LLVIP is already a
hand-curated academic selection across 26 real-world sequences, not raw continuous camera footage,
so the repository's own "corpus value is variety, not volume" dedup rationale does not transfer
cleanly to it. train_mae_real.py therefore defaults dedup off for this dataset as an
explicit, stated flag, not a silent change to the dedup code itself. Result: 0 of 15,488 frames
dropped, all kept. Source: REAL_DATA_RESULTS.md, honest caveat 2.
LLVIP (Jia, Chen, Zhu, Zheng, Wu, "LLVIP: A Visible-infrared Paired Dataset for Low-light
Vision," ICCV 2021 Workshops) is a real visible/infrared paired pedestrian dataset captured on
real streets and campus in low light, 15,488 genuine LWIR frames used here (infrared half only).
This is the complete, un-truncated masked-patch MSE loss log from that pilot, every point recorded
in REAL_DATA_RESULTS.md's loss trajectory table, not start and end numbers alone.
Hover or tap a point in the chart for its exact step, elapsed time, and learning rate.
| step | elapsed | loss | LR | phase |
|---|---|---|---|---|
| 40 | 4 s | 1.0022 | 1.3e-05 | warmup |
| 460 | 82 s | 0.9484 | 1.5e-04 | peak LR, warmup ends near step 446 |
| 1,640 | 302 s | 0.8400 | 1.4e-04 | cosine body |
| 3,260 | 601 s | 0.7266 | 1.1e-04 | cosine body |
| 4,880 | 902 s | 0.6976 | 7.0e-05 | cosine body |
| 6,500 | 1,202 s | 0.6709 | 2.8e-05 | cosine body |
| 8,120 | 1,501 s | 0.6563 | 3.4e-06 | approaching LR floor |
| 8,500 | 1,571 s | 0.6509 | 0.0 | LR floor reached |
| 9,740 | 1,802 s | 0.6539 | 0.0 | LR-floor coast |
| 11,360 | 2,102 s | 0.6452 | 0.0 | LR-floor coast |
| 12,960 | 2,397 s | 0.6438 | 0.0 | LR-floor coast, final logged point |
Minimum logged loss 0.6351 at step 11,040; mean of the first 20 logged
points 0.977 versus mean of the last 20 points 0.653. Read honestly: the loss decreased
meaningfully, about 0.98 to about 0.65 (a roughly 33% reduction), with the bulk of the descent in
the first ~5,000 steps, then a long flattening tail once the cosine schedule reached its floor
around step 8,500. This is a healthy, real MAE reconstruction-loss curve on genuine thermal sensor
data; it is still not evidence of learned-feature quality, 15,488 real frames remains far below the
repository's own 200,000-frame floor. Source: REAL_DATA_RESULTS.md loss trajectory
table, LLVIP real-infrared MAE pretraining pilot, RTX 5060 Ti, commit bcc1ef2 of the private
codebase (train_mae_real.py).
The first pilot, run before the LLVIP pilot, used the repository's own synthetic generator because FLIR ADAS v2 and KAIST Multispectral were both registration-gated. Its loss curve, in full, for direct comparison against the real-data curve above:
| step | elapsed | loss | LR | phase |
|---|---|---|---|---|
| 30 | 2 s | 0.9226 | 1.3e-05 | warmup |
| 500 | 93 s | 0.7818 | 1.5e-04 | peak LR, warmup done at ~324 |
| 1,000 | 193 s | 0.7396 | 1.5e-04 | cosine body |
| 2,000 | 390 s | 0.7365 | 1.2e-04 | cosine body |
| 3,000 | 586 s | 0.7241 | 9.1e-05 | cosine body |
| 4,000 | 782 s | 0.6965 | 5.3e-05 | cosine body |
| 6,000 | 1,172 s | 0.6883 | 2.4e-06 | approaching LR floor |
| 8,000 | 1,565 s | 0.6974 | 0.0 | LR-floor coast |
| 9,800 | 1,917 s | 0.6902 | 0.0 | LR-floor coast, final logged point |
Minimum logged loss 0.6682 at step 8,620; mean of the first 20 logged
points 0.918 versus mean of the last 20 points 0.687, a roughly 25% reduction. A schedule caveat
applies here: the startup throughput probe under-counted steps per second (first-run CUDA kernel
compilation inflated the probe time), so the planned total step count was an underestimate and the
cosine LR reached its floor before the 32-minute wall-clock cap, so the final portion of the run
coasted at near-zero LR. All reported numbers are real measurements from the actual run. The two
pilots' loss levels are not directly comparable to each other, different data distributions and
different corpus sizes, both simply confirm the trainer descends a real loss on the data it is
given. Source: TRAINING_RESULTS.md loss trajectory table.
The recipe the two pilots above trained a small slice of is fully specified in
src/thermalcore/ssl.py's PretrainRecipe dataclass: vit_small_patch16
architecture, 224x224 input, patch size 16 (196 patches per image), mask ratio 0.75 (only 49 of 196
patches reach the encoder), activity-weighted masking on, per-patch target normalization on,
plateau normalization baked into corpus selection, 400 epochs, batch size 1,024, base
LR 1.5e-4, 20-epoch warmup, weight decay 0.05, and the min_corpus_frames = 200,000 floor
this whole page keeps returning to. docs/COMPUTE_BUDGET.md concretizes that recipe
into an actual compute ask, arithmetic derived from the recipe's own tested defaults, not a
guess:
| dataset | usable thermal frames | resolution | access | fit |
|---|---|---|---|---|
| FLIR ADAS v2 | ~17,000 to 26,000 (of 26,442 annotated frames, 15 classes, ~520,000 boxes) | 640x512 | free, email/company registration + click-through terms, no approval step | MSCOCO-format annotations load as-is into evalx.read_coco_thermal, no adapter needed |
| KAIST Multispectral Pedestrian | 95,000 (of 95,000 aligned color-thermal pairs, 20 Hz vehicle rig, 103,128 annotations, CVPR 2015) | 640x480 | CC BY-NC-SA 4.0 / BSD-2-Clause, direct download, no approval gate | camera-ISP-processed 8-bit frames, not calibrated radiometric counts; annotations need a small format adapter before detection_pr can score them |
| combined (FLIR + KAIST) | ~112,000 to 121,000 | mixed | both gated behind registration, neither a plain download | still below min_corpus_frames = 200,000; this is the actual bottleneck, not GPU time |
LLVIP's 15,488 real infrared frames (used in the pilot above) were added afterward specifically because they needed no login gate at all; they still sit below the floor too, so the gap this table describes remains open, the LLVIP pilot demonstrates the trainer on real sensor data, not a corpus-scale fix.
No published single-GPU throughput benchmark exists for MAE at ViT-S/16 with 75% masking, so the throughput estimate is derived, not measured: anchored to Meta's own published MAE/ViT-Large run (64 V100s, 42 hours, ImageNet-1k), scaled down to ViT-S/16 by GFLOPs ratio, then scaled from V100 to RTX 4090 by generational throughput improvement, landing at an estimated 1,700 to 4,200 images/sec on a single RTX 4090. This is flagged in the source document as a derived estimate, not a citation, to be corrected with a short timing pilot before committing a full job.
| scenario | frames x epochs | GPU-hours | $ at $0.35/hr | $ at $0.50/hr |
|---|---|---|---|---|
| A, recipe-literal (200,000 frames, 400 epochs, if a real 200k corpus existed) | 80,000,000 | 5.3 to 13.1 | $1.86 to $4.59 | $2.65 to $6.55 |
| B, today's real corpus (112,000 to 121,000 frames, 400 epochs) | ~44.8M to 48.4M | 3.2 to 7.3 | $1.12 to $2.56 | $1.60 to $3.65 |
| B+margin, today's corpus at 800 epochs (partial offset for missing frames, not more data) | ~89.6M to 96.8M | 6.4 to 14.6 | $2.24 to $5.11 | $3.20 to $7.30 |
All three scenarios land under $8, well below a $15 to $40 ballpark. Scenario B+margin repeats the smaller corpus more times to partially match the recipe's total gradient-step count, it does not manufacture new content (the corpus dedup already collapses near-identical frames), so it is framed honestly as "more optimizer steps on the same data," not "more data." Estimated VRAM at batch 1,024 in mixed precision: roughly 540 MB for the ~30M-param model plus AdamW optimizer state, plus roughly 5.6 GB of activations, totaling an estimated 8 to 14 GB, comfortably inside a 24 GB card.
The repository does not yet define a pass/fail number for real
downstream transfer; the only existing bar, selfcheck's
probe.accuracy > 0.9, is a trivially separable synthetic install sanity check, not a
real-data performance threshold. The stated honest bar for a first real GPU run: the frozen-probe
accuracy and detection precision/recall from the SSL-pretrained encoder must beat the same
protocol run on a random-init (or ImageNet-supervised-init, the harder baseline) encoder of
identical architecture, trained under identical conditions. A win would be comparative, not
absolute, because no absolute number exists yet to compare against, declaring one now, before a
single real checkpoint exists, would be grading the recipe's own homework. Source:
docs/COMPUTE_BUDGET.md, sections "ASK," "2. Target public datasets," "3. GPU-hours +
VRAM," and "4. Downstream eval protocol," arithmetic individually asserted in
tests/test_corpus_ssl.py::test_recipe_emits_complete_json.
Every command below is the actual command that produced a number quoted somewhere on this page, not a simplified stand-in. CPU commands ran fresh this session; GPU commands are quoted as documented in the private codebase and were not rerun (no GPU was available in this session).
| what it produces | command | ran fresh this session |
|---|---|---|
| the 99-test suite result | PYTHONPATH=src .venv/bin/python -m pytest tests/ -q | yes, 99 passed in 0.40s |
| the self-check panel | PYTHONPATH=src .venv/bin/python -m thermalcore.cli selfcheck --seed 0 | yes |
| the scored proof run | thermalcore demo --outdir demo_out --seed 0 | yes |
| the before/after AGC panel | thermalcore.synth.generate_scene(seed=2, n_people=4, n_vehicles=1) then agc.minmax() / agc.plateau() on the same raw array | yes |
| fetching the LLVIP corpus | hf_hub_download(repo_id="jsonhash/LLVIP", repo_type="dataset", filename="LLVIP.zip"), then unzip 'LLVIP.zip' 'LLVIP/infrared/*' | no, documented in REAL_DATA_RESULTS.md |
| the synthetic GPU pilot loss curve | python scripts/train_mae.py --corpus-root ~/corpus --n-corpus-frames 4096 --batch-size 256 --base-lr 1.5e-4 --weight-decay 0.05 --drop-path 0.1 --mask-ratio 0.75 --max-minutes 32 --warmup-frac 0.05 | no, documented in TRAINING_RESULTS.md |
| the real LLVIP GPU pilot loss curve | python scripts/train_mae_real.py --image-dir /path/to/LLVIP/infrared --glob "*/*.jpg" --corpus-root ~/a5real-work/corpus --dedup-hamming -1 --store-size 256 --batch-size 256 --base-lr 1.5e-4 --weight-decay 0.05 --drop-path 0.1 --mask-ratio 0.75 --max-minutes 40 --warmup-frac 0.05 | no, documented in REAL_DATA_RESULTS.md |
| the GPU compute budget arithmetic | tests/test_corpus_ssl.py::test_recipe_emits_complete_json (asserts each PretrainRecipe field); docs/COMPUTE_BUDGET.md shows the derivation | the recipe fields are tested; the GPU-hour/dollar arithmetic is a documented derivation, not a code assertion |
| indexing your own frames into a corpus | thermalcore build-corpus --frames ./raw_frames --output ./corpus --sensor-id cam1 --normalization plateau | command available, not run against real footage this session |
| emitting the GPU-phase recipe for a corpus | thermalcore emit-recipe --corpus ./corpus | command available, not run against a real corpus this session |
PretrainRecipe.min_corpus_frames = 200,000 is a hard-coded threshold; the synthetic
pilot used 2,503 frames (about 80x below the floor) and the real LLVIP pilot used 15,488 frames
(about 13x below the floor). Even combining the two largest ungated-enough public thermal
annotation sets we identified, FLIR ADAS v2 and KAIST Multispectral, yields only roughly 110,000
to 120,000 usable frames, still short of the floor. Both GPU pilots on record are explicitly
labeled mechanics checks, not quality evidence, in their own source documents.thermalcore emit-recipe)
with its compute costed line by line, not shipped as weights. There is no model repo on the Hub
to point a buyer to, and none of the numbers on this page should be read as a stand-in for one.hotspot.py) is a zero-training
threshold and connected-component method, not a learned model. Its precision/recall numbers on
this page describe engine correctness against synthetic ground truth, not learned detection
accuracy on real scenes.Two different things, and this page is careful to keep them separate. The CPU-verified math (normalization, masking, corpus tooling, geometry, evaluation protocols, the 99-test suite, the scored proof run) runs against the repository's own physics-flavored synthetic radiometric generator, not real camera footage. The one real-sensor training pilot used LLVIP, a public academic visible/infrared paired pedestrian dataset (Jia et al., ICCV 2021 Workshops), infrared half only, 15,488 genuine long-wave-infrared night-scene frames captured on real streets and campus. Both are disclosed by name everywhere they are used on this page.
Both, and the split matters. Every claim about the perception engine's correctness (geometry, tracking, the hotspot detector, the scored demo) is verified against synthetic ground truth generated by the repository's own physics model, because ground truth for real footage is expensive and easy to get subtly wrong. The one GPU pretraining pilot on real sensor data used LLVIP, a genuine infrared dataset, not a simulation; that pilot's loss curve is a real measurement, but it is a training-mechanics check, not a claim about detection quality on real scenes.
The perception engine (geometry, tracking, native hotspot detection, explanations) accepts any
detector's output as a tp/1 JSON envelope and any 16-bit radiometric frame through a
configurable radiometry spec, so integrating a real camera and a real detector is supported today,
see docs/INTEGRATION.md. What is not available today is a pretrained thermal-native
backbone checkpoint, because none has been trained at the scale the repository's own floor
requires. If your use case needs a learned thermal backbone rather than the zero-training native
detector, that is exactly the gap the GPU-phase recipe above is costed to close, not something
this page can hand you as a file today.
This product's code is proprietary and not publicly released; the evidence, datasets, and numbers on this page are the public artifacts. Contact dhi-tech.com for access or a partnership inquiry.
Because a 16-bit thermal sensor's raw output typically fills only a narrow slice of its full range in a real scene (this session's default synthetic frame spans roughly 14,000 to 43,000 counts out of a possible 0 to 65,535), and which function maps that slice into a model's expected 0 to 1 input directly controls how much real scene contrast the model ever sees. The before/after panel above shows this is not theoretical: naive global min-max stretch on one real frame from this session produced an output standard deviation of 0.047 with only 1 pixel above the 0.95 brightness level, while plateau-limited histogram equalization on the identical raw frame produced a standard deviation of 0.254 with 2,744 pixels above that level. Two models trained on frames normalized two different ways are effectively trained on two different modalities, which is why the corpus manifest records the normalizer used per frame as a first-class field, not metadata.
It means every number on this page traces to either CPU-verified math tested against known physics, or to a small, explicitly time-boxed and disclosed GPU pilot, and none of it is a substitute for a trained, benchmarked thermal foundation model, because that model does not exist yet, here or (to our knowledge) anywhere at this scale. A buyer evaluating this product today is buying the pretraining groundwork, the evaluation protocols, the zero-training perception engine, and a costed, ready-to-execute recipe for the GPU phase, not a benchmark score for a shipped model. If you need a headline accuracy number today, this product does not have one to give you, and we would rather tell you that than invent one.