diff --git a/.github/workflows/recon.yml b/.github/workflows/recon.yml new file mode 100644 index 000000000..e0faa0075 --- /dev/null +++ b/.github/workflows/recon.yml @@ -0,0 +1,40 @@ +name: Recon + +on: + push: + branches: [main] + paths: + - "recon/**" + - ".github/workflows/recon.yml" + pull_request: + paths: + - "recon/**" + - ".github/workflows/recon.yml" + +permissions: + contents: read + +concurrency: + group: recon-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + recon-checks: + name: Recon checks + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: recon + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Reads tool.uv.required-version from recon/pyproject.toml to pick the uv version. + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + working-directory: recon + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q diff --git a/docs/04-prior-art-and-codes.md b/docs/04-prior-art-and-codes.md index 6a9a49232..87ceb104d 100644 --- a/docs/04-prior-art-and-codes.md +++ b/docs/04-prior-art-and-codes.md @@ -69,9 +69,9 @@ None of the products above runs the whole loop outdoors, from metric AR capture | Status | Models | | --- | --- | | Noncommercial | MapAnything's default checkpoint (use `facebook/map-anything-apache` instead), Depth Anything 3 Giant and Nested (Metric Large, Base and Small are Apache-2.0), VGGT-Ω, UniDepthV2, Mapillary-Vistas Mask2Former weights, Molmo 2 | -| Restricted or unclear | Depth Pro (Apple personal-use grant), SAM 3 (custom license, gated weights), HY-World 2.0 (custom community license), Metric3D v2 (its files disagree), MoGe-2 weights (UNVERIFIED, though the code is MIT) | +| Restricted or unclear | Depth Pro (Apple personal-use grant), SAM 3 (custom license, gated weights), HY-World 2.0 (custom community license), Metric3D v2 (its files disagree) | | Copyleft | YOLO-World, YOLOE and Ultralytics (GPL or AGPL), pymeshlab (GPL-3) | -| Permissive | SAM 2, Grounding DINO, OWLv2, Qwen3-VL and zxing-cpp (Apache-2.0), Florence-2, supervision, Open3D and Stray Scanner (MIT), shapely (BSD-3) | +| Permissive | SAM 2, Grounding DINO, OWLv2, Qwen3-VL and zxing-cpp (Apache-2.0), Florence-2, supervision, Open3D and Stray Scanner (MIT), MoGe-2 code and the `Ruicheng/moge-2-vitl-normal` weights at revision cb0e8bb, which the worker pins (MIT, from Hugging Face's metadata for that revision, checked 2026-09-26), shapely (BSD-3) | For recognition boxes, Gemini 2.5 Pro scored 13.3 zero-shot mAP on RF100-VL, against 1.5 for GPT-5. diff --git a/recon/.gitignore b/recon/.gitignore new file mode 100644 index 000000000..a230a78ae --- /dev/null +++ b/recon/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/recon/HANDOFF.md b/recon/HANDOFF.md new file mode 100644 index 000000000..16b84e388 --- /dev/null +++ b/recon/HANDOFF.md @@ -0,0 +1,147 @@ +# Handoff: photos into a 3D model, and what we measured on the way + +For the server team building photos, then a 3D model, then evaluation against the placement criteria. Every dataset number below came from real public data; the pose-prior settings add simulated pose error, and say so where they appear. Each points to the file that produced it: +- `evals/…` and `results/…` are under `experiments/evals/` on branch `t3/evals` (PR #12, SamGu-NRX/house-scanning-master); +- `recon/…` is this folder on `t3/recon`. + +## 1. What was measured + +**AR tracking drift.** Position error after walking, compared with the server's allowance of 0.16 ft per ft (`evals/drift.py`, `evals/modern_arkit.py`; METHODS.md sections 1 and 6). + +| Phone, data | p90 at 3 / 10 / 20 / 30 ft | Allowance | +| --- | --- | --- | +| iPhone 6s (2018), ADVIO | 18.6 / 55.7 / 94.6 / 132.6 in | 5.8 / 19.2 / 38.4 / 57.6 in | +| iPhone 14 Pro Max, MARViN, trusted walks | – / 8.6 / 13.4 / 18.5 in | same | + +- **ADVIO** is 2 to 3 times over the allowance, though its reference's own heading and position noise inflate that. ARKit read distances 5% to 17% short against a GPS-referenced truth. +- **ADVIO's reference** is mis-scaled about 20% in walks 20 and 21: its fix points come from a map. That explains a 345 m against 474 m path-length gap. +- **MARViN:** ARKit's scale is within 2% of the dataset's reference on 30 of 35 walks, and within a site the ARKit/reference ratio spreads 0.3% to 0.8% between walks (1 SD). That is disagreement between ARKit and the reference, not a bound on ARKit's error: the reference's local scale error could produce all of it. The reference's metric scale cannot be checked independently to 2%, and the phone has LiDAR. + +**Learned depth against a laser scan.** ETH3D electro, walls within 6 m, 1–3 m spans; `evals/recon.py`, `evals/pose_priors.py`, METHODS.md sections 2 and 3. +- **A model's own scale is 4% to 12% off.** MoGe-2 reads long and Depth Anything 3 metric reads short: median 6 to 8 in, p90 about 20 in. +- **One photo plus one taped distance:** median 1.6 in. The p90 is 9.3 in on surfaces, 13.6 in on vertical surfaces and 25.3 in at depth edges. +- **Averaging photos placed with true poses makes it worse.** On one set of seed groups, with one tape, p90 rises from 5.2 in with 1 photo to 11.9 in with 8, because each photo carries its own scale error. +- **Best recipe: MoGe-2 rescaled per photo by SIFT features triangulated with the AR poses** (method b), then fused over 8 photos. Wall p90, with 95% intervals over seed groups and 5 pose-noise draws: + - exact poses: 2.8 in [2.0, 4.6]; + - an assumed modern phone: 5.0 in [3.7, 6.9]. This is a synthetic setting, not a measured phone: ETH3D's true poses degraded by a simulated 2% scale error plus 1 cm and 0.1° of noise per photo (`evals/ar_poses.py`); + - the 2018 phone's measured pose error: 10.8 in [8.6, 13.1]. + + These assume perfect feature matching; with real matching, walls reach 6.3 in even with exact poses. Edges stay at 8 in or worse everywhere. +- **MapAnything is not a stable baseline.** Given poses, it fixed its scale on electro but not facade, and left 10% to 20% of pairs unpredicted. Its metric scale depends on input resolution: +17% on electro and −14% on facade at 392 px, and 10% to 33% short at 518 px in fp32. +- **Which photos to keep: the close ones.** Keeping only photos with the wall within 6 m cuts a single photo's p90 from 38.7 to 14.8 in on 3 to 10 m spans (METHODS.md section 4). +- **Evaluation floor:** depth rendered from the scan itself is off 0.2 to 0.8 in median. The visibility tolerance moves medians about 0.5 in and p90s up to 20% (`results/eth3d_visibility_sensitivity.md`). + +**Coverage and occlusion** (METHODS.md sections 7 and 7b; `evals/coverage.py`, `evals/coverage_options.py`). +- **The app's coverage map claims unseen wall.** It claims 1.1 ft of a 19.3 ft wall that no photo saw, behind a scaffold's footings. The pre-registered bar was 0.5 ft. The cause is `CoverageMap.swift` lines 241–248 at beede15 (t3/ios-mvf): it checks range, angle and image bounds, not occlusion. +- **Rules about how the phone moved don't fix it.** Views up to 2 m apart or 45° apart change nothing. The patch goes only at 5 m or 60° apart, which gives up 6.0 or 8.5 ft of wall that was seen. +- **A depth test does fix it.** Against true depth it claims nothing unseen but misses 3.3 ft: pilasters 0.36 m proud read as occluders. LiDAR's roughly 5 m reach costs 8.9 ft on these photos. + +**This worker, today** (`recon/results/eth3d_electro.md`, `make accept`). It fuses depth into a truncated signed distance field (TSDF) and tests coverage against it. On ETH3D electro: +- **Laser-as-LiDAR:** wall pair p90 1.8 in; 0.07 ft false-observed of 22.5 ft claimed (bar 0.5 ft: passes). +- **MoGe-2 photos-only:** wall pair p90 1.5 in; 0.00 ft false-observed of 19.9 ft claimed (passes). +- A wall sample counts as seen only where the depth puts a surface at it, and the acceptance truth credits no pixel without a laser return (`recon/results/eth3d_electro.md`). +- **Each laser point is scored from the nearest photo that sees it**, by the laser's own visibility. Before, the nearest photo scored it regardless of whether it was in frame or behind the scaffold. On the laser path, whose wall did not move, that fix alone took the pair p90 from 2.1 to 1.8 in. +- **Both paths fit the same wall.** The laser path fits a 31.9 ft stretch of it, and MoGe-2 a 20.5 ft stretch inside that one (`wall_ends_xz_m` in `recon/results/eth3d_electro.json`). Which MoGe-2 stretch wins is fragile. RANSAC splits MoGe-2's noisier surface into collinear pieces, and fitting the ground as a plane (tilted 0.4°) instead of taking its median height moved the pick from a 15.7 ft piece to the 20.4 ft one. The MoGe-2 row's drop from 2.8 to 1.5 in therefore mixes the scoring fix with a different stretch. +- **End to end:** at c6a8be3 the ADVIO replay and the app's Simulator bundle both reached a server result, `manual_review`. Neither saw much wall head-on, because the ADVIO camera looks along its path. They were not rerun after this round's geometry changes. + +## 2. What to reuse + +On `t3/evals`, from `experiments/evals`; data goes to `~/house-scanning-data/evals/`: + +| Command | Does | +| --- | --- | +| `make recon-data` | ETH3D download (about 2.4 GB), checked by sha256, prepared | +| `make recon-models` | MoGe-2 and Depth Anything 3 on ETH3D (`models/run.py`) | +| `make pose-priors` | AR-like pose noise, MapAnything with poses, method (b) (`evals/triangulate.py`) | +| `make drift`, `make replay` | ADVIO drift; the ADVIO replay session in Measure Lab format | +| `make modern-arkit` | MARViN scale and position error | +| `make coverage`, `make coverage-options` | Coverage-visibility harness against the laser scan; the app's code at a pinned commit | +| `make field SESSION=… TRUTH=… MAP=…` | A field session against a tape survey, scored by PR #4's harness; templates and sheet in `field/` | +| `make field-dryrun` | That command end to end on a synthetic session | + +In `recon/` on this branch: + +| Command | Does | +| --- | --- | +| `make run BUNDLE=… OUT=…` | The worker on an app bundle or Measure Lab session: `model.glb`, `geometry.json`, `coverage.json`, `scene.json`, server result | +| `make accept` | The worker against ETH3D electro's laser scan | +| `make test` | Unit tests and lint, as CI runs them | + +**Memory, on a shared 24 GB Mac with a 4 GB per-process limit:** +- MoGe-2 peaked at 4.07 GB on 24 MP photos. The worker feeds it at most 640 px, which peaks at 2.95 GB (`recon/models/moge_depth.py`). +- MapAnything fits only 2 and 4 views at 392 px with bf16 weights (4.05 GB). +- The worker itself peaks at 2.9 GB. `make accept` with MoGe-2's depth already cached peaked at 1.2 GB. + +## 3. Pitfalls we hit + +- **Sensor images are sideways.** Keyframe JPEGs are unrotated landscape sensor images, and the intrinsics match them. Turn the image upright for a depth model and turn the intrinsics with it (`recon/depth.py` `rotated_intrinsics`), then turn the depth back. +- **Pixel conventions differ by half a pixel.** + - ARKit, Measure Lab and COLMAP put pixel (0, 0) at the image's top-left corner; OpenCV puts it at the first pixel's centre. + - MoGe-2 assumes a centred principal point: the true one moved medians at most 0.1 in, but pass it anyway. +- **ARKit camera axes are +y up and looking along −z.** OpenCV's are +y down and looking along +z. Converting is `T @ diag(1, −1, −1, 1)`. +- **Scene poses are rounded.** `scene.json` rounds poses to 4 decimals, so their rotations are not orthonormal; snap them (`recon/capture.py`). +- **"Up" from the cameras was 10° off.** The mean camera up axis was 10° off on ETH3D facade; level on the ground plane under the cameras instead. +- **The ADVIO reference scale is wrong in places.** Use the GPS-referenced ARKit scales (`results/advio_drift.json`). +- **MARViN poses need converting.** Its quaternions are (w, x, y, z) in Unity's left-handed axes; handedness was chosen per walk by the steadiest heading offset. +- **One noise draw is not a result.** A single draw showed walls at 3.9 in; five draws put it at 5.0 [3.7, 6.9]. Bootstrap over seed groups and draws. +- **Caches go stale.** MoGe-2 depth is cached per bundle under `~/house-scanning-data/recon/work/`; delete it when the model or resolution changes. +- **Marching cubes finds false surfaces.** A TSDF (truncated signed distance field) leaves unobserved voxels at +1, which puts a false surface one truncation distance behind every real one. Drop vertices on half-observed edges (`recon/fusion.py`, tested). +- **Scikit-image flips normals.** Its marching-cubes normals point into the surface whichever `gradient_direction` is set; flip them. +- **Tapped planes aren't flat walls.** Pilasters and bins read as occluders against a tapped plane. Sample the wall's real face where it is flat and full height (`face_offsets`). +- **Measure Lab numbers things itself.** It assigns P1, M1 and R1 in order and lets no one name them. PR #4's example map uses lowercase ids and won't match a real session. + +## 4. Recommendations for the 3D path + +- **When the phone has LiDAR, use its depth and the ARKit mesh.** + - Keep confidence of medium or better. + - Its roughly 5 m reach means capture must stand within about 4 m of the wall. + - The worker reads per-keyframe `depth: {file, confidenceFile, w, h}`: Float32 meters and UInt8 confidence, row-major, in the keyframe's unrotated orientation, as Measure Lab writes them. S3 is adding the app's fields; match them at `recon/capture.py`. + - A keyframe saved without depth gets no depth rather than MoGe-2's: it adds nothing to the model, and what only it saw stays unobserved. Mixing in MoGe-2 would make the whole wall carry MoGe-2's error bar (`recon/depth.py`, `depth_maps`). +- **Without LiDAR:** MoGe-2 at 640 px or less, rescaled per photo by triangulation with the AR poses, fused into a TSDF. + - Expect wall p90 of about 5 in over 1–3 m spans at a simulated 2% pose scale error (not a measured phone): more at the 2018 phone's measured error, about 2 to 3 in with exact poses. + - Never trust a model's own scale. +- **Coverage must be a depth test against the reconstruction.** "Unseen is not clear": the server passes a check only over area reported observed. +- **What to test first:** + 1. A real field session with a tape survey (`make field`), for the phone's own pose error. + 2. A LiDAR bundle from the app once S3 exports depth. + 3. Why MoGe-2's wall splits into collinear stretches, and which one the worker should pick (a 0.4° ground change moved it). + 4. The ground band. It is still untested, because ETH3D's photographers stood too far away for a 65° view of the ground. + 5. Stepped walls, which the 6 cm line fit handles poorly. + +## 5. Interfaces + +- **Scan bundle (S3's `shareableScan`, t3/ios-mvf):** a zip of `scene.json` and the keyframe JPEGs it names. + - The scene frame is ARKit's gravity world in feet, with the ground at the wall at y = 0. + - Keyframe `pose` is column-major camera-to-world with the translation in feet; `intrinsics` are in pixels of the unrotated image. + - Contract: `ios/HouseScan/Contract/ScanContract.swift` and `ios/HouseScan/Runtime/ScanEngine+Export.swift`. + - The Simulator bundle's wall comes from the replay autopilot: it lies 4 ft from any reconstructed surface. +- **Scene contract:** `server/schemas/scene.schema.json` and "What settles each check" in `server/README.md` on t3/server. + - `coverage.observed` entries are `wall`, `ground` (with `out_ft`), `facing` and `overhead`. + - `facing` and `overheads` carry measured gaps and clearances. A facing gap is measured from the reconstructed wall's front, so in front of a pilaster it starts at the pilaster's face. + - The reconstructed wall carries `source`: `mesh` when LiDAR built it, `plane` from photos alone. It carries no `plus_minus_ft`, so the server applies that source's default (0.5 or 0.75 ft) plus 0.16 ft per foot walked from the meter. An explicit bound would switch that drift off. `facing` and `overheads` entries carry the depth source's error, 0.5 ft for LiDAR and 0.56 ft for photos. +- **Known limit for the server team: ground is sampled only 10 ft out** (`GROUND_MAX_M`, `recon/coverage.py`). The server asks for ground out to D + r + e, which for the public pool rule is 1.83 + 10 + e, at least 12.3 ft. A pool check therefore stays UNSURE even when the photos show ground past 10 ft. That errs toward UNSURE, never toward a pass. The reach should come from the server's request, not from a constant in the worker; the server team owns that request. +- **Servers:** + - public demo https://house-scanning-server.vercel.app, public rules only: `POST /v1/placements`, `POST /v1/placements/site-plan.svg`, bare `scene.json` under 4.5 MB; + - a private, key-protected deployment with the real rules: ask Sam for access and its auth scheme. + +## 6. Known defects + +Reviewers confirmed these in the worker at this branch's head, and none is fixed. Each can let the server settle a check on evidence the worker doesn't have. No replacement below has been built or validated. + +- **One view can count as two for free space.** `recon/coverage.py:183-187, 205-243`: `_slab` treats a voxel as observed when its fused weight is above zero, and any single frame gives it weight. Repro: a capture in which one frame alone sees the space in front of a wall cell reports a facing clearance there. Wall and ground cells need two positions 0.25 m apart for the same claim. The server at 903d86f can settle a check on that clearance. A fix needs visibility kept per frame for free space, with the same two-position test as the wall and ground. +- **The overhead strip is narrower than a battery.** `recon/coverage.py:42, 231-242`: overhead space is sampled only 0.10 to 0.56 m out from the wall front. Repro: an eave 0.05 m out from the wall, or 0.7 m out over a 3 ft deep battery, is not found, and the cell reports clearance above it. A fix needs the footprint of the battery the chosen rule places, passed from the server's rules or request. +- **Old facing and overhead entries survive a small meter move.** `recon/scene.py:50-57, 87-89`: when the meter moves 5 cm or less, the bundle's existing `facing` and `overheads` entries are kept and the new ones appended, although the reconstructed wall's line and orientation replaced the phone's. Repro: a bundle with a phone-measured facing gap, and a reconstructed wall rotated a few degrees about the meter, sends both the old gap and the new one. A fix needs the target wall's earlier entries dropped whenever its line is replaced, as they already are when the meter moves more than 5 cm. +- **The photos-only clearance error is not calibrated.** `recon/pipeline.py:29-35`: facing gaps and overhead clearances from photos carry ±0.56 ft. That comes from the 95% interval of the evals' surface pair-error p90 over 1 to 3 m spans, not from any measurement of where the fused volume puts a boundary. The evals put surface p90 at 5.64 in [4.63, 6.66] and edges at 9.65 in [4.93, 15.01]. Repro: a photos-only overhead clearance measured at 7.10 ft passes the 6.5 ft headroom rule, since 7.10 − 0.56 = 6.54 > 6.5. A fix needs the clearance estimator's own measured uncertainty, or a gate that returns the check unresolved until that exists. + +- **Partly occupied space counts as clear.** `recon/coverage.py:223, 235` (threshold at 179): a step out from the wall, or up over the footprint, counts as blocked only when at least 20% of its sampled voxels are occupied, on two steps in a row. That 20% has no source. Otherwise `free_space` reports the space clear through voxels the reconstruction marks occupied. Repro: at 5 cm voxels, a 5 cm rail crossing the facing band (0.3 to 1.8 m high) fills 1 of its 31 height rows, about 3%, and the facing clearance runs straight through it. An obstruction thinner than two steps passes the same way. A fix needs any occupied voxel in the band to end the clear distance or leave the check unresolved. +- **LiDAR without a confidence map is fused unfiltered.** `recon/depth.py:90-92` (accepted at `recon/capture.py:182-189`): a keyframe whose `depth` names a `file` but no `confidenceFile` is accepted, and `lidar()` then keeps every positive sample, including ARKit's low-confidence ones that section 4 says to drop. Repro: a packet keyframe with `"depth": {"file": "k.f32", "w": 256, "h": 192}` has all its samples fused, with no confidence filter. A fix needs a missing confidence map either refused, or treated as that frame having no usable LiDAR. + +Two more are filed as issues for the server team: coverage samples ground on a flat plane at the meter's height rather than the fitted slope (#95), and coverage cells can reach past the fitted wall's ends (#96). + +## 7. Data + +Nothing is in git: the datasets are non-commercial and measure accuracy only. Local copies live under `~/house-scanning-data/`: +- `evals/{advio,eth3d,marvin}` for the datasets, `evals/hf-cache` for checkpoints; +- `replays/` for the ADVIO replay, `reports/sim/` for the app's Simulator bundles, `recon/` for worker outputs. + +Download your own. URLs, sizes and sha256 are in `experiments/evals/METHODS.md`, "Datasets" (t3/evals). ARKitScenes, for a LiDAR test, ships depth, confidence and poses together only in its `raw` subset, about 0.3 to 0.6 GB per scene. Its pose convention is not documented. diff --git a/recon/Makefile b/recon/Makefile new file mode 100644 index 000000000..2541a5ad9 --- /dev/null +++ b/recon/Makefile @@ -0,0 +1,19 @@ +# The reconstruction worker. `make test` is what CI runs; the others need the MoGe-2 environment +# (models/, installed on first use) and local data under ~/house-scanning-data. + +.PHONY: test run accept + +test: + uv sync --locked + uv run ruff check . + uv run ruff format --check . + uv run pytest -q + +# BUNDLE: the app's scan bundle (zip or folder with scene.json) or a Measure Lab session. +# ARGS: extra flags, for example --no-server, --depth moge, --move-meter. +run: + uv run python -m recon "$(BUNDLE)" --out "$(OUT)" $(ARGS) + +# ETH3D electro against its laser scan; needs `make recon-data` from experiments/evals (t3/evals). +accept: + uv run python -m recon.eth3d electro diff --git a/recon/README.md b/recon/README.md new file mode 100644 index 000000000..dc7e52a44 --- /dev/null +++ b/recon/README.md @@ -0,0 +1,35 @@ +# recon: photos into a 3D model + +The reconstruction worker. It takes the app's scan bundle (keyframe photos, their AR poses and intrinsics, `scene.json`) and returns: + +1. `model.glb`: a colored mesh of the scanned area, in the meter's frame (origin at the meter's foot, y up, meters), viewable in macOS Quick Look; +2. `geometry.json`: the fitted wall and ground for the rules engine, in feet, in the scene frame and in the meter's wall frame; +3. `coverage.json`: which wall, ground, facing and overhead cells were actually seen, by a depth test against the reconstruction; +4. `scene.json`: the scene updated with that geometry and coverage in the server's contract, posted to the placement server, with its answer in `result.json` and `site-plan.svg`. + +```sh +cd recon +uv sync +make run BUNDLE=path/to/scan.zip OUT=path/to/out # or a Measure Lab session, zip or folder +make test # unit tests and lint, as CI runs them +make accept # ETH3D acceptance (prepared data needed) +``` + +## Acceptance criteria, fixed before the first acceptance run + +- **Coverage on ETH3D electro's wall**: wall the worker calls observed where at least 10 cm of the band was seen by no photo, against the laser scan's visibility, must be at most **0.5 ft**. This is the bar and metric of experiments/evals README section 7 (t3/evals), where the app's own coverage map claimed 1.1 ft. +- **Wall geometry on ETH3D electro**: reported in inches against the laser scan, with no pass bar set in advance. It covers the fitted wall plane's offset and angle, and the reconstruction's point at each pixel where a laser wall point is seen, as point-pair errors over 1 to 3 m spans, the evals' metric. +- **End to end**: the ADVIO replay and the app's Simulator bundle each reach a placement-server result. + +## Results + +`make accept` on ETH3D electro ([results/eth3d_electro.md](results/eth3d_electro.md)): + +| Depth | Wall pair error, 1–3 m, median / p90 | Wall claimed | False-observed | +| --- | --- | --- | --- | +| Laser scan standing in for LiDAR | 0.5 / 1.8 in | 22.5 of 31.9 ft | 0.07 ft (passes) | +| MoGe-2 rescaled with the poses | 0.7 / 1.5 in | 19.9 of 20.5 ft | 0.00 ft (passes) | + +Both paths fit the same wall; MoGe-2's stretch lies inside the laser path's. Each laser point is scored from the nearest photo in which the laser shows it unoccluded. At c6a8be3 the ADVIO replay and the app's Simulator bundle each reached a server result (`manual_review`: the ADVIO camera looks along its path, so little wall is seen head-on); they were not rerun after this round's geometry changes. The Simulator bundle needs `ARGS=--move-meter`, because its replayed wall has no surface behind it. + +The handoff to the server team, with everything measured and reusable, is [HANDOFF.md](HANDOFF.md). diff --git a/recon/models/.gitignore b/recon/models/.gitignore new file mode 100644 index 000000000..21d0b898f --- /dev/null +++ b/recon/models/.gitignore @@ -0,0 +1 @@ +.venv/ diff --git a/recon/models/moge_depth.py b/recon/models/moge_depth.py new file mode 100644 index 000000000..0c049b79b --- /dev/null +++ b/recon/models/moge_depth.py @@ -0,0 +1,69 @@ +"""MoGe-2 metric depth for a list of upright photos, one process, one model load. + +Input: a JSON manifest, a list of {"image", "fx", "out"}: an upright RGB image, its focal length in +pixels (MoGe takes only the horizontal field of view) and the .npz to write. Output per image: +`depth` (float32 meters, z along the camera axis, NaN where MoGe's mask is off), at the image's +own size. Images should be at most about 640 px on the long side: MoGe-2 peaked at 4.07 GB on +24-megapixel photos and 2.97 GB at 640 x 480 on this Mac, and the worker must stay under 4 GB. + +The checkpoint is pinned by revision; the caches live under $HOUSE_SCANNING_DATA/evals, shared +with the evals, so a machine that ran them does not download it again. +""" + +from __future__ import annotations + +import json +import math +import os +import sys +from pathlib import Path + +DATA = Path(os.environ.get("HOUSE_SCANNING_DATA", Path.home() / "house-scanning-data")) +os.environ.setdefault("HF_HOME", str(DATA / "evals" / "hf-cache")) +os.environ.setdefault("TORCH_HOME", str(DATA / "evals" / "torch-cache")) + +REPO = "Ruicheng/moge-2-vitl-normal" +REVISION = "cb0e8bbd6b1e243589717c78e750b1ba4c093acf" +RESOLUTION_LEVEL = 9 +MPS_CAP_GB = 3.6 + + +def main(manifest: Path) -> None: + import cv2 + import numpy as np + import torch + from moge.model.v2 import MoGeModel + + device = ( + "cuda" + if torch.cuda.is_available() + else "mps" + if torch.backends.mps.is_available() + else "cpu" + ) + if device == "mps": + # A cap turns runaway growth into an error instead of swapping the shared machine. + torch.mps.set_per_process_memory_fraction( + MPS_CAP_GB * 1e9 / torch.mps.recommended_max_memory() + ) + model = MoGeModel.from_pretrained(REPO, revision=REVISION).to(device).eval() + for item in json.loads(manifest.read_text()): + bgr = cv2.imread(item["image"], cv2.IMREAD_COLOR) + if bgr is None: + raise FileNotFoundError(item["image"]) + w = bgr.shape[1] + rgb = torch.from_numpy(bgr[..., ::-1].copy()).to(device).permute(2, 0, 1).float() / 255 + fov_x = math.degrees(2 * math.atan(w / (2 * item["fx"]))) + with torch.no_grad(): + out = model.infer(rgb, resolution_level=RESOLUTION_LEVEL, fov_x=fov_x, use_fp16=True) + depth = out["depth"].float().cpu().numpy() + depth[~out["mask"].cpu().numpy().astype(bool)] = np.nan + np.savez(item["out"], depth=depth.astype(np.float32)) + print(f"{Path(item['image']).name}: median {np.nanmedian(depth):.2f} m", file=sys.stderr) + del out, rgb + if device == "mps": + torch.mps.empty_cache() + + +if __name__ == "__main__": + main(Path(sys.argv[1])) diff --git a/recon/models/pyproject.toml b/recon/models/pyproject.toml new file mode 100644 index 000000000..23703cf49 --- /dev/null +++ b/recon/models/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "house-scanning-recon-depth" +version = "0.1.0" +description = "MoGe-2 depth for the reconstruction worker's photos-only path, run as its own process" +requires-python = ">=3.12,<3.13" +dependencies = [ + "huggingface-hub>=0.34", + "moge", + "numpy>=2.1", + "opencv-python-headless>=4.10,<5", + "pillow>=10", + "torch>=2.7", + "torchvision>=0.22", +] + +[tool.uv] +package = false +override-dependencies = ["opencv-python-headless>=4.10,<5", "opencv-python; sys_platform == 'never'"] + +# Pinned to the last commit before the MoGe-3 rewrite, whose packaging supports only Linux and +# Windows. Its packaging lists demo and training extras as hard requirements; the inference path +# needs only the list below. +[tool.uv.sources] +moge = { git = "https://github.com/microsoft/MoGe", rev = "07444410f1e33f402353b99d6ccd26bd31e469e8" } + +[[tool.uv.dependency-metadata]] +name = "moge" +version = "2.0.0" +requires-dist = [ + "huggingface-hub", + "numpy", + "opencv-python-headless", + "pillow", + "scipy", + "torch>=2.0.0", + "torchvision", + "utils3d @ git+https://github.com/EasternJournalist/utils3d.git@3fab839f0be9931dac7c8488eb0e1600c236e183", +] diff --git a/recon/models/uv.lock b/recon/models/uv.lock new file mode 100644 index 000000000..e34f0baf3 --- /dev/null +++ b/recon/models/uv.lock @@ -0,0 +1,848 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[manifest] +overrides = [ + { name = "opencv-python", marker = "sys_platform == 'never'" }, + { name = "opencv-python-headless", specifier = ">=4.10,<5" }, +] + +[[manifest.dependency-metadata]] +name = "moge" +version = "2.0.0" +requires-dist = ["huggingface-hub", "numpy", "opencv-python-headless", "pillow", "scipy", "torch>=2.0.0", "torchvision", "utils3d @ git+https://github.com/EasternJournalist/utils3d.git@3fab839f0be9931dac7c8488eb0e1600c236e183"] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[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 = "contourpy" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/11/1293429c1c3a3e19b551275e65efddd122a905bbe7e368816a59f3ef2a41/cuda_bindings-13.4.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfbd3f7d4ac04dd41dc49121b9e408c8283992f47124c2290ecb79bbbadcca8e", size = 6488226, upload-time = "2026-09-23T02:22:00.578Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c3/efb6bbb7307bf5c83dc4acca650280b210c67ed1a1a60f898a90e7c82e38/cuda_bindings-13.4.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f72bcfcdf3be23e1da3c792f68f508586f48d037bca8b10f552c4cca5971f2", size = 7172185, upload-time = "2026-09-23T02:22:03.114Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/59/239c7259e669c46ddbcac0aa60e3a0ef00bfeaaa687f905b24dd6a7a10fe/cuda_pathfinder-1.8.2-py3-none-any.whl", hash = "sha256:4e65059febdb4d19d5cbc4798677e19db2b582f2f702f457b609e571690d357e", size = 62551, upload-time = "2026-09-17T22:19:09.396Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +curand = [ + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "filelock" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/d7/37691dc5063438a448b646f6f2442b4beebf16cc0e18d8cdfa7aeec60b8c/filelock-4.0.4.tar.gz", hash = "sha256:90999ed63a26ccf86b93b959ab10cf1017f422d816be454ed54cbed263e71ab5", size = 244639, upload-time = "2026-09-26T15:19:12.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/ac/8b9c6dc2aa7e9cf5582e20a337006c3428c3d752c2255dbaca2c78a8be45/filelock-4.0.4-py3-none-any.whl", hash = "sha256:0df72be195ca7892216d16f2edce8d9b93a571f02402972020a8cff84c594c7b", size = 108629, upload-time = "2026-09-26T15:19:10.603Z" }, +] + +[[package]] +name = "fonttools" +version = "4.66.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/24/86f9930b930b97fc82266083320f3c34643ef67261c32651e893db525aca/fonttools-4.66.0.tar.gz", hash = "sha256:ef0610dfe7bb5bf574d9bdad6f597403ebc9807d124ac6f148d7604b2609be98", size = 3692007, upload-time = "2026-09-23T17:59:14.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c1/25821e3bddd0644df229cfdc2e8b4101d28e4da15ffbe6e6b563ea6f3acd/fonttools-4.66.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:67ea5af3ca60e1e5c9b2841b1f6dba5ef16aab581bf463abb68515447154ab61", size = 3096788, upload-time = "2026-09-23T17:57:08.302Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3d/d16b4cb63710abd91f1aaa482f2848d8b0c402502de5b8a72ba9d2613cb0/fonttools-4.66.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbee639a23c4e067aedfbcf84d2e466a71a1a1156ad04d6d09023791ed167495", size = 2586926, upload-time = "2026-09-23T17:57:10.678Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f0/dffa4fa83ec6780464e8484fa2f30ccc252d480137c8e4d3fc6ac510b3eb/fonttools-4.66.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35684b562df7154d7a0ebfa512db9199c7fb0a93b5abfb59dac9d022dbee7aaa", size = 5415821, upload-time = "2026-09-23T17:57:13.174Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/3c862bdd297a272a37e2ba1149da01446fd3f387ee80855699bb939bf5b2/fonttools-4.66.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aea0cc5609a5f2a2500f91bd6e1989fdd22da0f225be00dc1c4cec775838a7d0", size = 5396689, upload-time = "2026-09-23T17:57:15.987Z" }, + { url = "https://files.pythonhosted.org/packages/93/d7/f4b7c81a7a6b316b7226d11f8e8659f023babf1e0957f8e7ddcc4e155a82/fonttools-4.66.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8af85dfc2ad564f95b3e650024578bb2268bc4d827d418aab9650bbde4b6926a", size = 5353218, upload-time = "2026-09-23T17:57:18.803Z" }, + { url = "https://files.pythonhosted.org/packages/70/1e/f12bc535f3da359a4d29d5e7442ab2d7d67579d1516b9c7e1a38890168bd/fonttools-4.66.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b278e2abe596872b3c9fe611f956ded85588537974e48e2adbe2d6ee159e099", size = 5516965, upload-time = "2026-09-23T17:57:21.077Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/a1df66888c8b5f63c14e089400f2353da0c1ff9d09683cc0bf85afea348e/fonttools-4.66.0-cp312-cp312-win32.whl", hash = "sha256:ee7b9f6c835ea1a9beb5a35c8eb0c62b6a3290f8105d3d0c29a0a18c90f9b8ba", size = 2434425, upload-time = "2026-09-23T17:57:23.463Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f2/fd490f1ddf2e430289c13059982771e6b02a2bf37504401d2a5c53307892/fonttools-4.66.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d120ea0f5260b04e9b5ac0d9239efde4c23d990347d5d5889cdd37221726fec", size = 2486335, upload-time = "2026-09-23T17:57:25.561Z" }, + { url = "https://files.pythonhosted.org/packages/83/65/826290863c9df6041f2e36a5ae5d604cd8247fffc8ec7b45581fd2473e4d/fonttools-4.66.0-py3-none-any.whl", hash = "sha256:bc7b7ddc1a1f46c363354304e9a8dd93722e4a6a24f785015650898dacf40df9", size = 1201583, upload-time = "2026-09-23T17:59:12.104Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe", size = 333545, upload-time = "2026-09-18T17:50:42.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f", size = 221738, upload-time = "2026-09-18T17:50:41.341Z" }, +] + +[[package]] +name = "glcontext" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/80/8238a0e6e972292061176141c1028b5e670aa8c94cf4c2f819bd730d314e/glcontext-3.0.0.tar.gz", hash = "sha256:57168edcd38df2fc0d70c318edf6f7e59091fba1cd3dadb289d0aa50449211ef", size = 16422, upload-time = "2024-08-10T20:01:20.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/61/d8f77d44bbf477b235ecbff8d421a5511d4b4f6dc676dacd84d012348516/glcontext-3.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:848f870a2bc72a29de7ab6756b9e8f2e6ce052e17873ebc6b3f25129b6e0d58a", size = 9360, upload-time = "2024-08-10T20:00:20.42Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/680a97d974cfe7af798542918b65bd8e65a5f8f7647edfd9fdb91a95df6c/glcontext-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b3b12a66f57379566dd4d36899ac265abdbe040f3fc3293f50cd6678a1dcc9b", size = 9733, upload-time = "2024-08-10T20:00:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/74/2c/be188c4eb63b4d0cc74c644a1519b5e3a37488da3cbda724570cd5fed8d3/glcontext-3.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:449eaefd89c0519900715b8363ead59ac4aa32457722ca521ce01297441edb34", size = 50409, upload-time = "2024-08-10T20:00:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/32/ba/9ccb80650e5bd61e739f16f33aec3bb290a80f314631be8f7dc0a2b22b5f/glcontext-3.0.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04921720740438ceea8fb8a38b5665963520c7c8f27bef03df8aeb3ea3cfbfb6", size = 51440, upload-time = "2024-08-10T20:00:23.987Z" }, + { url = "https://files.pythonhosted.org/packages/c0/99/f6c9a0e614809ba5b83bd8403c475b788bd574d694bd5bc6b6ae2e2cefdf/glcontext-3.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:25538bdb106f673638d70e8a16a0c037a92a24c4cf40a05f0d3fa14b483d6194", size = 44820, upload-time = "2024-08-10T20:00:25.436Z" }, + { url = "https://files.pythonhosted.org/packages/a2/29/fdbb94e4c9374390639b741774384f7413efcd7634cc9c4baacc2cf00a1f/glcontext-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d11f7701b900a5a34c994e1d91c547be1cc469b73f881471460fd905f69f9e4c", size = 47055, upload-time = "2024-08-10T20:00:26.723Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fe/25b5348fe5e856697dacad34fc07e80f48eecfb38bd09806679fe0e62769/glcontext-3.0.0-cp312-cp312-win32.whl", hash = "sha256:5d2b567eaf34adb016aadce81fd2f1d4c8e4a39e3d6f2a395ce528e2a350dd3f", size = 12219, upload-time = "2024-08-10T20:00:27.76Z" }, + { url = "https://files.pythonhosted.org/packages/17/d3/6619693ddad97011ca1c9aaeb82216ab2bfd54757be752b12f4e9a2fc489/glcontext-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e80bb37ba727bd20c192f2754aea40c437a7665005c1001c10752f91913964e9", size = 12971, upload-time = "2024-08-10T20:00:29.575Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + +[[package]] +name = "house-scanning-recon-depth" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "huggingface-hub" }, + { name = "moge" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "torch" }, + { name = "torchvision" }, +] + +[package.metadata] +requires-dist = [ + { name = "huggingface-hub", specifier = ">=0.34" }, + { name = "moge", git = "https://github.com/microsoft/MoGe?rev=07444410f1e33f402353b99d6ccd26bd31e469e8" }, + { name = "numpy", specifier = ">=2.1" }, + { name = "opencv-python-headless", specifier = ">=4.10,<5" }, + { name = "pillow", specifier = ">=10" }, + { name = "torch", specifier = ">=2.7" }, + { name = "torchvision", specifier = ">=0.22" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx2" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/47/44e258e8f710d418bcf7522c966cd70c4b780fb2b65021f7c5f4f5461677/huggingface_hub-2.0.0.tar.gz", hash = "sha256:375e5ad35cb3505efbf19c0cdd7e3fbf1e2304b12cd0e6b7fc8d75c6f5484619", size = 1024306, upload-time = "2026-09-24T12:01:23.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/f9/d752d5756ce0405ba29405b65517b287d2baf9f77a1cf5329266f3d577f0/huggingface_hub-2.0.0-py3-none-any.whl", hash = "sha256:b3eecb60540c17e8ef94c187104f4a5c0c451afcb9510ba8e17739bedcc2d6dd", size = 828498, upload-time = "2026-09-24T12:01:21.358Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, +] + +[[package]] +name = "moderngl" +version = "5.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "glcontext" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/52/540e2f8c45060bb2709f56eb5a44ae828dfcc97ccecb342c1a7deb467889/moderngl-5.12.0.tar.gz", hash = "sha256:52936a98ccb2f2e1d6e3cb18528b2919f6831e7e3f924e788b5873badce5129b", size = 193232, upload-time = "2024-10-17T12:36:28.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/66/31161e81bc85ca3cdbb9d94f703f21575e4ae9a2919e9d1af98fc7fdb1ba/moderngl-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2750547707c1ec3790dfbeb9c90fb808672ff13f61cac392c706ba09fda10db0", size = 112101, upload-time = "2024-10-17T12:37:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/84/b2/7229a89a40d33a95119a7c64c7ee36a6a6e376c57c39fb577ea513602f37/moderngl-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c2a5fe06c7021183d9274df798f25516409c8d55898c324dae8a0b2de10144", size = 109377, upload-time = "2024-10-17T12:37:03.843Z" }, + { url = "https://files.pythonhosted.org/packages/d7/96/bcb5141eae24474d80b8157b0c3055d25fa75f9804d4abb4a514695bbba9/moderngl-5.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6c4972f3ddd10a3de6c30311da2c25bc493d023796e16c5d4e0f8bd6d5770be", size = 296394, upload-time = "2024-10-17T12:37:04.967Z" }, + { url = "https://files.pythonhosted.org/packages/b8/79/a9998ddf6757f4f15888b0a106d80a64a8c8991a8ce5c14047830704b9e6/moderngl-5.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d497ec6a3f6afa9ebd0be816d9bfe2fe20fec2105acfb88d956619c3ed8eb4", size = 270548, upload-time = "2024-10-17T12:37:07.57Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/313dc301db936b231035b961e004f1914c2954bdcdf4985e24bff15e7ed5/moderngl-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2f3d240e9bc5d83257378bae59f8f35638b89d22bb003cf674b88fd7932161ce", size = 1345817, upload-time = "2024-10-17T12:37:09.105Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6f/7b3587e7e3ae633b8c85038f035dfeb348ebf805de4beb01241c59c6b97c/moderngl-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6fa667d560d842e778e2a5968305fb78f9781616a11b1b93acd2562f97262ccf", size = 1274968, upload-time = "2024-10-17T12:37:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/0f3b3cd1be7b0a93f33dc613f76a42021d1393b4949c5b6a1ca2a01c6772/moderngl-5.12.0-cp312-cp312-win32.whl", hash = "sha256:0a02fddd54dccee1ca6060bfed75a2e6a17dd3ee06920fac418506d8a8233849", size = 101221, upload-time = "2024-10-17T12:37:12.642Z" }, + { url = "https://files.pythonhosted.org/packages/56/85/35498b1821cf31c731b1882168db8924207ff3c06d8f0da53e1cc373a89d/moderngl-5.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8698a59ad03539a2982125b7998efc1c107ba31d5d03437b6fcd72cb2c226922", size = 108525, upload-time = "2024-10-17T12:37:13.816Z" }, +] + +[[package]] +name = "moge" +version = "2.0.0" +source = { git = "https://github.com/microsoft/MoGe?rev=07444410f1e33f402353b99d6ccd26bd31e469e8#07444410f1e33f402353b99d6ccd26bd31e469e8" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "utils3d" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl", hash = "sha256:e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0", size = 2142205, upload-time = "2026-09-21T16:45:14.609Z" }, +] + +[[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" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.24.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/30/7c257e3d5cb4fecb147b93895c66e29c93f8e76d74b45bb418ff0587c4ec/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f", size = 650976863, upload-time = "2026-07-02T16:23:39.248Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ba/791cffd048fe5b044e620df55267e3e95c0e6e07d50b41e377c03dfc910f/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:71f181cd810e90f9b6023b01186fe82d13d65f0ec098581ee201d39fad769e4b", size = 553099438, upload-time = "2026-07-02T16:27:42.58Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.30.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/21/a73174c6157101bdf1ffc22b517f76ff0082613989dd9bc8f43e8034caac/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:ca786ffa5a647c75d4d1f5cc72a6c4f537947e2ba8823d7c8aaf768e7a7b9f77", size = 215983881, upload-time = "2026-06-09T03:23:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/3f/34/c500f90c7ae641b8e0f98965b36b8a7ac79cc8b296e8d251fe3eb592ee54/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:cefa7fdb9710efd0f39c5f1be1d61ff6fc9a996c451265bd7fbdcf9455ed4b50", size = 215965170, upload-time = "2026-06-09T03:23:39.73Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.4.92" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6b/eef7a9e32872b8f41e145bf10cddc9af26e153c338852811fe9a9baddf9e/nvidia_nvjitlink-13.4.92-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e0391f24ed94ec879b84e3da4d4ec320c879aff681f2c7a638462f7199284323", size = 42452378, upload-time = "2026-09-16T20:45:29.042Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a8/1cbd4014898af8b419e69b0d7dbc63da2121ee92d92b47d59f4fe9075349/nvidia_nvjitlink-13.4.92-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:25f74fad0d654271c921ac4dca614bd6258bc21791242fc7b2289dad7ae9c099", size = 40420120, upload-time = "2026-09-16T20:45:19.163Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.14.0.94" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/8a/51ff1c6287be0b6cd3070cf095875da4ff7d6eda38507e6ada5608786a78/opencv_python_headless-4.14.0.94.tar.gz", hash = "sha256:4afa2ea1214453648be88259f035712454faa9039b686de7753569ba8eec1577", size = 96405664, upload-time = "2026-07-28T19:23:48.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/56/12f9b05628cf4ab6aad54479b9124f7c1f2f92b6a4e47c071beb63d926a2/opencv_python_headless-4.14.0.94-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:bc7db37dc234f7bb3190a158fd9dd750357246fc7d8adb23698843ef721a993b", size = 46491231, upload-time = "2026-07-29T03:52:11.902Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/2f9c42466f62aa27e97aaf6c9d914008895ecde28013ba4beed7f541f593/opencv_python_headless-4.14.0.94-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1777f43c9fa064f54b916ad70d944b4fde0a644a17e49f04a966bb24a4b5f1e1", size = 33084176, upload-time = "2026-07-28T20:05:59.902Z" }, + { url = "https://files.pythonhosted.org/packages/7d/91/83c51e3fe000b5dfce4f3be5c5d320e07ebab5c1742abc4e45674503f90f/opencv_python_headless-4.14.0.94-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:29714d7716dbfddf9fec20ffb878765e94ccaecd7d3ebd6750877420296e2dc5", size = 35405458, upload-time = "2026-07-28T19:19:29.997Z" }, + { url = "https://files.pythonhosted.org/packages/de/06/14c8c5d331bc72738683a9b227966aceaa4c6d08752f33d64578aebcb6f8/opencv_python_headless-4.14.0.94-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5e02669eac0ba67b2a22d7245af1e8ee1a2ef1185ee526a063d8f0555224bd52", size = 57534165, upload-time = "2026-07-28T19:19:52.742Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/0b98bd582582253f61c4506f9b465cf5a8aa7fa4f7237dd99e1b5151a159/opencv_python_headless-4.14.0.94-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:97c6e818c6f71c0cfa214e12293b1d0266d679c38f4e086de08357c8ac6ece0d", size = 38104309, upload-time = "2026-07-28T19:20:08.818Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/7f538891722a4f06921419d55bac6f41729258d54442861edb2de0dbdf5e/opencv_python_headless-4.14.0.94-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:211e581f5a4670acbbe08fff36a35e9946039d2eea28b80394632d036d1be527", size = 61960165, upload-time = "2026-07-28T19:20:33.074Z" }, + { url = "https://files.pythonhosted.org/packages/e9/91/ded03abad74d7264fa6bc7c923d7cade6f94a9023eeb372cfad54c2d0334/opencv_python_headless-4.14.0.94-cp37-abi3-win32.whl", hash = "sha256:f70296aa7ac9d7ade0d925c43fdc006c83e20a23f30e2f40f1b82c74a7a54460", size = 33030271, upload-time = "2026-07-28T18:32:39.447Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8d/db8673846ee53cbb5de4c2b4decc11cf733e203eb7d5146297869f69bd48/opencv_python_headless-4.14.0.94-cp37-abi3-win_amd64.whl", hash = "sha256:cbed65415b8f6a9541c705afe3e64795840524d0ff3bc58f507826284a1dc64b", size = 41028030, upload-time = "2026-07-28T18:32:36.724Z" }, +] + +[[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 = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[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" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "torch" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/76/bb4770f56cf6d8971671dbcbb7493e5a6a15ad2825f4e359b02c27c38297/torch-2.14.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c1f844f1c750e87df4b68bc3afbc0e2b0c7ef19d7b8f666e48bdcf6a0c4f0056", size = 127303200, upload-time = "2026-09-02T13:43:20.311Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/ec44bacf2c8886b85ba4ca2285e8b09f2dff5d9c99e6a031326954082cb5/torch-2.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ada340e62591d06a2bcc2d68170f20f45f0b0665d372dc510a8ed7eb3b1d609a", size = 454010251, upload-time = "2026-09-02T13:44:24.927Z" }, + { url = "https://files.pythonhosted.org/packages/15/71/49399acd41f750a906c686bd23c08a2001ccb8dd25f2971003c2ed89c1dd/torch-2.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fecffb58f51fd643d213acd68da21cc3fc19bea05a3bc64b4ee55128f47a4963", size = 554620488, upload-time = "2026-09-02T13:44:49.442Z" }, + { url = "https://files.pythonhosted.org/packages/be/16/9489b137112040f9911d7527e452854f21cc4e499ce0da79864e6a7451a7/torch-2.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:cad84f41bbdf3dcf333ce394aeeaf25237c4d94fd6b659ba5eb813c829978823", size = 124114011, upload-time = "2026-09-02T13:43:55.034Z" }, +] + +[[package]] +name = "torchvision" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/d2/9daad500db2ab880eca0f0a569afd73e1afdd49f22c94560c85546dd87c2/torchvision-0.29.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:994687b818cac0e6d34cb407a41458e2c3262cb7db927465841c9522ba7ebb92", size = 1813740, upload-time = "2026-09-02T13:47:03.933Z" }, + { url = "https://files.pythonhosted.org/packages/41/14/702cd035fab1b8dfe944d06827cc4628c3b18e5d7ddaa676bf72c9be1c2c/torchvision-0.29.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b7a736eaa6b2e22476c95ceb62986d195f3e600cdd7d196d7329aa2dfc951994", size = 7586112, upload-time = "2026-09-02T13:47:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/3cf1992414fd712af4865ae5f8f5758d9003c1ed82624908d54f7afb4342/torchvision-0.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0e631a2f8b24732672d35224d2574ff89a78d56bef5364ef6094a625fa5f1771", size = 7430773, upload-time = "2026-09-02T13:47:07.178Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a8/59152b945c09840f582c40e38aa4128777625af7f2883ce2d77133f96a46/torchvision-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:0e3b99c62f7f095153887330c2c60c9ebc2da84dfbe08eea564a41ec361629cf", size = 1376791, upload-time = "2026-09-02T13:47:11.283Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, +] + +[[package]] +name = "triton" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/07/0f8cd8e8db0472334253efdaaab3d0819fea27aa99bf0e7f1aeea4ceb5ae/triton-3.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9c404c69ed4a39e8ec632eaf6b9fe058a060bf98979c177f6ef666f06bb8d50", size = 226474486, upload-time = "2026-08-28T16:08:18.29Z" }, + { url = "https://files.pythonhosted.org/packages/c1/09/b7012e5bfae67640f268aa584caa80fe1674f6b0da949046b679972c33e3/triton-3.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91ffa46d095b252248297292dd22bcbacd53a125a0c2eefbbbf74925a320bc3", size = 247972921, upload-time = "2026-08-28T15:55:53.157Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "utils3d" +version = "1.3" +source = { git = "https://github.com/EasternJournalist/utils3d.git?rev=3fab839f0be9931dac7c8488eb0e1600c236e183#3fab839f0be9931dac7c8488eb0e1600c236e183" } +dependencies = [ + { name = "matplotlib" }, + { name = "moderngl" }, + { name = "numpy" }, + { name = "scipy" }, +] diff --git a/recon/pyproject.toml b/recon/pyproject.toml new file mode 100644 index 000000000..a5ca0af00 --- /dev/null +++ b/recon/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "house-scanning-recon" +version = "0.1.0" +description = "Reconstruction worker: a scan bundle's photos and poses into a 3D model, wall and ground geometry, occlusion-aware coverage and an updated scene.json" +requires-python = ">=3.12,<3.13" +dependencies = [ + "numpy>=2.1", + "opencv-python-headless>=4.10,<5", + "scipy>=1.14", + "scikit-image>=0.24", +] + +[dependency-groups] +dev = ["pytest>=8.3", "ruff>=0.8"] + +[tool.uv] +required-version = ">=0.11.15,<0.12" +package = false + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "RUF", "SIM"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/recon/recon/__init__.py b/recon/recon/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/recon/recon/__main__.py b/recon/recon/__main__.py new file mode 100644 index 000000000..4e6c52b90 --- /dev/null +++ b/recon/recon/__main__.py @@ -0,0 +1,42 @@ +"""python -m recon BUNDLE --out OUT [--depth auto|lidar|moge] [--server URL | --no-server]""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from recon.pipeline import run +from recon.server import DEFAULT_URL + +DATA = Path(os.environ.get("HOUSE_SCANNING_DATA", Path.home() / "house-scanning-data")) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "bundle", + type=Path, + help="app scan bundle (zip or folder with scene.json) or Measure Lab session", + ) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument( + "--work", type=Path, default=DATA / "recon" / "work", help="unpacked bundles and depth maps" + ) + ap.add_argument("--depth", choices=["auto", "lidar", "moge"], default="auto") + ap.add_argument("--server", default=os.environ.get("HOUSESCAN_SERVER", DEFAULT_URL)) + ap.add_argument("--no-server", action="store_true") + ap.add_argument( + "--move-meter", + action="store_true", + help="when the phone's wall or meter has no reconstructed wall behind it, move the meter " + "onto the most-seen wall instead of refusing (drops the phone's marks)", + ) + args = ap.parse_args() + server = None if args.no_server else args.server + done = run(args.bundle, args.out, args.work, args.depth, server, args.move_meter) + print(done["report"]) + + +if __name__ == "__main__": + main() diff --git a/recon/recon/capture.py b/recon/recon/capture.py new file mode 100644 index 000000000..3c573ff87 --- /dev/null +++ b/recon/recon/capture.py @@ -0,0 +1,268 @@ +"""A capture: posed photos, optional LiDAR depth, and whatever the phone marked, in one form. + +Every input is adapted here and nowhere else. The internal form: +- world: ARKit's `.gravity` frame, meters, +y up. For the app's scan bundle it is the scene frame + in meters, so the ground at the wall is y = 0; for a Measure Lab session it is ARKit's own world. +- camera: ARKit axes (+x right, +y up in the unrotated sensor image, looking along -z); + `cam_to_world` is 4x4. +- intrinsics: fx, fy, cx, cy in pixels of that image, with (0, 0) the image's top-left corner. + +Readers: +- the app's scan bundle (`shareableScan` on t3/ios-mvf): `scene.json` (server/schemas/ + scene.schema.json, feet) and the keyframe JPEGs it names, as a folder or zip; +- a Measure Lab session, format 2 (experiments/measure-lab/README.md on t3/measure-lab), as a + folder or zip. The ADVIO replay is one. + +LiDAR depth, when a keyframe carries it, uses Measure Lab's layout: `depth: {file, confidenceFile, +w, h}`, Float32 meters and UInt8 confidence (0 low, 1 medium, 2 high), row-major, in the +keyframe's unrotated orientation. + +Every file a manifest names must lie inside the manifest's folder (`inside`), and every keyframe id +must be a single safe file-name component (`frame_id`). +""" + +from __future__ import annotations + +import hashlib +import json +import re +import zipfile +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +FEET = 0.3048 +UP = np.array([0.0, 1.0, 0.0]) + + +@dataclass(frozen=True) +class LidarDepth: + file: Path + confidence: Path | None + width: int + height: int + + +@dataclass(frozen=True) +class Frame: + id: str + image: Path + width: int + height: int + intrinsics: np.ndarray # fx, fy, cx, cy + cam_to_world: np.ndarray # 4x4, meters, ARKit camera axes + lidar: LidarDepth | None = None + + @property + def center(self) -> np.ndarray: + return self.cam_to_world[:3, 3] + + +@dataclass(frozen=True) +class WallHint: + """A wall line the phone marked: a point on its foot, and horizontal unit vectors.""" + + point: np.ndarray + along: np.ndarray + outward: np.ndarray + + +@dataclass +class Capture: + source: str # "scan-bundle" or "measure-lab" + root: Path + frames: list[Frame] + ground_y: float | None # world y of the ground at the wall, when the phone knows it + meter: np.ndarray | None # world position of the electric meter + wall: WallHint | None + scene: dict | None = None # the bundle's scene.json, updated in place of rebuilt + notes: list[str] = field(default_factory=list) + + def __post_init__(self) -> None: + # Depth maps and poses are looked up by id, while coverage walks every frame record, so a + # repeated id would let one depth view count from two camera positions and fake two-view + # coverage. Every capture is refused with one, however it was built. + seen: set[str] = set() + for f in self.frames: + if f.id in seen: + raise DuplicateFrameId( + f"keyframe id {f.id!r} appears more than once in {self.root}" + ) + seen.add(f.id) + + +ROTATION_TOL = 1e-2 # scene.json rounds poses to 4 decimals; anything further off is not a pose + + +def column_major(pose: list[float]) -> np.ndarray: + """A 4x4 from 16 column-major numbers, its rotation snapped to the nearest proper rotation. + Exported poses are rounded, so their rotations are orthonormal only to about 1e-4.""" + if len(pose) != 16: + raise ValueError(f"pose has {len(pose)} numbers, expected 16 (column-major 4x4)") + T = np.array(pose, dtype=np.float64).reshape(4, 4).T + u, _, vt = np.linalg.svd(T[:3, :3]) + R = u @ vt + if np.linalg.det(R) < 0 or np.abs(R - T[:3, :3]).max() > ROTATION_TOL: + raise ValueError(f"pose rotation is not a rotation: {T[:3, :3].round(4).tolist()}") + T[:3, :3] = R + return T + + +def outward_of(along: np.ndarray) -> np.ndarray: + """scene.schema.json: a baseline's outward side is its direction turned 90 degrees clockwise + in plan seen from above (+y). In (x, z) that takes (ax, az) to (-az, ax).""" + return np.array([-along[2], 0.0, along[0]]) + + +def unpack(path: Path, work: Path) -> Path: + """A folder as is; a zip extracted once under `work`, keyed by its sha256.""" + if path.is_dir(): + return path + digest = hashlib.sha256(path.read_bytes()).hexdigest()[:16] + out = work / "bundles" / digest + if not out.exists(): + with zipfile.ZipFile(path) as z: + names = [n for n in z.namelist() if not n.startswith("__MACOSX/")] + for n in names: + if n.startswith("/") or ".." in Path(n).parts: + raise ValueError(f"{path}: unsafe entry {n!r}") + z.extractall(out, members=names) + return out + + +def _find(root: Path, name: str) -> Path | None: + hits = [p for p in root.rglob(name) if "__MACOSX" not in p.parts] + if len(hits) > 1: + raise ValueError(f"{root}: {len(hits)} copies of {name}; expected one") + return hits[0] if hits else None + + +def load(path: Path, work: Path) -> Capture: + root = unpack(path, work) + scene = _find(root, "scene.json") + session = _find(root, "session.json") + if scene is not None: + return _scan_bundle(scene.parent, json.loads(scene.read_text())) + if session is not None: + doc = json.loads(session.read_text()) + if doc.get("format") != "measure-lab-session" or doc.get("formatVersion") != 2: + raise ValueError(f"{session}: not a Measure Lab session, format 2") + return _measure_lab(session.parent, doc) + raise ValueError(f"{path}: neither scene.json (app scan bundle) nor session.json (Measure Lab)") + + +class UnsafePath(ValueError): + """A file the manifest names lies outside the manifest's folder.""" + + +def inside(root: Path, ref: str, field: str) -> Path: + """`root / ref`, refused unless it stays inside `root`. The manifest is untrusted: an + absolute path, a `..` component or a symlink out of the folder would let a crafted packet read + any local file, and an image's colours would then reach the returned model. The check runs + on the resolved path, so a symlink is followed before it is judged; the path returned is the + unresolved one, as the manifest names it.""" + rel = Path(ref) + if rel.is_absolute() or ".." in rel.parts: + raise UnsafePath(f"{field} {ref!r} must be a relative path inside {root}") + base = root.resolve() + target = (base / rel).resolve() + if not target.is_relative_to(base): + raise UnsafePath(f"{field} {ref!r} resolves to {target}, outside {base}") + return root / rel + + +class BadFrameId(ValueError): + """A keyframe id that is not a single safe file-name component.""" + + +class DuplicateFrameId(ValueError): + """Two keyframes share an id.""" + + +FRAME_ID = re.compile(r"[A-Za-z0-9._-]+") + + +def frame_id(value: object) -> str: + """A keyframe id, checked. The worker names cache files after ids (`depth.moge` writes + `.upright.jpg` and has the model write `.moge2.npz`), so an id such as `/tmp/x` or + `../../x` would write outside the cache. Only letters, digits, `.`, `_` and `-` are allowed, + and not `.` or `..` alone.""" + if not isinstance(value, str) or not FRAME_ID.fullmatch(value) or value in (".", ".."): + raise BadFrameId( + f"keyframe id {value!r} must be letters, digits, '.', '_' or '-', and not '.' or '..'" + ) + return value + + +def _lidar(root: Path, kf: dict) -> LidarDepth | None: + d = kf.get("depth") + if not d: + return None + file = inside(root, d["file"], "depth.file") + conf = d.get("confidenceFile") + conf_path = inside(root, conf, "depth.confidenceFile") if conf else None + return LidarDepth(file, conf_path, int(d["w"]), int(d["h"])) + + +def _scan_bundle(root: Path, scene: dict) -> Capture: + frames = [] + for kf in scene.get("keyframes", []): + T = column_major(kf["pose"]) + T[:3, 3] *= FEET # scene frame translations are feet + frames.append( + Frame( + frame_id(kf["id"]), + inside(root, kf["img"], "img"), + int(kf["w"]), + int(kf["h"]), + np.array(kf["intrinsics"], dtype=np.float64), + T, + _lidar(root, kf), + ) + ) + if not frames: + raise ValueError(f"{root}/scene.json has no keyframes: nothing to reconstruct") + meter = np.array(scene["meter"]["pos"], dtype=np.float64) * FEET + wall = None + walls = {w["id"]: w for w in scene["walls"]} + base = np.array(walls[scene["meter"]["wall_id"]]["baseline"], dtype=np.float64) * FEET + along = np.array([base[-1, 0] - base[0, 0], 0.0, base[-1, 1] - base[0, 1]]) + along /= np.linalg.norm(along) + wall = WallHint(np.array([base[0, 0], 0.0, base[0, 1]]), along, outward_of(along)) + return Capture("scan-bundle", root, frames, 0.0, meter, wall, scene) + + +def _measure_lab(root: Path, doc: dict) -> Capture: + for kf in doc["keyframes"]: + frame_id(kf["id"]) + keyframes = sorted(doc["keyframes"], key=lambda k: k["id"]) + images = {kf["id"]: inside(root, kf["img"], "img") for kf in keyframes} + frames = [ + Frame( + kf["id"], + images[kf["id"]], + int(kf["w"]), + int(kf["h"]), + np.array(kf["intrinsics"], dtype=np.float64), + column_major(kf["pose"]), + _lidar(root, kf), + ) + for kf in keyframes + if images[kf["id"]].exists() # a JPEG still being written at share time has no entry + ] + points = {p["id"]: np.array(p["position"], dtype=np.float64) for p in doc.get("points", [])} + wall, ground = None, None + for w in doc.get("walls", []): + a, b = (points[c] for c in w["contacts"][:2]) + along = np.array([b[0] - a[0], 0.0, b[2] - a[2]]) + along /= np.linalg.norm(along) + out = outward_of(along) + cam = np.array(w["cameraPosition"], dtype=np.float64) + if (cam - a) @ out < 0: # the camera stood in front of the wall + along, out = -along, -out + wall, ground = WallHint(a.copy(), along, out), float((a[1] + b[1]) / 2) + notes = [] + if wall is None: + notes.append("the session marks no wall: the wall is found in the reconstruction") + return Capture("measure-lab", root, frames, ground, None, wall, None, notes) diff --git a/recon/recon/coverage.py b/recon/recon/coverage.py new file mode 100644 index 000000000..a04d50d01 --- /dev/null +++ b/recon/recon/coverage.py @@ -0,0 +1,330 @@ +"""Which wall, ground, facing and overhead cells were actually seen, by a depth test against the +reconstruction, in the server's terms (`coverage.observed` in scene.schema.json). + +The app's coverage map credits a cell whose samples fall in a keyframe's view, range and angle; +it cannot tell a wall from a bush in front of it, and on ETH3D electro it claimed 1.1 ft of wall no +photo saw (experiments/evals README section 7 on t3/evals). A depth test against true depth +claimed nothing unseen (section 7b). Here a sample counts as seen by a frame only when also: +- the frame's own depth at the sample's pixel lies within the tolerance max(10 cm, 4% of the + distance) of the sample's depth. Nearer means something hides the sample. Farther means no + surface is there, only a clear view through it: the fitted wall line bridges doors and openings + up to geometry.GAP_M, and a view through an opening to whatever lies behind it is not wall + backing, nor is a view into a pit ground; and +- no occupied voxel of the fused reconstruction lies between the camera and the sample, short of + that tolerance. +A cell is observed when every sample row was seen from two camera positions at least 0.25 m +apart, the app's bar for "covered". Wall samples sit on the wall's reconstructed face where that +face is a flat, full-height surface in front of the fitted plane (a pilaster), and on the plane +otherwise, so a bush in front of the wall hides the wall instead of standing in for it. + +Facing and overhead space needs no surface: it is observed where the fused volume shows it seen +empty (every voxel observed, none occupied), up to the first occupied voxel (a measured gap or +clearance) or the first unobserved one (seen clear only that far). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from recon.capture import FEET, UP, Frame +from recon.depth import Depth +from recon.fusion import MAX_RANGE_M, Mesh, Volume +from recon.geometry import WallFrame + +CELL_M = 0.1524 # half a foot, the app's cell +WALL_TOP_M = 1.9812 # 6.5 ft, the headroom the export's wall band claims +WALL_ROWS = 9 # about 0.25 m apart, so a box mounted between rows cannot slip through +GROUND_MAX_M = 3.048 # 10 ft out +FACING_MAX_M = 6.0 +FACING_BAND_M = (0.3, 1.8) +OVERHEAD_OUT_M = (0.1, 0.56) # above the battery's footprint (1.83 ft deep) +OVERHEAD_MAX_M = 3.0 +MAX_ANGLE = np.radians(65) # the app's limit on how oblique a view may be +IMAGE_MARGIN = 0.03 +BASELINE_M = 0.25 +HIDE_ABS_M, HIDE_REL = 0.10, 0.04 + + +def tolerance(distance: np.ndarray) -> np.ndarray: + return np.maximum(HIDE_ABS_M, HIDE_REL * distance) + + +def seen_by( + points: np.ndarray, + normal: np.ndarray, + frames: list[Frame], + depths: dict[str, Depth], + vol: Volume, +) -> np.ndarray: + """(N, frames) bool: frame j saw point i. `normal` is (3,) or (N, 3).""" + normal = np.broadcast_to(normal, points.shape) + out = np.zeros((len(points), len(frames)), bool) + for j, f in enumerate(frames): + T = f.cam_to_world + c = T[:3, 3] + to_cam = c - points + dist = np.linalg.norm(to_cam, axis=1) + ok = (dist > 0.1) & (dist <= MAX_RANGE_M) + ok &= np.einsum("ij,ij->i", to_cam, normal) / np.maximum(dist, 1e-9) >= np.cos(MAX_ANGLE) + p = (points - c) @ T[:3, :3] + z = -p[:, 2] + ok &= z > 0.05 + fx, fy, cx, cy = f.intrinsics + with np.errstate(divide="ignore", invalid="ignore"): + u = cx + fx * p[:, 0] / z + v = cy - fy * p[:, 1] / z + mx, my = f.width * IMAGE_MARGIN, f.height * IMAGE_MARGIN + ok &= (u >= mx) & (u <= f.width - mx) & (v >= my) & (v <= f.height - my) + idx = np.flatnonzero(ok) + if not len(idx): + continue + d = depths[f.id] + h, w = d.depth.shape + s = w / f.width + du = np.clip((u[idx] * s).astype(np.int64), 0, w - 1) + dv = np.clip((v[idx] * s).astype(np.int64), 0, h - 1) + measured = d.depth[dv, du] + tol = tolerance(dist[idx]) + # The frame's own depth must put a surface at the sample: unknown there, nearer (hidden) + # or farther (seen through, no surface) by more than the tolerance is not seen. + visible = np.isfinite(measured) & (np.abs(measured - z[idx]) <= tol) + # The fused reconstruction: nothing occupied between the camera and the sample. + keep = idx[visible] + if len(keep): + blocked = vol.blocked(c, points[keep], tol[visible] + vol.voxel) + out[keep[~blocked], j] = True + return out + + +def two_positions(saw: np.ndarray, centres: np.ndarray) -> np.ndarray: + far = (np.linalg.norm(centres[:, None] - centres[None], axis=-1) >= BASELINE_M).astype( + np.float32 + ) + s = saw.astype(np.float32) + return ((s @ far) * s).sum(axis=-1) > 0 + + +def face_offsets(wall: WallFrame, mesh: Mesh, cells: np.ndarray) -> np.ndarray: + """Per cell, how far a flat, full-height face stands in front of the fitted plane (0 if none). + Flat: in at least 75% of the 10 cm height bins from 0.3 to 1.9 m, the front-most surface within + 0.6 m lies within 3 cm of their median. A bush or bin is neither, so it stays an occluder.""" + loc = wall.local(mesh.vertices.astype(np.float64)) + s, h, out = loc[:, 0], loc[:, 1], loc[:, 2] + keep = (out > -0.15) & (out < 0.6) & (h > 0.3) & (h < 1.9) + col = np.floor((s[keep] - cells[0]) / CELL_M).astype(np.int64) + row = np.floor((h[keep] - 0.3) / 0.1).astype(np.int64) + ok = (col >= 0) & (col < len(cells)) + front = np.full((len(cells), 16), -np.inf) + np.maximum.at(front, (col[ok], row[ok]), out[keep][ok]) + offsets = np.zeros(len(cells)) + for c in range(len(cells)): + f = front[c][np.isfinite(front[c])] + if len(f) >= 12: + m = float(np.median(f)) + if np.sum(np.abs(f - m) <= 0.03) >= 12 and m > HIDE_ABS_M: + offsets[c] = m + return offsets + + +@dataclass +class CellCoverage: + cells: np.ndarray # left edge of each cell, meters of s + wall: np.ndarray # bool per cell + ground_out: np.ndarray # meters out from the wall the ground was seen, contiguous from the foot + facing_gap: np.ndarray # meters from the wall's front to the first thing facing it, or NaN + facing_clear: np.ndarray # meters seen empty out from the wall's front + overhead_clearance: np.ndarray # meters up to the first thing overhead, NaN if none seen + overhead_clear: np.ndarray # meters seen empty above the footprint + + +def wall_and_ground( + wall: WallFrame, + frames: list[Frame], + depths: dict[str, Depth], + vol: Volume, + mesh: Mesh, + cells: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + centres = np.array([f.center for f in frames]) + faces = face_offsets(wall, mesh, cells) + alongs = np.stack([cells + 0.25 * CELL_M, cells + 0.75 * CELL_M], axis=1) # (C, 2) + heights = np.linspace(0.05, WALL_TOP_M, WALL_ROWS) + S = np.broadcast_to(alongs[:, :, None], (len(cells), 2, WALL_ROWS)) + Hh = np.broadcast_to(heights[None, None, :], S.shape) + offs = np.broadcast_to(faces[:, None, None], S.shape) + wpts = wall.world(S, Hh, offs).reshape(-1, 3) + wseen = two_positions(seen_by(wpts, wall.outward, frames, depths, vol), centres) + wall_ok = wseen.reshape(len(cells), 2, WALL_ROWS).all(axis=(1, 2)) + + # Half-foot steps from the foot, and GROUND_MAX_M itself: the steps alone end 0.1 m short, + # and `ground_out` only ever reports a sampled distance, so 10 ft needs a sample at 10 ft. + outs = np.append(np.arange(0.05, GROUND_MAX_M - 1e-6, CELL_M), GROUND_MAX_M) + S = np.broadcast_to(alongs[:, :, None], (len(cells), 2, len(outs))) + Oo = np.maximum(np.broadcast_to(outs[None, None, :], S.shape), faces[:, None, None] + 0.05) + gpts = wall.world(S, np.zeros_like(S), Oo).reshape(-1, 3) + gseen = two_positions(seen_by(gpts, UP, frames, depths, vol), centres).reshape( + len(cells), 2, len(outs) + ) + row_ok = gseen.all(axis=1) # (C, outs) + ground_out = np.zeros(len(cells)) + for c in range(len(cells)): + bad = np.flatnonzero(~row_ok[c]) + n = bad[0] if len(bad) else len(outs) + ground_out[c] = outs[n - 1] if n > 0 else 0.0 + return wall_ok, ground_out, faces + + +OCCUPIED_SHARE = 0.2 # of a slab's voxels, over two consecutive steps, to count as something there +WALL_SEARCH_M = (-0.3, 1.0) + + +def _slab(wall: WallFrame, vol: Volume, across, heights, outs) -> tuple[float, bool]: + """Occupied share and whether every voxel was observed, over a grid of wall coordinates.""" + S, Hh, Oo = np.meshgrid(across, heights, outs, indexing="ij") + pts = wall.world(S, Hh, Oo).reshape(-1, 3) + return float(vol.occupied(pts).mean()), bool(vol.observed(pts).all()) + + +def wall_front(wall: WallFrame, vol: Volume, across: np.ndarray) -> float: + """Where the reconstructed wall surface ends toward the homeowner: marching out from behind + the fitted plane, the first step past the wall's occupied voxels. A noisy reconstruction + smears a wall several inches thick; space inside that smear is neither free nor an obstacle.""" + heights = np.arange(0.5, FACING_BAND_M[1] + 1e-9, vol.voxel) + front, inside = 0.0, False + for out in np.arange(WALL_SEARCH_M[0], WALL_SEARCH_M[1], vol.voxel): + share, _ = _slab(wall, vol, across, heights, [out]) + if share >= OCCUPIED_SHARE: + inside, front = True, out + elif inside: + break + return front + vol.voxel + + +def free_space(wall: WallFrame, vol: Volume, cells: np.ndarray): + """Per cell: (facing gap or NaN, facing clear, overhead clearance or NaN, overhead clear). + The facing gap and clear distance are measured out from the wall's reconstructed front, not + from the fitted plane: in front of a pilaster 0.3 m proud, something 1.3 m out of the plane + is 1.0 m from the face the battery would stand against. The front is the first free step + past the wall's occupied voxels, so both read up to a voxel short, never long. Overhead + clearance is a height above the ground over the footprint in front of that face.""" + step = vol.voxel + n = len(cells) + facing_gap, facing_clear = np.full(n, np.nan), np.zeros(n) + over_gap, over_clear = np.full(n, np.nan), np.zeros(n) + fh = np.arange(FACING_BAND_M[0], FACING_BAND_M[1] + 1e-9, step) + for c in range(n): + across = cells[c] + CELL_M * np.array([0.25, 0.5, 0.75]) + front = wall_front(wall, vol, across) + hits = 0 + for out in np.arange(front + step, front + FACING_MAX_M, step): + share, seen = _slab(wall, vol, across, fh, [out]) + hits = hits + 1 if share >= OCCUPIED_SHARE else 0 + if hits == 2: + facing_gap[c] = out - step - front + break + if not seen: + break + if hits == 0: + facing_clear[c] = out - front + oo = front + np.arange(OVERHEAD_OUT_M[0], OVERHEAD_OUT_M[1] + 1e-9, step) + hits = 0 + for h in np.arange(0.3, OVERHEAD_MAX_M, step): + share, seen = _slab(wall, vol, across, [h], oo) + hits = hits + 1 if share >= OCCUPIED_SHARE else 0 + if hits == 2: + over_gap[c] = h - step + break + if not seen: + break + if hits == 0: + over_clear[c] = h + return facing_gap, facing_clear, over_gap, over_clear + + +def compute( + wall: WallFrame, frames: list[Frame], depths: dict[str, Depth], vol: Volume, mesh: Mesh +) -> CellCoverage: + lo, hi = wall.s_range + cells = np.arange(np.floor(lo / CELL_M) * CELL_M, hi - 1e-9, CELL_M) + wall_ok, ground_out, _ = wall_and_ground(wall, frames, depths, vol, mesh, cells) + fg, fc, og, oc = free_space(wall, vol, cells) + return CellCoverage(cells, wall_ok, ground_out, fg, fc, og, oc) + + +def _runs(mask: np.ndarray) -> list[tuple[int, int]]: + """[start, end) index runs where mask is true.""" + runs, start = [], None + for i, m in enumerate(np.append(mask, False)): + if m and start is None: + start = i + elif not m and start is not None: + runs.append((start, i)) + start = None + return runs + + +def _ft(m: float) -> float: + return round(m / FEET, 3) + + +def observed(cov: CellCoverage, ground_levels_ft=(2.0, 4.0, 6.0, 8.0, 10.0)) -> list[dict]: + """The coverage as scene.json `coverage.observed` entries (feet, s from the meter). + + Ground is reported at a few depths so every clearance's request can be met by one entry; + `out_ft` is always a distance the samples cleared, rounded down.""" + span = lambda a, b: [_ft(cov.cells[a]), _ft(cov.cells[b - 1] + CELL_M)] # noqa: E731 + entries = [{"band": "wall", "span_ft": span(a, b)} for a, b in _runs(cov.wall)] + for level in ground_levels_ft: + for a, b in _runs(cov.ground_out >= level * FEET - 1e-9): + entries.append({"band": "ground", "span_ft": span(a, b), "out_ft": level}) + has_gap = np.isfinite(cov.facing_gap) + for a, b in _runs(has_gap): + entries.append({"band": "facing", "span_ft": span(a, b)}) + clear_ft = np.floor(cov.facing_clear / FEET * 2) / 2 # half feet, rounded down + for a, b in _runs(~has_gap & (clear_ft > 0)): + entries.append( + {"band": "facing", "span_ft": span(a, b), "out_ft": float(clear_ft[a:b].min())} + ) + has_over = np.isfinite(cov.overhead_clearance) + for a, b in _runs(has_over): + entries.append({"band": "overhead", "span_ft": span(a, b)}) + oclear_ft = np.floor(cov.overhead_clear / FEET * 2) / 2 + for a, b in _runs(~has_over & (oclear_ft > 0)): + entries.append( + {"band": "overhead", "span_ft": span(a, b), "out_ft": float(oclear_ft[a:b].min())} + ) + return entries + + +def measurements( + cov: CellCoverage, wall_id: str, plus_minus_ft: float +) -> tuple[list[dict], list[dict]]: + """scene.json `facing` and `overheads` entries: runs of cells whose measured gap or clearance + stays within half a foot, each reporting its smallest value.""" + + def runs_of(values: np.ndarray) -> list[tuple[int, int]]: + out = [] + for a, b in _runs(np.isfinite(values)): + start = a + for i in range(a + 1, b + 1): + if i == b or abs(values[i] - values[start:i].min()) > 0.5 * FEET: + out.append((start, i)) + start = i + return out + + def entry(a, b, key, values): + return { + "wall_id": wall_id, + "span_ft": [_ft(cov.cells[a]), _ft(cov.cells[b - 1] + CELL_M)], + key: _ft(float(values[a:b].min())), + "plus_minus_ft": plus_minus_ft, + } + + facing = [entry(a, b, "depth_ft", cov.facing_gap) for a, b in runs_of(cov.facing_gap)] + overheads = [ + entry(a, b, "clearance_ft", cov.overhead_clearance) + for a, b in runs_of(cov.overhead_clearance) + ] + return facing, overheads diff --git a/recon/recon/depth.py b/recon/recon/depth.py new file mode 100644 index 000000000..14889b09e --- /dev/null +++ b/recon/recon/depth.py @@ -0,0 +1,306 @@ +"""A metric depth map per frame: the phone's LiDAR when it has one, otherwise MoGe-2 rescaled by +features triangulated with the AR poses. + +LiDAR depth is metric as measured and is used as is, keeping medium and high confidence only. +Keyframes a LiDAR capture saved without depth get none (`depth_maps` says why). + +The photos-only path is method (b) of the evals (experiments/evals README section 3 on t3/evals): +MoGe-2 predicts each photo's depth with a scale that is a few percent wrong and different per +photo; SIFT features matched across the photo and its nearest neighbours facing the same way are +triangulated with the AR poses, and each photo's depth is multiplied by median(z_tri / z_pred). +Measured there on ETH3D electro, 8 photos, 1 to 3 m spans on walls: p90 2.8 in [2.0, 4.6] with +exact poses, 5.0 in [3.7, 6.9] with an assumed 2% pose scale error, 10.8 in [8.6, 13.1] with the +2018 iPhone's measured pose error. Those assume perfect feature matching; with real matching +walls reached 6.3 in even with exact poses. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + +from recon.capture import UP, Capture, Frame, UnsafePath +from recon.triangulate import view_scales + +MAX_SIDE = 640 # MoGe-2 input; keeps the model process under 4 GB (2.97 GB peak at 640 x 480) +MIN_CONFIDENCE = 1 # ARKit confidence: 0 low, 1 medium, 2 high +NEIGHBOURS = 7 +MODELS = Path(__file__).resolve().parents[1] / "models" +MOGE_SCRIPT = MODELS / "moge_depth.py" # pins the checkpoint revision and resolution level +JPEG_QUALITY = 95 +ACCURACY_NOTE = { + "lidar": "LiDAR depth as measured; this worker has no measured error bar for iPhone LiDAR.", + "moge2-triangulated": ( + "MoGe-2 rescaled per photo by features triangulated with the AR poses. On ETH3D, wall " + "p90 over 1 to 3 m spans was 5.0 in [3.7, 6.9] at an assumed 2% pose scale error and " + "10.8 in [8.6, 13.1] at the 2018 iPhone's measured pose error." + ), +} + + +@dataclass +class Depth: + depth: np.ndarray # float32 meters, z along the camera axis, NaN where unknown + intrinsics: ( + np.ndarray + ) # fx, fy, cx, cy for this map's resolution, (0, 0) at the top-left corner + color: np.ndarray # BGR uint8 image at the same resolution + source: str + + +def scaled(intrinsics: np.ndarray, s: float) -> np.ndarray: + return intrinsics * s + + +def upright_turns(cam_to_world: np.ndarray) -> int: + """np.rot90 turns (counter-clockwise) that put world up at the top of the frame's image. + Sensor images are sideways for a phone held upright; MoGe-2 was trained on upright photos.""" + x, y = (cam_to_world[:3, :3].T @ UP)[:2] # world up in ARKit camera axes (+y is image up) + if abs(y) >= abs(x): + return 0 if y > 0 else 2 + return 1 if x > 0 else 3 + + +def rotated_intrinsics(k: np.ndarray, w: float, h: float, turns: int) -> np.ndarray: + """Intrinsics of the image turned by np.rot90(image, turns), for a w x h original, with pixel + (0, 0) at the top-left corner: a turn takes (u, v) to (v, w - u).""" + fx, fy, cx, cy = k + return { + 0: np.array([fx, fy, cx, cy]), + 1: np.array([fy, fx, cy, w - cx]), + 2: np.array([fx, fy, w - cx, h - cy]), + 3: np.array([fy, fx, h - cy, cx]), + }[turns % 4] + + +def lidar(frame: Frame) -> Depth: + d = frame.lidar + depth = np.fromfile(d.file, dtype=" 0)] = np.nan + color = cv2.resize(_image(frame), (d.width, d.height), interpolation=cv2.INTER_AREA) + return Depth(depth, scaled(frame.intrinsics, d.width / frame.width), color, "lidar") + + +def _image(frame: Frame) -> np.ndarray: + img = cv2.imread(str(frame.image), cv2.IMREAD_COLOR) + if img is None: + raise FileNotFoundError(frame.image) + if img.shape[:2] != (frame.height, frame.width): + got, want = f"{img.shape[1]}x{img.shape[0]}", f"{frame.width}x{frame.height}" + raise ValueError(f"{frame.image}: {got}, keyframe says {want}") + return img + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def moge_key(capture: Capture) -> dict: + """Everything MoGe-2's depth maps for a capture depend on: each frame's image bytes, pose (it + sets the upright turn) and intrinsics (the field of view); the model process, by the bytes of + its script (checkpoint revision, resolution level) and lockfile (library versions); and the + resizing and JPEG quality of its input. Frame IDs and folder names repeat across captures.""" + return { + "model_script_sha256": _sha256(MOGE_SCRIPT), + "model_lock_sha256": _sha256(MODELS / "uv.lock"), + "max_side": MAX_SIDE, + "jpeg_quality": JPEG_QUALITY, + "frames": [ + { + "id": f.id, + "image_sha256": _sha256(f.image), + "w": f.width, + "h": f.height, + "intrinsics": f.intrinsics.tolist(), + "pose": f.cam_to_world.tolist(), + } + for f in capture.frames + ], + } + + +def moge_cache(capture: Capture, work: Path) -> Path: + """The capture's MoGe-2 cache folder under `work`, named by a hash of `moge_key` and holding + that key. Cached depth maps there are kept only when the stored key equals this capture's; + otherwise (a hash collision, a folder written without a key) they are deleted, to recompute.""" + key = json.dumps(moge_key(capture), sort_keys=True) + folder = work / hashlib.sha256(key.encode()).hexdigest()[:16] + folder.mkdir(parents=True, exist_ok=True) + key_file = folder / "key.json" + if not key_file.exists() or json.loads(key_file.read_text()) != json.loads(key): + stale = sorted(folder.glob("*.moge2.npz")) + if stale: + print( + f"depth: {folder} holds depth for other photos or another model; " + f"recomputing its {len(stale)} maps", + file=sys.stderr, + ) + for path in stale: + path.unlink() + # Written before the model runs, so the maps a run finishes before failing stay reusable. + key_file.write_text(key) + return folder + + +def cache_file(work: Path, fid: str, suffix: str) -> Path: + """`work/`, refused unless it resolves to a file directly in `work`. The + readers already refuse unsafe ids (`capture.frame_id`); this second check covers any Capture + built another way, since the path is written to and handed to the model process.""" + path = work / f"{fid}{suffix}" + if path.resolve().parent != work.resolve(): + raise UnsafePath(f"cache file for keyframe {fid!r} resolves outside {work}") + return path + + +def moge(capture: Capture, work: Path) -> dict[str, Depth]: + """MoGe-2 depth per frame at up to MAX_SIDE px, in the frame's own (unrotated) orientation, + cached under `work` by the capture's content (`moge_cache`).""" + work = moge_cache(capture, work) + manifest, meta = [], {} + for f in capture.frames: + img = _image(f) + s = min(1.0, MAX_SIDE / max(f.width, f.height)) + small = cv2.resize( + img, (round(f.width * s), round(f.height * s)), interpolation=cv2.INTER_AREA + ) + k_small = scaled(f.intrinsics, small.shape[1] / f.width) + turns = upright_turns(f.cam_to_world) + up_path = cache_file(work, f.id, ".upright.jpg") + cv2.imwrite(str(up_path), np.rot90(small, turns), [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]) + k_up = rotated_intrinsics(k_small, small.shape[1], small.shape[0], turns) + out = cache_file(work, f.id, ".moge2.npz") + meta[f.id] = (out, turns, k_small, small) + if not out.exists(): + manifest.append({"image": str(up_path), "fx": float(k_up[0]), "out": str(out)}) + if manifest: + (work / "moge2.json").write_text(json.dumps(manifest, indent=1)) + subprocess.run( + [ + "uv", + "run", + "--project", + str(MODELS), + "python", + str(MODELS / "moge_depth.py"), + str(work / "moge2.json"), + ], + check=True, + env={**os.environ}, + ) + depths = {} + for fid, (out, turns, k_small, small) in meta.items(): + d = np.rot90(np.load(out)["depth"], -turns).astype(np.float32) + if d.shape != small.shape[:2]: + raise ValueError(f"{out}: depth {d.shape} does not match the image {small.shape[:2]}") + depths[fid] = Depth(np.ascontiguousarray(d), k_small, small, "moge2") + return depths + + +def _cv_pose(T: np.ndarray) -> np.ndarray: + """ARKit camera axes to OpenCV's (+y down, +z forward).""" + return T @ np.diag([1.0, -1.0, -1.0, 1.0]) + + +def _cv_K(k: np.ndarray) -> np.ndarray: + fx, fy, cx, cy = k + return np.array([[fx, 0, cx - 0.5], [0, fy, cy - 0.5], [0, 0, 1.0]]) + + +def neighbours(poses: dict[str, np.ndarray], fid: str, n: int = NEIGHBOURS) -> list[str]: + """The frame and its n nearest frames looking the same way (within 60 degrees).""" + T = poses[fid] + fwd = -T[:3, 2] + near = [k for k in poses if k != fid and -poses[k][:3, 2] @ fwd > 0.5] + near.sort(key=lambda k: np.linalg.norm(poses[k][:3, 3] - T[:3, 3])) + return [fid, *near[:n]] + + +def rescale(capture: Capture, depths: dict[str, Depth]) -> tuple[dict[str, Depth], dict]: + """Multiplies each frame's MoGe-2 depth by its triangulated scale. Frames without enough + triangulated points take the median of the others; with none at all this refuses.""" + poses = {f.id: f.cam_to_world for f in capture.frames} + gray = {k: cv2.cvtColor(d.color, cv2.COLOR_BGR2GRAY) for k, d in depths.items()} + fits = {} + for fid in poses: + group = neighbours(poses, fid) + if len(group) < 2: + continue + fits[fid] = view_scales( + {k: gray[k] for k in group}, + {k: _cv_K(depths[k].intrinsics) for k in group}, + {k: _cv_pose(poses[k]) for k in group}, + {k: depths[k].depth for k in group}, + )[fid] + good = [f.scale for f in fits.values() if f.scale is not None] + if not good: + raise RuntimeError( + "no frame had enough features triangulated with the AR poses to fix MoGe-2's scale; " + "the photos need texture and overlap" + ) + fallback = float(np.median(good)) + out, used = {}, {} + for fid, d in depths.items(): + fit = fits.get(fid) + fitted = fit is not None and fit.scale is not None + s = fit.scale if fitted else fallback + used[fid] = {"scale": s, "points": fit.points if fit else 0, "fitted": fitted} + out[fid] = Depth(d.depth * np.float32(s), d.intrinsics, d.color, "moge2-triangulated") + report = { + "fitted": len(good), + "frames": len(depths), + "median_scale": fallback, + "scale_range": [float(min(good)), float(max(good))], + "per_frame": used, + } + return out, report + + +MIN_LIDAR_FRAMES = 2 # coverage needs a sample seen from two positions; one frame sees nothing + + +def depth_maps(capture: Capture, work: Path, mode: str) -> tuple[dict[str, Depth], dict]: + """Depth per frame, keyed by frame id, and a report. mode: "auto" (LiDAR when at least + MIN_LIDAR_FRAMES keyframes carry it, else MoGe-2), "lidar" or "moge". + + When only some keyframes carry LiDAR (a LiDAR phone that saved a frame without its depth + map), the reconstruction uses those frames' LiDAR and gives the others no depth at all. They + add nothing to the model and see nothing in coverage, so wall or ground that only they showed + stays unobserved and the server answers UNSURE there, never clear. Filling them with MoGe-2 + instead would fuse a second, coarser source into the same volume: its wall p90 on ETH3D was + about 5 in at a simulated 2% pose error, and the fused wall and every error bar sent with it + would then have to carry that worse bound. It would also start a 3 GB model for a few frames. + """ + with_lidar = [f for f in capture.frames if f.lidar is not None] + if mode == "lidar" or (mode == "auto" and len(with_lidar) >= MIN_LIDAR_FRAMES): + if len(with_lidar) < MIN_LIDAR_FRAMES: + raise ValueError( + f"--depth lidar, but only {len(with_lidar)} of {len(capture.frames)} keyframes " + f"carry LiDAR depth; coverage needs at least {MIN_LIDAR_FRAMES}" + ) + without = [f.id for f in capture.frames if f.lidar is None] + if without: + capture.notes.append( + f"{len(without)} of {len(capture.frames)} keyframes carry no LiDAR depth " + f"({', '.join(without[:5])}{', ...' if len(without) > 5 else ''}); they add " + "nothing to the model, and what only they saw stays unobserved" + ) + report = {"source": "lidar", "frames": len(with_lidar), "without_depth": without} + return {f.id: lidar(f) for f in with_lidar}, report + print(f"depth: MoGe-2 on {len(capture.frames)} frames", file=sys.stderr) + raw = moge(capture, work / "moge2") + depths, report = rescale(capture, raw) + return depths, {"source": "moge2-triangulated", **report} diff --git a/recon/recon/eth3d.py b/recon/recon/eth3d.py new file mode 100644 index 000000000..154347a11 --- /dev/null +++ b/recon/recon/eth3d.py @@ -0,0 +1,478 @@ +"""Acceptance on ETH3D (Schöps et al., CVPR 2017): the worker against a laser scan. + +`python -m recon.eth3d [scene]` runs the worker on one ETH3D scene twice, once with MoGe-2 depth +rescaled by the (exact) poses, the photos-only path, and once with depth rendered from the laser +scan standing in for LiDAR. It then measures: +- wall geometry: the fitted wall plane against a plane fitted to the laser scan's wall, and the + reconstruction's point at each pixel where a laser wall point is visible, as the evals measured + learned depth (point-pair errors over 1 to 3 m spans, inches); +- coverage: wall the worker calls observed where at least 10 cm of the band was seen by no photo + (false-observed length, feet), against the laser scan's visibility, as in experiments/evals + README section 7 on t3/evals. Pre-registered bar: at most 0.5 ft. + +Data: the prepared scene from `make recon-data` in experiments/evals (t3/evals): COLMAP text model, +the scan voxelized to `scan_points_10mm.npy`, occlusion splats to `occluders_20mm.npy`, images at +1024 px in `images_1024/`, masks. Non-commercial (CC BY-NC-SA 4.0): accuracy only, never +committed. +""" + +from __future__ import annotations + +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np +from scipy.spatial import cKDTree + +from recon import depth as dep +from recon.capture import FEET, UP, Capture, Frame +from recon.coverage import CELL_M, HIDE_ABS_M, HIDE_REL +from recon.fusion import MAX_RANGE_M, Volume +from recon.pipeline import reconstruct + +DATA = Path(os.environ.get("HOUSE_SCANNING_DATA", Path.home() / "house-scanning-data")) +ETH3D = DATA / "evals" / "eth3d" +RESULTS = Path(__file__).resolve().parents[1] / "results" +WIDTH = 1024 +LIDAR_WIDTH = 256 # an iPhone LiDAR depth map is 256 x 192 +PASS_FT = 0.5 +MISSING_FROM_SCAN = 2 # ETH3D mask label: people, vegetation and other things the scanner missed + + +@dataclass(frozen=True) +class View: + name: str + width: int + height: int + K: np.ndarray # fx, fy, cx, cy at full size, (0, 0) at the top-left corner + R_wc: np.ndarray + t_wc: np.ndarray + + @property + def center(self) -> np.ndarray: + return -self.R_wc.T @ self.t_wc + + +def quat_wxyz(q: np.ndarray) -> np.ndarray: + w, x, y, z = q / np.linalg.norm(q) + return np.array( + [ + [1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], + [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], + [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)], + ] + ) + + +def read_views(scene_dir: Path) -> list[View]: + """COLMAP's text model of the undistorted images. COLMAP's pixel (0, 0) is a corner, as here.""" + cal = scene_dir / "dslr_calibration_undistorted" + cams = {} + for line in (cal / "cameras.txt").read_text().splitlines(): + if line and not line.startswith("#"): + cid, model, w, h, *p = line.split() + if model != "PINHOLE": + raise ValueError(f"camera {cid} is {model}, expected PINHOLE") + cams[cid] = (int(w), int(h), np.array(list(map(float, p)))) + lines = [x for x in (cal / "images.txt").read_text().splitlines() if not x.startswith("#")] + views = [] + for pose in lines[0::2]: + if not pose.strip(): + continue + f = pose.split() + w, h, K = cams[f[8]] + views.append( + View( + Path(f[9]).stem, + w, + h, + K, + quat_wxyz(np.array(list(map(float, f[1:5])))), + np.array(list(map(float, f[5:8]))), + ) + ) + return sorted(views, key=lambda v: v.name) + + +def rotation_to_y(up: np.ndarray) -> np.ndarray: + v = np.cross(up, UP) + s, c = np.linalg.norm(v), float(up @ UP) + V = np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]]) + return np.eye(3) + V + V @ V * ((1 - c) / s**2) if s > 1e-12 else np.eye(3) + + +def level(views: list[View], scan: np.ndarray) -> tuple[np.ndarray, float]: + """Rotation into a world whose +y is up, from a plane through the ground under the cameras, + and the ground's height in it. The cameras' mean up axis alone was 10 degrees off on facade.""" + guess = np.mean([v.R_wc.T @ [0, -1.0, 0] for v in views], axis=0) + guess /= np.linalg.norm(guess) + a = np.cross(guess, [1.0, 0, 0]) + a /= np.linalg.norm(a) + b = np.cross(guess, a) + h = scan @ guess + tree = cKDTree(np.c_[scan @ a, scan @ b]) + feet = [] + for v in views: + c = v.center + idx = np.asarray(tree.query_ball_point([c @ a, c @ b], 0.7), dtype=np.int64) + below = idx[(h[idx] < c @ guess - 1.0) & (h[idx] > c @ guess - 2.5)] + if len(below) >= 30: + feet.append(scan[below][np.argsort(h[below])[: len(below) // 2]].mean(axis=0)) + feet = np.asarray(feet) + centre = feet.mean(axis=0) + n = np.linalg.svd(feet - centre)[2][2] + n *= np.sign(n @ guess) + R = rotation_to_y(n) + return R, float((R @ centre)[1]) + + +def load_scene(scene: str) -> tuple[list[View], np.ndarray, np.ndarray, Capture]: + root = ETH3D / scene + scan_file = root / "scan_points_10mm.npy" + if not scan_file.exists(): + raise FileNotFoundError( + f"{scan_file} missing: prepare ETH3D with `make recon-data` in experiments/evals" + ) + views = read_views(root) + scan = np.concatenate([np.load(scan_file), np.load(root / "occluders_20mm.npy")]) + R, ground = level(views, np.load(scan_file)[::3].astype(np.float64)) + frames = [] + for v in views: + img = root / "images_1024" / f"{v.name}.jpg" + h = round(v.height * WIDTH / v.width) + T = np.eye(4) + T[:3, :3] = R @ v.R_wc.T @ np.diag([1.0, -1.0, -1.0]) + T[:3, 3] = R @ v.center + frames.append(Frame(v.name, img, WIDTH, h, v.K * WIDTH / v.width, T)) + capture = Capture("eth3d", root, frames, ground, None, None, None) + return views, R, scan, capture + + +def zbuffer(view: View, points: np.ndarray, width: int, window: int) -> np.ndarray: + """Nearest laser depth per pixel of a `width`-wide image, holes closed by a minimum filter; + NaN where nothing projects.""" + height = round(view.height * width / view.width) + Xc = points @ view.R_wc.T.astype(np.float32) + view.t_wc.astype(np.float32) + z = Xc[:, 2] + ok = z > 0.1 + s = width / view.width + u = (view.K[0] * Xc[ok, 0] / z[ok] + view.K[2]) * s + v = (view.K[1] * Xc[ok, 1] / z[ok] + view.K[3]) * s + inside = (u >= 0) & (u < width) & (v >= 0) & (v < height) + buf = np.full((height, width), 1e6, np.float32) + np.minimum.at(buf, (v[inside].astype(np.int64), u[inside].astype(np.int64)), z[ok][inside]) + buf = cv2.erode(buf, np.ones((window, window), np.uint8)) + buf[buf >= 1e5] = np.nan + return buf + + +def missing_mask(scene: str, view: View, width: int) -> np.ndarray: + """ETH3D's mask of things the scanner missed, stretched to the undistorted frame and dilated + (the masks are drawn on the distorted images; see experiments/evals/evals/eth3d.py).""" + height = round(view.height * width / view.width) + path = ETH3D / scene / "masks_for_images" / "dslr_images" / f"{view.name}.png" + if not path.exists(): + return np.zeros((height, width), bool) + m = cv2.resize( + (cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) == MISSING_FROM_SCAN).astype(np.uint8), + (width, height), + interpolation=cv2.INTER_NEAREST, + ) + k = max(1, round(13 * width / 1024)) + return cv2.dilate(m, np.ones((2 * k + 1, 2 * k + 1), np.uint8)).astype(bool) + + +def laser_depths(scene: str, views: list[View], capture: Capture, scan: np.ndarray) -> dict: + """The laser scan rendered at LiDAR resolution: metric depth with exact occlusion. Things the + scanner missed (ETH3D's masks) are left unknown rather than guessed.""" + out = {} + for v, f in zip(views, capture.frames, strict=True): + d = zbuffer(v, scan, LIDAR_WIDTH, 3) + d[missing_mask(scene, v, LIDAR_WIDTH)] = np.nan + color = cv2.resize( + cv2.imread(str(f.image)), (d.shape[1], d.shape[0]), interpolation=cv2.INTER_AREA + ) + out[f.id] = dep.Depth(d, f.intrinsics * LIDAR_WIDTH / f.width, color, "lidar") + return out + + +@dataclass(frozen=True) +class LaserView: + """A view with the laser scan rendered into it: the nearest laser depth per pixel at 512 px + wide, and ETH3D's mask of what the scanner missed at 1024 px.""" + + view: View + zbuf: np.ndarray + missing: np.ndarray + + +def laser_views(scene: str, views: list[View], scan: np.ndarray) -> list[LaserView]: + return [LaserView(v, zbuffer(v, scan, 512, 5), missing_mask(scene, v, 1024)) for v in views] + + +def laser_sees(nearest: np.ndarray, z: np.ndarray) -> np.ndarray: + """Whether the laser shows a sample at depth `z` visible: its nearest surface at the pixel is + not nearer by more than the tolerance. A pixel with no laser return (NaN) is no evidence, so + the sample stays unseen: an unknown must not lower the false-observed length it is checked + against.""" + return np.isfinite(nearest) & (nearest >= z - np.maximum(HIDE_ABS_M, HIDE_REL * z)) + + +def laser_visible( + lv: LaserView, X: np.ndarray, pts: np.ndarray, R: np.ndarray, outward: np.ndarray +) -> np.ndarray: + """Which points the laser shows visible in this view: inside the image, within MAX_RANGE_M, + with the camera on the wall's outward side, the laser's nearest surface at the pixel being the + point itself (`laser_sees`), and the pixel not one the scanner missed. `X` holds the points in + ETH3D's world, `pts` the same points levelled.""" + v = lv.view + Xc = X @ v.R_wc.T + v.t_wc + z = Xc[:, 2] + with np.errstate(divide="ignore", invalid="ignore"): + u = v.K[0] * Xc[:, 0] / z + v.K[2] + vv = v.K[1] * Xc[:, 1] / z + v.K[3] + framed = (z > 0.05) & (u >= 0) & (u <= v.width) & (vv >= 0) & (vv <= v.height) + near = np.linalg.norm(X - v.center, axis=1) <= MAX_RANGE_M + front = (R @ v.center - pts) @ outward > 0 + + def pixel(mask: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray: + s = mask.shape[1] / v.width + iu = np.clip(np.nan_to_num(a * s).astype(int), 0, mask.shape[1] - 1) + iv = np.clip(np.nan_to_num(b * s).astype(int), 0, mask.shape[0] - 1) + return mask[iv, iu] + + visible = laser_sees(pixel(lv.zbuf, u, vv), z) & ~pixel(lv.missing, u, vv) + return framed & near & front & visible + + +def nearest_seeing(dist: np.ndarray, sees: np.ndarray) -> np.ndarray: + """Per point (row), the nearest view (column) that sees it, or -1 when none does.""" + d = np.where(sees, dist, np.inf) + best = np.argmin(d, axis=1) + return np.where(np.isfinite(d[np.arange(len(d)), best]), best, -1) + + +# --- Geometry against the scan --------------------------------------------------------------- + + +def surface_along( + vol: Volume, origin: np.ndarray, dirs: np.ndarray, far: float = 8.0 +) -> np.ndarray: + """Where each ray from `origin` first crosses the reconstruction's surface (NaN if never).""" + step = vol.voxel / 2 + ts = np.arange(0.3, far, step) + out = np.full((len(dirs), 3), np.nan) + for i in range(0, len(dirs), 2048): + d = dirs[i : i + 2048] + pts = origin + d[:, None, :] * ts[None, :, None] + t, w = vol.lookup(pts) + inside = (w > 0) & (t <= 0) + first = np.where(inside.any(axis=1), inside.argmax(axis=1), -1) + for k, j in enumerate(first): + if j <= 0: + continue + t0, t1 = t[k, j - 1], t[k, j] + frac = t0 / (t0 - t1) if t0 != t1 else 0.0 + out[i + k] = origin + d[k] * (ts[j - 1] + frac * step) + return out + + +def geometry_errors( + r: dict, lviews: list[LaserView], R: np.ndarray, scan_lev: np.ndarray, rng: np.random.Generator +) -> dict: + wall = r["wall"] + loc = wall.local(scan_lev) + lo, hi = wall.s_range + keep = (np.abs(loc[:, 2]) < 0.3) & (loc[:, 1] > 0.3) & (loc[:, 1] < 2.0) + keep &= (loc[:, 0] > lo) & (loc[:, 0] < hi) + s, out = loc[keep, 0], loc[keep, 2] + b, a = np.polyfit(s, out, 1) # the laser wall face in the fitted wall's coordinates + ends = { + name: (a + b * x) / FEET * 12 + for name, x in (("left", lo), ("middle", (lo + hi) / 2), ("right", hi)) + } + + # Per-pixel correspondence: each sampled laser wall point, along its ray from the nearest view + # in which the laser shows it visible. The nearest view regardless could be aimed elsewhere or + # have the scaffold in front of the point, and its ray would then meet another surface. + pts = scan_lev[keep][rng.choice(keep.sum(), min(3000, keep.sum()), replace=False)] + centers = np.array([R @ lv.view.center for lv in lviews]) + X = pts @ R # levelled -> ETH3D world + sees = np.stack([laser_visible(lv, X, pts, R, wall.outward) for lv in lviews], axis=1) + pick = nearest_seeing(np.linalg.norm(pts[:, None] - centers[None], axis=-1), sees) + recon = np.full_like(pts, np.nan) + for j in np.unique(pick[pick >= 0]): + sel = np.flatnonzero(pick == j) + dirs = pts[sel] - centers[j] + dirs /= np.linalg.norm(dirs, axis=1, keepdims=True) + recon[sel] = surface_along(r["volume"], centers[j], dirs) + ok = np.isfinite(recon[:, 0]) + along = np.linalg.norm(recon[ok] - pts[ok], axis=1) + idx = np.flatnonzero(ok) + i, j = rng.integers(0, len(idx), (2, 20000)) + dt = np.linalg.norm(pts[idx[i]] - pts[idx[j]], axis=1) + sel = (dt >= 1.0) & (dt <= 3.0) + dr = np.linalg.norm(recon[idx[i]] - recon[idx[j]], axis=1) + pair = np.abs(dr[sel] - dt[sel]) / FEET * 12 + return { + # The fitted wall's ends in plan (x, z), meters in the levelled ETH3D world, so rows from + # the two depth paths show whether they fitted the same wall. + "wall_ends_xz_m": [ + [round(float(p[0]), 2), round(float(p[2]), 2)] + for p in wall.world(np.array([lo, hi]), 0.0) + ], + "wall_offset_in": {k: round(float(v), 2) for k, v in ends.items()}, + "wall_angle_deg": round(float(np.degrees(np.arctan(b))), 2), + "laser_wall_points": int(keep.sum()), + "points_visible": f"{int((pick >= 0).sum())} of {len(pts)}", + "points_found": f"{int(ok.sum())} of {int((pick >= 0).sum())} visible", + "point_error_in": { + "median": round(float(np.median(along)) / FEET * 12, 2), + "p90": round(float(np.percentile(along, 90)) / FEET * 12, 2), + }, + "pair_error_1_3m_in": { + "pairs": int(sel.sum()), + "median": round(float(np.median(pair)), 2), + "p90": round(float(np.percentile(pair, 90)), 2), + }, + "laser_face": (float(a), float(b)), + } + + +# --- Coverage against the scan's visibility -------------------------------------------------- + + +def laser_face( + wall, scan_lev: np.ndarray, cols: np.ndarray, plane: tuple[float, float] +) -> np.ndarray: + """Per 2 cm column, where the wall's laser-scanned face is, in `out`: the laser plane, or a + flat, full-height face in front of it (a pilaster), found as in section 7: in at least 75% of + the 10 cm height bins from 0.3 to 1.9 m, the front-most point within 0.6 m of the plane lies + within 3 cm of their median.""" + a, b = plane + loc = wall.local(scan_lev) + s, h = loc[:, 0], loc[:, 1] + rel = loc[:, 2] - (a + b * s) + keep = (rel > -0.15) & (rel < 0.6) & (h > 0.3) & (h < 1.9) + col = np.floor((s[keep] - cols[0] + 0.01) / 0.02).astype(np.int64) + row = np.floor((h[keep] - 0.3) / 0.1).astype(np.int64) + ok = (col >= 0) & (col < len(cols)) + front = np.full((len(cols), 16), -np.inf) + np.maximum.at(front, (col[ok], row[ok]), rel[keep][ok]) + out = a + b * cols + for c in range(len(cols)): + f = front[c][np.isfinite(front[c])] + if len(f) >= 12: + m = float(np.median(f)) + if np.sum(np.abs(f - m) <= 0.03) >= 12 and m > HIDE_ABS_M: + out[c] += m + return out + + +def false_observed( + r: dict, + lviews: list[LaserView], + R: np.ndarray, + plane: tuple[float, float], + scan_lev: np.ndarray, +) -> dict: + """README section 7's metric on the worker's claim: claimed wall columns (2 cm) where at least + two 5 cm samples of the band were seen by no photo, on the laser's wall face.""" + wall, cov = r["wall"], r["coverage"] + lo, hi = wall.s_range + cols = np.arange(lo + 0.01, hi, 0.02) + heights = 1.9812 * (np.arange(40) + 0.5) / 40 + face = laser_face(wall, scan_lev, cols, plane) + S, H = np.meshgrid(cols, heights, indexing="ij") + pts = wall.world(S, H, np.broadcast_to(face[:, None], S.shape)).reshape(-1, 3) + X = pts @ R # levelled -> ETH3D world + seen = np.zeros(len(pts), bool) + for lv in lviews: + seen |= laser_visible(lv, X, pts, R, wall.outward) + unseen = (~seen).reshape(len(cols), len(heights)).sum(axis=1) + cell = np.floor((cols - cov.cells[0]) / CELL_M).astype(int) + claimed = cov.wall[np.clip(cell, 0, len(cov.wall) - 1)] & (cell >= 0) & (cell < len(cov.wall)) + bad = claimed & (unseen >= 2) + runs, start = [], None + for i, x in enumerate(np.append(bad, False)): + if x and start is None: + start = i + elif not x and start is not None: + runs.append([round(cols[start] / FEET, 2), round(cols[i - 1] / FEET, 2)]) + start = None + return { + "false_observed_runs_ft": runs, + "wall_ft": round((hi - lo) / FEET, 1), + "claimed_ft": round(claimed.sum() * 0.02 / FEET, 2), + "false_observed_ft": round(bad.sum() * 0.02 / FEET, 2), + "passes": bool(bad.sum() * 0.02 / FEET <= PASS_FT), + } + + +def run(scene: str, work: Path) -> dict: + views, R, scan, capture = load_scene(scene) + scan_lev = np.load(ETH3D / scene / "scan_points_10mm.npy")[::2].astype(np.float64) @ R.T + lviews = laser_views(scene, views, scan) + rng = np.random.default_rng(0) + rows = {} + for label in ("laser as LiDAR", "MoGe-2, photos only"): + if label.startswith("laser"): + depths, report = laser_depths(scene, views, capture, scan), {"source": "lidar"} + else: + raw = dep.moge(capture, work / scene / "moge2") + depths, report = dep.rescale(capture, raw) + report["source"] = "moge2-triangulated" + r = reconstruct(capture, depths, report) + geo = geometry_errors(r, lviews, R, scan_lev, rng) + cov = false_observed(r, lviews, R, geo.pop("laser_face"), scan_lev) + rows[label] = {"wall": geo, "coverage": cov, "notes": list(capture.notes)} + capture.notes.clear() + print(label, json.dumps(rows[label]), file=sys.stderr) + return rows + + +def markdown(scene: str, rows: dict) -> str: + lines = [ + f"# Worker acceptance on ETH3D {scene} (generated by `make accept`)", + "", + "Wall offset: the laser's wall face relative to the worker's fitted plane at the wall's " + "ends " + "and middle. Point error: the reconstruction's surface along each pixel's ray against the " + "laser point seen there, from the nearest photo in which the laser shows that point " + "unoccluded. Pair error: |reconstructed - laser| distance between two such " + f"points 1 to 3 m apart. False-observed: pass at {PASS_FT} ft or less.", + "", + "| Depth | Wall offset L / mid / R (in) | Angle | Point error median / p90 (in) " + "| Pair error median / p90 (in) | Wall | Claimed | False-observed | Pass |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for label, row in rows.items(): + w, c = row["wall"], row["coverage"] + o = w["wall_offset_in"] + lines.append( + f"| {label} | {o['left']:+.1f} / {o['middle']:+.1f} / {o['right']:+.1f} | " + f"{w['wall_angle_deg']:+.2f}° | " + f"{w['point_error_in']['median']:.1f} / {w['point_error_in']['p90']:.1f} | " + f"{w['pair_error_1_3m_in']['median']:.1f} / {w['pair_error_1_3m_in']['p90']:.1f} | " + f"{c['wall_ft']:.1f} ft | {c['claimed_ft']:.1f} ft | {c['false_observed_ft']:.2f} ft | " + f"{'yes' if c['passes'] else 'no'} |" + ) + return "\n".join(lines) + "\n" + + +def main() -> None: + scene = sys.argv[1] if len(sys.argv) > 1 else "electro" + rows = run(scene, DATA / "recon" / "work" / "eth3d") + RESULTS.mkdir(exist_ok=True) + (RESULTS / f"eth3d_{scene}.json").write_text(json.dumps(rows, indent=1)) + md = markdown(scene, rows) + (RESULTS / f"eth3d_{scene}.md").write_text(md) + print(md) + + +if __name__ == "__main__": + main() diff --git a/recon/recon/fusion.py b/recon/recon/fusion.py new file mode 100644 index 000000000..a5d92b04d --- /dev/null +++ b/recon/recon/fusion.py @@ -0,0 +1,199 @@ +"""Depth maps fused into one volume (a truncated signed distance field, TSDF), the mesh inside it, +and the questions coverage asks of it: is this space occupied, observed, or visible from there. + +Each voxel stores the weighted mean of clip((depth - z) / trunc, -1, 1) over the frames whose ray +through it reached depth within `trunc` behind it. A voxel no frame's ray reached keeps weight 0: +that is space nobody saw, which is what makes coverage honest. Negative means inside a surface +(occupied), positive means seen empty. The mesh is the zero level, from marching cubes over the +observed voxels only. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from skimage.measure import marching_cubes + +from recon.depth import Depth + +MAX_RANGE_M = 6.0 # the app's coverage range; photos resolve too little of a wall beyond it +MAX_VOXELS = 12_000_000 # about 240 MB of volume +MIN_VOXEL_M = 0.04 +CHUNK = 2_000_000 + + +@dataclass +class Volume: + origin: np.ndarray # world position of voxel (0, 0, 0)'s centre + voxel: float + shape: tuple[int, int, int] + tsdf: np.ndarray # (nx, ny, nz) float32 + weight: np.ndarray # (nx, ny, nz) float32 + color: np.ndarray # (nx, ny, nz, 3) float32 BGR sums of near-surface samples + color_n: np.ndarray # (nx, ny, nz) float32 + + @property + def trunc(self) -> float: + return 3 * self.voxel + + def ijk(self, points: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Nearest voxel indices (..., 3) and whether each lies inside the volume.""" + idx = np.rint((points - self.origin) / self.voxel).astype(np.int64) + inside = np.all((idx >= 0) & (idx < np.array(self.shape)), axis=-1) + return np.clip(idx, 0, np.array(self.shape) - 1), inside + + def lookup(self, points: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """(tsdf, weight) at the nearest voxel; weight 0 outside the volume.""" + idx, inside = self.ijk(points) + t = self.tsdf[idx[..., 0], idx[..., 1], idx[..., 2]] + w = np.where(inside, self.weight[idx[..., 0], idx[..., 1], idx[..., 2]], 0.0) + return t, w + + def occupied(self, points: np.ndarray) -> np.ndarray: + t, w = self.lookup(points) + return (w > 0) & (t < 0) + + def observed(self, points: np.ndarray) -> np.ndarray: + return self.lookup(points)[1] > 0 + + def blocked(self, start: np.ndarray, ends: np.ndarray, stop_short: np.ndarray) -> np.ndarray: + """Whether an occupied voxel lies on the segment from `start` to each end, ignoring the + last `stop_short` meters (the surface being looked at, within tolerance).""" + d = ends - start + length = np.linalg.norm(d, axis=-1) + reach = np.maximum(length - stop_short, 0.0) + step = self.voxel / 2 + n = int(np.ceil(reach.max() / step)) + 1 if len(reach) else 0 + out = np.zeros(len(ends), bool) + for i in range(0, len(ends), 4096): + sl = slice(i, i + 4096) + ts = np.arange(n) * step # meters along each segment + along = d[sl] / np.maximum(length[sl], 1e-9)[:, None] + pts = start + along[:, None, :] * ts[None, :, None] + occ = self.occupied(pts) & (ts[None, :] < reach[sl][:, None]) + out[sl] = occ.any(axis=1) + return out + + +def _points(depth: Depth, T: np.ndarray, stride: int = 8) -> np.ndarray: + """World points of a depth map, every `stride` pixels, within MAX_RANGE_M.""" + h, w = depth.depth.shape + v, u = np.mgrid[stride // 2 : h : stride, stride // 2 : w : stride] + z = depth.depth[v, u] + ok = np.isfinite(z) & (z > 0.1) & (z < MAX_RANGE_M) + fx, fy, cx, cy = depth.intrinsics + x = (u[ok] + 0.5 - cx) / fx * z[ok] + y = -(v[ok] + 0.5 - cy) / fy * z[ok] + cam = np.stack([x, y, -z[ok]], axis=-1) + return cam @ T[:3, :3].T + T[:3, 3] + + +def bounds(depths: dict[str, Depth], poses: dict[str, np.ndarray]) -> tuple[np.ndarray, np.ndarray]: + """The box holding the cameras and the middle 98% of what they saw, plus half a meter.""" + pts = np.concatenate([_points(depths[k], poses[k]) for k in depths]) + cams = np.array([poses[k][:3, 3] for k in depths]) + lo = np.minimum(np.percentile(pts, 1, axis=0), cams.min(axis=0)) - 0.5 + hi = np.maximum(np.percentile(pts, 99, axis=0), cams.max(axis=0)) + 0.5 + return lo, hi + + +def integrate( + depths: dict[str, Depth], poses: dict[str, np.ndarray], voxel: float | None = None +) -> Volume: + lo, hi = bounds(depths, poses) + size = hi - lo + if voxel is None: + voxel = max(MIN_VOXEL_M, float(np.cbrt(np.prod(size) / MAX_VOXELS))) + shape = tuple(int(np.ceil(s / voxel)) + 1 for s in size) + vol = Volume( + lo, + voxel, + shape, + np.ones(shape, np.float32), + np.zeros(shape, np.float32), + np.zeros((*shape, 3), np.float32), + np.zeros(shape, np.float32), + ) + flat_t, flat_w = vol.tsdf.reshape(-1), vol.weight.reshape(-1) + flat_c, flat_n = vol.color.reshape(-1, 3), vol.color_n.reshape(-1) + total = flat_t.size + grid = np.array(shape) + for key, dm in depths.items(): + T = poses[key] + R, c = T[:3, :3], T[:3, 3] + fx, fy, cx, cy = dm.intrinsics + h, w = dm.depth.shape + for start in range(0, total, CHUNK): + ids = np.arange(start, min(start + CHUNK, total)) + ijk = np.stack(np.unravel_index(ids, grid), axis=-1) + X = lo + ijk * voxel + p = (X - c) @ R # camera coordinates, ARKit axes + z = -p[:, 2] + ok = (z > 0.1) & (z <= MAX_RANGE_M) + u = np.full(len(z), -1.0) + v = np.full(len(z), -1.0) + u[ok] = cx + fx * p[ok, 0] / z[ok] + v[ok] = cy - fy * p[ok, 1] / z[ok] + ok &= (u >= 0) & (u < w) & (v >= 0) & (v < h) + sel = np.flatnonzero(ok) + d = dm.depth[v[sel].astype(np.int64), u[sel].astype(np.int64)] + sdf = d - z[sel] + good = np.isfinite(sdf) & (sdf > -vol.trunc) + sel, sdf = sel[good], sdf[good] + gid = ids[sel] + t = np.clip(sdf / vol.trunc, -1, 1).astype(np.float32) + wo = flat_w[gid] + flat_t[gid] = (flat_t[gid] * wo + t) / (wo + 1) + flat_w[gid] = wo + 1 + near = np.abs(sdf) < vol.trunc + ui, vi = u[sel][near].astype(np.int64), v[sel][near].astype(np.int64) + flat_c[gid[near]] += dm.color[vi, ui].astype(np.float32) + flat_n[gid[near]] += 1 + return vol + + +@dataclass +class Mesh: + vertices: np.ndarray # (V, 3) world meters + faces: np.ndarray # (F, 3) uint32 + normals: np.ndarray # (V, 3), pointing out of the surface toward where it was seen from + colors: np.ndarray # (V, 3) uint8 RGB + + +def mesh(vol: Volume) -> Mesh: + """The zero level of the observed volume. Unobserved voxels keep the initial +1, so marching + cubes also finds a false surface between the last observed voxel behind a wall (-1) and the + unobserved one beyond it, a truncation distance behind every real surface. Every vertex lies + on an edge between two voxels; vertices on an edge with an unobserved end are dropped, with + their faces.""" + observed = vol.weight > 0 + verts, faces, normals, _ = marching_cubes( + vol.tsdf, + level=0.0, + spacing=(vol.voxel,) * 3, + allow_degenerate=False, + ) + grid = verts / vol.voxel + hi = np.array(vol.shape) - 1 + lo_i = np.clip(np.floor(grid + 1e-6).astype(np.int64), 0, hi) + hi_i = np.clip(np.ceil(grid - 1e-6).astype(np.int64), 0, hi) + good = observed[tuple(lo_i.T)] & observed[tuple(hi_i.T)] + faces = faces[good[faces].all(axis=1)] + used = np.unique(faces) + remap = np.full(len(verts), -1, np.int64) + remap[used] = np.arange(len(used)) + verts, normals, faces = verts[used] + vol.origin, normals[used], remap[faces] + idx, _ = vol.ijk(verts) + n = vol.color_n[idx[:, 0], idx[:, 1], idx[:, 2]] + bgr = vol.color[idx[:, 0], idx[:, 1], idx[:, 2]] / np.maximum(n, 1)[:, None] + rgb = np.where(n[:, None] > 0, bgr[:, ::-1], 128).clip(0, 255).astype(np.uint8) + # scikit-image's normals point down the gradient, into the surface; flip them toward the seen + # side, and wind the faces counter-clockwise seen from there (glTF's front faces). Checked in + # tests/test_worker.py. + normals = -normals + v = verts[faces] + face_n = np.cross(v[:, 1] - v[:, 0], v[:, 2] - v[:, 0]) + if np.median(np.einsum("ij,ij->i", face_n, normals[faces].mean(axis=1))) < 0: + faces = faces[:, ::-1] + return Mesh(verts.astype(np.float32), faces.astype(np.uint32), normals.astype(np.float32), rgb) diff --git a/recon/recon/geometry.py b/recon/recon/geometry.py new file mode 100644 index 000000000..e054b4027 --- /dev/null +++ b/recon/recon/geometry.py @@ -0,0 +1,264 @@ +"""Wall and ground geometry fitted to the reconstruction, and the meter's wall frame. + +The ground is a least-squares plane through the reconstruction's upward-facing surface near the +phone's ground (or its lowest broad surface); the wall frame's ground height is that plane at the +meter's foot. Walls are vertical planes: straight lines in plan through the reconstruction's +vertical surface between 0.3 and 2 m above the local ground, found by repeated RANSAC. The wall +the phone marked is the fitted stretch along its line nearest the meter; without one, the wall is +the stretch the most frames see. + +Wall frame (the app's `WallGeometry`): `s` meters along the wall from the meter, positive to the +right for someone facing the wall; `out` meters from the wall toward the homeowner; height above +the ground. along = cross(-outward, up). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from recon.capture import UP, Capture +from recon.fusion import Mesh + +WALL_BAND_M = (0.3, 2.0) +LINE_TOL_M = 0.06 # walls step by several cm (pilasters, trim); a tapped wall line is off by more +MIN_STRETCH_M = 1.5 +GAP_M = 1.0 # doors and glass return no depth but are part of the wall +METER_HEIGHT_M = 1.5 + + +@dataclass(frozen=True) +class Ground: + point: np.ndarray # on the fitted plane: the centroid of the points it was fitted to + normal: np.ndarray # unit, pointing up + rms_m: float + points: int + + def height_at(self, x, z): + """The plane's y above plan position (x, z). On sloped ground this differs from the + samples' median height wherever the samples are not centred on (x, z).""" + px, py, pz = self.point + nx, ny, nz = self.normal + return py - (nx * (np.asarray(x) - px) + nz * (np.asarray(z) - pz)) / ny + + +@dataclass(frozen=True) +class WallLine: + foot: np.ndarray # a point on the wall's foot (y = ground) + along: np.ndarray + outward: np.ndarray + extent: tuple[float, float] # t along `along` from `foot`, where the fitted surface is + rms_m: float + points: int + + +@dataclass(frozen=True) +class WallFrame: + meter: np.ndarray # on the wall face + along: np.ndarray + outward: np.ndarray + ground_y: float + s_range: tuple[float, float] # the fitted wall's extent in s + meter_moved_m: float = 0.0 # how far the phone's meter was moved onto the fitted wall face + + @property + def origin(self) -> np.ndarray: + return np.array([self.meter[0], self.ground_y, self.meter[2]]) + + def world(self, s, height, out=0.0) -> np.ndarray: + s, height, out = np.broadcast_arrays( + np.asarray(s, float), np.asarray(height, float), np.asarray(out, float) + ) + return ( + self.origin + + s[..., None] * self.along + + out[..., None] * self.outward + + height[..., None] * UP + ) + + def local(self, points: np.ndarray) -> np.ndarray: + """(s, height, out) of world points.""" + d = points - self.origin + return np.stack([d @ self.along, d[..., 1], d @ self.outward], axis=-1) + + +def fit_ground(mesh: Mesh, hint_y: float | None) -> Ground: + up = mesh.normals @ UP > 0.9 + y = mesh.vertices[:, 1] + cand = mesh.vertices[up] + if hint_y is not None: + cand = cand[np.abs(cand[:, 1] - hint_y) < 0.3] + else: + # The lowest broad surface: the 10th percentile of upward-facing heights, +-0.3 m. + base = np.percentile(y[up], 10) if up.any() else np.percentile(y, 5) + cand = cand[np.abs(cand[:, 1] - base) < 0.3] + if len(cand) < 50: + raise RuntimeError( + f"only {len(cand)} ground points in the reconstruction; " + "the photos need to show the ground" + ) + for _ in range(3): # least squares, then drop points more than 3 RMS off and refit + c = cand.mean(axis=0) + n = np.linalg.svd(cand - c, full_matrices=False)[2][2] + n = n if n[1] > 0 else -n + r = (cand - c) @ n + rms = float(np.sqrt(np.mean(r**2))) + cand = cand[np.abs(r) <= max(3 * rms, 0.02)] + return Ground(c, n, rms, len(cand)) + + +def _runs(t: np.ndarray, gap: float, min_length: float) -> list[tuple[float, float]]: + t = np.sort(t) + if len(t) == 0: + return [] + breaks = np.flatnonzero(np.diff(t) > gap) + starts, ends = np.r_[0, breaks + 1], np.r_[breaks, len(t) - 1] + return [ + (float(t[i]), float(t[j])) + for i, j in zip(starts, ends, strict=True) + if t[j] - t[i] >= min_length + ] + + +def wall_lines( + mesh: Mesh, ground: Ground, cameras: np.ndarray, rng: np.random.Generator +) -> list[WallLine]: + """Straight wall stretches in the reconstruction, each with its outward side toward the + cameras that saw it.""" + v = mesh.vertices + h = v[:, 1] - ground.height_at(v[:, 0], v[:, 2]) + vertical = (np.abs(mesh.normals @ UP) < 0.3) & (h > WALL_BAND_M[0]) & (h < WALL_BAND_M[1]) + plan = np.unique(np.floor(v[vertical][:, [0, 2]] / 0.05), axis=0) * 0.05 + 0.025 + lines, remaining = [], plan + for _ in range(15): + if len(remaining) < 100: + break + best = None + for _ in range(300): + p, q = remaining[rng.choice(len(remaining), 2, replace=False)] + d = q - p + if np.linalg.norm(d) < 0.5: + continue + n = np.array([-d[1], d[0]]) / np.linalg.norm(d) + inl = np.abs((remaining - p) @ n) < LINE_TOL_M + if best is None or inl.sum() > best.sum(): + best = inl + if best is None: + break + pts = remaining[best] + c = pts.mean(axis=0) + d2 = np.linalg.svd(pts - c, full_matrices=False)[2][0] + n2 = np.array([-d2[1], d2[0]]) + rms = float(np.sqrt(np.mean(((pts - c) @ n2) ** 2))) + t = (pts - c) @ d2 + for lo, hi in _runs(t, GAP_M, MIN_STRETCH_M): + along = np.array([d2[0], 0.0, d2[1]]) + outward = np.array([-along[2], 0.0, along[0]]) + foot = np.array([c[0], float(ground.height_at(c[0], c[1])), c[1]]) + mid = foot + along * (lo + hi) / 2 + if np.median((cameras - mid) @ outward) < 0: # outward faces the cameras + along, outward, lo, hi = -along, -outward, -hi, -lo + lines.append(WallLine(foot, along, outward, (lo, hi), rms, int(best.sum()))) + remaining = remaining[~best] + return lines + + +MATCH_ANGLE_DEG = 25 +MATCH_OFFSET_M = 0.6 # a tapped wall line is within a few inches of the wall; 0.6 m is generous +METER_MOVE_LIMIT_M = 0.9 # the server refuses a meter over 3 ft from its wall + + +class WallMismatch(RuntimeError): + """The phone's wall has no reconstructed surface behind it.""" + + +def frames_seeing(line: WallLine, frames) -> int: + """How many frames see at least 1 m of the line at 1 m up, within range and in frame.""" + ts = np.arange(line.extent[0], line.extent[1], 0.2) + pts = line.foot + ts[:, None] * line.along + UP * 1.0 + count = 0 + for f in frames: + p = (pts - f.center) @ f.cam_to_world[:3, :3] + z = -p[:, 2] + with np.errstate(divide="ignore", invalid="ignore"): + u = f.intrinsics[2] + f.intrinsics[0] * p[:, 0] / z + v = f.intrinsics[3] - f.intrinsics[1] * p[:, 1] / z + inside = (z > 0.1) & (z <= 6.0) & (u >= 0) & (u <= f.width) & (v >= 0) & (v <= f.height) + count += int(inside.sum() >= 5) + return count + + +def beyond_extent(line: WallLine, point: np.ndarray) -> float: + """How far past the ends of the line's fitted stretch a point lies, along the line (0 when + it is beside the stretch).""" + t = float((point - line.foot) @ line.along) + return max(line.extent[0] - t, t - line.extent[1], 0.0) + + +def choose_wall(lines: list[WallLine], capture: Capture, move_meter: bool) -> WallLine: + """The stretch matching the phone's wall: within 25 degrees and 0.6 m of its line, and of + those the one whose fitted extent lies nearest the meter (the phone's first wall point + without one). RANSAC splits a wall at a doorway into collinear stretches that share one + infinite line, so the line's offset alone cannot tell them apart. With no match this refuses, + since the homeowner's wall and meter would be silently replaced, unless `move_meter` allows + the most-seen wall. Without a phone wall, the most-seen wall.""" + if not lines: + raise RuntimeError("no straight vertical wall found in the reconstruction") + most_seen = max( + lines, + key=lambda line: (frames_seeing(line, capture.frames), line.extent[1] - line.extent[0]), + ) + hint = capture.wall + if hint is None: + return most_seen + + def offset(line: WallLine) -> float: + parallel = abs(line.along @ hint.along) > np.cos(np.radians(MATCH_ANGLE_DEG)) + return abs((hint.point - line.foot) @ line.outward) if parallel else np.inf + + anchor = capture.meter if capture.meter is not None else hint.point + matching = [line for line in lines if offset(line) <= MATCH_OFFSET_M] + if matching: + return min(matching, key=lambda line: (beyond_extent(line, anchor), offset(line))) + nearest = min(offset(line) for line in lines) / 0.3048 + msg = ( + f"no reconstructed wall lies along the phone's wall line (the nearest parallel one is " + f"{nearest:.1f} ft away)" + ) + if not move_meter: + raise WallMismatch(f"{msg}; rerun with --move-meter to use the most-seen wall instead") + capture.notes.append(f"{msg}: the meter was moved onto the most-seen wall (--move-meter)") + return most_seen + + +def wall_frame(line: WallLine, capture: Capture, ground: Ground, move_meter: bool) -> WallFrame: + """The meter's frame on the fitted wall: the phone's meter moved onto the fitted wall face, + which must be within 3 ft unless `move_meter`; without one, a meter assumed mid-stretch, + 1.5 m up, and said so. The frame's ground height is the ground plane at the meter's foot.""" + if capture.meter is not None: + t = (capture.meter - line.foot) @ line.along + move = abs((capture.meter - line.foot) @ line.outward) + if move > METER_MOVE_LIMIT_M and not move_meter: + raise WallMismatch( + f"the phone's meter is {move / 0.3048:.1f} ft off the reconstructed wall; " + "rerun with --move-meter to move it onto the wall" + ) + if move > METER_MOVE_LIMIT_M: # moved: keep it on the stretch that was reconstructed + t = float(np.clip(t, *line.extent)) + meter = line.foot + line.along * t + meter[1] = capture.meter[1] + moved = float(np.linalg.norm((meter - capture.meter)[[0, 2]])) + else: + t = sum(line.extent) / 2 + meter = line.foot + line.along * t + meter[1] = ground.height_at(meter[0], meter[2]) + METER_HEIGHT_M + moved = 0.0 + capture.notes.append( + "the capture marks no electric meter: one is assumed mid-wall, 1.5 m up, so the " + "placement is a demonstration, not an answer for this house" + ) + # A meter past the reconstructed end of its wall extends the wall to it. + lo, hi = min(line.extent[0] - t, 0.0), max(line.extent[1] - t, 0.0) + ground_y = float(ground.height_at(meter[0], meter[2])) + return WallFrame(meter, line.along, line.outward, ground_y, (lo, hi), moved) diff --git a/recon/recon/glb.py b/recon/recon/glb.py new file mode 100644 index 000000000..b8a42713c --- /dev/null +++ b/recon/recon/glb.py @@ -0,0 +1,107 @@ +"""A binary glTF 2.0 (.glb) of a colored triangle mesh, viewable in macOS Quick Look. + +glTF is y-up, right-handed and in meters, the same as ARKit's world, so vertices go in unchanged. +Vertex colors are normalized unsigned bytes, the smallest form glTF allows. +""" + +from __future__ import annotations + +import json +import struct +from pathlib import Path + +import numpy as np + +GLB_MAGIC = 0x46546C67 +JSON_CHUNK = 0x4E4F534A +BIN_CHUNK = 0x004E4942 +ARRAY_BUFFER, ELEMENT_ARRAY_BUFFER = 34962, 34963 +FLOAT, UNSIGNED_BYTE, UNSIGNED_INT = 5126, 5121, 5125 + + +def _pad(b: bytes, fill: bytes) -> bytes: + return b + fill * ((4 - len(b) % 4) % 4) + + +def write_glb( + path: Path, + vertices: np.ndarray, + faces: np.ndarray, + colors: np.ndarray, + normals: np.ndarray | None = None, +) -> None: + v = np.ascontiguousarray(vertices, np.float32) + f = np.ascontiguousarray(faces, np.uint32) + c = np.ascontiguousarray(np.c_[colors, np.full(len(colors), 255)], np.uint8) + if len(v) == 0 or len(f) == 0: + raise ValueError("empty mesh: nothing to write") + if f.max() >= len(v) or len(c) != len(v): + raise ValueError("faces index past the vertices, or colors do not match vertices") + parts = [ + ("POSITION", v.tobytes(), FLOAT, "VEC3", True), + ("COLOR_0", c.tobytes(), UNSIGNED_BYTE, "VEC4", False), + ] + if normals is not None: + n = np.ascontiguousarray(normals, np.float32) + n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9) + parts.append(("NORMAL", n.tobytes(), FLOAT, "VEC3", False)) + blob, views, accessors, attributes = b"", [], [], {} + for name, data, ctype, kind, bounds in parts: + views.append( + {"buffer": 0, "byteOffset": len(blob), "byteLength": len(data), "target": ARRAY_BUFFER} + ) + acc = {"bufferView": len(views) - 1, "componentType": ctype, "count": len(v), "type": kind} + if ctype == UNSIGNED_BYTE: + acc["normalized"] = True + if bounds: + acc["min"], acc["max"] = v.min(axis=0).tolist(), v.max(axis=0).tolist() + accessors.append(acc) + attributes[name] = len(accessors) - 1 + blob = _pad(blob + data, b"\0") + views.append( + { + "buffer": 0, + "byteOffset": len(blob), + "byteLength": f.nbytes, + "target": ELEMENT_ARRAY_BUFFER, + } + ) + accessors.append( + { + "bufferView": len(views) - 1, + "componentType": UNSIGNED_INT, + "count": f.size, + "type": "SCALAR", + } + ) + blob = _pad(blob + f.tobytes(), b"\0") + doc = { + "asset": {"version": "2.0", "generator": "house-scanning recon"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [ + {"primitives": [{"attributes": attributes, "indices": len(accessors) - 1, "mode": 4}]} + ], + "buffers": [{"byteLength": len(blob)}], + "bufferViews": views, + "accessors": accessors, + } + js = _pad(json.dumps(doc, separators=(",", ":")).encode(), b" ") + total = 12 + 8 + len(js) + 8 + len(blob) + with path.open("wb") as fh: + fh.write(struct.pack(" tuple[dict, bytes]: + """The JSON document and binary chunk of a .glb, for checks.""" + data = path.read_bytes() + magic, version, total = struct.unpack_from(" depth maps (LiDAR, or MoGe-2 rescaled by the AR poses) -> fused volume and +mesh -> ground and wall geometry -> occlusion-aware coverage -> scene.json -> placement server. +""" + +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +import numpy as np + +from recon import capture as cap +from recon import coverage, depth, fusion, geometry, scene, server +from recon.capture import FEET, Capture +from recon.glb import write_glb + +# The fitted wall line's `source` in scene.json, by depth source. The server gives a wall without +# `plus_minus_ft` its source's default error plus drift per foot walked from the meter (mesh 0.5 ft +# or plane 0.75 ft, plus 0.16 ft/ft, in server/rules.yaml on t3/server), so the worker sends no +# wall bound: neither depth path has a measured error for a wall longer than the 1 to 3 m spans +# the evals measured. "plane" is the schema's non-LiDAR source, and its default is the larger one. +WALL_SOURCE = {"lidar": "mesh", "moge2-triangulated": "plane"} + +# Error bars on the facing gaps and overhead clearances the worker measures, by depth source. +# These are short distances out from or up from the wall, not positions along it, and the server +# adds no drift to them. MoGe-2: the upper end of the 95% interval of the ETH3D surface p90 over +# 1 to 3 m spans at an assumed 2% pose error, 6.7 in (experiments/evals README section 3 on +# t3/evals). LiDAR: no measured value exists here; 0.5 ft is the server's default for mesh +# measurements (errors.mesh_ft, a day-1 estimate). +PLUS_MINUS_FT = {"moge2-triangulated": 0.56, "lidar": 0.5} + + +def _log(msg: str) -> None: + print(f"recon: {msg}", file=sys.stderr, flush=True) + + +def reconstruct( + capture: Capture, depths: dict, depth_report: dict, move_meter: bool = False +) -> dict: + """Everything after depth, up to coverage, for the writers and the acceptance checks.""" + t0 = time.perf_counter() + source = depth_report["source"] + poses = {f.id: f.cam_to_world for f in capture.frames} + vol = fusion.integrate(depths, poses) + surface = fusion.mesh(vol) + _log(f"fused at {vol.voxel * 100:.1f} cm voxels: {len(surface.vertices)} vertices") + ground = geometry.fit_ground(surface, capture.ground_y) + cams = np.array([f.center for f in capture.frames]) + lines = geometry.wall_lines(surface, ground, cams, np.random.default_rng(0)) + line = geometry.choose_wall(lines, capture, move_meter) + wall = geometry.wall_frame(line, capture, ground, move_meter) + # Only frames with a depth map can see anything (depth.depth_maps: LiDAR frames saved + # without depth get none). + seeing = [f for f in capture.frames if f.id in depths] + cov = coverage.compute(wall, seeing, depths, vol, surface) + _log(f"coverage over {len(cov.cells)} cells ({time.perf_counter() - t0:.0f} s)") + return { + "depths": depths, + "depth_report": depth_report, + "source": source, + "volume": vol, + "mesh": surface, + "ground": ground, + "lines": lines, + "line": line, + "wall": wall, + "coverage": cov, + } + + +def geometry_doc(r: dict) -> dict: + """Walls and ground for the rules engine: the scene frame in feet, and the meter's frame.""" + wall, line, ground = r["wall"], r["line"], r["ground"] + lo, hi = wall.s_range + ends = [wall.world(s, 0.0) for s in (lo, hi)] + return { + "units": "ft", + "depth_source": r["source"], + "accuracy": depth.ACCURACY_NOTE[r["source"]], + "meter": {"pos": [round(float(v) / FEET, 4) for v in wall.meter]}, + "walls": [ + { + "id": scene.WALL_ID, + "baseline": [ + [round(float(p[0]) / FEET, 4), round(float(p[2]) / FEET, 4)] for p in ends + ], + "s_range_ft": [round(lo / FEET, 3), round(hi / FEET, 3)], + "along": [round(float(v), 5) for v in wall.along], + "outward": [round(float(v), 5) for v in wall.outward], + "fit_rms_in": round(line.rms_m / FEET * 12, 2), + } + ], + "other_walls": [ + { + "baseline": [ + [round(float(p[0]) / FEET, 4), round(float(p[2]) / FEET, 4)] + for p in (w.foot + w.along * w.extent[0], w.foot + w.along * w.extent[1]) + ], + "fit_rms_in": round(w.rms_m / FEET * 12, 2), + } + for w in r["lines"] + if w is not line + ], + "ground": { + "height_ft": round(wall.ground_y / FEET, 4), # the plane at the meter's foot + "normal": [round(float(v), 5) for v in ground.normal], + "tilt_deg": round(float(np.degrees(np.arccos(min(1.0, ground.normal[1])))), 2), + "fit_rms_in": round(ground.rms_m / FEET * 12, 2), + }, + } + + +def write_model(r: dict, path: Path) -> None: + """The mesh in the meter's frame (origin at the meter's foot, axes as the world's).""" + m = r["mesh"] + write_glb(path, m.vertices - r["wall"].origin, m.faces, m.colors, m.normals) + + +def run( + bundle: Path, out: Path, work: Path, depth_mode: str, server_url: str | None, move_meter: bool +) -> dict: + out.mkdir(parents=True, exist_ok=True) + capture = cap.load(bundle, work) + _log(f"{capture.source}: {len(capture.frames)} frames from {bundle}") + t0 = time.perf_counter() + depths, depth_report = depth.depth_maps(capture, work, depth_mode) + _log(f"depth from {depth_report['source']} ({time.perf_counter() - t0:.0f} s)") + r = reconstruct(capture, depths, depth_report, move_meter) + write_model(r, out / "model.glb") + geo = geometry_doc(r) + (out / "geometry.json").write_text(json.dumps(geo, indent=1)) + cov = r["coverage"] + src = r["source"] + doc = scene.build(capture, r["wall"], cov, WALL_SOURCE[src], PLUS_MINUS_FT[src]) + (out / "scene.json").write_text(json.dumps(doc, indent=1)) + (out / "coverage.json").write_text(json.dumps(_coverage_doc(cov), indent=1)) + result = None + if server_url: + result = server.place(doc, server_url, out) + _log(f"server: {result.get('decision')}") + report = _report(capture, r, geo, doc, result) + (out / "report.md").write_text(report) + return {"report": report, "result": result} + + +def _coverage_doc(cov: coverage.CellCoverage) -> dict: + ft = lambda a: [None if not np.isfinite(x) else round(float(x) / FEET, 3) for x in a] # noqa: E731 + return { + "cell_ft": round(coverage.CELL_M / FEET, 3), + "cells_s_ft": ft(cov.cells), + "wall_observed": cov.wall.tolist(), + "ground_out_ft": ft(cov.ground_out), + "facing_gap_ft": ft(cov.facing_gap), + "facing_clear_ft": ft(cov.facing_clear), + "overhead_clearance_ft": ft(cov.overhead_clearance), + "overhead_clear_ft": ft(cov.overhead_clear), + } + + +def _report(capture: Capture, r: dict, geo: dict, doc: dict, result: dict | None) -> str: + cov, dr = r["coverage"], r["depth_report"] + cell = coverage.CELL_M / FEET + wall, ground = geo["walls"][0], geo["ground"] + lo, hi = wall["s_range_ft"] + lines = [ + f"# Reconstruction of {capture.root.name}", + "", + f"- Input: {capture.source}, {len(capture.frames)} frames.", + f"- Depth: {r['source']}. {depth.ACCURACY_NOTE[r['source']]}", + ] + if r["source"] == "moge2-triangulated": + a, b = dr["scale_range"] + lines.append( + f"- Scale fitted for {dr['fitted']} of {dr['frames']} frames: MoGe-2 x " + f"{dr['median_scale']:.3f} median ({a:.3f} to {b:.3f}); the rest take the median." + ) + facing = np.isfinite(cov.facing_gap).sum() * cell + over = np.isfinite(cov.overhead_clearance).sum() * cell + obs = doc["coverage"]["observed"] + lines += [ + f"- Model: {len(r['mesh'].vertices)} vertices at {r['volume'].voxel * 100:.1f} cm voxels.", + f"- Wall: {lo:.1f} to {hi:.1f} ft of s, plane fit RMS {wall['fit_rms_in']:.1f} in; " + f"{len(geo['other_walls'])} other wall stretches.", + f"- Ground: {ground['height_ft']:+.2f} ft, tilt {ground['tilt_deg']:.1f} deg, " + f"fit RMS {ground['fit_rms_in']:.1f} in.", + f"- Wall band observed: {cov.wall.sum() * cell:.1f} of {len(cov.cells) * cell:.1f} ft.", + f"- Ground seen 4 ft out or more: {(cov.ground_out >= 4 * FEET).sum() * cell:.1f} ft.", + f"- Facing gap measured over {facing:.1f} ft; overhead clearance over {over:.1f} ft.", + f"- scene.json: {len(obs)} observed entries, {len(doc['facing'])} facing, " + f"{len(doc['overheads'])} overheads.", + ] + lines += [f"- Note: {n}" for n in capture.notes] + if result is not None: + lines += ["", f"## Server: {result.get('decision')}", "", result.get("summary", ""), ""] + lines += ["| Check | Outcome | Reason |", "| --- | --- | --- |"] + for c in result.get("checks", []): + reason = str(c.get("reason", "")).replace("|", "/") + lines.append(f"| {c.get('id', c.get('check'))} | {c.get('outcome')} | {reason} |") + return "\n".join(lines) + "\n" diff --git a/recon/recon/scene.py b/recon/recon/scene.py new file mode 100644 index 000000000..6f0ce4bcc --- /dev/null +++ b/recon/recon/scene.py @@ -0,0 +1,97 @@ +"""scene.json out: the reconstruction's wall line, coverage and measured gaps in the server's +contract (server/schemas/scene.schema.json and "What settles each check" in server/README.md on +t3/server). Feet, in the scene frame: the world with the ground at the wall at y = 0. + +A scan bundle's scene is updated: the meter's wall takes the fitted line (over the union of its +fitted extent and the phone's baseline), `coverage.observed` is replaced by the occlusion-aware +coverage, and the measured facing gaps and overhead clearances are added to whatever the phone +marked. A Measure Lab session gets a new scene with the same parts. + +The fitted wall takes the source of the depth that built it (`pipeline.WALL_SOURCE`: "mesh" from +LiDAR, "plane" from photos alone) and no `plus_minus_ft`; a bundle's tapped bound is removed. The +server then applies that source's default error plus its drift per foot walked from the meter +(errors.mesh_ft, plane_ft and drift_per_ft in server/rules.yaml on t3/server). An explicit bound +would switch the drift off: the server adds none to a wall that carries one, so a LiDAR wall +30 ft from the meter would stay at 0.5 ft instead of about 5.3 ft. Keeping the old line's +provenance would be wrong too: a bundle's tap source and bound, or with none the tap default of +0.3 ft, describe a line the reconstruction replaced. +""" + +from __future__ import annotations + +import copy + +import numpy as np + +from recon.capture import FEET, Capture +from recon.coverage import CellCoverage, measurements, observed +from recon.geometry import WallFrame + +WALL_ID = "wall" + + +def _plan_ft(p: np.ndarray) -> list[float]: + return [round(float(p[0]) / FEET, 4), round(float(p[2]) / FEET, 4)] + + +def build( + capture: Capture, wall: WallFrame, cov: CellCoverage, wall_source: str, plus_minus_ft: float +) -> dict: + """The scene to send. `wall_source` labels the fitted wall line; `plus_minus_ft` is the error + of the measured facing gaps and overhead clearances. For a bundle the scene frame is the + bundle's own (its ground is y = 0 by the phone's estimate); for a session it is ARKit's world + lowered to the fitted ground.""" + shift = 0.0 if capture.scene is not None else wall.ground_y + lo, hi = wall.s_range + if capture.scene is not None: + doc = copy.deepcopy(capture.scene) + wid = doc["meter"]["wall_id"] + target = next(w for w in doc["walls"] if w["id"] == wid) + if wall.meter_moved_m > 0.05: + # The phone's marks were placed against a wall line the reconstruction does not have. + doc["meter"]["pos"] = [round(float(v) / FEET, 4) for v in wall.meter] + doc["objects"] = [] + doc["facing"] = [] + doc["overheads"] = [] + old = np.array(target["baseline"], dtype=np.float64) * FEET + if wall.meter_moved_m <= 0.05: + old_s = [(np.array([x, 0.0, z]) - wall.origin) @ wall.along for x, z in old] + lo, hi = min(lo, *old_s), max(hi, *old_s) + else: + wid = WALL_ID + m = wall.meter - np.array([0.0, shift, 0.0]) + doc = { + "schema_version": "1.0", + "meter": {"pos": [round(float(v) / FEET, 4) for v in m], "wall_id": wid}, + "walls": [{"id": wid, "baseline": []}], + "objects": [], + "ground": [], + "coverage": {"ends": {"left": {"kind": "unexplored"}, "right": {"kind": "unexplored"}}}, + "keyframes": [ + { + "id": f.id, + "pose": _pose_ft(f.cam_to_world, shift), + "intrinsics": [round(float(v), 4) for v in f.intrinsics], + "w": f.width, + "h": f.height, + "img": f.image.name, + } + for f in capture.frames + ], + } + target = doc["walls"][0] + target["baseline"] = [_plan_ft(wall.world(s, 0.0)) for s in (lo, hi)] + target["source"] = wall_source + target.pop("plus_minus_ft", None) + doc.setdefault("coverage", {})["observed"] = observed(cov)[:500] + facing, overheads = measurements(cov, wid, plus_minus_ft) + doc["facing"] = [*doc.get("facing", []), *facing] + doc["overheads"] = [*doc.get("overheads", []), *overheads] + return doc + + +def _pose_ft(T: np.ndarray, shift: float) -> list[float]: + P = T.copy() + P[1, 3] -= shift + P[:3, 3] /= FEET + return [round(float(v), 4) for v in P.T.reshape(-1)] diff --git a/recon/recon/server.py b/recon/recon/server.py new file mode 100644 index 000000000..7a31b5297 --- /dev/null +++ b/recon/recon/server.py @@ -0,0 +1,46 @@ +"""Posting a scene to the placement server (server/README.md, "API", on t3/server).""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from pathlib import Path + +DEFAULT_URL = "https://house-scanning-server.vercel.app" +TIMEOUT_S = 60 + + +def _post(url: str, body: bytes) -> tuple[int, bytes]: + # The public demo needs no key. The key-protected deployment's scheme is not known here. + headers = {"Content-Type": "application/json"} + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + +def place(scene: dict, base_url: str, out: Path) -> dict: + """POSTs bare scene.json (the server reads no images, and Vercel caps bodies at 4.5 MB) and + saves result.json and site-plan.svg. A refusal of either raises; a refused placement's body + is saved as result.json. Both files are deleted first, so a reused output folder never shows + a previous run's answer or plan beside this run's scene.""" + for name in ("result.json", "site-plan.svg"): + (out / name).unlink(missing_ok=True) + body = json.dumps(scene).encode() + status, data = _post(f"{base_url.rstrip('/')}/v1/placements", body) + (out / "result.json").write_bytes(data) + if status != 200: + raise RuntimeError( + f"server refused the scene ({status}): {data[:500].decode(errors='replace')}" + ) + status, svg = _post(f"{base_url.rstrip('/')}/v1/placements/site-plan.svg", body) + if status != 200: + raise RuntimeError( + f"server placed the scene but refused its site plan ({status}): " + f"{svg[:500].decode(errors='replace')}" + ) + (out / "site-plan.svg").write_bytes(svg) + return json.loads(data) diff --git a/recon/recon/triangulate.py b/recon/recon/triangulate.py new file mode 100644 index 000000000..de45003df --- /dev/null +++ b/recon/recon/triangulate.py @@ -0,0 +1,215 @@ +"""Per-photo scale factors for predicted depth, from features triangulated with given poses. + +Learned metric depth gets each photo's scale wrong by a few percent, differently per photo. Poses +from a phone's AR tracking are metric (with their own error). This module matches SIFT features +across a group of photos, triangulates each match with the given poses and intrinsics, and fits +one factor per photo that makes its predicted depth agree with the triangulated depths: +scale = median(z_tri / z_pred) over the points that photo sees. Multiplying the photo's depth map +by `scale` puts it on the poses' metric. + +Conventions: OpenCV cameras (+x right, +y down, +z forward), pixel centres at integers, poses as +4x4 camera-to-world in meters, depth as z along the camera axis. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import combinations + +import cv2 +import numpy as np + +MAX_FEATURES = 4000 +RATIO = 0.8 +MAD_TO_SIGMA = 1.4826 # MAD of a normal distribution times this is its standard deviation + + +@dataclass(frozen=True) +class ScaleFit: + scale: float | None # multiply the photo's predicted depth by this; None if too few points + points: int # triangulated points used for this photo + spread: float | None # 1.4826 * MAD of log(z_tri / z_pred) over those points (dimensionless) + + +@dataclass(frozen=True) +class PairPoints: + X: np.ndarray # (N, 3) world points + keep: np.ndarray # (N,) bool: in front of both cameras, reprojects, wide enough angle + + +def sample_depth(depth: np.ndarray, uv: np.ndarray) -> np.ndarray: + """Bilinear depth at float pixel positions (OpenCV integer-centred). NaN where any of the four + neighbours is NaN, non-positive or outside the image: blending across a hole or an edge of + valid depth would invent a value.""" + d = np.where(np.isfinite(depth) & (depth > 0), depth, np.nan).astype(np.float64) + h, w = d.shape + x, y = uv[:, 0], uv[:, 1] + x0, y0 = np.floor(x).astype(np.int64), np.floor(y).astype(np.int64) + fx, fy = x - x0, y - y0 + out = np.full(len(uv), np.nan) + ok = (x0 >= 0) & (y0 >= 0) & (x0 + 1 < w) & (y0 + 1 < h) + x0, y0, fx, fy = x0[ok], y0[ok], fx[ok], fy[ok] + out[ok] = ( + d[y0, x0] * (1 - fx) * (1 - fy) + + d[y0, x0 + 1] * fx * (1 - fy) + + d[y0 + 1, x0] * (1 - fx) * fy + + d[y0 + 1, x0 + 1] * fx * fy + ) + return out + + +def projection(K: np.ndarray, cam_to_world: np.ndarray) -> np.ndarray: + """3x4 P = K [R | t] with R, t world-to-camera.""" + R = cam_to_world[:3, :3].T + t = -R @ cam_to_world[:3, 3] + return K @ np.c_[R, t] + + +def triangulate(P1: np.ndarray, P2: np.ndarray, x1: np.ndarray, x2: np.ndarray) -> np.ndarray: + """Linear (DLT) triangulation of (N, 2) pixel correspondences into (N, 3) world points. + + Each view gives two rows of A X = 0 (u P[2] - P[0], v P[2] - P[1]); X is A's right singular + vector with the smallest singular value. Rows are normalised to unit length so pixel units do + not weight one view over the other. A point at infinity comes back as inf or NaN. + """ + A = np.stack( + [ + x1[:, :1] * P1[2] - P1[0], + x1[:, 1:2] * P1[2] - P1[1], + x2[:, :1] * P2[2] - P2[0], + x2[:, 1:2] * P2[2] - P2[1], + ], + axis=1, + ) + A /= np.linalg.norm(A, axis=2, keepdims=True) + Xh = np.linalg.svd(A)[2][:, -1] + with np.errstate(divide="ignore", invalid="ignore"): + return Xh[:, :3] / Xh[:, 3:] + + +def triangulate_pair( + K1: np.ndarray, + T1: np.ndarray, + K2: np.ndarray, + T2: np.ndarray, + x1: np.ndarray, + x2: np.ndarray, + max_reproj_px: float, + min_angle_deg: float, +) -> PairPoints: + """Triangulate correspondences between two posed cameras and flag the trustworthy ones: in + front of both cameras, reprojecting within `max_reproj_px` in both, and seen along rays at + least `min_angle_deg` apart (with a tiny baseline, a sub-pixel error moves depth a lot).""" + P1, P2 = projection(K1, T1), projection(K2, T2) + X = triangulate(P1, P2, x1, x2) + keep = np.isfinite(X).all(axis=1) + Xh = np.c_[X, np.ones(len(X))] + for P, x in ((P1, x1), (P2, x2)): + p = Xh @ P.T + with np.errstate(divide="ignore", invalid="ignore"): + err = np.linalg.norm(p[:, :2] / p[:, 2:] - x, axis=1) + # K has a positive last row, so p's third coordinate has the sign of the camera-frame z. + keep &= (p[:, 2] > 0) & (err <= max_reproj_px) + r1 = X - T1[:3, 3] + r2 = X - T2[:3, 3] + with np.errstate(divide="ignore", invalid="ignore"): + cos = np.sum(r1 * r2, axis=1) / (np.linalg.norm(r1, axis=1) * np.linalg.norm(r2, axis=1)) + keep &= cos <= np.cos(np.radians(min_angle_deg)) + return PairPoints(X=X, keep=keep) + + +def match(desc1: np.ndarray, desc2: np.ndarray, ratio: float = RATIO) -> np.ndarray: + """(M, 2) index pairs (into desc1, desc2): Lowe's ratio test from 1 to 2, and 1's best match + in 2 must pick 1 back as its best match in 1 (mutual).""" + if len(desc1) < 2 or len(desc2) < 2: + return np.empty((0, 2), np.int64) + bf = cv2.BFMatcher(cv2.NORM_L2) + fwd = bf.knnMatch(desc1, desc2, k=2) + back = np.array([m[0].trainIdx for m in bf.knnMatch(desc2, desc1, k=1)]) + out = [ + (m[0].queryIdx, m[0].trainIdx) + for m in fwd + if len(m) == 2 + and m[0].distance < ratio * m[1].distance + and back[m[0].trainIdx] == m[0].queryIdx + ] + return np.array(out, np.int64).reshape(-1, 2) + + +def _check_inputs(images, K, cam_to_world, depth) -> None: + names = set(images) + for label, d in (("K", K), ("cam_to_world", cam_to_world), ("depth", depth)): + if set(d) != names: + raise ValueError( + f"{label} names {sorted(d)} do not match images {sorted(names)}: every photo " + "needs an image, intrinsics, a pose and a depth map" + ) + if len(names) < 2: + raise ValueError(f"need at least 2 photos to triangulate, got {len(names)}") + for n in names: + img = images[n] + if img.ndim != 2 or img.dtype != np.uint8: + raise ValueError(f"{n}: image must be grayscale uint8 HxW, got {img.dtype} {img.shape}") + if depth[n].shape != img.shape: + raise ValueError(f"{n}: depth {depth[n].shape} != image {img.shape}") + if K[n].shape != (3, 3): + raise ValueError(f"{n}: K must be 3x3, got {K[n].shape}") + T = cam_to_world[n] + if T.shape != (4, 4): + raise ValueError(f"{n}: cam_to_world must be 4x4, got {T.shape}") + R = T[:3, :3] + if not np.allclose(R.T @ R, np.eye(3), atol=1e-6) or np.linalg.det(R) < 0: + raise ValueError(f"{n}: cam_to_world rotation is not a proper rotation") + + +def view_scales( + images: dict[str, np.ndarray], + K: dict[str, np.ndarray], + cam_to_world: dict[str, np.ndarray], + depth: dict[str, np.ndarray], + min_points: int = 20, + max_reproj_px: float = 2.0, + min_angle_deg: float = 2.0, +) -> dict[str, ScaleFit]: + """{photo: ScaleFit} for a group of posed photos with predicted depth. + + Every pair of photos is matched and triangulated. A kept point counts once for each photo of + its pair: z_tri is its depth in that photo's camera, z_pred the predicted depth bilinearly + sampled at that photo's keypoint (skipped where invalid). The median ratio is robust to the + mismatches that survive the filters. + """ + _check_inputs(images, K, cam_to_world, depth) + sift = cv2.SIFT_create(nfeatures=MAX_FEATURES) + feats = {} + for n, img in images.items(): + kps, desc = sift.detectAndCompute(img, None) + uv = np.array([kp.pt for kp in kps], np.float64).reshape(-1, 2) + feats[n] = (uv, np.zeros((0, 128), np.float32) if desc is None else desc) + + ratios: dict[str, list[np.ndarray]] = {n: [] for n in images} + for a, b in combinations(sorted(images), 2): + idx = match(feats[a][1], feats[b][1]) + if len(idx) == 0: + continue + xa, xb = feats[a][0][idx[:, 0]], feats[b][0][idx[:, 1]] + pts = triangulate_pair( + K[a], cam_to_world[a], K[b], cam_to_world[b], xa, xb, max_reproj_px, min_angle_deg + ) + X = pts.X[pts.keep] + for n, x in ((a, xa[pts.keep]), (b, xb[pts.keep])): + T = cam_to_world[n] + z_tri = (X - T[:3, 3]) @ T[:3, 2] # camera z axis in world = third column of R_cw + z_pred = sample_depth(depth[n], x) + ok = np.isfinite(z_pred) + ratios[n].append(z_tri[ok] / z_pred[ok]) + + out = {} + for n in sorted(images): + r = np.concatenate(ratios[n]) if ratios[n] else np.empty(0) + if len(r) < min_points: + out[n] = ScaleFit(scale=None, points=len(r), spread=None) + continue + log_r = np.log(r) + spread = MAD_TO_SIGMA * float(np.median(np.abs(log_r - np.median(log_r)))) + out[n] = ScaleFit(scale=float(np.median(r)), points=len(r), spread=spread) + return out diff --git a/recon/results/eth3d_electro.json b/recon/results/eth3d_electro.json new file mode 100644 index 000000000..009bf48cd --- /dev/null +++ b/recon/results/eth3d_electro.json @@ -0,0 +1,91 @@ +{ + "laser as LiDAR": { + "wall": { + "wall_ends_xz_m": [ + [ + -0.33, + -4.69 + ], + [ + 8.17, + -9.39 + ] + ], + "wall_offset_in": { + "left": 1.01, + "middle": -0.16, + "right": -1.34 + }, + "wall_angle_deg": -0.35, + "laser_wall_points": 95188, + "points_visible": "2946 of 3000", + "points_found": "2946 of 2946 visible", + "point_error_in": { + "median": 1.46, + "p90": 3.85 + }, + "pair_error_1_3m_in": { + "pairs": 8072, + "median": 0.52, + "p90": 1.76 + } + }, + "coverage": { + "false_observed_runs_ft": [ + [ + 1.49, + 1.49 + ] + ], + "wall_ft": 31.9, + "claimed_ft": 22.51, + "false_observed_ft": 0.07, + "passes": true + }, + "notes": [ + "the capture marks no electric meter: one is assumed mid-wall, 1.5 m up, so the placement is a demonstration, not an answer for this house" + ] + }, + "MoGe-2, photos only": { + "wall": { + "wall_ends_xz_m": [ + [ + 0.64, + -5.19 + ], + [ + 6.07, + -8.29 + ] + ], + "wall_offset_in": { + "left": -0.25, + "middle": 0.36, + "right": 0.96 + }, + "wall_angle_deg": 0.28, + "laser_wall_points": 71058, + "points_visible": "2984 of 3000", + "points_found": "2984 of 2984 visible", + "point_error_in": { + "median": 1.38, + "p90": 3.35 + }, + "pair_error_1_3m_in": { + "pairs": 10092, + "median": 0.66, + "p90": 1.5 + } + }, + "coverage": { + "false_observed_runs_ft": [], + "wall_ft": 20.5, + "claimed_ft": 19.95, + "false_observed_ft": 0.0, + "passes": true + }, + "notes": [ + "the capture marks no electric meter: one is assumed mid-wall, 1.5 m up, so the placement is a demonstration, not an answer for this house" + ] + } +} \ No newline at end of file diff --git a/recon/results/eth3d_electro.md b/recon/results/eth3d_electro.md new file mode 100644 index 000000000..a43673742 --- /dev/null +++ b/recon/results/eth3d_electro.md @@ -0,0 +1,8 @@ +# Worker acceptance on ETH3D electro (generated by `make accept`) + +Wall offset: the laser's wall face relative to the worker's fitted plane at the wall's ends and middle. Point error: the reconstruction's surface along each pixel's ray against the laser point seen there, from the nearest photo in which the laser shows that point unoccluded. Pair error: |reconstructed - laser| distance between two such points 1 to 3 m apart. False-observed: pass at 0.5 ft or less. + +| Depth | Wall offset L / mid / R (in) | Angle | Point error median / p90 (in) | Pair error median / p90 (in) | Wall | Claimed | False-observed | Pass | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| laser as LiDAR | +1.0 / -0.2 / -1.3 | -0.35° | 1.5 / 3.9 | 0.5 / 1.8 | 31.9 ft | 22.5 ft | 0.07 ft | yes | +| MoGe-2, photos only | -0.2 / +0.4 / +1.0 | +0.28° | 1.4 / 3.4 | 0.7 / 1.5 | 20.5 ft | 19.9 ft | 0.00 ft | yes | diff --git a/recon/tests/test_triangulate.py b/recon/tests/test_triangulate.py new file mode 100644 index 000000000..47f552fdd --- /dev/null +++ b/recon/tests/test_triangulate.py @@ -0,0 +1,141 @@ +"""Hand-computed cases for per-photo depth scales from triangulated features.""" + +import cv2 +import numpy as np +import pytest + +from recon.triangulate import ( + ScaleFit, + projection, + sample_depth, + triangulate, + triangulate_pair, + view_scales, +) + +F, W, H = 500.0, 640, 480 +K0 = np.array([[F, 0, (W - 1) / 2], [0, F, (H - 1) / 2], [0, 0, 1.0]]) + + +def _pose(center, R=None) -> np.ndarray: + T = np.eye(4) + if R is not None: + T[:3, :3] = R + T[:3, 3] = center + return T + + +def _project(K, T, X): + p = projection(K, T) @ np.r_[X, 1.0] + return p[:2] / p[2] + + +def test_sample_depth_bilinear_and_invalid(): + d = np.array([[1.0, 2.0, 5.0], [3.0, 4.0, np.nan]]) + uv = np.array([[0.5, 0.5], [0.0, 0.0], [0.25, 0.0], [1.5, 0.5], [-0.1, 0.0], [0.0, 1.0]]) + got = sample_depth(d, uv) + # Centre of the first 2x2 block: mean of 1, 2, 3, 4. A quarter of the way from 1 to 2 is 1.25. + np.testing.assert_allclose(got[:3], [2.5, 1.0, 1.25]) + # A NaN neighbour, a position left of the image, and the last row (no row below) give NaN. + assert np.isnan(got[3:]).all() + + +def test_triangulate_recovers_known_point(): + X = np.array([0.3, -0.2, 5.0]) + c = np.cos(np.radians(10)) + s = np.sin(np.radians(10)) + T1 = _pose([0.0, 0.0, 0.0]) + # Second camera 1 m to the right, turned 10 degrees left (about camera y) toward the point. + T2 = _pose([1.0, 0.1, 0.2], np.array([[c, 0, -s], [0, 1, 0], [s, 0, c]])) + x1, x2 = _project(K0, T1, X), _project(K0, T2, X) + got = triangulate(projection(K0, T1), projection(K0, T2), x1[None], x2[None]) + np.testing.assert_allclose(got[0], X, atol=1e-9) + + +def test_filters_reject_tiny_baseline_and_displaced_match(): + X = np.array([0.0, 0.0, 5.0]) + T1 = _pose([0.0, 0.0, 0.0]) + wide, tiny = _pose([1.0, 0.0, 0.0]), _pose([0.001, 0.0, 0.0]) + + def run(T2, shift): + x1 = _project(K0, T1, X)[None] + x2 = _project(K0, T2, X)[None] + np.array(shift) + return triangulate_pair(K0, T1, K0, T2, x1, x2, max_reproj_px=2.0, min_angle_deg=2.0) + + # 1 m baseline at 5 m: rays about 11 degrees apart, kept. + assert run(wide, [0.0, 0.0]).keep[0] + # 1 mm baseline: rays 0.011 degrees apart, rejected by the angle test. + assert not run(tiny, [0.0, 0.0]).keep[0] + # The baseline is along x, so a 10 px shift in y cannot be explained by any depth: the best + # point leaves about 5 px of error in each view, rejected by the reprojection test. + assert not run(wide, [0.0, 10.0]).keep[0] + + +def _plane_scene(): + """Three photos of a textured plane 4 m ahead, cameras with no rotation, 0.4 m apart in x and + in y. At f = 500 px a 0.4 m shift moves the plane's image by exactly 500 * 0.4 / 4 = 50 px, so + each photo is an integer crop of one texture and the true depth is 4 m at every pixel.""" + noise = np.random.default_rng(0).uniform(0, 255, (H + 100, W + 100)).astype(np.float32) + tex = cv2.normalize(cv2.GaussianBlur(noise, (0, 0), 2.0), None, 0, 255, cv2.NORM_MINMAX) + tex = tex.astype(np.uint8) + # A camera at +x sees the plane shifted left in its image: its crop starts 50 px further right. + images = { + "a": tex[0:H, 0:W].copy(), + "b": tex[0:H, 50 : 50 + W].copy(), + "c": tex[50 : 50 + H, 0:W].copy(), + } + poses = { + "a": _pose([0.0, 0.0, 0.0]), + "b": _pose([0.4, 0.0, 0.0]), + "c": _pose([0.0, 0.4, 0.0]), + } + K = {n: K0 for n in images} + return images, K, poses + + +def test_view_scales_undo_depth_scale(): + images, K, poses = _plane_scene() + depth = {n: np.full((H, W), 4.0 * 1.25) for n in images} + fits = view_scales(images, K, poses, depth) + for n, fit in fits.items(): + assert fit.points >= 100, n + assert fit.scale == pytest.approx(0.8, rel=1e-4), n + assert fit.spread < 0.01, n + + +def test_view_scales_follow_pose_scale(): + """Poses stretched by 0.9 about the first camera triangulate the plane at 0.9 x 4 m; the fit + trusts the poses, so each depth map is scaled by 0.9 / 1.25.""" + images, K, poses = _plane_scene() + origin = poses["a"][:3, 3] + shrunk = {} + for n, T in poses.items(): + S = T.copy() + S[:3, 3] = origin + 0.9 * (T[:3, 3] - origin) + shrunk[n] = S + depth = {n: np.full((H, W), 4.0 * 1.25) for n in images} + fits = view_scales(images, K, shrunk, depth) + for n, fit in fits.items(): + assert fit.scale == pytest.approx(0.9 / 1.25, rel=1e-4), n + + +def test_view_scales_skips_invalid_depth_and_reports_too_few_points(): + images, K, poses = _plane_scene() + depth = {n: np.full((H, W), 5.0) for n in images} + depth["a"][:] = np.nan + fits = view_scales(images, K, poses, depth) + assert fits["a"] == ScaleFit(scale=None, points=0, spread=None) + assert fits["b"].scale == pytest.approx(0.8, rel=1e-4) + few = view_scales(images, K, poses, depth, min_points=100_000)["b"] + assert few.scale is None and few.spread is None and few.points == fits["b"].points + + +def test_view_scales_rejects_mismatched_inputs(): + images, K, poses = _plane_scene() + depth = {n: np.full((H, W), 5.0) for n in images} + del depth["c"] + with pytest.raises(ValueError, match="depth names"): + view_scales(images, K, poses, depth) + depth["c"] = np.full((H, W + 1), 5.0) + with pytest.raises(ValueError, match=r"c: depth \(480, 641\) != image \(480, 640\)"): + view_scales(images, K, poses, depth) diff --git a/recon/tests/test_worker.py b/recon/tests/test_worker.py new file mode 100644 index 000000000..e4b639cdf --- /dev/null +++ b/recon/tests/test_worker.py @@ -0,0 +1,688 @@ +"""Hand-computed cases for the worker's adapters, depth helpers and cache, fusion, geometry, +coverage, scene.json output, the server call, the acceptance oracle and GLB.""" + +import json +import re +from dataclasses import replace +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from recon import capture as cap +from recon import coverage, depth, geometry, scene, server +from recon.coverage import CELL_M, GROUND_MAX_M, CellCoverage, _runs, observed, seen_by +from recon.depth import Depth, lidar, rotated_intrinsics, upright_turns +from recon.eth3d import LaserView, View, laser_sees, laser_visible, nearest_seeing +from recon.fusion import Mesh, Volume, integrate, mesh +from recon.geometry import WallFrame, WallLine +from recon.glb import read_glb, write_glb +from recon.pipeline import PLUS_MINUS_FT, WALL_SOURCE + + +def test_outward_is_the_baseline_turned_clockwise_from_above(): + # Facing a wall along +x from +z: +x is to the right, so outward is +z. + np.testing.assert_allclose(cap.outward_of(np.array([1.0, 0, 0])), [0, 0, 1]) + + +def test_column_major_snaps_a_rounded_rotation_and_refuses_a_non_rotation(): + c, s = np.cos(0.3), np.sin(0.3) + R = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]]) + T = np.eye(4) + T[:3, :3] = np.round(R, 4) + T[:3, 3] = [1, 2, 3] + out = cap.column_major(T.T.reshape(-1).tolist()) + np.testing.assert_allclose(out[:3, :3] @ out[:3, :3].T, np.eye(3), atol=1e-12) + np.testing.assert_allclose(out[:3, 3], [1, 2, 3]) + with pytest.raises(ValueError): + cap.column_major(np.diag([1.0, 1.0, -1.0, 1.0]).reshape(-1).tolist()) # a mirror + + +def test_scan_bundle_reads_feet_into_meters(tmp_path: Path): + pose = np.eye(4) + pose[:3, 3] = [10.0, 5.0, 0.0] # feet + scene = { + "schema_version": "1.0", + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w"}, + "walls": [{"id": "w", "baseline": [[-10.0, 0.0], [10.0, 0.0]]}], + "keyframes": [ + { + "id": "k1", + "img": "k1.jpg", + "w": 4, + "h": 3, + "intrinsics": [2, 2, 2, 1.5], + "pose": pose.T.reshape(-1).tolist(), + } + ], + } + (tmp_path / "scene.json").write_text(json.dumps(scene)) + c = cap.load(tmp_path, tmp_path / "work") + assert c.source == "scan-bundle" and c.ground_y == 0.0 + np.testing.assert_allclose(c.frames[0].center, [3.048, 1.524, 0.0]) + np.testing.assert_allclose(c.meter, [0.0, 1.524, 0.0]) + np.testing.assert_allclose(c.wall.along, [1, 0, 0]) + np.testing.assert_allclose(c.wall.outward, [0, 0, 1]) + + +def test_rotated_intrinsics_follow_np_rot90(): + # A 4 x 2 image, one turn counter-clockwise: pixel (u, v) goes to (v, w - u). + k = np.array([10.0, 20.0, 1.0, 0.5]) + np.testing.assert_allclose(rotated_intrinsics(k, 4, 2, 1), [20, 10, 0.5, 3.0]) + np.testing.assert_allclose(rotated_intrinsics(k, 4, 2, 2), [10, 20, 3.0, 1.5]) + np.testing.assert_allclose(rotated_intrinsics(k, 4, 2, 3), [20, 10, 1.5, 1.0]) + + +def test_upright_turns_for_a_phone_held_upright(): + # Portrait phone: the sensor's +x points up in the world, so one turn counter-clockwise. + T = np.eye(4) + T[:3, :3] = np.array([[0.0, -1, 0], [1, 0, 0], [0, 0, 1]]) # camera +x -> world +y + assert upright_turns(T) == 1 + assert upright_turns(np.eye(4)) == 0 + + +def test_lidar_reads_float_meters_and_drops_low_confidence(tmp_path: Path): + cv2.imwrite(str(tmp_path / "k.jpg"), np.zeros((6, 8, 3), np.uint8)) + np.array([[1.0, 2.0], [3.0, 0.0]], " 0.9 # toward the camera (+z) + v = m.vertices[m.faces.astype(int)] + winding = np.cross(v[:, 1] - v[:, 0], v[:, 2] - v[:, 0])[:, 2] + assert np.median(winding) > 0 # counter-clockwise seen from the camera + assert vol.occupied(np.array([[0.0, 0, -2.1]]))[0] + assert ( + not vol.occupied(np.array([[0.0, 0, -1.0]]))[0] + and vol.observed(np.array([[0.0, 0, -1.0]]))[0] + ) + assert not vol.observed(np.array([[0.0, 0, -2.5]]))[0] # behind the wall: never seen + + +def test_observed_entries_in_feet(): + cells = np.arange(4) * CELL_M + cov = CellCoverage( + cells=cells, + wall=np.array([True, True, False, True]), + ground_out=np.array([1.3, 1.3, 0.0, 0.0]), # 4.27 ft out for the first two cells + facing_gap=np.array([np.nan, 1.0, 1.0, np.nan]), + facing_clear=np.array([2.0, 0.0, 0.0, 0.5]), + overhead_clearance=np.full(4, np.nan), + overhead_clear=np.array([2.5, 2.5, 0.0, 0.0]), + ) + e = observed(cov) + assert {"band": "wall", "span_ft": [0.0, 1.0]} in e + assert {"band": "wall", "span_ft": [1.5, 2.0]} in e + assert {"band": "ground", "span_ft": [0.0, 1.0], "out_ft": 4.0} in e + assert not any(x["band"] == "ground" and x["out_ft"] == 6.0 for x in e) + assert {"band": "facing", "span_ft": [0.5, 1.5]} in e # something measured in front + assert {"band": "facing", "span_ft": [0.0, 0.5], "out_ft": 6.5} in e # 2.0 m seen clear + assert {"band": "overhead", "span_ft": [0.0, 1.0], "out_ft": 8.0} in e + + +def test_runs(): + assert _runs(np.array([True, True, False, True])) == [(0, 2), (3, 4)] + assert _runs(np.array([False])) == [] + + +def test_glb_round_trip(tmp_path: Path): + v = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], float) + write_glb( + tmp_path / "m.glb", + v, + np.array([[0, 1, 2]]), + np.array([[255, 0, 0]] * 3), + np.tile([0, 0, 1.0], (3, 1)), + ) + doc, blob = read_glb(tmp_path / "m.glb") + prim = doc["meshes"][0]["primitives"][0] + assert set(prim["attributes"]) == {"POSITION", "COLOR_0", "NORMAL"} + pos = doc["accessors"][prim["attributes"]["POSITION"]] + assert pos["count"] == 3 and pos["max"] == [1.0, 1.0, 0.0] + view = doc["bufferViews"][pos["bufferView"]] + got = np.frombuffer(blob, np.float32, 9, view["byteOffset"]).reshape(3, 3) + np.testing.assert_allclose(got, v) + + +# --- Coverage: what counts as seen ------------------------------------------------------------ + +W, H, K = 64, 48, np.array([40.0, 40, 32, 24]) + + +def _frames_facing(ids_x: dict[str, float], height: float, rot: np.ndarray) -> list[cap.Frame]: + frames = [] + for fid, x in ids_x.items(): + T = np.eye(4) + T[:3, :3] = rot + T[:3, 3] = [x, height, 0.0] + frames.append(cap.Frame(fid, Path(f"{fid}.jpg"), W, H, K, T)) + return frames + + +def _flat_depths(frames: list[cap.Frame], z: float) -> dict[str, Depth]: + """Every pixel at camera depth z: a plane square to each camera's axis.""" + return { + f.id: Depth(np.full((H, W), z, np.float32), K, np.zeros((H, W, 3), np.uint8), "lidar") + for f in frames + } + + +def _seen(frames, depths, points, normal) -> np.ndarray: + poses = {f.id: f.cam_to_world for f in frames} + vol = integrate(depths, poses, voxel=0.05) + return seen_by(points, normal, frames, depths, vol) + + +# Two cameras 0.4 m apart at 1 m height, looking along -z at the plane z = -2 (outward +z). +WALL_FRAMES = _frames_facing({"a": -0.2, "b": 0.2}, 1.0, np.eye(3)) +WALL_POINTS = np.array([[x, y, -2.0] for x in (-0.2, 0.0, 0.2) for y in (0.6, 1.0, 1.4)]) + + +def test_a_wall_where_the_depth_puts_a_surface_is_seen(): + saw = _seen(WALL_FRAMES, _flat_depths(WALL_FRAMES, 2.0), WALL_POINTS, np.array([0, 0, 1.0])) + assert saw.all() + + +def test_an_opening_with_a_surface_behind_it_is_not_wall_seen(): + # No surface at the wall line; the depth reaches a surface 1 m behind it. Nothing occludes the + # samples, but a clear view through an opening is no evidence of wall there. + saw = _seen(WALL_FRAMES, _flat_depths(WALL_FRAMES, 3.0), WALL_POINTS, np.array([0, 0, 1.0])) + assert not saw.any() + + +def test_a_wall_hidden_by_something_nearer_is_not_seen(): + saw = _seen(WALL_FRAMES, _flat_depths(WALL_FRAMES, 1.5), WALL_POINTS, np.array([0, 0, 1.0])) + assert not saw.any() + + +# Two cameras 0.4 m apart, 1.5 m up, looking straight down (camera -z is world -y). +DOWN = np.array([[1.0, 0, 0], [0, 0, 1], [0, -1, 0]]) +GROUND_FRAMES = _frames_facing({"a": -0.2, "b": 0.2}, 1.5, DOWN) +GROUND_POINTS = np.array([[x, 0.0, z] for x in (-0.2, 0.0, 0.2) for z in (-0.3, 0.0, 0.3)]) + + +def test_ground_needs_a_surface_at_the_ground_too(): + up = coverage.UP + assert _seen(GROUND_FRAMES, _flat_depths(GROUND_FRAMES, 1.5), GROUND_POINTS, up).all() + # A pit (a window well) 1 m deep: seen into, but there is no ground at the sample. + assert not _seen(GROUND_FRAMES, _flat_depths(GROUND_FRAMES, 2.5), GROUND_POINTS, up).any() + + +def _all_visible_setup(): + """A wall along +x through the origin facing +z, two cells, no reconstructed face.""" + frames = _frames_facing({"a": -0.2, "b": 0.2}, 1.0, np.eye(3)) + wall = WallFrame( + np.array([0.0, 1.0, 0.0]), np.array([1.0, 0, 0]), np.array([0, 0, 1.0]), 0.0, (0, 0.3) + ) + empty = Mesh( + np.zeros((0, 3), np.float32), + np.zeros((0, 3), np.uint32), + np.zeros((0, 3), np.float32), + np.zeros((0, 3), np.uint8), + ) + return wall, frames, {}, None, empty, np.array([0.0, CELL_M]) + + +def test_ground_seen_everywhere_reaches_ten_feet(monkeypatch): + monkeypatch.setattr(coverage, "seen_by", lambda points, *_: np.ones((len(points), 2), bool)) + wall_ok, ground_out, _ = coverage.wall_and_ground(*_all_visible_setup()) + assert wall_ok.all() + np.testing.assert_array_equal(ground_out, [GROUND_MAX_M, GROUND_MAX_M]) + nan = np.full(2, np.nan) + cells = np.array([0.0, CELL_M]) + cov = CellCoverage(cells, wall_ok, ground_out, nan, np.zeros(2), nan, np.zeros(2)) + assert {"band": "ground", "span_ft": [0.0, 1.0], "out_ft": 10.0} in observed(cov) + + +def test_ground_unseen_at_ten_feet_reports_the_last_sampled_distance(monkeypatch): + # Seen everywhere short of 3 m out (out is world z here): 10 ft itself was not seen. + monkeypatch.setattr( + coverage, "seen_by", lambda points, *_: np.repeat((points[:, 2] < 3.0)[:, None], 2, 1) + ) + _, ground_out, _ = coverage.wall_and_ground(*_all_visible_setup()) + assert np.all(ground_out < GROUND_MAX_M - 0.1) + nan = np.full(2, np.nan) + cells = np.array([0.0, CELL_M]) + cov = CellCoverage(cells, np.ones(2, bool), ground_out, nan, np.zeros(2), nan, np.zeros(2)) + levels = {e["out_ft"] for e in observed(cov) if e["band"] == "ground"} + assert levels == {2.0, 4.0, 6.0, 8.0} + + +# --- The acceptance oracle ------------------------------------------------------------------ + + +def test_no_laser_return_is_unseen_not_seen(): + z = np.array([2.0, 2.0, 2.0, 2.0]) + nearest = np.array([np.nan, 2.0, 1.5, 3.0]) # no return; the surface; an occluder; beyond + np.testing.assert_array_equal(laser_sees(nearest, z), [False, True, False, True]) + + +# --- scene.json: the wall line's provenance ---------------------------------------------------- + +WALL = WallFrame( + np.array([0.0, 1.5, 0.0]), np.array([1.0, 0, 0]), np.array([0, 0, 1.0]), 0.0, (-1.0, 1.0) +) +NO_COVERAGE = CellCoverage( + np.array([0.0]), + np.array([False]), + np.zeros(1), + np.full(1, np.nan), + np.zeros(1), + np.full(1, np.nan), + np.zeros(1), +) + + +def _new_scene(source: str) -> dict: + frames = _frames_facing({"a": 0.0}, 1.0, np.eye(3)) + c = cap.Capture("measure-lab", Path("s"), frames, 0.0, None, None) + return scene.build(c, WALL, NO_COVERAGE, WALL_SOURCE[source], PLUS_MINUS_FT[source]) + + +def test_a_new_scene_labels_its_wall_by_depth_source_and_leaves_the_error_to_the_server(): + # No plus_minus_ft: the server adds its drift per foot walked only to walls without one. + (lidar_wall,) = _new_scene("lidar")["walls"] + (photo_wall,) = _new_scene("moge2-triangulated")["walls"] + assert lidar_wall["source"] == "mesh" and "plus_minus_ft" not in lidar_wall + assert photo_wall["source"] == "plane" and "plus_minus_ft" not in photo_wall + + +def test_a_bundles_tap_bound_does_not_survive_the_reconstructed_line(): + prior = { + "schema_version": "1.0", + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w"}, + "walls": [ + { + "id": "w", + "baseline": [[-3.0, 0.0], [3.0, 0.0]], + "source": "tap", + "plus_minus_ft": 0.1, + "height_ft": 9.0, + }, + {"id": "v", "baseline": [[3.0, 0.0], [3.0, -5.0]], "plus_minus_ft": 0.2}, + ], + } + c = cap.Capture("scan-bundle", Path("b"), [], 0.0, np.array([0.0, 1.524, 0]), None, prior) + doc = scene.build(c, WALL, NO_COVERAGE, WALL_SOURCE["lidar"], PLUS_MINUS_FT["lidar"]) + w, v = doc["walls"] + assert w["source"] == "mesh" and "plus_minus_ft" not in w and w["height_ft"] == 9.0 + assert v == prior["walls"][1] # another wall's tap stays as the phone marked it + assert prior["walls"][0]["plus_minus_ft"] == 0.1 # the input scene is not modified + + +# --- The MoGe-2 depth cache ----------------------------------------------------------------- + + +def _capture_named_scan(parent: Path, pixel: int) -> cap.Capture: + root = parent / "scan" + root.mkdir(parents=True) + cv2.imwrite(str(root / "k.png"), np.full((6, 8, 3), pixel, np.uint8)) + frame = cap.Frame("k", root / "k.png", 8, 6, np.array([8.0, 8, 4, 3]), np.eye(4)) + return cap.Capture("scan-bundle", root, [frame], 0.0, None, None) + + +def _fake_moge(calls: list): + """Stands in for the MoGe-2 process: depth = the image's mean pixel value.""" + + def run(cmd, **_): + items = json.loads(Path(cmd[-1]).read_text()) + calls.append(len(items)) + for item in items: + img = cv2.imread(item["image"]) + np.savez(item["out"], depth=np.full(img.shape[:2], img.mean(), np.float32)) + + return run + + +def test_same_named_captures_with_different_pixels_do_not_share_depth(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr(depth.subprocess, "run", _fake_moge(calls)) + a = _capture_named_scan(tmp_path / "a", 10) + b = _capture_named_scan(tmp_path / "b", 200) + assert a.root.name == b.root.name == "scan" + work = tmp_path / "work" + da, db = depth.moge(a, work)["k"].depth, depth.moge(b, work)["k"].depth + assert calls == [1, 1] and np.allclose(da, 10) and np.allclose(db, 200) + depth.moge(a, work) + assert calls == [1, 1] # the same photos again: reused + + +def test_a_cache_whose_stored_key_differs_is_recomputed(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr(depth.subprocess, "run", _fake_moge(calls)) + a = _capture_named_scan(tmp_path / "a", 10) + folder = depth.moge_cache(a, tmp_path / "work") + np.savez(folder / "k.moge2.npz", depth=np.full((6, 8), 99.0, np.float32)) # someone else's + (folder / "key.json").write_text(json.dumps({"frames": []})) + d = depth.moge(a, tmp_path / "work")["k"].depth + assert calls == [1] and np.allclose(d, 10) + + +def test_the_cache_key_changes_with_pose_intrinsics_and_the_model(tmp_path, monkeypatch): + c = _capture_named_scan(tmp_path, 10) + (f,) = c.frames + base = depth.moge_key(c) + moved = f.cam_to_world.copy() + moved[0, 3] = 0.5 + for changed in (replace(f, cam_to_world=moved), replace(f, intrinsics=f.intrinsics * 2)): + assert depth.moge_key(replace(c, frames=[changed])) != base + other = tmp_path / "moge_depth.py" + other.write_text( + depth.MOGE_SCRIPT.read_text().replace("RESOLUTION_LEVEL = 9", "RESOLUTION_LEVEL = 8") + ) + monkeypatch.setattr(depth, "MOGE_SCRIPT", other) + assert depth.moge_key(c) != base + + +# --- Depth: a LiDAR capture with frames saved without depth ----------------------------------- + + +def _lidar_frames(root: Path, with_depth: dict[str, bool]) -> list[cap.Frame]: + frames = [] + for fid, has in with_depth.items(): + cv2.imwrite(str(root / f"{fid}.jpg"), np.zeros((6, 8, 3), np.uint8)) + lid = None + if has: + np.full((2, 2), 2.0, " Mesh: + n = len(points) + return Mesh( + points.astype(np.float32), + np.zeros((0, 3), np.uint32), + np.tile([0.0, 1.0, 0.0], (n, 1)).astype(np.float32), + np.zeros((n, 3), np.uint8), + ) + + +def test_ground_height_is_the_plane_at_the_meter_not_the_median_sample(): + # Ground rising 1 in 10 away from a wall at x = 1, where it is 0.5 m; sampled only on the + # wall's outward side, so the samples' median height (0.65 m) is 1.5 m out, not at the wall. + x, z = np.meshgrid(np.linspace(1, 4, 31), np.linspace(-2, 2, 21)) + pts = np.stack([x.ravel(), 0.5 + 0.1 * (x.ravel() - 1), z.ravel()], axis=1) + ground = geometry.fit_ground(_upward_mesh(pts), 0.5) + assert np.median(pts[:, 1]) == pytest.approx(0.65) + assert ground.height_at(1.0, 0.7) == pytest.approx(0.5, abs=1e-5) + line = WallLine( + np.array([1.0, 0.5, 0.0]), np.array([0, 0, -1.0]), np.array([1.0, 0, 0]), (-2, 2), 0, 0 + ) + c = cap.Capture("measure-lab", Path("s"), [], 0.5, np.array([1.1, 1.7, 0.4]), None) + wall = geometry.wall_frame(line, c, ground, False) + assert wall.ground_y == pytest.approx(0.5, abs=1e-5) + + +def _stretch(extent: tuple[float, float], z: float = 0.0) -> WallLine: + return WallLine( + np.array([0.0, 0.0, z]), np.array([1.0, 0, 0]), np.array([0, 0, 1.0]), extent, 0.01, 100 + ) + + +def test_the_stretch_beside_the_meter_wins_over_a_collinear_one_past_a_doorway(): + # One wall split at a doorway into two stretches on the same infinite line. + far, near = _stretch((-6.0, -3.0)), _stretch((1.0, 4.0)) + hint = cap.WallHint(np.array([-5.0, 0, 0.1]), np.array([1.0, 0, 0]), np.array([0, 0, 1.0])) + meter = np.array([2.0, 1.2, 0.05]) + with_meter = cap.Capture("scan-bundle", Path("b"), [], 0.0, meter, hint) + assert geometry.choose_wall([far, near], with_meter, False) is near + no_meter = cap.Capture("measure-lab", Path("s"), [], 0.0, None, hint) # the phone's point + assert geometry.choose_wall([near, far], no_meter, False) is far + with pytest.raises(geometry.WallMismatch): # a parallel line 1 m off is not the wall + geometry.choose_wall([_stretch((1.0, 4.0), z=1.0)], with_meter, False) + + +# --- Coverage: facing space is measured from the wall's front --------------------------------- + + +def _slab_volume(occupied: list[tuple[float, float]], observed_to: float) -> Volume: + """x in [-0.5, 0.5], y in [0, 3.2], z in [-0.5, 7] at 5 cm: occupied where z falls in one of + the ranges, seen empty elsewhere up to z = observed_to, unobserved beyond.""" + voxel, origin, shape = 0.05, np.array([-0.5, 0.0, -0.5]), (21, 65, 151) + z = origin[2] + np.arange(shape[2]) * voxel + occ = np.zeros(shape[2], bool) + for a, b in occupied: + occ |= (z >= a - 1e-6) & (z <= b + 1e-6) + tsdf = np.broadcast_to(np.where(occ, -1.0, 1.0).astype(np.float32), shape).copy() + weight = np.broadcast_to((z <= observed_to + 1e-6).astype(np.float32), shape).copy() + color = np.zeros((*shape, 3), np.float32) + return Volume(origin, voxel, shape, tsdf, weight, color, np.zeros(shape, np.float32)) + + +def test_facing_gap_and_clear_space_start_at_a_proud_wall_face(): + # The fitted plane is z = 0 (outward +z). A pilaster stands 0.3 m proud of it; something + # faces the wall 1.0 m from the pilaster's face, 1.3 m from the plane. + wall = WallFrame( + np.array([0.0, 1.0, 0.0]), np.array([1.0, 0, 0]), np.array([0, 0, 1.0]), 0.0, (0, 0.3) + ) + cells = np.array([0.0]) + flat = coverage.free_space(wall, _slab_volume([(-0.3, 0.0), (1.0, 1.4)], 7.0), cells) + proud = coverage.free_space(wall, _slab_volume([(-0.3, 0.3), (1.3, 1.7)], 7.0), cells) + assert proud[0][0] == pytest.approx(flat[0][0]) == pytest.approx(0.95, abs=0.01) + # Nothing faces it, and the volume was seen empty to 2.0 m from the plane: 1.7 m from the face. + clear = coverage.free_space(wall, _slab_volume([(-0.3, 0.3)], 2.0), cells) + assert np.isnan(clear[0][0]) and 1.6 <= clear[1][0] <= 1.7 + + +# --- The server call ------------------------------------------------------------------------ + + +def _replies(monkeypatch, *replies): + it = iter(replies) + monkeypatch.setattr(server, "_post", lambda *_: next(it)) + + +def test_a_refused_site_plan_fails_the_run_and_leaves_no_plan_behind(tmp_path, monkeypatch): + (tmp_path / "site-plan.svg").write_text("a previous run's plan") + _replies(monkeypatch, (200, b'{"decision": "manual_review"}'), (500, b"plan failed")) + with pytest.raises(RuntimeError, match=r"refused its site plan \(500\): plan failed"): + server.place({}, "https://example.test", tmp_path) + assert not (tmp_path / "site-plan.svg").exists() + + +def test_a_placed_scene_saves_its_result_and_plan(tmp_path, monkeypatch): + _replies(monkeypatch, (200, b'{"decision": "manual_review"}'), (200, b"")) + assert server.place({}, "https://example.test", tmp_path) == {"decision": "manual_review"} + assert (tmp_path / "site-plan.svg").read_bytes() == b"" + + +# --- The acceptance oracle: which view scores a laser point ----------------------------------- + + +def test_a_laser_point_is_scored_from_the_nearest_view_that_sees_it(): + dist = np.array([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]]) + sees = np.array([[False, True, True], [False, False, False]]) + np.testing.assert_array_equal(nearest_seeing(dist, sees), [1, -1]) + + +def test_laser_visibility_needs_the_point_in_frame_and_unoccluded(): + # A COLMAP camera at the origin looking along +z; points 2 m ahead, and one out of frame. + view = View("v", 512, 512, np.array([256.0, 256, 256, 256]), np.eye(3), np.zeros(3)) + pts = np.array([[0.0, 0, 2.0], [10.0, 0, 2.0]]) + toward_camera = np.array([0, 0, -1.0]) + miss = np.zeros((1024, 1024), bool) + + def visible(nearest: float) -> list[bool]: + lv = LaserView(view, np.full((512, 512), nearest, np.float32), miss) + return laser_visible(lv, pts, pts, np.eye(3), toward_camera).tolist() + + assert visible(2.0) == [True, False] + assert visible(1.0) == [False, False] # the laser saw something nearer: occluded + assert visible(np.nan) == [False, False] # no laser return is no evidence + + +# --- Adapters: files a manifest names stay inside its folder --------------------------------- + + +def _manifest(root: Path, fmt: str, img: str, depth_file: str, conf_file: str) -> None: + """A one-keyframe scan bundle or Measure Lab session naming the three files.""" + kf = { + "id": "k", + "img": img, + "w": 8, + "h": 6, + "intrinsics": [8, 8, 4, 3], + "pose": np.eye(4).reshape(-1).tolist(), + "depth": {"file": depth_file, "confidenceFile": conf_file, "w": 2, "h": 2}, + } + if fmt == "scan-bundle": + doc = { + "schema_version": "1.0", + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w"}, + "walls": [{"id": "w", "baseline": [[-3.0, 0.0], [3.0, 0.0]]}], + "keyframes": [kf], + } + (root / "scene.json").write_text(json.dumps(doc)) + else: + doc = {"format": "measure-lab-session", "formatVersion": 2, "keyframes": [kf]} + (root / "session.json").write_text(json.dumps(doc)) + + +@pytest.fixture +def packet(tmp_path: Path) -> tuple[Path, Path]: + """A packet folder holding valid files, and a secret beside it, outside the folder.""" + root = tmp_path / "packet" + root.mkdir() + (root / "k.jpg").write_bytes(b"jpg") + (root / "k.f32").write_bytes(b"depth") + (root / "k.u8").write_bytes(b"conf") + secret = tmp_path / "secret.jpg" + secret.write_bytes(b"not the homeowner's") + (root / "escape.jpg").symlink_to(secret) + return root, secret + + +FORMATS = ["scan-bundle", "measure-lab"] +FIELDS = {"img": 0, "depth.file": 1, "depth.confidenceFile": 2} + + +@pytest.mark.parametrize("fmt", FORMATS) +def test_files_inside_the_packet_load(packet, fmt): + root, _ = packet + _manifest(root, fmt, "k.jpg", "k.f32", "k.u8") + (f,) = cap.load(root, root / "work").frames + assert f.image == root / "k.jpg" + assert f.lidar.file == root / "k.f32" and f.lidar.confidence == root / "k.u8" + + +@pytest.mark.parametrize("fmt", FORMATS) +@pytest.mark.parametrize("field", FIELDS) +@pytest.mark.parametrize("kind", ["absolute", "traversal", "symlink"]) +def test_a_file_outside_the_packet_is_refused(packet, fmt, field, kind): + root, secret = packet + bad = {"absolute": str(secret), "traversal": "../secret.jpg", "symlink": "escape.jpg"}[kind] + refs = ["k.jpg", "k.f32", "k.u8"] + refs[FIELDS[field]] = bad + _manifest(root, fmt, *refs) + with pytest.raises(cap.UnsafePath, match=f"^{re.escape(field)} "): + cap.load(root, root / "work") + + +@pytest.mark.parametrize("fmt", FORMATS) +@pytest.mark.parametrize("bad", ["/tmp/x", "../../x", "a/b", "..", ".", "", 7]) +def test_a_frame_id_that_is_not_one_safe_name_is_refused(packet, fmt, bad): + root, _ = packet + _manifest(root, fmt, "k.jpg", "k.f32", "k.u8") + name = "scene.json" if fmt == "scan-bundle" else "session.json" + doc = json.loads((root / name).read_text()) + doc["keyframes"][0]["id"] = bad + (root / name).write_text(json.dumps(doc)) + with pytest.raises(cap.BadFrameId, match="keyframe id"): + cap.load(root, root / "work") + + +@pytest.mark.parametrize("good", ["k00001", "DSC_9257", "frame-1.v2"]) +def test_frame_ids_real_captures_use_are_accepted(good): + assert cap.frame_id(good) == good + + +@pytest.mark.parametrize("bad", ["../escaped", "/tmp/escaped", "sub/escaped"]) +def test_the_moge_cache_refuses_a_file_outside_its_folder(tmp_path, monkeypatch, bad): + # A Capture built without the readers' id check: the cache still refuses to write out. + monkeypatch.setattr(depth.subprocess, "run", lambda *_, **__: pytest.fail("model ran")) + c = _capture_named_scan(tmp_path, 10) + c = replace(c, frames=[replace(c.frames[0], id=bad)]) + work = tmp_path / "work" + with pytest.raises(cap.UnsafePath, match="resolves outside"): + depth.moge(c, work) + assert not list(tmp_path.glob("escaped*")) and not list(work.rglob("*.upright.jpg")) + + +def test_the_moge_cache_file_for_a_valid_id_is_directly_in_its_folder(tmp_path): + assert depth.cache_file(tmp_path, "k00001", ".moge2.npz") == tmp_path / "k00001.moge2.npz" + + +@pytest.mark.parametrize("fmt", FORMATS) +def test_two_keyframes_sharing_an_id_are_refused(packet, fmt): + # The caretaker's case: two views 0.3 m apart. With one id their depth maps collapse to one, + # and that one view would count from both positions as two-view coverage. + root, _ = packet + _manifest(root, fmt, "k.jpg", "k.f32", "k.u8") + name = "scene.json" if fmt == "scan-bundle" else "session.json" + doc = json.loads((root / name).read_text()) + second = json.loads(json.dumps(doc["keyframes"][0])) + pose = np.eye(4) + pose[0, 3] = 0.3 + second["pose"] = pose.T.reshape(-1).tolist() # column-major, 0.3 m along x + doc["keyframes"].append(second) + (root / name).write_text(json.dumps(doc)) + with pytest.raises(cap.DuplicateFrameId, match="keyframe id 'k' appears more than once"): + cap.load(root, root / "work") + second["id"] = "k2" + (root / name).write_text(json.dumps(doc)) + assert [f.id for f in cap.load(root, root / "work").frames] == ["k", "k2"] diff --git a/recon/uv.lock b/recon/uv.lock new file mode 100644 index 000000000..fcac3fc3b --- /dev/null +++ b/recon/uv.lock @@ -0,0 +1,268 @@ +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 = "house-scanning-recon" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "scikit-image" }, + { 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,<5" }, + { name = "scikit-image", specifier = ">=0.24" }, + { name = "scipy", specifier = ">=1.14" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "imageio" +version = "2.37.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3", size = 390173, upload-time = "2026-07-20T05:26:11.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6", size = 318000, upload-time = "2026-07-20T05:26:09.874Z" }, +] + +[[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 = "lazy-loader" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/8c/0f2ff2a8b7513e68871a74740c17a504b0488c679b379e6445ebb7bd78dc/lazy_loader-0.6.tar.gz", hash = "sha256:2f4b7824d6401958639008a0cae20c776b61dff619accd6890693e2de8260167", size = 18438, upload-time = "2026-09-21T21:31:32.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/93/4e45e37f26c820216704b36b8e67cc55c490a7abd287a78a26a2c0e21386/lazy_loader-0.6-py3-none-any.whl", hash = "sha256:77253be3391b06124a0e16105bd663b6c54470af1a9ca8e1cf026f38d58ed056", size = 8791, upload-time = "2026-09-21T21:31:31.632Z" }, +] + +[[package]] +name = "networkx" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl", hash = "sha256:e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0", size = 2142205, upload-time = "2026-09-21T16:45:14.609Z" }, +] + +[[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" }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.14.0.94" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/8a/51ff1c6287be0b6cd3070cf095875da4ff7d6eda38507e6ada5608786a78/opencv_python_headless-4.14.0.94.tar.gz", hash = "sha256:4afa2ea1214453648be88259f035712454faa9039b686de7753569ba8eec1577", size = 96405664, upload-time = "2026-07-28T19:23:48.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/56/12f9b05628cf4ab6aad54479b9124f7c1f2f92b6a4e47c071beb63d926a2/opencv_python_headless-4.14.0.94-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:bc7db37dc234f7bb3190a158fd9dd750357246fc7d8adb23698843ef721a993b", size = 46491231, upload-time = "2026-07-29T03:52:11.902Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/2f9c42466f62aa27e97aaf6c9d914008895ecde28013ba4beed7f541f593/opencv_python_headless-4.14.0.94-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1777f43c9fa064f54b916ad70d944b4fde0a644a17e49f04a966bb24a4b5f1e1", size = 33084176, upload-time = "2026-07-28T20:05:59.902Z" }, + { url = "https://files.pythonhosted.org/packages/7d/91/83c51e3fe000b5dfce4f3be5c5d320e07ebab5c1742abc4e45674503f90f/opencv_python_headless-4.14.0.94-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:29714d7716dbfddf9fec20ffb878765e94ccaecd7d3ebd6750877420296e2dc5", size = 35405458, upload-time = "2026-07-28T19:19:29.997Z" }, + { url = "https://files.pythonhosted.org/packages/de/06/14c8c5d331bc72738683a9b227966aceaa4c6d08752f33d64578aebcb6f8/opencv_python_headless-4.14.0.94-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5e02669eac0ba67b2a22d7245af1e8ee1a2ef1185ee526a063d8f0555224bd52", size = 57534165, upload-time = "2026-07-28T19:19:52.742Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/0b98bd582582253f61c4506f9b465cf5a8aa7fa4f7237dd99e1b5151a159/opencv_python_headless-4.14.0.94-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:97c6e818c6f71c0cfa214e12293b1d0266d679c38f4e086de08357c8ac6ece0d", size = 38104309, upload-time = "2026-07-28T19:20:08.818Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/7f538891722a4f06921419d55bac6f41729258d54442861edb2de0dbdf5e/opencv_python_headless-4.14.0.94-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:211e581f5a4670acbbe08fff36a35e9946039d2eea28b80394632d036d1be527", size = 61960165, upload-time = "2026-07-28T19:20:33.074Z" }, + { url = "https://files.pythonhosted.org/packages/e9/91/ded03abad74d7264fa6bc7c923d7cade6f94a9023eeb372cfad54c2d0334/opencv_python_headless-4.14.0.94-cp37-abi3-win32.whl", hash = "sha256:f70296aa7ac9d7ade0d925c43fdc006c83e20a23f30e2f40f1b82c74a7a54460", size = 33030271, upload-time = "2026-07-28T18:32:39.447Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8d/db8673846ee53cbb5de4c2b4decc11cf733e203eb7d5146297869f69bd48/opencv_python_headless-4.14.0.94-cp37-abi3-win_amd64.whl", hash = "sha256:cbed65415b8f6a9541c705afe3e64795840524d0ff3bc58f507826284a1dc64b", size = 41028030, upload-time = "2026-07-28T18:32:36.724Z" }, +] + +[[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 = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, +] + +[[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 = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "tifffile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, + { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, +] + +[[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" }, +] + +[[package]] +name = "tifffile" +version = "2026.9.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/66/634db78ebad513038d753830dd8815eea26278b5463ba0f43198b0c24c4e/tifffile-2026.9.20.tar.gz", hash = "sha256:30e145a7042ce7143ae50a50fe8b7221b0070aae22adab8f9e79a264be6b5cdc", size = 449417, upload-time = "2026-09-21T03:59:40.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/bf/04f3e61cb20a03678ca43f29bae9a7d0b7d9f563b86f5f600b2d8fba9712/tifffile-2026.9.20-py3-none-any.whl", hash = "sha256:9b913167b8f66a57f2e7c0454486c4c4607196d494797461226166bd0755c0e6", size = 275004, upload-time = "2026-09-21T03:59:38.46Z" }, +]