Hilti 2022 fisheye stereo falsification (development run)¶
The KITTI data-only decision
(KITTI Camera-LiDAR falsification) repeated on a
second dataset: two handheld Hilti 2022 recordings whose camera pair is an
equidistant fisheye (alphasense cam0 and cam1, 0.108 m apart) and whose LiDAR is a
PandarXT-32. Both recordings share one calibration (calib_3). These are development
runs, not a published artifact: the bags are not redistributed and no CI test pins the
numbers below.
| recording | decision | reference | known-bad |
|---|---|---|---|
exp21_outside_building |
pass |
roll, pitch, yaw pass; z unjudged |
pitch fail; z unjudged |
exp01_construction_ground_level |
pass |
roll, pitch, yaw, z pass |
pitch and z fail |
exp01 was chosen as the second recording before it was scored: it is an indoor
construction site, where exp21 is outdoors. Both runs use the tool unchanged and the
first 6 GB of each bag.
Setup¶
- Candidates. The reference
T_cam0_lidaris composed from the dataset's Kalibr camchain (cam0 to IMU) and itslidar_calibration.yaml(IMU to LiDAR). The known-bad is declared before scoring, the same perturbation in camera terms as KITTI's: the reference turned 5 deg about the camera's y axis (pitch) and moved 0.25 m along its optical axis. Its declared axes are pitch and z. - Frames. What
calibrex checkreads for each candidate, as it would on this bag: - its selection of cam0 images and scans (the short-recording policy);
- each image paired with the scan at the time the candidate's visible points were
measured, read from the cloud's per-point
timestampfield; - the points turned to the image time with the IMU's gyro. This corrects rotation only: the bag has no velocity topic, so the rig's translation during the sweep is not compensated.
- Each image comes with the cam1 image of the same stamp.
- Rotation from edge alignment on the raw fisheye cam0 images (equidistant model).
- Translation along the optical axis from stereo depth on the rectified pair
(
calibrex.evaluation.fisheye_stereo): both cameras are re-projected onto a common pinhole whose x axis is the baseline. cam1 sits to the left of cam0, so the rectified frame is turned 180 deg about the optical axis; the transform to it is applied to every candidate the same way. - Rule and decision. Unchanged from KITTI:
calibrex check'sjudge_pair, and the decision passes when the reference passes and the known-bad fails.
Result: exp21 (outside a building)¶
pass, on rotation. The reference passes on roll, pitch and yaw, and the known-bad
fails on pitch. z goes unjudged on both candidates. The run used 48 frame pairs from
the start of exp21 (git commit fb599b2), with a vertical offset of
−0.83 px removed and the gyro turning each scan by 0.14 deg (median; at most 0.39 deg).
| candidate | roll (deg) | pitch (deg) | yaw (deg) | z (m) | verdict |
|---|---|---|---|---|---|
| dataset reference | −0.10 / 0.50 pass |
−0.20 / 0.50 pass |
+0.15 / 0.63 pass |
−0.096, unjudged | pass |
| known-bad | unjudged | +4.95 / 0.50 fail |
+0.20 / 0.50 pass |
+0.154, unjudged | fail |
Each judged cell gives the candidate's error, then its tolerance.
- The known-bad's pitch is judged. The decision is therefore a pass, not
inconclusive: pitch is one of its declared axes. - Stereo depth sees the 0.25 m move but is not trusted. It puts the known-bad 0.250 m beyond the reference, exactly the declared move. Its jackknife std, 0.053 m and 0.051 m, is just above the 0.05 m gate, so z is not judged.
- Depth still reads the reference about 0.1 m short (−0.096 m). The joint stereo fit (cross-check; it judges nothing) disagrees.
- It reads the reference's z at +0.029 ± 0.036 m and the known-bad's at +0.276 ± 0.035 m, both trusted.
- It fits a disparity scale of −2.6%; depth alone has no scale term.
- Its yaw of the reference, −2.1 ± 0.4 deg, disagrees with the edges' +0.15 deg. Its x and y are untrusted.
- The rectification is not the source of the scale. A synthetic check projects points at 2, 5 and 10 m through the rectified pair. Disparity equals fx·B/Z to 1e-4 px, and both maps land on the fisheye projections to 1e-4 px.
Result: exp01 (construction site, ground level)¶
pass, on rotation and depth. All four axes are judged on both candidates. The
reference passes on every one, and the known-bad fails on its two declared axes. The
run used 48 frame pairs from the start of exp01 (git commit 3ba887d), with a
vertical offset of +0.33 px removed. The gyro turned each scan by 0.14 deg (median; at
most 1.48 deg).
| candidate | roll (deg) | pitch (deg) | yaw (deg) | z (m) | verdict |
|---|---|---|---|---|---|
| dataset reference | −0.05 / 0.50 pass |
−0.15 / 0.50 pass |
+0.10 / 0.50 pass |
+0.005 / 0.042 pass |
pass |
| known-bad | −0.05 / 0.50 pass |
+4.85 / 0.50 fail |
+0.05 / 0.50 pass |
+0.245 / 0.027 fail |
fail |
- Stereo depth is trusted on both candidates.
- Reference: jackknife std 0.014 m on 20,588 points; the control response is 1.03.
- Known-bad: std 0.009 m; the control response is 0.99.
- Depth puts the known-bad 0.240 m beyond the reference, against a declared move of 0.25 m.
exp21's depth offset does not repeat. Onexp01depth reads the reference at +0.005 m, against −0.096 m onexp21. The offset belongs to that recording, not to the rig or the estimator.- The joint stereo fit diverges here. It trusts no axis on either candidate (its jackknife std exceeds its gates) and fits a disparity scale of −7.1% and −4.3%. As on KITTI, it judges nothing.
- The vertical offset changes sign between recordings: +0.33 px here, −0.83 px on
exp21. The next section traces it to the cameras' relative pitch.
The vertical offset: the cameras' relative pitch, different per recording¶
The decision removes one row offset per recording, measured from its rectified images.
tools/hilti_vertical_offset.py samples every frame's matched patches and separates
the explanations. It fits the offset as a constant plus terms in disparity, column and
row, on the patches whose texture constrains rows, with a jackknife over frames.
exp21 (2022-04-21) |
exp01 (2022-03-03) |
|
|---|---|---|
| patches (constraining rows) | 12,367 (5,117) | 7,606 (4,301) |
| constant | −0.809 ± 0.078 px | +0.378 ± 0.020 px |
| per px of disparity | −0.008 ± 0.009 px | +0.004 ± 0.002 px |
| per 100 columns | +0.000 ± 0.021 px | +0.027 ± 0.013 px |
| per 100 rows | −0.040 ± 0.047 px | −0.050 ± 0.043 px |
| first half, second half (frame medians) | −0.86, −0.84 px | +0.32, +0.36 px |
| correlation with the gyro's rate about the baseline | −0.17 (implied −0.16 ms) | +0.11 (implied +0.19 ms) |
- A constant: a relative pitch. Each recording's offset is almost all constant and
stable over the recording. At fx = 351 px it is a relative pitch of −0.13 deg on
exp21and +0.06 deg onexp01against the camchain. That is 0.19 deg between two recordings seven weeks apart, both against the same calibration file. - Not the scene. A vertical baseline component would grow with disparity. It is at most 0.004 px per px here, so a near indoor scene and a far outdoor one would differ by a fraction of the 1.19 px between them.
- Not timing. A time offset between the cameras would make the offset follow the rig's angular rate. With rates up to 26 deg/s, the correlation is weak, and the implied time offsets (under 0.2 ms) disagree in sign between the two recordings.
- What this does not tell. Whether the rig moved between sessions or the camchain
was already off for both cannot be told from two recordings. Measuring the offset per
recording from its own images, as the decision does, is right either way. A stereo
extrinsic that drifts by 0.1 to 0.2 deg between sessions is itself a finding: it is
the kind of drift
calibrex checkexists to catch.
The camera-camera pair of calibrex check now measures this rotation on any stereo
bag. On these recordings it reads −0.12 deg about cam0's x on exp21 and +0.05 deg on
exp01, and fails a camchain turned 1 deg on exp01; see
the tutorial.
The other camera pairs: within and between recordings (2026-10-13)¶
With all five cameras, calibrex check reads the side and down pairs on both
recordings. Where an axis is judged on both, cam1–cam3's roll reads −0.10 deg on
exp01 and +0.21 deg on exp21, about six times its jackknife std (see
the tutorial).
Two explanations were open: the rig changed between the sessions, or the estimate is
biased at the edges of the fisheye images, where the side pairs overlap.
tools/hilti_camera_pair_reproducibility.py reads the frames where all five cameras
fired together, 1 s apart: 78 on exp01 and 80 on exp21, the bags' first 79 and 81 s.
It estimates every pair on five subsets of them:
allframes;- the
firstandsecondhalf in time, which see different parts of the scene; - the
evenandoddframes, which see the same parts.
Each estimate is the check's own, with the same options.
Within a recording¶
For each pair of subsets, z is the difference over the two subsets' combined std. Judged axes are those observable in both subsets.
| subsets | axes compared (judged in both) | RMS z (judged) | |z| > 2 (judged) | largest judged difference |
|---|---|---|---|---|
| first vs second half | 39 (19) | 1.69 (1.49) | 6 (2) | 0.19 deg |
| even vs odd frames | 42 (25) | 0.46 (0.37) | 0 (0) | 0.08 deg |
- The std measures the spread within the frames analysed. Interleaved subsets see the same scene and agree to better than their std.
- Other parts of the scene move the reading by more. Halves in time differ by 1.5
to 1.7 times their combined std (RMS). The largest difference on an axis judged in
both halves is 0.19 deg (cam0–cam4 yaw,
exp21). cam0–cam1's pitch onexp21differs by 0.05 deg, 4.8 times its std. - Consequence. The 0.5 deg tolerance of the verdicts is not at risk. A comparison of two readings "within their std" is: a side pair's std understates how its reading moves with the scene by about 1.5 times.
Between recordings¶
On all 78 and 80 frames, cam0–cam1 changes by (−0.177, −0.059, −0.013) deg between
exp01 and exp21, with stds of at most 0.04 deg. Its halves agree within each
recording. This is the 1.19 px row offset of the previous section.
Suppose only cam1 turned relative to the other cameras:
- each cam1–camN pair changes by cam0–cam1's change, turned into cam1's axes and with its sign reversed;
- the cam0–camN pairs do not change.
| pair, axis (judged on both) | change, exp21 − exp01 | only cam1 turned | z | no change, z |
|---|---|---|---|---|
| cam1–cam3 roll | +0.241 ± 0.053 deg | +0.177 deg | +1.2 | +4.5 |
| cam1–cam3 yaw | −0.046 ± 0.057 deg | +0.014 deg | −1.1 | −0.8 |
| cam0–cam4 roll | −0.130 ± 0.075 deg | 0 | −1.7 | −1.7 |
- The model removes the one large difference. Over all 18 axes, judged or not, χ²/n is 2.15 with the model and 2.78 without. Scaling by the 1.5 times above gives an expected χ²/n of about 2.2.
- One exception: cam1–cam4's pitch. It changes by −0.28 deg (z −4.5). It is unjudged on both recordings, and pitch is the axis the side pairs constrain least.
- cam1, not cam0. If cam0 had turned instead, the cam0–camN pairs would change and the cam1–camN pairs would not. cam1–cam3's roll, the largest judged change, rules that out.
Conclusion. cam1–cam3's six-sigma difference between the recordings is cam1 itself: the same 0.18 deg turn about the baseline that cam0–cam1 measures to 0.005 deg. It is not a bias at the image edges. The rest is the scene dependence measured within each recording.
Reproduce (about 50 minutes per recording):
python tools/hilti_camera_pair_reproducibility.py exp21_head.bag \
--camchain calib_3_cam0-1-camchain-imucam.yaml \
--camchain calib_3_cam2-camchain-imucam.yaml \
--camchain calib_3_cam3-camchain-imucam.yaml \
--camchain calib_3_cam4-camchain-imucam.yaml \
--output exp21_reproducibility.json
Re-centring the rectification (2026-10-13)¶
camera-camera measured row offsets in a rectification built from the candidate,
searching ±4 rows and only positive disparities. Two things went wrong with a
candidate a degree off:
- The turn left the search. A 1 deg turn moves rows by about 6 px at 350 px.
- Far points went negative. A turn about the rectified y axis moves far points to negative disparity.
The test turned cam3's camchain 1 deg about its own y axis on exp01. Its three camera
pairs became estimator_failed, and the turn went unseen.
The fix. The estimator now measures coarsely first (±12 rows, disparities from
−30 px, 100 patches per pair). If the coarse reading exceeds 0.2 deg, it re-centres the
rectification on the estimate and measures as before, up to three times. The error
composes the two. With the test's 1 deg turn, cam0–cam3 and cam1–cam3 fail with pitch
+1.37 and +1.40 deg, and the check's attribution names cam3.
Does the reading depend on where it starts?
tools/hilti_camera_pair_start_dependence.py starts each side pair from the camchain
turned −0.5, 0 and +0.5 deg in pitch. It removes the start's own turn from the reading,
so a start-independent reading comes out the same each time. It does this with the
re-centring and with the single measurement used before. exp01, 48 frames where all
five cameras fired together:
| pair | start (pitch) | re-centred: pitch, residual | single: pitch, residual |
|---|---|---|---|
| cam0–cam3 | −0.5 deg | +0.38, 0.17 px | +0.38, 0.17 px |
| cam0–cam3 | 0 | +0.36, 0.14 px | +0.31, 0.66 px |
| cam0–cam3 | +0.5 deg | +0.37, 0.32 px | −0.30 (unjudged), 1.60 px |
| cam1–cam3 | −0.5 deg | +0.38, 0.18 px | +0.38, 0.18 px |
| cam1–cam3 | 0 | +0.39, 0.13 px | +0.25 (unjudged), 0.43 px |
| cam1–cam3 | +0.5 deg | +0.44, 0.11 px | −0.78 (unjudged), 0.97 px |
| cam0–cam4 | −0.5 deg | −0.37 (unjudged), 0.12 px | inconclusive |
| cam0–cam4 | 0 | −0.48 (unjudged), 0.13 px | −0.32 (unjudged), 0.29 px |
| cam0–cam4 | +0.5 deg | −0.43, 0.20 px | −0.43, 0.20 px |
| cam1–cam4 | −0.5 deg | −0.04, 0.20 px | +0.15, 0.40 px |
| cam1–cam4 | 0 | −0.02, 0.19 px | −0.02, 0.19 px |
| cam1–cam4 | +0.5 deg | +0.00, 0.28 px | −0.03, 0.23 px |
The pitch is in degrees against the camchain. Where the start lands within 0.2 deg of the reading, the re-centring does nothing and the two methods agree.
- The re-centred readings agree across the starts within 0.11 deg. Their residuals are 0.1 to 0.3 px.
- The single measurement does not. When the reading is 0.4 to 0.9 deg from the
start, the residual grows to 0.29 to 1.60 px. The reading is then attenuated (+0.31
and +0.25 instead of +0.37), lands on the wrong side (−0.78), or collapses
(
inconclusive). The check's earlier readings were single measurements from the candidate, the 0 rows. - cam3's candidate pitch is about 0.38 deg off. cam0–cam3 and cam1–cam3 agree, and
cam0–cam3 reads +0.40 deg on
exp21. - cam0–cam4 and cam1–cam4 disagree on pitch by about 0.45 deg. Each is stable across starts. cam0 and cam1 agree to 0.05 deg, so one of the two pairs carries a bias of that size, specific to its view. The next section traces it to a vertical scale mismatch read as a turn.
The vertical scale of side pairs (2026-10-14)¶
Which of cam0–cam4 and cam1–cam4 is biased? cam4 sits 5.8 cm behind cam1 and 13.4 cm from cam0, on their left; cam1 is on cam0's left.
First suspect: the lens model at the edge of the view.
tools/hilti_camera_edge_bias.py rectifies each pair with the check's estimate,
matches again, and traces every patch to its raw pixel in both cameras. It then
refits the residual rotation on subsets of the patches.
* Where the patches lie. Both pairs' patches lie 30 to 61 deg from the optical
axes (10th to 90th percentile).
* No trend. The residual pitch shows no trend with either camera's angle.
* The lens curves. Up to 60 deg, the five cameras' equidistant curves agree
within a pixel. cam0's curve bends away beyond 70 deg (444 px at 80 deg, against
446 to 450 for the others; decreasing beyond 90 deg), but no pair uses those
angles.
Second suspect: a term the rotation model leaves out. In a side pair, the matched
patches sit on one side of the rectified image. There, a row offset proportional to
the row cannot be told from a turn about the vertical axis. Such an offset is a
vertical scale mismatch between the rectified images: a focal length or distortion
slightly off in either camera. The tool refits all patches with that term added
(exp01):
| pair | pitch, rotation only | pitch, with the scale term |
|---|---|---|
| cam0–cam4 | −0.47 ± 0.03 deg | −0.43 ± 0.05 deg |
| cam1–cam4 | −0.04 ± 0.06 deg | −0.51 ± 0.47 deg |
| triangle cam0–cam4 − cam0–cam1 − cam1–cam4 | −0.49 deg | +0.02 deg |
- cam1–cam4. Its pitch is degenerate with the scale term: the std grows eightfold. Its tight −0.04 deg holds only if the two cameras' vertical scales agree exactly.
- cam0–cam4. Its pitch is not degenerate.
- The triangle. With the term, it closes.
exp21. The freed fits are too uncertain to confirm it (stds 0.2 to 0.8 deg).
On synthetic pairs (tests/unit/test_stereo_rotation.py), the right camera's true
fy is 0.5% above its model:
| pair | rotation only | with the scale term |
|---|---|---|
| forward | roll −0.17, pitch +0.34 deg (warn) |
within 0.03 deg (pass); scale read 0.57% |
| 60 deg apart | pitch −0.66 deg (warn) |
pitch −0.10 ± 0.07 deg (pass); scale read 0.51% |
A 0.5% scale mismatch, a pixel or two of focal length, makes about the half degree seen on Hilti.
The estimator now fits the scale term. * Freely fitted, it costs detection. On the side pairs pitch became unjudged, and a 1 deg turn of cam3 went unseen again. * So it gets a prior. The images' estimate of the scale is combined with a prior of 0.3%. The rotation is fitted with the result removed. What stays uncertain is added to each axis's std, not to the observability gate.
All-camera calibrex check runs:
| rotation only (#203) | scale term with prior | |
|---|---|---|
exp01 cam0–cam4 |
warn, pitch −0.56 deg |
pass (pitch −0.63 deg fails its control, response 1.72: unjudged) |
exp01 cam1–cam4 pitch |
−0.02 ± 0.06 deg | −0.10 ± 0.19 deg (0.18 of it the scale) |
| cam3's pitch (cam0–cam3, cam1–cam3) | +0.37, +0.39 deg (exp01); +0.40 (exp21) |
+0.24 ± 0.06, +0.32 ± 0.10 (exp01); +0.24 ± 0.19 (exp21) |
cam3 turned 1 deg (exp01) |
fail, cam3 named at +1.38 deg |
fail, cam3 named at +1.32 ± 0.12 deg |
| judged camera-pair axes, both recordings | 31 | 29 |
closure, exp01 |
warn (the triangle above) |
pass |
- The scale mismatches are not stable. The fitted ratios move between recordings
(cam0–cam4 +0.03% on
exp01, +0.30% onexp21). The term absorbs more than one camera's focal length, and these recordings do not name a camera whose lens model is off. - What can be said. cam1–cam4's pitch could not be measured independently of the two cameras' vertical scales. The disagreement it showed is within the uncertainty that scale adds.
Earlier run: stamp pairing, no motion¶
The first run paired images and scans by their stamps and left the points where they
were measured, which is not what the check does. The in-view points were then measured
61 ms after the image (59 to 62 ms per frame); with the check's pairing the gap is 11 ms
(−14 to +12 ms). That run read the reference's z at −0.137 m against a 0.111 m tolerance
(warn), so the decision was inconclusive. It is reproduced with --stamp-pairing
--no-motion.
A disparity scale in the depth estimate: tried and rejected¶
The joint fit's scale suggested adding a scale term to the depth estimate. A pilot fitted both variants on the same residuals at the declared reference, without edge alignment, with 50 frames per KITTI run and 47 frames of the earlier Hilti run.
| run | current fit: reference / +0.25 m reads | free scale: reference (std) | joint fit's scale removed: reference (std) |
|---|---|---|---|
| KITTI 0005, spread | +0.048 / +0.258 | +0.023 (0.047) | +0.058 (0.016) |
| KITTI 0005, first 50 | +0.026 / +0.268 | +0.072 (0.049) | +0.093 (0.009) |
| KITTI 0009, spread | +0.002 / +0.233 | −0.114 (0.112) | +0.020 (0.038) |
| KITTI 0009, first 50 | −0.025 / +0.113 | −0.186 (0.047) | +0.044 (0.028) |
Hilti exp21, stamp pairing |
−0.124 / +0.103 | +0.398 (0.380) | −0.016 (0.044) |
- A free scale is not separable from depth. On camera-facing surfaces it trades off against the offset. The std grows 3 to 10 times, the fitted scales disagree in sign between runs, and on Hilti the control response falls to −0.15.
- The joint fit's scale is not the rig's. On one KITTI rig and calibration it is −0.2% to −0.3% on spread frames but −1.3% to −1.4% on the first 50. Removing it brings Hilti's reference to −0.016 m, but moves KITTI 0005's first-50 reference to +0.093 m with a 0.009 m std, beyond its tolerance.
- The estimator is unchanged.
What was decided after seeing results¶
- The pair's vertical offset is removed. In a first run the rectified rows of cam0
and cam1 disagreed by 0.79 px. Stereo depth then read the reference with a 0.11 m
jackknife std, more than its 0.05 m gate, so z went unjudged on both candidates. The
offset is now measured from the rectified images themselves (the median row shift of
matched patches,
estimate_vertical_offset) and removed from cam1's map before matching. The measurement uses no LiDAR and no candidate. - The frames follow the check. The tool's description already said it used the
check's frames. The visible-time pairing and the gyro were added after the
inconclusiverun, when the tool was found not to do what the check does. This turned the decision frominconclusivetopass.
Limits¶
- Translation during the sweep is not compensated. The bag has no velocity topic. With the check's pairing, the in-view points are within about 14 ms of the image.
- Two recordings, 48 frames each, from the start of each bag.
- z is judged on
exp01only. Onexp21the depth std is just above its gate, and the reference's depth reading there, about −0.1 m, is unexplained. The joint fit reads +0.03 m there, the scale that would reconcile the two does not carry over to KITTI, andexp01does not show the offset. - The reference is not ground truth. Its readings are findings about the data and the estimators, not evidence that Hilti's calibration is wrong.
Reproduce¶
python tools/hilti_stereo_falsification.py exp21_head.bag \
--camchain calib_3_cam0-1-camchain-imucam.yaml \
--lidar-calibration lidar_calibration.yaml --output hilti_exp21.json
The vertical offset's analysis (about 6 minutes per recording):
python tools/hilti_vertical_offset.py exp21_head.bag \
--camchain calib_3_cam0-1-camchain-imucam.yaml \
--lidar-calibration lidar_calibration.yaml --output exp21_vertical.json
exp01_head.bag is made the same way as exp21_head.bag
(camera-LiDAR check):
curl -L -r 0-6442450943 -o exp01_head.bag \
https://huggingface.co/datasets/Hilti-Research/hilti-slam-challenge-2022/resolve/main/rosbags/exp01_construction_ground_level.bag
python tools/trim_partial_rosbag1.py exp01_head.bag
About 6 minutes on 8 cores. The output records the git commit, the bag's name and
size, the SHA-256 of both calibration files, the per-point time field and the gyro's
correction per candidate. --stamp-pairing --no-motion reproduces the earlier run.
The scale-term pilot (about 4 minutes per run):
python tools/stereo_depth_scale_pilot.py kitti 2011_09_26_drive_0005_sync spread \
--fixed-scale -0.0019
python tools/stereo_depth_scale_pilot.py hilti exp21_head.bag \
--camchain calib_3_cam0-1-camchain-imucam.yaml \
--lidar-calibration lidar_calibration.yaml --stamp-pairing --no-motion \
--fixed-scale -0.0267
The fixed scales are the joint fit's for the reference in each published run (KITTI 0005: −0.0019 spread, −0.0129 first 50; 0009: −0.0029, −0.0131) and in the earlier Hilti run.