diff --git a/experiments/drift-anatomy/METHODS.md b/experiments/drift-anatomy/METHODS.md new file mode 100644 index 000000000..7fba32f7e --- /dev/null +++ b/experiments/drift-anatomy/METHODS.md @@ -0,0 +1,32 @@ +# Drift anatomy: methods and full results + +The README is the front door. This file keeps the method as it was pre-registered in 397507f, and the full result as the run reported it. + +## Question + +The server allows 0.16 ft of error per foot along the wall. That allowance dominates a check's error bar beyond a few feet, so which part of ARKit's error is worth attacking? The data is MARViN (iPhone 14 Pro Max, bar and church, walks within 10% of the reference's scale) and ADVIO 20–22 (iPhone 6s), using the evals' windows. + +- **A1, return to the meter.** When a walk comes back within 0.3 m of an earlier point more than 30 s later, ARKit's closure residual r is the error in the displacement between the two visits. Does r bound the largest error between the visits? +- **A2, scale.** Is ARKit's scale one number per walk? This measures the spread of scale over sliding 5 m windows within a walk. +- **A3, the wall plane.** What share of the error runs across the direction of travel, which a wall plane would remove, compared with along it? +- **A4, a range to the meter.** A second phone left at the meter ranges the walking phone by UWB (σ 10 cm, simulated) at every window end. What is the p90 left over? + +## Pass criteria, fixed before the run + +- **A1.** Across loops, the largest error between visits is at most max(2r, 2 in) in at least 90% of loops, so r can set a per-session error bar. Needs at least 8 loops. +- **A2.** One reference object near the meter is enough if the within-walk spread is under 1% (1 SD). Drop that idea if it is over 3%. +- **A3.** A wall-plane constraint is worth building if the along-travel p90 is at most 60% of the total p90 at 20 ft. +- **A4.** The p90 at 20 and 30 ft is at most 6 in. + +## Result + +All four fail. Tables: [results/drift_anatomy.md](results/drift_anatomy.md). + +- **A1.** 30 of 56 MARViN loops (54%). ADVIO has no revisit under 0.3 m while ARKit tracks. Scale and heading error cancel on return, so r misses them. +- **A2.** 5.7% SD on MARViN, 19% on ADVIO: drop. A few outlier windows inflate MARViN's SD; its robust SD is 1.8%. +- **A3.** At 20 ft, along is 0.92 of the p90 on MARViN and 0.76 on ADVIO. +- **A4.** MARViN goes from 10.4 and 13.6 in to 7.7 and 8.5 in. At σ 10 cm the range's own p90 is 6.5 in, so the bar was nearly out of reach. At 5 cm: 4.9 and 6.1 in. + +**Run:** `uv run python run.py`. Needs the evals checkout at 190ed33 (`EVALS_HARNESS`) and MARViN and ADVIO in `~/house-scanning-data`. + +**What it changes:** the error worth attacking runs along travel. A range to the meter reduces it, a wall plane wouldn't, and r can't bound a session. diff --git a/experiments/drift-anatomy/README.md b/experiments/drift-anatomy/README.md new file mode 100644 index 000000000..e745d756f --- /dev/null +++ b/experiments/drift-anatomy/README.md @@ -0,0 +1,25 @@ +# Drift anatomy + +## Question + +The server allows 0.16 ft of error per foot along the wall, which dominates a check's error bar beyond a few feet. Which part of ARKit's error is worth attacking? A1 asks whether returning to the meter bounds it, A2 whether scale is one number per walk, A3 how much runs across travel, where a wall plane would help, and A4 what a UWB range to the meter leaves. The data is MARViN (iPhone 14 Pro Max) and ADVIO (iPhone 6s); [METHODS.md](METHODS.md) has the details. + +## Pass criteria, fixed before the run (397507f) + +- **A1.** Across loops, the largest error between visits is at most max(2r, 2 in) in at least 90% of loops, so r can set a per-session error bar. Needs at least 8 loops. +- **A2.** One reference object near the meter is enough if the within-walk spread is under 1% (1 SD). Drop that idea if it is over 3%. +- **A3.** A wall-plane constraint is worth building if the along-travel p90 is at most 60% of the total p90 at 20 ft. +- **A4.** The p90 at 20 and 30 ft is at most 6 in. + +## Result + +All four fail ([tables](results/drift_anatomy.md)). + +- **A1.** The closure residual bounds the peak error in 30 of 56 loops. Scale and heading error cancel on return. +- **A2.** Scale spread within a walk is 1.8% robust SD (5.7% SD) on MARViN. +- **A3.** At 20 ft, the along-travel p90 is 9.6 of 10.4 in. +- **A4.** UWB cuts 20 ft from 10.4 to 7.7 in at σ 10 cm, and to 4.9 in at σ 5 cm. + +**Run:** `uv run python run.py`: 20 s. Needs the evals checkout at 190ed33 and MARViN and ADVIO in `~/house-scanning-data`. + +**What it changes:** the error worth attacking runs along travel. Measuring a span inside one photo avoids it. A wall plane, a return to the meter or one ruler at the meter don't. diff --git a/experiments/drift-anatomy/metrics.py b/experiments/drift-anatomy/metrics.py new file mode 100644 index 000000000..1107729a4 --- /dev/null +++ b/experiments/drift-anatomy/metrics.py @@ -0,0 +1,185 @@ +"""Geometry for the drift-anatomy questions. Pure numpy, no dataset access; every function here has +a strict test on a synthetic trajectory in tests/test_metrics.py. + +Conventions: positions are (N, 3) in a y-up frame, meters. "Horizontal" is the (x, z) part. A +heading offset delta turns a y-up vector about +y the way `evals.modern_arkit.turn` and +`evals.geometry.rot_y` do, so ARKit displacements turned by the offset at a window's start are in the +reference's frame. +""" + +from __future__ import annotations + +import numpy as np + +MAD_TO_SD = 1.4826 # median absolute deviation to standard deviation, for normal data + + +def turn(v: np.ndarray, delta: np.ndarray | float) -> np.ndarray: + """Turn y-up vectors (N, 3) about +y by delta (radians); the same map as evals.modern_arkit.turn.""" + c, s = np.cos(delta), np.sin(delta) + return np.c_[v[:, 0] * c + v[:, 2] * s, v[:, 1], v[:, 2] * c - v[:, 0] * s] + + +def yaw_offset(R_from: np.ndarray, R_to: np.ndarray) -> np.ndarray: + """Heading angle (N,) of the rotation about +y closest to R_to @ R_from^T, radians. Turning a + vector by it with `turn` equals applying evals.drift.yaw_between(R_from, R_to).""" + M = R_to @ np.swapaxes(R_from, -1, -2) + return np.arctan2(M[..., 0, 2] - M[..., 2, 0], M[..., 0, 0] + M[..., 2, 2]) + + +def at(p: np.ndarray, j: np.ndarray) -> np.ndarray: + """Positions at fractional sample indices, linearly interpolated (as evals.drift.at).""" + lo = np.floor(j).astype(int) + hi = np.minimum(lo + 1, len(p) - 1) + f = (j - lo)[:, None] + return p[lo] * (1 - f) + p[hi] * f + + +def path_length(p: np.ndarray) -> np.ndarray: + """Cumulative horizontal distance travelled, starting at 0.""" + steps = np.linalg.norm(np.diff(p[:, [0, 2]], axis=0), axis=1) + return np.concatenate([[0.0], np.cumsum(steps)]) + + +def horizontal_norm(v: np.ndarray) -> np.ndarray: + return np.linalg.norm(v[..., [0, 2]], axis=-1) + + +# A1: return to the meter + + +def find_loops( + times: np.ndarray, + ref: np.ndarray, + min_gap_s: float = 30.0, + max_dist_m: float = 0.3, + separation_s: float = 5.0, +) -> list[tuple[int, int]]: + """Revisits (i, j): times[j] - times[i] > min_gap_s and |ref[j] - ref[i]| < max_dist_m. + + Each start i keeps its closest qualifying j. Starts less than separation_s apart form one + cluster (so their separation_s-wide neighbourhoods overlap), and each cluster keeps only its + closest pair. A walker who pauses at the meter, or retraces a stretch of path, therefore + yields one loop, not one per sample. Returned in time order. + """ + if len(times) != len(ref): + raise ValueError(f"{len(times)} times for {len(ref)} positions") + if np.any(np.diff(times) <= 0): + raise ValueError("times must increase") + candidates = [] + for i in range(len(times)): + later = np.flatnonzero(times - times[i] > min_gap_s) + if len(later) == 0: + break + d = np.linalg.norm(ref[later] - ref[i], axis=1) + k = int(np.argmin(d)) + if d[k] < max_dist_m: + candidates.append((float(d[k]), i, int(later[k]))) + loops: list[tuple[int, int]] = [] + cluster: list[tuple[float, int, int]] = [] + for c in candidates: # already in time order of i + if cluster and times[c[1]] - times[cluster[-1][1]] >= separation_s: + loops.append(min(cluster)[1:]) + cluster = [] + cluster.append(c) + if cluster: + loops.append(min(cluster)[1:]) + return loops + + +def loop_errors( + ark: np.ndarray, ref: np.ndarray, delta: np.ndarray, i: int, j: int +) -> dict[str, float]: + """For a loop from sample i to sample j: the horizontal error of ARKit's displacement from i + (turned by the heading offset at i) against the reference's, at j (the residual r) and at its + largest over the samples strictly between (the peak). Also the reference's horizontal path + length from i to j and its largest horizontal distance from the point at i (the excursion).""" + if not 0 <= i < j - 1 < len(ref) - 1: + raise ValueError(f"need a sample strictly between i={i} and j={j}") + seg = slice(i, j + 1) + da = turn(ark[seg] - ark[i], delta[i]) + dr = ref[seg] - ref[i] + e = horizontal_norm(da - dr) + return { + "r": float(e[-1]), + "peak": float(e[1:-1].max()), + "path_m": float(path_length(ref[seg])[-1]), + "excursion_m": float(horizontal_norm(dr).max()), + } + + +# A2: local scale + + +def path_windows(ref: np.ndarray, length_m: float, every_m: float) -> tuple[np.ndarray, np.ndarray]: + """Windows of exactly `length_m` of horizontal reference path. Starts are the first sample at + or past each multiple of `every_m` along the path (duplicates removed); ends are fractional + sample indices, interpolated to the exact length. Windows that run off the end are dropped.""" + walked = path_length(ref) + marks = np.arange(0.0, walked[-1] - length_m, every_m) + i = np.unique(np.searchsorted(walked, marks)) + target = walked[i] + length_m + j0 = np.searchsorted(walked, target) + keep = j0 < len(ref) + i, j0, target = i[keep], j0[keep], target[keep] + frac = (target - walked[j0 - 1]) / (walked[j0] - walked[j0 - 1]) + return i, (j0 - 1) + frac + + +def local_scale(ark: np.ndarray, ref: np.ndarray, i: np.ndarray, j: np.ndarray) -> np.ndarray: + """ARKit's straight-line displacement over the reference's, per window.""" + return np.linalg.norm(at(ark, j) - ark[i], axis=1) / np.linalg.norm(at(ref, j) - ref[i], axis=1) + + +def robust_sd(x: np.ndarray) -> float: + """1.4826 x the median absolute deviation from the median.""" + return float(MAD_TO_SD * np.median(np.abs(x - np.median(x)))) + + +def split_by_length( + sd_short: float, sd_long: float, short_m: float, long_m: float +) -> tuple[float, float]: + """Split a scale spread measured on windows of two lengths into a part that falls as 1/length + and a part that does not: sd(L)^2 = persistent^2 + (c / L)^2. Endpoint noise, from either + track, falls as 1/L. Returns (the 1/L part at short_m, the persistent part), each clipped at 0 + when the two spreads are inconsistent with the model.""" + k = (long_m / short_m) ** 2 + falling_short = (sd_short**2 - sd_long**2) * k / (k - 1) + persistent = (k * sd_long**2 - sd_short**2) / (k - 1) + return float(np.sqrt(max(falling_short, 0.0))), float(np.sqrt(max(persistent, 0.0))) + + +# A3: along and across travel + + +def along_across(err: np.ndarray, ref_disp: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Horizontal error (N, 3) split into its signed component along the reference's horizontal + displacement (N, 3) and the signed component across it. With u the unit travel direction and e + the error, both as (x, z): along = u . e and across = u_z e_x - u_x e_z.""" + u = ref_disp[:, [0, 2]] + n = np.linalg.norm(u, axis=1) + if np.any(n == 0): + raise ValueError("a window with no horizontal reference displacement has no direction") + u = u / n[:, None] + e = err[:, [0, 2]] + along = (e * u).sum(axis=1) + across = u[:, 1] * e[:, 0] - u[:, 0] * e[:, 1] + return along, across + + +# A4: a range to the meter + + +def range_correct(est: np.ndarray, rng_m: np.ndarray) -> np.ndarray: + """Move each estimated displacement from the anchor (N, 3) radially in the horizontal plane, + keeping its height, until its 3-D distance from the anchor equals the measured range. A range + shorter than the height difference puts the point directly above or below the anchor.""" + h = horizontal_norm(est) + if np.any(h == 0): + raise ValueError( + "an estimate on the anchor's vertical has no horizontal direction to move along" + ) + target = np.sqrt(np.maximum(np.asarray(rng_m) ** 2 - est[:, 1] ** 2, 0.0)) + out = est.copy() + out[:, [0, 2]] *= (target / h)[:, None] + return out diff --git a/experiments/drift-anatomy/pyproject.toml b/experiments/drift-anatomy/pyproject.toml new file mode 100644 index 000000000..efbaa7345 --- /dev/null +++ b/experiments/drift-anatomy/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "drift-anatomy" +version = "0.1.0" +description = "Which part of ARKit's position error is worth attacking: loop closure, local scale, along versus across travel, and a range to the meter" +requires-python = ">=3.12" +dependencies = ["numpy>=2.1", "scipy>=1.14"] + +[dependency-groups] +dev = ["pytest>=8.3", "ruff>=0.8"] + +[tool.uv] +package = false + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = ["--strict-markers", "--strict-config"] diff --git a/experiments/drift-anatomy/results/drift_anatomy.json b/experiments/drift-anatomy/results/drift_anatomy.json new file mode 100644 index 000000000..73d0a8375 --- /dev/null +++ b/experiments/drift-anatomy/results/drift_anatomy.json @@ -0,0 +1,1482 @@ +{ + "meta": { + "harness_commit": "190ed339f41924ae0244e0b88c5ebe647ddd9eb4", + "command": "uv run python run.py", + "walks": { + "marvin": 25, + "advio": 3 + }, + "marvin_dropped_off_scale": [], + "walk_scale": { + "bar/seq1": 1.0119566167379175, + "bar/seq2": 1.0172815131096447, + "bar/seq3": 1.0212684332194755, + "bar/seq4": 1.0121136482692539, + "bar/seq5": 1.0009616239552566, + "bar/seq6": 1.0010668096557958, + "bar/seq7": 1.0167883077362732, + "bar/seq8": 1.0189493528347173, + "bar/seq9": 1.0066311229670113, + "bar/seq10": 1.0083759873921443, + "bar/seq11": 1.010333121129963, + "bar/seq12": 0.996197045053592, + "bar/seq13": 1.0119872935503242, + "church/seq1": 0.9848750055548867, + "church/seq2": 0.9902665052054505, + "church/seq3": 0.9800607376538126, + "church/seq4": 0.9778316135150016, + "church/seq5": 0.9821881576545499, + "church/seq6": 0.9823983125136755, + "church/seq7": 0.9873264572724576, + "church/seq8": 0.9819571546728899, + "church/seq9": 0.9842011055064939, + "church/seq10": 0.982038687843804, + "church/seq11": 0.9868560546713097, + "church/seq12": 0.9845775222021418, + "advio-20": 0.8377000209344116, + "advio-21": 0.9425183631595471, + "advio-22": 0.9508629085388776 + }, + "seed": 0 + }, + "a1": { + "loops": [ + { + "dataset": "marvin", + "walk": "bar/seq1", + "t0_s": 24.0, + "t1_s": 105.0, + "ref_gap_m": 0.14332086832962943, + "path_m": 69.37452567542671, + "excursion_m": 28.52395203587133, + "r_in": 17.796458555645145, + "peak_in": 19.265780685615717, + "bound_in": 35.59291711129029, + "ok": true, + "allowance_in": 179.67843802123673, + "peak_walk_offset_in": 18.76279031651448, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq1", + "t0_s": 37.0, + "t1_s": 131.0, + "ref_gap_m": 0.18707715892142515, + "path_m": 76.25407985249154, + "excursion_m": 16.701998415928294, + "r_in": 16.022256749885997, + "peak_in": 31.316668034589572, + "bound_in": 32.044513499771995, + "ok": true, + "allowance_in": 105.2094388404932, + "peak_walk_offset_in": 30.35168201190253, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq1", + "t0_s": 54.0, + "t1_s": 158.0, + "ref_gap_m": 0.16757174422812507, + "path_m": 78.6362361387909, + "excursion_m": 32.339589680319605, + "r_in": 6.281992755075309, + "peak_in": 30.686492378554192, + "bound_in": 12.563985510150617, + "ok": false, + "allowance_in": 203.71395074217077, + "peak_walk_offset_in": 23.27812103563565, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "bar/seq1", + "t0_s": 63.0, + "t1_s": 197.0, + "ref_gap_m": 0.08112315332663285, + "path_m": 99.74350788584039, + "excursion_m": 26.949133776436415, + "r_in": 7.63146596260574, + "peak_in": 20.236372818364035, + "bound_in": 15.26293192521148, + "ok": false, + "allowance_in": 169.7583230011743, + "peak_walk_offset_in": 21.834031367779826, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq1", + "t0_s": 72.0, + "t1_s": 134.0, + "ref_gap_m": 0.16983852519289983, + "path_m": 47.12447191204491, + "excursion_m": 18.025001444728613, + "r_in": 3.35790357052318, + "peak_in": 14.001191339484214, + "bound_in": 6.71580714104636, + "ok": false, + "allowance_in": 113.54331618726687, + "peak_walk_offset_in": 15.08115467409488, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "bar/seq1", + "t0_s": 122.0, + "t1_s": 212.0, + "ref_gap_m": 0.18113986991079667, + "path_m": 70.3848884735672, + "excursion_m": 32.94149851753823, + "r_in": 12.598624986756388, + "peak_in": 20.956380748792157, + "bound_in": 25.197249973512776, + "ok": true, + "allowance_in": 207.50550247268177, + "peak_walk_offset_in": 20.798110848473158, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq1", + "t0_s": 160.0, + "t1_s": 191.0, + "ref_gap_m": 0.29717554329052387, + "path_m": 19.54279624071472, + "excursion_m": 9.291355062884861, + "r_in": 0.2812834480241445, + "peak_in": 3.5339501200189334, + "bound_in": 2.0, + "ok": false, + "allowance_in": 58.52822086856606, + "peak_walk_offset_in": 4.751142142166422, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "bar/seq2", + "t0_s": 0.0, + "t1_s": 126.0, + "ref_gap_m": 0.1722485223587754, + "path_m": 74.33440842116248, + "excursion_m": 36.090296525207094, + "r_in": 7.846197737257828, + "peak_in": 28.83308503492335, + "bound_in": 15.692395474515656, + "ok": false, + "allowance_in": 227.34045055248563, + "peak_walk_offset_in": 28.652678017300047, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq2", + "t0_s": 38.0, + "t1_s": 86.0, + "ref_gap_m": 0.2351985625310395, + "path_m": 29.587297726544396, + "excursion_m": 14.379358845287173, + "r_in": 1.712803279775875, + "peak_in": 9.857153476717201, + "bound_in": 3.42560655955175, + "ok": false, + "allowance_in": 90.57863839550976, + "peak_walk_offset_in": 9.879391827726598, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "bar/seq2", + "t0_s": 145.0, + "t1_s": 211.0, + "ref_gap_m": 0.08488821310929381, + "path_m": 42.08670253379037, + "excursion_m": 20.343437459444125, + "r_in": 12.692906856356583, + "peak_in": 27.624967339408276, + "bound_in": 25.385813712713166, + "ok": false, + "allowance_in": 128.14763753980552, + "peak_walk_offset_in": 26.67707125096025, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq2", + "t0_s": 157.0, + "t1_s": 199.0, + "ref_gap_m": 0.09950828766195552, + "path_m": 26.58643322746184, + "excursion_m": 12.73431496332639, + "r_in": 9.666626228311637, + "peak_in": 20.559084451881702, + "bound_in": 19.333252456623274, + "ok": false, + "allowance_in": 80.2161572493001, + "peak_walk_offset_in": 20.594314745612984, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq3", + "t0_s": 89.0, + "t1_s": 194.0, + "ref_gap_m": 0.20210256589542086, + "path_m": 79.96467720578816, + "excursion_m": 25.86351139147429, + "r_in": 9.845253191624755, + "peak_in": 22.47657449864035, + "bound_in": 19.69050638324951, + "ok": false, + "allowance_in": 162.91975679668846, + "peak_walk_offset_in": 22.521834786143177, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq3", + "t0_s": 116.0, + "t1_s": 188.0, + "ref_gap_m": 0.27575676981356084, + "path_m": 59.90759673249809, + "excursion_m": 25.435092627903018, + "r_in": 7.764192600573239, + "peak_in": 20.032163114553967, + "bound_in": 15.528385201146477, + "ok": false, + "allowance_in": 160.22105592379853, + "peak_walk_offset_in": 20.347765024309318, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq3", + "t0_s": 124.0, + "t1_s": 185.0, + "ref_gap_m": 0.24520234790864887, + "path_m": 51.42625184554949, + "excursion_m": 24.236686567868855, + "r_in": 9.83973605886616, + "peak_in": 22.751386356640413, + "bound_in": 19.67947211773232, + "ok": false, + "allowance_in": 152.67204137240225, + "peak_walk_offset_in": 21.334483424368347, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq4", + "t0_s": 15.0, + "t1_s": 144.0, + "ref_gap_m": 0.2755466078217409, + "path_m": 80.01150098538862, + "excursion_m": 30.57513509541923, + "r_in": 14.552112044692723, + "peak_in": 15.361515501972223, + "bound_in": 29.104224089385447, + "ok": true, + "allowance_in": 192.5992761916172, + "peak_walk_offset_in": 15.099830605353956, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq4", + "t0_s": 26.0, + "t1_s": 114.0, + "ref_gap_m": 0.12168503025212708, + "path_m": 55.53505969245789, + "excursion_m": 24.014539550597753, + "r_in": 7.768622347012305, + "peak_in": 16.45118567226345, + "bound_in": 15.53724469402461, + "ok": false, + "allowance_in": 151.27269008250556, + "peak_walk_offset_in": 17.287615689726074, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq4", + "t0_s": 73.0, + "t1_s": 193.0, + "ref_gap_m": 0.26937037609325565, + "path_m": 76.46710818009942, + "excursion_m": 34.99438888067746, + "r_in": 43.70182128111379, + "peak_in": 45.8140836646689, + "bound_in": 87.40364256222757, + "ok": true, + "allowance_in": 220.4370953113541, + "peak_walk_offset_in": 45.7902803212238, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq4", + "t0_s": 94.0, + "t1_s": 175.0, + "ref_gap_m": 0.11964834041863737, + "path_m": 49.64774392951282, + "excursion_m": 23.100459180260877, + "r_in": 27.248900493816738, + "peak_in": 15.739752932771292, + "bound_in": 54.497800987633475, + "ok": true, + "allowance_in": 145.5147034977063, + "peak_walk_offset_in": 15.019714032770976, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq5", + "t0_s": 13.0, + "t1_s": 74.0, + "ref_gap_m": 0.27601932974149085, + "path_m": 45.940754566270385, + "excursion_m": 21.42462273547305, + "r_in": 96.38907613055025, + "peak_in": 98.64304972713695, + "bound_in": 192.7781522611005, + "ok": true, + "allowance_in": 134.95825345179875, + "peak_walk_offset_in": 98.55597974605995, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq5", + "t0_s": 114.0, + "t1_s": 200.0, + "ref_gap_m": 0.16285213790500802, + "path_m": 68.74591180791712, + "excursion_m": 22.620642775934307, + "r_in": 41.523845233095706, + "peak_in": 41.354296716128964, + "bound_in": 83.04769046619141, + "ok": true, + "allowance_in": 142.4922379586413, + "peak_walk_offset_in": 41.1635718897577, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq5", + "t0_s": 123.0, + "t1_s": 171.0, + "ref_gap_m": 0.20271265056152982, + "path_m": 40.971657805368714, + "excursion_m": 16.400365047677187, + "r_in": 26.77094502782434, + "peak_in": 32.46524233343452, + "bound_in": 53.54189005564868, + "ok": true, + "allowance_in": 103.3093861271004, + "peak_walk_offset_in": 33.02364567616874, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq6", + "t0_s": 61.0, + "t1_s": 133.0, + "ref_gap_m": 0.23954245597330687, + "path_m": 49.21178934952984, + "excursion_m": 22.579342200876127, + "r_in": 5.128346564666303, + "peak_in": 10.885641818558096, + "bound_in": 10.256693129332605, + "ok": false, + "allowance_in": 142.23207685591262, + "peak_walk_offset_in": 11.675018024483418, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq7", + "t0_s": 28.0, + "t1_s": 163.0, + "ref_gap_m": 0.11919745176013513, + "path_m": 123.832275133548, + "excursion_m": 44.12250214284794, + "r_in": 14.586647580190734, + "peak_in": 35.71186902666882, + "bound_in": 29.173295160381468, + "ok": false, + "allowance_in": 277.9370213722705, + "peak_walk_offset_in": 35.93547539569701, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq8", + "t0_s": 14.0, + "t1_s": 176.0, + "ref_gap_m": 0.13929701091031516, + "path_m": 171.14539765049636, + "excursion_m": 25.838969328322907, + "r_in": 98.7049813919242, + "peak_in": 127.54501942929991, + "bound_in": 197.4099627838484, + "ok": true, + "allowance_in": 162.76516112329392, + "peak_walk_offset_in": 116.4286258238901, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq8", + "t0_s": 37.0, + "t1_s": 80.0, + "ref_gap_m": 0.16295282943049655, + "path_m": 49.43476294816631, + "excursion_m": 21.875002749800238, + "r_in": 47.824899195486914, + "peak_in": 49.04777628954225, + "bound_in": 95.64979839097383, + "ok": true, + "allowance_in": 137.79529291212748, + "peak_walk_offset_in": 51.04101376862325, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq8", + "t0_s": 55.0, + "t1_s": 167.0, + "ref_gap_m": 0.2954849677233324, + "path_m": 116.19802033802452, + "excursion_m": 24.411998371390297, + "r_in": 76.55770240241067, + "peak_in": 94.03209890637952, + "bound_in": 153.11540480482134, + "ok": true, + "allowance_in": 153.77636769379717, + "peak_walk_offset_in": 99.70611168698278, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq8", + "t0_s": 64.0, + "t1_s": 191.0, + "ref_gap_m": 0.19569079184390295, + "path_m": 126.75417967552156, + "excursion_m": 21.898179686724685, + "r_in": 77.66228688189513, + "peak_in": 93.97270731873128, + "bound_in": 155.32457376379026, + "ok": true, + "allowance_in": 137.94128936519488, + "peak_walk_offset_in": 96.26708758741881, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq9", + "t0_s": 5.0, + "t1_s": 160.0, + "ref_gap_m": 0.12974631971735018, + "path_m": 132.9426459877951, + "excursion_m": 37.274115810862455, + "r_in": 13.639766073615064, + "peak_in": 16.311109077324463, + "bound_in": 27.279532147230128, + "ok": true, + "allowance_in": 234.79757991094462, + "peak_walk_offset_in": 26.223986711424708, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq9", + "t0_s": 13.0, + "t1_s": 151.0, + "ref_gap_m": 0.22699228812185648, + "path_m": 117.22800307337816, + "excursion_m": 31.340963297913934, + "r_in": 6.073397032331357, + "peak_in": 25.21566782151836, + "bound_in": 12.146794064662714, + "ok": false, + "allowance_in": 197.42339085300117, + "peak_walk_offset_in": 23.426540783115172, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "bar/seq9", + "t0_s": 51.0, + "t1_s": 116.0, + "ref_gap_m": 0.2616527573025407, + "path_m": 51.97305783600315, + "excursion_m": 21.622479460417082, + "r_in": 2.527712993644253, + "peak_in": 16.278472189671838, + "bound_in": 5.055425987288506, + "ok": false, + "allowance_in": 136.20459502624934, + "peak_walk_offset_in": 17.88839225000414, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "bar/seq10", + "t0_s": 85.0, + "t1_s": 197.0, + "ref_gap_m": 0.13232741191981734, + "path_m": 101.71639741106732, + "excursion_m": 32.66221096035956, + "r_in": 29.026094129857555, + "peak_in": 46.40389646643247, + "bound_in": 58.05218825971511, + "ok": true, + "allowance_in": 205.7462107739185, + "peak_walk_offset_in": 46.57806637311154, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq11", + "t0_s": 43.0, + "t1_s": 78.0, + "ref_gap_m": 0.27057637026206904, + "path_m": 27.62053400960569, + "excursion_m": 10.754068488806869, + "r_in": 12.475031679007003, + "peak_in": 13.879467217866733, + "bound_in": 24.950063358014006, + "ok": true, + "allowance_in": 67.74216370901965, + "peak_walk_offset_in": 14.51044081836944, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq11", + "t0_s": 71.0, + "t1_s": 167.0, + "ref_gap_m": 0.23523258051132817, + "path_m": 79.34311300572892, + "excursion_m": 24.72679853819777, + "r_in": 25.840612122084767, + "peak_in": 30.201551618203794, + "bound_in": 51.681224244169535, + "ok": true, + "allowance_in": 155.75936087053714, + "peak_walk_offset_in": 30.29681495844835, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "bar/seq11", + "t0_s": 113.0, + "t1_s": 157.0, + "ref_gap_m": 0.10577879167692955, + "path_m": 38.85343108127658, + "excursion_m": 18.504846990206598, + "r_in": 11.861097113980353, + "peak_in": 15.609753503172994, + "bound_in": 23.722194227960706, + "ok": true, + "allowance_in": 116.56596529264, + "peak_walk_offset_in": 14.714503964562063, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq1", + "t0_s": 10.0, + "t1_s": 125.0, + "ref_gap_m": 0.12027287182069922, + "path_m": 129.07624368756242, + "excursion_m": 38.40266337496425, + "r_in": 16.083702714094684, + "peak_in": 31.502474373673806, + "bound_in": 32.16740542818937, + "ok": true, + "allowance_in": 241.9065409446567, + "peak_walk_offset_in": 31.731249469514843, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq1", + "t0_s": 52.0, + "t1_s": 193.0, + "ref_gap_m": 0.15896238142237606, + "path_m": 162.26448896003322, + "excursion_m": 39.21913411119755, + "r_in": 6.711932147834532, + "peak_in": 27.743335560352815, + "bound_in": 13.423864295669064, + "ok": false, + "allowance_in": 247.049663692583, + "peak_walk_offset_in": 28.108351267618083, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq3", + "t0_s": 19.0, + "t1_s": 141.0, + "ref_gap_m": 0.1874971386095335, + "path_m": 136.57413946336288, + "excursion_m": 42.39700686998337, + "r_in": 28.843314789850805, + "peak_in": 46.469692139554795, + "bound_in": 57.68662957970161, + "ok": true, + "allowance_in": 267.0677598109189, + "peak_walk_offset_in": 46.15885968017031, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq5", + "t0_s": 95.0, + "t1_s": 184.0, + "ref_gap_m": 0.20346746488018017, + "path_m": 100.76236206777857, + "excursion_m": 35.490899309916045, + "r_in": 2.2761342914733453, + "peak_in": 38.39814637620797, + "bound_in": 4.552268582946691, + "ok": false, + "allowance_in": 223.5647200624633, + "peak_walk_offset_in": 35.84159112180687, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "church/seq5", + "t0_s": 101.0, + "t1_s": 161.0, + "ref_gap_m": 0.143347810310218, + "path_m": 66.21870296301032, + "excursion_m": 28.378685000416233, + "r_in": 24.38203250545501, + "peak_in": 21.60071902659748, + "bound_in": 48.76406501091002, + "ok": true, + "allowance_in": 178.7633700813621, + "peak_walk_offset_in": 21.622794393284682, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq5", + "t0_s": 112.0, + "t1_s": 150.0, + "ref_gap_m": 0.23785625022065576, + "path_m": 39.40617339901275, + "excursion_m": 15.294469969403387, + "r_in": 4.743052168813935, + "peak_in": 9.86372422646667, + "bound_in": 9.48610433762787, + "ok": false, + "allowance_in": 96.34311791750166, + "peak_walk_offset_in": 10.901060258033553, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "church/seq5", + "t0_s": 157.0, + "t1_s": 193.0, + "ref_gap_m": 0.07237743726052223, + "path_m": 44.889464654857505, + "excursion_m": 20.535482565201658, + "r_in": 12.986468337223416, + "peak_in": 22.36549099734365, + "bound_in": 25.97293667444683, + "ok": true, + "allowance_in": 129.35737048945927, + "peak_walk_offset_in": 24.04129201326065, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq6", + "t0_s": 4.0, + "t1_s": 190.0, + "ref_gap_m": 0.16036898378397238, + "path_m": 132.5195942999297, + "excursion_m": 40.605714103691454, + "r_in": 18.36448847174995, + "peak_in": 33.277245016751806, + "bound_in": 36.7289769434999, + "ok": true, + "allowance_in": 255.7840258500249, + "peak_walk_offset_in": 33.87514236203537, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq6", + "t0_s": 9.0, + "t1_s": 194.0, + "ref_gap_m": 0.2412992572252682, + "path_m": 132.33918864177176, + "excursion_m": 41.82520561875045, + "r_in": 20.709504419817463, + "peak_in": 37.91891280200022, + "bound_in": 41.41900883963493, + "ok": true, + "allowance_in": 263.4658621653572, + "peak_walk_offset_in": 36.93880498253565, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq8", + "t0_s": 8.0, + "t1_s": 187.0, + "ref_gap_m": 0.11021708961118272, + "path_m": 200.37811673716888, + "excursion_m": 32.624023303508956, + "r_in": 13.505817478555262, + "peak_in": 92.55712149397334, + "bound_in": 27.011634957110523, + "ok": false, + "allowance_in": 205.50565860478085, + "peak_walk_offset_in": 65.5220655377169, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "church/seq8", + "t0_s": 25.0, + "t1_s": 56.0, + "ref_gap_m": 0.2828188831499892, + "path_m": 31.403177921211505, + "excursion_m": 12.432889397894336, + "r_in": 17.117039034405202, + "peak_in": 15.922867620325542, + "bound_in": 34.234078068810405, + "ok": true, + "allowance_in": 78.31741353004305, + "peak_walk_offset_in": 14.915166397167816, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq8", + "t0_s": 139.0, + "t1_s": 206.0, + "ref_gap_m": 0.08269251857903062, + "path_m": 72.8815123591878, + "excursion_m": 35.04185767627533, + "r_in": 56.75507975424725, + "peak_in": 66.09970939827375, + "bound_in": 113.5101595084945, + "ok": true, + "allowance_in": 220.73611134661624, + "peak_walk_offset_in": 66.21445092567471, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq8", + "t0_s": 144.0, + "t1_s": 200.0, + "ref_gap_m": 0.29795978306610993, + "path_m": 59.10868378587789, + "excursion_m": 28.20509049389657, + "r_in": 61.95269282320738, + "peak_in": 67.63507439519198, + "bound_in": 123.90538564641476, + "ok": true, + "allowance_in": 177.66986137887605, + "peak_walk_offset_in": 68.273432988387, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq8", + "t0_s": 154.0, + "t1_s": 188.0, + "ref_gap_m": 0.1443568367082122, + "path_m": 32.74518716550093, + "excursion_m": 15.264996945770985, + "r_in": 43.09142048811529, + "peak_in": 45.372746518137994, + "bound_in": 86.18284097623058, + "ok": true, + "allowance_in": 96.15746107572275, + "peak_walk_offset_in": 46.01211604152822, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq9", + "t0_s": 4.0, + "t1_s": 172.0, + "ref_gap_m": 0.2811728936819522, + "path_m": 154.1168806121152, + "excursion_m": 32.0575241036535, + "r_in": 26.32408005973307, + "peak_in": 39.5087800589614, + "bound_in": 52.64816011946614, + "ok": true, + "allowance_in": 201.93715970805357, + "peak_walk_offset_in": 38.20198610679817, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq9", + "t0_s": 38.0, + "t1_s": 197.0, + "ref_gap_m": 0.06737799332817955, + "path_m": 155.60616934132602, + "excursion_m": 39.55901986668887, + "r_in": 3.654551454377172, + "peak_in": 46.95703190918746, + "bound_in": 7.309102908754344, + "ok": false, + "allowance_in": 249.1906763255992, + "peak_walk_offset_in": 47.40172271711361, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "church/seq9", + "t0_s": 59.0, + "t1_s": 90.0, + "ref_gap_m": 0.11686261166644923, + "path_m": 25.916615484253505, + "excursion_m": 8.893595958084845, + "r_in": 0.7637079099955484, + "peak_in": 4.440752709240459, + "bound_in": 2.0, + "ok": false, + "allowance_in": 56.022651704471464, + "peak_walk_offset_in": 4.223603998907584, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "church/seq9", + "t0_s": 97.0, + "t1_s": 198.0, + "ref_gap_m": 0.2835561907307814, + "path_m": 114.92895398186194, + "excursion_m": 39.82412946553097, + "r_in": 7.723354926227159, + "peak_in": 41.323351672168705, + "bound_in": 15.446709852454317, + "ok": false, + "allowance_in": 250.8606580505888, + "peak_walk_offset_in": 41.628088147106034, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "church/seq10", + "t0_s": 13.0, + "t1_s": 204.0, + "ref_gap_m": 0.1548086352942968, + "path_m": 174.40092918340963, + "excursion_m": 41.41998334601129, + "r_in": 9.77876411971874, + "peak_in": 29.701433210530432, + "bound_in": 19.55752823943748, + "ok": false, + "allowance_in": 260.9132809197562, + "peak_walk_offset_in": 30.0861518601904, + "ok_walk_offset": false, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq11", + "t0_s": 34.0, + "t1_s": 203.0, + "ref_gap_m": 0.22712682738827603, + "path_m": 154.92892873148213, + "excursion_m": 39.23768729197794, + "r_in": 34.84406850312271, + "peak_in": 39.838493833204126, + "bound_in": 69.68813700624543, + "ok": true, + "allowance_in": 247.1665341226957, + "peak_walk_offset_in": 39.17256673787897, + "ok_walk_offset": true, + "ok_walk_scale_removed": true + }, + { + "dataset": "marvin", + "walk": "church/seq12", + "t0_s": 48.0, + "t1_s": 189.0, + "ref_gap_m": 0.1913199875431373, + "path_m": 154.34606609370377, + "excursion_m": 45.61514338658002, + "r_in": 7.718592232813788, + "peak_in": 31.239833765479467, + "bound_in": 15.437184465627576, + "ok": false, + "allowance_in": 287.3394858997167, + "peak_walk_offset_in": 33.16466804209621, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + }, + { + "dataset": "marvin", + "walk": "church/seq12", + "t0_s": 63.0, + "t1_s": 202.0, + "ref_gap_m": 0.2565343496537738, + "path_m": 153.57217380712245, + "excursion_m": 32.37034066320749, + "r_in": 2.429588303932039, + "peak_in": 34.612284447268095, + "bound_in": 4.859176607864078, + "ok": false, + "allowance_in": 203.9076577209921, + "peak_walk_offset_in": 33.69392727277366, + "ok_walk_offset": false, + "ok_walk_scale_removed": false + } + ], + "closest_revisit_m": { + "bar/seq1": 0.08112315332663285, + "bar/seq2": 0.08488821310929381, + "bar/seq3": 0.20210256589542086, + "bar/seq4": 0.11964834041863737, + "bar/seq5": 0.16285213790500802, + "bar/seq6": 0.23954245597330687, + "bar/seq7": 0.11919745176013513, + "bar/seq8": 0.13929701091031516, + "bar/seq9": 0.12974631971735018, + "bar/seq10": 0.13232741191981734, + "bar/seq11": 0.10577879167692955, + "bar/seq12": 0.5089724078439455, + "bar/seq13": 2.0283092124576214, + "church/seq1": 0.12027287182069922, + "church/seq2": 3.0748578623315095, + "church/seq3": 0.1874971386095335, + "church/seq4": 1.5295386816220726, + "church/seq5": 0.07237743726052223, + "church/seq6": 0.16036898378397238, + "church/seq7": 0.3396359061007401, + "church/seq8": 0.08269251857903062, + "church/seq9": 0.06737799332817955, + "church/seq10": 0.1548086352942968, + "church/seq11": 0.22712682738827603, + "church/seq12": 0.1913199875431373, + "advio-20": 1.5885449876734554, + "advio-21": 0.7564051077919463, + "advio-22": 2.127091722394325 + }, + "count_by_dataset": { + "marvin": 56, + "advio": 0 + }, + "count": 56, + "share_ok": 0.5357142857142857, + "pass": false, + "one_per_walk": { + "count": 20, + "share_ok": 0.6 + }, + "walk_offset_share_ok": 0.5357142857142857, + "walk_scale_removed_share_ok": 0.75, + "peak_over_r": { + "median": 1.892781842648455, + "p90": 6.127363407782957 + }, + "peak_over_allowance": { + "median": 0.14982825254332244, + "p90": 0.41553279638911944, + "share_within": 1.0 + }, + "r_in": { + "median": 13.24614290788934 + } + }, + "a2": { + "marvin": { + "walks": [ + { + "walk": "bar/seq1", + "windows_5m": 153, + "median_5m": 1.0132486425873355, + "sd_5m": 0.02041798771288748, + "robust_sd_5m": 0.017906355679220675, + "sd_10m": 0.013974660633363764, + "robust_sd_10m": 0.013516579662038555 + }, + { + "walk": "bar/seq2", + "windows_5m": 117, + "median_5m": 1.017270598945419, + "sd_5m": 0.015988835411004616, + "robust_sd_5m": 0.011435289938115182, + "sd_10m": 0.012789119317405137, + "robust_sd_10m": 0.008668258448848604 + }, + { + "walk": "bar/seq3", + "windows_5m": 104, + "median_5m": 1.0187310084488024, + "sd_5m": 0.013084871535283002, + "robust_sd_5m": 0.014658778236240675, + "sd_10m": 0.009327556321664066, + "robust_sd_10m": 0.008848652728481296 + }, + { + "walk": "bar/seq4", + "windows_5m": 112, + "median_5m": 1.0124619049798325, + "sd_5m": 0.042282973549817825, + "robust_sd_5m": 0.020837536194438015, + "sd_10m": 0.03212146904476728, + "robust_sd_10m": 0.01735927787685949 + }, + { + "walk": "bar/seq5", + "windows_5m": 119, + "median_5m": 1.005532062396286, + "sd_5m": 0.10444269211706086, + "robust_sd_5m": 0.03443151126244167, + "sd_10m": 0.06583206709834258, + "robust_sd_10m": 0.03194453540328688 + }, + { + "walk": "bar/seq6", + "windows_5m": 123, + "median_5m": 0.999765932396783, + "sd_5m": 0.19675805170550267, + "robust_sd_5m": 0.02304571922811731, + "sd_10m": 0.1713572189587108, + "robust_sd_10m": 0.021778265670255155 + }, + { + "walk": "bar/seq7", + "windows_5m": 136, + "median_5m": 1.0164343702553649, + "sd_5m": 0.02093656599810274, + "robust_sd_5m": 0.01572822494260743, + "sd_10m": 0.015431208933655982, + "robust_sd_10m": 0.011342922560075934 + }, + { + "walk": "bar/seq8", + "windows_5m": 166, + "median_5m": 1.0188947939593573, + "sd_5m": 0.06404794646962757, + "robust_sd_5m": 0.03318557847189994, + "sd_10m": 0.039567718676324165, + "robust_sd_10m": 0.03534376609551032 + }, + { + "walk": "bar/seq9", + "windows_5m": 162, + "median_5m": 1.006756375729659, + "sd_5m": 0.12599449666152912, + "robust_sd_5m": 0.02399151689235098, + "sd_10m": 0.10961862266369395, + "robust_sd_10m": 0.017034403482401053 + }, + { + "walk": "bar/seq10", + "windows_5m": 159, + "median_5m": 1.0063562856394448, + "sd_5m": 0.025768021474475857, + "robust_sd_5m": 0.02208767233721462, + "sd_10m": 0.016971008544086827, + "robust_sd_10m": 0.013692373298140296 + }, + { + "walk": "bar/seq11", + "windows_5m": 167, + "median_5m": 1.008722097602756, + "sd_5m": 0.04106616170381301, + "robust_sd_5m": 0.029431123996523093, + "sd_10m": 0.03575447781029782, + "robust_sd_10m": 0.0255875991506157 + }, + { + "walk": "bar/seq12", + "windows_5m": 159, + "median_5m": 0.9935982087052441, + "sd_5m": 0.07657956790981732, + "robust_sd_5m": 0.03705550785542915, + "sd_10m": 0.05143530886829172, + "robust_sd_10m": 0.021314383428434225 + }, + { + "walk": "bar/seq13", + "windows_5m": 107, + "median_5m": 1.0118527640964743, + "sd_5m": 0.025500446138543423, + "robust_sd_5m": 0.02155022170517527, + "sd_10m": 0.014500102309377334, + "robust_sd_10m": 0.011589342769096143 + }, + { + "walk": "church/seq1", + "windows_5m": 177, + "median_5m": 0.984031204154508, + "sd_5m": 0.016083342772575095, + "robust_sd_5m": 0.014343174170185501, + "sd_10m": 0.011272149692649811, + "robust_sd_10m": 0.009339182777870308 + }, + { + "walk": "church/seq2", + "windows_5m": 147, + "median_5m": 0.9897388822499957, + "sd_5m": 0.01510979496723052, + "robust_sd_5m": 0.014708124625209762, + "sd_10m": 0.009658385619244357, + "robust_sd_10m": 0.008255788500751217 + }, + { + "walk": "church/seq3", + "windows_5m": 174, + "median_5m": 0.9794105938798043, + "sd_5m": 0.012779569796562008, + "robust_sd_5m": 0.013015374864086897, + "sd_10m": 0.009587939976162781, + "robust_sd_10m": 0.008380418162065601 + }, + { + "walk": "church/seq4", + "windows_5m": 143, + "median_5m": 0.9780190961169889, + "sd_5m": 0.05875298962858606, + "robust_sd_5m": 0.010215862635852502, + "sd_10m": 0.034886627803775425, + "robust_sd_10m": 0.007674441698991181 + }, + { + "walk": "church/seq5", + "windows_5m": 183, + "median_5m": 0.982151406904885, + "sd_5m": 0.023710040603041504, + "robust_sd_5m": 0.01825441494260611, + "sd_10m": 0.019663349742832653, + "robust_sd_10m": 0.015720187097866305 + }, + { + "walk": "church/seq6", + "windows_5m": 137, + "median_5m": 0.9825843641899543, + "sd_5m": 0.010742490933442272, + "robust_sd_5m": 0.010660027747967033, + "sd_10m": 0.007284335802899762, + "robust_sd_10m": 0.007603168444267558 + }, + { + "walk": "church/seq7", + "windows_5m": 179, + "median_5m": 0.9858512743107942, + "sd_5m": 0.019373687234364417, + "robust_sd_5m": 0.02081841850430851, + "sd_10m": 0.014986335311139068, + "robust_sd_10m": 0.017708138182081314 + }, + { + "walk": "church/seq8", + "windows_5m": 176, + "median_5m": 0.9803346105017701, + "sd_5m": 0.02823357460372321, + "robust_sd_5m": 0.01754759942268788, + "sd_10m": 0.019577060825189865, + "robust_sd_10m": 0.012157518425793231 + }, + { + "walk": "church/seq9", + "windows_5m": 159, + "median_5m": 0.9827039547469477, + "sd_5m": 0.021738134152295772, + "robust_sd_5m": 0.014608965532764783, + "sd_10m": 0.017693263188772547, + "robust_sd_10m": 0.009676303277708147 + }, + { + "walk": "church/seq10", + "windows_5m": 168, + "median_5m": 0.9823124527273467, + "sd_5m": 0.0199242391501203, + "robust_sd_5m": 0.01840827388508803, + "sd_10m": 0.014701222561543029, + "robust_sd_10m": 0.013130256908799305 + }, + { + "walk": "church/seq11", + "windows_5m": 159, + "median_5m": 0.9879018462480095, + "sd_5m": 0.02505168404685514, + "robust_sd_5m": 0.016692495171860354, + "sd_10m": 0.019228220930844406, + "robust_sd_10m": 0.011845604392864001 + }, + { + "walk": "church/seq12", + "windows_5m": 196, + "median_5m": 0.98560869285639, + "sd_5m": 0.015548648304818344, + "robust_sd_5m": 0.015346839264878044, + "sd_10m": 0.012952430913587795, + "robust_sd_10m": 0.014796802306428632 + } + ], + "windows_5m": 3782, + "windows_dropped_as_turning": { + "5.0": 249, + "10.0": 547 + }, + "sd_5m": 0.05698584446099904, + "robust_sd_5m": 0.01829890544536861, + "sd_10m": 0.04499214451578157, + "robust_sd_10m": 0.013280423365657044, + "split_sd": { + "falls_with_length_at_5m": 0.0403830558659479, + "persistent": 0.04020690572353095 + }, + "split_robust_sd": { + "falls_with_length_at_5m": 0.014536404678252984, + "persistent": 0.011114984459215665 + }, + "verdict": "drop", + "share_over_10pct_from_walk_median_5m": 0.026705446853516657 + }, + "advio": { + "walks": [ + { + "walk": "advio-20", + "windows_5m": 387, + "median_5m": 0.8311015061713136, + "sd_5m": 0.1721796708586623, + "robust_sd_5m": 0.08563370414509283, + "sd_10m": 0.13945202348976074, + "robust_sd_10m": 0.07346392515552412 + }, + { + "walk": "advio-21", + "windows_5m": 375, + "median_5m": 0.9368585587320972, + "sd_5m": 0.1494016956563095, + "robust_sd_5m": 0.08596661993671632, + "sd_10m": 0.11643868973635815, + "robust_sd_10m": 0.07919934235482444 + }, + { + "walk": "advio-22", + "windows_5m": 505, + "median_5m": 0.9281418944560071, + "sd_5m": 0.23386930611446238, + "robust_sd_5m": 0.15417330080793096, + "sd_10m": 0.21957334760678934, + "robust_sd_10m": 0.14045069101287408 + } + ], + "windows_5m": 1267, + "windows_dropped_as_turning": { + "5.0": 0, + "10.0": 0 + }, + "sd_5m": 0.19356233988541618, + "robust_sd_5m": 0.1091482331396367, + "sd_10m": 0.17087857948846058, + "robust_sd_10m": 0.0955234332945145, + "split_sd": { + "falls_with_length_at_5m": 0.1049881929483707, + "persistent": 0.16261567809824815 + }, + "split_robust_sd": { + "falls_with_length_at_5m": 0.06097661288431738, + "persistent": 0.09052728582400218 + }, + "verdict": "drop", + "share_over_10pct_from_walk_median_5m": 0.39700078926598265 + } + }, + "a3": { + "marvin": { + "10": { + "windows": 5047, + "p90_along_in": 5.93244053666383, + "p90_across_in": 2.383144858716218, + "p90_horizontal_in": 6.356644425002893, + "median_horizontal_in": 2.6181875496018105, + "p90_vertical_in": 1.9654002913094888, + "p90_3d_in": 6.796782660764225, + "along_share_of_total_p90": 0.9332660661857188 + }, + "20": { + "windows": 4956, + "p90_along_in": 9.614029066697613, + "p90_across_in": 3.8201651149007376, + "p90_horizontal_in": 10.416259256830854, + "median_horizontal_in": 4.395873486077264, + "p90_vertical_in": 2.9551007554751285, + "p90_3d_in": 10.87021034634674, + "along_share_of_total_p90": 0.9229828895044881 + }, + "30": { + "windows": 4866, + "p90_along_in": 12.50959372814818, + "p90_across_in": 5.464582640836947, + "p90_horizontal_in": 13.628585737497145, + "median_horizontal_in": 6.016112751925093, + "p90_vertical_in": 3.6388989773814653, + "p90_3d_in": 14.139117730080716, + "along_share_of_total_p90": 0.917893754282206 + } + }, + "advio": { + "10": { + "windows": 1923, + "p90_along_in": 40.16740160527824, + "p90_across_in": 41.217205108912495, + "p90_horizontal_in": 55.603313400377814, + "median_horizontal_in": 26.202438486533016, + "p90_vertical_in": 3.697033252704125, + "p90_3d_in": 55.7048750882303, + "along_share_of_total_p90": 0.7223922307659694 + }, + "20": { + "windows": 1903, + "p90_along_in": 72.05520337403567, + "p90_across_in": 77.9870905225656, + "p90_horizontal_in": 94.28551696290192, + "median_horizontal_in": 50.231545101074694, + "p90_vertical_in": 6.375715770353499, + "p90_3d_in": 94.60404389848367, + "along_share_of_total_p90": 0.76422345334742 + }, + "30": { + "windows": 1889, + "p90_along_in": 97.37446063335635, + "p90_across_in": 110.39001216955775, + "p90_horizontal_in": 132.53687138322834, + "median_horizontal_in": 73.64854529941651, + "p90_vertical_in": 9.174807119748339, + "p90_3d_in": 132.57989402774612, + "along_share_of_total_p90": 0.7346971421394095 + } + } + }, + "a4": { + "marvin": { + "10": { + "before_p90_in": 6.356644425002893, + "after_p90_in_sigma_0.05": 4.060144192668973, + "after_p90_in_sigma_0.10": 7.129415660716873, + "after_p90_in_sigma_0.20": 13.538953070497568 + }, + "20": { + "before_p90_in": 10.416259256830854, + "after_p90_in_sigma_0.05": 4.8821363333966605, + "after_p90_in_sigma_0.10": 7.712543676167665, + "after_p90_in_sigma_0.20": 13.975444826011834 + }, + "30": { + "before_p90_in": 13.628585737497145, + "after_p90_in_sigma_0.05": 6.102297249670471, + "after_p90_in_sigma_0.10": 8.532586645014746, + "after_p90_in_sigma_0.20": 14.664426308330645 + } + }, + "advio": { + "10": { + "before_p90_in": 55.603313400377814, + "after_p90_in_sigma_0.05": 40.12746532581643, + "after_p90_in_sigma_0.10": 40.38966128003834, + "after_p90_in_sigma_0.20": 40.99962246875072 + }, + "20": { + "before_p90_in": 94.28551696290192, + "after_p90_in_sigma_0.05": 77.15925119564788, + "after_p90_in_sigma_0.10": 77.27860055197655, + "after_p90_in_sigma_0.20": 77.5511185895589 + }, + "30": { + "before_p90_in": 132.53687138322834, + "after_p90_in_sigma_0.05": 112.46921024704687, + "after_p90_in_sigma_0.10": 112.52115074566565, + "after_p90_in_sigma_0.20": 112.69292938307396 + } + } + }, + "a5": { + "advio-20": { + "windows": 387, + "spearman": -0.019356656641859305, + "median_excitation_m_s2": 1.173895618715156 + }, + "advio-21": { + "windows": 375, + "spearman": -0.07633837751735123, + "median_excitation_m_s2": 1.1016294822104986 + }, + "advio-22": { + "windows": 505, + "spearman": -0.18161114248070773, + "median_excitation_m_s2": 1.6666437476162586 + }, + "pooled": { + "windows": 1267, + "spearman": 0.019858281166852475 + } + } +} diff --git a/experiments/drift-anatomy/results/drift_anatomy.md b/experiments/drift-anatomy/results/drift_anatomy.md new file mode 100644 index 000000000..bece27d8d --- /dev/null +++ b/experiments/drift-anatomy/results/drift_anatomy.md @@ -0,0 +1,159 @@ +# Drift anatomy (generated by `uv run python run.py`) + +Evals harness commit 190ed339f41924ae0244e0b88c5ebe647ddd9eb4 (branch t3/evals). Walks: MARViN bar and church, 25 walks, all within 10% of the reference's scale; atrium is left out because its reference jitters by up to 0.4 m. ADVIO 20 to 22, 3 walks, against the ground truth rescaled to GPS. Heading offset: the per-sample offset at each loop's or window's start, as the evals use it. + +## A1, return to the meter + +Loops: reference points more than 30 s apart and under 0.3 m apart (3-D). Starts within 5 s of each other form one cluster, and each cluster keeps its closest pair. r is the horizontal error of ARKit's displacement at the return; the peak is the largest such error at the samples between. + +Loops: 56 (MARViN 56, ADVIO 0). Closest revisit per ADVIO walk, within the span where ARKit tracks: advio-20 1.59 m, advio-21 0.76 m, advio-22 2.13 m. On 20 and 21 the truth comes back closer to where it was in its first 2 s, but ARKit only starts tracking about 4 s in. + +Peak within max(2r, 2 in): 30 of 56 (54%). Criterion: at least 90% of at least 8 loops. **FAIL.** + +Not graded: median r 13.2 in. Peak / r: median 1.9, p90 6.1. Peak / (0.16 x largest excursion): median 0.15, p90 0.42; within the allowance in 100% of loops. Checks not in the pre-registration: one loop per walk (its closest pair), 20 loops, 60% within the bound; one heading offset per walk (its circular mean) instead of the sample at t0, 54% within the bound. + +Added after the run: a scale or heading error grows with distance from the start and cancels on return, so r cannot see it. With each walk's own scale divided out of ARKit, 75% of loops fall within the bound. + +| Walk | t0 (s) | t1 (s) | Gap (m) | Path (m) | Largest excursion (m) | r (in) | Peak (in) | max(2r, 2 in) | Within | 0.16 x excursion (in) | Peak, walk offset (in) | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| bar/seq1 | 24 | 105 | 0.14 | 69 | 28.5 | 17.8 | 19.3 | 35.6 | yes | 179.7 | 18.8 | +| bar/seq1 | 37 | 131 | 0.19 | 76 | 16.7 | 16.0 | 31.3 | 32.0 | yes | 105.2 | 30.4 | +| bar/seq1 | 54 | 158 | 0.17 | 79 | 32.3 | 6.3 | 30.7 | 12.6 | no | 203.7 | 23.3 | +| bar/seq1 | 63 | 197 | 0.08 | 100 | 26.9 | 7.6 | 20.2 | 15.3 | no | 169.8 | 21.8 | +| bar/seq1 | 72 | 134 | 0.17 | 47 | 18.0 | 3.4 | 14.0 | 6.7 | no | 113.5 | 15.1 | +| bar/seq1 | 122 | 212 | 0.18 | 70 | 32.9 | 12.6 | 21.0 | 25.2 | yes | 207.5 | 20.8 | +| bar/seq1 | 160 | 191 | 0.30 | 20 | 9.3 | 0.3 | 3.5 | 2.0 | no | 58.5 | 4.8 | +| bar/seq2 | 0 | 126 | 0.17 | 74 | 36.1 | 7.8 | 28.8 | 15.7 | no | 227.3 | 28.7 | +| bar/seq2 | 38 | 86 | 0.24 | 30 | 14.4 | 1.7 | 9.9 | 3.4 | no | 90.6 | 9.9 | +| bar/seq2 | 145 | 211 | 0.08 | 42 | 20.3 | 12.7 | 27.6 | 25.4 | no | 128.1 | 26.7 | +| bar/seq2 | 157 | 199 | 0.10 | 27 | 12.7 | 9.7 | 20.6 | 19.3 | no | 80.2 | 20.6 | +| bar/seq3 | 89 | 194 | 0.20 | 80 | 25.9 | 9.8 | 22.5 | 19.7 | no | 162.9 | 22.5 | +| bar/seq3 | 116 | 188 | 0.28 | 60 | 25.4 | 7.8 | 20.0 | 15.5 | no | 160.2 | 20.3 | +| bar/seq3 | 124 | 185 | 0.25 | 51 | 24.2 | 9.8 | 22.8 | 19.7 | no | 152.7 | 21.3 | +| bar/seq4 | 15 | 144 | 0.28 | 80 | 30.6 | 14.6 | 15.4 | 29.1 | yes | 192.6 | 15.1 | +| bar/seq4 | 26 | 114 | 0.12 | 56 | 24.0 | 7.8 | 16.5 | 15.5 | no | 151.3 | 17.3 | +| bar/seq4 | 73 | 193 | 0.27 | 76 | 35.0 | 43.7 | 45.8 | 87.4 | yes | 220.4 | 45.8 | +| bar/seq4 | 94 | 175 | 0.12 | 50 | 23.1 | 27.2 | 15.7 | 54.5 | yes | 145.5 | 15.0 | +| bar/seq5 | 13 | 74 | 0.28 | 46 | 21.4 | 96.4 | 98.6 | 192.8 | yes | 135.0 | 98.6 | +| bar/seq5 | 114 | 200 | 0.16 | 69 | 22.6 | 41.5 | 41.4 | 83.0 | yes | 142.5 | 41.2 | +| bar/seq5 | 123 | 171 | 0.20 | 41 | 16.4 | 26.8 | 32.5 | 53.5 | yes | 103.3 | 33.0 | +| bar/seq6 | 61 | 133 | 0.24 | 49 | 22.6 | 5.1 | 10.9 | 10.3 | no | 142.2 | 11.7 | +| bar/seq7 | 28 | 163 | 0.12 | 124 | 44.1 | 14.6 | 35.7 | 29.2 | no | 277.9 | 35.9 | +| bar/seq8 | 14 | 176 | 0.14 | 171 | 25.8 | 98.7 | 127.5 | 197.4 | yes | 162.8 | 116.4 | +| bar/seq8 | 37 | 80 | 0.16 | 49 | 21.9 | 47.8 | 49.0 | 95.6 | yes | 137.8 | 51.0 | +| bar/seq8 | 55 | 167 | 0.30 | 116 | 24.4 | 76.6 | 94.0 | 153.1 | yes | 153.8 | 99.7 | +| bar/seq8 | 64 | 191 | 0.20 | 127 | 21.9 | 77.7 | 94.0 | 155.3 | yes | 137.9 | 96.3 | +| bar/seq9 | 5 | 160 | 0.13 | 133 | 37.3 | 13.6 | 16.3 | 27.3 | yes | 234.8 | 26.2 | +| bar/seq9 | 13 | 151 | 0.23 | 117 | 31.3 | 6.1 | 25.2 | 12.1 | no | 197.4 | 23.4 | +| bar/seq9 | 51 | 116 | 0.26 | 52 | 21.6 | 2.5 | 16.3 | 5.1 | no | 136.2 | 17.9 | +| bar/seq10 | 85 | 197 | 0.13 | 102 | 32.7 | 29.0 | 46.4 | 58.1 | yes | 205.7 | 46.6 | +| bar/seq11 | 43 | 78 | 0.27 | 28 | 10.8 | 12.5 | 13.9 | 25.0 | yes | 67.7 | 14.5 | +| bar/seq11 | 71 | 167 | 0.24 | 79 | 24.7 | 25.8 | 30.2 | 51.7 | yes | 155.8 | 30.3 | +| bar/seq11 | 113 | 157 | 0.11 | 39 | 18.5 | 11.9 | 15.6 | 23.7 | yes | 116.6 | 14.7 | +| church/seq1 | 10 | 125 | 0.12 | 129 | 38.4 | 16.1 | 31.5 | 32.2 | yes | 241.9 | 31.7 | +| church/seq1 | 52 | 193 | 0.16 | 162 | 39.2 | 6.7 | 27.7 | 13.4 | no | 247.0 | 28.1 | +| church/seq3 | 19 | 141 | 0.19 | 137 | 42.4 | 28.8 | 46.5 | 57.7 | yes | 267.1 | 46.2 | +| church/seq5 | 95 | 184 | 0.20 | 101 | 35.5 | 2.3 | 38.4 | 4.6 | no | 223.6 | 35.8 | +| church/seq5 | 101 | 161 | 0.14 | 66 | 28.4 | 24.4 | 21.6 | 48.8 | yes | 178.8 | 21.6 | +| church/seq5 | 112 | 150 | 0.24 | 39 | 15.3 | 4.7 | 9.9 | 9.5 | no | 96.3 | 10.9 | +| church/seq5 | 157 | 193 | 0.07 | 45 | 20.5 | 13.0 | 22.4 | 26.0 | yes | 129.4 | 24.0 | +| church/seq6 | 4 | 190 | 0.16 | 133 | 40.6 | 18.4 | 33.3 | 36.7 | yes | 255.8 | 33.9 | +| church/seq6 | 9 | 194 | 0.24 | 132 | 41.8 | 20.7 | 37.9 | 41.4 | yes | 263.5 | 36.9 | +| church/seq8 | 8 | 187 | 0.11 | 200 | 32.6 | 13.5 | 92.6 | 27.0 | no | 205.5 | 65.5 | +| church/seq8 | 25 | 56 | 0.28 | 31 | 12.4 | 17.1 | 15.9 | 34.2 | yes | 78.3 | 14.9 | +| church/seq8 | 139 | 206 | 0.08 | 73 | 35.0 | 56.8 | 66.1 | 113.5 | yes | 220.7 | 66.2 | +| church/seq8 | 144 | 200 | 0.30 | 59 | 28.2 | 62.0 | 67.6 | 123.9 | yes | 177.7 | 68.3 | +| church/seq8 | 154 | 188 | 0.14 | 33 | 15.3 | 43.1 | 45.4 | 86.2 | yes | 96.2 | 46.0 | +| church/seq9 | 4 | 172 | 0.28 | 154 | 32.1 | 26.3 | 39.5 | 52.6 | yes | 201.9 | 38.2 | +| church/seq9 | 38 | 197 | 0.07 | 156 | 39.6 | 3.7 | 47.0 | 7.3 | no | 249.2 | 47.4 | +| church/seq9 | 59 | 90 | 0.12 | 26 | 8.9 | 0.8 | 4.4 | 2.0 | no | 56.0 | 4.2 | +| church/seq9 | 97 | 198 | 0.28 | 115 | 39.8 | 7.7 | 41.3 | 15.4 | no | 250.9 | 41.6 | +| church/seq10 | 13 | 204 | 0.15 | 174 | 41.4 | 9.8 | 29.7 | 19.6 | no | 260.9 | 30.1 | +| church/seq11 | 34 | 203 | 0.23 | 155 | 39.2 | 34.8 | 39.8 | 69.7 | yes | 247.2 | 39.2 | +| church/seq12 | 48 | 189 | 0.19 | 154 | 45.6 | 7.7 | 31.2 | 15.4 | no | 287.3 | 33.2 | +| church/seq12 | 63 | 202 | 0.26 | 154 | 32.4 | 2.4 | 34.6 | 4.9 | no | 203.9 | 33.7 | + +## A2, scale within a walk + +Local scale: ARKit's straight-line displacement over the reference's, on windows of 5 m and 10 m of reference path starting every 1 m. Windows whose reference chord is under 60% of their path (the walker turned back) are left out. Spread is within-walk: SD about each walk's mean, and 1.4826 x MAD about each walk's median, pooled over walks. The split fits SD(L)^2 = persistent^2 + (c/L)^2 to the 5 m and 10 m spreads. The part falling as 1/L is endpoint noise from either track, the reference's jitter included. + +| Data | 5 m windows (turning, left out) | SD 5 m | Robust SD 5 m | SD 10 m | Robust SD 10 m | 1/L part at 5 m (SD / robust) | Persistent part (SD / robust) | Criterion | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MARViN | 3782 (249) | 5.70% | 1.83% | 4.50% | 1.33% | 4.04% / 1.45% | 4.02% / 1.11% | over 3%: drop | +| ADVIO | 1267 (0) | 19.36% | 10.91% | 17.09% | 9.55% | 10.50% / 6.10% | 16.26% / 9.05% | over 3%: drop | + +Added after the run: on MARViN the SD is three times the robust SD because of a few windows where ARKit barely moves while the reference walks on, the disagreement that put three atrium walks in doubt in the evals. Share of 5 m windows more than 10% from their walk's median: MARViN 2.7%; ADVIO 39.7%. ADVIO's spread is broad, not a tail. Its truth is an inertial track pinned at fix points, so its own scale can wander between pins, and that does not fall as 1/L either. + +Per walk, 5 m windows (median local scale, SD, robust SD). MARViN's median is against a reference whose own scale is unverified; ADVIO's is against GPS. + +| Walk | Windows | Median | SD | Robust SD | +| --- | --- | --- | --- | --- | +| bar/seq1 | 153 | 1.013 | 2.04% | 1.79% | +| bar/seq2 | 117 | 1.017 | 1.60% | 1.14% | +| bar/seq3 | 104 | 1.019 | 1.31% | 1.47% | +| bar/seq4 | 112 | 1.012 | 4.23% | 2.08% | +| bar/seq5 | 119 | 1.006 | 10.44% | 3.44% | +| bar/seq6 | 123 | 1.000 | 19.68% | 2.30% | +| bar/seq7 | 136 | 1.016 | 2.09% | 1.57% | +| bar/seq8 | 166 | 1.019 | 6.40% | 3.32% | +| bar/seq9 | 162 | 1.007 | 12.60% | 2.40% | +| bar/seq10 | 159 | 1.006 | 2.58% | 2.21% | +| bar/seq11 | 167 | 1.009 | 4.11% | 2.94% | +| bar/seq12 | 159 | 0.994 | 7.66% | 3.71% | +| bar/seq13 | 107 | 1.012 | 2.55% | 2.16% | +| church/seq1 | 177 | 0.984 | 1.61% | 1.43% | +| church/seq2 | 147 | 0.990 | 1.51% | 1.47% | +| church/seq3 | 174 | 0.979 | 1.28% | 1.30% | +| church/seq4 | 143 | 0.978 | 5.88% | 1.02% | +| church/seq5 | 183 | 0.982 | 2.37% | 1.83% | +| church/seq6 | 137 | 0.983 | 1.07% | 1.07% | +| church/seq7 | 179 | 0.986 | 1.94% | 2.08% | +| church/seq8 | 176 | 0.980 | 2.82% | 1.75% | +| church/seq9 | 159 | 0.983 | 2.17% | 1.46% | +| church/seq10 | 168 | 0.982 | 1.99% | 1.84% | +| church/seq11 | 159 | 0.988 | 2.51% | 1.67% | +| church/seq12 | 196 | 0.986 | 1.55% | 1.53% | +| advio-20 | 387 | 0.831 | 17.22% | 8.56% | +| advio-21 | 375 | 0.937 | 14.94% | 8.60% | +| advio-22 | 505 | 0.928 | 23.39% | 15.42% | + +## A3, along and across travel + +The evals' windows: from every image (MARViN) or every 0.5 s (ADVIO) until the reference has covered exactly the distance. The horizontal error splits into the part along the reference's horizontal displacement and the part across it. p90 of |error|, inches. + +| Data | Walked | Windows | Along | Across | Horizontal total | Along / total | Vertical | 3-D total | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MARViN | 10 ft | 5047 | 5.9 | 2.4 | 6.4 | 0.93 | 2.0 | 6.8 | +| MARViN | 20 ft | 4956 | 9.6 | 3.8 | 10.4 | 0.92 | 3.0 | 10.9 | +| MARViN | 30 ft | 4866 | 12.5 | 5.5 | 13.6 | 0.92 | 3.6 | 14.1 | +| ADVIO | 10 ft | 1923 | 40.2 | 41.2 | 55.6 | 0.72 | 3.7 | 55.7 | +| ADVIO | 20 ft | 1903 | 72.1 | 78.0 | 94.3 | 0.76 | 6.4 | 94.6 | +| ADVIO | 30 ft | 1889 | 97.4 | 110.4 | 132.5 | 0.73 | 9.2 | 132.6 | + +Criterion at 20 ft: along p90 at most 0.60 of the horizontal total p90. MARViN 0.92, FAIL; ADVIO 0.76, FAIL. + +## A4, a range to the meter + +A simulated range from each window's start to its end: the reference's 3-D distance plus N(0, sigma), 20 draws per window (seed 0). ARKit's end moves radially in the horizontal plane about the start, keeping its height, onto the range. p90 horizontal error, inches. sigma 0.10 m is graded; 0.05 and 0.20 m are not. + +| Data | Walked | Before | After, 0.05 m | After, 0.10 m | After, 0.20 m | +| --- | --- | --- | --- | --- | --- | +| MARViN | 10 ft | 6.4 | 4.1 | 7.1 | 13.5 | +| MARViN | 20 ft | 10.4 | 4.9 | 7.7 | 14.0 | +| MARViN | 30 ft | 13.6 | 6.1 | 8.5 | 14.7 | +| ADVIO | 10 ft | 55.6 | 40.1 | 40.4 | 41.0 | +| ADVIO | 20 ft | 94.3 | 77.2 | 77.3 | 77.6 | +| ADVIO | 30 ft | 132.5 | 112.5 | 112.5 | 112.7 | + +Criterion: p90 at most 6 in at 20 and 30 ft, sigma 0.10 m. MARViN 7.7 and 8.5 in, FAIL; ADVIO 77.3 and 112.5 in, FAIL. The criterion is nearly out of reach at this sigma: the range's own error has a p90 of 6.5 in, and the correction passes it straight into the along-travel error while leaving the across-travel error untouched. + +## A5, IMU excitation and local scale (exploratory, not graded) + +ADVIO 5 m windows as in A2. Excitation is the SD of |a| from `iphone/accelerometer.csv` over the window's time span; the scale error is |local scale - 1| against the GPS-rescaled truth. A negative Spearman rank correlation would mean more motion goes with a truer scale. The windows overlap (5 m windows every 1 m), so the independent count is about a fifth of the window count. + +| Walk | Windows | Median excitation (m/s^2) | Spearman | +| --- | --- | --- | --- | +| advio-20 | 387 | 1.17 | -0.02 | +| advio-21 | 375 | 1.10 | -0.08 | +| advio-22 | 505 | 1.67 | -0.18 | +| pooled | 1267 | | +0.02 | diff --git a/experiments/drift-anatomy/run.py b/experiments/drift-anatomy/run.py new file mode 100644 index 000000000..51556068c --- /dev/null +++ b/experiments/drift-anatomy/run.py @@ -0,0 +1,538 @@ +"""Drift anatomy: A1 to A5 of README.md on MARViN (bar, church) and ADVIO 20 to 22. + + uv run python run.py # writes results/drift_anatomy.md and results/drift_anatomy.json + +Needs the evals harness (EVALS_HARNESS, branch t3/evals) and its data under HOUSE_SCANNING_DATA; +see walks.py. Takes no network access. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +from scipy.stats import norm, spearmanr + +from metrics import ( + along_across, + at, + find_loops, + local_scale, + loop_errors, + path_windows, + range_correct, + robust_sd, + split_by_length, + turn, +) +from walks import ( + EVALS_HARNESS, + EXPECTED_COMMIT, + Walk, + advio_walks, + harness_commit, + harness_modules, + marvin_walks, +) + +FEET = 0.3048 +INCH = 0.0254 +ALLOWANCE = 0.16 # the server's error allowance, ft per ft +RESULTS = Path(__file__).resolve().parent / "results" + +# A1, fixed in the README before the run. +LOOP_GAP_S, LOOP_DIST_M, LOOP_SEPARATION_S = 30.0, 0.3, 5.0 +PEAK_FLOOR_M = 2 * INCH +PEAK_FACTOR = 2.0 +MIN_LOOPS, LOOP_SHARE = 8, 0.90 +# A2. A window whose reference chord is under this share of its path turned back on itself; its +# ratio divides by a short chord and mostly measures endpoint noise, so it is left out. +SCALE_WINDOWS_M = (5.0, 10.0) +SCALE_EVERY_M = 1.0 +MIN_CHORD_SHARE = 0.6 +# A3 and A4. +WINDOW_FT = (10, 20, 30) +RANGE_SIGMAS_M = (0.05, 0.10, 0.20) +GRADED_SIGMA_M = 0.10 +RANGE_DRAWS = 20 +SEED = 0 + + +def p90(x: np.ndarray) -> float: + return float(np.percentile(x, 90)) + + +def by_dataset(walks: list[Walk]) -> dict[str, list[Walk]]: + out: dict[str, list[Walk]] = {} + for w in walks: + out.setdefault(w.dataset, []).append(w) + return out + + +# A1 + + +def closest_revisit_m(w: Walk) -> float: + """Smallest 3-D reference distance between two samples more than LOOP_GAP_S apart.""" + best = np.inf + for i in range(len(w.times)): + later = w.times - w.times[i] > LOOP_GAP_S + if not later.any(): + break + best = min(best, float(np.linalg.norm(w.ref[later] - w.ref[i], axis=1).min())) + return best + + +def a1(walks: list[Walk]) -> dict: + loops = [] + revisits = {w.name: closest_revisit_m(w) for w in walks} + for w in walks: + mean_offset = np.angle(np.mean(np.exp(1j * w.delta))) + for i, j in find_loops(w.times, w.ref, LOOP_GAP_S, LOOP_DIST_M, LOOP_SEPARATION_S): + e = loop_errors(w.ark, w.ref, w.delta, i, j) + # Sensitivity, not graded: one heading offset for the whole walk instead of the sample + # at t0, to see how much a single orientation sample's noise drives the peak. + e_mean = loop_errors(w.ark, w.ref, np.full(len(w.delta), mean_offset), i, j) + # Added after the run: a scale or heading error grows with distance from the start and + # vanishes on return, so r cannot see it. Dividing out the walk's own scale shows how + # much of the peak is scale. + e_scaled = loop_errors(w.ark / w.scale, w.ref, w.delta, i, j) + bound = max(PEAK_FACTOR * e["r"], PEAK_FLOOR_M) + loops.append( + { + "dataset": w.dataset, + "walk": w.name, + "t0_s": float(w.times[i] - w.times[0]), + "t1_s": float(w.times[j] - w.times[0]), + "ref_gap_m": float(np.linalg.norm(w.ref[j] - w.ref[i])), + "path_m": e["path_m"], + "excursion_m": e["excursion_m"], + "r_in": e["r"] / INCH, + "peak_in": e["peak"] / INCH, + "bound_in": bound / INCH, + "ok": bool(e["peak"] <= bound), + "allowance_in": ALLOWANCE * e["excursion_m"] / INCH, + "peak_walk_offset_in": e_mean["peak"] / INCH, + "ok_walk_offset": bool( + e_mean["peak"] <= max(PEAK_FACTOR * e_mean["r"], PEAK_FLOOR_M) + ), + "ok_walk_scale_removed": bool( + e_scaled["peak"] <= max(PEAK_FACTOR * e_scaled["r"], PEAK_FLOOR_M) + ), + } + ) + n = len(loops) + share = float(np.mean([x["ok"] for x in loops])) if n else float("nan") + # Not pre-registered: loops from one walk share its tracking, so check one loop per walk. + first = {} + for x in sorted(loops, key=lambda x: x["ref_gap_m"]): + first.setdefault(x["walk"], x) + ratio = np.array([x["peak_in"] / x["r_in"] for x in loops]) if n else np.array([]) + over = np.array([x["peak_in"] / x["allowance_in"] for x in loops]) if n else np.array([]) + return { + "loops": loops, + "closest_revisit_m": revisits, + "count_by_dataset": {d: sum(x["dataset"] == d for x in loops) for d in ("marvin", "advio")}, + "count": n, + "share_ok": share, + "pass": bool(n >= MIN_LOOPS and share >= LOOP_SHARE), + "one_per_walk": { + "count": len(first), + "share_ok": float(np.mean([x["ok"] for x in first.values()])) if first else None, + }, + "walk_offset_share_ok": float(np.mean([x["ok_walk_offset"] for x in loops])) if n else None, + "walk_scale_removed_share_ok": float(np.mean([x["ok_walk_scale_removed"] for x in loops])) + if n + else None, + "peak_over_r": {"median": float(np.median(ratio)), "p90": p90(ratio)} if n else None, + "peak_over_allowance": { + "median": float(np.median(over)), + "p90": p90(over), + "share_within": float(np.mean(over <= 1)), + } + if n + else None, + "r_in": {"median": float(np.median([x["r_in"] for x in loops]))} if n else None, + } + + +# A2 + + +def scale_windows(w: Walk, length_m: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + i, j = path_windows(w.ref, length_m, SCALE_EVERY_M) + chord = np.linalg.norm((at(w.ref, j) - w.ref[i])[:, [0, 2]], axis=1) + keep = chord >= MIN_CHORD_SHARE * length_m + i, j = i[keep], j[keep] + return i, j, local_scale(w.ark, w.ref, i, j) + + +def pooled_spread(per_walk: list[np.ndarray]) -> tuple[float, float]: + """Within-walk SD (deviations from each walk's mean) and robust SD (from each walk's median).""" + dev = np.concatenate([s - s.mean() for s in per_walk]) + dof = sum(len(s) - 1 for s in per_walk) + rdev = np.concatenate([s - np.median(s) for s in per_walk]) + return float(np.sqrt((dev**2).sum() / dof)), robust_sd(rdev) + + +def a2(walks: list[Walk]) -> dict: + out = {} + for dataset, ws in by_dataset(walks).items(): + per_len = {L: [scale_windows(w, L)[2] for w in ws] for L in SCALE_WINDOWS_M} + rows = [] + for k, w in enumerate(ws): + s5, s10 = per_len[5.0][k], per_len[10.0][k] + rows.append( + { + "walk": w.name, + "windows_5m": len(s5), + "median_5m": float(np.median(s5)), + "sd_5m": float(s5.std(ddof=1)), + "robust_sd_5m": robust_sd(s5), + "sd_10m": float(s10.std(ddof=1)), + "robust_sd_10m": robust_sd(s10), + } + ) + sd5, rsd5 = pooled_spread(per_len[5.0]) + sd10, rsd10 = pooled_spread(per_len[10.0]) + dropped = { + L: sum(len(path_windows(w.ref, L, SCALE_EVERY_M)[0]) for w in ws) + - sum(len(s) for s in per_len[L]) + for L in SCALE_WINDOWS_M + } + out[dataset] = { + "walks": rows, + "windows_5m": sum(len(s) for s in per_len[5.0]), + "windows_dropped_as_turning": dropped, + "sd_5m": sd5, + "robust_sd_5m": rsd5, + "sd_10m": sd10, + "robust_sd_10m": rsd10, + "split_sd": dict( + zip( + ("falls_with_length_at_5m", "persistent"), + split_by_length(sd5, sd10, 5.0, 10.0), + strict=True, + ) + ), + "split_robust_sd": dict( + zip( + ("falls_with_length_at_5m", "persistent"), + split_by_length(rsd5, rsd10, 5.0, 10.0), + strict=True, + ) + ), + "verdict": "pass" if sd5 < 0.01 else ("drop" if sd5 > 0.03 else "between"), + # Added after the run: the SD is several times the robust SD because of windows where + # ARKit barely moves while the reference walks on; this counts them. + "share_over_10pct_from_walk_median_5m": float( + np.mean(np.concatenate([np.abs(s - np.median(s)) > 0.10 for s in per_len[5.0]])) + ), + } + return out + + +# A3 and A4 + + +def window_errors(ws: list[Walk], ft: int, window_pairs) -> tuple[np.ndarray, np.ndarray]: + """ARKit's displacement turned by the heading offset at each window's start, and the + reference's, pooled over walks; the evals' windows (every start sample, exactly ft of path).""" + est, ref = [], [] + for w in ws: + i, j = window_pairs(w.ref, ft * FEET, w.start_step) + est.append(turn(at(w.ark, j) - w.ark[i], w.delta[i])) + ref.append(at(w.ref, j) - w.ref[i]) + return np.concatenate(est), np.concatenate(ref) + + +def a3_a4(walks: list[Walk]) -> tuple[dict, dict]: + _, drift, _ = harness_modules() + rng = np.random.default_rng(SEED) + a3, a4 = {}, {} + for dataset, ws in by_dataset(walks).items(): + a3[dataset], a4[dataset] = {}, {} + for ft in WINDOW_FT: + est, ref = window_errors(ws, ft, drift.window_pairs) + err = est - ref + along, across = along_across(err, ref) + horiz = np.hypot(along, across) + a3[dataset][ft] = { + "windows": len(err), + "p90_along_in": p90(np.abs(along)) / INCH, + "p90_across_in": p90(np.abs(across)) / INCH, + "p90_horizontal_in": p90(horiz) / INCH, + "median_horizontal_in": float(np.median(horiz)) / INCH, + "p90_vertical_in": p90(np.abs(err[:, 1])) / INCH, + "p90_3d_in": p90(np.linalg.norm(err, axis=1)) / INCH, + "along_share_of_total_p90": p90(np.abs(along)) / p90(horiz), + } + true_range = np.linalg.norm(ref, axis=1) + a4[dataset][ft] = {"before_p90_in": p90(horiz) / INCH} + for sigma in RANGE_SIGMAS_M: + noise = rng.normal(0.0, sigma, size=(RANGE_DRAWS, len(ref))) + measured = np.maximum(true_range[None] + noise, 0.0) + fixed = range_correct(np.tile(est, (RANGE_DRAWS, 1)), measured.ravel()) + after = np.linalg.norm((fixed - np.tile(ref, (RANGE_DRAWS, 1)))[:, [0, 2]], axis=1) + a4[dataset][ft][f"after_p90_in_sigma_{sigma:.2f}"] = p90(after) / INCH + return a3, a4 + + +# A5 + + +def a5(walks: list[Walk]) -> dict: + """Exploratory: per ADVIO 5 m window, |local scale - 1| against the SD of |a| (m/s^2).""" + rows = {} + pooled_x, pooled_y = [], [] + for w in walks: + if w.dataset != "advio": + continue + i, j, s = scale_windows(w, 5.0) + t_end = np.interp(j, np.arange(len(w.times)), w.times) + excite = np.empty(len(i)) + for k, (a, b) in enumerate(zip(w.times[i], t_end, strict=True)): + m = (w.accel_t >= a) & (w.accel_t <= b) + if m.sum() < 50: + raise ValueError( + f"{w.name}: only {m.sum()} accelerometer samples in {a:.1f}-{b:.1f} s" + ) + excite[k] = w.accel_norm[m].std() + err = np.abs(s - 1) + rho = spearmanr(excite, err).statistic + rows[w.name] = { + "windows": len(i), + "spearman": float(rho), + "median_excitation_m_s2": float(np.median(excite)), + } + pooled_x.append(excite) + pooled_y.append(err) + rows["pooled"] = { + "windows": int(sum(len(x) for x in pooled_x)), + "spearman": float(spearmanr(np.concatenate(pooled_x), np.concatenate(pooled_y)).statistic), + } + return rows + + +# Report + + +def fmt_pct(x: float) -> str: + return f"{100 * x:.2f}%" + + +def markdown(meta: dict, r1: dict, r2: dict, r3: dict, r4: dict, r5: dict) -> str: + L = [ + "# Drift anatomy (generated by `uv run python run.py`)", + "", + f"Evals harness commit {meta['harness_commit']} (branch t3/evals). Walks: MARViN bar and " + f"church, {meta['walks']['marvin']} walks, all within {int(100 * 0.10)}% of the reference's " + "scale; atrium is left out because its reference jitters by up to 0.4 m. ADVIO 20 to 22, " + f"{meta['walks']['advio']} walks, against the ground truth rescaled to GPS. Heading offset: " + "the per-sample offset at each loop's or window's start, as the evals use it.", + "", + "## A1, return to the meter", + "", + f"Loops: reference points more than {LOOP_GAP_S:.0f} s apart and under {LOOP_DIST_M} m " + f"apart (3-D). Starts within {LOOP_SEPARATION_S:.0f} s of each other form one cluster, " + "and each cluster keeps its closest pair. r is the horizontal error of ARKit's displacement " + "at the return; the peak is the largest such error at the samples between.", + "", + f"Loops: {r1['count']} (MARViN {r1['count_by_dataset']['marvin']}, ADVIO " + f"{r1['count_by_dataset']['advio']}). Closest revisit per ADVIO walk, within the span " + "where ARKit tracks: " + + ", ".join( + f"{name} {d:.2f} m" for name, d in r1["closest_revisit_m"].items() if "advio" in name + ) + + ". On 20 and 21 the truth comes back closer to where it was in its first 2 s, but ARKit " + "only starts tracking about 4 s in.", + "", + ] + if r1["count"]: + L += [ + f"Peak within max(2r, 2 in): {sum(x['ok'] for x in r1['loops'])} of {r1['count']} " + f"({100 * r1['share_ok']:.0f}%). Criterion: at least {int(100 * LOOP_SHARE)}% of at " + f"least {MIN_LOOPS} loops. **{'PASS' if r1['pass'] else 'FAIL'}.**", + "", + f"Not graded: median r {r1['r_in']['median']:.1f} in. Peak / r: median " + f"{r1['peak_over_r']['median']:.1f}, p90 {r1['peak_over_r']['p90']:.1f}. Peak / " + f"(0.16 x largest excursion): median {r1['peak_over_allowance']['median']:.2f}, p90 " + f"{r1['peak_over_allowance']['p90']:.2f}; within the allowance in " + f"{100 * r1['peak_over_allowance']['share_within']:.0f}% of loops. Checks not in the " + f"pre-registration: one loop per walk (its closest pair), {r1['one_per_walk']['count']} " + f"loops, {100 * r1['one_per_walk']['share_ok']:.0f}% within the bound; one heading " + f"offset per walk (its circular mean) instead of the sample at t0, " + f"{100 * r1['walk_offset_share_ok']:.0f}% within the bound.", + "", + "Added after the run: a scale or heading error grows with distance from the start and " + "cancels on return, so r cannot see it. With each walk's own scale divided out of " + f"ARKit, {100 * r1['walk_scale_removed_share_ok']:.0f}% of loops fall within the bound.", + "", + "| Walk | t0 (s) | t1 (s) | Gap (m) | Path (m) | Largest excursion (m) | r (in) | Peak (in) | max(2r, 2 in) | Within | 0.16 x excursion (in) | Peak, walk offset (in) |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for x in r1["loops"]: + L.append( + f"| {x['walk']} | {x['t0_s']:.0f} | {x['t1_s']:.0f} | {x['ref_gap_m']:.2f} | " + f"{x['path_m']:.0f} | {x['excursion_m']:.1f} | {x['r_in']:.1f} | {x['peak_in']:.1f} | " + f"{x['bound_in']:.1f} | {'yes' if x['ok'] else 'no'} | {x['allowance_in']:.1f} | " + f"{x['peak_walk_offset_in']:.1f} |" + ) + L += [ + "", + "## A2, scale within a walk", + "", + f"Local scale: ARKit's straight-line displacement over the reference's, on windows of 5 m " + f"and 10 m of reference path starting every {SCALE_EVERY_M:.0f} m. Windows whose " + f"reference chord is under {MIN_CHORD_SHARE:.0%} of their path (the walker turned back) " + "are left out. Spread is within-walk: SD about each walk's mean, and 1.4826 x MAD about " + "each walk's median, pooled over walks. The split fits SD(L)^2 = persistent^2 + (c/L)^2 " + "to the 5 m and 10 m spreads. The part falling as 1/L is endpoint noise from either track, " + "the reference's jitter included.", + "", + "| Data | 5 m windows (turning, left out) | SD 5 m | Robust SD 5 m | SD 10 m | Robust SD 10 m | 1/L part at 5 m (SD / robust) | Persistent part (SD / robust) | Criterion |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + names = {"marvin": "MARViN", "advio": "ADVIO"} + verdicts = {"pass": "under 1%: PASS", "drop": "over 3%: drop", "between": "1% to 3%: neither"} + for d, x in r2.items(): + L.append( + f"| {names[d]} | {x['windows_5m']} ({x['windows_dropped_as_turning'][5.0]}) | " + f"{fmt_pct(x['sd_5m'])} | {fmt_pct(x['robust_sd_5m'])} | {fmt_pct(x['sd_10m'])} | " + f"{fmt_pct(x['robust_sd_10m'])} | {fmt_pct(x['split_sd']['falls_with_length_at_5m'])} / " + f"{fmt_pct(x['split_robust_sd']['falls_with_length_at_5m'])} | " + f"{fmt_pct(x['split_sd']['persistent'])} / {fmt_pct(x['split_robust_sd']['persistent'])} | " + f"{verdicts[x['verdict']]} |" + ) + L += [ + "", + "Added after the run: on MARViN the SD is three times the robust SD because of a few " + "windows where ARKit barely moves while the reference walks on, the disagreement that put " + "three atrium walks in doubt in the evals. Share of 5 m windows more than 10% from their " + "walk's median: " + + "; ".join( + f"{names[d]} {100 * x['share_over_10pct_from_walk_median_5m']:.1f}%" + for d, x in r2.items() + ) + + ". ADVIO's spread is broad, not a tail. Its truth is an inertial track pinned at fix " + "points, so its own scale can wander between pins, and that does not fall as 1/L either.", + "", + "Per walk, 5 m windows (median local scale, SD, robust SD). MARViN's median is against a " + "reference whose own scale is unverified; ADVIO's is against GPS.", + "", + "| Walk | Windows | Median | SD | Robust SD |", + "| --- | --- | --- | --- | --- |", + ] + for x in r2.values(): + for w in x["walks"]: + L.append( + f"| {w['walk']} | {w['windows_5m']} | {w['median_5m']:.3f} | {fmt_pct(w['sd_5m'])} | " + f"{fmt_pct(w['robust_sd_5m'])} |" + ) + L += [ + "", + "## A3, along and across travel", + "", + "The evals' windows: from every image (MARViN) or every 0.5 s (ADVIO) until the reference " + "has covered exactly the distance. The horizontal error splits into the part along the " + "reference's horizontal displacement and the part across it. p90 of |error|, inches.", + "", + "| Data | Walked | Windows | Along | Across | Horizontal total | Along / total | Vertical | 3-D total |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for d, x in r3.items(): + for ft, y in x.items(): + L.append( + f"| {names[d]} | {ft} ft | {y['windows']} | {y['p90_along_in']:.1f} | " + f"{y['p90_across_in']:.1f} | {y['p90_horizontal_in']:.1f} | " + f"{y['along_share_of_total_p90']:.2f} | {y['p90_vertical_in']:.1f} | {y['p90_3d_in']:.1f} |" + ) + L += [ + "", + "Criterion at 20 ft: along p90 at most 0.60 of the horizontal total p90. " + + "; ".join( + f"{names[d]} {x[20]['along_share_of_total_p90']:.2f}, " + f"{'PASS' if x[20]['along_share_of_total_p90'] <= 0.60 else 'FAIL'}" + for d, x in r3.items() + ) + + ".", + "", + "## A4, a range to the meter", + "", + f"A simulated range from each window's start to its end: the reference's 3-D distance plus " + f"N(0, sigma), {RANGE_DRAWS} draws per window (seed {SEED}). ARKit's end moves radially in " + "the horizontal plane about the start, keeping its height, onto the range. p90 horizontal " + "error, inches. sigma 0.10 m is graded; 0.05 and 0.20 m are not.", + "", + "| Data | Walked | Before | After, 0.05 m | After, 0.10 m | After, 0.20 m |", + "| --- | --- | --- | --- | --- | --- |", + ] + for d, x in r4.items(): + for ft, y in x.items(): + L.append( + f"| {names[d]} | {ft} ft | {y['before_p90_in']:.1f} | " + f"{y['after_p90_in_sigma_0.05']:.1f} | {y['after_p90_in_sigma_0.10']:.1f} | " + f"{y['after_p90_in_sigma_0.20']:.1f} |" + ) + L += [ + "", + "Criterion: p90 at most 6 in at 20 and 30 ft, sigma 0.10 m. " + + "; ".join( + f"{names[d]} {x[20]['after_p90_in_sigma_0.10']:.1f} and " + f"{x[30]['after_p90_in_sigma_0.10']:.1f} in, " + f"{'PASS' if max(x[20]['after_p90_in_sigma_0.10'], x[30]['after_p90_in_sigma_0.10']) <= 6 else 'FAIL'}" + for d, x in r4.items() + ) + + f". The criterion is nearly out of reach at this sigma: the range's own error has a p90 " + f"of {norm.ppf(0.95) * GRADED_SIGMA_M / INCH:.1f} in, and the correction passes it straight " + "into the along-travel error while leaving the across-travel error untouched.", + "", + "## A5, IMU excitation and local scale (exploratory, not graded)", + "", + "ADVIO 5 m windows as in A2. Excitation is the SD of |a| from `iphone/accelerometer.csv` " + "over the window's time span; the scale error is |local scale - 1| against the " + "GPS-rescaled truth. A negative Spearman rank correlation would mean more motion goes with " + "a truer scale. The windows overlap (5 m windows every 1 m), so the independent count is " + "about a fifth of the window count.", + "", + "| Walk | Windows | Median excitation (m/s^2) | Spearman |", + "| --- | --- | --- | --- |", + ] + for name, x in r5.items(): + exc = f"{x['median_excitation_m_s2']:.2f}" if "median_excitation_m_s2" in x else "" + L.append(f"| {name} | {x['windows']} | {exc} | {x['spearman']:+.2f} |") + return "\n".join(L) + "\n" + + +def main() -> None: + commit = harness_commit() + if not commit.startswith(EXPECTED_COMMIT): + raise RuntimeError( + f"evals harness at {EVALS_HARNESS} is at {commit}, not {EXPECTED_COMMIT}; the loaders " + "mirror that commit's code" + ) + mw, dropped = marvin_walks() + walks = mw + advio_walks() + meta = { + "harness_commit": commit, + "command": "uv run python run.py", + "walks": {"marvin": len(mw), "advio": len(walks) - len(mw)}, + "marvin_dropped_off_scale": dropped, + "walk_scale": {w.name: w.scale for w in walks}, + "seed": SEED, + } + r1 = a1(walks) + r2 = a2(walks) + r3, r4 = a3_a4(walks) + r5 = a5(walks) + RESULTS.mkdir(exist_ok=True) + report = {"meta": meta, "a1": r1, "a2": r2, "a3": r3, "a4": r4, "a5": r5} + (RESULTS / "drift_anatomy.json").write_text(json.dumps(report, indent=1, default=str) + "\n") + md = markdown(meta, r1, r2, r3, r4, r5) + (RESULTS / "drift_anatomy.md").write_text(md) + print(md) + + +if __name__ == "__main__": + main() diff --git a/experiments/drift-anatomy/tests/test_metrics.py b/experiments/drift-anatomy/tests/test_metrics.py new file mode 100644 index 000000000..3b49b968e --- /dev/null +++ b/experiments/drift-anatomy/tests/test_metrics.py @@ -0,0 +1,218 @@ +"""Strict checks of metrics.py on synthetic trajectories with known answers.""" + +import numpy as np +import pytest + +from metrics import ( + along_across, + at, + find_loops, + local_scale, + loop_errors, + path_length, + path_windows, + range_correct, + split_by_length, + turn, + yaw_offset, +) + + +def rot_y(a: float) -> np.ndarray: + c, s = np.cos(a), np.sin(a) + return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]]) + + +def square_lap(side_m: float = 15.0, speed: float = 1.0, hz: float = 1.0) -> np.ndarray: + """A square walked counter-clockwise from the origin back to it, y up, samples at `hz`.""" + corners = np.array([[0, 0], [side_m, 0], [side_m, side_m], [0, side_m], [0, 0]], float) + s = np.arange(0, 4 * side_m + 1e-9, speed / hz) + seg = np.minimum((s // side_m).astype(int), 3) + f = (s - seg * side_m) / side_m + xz = corners[seg] * (1 - f[:, None]) + corners[seg + 1] * f[:, None] + return np.c_[xz[:, 0], np.full(len(s), 1.4), xz[:, 1]] + + +def stand(p: np.ndarray, seconds: int) -> np.ndarray: + return np.repeat(p[None], seconds, axis=0) + + +# Loop detection + + +def test_pause_at_the_start_and_end_counts_one_loop(): + lap = square_lap() # 61 samples, origin at both ends + ref = np.vstack([stand(lap[0], 10), lap[1:], stand(lap[-1], 10)]) # back at the origin at 69 + times = np.arange(len(ref), dtype=float) + assert find_loops(times, ref) == [(0, 69)] + + +def test_two_laps_collapse_to_one_loop_because_every_start_revisits(): + lap = square_lap() + ref = np.vstack([lap, lap[1:]]) + times = np.arange(len(ref), dtype=float) + # Every sample of lap 1 has a twin 60 s later, so the starts form one cluster. + assert find_loops(times, ref) == [(0, 60)] + + +def test_two_separate_returns_count_two_loops(): + lap = square_lap() + away = lap[:31] # out to the far corner + ref = np.vstack([lap, away[1:], away[::-1][1:], stand(lap[0], 1)]) + times = np.arange(len(ref), dtype=float) + # Lap 1 returns at 60 and the out-and-back from 60 returns at 120. Starts 0 to 30 revisit (the + # out-and-back retraces lap 1's first half) and starts 60 to 74 revisit on the way back, so + # there are two clusters with a gap of 30 s between them. + assert find_loops(times, ref) == [(0, 60), (60, 120)] + + +def test_returns_under_30_s_or_over_the_distance_are_not_loops(): + lap = square_lap(side_m=5.0) # 20 s lap + times = np.arange(len(lap), dtype=float) + assert find_loops(times, lap) == [] + big = square_lap() + for miss, expected in ((0.25, 1), (0.35, 0)): + ref = big.copy() + ref[-1, 0] += miss + found = find_loops(np.arange(len(ref), dtype=float), ref) + assert len(found) == expected + + +def test_find_loops_rejects_unordered_times(): + with pytest.raises(ValueError, match="increase"): + find_loops(np.array([0.0, 2.0, 1.0]), np.zeros((3, 3))) + + +# Heading alignment + + +def test_turn_matches_the_rotation_matrix_about_y(): + v = np.random.default_rng(0).normal(size=(20, 3)) + a = 0.7 + np.testing.assert_allclose(turn(v, a), v @ rot_y(a).T, atol=1e-12) + + +def test_yaw_offset_recovers_a_planted_heading_and_aligns_displacements(): + rng = np.random.default_rng(1) + theta = np.radians(-137.0) + ref = np.cumsum(rng.normal(size=(50, 3)), axis=0) + # Arbitrary reference orientations: yaw, then a pitch and roll. + R_ref = np.array( + [ + rot_y(y) @ np.array([[1, 0, 0], [0, np.cos(p), -np.sin(p)], [0, np.sin(p), np.cos(p)]]) + for y, p in zip(rng.uniform(-3, 3, 50), rng.uniform(-0.3, 0.3, 50), strict=True) + ] + ) + # ARKit's world is the reference's turned by -theta about y and shifted. + ark = ref @ rot_y(-theta).T + np.array([4.0, -2.0, 9.0]) + R_ark = rot_y(-theta) @ R_ref + delta = yaw_offset(R_ark, R_ref) + np.testing.assert_allclose(np.angle(np.exp(1j * (delta - theta))), 0, atol=1e-12) + np.testing.assert_allclose(turn(ark[7:] - ark[3], delta[3]), ref[7:] - ref[3], atol=1e-9) + + +def test_loop_errors_use_the_offset_at_the_loop_start_and_find_the_peak(): + lap = square_lap() + ref = np.vstack([stand(lap[0], 5), lap]) + n = len(ref) + delta = np.linspace(0.2, 0.9, n) # varies, so using any sample but the start would be wrong + ark = turn(ref, -delta[5]) + bump = np.zeros((n, 3)) + bump[30] = [0.3, 0.5, 0.4] # horizontal size 0.5 + ark = ark + turn(bump, -delta[5]) + ark[-1] += turn(np.array([[0.03, 0.0, -0.04]]), -delta[5])[0] # r = 0.05 + out = loop_errors(ark, ref, delta, 5, n - 1) + assert out["r"] == pytest.approx(0.05, abs=1e-12) + assert out["peak"] == pytest.approx(0.5, abs=1e-12) + assert out["path_m"] == pytest.approx(60.0, abs=1e-9) + assert out["excursion_m"] == pytest.approx(15 * np.sqrt(2), abs=1e-9) + + +# Local scale + + +def test_windows_cover_exactly_the_path_length(): + ref = square_lap(hz=3.0) + i, j = path_windows(ref, 5.0, 1.0) + walked = path_length(ref) + ends = np.interp(j, np.arange(len(ref)), walked) + np.testing.assert_allclose(ends - walked[i], 5.0, atol=1e-9) + np.testing.assert_allclose(walked[i], np.arange(len(i)), atol=0.34) + + +def test_local_scale_reads_a_planted_three_percent(): + rng = np.random.default_rng(2) + ref = np.cumsum(rng.normal(scale=0.3, size=(400, 3)) * [1, 0.1, 1], axis=0) + ark = 1.03 * ref @ rot_y(1.1).T + 5.0 + i, j = path_windows(ref, 5.0, 1.0) + np.testing.assert_allclose(local_scale(ark, ref, i, j), 1.03, atol=1e-12) + + +def test_local_scale_follows_a_scale_change_within_the_walk(): + ref = np.c_[np.arange(0, 40.01, 0.25), np.zeros(161), np.zeros(161)] + ark = ref.copy() + ark[81:, 0] = ark[80, 0] + 1.03 * (ref[81:, 0] - ref[80, 0]) # 3% long after 20 m + i, j = path_windows(ref, 5.0, 1.0) + s = local_scale(ark, ref, i, j) + start, end = ref[i, 0], at(ref, j)[:, 0] + np.testing.assert_allclose(s[end <= 20.0], 1.0, atol=1e-12) + np.testing.assert_allclose(s[start >= 20.0], 1.03, atol=1e-12) + + +def test_split_by_length_recovers_both_parts(): + persistent, c = 0.004, 0.05 + sd5 = np.hypot(persistent, c / 5) + sd10 = np.hypot(persistent, c / 10) + falling5, kept = split_by_length(sd5, sd10, 5.0, 10.0) + assert falling5 == pytest.approx(c / 5, rel=1e-9) + assert kept == pytest.approx(persistent, rel=1e-9) + + +# Along and across travel + + +def test_along_across_split_a_known_error(): + travel = np.array([[3.0, 0.2, 4.0]]) # horizontal direction (0.6, 0.8) + err = np.array([[0.6 * 2 - 0.8 * 5, 7.0, 0.8 * 2 + 0.6 * 5]]) # 2 along, 5 across + along, across = along_across(err, travel) + assert along[0] == pytest.approx(2.0, abs=1e-12) + assert abs(across[0]) == pytest.approx(5.0, abs=1e-12) + + +def test_along_across_is_pythagorean_and_turn_invariant(): + rng = np.random.default_rng(3) + err, travel = rng.normal(size=(100, 3)), rng.normal(size=(100, 3)) + along, across = along_across(err, travel) + np.testing.assert_allclose(along**2 + across**2, (err[:, [0, 2]] ** 2).sum(1), rtol=1e-12) + a2, c2 = along_across(turn(err, 0.4), turn(travel, 0.4)) + np.testing.assert_allclose(a2, along, atol=1e-12) + np.testing.assert_allclose(c2, across, atol=1e-12) + + +# Range to the meter + + +def test_range_correction_is_exact_without_noise_when_the_direction_is_right(): + rng = np.random.default_rng(4) + truth = rng.normal(scale=5.0, size=(200, 3)) + est = truth.copy() + est[:, [0, 2]] *= rng.uniform(0.8, 1.2, size=(200, 1)) # scale error only + fixed = range_correct(est, np.linalg.norm(truth, axis=1)) + np.testing.assert_allclose(fixed, truth, atol=1e-9) + + +def test_range_correction_keeps_height_and_direction_and_meets_the_range(): + rng = np.random.default_rng(5) + est = rng.normal(scale=5.0, size=(200, 3)) + ranges = rng.uniform(np.abs(est[:, 1]) + 0.1, 20.0) + fixed = range_correct(est, ranges) + np.testing.assert_allclose(np.linalg.norm(fixed, axis=1), ranges, rtol=1e-12) + np.testing.assert_allclose(fixed[:, 1], est[:, 1]) + cross = fixed[:, 0] * est[:, 2] - fixed[:, 2] * est[:, 0] + np.testing.assert_allclose(cross, 0, atol=1e-9) + assert np.all(fixed[:, 0] * est[:, 0] + fixed[:, 2] * est[:, 2] > 0) + + +def test_range_shorter_than_the_height_lands_on_the_vertical(): + fixed = range_correct(np.array([[1.0, 2.0, 1.0]]), np.array([1.5])) + np.testing.assert_allclose(fixed, [[0.0, 2.0, 0.0]]) diff --git a/experiments/drift-anatomy/uv.lock b/experiments/drift-anatomy/uv.lock new file mode 100644 index 000000000..9d0bee4f4 --- /dev/null +++ b/experiments/drift-anatomy/uv.lock @@ -0,0 +1,260 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "drift-anatomy" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.1" }, + { name = "scipy", specifier = ">=1.14" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] diff --git a/experiments/drift-anatomy/walks.py b/experiments/drift-anatomy/walks.py new file mode 100644 index 000000000..09000bb78 --- /dev/null +++ b/experiments/drift-anatomy/walks.py @@ -0,0 +1,176 @@ +"""Walks with ARKit's track, a reference track and the heading offset between them, loaded through +the evals harness (branch t3/evals) so the alignment is the one the evals' tables used. + +The harness is imported read-only from EVALS_HARNESS (default: the house-scanning-evals checkout). +Nothing here writes into it: bytecode caching is off before the import. Data comes from +HOUSE_SCANNING_DATA (default ~/house-scanning-data), as in the evals. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import types +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +from metrics import yaw_offset + +EVALS_HARNESS = Path( + os.environ.get( + "EVALS_HARNESS", + Path.home() / "Programming Projects/house-scanning-evals/experiments/evals", + ) +) +EXPECTED_COMMIT = "190ed33" +MARVIN_SITES = ("bar", "church") # atrium left out: its reference jitters by up to 0.4 m +ADVIO_WALKS = (20, 21, 22) # 23 lost tracking 5.5 s in +SCALE_OUTLIER = ( + 0.10 # walks further than this from the reference's scale are dropped, as in the evals +) + + +@dataclass +class Walk: + dataset: str # "marvin" or "advio" + name: str # "bar/seq3", "advio-21" + times: np.ndarray # (N,) seconds + ark: np.ndarray # (N, 3) ARKit positions, y up, in a frame with the reference's handedness + ref: np.ndarray # (N, 3) reference positions, y up, meters + delta: np.ndarray # (N,) heading offset, reference minus ARKit, radians + start_step: int # samples between window starts in the evals' 3 to 30 ft windows + scale: float # ARKit / reference, the evals' median over windows of 10 ft or more + accel_t: np.ndarray = field(default_factory=lambda: np.empty(0)) + accel_norm: np.ndarray = field(default_factory=lambda: np.empty(0)) # |a|, m/s^2 + + +def harness_modules(): + if not (EVALS_HARNESS / "evals" / "drift.py").exists(): + raise FileNotFoundError( + f"evals harness not found at {EVALS_HARNESS}; set EVALS_HARNESS to the " + "experiments/evals folder of a house-scanning checkout on branch t3/evals" + ) + sys.dont_write_bytecode = True + # evals.modern_arkit imports gdown for its fetch(), which this experiment never calls: the data + # is already on disk and this run makes no downloads. Any use of the stand-in fails loudly. + if "gdown" not in sys.modules: + stub = types.ModuleType("gdown") + + def _refuse(name: str): + raise RuntimeError(f"gdown.{name}: drift-anatomy never downloads") + + stub.__getattr__ = _refuse # type: ignore[method-assign] + sys.modules["gdown"] = stub + if str(EVALS_HARNESS) not in sys.path: + sys.path.insert(0, str(EVALS_HARNESS)) + import evals.advio as advio + import evals.drift as drift + import evals.modern_arkit as modern_arkit + + return advio, drift, modern_arkit + + +def harness_commit() -> str: + out = subprocess.run( + ["git", "-C", str(EVALS_HARNESS), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + return out.stdout.strip() + + +def marvin_walks() -> tuple[list[Walk], list[tuple[str, float]]]: + """Bar and church walks, and the (name, scale) of walks dropped for being off the reference's + scale by more than SCALE_OUTLIER. MARViN has one image a second (evals README, section 6), so + an image's number is its time in seconds.""" + _, drift, ma = harness_modules() + walks, dropped = [], [] + for site in MARVIN_SITES: + root = ma.MARVIN_DIR / site + truth = {**ma.read_table(root / "train.txt", 8), **ma.read_table(root / "test.txt", 8)} + frame = ma.level_frame(np.array([v[:3] for v in truth.values()])) + for seq in sorted(root.glob("seq*/ARkitPose.txt"), key=lambda p: int(p.parent.name[3:])): + name = seq.parent.name + ark_rows = ma.read_table(seq, 8) + keys = sorted(k for k in ark_rows if f"{name}/{k}" in truth) + if len(keys) < 30: # the evals' own cut: walks without a reference + continue + # Mirrors evals.modern_arkit.evaluate: level the reference, y up, pair the handedness. + t = np.array([truth[f"{name}/{k}"][:3] for k in keys]) @ frame.T + t = t[:, [0, 2, 1]] + fwd = ( + np.array([ma.quat_wxyz_to_matrix(truth[f"{name}/{k}"][3:])[2] for k in keys]) + @ frame.T + ) + ark, delta, _ = ma.align_headings( + np.array([ark_rows[k][:3] for k in keys]), + np.array([ark_rows[k][3:] for k in keys]), + t, + fwd[:, [0, 2, 1]], + ) + errs = ma.walk_errors(ark, t, delta) + ratios = np.concatenate( + [errs[ft]["ark"] / errs[ft]["truth"] for ft in drift.DISTANCES_FT if ft >= 10] + ) + scale = float(np.median(ratios)) + label = f"{site}/{name}" + if abs(scale - 1) > SCALE_OUTLIER: + dropped.append((label, scale)) + continue + times = np.array([float(k.split("_")[1].split(".")[0]) for k in keys]) + walks.append(Walk("marvin", label, times, ark, t, delta, 1, scale)) + return walks, dropped + + +def advio_walks() -> list[Walk]: + """ADVIO 20 to 22 at 10 Hz against the ground truth rescaled to GPS, exactly as + evals.drift.evaluate_sequence builds them.""" + advio, drift, _ = harness_modules() + walks = [] + for number in ADVIO_WALKS: + root = drift.ADVIO_DIR / f"advio-{number:02d}" + seq = advio.load_sequence(root) + ark, truth = seq.arkit, seq.ground_truth + arcore = advio._read_pose_csv(root / "pixel" / "arcore.csv") + started = ark.t[np.linalg.norm(ark.p, axis=1) > 0][0] + t0 = max(started, truth.t[0], arcore.t[0]) + t1 = min(ark.t[-1], truth.t[-1], arcore.t[-1]) + times = np.arange(t0, t1, 1.0 / drift.SAMPLE_HZ) + A = ark.interpolate_positions(times) + failed = drift.tracking_failure_time(A, times) + if failed is not None: + raise RuntimeError(f"advio-{number}: ARKit lost tracking at {failed:.1f} s") + check = drift.reference_scale_check(root, truth, ark, arcore, times) + ref = truth.interpolate_positions(times) * check.gps_over_truth + ark_R = ark.R[[ark.nearest(x) for x in times]] + truth_R = truth.R[[truth.nearest(x) for x in times]] + delta = yaw_offset(ark_R, truth_R) + step = int(drift.START_EVERY_S * drift.SAMPLE_HZ) + ratios = [] + for ft in drift.DISTANCES_FT: + if ft >= 10: + i, j = drift.window_pairs(ref, ft * drift.FEET, step) + ratios.append( + np.linalg.norm(drift.at(A, j) - A[i], axis=1) + / np.linalg.norm(drift.at(ref, j) - ref[i], axis=1) + ) + acc = np.loadtxt(root / "iphone" / "accelerometer.csv", delimiter=",", ndmin=2) + walks.append( + Walk( + "advio", + f"advio-{number}", + times, + A, + ref, + delta, + step, + float(np.median(np.concatenate(ratios))), + accel_t=acc[:, 0], + accel_norm=np.linalg.norm(acc[:, 1:4], axis=1), + ) + ) + return walks diff --git a/experiments/edge-geometry/METHODS.md b/experiments/edge-geometry/METHODS.md new file mode 100644 index 000000000..cacfc3c06 --- /dev/null +++ b/experiments/edge-geometry/METHODS.md @@ -0,0 +1,42 @@ +# Edge geometry: methods and full results + +The README is the front door. This file keeps the method as it was pre-registered in 397507f, and the full result as the run reported it. + +## Question + +Clearances start at edges: a window jamb, the side of an AC unit. Learned depth is 8 in or worse there. How much of that error lands along the wall, where the rules measure, and how does it depend on the angle a photo sees the edge from? Can an edge be placed better without reading depth at it? + +The data is ETH3D electro and facade: photos within 6 m, and the evals' depth-edge points taken on their near side, so they are object edges rather than the wall behind them. The points are split into in-plane edges, within 5 cm of a wall plane, and proud edges. "Along the wall" means the horizontal component of the error within that wall plane. The view angle is measured between the ray and the wall's normal. + +- **M0, depth at the pixel.** MoGe-2 depth, with each photo's scale set from the laser scan so that only the edge's own error remains. +- **M1, ray to the wall plane.** The plane is fitted to the same photo's depth on wall interior pixels. +- **M2, tap, snap and median.** A tap is jittered by σ = 10 px, snapped to the strongest vertical image edge within 25 px, and intersected with the wall plane. The median is taken over every photo that sees the point. +- **M3, two photos.** The edge is matched along the epipolar line by patch correlation and triangulated. + +- **M1-anchor, the app's way.** The app intersects every feature tap with one plane, built from its single raycast at the meter (`ScanEngine+Actions.swift` on `t3/ios-mvf`). M1-anchor fits that plane to a 0.3 m patch around a wall point 1 to 3 m from the edge. The fit's yaw error ψ should move the edge by about s·ψ·tan(view angle), which is the suspected cause of #30. + +M2 and M3 run with `exact` and `modern_assumed` poses. + +## Pass criteria, fixed before the run + +- **P1, angle.** M0's along-wall p90 at 0–15° is at most half its p90 beyond 30°. +- **P2.** M1's along-wall p90 on in-plane edges is at most 4 in. Reported beside it, not graded: M1-anchor, and its error against s·ψ·tan(view angle). +- **P3.** With `modern_assumed` poses, M2's along-wall p90 on in-plane edges is at most 2 in. +- **P4.** With `modern_assumed` poses, M3's 3-D p90 is at most 2 in, and at least 70% of points match. +- **Kill.** If M0 is already within 4 in along the wall at every angle, edges don't limit along-wall clearances. + +## Result + +Electro: 3,000 edge points on 21 laser wall planes, 2,258 in-plane and 742 proud. Numbers are p90 in inches, from `results/edge_geometry.md`. M0 to M2 use the oracle scale and take wall pixels from the laser planes. + +- **P1 passes.** M0 along the wall is 1.4 at 0–15° and 19.8 beyond 30°. +- **P2 fails.** M1 is 6.7: 0.5 at 0–15°, 15.0 beyond 45°. M1-anchor is 10.4. Its yaw error ψ has a median of 2.2° and a p90 of 6.7°. The error's median ratio to s·ψ·tan(angle) is 0.94, and the signs agree 74% of the time, but the signed fit explains only 2% of the variance. +- **P3 fails.** M2 is 12.6 (95% CI 11.7–13.8). +- **P4 fails.** 41% of points match, and the anchor-to-edge span's 3-D p90 is 155. +- **Kill** is not triggered: 1.4, 4.6, 8.9 and 24.4 by angle bin. + +Added after the run: M2 with the true pixel still gives 11.5. The MoGe-2 wall plane misses the edge point by 10.0 at p90, where the laser plane misses it by 1.4. A snap started at the true pixel moves 8.8 px on vertical edges, so the snap's pixel error is ill-posed for these points. M3 matches within 2 px give 2.9 with exact poses and 8.9 with `modern_assumed`. Facade has 866 points but no wall within 6 m, so only M0 runs there. + +Run: `uv run python run.py`. It takes 2 minutes and 0.9 GB. It needs the evals checkout at `190ed33` (`EDGE_EVALS_DIR`) and its ETH3D, MoGe-2 and pose caches. + +What it changes: the wall plane's depth error times tan(view angle) sets the edge error, so get the edge photo within 30° of head-on. A snapped tap can't beat the plane's error, and patch matching at edges fails on more than half the points. diff --git a/experiments/edge-geometry/README.md b/experiments/edge-geometry/README.md new file mode 100644 index 000000000..36d3563d8 --- /dev/null +++ b/experiments/edge-geometry/README.md @@ -0,0 +1,26 @@ +# Edge geometry + +## Question + +Clearances start at edges, and learned depth is 8 in or worse there. How much of that error lands along the wall, and how does it depend on the angle a photo sees the edge from? It compares four ways to place an edge (M0 to M3), plus the app's plane from one patch (M1-anchor), on ETH3D electro's laser truth. [METHODS.md](METHODS.md) defines them. + +## Pass criteria, fixed before the run (397507f) + +- **P1, angle.** M0's along-wall p90 at 0–15° is at most half its p90 beyond 30°. +- **P2.** M1's along-wall p90 on in-plane edges is at most 4 in. Reported beside it, not graded: M1-anchor, and its error against s·ψ·tan(view angle). +- **P3.** With `modern_assumed` poses, M2's along-wall p90 on in-plane edges is at most 2 in. +- **P4.** With `modern_assumed` poses, M3's 3-D p90 is at most 2 in, and at least 70% of points match. +- **Kill.** If M0 is already within 4 in along the wall at every angle, edges don't limit along-wall clearances. + +## Result + +P1 passes; P2 to P4 fail ([tables](results/edge_geometry.md)). + +- **Angle.** In-plane edges are 0.9 in p90 along the wall within 15° of face-on and 24 in beyond 45°. +- **Ray to plane (M1).** 6.7 in overall, 0.5 in within 15°. +- **The app's plane (M1-anchor).** Its yaw error is 2.2° median and 6.7° p90. +- **M2 and M3.** Tap, snap and median gives 12.6 in, because the learned plane's own error dominates. Two-photo matching finds 41% of points. + +**Run:** `uv run python run.py`: 2 min, 0.9 GB. Needs the evals checkout at 190ed33 and its ETH3D and MoGe-2 caches. + +**What it changes:** ask for each edge from within about 20° of face-on, record the tap's view angle, and fit the wall plane across the walk. diff --git a/experiments/edge-geometry/geom.py b/experiments/edge-geometry/geom.py new file mode 100644 index 000000000..826035ed7 --- /dev/null +++ b/experiments/edge-geometry/geom.py @@ -0,0 +1,189 @@ +"""Geometry with one correct answer: planes, rays, the wall frame, view-angle bins, triangulation. + +A plane is (n, d) with unit normal n and n . x = d. Rays are origin + t * direction, t > 0. +""" + +from __future__ import annotations + +import numpy as np +from scipy.spatial import cKDTree + +ANGLE_EDGES_DEG = (0.0, 15.0, 30.0, 45.0, 90.0) +ANGLE_LABELS = ("0-15", "15-30", "30-45", ">45") + + +def ray_plane(origin: np.ndarray, direction: np.ndarray, n: np.ndarray, d: float) -> np.ndarray: + """Where each ray meets the plane, (N, 3). NaN for rays parallel to it or meeting it behind + the origin. `origin` is (3,) or (N, 3); `direction` is (N, 3) and need not be unit length.""" + direction = np.atleast_2d(direction).astype(np.float64) + origin = np.broadcast_to(np.asarray(origin, np.float64), direction.shape) + denom = direction @ n + with np.errstate(divide="ignore", invalid="ignore"): + t = (d - origin @ n) / denom + out = origin + t[:, None] * direction + bad = (np.abs(denom) < 1e-12) | ~(t > 0) + out[bad] = np.nan + return out + + +def along_wall_axis(n: np.ndarray, up: np.ndarray) -> np.ndarray: + """The horizontal unit vector lying in a plane with normal n: up x n, normalised. Its sign is + fixed by n and up, so a plane's along-wall coordinate is well defined.""" + h = np.cross(up, n) + norm = np.linalg.norm(h) + if norm < 1e-6: + raise ValueError(f"plane normal {n} is parallel to up {up}; it has no along-wall axis") + return h / norm + + +def wall_frame(n: np.ndarray, up: np.ndarray) -> np.ndarray: + """Orthonormal rows (h, n, w): along the wall, out of it, and w = n x h up the wall.""" + n = n / np.linalg.norm(n) + h = along_wall_axis(n, up) + return np.stack([h, n, np.cross(n, h)]) + + +def decompose(err: np.ndarray, n: np.ndarray, up: np.ndarray) -> dict[str, np.ndarray]: + """Split (N, 3) error vectors into signed along-wall and out-of-plane parts, and the 3-D norm.""" + frame = wall_frame(n, up) + err = np.atleast_2d(err) + return { + "along": err @ frame[0], + "out": err @ frame[1], + "3d": np.linalg.norm(err, axis=1), + } + + +def view_angle_deg(ray: np.ndarray, n: np.ndarray) -> np.ndarray: + """Angle between each viewing ray (N, 3) and the plane normal, 0 to 90 degrees, either side.""" + ray = np.atleast_2d(ray) + c = np.abs(ray @ n) / (np.linalg.norm(ray, axis=1) * np.linalg.norm(n)) + return np.degrees(np.arccos(np.clip(c, 0.0, 1.0))) + + +def angle_bin(angle_deg: np.ndarray) -> np.ndarray: + """Bin index into ANGLE_LABELS: [0, 15), [15, 30), [30, 45), [45, 90].""" + a = np.asarray(angle_deg, np.float64) + if np.any((a < 0) | (a > 90) | ~np.isfinite(a)): + raise ValueError("view angles must lie in [0, 90] degrees") + return np.minimum(np.searchsorted(ANGLE_EDGES_DEG, a, side="right") - 1, len(ANGLE_LABELS) - 1) + + +def fit_plane(points: np.ndarray, trim_rounds: int = 2, k: float = 3.0, floor: float = 0.01): + """Least-squares plane (n, d) through (N, 3) points, refitted `trim_rounds` times without + points whose residual exceeds max(k robust sigmas, `floor` metres).""" + pts = np.asarray(points, np.float64) + if len(pts) < 3: + raise ValueError(f"a plane needs at least 3 points, got {len(pts)}") + keep = np.ones(len(pts), bool) + for rnd in range(trim_rounds + 1): + c = pts[keep].mean(axis=0) + n = np.linalg.svd(pts[keep] - c, full_matrices=False)[2][2] + if rnd == trim_rounds: + break + r = np.abs((pts - c) @ n) + sigma = 1.4826 * np.median(r[keep]) + new = r <= max(k * sigma, floor) + if new.sum() < 3 or np.array_equal(new, keep): + break + keep = new + return n, float(n @ c) + + +def signed_yaw(n_est: np.ndarray, n_ref: np.ndarray, up: np.ndarray) -> float: + """Rotation about up (radians) taking n_ref's horizontal part to n_est's, after flipping n_est + to face the same way. Positive is counter-clockwise seen from above.""" + a = n_ref - (n_ref @ up) * up + b = n_est - (n_est @ up) * up + if a @ b < 0: + b = -b + return float(np.arctan2(up @ np.cross(a, b), a @ b)) + + +def yaw_shift(s: np.ndarray, psi: np.ndarray, ray: np.ndarray, n: np.ndarray, up: np.ndarray): + """First-order along-wall error when the wall plane is turned by yaw psi about a vertical axis + through a point s metres (signed, along `along_wall_axis`) from the edge: -s psi (r.h)/(r.n).""" + h = along_wall_axis(n, up) + ray = np.atleast_2d(ray) + return -np.asarray(s) * np.asarray(psi) * (ray @ h) / (ray @ n) + + +def midpoint_triangulate(c1, d1, c2, d2) -> tuple[np.ndarray, float]: + """Midpoint of the shortest segment between rays c1 + t d1 and c2 + u d2, and the angle + between the rays in degrees. NaN point for parallel rays or a point behind either camera.""" + d1 = d1 / np.linalg.norm(d1) + d2 = d2 / np.linalg.norm(d2) + w = c1 - c2 + b = d1 @ d2 + denom = 1.0 - b * b + angle = float(np.degrees(np.arccos(np.clip(b, -1.0, 1.0)))) + if denom < 1e-12: + return np.full(3, np.nan), angle + t = (b * (d2 @ w) - (d1 @ w)) / denom + u = ((d2 @ w) - b * (d1 @ w)) / denom + if t <= 0 or u <= 0: + return np.full(3, np.nan), angle + return 0.5 * ((c1 + t * d1) + (c2 + u * d2)), angle + + +def ransac_planes( + points: np.ndarray, + normals: np.ndarray, + up: np.ndarray, + tol: float, + max_tilt_deg: float, + agree_deg: float = 25.0, + min_inliers: int = 1500, + max_planes: int = 40, + hypotheses: int = 600, + score_sample: int = 50_000, + seed: int = 0, +) -> list[tuple[np.ndarray, float]]: + """Sequential RANSAC for near-vertical planes. Each hypothesis is three nearby points; it is + scored by points within `tol` whose own normal agrees within `agree_deg`, refitted by least + squares on its inliers, and those inliers are removed before the next plane.""" + rng = np.random.default_rng(seed) + sin_tilt = np.sin(np.radians(max_tilt_deg)) + cos_agree = np.cos(np.radians(agree_deg)) + remaining = np.arange(len(points)) + planes: list[tuple[np.ndarray, float]] = [] + + def inliers(pts, nrm, n, d): + return (np.abs(pts @ n - d) < tol) & (np.abs(nrm @ n) > cos_agree) + + while len(planes) < max_planes and len(remaining) >= min_inliers: + pts = points[remaining].astype(np.float64) + nrm = normals[remaining].astype(np.float64) + k = min(64, len(pts)) + seeds = rng.integers(len(pts), size=hypotheses) + _, nn = cKDTree(pts).query(pts[seeds], k=k) + pick = nn[np.arange(hypotheses)[:, None], rng.integers(1, k, size=(hypotheses, 2))] + a, b, c = pts[seeds], pts[pick[:, 0]], pts[pick[:, 1]] + n = np.cross(b - a, c - a) + norm = np.linalg.norm(n, axis=1) + ok = norm > 1e-9 + n = n[ok] / norm[ok, None] + a = a[ok] + ok = np.abs(n @ up) <= sin_tilt + n, a = n[ok], a[ok] + if not len(n): + break + d = np.sum(n * a, axis=1) + sub = rng.choice(len(pts), size=min(score_sample, len(pts)), replace=False) + score = (np.abs(pts[sub] @ n.T - d) < tol) & (np.abs(nrm[sub] @ n.T) > cos_agree) + best = int(np.argmax(score.sum(axis=0))) + inl = inliers(pts, nrm, n[best], d[best]) + if inl.sum() < min_inliers: + break + nb, db = fit_plane(pts[inl], trim_rounds=0) + if abs(nb @ up) <= sin_tilt: + refit = inliers(pts, nrm, nb, db) + if refit.sum() >= inl.sum(): + inl, n_best, d_best = refit, nb, db + else: + n_best, d_best = n[best], d[best] + else: + n_best, d_best = n[best], d[best] + planes.append((n_best, float(d_best))) + remaining = remaining[~inl] + return planes diff --git a/experiments/edge-geometry/imagematch.py b/experiments/edge-geometry/imagematch.py new file mode 100644 index 000000000..5fb90a19d --- /dev/null +++ b/experiments/edge-geometry/imagematch.py @@ -0,0 +1,123 @@ +"""Image operations for M2 and M3: snapping a tap to a vertical edge, and matching a pixel along +its epipolar segment in a second photo. Pixels are OpenCV integer-centred; poses are 4x4 +camera-to-world with OpenCV camera axes.""" + +from __future__ import annotations + +import cv2 +import numpy as np + + +def vertical_edge_strength(gray: np.ndarray, rows: int = 9) -> np.ndarray: + """|dI/dx| averaged over `rows` rows, so a vertical edge scores along its whole run and a + horizontal one scores nothing.""" + gx = cv2.Sobel(gray.astype(np.float32), cv2.CV_32F, 1, 0, ksize=3) + return cv2.blur(np.abs(gx), (1, rows), borderType=cv2.BORDER_REFLECT) + + +def _parabola_offset(left: float, mid: float, right: float) -> float: + """Sub-sample offset of a peak from three samples, clipped to half a sample.""" + denom = left - 2.0 * mid + right + if denom >= 0 or not np.isfinite(denom): + return 0.0 + return float(np.clip(0.5 * (left - right) / denom, -0.5, 0.5)) + + +def snap(strength: np.ndarray, u: float, v: float, radius: int = 25) -> tuple[float, float]: + """Move a tap horizontally to the strongest vertical edge within `radius` px on its row, + refined to sub-pixel by a parabola. A tap with no gradient in reach stays where it is.""" + h, w = strength.shape + row = int(np.clip(round(v), 0, h - 1)) + lo = int(np.clip(round(u) - radius, 0, w - 1)) + hi = int(np.clip(round(u) + radius, 0, w - 1)) + line = strength[row, lo : hi + 1] + if not len(line) or line.max() <= 0: + return float(u), float(v) + i = int(np.argmax(line)) + off = 0.0 + if 0 < i < len(line) - 1: + off = _parabola_offset(float(line[i - 1]), float(line[i]), float(line[i + 1])) + return float(lo + i + off), float(v) + + +def pixel_ray(K: np.ndarray, T: np.ndarray, uv: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """World origin and direction of the ray through pixel uv; the direction has unit optical-axis + depth, so origin + z * direction is the point at depth z.""" + d_cam = np.array([(uv[0] - K[0, 2]) / K[0, 0], (uv[1] - K[1, 2]) / K[1, 1], 1.0]) + return T[:3, 3].copy(), T[:3, :3] @ d_cam + + +def project(K: np.ndarray, T: np.ndarray, X: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Pixels (N, 2) and camera depths (N,) of world points (N, 3).""" + Xc = (np.atleast_2d(X) - T[:3, 3]) @ T[:3, :3] + with np.errstate(divide="ignore", invalid="ignore"): + uv = np.c_[K[0, 0] * Xc[:, 0] / Xc[:, 2] + K[0, 2], K[1, 1] * Xc[:, 1] / Xc[:, 2] + K[1, 2]] + return uv, Xc[:, 2] + + +def _ncc(template: np.ndarray, patches: np.ndarray) -> np.ndarray: + t = template.ravel() - template.mean() + p = patches.reshape(len(patches), -1) + p = p - p.mean(axis=1, keepdims=True) + denom = np.linalg.norm(t) * np.linalg.norm(p, axis=1) + with np.errstate(divide="ignore", invalid="ignore"): + out = (p @ t) / denom + return np.where(denom > 1e-9, out, -1.0) + + +def epipolar_match( + img1: np.ndarray, + img2: np.ndarray, + uv1: np.ndarray, + K1: np.ndarray, + T1: np.ndarray, + K2: np.ndarray, + T2: np.ndarray, + depth_range: tuple[float, float] = (0.5, 10.0), + patch: int = 15, + step_px: float = 0.5, +) -> tuple[np.ndarray, float]: + """Best match in img2 for the `patch` x `patch` window around uv1 in img1, by normalised + cross-correlation along the segment of uv1's epipolar line that depths in `depth_range` map + to, sampled every `step_px` and refined by a parabola. Returns (uv2, peak NCC); uv2 is NaN + and NCC -1 when no part of the segment fits in img2 or the template leaves img1.""" + half = patch // 2 + h1, w1 = img1.shape + h2, w2 = img2.shape + fail = (np.full(2, np.nan), -1.0) + if not (half <= uv1[0] <= w1 - 1 - half and half <= uv1[1] <= h1 - 1 - half): + return fail + template = cv2.getRectSubPix(img1.astype(np.float32), (patch, patch), (float(uv1[0]), float(uv1[1]))) + + # The depths whose projection lands inside img2, in front of it: an interval along the ray, + # so its two ends bound a straight segment in img2. + o, dvec = pixel_ray(K1, T1, uv1) + z = np.geomspace(depth_range[0], depth_range[1], 512) + uv, zc = project(K2, T2, o + z[:, None] * dvec) + inside = ( + (zc > 0.05) + & (uv[:, 0] >= half) + & (uv[:, 0] <= w2 - 1 - half) + & (uv[:, 1] >= half) + & (uv[:, 1] <= h2 - 1 - half) + ) + if not inside.any(): + return fail + ends = uv[np.flatnonzero(inside)[[0, -1]]] + length = float(np.linalg.norm(ends[1] - ends[0])) + n = max(3, int(np.ceil(length / step_px)) + 1) + samples = ends[0] + np.linspace(0.0, 1.0, n)[:, None] * (ends[1] - ends[0]) + + offs = np.arange(patch, dtype=np.float32) - half + map_x = (samples[:, 0, None, None] + offs[None, None, :]).astype(np.float32) + map_y = (samples[:, 1, None, None] + offs[None, :, None]).astype(np.float32) + map_x = np.broadcast_to(map_x, (n, patch, patch)).reshape(n * patch, patch) + map_y = np.broadcast_to(map_y, (n, patch, patch)).reshape(n * patch, patch) + patches = cv2.remap(img2.astype(np.float32), map_x, map_y, cv2.INTER_LINEAR) + score = _ncc(template, patches.reshape(n, patch, patch)) + i = int(np.argmax(score)) + off = 0.0 + if 0 < i < n - 1: + off = _parabola_offset(float(score[i - 1]), float(score[i]), float(score[i + 1])) + step = (ends[1] - ends[0]) / (n - 1) + return samples[i] + off * step, float(score[i]) diff --git a/experiments/edge-geometry/pyproject.toml b/experiments/edge-geometry/pyproject.toml new file mode 100644 index 000000000..df11f7499 --- /dev/null +++ b/experiments/edge-geometry/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "edge-geometry" +version = "0.1.0" +description = "How learned depth's error at object edges splits along and across a wall, and whether a ray to the wall plane, a snapped tap or a second photo places an edge better" +requires-python = ">=3.12" +dependencies = [ + "numpy>=2.1", + "opencv-python-headless>=4.10", + "scipy>=1.14", +] + +[dependency-groups] +dev = ["pytest>=8.3", "ruff>=0.8"] + +[tool.uv] +package = false + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = ["--strict-markers", "--strict-config"] diff --git a/experiments/edge-geometry/results/edge_geometry.json b/experiments/edge-geometry/results/edge_geometry.json new file mode 100644 index 000000000..3f1f82e6e --- /dev/null +++ b/experiments/edge-geometry/results/edge_geometry.json @@ -0,0 +1,4169 @@ +{ + "evals_commit": "190ed339f41924ae0244e0b88c5ebe647ddd9eb4", + "evals_dir": "~/Programming Projects/house-scanning-evals/experiments/evals", + "parameters": { + "NEAR_M": 6.0, + "TOLERANCE": 0.04, + "VERTICAL_MAX_UP": 0.3, + "WIDTH": 1024, + "SCENES": [ + "electro", + "facade" + ], + "MIN_POINTS_TO_REPORT": 200, + "MAX_POINTS": 3000, + "PLANE_TOL_M": 0.03, + "MAX_TILT_DEG": 17.0, + "RANSAC_POINTS": 400000, + "JOIN_M": 0.6, + "MEMBER_REACH_M": 1.0, + "IN_PLANE_M": 0.05, + "EDGE_RADIUS_M": 0.15, + "VERTICAL_EDGE_DEG": 30.0, + "MIN_WALL_PX": 50, + "ANCHOR_ALONG_M": [ + 1.0, + 3.0 + ], + "ANCHOR_TARGET_M": 2.0, + "PATCH_RADIUS_M": 0.3, + "MIN_PATCH_PX": 20, + "ANCHOR_TRIES": 5, + "TAP_SIGMA_PX": 10.0, + "SNAP_RADIUS_PX": 25, + "SNAP_ROWS": 9, + "WORLD_PLANE_PX_PER_PHOTO": 3000, + "MIN_BASELINE_M": 0.3, + "MIN_RAY_ANGLE_DEG": 5.0, + "NCC_MIN": 0.8, + "NCC_PATCH": 15, + "DEPTH_RANGE_M": [ + 0.5, + 10.0 + ], + "SEED": 20260926 + }, + "scenes": { + "electro": { + "meta": { + "photos": 45, + "wall_planes": 21, + "edge_points_within_6m": 27436, + "edge_points_on_a_wall": 25492, + "points_sampled": 3000, + "in_plane": 2258, + "proud": 742, + "vertical_edges": 730, + "up_vs_camera_up_deg": 5.21, + "wall_tilt_deg_max": 3.14, + "oracle_scale_median": 0.9961415148011799, + "seconds": 93 + }, + "single": { + "m0": { + "all": { + "all angles": { + "along": { + "n": 7871, + "points": 2994, + "median_in": 2.13, + "p90_in": 15.64 + }, + "out": { + "n": 7871, + "points": 2994, + "median_in": 3.11, + "p90_in": 19.17 + }, + "3d": { + "n": 7871, + "points": 2994, + "median_in": 5.72, + "p90_in": 38.62 + }, + "missing": 23 + }, + "0-15": { + "along": { + "n": 647, + "points": 345, + "median_in": 0.19, + "p90_in": 1.35 + }, + "out": { + "n": 647, + "points": 345, + "median_in": 4.83, + "p90_in": 14.83 + }, + "3d": { + "n": 647, + "points": 345, + "median_in": 4.9, + "p90_in": 14.93 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 1483, + "points": 912, + "median_in": 0.73, + "p90_in": 4.59 + }, + "out": { + "n": 1483, + "points": 912, + "median_in": 3.76, + "p90_in": 15.26 + }, + "3d": { + "n": 1483, + "points": 912, + "median_in": 4.15, + "p90_in": 16.98 + }, + "missing": 2 + }, + "30-45": { + "along": { + "n": 2460, + "points": 1430, + "median_in": 2.02, + "p90_in": 8.89 + }, + "out": { + "n": 2460, + "points": 1430, + "median_in": 4.17, + "p90_in": 20.37 + }, + "3d": { + "n": 2460, + "points": 1430, + "median_in": 5.25, + "p90_in": 24.91 + }, + "missing": 14 + }, + ">45": { + "along": { + "n": 3281, + "points": 1593, + "median_in": 5.09, + "p90_in": 24.36 + }, + "out": { + "n": 3281, + "points": 1593, + "median_in": 1.72, + "p90_in": 23.7 + }, + "3d": { + "n": 3281, + "points": 1593, + "median_in": 7.5, + "p90_in": 66.17 + }, + "missing": 7 + } + }, + "in_plane": { + "all angles": { + "along": { + "n": 6067, + "points": 2258, + "median_in": 2.0, + "p90_in": 14.56 + }, + "out": { + "n": 6067, + "points": 2258, + "median_in": 2.78, + "p90_in": 16.17 + }, + "3d": { + "n": 6067, + "points": 2258, + "median_in": 5.33, + "p90_in": 31.73 + }, + "missing": 9 + }, + "0-15": { + "along": { + "n": 547, + "points": 281, + "median_in": 0.19, + "p90_in": 0.91 + }, + "out": { + "n": 547, + "points": 281, + "median_in": 4.83, + "p90_in": 14.63 + }, + "3d": { + "n": 547, + "points": 281, + "median_in": 4.9, + "p90_in": 14.78 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 1067, + "points": 708, + "median_in": 0.67, + "p90_in": 4.47 + }, + "out": { + "n": 1067, + "points": 708, + "median_in": 3.36, + "p90_in": 16.86 + }, + "3d": { + "n": 1067, + "points": 708, + "median_in": 3.64, + "p90_in": 18.19 + }, + "missing": 2 + }, + "30-45": { + "along": { + "n": 1835, + "points": 1077, + "median_in": 1.9, + "p90_in": 7.84 + }, + "out": { + "n": 1835, + "points": 1077, + "median_in": 3.85, + "p90_in": 15.95 + }, + "3d": { + "n": 1835, + "points": 1077, + "median_in": 4.91, + "p90_in": 19.86 + }, + "missing": 5 + }, + ">45": { + "along": { + "n": 2618, + "points": 1211, + "median_in": 4.78, + "p90_in": 24.06 + }, + "out": { + "n": 2618, + "points": 1211, + "median_in": 1.52, + "p90_in": 17.68 + }, + "3d": { + "n": 2618, + "points": 1211, + "median_in": 6.63, + "p90_in": 62.06 + }, + "missing": 2 + } + }, + "proud": { + "all angles": { + "along": { + "n": 1804, + "points": 736, + "median_in": 2.45, + "p90_in": 18.03 + }, + "out": { + "n": 1804, + "points": 736, + "median_in": 4.17, + "p90_in": 25.02 + }, + "3d": { + "n": 1804, + "points": 736, + "median_in": 7.34, + "p90_in": 57.26 + }, + "missing": 14 + }, + "0-15": { + "along": { + "n": 100, + "points": 64, + "median_in": 0.35, + "p90_in": 2.73 + }, + "out": { + "n": 100, + "points": 64, + "median_in": 4.85, + "p90_in": 16.17 + }, + "3d": { + "n": 100, + "points": 64, + "median_in": 4.93, + "p90_in": 16.4 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 416, + "points": 204, + "median_in": 0.93, + "p90_in": 4.76 + }, + "out": { + "n": 416, + "points": 204, + "median_in": 4.22, + "p90_in": 12.73 + }, + "3d": { + "n": 416, + "points": 204, + "median_in": 4.6, + "p90_in": 14.1 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 625, + "points": 353, + "median_in": 2.4, + "p90_in": 12.12 + }, + "out": { + "n": 625, + "points": 353, + "median_in": 5.44, + "p90_in": 24.81 + }, + "3d": { + "n": 625, + "points": 353, + "median_in": 6.87, + "p90_in": 30.17 + }, + "missing": 9 + }, + ">45": { + "along": { + "n": 663, + "points": 382, + "median_in": 6.76, + "p90_in": 27.06 + }, + "out": { + "n": 663, + "points": 382, + "median_in": 3.14, + "p90_in": 48.07 + }, + "3d": { + "n": 663, + "points": 382, + "median_in": 19.91, + "p90_in": 116.19 + }, + "missing": 5 + } + } + }, + "m1": { + "all": { + "all angles": { + "along": { + "n": 6257, + "points": 2712, + "median_in": 1.26, + "p90_in": 7.25 + }, + "out": { + "n": 6257, + "points": 2712, + "median_in": 3.11, + "p90_in": 10.49 + }, + "3d": { + "n": 6257, + "points": 2712, + "median_in": 4.19, + "p90_in": 15.1 + }, + "missing": 1637 + }, + "0-15": { + "along": { + "n": 615, + "points": 342, + "median_in": 0.16, + "p90_in": 0.58 + }, + "out": { + "n": 615, + "points": 342, + "median_in": 4.16, + "p90_in": 11.43 + }, + "3d": { + "n": 615, + "points": 342, + "median_in": 4.17, + "p90_in": 11.57 + }, + "missing": 32 + }, + "15-30": { + "along": { + "n": 1406, + "points": 898, + "median_in": 0.53, + "p90_in": 2.75 + }, + "out": { + "n": 1406, + "points": 898, + "median_in": 2.88, + "p90_in": 10.29 + }, + "3d": { + "n": 1406, + "points": 898, + "median_in": 3.17, + "p90_in": 11.11 + }, + "missing": 79 + }, + "30-45": { + "along": { + "n": 2358, + "points": 1376, + "median_in": 1.63, + "p90_in": 6.32 + }, + "out": { + "n": 2358, + "points": 1376, + "median_in": 3.31, + "p90_in": 8.87 + }, + "3d": { + "n": 2358, + "points": 1376, + "median_in": 4.16, + "p90_in": 11.35 + }, + "missing": 116 + }, + ">45": { + "along": { + "n": 1878, + "points": 1219, + "median_in": 3.49, + "p90_in": 21.69 + }, + "out": { + "n": 1878, + "points": 1219, + "median_in": 2.56, + "p90_in": 16.32 + }, + "3d": { + "n": 1878, + "points": 1219, + "median_in": 5.33, + "p90_in": 45.01 + }, + "missing": 1410 + } + }, + "in_plane": { + "all angles": { + "along": { + "n": 4920, + "points": 2097, + "median_in": 1.13, + "p90_in": 6.65 + }, + "out": { + "n": 4920, + "points": 2097, + "median_in": 2.7, + "p90_in": 9.16 + }, + "3d": { + "n": 4920, + "points": 2097, + "median_in": 3.77, + "p90_in": 12.0 + }, + "missing": 1156 + }, + "0-15": { + "along": { + "n": 516, + "points": 278, + "median_in": 0.13, + "p90_in": 0.45 + }, + "out": { + "n": 516, + "points": 278, + "median_in": 4.25, + "p90_in": 11.52 + }, + "3d": { + "n": 516, + "points": 278, + "median_in": 4.27, + "p90_in": 11.65 + }, + "missing": 31 + }, + "15-30": { + "along": { + "n": 1041, + "points": 699, + "median_in": 0.42, + "p90_in": 2.3 + }, + "out": { + "n": 1041, + "points": 699, + "median_in": 2.44, + "p90_in": 9.78 + }, + "3d": { + "n": 1041, + "points": 699, + "median_in": 2.7, + "p90_in": 10.67 + }, + "missing": 28 + }, + "30-45": { + "along": { + "n": 1787, + "points": 1044, + "median_in": 1.52, + "p90_in": 5.76 + }, + "out": { + "n": 1787, + "points": 1044, + "median_in": 3.01, + "p90_in": 7.64 + }, + "3d": { + "n": 1787, + "points": 1044, + "median_in": 3.74, + "p90_in": 10.11 + }, + "missing": 53 + }, + ">45": { + "along": { + "n": 1576, + "points": 998, + "median_in": 2.89, + "p90_in": 15.02 + }, + "out": { + "n": 1576, + "points": 998, + "median_in": 1.89, + "p90_in": 8.92 + }, + "3d": { + "n": 1576, + "points": 998, + "median_in": 4.49, + "p90_in": 36.17 + }, + "missing": 1044 + } + }, + "proud": { + "all angles": { + "along": { + "n": 1337, + "points": 615, + "median_in": 2.01, + "p90_in": 14.46 + }, + "out": { + "n": 1337, + "points": 615, + "median_in": 5.0, + "p90_in": 19.02 + }, + "3d": { + "n": 1337, + "points": 615, + "median_in": 6.29, + "p90_in": 27.46 + }, + "missing": 481 + }, + "0-15": { + "along": { + "n": 99, + "points": 64, + "median_in": 0.28, + "p90_in": 1.05 + }, + "out": { + "n": 99, + "points": 64, + "median_in": 3.82, + "p90_in": 7.23 + }, + "3d": { + "n": 99, + "points": 64, + "median_in": 3.88, + "p90_in": 7.34 + }, + "missing": 1 + }, + "15-30": { + "along": { + "n": 365, + "points": 199, + "median_in": 0.99, + "p90_in": 3.54 + }, + "out": { + "n": 365, + "points": 199, + "median_in": 4.62, + "p90_in": 11.83 + }, + "3d": { + "n": 365, + "points": 199, + "median_in": 5.02, + "p90_in": 12.83 + }, + "missing": 51 + }, + "30-45": { + "along": { + "n": 571, + "points": 332, + "median_in": 2.23, + "p90_in": 8.69 + }, + "out": { + "n": 571, + "points": 332, + "median_in": 4.54, + "p90_in": 15.93 + }, + "3d": { + "n": 571, + "points": 332, + "median_in": 5.83, + "p90_in": 20.66 + }, + "missing": 63 + }, + ">45": { + "along": { + "n": 302, + "points": 221, + "median_in": 12.09, + "p90_in": 31.95 + }, + "out": { + "n": 302, + "points": 221, + "median_in": 9.57, + "p90_in": 29.5 + }, + "3d": { + "n": 302, + "points": 221, + "median_in": 19.18, + "p90_in": 77.71 + }, + "missing": 366 + } + } + }, + "m1a": { + "all": { + "all angles": { + "along": { + "n": 3411, + "points": 1707, + "median_in": 2.04, + "p90_in": 12.18 + }, + "out": { + "n": 3411, + "points": 1707, + "median_in": 3.82, + "p90_in": 17.86 + }, + "3d": { + "n": 3411, + "points": 1707, + "median_in": 5.11, + "p90_in": 27.19 + }, + "missing": 4483 + }, + "0-15": { + "along": { + "n": 301, + "points": 199, + "median_in": 0.15, + "p90_in": 0.57 + }, + "out": { + "n": 301, + "points": 199, + "median_in": 3.72, + "p90_in": 12.65 + }, + "3d": { + "n": 301, + "points": 199, + "median_in": 3.77, + "p90_in": 12.77 + }, + "missing": 346 + }, + "15-30": { + "along": { + "n": 821, + "points": 592, + "median_in": 1.13, + "p90_in": 5.55 + }, + "out": { + "n": 821, + "points": 592, + "median_in": 3.92, + "p90_in": 14.03 + }, + "3d": { + "n": 821, + "points": 592, + "median_in": 4.26, + "p90_in": 15.65 + }, + "missing": 664 + }, + "30-45": { + "along": { + "n": 1411, + "points": 924, + "median_in": 2.16, + "p90_in": 10.46 + }, + "out": { + "n": 1411, + "points": 924, + "median_in": 4.28, + "p90_in": 19.67 + }, + "3d": { + "n": 1411, + "points": 924, + "median_in": 5.39, + "p90_in": 26.69 + }, + "missing": 1063 + }, + ">45": { + "along": { + "n": 878, + "points": 633, + "median_in": 5.12, + "p90_in": 27.12 + }, + "out": { + "n": 878, + "points": 633, + "median_in": 3.03, + "p90_in": 18.81 + }, + "3d": { + "n": 878, + "points": 633, + "median_in": 6.57, + "p90_in": 36.78 + }, + "missing": 2410 + } + }, + "in_plane": { + "all angles": { + "along": { + "n": 2755, + "points": 1360, + "median_in": 1.93, + "p90_in": 10.43 + }, + "out": { + "n": 2755, + "points": 1360, + "median_in": 3.38, + "p90_in": 13.62 + }, + "3d": { + "n": 2755, + "points": 1360, + "median_in": 4.66, + "p90_in": 21.04 + }, + "missing": 3321 + }, + "0-15": { + "along": { + "n": 278, + "points": 181, + "median_in": 0.15, + "p90_in": 0.5 + }, + "out": { + "n": 278, + "points": 181, + "median_in": 3.5, + "p90_in": 11.78 + }, + "3d": { + "n": 278, + "points": 181, + "median_in": 3.52, + "p90_in": 12.04 + }, + "missing": 269 + }, + "15-30": { + "along": { + "n": 633, + "points": 478, + "median_in": 1.02, + "p90_in": 5.83 + }, + "out": { + "n": 633, + "points": 478, + "median_in": 3.49, + "p90_in": 13.65 + }, + "3d": { + "n": 633, + "points": 478, + "median_in": 3.88, + "p90_in": 15.34 + }, + "missing": 436 + }, + "30-45": { + "along": { + "n": 1088, + "points": 727, + "median_in": 2.21, + "p90_in": 8.58 + }, + "out": { + "n": 1088, + "points": 727, + "median_in": 4.06, + "p90_in": 16.15 + }, + "3d": { + "n": 1088, + "points": 727, + "median_in": 5.05, + "p90_in": 19.5 + }, + "missing": 752 + }, + ">45": { + "along": { + "n": 756, + "points": 526, + "median_in": 4.54, + "p90_in": 27.69 + }, + "out": { + "n": 756, + "points": 526, + "median_in": 2.76, + "p90_in": 12.21 + }, + "3d": { + "n": 756, + "points": 526, + "median_in": 5.68, + "p90_in": 36.52 + }, + "missing": 1864 + } + }, + "proud": { + "all angles": { + "along": { + "n": 656, + "points": 347, + "median_in": 2.48, + "p90_in": 15.62 + }, + "out": { + "n": 656, + "points": 347, + "median_in": 6.29, + "p90_in": 42.42 + }, + "3d": { + "n": 656, + "points": 347, + "median_in": 7.99, + "p90_in": 64.13 + }, + "missing": 1162 + }, + "0-15": { + "along": { + "n": 23, + "points": 18, + "median_in": 2.13, + "p90_in": 154.09 + }, + "out": { + "n": 23, + "points": 18, + "median_in": 12.74, + "p90_in": 3149.65 + }, + "3d": { + "n": 23, + "points": 18, + "median_in": 12.98, + "p90_in": 3164.05 + }, + "missing": 77 + }, + "15-30": { + "along": { + "n": 188, + "points": 114, + "median_in": 1.69, + "p90_in": 5.06 + }, + "out": { + "n": 188, + "points": 114, + "median_in": 7.13, + "p90_in": 14.6 + }, + "3d": { + "n": 188, + "points": 114, + "median_in": 7.77, + "p90_in": 16.43 + }, + "missing": 228 + }, + "30-45": { + "along": { + "n": 323, + "points": 197, + "median_in": 1.99, + "p90_in": 15.69 + }, + "out": { + "n": 323, + "points": 197, + "median_in": 4.69, + "p90_in": 63.53 + }, + "3d": { + "n": 323, + "points": 197, + "median_in": 5.84, + "p90_in": 79.7 + }, + "missing": 311 + }, + ">45": { + "along": { + "n": 122, + "points": 107, + "median_in": 9.51, + "p90_in": 17.91 + }, + "out": { + "n": 122, + "points": 107, + "median_in": 7.42, + "p90_in": 41.02 + }, + "3d": { + "n": 122, + "points": 107, + "median_in": 14.5, + "p90_in": 63.92 + }, + "missing": 546 + } + } + }, + "m1a_yaw": { + "observations": 3411, + "abs_psi_deg": { + "median": 2.15, + "p90": 6.72 + }, + "abs_s_m_median": 2.0, + "all_added_after_the_run": { + "sign_agreement_where_prediction_over_1cm": 0.7, + "median_error_over_prediction_where_over_1cm": 0.73, + "view_angle_under_60": { + "n": 3080, + "slope": 0.36, + "r2": 0.01 + }, + "spearman_abs_error_vs_tan_angle_alone": 0.5 + }, + "all": { + "n": 3411, + "signed_first_order_slope": 0.05, + "signed_first_order_r2": 0.02, + "signed_r2_with_slope_1": -4.58, + "abs_error_over_s_psi_tan_median": 0.94, + "abs_error_over_s_psi_tan_n": 2811, + "spearman_abs_error_vs_s_psi_tan": 0.63, + "abs_error_p90_in": 12.18, + "residual_after_first_order_p90_in": 10.9 + }, + "in_plane_added_after_the_run": { + "sign_agreement_where_prediction_over_1cm": 0.74, + "median_error_over_prediction_where_over_1cm": 0.83, + "view_angle_under_60": { + "n": 2455, + "slope": 0.29, + "r2": 0.02 + }, + "spearman_abs_error_vs_tan_angle_alone": 0.54 + }, + "in_plane": { + "n": 2755, + "signed_first_order_slope": 0.05, + "signed_first_order_r2": 0.02, + "signed_r2_with_slope_1": -12.61, + "abs_error_over_s_psi_tan_median": 0.94, + "abs_error_over_s_psi_tan_n": 2231, + "spearman_abs_error_vs_s_psi_tan": 0.67, + "abs_error_p90_in": 10.43, + "residual_after_first_order_p90_in": 9.41 + } + }, + "added_after_the_run_edge_point_to_plane": { + "laser_plane_in_plane_edges": { + "n": 6076, + "points": 2258, + "median_in": 0.4, + "p90_in": 1.38 + }, + "m1_plane_in_plane_edges": { + "n": 4920, + "points": 2097, + "median_in": 2.54, + "p90_in": 9.81 + } + } + }, + "multi": { + "exact": { + "all": { + "M2": { + "span_along": { + "n": 1819, + "points": 1819, + "median_in": 4.01, + "p90_in": 14.46, + "p90_ci95_in": [ + 13.85, + 15.72 + ] + }, + "span_3d": { + "n": 1819, + "points": 1819, + "median_in": 6.39, + "p90_in": 23.93 + }, + "frame_along": { + "n": 2617, + "points": 2617, + "median_in": 3.37, + "p90_in": 16.39 + }, + "frame_3d": { + "n": 2617, + "points": 2617, + "median_in": 6.08, + "p90_in": 27.83 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 1819, + "points": 1819, + "median_in": 2.8, + "p90_in": 12.97 + }, + "span_along_true_pixel": { + "n": 1819, + "points": 1819, + "median_in": 2.55, + "p90_in": 12.57 + }, + "frame_along_true_pixel": { + "n": 2617, + "points": 2617, + "median_in": 1.31, + "p90_in": 13.11 + }, + "edge_point_to_fused_plane": { + "n": 2617, + "points": 2617, + "median_in": 2.69, + "p90_in": 13.87 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 191, + "points": 191, + "median_in": 2.22, + "p90_in": 5.69 + }, + "15-30": { + "n": 343, + "points": 343, + "median_in": 3.28, + "p90_in": 11.66 + }, + "30-45": { + "n": 959, + "points": 959, + "median_in": 4.16, + "p90_in": 12.33 + }, + ">45": { + "n": 326, + "points": 326, + "median_in": 7.53, + "p90_in": 38.75 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 191, + "points": 191, + "median_in": 0.17, + "p90_in": 4.53 + }, + "15-30": { + "n": 343, + "points": 343, + "median_in": 1.28, + "p90_in": 12.04 + }, + "30-45": { + "n": 959, + "points": 959, + "median_in": 2.58, + "p90_in": 11.79 + }, + ">45": { + "n": 326, + "points": 326, + "median_in": 5.95, + "p90_in": 51.18 + } + } + }, + "M3": { + "pairs": 2489, + "match_rate": 0.472, + "match_px": { + "n": 1175, + "median": 2.21, + "p90": 103.64 + }, + "frame_3d": { + "n": 1175, + "points": 1175, + "median_in": 2.73, + "p90_in": 102.88 + }, + "frame_along": { + "n": 1175, + "points": 1175, + "median_in": 1.02, + "p90_in": 41.1 + }, + "span_along": { + "n": 556, + "points": 556, + "median_in": 2.14, + "p90_in": 41.09, + "p90_ci95_in": [ + 33.97, + 55.97 + ] + }, + "span_3d": { + "n": 556, + "points": 556, + "median_in": 6.01, + "p90_in": 112.45, + "p90_ci95_in": [ + 106.82, + 121.49 + ] + }, + "anchor_matched": 556, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 571, + "points": 571, + "median_in": 0.73, + "p90_in": 2.85 + }, + "added_after_the_run_share_of_matches_within_2px": 0.486 + } + }, + "in_plane": { + "M2": { + "span_along": { + "n": 1452, + "points": 1452, + "median_in": 3.74, + "p90_in": 13.02, + "p90_ci95_in": [ + 12.06, + 14.18 + ] + }, + "span_3d": { + "n": 1452, + "points": 1452, + "median_in": 5.78, + "p90_in": 22.02 + }, + "frame_along": { + "n": 2028, + "points": 2028, + "median_in": 3.26, + "p90_in": 14.39 + }, + "frame_3d": { + "n": 2028, + "points": 2028, + "median_in": 5.85, + "p90_in": 19.3 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 1452, + "points": 1452, + "median_in": 2.55, + "p90_in": 10.89 + }, + "span_along_true_pixel": { + "n": 1452, + "points": 1452, + "median_in": 2.15, + "p90_in": 10.93 + }, + "frame_along_true_pixel": { + "n": 2028, + "points": 2028, + "median_in": 1.23, + "p90_in": 10.19 + }, + "edge_point_to_fused_plane": { + "n": 2028, + "points": 2028, + "median_in": 2.26, + "p90_in": 10.04 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 178, + "points": 178, + "median_in": 2.29, + "p90_in": 5.63 + }, + "15-30": { + "n": 245, + "points": 245, + "median_in": 2.68, + "p90_in": 6.78 + }, + "30-45": { + "n": 780, + "points": 780, + "median_in": 4.19, + "p90_in": 11.6 + }, + ">45": { + "n": 249, + "points": 249, + "median_in": 6.09, + "p90_in": 29.1 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 178, + "points": 178, + "median_in": 0.15, + "p90_in": 4.27 + }, + "15-30": { + "n": 245, + "points": 245, + "median_in": 1.12, + "p90_in": 3.63 + }, + "30-45": { + "n": 780, + "points": 780, + "median_in": 2.54, + "p90_in": 10.5 + }, + ">45": { + "n": 249, + "points": 249, + "median_in": 5.06, + "p90_in": 13.8 + } + } + }, + "M3": { + "pairs": 1951, + "match_rate": 0.498, + "match_px": { + "n": 971, + "median": 2.2, + "p90": 109.17 + }, + "frame_3d": { + "n": 971, + "points": 971, + "median_in": 2.74, + "p90_in": 105.24 + }, + "frame_along": { + "n": 971, + "points": 971, + "median_in": 1.15, + "p90_in": 43.17 + }, + "span_along": { + "n": 485, + "points": 485, + "median_in": 2.1, + "p90_in": 34.87, + "p90_ci95_in": [ + 25.15, + 43.17 + ] + }, + "span_3d": { + "n": 485, + "points": 485, + "median_in": 5.73, + "p90_in": 109.91, + "p90_ci95_in": [ + 104.83, + 118.28 + ] + }, + "anchor_matched": 485, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 473, + "points": 473, + "median_in": 0.75, + "p90_in": 2.91 + }, + "added_after_the_run_share_of_matches_within_2px": 0.487 + } + }, + "proud": { + "M2": { + "span_along": { + "n": 367, + "points": 367, + "median_in": 5.46, + "p90_in": 18.1, + "p90_ci95_in": [ + 17.13, + 20.83 + ] + }, + "span_3d": { + "n": 367, + "points": 367, + "median_in": 11.71, + "p90_in": 43.59 + }, + "frame_along": { + "n": 589, + "points": 589, + "median_in": 3.95, + "p90_in": 24.87 + }, + "frame_3d": { + "n": 589, + "points": 589, + "median_in": 7.52, + "p90_in": 44.47 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 367, + "points": 367, + "median_in": 4.91, + "p90_in": 17.96 + }, + "span_along_true_pixel": { + "n": 367, + "points": 367, + "median_in": 5.41, + "p90_in": 17.03 + }, + "frame_along_true_pixel": { + "n": 589, + "points": 589, + "median_in": 2.21, + "p90_in": 27.38 + }, + "edge_point_to_fused_plane": { + "n": 589, + "points": 589, + "median_in": 4.91, + "p90_in": 31.59 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 13, + "points": 13, + "median_in": 1.56, + "p90_in": 7.34 + }, + "15-30": { + "n": 98, + "points": 98, + "median_in": 7.3, + "p90_in": 14.5 + }, + "30-45": { + "n": 179, + "points": 179, + "median_in": 4.14, + "p90_in": 15.66 + }, + ">45": { + "n": 77, + "points": 77, + "median_in": 13.7, + "p90_in": 103.07 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 13, + "points": 13, + "median_in": 4.53, + "p90_in": 5.42 + }, + "15-30": { + "n": 98, + "points": 98, + "median_in": 5.98, + "p90_in": 13.97 + }, + "30-45": { + "n": 179, + "points": 179, + "median_in": 3.58, + "p90_in": 15.8 + }, + ">45": { + "n": 77, + "points": 77, + "median_in": 10.15, + "p90_in": 122.8 + } + } + }, + "M3": { + "pairs": 538, + "match_rate": 0.379, + "match_px": { + "n": 204, + "median": 2.32, + "p90": 86.19 + }, + "frame_3d": { + "n": 204, + "points": 204, + "median_in": 2.68, + "p90_in": 85.16 + }, + "frame_along": { + "n": 204, + "points": 204, + "median_in": 0.58, + "p90_in": 33.74 + }, + "span_along": { + "n": 71, + "points": 71, + "median_in": 2.28, + "p90_in": 59.87, + "p90_ci95_in": [ + 55.48, + 84.62 + ] + }, + "span_3d": { + "n": 71, + "points": 71, + "median_in": 14.92, + "p90_in": 134.29, + "p90_ci95_in": [ + 90.11, + 164.99 + ] + }, + "anchor_matched": 71, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 98, + "points": 98, + "median_in": 0.69, + "p90_in": 2.27 + }, + "added_after_the_run_share_of_matches_within_2px": 0.48 + } + }, + "in_plane_vertical": { + "M2": { + "span_along": { + "n": 381, + "points": 381, + "median_in": 3.25, + "p90_in": 15.43, + "p90_ci95_in": [ + 13.66, + 16.83 + ] + }, + "span_3d": { + "n": 381, + "points": 381, + "median_in": 5.92, + "p90_in": 24.14 + }, + "frame_along": { + "n": 529, + "points": 529, + "median_in": 2.96, + "p90_in": 12.86 + }, + "frame_3d": { + "n": 529, + "points": 529, + "median_in": 5.94, + "p90_in": 20.94 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 381, + "points": 381, + "median_in": 2.91, + "p90_in": 11.88 + }, + "span_along_true_pixel": { + "n": 381, + "points": 381, + "median_in": 2.62, + "p90_in": 11.38 + }, + "frame_along_true_pixel": { + "n": 529, + "points": 529, + "median_in": 1.61, + "p90_in": 10.26 + }, + "edge_point_to_fused_plane": { + "n": 529, + "points": 529, + "median_in": 2.63, + "p90_in": 13.28 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 43, + "points": 43, + "median_in": 1.47, + "p90_in": 3.45 + }, + "15-30": { + "n": 83, + "points": 83, + "median_in": 3.25, + "p90_in": 8.93 + }, + "30-45": { + "n": 165, + "points": 165, + "median_in": 5.12, + "p90_in": 13.6 + }, + ">45": { + "n": 90, + "points": 90, + "median_in": 4.17, + "p90_in": 23.98 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 43, + "points": 43, + "median_in": 0.6, + "p90_in": 2.6 + }, + "15-30": { + "n": 83, + "points": 83, + "median_in": 1.99, + "p90_in": 6.95 + }, + "30-45": { + "n": 165, + "points": 165, + "median_in": 2.7, + "p90_in": 12.55 + }, + ">45": { + "n": 90, + "points": 90, + "median_in": 5.23, + "p90_in": 11.84 + } + } + }, + "M3": { + "pairs": 509, + "match_rate": 0.517, + "match_px": { + "n": 263, + "median": 3.37, + "p90": 124.2 + }, + "frame_3d": { + "n": 263, + "points": 263, + "median_in": 3.77, + "p90_in": 113.56 + }, + "frame_along": { + "n": 263, + "points": 263, + "median_in": 1.53, + "p90_in": 74.41 + }, + "span_along": { + "n": 107, + "points": 107, + "median_in": 5.84, + "p90_in": 33.65, + "p90_ci95_in": [ + 24.66, + 58.55 + ] + }, + "span_3d": { + "n": 107, + "points": 107, + "median_in": 18.39, + "p90_in": 103.92, + "p90_ci95_in": [ + 95.79, + 112.73 + ] + }, + "anchor_matched": 107, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 110, + "points": 110, + "median_in": 1.01, + "p90_in": 2.93 + }, + "added_after_the_run_share_of_matches_within_2px": 0.418 + } + } + }, + "modern_assumed": { + "all": { + "M2": { + "span_along": { + "n": 9095, + "points": 1819, + "median_in": 3.92, + "p90_in": 14.33, + "p90_ci95_in": [ + 13.42, + 15.32 + ] + }, + "span_3d": { + "n": 9095, + "points": 1819, + "median_in": 6.52, + "p90_in": 23.25 + }, + "frame_along": { + "n": 13085, + "points": 2617, + "median_in": 4.24, + "p90_in": 17.4 + }, + "frame_3d": { + "n": 13085, + "points": 2617, + "median_in": 6.93, + "p90_in": 28.87 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 9090, + "points": 1818, + "median_in": 2.98, + "p90_in": 13.5 + }, + "span_along_true_pixel": { + "n": 9090, + "points": 1818, + "median_in": 2.87, + "p90_in": 12.74 + }, + "frame_along_true_pixel": { + "n": 13080, + "points": 2616, + "median_in": 1.66, + "p90_in": 14.48 + }, + "edge_point_to_fused_plane": { + "n": 13085, + "points": 2617, + "median_in": 2.79, + "p90_in": 13.8 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 955, + "points": 191, + "median_in": 2.57, + "p90_in": 6.1 + }, + "15-30": { + "n": 1715, + "points": 343, + "median_in": 3.2, + "p90_in": 11.31 + }, + "30-45": { + "n": 4795, + "points": 959, + "median_in": 4.1, + "p90_in": 12.39 + }, + ">45": { + "n": 1630, + "points": 326, + "median_in": 6.28, + "p90_in": 37.39 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 955, + "points": 191, + "median_in": 0.46, + "p90_in": 5.26 + }, + "15-30": { + "n": 1715, + "points": 343, + "median_in": 1.35, + "p90_in": 10.79 + }, + "30-45": { + "n": 4795, + "points": 959, + "median_in": 2.88, + "p90_in": 12.15 + }, + ">45": { + "n": 1625, + "points": 325, + "median_in": 6.34, + "p90_in": 46.74 + } + } + }, + "M3": { + "pairs": 12445, + "match_rate": 0.413, + "match_px": { + "n": 5145, + "median": 5.1, + "p90": 131.21 + }, + "frame_3d": { + "n": 5145, + "points": 1221, + "median_in": 9.02, + "p90_in": 109.29 + }, + "frame_along": { + "n": 5145, + "points": 1221, + "median_in": 3.51, + "p90_in": 49.67 + }, + "span_along": { + "n": 2054, + "points": 569, + "median_in": 6.65, + "p90_in": 62.45, + "p90_ci95_in": [ + 57.23, + 68.01 + ] + }, + "span_3d": { + "n": 2054, + "points": 569, + "median_in": 28.61, + "p90_in": 154.63, + "p90_ci95_in": [ + 137.67, + 165.32 + ] + }, + "anchor_matched": 2054, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 1186, + "points": 545, + "median_in": 4.01, + "p90_in": 8.93 + }, + "added_after_the_run_share_of_matches_within_2px": 0.231 + } + }, + "in_plane": { + "M2": { + "span_along": { + "n": 7260, + "points": 1452, + "median_in": 3.63, + "p90_in": 12.55, + "p90_ci95_in": [ + 11.74, + 13.77 + ] + }, + "span_3d": { + "n": 7260, + "points": 1452, + "median_in": 5.87, + "p90_in": 21.37 + }, + "frame_along": { + "n": 10140, + "points": 2028, + "median_in": 4.32, + "p90_in": 16.03 + }, + "frame_3d": { + "n": 10140, + "points": 2028, + "median_in": 6.74, + "p90_in": 20.76 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 7260, + "points": 1452, + "median_in": 2.68, + "p90_in": 11.52 + }, + "span_along_true_pixel": { + "n": 7260, + "points": 1452, + "median_in": 2.47, + "p90_in": 11.53 + }, + "frame_along_true_pixel": { + "n": 10140, + "points": 2028, + "median_in": 1.7, + "p90_in": 12.76 + }, + "edge_point_to_fused_plane": { + "n": 10140, + "points": 2028, + "median_in": 2.26, + "p90_in": 10.01 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 890, + "points": 178, + "median_in": 2.6, + "p90_in": 6.0 + }, + "15-30": { + "n": 1225, + "points": 245, + "median_in": 2.72, + "p90_in": 6.89 + }, + "30-45": { + "n": 3900, + "points": 780, + "median_in": 4.04, + "p90_in": 11.78 + }, + ">45": { + "n": 1245, + "points": 249, + "median_in": 5.25, + "p90_in": 26.92 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 890, + "points": 178, + "median_in": 0.43, + "p90_in": 4.88 + }, + "15-30": { + "n": 1225, + "points": 245, + "median_in": 1.17, + "p90_in": 3.77 + }, + "30-45": { + "n": 3900, + "points": 780, + "median_in": 2.83, + "p90_in": 10.95 + }, + ">45": { + "n": 1245, + "points": 249, + "median_in": 5.23, + "p90_in": 16.1 + } + } + }, + "M3": { + "pairs": 9755, + "match_rate": 0.434, + "match_px": { + "n": 4232, + "median": 5.27, + "p90": 136.51 + }, + "frame_3d": { + "n": 4232, + "points": 995, + "median_in": 9.41, + "p90_in": 112.08 + }, + "frame_along": { + "n": 4232, + "points": 995, + "median_in": 3.77, + "p90_in": 54.68 + }, + "span_along": { + "n": 1738, + "points": 488, + "median_in": 6.09, + "p90_in": 58.66, + "p90_ci95_in": [ + 46.77, + 67.97 + ] + }, + "span_3d": { + "n": 1738, + "points": 488, + "median_in": 26.83, + "p90_in": 154.29, + "p90_ci95_in": [ + 137.06, + 166.73 + ] + }, + "anchor_matched": 1738, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 928, + "points": 435, + "median_in": 4.09, + "p90_in": 9.17 + }, + "added_after_the_run_share_of_matches_within_2px": 0.219 + } + }, + "proud": { + "M2": { + "span_along": { + "n": 1835, + "points": 367, + "median_in": 5.98, + "p90_in": 18.22, + "p90_ci95_in": [ + 16.42, + 21.24 + ] + }, + "span_3d": { + "n": 1835, + "points": 367, + "median_in": 12.2, + "p90_in": 45.64 + }, + "frame_along": { + "n": 2945, + "points": 589, + "median_in": 4.01, + "p90_in": 26.32 + }, + "frame_3d": { + "n": 2945, + "points": 589, + "median_in": 7.98, + "p90_in": 51.54 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 1830, + "points": 366, + "median_in": 5.53, + "p90_in": 18.13 + }, + "span_along_true_pixel": { + "n": 1830, + "points": 366, + "median_in": 5.54, + "p90_in": 17.16 + }, + "frame_along_true_pixel": { + "n": 2940, + "points": 588, + "median_in": 1.6, + "p90_in": 28.32 + }, + "edge_point_to_fused_plane": { + "n": 2945, + "points": 589, + "median_in": 5.38, + "p90_in": 30.69 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 65, + "points": 13, + "median_in": 2.06, + "p90_in": 9.88 + }, + "15-30": { + "n": 490, + "points": 98, + "median_in": 8.12, + "p90_in": 13.94 + }, + "30-45": { + "n": 895, + "points": 179, + "median_in": 4.6, + "p90_in": 15.9 + }, + ">45": { + "n": 385, + "points": 77, + "median_in": 13.6, + "p90_in": 63.84 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 65, + "points": 13, + "median_in": 5.07, + "p90_in": 7.76 + }, + "15-30": { + "n": 490, + "points": 98, + "median_in": 7.29, + "p90_in": 13.36 + }, + "30-45": { + "n": 895, + "points": 179, + "median_in": 4.06, + "p90_in": 15.56 + }, + ">45": { + "n": 380, + "points": 76, + "median_in": 10.0, + "p90_in": 73.08 + } + } + }, + "M3": { + "pairs": 2690, + "match_rate": 0.339, + "match_px": { + "n": 913, + "median": 4.39, + "p90": 89.18 + }, + "frame_3d": { + "n": 913, + "points": 226, + "median_in": 6.65, + "p90_in": 100.39 + }, + "frame_along": { + "n": 913, + "points": 226, + "median_in": 2.1, + "p90_in": 36.86 + }, + "span_along": { + "n": 316, + "points": 81, + "median_in": 12.98, + "p90_in": 64.07, + "p90_ci95_in": [ + 61.35, + 88.23 + ] + }, + "span_3d": { + "n": 316, + "points": 81, + "median_in": 55.01, + "p90_in": 158.34, + "p90_ci95_in": [ + 122.03, + 173.41 + ] + }, + "anchor_matched": 316, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 258, + "points": 110, + "median_in": 3.59, + "p90_in": 6.75 + }, + "added_after_the_run_share_of_matches_within_2px": 0.283 + } + }, + "in_plane_vertical": { + "M2": { + "span_along": { + "n": 1905, + "points": 381, + "median_in": 3.36, + "p90_in": 15.22, + "p90_ci95_in": [ + 13.42, + 16.5 + ] + }, + "span_3d": { + "n": 1905, + "points": 381, + "median_in": 6.45, + "p90_in": 22.94 + }, + "frame_along": { + "n": 2645, + "points": 529, + "median_in": 3.63, + "p90_in": 15.53 + }, + "frame_3d": { + "n": 2645, + "points": 529, + "median_in": 6.65, + "p90_in": 22.96 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 1905, + "points": 381, + "median_in": 3.17, + "p90_in": 14.45 + }, + "span_along_true_pixel": { + "n": 1905, + "points": 381, + "median_in": 2.97, + "p90_in": 12.6 + }, + "frame_along_true_pixel": { + "n": 2645, + "points": 529, + "median_in": 2.47, + "p90_in": 14.19 + }, + "edge_point_to_fused_plane": { + "n": 2645, + "points": 529, + "median_in": 2.58, + "p90_in": 13.44 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 215, + "points": 43, + "median_in": 1.55, + "p90_in": 3.68 + }, + "15-30": { + "n": 415, + "points": 83, + "median_in": 2.97, + "p90_in": 9.15 + }, + "30-45": { + "n": 825, + "points": 165, + "median_in": 4.59, + "p90_in": 13.03 + }, + ">45": { + "n": 450, + "points": 90, + "median_in": 5.83, + "p90_in": 22.66 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 215, + "points": 43, + "median_in": 0.99, + "p90_in": 2.93 + }, + "15-30": { + "n": 415, + "points": 83, + "median_in": 1.79, + "p90_in": 6.92 + }, + "30-45": { + "n": 825, + "points": 165, + "median_in": 3.01, + "p90_in": 13.04 + }, + ">45": { + "n": 450, + "points": 90, + "median_in": 7.77, + "p90_in": 14.15 + } + } + }, + "M3": { + "pairs": 2545, + "match_rate": 0.479, + "match_px": { + "n": 1220, + "median": 5.29, + "p90": 149.51 + }, + "frame_3d": { + "n": 1220, + "points": 274, + "median_in": 8.75, + "p90_in": 120.88 + }, + "frame_along": { + "n": 1220, + "points": 274, + "median_in": 3.23, + "p90_in": 78.49 + }, + "span_along": { + "n": 493, + "points": 130, + "median_in": 5.46, + "p90_in": 63.37, + "p90_ci95_in": [ + 45.96, + 86.1 + ] + }, + "span_3d": { + "n": 493, + "points": 130, + "median_in": 39.14, + "p90_in": 138.7, + "p90_ci95_in": [ + 125.14, + 200.66 + ] + }, + "anchor_matched": 493, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 284, + "points": 119, + "median_in": 3.65, + "p90_in": 8.72 + }, + "added_after_the_run_share_of_matches_within_2px": 0.233 + } + } + }, + "tap_px": { + "tap_abs_du": { + "n": 12581, + "median": 6.73, + "p90": 16.47 + }, + "snap_abs_du": { + "n": 12581, + "median": 11.34, + "p90": 27.79 + }, + "snap_within_1px": 0.058, + "added_after_the_run_snap_from_true_pixel_abs_du": { + "n": 12581, + "median": 11.66, + "p90": 23.29 + }, + "added_after_the_run_vertical_in_plane_edges": { + "tap_px": { + "n": 2511, + "median": 6.6, + "p90": 16.28 + }, + "snap_px": { + "n": 2511, + "median": 8.85, + "p90": 24.59 + }, + "snap_from_true_px": { + "n": 2511, + "median": 8.8, + "p90": 21.87 + } + } + }, + "plans": { + "points_with_2_photos_in_a_group": 2641, + "photos_per_point_median": 5.0, + "points_with_a_second_photo": 2489 + } + }, + "criteria": { + "P1": { + "m0_along_p90_in_0_15": 1.35, + "m0_along_p90_in_over_30": 19.8, + "pass": true + }, + "P2": { + "m1_along_p90_in_in_plane": 6.65, + "pass": false + }, + "P3": { + "m2_span_along_p90_in_in_plane_modern": 12.55, + "pass": false + }, + "P4": { + "m3_span_3d_p90_in_modern": 154.63, + "match_rate": 0.413, + "pass": false + }, + "kill": { + "m0_along_p90_in_per_bin": [ + 1.35, + 4.59, + 8.89, + 24.36 + ], + "triggered": false + } + } + }, + "facade": { + "meta": { + "photos": 76, + "wall_planes": 2, + "edge_points_within_6m": 4734, + "edge_points_on_a_wall": 866, + "points_sampled": 866, + "in_plane": 12, + "proud": 854, + "vertical_edges": 19, + "up_vs_camera_up_deg": 10.52, + "wall_tilt_deg_max": 1.05, + "oracle_scale_median": 1.0735974441681835, + "seconds": 13 + }, + "single": { + "m0": { + "all": { + "all angles": { + "along": { + "n": 1446, + "points": 866, + "median_in": 13.89, + "p90_in": 29.41 + }, + "out": { + "n": 1446, + "points": 866, + "median_in": 7.3, + "p90_in": 12.66 + }, + "3d": { + "n": 1446, + "points": 866, + "median_in": 16.03, + "p90_in": 31.68 + }, + "missing": 0 + }, + "0-15": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 19, + "points": 19, + "median_in": 4.89, + "p90_in": 17.16 + }, + "out": { + "n": 19, + "points": 19, + "median_in": 5.41, + "p90_in": 17.57 + }, + "3d": { + "n": 19, + "points": 19, + "median_in": 7.36, + "p90_in": 24.58 + }, + "missing": 0 + }, + ">45": { + "along": { + "n": 1427, + "points": 859, + "median_in": 13.97, + "p90_in": 29.65 + }, + "out": { + "n": 1427, + "points": 859, + "median_in": 7.32, + "p90_in": 12.55 + }, + "3d": { + "n": 1427, + "points": 859, + "median_in": 16.16, + "p90_in": 31.92 + }, + "missing": 0 + } + }, + "in_plane": { + "all angles": { + "along": { + "n": 49, + "points": 12, + "median_in": 15.03, + "p90_in": 31.74 + }, + "out": { + "n": 49, + "points": 12, + "median_in": 7.58, + "p90_in": 20.86 + }, + "3d": { + "n": 49, + "points": 12, + "median_in": 18.21, + "p90_in": 36.22 + }, + "missing": 0 + }, + "0-15": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 8, + "points": 8, + "median_in": 5.23, + "p90_in": 18.45 + }, + "out": { + "n": 8, + "points": 8, + "median_in": 5.71, + "p90_in": 19.29 + }, + "3d": { + "n": 8, + "points": 8, + "median_in": 7.82, + "p90_in": 26.72 + }, + "missing": 0 + }, + ">45": { + "along": { + "n": 41, + "points": 11, + "median_in": 15.1, + "p90_in": 31.94 + }, + "out": { + "n": 41, + "points": 11, + "median_in": 7.63, + "p90_in": 20.72 + }, + "3d": { + "n": 41, + "points": 11, + "median_in": 18.22, + "p90_in": 36.89 + }, + "missing": 0 + } + }, + "proud": { + "all angles": { + "along": { + "n": 1397, + "points": 854, + "median_in": 13.67, + "p90_in": 29.35 + }, + "out": { + "n": 1397, + "points": 854, + "median_in": 7.2, + "p90_in": 12.5 + }, + "3d": { + "n": 1397, + "points": 854, + "median_in": 15.89, + "p90_in": 31.46 + }, + "missing": 0 + }, + "0-15": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 11, + "points": 11, + "median_in": 4.66, + "p90_in": 5.43 + }, + "out": { + "n": 11, + "points": 11, + "median_in": 5.25, + "p90_in": 5.92 + }, + "3d": { + "n": 11, + "points": 11, + "median_in": 7.08, + "p90_in": 8.11 + }, + "missing": 0 + }, + ">45": { + "along": { + "n": 1386, + "points": 848, + "median_in": 13.81, + "p90_in": 29.41 + }, + "out": { + "n": 1386, + "points": 848, + "median_in": 7.25, + "p90_in": 12.5 + }, + "3d": { + "n": 1386, + "points": 848, + "median_in": 15.97, + "p90_in": 31.52 + }, + "missing": 0 + } + } + }, + "m1": { + "all": { + "all angles": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 1446 + }, + "0-15": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 19 + }, + ">45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 1427 + } + }, + "in_plane": { + "all angles": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 49 + }, + "0-15": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 8 + }, + ">45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 41 + } + }, + "proud": { + "all angles": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 1397 + }, + "0-15": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 11 + }, + ">45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 1386 + } + } + }, + "m1a": { + "all": { + "all angles": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 1446 + }, + "0-15": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 19 + }, + ">45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 1427 + } + }, + "in_plane": { + "all angles": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 49 + }, + "0-15": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 8 + }, + ">45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 41 + } + }, + "proud": { + "all angles": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 1397 + }, + "0-15": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "15-30": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 0 + }, + "30-45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 11 + }, + ">45": { + "along": { + "n": 0, + "points": 0 + }, + "out": { + "n": 0, + "points": 0 + }, + "3d": { + "n": 0, + "points": 0 + }, + "missing": 1386 + } + } + }, + "m1a_yaw": { + "observations": 0 + } + }, + "multi": { + "exact": { + "all": { + "M2": { + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 0, + "points": 0 + }, + "span_along_true_pixel": { + "n": 0, + "points": 0 + }, + "frame_along_true_pixel": { + "n": 0, + "points": 0 + }, + "edge_point_to_fused_plane": { + "n": 0, + "points": 0 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + } + }, + "M3": { + "pairs": 603, + "match_rate": 0.149, + "match_px": { + "n": 90, + "median": 2.08, + "p90": 33.57 + }, + "frame_3d": { + "n": 90, + "points": 90, + "median_in": 3.45, + "p90_in": 75.04 + }, + "frame_along": { + "n": 90, + "points": 90, + "median_in": 2.67, + "p90_in": 55.5 + }, + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "anchor_matched": 0, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 41, + "points": 41, + "median_in": 1.14, + "p90_in": 3.03 + }, + "added_after_the_run_share_of_matches_within_2px": 0.456 + } + }, + "in_plane": { + "M2": { + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 0, + "points": 0 + }, + "span_along_true_pixel": { + "n": 0, + "points": 0 + }, + "frame_along_true_pixel": { + "n": 0, + "points": 0 + }, + "edge_point_to_fused_plane": { + "n": 0, + "points": 0 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + } + }, + "M3": { + "pairs": 12, + "match_rate": 0.0, + "match_px": { + "n": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "anchor_matched": 0, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 0, + "points": 0 + }, + "added_after_the_run_share_of_matches_within_2px": NaN + } + }, + "proud": { + "M2": { + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 0, + "points": 0 + }, + "span_along_true_pixel": { + "n": 0, + "points": 0 + }, + "frame_along_true_pixel": { + "n": 0, + "points": 0 + }, + "edge_point_to_fused_plane": { + "n": 0, + "points": 0 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + } + }, + "M3": { + "pairs": 591, + "match_rate": 0.152, + "match_px": { + "n": 90, + "median": 2.08, + "p90": 33.57 + }, + "frame_3d": { + "n": 90, + "points": 90, + "median_in": 3.45, + "p90_in": 75.04 + }, + "frame_along": { + "n": 90, + "points": 90, + "median_in": 2.67, + "p90_in": 55.5 + }, + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "anchor_matched": 0, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 41, + "points": 41, + "median_in": 1.14, + "p90_in": 3.03 + }, + "added_after_the_run_share_of_matches_within_2px": 0.456 + } + }, + "in_plane_vertical": { + "M2": { + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 0, + "points": 0 + }, + "span_along_true_pixel": { + "n": 0, + "points": 0 + }, + "frame_along_true_pixel": { + "n": 0, + "points": 0 + }, + "edge_point_to_fused_plane": { + "n": 0, + "points": 0 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + } + }, + "M3": { + "pairs": 0, + "match_rate": 0.0, + "match_px": { + "n": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "anchor_matched": 0, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 0, + "points": 0 + }, + "added_after_the_run_share_of_matches_within_2px": NaN + } + } + }, + "modern_assumed": { + "all": { + "M2": { + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 0, + "points": 0 + }, + "span_along_true_pixel": { + "n": 0, + "points": 0 + }, + "frame_along_true_pixel": { + "n": 0, + "points": 0 + }, + "edge_point_to_fused_plane": { + "n": 0, + "points": 0 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + } + }, + "M3": { + "pairs": 3015, + "match_rate": 0.119, + "match_px": { + "n": 359, + "median": 4.78, + "p90": 39.92 + }, + "frame_3d": { + "n": 359, + "points": 105, + "median_in": 10.53, + "p90_in": 85.84 + }, + "frame_along": { + "n": 359, + "points": 105, + "median_in": 8.2, + "p90_in": 64.23 + }, + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "anchor_matched": 0, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 102, + "points": 54, + "median_in": 4.02, + "p90_in": 13.34 + }, + "added_after_the_run_share_of_matches_within_2px": 0.284 + } + }, + "in_plane": { + "M2": { + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 0, + "points": 0 + }, + "span_along_true_pixel": { + "n": 0, + "points": 0 + }, + "frame_along_true_pixel": { + "n": 0, + "points": 0 + }, + "edge_point_to_fused_plane": { + "n": 0, + "points": 0 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + } + }, + "M3": { + "pairs": 60, + "match_rate": 0.0, + "match_px": { + "n": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "anchor_matched": 0, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 0, + "points": 0 + }, + "added_after_the_run_share_of_matches_within_2px": NaN + } + }, + "proud": { + "M2": { + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 0, + "points": 0 + }, + "span_along_true_pixel": { + "n": 0, + "points": 0 + }, + "frame_along_true_pixel": { + "n": 0, + "points": 0 + }, + "edge_point_to_fused_plane": { + "n": 0, + "points": 0 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + } + }, + "M3": { + "pairs": 2955, + "match_rate": 0.121, + "match_px": { + "n": 359, + "median": 4.78, + "p90": 39.92 + }, + "frame_3d": { + "n": 359, + "points": 105, + "median_in": 10.53, + "p90_in": 85.84 + }, + "frame_along": { + "n": 359, + "points": 105, + "median_in": 8.2, + "p90_in": 64.23 + }, + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "anchor_matched": 0, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 102, + "points": 54, + "median_in": 4.02, + "p90_in": 13.34 + }, + "added_after_the_run_share_of_matches_within_2px": 0.284 + } + }, + "in_plane_vertical": { + "M2": { + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + } + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": { + "n": 0, + "points": 0 + }, + "span_along_true_pixel": { + "n": 0, + "points": 0 + }, + "frame_along_true_pixel": { + "n": 0, + "points": 0 + }, + "edge_point_to_fused_plane": { + "n": 0, + "points": 0 + }, + "span_along_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + }, + "span_along_true_pixel_by_median_view_angle": { + "0-15": { + "n": 0, + "points": 0 + }, + "15-30": { + "n": 0, + "points": 0 + }, + "30-45": { + "n": 0, + "points": 0 + }, + ">45": { + "n": 0, + "points": 0 + } + } + }, + "M3": { + "pairs": 0, + "match_rate": 0.0, + "match_px": { + "n": 0 + }, + "frame_3d": { + "n": 0, + "points": 0 + }, + "frame_along": { + "n": 0, + "points": 0 + }, + "span_along": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "span_3d": { + "n": 0, + "points": 0, + "p90_ci95_in": null + }, + "anchor_matched": 0, + "added_after_the_run_frame_3d_where_match_within_2px": { + "n": 0, + "points": 0 + }, + "added_after_the_run_share_of_matches_within_2px": NaN + } + } + }, + "tap_px": { + "tap_abs_du": { + "n": 3030, + "median": 6.75, + "p90": 16.18 + }, + "snap_abs_du": { + "n": 3030, + "median": 12.78, + "p90": 28.05 + }, + "snap_within_1px": 0.037, + "added_after_the_run_snap_from_true_pixel_abs_du": { + "n": 3030, + "median": 13.12, + "p90": 23.39 + }, + "added_after_the_run_vertical_in_plane_edges": { + "tap_px": { + "n": 0 + }, + "snap_px": { + "n": 0 + }, + "snap_from_true_px": { + "n": 0 + } + } + }, + "plans": { + "points_with_2_photos_in_a_group": 606, + "photos_per_point_median": 5.0, + "points_with_a_second_photo": 603 + } + }, + "criteria": { + "P1": { + "m0_along_p90_in_0_15": NaN, + "m0_along_p90_in_over_30": 29.41, + "pass": false + }, + "P2": { + "m1_along_p90_in_in_plane": NaN, + "pass": false + }, + "P3": { + "m2_span_along_p90_in_in_plane_modern": NaN, + "pass": false + }, + "P4": { + "m3_span_3d_p90_in_modern": NaN, + "match_rate": 0.119, + "pass": false + }, + "kill": { + "m0_along_p90_in_per_bin": [ + NaN, + NaN, + 17.16, + 29.65 + ], + "triggered": false + } + } + } + } +} diff --git a/experiments/edge-geometry/results/edge_geometry.md b/experiments/edge-geometry/results/edge_geometry.md new file mode 100644 index 000000000..a48d69fcf --- /dev/null +++ b/experiments/edge-geometry/results/edge_geometry.md @@ -0,0 +1,231 @@ +# Edge geometry (generated by `uv run python run.py`) + +Evals harness commit `190ed339f41924ae0244e0b88c5ebe647ddd9eb4`. Errors in inches, median / p90 (points). + +## electro + +`{"photos": 45, "wall_planes": 21, "edge_points_within_6m": 27436, "edge_points_on_a_wall": 25492, "points_sampled": 3000, "in_plane": 2258, "proud": 742, "vertical_edges": 730, "up_vs_camera_up_deg": 5.21, "wall_tilt_deg_max": 3.14, "oracle_scale_median": 0.9961415148011799, "seconds": 93}` + +### M0, depth at the pixel + +| Edges | View angle | Along the wall | Out of plane | 3-D | +| --- | --- | --- | --- | --- | +| all | all angles | 2.1 / 15.6 (2994) | 3.1 / 19.2 (2994) | 5.7 / 38.6 (2994) | +| all | 0-15 | 0.2 / 1.4 (345) | 4.8 / 14.8 (345) | 4.9 / 14.9 (345) | +| all | 15-30 | 0.7 / 4.6 (912) | 3.8 / 15.3 (912) | 4.2 / 17.0 (912) | +| all | 30-45 | 2.0 / 8.9 (1430) | 4.2 / 20.4 (1430) | 5.2 / 24.9 (1430) | +| all | >45 | 5.1 / 24.4 (1593) | 1.7 / 23.7 (1593) | 7.5 / 66.2 (1593) | +| in_plane | all angles | 2.0 / 14.6 (2258) | 2.8 / 16.2 (2258) | 5.3 / 31.7 (2258) | +| in_plane | 0-15 | 0.2 / 0.9 (281) | 4.8 / 14.6 (281) | 4.9 / 14.8 (281) | +| in_plane | 15-30 | 0.7 / 4.5 (708) | 3.4 / 16.9 (708) | 3.6 / 18.2 (708) | +| in_plane | 30-45 | 1.9 / 7.8 (1077) | 3.9 / 15.9 (1077) | 4.9 / 19.9 (1077) | +| in_plane | >45 | 4.8 / 24.1 (1211) | 1.5 / 17.7 (1211) | 6.6 / 62.1 (1211) | +| proud | all angles | 2.5 / 18.0 (736) | 4.2 / 25.0 (736) | 7.3 / 57.3 (736) | +| proud | 0-15 | 0.3 / 2.7 (64) | 4.8 / 16.2 (64) | 4.9 / 16.4 (64) | +| proud | 15-30 | 0.9 / 4.8 (204) | 4.2 / 12.7 (204) | 4.6 / 14.1 (204) | +| proud | 30-45 | 2.4 / 12.1 (353) | 5.4 / 24.8 (353) | 6.9 / 30.2 (353) | +| proud | >45 | 6.8 / 27.1 (382) | 3.1 / 48.1 (382) | 19.9 / 116.2 (382) | + +### M1, ray to this photo's wall plane + +| Edges | View angle | Along the wall | Out of plane | 3-D | +| --- | --- | --- | --- | --- | +| all | all angles | 1.3 / 7.2 (2712) | 3.1 / 10.5 (2712) | 4.2 / 15.1 (2712) | +| all | 0-15 | 0.2 / 0.6 (342) | 4.2 / 11.4 (342) | 4.2 / 11.6 (342) | +| all | 15-30 | 0.5 / 2.8 (898) | 2.9 / 10.3 (898) | 3.2 / 11.1 (898) | +| all | 30-45 | 1.6 / 6.3 (1376) | 3.3 / 8.9 (1376) | 4.2 / 11.3 (1376) | +| all | >45 | 3.5 / 21.7 (1219) | 2.6 / 16.3 (1219) | 5.3 / 45.0 (1219) | +| in_plane | all angles | 1.1 / 6.7 (2097) | 2.7 / 9.2 (2097) | 3.8 / 12.0 (2097) | +| in_plane | 0-15 | 0.1 / 0.5 (278) | 4.2 / 11.5 (278) | 4.3 / 11.7 (278) | +| in_plane | 15-30 | 0.4 / 2.3 (699) | 2.4 / 9.8 (699) | 2.7 / 10.7 (699) | +| in_plane | 30-45 | 1.5 / 5.8 (1044) | 3.0 / 7.6 (1044) | 3.7 / 10.1 (1044) | +| in_plane | >45 | 2.9 / 15.0 (998) | 1.9 / 8.9 (998) | 4.5 / 36.2 (998) | +| proud | all angles | 2.0 / 14.5 (615) | 5.0 / 19.0 (615) | 6.3 / 27.5 (615) | +| proud | 0-15 | 0.3 / 1.1 (64) | 3.8 / 7.2 (64) | 3.9 / 7.3 (64) | +| proud | 15-30 | 1.0 / 3.5 (199) | 4.6 / 11.8 (199) | 5.0 / 12.8 (199) | +| proud | 30-45 | 2.2 / 8.7 (332) | 4.5 / 15.9 (332) | 5.8 / 20.7 (332) | +| proud | >45 | 12.1 / 31.9 (221) | 9.6 / 29.5 (221) | 19.2 / 77.7 (221) | + +### M1-anchor, ray to a 0.3 m patch plane 1-3 m away + +| Edges | View angle | Along the wall | Out of plane | 3-D | +| --- | --- | --- | --- | --- | +| all | all angles | 2.0 / 12.2 (1707) | 3.8 / 17.9 (1707) | 5.1 / 27.2 (1707) | +| all | 0-15 | 0.1 / 0.6 (199) | 3.7 / 12.7 (199) | 3.8 / 12.8 (199) | +| all | 15-30 | 1.1 / 5.5 (592) | 3.9 / 14.0 (592) | 4.3 / 15.7 (592) | +| all | 30-45 | 2.2 / 10.5 (924) | 4.3 / 19.7 (924) | 5.4 / 26.7 (924) | +| all | >45 | 5.1 / 27.1 (633) | 3.0 / 18.8 (633) | 6.6 / 36.8 (633) | +| in_plane | all angles | 1.9 / 10.4 (1360) | 3.4 / 13.6 (1360) | 4.7 / 21.0 (1360) | +| in_plane | 0-15 | 0.1 / 0.5 (181) | 3.5 / 11.8 (181) | 3.5 / 12.0 (181) | +| in_plane | 15-30 | 1.0 / 5.8 (478) | 3.5 / 13.7 (478) | 3.9 / 15.3 (478) | +| in_plane | 30-45 | 2.2 / 8.6 (727) | 4.1 / 16.1 (727) | 5.0 / 19.5 (727) | +| in_plane | >45 | 4.5 / 27.7 (526) | 2.8 / 12.2 (526) | 5.7 / 36.5 (526) | +| proud | all angles | 2.5 / 15.6 (347) | 6.3 / 42.4 (347) | 8.0 / 64.1 (347) | +| proud | 0-15 | 2.1 / 154.1 (18) | 12.7 / 3149.7 (18) | 13.0 / 3164.1 (18) | +| proud | 15-30 | 1.7 / 5.1 (114) | 7.1 / 14.6 (114) | 7.8 / 16.4 (114) | +| proud | 30-45 | 2.0 / 15.7 (197) | 4.7 / 63.5 (197) | 5.8 / 79.7 (197) | +| proud | >45 | 9.5 / 17.9 (107) | 7.4 / 41.0 (107) | 14.5 / 63.9 (107) | + +### M1-anchor yaw + +`{"observations": 3411, "abs_psi_deg": {"median": 2.15, "p90": 6.72}, "abs_s_m_median": 2.0, "all_added_after_the_run": {"sign_agreement_where_prediction_over_1cm": 0.7, "median_error_over_prediction_where_over_1cm": 0.73, "view_angle_under_60": {"n": 3080, "slope": 0.36, "r2": 0.01}, "spearman_abs_error_vs_tan_angle_alone": 0.5}, "all": {"n": 3411, "signed_first_order_slope": 0.05, "signed_first_order_r2": 0.02, "signed_r2_with_slope_1": -4.58, "abs_error_over_s_psi_tan_median": 0.94, "abs_error_over_s_psi_tan_n": 2811, "spearman_abs_error_vs_s_psi_tan": 0.63, "abs_error_p90_in": 12.18, "residual_after_first_order_p90_in": 10.9}, "in_plane_added_after_the_run": {"sign_agreement_where_prediction_over_1cm": 0.74, "median_error_over_prediction_where_over_1cm": 0.83, "view_angle_under_60": {"n": 2455, "slope": 0.29, "r2": 0.02}, "spearman_abs_error_vs_tan_angle_alone": 0.54}, "in_plane": {"n": 2755, "signed_first_order_slope": 0.05, "signed_first_order_r2": 0.02, "signed_r2_with_slope_1": -12.61, "abs_error_over_s_psi_tan_median": 0.94, "abs_error_over_s_psi_tan_n": 2231, "spearman_abs_error_vs_s_psi_tan": 0.67, "abs_error_p90_in": 10.43, "residual_after_first_order_p90_in": 9.41}}` + +### M2 and M3 + +| Poses | Edges | M2 span along | M2 p90 95% CI | M3 pairs | M3 match | M3 match px | M3 span along | M3 span 3-D | M3 3-D p90 95% CI | M3 frame 3-D | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| exact | all | 4.0 / 14.5 (1819) | [13.85, 15.72] | 2489 | 47% | 2.21 / 103.64 | 2.1 / 41.1 (556) | 6.0 / 112.5 (556) | [106.82, 121.49] | 2.7 / 102.9 (1175) | +| exact | in_plane | 3.7 / 13.0 (1452) | [12.06, 14.18] | 1951 | 50% | 2.2 / 109.17 | 2.1 / 34.9 (485) | 5.7 / 109.9 (485) | [104.83, 118.28] | 2.7 / 105.2 (971) | +| exact | proud | 5.5 / 18.1 (367) | [17.13, 20.83] | 538 | 38% | 2.32 / 86.19 | 2.3 / 59.9 (71) | 14.9 / 134.3 (71) | [90.11, 164.99] | 2.7 / 85.2 (204) | +| exact | in_plane_vertical | 3.2 / 15.4 (381) | [13.66, 16.83] | 509 | 52% | 3.37 / 124.2 | 5.8 / 33.6 (107) | 18.4 / 103.9 (107) | [95.79, 112.73] | 3.8 / 113.6 (263) | +| modern_assumed | all | 3.9 / 14.3 (1819) | [13.42, 15.32] | 12445 | 41% | 5.1 / 131.21 | 6.7 / 62.5 (569) | 28.6 / 154.6 (569) | [137.67, 165.32] | 9.0 / 109.3 (1221) | +| modern_assumed | in_plane | 3.6 / 12.6 (1452) | [11.74, 13.77] | 9755 | 43% | 5.27 / 136.51 | 6.1 / 58.7 (488) | 26.8 / 154.3 (488) | [137.06, 166.73] | 9.4 / 112.1 (995) | +| modern_assumed | proud | 6.0 / 18.2 (367) | [16.42, 21.24] | 2690 | 34% | 4.39 / 89.18 | 13.0 / 64.1 (81) | 55.0 / 158.3 (81) | [122.03, 173.41] | 6.7 / 100.4 (226) | +| modern_assumed | in_plane_vertical | 3.4 / 15.2 (381) | [13.42, 16.5] | 2545 | 48% | 5.29 / 149.51 | 5.5 / 63.4 (130) | 39.1 / 138.7 (130) | [125.14, 200.66] | 8.8 / 120.9 (274) | + +M3 match px is in pixels, not inches. + +### Added after the run + +| Poses | Edges | M2 with the true pixel | M2 tap, no snap | Edge point to fused MoGe-2 plane | M3 3-D where the match is within 2 px | Matches within 2 px | +| --- | --- | --- | --- | --- | --- | --- | +| exact | all | 2.5 / 12.6 (1819) | 2.8 / 13.0 (1819) | 2.7 / 13.9 (2617) | 0.7 / 2.9 (571) | 0.486 | +| exact | in_plane | 2.1 / 10.9 (1452) | 2.5 / 10.9 (1452) | 2.3 / 10.0 (2028) | 0.8 / 2.9 (473) | 0.487 | +| exact | proud | 5.4 / 17.0 (367) | 4.9 / 18.0 (367) | 4.9 / 31.6 (589) | 0.7 / 2.3 (98) | 0.48 | +| exact | in_plane_vertical | 2.6 / 11.4 (381) | 2.9 / 11.9 (381) | 2.6 / 13.3 (529) | 1.0 / 2.9 (110) | 0.418 | +| modern_assumed | all | 2.9 / 12.7 (1818) | 3.0 / 13.5 (1818) | 2.8 / 13.8 (2617) | 4.0 / 8.9 (545) | 0.231 | +| modern_assumed | in_plane | 2.5 / 11.5 (1452) | 2.7 / 11.5 (1452) | 2.3 / 10.0 (2028) | 4.1 / 9.2 (435) | 0.219 | +| modern_assumed | proud | 5.5 / 17.2 (366) | 5.5 / 18.1 (366) | 5.4 / 30.7 (589) | 3.6 / 6.8 (110) | 0.283 | +| modern_assumed | in_plane_vertical | 3.0 / 12.6 (381) | 3.2 / 14.4 (381) | 2.6 / 13.4 (529) | 3.6 / 8.7 (119) | 0.233 | + +M2 along-wall span error by the point's median view angle (snap, then true pixel): + +- exact, in-plane, snapped: 0-15 2.3 / 5.6 (178), 15-30 2.7 / 6.8 (245), 30-45 4.2 / 11.6 (780), >45 6.1 / 29.1 (249) +- exact, in-plane, true pixel: 0-15 0.1 / 4.3 (178), 15-30 1.1 / 3.6 (245), 30-45 2.5 / 10.5 (780), >45 5.1 / 13.8 (249) +- modern_assumed, in-plane, snapped: 0-15 2.6 / 6.0 (178), 15-30 2.7 / 6.9 (245), 30-45 4.0 / 11.8 (780), >45 5.2 / 26.9 (249) +- modern_assumed, in-plane, true pixel: 0-15 0.4 / 4.9 (178), 15-30 1.2 / 3.8 (245), 30-45 2.8 / 10.9 (780), >45 5.2 / 16.1 (249) + +Edge point to plane (single photo): `{"laser_plane_in_plane_edges": {"n": 6076, "points": 2258, "median_in": 0.4, "p90_in": 1.38}, "m1_plane_in_plane_edges": {"n": 4920, "points": 2097, "median_in": 2.54, "p90_in": 9.81}}` + + +Tap and snap, horizontal pixel error: `{"tap_abs_du": {"n": 12581, "median": 6.73, "p90": 16.47}, "snap_abs_du": {"n": 12581, "median": 11.34, "p90": 27.79}, "snap_within_1px": 0.058, "added_after_the_run_snap_from_true_pixel_abs_du": {"n": 12581, "median": 11.66, "p90": 23.29}, "added_after_the_run_vertical_in_plane_edges": {"tap_px": {"n": 2511, "median": 6.6, "p90": 16.28}, "snap_px": {"n": 2511, "median": 8.85, "p90": 24.59}, "snap_from_true_px": {"n": 2511, "median": 8.8, "p90": 21.87}}}` + +`{"points_with_2_photos_in_a_group": 2641, "photos_per_point_median": 5.0, "points_with_a_second_photo": 2489}` + +### Criteria + +`{"P1": {"m0_along_p90_in_0_15": 1.35, "m0_along_p90_in_over_30": 19.8, "pass": true}, "P2": {"m1_along_p90_in_in_plane": 6.65, "pass": false}, "P3": {"m2_span_along_p90_in_in_plane_modern": 12.55, "pass": false}, "P4": {"m3_span_3d_p90_in_modern": 154.63, "match_rate": 0.413, "pass": false}, "kill": {"m0_along_p90_in_per_bin": [1.35, 4.59, 8.89, 24.36], "triggered": false}}` + +## facade + +`{"photos": 76, "wall_planes": 2, "edge_points_within_6m": 4734, "edge_points_on_a_wall": 866, "points_sampled": 866, "in_plane": 12, "proud": 854, "vertical_edges": 19, "up_vs_camera_up_deg": 10.52, "wall_tilt_deg_max": 1.05, "oracle_scale_median": 1.0735974441681835, "seconds": 13}` + +### M0, depth at the pixel + +| Edges | View angle | Along the wall | Out of plane | 3-D | +| --- | --- | --- | --- | --- | +| all | all angles | 13.9 / 29.4 (866) | 7.3 / 12.7 (866) | 16.0 / 31.7 (866) | +| all | 0-15 | n/a | n/a | n/a | +| all | 15-30 | n/a | n/a | n/a | +| all | 30-45 | 4.9 / 17.2 (19) | 5.4 / 17.6 (19) | 7.4 / 24.6 (19) | +| all | >45 | 14.0 / 29.6 (859) | 7.3 / 12.6 (859) | 16.2 / 31.9 (859) | +| in_plane | all angles | 15.0 / 31.7 (12) | 7.6 / 20.9 (12) | 18.2 / 36.2 (12) | +| in_plane | 0-15 | n/a | n/a | n/a | +| in_plane | 15-30 | n/a | n/a | n/a | +| in_plane | 30-45 | 5.2 / 18.4 (8) | 5.7 / 19.3 (8) | 7.8 / 26.7 (8) | +| in_plane | >45 | 15.1 / 31.9 (11) | 7.6 / 20.7 (11) | 18.2 / 36.9 (11) | +| proud | all angles | 13.7 / 29.4 (854) | 7.2 / 12.5 (854) | 15.9 / 31.5 (854) | +| proud | 0-15 | n/a | n/a | n/a | +| proud | 15-30 | n/a | n/a | n/a | +| proud | 30-45 | 4.7 / 5.4 (11) | 5.2 / 5.9 (11) | 7.1 / 8.1 (11) | +| proud | >45 | 13.8 / 29.4 (848) | 7.2 / 12.5 (848) | 16.0 / 31.5 (848) | + +### M1, ray to this photo's wall plane + +| Edges | View angle | Along the wall | Out of plane | 3-D | +| --- | --- | --- | --- | --- | +| all | all angles | n/a | n/a | n/a | +| all | 0-15 | n/a | n/a | n/a | +| all | 15-30 | n/a | n/a | n/a | +| all | 30-45 | n/a | n/a | n/a | +| all | >45 | n/a | n/a | n/a | +| in_plane | all angles | n/a | n/a | n/a | +| in_plane | 0-15 | n/a | n/a | n/a | +| in_plane | 15-30 | n/a | n/a | n/a | +| in_plane | 30-45 | n/a | n/a | n/a | +| in_plane | >45 | n/a | n/a | n/a | +| proud | all angles | n/a | n/a | n/a | +| proud | 0-15 | n/a | n/a | n/a | +| proud | 15-30 | n/a | n/a | n/a | +| proud | 30-45 | n/a | n/a | n/a | +| proud | >45 | n/a | n/a | n/a | + +### M1-anchor, ray to a 0.3 m patch plane 1-3 m away + +| Edges | View angle | Along the wall | Out of plane | 3-D | +| --- | --- | --- | --- | --- | +| all | all angles | n/a | n/a | n/a | +| all | 0-15 | n/a | n/a | n/a | +| all | 15-30 | n/a | n/a | n/a | +| all | 30-45 | n/a | n/a | n/a | +| all | >45 | n/a | n/a | n/a | +| in_plane | all angles | n/a | n/a | n/a | +| in_plane | 0-15 | n/a | n/a | n/a | +| in_plane | 15-30 | n/a | n/a | n/a | +| in_plane | 30-45 | n/a | n/a | n/a | +| in_plane | >45 | n/a | n/a | n/a | +| proud | all angles | n/a | n/a | n/a | +| proud | 0-15 | n/a | n/a | n/a | +| proud | 15-30 | n/a | n/a | n/a | +| proud | 30-45 | n/a | n/a | n/a | +| proud | >45 | n/a | n/a | n/a | + +### M1-anchor yaw + +`{"observations": 0}` + +### M2 and M3 + +| Poses | Edges | M2 span along | M2 p90 95% CI | M3 pairs | M3 match | M3 match px | M3 span along | M3 span 3-D | M3 3-D p90 95% CI | M3 frame 3-D | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| exact | all | n/a | None | 603 | 15% | 2.08 / 33.57 | n/a | n/a | None | 3.5 / 75.0 (90) | +| exact | in_plane | n/a | None | 12 | 0% | n/a / n/a | n/a | n/a | None | n/a | +| exact | proud | n/a | None | 591 | 15% | 2.08 / 33.57 | n/a | n/a | None | 3.5 / 75.0 (90) | +| exact | in_plane_vertical | n/a | None | 0 | 0% | n/a / n/a | n/a | n/a | None | n/a | +| modern_assumed | all | n/a | None | 3015 | 12% | 4.78 / 39.92 | n/a | n/a | None | 10.5 / 85.8 (105) | +| modern_assumed | in_plane | n/a | None | 60 | 0% | n/a / n/a | n/a | n/a | None | n/a | +| modern_assumed | proud | n/a | None | 2955 | 12% | 4.78 / 39.92 | n/a | n/a | None | 10.5 / 85.8 (105) | +| modern_assumed | in_plane_vertical | n/a | None | 0 | 0% | n/a / n/a | n/a | n/a | None | n/a | + +M3 match px is in pixels, not inches. + +### Added after the run + +| Poses | Edges | M2 with the true pixel | M2 tap, no snap | Edge point to fused MoGe-2 plane | M3 3-D where the match is within 2 px | Matches within 2 px | +| --- | --- | --- | --- | --- | --- | --- | +| exact | all | n/a | n/a | n/a | 1.1 / 3.0 (41) | 0.456 | +| exact | in_plane | n/a | n/a | n/a | n/a | nan | +| exact | proud | n/a | n/a | n/a | 1.1 / 3.0 (41) | 0.456 | +| exact | in_plane_vertical | n/a | n/a | n/a | n/a | nan | +| modern_assumed | all | n/a | n/a | n/a | 4.0 / 13.3 (54) | 0.284 | +| modern_assumed | in_plane | n/a | n/a | n/a | n/a | nan | +| modern_assumed | proud | n/a | n/a | n/a | 4.0 / 13.3 (54) | 0.284 | +| modern_assumed | in_plane_vertical | n/a | n/a | n/a | n/a | nan | + +M2 along-wall span error by the point's median view angle (snap, then true pixel): + +- exact, in-plane, snapped: 0-15 n/a, 15-30 n/a, 30-45 n/a, >45 n/a +- exact, in-plane, true pixel: 0-15 n/a, 15-30 n/a, 30-45 n/a, >45 n/a +- modern_assumed, in-plane, snapped: 0-15 n/a, 15-30 n/a, 30-45 n/a, >45 n/a +- modern_assumed, in-plane, true pixel: 0-15 n/a, 15-30 n/a, 30-45 n/a, >45 n/a + +Edge point to plane (single photo): `null` + + +Tap and snap, horizontal pixel error: `{"tap_abs_du": {"n": 3030, "median": 6.75, "p90": 16.18}, "snap_abs_du": {"n": 3030, "median": 12.78, "p90": 28.05}, "snap_within_1px": 0.037, "added_after_the_run_snap_from_true_pixel_abs_du": {"n": 3030, "median": 13.12, "p90": 23.39}, "added_after_the_run_vertical_in_plane_edges": {"tap_px": {"n": 0}, "snap_px": {"n": 0}, "snap_from_true_px": {"n": 0}}}` + +`{"points_with_2_photos_in_a_group": 606, "photos_per_point_median": 5.0, "points_with_a_second_photo": 603}` + +### Criteria + +`{"P1": {"m0_along_p90_in_0_15": NaN, "m0_along_p90_in_over_30": 29.41, "pass": false}, "P2": {"m1_along_p90_in_in_plane": NaN, "pass": false}, "P3": {"m2_span_along_p90_in_in_plane_modern": NaN, "pass": false}, "P4": {"m3_span_3d_p90_in_modern": NaN, "match_rate": 0.119, "pass": false}, "kill": {"m0_along_p90_in_per_bin": [NaN, NaN, 17.16, 29.65], "triggered": false}}` diff --git a/experiments/edge-geometry/run.py b/experiments/edge-geometry/run.py new file mode 100644 index 000000000..2d62dc943 --- /dev/null +++ b/experiments/edge-geometry/run.py @@ -0,0 +1,898 @@ +"""Edge geometry on ETH3D: where MoGe-2's error at object edges lands relative to the wall, and +whether a ray to the wall plane, a snapped tap or a second photo places an edge better. + + uv run python run.py # writes results/edge_geometry.json and results/edge_geometry.md + +Reads the evals harness read-only (EDGE_EVALS_DIR, default below) and its caches under +~/house-scanning-data/evals: the ETH3D scenes after `evals.recon prepare`, MoGe-2 predictions +and the AR-like poses. Every draw is seeded, so a rerun gives the same numbers. +""" + +from __future__ import annotations + +import json +import os +import pickle +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np +from scipy.spatial import cKDTree + +import geom +import imagematch +import stats + +# The evals checkout is read-only here: importing it must not write __pycache__ into it. +sys.dont_write_bytecode = True +EVALS_DIR = Path( + os.environ.get( + "EDGE_EVALS_DIR", + Path.home() / "Programming Projects/house-scanning-evals/experiments/evals", + ) +) +sys.path.insert(0, str(EVALS_DIR)) + +from evals.eth3d import read_views, up_direction # noqa: E402 +from evals.paths import ETH3D_DIR # noqa: E402 +from evals.recon import ( # noqa: E402 + NEAR_M, + PREDICTIONS, + TOLERANCE, + VERTICAL_MAX_UP, + WIDTH, + eval_candidates, + load_prediction, + visibility_dir, +) +from evals.triangulate import sample_depth # noqa: E402 + +HERE = Path(__file__).parent +RESULTS = HERE / "results" + +SCENES = ("electro", "facade") +MIN_POINTS_TO_REPORT = 200 # facade is reported only with this many edge points within 6 m +MAX_POINTS = 3000 + +# Wall planes from the laser scan. +PLANE_TOL_M = 0.03 +MAX_TILT_DEG = 17.0 # the evals' vertical-surface limit, |normal . up| < 0.3 +RANSAC_POINTS = 400_000 +JOIN_M = 0.6 # an edge point joins the nearest plane this close +MEMBER_REACH_M = 1.0 # ... if one of the plane's own scan points lies this close to it +IN_PLANE_M = 0.05 +EDGE_RADIUS_M = 0.15 # neighbourhood for an edge's direction +VERTICAL_EDGE_DEG = 30.0 + +# Methods. +MIN_WALL_PX = 50 # interior wall pixels a photo needs for an M1 plane +ANCHOR_ALONG_M = (1.0, 3.0) +ANCHOR_TARGET_M = 2.0 +PATCH_RADIUS_M = 0.3 +MIN_PATCH_PX = 20 +ANCHOR_TRIES = 5 +TAP_SIGMA_PX = 10.0 +SNAP_RADIUS_PX = 25 +SNAP_ROWS = 9 +WORLD_PLANE_PX_PER_PHOTO = 3000 +MIN_BASELINE_M = 0.3 +MIN_RAY_ANGLE_DEG = 5.0 +NCC_MIN = 0.8 +NCC_PATCH = 15 +DEPTH_RANGE_M = (0.5, 10.0) +SEED = 20260926 + +SETTINGS = [("exact", 0, "exact.json")] + [ + ("modern_assumed", d, "modern_assumed.json" if d == 0 else f"modern_assumed.draw{d}.json") + for d in range(5) +] + + +@dataclass +class Wall: + """One photo's interior pixels on one laser plane, within NEAR_M of the camera.""" + + ids: np.ndarray # candidate indices, sorted + world: np.ndarray # laser positions (N, 3) + cam: np.ndarray # MoGe-2 points in the camera frame at the oracle scale (N, 3) + + +@dataclass +class ViewData: + name: str + K: np.ndarray + T: np.ndarray # true camera-to-world + index: np.ndarray # visible candidates within NEAR_M of the camera, sorted + uv: np.ndarray + edge: np.ndarray + depth: np.ndarray # MoGe-2 depth at uv times the oracle scale, NaN where invalid + scale: float + walls: dict[int, Wall] + + @property + def C(self) -> np.ndarray: + return self.T[:3, 3] + + def find(self, ids: np.ndarray) -> np.ndarray: + """Row of each candidate id in this view, or -1 when the view does not see it.""" + if not len(self.index): + return np.full(np.shape(ids), -1) + pos = np.searchsorted(self.index, ids) + pos = np.minimum(pos, len(self.index) - 1) + return np.where(self.index[pos] == ids, pos, -1) + + +def backproject(K: np.ndarray, uv: np.ndarray, z: np.ndarray) -> np.ndarray: + return np.c_[(uv[:, 0] - K[0, 2]) / K[0, 0] * z, (uv[:, 1] - K[1, 2]) / K[1, 1] * z, z] + + +def cam_dirs(K: np.ndarray, uv: np.ndarray) -> np.ndarray: + uv = np.atleast_2d(uv) + return np.c_[(uv[:, 0] - K[0, 2]) / K[0, 0], (uv[:, 1] - K[1, 2]) / K[1, 1], np.ones(len(uv))] + + +def evals_commit() -> str: + out = subprocess.run( + ["git", "-C", str(EVALS_DIR), "rev-parse", "HEAD"], capture_output=True, text=True + ) + return out.stdout.strip() or "unknown" + + +def plane_members(cands, normals, planes) -> np.ndarray: + """Plane index per candidate (-1 for none): within PLANE_TOL_M and facing the same way, + nearest plane first.""" + best = np.full(len(cands), -1, np.int16) + best_d = np.full(len(cands), np.inf, np.float32) + cos_agree = np.cos(np.radians(25.0)) + for k, (n, d) in enumerate(planes): + dist = np.abs(cands @ n.astype(np.float32) - np.float32(d)) + ok = (dist < PLANE_TOL_M) & (np.abs(normals @ n.astype(np.float32)) > cos_agree) + ok &= dist < best_d + best[ok] = k + best_d[ok] = dist[ok] + return best + + +def prep_view(scene, view, cands, plane_of) -> ViewData: + h = round(view.height * WIDTH / view.width) + K = view.scaled_K(WIDTH, h) + z = np.load(visibility_dir(scene, TOLERANCE) / f"{view.name}.npz") + index, uv, edge = z["index"], z["uv"].astype(np.float64), z["edge"] + P = cands[index].astype(np.float64) + z_laser = (P @ view.R_wc.T + view.t_wc)[:, 2] + depth, _, _ = load_prediction(PREDICTIONS / scene / "moge2" / f"{view.name}.npz") + if depth.shape != (h, WIDTH): + raise ValueError(f"{scene}/{view.name}: MoGe-2 depth {depth.shape}, expected {(h, WIDTH)}") + pred = sample_depth(depth, uv) + interior = ~edge & np.isfinite(pred) + # The oracle scale: the photo's own laser depth over its prediction, on interior pixels. + scale = float(np.median(z_laser[interior] / pred[interior])) + near = np.linalg.norm(P - view.center, axis=1) <= NEAR_M + index, uv, edge, pred, P = index[near], uv[near], edge[near], pred[near] * scale, P[near] + walls = {} + on = ~edge & np.isfinite(pred) & (plane_of[index] >= 0) + for k in np.unique(plane_of[index[on]]): + sel = on & (plane_of[index] == k) + if sel.sum() >= MIN_WALL_PX: + walls[int(k)] = Wall( + ids=index[sel], world=P[sel], cam=backproject(K, uv[sel], pred[sel]) + ) + return ViewData(view.name, K, view.cam_to_world, index, uv, edge, pred, scale, walls) + + +def level_up(normals, near_cam, camera_up) -> np.ndarray: + """World up as the mean normal of near-horizontal scan points (the ground and other level + surfaces) near the cameras. The mean camera up axis the evals use is about 5 degrees off on + electro: every wall plane tilts 3 to 6 degrees against it and under 1 degree against this.""" + n = normals[near_cam].astype(np.float64) + level = n[np.abs(n @ camera_up) > np.cos(np.radians(15))] + if len(level) < 1000: + raise ValueError(f"only {len(level)} level scan points near the cameras to fix up") + level *= np.sign(level @ camera_up)[:, None] + up = level.mean(axis=0) + return up / np.linalg.norm(up) + + +def assign_edges(E, cands, plane_of, planes) -> tuple[np.ndarray, np.ndarray]: + """Nearest plane within JOIN_M for each edge point, provided one of the plane's own points is + within MEMBER_REACH_M (planes are infinite; this keeps a point on its own stretch of wall).""" + best = np.full(len(E), -1) + best_sd = np.full(len(E), np.inf) + for k, (n, d) in enumerate(planes): + sd = E @ n - d + cand = np.flatnonzero((np.abs(sd) <= JOIN_M) & (np.abs(sd) < np.abs(best_sd))) + if not len(cand): + continue + members = cands[plane_of == k].astype(np.float64) + dist, _ = cKDTree(members).query(E[cand], distance_upper_bound=MEMBER_REACH_M) + ok = cand[np.isfinite(dist)] + best[ok] = k + best_sd[ok] = sd[ok] + return best, best_sd + + +def edge_directions(E_all, E_query, up) -> np.ndarray: + """1 for a vertical edge, 0 for any other line or an unclear shape: the principal axis of the + edge points within EDGE_RADIUS_M, whose spread along it must be at least twice the next.""" + tree = cKDTree(E_all) + out = np.zeros(len(E_query), np.int8) + for i, nb in enumerate(tree.query_ball_point(E_query, EDGE_RADIUS_M)): + if len(nb) < 5: + continue + X = E_all[nb] - E_all[nb].mean(axis=0) + w, V = np.linalg.eigh(X.T @ X) + if w[2] >= 4.0 * max(w[1], 1e-12) and abs(V[:, 2] @ up) >= np.cos( + np.radians(VERTICAL_EDGE_DEG) + ): + out[i] = 1 + return out + + +def choose_anchors(wall: Wall, s_edge: float, h: np.ndarray) -> np.ndarray: + """Rows of `wall` 1 to 3 m along the wall from the edge, nearest to 2 m first.""" + gap = np.abs(wall.world @ h - s_edge) + rows = np.flatnonzero((gap >= ANCHOR_ALONG_M[0]) & (gap <= ANCHOR_ALONG_M[1])) + return rows[np.lexsort((rows, np.abs(gap[rows] - ANCHOR_TARGET_M)))] + + +class Images: + """Grey images and their vertical-edge strength, loaded on first use.""" + + def __init__(self, scene: str): + self.dir = ETH3D_DIR / scene / f"images_{WIDTH}" + self.gray: dict[str, np.ndarray] = {} + self.strength: dict[str, np.ndarray] = {} + + def get(self, name: str) -> np.ndarray: + if name not in self.gray: + img = cv2.imread(str(self.dir / f"{name}.jpg"), cv2.IMREAD_GRAYSCALE) + if img is None: + raise FileNotFoundError(self.dir / f"{name}.jpg") + self.gray[name] = img.astype(np.float32) / 255.0 + return self.gray[name] + + def edges(self, name: str) -> np.ndarray: + if name not in self.strength: + self.strength[name] = imagematch.vertical_edge_strength(self.get(name), SNAP_ROWS) + return self.strength[name] + + +def run_scene(scene: str) -> dict: + t0 = time.time() + views = {v.name: v for v in read_views(ETH3D_DIR / scene)} + up = up_direction(list(views.values())) + pred_dir = PREDICTIONS / scene / "moge2" + vis_dir = visibility_dir(scene, TOLERANCE) + img_dir = ETH3D_DIR / scene / f"images_{WIDTH}" + names = sorted( + n + for n in views + if (vis_dir / f"{n}.npz").exists() + and (pred_dir / f"{n}.npz").exists() + and (img_dir / f"{n}.jpg").exists() + ) + cands = eval_candidates(scene) + normals = np.load(ETH3D_DIR / scene / "normals.npy") + + # Wall planes: RANSAC over vertical scan points within NEAR_M of some camera. + centres = np.array([views[n].center for n in names]) + near_cam = cKDTree(centres).query(cands, distance_upper_bound=NEAR_M)[0] < np.inf + camera_up = up + up = level_up(normals, near_cam, camera_up) + vert = np.flatnonzero(near_cam & (np.abs(normals @ up.astype(np.float32)) < VERTICAL_MAX_UP)) + sub = vert[np.linspace(0, len(vert) - 1, min(RANSAC_POINTS, len(vert))).astype(int)] + planes = geom.ransac_planes( + cands[sub].astype(np.float64), + normals[sub].astype(np.float64), + up, + tol=PLANE_TOL_M, + max_tilt_deg=MAX_TILT_DEG, + seed=SEED, + ) + plane_of = plane_members(cands, normals, planes) + frames = [geom.wall_frame(n, up) for n, _ in planes] + print(f"{scene}: {len(names)} photos, {len(planes)} wall planes ({time.time() - t0:.0f} s)") + + vd = {n: prep_view(scene, views[n], cands, plane_of) for n in names} + print(f"{scene}: views prepared ({time.time() - t0:.0f} s)") + + # Edge points: near side of a depth edge in at least one photo within NEAR_M. + edge_ids = np.unique(np.concatenate([d.index[d.edge] for d in vd.values()])) + E_all = cands[edge_ids].astype(np.float64) + plane_e, sd_e = assign_edges(E_all, cands, plane_of, planes) + assigned = np.flatnonzero(plane_e >= 0) + pick = assigned[np.unique(np.linspace(0, len(assigned) - 1, min(MAX_POINTS, len(assigned))).round().astype(int))] if len(assigned) else assigned + ids = edge_ids[pick] + P = E_all[pick] + plane = plane_e[pick] + in_plane = np.abs(sd_e[pick]) <= IN_PLANE_M + vertical_edge = edge_directions(E_all, P, up).astype(bool) + meta = { + "photos": len(names), + "wall_planes": len(planes), + "edge_points_within_6m": len(edge_ids), + "edge_points_on_a_wall": len(assigned), + "points_sampled": len(ids), + "in_plane": int(in_plane.sum()), + "proud": int((~in_plane).sum()), + "vertical_edges": int(vertical_edge.sum()), + "up_vs_camera_up_deg": round(float(np.degrees(np.arccos(up @ camera_up))), 2), + "wall_tilt_deg_max": round(float(max(np.degrees(np.arcsin(abs(n @ up))) for n, _ in planes)), 2), + "oracle_scale_median": float(np.median([d.scale for d in vd.values()])), + } + print(f"{scene}: {meta}") + if len(ids) < MIN_POINTS_TO_REPORT: + return {"meta": meta, "skipped": f"{len(ids)} edge points on a wall within 6 m"} + + # Every (point, photo) where the photo sees the point within NEAR_M. + obs = {"j": [], "view": [], "row": []} + for vi, n in enumerate(names): + rows = vd[n].find(ids) + j = np.flatnonzero(rows >= 0) + obs["j"].append(j) + obs["view"].append(np.full(len(j), vi)) + obs["row"].append(rows[j]) + obs = {k: np.concatenate(v) for k, v in obs.items()} + + single = single_photo_methods(ids, P, plane, planes, frames, names, vd, obs, up) + single["in_plane"] = in_plane[single["j"]] + multi = multi_photo_methods(scene, ids, P, plane, planes, frames, names, vd, obs, up) + for rec in (multi["exact"], multi["modern_assumed"], multi["tap"]): + rec["in_plane"] = in_plane[rec["j"]] + rec["vertical"] = vertical_edge[rec["j"]] + single["laser_offset"] = np.abs(sd_e[pick])[single["j"]] + meta["seconds"] = round(time.time() - t0) + return {"meta": meta, "single": single, "multi": multi} + + +def single_photo_methods(ids, P, plane, planes, frames, names, vd, obs, up) -> dict: + """M0, M1 and M1-anchor on every photo where the point is an edge.""" + out: dict[str, list] = {k: [] for k in ( + "j", "angle", "m0", "m1", "m1_offset", "m1a", "psi", "s", "pred_signed", "pred_mag", + )} + fits: dict[tuple[str, int], tuple[np.ndarray, float]] = {} + for j, vi, row in zip(obs["j"], obs["view"], obs["row"], strict=True): + d = vd[names[vi]] + if not d.edge[row]: + continue + k = int(plane[j]) + n_k, F = planes[k][0], frames[k] + p = P[j] + ray = p - d.C + angle = float(geom.view_angle_deg(ray[None], n_k)[0]) + R, C = d.T[:3, :3], d.C + uv = d.uv[row] + nan3 = np.full(3, np.nan) + + m0 = nan3 + if np.isfinite(d.depth[row]): + m0 = F @ (R @ backproject(d.K, uv[None], d.depth[row : row + 1])[0] + C - p) + + m1, m1_offset = nan3, np.nan + wall = d.walls.get(k) + if wall is not None: + if (d.name, k) not in fits: + fits[(d.name, k)] = geom.fit_plane(wall.cam) + n_c, d_c = fits[(d.name, k)] + X = geom.ray_plane(np.zeros(3), cam_dirs(d.K, uv), n_c, d_c)[0] + m1 = F @ (R @ X + C - p) + m1_offset = abs(n_c @ (R.T @ (p - C)) - d_c) + + m1a, psi, s = nan3, np.nan, np.nan + if wall is not None: + s_edge = p @ F[0] + for a in choose_anchors(wall, s_edge, F[0])[:ANCHOR_TRIES]: + patch = np.linalg.norm(wall.world - wall.world[a], axis=1) <= PATCH_RADIUS_M + if patch.sum() < MIN_PATCH_PX: + continue + n_c, d_c = geom.fit_plane(wall.cam[patch]) + X = geom.ray_plane(np.zeros(3), cam_dirs(d.K, uv), n_c, d_c)[0] + m1a = F @ (R @ X + C - p) + psi = geom.signed_yaw(R @ n_c, n_k, up) + s = s_edge - wall.world[a] @ F[0] + break + out["j"].append(j) + out["angle"].append(angle) + out["m0"].append(m0) + out["m1"].append(m1) + out["m1_offset"].append(m1_offset) + out["m1a"].append(m1a) + out["psi"].append(psi) + out["s"].append(s) + out["pred_signed"].append(float(geom.yaw_shift(s, psi, ray[None], n_k, up)[0])) + out["pred_mag"].append(abs(s) * abs(psi) * np.tan(np.radians(angle))) + return {k: np.array(v) for k, v in out.items()} + + +def load_poses(scene: str) -> dict[tuple[str, int], dict]: + root = ETH3D_DIR / scene / "ar_poses" + out = {} + for setting, draw, fname in SETTINGS: + groups = json.loads((root / fname).read_text()) + out[(setting, draw)] = { + g: {m: np.array(T) for m, T in body["poses"].items()} + for g, body in groups.items() + if g.startswith("n8-") + } + return out + + +def multi_photo_methods(scene, ids, P, plane, planes, frames, names, vd, obs, up) -> dict: + """M2 (tap, snap, median) and M3 (two photos) with each pose setting and draw.""" + poses = load_poses(scene) + groups = poses[("exact", 0)] + for (setting, draw), gp in poses.items(): + if gp.keys() != groups.keys(): + raise ValueError(f"{scene} {setting} draw {draw}: groups differ from exact") + images = Images(scene) + name_i = {n: i for i, n in enumerate(names)} + + # Photos seeing each point, and the one group of 8 per point that has the most of them. + seen: dict[int, list[tuple[str, int]]] = {} + for j, vi, row in zip(obs["j"], obs["view"], obs["row"], strict=True): + seen.setdefault(int(j), []).append((names[vi], int(row))) + plan = {} + for j, photos in seen.items(): + best = None + for g in sorted(groups): + members = [(n, r) for n, r in photos if n in groups[g]] + if len(members) >= 2 and (best is None or len(members) > len(best[1])): + best = (g, members) + if best is None: + continue + g, members = best + p = P[j] + dist = [np.linalg.norm(vd[n].C - p) for n, _ in members] + flagged = [i for i, (n, r) in enumerate(members) if vd[n].edge[r]] + ref = min(flagged or range(len(members)), key=lambda i: dist[i]) + rn = members[ref][0] + second = None + best_angle = np.inf + for n, r in members: + if n == rn or np.linalg.norm(vd[n].C - vd[rn].C) < MIN_BASELINE_M: + continue + a, b = p - vd[rn].C, p - vd[n].C + ang = np.degrees(np.arccos(np.clip(a @ b / np.linalg.norm(a) / np.linalg.norm(b), -1, 1))) + if MIN_RAY_ANGLE_DEG <= ang < best_angle: + second, best_angle = (n, r), ang + plan[j] = (g, members, ref, second) + + # Taps: one draw per (point, photo), shared by every pose setting. "snap" is M2; "tap" (no + # snap) and "true" (the true pixel) were added after the run to locate M2's error. + pixels: dict[str, dict] = {"snap": {}, "tap": {}, "true": {}} + tap_err, snap_err, snap_true_err, tap_j = [], [], [], [] + for j, (_g, members, _ref, _second) in plan.items(): + for n, r in members: + d = vd[n] + rng = np.random.default_rng([SEED, int(ids[j]), name_i[n]]) + tap = d.uv[r] + rng.normal(0.0, TAP_SIGMA_PX, 2) + snapped = np.array(imagematch.snap(images.edges(n), tap[0], tap[1], SNAP_RADIUS_PX)) + pixels["snap"][(j, n)] = snapped + pixels["tap"][(j, n)] = tap + pixels["true"][(j, n)] = d.uv[r] + tap_err.append(abs(tap[0] - d.uv[r][0])) + snap_err.append(abs(snapped[0] - d.uv[r][0])) + from_true = imagematch.snap(images.edges(n), d.uv[r][0], d.uv[r][1], SNAP_RADIUS_PX) + snap_true_err.append(abs(from_true[0] - d.uv[r][0])) + tap_j.append(j) + m2_angle = { + j: float(np.median([geom.view_angle_deg((P[j] - vd[n].C)[None], planes[int(plane[j])][0])[0] for n, _ in members])) + for j, (_g, members, _ref, _second) in plan.items() + } + + world_planes: dict = {} + + def world_plane(key, g, k, gp): + if key not in world_planes: + pts = [] + for m in gp[g]: + wall = vd[m].walls.get(k) if m in vd else None + if wall is None: + continue + sel = np.linspace(0, len(wall.cam) - 1, min(WORLD_PLANE_PX_PER_PHOTO, len(wall.cam))).astype(int) + T = gp[g][m] + pts.append(wall.cam[sel] @ T[:3, :3].T + T[:3, 3]) + n_pts = sum(len(x) for x in pts) + world_planes[key] = geom.fit_plane(np.concatenate(pts)) if n_pts >= MIN_WALL_PX else None + return world_planes[key] + + out = {} + for (setting, draw), gp in poses.items(): + rec: dict[str, list] = {k: [] for k in ( + "j", "draw", "m2_frame", "m2_span", "m2_span_tap", "m2_span_true", "m2_frame_true", + "m2_angle", "m2_offset", "m3_pair", "m3_ncc", "m3_px", "m3_frame", + "m3_span", "m3_anchor", + )} + for j, (g, members, ref, second) in plan.items(): + k = int(plane[j]) + F, p = frames[k], P[j] + rn, rrow = members[ref] + wp = world_plane((setting, draw, g, k), g, k, gp) + nan3 = np.full(3, np.nan) + + m2 = {src: (nan3, nan3) for src in pixels} + m2_offset = np.nan + wall = vd[rn].walls.get(k) + if wp is not None: + a_est, a_true = nan3, nan3 + if wall is not None: + for a in choose_anchors(wall, p @ F[0], F[0])[:1]: + a_hits = [] + for n, _ in members: + row = vd[n].find(wall.ids[a : a + 1])[0] + if row < 0: + continue + o, dvec = imagematch.pixel_ray(vd[n].K, gp[g][n], vd[n].uv[row]) + a_hits.append(F @ geom.ray_plane(o, dvec[None], *wp)[0]) + a_ok = [x for x in a_hits if np.isfinite(x).all()] + a_est = np.median(a_ok, axis=0) if a_ok else nan3 + a_true = F @ wall.world[a] + for src, px in pixels.items(): + hits = [] + for n, _ in members: + o, dvec = imagematch.pixel_ray(vd[n].K, gp[g][n], px[(j, n)]) + hits.append(F @ geom.ray_plane(o, dvec[None], *wp)[0]) + ok_hits = [x for x in hits if np.isfinite(x).all()] + est = np.median(ok_hits, axis=0) if ok_hits else nan3 + m2[src] = (est - F @ p, (est - a_est) - (F @ p - a_true)) + m2_offset = abs(wp[0] @ p - wp[1]) + + m3_ncc, m3_px, m3_frame, m3_span, m3_anchor = np.nan, np.nan, nan3, nan3, False + if second is not None: + n2, row2 = second + K1, K2 = vd[rn].K, vd[n2].K + T1, T2 = gp[g][rn], gp[g][n2] + img1, img2 = images.get(rn), images.get(n2) + + def triangulate(uv1, K1=K1, K2=K2, T1=T1, T2=T2, img1=img1, img2=img2): + uv2, score = imagematch.epipolar_match( + img1, img2, uv1, K1, T1, K2, T2, DEPTH_RANGE_M, NCC_PATCH + ) + if score < NCC_MIN: + return None, uv2, score + o1, d1 = imagematch.pixel_ray(K1, T1, uv1) + o2, d2 = imagematch.pixel_ray(K2, T2, uv2) + X, _ = geom.midpoint_triangulate(o1, d1, o2, d2) + return (X if np.isfinite(X).all() else None), uv2, score + + X, uv2, m3_ncc = triangulate(vd[rn].uv[rrow]) + if X is not None: + m3_px = float(np.linalg.norm(uv2 - vd[n2].uv[row2])) + m3_frame = F @ (X - p) + wall = vd[rn].walls.get(k) + if wall is not None: + rows2 = vd[n2].find(wall.ids) + tried = 0 + for a in choose_anchors(wall, p @ F[0], F[0]): + if rows2[a] < 0 or vd[n2].edge[rows2[a]]: + continue + tried += 1 + if tried > ANCHOR_TRIES: + break + row_a = vd[rn].find(wall.ids[a : a + 1])[0] + A, _, _ = triangulate(vd[rn].uv[row_a]) + if A is not None: + m3_span = F @ ((X - A) - (p - wall.world[a])) + m3_anchor = True + break + rec["j"].append(j) + rec["draw"].append(draw) + rec["m2_frame"].append(m2["snap"][0]) + rec["m2_span"].append(m2["snap"][1]) + rec["m2_span_tap"].append(m2["tap"][1]) + rec["m2_span_true"].append(m2["true"][1]) + rec["m2_frame_true"].append(m2["true"][0]) + rec["m2_angle"].append(m2_angle[j]) + rec["m2_offset"].append(m2_offset) + rec["m3_pair"].append(second is not None) + rec["m3_ncc"].append(m3_ncc) + rec["m3_px"].append(m3_px) + rec["m3_frame"].append(m3_frame) + rec["m3_span"].append(m3_span) + rec["m3_anchor"].append(m3_anchor) + out[(setting, draw)] = {k: np.array(v) for k, v in rec.items()} + print(f"{scene}: {setting} draw {draw} done") + merged = {"exact": out[("exact", 0)]} + draws = [out[("modern_assumed", d)] for d in range(5)] + merged["modern_assumed"] = {k: np.concatenate([x[k] for x in draws]) for k in draws[0]} + merged["tap"] = { + "tap_px": np.array(tap_err), + "snap_px": np.array(snap_err), + "snap_from_true_px": np.array(snap_true_err), + "j": np.array(tap_j, int), + } + merged["plans"] = { + "points_with_2_photos_in_a_group": len(plan), + "photos_per_point_median": float(np.median([len(m) for _, m, _, _ in plan.values()])), + "points_with_a_second_photo": sum(1 for *_, s in plan.values() if s is not None), + } + return merged + + +# ---------------------------------------------------------------- summaries + + +def components(vecs: np.ndarray) -> dict[str, np.ndarray]: + """Wall-frame error vectors (h, n, w) to along-wall, out-of-plane and 3-D magnitudes.""" + v = np.atleast_2d(vecs) + return {"along": v[:, 0], "out": v[:, 1], "3d": np.linalg.norm(v, axis=1)} + + +def summarise_single(s: dict) -> dict: + bins = geom.angle_bin(np.clip(s["angle"], 0, 90)) + cats = {"all": np.ones(len(s["j"]), bool), "in_plane": s["in_plane"], "proud": ~s["in_plane"]} + out: dict = {} + for method in ("m0", "m1", "m1a"): + comp = components(s[method]) + out[method] = {} + for cat, cmask in cats.items(): + out[method][cat] = {} + for b, label in [(-1, "all angles"), *enumerate(geom.ANGLE_LABELS)]: + mask = cmask if b < 0 else cmask & (bins == b) + out[method][cat][label] = { + c: stats.summary(x[mask], s["j"][mask]) for c, x in comp.items() + } | {"missing": int((~np.isfinite(comp["3d"][mask])).sum())} + # M1-anchor: yaw error and how well s psi tan(angle) explains the along-wall error. + ok = np.isfinite(s["psi"]) & np.isfinite(s["m1a"]).all(axis=1) + psi = np.degrees(np.abs(s["psi"][ok])) + err = s["m1a"][ok, 0] + pred = s["pred_signed"][ok] + mag = s["pred_mag"][ok] + ip = s["in_plane"][ok] + ang = s["angle"][ok] + if ok.sum() < 3: + out["m1a_yaw"] = {"observations": int(ok.sum())} + return out + yaw: dict = { + "observations": int(ok.sum()), + "abs_psi_deg": {q: round(float(np.percentile(psi, p)), 2) for q, p in (("median", 50), ("p90", 90))}, + "abs_s_m_median": round(float(np.median(np.abs(s["s"][ok]))), 2), + } + for cat, m in (("all", np.ones(len(err), bool)), ("in_plane", ip)): + e, pr, mg = err[m], pred[m], mag[m] + if len(e) < 3: + yaw[cat] = {"n": len(e)} + continue + ss = float(np.sum((e - e.mean()) ** 2)) + slope = float(pr @ e / (pr @ pr)) + r2 = 1 - float(np.sum((e - slope * pr) ** 2)) / ss + big = mg > 0.01 + big1 = np.abs(pr) > 0.01 + low = ang[m] < 60 + pl, el = pr[low], e[low] + slope60 = float(pl @ el / (pl @ pl)) + r2_60 = 1 - float(np.sum((el - slope60 * pl) ** 2) / np.sum((el - el.mean()) ** 2)) + yaw[cat + "_added_after_the_run"] = { + "sign_agreement_where_prediction_over_1cm": round(float(np.mean(np.sign(e[big1]) == np.sign(pr[big1]))), 2), + "median_error_over_prediction_where_over_1cm": round(float(np.median(e[big1] / pr[big1])), 2), + "view_angle_under_60": {"n": int(low.sum()), "slope": round(slope60, 2), "r2": round(r2_60, 2)}, + "spearman_abs_error_vs_tan_angle_alone": round(spearman(np.abs(e), np.tan(np.radians(ang[m]))), 2), + } + yaw[cat] = { + "n": int(m.sum()), + "signed_first_order_slope": round(slope, 2), + "signed_first_order_r2": round(r2, 2), + "signed_r2_with_slope_1": round(1 - float(np.sum((e - pr) ** 2)) / ss, 2), + "abs_error_over_s_psi_tan_median": round(float(np.median(np.abs(e[big]) / mg[big])), 2), + "abs_error_over_s_psi_tan_n": int(big.sum()), + "spearman_abs_error_vs_s_psi_tan": round(spearman(np.abs(e), mg), 2), + "abs_error_p90_in": round(float(np.percentile(np.abs(e), 90)) / stats.M_PER_IN, 2), + "residual_after_first_order_p90_in": round( + float(np.percentile(np.abs(e - pr), 90)) / stats.M_PER_IN, 2 + ), + } + out["m1a_yaw"] = yaw + ipm = s["in_plane"] + out["added_after_the_run_edge_point_to_plane"] = { + "laser_plane_in_plane_edges": stats.summary(s["laser_offset"][ipm], s["j"][ipm]), + "m1_plane_in_plane_edges": stats.summary(s["m1_offset"][ipm], s["j"][ipm]), + } + return out + + +def spearman(a: np.ndarray, b: np.ndarray) -> float: + ra = np.argsort(np.argsort(a)) + rb = np.argsort(np.argsort(b)) + return float(np.corrcoef(ra, rb)[0, 1]) + + +def summarise_multi(m: dict) -> dict: + out: dict = {} + for setting in ("exact", "modern_assumed"): + r = m[setting] + cats = { + "all": np.ones(len(r["j"]), bool), + "in_plane": r["in_plane"], + "proud": ~r["in_plane"], + "in_plane_vertical": r["in_plane"] & r["vertical"], + } + bins = geom.angle_bin(np.clip(r["m2_angle"], 0, 90)) + res: dict = {} + for cat, c in cats.items(): + span = components(r["m2_span"][c]) + frame = components(r["m2_frame"][c]) + pair = r["m3_pair"][c] + matched = pair & (r["m3_ncc"][c] >= NCC_MIN) & np.isfinite(r["m3_frame"][c]).all(axis=1) + m3_span = components(r["m3_span"][c]) + m3_frame = components(r["m3_frame"][c]) + j = r["j"][c] + res[cat] = { + "M2": { + "span_along": stats.summary(span["along"], j) + | {"p90_ci95_in": stats.bootstrap_p90(span["along"], j)}, + "span_3d": stats.summary(span["3d"], j), + "frame_along": stats.summary(frame["along"], j), + "frame_3d": stats.summary(frame["3d"], j), + }, + "M2_added_after_the_run": { + "span_along_tap_no_snap": stats.summary(r["m2_span_tap"][c][:, 0], j), + "span_along_true_pixel": stats.summary(r["m2_span_true"][c][:, 0], j), + "frame_along_true_pixel": stats.summary(r["m2_frame_true"][c][:, 0], j), + "edge_point_to_fused_plane": stats.summary(r["m2_offset"][c], j), + "span_along_by_median_view_angle": { + label: stats.summary(span["along"][bins[c] == b], j[bins[c] == b]) + for b, label in enumerate(geom.ANGLE_LABELS) + }, + "span_along_true_pixel_by_median_view_angle": { + label: stats.summary(r["m2_span_true"][c][bins[c] == b, 0], j[bins[c] == b]) + for b, label in enumerate(geom.ANGLE_LABELS) + }, + }, + "M3": { + "pairs": int(pair.sum()), + "match_rate": round(float(matched.sum() / max(pair.sum(), 1)), 3), + "match_px": pct(r["m3_px"][c][matched]), + "frame_3d": stats.summary(m3_frame["3d"], j), + "frame_along": stats.summary(m3_frame["along"], j), + "span_along": stats.summary(m3_span["along"], j) + | {"p90_ci95_in": stats.bootstrap_p90(m3_span["along"], j)}, + "span_3d": stats.summary(m3_span["3d"], j) + | {"p90_ci95_in": stats.bootstrap_p90(m3_span["3d"], j)}, + "anchor_matched": int(r["m3_anchor"][c].sum()), + "added_after_the_run_frame_3d_where_match_within_2px": stats.summary( + m3_frame["3d"][matched & (r["m3_px"][c] <= 2.0)], j[matched & (r["m3_px"][c] <= 2.0)] + ), + "added_after_the_run_share_of_matches_within_2px": round( + float(np.mean(r["m3_px"][c][matched] <= 2.0)) if matched.any() else float("nan"), 3 + ), + }, + } + out[setting] = res + tap = m["tap"] + out["tap_px"] = { + "tap_abs_du": pct(tap["tap_px"]), + "snap_abs_du": pct(tap["snap_px"]), + "snap_within_1px": round(float(np.mean(tap["snap_px"] <= 1.0)), 3), + "added_after_the_run_snap_from_true_pixel_abs_du": pct(tap["snap_from_true_px"]), + "added_after_the_run_vertical_in_plane_edges": { + k: pct(tap[k][tap["vertical"] & tap["in_plane"]]) + for k in ("tap_px", "snap_px", "snap_from_true_px") + }, + } + out["plans"] = m["plans"] + return out + + +def pct(x: np.ndarray) -> dict: + if not len(x): + return {"n": 0} + return {"n": len(x), "median": round(float(np.median(x)), 2), "p90": round(float(np.percentile(x, 90)), 2)} + + +def main() -> None: + RESULTS.mkdir(exist_ok=True) + report: dict = { + "evals_commit": evals_commit(), + "evals_dir": str(EVALS_DIR).replace(str(Path.home()), "~"), + "parameters": {k: v for k, v in globals().items() if k.isupper() and isinstance(v, (int, float, tuple))}, + "scenes": {}, + } + # `--from-raw` re-summarises the per-point records of the last full run (data/, git-ignored). + for scene in SCENES: + raw = HERE / "data" / f"raw_{scene}.pkl" + if "--from-raw" in sys.argv: + res = pickle.loads(raw.read_bytes()) + else: + res = run_scene(scene) + raw.parent.mkdir(exist_ok=True) + raw.write_bytes(pickle.dumps(res)) + if "skipped" in res: + report["scenes"][scene] = res + continue + single, multi = res["single"], res["multi"] + summary = {"meta": res["meta"], "single": summarise_single(single), "multi": summarise_multi(multi)} + summary["criteria"] = grade(single, multi) + report["scenes"][scene] = summary + (RESULTS / "edge_geometry.json").write_text(json.dumps(report, indent=1, default=float) + "\n") + (RESULTS / "edge_geometry.md").write_text(markdown(report)) + print((RESULTS / "edge_geometry.md").read_text()) + + +def p90_in(x: np.ndarray) -> float: + x = np.abs(x[np.isfinite(x)]) + return round(float(np.percentile(x, 90)) / stats.M_PER_IN, 2) if len(x) else float("nan") + + +def grade(single: dict, multi: dict) -> dict: + bins = geom.angle_bin(np.clip(single["angle"], 0, 90)) + along = single["m0"][:, 0] + near = p90_in(along[bins == 0]) + wide = p90_in(along[bins >= 2]) + per_bin = [p90_in(along[bins == b]) for b in range(len(geom.ANGLE_LABELS))] + ip = single["in_plane"] + m1 = p90_in(single["m1"][ip, 0]) + ma = multi["modern_assumed"] + m2 = p90_in(ma["m2_span"][ma["in_plane"], 0]) + pair = ma["m3_pair"] + matched = pair & (ma["m3_ncc"] >= NCC_MIN) & np.isfinite(ma["m3_frame"]).all(axis=1) + rate = float(matched.sum() / max(pair.sum(), 1)) + m3 = p90_in(np.linalg.norm(ma["m3_span"], axis=1)) + return { + "P1": {"m0_along_p90_in_0_15": near, "m0_along_p90_in_over_30": wide, "pass": bool(near <= 0.5 * wide)}, + "P2": {"m1_along_p90_in_in_plane": m1, "pass": bool(m1 <= 4.0)}, + "P3": {"m2_span_along_p90_in_in_plane_modern": m2, "pass": bool(m2 <= 2.0)}, + "P4": {"m3_span_3d_p90_in_modern": m3, "match_rate": round(rate, 3), "pass": bool(m3 <= 2.0 and rate >= 0.7)}, + "kill": {"m0_along_p90_in_per_bin": per_bin, "triggered": bool(all(x <= 4.0 for x in per_bin))}, + } + + +def fmt(x: dict | None, key: str = "p90_in") -> str: + if not x or key not in x: + return "n/a" + return f"{x['median_in']:.1f} / {x[key]:.1f} ({x.get('points', x['n'])})" + + +def markdown(report: dict) -> str: + L = [ + "# Edge geometry (generated by `uv run python run.py`)", + "", + f"Evals harness commit `{report['evals_commit']}`. Errors in inches, median / p90 (points).", + ] + for scene, r in report["scenes"].items(): + L += ["", f"## {scene}", "", f"`{json.dumps(r['meta'])}`"] + if "skipped" in r: + L += ["", f"Skipped: {r['skipped']}."] + continue + s = r["single"] + for method, title in (("m0", "M0, depth at the pixel"), ("m1", "M1, ray to this photo's wall plane"), ("m1a", "M1-anchor, ray to a 0.3 m patch plane 1-3 m away")): + L += ["", f"### {title}", "", "| Edges | View angle | Along the wall | Out of plane | 3-D |", "| --- | --- | --- | --- | --- |"] + for cat in ("all", "in_plane", "proud"): + for label, row in s[method][cat].items(): + L.append(f"| {cat} | {label} | {fmt(row['along'])} | {fmt(row['out'])} | {fmt(row['3d'])} |") + L += ["", "### M1-anchor yaw", "", f"`{json.dumps(s['m1a_yaw'])}`"] + m = r["multi"] + L += ["", "### M2 and M3", "", "| Poses | Edges | M2 span along | M2 p90 95% CI | M3 pairs | M3 match | M3 match px | M3 span along | M3 span 3-D | M3 3-D p90 95% CI | M3 frame 3-D |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"] + for setting in ("exact", "modern_assumed"): + for cat, row in m[setting].items(): + m2, m3 = row["M2"], row["M3"] + L.append( + f"| {setting} | {cat} | {fmt(m2['span_along'])} | {m2['span_along'].get('p90_ci95_in')} | {m3['pairs']} | {m3['match_rate']:.0%} " + f"| {m3['match_px'].get('median', 'n/a')} / {m3['match_px'].get('p90', 'n/a')} | {fmt(m3['span_along'])} | {fmt(m3['span_3d'])} | {m3['span_3d'].get('p90_ci95_in')} | {fmt(m3['frame_3d'])} |" + ) + L += ["", "M3 match px is in pixels, not inches.", "", "### Added after the run", ""] + L += ["| Poses | Edges | M2 with the true pixel | M2 tap, no snap | Edge point to fused MoGe-2 plane | M3 3-D where the match is within 2 px | Matches within 2 px |", "| --- | --- | --- | --- | --- | --- | --- |"] + for setting in ("exact", "modern_assumed"): + for cat, row in m[setting].items(): + d2, m3 = row["M2_added_after_the_run"], row["M3"] + L.append( + f"| {setting} | {cat} | {fmt(d2['span_along_true_pixel'])} | {fmt(d2['span_along_tap_no_snap'])} | {fmt(d2['edge_point_to_fused_plane'])} " + f"| {fmt(m3['added_after_the_run_frame_3d_where_match_within_2px'])} | {m3['added_after_the_run_share_of_matches_within_2px']} |" + ) + L += ["", "M2 along-wall span error by the point's median view angle (snap, then true pixel):", ""] + for setting in ("exact", "modern_assumed"): + d2 = m[setting]["in_plane"]["M2_added_after_the_run"] + snapped = ", ".join(f"{b} {fmt(x)}" for b, x in d2["span_along_by_median_view_angle"].items()) + true = ", ".join(f"{b} {fmt(x)}" for b, x in d2["span_along_true_pixel_by_median_view_angle"].items()) + L += [f"- {setting}, in-plane, snapped: {snapped}", f"- {setting}, in-plane, true pixel: {true}"] + L += ["", f"Edge point to plane (single photo): `{json.dumps(s.get('added_after_the_run_edge_point_to_plane'))}`"] + L += ["", "", f"Tap and snap, horizontal pixel error: `{json.dumps(m['tap_px'])}`", "", f"`{json.dumps(m['plans'])}`"] + L += ["", "### Criteria", "", f"`{json.dumps(r['criteria'])}`"] + return "\n".join(L) + "\n" + + +if __name__ == "__main__": + main() diff --git a/experiments/edge-geometry/stats.py b/experiments/edge-geometry/stats.py new file mode 100644 index 000000000..759899697 --- /dev/null +++ b/experiments/edge-geometry/stats.py @@ -0,0 +1,44 @@ +"""Summaries in inches, and a bootstrap over points for p90.""" + +from __future__ import annotations + +import numpy as np + +M_PER_IN = 0.0254 + + +def summary(err_m: np.ndarray, points: np.ndarray | None = None) -> dict: + """Median and p90 of |error| in inches, with the number of values and of distinct points.""" + e = np.abs(np.asarray(err_m, np.float64)) + e = e[np.isfinite(e)] + out: dict = {"n": len(e)} + if points is not None: + pts = np.asarray(points)[np.isfinite(np.asarray(err_m, np.float64))] + out["points"] = len(np.unique(pts)) + if len(e): + out["median_in"] = round(float(np.median(e)) / M_PER_IN, 2) + out["p90_in"] = round(float(np.percentile(e, 90)) / M_PER_IN, 2) + return out + + +def bootstrap_p90(err_m: np.ndarray, points: np.ndarray, reps: int = 2000, seed: int = 7): + """95% percentile-bootstrap interval of the p90 of |error| in inches, resampling points with + replacement and keeping every value of a drawn point (draws of one point are not independent).""" + e = np.abs(np.asarray(err_m, np.float64)) + ok = np.isfinite(e) + e, pts = e[ok], np.asarray(points)[ok] + if len(e) < 2: + return None + _, cluster = np.unique(pts, return_inverse=True) + n_clusters = cluster.max() + 1 + order = np.argsort(e) + e_sorted, c_sorted = e[order], cluster[order] + rng = np.random.default_rng(seed) + p90 = np.empty(reps) + for b in range(reps): + weight = np.bincount(rng.integers(n_clusters, size=n_clusters), minlength=n_clusters) + w = weight[c_sorted].astype(np.float64) + cum = np.cumsum(w) + p90[b] = e_sorted[np.searchsorted(cum, 0.9 * cum[-1])] + lo, hi = np.percentile(p90, [2.5, 97.5]) / M_PER_IN + return [round(float(lo), 2), round(float(hi), 2)] diff --git a/experiments/edge-geometry/tests/test_geom.py b/experiments/edge-geometry/tests/test_geom.py new file mode 100644 index 000000000..e13fd1ffd --- /dev/null +++ b/experiments/edge-geometry/tests/test_geom.py @@ -0,0 +1,116 @@ +import numpy as np +import pytest + +from geom import ( + angle_bin, + decompose, + fit_plane, + midpoint_triangulate, + ray_plane, + signed_yaw, + view_angle_deg, + wall_frame, + yaw_shift, +) + +UP = np.array([0.0, 0.0, 1.0]) + + +def test_ray_plane_hits_known_point(): + n, d = np.array([0.0, 1.0, 0.0]), 3.0 # the plane y = 3 + X = ray_plane(np.array([1.0, 0.0, 2.0]), np.array([[1.0, 1.0, 0.0], [0.0, 2.0, -1.0]]), n, d) + np.testing.assert_allclose(X, [[4.0, 3.0, 2.0], [1.0, 3.0, 0.5]], atol=1e-12) + + +def test_ray_plane_rejects_parallel_and_behind(): + n, d = np.array([0.0, 1.0, 0.0]), 3.0 + X = ray_plane(np.zeros(3), np.array([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0]]), n, d) + assert np.isnan(X).all() + + +def test_decompose_splits_error_along_and_across_the_wall(): + n = np.array([0.0, 1.0, 0.0]) + # h = up x n = (0,0,1) x (0,1,0) = (-1, 0, 0) + np.testing.assert_allclose(wall_frame(n, UP)[0], [-1.0, 0.0, 0.0]) + parts = decompose(np.array([[0.02, -0.03, 0.5]]), n, UP) + assert parts["along"][0] == pytest.approx(-0.02) + assert parts["out"][0] == pytest.approx(-0.03) + assert parts["3d"][0] == pytest.approx(np.sqrt(0.02**2 + 0.03**2 + 0.5**2)) + + +def test_decompose_on_a_tilted_wall_keeps_along_horizontal(): + tilt = np.radians(10) + n = np.array([0.0, np.cos(tilt), np.sin(tilt)]) + frame = wall_frame(n, UP) + assert frame[0] @ UP == pytest.approx(0.0) + assert frame[0] @ n == pytest.approx(0.0) + np.testing.assert_allclose(frame @ frame.T, np.eye(3), atol=1e-12) + # A vertical error has no along-wall part. + assert decompose(np.array([[0.0, 0.0, 0.1]]), n, UP)["along"][0] == pytest.approx(0.0) + + +def test_view_angle_and_bins(): + n = np.array([0.0, 1.0, 0.0]) + rays = np.array([[0.0, -1.0, 0.0], [np.sin(np.radians(20)), np.cos(np.radians(20)), 0.0]]) + np.testing.assert_allclose(view_angle_deg(rays, n), [0.0, 20.0], atol=1e-9) + assert list(angle_bin([0.0, 14.999, 15.0, 29.9, 30.0, 45.0, 89.0, 90.0])) == [ + 0, 0, 1, 1, 2, 3, 3, 3, + ] + with pytest.raises(ValueError): + angle_bin([91.0]) + + +def test_fit_plane_ignores_outliers(): + rng = np.random.default_rng(0) + xy = rng.uniform(-1, 1, size=(500, 2)) + pts = np.c_[xy[:, 0], np.full(500, 2.0) + rng.normal(0, 0.002, 500), xy[:, 1]] + pts[:40, 1] += 0.5 # a box in front of the wall + n, d = fit_plane(pts) + assert abs(n[1]) == pytest.approx(1.0, abs=1e-4) + assert d * np.sign(n[1]) == pytest.approx(2.0, abs=1e-3) + + +def test_signed_yaw_and_yaw_shift_match_a_rotated_plane(): + n = np.array([0.0, 1.0, 0.0]) + psi = np.radians(2.0) + c, s = np.cos(psi), np.sin(psi) + Rz = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1.0]]) + n_rot = Rz @ n + assert signed_yaw(n_rot, n, UP) == pytest.approx(psi) + assert signed_yaw(-n_rot, n, UP) == pytest.approx(psi) # sign of the normal does not matter + + # Wall y = 3; anchor at x = 0; edge 2 m along the wall; camera looking obliquely at the edge. + anchor = np.array([0.0, 3.0, 1.0]) + edge = np.array([2.0, 3.0, 1.0]) + cam = np.array([-1.0, 0.0, 1.0]) + ray = (edge - cam)[None] + hit = ray_plane(cam, ray, n_rot, float(n_rot @ anchor))[0] + along_err = decompose((hit - edge)[None], n, UP)["along"][0] + h = wall_frame(n, UP)[0] + predicted = yaw_shift(h @ (edge - anchor), psi, ray, n, UP)[0] + assert predicted == pytest.approx(along_err, rel=0.05) + + +def test_midpoint_triangulation(): + X = np.array([0.3, -0.2, 4.0]) + c1, c2 = np.zeros(3), np.array([0.5, 0.0, 0.0]) + got, angle = midpoint_triangulate(c1, X - c1, c2, X - c2) + np.testing.assert_allclose(got, X, atol=1e-12) + r1, r2 = X / np.linalg.norm(X), (X - c2) / np.linalg.norm(X - c2) + assert angle == pytest.approx(np.degrees(np.arccos(r1 @ r2))) + + +def test_midpoint_of_skew_rays_is_the_middle_of_their_common_perpendicular(): + # x-axis ray from (-1, 0, 0) and a ray along -y from (0, 1, 1): closest at (0,0,0) and (0,0,1). + got, angle = midpoint_triangulate( + np.array([-1.0, 0, 0]), np.array([1.0, 0, 0]), np.array([0, 1.0, 1.0]), np.array([0, -1.0, 0]) + ) + np.testing.assert_allclose(got, [0.0, 0.0, 0.5], atol=1e-12) + assert angle == pytest.approx(90.0) + + +def test_midpoint_triangulation_rejects_parallel_and_behind(): + got, _ = midpoint_triangulate(np.zeros(3), np.array([0, 0, 1.0]), np.ones(3), np.array([0, 0, 1.0])) + assert np.isnan(got).all() + got, _ = midpoint_triangulate(np.zeros(3), np.array([0, 0, -1.0]), np.array([1.0, 0, 0]), np.array([-1.0, 0, 1.0])) + assert np.isnan(got).all() diff --git a/experiments/edge-geometry/tests/test_imagematch.py b/experiments/edge-geometry/tests/test_imagematch.py new file mode 100644 index 000000000..d6f1eac72 --- /dev/null +++ b/experiments/edge-geometry/tests/test_imagematch.py @@ -0,0 +1,89 @@ +import cv2 +import numpy as np +import pytest + +from geom import midpoint_triangulate +from imagematch import epipolar_match, pixel_ray, project, snap, vertical_edge_strength + +K = np.array([[560.0, 0, 320.0], [0, 560.0, 240.0], [0, 0, 1.0]]) + + +def step_image(edge_after_col: int, width: int = 200, height: int = 120) -> np.ndarray: + img = np.full((height, width), 0.2, np.float32) + img[:, edge_after_col + 1 :] = 0.8 + return img + + +def test_snap_finds_a_vertical_step_to_sub_pixel(): + # Dark up to column 99, bright from 100: the edge sits at 99.5 in integer-centred pixels. + strength = vertical_edge_strength(step_image(99)) + u, v = snap(strength, 112.4, 60.7, radius=25) + assert u == pytest.approx(99.5, abs=0.05) + assert v == 60.7 + + +def test_snap_does_not_reach_past_its_radius(): + strength = vertical_edge_strength(step_image(99)) + assert snap(strength, 140.0, 60.0, radius=25) == (140.0, 60.0) + + +def test_snap_ignores_a_horizontal_edge(): + img = np.full((120, 200), 0.2, np.float32) + img[60:, :] = 0.8 + assert snap(vertical_edge_strength(img), 100.0, 60.0) == (100.0, 60.0) + + +def look_at(center: np.ndarray, target: np.ndarray) -> np.ndarray: + """Camera-to-world pose at `center` looking at `target`, image y pointing down (world -y).""" + z = target - center + z /= np.linalg.norm(z) + x = np.cross(np.array([0.0, -1.0, 0.0]), z) + x /= np.linalg.norm(x) + T = np.eye(4) + T[:3, :3] = np.c_[x, np.cross(z, x), z] + T[:3, 3] = center + return T + + +def render_plane(T: np.ndarray, texture: np.ndarray, texel_m: float, z_plane: float) -> np.ndarray: + """View of the textured plane z = z_plane (texture centred on the origin) from pose T.""" + h, w = 480, 640 + u, v = np.meshgrid(np.arange(w, dtype=np.float64), np.arange(h, dtype=np.float64)) + d_cam = np.stack([(u - K[0, 2]) / K[0, 0], (v - K[1, 2]) / K[1, 1], np.ones_like(u)], -1) + d = d_cam @ T[:3, :3].T + t = (z_plane - T[2, 3]) / d[..., 2] + X = T[:3, 3] + t[..., None] * d + th, tw = texture.shape + mx = (X[..., 0] / texel_m + tw / 2).astype(np.float32) + my = (X[..., 1] / texel_m + th / 2).astype(np.float32) + return cv2.remap(texture, mx, my, cv2.INTER_LINEAR) + + +def test_epipolar_search_on_a_textured_plane(): + rng = np.random.default_rng(3) + texel = 0.002 + texture = cv2.GaussianBlur(rng.random((3000, 3000)).astype(np.float32), (0, 0), 3) + z_plane = 3.0 + T1 = look_at(np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, z_plane])) + T2 = look_at(np.array([0.4, 0.05, 0.1]), np.array([0.1, 0.0, z_plane])) + img1 = render_plane(T1, texture, texel, z_plane) + img2 = render_plane(T2, texture, texel, z_plane) + + for uv1 in (np.array([300.3, 250.6]), np.array([400.0, 180.25])): + o1, d1 = pixel_ray(K, T1, uv1) + X = o1 + (z_plane - o1[2]) / d1[2] * d1 + truth = project(K, T2, X[None])[0][0] + uv2, score = epipolar_match(img1, img2, uv1, K, T1, K, T2) + assert score > 0.95 + assert np.linalg.norm(uv2 - truth) < 0.3 + o2, d2 = pixel_ray(K, T2, uv2) + got, _ = midpoint_triangulate(o1, d1, o2, d2) + assert np.linalg.norm(got - X) < 0.01 + + +def test_epipolar_search_fails_cleanly_when_the_template_leaves_the_image(): + img = np.zeros((480, 640), np.float32) + T = np.eye(4) + uv2, score = epipolar_match(img, img, np.array([3.0, 3.0]), K, T, K, T) + assert np.isnan(uv2).all() + assert score == -1.0 diff --git a/experiments/edge-geometry/uv.lock b/experiments/edge-geometry/uv.lock new file mode 100644 index 000000000..51ed82604 --- /dev/null +++ b/experiments/edge-geometry/uv.lock @@ -0,0 +1,281 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "edge-geometry" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "scipy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.1" }, + { name = "opencv-python-headless", specifier = ">=4.10" }, + { name = "scipy", specifier = ">=1.14" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] diff --git a/experiments/plane-consistency/METHODS.md b/experiments/plane-consistency/METHODS.md new file mode 100644 index 000000000..4be6f8989 --- /dev/null +++ b/experiments/plane-consistency/METHODS.md @@ -0,0 +1,30 @@ +# Plane consistency: methods and full results + +The README is the front door. This file keeps the method as it was pre-registered in 397507f, and the full result as the run reported it. + +## Question + +Without LiDAR, can the photos themselves show that something stands in front of the wall? Today coverage credits a wall sample to any photo that frames it within range and angle, so on ETH3D electro it claims 1.1 ft of wall hidden behind scaffold footings (evals §7). On phones without LiDAR, the only known fix, needing views 5 m or 60° apart, also throws away 6 to 8.5 ft of good wall (§7b). + +A photo keeps credit for a sample only when another photo agrees with it. The two are compared through the wall plane: each patch is warped onto the plane, the plane is slid 0 to 0.4 m toward the camera to allow for wall relief, and a match needs normalized cross-correlation (NCC) of at least 0.6 on 11 × 11 px patches. + +A pair can speak for a sample only when it has at least 3 px of parallax between the wall and a surface 0.5 m in front of it. A sample no pair can test, or one too plain to correlate, is not claimed. + +The wall, its samples, the app's view gates and the truth are those of evals §7, imported from `t3/evals` at `190ed33`. It runs with the `exact` and `modern_assumed` poses. + +## Pass criteria, fixed before the run + +- **False-observed.** Wall claimed where at least 10 cm of band was seen by no photo: at most 0.5 ft, as in §7. +- **Missed.** Band that two photos 0.25 m apart saw, left unclaimed: at most 3.3 ft, which is what the LiDAR depth test with no relief allowance misses. +- Both must hold with `modern_assumed` poses. Missed length is split into no qualifying pair, too plain, and disagreed. + +## Result + +Both fail ([results](results/plane_consistency.md)). Unfiltered, the setup reproduces §7: 19.3, 1.1, 0.0 ft. + +- **False-observed.** Worst draw 1.1 ft, exact 0.7. Pairs with 12 to 43 px of parallax agree on the footings at δ = 0.3 or 0.4 m, so the relief that keeps the pilasters keeps the footings too. +- **Missed.** Worst draw 9.2 ft: 5.1 too plain, 4.1 disagreed, 0 without a pair. At 1024 px most of the wall is nearly featureless. Too plain means a standard deviation under 0.91 grey levels, where noise of 0.57 holds even a perfect match below NCC 0.6. + +**Run:** `uv run python run.py`, 2 min, 0.9 GB. Needs the evals checkout at 190ed33 (`EVALS_DIR`) and ETH3D electro. `modern_assumed` is redrawn over all 45 photos, since `ar_poses/*.json` holds only groups of 2 to 8. + +**What it changes:** photo agreement doesn't replace a depth test on phones without LiDAR. Claims from those phones stay unconfirmed, as §7b recommends. diff --git a/experiments/plane-consistency/README.md b/experiments/plane-consistency/README.md new file mode 100644 index 000000000..f014a9ead --- /dev/null +++ b/experiments/plane-consistency/README.md @@ -0,0 +1,19 @@ +# Plane consistency + +## Question + +Without LiDAR, can the photos themselves show that something stands in front of the wall? A photo keeps credit for a wall sample only when another photo agrees with it through the wall plane. It runs on the 19.3 ft wall of evals §7, where coverage claims 1.1 ft hidden behind scaffold footings. [METHODS.md](METHODS.md) has the matching rule. + +## Pass criteria, fixed before the run (397507f) + +- **False-observed.** Wall claimed where at least 10 cm of band was seen by no photo: at most 0.5 ft, as in §7. +- **Missed.** Band that two photos 0.25 m apart saw, left unclaimed: at most 3.3 ft, which is what the LiDAR depth test with no relief allowance misses. +- Both must hold with `modern_assumed` poses. Missed length is split into no qualifying pair, too plain, and disagreed. + +## Result + +Both fail ([tables](results/plane_consistency.md)). Without the filter the setup reproduces §7 exactly. With it, the worst `modern_assumed` draw claims 1.1 ft that no photo saw, because pairs agree on the footings inside the 0.4 m relief the pilasters need. It also misses 9.2 ft, 5.1 of it wall too plain to correlate. + +**Run:** `uv run python run.py`: 2 min, 0.9 GB. Needs the evals checkout at 190ed33 and ETH3D electro. + +**What it changes:** photo agreement doesn't replace a depth test on phones without LiDAR. Coverage from those phones stays unconfirmed, as §7b recommends. diff --git a/experiments/plane-consistency/plane_filter.py b/experiments/plane-consistency/plane_filter.py new file mode 100644 index 000000000..2a7506ec0 --- /dev/null +++ b/experiments/plane-consistency/plane_filter.py @@ -0,0 +1,188 @@ +"""The parts of the plane-consistency filter that have one correct answer. + +Cameras are world-to-camera (R, t) in OpenCV axes (+x right, +y down, +z forward), with pixel +centres on integers. A plane is n . X = d in world coordinates, n a unit vector. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence + +import cv2 +import numpy as np + + +def plane_homography( + K_a: np.ndarray, + R_a: np.ndarray, + t_a: np.ndarray, + K_b: np.ndarray, + R_b: np.ndarray, + t_b: np.ndarray, + n: np.ndarray, + d: float, +) -> np.ndarray: + """The 3x3 map from pixels of camera A to pixels of camera B of points on the plane n . X = d. + + In A's frame the plane is n_a . X_a = d_a with n_a = R_a n and d_a = d + n_a . t_a. A point on + it maps to B's frame as X_b = (R + t n_a^T / d_a) X_a, where R and t take A's frame to B's. + """ + n_a = R_a @ n + d_a = d + n_a @ t_a + if abs(d_a) < 1e-9: + raise ValueError("camera A lies on the plane; its homography is undefined") + R = R_b @ R_a.T + t = t_b - R @ t_a + return K_b @ (R + np.outer(t, n_a) / d_a) @ np.linalg.inv(K_a) + + +def apply_homography(H: np.ndarray, uv: np.ndarray) -> np.ndarray: + """Pixels (..., 2) through H. Points H sends to infinity come back as inf or NaN.""" + uv = np.asarray(uv, dtype=np.float64) + p = uv @ H[:, :2].T + H[:, 2] + with np.errstate(divide="ignore", invalid="ignore"): + return p[..., :2] / p[..., 2:3] + + +def parallax_px(H_wall: np.ndarray, H_front: np.ndarray, uv: np.ndarray) -> np.ndarray: + """How far apart in B the wall plane and a plane in front of it put A's pixel `uv`.""" + return np.linalg.norm(apply_homography(H_wall, uv) - apply_homography(H_front, uv), axis=-1) + + +def parallax_gate( + H_wall: np.ndarray, H_front: np.ndarray, uv: np.ndarray, min_px: float +) -> tuple[float, bool]: + """The pair's parallax at `uv`, and whether it is enough to tell the two planes apart.""" + p = float(parallax_px(H_wall, H_front, uv)) + return p, p >= min_px + + +def zncc(a: np.ndarray, b: np.ndarray) -> float: + """Zero-mean normalized cross-correlation of two equal-shaped patches, NaN if either is flat.""" + a = np.asarray(a, dtype=np.float64).ravel() + b = np.asarray(b, dtype=np.float64).ravel() + if a.shape != b.shape: + raise ValueError(f"patch shapes differ: {a.shape} vs {b.shape}") + a = a - a.mean() + b = b - b.mean() + denom = np.sqrt((a @ a) * (b @ b)) + return float(a @ b / denom) if denom > 0 else float("nan") + + +def plain_threshold(noise_sigma: float, ncc_min: float) -> float: + """Patch standard deviation below which even a perfect match cannot reach `ncc_min`. + + A patch with signal standard deviation s, seen twice with independent noise sigma, has an + expected ZNCC of s^2 / (s^2 + sigma^2). With the observed deviation T = sqrt(s^2 + sigma^2), + that is 1 - sigma^2 / T^2, which reaches `ncc_min` only when T >= sigma / sqrt(1 - ncc_min). + """ + if not 0 <= ncc_min < 1: + raise ValueError(f"ncc_min must be in [0, 1), got {ncc_min}") + return noise_sigma / np.sqrt(1.0 - ncc_min) + + +def too_plain(patch: np.ndarray, threshold: float) -> bool: + return float(np.std(np.asarray(patch, dtype=np.float64))) < threshold + + +def noise_sigma(gray: np.ndarray) -> float: + """Robust image noise standard deviation (Immerkaer's operator, median instead of mean). + + The 3x3 operator below cancels any locally planar intensity, and applied to white noise of + deviation sigma it gives deviation 6 sigma (its weights' squares sum to 36). The median of its + absolute response, over 0.6745 for a Gaussian, ignores the minority of pixels on edges. + + On an 8-bit image the response is a whole number, so a plain median moves in steps of 0.25 + grey levels of sigma. The median is instead read off the histogram with each whole number k + spread over [k - 0.5, k + 0.5] (the grouped-data median). + """ + kernel = np.array([[1, -2, 1], [-2, 4, -2], [1, -2, 1]], dtype=np.float32) + response = cv2.filter2D(gray.astype(np.float32), -1, kernel, borderType=cv2.BORDER_REFLECT) + return grouped_median(np.abs(response[1:-1, 1:-1])) / (0.6745 * 6.0) + + +def grouped_median(values: np.ndarray) -> float: + """Median of non-negative whole numbers, each k taken as spread evenly over [k - 0.5, k + 0.5] + (0 over [0, 0.5]).""" + k = np.rint(values).astype(np.int64).ravel() + if k.size == 0 or k.min() < 0: + raise ValueError("grouped_median needs non-negative values") + counts = np.bincount(k) + cdf = np.cumsum(counts) / k.size + m = int(np.searchsorted(cdf, 0.5)) + below = cdf[m - 1] if m else 0.0 + lo, width = (0.0, 0.5) if m == 0 else (m - 0.5, 1.0) + return float(lo + (0.5 - below) / (counts[m] / k.size) * width) + + +def patch_pixels(centre: Sequence[float], half: int) -> np.ndarray: + """(2*half+1)^2 integer pixels (u, v) around `centre` rounded to the nearest pixel, (N, 2).""" + cu, cv_ = round(centre[0]), round(centre[1]) + du, dv = np.meshgrid(np.arange(-half, half + 1), np.arange(-half, half + 1)) + return np.stack([cu + du.ravel(), cv_ + dv.ravel()], axis=-1).astype(np.float64) + + +def sample_bilinear(image: np.ndarray, uv: np.ndarray) -> np.ndarray | None: + """Bilinear samples of a single-channel float image at pixels uv (N, 2); None if any falls + outside the image.""" + h, w = image.shape + u, v = uv[:, 0], uv[:, 1] + if not (np.all(np.isfinite(uv)) and u.min() >= 0 and v.min() >= 0): + return None + if u.max() > w - 1 or v.max() > h - 1: + return None + u0 = np.minimum(np.floor(u).astype(np.int64), w - 2) + v0 = np.minimum(np.floor(v).astype(np.int64), h - 2) + fu, fv = u - u0, v - v0 + top = image[v0, u0] * (1 - fu) + image[v0, u0 + 1] * fu + bottom = image[v0 + 1, u0] * (1 - fu) + image[v0 + 1, u0 + 1] * fu + return top * (1 - fv) + bottom * fv + + +def covered_cells( + sightings: Iterable[tuple[int, int, Iterable[int]]], + positions: np.ndarray, + rows: int, + baseline: float, + allowed: Iterable[int] | None = None, +) -> set[int]: + """`CoverageMap.record` (HouseScanKit beede15, CoverageMap.swift lines 171-195) over a list of + (photo, cell, rows seen) in capture order: the cells every row of which holds two camera + positions at least `baseline` apart. + + Like the app, a row keeps the first position that sees it and then the first later one far + enough from it, and stops at two, so a pair that only a third position would complete is not + found. `allowed`, if given, is the set of cells inside the marked ends. + """ + allow = None if allowed is None else set(allowed) + held: dict[int, list[list[np.ndarray]]] = {} + for photo, cell, seen in sightings: + if allow is not None and cell not in allow: + continue + cell_rows = held.setdefault(cell, [[] for _ in range(rows)]) + p = positions[photo] + for r in seen: + if not 0 <= r < rows: + continue + kept = cell_rows[r] + if len(kept) < 2 and all(np.linalg.norm(q - p) >= baseline for q in kept): + kept.append(p) + return {c for c, rs in held.items() if all(len(k) >= 2 for k in rs)} + + +def covered_intervals( + cells: Iterable[int], cell_width: float, left: float, right: float +) -> list[list[float]]: + """`CoverageMap.coveredIntervals` (lines 335-349): covered cells merged into runs of s, the + first and last run clipped to the marked ends.""" + runs: list[list[float]] = [] + for index in sorted(cells): + lo, hi = index * cell_width, (index + 1) * cell_width + if runs and abs(runs[-1][1] - lo) < cell_width * 0.01: + runs[-1][1] = hi + else: + runs.append([lo, hi]) + if runs: + runs[0][0] = max(runs[0][0], left) + runs[-1][1] = min(runs[-1][1], right) + return runs diff --git a/experiments/plane-consistency/pyproject.toml b/experiments/plane-consistency/pyproject.toml new file mode 100644 index 000000000..043324217 --- /dev/null +++ b/experiments/plane-consistency/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "plane-consistency" +version = "0.1.0" +description = "Can two photos, compared through the wall plane, show that something stands in front of the wall?" +requires-python = ">=3.12" +# The evals harness is imported read-only from its own worktree (see run.py), so these match its +# dependencies. +dependencies = [ + "numpy>=2.1", + "opencv-python-headless>=4.10", + "scipy>=1.18.1", +] + +[dependency-groups] +dev = ["pytest>=8.3", "ruff>=0.8"] + +[tool.uv] +package = false + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = ["--strict-markers", "--strict-config"] diff --git a/experiments/plane-consistency/results/plane_consistency.json b/experiments/plane-consistency/results/plane_consistency.json new file mode 100644 index 000000000..42ace9302 --- /dev/null +++ b/experiments/plane-consistency/results/plane_consistency.json @@ -0,0 +1,527 @@ +{ + "scene": "electro", + "evals_commit": "190ed339f41924ae0244e0b88c5ebe647ddd9eb4", + "housescankit_commit": "beede15f568b3a4d694fb275caf9eaaa882c546b", + "noise_sigma_measured": 0.5728785556214293, + "noise_sigma_fixed": 0.573, + "plain_std": 0.9059925496382406, + "validity": { + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "evals_section_7": { + "claimed_ft": 19.3, + "false_observed_ft": 1.1, + "missed_ft": 0.0 + } + }, + "wall_ft": 19.311015888810974, + "runs": [ + { + "setting": "exact", + "app_driver_agrees": true, + "app": { + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0 + }, + "filter": { + "claimed_ft": 11.67979002624672, + "false_observed_ft": 0.7217847769028871, + "missed_ft": 7.086614173228346 + }, + "row_sightings": 876, + "row_sightings_kept": 557, + "dropped_row_causes": { + "disagreed": 239, + "too plain": 216 + }, + "missed_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 5.577427821522309, + "disagreed": 1.5091863517060367, + "the app itself": 0.0 + }, + "footing_ft": 1.1154855643044619, + "footing_still_claimed_ft": 0.7217847769028871, + "footing_still_claimed_s_m": [ + [ + -2.94, + -2.74 + ], + [ + 2.38, + 2.4 + ] + ], + "footing_dropped_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 0.0, + "disagreed": 0.39370078740157477, + "the app itself": 0.0 + }, + "false_outside_footing_ft": 0.0, + "pilaster_ft": 3.805774278215223, + "pilaster_claimed_app_ft": 3.805774278215223, + "pilaster_claimed_filter_ft": 2.2965879265091864, + "pilaster_accepted_delta_samples": { + "0": 74, + "0.1": 21, + "0.2": 9, + "0.3": 51, + "0.4": 115 + }, + "ncc_sweep": { + "0.4": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 4.0682414698162725 + }, + "0.5": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 5.57742782152231 + }, + "0.6": { + "false_observed_ft": 0.7217847769028871, + "missed_ft": 7.086614173228346 + }, + "0.7": { + "false_observed_ft": 0.5905511811023622, + "missed_ft": 8.530183727034121 + }, + "0.8": { + "false_observed_ft": 0.0, + "missed_ft": 11.417322834645669 + } + }, + "feature_refined": { + "claimed_ft": 10.62992125984252, + "false_observed_ft": 0.6561679790026247, + "missed_ft": 8.005249343832022, + "pairs": 198, + "pairs_refined": 34 + } + }, + { + "setting": "modern_assumed draw 0", + "app_driver_agrees": true, + "app": { + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0 + }, + "filter": { + "claimed_ft": 10.695538057742782, + "false_observed_ft": 0.6561679790026247, + "missed_ft": 8.136482939632545 + }, + "row_sightings": 867, + "row_sightings_kept": 462, + "dropped_row_causes": { + "disagreed": 359, + "too plain": 215 + }, + "missed_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 5.05249343832021, + "disagreed": 3.083989501312336, + "the app itself": 0.0 + }, + "footing_ft": 1.1154855643044619, + "footing_still_claimed_ft": 0.6561679790026247, + "footing_still_claimed_s_m": [ + [ + -2.8, + -2.62 + ], + [ + 2.38, + 2.4 + ] + ], + "footing_dropped_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 0.0, + "disagreed": 0.4593175853018373, + "the app itself": 0.0 + }, + "false_outside_footing_ft": 0.0, + "pilaster_ft": 3.805774278215223, + "pilaster_claimed_app_ft": 3.805774278215223, + "pilaster_claimed_filter_ft": 2.1653543307086616, + "pilaster_accepted_delta_samples": { + "0": 68, + "0.1": 48, + "0.2": 29, + "0.3": 63, + "0.4": 32 + }, + "ncc_sweep": { + "0.4": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 5.05249343832021 + }, + "0.5": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 5.511811023622047 + }, + "0.6": { + "false_observed_ft": 0.6561679790026247, + "missed_ft": 8.136482939632545 + }, + "0.7": { + "false_observed_ft": 0.5905511811023622, + "missed_ft": 11.15485564304462 + }, + "0.8": { + "false_observed_ft": 0.5905511811023622, + "missed_ft": 14.63254593175853 + } + }, + "feature_refined": { + "claimed_ft": 12.270341207349082, + "false_observed_ft": 0.6561679790026247, + "missed_ft": 6.561679790026247, + "pairs": 202, + "pairs_refined": 35 + } + }, + { + "setting": "modern_assumed draw 1", + "app_driver_agrees": true, + "app": { + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0 + }, + "filter": { + "claimed_ft": 12.729658792650918, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 6.561679790026247 + }, + "row_sightings": 866, + "row_sightings_kept": 514, + "dropped_row_causes": { + "disagreed": 283, + "too plain": 214 + }, + "missed_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 5.05249343832021, + "disagreed": 1.5091863517060367, + "the app itself": 0.0 + }, + "footing_ft": 1.1154855643044619, + "footing_still_claimed_ft": 1.1154855643044619, + "footing_still_claimed_s_m": [ + [ + -2.94, + -2.62 + ], + [ + 2.38, + 2.4 + ] + ], + "footing_dropped_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 0.0, + "disagreed": 0.0, + "the app itself": 0.0 + }, + "false_outside_footing_ft": 0.0, + "pilaster_ft": 3.805774278215223, + "pilaster_claimed_app_ft": 3.805774278215223, + "pilaster_claimed_filter_ft": 3.280839895013123, + "pilaster_accepted_delta_samples": { + "0": 56, + "0.1": 49, + "0.2": 38, + "0.3": 64, + "0.4": 83 + }, + "ncc_sweep": { + "0.4": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 5.05249343832021 + }, + "0.5": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 5.05249343832021 + }, + "0.6": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 6.561679790026247 + }, + "0.7": { + "false_observed_ft": 0.6561679790026247, + "missed_ft": 9.186351706036746 + }, + "0.8": { + "false_observed_ft": 0.6561679790026247, + "missed_ft": 13.779527559055119 + } + }, + "feature_refined": { + "claimed_ft": 12.729658792650918, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 6.561679790026247, + "pairs": 202, + "pairs_refined": 32 + } + }, + { + "setting": "modern_assumed draw 2", + "app_driver_agrees": true, + "app": { + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0 + }, + "filter": { + "claimed_ft": 13.779527559055119, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 5.511811023622047 + }, + "row_sightings": 867, + "row_sightings_kept": 559, + "dropped_row_causes": { + "disagreed": 233, + "too plain": 198 + }, + "missed_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 4.986876640419947, + "disagreed": 0.5249343832020997, + "the app itself": 0.0 + }, + "footing_ft": 1.1154855643044619, + "footing_still_claimed_ft": 1.1154855643044619, + "footing_still_claimed_s_m": [ + [ + -2.94, + -2.62 + ], + [ + 2.38, + 2.4 + ] + ], + "footing_dropped_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 0.0, + "disagreed": 0.0, + "the app itself": 0.0 + }, + "false_outside_footing_ft": 0.0, + "pilaster_ft": 3.805774278215223, + "pilaster_claimed_app_ft": 3.805774278215223, + "pilaster_claimed_filter_ft": 3.805774278215223, + "pilaster_accepted_delta_samples": { + "0": 61, + "0.1": 49, + "0.2": 30, + "0.3": 94, + "0.4": 102 + }, + "ncc_sweep": { + "0.4": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 3.543307086614173 + }, + "0.5": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 4.4619422572178475 + }, + "0.6": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 5.511811023622047 + }, + "0.7": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 6.561679790026247 + }, + "0.8": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 10.236220472440944 + } + }, + "feature_refined": { + "claimed_ft": 13.779527559055119, + "false_observed_ft": 1.0498687664041995, + "missed_ft": 5.380577427821522, + "pairs": 202, + "pairs_refined": 32 + } + }, + { + "setting": "modern_assumed draw 3", + "app_driver_agrees": true, + "app": { + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0 + }, + "filter": { + "claimed_ft": 10.10498687664042, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 9.186351706036746 + }, + "row_sightings": 870, + "row_sightings_kept": 479, + "dropped_row_causes": { + "disagreed": 358, + "too plain": 210 + }, + "missed_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 5.05249343832021, + "disagreed": 4.133858267716535, + "the app itself": 0.0 + }, + "footing_ft": 1.1154855643044619, + "footing_still_claimed_ft": 1.1154855643044619, + "footing_still_claimed_s_m": [ + [ + -2.94, + -2.62 + ], + [ + 2.38, + 2.4 + ] + ], + "footing_dropped_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 0.0, + "disagreed": 0.0, + "the app itself": 0.0 + }, + "false_outside_footing_ft": 0.0, + "pilaster_ft": 3.805774278215223, + "pilaster_claimed_app_ft": 3.805774278215223, + "pilaster_claimed_filter_ft": 1.7060367454068242, + "pilaster_accepted_delta_samples": { + "0": 63, + "0.1": 19, + "0.2": 55, + "0.3": 45, + "0.4": 46 + }, + "ncc_sweep": { + "0.4": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 4.4619422572178475 + }, + "0.5": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 6.561679790026247 + }, + "0.6": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 9.186351706036746 + }, + "0.7": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 10.761154855643044 + }, + "0.8": { + "false_observed_ft": 0.5905511811023622, + "missed_ft": 14.10761154855643 + } + }, + "feature_refined": { + "claimed_ft": 13.254593175853017, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 6.036745406824147, + "pairs": 202, + "pairs_refined": 33 + } + }, + { + "setting": "modern_assumed draw 4", + "app_driver_agrees": true, + "app": { + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0 + }, + "filter": { + "claimed_ft": 12.664041994750656, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 6.627296587926509 + }, + "row_sightings": 868, + "row_sightings_kept": 514, + "dropped_row_causes": { + "disagreed": 324, + "too plain": 200 + }, + "missed_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 3.0183727034120733, + "disagreed": 3.608923884514436, + "the app itself": 0.0 + }, + "footing_ft": 1.1154855643044619, + "footing_still_claimed_ft": 1.1154855643044619, + "footing_still_claimed_s_m": [ + [ + -2.94, + -2.62 + ], + [ + 2.38, + 2.4 + ] + ], + "footing_dropped_by_cause_ft": { + "no qualifying pair": 0.0, + "too plain": 0.0, + "disagreed": 0.0, + "the app itself": 0.0 + }, + "false_outside_footing_ft": 0.0, + "pilaster_ft": 3.805774278215223, + "pilaster_claimed_app_ft": 3.805774278215223, + "pilaster_claimed_filter_ft": 1.7060367454068242, + "pilaster_accepted_delta_samples": { + "0": 72, + "0.1": 35, + "0.2": 34, + "0.3": 74, + "0.4": 45 + }, + "ncc_sweep": { + "0.4": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 3.543307086614173 + }, + "0.5": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 3.543307086614173 + }, + "0.6": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 6.627296587926509 + }, + "0.7": { + "false_observed_ft": 1.1154855643044619, + "missed_ft": 10.10498687664042 + }, + "0.8": { + "false_observed_ft": 1.0498687664041995, + "missed_ft": 13.58267716535433 + } + }, + "feature_refined": { + "claimed_ft": 16.27296587926509, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 3.0183727034120733, + "pairs": 202, + "pairs_refined": 33 + } + } + ], + "seconds": 130, + "peak_rss_mb": 882 +} \ No newline at end of file diff --git a/experiments/plane-consistency/results/plane_consistency.md b/experiments/plane-consistency/results/plane_consistency.md new file mode 100644 index 000000000..0ab8b39c0 --- /dev/null +++ b/experiments/plane-consistency/results/plane_consistency.md @@ -0,0 +1,63 @@ +# Plane consistency on ETH3D electro (generated by `uv run python run.py`) + +Evals harness at 190ed339f41924ae0244e0b88c5ebe647ddd9eb4 (t3/evals); the app's view gates and `record` replicated from HouseScanKit beede15. The 19.3 ft wall and truth of evals section 7. Feet along the wall. False-observed: claimed wall with 10 cm or more of band no photo saw (pass at 0.5 or less). Missed: band two photos 0.25 m apart saw, left unclaimed (pass at 3.3 or less). + +## Validity + +- The app without the filter, exact poses: claimed 19.3, false-observed 1.1, missed 0.0 (evals section 7: 19.3, 1.1, 0.0). +- The app's own code (the evals' built coverage-driver) agrees with the replicas on every wall sighting and claim in every pose setting: yes. +- Too plain: patch standard deviation below 0.906 grey levels, from a noise floor of 0.573 (measured this run: 0.573). + +## The filter, graded + +| Poses | App claimed | App false-observed | App missed | Claimed | False-observed | Missed | Missed: no qualifying pair / too plain / disagreed / the app itself | Row sightings kept | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| exact | 19.3 | 1.1 | 0.0 | 11.7 | 0.7 | 7.1 | 0.0 / 5.6 / 1.5 / 0.0 | 557 of 876 | +| modern_assumed draw 0 | 19.3 | 1.1 | 0.0 | 10.7 | 0.7 | 8.1 | 0.0 / 5.1 / 3.1 / 0.0 | 462 of 867 | +| modern_assumed draw 1 | 19.3 | 1.1 | 0.0 | 12.7 | 1.1 | 6.6 | 0.0 / 5.1 / 1.5 / 0.0 | 514 of 866 | +| modern_assumed draw 2 | 19.3 | 1.1 | 0.0 | 13.8 | 1.1 | 5.5 | 0.0 / 5.0 / 0.5 / 0.0 | 559 of 867 | +| modern_assumed draw 3 | 19.3 | 1.1 | 0.0 | 10.1 | 1.1 | 9.2 | 0.0 / 5.1 / 4.1 / 0.0 | 479 of 870 | +| modern_assumed draw 4 | 19.3 | 1.1 | 0.0 | 12.7 | 1.1 | 6.6 | 0.0 / 3.0 / 3.6 / 0.0 | 514 of 868 | + +Worst `modern_assumed` draw: false-observed 1.1 (FAIL), missed 9.2 (FAIL). + +## Footings and pilasters + +Footing columns: the columns the app claims falsely with exact poses. Pilaster columns: the wall face stands 0.36 m proud of the tapped plane. + +| Poses | Footing ft | Still claimed | At s (m) | Dropped: no qualifying pair / too plain / disagreed | False-observed elsewhere | Pilaster ft | Claimed by app | Claimed by filter | Accepted delta (m: samples) on pilaster cells | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| exact | 1.1 | 0.7 | -2.94 to -2.74, 2.38 to 2.4 | 0.0 / 0.0 / 0.4 | 0.0 | 3.8 | 3.8 | 2.3 | 0: 74, 0.1: 21, 0.2: 9, 0.3: 51, 0.4: 115 | +| modern_assumed draw 0 | 1.1 | 0.7 | -2.8 to -2.62, 2.38 to 2.4 | 0.0 / 0.0 / 0.5 | 0.0 | 3.8 | 3.8 | 2.2 | 0: 68, 0.1: 48, 0.2: 29, 0.3: 63, 0.4: 32 | +| modern_assumed draw 1 | 1.1 | 1.1 | -2.94 to -2.62, 2.38 to 2.4 | 0.0 / 0.0 / 0.0 | 0.0 | 3.8 | 3.8 | 3.3 | 0: 56, 0.1: 49, 0.2: 38, 0.3: 64, 0.4: 83 | +| modern_assumed draw 2 | 1.1 | 1.1 | -2.94 to -2.62, 2.38 to 2.4 | 0.0 / 0.0 / 0.0 | 0.0 | 3.8 | 3.8 | 3.8 | 0: 61, 0.1: 49, 0.2: 30, 0.3: 94, 0.4: 102 | +| modern_assumed draw 3 | 1.1 | 1.1 | -2.94 to -2.62, 2.38 to 2.4 | 0.0 / 0.0 / 0.0 | 0.0 | 3.8 | 3.8 | 1.7 | 0: 63, 0.1: 19, 0.2: 55, 0.3: 45, 0.4: 46 | +| modern_assumed draw 4 | 1.1 | 1.1 | -2.94 to -2.62, 2.38 to 2.4 | 0.0 / 0.0 / 0.0 | 0.0 | 3.8 | 3.8 | 1.7 | 0: 72, 0.1: 35, 0.2: 34, 0.3: 74, 0.4: 45 | + +## Secondary, not graded + +### NCC threshold: false-observed / missed + +| Poses | NCC 0.4 | NCC 0.5 | NCC 0.6 | NCC 0.7 | NCC 0.8 | +| --- | --- | --- | --- | --- | --- | +| exact | 1.1 / 4.1 | 1.1 / 5.6 | 0.7 / 7.1 | 0.6 / 8.5 | 0.0 / 11.4 | +| modern_assumed draw 0 | 1.1 / 5.1 | 1.1 / 5.5 | 0.7 / 8.1 | 0.6 / 11.2 | 0.6 / 14.6 | +| modern_assumed draw 1 | 1.1 / 5.1 | 1.1 / 5.1 | 1.1 / 6.6 | 0.7 / 9.2 | 0.7 / 13.8 | +| modern_assumed draw 2 | 1.1 / 3.5 | 1.1 / 4.5 | 1.1 / 5.5 | 1.1 / 6.6 | 1.1 / 10.2 | +| modern_assumed draw 3 | 1.1 / 4.5 | 1.1 / 6.6 | 1.1 / 9.2 | 1.1 / 10.8 | 0.6 / 14.1 | +| modern_assumed draw 4 | 1.1 / 3.5 | 1.1 / 3.5 | 1.1 / 6.6 | 1.1 / 10.1 | 1.0 / 13.6 | + +### Wall homography fitted to SIFT matches instead of taken from the poses + +Matches must pass Lowe's ratio at 0.8, lie on the wall band's image, and land within 20 px of the pose homography's prediction; RANSAC at 3 px needs 20 inliers, else the pair keeps its pose homography. The offsets delta still come from the poses. + +| Poses | Claimed | False-observed | Missed | Pairs refined | +| --- | --- | --- | --- | --- | +| exact | 10.6 | 0.7 | 8.0 | 34 of 198 | +| modern_assumed draw 0 | 12.3 | 0.7 | 6.6 | 35 of 202 | +| modern_assumed draw 1 | 12.7 | 1.1 | 6.6 | 32 of 202 | +| modern_assumed draw 2 | 13.8 | 1.0 | 5.4 | 32 of 202 | +| modern_assumed draw 3 | 13.3 | 1.1 | 6.0 | 33 of 202 | +| modern_assumed draw 4 | 16.3 | 1.1 | 3.0 | 33 of 202 | + +Run time 130 s, peak memory 882 MB. diff --git a/experiments/plane-consistency/run.py b/experiments/plane-consistency/run.py new file mode 100644 index 000000000..9df288f0d --- /dev/null +++ b/experiments/plane-consistency/run.py @@ -0,0 +1,813 @@ +"""Plane consistency on ETH3D electro's wall (README). Writes results/plane_consistency.{md,json}. + + uv run python run.py + +The wall, the app's view gates, the truth and the grading come from the evals harness (branch +t3/evals, `EVALS_COMMIT`), imported read-only from `EVALS_DIR`. Its Swift driver of the app is not +needed: `evals.coverage.app_rows` replicates the app's gates, and `plane_filter.covered_cells` its +`record`. If the evals' already-built driver binary is present it is run (it only reads stdin and +writes stdout) to check both replicas against the app's own answer in every pose setting. + +Poses. `exact` is ETH3D's. `modern_assumed` applies `evals.ar_poses.degrade` with that setting's +errors (2% scale, 1 cm, 0.1 degrees) and `group_poses`' seeds, to one group of all 45 photos in +capture order. The files in eth3d/electro/ar_poses/ can't be used: they hold groups of 2 to 8 +photos, each scaled about its own first photo, and 4 photos appear in none, so they give no single +trajectory. The wall is tapped in the same AR world as the poses, so the scale error moves it +with the cameras (about the first camera): AR s = 0.98 true s. Claims are mapped back to true s +before grading, and the truth always uses the true poses. +""" + +from __future__ import annotations + +import json +import os +import resource +import subprocess +import sys +import time +from collections import Counter +from dataclasses import dataclass, field, replace +from pathlib import Path + +# Importing the evals must not write __pycache__ into its worktree. +sys.dont_write_bytecode = True + +import cv2 # noqa: E402 +import numpy as np # noqa: E402 + +from plane_filter import ( # noqa: E402 + apply_homography, + covered_cells, + covered_intervals, + noise_sigma, + parallax_gate, + patch_pixels, + plain_threshold, + plane_homography, + sample_bilinear, + too_plain, + zncc, +) + +EVALS_DIR = Path( + os.environ.get( + "EVALS_DIR", Path.home() / "Programming Projects/house-scanning-evals/experiments/evals" + ) +) +EVALS_COMMIT = "190ed339f41924ae0244e0b88c5ebe647ddd9eb4" +sys.path.insert(0, str(EVALS_DIR)) + +from evals import coverage as cov # noqa: E402 +from evals.ar_poses import SETTINGS, _seed, degrade # noqa: E402 +from evals.paths import ETH3D_DIR # noqa: E402 + +HERE = Path(__file__).resolve().parent +RESULTS = HERE / "results" +SCENE = "electro" +IMAGE_WIDTH = 1024 # the only resolution on disk for all 45 photos + +# The app's default CoverageConfig, exactly as the evals' coverage-driver prints it (the replica +# reproduced all 337 of the app's sightings with these values, evals README section 7). +APP_CONFIG = { + "cellWidth": 0.1524, + "coveringBaseline": 0.25, + "groundBandDepth": 1.2, + "imageMargin": 0.03, + "maxAngleFromNormal": 1.1344639, + "maxDistance": 6, + "rowsPerBand": 3, + "wallBandHeight": 1.9812, +} + +# The filter, fixed in the README before the run. +PATCH_HALF = 5 # 11 x 11 px +DELTAS_M = (0.0, 0.1, 0.2, 0.3, 0.4) # wall plane slid toward the cameras +NCC_MIN = 0.6 +PARALLAX_FRONT_M = 0.5 +PARALLAX_MIN_PX = 3.0 +# Median over electro's 45 photos at 1024 px of `plane_filter.noise_sigma` (grey levels; the +# photos range from 0.36 to 2.2), measured once before any filter run. The too-plain threshold +# is `plain_threshold(NOISE_SIGMA, NCC_MIN)` = 0.906: below it even a perfect match of a +# typical photo is expected to fall short of NCC 0.6. The run re-measures and stops if it moved. +NOISE_SIGMA = 0.573 +PLAIN_STD = plain_threshold(NOISE_SIGMA, NCC_MIN) + +# Secondary, not graded. +NCC_SWEEP = (0.4, 0.5, 0.6, 0.7, 0.8) +SIFT_RATIO = 0.8 # Lowe's ratio test +GUIDE_PX = 20.0 # a feature match must land this close to where the pose homography puts it +RANSAC_PX = 3.0 +MIN_INLIERS = 20 # fewer and the pair keeps its pose homography + +CAUSES = ("no qualifying pair", "too plain", "disagreed") +MISSED_CAUSES = (*CAUSES, "the app itself") + + +@dataclass +class Frame: + """One photo in the AR world (levelled), OpenCV axes, at IMAGE_WIDTH.""" + + name: str + R_wc: np.ndarray + t_wc: np.ndarray + centre: np.ndarray + K: np.ndarray + cam: tuple # (pose, intrinsics, size) in the app's CameraFrame convention, full resolution + + def pixel(self, X: np.ndarray) -> np.ndarray: + x = self.K @ (self.R_wc @ X + self.t_wc) + return x[:2] / x[2] + + +@dataclass +class PoseSetting: + label: str + scale: float + frames: list[Frame] + wall: cov.Wall # the tapped wall in the AR world + + +@dataclass +class SampleTest: + """One of a row's two samples, as photo A saw it: A's patch plainness and each partner.""" + + a_plain: bool + # partner photo -> (parallax px, best NCC over deltas or NaN, delta of the best, qualifies) + partners: dict[int, tuple[float, float, float, bool]] = field(default_factory=dict) + + +# Setup + + +def check_evals() -> None: + head = subprocess.run( + ["git", "-C", str(EVALS_DIR), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + # --no-optional-locks keeps git status from rewriting the index of the evals worktree. + dirty = subprocess.run( + [ + "git", + "--no-optional-locks", + "-C", + str(EVALS_DIR), + "status", + "--porcelain", + "--", + "evals", + ], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if head != EVALS_COMMIT or dirty: + raise SystemExit( + f"{EVALS_DIR} is at {head} with changes {dirty!r}; expected {EVALS_COMMIT}, clean" + ) + + +def load_gray(view) -> np.ndarray: + path = ETH3D_DIR / SCENE / f"images_{IMAGE_WIDTH}" / f"{view.name}.jpg" + img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) + if img is None: + raise FileNotFoundError(path) + return img.astype(np.float32) + + +def pose_setting(setup: cov.Setup, setting: str, draw: int) -> PoseSetting: + views = setup.views + truth = [v.cam_to_world for v in views] + if setting == "exact": + c2w, scale = truth, 1.0 + else: + error = SETTINGS[setting] + scale = error.scales[0] + rng = np.random.default_rng(_seed(setting) if draw == 0 else [_seed(setting), draw]) + c2w = degrade(truth, scale, error, rng) + frames = [] + for v, D in zip(views, c2w, strict=True): + R_c2w = setup.R @ D[:3, :3] + centre = setup.R @ D[:3, 3] + pose = np.eye(4) + pose[:3, :3] = R_c2w @ np.diag([1.0, -1.0, -1.0]) + pose[:3, 3] = centre + intr = [v.K[0, 0], v.K[1, 1], v.K[0, 2] + 0.5, v.K[1, 2] + 0.5] + height = round(v.height * IMAGE_WIDTH / v.width) + frames.append( + Frame( + v.name, + R_c2w.T, + -R_c2w.T @ centre, + centre, + v.scaled_K(IMAGE_WIDTH, height), + (pose, intr, [float(v.width), float(v.height)]), + ) + ) + o = setup.R @ views[0].center # degrade scales offsets from the first camera + + def similar(x): + return o + scale * (x - o) + + w = setup.wall + wall = replace( + w, + meter=similar(w.meter), + ground_y=float(similar(w.origin)[1]), + left=scale * w.left, + right=scale * w.right, + ) + label = setting if setting == "exact" else f"{setting} draw {draw}" + return PoseSetting(label, scale, frames, wall) + + +def app_sightings(ps: PoseSetting, cfg: dict) -> list[tuple[int, int, list[int]]]: + """The app's wall sightings (photo, cell, rows), in capture order, from the replica gates.""" + out = [] + for i, f in enumerate(ps.frames): + for index in cov.candidate_cells(ps.wall, f.cam, cfg): + rows, _ = cov.app_rows(ps.wall, "wall", index, f.cam, cfg) + if rows: + out.append((i, index, sorted(rows))) + return out + + +def driver_check(ps: PoseSetting, sightings, intervals) -> bool | None: + """Whether the app itself (the evals' built coverage-driver) gives the same wall sightings and + claims as the replicas; None when the binary is not there.""" + if not cov.DRIVER_BIN.exists(): + return None + cams = {f.name: f.cam for f in ps.frames} + app = cov.run_app(ps.wall, cams) + got = { + (x["keyframe"], x["index"], tuple(sorted(x["rows"]))) + for x in app["sightings"] + if x["band"] == "wall" + } + mine = {(ps.frames[i].name, c, tuple(r)) for i, c, r in sightings} + same_claims = len(app["covered"]["wall"]) == len(intervals) and np.allclose( + np.asarray(app["covered"]["wall"], float).reshape(-1, 2), + np.asarray(intervals, float).reshape(-1, 2), + atol=1e-5, + ) + return got == mine and bool(same_claims) + + +# The filter + + +class Homographies: + """Pose homographies of the wall plane slid toward the cameras, cached per pair and offset, + optionally with the delta = 0 map replaced by one fitted to wall feature matches.""" + + def __init__(self, ps: PoseSetting, refined: dict | None = None): + self.ps = ps + self.n = ps.wall.outward + self.d0 = float(ps.wall.origin @ ps.wall.outward) + self.refined = refined or {} + self.cache: dict = {} + + def pose(self, i: int, j: int, delta: float) -> np.ndarray: + key = (i, j, delta) + if key not in self.cache: + a, b = self.ps.frames[i], self.ps.frames[j] + self.cache[key] = plane_homography( + a.K, a.R_wc, a.t_wc, b.K, b.R_wc, b.t_wc, self.n, self.d0 + delta + ) + return self.cache[key] + + def warp(self, i: int, j: int, delta: float) -> np.ndarray: + H = self.refined.get((i, j)) + if H is None: + return self.pose(i, j, delta) + # Keep the fitted wall map and move it by what the poses say delta does, in B's pixels. + return self.pose(i, j, delta) @ np.linalg.inv(self.pose(i, j, 0.0)) @ H + + +def test_samples( + ps: PoseSetting, sightings, cfg: dict, images: dict[int, np.ndarray], H: Homographies +) -> dict[tuple[int, int, int], list[SampleTest]]: + """For every row the app credits a photo with, both samples tested against every other photo + the app credits with the same row.""" + credited: dict[tuple[int, int], list[int]] = {} + for i, cell, rows in sightings: + for r in rows: + credited.setdefault((cell, r), []).append(i) + out = {} + for i, cell, rows in sightings: + points = cov.row_points(ps.wall, "wall", cell, cfg) + img_a = images[i] + for r in rows: + tests = [] + for X in points[r]: + uv_a = ps.frames[i].pixel(X) + grid = patch_pixels(uv_a, PATCH_HALF) + a = sample_bilinear(img_a, grid) + if a is None: + raise RuntimeError( + f"{ps.frames[i].name}: credited sample's patch off the image" + ) + t = SampleTest(a_plain=too_plain(a, PLAIN_STD)) + for j in credited[(cell, r)]: + if j == i: + continue + par, qualifies = parallax_gate( + H.pose(i, j, 0.0), H.pose(i, j, PARALLAX_FRONT_M), uv_a, PARALLAX_MIN_PX + ) + best, best_delta = float("nan"), float("nan") + if qualifies and not t.a_plain: + for delta in DELTAS_M: + b = sample_bilinear( + images[j], apply_homography(H.warp(i, j, delta), grid) + ) + if b is None or too_plain(b, PLAIN_STD): + continue + score = zncc(a, b) + if np.isnan(best) or score > best: + best, best_delta = score, delta + t.partners[j] = (par, best, best_delta, qualifies) + tests.append(t) + out[(i, cell, r)] = tests + return out + + +def sample_verdict(t: SampleTest, ncc_min: float) -> str | None: + """None when the sample survives, else the cause.""" + if t.a_plain: + return "too plain" + qualifying = [p for p in t.partners.values() if p[3]] + if not qualifying: + return "no qualifying pair" + if any(p[1] >= ncc_min for p in qualifying): + return None + if all(np.isnan(p[1]) for p in qualifying): + return "too plain" + return "disagreed" + + +def decide(tests, ncc_min: float) -> tuple[set, dict[tuple, Counter]]: + """Surviving (photo, cell, row) sightings, and each dropped one's sample causes. A row survives + only when both its samples do, as the app sees a row only when it sees both.""" + keep, causes = set(), {} + for key, samples in tests.items(): + verdicts = [sample_verdict(t, ncc_min) for t in samples] + if all(v is None for v in verdicts): + keep.add(key) + else: + causes[key] = Counter(v for v in verdicts if v is not None) + return keep, causes + + +def filtered(sightings, keep: set) -> list[tuple[int, int, list[int]]]: + out = [] + for i, cell, rows in sightings: + kept = [r for r in rows if (i, cell, r) in keep] + if kept: + out.append((i, cell, kept)) + return out + + +# Grading + + +@dataclass +class Truth: + """Section 7's per-column truth on the true wall.""" + + s: np.ndarray + seen1: np.ndarray # (C, R) some photo saw the sample + seen2: np.ndarray # (C, R) two photos 0.25 m apart saw it + pilaster: np.ndarray # (C,) the column's face stands proud of the plane + + +def truth_of(setup: cov.Setup, cfg: dict) -> Truth: + s, _, pts = cov.band_samples(setup.wall, "wall", cfg, setup.faces) + saw = np.stack([t.saw for t in setup.truths["wall"]], axis=-1) + absent = np.isnan(pts[..., 0]) + return Truth( + s, + saw.any(axis=-1) | absent, + cov.two_positions(saw, setup.centres, cfg["coveringBaseline"]) | absent, + setup.faces > 0, + ) + + +def grade(setup, cfg, truth: Truth, ps: PoseSetting, cells: set[int]) -> dict: + ar = covered_intervals(cells, cfg["cellWidth"], ps.wall.left, ps.wall.right) + true_intervals = [[lo / ps.scale, hi / ps.scale] for lo, hi in ar] + band = cov.evaluate_band( + setup.wall, + "wall", + cfg, + {"covered": {"wall": true_intervals}, "sightings": []}, + setup.views, + [], + setup.truths, + setup.centres, + setup.faces, + causes=False, + ) + claimed = cov.in_intervals(truth.s, true_intervals) + false_cols = claimed & ((~truth.seen1).sum(axis=1) >= cov.UNSEEN_MIN_SAMPLES) + missed_cols = ~claimed & truth.seen2.all(axis=1) + ft = cov.COLUMN_M / cov.FEET + # The per-column arrays below must add up to the harness's own grading. + if not ( + np.isclose(false_cols.sum() * ft, band["false_observed_ft"]) + and np.isclose(missed_cols.sum() * ft, band["missed_ft"]) + and np.isclose(claimed.sum() * ft, band["claimed_ft"]) + ): + raise RuntimeError("column grading disagrees with evals.coverage.evaluate_band") + return { + "band": band, + "intervals_true_s": true_intervals, + "claimed": claimed, + "false": false_cols, + "missed": missed_cols, + } + + +def column_causes( + truth: Truth, ps: PoseSetting, cfg, cols: np.ndarray, base_cells, sightings, keep, causes +) -> Counter: + """Why each column in `cols` lost its claim: the majority cause over the dropped sightings of + the rows that the filter left without two positions, or "the app itself" when the app did not + claim the cell either. Counted in columns.""" + positions = np.array([f.centre for f in ps.frames]) + rows = int(cfg["rowsPerBand"]) + out: Counter = Counter() + for c in np.flatnonzero(cols): + cell = cov.cell_index(ps.scale * truth.s[c], cfg) + if cell not in base_cells: + out["the app itself"] += 1 + continue + mine = [(i, x, rs) for i, x, rs in filtered(sightings, keep) if x == cell] + # `record` treats rows independently, so each row is run alone as a one-row cell. + deficient = [ + r + for r in range(rows) + if not covered_cells( + [(i, 0, [0]) for i, _, rs in mine if r in rs], positions, 1, cfg["coveringBaseline"] + ) + ] + counts: Counter = Counter() + for i, x, rs in sightings: + if x == cell: + for r in rs: + if r in deficient and (i, x, r) in causes: + counts.update(causes[(i, x, r)]) + out[cov.majority(counts, CAUSES)] += 1 + return out + + +def ft_of(n: int) -> float: + return n * cov.COLUMN_M / cov.FEET + + +def column_runs(s: np.ndarray, mask: np.ndarray) -> list[list[float]]: + """Stretches of s (m, column edges) where `mask` holds on consecutive columns.""" + runs: list[list[float]] = [] + for c in np.flatnonzero(mask): + lo, hi = s[c] - cov.COLUMN_M / 2, s[c] + cov.COLUMN_M / 2 + if runs and abs(runs[-1][1] - lo) < 1e-6: + runs[-1][1] = hi + else: + runs.append([lo, hi]) + return [[round(lo, 2), round(hi, 2)] for lo, hi in runs] + + +# Secondary: homographies refined from wall feature matches + + +def wall_keypoints(ps: PoseSetting, cfg, i: int, image: np.ndarray, sift): + """SIFT keypoints of photo i whose ray meets the wall band's plane inside the marked stretch.""" + kps, desc = sift.detectAndCompute(image.astype(np.uint8), None) + if desc is None: + return np.zeros((0, 2)), None + uv = np.array([k.pt for k in kps]) + f, w = ps.frames[i], ps.wall + rays = np.linalg.inv(f.K) @ np.c_[uv, np.ones(len(uv))].T + dirs = (f.R_wc.T @ rays).T + with np.errstate(divide="ignore", invalid="ignore"): + t = ((w.origin - f.centre) @ w.outward) / (dirs @ w.outward) + X = f.centre + t[:, None] * dirs + s, h = w.s_of(X), X[:, 1] - w.ground_y + ok = (t > 0) & (s >= w.left) & (s <= w.right) & (h >= 0) & (h <= cfg["wallBandHeight"]) + return uv[ok], desc[ok] + + +def refine(ps: PoseSetting, cfg, pairs, images, H: Homographies) -> tuple[dict, int]: + sift = cv2.SIFT_create() + feats = {i: wall_keypoints(ps, cfg, i, images[i], sift) for i in {p for q in pairs for p in q}} + matcher = cv2.BFMatcher(cv2.NORM_L2) + refined = {} + for i, j in sorted(pairs): + (ua, da), (ub, db) = feats[i], feats[j] + if da is None or db is None or len(ua) < MIN_INLIERS or len(ub) < 2: + continue + good = [ + m for m, n2 in matcher.knnMatch(da, db, k=2) if m.distance < SIFT_RATIO * n2.distance + ] + if len(good) < MIN_INLIERS: + continue + pa = ua[[m.queryIdx for m in good]] + pb = ub[[m.trainIdx for m in good]] + near = np.linalg.norm(apply_homography(H.pose(i, j, 0.0), pa) - pb, axis=1) < GUIDE_PX + if near.sum() < MIN_INLIERS: + continue + Hf, inl = cv2.findHomography(pa[near], pb[near], cv2.RANSAC, RANSAC_PX) + if Hf is not None and inl.sum() >= MIN_INLIERS: + refined[(i, j)] = Hf + return refined, len(pairs) + + +# Run + + +def run_setting(setup, cfg, truth, ps: PoseSetting, images_of, footing_cols) -> dict: + sightings = app_sightings(ps, cfg) + base_cells = covered_cells( + [(i, c, r) for i, c, r in sightings], + np.array([f.centre for f in ps.frames]), + int(cfg["rowsPerBand"]), + cfg["coveringBaseline"], + ) + base = grade(setup, cfg, truth, ps, base_cells) + photos = sorted({i for i, _, _ in sightings}) + images = {i: images_of(i) for i in photos} + H = Homographies(ps) + tests = test_samples(ps, sightings, cfg, images, H) + positions = np.array([f.centre for f in ps.frames]) + + def outcome(tests_, ncc_min): + keep, causes = decide(tests_, ncc_min) + cells = covered_cells( + filtered(sightings, keep), positions, int(cfg["rowsPerBand"]), cfg["coveringBaseline"] + ) + g = grade(setup, cfg, truth, ps, cells) + return keep, causes, cells, g + + keep, causes, _cells, g = outcome(tests, NCC_MIN) + missed_split = column_causes(truth, ps, cfg, g["missed"], base_cells, sightings, keep, causes) + lost_footing = footing_cols & ~g["claimed"] + footing_split = column_causes(truth, ps, cfg, lost_footing, base_cells, sightings, keep, causes) + # Deltas of the accepted matches on pilaster cells, and how many row sightings survived there. + pil_cells = {cov.cell_index(ps.scale * s, cfg) for s in truth.s[truth.pilaster]} + pil_deltas: Counter = Counter() + for (i, cell, r), ts in tests.items(): + if cell in pil_cells and (i, cell, r) in keep: + for t in ts: + best = max( + (p for p in t.partners.values() if p[3] and p[1] >= NCC_MIN), + key=lambda p: p[1], + ) + pil_deltas[f"{best[2]:g}"] += 1 + sweep = {} + for m in NCC_SWEEP: + gm = outcome(tests, m)[3]["band"] + sweep[f"{m:g}"] = { + "false_observed_ft": gm["false_observed_ft"], + "missed_ft": gm["missed_ft"], + } + + pairs = {(i, j) for (i, _, _), ts in tests.items() for t in ts for j in t.partners} + refined, n_pairs = refine(ps, cfg, pairs, images, H) + rtests = test_samples(ps, sightings, cfg, images, Homographies(ps, refined)) + rg = outcome(rtests, NCC_MIN)[3]["band"] + + row_sightings = len(tests) + return { + "setting": ps.label, + "app_driver_agrees": driver_check( + ps, + sightings, + covered_intervals(base_cells, cfg["cellWidth"], ps.wall.left, ps.wall.right), + ), + "app": summary(base["band"]), + "filter": summary(g["band"]), + "row_sightings": row_sightings, + "row_sightings_kept": len(keep), + "dropped_row_causes": dict(sum(causes.values(), Counter())), + "missed_by_cause_ft": {k: ft_of(missed_split[k]) for k in MISSED_CAUSES}, + "footing_ft": ft_of(int(footing_cols.sum())), + "footing_still_claimed_ft": ft_of(int((footing_cols & g["claimed"]).sum())), + "footing_still_claimed_s_m": column_runs(truth.s, footing_cols & g["claimed"]), + "footing_dropped_by_cause_ft": {k: ft_of(footing_split[k]) for k in MISSED_CAUSES}, + "false_outside_footing_ft": ft_of(int((g["false"] & ~footing_cols).sum())), + "pilaster_ft": ft_of(int(truth.pilaster.sum())), + "pilaster_claimed_app_ft": ft_of(int((truth.pilaster & base["claimed"]).sum())), + "pilaster_claimed_filter_ft": ft_of(int((truth.pilaster & g["claimed"]).sum())), + "pilaster_accepted_delta_samples": dict(sorted(pil_deltas.items())), + "ncc_sweep": sweep, + "feature_refined": { + **summary(rg), + "pairs": n_pairs, + "pairs_refined": len(refined), + }, + } + + +def summary(band: dict) -> dict: + return { + "claimed_ft": band["claimed_ft"], + "false_observed_ft": band["false_observed_ft"], + "missed_ft": band["missed_ft"], + } + + +def measured_noise(setup) -> float: + return float(np.median([noise_sigma(load_gray(v)) for v in setup.views])) + + +def main() -> None: + started = time.time() + check_evals() + cfg = dict(APP_CONFIG) + cov.app_config = lambda: dict(APP_CONFIG) # setup_scene would otherwise ask the Swift driver + setup = cov.setup_scene(SCENE) + if setup is None: + raise SystemExit(f"{SCENE}: no wall within the app's range") + sigma = measured_noise(setup) + if abs(sigma - NOISE_SIGMA) > 0.1 * NOISE_SIGMA: + raise SystemExit(f"noise floor is {sigma:.3f}, not the {NOISE_SIGMA} the threshold assumes") + truth = truth_of(setup, cfg) + + exact = pose_setting(setup, "exact", 0) + for f, v in zip(exact.frames, setup.views, strict=True): + if not np.allclose(f.cam[0], setup.cams[v.name][0]): + raise RuntimeError(f"{v.name}: exact AR pose differs from evals.coverage.to_arkit") + base_exact = grade( + setup, + cfg, + truth, + exact, + covered_cells( + app_sightings(exact, cfg), + np.array([f.centre for f in exact.frames]), + int(cfg["rowsPerBand"]), + cfg["coveringBaseline"], + ), + ) + b = base_exact["band"] + validity = { + "claimed_ft": b["claimed_ft"], + "false_observed_ft": b["false_observed_ft"], + "missed_ft": b["missed_ft"], + "evals_section_7": {"claimed_ft": 19.3, "false_observed_ft": 1.1, "missed_ft": 0.0}, + } + expected = validity["evals_section_7"] + if any(abs(round(validity[k], 1) - expected[k]) > 0.1 + 1e-9 for k in expected): + raise SystemExit(f"baseline not reproduced: {validity}") + footing_cols = base_exact["false"] + + def images_of(i): + return load_gray(setup.views[i]) + + runs = [run_setting(setup, cfg, truth, exact, images_of, footing_cols)] + draws = range(5) + for d in draws: + ps = pose_setting(setup, "modern_assumed", d) + runs.append(run_setting(setup, cfg, truth, ps, images_of, footing_cols)) + print(f"{ps.label} done", file=sys.stderr) + + res = { + "scene": SCENE, + "evals_commit": EVALS_COMMIT, + "housescankit_commit": cov.KIT_COMMIT, + "noise_sigma_measured": sigma, + "noise_sigma_fixed": NOISE_SIGMA, + "plain_std": PLAIN_STD, + "validity": validity, + "wall_ft": (setup.wall.right - setup.wall.left) / cov.FEET, + "runs": runs, + "seconds": round(time.time() - started), + # ru_maxrss is bytes on macOS. + "peak_rss_mb": round(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 2**20), + } + RESULTS.mkdir(exist_ok=True) + (RESULTS / "plane_consistency.json").write_text(json.dumps(res, indent=1, default=_json)) + md = markdown(res) + (RESULTS / "plane_consistency.md").write_text(md) + print(md) + + +def _json(x): + if isinstance(x, np.generic): + return x.item() + raise TypeError(type(x)) + + +def _f(x: float) -> str: + return f"{x:.1f}" + + +def markdown(res: dict) -> str: + runs = res["runs"] + modern = [r for r in runs if r["setting"] != "exact"] + worst_fo = max(r["filter"]["false_observed_ft"] for r in modern) + worst_missed = max(r["filter"]["missed_ft"] for r in modern) + v = res["validity"] + agree = {r["app_driver_agrees"] for r in runs} + lines = [ + "# Plane consistency on ETH3D electro (generated by `uv run python run.py`)", + "", + f"Evals harness at {res['evals_commit']} (t3/evals); the app's view gates and `record` " + f"replicated from HouseScanKit {res['housescankit_commit'][:7]}. The " + f"{res['wall_ft']:.1f} ft wall and truth of evals section 7. Feet along the wall. " + "False-observed: claimed wall with 10 cm or more of band no photo saw (pass at 0.5 or " + "less). Missed: band two photos 0.25 m apart saw, left unclaimed (pass at 3.3 or less).", + "", + "## Validity", + "", + f"- The app without the filter, exact poses: claimed {_f(v['claimed_ft'])}, " + f"false-observed {_f(v['false_observed_ft'])}, missed {_f(v['missed_ft'])} " + "(evals section 7: 19.3, 1.1, 0.0).", + "- The app's own code (the evals' built coverage-driver) agrees with the replicas on " + "every wall sighting and claim in every pose setting: " + + { + frozenset({True}): "yes.", + frozenset({None}): "not checked, driver binary absent.", + }.get(frozenset(agree), f"NO ({[r['app_driver_agrees'] for r in runs]})."), + f"- Too plain: patch standard deviation below {res['plain_std']:.3f} grey levels, from a " + f"noise floor of {res['noise_sigma_fixed']} (measured this run: " + f"{res['noise_sigma_measured']:.3f}).", + "", + "## The filter, graded", + "", + "| Poses | App claimed | App false-observed | App missed | Claimed | False-observed | Missed | Missed: no qualifying pair / too plain / disagreed / the app itself | Row sightings kept |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for r in runs: + a, f, m = r["app"], r["filter"], r["missed_by_cause_ft"] + lines.append( + f"| {r['setting']} | {_f(a['claimed_ft'])} | {_f(a['false_observed_ft'])} | " + f"{_f(a['missed_ft'])} | {_f(f['claimed_ft'])} | {_f(f['false_observed_ft'])} | " + f"{_f(f['missed_ft'])} | {' / '.join(_f(m[k]) for k in MISSED_CAUSES)} | " + f"{r['row_sightings_kept']} of {r['row_sightings']} |" + ) + lines += [ + "", + f"Worst `modern_assumed` draw: false-observed {_f(worst_fo)} " + f"({'PASS' if worst_fo <= 0.5 else 'FAIL'}), missed {_f(worst_missed)} " + f"({'PASS' if worst_missed <= 3.3 else 'FAIL'}).", + "", + "## Footings and pilasters", + "", + "Footing columns: the columns the app claims falsely with exact poses. Pilaster columns: " + "the wall face stands 0.36 m proud of the tapped plane.", + "", + "| Poses | Footing ft | Still claimed | At s (m) | Dropped: no qualifying pair / too plain / disagreed | False-observed elsewhere | Pilaster ft | Claimed by app | Claimed by filter | Accepted delta (m: samples) on pilaster cells |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for r in runs: + fd = r["footing_dropped_by_cause_ft"] + deltas = ", ".join(f"{k}: {n}" for k, n in r["pilaster_accepted_delta_samples"].items()) + at = ", ".join(f"{lo:g} to {hi:g}" for lo, hi in r["footing_still_claimed_s_m"]) + lines.append( + f"| {r['setting']} | {_f(r['footing_ft'])} | {_f(r['footing_still_claimed_ft'])} | " + f"{at or 'none'} | " + f"{' / '.join(_f(fd[k]) for k in CAUSES)} | {_f(r['false_outside_footing_ft'])} | " + f"{_f(r['pilaster_ft'])} | {_f(r['pilaster_claimed_app_ft'])} | " + f"{_f(r['pilaster_claimed_filter_ft'])} | {deltas or 'none'} |" + ) + lines += [ + "", + "## Secondary, not graded", + "", + "### NCC threshold: false-observed / missed", + "", + "| Poses | " + " | ".join(f"NCC {m:g}" for m in NCC_SWEEP) + " |", + "| --- |" + " --- |" * len(NCC_SWEEP), + ] + for r in runs: + cells = [ + f"{_f(r['ncc_sweep'][f'{m:g}']['false_observed_ft'])} / " + f"{_f(r['ncc_sweep'][f'{m:g}']['missed_ft'])}" + for m in NCC_SWEEP + ] + lines.append(f"| {r['setting']} | " + " | ".join(cells) + " |") + lines += [ + "", + "### Wall homography fitted to SIFT matches instead of taken from the poses", + "", + f"Matches must pass Lowe's ratio at {SIFT_RATIO}, lie on the wall band's image, and land " + f"within {GUIDE_PX:g} px of the pose homography's prediction; RANSAC at {RANSAC_PX:g} px " + f"needs {MIN_INLIERS} inliers, else the pair keeps its pose homography. The offsets " + "delta still come from the poses.", + "", + "| Poses | Claimed | False-observed | Missed | Pairs refined |", + "| --- | --- | --- | --- | --- |", + ] + for r in runs: + fr = r["feature_refined"] + lines.append( + f"| {r['setting']} | {_f(fr['claimed_ft'])} | {_f(fr['false_observed_ft'])} | " + f"{_f(fr['missed_ft'])} | {fr['pairs_refined']} of {fr['pairs']} |" + ) + lines += ["", f"Run time {res['seconds']} s, peak memory {res['peak_rss_mb']} MB."] + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + main() diff --git a/experiments/plane-consistency/tests/test_plane_filter.py b/experiments/plane-consistency/tests/test_plane_filter.py new file mode 100644 index 000000000..b0d386b12 --- /dev/null +++ b/experiments/plane-consistency/tests/test_plane_filter.py @@ -0,0 +1,271 @@ +"""Synthetic checks of the filter's parts that have one correct answer.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from plane_filter import ( + apply_homography, + covered_cells, + covered_intervals, + grouped_median, + noise_sigma, + parallax_gate, + parallax_px, + patch_pixels, + plain_threshold, + plane_homography, + sample_bilinear, + too_plain, + zncc, +) + + +def rotation(axis_angle: np.ndarray) -> np.ndarray: + theta = np.linalg.norm(axis_angle) + k = axis_angle / theta + K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]]) + return np.eye(3) + np.sin(theta) * K + (1 - np.cos(theta)) * K @ K + + +def look_at(centre: np.ndarray, target: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """World-to-camera (R, t), OpenCV axes, looking from `centre` at `target`, world up -y.""" + z = (target - centre) / np.linalg.norm(target - centre) + x = np.cross([0.0, -1.0, 0.0], z) + x /= np.linalg.norm(x) + y = np.cross(z, x) + R = np.stack([x, y, z]) + return R, -R @ centre + + +def K_of(f: float, cx: float, cy: float) -> np.ndarray: + return np.array([[f, 0, cx], [0, f * 1.001, cy], [0, 0, 1.0]]) + + +# Plane-induced homography + + +@pytest.mark.parametrize("seed", range(5)) +def test_homography_matches_unproject_then_project(seed): + rng = np.random.default_rng(seed) + n = rng.normal(size=3) + n /= np.linalg.norm(n) + d = rng.uniform(-2, 2) + on_plane = n * d + K_a, K_b = K_of(560, 512, 341), K_of(610, 500, 330) + c_a = on_plane + 4 * n + rng.normal(size=3) + c_b = on_plane + 3 * n + rng.normal(size=3) + R_a, t_a = look_at(c_a, on_plane) + R_b, t_b = look_at(c_b, on_plane) + # Perturb B so the rotation isn't a pure look-at. + R_b = rotation(np.array([0.02, -0.03, 0.05])) @ R_b + t_b = -R_b @ c_b + uv = rng.uniform([200, 150], [800, 550], size=(50, 2)) + + rays = (R_a.T @ (np.linalg.inv(K_a) @ np.c_[uv, np.ones(50)].T)).T + s = (d - n @ c_a) / (rays @ n) + X = c_a + s[:, None] * rays + assert np.allclose(X @ n, d) + x_b = (K_b @ (R_b @ X.T + t_b[:, None])).T + direct = x_b[:, :2] / x_b[:, 2:] + + H = plane_homography(K_a, R_a, t_a, K_b, R_b, t_b, n, d) + assert np.allclose(apply_homography(H, uv), direct, atol=1e-8) + + +def test_homography_is_identity_for_the_same_camera(): + R, t = look_at(np.array([0.0, 0, -5]), np.zeros(3)) + K = K_of(560, 512, 341) + H = plane_homography(K, R, t, K, R, t, np.array([0.0, 0, 1]), 0.0) + assert np.allclose(H / H[2, 2], np.eye(3)) + + +def test_homography_refuses_a_camera_on_the_plane(): + R, t = look_at(np.array([0.0, 0, 0]), np.array([0.0, 0, 5])) + K = K_of(560, 512, 341) + with pytest.raises(ValueError, match="on the plane"): + plane_homography(K, R, t, K, R, t, np.array([1.0, 0, 0]), 0.0) + + +# Parallax gate + + +def stereo_pair(baseline: float): + """Two cameras at z = 0, B `baseline` m to the right of A, both looking along +z.""" + K = K_of(560, 512, 341) + K[1, 1] = 560 + R = np.eye(3) + return K, R, np.zeros(3), R, np.array([-baseline, 0.0, 0.0]) + + +def test_parallax_is_the_disparity_difference_of_the_two_planes(): + K, R_a, t_a, R_b, t_b = stereo_pair(0.3) + n, wall = np.array([0.0, 0, 1]), 5.0 # wall at z = 5, the cameras at z = 0 + H_wall = plane_homography(K, R_a, t_a, K, R_b, t_b, n, wall) + H_front = plane_homography(K, R_a, t_a, K, R_b, t_b, n, wall - 0.5) + expected = 560 * 0.3 * (1 / 4.5 - 1 / 5.0) + uv = np.array([[512.0, 341.0], [100.0, 600.0]]) + assert np.allclose(parallax_px(H_wall, H_front, uv), expected) + + +def test_parallax_gate_admits_at_three_px_and_refuses_just_below(): + n, wall = np.array([0.0, 0, 1]), 5.0 + per_metre = 560 * (1 / 4.5 - 1 / 5.0) # parallax per metre of baseline + uv = np.array([512.0, 341.0]) + for baseline, admitted in ((3.0 / per_metre, True), (2.99 / per_metre, False)): + K, R_a, t_a, R_b, t_b = stereo_pair(baseline) + H_wall = plane_homography(K, R_a, t_a, K, R_b, t_b, n, wall) + H_front = plane_homography(K, R_a, t_a, K, R_b, t_b, n, wall - 0.5) + p, ok = parallax_gate(H_wall, H_front, uv, 3.0 - 1e-9) + assert ok is admitted, p + + +def test_parallax_gate_refuses_a_pure_rotation(): + K = K_of(560, 512, 341) + R_b = rotation(np.array([0.0, 0.2, 0.0])) + n, wall = np.array([0.0, 0, 1]), 5.0 + H_wall = plane_homography(K, np.eye(3), np.zeros(3), K, R_b, np.zeros(3), n, wall) + H_front = plane_homography(K, np.eye(3), np.zeros(3), K, R_b, np.zeros(3), n, wall - 0.5) + p, ok = parallax_gate(H_wall, H_front, np.array([512.0, 341.0]), 3.0) + assert p == pytest.approx(0, abs=1e-9) + assert not ok + + +# NCC + + +def test_zncc_values(): + rng = np.random.default_rng(0) + a = rng.normal(size=(11, 11)) + assert zncc(a, a) == pytest.approx(1.0) + assert zncc(a, 3 * a + 40) == pytest.approx(1.0) + assert zncc(a, -a) == pytest.approx(-1.0) + x = np.array([1.0, -1, 1, -1]) + y = np.array([1.0, 1, -1, -1]) + assert zncc(x, y) == pytest.approx(0.0) + assert zncc(np.array([1.0, 2, 3, 4]), np.array([1.0, 2, 4, 3])) == pytest.approx(0.8) + + +def test_zncc_of_a_flat_patch_is_nan(): + assert np.isnan(zncc(np.full(121, 7.0), np.arange(121.0))) + + +def test_zncc_refuses_patches_of_different_size(): + with pytest.raises(ValueError, match="shapes differ"): + zncc(np.zeros(121), np.zeros(120)) + + +# Too plain + + +def test_plain_threshold_formula(): + assert plain_threshold(1.0, 0.6) == pytest.approx(1 / np.sqrt(0.4)) + assert plain_threshold(0.573, 0.6) == pytest.approx(0.906, abs=1e-3) + with pytest.raises(ValueError): + plain_threshold(1.0, 1.0) + + +def test_a_patch_at_the_threshold_matches_itself_at_the_ncc_it_was_derived_from(): + """The derivation: signal of deviation s, seen twice with independent noise sigma, correlates + at s^2 / (s^2 + sigma^2) on average; at the threshold that is 0.6.""" + rng = np.random.default_rng(1) + sigma = 2.0 + T = plain_threshold(sigma, 0.6) + s = np.sqrt(T**2 - sigma**2) + scores = [] + for _ in range(4000): + signal = rng.normal(0, s, 121) + scores.append(zncc(signal + rng.normal(0, sigma, 121), signal + rng.normal(0, sigma, 121))) + assert np.mean(scores) == pytest.approx(0.6, abs=0.01) + + +def test_too_plain_is_strictly_below_the_threshold(): + patch = np.tile([0.0, 2.0], 60) # standard deviation exactly 1 + assert not too_plain(patch, 1.0) + assert too_plain(patch, 1.0 + 1e-9) + assert too_plain(np.full((11, 11), 128.0), 0.5) + + +@pytest.mark.parametrize("sigma", [0.8, 3.0]) +def test_noise_sigma_recovers_gaussian_noise_on_a_ramp(sigma): + rng = np.random.default_rng(2) + yy, xx = np.mgrid[0:400, 0:600] + ramp = 50 + 0.2 * xx + 0.1 * yy # planar intensity, cancelled by the operator + img = np.clip(np.rint(ramp + rng.normal(0, sigma, ramp.shape)), 0, 255) + assert noise_sigma(img) == pytest.approx(sigma, rel=0.08) + + +def test_grouped_median(): + # Half the values are 0 and half 2: the median sits at the top of 0's bin. + assert grouped_median(np.array([0, 0, 2, 2])) == pytest.approx(0.5) + # 1 fills [0.5, 1.5]; its bin holds the middle, three quarters in. + assert grouped_median(np.array([0, 1, 1, 1, 1, 1, 1, 2])) == pytest.approx(0.5 + 3 / 6) + with pytest.raises(ValueError): + grouped_median(np.array([-1, 2])) + + +# Patches + + +def test_patch_pixels_and_bilinear_sampling(): + grid = patch_pixels((10.4, 20.6), 5) + assert grid.shape == (121, 2) + assert grid[:, 0].min() == 5 and grid[:, 0].max() == 15 + assert grid[:, 1].min() == 16 and grid[:, 1].max() == 26 + yy, xx = np.mgrid[0:40, 0:30].astype(np.float32) + img = 3 * xx + 5 * yy + uv = np.array([[2.25, 7.5], [29.0, 39.0], [0.0, 0.0]]) + assert np.allclose(sample_bilinear(img, uv), 3 * uv[:, 0] + 5 * uv[:, 1]) + assert sample_bilinear(img, np.array([[29.01, 3.0]])) is None + assert sample_bilinear(img, np.array([[-0.01, 3.0]])) is None + assert sample_bilinear(img, np.array([[np.nan, 3.0]])) is None + + +# The claim rule over surviving sightings + + +P = np.array([[0.0, 0, 0], [0.3, 0, 0], [0.2, 0, 0], [-0.2, 0, 0], [1.0, 0, 0], [1.1, 0, 0]]) + + +def test_two_positions_apart_cover_every_row(): + assert covered_cells([(0, 5, [0, 1, 2]), (1, 5, [0, 1, 2])], P, 3, 0.25) == {5} + + +def test_positions_closer_than_the_baseline_do_not_cover(): + assert covered_cells([(0, 5, [0, 1, 2]), (2, 5, [0, 1, 2])], P, 3, 0.25) == set() + + +def test_rows_may_be_covered_by_different_photos(): + s = [(0, 5, [0, 1]), (1, 5, [0, 1]), (4, 5, [2]), (0, 5, [2])] + assert covered_cells(s, P, 3, 0.25) == {5} + + +def test_a_row_left_without_two_positions_leaves_the_cell_unclaimed(): + s = [(0, 5, [0, 1]), (1, 5, [0, 1]), (4, 5, [2])] + assert covered_cells(s, P, 3, 0.25) == set() + + +def test_a_row_keeps_its_first_position_as_the_app_does(): + # 0.2 and -0.2 are 0.4 apart, but each is within 0.25 of the first position, 0.0. + s = [(0, 5, [0]), (2, 5, [0]), (3, 5, [0])] + assert covered_cells(s, P, 1, 0.25) == set() + # A later position far enough from the first completes the row. + assert covered_cells([*s, (1, 5, [0])], P, 1, 0.25) == {5} + + +def test_dropping_a_sighting_can_uncover_a_cell(): + kept = [(0, 7, [0]), (1, 7, [0]), (4, 8, [0]), (5, 8, [0])] + assert covered_cells(kept, P, 1, 0.25) == {7} # 1.0 and 1.1 are 0.1 apart + assert covered_cells(kept[1:], P, 1, 0.25) == set() + + +def test_cells_outside_the_marked_ends_are_ignored(): + s = [(0, 5, [0]), (1, 5, [0]), (0, 6, [0]), (1, 6, [0])] + assert covered_cells(s, P, 1, 0.25, allowed={6}) == {6} + + +def test_covered_intervals_merge_split_and_clip(): + w = 0.5 + assert covered_intervals({1, 2, 4}, w, left=0.6, right=2.2) == [[0.6, 1.5], [2.0, 2.2]] + assert covered_intervals(set(), w, 0, 1) == [] diff --git a/experiments/plane-consistency/uv.lock b/experiments/plane-consistency/uv.lock new file mode 100644 index 000000000..e3f50964d --- /dev/null +++ b/experiments/plane-consistency/uv.lock @@ -0,0 +1,281 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "plane-consistency" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "scipy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.1" }, + { name = "opencv-python-headless", specifier = ">=4.10" }, + { name = "scipy", specifier = ">=1.18.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] diff --git a/experiments/sensor-budget/METHODS.md b/experiments/sensor-budget/METHODS.md new file mode 100644 index 000000000..9fdf0265c --- /dev/null +++ b/experiments/sensor-budget/METHODS.md @@ -0,0 +1,40 @@ +# Sensor budget: methods and full results + +The README is the front door. This file keeps the method as it was pre-registered in 397507f, and the full result as the run reported it. + +## Question + +Some candidate signals can't be tested on data we have: dual-camera disparity, focus distance, barometer, ultra-wideband ranging to a second phone, known-size objects (meter cover, brick courses, siding, a Letter sheet), measuring both ends of a span in one photo, and the 4032 × 3024 still. What error does physics allow each one at the range it would be used? Each gets a formula, parameters at the optimistic and conservative ends of published values, and a Monte Carlo where the parts combine non-linearly. + +## Pass criteria, fixed before the run + +- **Worth a device test:** with conservative parameters, p90 error at the operating range is at most 2 in for a position or length, or at most 1% for a scale. +- **Drop:** even optimistic parameters exceed 4 in, or 3% for a scale. +- **In between:** listed with the parameter that decides it. + +## Result + +[results/budget.md](results/budget.md) has every formula, parameter and source. + +- **Drop** (optimistic p90 over 4 in or 3%): + - dual-camera disparity, 4 to 42 in per pixel at 2.5 m, and Apple labels it relative depth; + - focus distance, 8 to 37 in; + - the barometer, 5 to 34 in on a height; + - a meter cover whose maker is unknown, 4.8% (covers run 6.29 to 6.95 in). +- **Worth a device test:** + - an ID-1 card or a Letter sheet at the meter, 1% or better; + - one photo holding both ends of a 6 ft span on a LiDAR phone, 0.5 to 1.3 in; + - the phone pressed to a corner as a probe, 0.3 to 1.9 in, which hinges on tracking while the camera faces the wall; + - a tap snapped to an image edge. This passes only on localization noise. Snapping to the wrong edge is the real risk, and edge-geometry's snap test couldn't measure it. +- **In between, and the parameter that decides each:** + - UWB needs σ ≤ 3 cm; + - one photo without LiDAR needs ARKit scale within 1%; + - brick, the door and a meter whose maker is read from the nameplate need their tolerance confirmed; + - acoustic echo needs outdoor range noise. + - The app's one-normal wall plane gives 3.1 to 9.8 in for an edge 6 ft away seen 35° off face-on. Its yaw range was updated after the run from edge-geometry's measured proxy. It had assumed 1 to 5°. + +A scale reference at the meter corrects ARKit only near the meter. Drift anatomy found scale wandering about 1 to 2% within a walk. + +**Run:** `uv run python budget.py`. It needs no data. + +**What it changes:** there are no new sensors to build. The candidates are a card at the meter, a wider photo, and fixing the wall plane. diff --git a/experiments/sensor-budget/README.md b/experiments/sensor-budget/README.md new file mode 100644 index 000000000..eacd73acc --- /dev/null +++ b/experiments/sensor-budget/README.md @@ -0,0 +1,21 @@ +# Sensor budget + +## Question + +What error does physics allow each signal we can't test on data, at the range it would be used? The signals are dual-camera disparity, focus distance, the barometer, UWB, known-size objects, one photo per span, and the phone as a probe. Each gets a formula and sourced optimistic and conservative parameters; [METHODS.md](METHODS.md) and [results/budget.md](results/budget.md) list them. + +## Pass criteria, fixed before the run (397507f) + +- **Worth a device test:** with conservative parameters, p90 error at the operating range is at most 2 in for a position or length, or at most 1% for a scale. +- **Drop:** even optimistic parameters exceed 4 in, or 3% for a scale. +- **In between:** listed with the parameter that decides it. + +## Result + +- **Drop:** dual-camera disparity, focus distance, the barometer, and a meter cover whose maker is unknown. +- **Worth a device test:** an ID card or Letter sheet at the meter, one photo per span with LiDAR (0.5 to 1.3 in over 6 ft), and a contact probe. +- **In between:** UWB needs σ ≤ 3 cm. The app's one-normal wall plane gives 3.1 to 9.8 in for an edge 6 ft away seen 35° off face-on. + +**Run:** `uv run python budget.py`. It needs no data. + +**What it changes:** there are no new sensors to build. The candidates are a card at the meter, a wider photo, and fixing the wall plane. diff --git a/experiments/sensor-budget/budget.py b/experiments/sensor-budget/budget.py new file mode 100644 index 000000000..fb2b7d733 --- /dev/null +++ b/experiments/sensor-budget/budget.py @@ -0,0 +1,489 @@ +"""Physics error budgets for phone signals that no dataset on disk can test. + +Each candidate is a closed-form error model. Its parameters run between an optimistic and a +conservative value, and each carries its source (`PARAMS`). The Monte Carlo draws the random +terms as standard normals and reports the p90 of the absolute error three ways: every parameter +at its optimistic end, every parameter at its conservative end, and parameters drawn uniformly +between the two. The verdict applies the README's rule, fixed before the run, to the first two. + +Run: uv run python budget.py (writes results/budget.md and results/budget.json) +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import asdict, dataclass +from pathlib import Path + +import numpy as np + +IN_PER_M = 1 / 0.0254 +FT = 0.3048 +N = 200_000 +SEED = 20260926 + + +@dataclass(frozen=True) +class P: + opt: float + cons: float + unit: str + source: str + + +# Every value names where it came from. "Assumed" means no source was found; the verdict then +# says which parameter decides it. +PARAMS: dict[str, P] = { + # Geometry of the capture + "standoff_m": P(2.5, 2.5, "m", "app guidance: stand 1 to 3 m from the wall (docs/00)"), + "f_px": P( + 1450.0, + 1450.0, + "px", + "wide camera focal length at 1920 px, typical ARKit capturedImage intrinsics", + ), + # Dual-camera disparity (AVDepthData, builtInDualWideCamera) + "dual_f_px": P( + 500.0, + 400.0, + "px", + "depth map about 640 px wide: the 1450 px focal length scaled down (assumed)", + ), + "dual_baseline_m": P( + 0.020, + 0.012, + "m", + "wide to ultra-wide spacing, assumed; the idea lab estimated 1.5 to 2 cm; no Apple spec", + ), + "dual_disp_px": P(0.1, 0.5, "px", "disparity noise, assumed; sub-pixel stereo range"), + # Focus distance from lens position + "focal_mm": P( + 6.9, 5.7, "mm", "iPhone main camera focal lengths, 13 to 15 Pro (Apple tech specs)" + ), + "lens_pos_um": P( + 1.0, + 3.0, + "um", + "lens position repeatability including heat and gravity sag, assumed; AVCaptureDevice.lensPosition is not calibrated to distance", + ), + # Barometer + "baro_pa": P( + 1.0, + 6.0, + "Pa", + "relative pressure noise plus wind gusts; 1 Pa is a quiet sensor, 6 Pa a 3 m/s gust's dynamic pressure (assumed)", + ), + "rho_g": P(12.0, 11.5, "Pa/m", "air density times g near sea level"), + # UWB range to a phone at the meter + "uwb_sigma_m": P( + 0.05, + 0.20, + "m", + "arXiv 2303.11220: errors under 20 cm in tested conditions; 5 cm assumed line of sight", + ), + "uwb_span_ft": P(20.0, 20.0, "ft", "operating distance along the wall"), + # Finger tap on the screen + "tap_sigma_mm": P( + 1.5, + 4.0, + "mm", + "finger landing spread, assumed; Apple's HIG minimum target is 44 pt (about 7 mm)", + ), + "screen_px_per_mm": P( + 12.4, + 12.4, + "px/mm", + "iPhone 15 aspect-fill: 1920 px on 852 pt, 5.5 pt/mm, so 2.25 image px per pt", + ), + "snap_px": P( + 0.5, + 2.0, + "px", + "sub-pixel edge localization: Steger reports under 0.1 px on clean edges; 2 px allows for texture", + ), + # The app's wall plane: one raycast normal at the meter + "anchor_yaw_deg": P( + 2.15, + 6.72, + "deg", + "added after the run: median and p90 yaw of a 0.3 m depth-patch plane on ETH3D electro (experiments/edge-geometry), a proxy for one ARKit raycast normal; the first run assumed 1 to 5", + ), + "tap_view_deg": P( + 35.0, + 35.0, + "deg", + "view angle off face-on for an edge 5 ft from the meter tapped from 7 ft out", + ), + "tap_span_ft": P(6.0, 6.0, "ft", "edge distance from the meter"), + # One photo holding both ends of a span + "span_ft": P(6.0, 6.0, "ft", "a window or gas meter 6 ft from the meter"), + "one_photo_standoff_m": P(3.0, 3.0, "m", "step back so both ends fit in a landscape frame"), + "lidar_scale": P( + 0.004, + 0.01, + "fraction", + "iPhone LiDAR range error, about 1 cm at 3 m (assumed from Measure-style apps); 1% conservative", + ), + "vio_scale": P( + 0.01, + 0.03, + "fraction", + "MARViN: 30 of 35 iPhone 14 Pro Max walks within 2% (evals section 6); 3% for a phone without LiDAR, assumed", + ), + "wall_yaw_lidar_deg": P(0.2, 0.5, "deg", "plane fitted to thousands of LiDAR points, assumed"), + "wall_yaw_vio_deg": P(0.5, 2.0, "deg", "plane from feature points or vanishing lines, assumed"), + # Known-size references, as a scale error (fraction, one standard deviation) + "meter_cover_unknown_maker": P( + 0.029, + 0.029, + "fraction", + "covers span 6.29 to 6.95 in across Itron, Aclara, Elster (maker drawings): ±5% uniform", + ), + "meter_cover_known_maker": P( + 0.003, 0.01, "fraction", "one maker's drawing, assumed moulding tolerance" + ), + "brick_course": P( + 0.005, + 0.016, + "fraction", + "3 courses = 8 in (BIA 9A); a mason's story pole holds coursing, 1/8 in per 8 in taken as the conservative end", + ), + "door_slab": P( + 0.006, + 0.013, + "fraction", + "exterior slab 79.5 in, opening 80 in: which edge the photo shows is 0.5 to 1 in (Mastercraft)", + ), + "letter_sheet": P( + 0.003, 0.006, "fraction", "11 in sheet; cut tolerance unknown, assumed 1/32 to 1/16 in" + ), + "id1_card": P( + 0.002, + 0.006, + "fraction", + "ISO 7810 85.60 mm, new cards 85.47 to 85.72 (±0.15%), plus 0.3 to 1 px edges at 0.5 m", + ), + # Acoustic echo to a facing fence + "echo_m": P( + 0.015, 0.10, "m", "BatMapper: 1 to 2 cm at p80 indoors to 3.5 m; 10 cm assumed outdoors" + ), + # Phone as a contact probe + "probe_m": P( + 0.005, + 0.03, + "m", + "tracking error while the camera faces the wall at contact, assumed; device geometry adds about 1 mm", + ), +} + + +@dataclass(frozen=True) +class Candidate: + name: str + kind: str # "length" in inches, "scale" in percent + operating: str + model: str # the formula, in words + uses: tuple[str, ...] + error: Callable[[Callable[[str], np.ndarray], np.random.Generator], np.ndarray] + + +def _p90(x: np.ndarray) -> float: + return float(np.quantile(np.abs(x), 0.9)) + + +def _rad(deg: np.ndarray) -> np.ndarray: + return np.deg2rad(deg) + + +def _dual(v, rng): + z = v("standoff_m") + sigma = z**2 * v("dual_disp_px") / (v("dual_f_px") * v("dual_baseline_m")) + return rng.standard_normal(N) * sigma * IN_PER_M + + +def _focus(v, rng): + z = v("standoff_m") + f = v("focal_mm") * 1e-3 + sigma = z**2 * v("lens_pos_um") * 1e-6 / f**2 + return rng.standard_normal(N) * sigma * IN_PER_M + + +def _baro(v, rng): + return rng.standard_normal(N) * v("baro_pa") / v("rho_g") * IN_PER_M + + +def _uwb(v, rng): + s = v("uwb_span_ft") * FT + d = v("standoff_m") + r = np.sqrt(s**2 + d**2) + return rng.standard_normal(N) * v("uwb_sigma_m") * r / s * IN_PER_M + + +def _tap_raw(v, rng): + px = v("tap_sigma_mm") * v("screen_px_per_mm") + metres_per_px = v("standoff_m") / v("f_px") + return rng.standard_normal(N) * px * metres_per_px * IN_PER_M + + +def _tap_snapped(v, rng): + metres_per_px = v("standoff_m") / v("f_px") + return rng.standard_normal(N) * v("snap_px") * metres_per_px * IN_PER_M + + +def _anchor_plane(v, rng): + s = v("tap_span_ft") * FT + psi = _rad(v("anchor_yaw_deg")) * rng.standard_normal(N) + return s * np.tan(psi) * np.tan(_rad(v("tap_view_deg"))) * IN_PER_M + + +def _one_photo(scale_key: str, yaw_key: str): + def err(v, rng): + s = v("span_ft") * FT + d = v("one_photo_standoff_m") + scale = v(scale_key) * rng.standard_normal(N) + psi = _rad(v(yaw_key)) * rng.standard_normal(N) + # Meter face-on at bearing 0, feature at bearing atan(s/d). A yaw error psi moves the + # feature's bearing relative to the assumed normal: s' = d tan(atan(s/d) + psi) - d tan(psi). + a = np.arctan(s / d) + s_yaw = d * np.tan(a + psi) - d * np.tan(psi) + return ((1 + scale) * s_yaw - s) * IN_PER_M + + return err + + +def _scale(key: str): + def err(v, rng): + return rng.standard_normal(N) * v(key) * 100 + + return err + + +def _echo(v, rng): + return rng.standard_normal(N) * v("echo_m") * IN_PER_M + + +def _probe(v, rng): + return rng.standard_normal(N) * v("probe_m") * IN_PER_M + + +CANDIDATES = [ + Candidate( + "Dual-camera disparity, depth per pixel", + "length", + "2.5 m", + "Z² σd / (f B)", + ("standoff_m", "dual_f_px", "dual_baseline_m", "dual_disp_px"), + _dual, + ), + Candidate( + "Focus distance from lens position", + "length", + "2.5 m", + "Z² σΔ / f²", + ("standoff_m", "focal_mm", "lens_pos_um"), + _focus, + ), + Candidate( + "Barometer, height of a feature", + "length", + "one height", + "σP / (ρ g)", + ("baro_pa", "rho_g"), + _baro, + ), + Candidate( + "UWB range to a phone at the meter", + "length", + "20 ft along the wall", + "σr · r / s", + ("uwb_sigma_m", "uwb_span_ft", "standoff_m"), + _uwb, + ), + Candidate( + "Finger tap, no snap (the app today)", + "length", + "2.5 m", + "σ_tap · px/mm · Z / f", + ("tap_sigma_mm", "screen_px_per_mm", "standoff_m", "f_px"), + _tap_raw, + ), + Candidate( + "Tap snapped to an image edge", + "length", + "2.5 m", + "σ_snap · Z / f", + ("snap_px", "standoff_m", "f_px"), + _tap_snapped, + ), + Candidate( + "The app's wall plane: one normal at the meter", + "length", + "edge 6 ft away, seen 35° off", + "s · tan ψ · tan α", + ("anchor_yaw_deg", "tap_view_deg", "tap_span_ft"), + _anchor_plane, + ), + Candidate( + "One photo holds both ends, LiDAR", + "length", + "6 ft span from 3 m", + "scale · s and yaw through d·tan", + ("span_ft", "one_photo_standoff_m", "lidar_scale", "wall_yaw_lidar_deg"), + _one_photo("lidar_scale", "wall_yaw_lidar_deg"), + ), + Candidate( + "One photo holds both ends, no LiDAR", + "length", + "6 ft span from 3 m", + "scale · s and yaw through d·tan", + ("span_ft", "one_photo_standoff_m", "vio_scale", "wall_yaw_vio_deg"), + _one_photo("vio_scale", "wall_yaw_vio_deg"), + ), + Candidate( + "Meter cover, maker unknown", + "scale", + "at the meter", + "size spread", + ("meter_cover_unknown_maker",), + _scale("meter_cover_unknown_maker"), + ), + Candidate( + "Meter cover, maker read from the nameplate", + "scale", + "at the meter", + "maker's drawing", + ("meter_cover_known_maker",), + _scale("meter_cover_known_maker"), + ), + Candidate( + "Brick coursing", + "scale", + "any brick wall", + "course spacing", + ("brick_course",), + _scale("brick_course"), + ), + Candidate( + "Exterior door height", + "scale", + "any door in view", + "slab vs opening", + ("door_slab",), + _scale("door_slab"), + ), + Candidate( + "US Letter sheet held to the wall", + "scale", + "at the meter", + "sheet size", + ("letter_sheet",), + _scale("letter_sheet"), + ), + Candidate( + "ID-1 card held to the wall", + "scale", + "at the meter, 0.5 m", + "card size and edges", + ("id1_card",), + _scale("id1_card"), + ), + Candidate( + "Acoustic echo to a facing fence", "length", "3 ft gap", "σ range", ("echo_m",), _echo + ), + Candidate( + "Phone as a contact probe", + "length", + "any reachable corner", + "tracking at contact", + ("probe_m",), + _probe, + ), +] + + +def verdict(kind: str, opt: float, cons: float) -> str: + good, bad = (2.0, 4.0) if kind == "length" else (1.0, 3.0) + if cons <= good: + return "worth a device test" + if opt > bad: + return "drop" + return "in between" + + +def run() -> list[dict]: + rows = [] + for i, c in enumerate(CANDIDATES): + out = {} + for mode in ("opt", "cons", "range"): + rng = np.random.default_rng(SEED + i) + + def value(name: str, mode: str = mode, rng: np.random.Generator = rng) -> np.ndarray: + p = PARAMS[name] + if mode == "opt": + return np.full(N, p.opt) + if mode == "cons": + return np.full(N, p.cons) + lo, hi = sorted((p.opt, p.cons)) + return rng.uniform(lo, hi, N) + + out[mode] = _p90(c.error(value, rng)) + rows.append( + { + "name": c.name, + "kind": c.kind, + "operating": c.operating, + "model": c.model, + "uses": list(c.uses), + "p90_opt": out["opt"], + "p90_cons": out["cons"], + "p90_range": out["range"], + "verdict": verdict(c.kind, out["opt"], out["cons"]), + } + ) + return rows + + +def _fmt(x: float) -> str: + return f"{x:.2f}" if x < 10 else f"{x:.0f}" + + +def write(rows: list[dict], out: Path) -> None: + out.mkdir(exist_ok=True) + (out / "budget.json").write_text( + json.dumps({"rows": rows, "params": {k: asdict(v) for k, v in PARAMS.items()}}, indent=2) + + "\n" + ) + lines = [ + "# Sensor budget (generated by `uv run python budget.py`)", + "", + "p90 of |error|: inches for lengths, percent for scales. Optimistic and conservative put every", + "parameter at that end; the range column draws them uniformly between. Verdict rule (fixed before", + "the run): worth a device test if conservative p90 ≤ 2 in or ≤ 1%; drop if optimistic p90 > 4 in or > 3%.", + "", + "| Candidate | Used at | Model | Optimistic | Conservative | Range | Verdict |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + for r in rows: + unit = "in" if r["kind"] == "length" else "%" + lines.append( + f"| {r['name']} | {r['operating']} | {r['model']} | {_fmt(r['p90_opt'])} {unit} | " + f"{_fmt(r['p90_cons'])} {unit} | {_fmt(r['p90_range'])} {unit} | {r['verdict']} |" + ) + lines += [ + "", + "## Parameters", + "", + "| Name | Optimistic | Conservative | Unit | Source |", + "| --- | --- | --- | --- | --- |", + ] + for k, p in PARAMS.items(): + lines.append(f"| `{k}` | {p.opt:g} | {p.cons:g} | {p.unit} | {p.source} |") + (out / "budget.md").write_text("\n".join(lines) + "\n") + + +if __name__ == "__main__": + rows = run() + write(rows, Path(__file__).parent / "results") + for r in rows: + print(f"{r['name']:<48} {_fmt(r['p90_opt']):>6} {_fmt(r['p90_cons']):>6} {r['verdict']}") diff --git a/experiments/sensor-budget/pyproject.toml b/experiments/sensor-budget/pyproject.toml new file mode 100644 index 000000000..e80bb4771 --- /dev/null +++ b/experiments/sensor-budget/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "sensor-budget" +version = "0.1.0" +description = "Physics error budgets for phone signals that no dataset on disk can test" +requires-python = ">=3.12" +dependencies = ["numpy>=2.1"] + +[dependency-groups] +dev = ["pytest>=8.3", "ruff>=0.8"] + +[tool.uv] +package = false + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"] +# Formulas in result tables use Greek letters on purpose. +ignore = ["E501", "RUF001"] + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/experiments/sensor-budget/results/budget.json b/experiments/sensor-budget/results/budget.json new file mode 100644 index 000000000..f14d7abc8 --- /dev/null +++ b/experiments/sensor-budget/results/budget.json @@ -0,0 +1,434 @@ +{ + "rows": [ + { + "name": "Dual-camera disparity, depth per pixel", + "kind": "length", + "operating": "2.5 m", + "model": "Z\u00b2 \u03c3d / (f B)", + "uses": [ + "standoff_m", + "dual_f_px", + "dual_baseline_m", + "dual_disp_px" + ], + "p90_opt": 4.035020745529604, + "p90_cons": 42.03146609926671, + "p90_range": 18.79972245076351, + "verdict": "drop" + }, + { + "name": "Focus distance from lens position", + "kind": "length", + "operating": "2.5 m", + "model": "Z\u00b2 \u03c3\u0394 / f\u00b2", + "uses": [ + "standoff_m", + "focal_mm", + "lens_pos_um" + ], + "p90_opt": 8.483664857900104, + "p90_cons": 37.29522473542235, + "p90_range": 21.560929072268088, + "verdict": "drop" + }, + { + "name": "Barometer, height of a feature", + "kind": "length", + "operating": "one height", + "model": "\u03c3P / (\u03c1 g)", + "uses": [ + "baro_pa", + "rho_g" + ], + "p90_opt": 5.387221754315046, + "p90_cons": 33.728692722668114, + "p90_range": 21.12037323984991, + "verdict": "drop" + }, + { + "name": "UWB range to a phone at the meter", + "kind": "length", + "operating": "20 ft along the wall", + "model": "\u03c3r \u00b7 r / s", + "uses": [ + "uwb_sigma_m", + "uwb_span_ft", + "standoff_m" + ], + "p90_opt": 3.5007244679755622, + "p90_cons": 14.002897871902249, + "p90_range": 9.287109006158909, + "verdict": "in between" + }, + { + "name": "Finger tap, no snap (the app today)", + "kind": "length", + "operating": "2.5 m", + "model": "\u03c3_tap \u00b7 px/mm \u00b7 Z / f", + "uses": [ + "tap_sigma_mm", + "screen_px_per_mm", + "standoff_m", + "f_px" + ], + "p90_opt": 2.078843813449602, + "p90_cons": 5.543583502532272, + "p90_range": 3.9341304795744287, + "verdict": "in between" + }, + { + "name": "Tap snapped to an image edge", + "kind": "length", + "operating": "2.5 m", + "model": "\u03c3_snap \u00b7 Z / f", + "uses": [ + "snap_px", + "standoff_m", + "f_px" + ], + "p90_opt": 0.05600534664353872, + "p90_cons": 0.22402138657415488, + "p90_range": 0.148578569316956, + "verdict": "worth a device test" + }, + { + "name": "The app's wall plane: one normal at the meter", + "kind": "length", + "operating": "edge 6 ft away, seen 35\u00b0 off", + "model": "s \u00b7 tan \u03c8 \u00b7 tan \u03b1", + "uses": [ + "anchor_yaw_deg", + "tap_view_deg", + "tap_span_ft" + ], + "p90_opt": 3.108386125237517, + "p90_cons": 9.824847616643064, + "p90_range": 6.7720751830406085, + "verdict": "in between" + }, + { + "name": "One photo holds both ends, LiDAR", + "kind": "length", + "operating": "6 ft span from 3 m", + "model": "scale \u00b7 s and yaw through d\u00b7tan", + "uses": [ + "span_ft", + "one_photo_standoff_m", + "lidar_scale", + "wall_yaw_lidar_deg" + ], + "p90_opt": 0.536670361631079, + "p90_cons": 1.3421860758694923, + "p90_range": 0.964574551264537, + "verdict": "worth a device test" + }, + { + "name": "One photo holds both ends, no LiDAR", + "kind": "length", + "operating": "6 ft span from 3 m", + "model": "scale \u00b7 s and yaw through d\u00b7tan", + "uses": [ + "span_ft", + "one_photo_standoff_m", + "vio_scale", + "wall_yaw_vio_deg" + ], + "p90_opt": 1.3406195452947756, + "p90_cons": 4.366952280587392, + "p90_range": 2.9766006632291786, + "verdict": "in between" + }, + { + "name": "Meter cover, maker unknown", + "kind": "scale", + "operating": "at the meter", + "model": "size spread", + "uses": [ + "meter_cover_unknown_maker" + ], + "p90_opt": 4.7804990805547, + "p90_cons": 4.7804990805547, + "p90_range": 4.7804990805547, + "verdict": "drop" + }, + { + "name": "Meter cover, maker read from the nameplate", + "kind": "scale", + "operating": "at the meter", + "model": "maker's drawing", + "uses": [ + "meter_cover_known_maker" + ], + "p90_opt": 0.4935524522616822, + "p90_cons": 1.6451748408722737, + "p90_range": 1.1222033876541635, + "verdict": "in between" + }, + { + "name": "Brick coursing", + "kind": "scale", + "operating": "any brick wall", + "model": "course spacing", + "uses": [ + "brick_course" + ], + "p90_opt": 0.8203599227316022, + "p90_cons": 2.625151752741127, + "p90_range": 1.8086253556050726, + "verdict": "in between" + }, + { + "name": "Exterior door height", + "kind": "scale", + "operating": "any door in view", + "model": "slab vs opening", + "uses": [ + "door_slab" + ], + "p90_opt": 0.9856119453078988, + "p90_cons": 2.135492548167114, + "p90_range": 1.59577642556761, + "verdict": "in between" + }, + { + "name": "US Letter sheet held to the wall", + "kind": "scale", + "operating": "at the meter", + "model": "sheet size", + "uses": [ + "letter_sheet" + ], + "p90_opt": 0.4929727582387478, + "p90_cons": 0.9859455164774956, + "p90_range": 0.7515821601013287, + "verdict": "worth a device test" + }, + { + "name": "ID-1 card held to the wall", + "kind": "scale", + "operating": "at the meter, 0.5 m", + "model": "card size and edges", + "uses": [ + "id1_card" + ], + "p90_opt": 0.329565834322981, + "p90_cons": 0.9886975029689428, + "p90_range": 0.6861139567035768, + "verdict": "worth a device test" + }, + { + "name": "Acoustic echo to a facing fence", + "kind": "length", + "operating": "3 ft gap", + "model": "\u03c3 range", + "uses": [ + "echo_m" + ], + "p90_opt": 0.970525949325062, + "p90_cons": 6.4701729955004135, + "p90_range": 4.109319437691939, + "verdict": "in between" + }, + { + "name": "Phone as a contact probe", + "kind": "length", + "operating": "any reachable corner", + "model": "tracking at contact", + "uses": [ + "probe_m" + ], + "p90_opt": 0.3233242127106719, + "p90_cons": 1.9399452762640312, + "p90_range": 1.2436081952724634, + "verdict": "worth a device test" + } + ], + "params": { + "standoff_m": { + "opt": 2.5, + "cons": 2.5, + "unit": "m", + "source": "app guidance: stand 1 to 3 m from the wall (docs/00)" + }, + "f_px": { + "opt": 1450.0, + "cons": 1450.0, + "unit": "px", + "source": "wide camera focal length at 1920 px, typical ARKit capturedImage intrinsics" + }, + "dual_f_px": { + "opt": 500.0, + "cons": 400.0, + "unit": "px", + "source": "depth map about 640 px wide: the 1450 px focal length scaled down (assumed)" + }, + "dual_baseline_m": { + "opt": 0.02, + "cons": 0.012, + "unit": "m", + "source": "wide to ultra-wide spacing, assumed; the idea lab estimated 1.5 to 2 cm; no Apple spec" + }, + "dual_disp_px": { + "opt": 0.1, + "cons": 0.5, + "unit": "px", + "source": "disparity noise, assumed; sub-pixel stereo range" + }, + "focal_mm": { + "opt": 6.9, + "cons": 5.7, + "unit": "mm", + "source": "iPhone main camera focal lengths, 13 to 15 Pro (Apple tech specs)" + }, + "lens_pos_um": { + "opt": 1.0, + "cons": 3.0, + "unit": "um", + "source": "lens position repeatability including heat and gravity sag, assumed; AVCaptureDevice.lensPosition is not calibrated to distance" + }, + "baro_pa": { + "opt": 1.0, + "cons": 6.0, + "unit": "Pa", + "source": "relative pressure noise plus wind gusts; 1 Pa is a quiet sensor, 6 Pa a 3 m/s gust's dynamic pressure (assumed)" + }, + "rho_g": { + "opt": 12.0, + "cons": 11.5, + "unit": "Pa/m", + "source": "air density times g near sea level" + }, + "uwb_sigma_m": { + "opt": 0.05, + "cons": 0.2, + "unit": "m", + "source": "arXiv 2303.11220: errors under 20 cm in tested conditions; 5 cm assumed line of sight" + }, + "uwb_span_ft": { + "opt": 20.0, + "cons": 20.0, + "unit": "ft", + "source": "operating distance along the wall" + }, + "tap_sigma_mm": { + "opt": 1.5, + "cons": 4.0, + "unit": "mm", + "source": "finger landing spread, assumed; Apple's HIG minimum target is 44 pt (about 7 mm)" + }, + "screen_px_per_mm": { + "opt": 12.4, + "cons": 12.4, + "unit": "px/mm", + "source": "iPhone 15 aspect-fill: 1920 px on 852 pt, 5.5 pt/mm, so 2.25 image px per pt" + }, + "snap_px": { + "opt": 0.5, + "cons": 2.0, + "unit": "px", + "source": "sub-pixel edge localization: Steger reports under 0.1 px on clean edges; 2 px allows for texture" + }, + "anchor_yaw_deg": { + "opt": 2.15, + "cons": 6.72, + "unit": "deg", + "source": "added after the run: median and p90 yaw of a 0.3 m depth-patch plane on ETH3D electro (experiments/edge-geometry), a proxy for one ARKit raycast normal; the first run assumed 1 to 5" + }, + "tap_view_deg": { + "opt": 35.0, + "cons": 35.0, + "unit": "deg", + "source": "view angle off face-on for an edge 5 ft from the meter tapped from 7 ft out" + }, + "tap_span_ft": { + "opt": 6.0, + "cons": 6.0, + "unit": "ft", + "source": "edge distance from the meter" + }, + "span_ft": { + "opt": 6.0, + "cons": 6.0, + "unit": "ft", + "source": "a window or gas meter 6 ft from the meter" + }, + "one_photo_standoff_m": { + "opt": 3.0, + "cons": 3.0, + "unit": "m", + "source": "step back so both ends fit in a landscape frame" + }, + "lidar_scale": { + "opt": 0.004, + "cons": 0.01, + "unit": "fraction", + "source": "iPhone LiDAR range error, about 1 cm at 3 m (assumed from Measure-style apps); 1% conservative" + }, + "vio_scale": { + "opt": 0.01, + "cons": 0.03, + "unit": "fraction", + "source": "MARViN: 30 of 35 iPhone 14 Pro Max walks within 2% (evals section 6); 3% for a phone without LiDAR, assumed" + }, + "wall_yaw_lidar_deg": { + "opt": 0.2, + "cons": 0.5, + "unit": "deg", + "source": "plane fitted to thousands of LiDAR points, assumed" + }, + "wall_yaw_vio_deg": { + "opt": 0.5, + "cons": 2.0, + "unit": "deg", + "source": "plane from feature points or vanishing lines, assumed" + }, + "meter_cover_unknown_maker": { + "opt": 0.029, + "cons": 0.029, + "unit": "fraction", + "source": "covers span 6.29 to 6.95 in across Itron, Aclara, Elster (maker drawings): \u00b15% uniform" + }, + "meter_cover_known_maker": { + "opt": 0.003, + "cons": 0.01, + "unit": "fraction", + "source": "one maker's drawing, assumed moulding tolerance" + }, + "brick_course": { + "opt": 0.005, + "cons": 0.016, + "unit": "fraction", + "source": "3 courses = 8 in (BIA 9A); a mason's story pole holds coursing, 1/8 in per 8 in taken as the conservative end" + }, + "door_slab": { + "opt": 0.006, + "cons": 0.013, + "unit": "fraction", + "source": "exterior slab 79.5 in, opening 80 in: which edge the photo shows is 0.5 to 1 in (Mastercraft)" + }, + "letter_sheet": { + "opt": 0.003, + "cons": 0.006, + "unit": "fraction", + "source": "11 in sheet; cut tolerance unknown, assumed 1/32 to 1/16 in" + }, + "id1_card": { + "opt": 0.002, + "cons": 0.006, + "unit": "fraction", + "source": "ISO 7810 85.60 mm, new cards 85.47 to 85.72 (\u00b10.15%), plus 0.3 to 1 px edges at 0.5 m" + }, + "echo_m": { + "opt": 0.015, + "cons": 0.1, + "unit": "m", + "source": "BatMapper: 1 to 2 cm at p80 indoors to 3.5 m; 10 cm assumed outdoors" + }, + "probe_m": { + "opt": 0.005, + "cons": 0.03, + "unit": "m", + "source": "tracking error while the camera faces the wall at contact, assumed; device geometry adds about 1 mm" + } + } +} diff --git a/experiments/sensor-budget/results/budget.md b/experiments/sensor-budget/results/budget.md new file mode 100644 index 000000000..a13085a98 --- /dev/null +++ b/experiments/sensor-budget/results/budget.md @@ -0,0 +1,61 @@ +# Sensor budget (generated by `uv run python budget.py`) + +p90 of |error|: inches for lengths, percent for scales. Optimistic and conservative put every +parameter at that end; the range column draws them uniformly between. Verdict rule (fixed before +the run): worth a device test if conservative p90 ≤ 2 in or ≤ 1%; drop if optimistic p90 > 4 in or > 3%. + +| Candidate | Used at | Model | Optimistic | Conservative | Range | Verdict | +| --- | --- | --- | --- | --- | --- | --- | +| Dual-camera disparity, depth per pixel | 2.5 m | Z² σd / (f B) | 4.04 in | 42 in | 19 in | drop | +| Focus distance from lens position | 2.5 m | Z² σΔ / f² | 8.48 in | 37 in | 22 in | drop | +| Barometer, height of a feature | one height | σP / (ρ g) | 5.39 in | 34 in | 21 in | drop | +| UWB range to a phone at the meter | 20 ft along the wall | σr · r / s | 3.50 in | 14 in | 9.29 in | in between | +| Finger tap, no snap (the app today) | 2.5 m | σ_tap · px/mm · Z / f | 2.08 in | 5.54 in | 3.93 in | in between | +| Tap snapped to an image edge | 2.5 m | σ_snap · Z / f | 0.06 in | 0.22 in | 0.15 in | worth a device test | +| The app's wall plane: one normal at the meter | edge 6 ft away, seen 35° off | s · tan ψ · tan α | 3.11 in | 9.82 in | 6.77 in | in between | +| One photo holds both ends, LiDAR | 6 ft span from 3 m | scale · s and yaw through d·tan | 0.54 in | 1.34 in | 0.96 in | worth a device test | +| One photo holds both ends, no LiDAR | 6 ft span from 3 m | scale · s and yaw through d·tan | 1.34 in | 4.37 in | 2.98 in | in between | +| Meter cover, maker unknown | at the meter | size spread | 4.78 % | 4.78 % | 4.78 % | drop | +| Meter cover, maker read from the nameplate | at the meter | maker's drawing | 0.49 % | 1.65 % | 1.12 % | in between | +| Brick coursing | any brick wall | course spacing | 0.82 % | 2.63 % | 1.81 % | in between | +| Exterior door height | any door in view | slab vs opening | 0.99 % | 2.14 % | 1.60 % | in between | +| US Letter sheet held to the wall | at the meter | sheet size | 0.49 % | 0.99 % | 0.75 % | worth a device test | +| ID-1 card held to the wall | at the meter, 0.5 m | card size and edges | 0.33 % | 0.99 % | 0.69 % | worth a device test | +| Acoustic echo to a facing fence | 3 ft gap | σ range | 0.97 in | 6.47 in | 4.11 in | in between | +| Phone as a contact probe | any reachable corner | tracking at contact | 0.32 in | 1.94 in | 1.24 in | worth a device test | + +## Parameters + +| Name | Optimistic | Conservative | Unit | Source | +| --- | --- | --- | --- | --- | +| `standoff_m` | 2.5 | 2.5 | m | app guidance: stand 1 to 3 m from the wall (docs/00) | +| `f_px` | 1450 | 1450 | px | wide camera focal length at 1920 px, typical ARKit capturedImage intrinsics | +| `dual_f_px` | 500 | 400 | px | depth map about 640 px wide: the 1450 px focal length scaled down (assumed) | +| `dual_baseline_m` | 0.02 | 0.012 | m | wide to ultra-wide spacing, assumed; the idea lab estimated 1.5 to 2 cm; no Apple spec | +| `dual_disp_px` | 0.1 | 0.5 | px | disparity noise, assumed; sub-pixel stereo range | +| `focal_mm` | 6.9 | 5.7 | mm | iPhone main camera focal lengths, 13 to 15 Pro (Apple tech specs) | +| `lens_pos_um` | 1 | 3 | um | lens position repeatability including heat and gravity sag, assumed; AVCaptureDevice.lensPosition is not calibrated to distance | +| `baro_pa` | 1 | 6 | Pa | relative pressure noise plus wind gusts; 1 Pa is a quiet sensor, 6 Pa a 3 m/s gust's dynamic pressure (assumed) | +| `rho_g` | 12 | 11.5 | Pa/m | air density times g near sea level | +| `uwb_sigma_m` | 0.05 | 0.2 | m | arXiv 2303.11220: errors under 20 cm in tested conditions; 5 cm assumed line of sight | +| `uwb_span_ft` | 20 | 20 | ft | operating distance along the wall | +| `tap_sigma_mm` | 1.5 | 4 | mm | finger landing spread, assumed; Apple's HIG minimum target is 44 pt (about 7 mm) | +| `screen_px_per_mm` | 12.4 | 12.4 | px/mm | iPhone 15 aspect-fill: 1920 px on 852 pt, 5.5 pt/mm, so 2.25 image px per pt | +| `snap_px` | 0.5 | 2 | px | sub-pixel edge localization: Steger reports under 0.1 px on clean edges; 2 px allows for texture | +| `anchor_yaw_deg` | 2.15 | 6.72 | deg | added after the run: median and p90 yaw of a 0.3 m depth-patch plane on ETH3D electro (experiments/edge-geometry), a proxy for one ARKit raycast normal; the first run assumed 1 to 5 | +| `tap_view_deg` | 35 | 35 | deg | view angle off face-on for an edge 5 ft from the meter tapped from 7 ft out | +| `tap_span_ft` | 6 | 6 | ft | edge distance from the meter | +| `span_ft` | 6 | 6 | ft | a window or gas meter 6 ft from the meter | +| `one_photo_standoff_m` | 3 | 3 | m | step back so both ends fit in a landscape frame | +| `lidar_scale` | 0.004 | 0.01 | fraction | iPhone LiDAR range error, about 1 cm at 3 m (assumed from Measure-style apps); 1% conservative | +| `vio_scale` | 0.01 | 0.03 | fraction | MARViN: 30 of 35 iPhone 14 Pro Max walks within 2% (evals section 6); 3% for a phone without LiDAR, assumed | +| `wall_yaw_lidar_deg` | 0.2 | 0.5 | deg | plane fitted to thousands of LiDAR points, assumed | +| `wall_yaw_vio_deg` | 0.5 | 2 | deg | plane from feature points or vanishing lines, assumed | +| `meter_cover_unknown_maker` | 0.029 | 0.029 | fraction | covers span 6.29 to 6.95 in across Itron, Aclara, Elster (maker drawings): ±5% uniform | +| `meter_cover_known_maker` | 0.003 | 0.01 | fraction | one maker's drawing, assumed moulding tolerance | +| `brick_course` | 0.005 | 0.016 | fraction | 3 courses = 8 in (BIA 9A); a mason's story pole holds coursing, 1/8 in per 8 in taken as the conservative end | +| `door_slab` | 0.006 | 0.013 | fraction | exterior slab 79.5 in, opening 80 in: which edge the photo shows is 0.5 to 1 in (Mastercraft) | +| `letter_sheet` | 0.003 | 0.006 | fraction | 11 in sheet; cut tolerance unknown, assumed 1/32 to 1/16 in | +| `id1_card` | 0.002 | 0.006 | fraction | ISO 7810 85.60 mm, new cards 85.47 to 85.72 (±0.15%), plus 0.3 to 1 px edges at 0.5 m | +| `echo_m` | 0.015 | 0.1 | m | BatMapper: 1 to 2 cm at p80 indoors to 3.5 m; 10 cm assumed outdoors | +| `probe_m` | 0.005 | 0.03 | m | tracking error while the camera faces the wall at contact, assumed; device geometry adds about 1 mm | diff --git a/experiments/sensor-budget/tests/test_budget.py b/experiments/sensor-budget/tests/test_budget.py new file mode 100644 index 000000000..bee86e020 --- /dev/null +++ b/experiments/sensor-budget/tests/test_budget.py @@ -0,0 +1,76 @@ +"""Strict checks of the closed-form models: each has one correct answer.""" + +import numpy as np +import pytest + +import budget as b + + +def fixed(values): + return lambda name: np.full(b.N, values[name]) + + +class ZeroRng: + """A generator whose normals are all 1, so a model returns its 1-sigma value.""" + + def standard_normal(self, n): + return np.ones(n) + + +def test_dual_camera_depth_noise_formula(): + v = fixed( + {"standoff_m": 2.0, "dual_disp_px": 0.25, "dual_f_px": 500.0, "dual_baseline_m": 0.02} + ) + got = b._dual(v, ZeroRng())[0] + assert got == pytest.approx(4.0 * 0.25 / (500 * 0.02) * b.IN_PER_M) + + +def test_focus_depth_noise_formula(): + v = fixed({"standoff_m": 2.0, "focal_mm": 6.0, "lens_pos_um": 1.0}) + got = b._focus(v, ZeroRng())[0] + assert got == pytest.approx(4.0 * 1e-6 / 0.006**2 * b.IN_PER_M) + + +def test_uwb_error_grows_as_the_span_shrinks(): + v = fixed({"uwb_span_ft": 10.0, "standoff_m": 3.048, "uwb_sigma_m": 0.1}) + got = b._uwb(v, ZeroRng())[0] + assert got == pytest.approx(0.1 * np.sqrt(2) * b.IN_PER_M) + + +def test_anchor_plane_error_is_s_tan_psi_tan_alpha(): + v = fixed({"tap_span_ft": 6.0, "anchor_yaw_deg": 5.0, "tap_view_deg": 45.0}) + got = b._anchor_plane(v, ZeroRng())[0] + assert got == pytest.approx(6 * b.FT * np.tan(np.deg2rad(5.0)) * b.IN_PER_M) + + +def test_one_photo_is_exact_without_error(): + v = fixed({"span_ft": 6.0, "one_photo_standoff_m": 3.0, "s": 0.0, "y": 0.0}) + + class Zero: + def standard_normal(self, n): + return np.zeros(n) + + assert np.all(np.abs(b._one_photo("s", "y")(v, Zero())) < 1e-9) + + +def test_one_photo_yaw_term_matches_first_order(): + s, d, psi_deg = 6 * b.FT, 3.0, 0.5 + v = fixed({"span_ft": 6.0, "one_photo_standoff_m": d, "s": 0.0, "y": psi_deg}) + got = b._one_photo("s", "y")(v, ZeroRng())[0] + first_order = np.deg2rad(psi_deg) * s**2 / d * b.IN_PER_M + assert got == pytest.approx(first_order, rel=0.05) + + +@pytest.mark.parametrize( + ("kind", "opt", "cons", "want"), + [ + ("length", 1.0, 2.0, "worth a device test"), + ("length", 1.0, 2.01, "in between"), + ("length", 4.01, 9.0, "drop"), + ("length", 4.0, 9.0, "in between"), + ("scale", 0.5, 1.0, "worth a device test"), + ("scale", 3.01, 5.0, "drop"), + ], +) +def test_verdict_boundaries(kind, opt, cons, want): + assert b.verdict(kind, opt, cons) == want diff --git a/experiments/sensor-budget/uv.lock b/experiments/sensor-budget/uv.lock new file mode 100644 index 000000000..4c7b304bf --- /dev/null +++ b/experiments/sensor-budget/uv.lock @@ -0,0 +1,185 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "sensor-budget" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy", specifier = ">=2.1" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +]