Dhi Labs, product A5

Thermal Perception: honest about the sensor gap

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.

99 / 99
tests passing, fresh this session (0.40s)
1.0 / 1.0
precision / recall, scored proof run vs. ground truth
13x below floor
15,488 real LLVIP frames vs. the 200,000-frame pretraining floor
0
trained checkpoints shipped, by design
Read this before any number below
No fabricated benchmark No trained checkpoint GPU run as a costed recipe Data floor stated, not hidden

The problem: what thermal buys you, and what it costs

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.

How it works: normalization-aware pretraining, honestly staged

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.

Perception engine input and output

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:

Research half: the pretraining groundwork

Integrating a real detector

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 pipeline, end to end

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.

Raw thermalsensor frame 16-bit radiometric counts,microbolometer noise AGC normalizationchoice min-max vs. plateau-limited(vs. percentile, tile CLAHE) Masked-pretrainingencoder MAE math, CPU-verified,no GPU checkpoint yet Downstreamperception head hotspot / geometry / tracks /explanations (tp/1) Scored end-to-endproof run engine scores its ownoutput vs. ground truth Disclosedreal-data results LLVIP loss curve,GPU pilots, caveats

The interactive demo

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.

Before and after AGC: why the normalization choice matters

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:

Before: agc.minmax (naive global stretch) The same thermal frame stretched by naive global minmax, looking flat and low contrast

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.

After: agc.plateau (FLIR-style default) The same thermal frame after plateau-limited histogram equalization, showing much more visible contrast and structure

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.

A sample frame and what the self-check verified

A sample synthetic radiometric frame A synthetic thermal frame rendered with an ironbow-style colormap, with green ground-truth boxes around warm bodies

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.

What the self-check actually verified, run fresh 2026-07-09

Reproduce with PYTHONPATH=src .venv/bin/python -m thermalcore.cli selfcheck --seed 0.

Illustrative toy: what masked pretraining hides from the encoder

This is an illustrative reconstruction of the masking algorithm only. No trained model runs here and no reconstruction happens; it exists to make ssl.random_mask concrete, not to demonstrate model quality. No checkpoint exists for this product yet.
Sample thermal frame with a patch mask overlay
total patches
masked (hidden from encoder)
visible (kept)

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.

Measured results

What the 99-test suite actually checks

"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 filetestscovers
test_schema_robustness.py18malformed / partial tp/1 envelopes degrade gracefully instead of crashing
test_thermal_core.py16AGC normalizers, radiometry spec conversion, synthetic scene generation
test_geometry_tracks.py16pinhole/flat-ground geometry, true-projection round trips, track kinematics
test_engine_explain.py15plain-language explanation templates and the Ollama explainer fallback path
test_thermal_detection.py14the native hotspot detector: thresholding, connected components, core-area filtering
test_corpus_ssl.py10corpus dedup/bucketing, MAE masking math, PretrainRecipe JSON emission
test_evalx_cli.py10the closed-form linear probe, depth metrics, COCO-thermal adapter, CLI entry points
total99all CPU-only, no GPU or network required

The scored end-to-end proof run

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:

metricvalue
hotspot detector precision / recall1.0 / 1.0 (tp 4, fp 0, 4 ground-truth warm bodies)
walker track path length4.246 m
walker track average speed1.062 m/s
walker track heading90.0 degrees
on-axis geometry round trip0.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 speedreads 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."
passedtrue

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.

GPU training pilots: repo-reported, not independently rerun this session

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.

pilotframeswall-clockstepsthroughputpeak VRAMloss (start to end)
synthetic data2,503 (kept, deduped from 4,096 generated)32 min (1,920.0 s)9,815~1,306 img/s (peak 1,424)4,875 MB0.9226 to 0.6902 (~25% reduction)
LLVIP real infrared15,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 MB1.0022 to 0.6438 (~33% reduction)
The data floor, stated plainly Both documents state explicitly that 2,503 and 15,488 frames sit far below the repository's own 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.

Same hyperparameter recipe, run twice

hyperparametervalue
mask_ratio0.75
target normalizationper-patch z-score, ddof=0
activity-weighted maskingon
optimizerAdamW, betas (0.9, 0.95), weight_decay 0.05
batch size256 (recipe default is 1,024; batch 1,024 OOMs the MAE decoder's full self-attention over 197 tokens)
base_lr to effective LR1.5e-4 to 1.5e-4 (linear batch scaling, effective batch 256)
warmup5% of planned steps (scheduler-probed)
LR schedulecosine decay to 0 after warmup
drop_path0.1, linear across 12 encoder blocks (above recipe default, regularization biased up for a small corpus)
patch embedConv2d(in_chans=1), 224x224 input, patch 16
augmentationRandomResizedCrop(scale 0.2 to 1.0) plus horizontal flip only
architectureViT-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 real-data corpus, as built

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.

stepvalue
frames extracted from LLVIP.zip's infrared/ folder15,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 bucketsempty 12,165 / sparse 3,285 / busy 38
final corpus size on disk2.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.

Why the corpus-level dedup was measured, then turned off, for the real pilot

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 thresholdkeptkept %
<=4 (repository default)29 / 6004.8%
<=292 / 60015.3%
<=1161 / 60026.8%
<=0 (exact hash match only)262 / 60043.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.

The real LLVIP loss curve, point by point

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.

stepelapsedlossLRphase
404 s1.00221.3e-05warmup
46082 s0.94841.5e-04peak LR, warmup ends near step 446
1,640302 s0.84001.4e-04cosine body
3,260601 s0.72661.1e-04cosine body
4,880902 s0.69767.0e-05cosine body
6,5001,202 s0.67092.8e-05cosine body
8,1201,501 s0.65633.4e-06approaching LR floor
8,5001,571 s0.65090.0LR floor reached
9,7401,802 s0.65390.0LR-floor coast
11,3602,102 s0.64520.0LR-floor coast
12,9602,397 s0.64380.0LR-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 synthetic pilot's loss curve, for comparison

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:

stepelapsedlossLRphase
302 s0.92261.3e-05warmup
50093 s0.78181.5e-04peak LR, warmup done at ~324
1,000193 s0.73961.5e-04cosine body
2,000390 s0.73651.2e-04cosine body
3,000586 s0.72419.1e-05cosine body
4,000782 s0.69655.3e-05cosine body
6,0001,172 s0.68832.4e-06approaching LR floor
8,0001,565 s0.69740.0LR-floor coast
9,8001,917 s0.69020.0LR-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.

GPU compute budget for the next phase, costed line by line

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:

The ask Approximately 5 to 16 GPU-hours on a single 24 GB RTX 4090 or A10-class card (about 8 to 14 GB VRAM actually used at the recipe's batch size), training on FLIR ADAS v2 plus KAIST Multispectral Pedestrian (thermal channel), for an estimated $2 to $16 at $0.35 to $0.50 per hour rented spot pricing. GPU-hours are not the constraint. FLIR ADAS v2 (26,442 fully annotated frames, roughly 17,000 to 26,000 usable thermal frames depending on whether the video-frame split is counted) plus KAIST together give roughly 110,000 to 120,000 usable real thermal frames, which still does not meet the recipe's own 200,000-frame floor, and both datasets sit behind an email or company registration gate rather than a plain download.
datasetusable thermal framesresolutionaccessfit
FLIR ADAS v2~17,000 to 26,000 (of 26,442 annotated frames, 15 classes, ~520,000 boxes)640x512free, email/company registration + click-through terms, no approval stepMSCOCO-format annotations load as-is into evalx.read_coco_thermal, no adapter needed
KAIST Multispectral Pedestrian95,000 (of 95,000 aligned color-thermal pairs, 20 Hz vehicle rig, 103,128 annotations, CVPR 2015)640x480CC BY-NC-SA 4.0 / BSD-2-Clause, direct download, no approval gatecamera-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,000mixedboth gated behind registration, neither a plain downloadstill 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.

GPU-hours and dollar cost, two honest scenarios

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.

scenarioframes x epochsGPU-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,0005.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.4M3.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.8M6.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 acceptance bar for a real GPU run

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.

Reproduce every number on this page

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 producescommandran fresh this session
the 99-test suite resultPYTHONPATH=src .venv/bin/python -m pytest tests/ -qyes, 99 passed in 0.40s
the self-check panelPYTHONPATH=src .venv/bin/python -m thermalcore.cli selfcheck --seed 0yes
the scored proof runthermalcore demo --outdir demo_out --seed 0yes
the before/after AGC panelthermalcore.synth.generate_scene(seed=2, n_people=4, n_vehicles=1) then agc.minmax() / agc.plateau() on the same raw arrayyes
fetching the LLVIP corpushf_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 curvepython 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.05no, documented in TRAINING_RESULTS.md
the real LLVIP GPU pilot loss curvepython 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.05no, documented in REAL_DATA_RESULTS.md
the GPU compute budget arithmetictests/test_corpus_ssl.py::test_recipe_emits_complete_json (asserts each PretrainRecipe field); docs/COMPUTE_BUDGET.md shows the derivationthe recipe fields are tested; the GPU-hour/dollar arithmetic is a documented derivation, not a code assertion
indexing your own frames into a corpusthermalcore build-corpus --frames ./raw_frames --output ./corpus --sensor-id cam1 --normalization plateaucommand available, not run against real footage this session
emitting the GPU-phase recipe for a corpusthermalcore emit-recipe --corpus ./corpuscommand available, not run against a real corpus this session

Honest limitations

FAQ

What data was this actually measured on?

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.

Is this real-world or synthetic?

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.

Can I run this on my own thermal cameras?

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.

How do I get access or evaluate this for a partnership?

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.

Why is the AGC normalization choice a big deal instead of a minor preprocessing detail?

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.

What does "no model here, on purpose" actually mean for a buyer evaluating this?

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.