diff --git a/.github/labels.json b/.github/labels.json index d972796e3..bf7807164 100644 --- a/.github/labels.json +++ b/.github/labels.json @@ -24,6 +24,11 @@ "color": "b60205", "description": "Not ready to merge. State what must change before it is ready." }, + { + "name": "full-ui", + "color": "5319e7", + "description": "iOS CI also runs the every-state accessibility audit (about 10 more minutes)." + }, { "name": "size:XS", "color": "0e8a16", diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml new file mode 100644 index 000000000..29aa76844 --- /dev/null +++ b/.github/workflows/evals.yml @@ -0,0 +1,42 @@ +name: Evals + +on: + push: + branches: [main] + paths: + - "experiments/evals/**" + - ".github/workflows/evals.yml" + pull_request: + paths: + - "experiments/evals/**" + - ".github/workflows/evals.yml" + +permissions: + contents: read + +concurrency: + group: evals-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + evals-checks: + name: Evals checks + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: experiments/evals + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Reads tool.uv.required-version from experiments/evals/pyproject.toml. + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + working-directory: experiments/evals + # The datasets are non-commercial and never in git, so CI runs the metric code's unit tests + # (hand-computed cases), lint and format; the result tables are regenerated locally. + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index c7eaba1c2..ae04760df 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -2,35 +2,57 @@ name: iOS on: push: - branches: [main] + branches: [main, t3/ios-mvf] paths: - "ios/**" + - "!ios/**/*.md" - ".github/workflows/ios.yml" pull_request: + # ready_for_review: drafts are skipped below, so run once a draft is marked ready. + # labeled: adding full-ui reruns the pull request with the accessibility audit. + types: [opened, synchronize, reopened, ready_for_review, labeled] paths: - "ios/**" + - "!ios/**/*.md" - ".github/workflows/ios.yml" + # Runs every test, the audit included, on any branch. + workflow_dispatch: permissions: contents: read concurrency: - group: ios-${{ github.ref }} + # Adding any label other than full-ui gets a group of its own, so it cannot cancel a real run. + group: ios-${{ github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'full-ui' && format('-{0}', github.run_id) || '' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ios-build: name: iOS build + # A free account runs 5 macOS jobs at once, shared by every open pull request, so drafts and + # labels other than full-ui don't take a slot. + if: >- + github.event_name != 'pull_request' || + (!github.event.pull_request.draft && + (github.event.action != 'labeled' || github.event.label.name == 'full-ui')) # macos-26 (arm64) is the newest GA macOS image. The xcode-27 image is still a preview. runs-on: macos-26 - timeout-minutes: 30 + # Beta run 36302099002 took 43 minutes before the spot-confirmation and review checks + # were added. Leave room for those checks without dropping the every-state audit. + timeout-minutes: 60 env: # Newest stable Xcode on macos-26 per images/macos/macos-26-arm64-Readme.md (image 20260907). DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + # iPhone 17 on iOS 26.5 ships with Xcode 26.6 on macos-26 (images/macos/macos-26-arm64-Readme.md). + SIM_NAME: iPhone 17 + SIM_OS: "26.5" # Homebrew only installs the latest XcodeGen. The release zip gives an exact version, # and the drift check below needs the same version the committed project was made with. XCODEGEN_VERSION: 2.46.0 XCODEGEN_SHA256: 4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806 + # The every-state accessibility audit relaunches the app 65 times (about 10 min). Pull + # requests skip it unless labelled full-ui; pushes and manual runs always include it. + FULL_UI: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ui') }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -45,6 +67,45 @@ jobs: xcodebuild -version swift --version + # Booting takes 2 to 3 minutes and needs nothing from the build, so it runs alongside the + # steps below; the UI test step waits for it. + - name: Boot the Simulator in the background + run: | + runtime="com.apple.CoreSimulator.SimRuntime.iOS-${SIM_OS//./-}" + udid="$(xcrun simctl list devices available -j \ + | jq -r --arg runtime "$runtime" --arg name "$SIM_NAME" \ + '(.devices[$runtime] // [])[] | select(.name == $name) | .udid' | head -n 1)" + if [ -z "$udid" ]; then + echo "::error::No $SIM_NAME on iOS $SIM_OS on this runner image. Pick one from the image readme." + xcrun simctl list devices available + exit 1 + fi + echo "SIM_UDID=$udid" >> "$GITHUB_ENV" + nohup xcrun simctl bootstatus "$udid" -b > "$RUNNER_TEMP/simulator-boot.log" 2>&1 & + + # The package's drift test compares the vendored server schemas with the server's own copies: + # the checkout's files once that branch merges, else the t3/server branch, which the shallow + # checkout leaves out. One of the two is required: + # a failed fetch fails the run, and HOUSESCAN_REQUIRE_UPSTREAM makes the tests fail rather + # than skip, so CI can't go green without comparing. The checkout counts only when it holds + # every compared file: a folder alone isn't enough (main has a server/ without schemas). + - name: Fetch the server contract + run: | + fetch_unless_present() { + local branch="$1"; shift + local missing=0 + for file in "$@"; do [ -f "$file" ] || missing=1; done + if [ "$missing" = 0 ]; then + echo "$* are in the checkout; the drift tests read them." + return + fi + if ! git fetch --no-tags --depth=1 origin "+refs/heads/$branch:refs/remotes/origin/$branch"; then + echo "::error::Neither the checkout nor $branch holds $*, so the vendored copies can't be compared." + exit 1 + fi + } + fetch_unless_present t3/server server/schemas/scene.schema.json server/schemas/result.schema.json server/tests/fixtures/example-scene.json + - name: Install XcodeGen run: | curl -fsSL -o "$RUNNER_TEMP/xcodegen.zip" \ @@ -69,7 +130,44 @@ jobs: exit 1 fi + - name: Test HouseScanKit + # The capture logic is a plain Swift package; its tests run on macOS without a Simulator. + env: + HOUSESCAN_REQUIRE_UPSTREAM: "1" + run: swift test --package-path ios/HouseScanKit -Xswiftc -warnings-as-errors + + # CI builds once from clean, so the index Xcode keeps for editing is wasted work. - name: Build run: >- xcodebuild -project ios/HouseScan.xcodeproj -scheme HouseScan -configuration Debug - -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO build + -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO COMPILER_INDEX_STORE_ENABLE=NO build + + - name: Wait for the Simulator + timeout-minutes: 10 + run: | + xcrun simctl bootstatus "$SIM_UDID" -b + echo "Booted $SIM_NAME on iOS $SIM_OS ($SIM_UDID)." + + - name: UI test the whole flow in the Simulator + # Replays the committed synthetic wall (ios/HouseScanUITests/Fixtures/synthetic-wall) with + # -autopilot and the bundled sample result. + run: | + skip=() + if [ "$FULL_UI" != "true" ]; then + skip=(-skip-testing:HouseScanUITests/ScreenStatesUITests/testEveryStatePassesTheAccessibilityAudit) + echo "::notice::Skipped the every-state accessibility audit (about 10 min). Add the full-ui label to run it on this pull request." + fi + xcodebuild -project ios/HouseScan.xcodeproj -scheme HouseScan -configuration Debug \ + -destination "id=$SIM_UDID" \ + -resultBundlePath "$RUNNER_TEMP/HouseScanUITests.xcresult" \ + -only-testing:HouseScanUITests "${skip[@]}" \ + COMPILER_INDEX_STORE_ENABLE=NO test + + - name: Keep UI test screenshots + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ui-test-results + path: ${{ runner.temp }}/HouseScanUITests.xcresult + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/measure-lab.yml b/.github/workflows/measure-lab.yml new file mode 100644 index 000000000..7dce231f0 --- /dev/null +++ b/.github/workflows/measure-lab.yml @@ -0,0 +1,97 @@ +name: Measure lab + +on: + push: + branches: [main] + paths: + - "experiments/measure-lab/**" + - ".github/workflows/measure-lab.yml" + pull_request: + paths: + - "experiments/measure-lab/**" + - ".github/workflows/measure-lab.yml" + +permissions: + contents: read + +concurrency: + group: measure-lab-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + geometry-linux: + name: Measure lab geometry (Linux) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + container: + # Swift 6.2 is the oldest toolchain the tests compile with (their raw-identifier names need + # it); the app job's Xcode 26.6 ships Swift 6.3. Pinned by digest so a retag can't change + # the compiler under the tests. + image: swift:6.2-noble@sha256:9bea530093ffff8cf6c259991715ee843fe4d0f932e612f7f4b79cca6e00db87 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Show toolchain + run: swift --version + + - name: Test + working-directory: experiments/measure-lab/Geometry + run: swift test + + app-build: + name: Measure lab app build + # Same image and Xcode as .github/workflows/ios.yml. + runs-on: macos-26 + timeout-minutes: 30 + env: + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + XCODEGEN_VERSION: 2.46.0 + XCODEGEN_SHA256: 4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Show toolchain + run: | + if [ ! -d "$DEVELOPER_DIR" ]; then + echo "::error::$DEVELOPER_DIR is not on this runner image. Pick an installed Xcode from the image readme." + exit 1 + fi + xcodebuild -version + swift --version + + - name: Install XcodeGen + run: | + curl -fsSL -o "$RUNNER_TEMP/xcodegen.zip" \ + "https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip" + echo "${XCODEGEN_SHA256} $RUNNER_TEMP/xcodegen.zip" | shasum -a 256 -c - + unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP" + echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH" + + - name: Generate project + run: xcodegen generate --spec experiments/measure-lab/project.yml + + - name: Check committed project matches project.yml + run: | + if ! git diff --exit-code -- experiments/measure-lab; then + echo "::error::MeasureLab.xcodeproj differs from project.yml. Run 'make project' in experiments/measure-lab (XcodeGen ${XCODEGEN_VERSION}) and commit the result." + exit 1 + fi + untracked="$(git ls-files --others --exclude-standard -- experiments/measure-lab)" + if [ -n "$untracked" ]; then + echo "::error::xcodegen generate wrote files that are not committed. Run 'make project' and commit them." + echo "$untracked" + exit 1 + fi + + - name: Test geometry on macOS + working-directory: experiments/measure-lab/Geometry + run: swift test + + - name: Build + run: >- + xcodebuild -project experiments/measure-lab/MeasureLab.xcodeproj -scheme MeasureLab + -configuration Debug -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO build diff --git a/.github/workflows/meter-closeup.yml b/.github/workflows/meter-closeup.yml new file mode 100644 index 000000000..543b2d077 --- /dev/null +++ b/.github/workflows/meter-closeup.yml @@ -0,0 +1,45 @@ +name: Meter close-up experiment + +on: + push: + branches: [main] + paths: + - "experiments/meter-closeup/**" + - ".github/workflows/meter-closeup.yml" + pull_request: + paths: + - "experiments/meter-closeup/**" + - ".github/workflows/meter-closeup.yml" + +permissions: + contents: read + +concurrency: + group: meter-closeup-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + checks: + name: Meter close-up checks + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: experiments/meter-closeup + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + working-directory: experiments/meter-closeup + # The unit tests cover the metric code only; the Vision reader needs macOS and the photos. + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q + # Looks for any transcribed meter identifier in the tracked files. The key stays in a + # repository secret; without it the step fails with an explanation. + - run: uv run python -m meter_eval.leakcheck + env: + METER_HMAC_KEY: ${{ secrets.METER_HMAC_KEY }} diff --git a/.github/workflows/panel-label.yml b/.github/workflows/panel-label.yml new file mode 100644 index 000000000..0917bf8a6 --- /dev/null +++ b/.github/workflows/panel-label.yml @@ -0,0 +1,46 @@ +name: Panel label experiment + +on: + push: + branches: [main] + paths: + - "experiments/panel-label/**" + - ".github/workflows/panel-label.yml" + pull_request: + paths: + - "experiments/panel-label/**" + - ".github/workflows/panel-label.yml" + +permissions: + contents: read + +concurrency: + group: panel-label-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + checks: + name: Panel label checks + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: experiments/panel-label + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + working-directory: experiments/panel-label + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q + # Photos of real panels can show addresses and inspection stickers; they stay out of git. + - name: No photos committed + run: | + if git ls-files . | grep -iE '\.(jpe?g|png|heic|webp|tiff?)$'; then + echo "Photos must stay in ~/house-scanning-data/panel, not in the repository." >&2 + exit 1 + fi diff --git a/.github/workflows/scoring.yml b/.github/workflows/scoring.yml new file mode 100644 index 000000000..8e9b902b0 --- /dev/null +++ b/.github/workflows/scoring.yml @@ -0,0 +1,40 @@ +name: Scoring experiment + +on: + push: + branches: [main] + paths: + - "experiments/scoring/**" + - ".github/workflows/scoring.yml" + pull_request: + paths: + - "experiments/scoring/**" + - ".github/workflows/scoring.yml" + +permissions: + contents: read + +concurrency: + group: scoring-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + scoring-checks: + name: Scoring checks + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: experiments/scoring + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Reads tool.uv.required-version from experiments/scoring/pyproject.toml to pick the uv version. + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + working-directory: experiments/scoring + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 2c9a1bf82..bac68182a 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -163,6 +163,12 @@ jobs: [ "$failed" -eq 0 ] || exit 1 echo "No build scripts, build rules, scheme actions, plugins, macros or external packages in $PROJECT_DIR." + # The commit this build comes from, for the scan stamp (ScanEngine+Stamp.swift). Workflow + # code, not repository code, and it runs before the key exists. + - name: Stamp the git commit + if: inputs.app == 'house-scan' + run: /usr/libexec/PlistBuddy -c "Set :HouseScanGitCommit ${GITHUB_SHA:0:12}" "$PROJECT_DIR/Config/Info.plist" + - name: Archive env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml new file mode 100644 index 000000000..ad38024b2 --- /dev/null +++ b/.github/workflows/verification.yml @@ -0,0 +1,42 @@ +name: Verification + +on: + push: + branches: [main] + paths: + - "verification/**" + - ".github/workflows/verification.yml" + pull_request: + paths: + - "verification/**" + - ".github/workflows/verification.yml" + +permissions: + contents: read + +concurrency: + group: verification-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + verification-checks: + name: Verification checks + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: verification + steps: + # The default depth of 1 is enough: tests/test_gitref.py only reads HEAD, and a detached + # worktree of HEAD works in a shallow clone. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Reads tool.uv.required-version from verification/pyproject.toml to pick the uv version. + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + working-directory: verification + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ce41fefe..d8d9706e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,11 +28,13 @@ The `size:*` label describes the effective diff. It is a review signal, not a me | --- | --- | --- | | Server checks | `server/` changes | `make server` | | Web checks | `web/` changes | `make web` | -| iOS build | `ios/` changes | `make ios` | +| iOS build | `ios/` changes outside Markdown, on non-draft pull requests | `make ios` | | Sync label definitions | `.github/labels.json` changes on `main` | none | | Label PR size | a pull request opens or updates | none | | TestFlight | someone runs it from the Actions tab | none | +The iOS UI tests skip the every-state accessibility audit on pull requests, because it adds about 10 minutes and macOS runners are scarce. Add the `full-ui` label when a pull request changes screens or copy; pushes to `t3/ios-mvf` and `main` always run it. + `make check` runs the three local suites. They need uv, Node 24 with pnpm, and Xcode 26 or newer; each directory's README has details. No check is required by branch rules yet. Don't call one required until the rules require its status. Keep workflows that run pull-request code away from production credentials and destructive external systems. A green CI run is evidence for the checks it ran, not proof that a capture works on a real house. diff --git a/Makefile b/Makefile index 13bca16c8..b1350ec4b 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ XCODEGEN ?= xcodegen # Must match XCODEGEN_VERSION in .github/workflows/ios.yml; other versions write a different project. XCODEGEN_VERSION := 2.46.0 -.PHONY: check server web ios ios-project +.PHONY: check server web ios ios-project smoke check: server web ios @@ -34,3 +34,9 @@ ios-project: exit 1; \ fi $(XCODEGEN) generate --spec ios/project.yml + +# Posts each scene in server/examples to a placement server and prints its decision: +# make smoke URL=https://house-scanning-server.vercel.app [KEY_FILE=server/.env.private.local] +smoke: + @test -n "$(URL)" || { echo "usage: make smoke URL= [KEY_FILE=]" >&2; exit 2; } + python3 server/examples/smoke.py "$(URL)" $(if $(KEY_FILE),--key-file "$(KEY_FILE)") diff --git a/README.md b/README.md index 98939ca68..24e05bcd5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Two teams share the work. The client team, Sam with AI agents and Aiden on video | --- | --- | | [docs/how-it-works.html](docs/how-it-works.html) | A picture-first walkthrough of the system, about fifteen minutes | | [docs/00-overview.md](docs/00-overview.md) | The plan, the decisions and their reasons, the evidence so far, and open questions | +| [docs/06-research-handoff.md](docs/06-research-handoff.md) | The server team's research handoff: findings, tested prototypes and remaining field work | | [AGENTS.md](AGENTS.md) | The rules for anyone changing this repository, person or agent | | [CONTRIBUTING.md](CONTRIBUTING.md) | Branches, CI checks and TestFlight | diff --git a/docs/06-research-handoff.md b/docs/06-research-handoff.md new file mode 100644 index 000000000..fd2f64c54 --- /dev/null +++ b/docs/06-research-handoff.md @@ -0,0 +1,83 @@ +# Live survey research: findings and handoff + +September 26, 2026. Research stopped at the user's request. This report packages the completed work, useful implementation proposals, and remaining validation gaps. It is a research handoff, not a production-readiness or installation-accuracy claim. + +**Recommendation: build a hybrid evidence loop around native capture.** Use Swift, ARKit and RealityKit for the open camera, pose tracking, photo selection and immediate guidance. Save each useful original photograph with its own calibration and pose. Let asynchronous services perform heavier recognition and reconstruction, then evaluate requirements with explicit evidence and uncertainty. A complete photorealistic house model should not block a useful, source-backed answer. + +## What was achieved + +| Area | Completed work | Practical outcome | +| --- | --- | --- | +| Camera and capture | Compared native iOS, Android, browser and native-module paths; typechecked relevant ARKit still-capture and guidance APIs; built a durable capture-admission prototype | Native iOS is the leading integration path. Progress must follow the returned, accepted, durably saved photograph—not the preview that triggered it | +| Coverage and guidance | Explored surface coverage, unknown space, task evidence, next-view policies and screen projection; exercised synthetic policies and actual native coverage code | Separate **where to move**, **where to aim**, and **what evidence is still missing**. Geometry revisions can invalidate earlier coverage | +| Reconstruction and visualization | Audited classical, learned batch, streaming and phone-depth alternatives; ran bounded DA3, MoGe, Apple depth and COLMAP probes | Several components run locally, but none establishes exterior measurement accuracy. Keep fast local geometry and slower reconstruction revisions separate | +| Recognition and association | Tested OCR/barcodes, box trackers, image correspondence and causal point tracking; built a revision-aware association prototype | Recognition confidence and a persistent tracking ID do not prove the correct equipment or exact label value | +| Architecture and integration | Checked camera/calibration conversions, evidence admission, reset races, export/version semantics and deployment limits | Preserve originals and coordinate epochs; use resumable object storage separately from compact placement requests | +| Reviewable implementation | Published two standalone Python prototypes and two isolated Swift patch proposals; existing camera-conversion work remains in [PR #9](https://github.com/SamGu-NRX/house-scanning/pull/9) | Useful work is available for selective integration without changing teammates' application branches | + +The detailed [options and resources](research/2026-09-26-options-and-resources.md) retain alternatives. The [experiment evidence](research/2026-09-26-experiment-evidence.md) records positive, negative, blocked and unrun outcomes. + +## Recommended flow + +```mermaid +flowchart TD + A[Open camera and capability checks] --> B[Local tracking and image-quality checks] + B --> C[Coverage and missing task evidence] + C --> D[Move cue or aim cue] + D --> E[Select a useful still] + E --> F[Assess actual returned image and metadata] + F --> G[Durably admit original and evidence] + G --> C + G --> H[Resumable upload to object storage] + H --> I[Asynchronous recognition and reconstruction] + I --> J[Versioned geometry and sourced observations] + J --> K[Deterministic checks with uncertainty] + K --> L[Supported result or specific missing evidence] + L --> C + J --> M[Inspectable 3D preview with original photos] +``` + +Local feedback continues while uploads and workers are pending. Capture completion, upload completion, analysis completion and assessment completion are separate states. A newer result is accepted only when its evidence and coordinate dependencies are still valid. + +## Findings that change the design + +1. **A view is useful only after the actual still is assessed and saved.** A preview may look sharp while the returned still is late, blurred, from another pose, or associated with an obsolete task. The capture prototype makes admission and progress transactional, preserves original evidence, and prevents old coordinate epochs from silently satisfying current spatial requirements. + +2. **Fog of war needs more than the path the customer walked.** Surface coverage, unknown/occupied space and required close-up evidence need separate records. A missed ray in an incomplete mesh means unknown. It does not establish open space or a safe route. If ground or wall geometry changes, dependent coverage must be recomputed or invalidated. + +3. **Coverage can become stale in the current native implementation.** At the inspected source commit, changing only the ground height retained 14 covered cells; a fresh map with the same cameras and new height had zero covered cells. This is a reproducible software-state inconsistency, not a sensor-accuracy result. The published replay helper is a limited, unrun proposal; its refusal of an unresolved horizontal cell shift still needs an integration policy. + +4. **Reconstruction updates can revise earlier geometry.** Adding a fourth image to the DA3 batch changed earlier depth estimates: 3.06% mean absolute relative revision before alignment and 6.51% after it. These describe change, not improved accuracy. Measurements and results derived from revised geometry need revalidation. + +5. **Tracking needs a recovery path and independent context checks.** BootsTAPIR processed all 175 frames of one public indoor book sequence. Only **457/1,392** point-frame opportunities were both marked visible and inside the object mask, despite **457/458** containment among visible outputs. There were 77 frames with no visible query. Mask containment cannot establish exact point correspondence or equipment identity. This Mac run also does not establish real-time phone performance. + +6. **OCR confidence is insufficient for exact identifiers.** Synthetic native and portable OCR checks produced identifier mistakes at high confidence. Preserve original crops, text spans and field associations; keep unreadable or conflicting values unresolved. Two inspected real photographs supplied observations, not an independently adjudicated accuracy benchmark. + +7. **Use separate transport for original photographs.** Vercel documents a 4.5 MB Function request/response envelope. Send original media to a suitable storage endpoint and submit compact scene/placement data separately. Live checks covered only health and schema reads; no upload recovery or placement service was validated. [Vercel limit](https://vercel.com/docs/functions/limitations#request-body-size) + +## Implementation delivered + +| Artifact | What it contains | Validation boundary | +| --- | --- | --- | +| [Capture admission](../experiments/research-handoff/capture-admission/README.md) | SQLite prototype for request/returned-still separation, atomic evidence admission, epoch/revision checks, crash rollback and acknowledgments | Synthetic bytes and supplied assessments; no camera, real upload or production storage integration | +| [Equipment association](../experiments/research-handoff/equipment-association/README.md) | State machine separating observations, nominated equipment, context revisions and reviewed label associations | Synthetic in-memory transitions; no physical identity or image-recognition validation | +| [Native patch proposals](../experiments/research-handoff/native-proposals/README.md) | Small current-source reset patch plus a separate ground-coverage replay helper proposal | Reset patch passed nine paired harness cases; ground helper remains unrun. Neither was applied to the app | +| [Camera metadata adapter, PR #9](https://github.com/SamGu-NRX/house-scanning/pull/9) | Camera coordinate conversion and input-validation experiment | Existing mathematical tests; not physical calibration or field accuracy | + +The public package contains original reusable code, synthetic tests, patch proposals and curated results. Downloaded datasets, model weights, build caches, private source material and detailed local run receipts remain outside Git. Historical failures are retained locally rather than silently replaced by later passes. + +Publication validation: the exported capture-admission suite passed **26 tests** and equipment-association suite passed **11 tests** on Python 3.13.7. Their READMEs contain the exact commands. Python syntax, JSON, relative document links and patch hashes were checked; the published implementations match the reviewed source. The app/server/web files were not changed or built in this handoff. + +## What remains unresolved + +**The largest gap is a real-phone session on representative exterior geometry with independently measured dimensions.** Desktop API checks and synthetic controls cannot answer whether still capture disrupts tracking, whether arrows remain aligned, or whether a phone can sustain the workload without thermal or battery problems. + +The next useful work, if resumed, is: + +1. **One small field capture:** ordinary supported iPhone first, optional LiDAR comparison. Keep per-image pose, calibration, original bytes and coordinate epochs. Record interruptions, bad captures and recovery—not just the happy path. +2. **Independent geometry checks:** measure identified endpoints with a separate method. Reserve held-out distances when fitting scale or alignment. The acquired TUM reference sequence is ready for a fixed depth-consistency diagnostic, but that diagnostic and model evaluation were not run. +3. **Focused guidance validation:** test move-versus-aim behavior, viewport/orientation changes, stale results and geometry revisions. Observe whether customers follow the cues and whether missing evidence remains visible. +4. **Durable end-to-end integration:** review the isolated reset patch, implement qualified observation replay, test interrupted/background uploads, and bind each assessment to exact image, scene and rules versions. +5. **Representative recognition and identity evaluation:** use adjudicated labels and per-point correspondence/visibility truth. Compare abstention and wrong associations as well as successful reads. + +Keep browser coaching, ARCore, alternate reconstruction models and simpler evidence-first outputs available when device reach, measured quality or deployment costs change the tradeoff. No permanent model winner or whole-house accuracy claim was established. diff --git a/docs/research/2026-09-26-evidence-index.json b/docs/research/2026-09-26-evidence-index.json new file mode 100644 index 000000000..89f4a2f1e --- /dev/null +++ b/docs/research/2026-09-26-evidence-index.json @@ -0,0 +1,97 @@ +{ + "schemaVersion": 1, + "date": "2026-09-26", + "purpose": "Selected local evidence identities for the public research handoff. Paths are relative to the retained, ignored private/research archive; these files are not shipped in this commit. Hashes do not make unshipped evidence independently reproducible. Read the accompanying experiment-evidence report for the status and scope of each result; a preparation manifest does not imply execution.", + "records": [ + { + "localArchiveRelativePath": "experiments/capture-admission/REPORT.md", + "bytes": 9448, + "sha256": "548df37bfd2d537f96cb170805c97822e0c715f678e9ade08cdf469fdc7dfbf2" + }, + { + "localArchiveRelativePath": "experiments/capture-admission/review/fix-receipts.json", + "bytes": 11513, + "sha256": "702ccf8d1ab1ea22d3f847f69e1b3ce0d5986685d750405c461aec3883f5aee5" + }, + { + "localArchiveRelativePath": "experiments/equipment-association/fix-v2/REPORT.md", + "bytes": 2151, + "sha256": "bb033b9af77b95854a3f81d227911242574c246e6694d835f0eb40486ad69588" + }, + { + "localArchiveRelativePath": "native-producer-update-20260926-1217/reset-review/current-patch/REPORT.md", + "bytes": 5509, + "sha256": "03606b7c18994aa4a4dfb84e24078cffab8dbf2433d58bdc6e950d547047709d" + }, + { + "localArchiveRelativePath": "native-producer-update-20260926-1217/reset-review/current-patch/final-manifest.json", + "bytes": 18424, + "sha256": "2a41a977e0a209d54963e263b59a8e77ff57663f41821d68d3610e05acceee56" + }, + { + "localArchiveRelativePath": "native-producer-update-20260926-1217/geometry-review/ground-update-probe/REPORT.md", + "bytes": 4505, + "sha256": "678428ffb39c1cb24c9b0a77426335a4cf3424faa780cde7e78098756ff3ab29" + }, + { + "localArchiveRelativePath": "native-producer-update-20260926-1217/geometry-review/ground-update-probe/artifact-manifest.json", + "bytes": 15089, + "sha256": "329968483d233783d6df0334f6c890b37b8fd0e7bc41b5a8bcf4b7cdae38f658" + }, + { + "localArchiveRelativePath": "experiments/bootstapir-book/REPORT.md", + "bytes": 5189, + "sha256": "1224f49aa3c3a936366c5f3393a40107a5c712bc4ce626ff73b54999315d8dde" + }, + { + "localArchiveRelativePath": "experiments/bootstapir-book/independent-review.md", + "bytes": 4276, + "sha256": "849b343413af50acab3fbdb98095b65daef77a5a443bce34731aabb26b24be85" + }, + { + "localArchiveRelativePath": "experiments/bootstapir-book/final-manifest.json", + "bytes": 5108, + "sha256": "eecfbf6500a0191b5d9c9abc9c6181bac00f6b9a225ffec26c3f2d6912db7fb9" + }, + { + "localArchiveRelativePath": "experiments/da3-prefix-update/REPORT.md", + "bytes": 8270, + "sha256": "0d9ebcd60a0a4fa85e09b9919fa9d2e7cce685227df0007a28049116dfde2550" + }, + { + "localArchiveRelativePath": "experiments/apple-depth-runtime/README.md", + "bytes": 7200, + "sha256": "a5e0d85923ae53ec6327cec6ef1a72d625814d0fee075b9462f8f617bde2715b" + }, + { + "localArchiveRelativePath": "experiments/tum-fr3-reference-prep/acquisition-prep/REPORT.md", + "bytes": 4680, + "sha256": "36d99f7567d7dd36ce52cc157616082ab66e2b79b4a0147a89ef9cac220c2d21" + }, + { + "localArchiveRelativePath": "experiments/tum-fr3-reference-prep/acquisition-prep/artifact-manifest.json", + "bytes": 200386, + "sha256": "7ce8dc59d2d75cc9830b099779969c7997f917a171313645fe07a838772b66cf" + }, + { + "localArchiveRelativePath": "experiments/tum-fr3-reference-consistency/prepared-manifest.json", + "bytes": 1120, + "sha256": "133358bd1281c13530e1335d4044c831856d550f9643458f96a899bed2c0d2f8" + }, + { + "localArchiveRelativePath": "deployment-input-limits/REPORT.md", + "bytes": 4991, + "sha256": "1e72c6f955242fda08d6e0ea54ca35eb3cf1c7d4dce67fb03639989918973124" + }, + { + "localArchiveRelativePath": "experiments/upload-direct-build/runtime-prep/RUNTIME-RESULT.md", + "bytes": 2636, + "sha256": "7f63c04670ab85431fcf15aad3fede592b160ee2e5753eb20a6d11fdf63dea29" + }, + { + "localArchiveRelativePath": "point-reference-options.md", + "bytes": 7286, + "sha256": "5e3135236423f859f83829e683dcf637f627e2f60632bce1f1faf2fed101e30e" + } + ] +} diff --git a/docs/research/2026-09-26-experiment-evidence.md b/docs/research/2026-09-26-experiment-evidence.md new file mode 100644 index 000000000..10cdf8f3f --- /dev/null +++ b/docs/research/2026-09-26-experiment-evidence.md @@ -0,0 +1,116 @@ +# Experiment evidence at research stop + +September 26, 2026. This is a curated record of the completed investigation. Counts belong to the stated inputs and exact experiment, not a shared leaderboard. Most runtime work used an Apple M5 Mac with 16 GiB memory; no physical-phone performance result was established. + +**Evidence levels:** source/API inspection establishes documented contracts; synthetic execution establishes behavior for constructed cases; real-image execution establishes output on those images; independent physical accuracy requires separate truth. These levels are not interchangeable. Successful audit of saved arithmetic is not a second model or field run. + +## Capture, guidance and evidence handling + +| Investigation | Observed result | Limit / consequence | +| --- | --- | --- | +| ARKit still-capture APIs | Corrected standalone Swift probe typechecked with SDK 26, including returned-frame pose, intrinsics, timestamp and image dimensions | Initial naming error preserved. No camera, concurrency, throughput or thermal validation | +| Native guidance APIs | SDK 26 orientation, projection and window geometry calls typechecked for an iOS 16 deployment target; newer overload negative controls failed as expected | No physical screen alignment. World projection and captured-image display transforms remain different contracts | +| Coverage replay | Synthetic path/frustum examples exposed false-completion cases and exercised unknown/recovery handling | Walking nearby and seeing a surface are different. Analytic scenes do not establish exterior performance | +| Next-view policy | 15 synthetic scenarios across four variants; 60 controls passed, with a separate saved-state/metric review | Five cases had explicit preference labels. Full dwell expiration, users and real navigation remain untested | +| Capture admission | Latest prototype passed 19 producer controls and seven independent regression controls after epoch, revision-race and malformed-ID counterexamples were fixed | Simulated image assessment and bytes. Actual SIGKILL rollback covers one controlled transaction point, not all storage failures | +| Export coherence | 50 expected synthetic outcomes; separate audit covered 408 records across 16 cases | Source identity, references and warnings were checked. Physical endpoints, exposure identity and calibrated uncertainty were not established | +| Camera adapter | Existing [PR #9](https://github.com/SamGu-NRX/house-scanning/pull/9): 14 tests and 100 rotation controls | Coordinate arithmetic and input validation, not device calibration | +| Network replay | 54 primary and 18 additional synthetic controls | Invented workload; no real network, throughput or battery result | + +Published versions of the two reusable state prototypes have their own test instructions and export-validation results under [research-handoff](../../experiments/research-handoff/README.md). + +## Actual native source findings + +The following findings apply to inspected commit [`a39d0a503412d688dd151f628cdeebc00f8221b6`](https://github.com/SamGu-NRX/house-scanning/tree/a39d0a503412d688dd151f628cdeebc00f8221b6), not an automatically moving branch head. Teammates' source was read without modifying their branches. + +### Reset and asynchronous completion + +One Swift build and one extracted-engine harness run produced 18 observations: nine baseline/patch pairs. Four baseline schedules admitted obsolete storage work or published a close-up after reset/phase change. All nine patched cases matched expectations, including normal success/failure and independent draining of old/new save generations. + +The current implementation already uses store epochs and a per-generation pending-save dictionary. The proposal preserves those improvements. It adds task-entry/post-await validity checks and ensures the captured generation drains on early return. An earlier, larger patch against an older source revision is historical and should not be applied wholesale. + +This harness uses exact extracted engine methods with declared completion instrumentation, controlled storage and UI/framework doubles. It does not execute the full iOS app, actual current disk writes, JPEG decoding, camera, OCR or upload. The [published patch](../../experiments/research-handoff/native-proposals/README.md) requires integration review. + +### Ground-height coverage invalidation + +Untouched `Camera`, `WallFrame` and `CoverageMap` sources were compiled into a fixed synthetic probe. Raising ground height from 0 to 0.5 m left the existing map's **14 covered cells** and revision unchanged; rebuilding from the identical observations at the new height produced **zero covered cells**. A no-op update preserved the original state as expected. All 11 predeclared checks passed. + +The upper wall samples moved outside both images, but cached row evidence remained associated with the previous sample positions. Merely incrementing a revision would not fix the evidence. A replacement map may also reuse an old numeric revision, so downstream publication needs an explicit update contract. + +The proposed helper replays qualified cameras in their original acceptance order and retains skip annotations. It was **prepared only**, not compiled or executed. It refuses a nonzero pending horizontal cell shift, a limitation that can occur during normal anchor refinement. It is not a complete app fix, observation ledger or UI integration. + +## Geometry and reconstruction + +| Investigation | Observed result | Interpretation | +| --- | --- | --- | +| MoGeSmall ONNX | CPU compatibility and one small illustrative forward completed; postprocessing had 19 mathematical controls | Not a reconstruction-quality or phone benchmark; PyTorch/ONNX parity remained unresolved | +| MoGe single exterior image | Declared conditional depth discrepancy decreased from 53.6% to 29.9% between two token settings | One image; depth/range and field-of-view conventions remained unresolved. Do not treat as a reliable accuracy score or model ranking | +| DA3 synthetic compatibility | Three-view CPU/MPS outputs were finite; invalid metric-mode usage was identified and preserved | Compatibility only | +| DA3 real views | Three public real images produced 29,400 exported, unfused points in one CPU run; output and viewer data were reviewed | Supplied poses/scale, moving foreground and unresolved reference scan semantics prevent accuracy scoring | +| DA3 added view | Fourth-image batch changed 117,600 shared depth samples: 3.06% raw and 6.51% aligned mean absolute relative revision | Context and alignment changed together. Neither value means improvement or accuracy | +| Apple Core ML depth | One native Mac CPU prediction returned all 203,056 finite FP16 samples; packing/row-stride/output checks passed | This artifact divides each image's learned output by its own maximum. It is a relative cue, not meters; one stretched input and one run | +| COLMAP | Initial database/thread precondition failures retained; subsequent saved-feature continuation yielded 371 raw and 333 verified pair rows, **zero final 3D points** | No successful reconstruction. Correspondence and timestamp audits did not justify silently changing pose associations or rerunning | +| MapAnything | Source/weight-license audit and helper-only execution covering 11 behavior groups | No model weights or inference. Crop, camera conventions, units and output revisions need integration checks | +| TUM reference acquisition | Complete 211,008,272-byte archive acquired; 465 RGB, 454 depth images and 1,554 poses; 451 associated pairs; three fixed views admitted from metadata | Original archive/pose identities checked; visual inspection showed a flat printed-paper scene with blur. Depth consistency and learned-model evaluation remain **unrun** | + +The TUM profile fixes intrinsics, depth scale, timestamps, pose interpolation and three metadata-selected views before evaluation. Its prepared diagnostic would compare all six directed view pairs without fitting calibration or discarding residuals. It was not executed before the stop request. A small indoor planar reference would still not establish exterior-house accuracy. [Publisher dataset documentation](https://cvg.cit.tum.de/data/datasets/rgbd-dataset) + +Streaming claims also need precise interpretation: a stateful lower-level model can be wrapped by code that resets state; accepting chunks does not necessarily mean causal live input. MapAnything, LingBot, MoGe-3 and classical reconstruction remain alternatives in the [resource comparison](2026-09-26-options-and-resources.md). + +## Recognition, tracking and association + +| Investigation | Observed result | Interpretation | +| --- | --- | --- | +| Native OCR | 84 synthetic requests included exact-identifier errors at confidence 1.0 | Confidence saturation does not justify accepting an exact identifier | +| PP-OCR tiny | Same 21 synthetic inputs: 39/68 lines and 2/17 labels in the declared scoring contract | Different score meanings/timing and a source-derived adapter; not a field ranking | +| Vision 26 | 42 requests; document path 47/68 lines and 5/17 labels, with top-1 strings matching legacy on this set; smudge API ran | Synthetic inputs do not validate actual lens contamination or equipment text | +| Two real label photos | Native OCR yielded 19 observations, illustrating split/merged fields | Provisional readings, no independently adjudicated truth or accuracy rate | +| Native barcode | Two exact synthetic strings recovered; separate byte/segment analysis explained original raw-byte mismatches | Original comparison failure retained. No universal payload-format or equipment-identity claim | +| Vision box tracker | 175-frame book run: 160/174 later frames had zero box overlap despite persistent identity/confidence signals | One indoor sequence, initial annotation assistance; not a phone or residential test | +| ViTTrack box tracker | Same sequence: mean box IoU 0.40943 versus Vision 0.03681; 12 false updates counted zero, 43/162 accepted boxes had zero overlap | Better on this clip still includes confident wrong targets; runtime boundaries differ | +| Pairwise SIFT | Fixed 15-feature nomination against nine target views refused all for insufficient matches | No homography fitted; not a universal SIFT failure | +| LightGlue, nomination only | Nine fixed 15-by-500 feature pairs produced no matches | Exact saved features and author checkpoint; not the author's full extraction pipeline | +| LightGlue, full context | Same original source expanded to 500 features: 1,093 full-scene matches; only two matches originated in the fixed source nomination, both paired with frame 175 | Target-object membership was not verified; no correspondence truth or geometry fit. Scene agreement is not nominated-equipment agreement | +| Vision registration | Known synthetic warp returned confidence 1; none of eight declared coordinate families or inverse diagnostics met the fixed residual bounds; best RMS 8.587 px | Registration versus an unmodeled coordinate convention remained unresolved; not an API-wide defect claim | +| Equipment association | Corrected prototype passed 11 synthetic transition cases after independent review found a sibling-ticket race | In-memory state semantics, not physical identity, image resolution or authenticated review | + +### Causal point tracking: useful signal, incomplete availability + +BootsTAPIR at tapnet commit `730cda1c730877cfedbe01bf87fb1cadb78a565d` first passed strict checkpoint loading and two synthetic causal steps. It then completed one continuous CPU run over all **175 original book images**, with eight fixed first-frame queries, no reset, no query replacement and default visibility. Frame 1 was initialization; all 174 subsequent frames remained in the denominator. + +| Outcome | Result | +| --- | ---: | +| Visible and inside target mask, all opportunities | **457 / 1,392 (32.83%)** | +| Inside target mask, conditional on visible | **457 / 458 (99.78%)** | +| Marked not visible | 934 / 1,392 | +| Frames with no visible query | 77 / 174 | +| Visible outside-mask outputs | 1 | +| Whole watched CPU process | 83.80 s | +| Sampled owned-process RSS maximum | 731,660,288 bytes | +| Median prediction boundary | 0.4568 s | + +The timing boundary includes feature extraction, tracking, coordinate conversion and scalar serialization, but excludes image decode and later checks. Sampled memory can miss short peaks. These measurements are not iPhone performance or steady-state FPS. + +Independent saved-output arithmetic reproduced all 1,400 mask memberships, including initialization, the 1,392 evaluation classifications, coordinate ratios and visibility decisions. **Containment is not exact point tracking:** a prediction can slide inside the object's mask. A nonempty object mask does not prove a particular nominated point is visible. Therefore the 751 hidden-but-contained outputs cannot be labeled false occlusions. The already-seen clip, mask-assisted query selection and differing tasks also prevent a fair leaderboard against box IoU. [BootsTAPIR implementation](https://github.com/google-deepmind/tapnet) + +The final source/metadata search found one smaller reference with actual per-point trajectories and occlusion labels: TAP-Vid RGB-Stacking, a publisher-described 50-video synthetic robotics set. Its complete ZIP advertises 187,291,581 bytes and the publisher assigns CC BY 4.0 to videos and annotations. No dataset body was acquired, so expansion, exact useful transitions and evaluation suitability remain uninspected. No complete comparably small real-video option was verified. This is a candidate for a later point-accuracy test, not a new result. [Publisher dataset documentation](https://github.com/google-deepmind/tapnet/tree/730cda1c730877cfedbe01bf87fb1cadb78a565d/tapnet/tapvid) + +## Criteria, integration and transport + +| Investigation | Established | Limit | +| --- | --- | --- | +| Placement intervals | Seven synthetic scenarios and 3,315 finite oracle comparisons exposed a coarse-grid miss | One-dimensional hard-bound assumptions; no calibrated physical uncertainty | +| Measurement interoperability | 30 paired synthetic CLI cases plus saved-output review | Units, equality, missing evidence and validator scopes; no image inspection or measurement-endpoint truth | +| One-wall handoff adapter | 23 methods / 30 calls, two parser controls and nine reply checks; 10 provisional and 20 withheld | Synthetic coordinate epochs and a separate measurement path, not production capture integration | +| Server pruning review | Actual source reproduced overly aggressive pruning; isolated correction tested; newer team source independently fixed the original default case | A remaining extreme-drift boundary was a stress case, not an observed household outcome. No whole-site/API accuracy result | +| Deployment observation | Official 4.5 MB Function envelope; three live GETs for health and schemas; served schema bytes matched inspected source | No deployed-code attestation, placement POST, media upload or reconstruction service | +| TUSKit upload work | Initial build exceeded its cap; a separate direct build produced client/library artifacts but exposed an SDK identity mismatch | A later explicitly scoped runtime attempt stopped at process-monitor permission denial **before** server, client or payload. No transfer ran | +| Apple segmentation | Actual model-interface inspection and prompt-harness controls completed | Prompt-only Core ML attempt stopped at a working-directory permission denial before model load/prediction. No mask-quality result | + +Failed and denied attempts were preserved. No alternate permission path was used to bypass them. No paid GPU was provisioned. The user stop arrived before running the ground replay proposal, the prepared TUM depth diagnostic or any further research candidates. + +## Publication and reproducibility + +The checked-in [experiment packages](../../experiments/research-handoff/README.md) can be reviewed and tested independently. The native patches identify their precise public baseline and remain proposals. Raw local experiment trees retain frozen inputs, source/model identities, output records and reviews, but are not published here because they include third-party media, model files, build artifacts or private context. + +The accompanying [evidence index](2026-09-26-evidence-index.json) records SHA-256 identities of selected locally retained reports and manifests. Those hashes identify the research snapshot; they are **not** a substitute for the unshipped evidence or an independently reproducible benchmark. The high-level [handoff](../06-research-handoff.md) gives the recommended next decisions and field test. diff --git a/docs/research/2026-09-26-options-and-resources.md b/docs/research/2026-09-26-options-and-resources.md new file mode 100644 index 000000000..5c1ab95f5 --- /dev/null +++ b/docs/research/2026-09-26-options-and-resources.md @@ -0,0 +1,95 @@ +# Capture, guidance and reconstruction: options and resources + +Research snapshot: **September 26, 2026**. This page consolidates existing primary-source reviews and bounded local experiments. It contains no private survey criteria or customer imagery. Publisher results, local execution and proposed integrations are different kinds of evidence; none of the work establishes a validated exterior-house survey on a phone. + +Read the [high-level handoff](../06-research-handoff.md) for the overall recommendation and the [experiment ledger](2026-09-26-experiment-evidence.md) for individual outcomes. This page explains the alternatives and links their primary resources. + +**Recommended direction:** native iPhone capture, optional depth, local quality and guidance, durable selected photographs, asynchronous server interpretation, and explicit geometric checks. Keep a simple reviewable scene ahead of a photorealistic model. Retain Android and browser paths as meaningful alternatives with different evidence capabilities. + +Evidence labels below: + +- **Local:** the stated code or model path actually ran on this Mac. The fixture and scope still matter. +- **Source:** official documentation, pinned source or artifact contracts were inspected; the product integration was not run. +- **Proposed:** a design choice or next comparison, not an observed result. +- **Blocked / empty:** a preserved outcome, not a successful benchmark or proof that the underlying method is unsuitable. + +## 1. Capture the exterior and useful detail + +| Path | What distinguishes it | Recommendation and evidence | +|---|---|---| +| **Swift + ARKit / RealityKit** | One native session provides images, camera poses, calibration and tracking state. Supported devices can add depth and scene meshes. | **Primary path. Source + API typechecks.** Own the camera, buffers, frame metadata and overlays in native code. No physical-phone capture, outdoor accuracy or thermal result was obtained. [Tracking lifecycle](https://developer.apple.com/documentation/arkit/managing-session-life-cycle-and-tracking-quality), [scene depth](https://developer.apple.com/documentation/arkit/arframe/scenedepth). | +| **Object Capture area mode** | Automatic photos, capture feedback and a point-cloud UI for surfaces that cannot be circled. | **Native alternative for a geometry demo. Source/API inspection.** Apple's sample requires supported LiDAR hardware. The inspected public session API does not expose the same per-frame image/pose feed as an app-owned ARSession; capture and photogrammetry are separate phases. [WWDC24 area mode](https://developer.apple.com/videos/play/wwdc2024/10107/), [sample](https://developer.apple.com/documentation/realitykit/scanning-objects-using-object-capture). | +| **Android ARCore** | Tracking, planes, calibration and optional depth; supported devices can compute depth without a dedicated depth sensor. | **Credible expansion path. Source only.** Check tracking and depth support separately. Raw depth can contain holes or reproject earlier observations; it is not a ready environment mesh or an independent new measurement every frame. [Raw depth](https://developers.google.com/ar/develop/java/depth/raw-depth), [shared camera](https://developers.google.com/ar/develop/java/camera-sharing). | +| **Browser photo coaching** | Link-based entry, camera preview, framing instructions and asynchronous analysis with less installation friction. | **Useful reduced-capability alternative. Source only.** Ordinary iPhone Safari was not established as a native WebXR/ARKit pose or LiDAR-export path. Chrome Android has an ARCore-backed WebXR path, but photographic evidence/calibration export still needs a separate check. [WebKit issue](https://bugs.webkit.org/show_bug.cgi?id=309550), [Android WebXR](https://developers.google.com/ar/develop/webxr). | +| **Expo shell + native capture module** | Retains React Native screens while Swift owns the complete AR capture view and data stream. | **Integration alternative, not removal of native work. Source only.** Use a development build; Expo Go cannot load arbitrary custom native code. Send compact events/file references to JavaScript rather than every camera buffer. [Expo native code](https://docs.expo.dev/workflow/customizing/). | + +Ordinary ARKit tracking and LiDAR are different capability tiers. Do not require a depth sensor merely to show spatial cues, and do not infer measured free space when depth is missing. Save each selected photograph with **its own** calibration, dimensions, timestamp and pose. ARKit's [high-resolution frame API](https://developer.apple.com/documentation/arkit/arsession/capturehighresolutionframe(completion:)) can deliver an asynchronous still during tracking; format support and the returned frame must be checked. + +Useful open-source references include [Stray Scanner](https://github.com/strayrobots/scanner) (MIT; inspected recorder requires scene depth), [NeRFCapture](https://github.com/jc211/NeRFCapture) (MIT; optional-depth capture reference), and [arkit-3dgs-scanner](https://github.com/KuoFengYuan/arkit-3dgs-scanner) (Apache-2.0; automatic selection/quality code). None is a locally validated turnkey survey app. + +For distribution, an [App Clip can use ARKit](https://developer.apple.com/documentation/appclip/interacting-with-app-clip-codes-in-ar), but its [capability limits](https://developer.apple.com/documentation/appclip/choosing-the-right-functionality-for-your-app-clip) include no background URLSession activity. Browser alternatives also need lifecycle testing. The 2026 [8th Wall release](https://8thwall.org/docs/open-source) opens its MIT framework, **not its SLAM engine**: the separate binary has a [different license](https://github.com/8thwall/engine/blob/main/LICENSE). [AlvaAR](https://github.com/alanross/AlvaAR) is a GPLv3 browser visual-SLAM research alternative, not a demonstrated native-equivalent phone survey engine. + +## 2. Guide the person and choose photographs + +**Recommended baseline:** combine current task needs, tracking quality, image quality and accepted-view history. Keep “move to another viewpoint� separate from “aim here,� “hold still� and “recover tracking.� A useful preview requests a still; progress advances only after the returned image and metadata are accepted and durably saved. + +| Component | Useful resource or approach | Evidence and remaining gap | +|---|---|---| +| Blur, exposure, text size and motion checks | Native image statistics, AR tracking state and sampled [Vision OCR](https://developer.apple.com/documentation/vision/vnrecognizetextrequest). | **Source + Mac OCR probes.** No universal phone-quality threshold was established. High OCR confidence can accompany a wrong identifier. | +| Task-aware capture admission | Bounded preview analysis; one capture in flight; reassess the actual still; persist before crediting progress. | **Local synthetic protocol controls.** Save failures and interrupted transactions did not become successful captures in the tested prototype. This does not measure camera/still latency or image usefulness. | +| Fog of war | A ledger of observed surface patches and unresolved evidence tasks, supported by visible saved images. | **Local synthetic replay.** Walking a route or placing a surface inside the camera frustum does not prove that surface was seen through an occluder or at usable quality. | +| Viewpoint selection | Small candidate-view search with accepted viewing-direction history, a stable target and recovery preconditions. | **Local synthetic replay.** Hysteresis reduced scripted target switching; a wrong predicted footprint could make look-ahead worse. No homeowner-effort improvement was measured. | +| Visibility and unknown space | [Open3D ray queries](https://www.open3d.org/docs/latest/python_api/open3d.t.geometry.RaycastingScene.html); [OctoMap](https://octomap.github.io/) for free/occupied/unknown mapping. | **Source; simple synthetic geometry also exercised.** A partial mesh limits visibility claims. Open3D is MIT; OctoMap core is BSD, while its viewer has different terms. Neither is a complete phone guidance system. | +| Screen-space arrows/reticles | ARCamera projection using the correct orientation, viewport and frame; suppress unsupported world cues after a coordinate reset. | **Local API typecheck + source contract.** Signatures compiled; physical camera alignment and live cue stability remain untested. A simulator cannot validate real camera/VIO. | + +Learned next-best-view systems remain alternatives, not necessary dependencies. [SEE++](https://github.com/robotic-esp/see-public) offers an MIT point-density/frontier approach with a robotics stack. [GenNBV](https://github.com/zjwzcx/GenNBV) supplies learned planning code and checkpoints, but uses a drone action space, substantial GPU/simulator resources and CC-BY-NC-SA terms. Neither was run locally. Their published benchmarks do not establish safe, accessible movement instructions for a person around a house. + +## 3. Reconstruct and visualize + +Use a versioned, inspectable geometry representation for measurements and a separate rendering asset for review. Metric units, photorealism, model confidence and additional photographs do not by themselves establish metric accuracy. + +| Option | Actual update/output contract | Recommendation, local status and license boundary | +|---|---|---| +| **ARKit planes / optional mesh** | Device tracking and geometry updates during capture. | **Primary live spatial reference. Source/API evidence; no phone validation.** Apple platform SDK, not open-source model weights. Missing geometry remains unknown. | +| **DA3-Small** | Multi-view batch depth/camera inference; the separate streaming wrapper uses overlapping chunks and alignment. | **Small reconstruction challenger. Local CPU/MPS and real-image probes completed.** Adding a fourth view revised existing outputs, so batch results need revision handling. Small/Base weights are Apache-2.0; several larger variants are noncommercial. [Official project/model table](https://github.com/ByteDance-Seed/Depth-Anything-3). | +| **MapAnything Apache** | Set-based inference conditioned on images and optional calibration, poses or depth. Supplied geometry is conditioning, not a guaranteed fixed output. | **Leading larger calibrated-input challenger. Source/header audit + local preprocessing helpers only; no model forward.** Explicitly select [Apache weights](https://huggingface.co/facebook/map-anything-apache); the default other checkpoint is noncommercial. Roughly 4.58 GiB of F32 weights is not a measured VRAM requirement. [Code](https://github.com/facebookresearch/map-anything). | +| **LingBot-Map** | Actual causal frame processing with retained state after initialization. The demo wrapper resets state at the beginning of a run. | **Streaming research candidate. Source only.** A service must own persistent session state rather than repeatedly call the sequence wrapper. Documented CUDA/FlashInfer route; no local MPS/phone path demonstrated. Code and official weights declare Apache-2.0. [Project](https://github.com/Robbyant/lingbot-map), [weights](https://huggingface.co/robbyant/lingbot-map). | +| **MoGe-2 Small** | Single-image geometry, normals and scale-related outputs; tracking/fusion are separate. | **Compact geometry comparator. Local CPU ONNX and one exterior reference-discrepancy probe completed.** Export postprocessing and reference semantics matter; these runs did not establish house-survey accuracy. Official PyTorch weights declare MIT; retain the ONNX artifact's separate provenance. Newer MoGe-3 documents a different runtime and no macOS support. [Project](https://github.com/microsoft/MoGe), [Small weights](https://huggingface.co/Ruicheng/moge-2-vits-normal). | +| **COLMAP 4.2** | Established sparse feature reconstruction; dense geometry requires further stages. Known poses or external scale can be supplied, but do not validate themselves. | **Classical baseline. Local fixed-pose matching completed with an empty final sparse model.** Preserve that result and earlier setup failures; it is not evidence that COLMAP fails on all houses. BSD core; learned components have separate licenses/build requirements. [Tutorial](https://colmap.github.io/tutorial.html), [4.2 release](https://github.com/colmap/colmap/releases/tag/4.2.0). | +| **Apple Depth Anything V2 Small Core ML** | An image-depth package whose inspected graph normalizes by the image's own maximum. | **Native auxiliary-depth candidate. One Mac CPU compile/load/prediction completed.** It did not establish metric depth, near/far interpretation or iPhone behavior. [Official package](https://huggingface.co/apple/coreml-depth-anything-v2-small). | + +Start review visuals with points, planes, footprints and evidence links. [RealityKit LowLevelMesh](https://developer.apple.com/documentation/realitykit/lowlevelmesh) and [Three.js](https://threejs.org/docs/) support changing geometry. [Rerun](https://rerun.io/docs/reference/sdk/operating-modes) is useful for synchronized engineering timelines; it is not the durable evidence store. [Spark](https://sparkjs.dev/docs/lod-getting-started/) can stream prepared Gaussian assets for rich browser review, but that is different from incrementally reconstructing the scene. Three.js/Spark are MIT; Rerun offers MIT/Apache licensing. These viewer integrations were source-reviewed, not delivered as an integrated live product. Splats are a visual representation, not guaranteed solid collision or clearance geometry. + +## 4. Recognize evidence, then apply explicit checks + +Recognition should produce observations linked to original images and geometry. A deterministic evaluator then consumes supported measurements, applicable published/configured rules and explicit uncertainty. A class score, mask score, tracking score and measurement-error bound are different quantities. Preserve unresolved observations and review paths rather than turning missing evidence into a favorable result. + +| Need | Recommended baseline and alternatives | Evidence and adoption limit | +|---|---|---| +| Exact label text | **Apple Vision** locally; [PP-OCRv6 tiny](https://huggingface.co/PaddlePaddle/PP-OCRv6_tiny_rec) as a specialist open-source comparator. | **Both ran on the same generic synthetic fixtures on Mac.** Both produced high-score exact-string errors. PP-OCR requires detection, crop processing and recognition; its checked code/model cards are Apache-2.0. This is not field identifier accuracy or a phone-speed ranking. | +| Equipment proposals and masks | User nomination plus [Grounding DINO](https://github.com/IDEA-Research/GroundingDINO) / [SAM 2.1](https://github.com/facebookresearch/sam2); newer [SAM 3.1](https://huggingface.co/facebook/sam3.1) as a server challenger. | **Source only for these full pipelines.** DINO/SAM 2 baseline artifacts have Apache terms. SAM 3 uses a custom gated license and a larger GPU-oriented stack. A semantic phrase or mask does not prove equipment identity or absence. | +| Native image segmentation | [Apple's SAM 2.1 Tiny packages](https://huggingface.co/apple/coreml-sam2.1-tiny/tree/39ae0a8a83e5e6cd196e804bf7cccc5f8171f306); [EdgeTAM](https://github.com/facebookresearch/EdgeTAM) and [YOLOE](https://github.com/THU-MIG/yoloe) as alternatives. | **Artifact/source audits; native prompt-model execution blocked before prediction by the declared sandbox.** The Apple package card declares Apache-2.0. That is not a mask-quality failure. EdgeTAM's reviewed Core ML export omitted temporal memory and had preprocessing/input issues. YOLOE can fuse a fixed vocabulary for export, but has AGPL terms and text-model dependencies. No phone mask pipeline was validated. | +| Carry a nominated region across frames | Vision object tracking; [OpenCV ViTTrack](https://github.com/opencv/opencv_zoo/tree/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_tracking_vittrack); causal [BootsTAPIR](https://github.com/google-deepmind/tapnet). | **All ran locally on the same already-seen book clip, with different tasks.** Vision's confidence did not reliably flag observed box loss. ViTTrack returned rejected updates that must not become fresh boxes. BootsTAPIR's visible point containment was 457/1,392 overall, alongside 457/458 among declared-visible outputs—not point accuracy or box IoU. ViTTrack Zoo files and current TAPNet code/checkpoint declarations are Apache-2.0. | +| Other point-tracking alternatives | [CoTracker3](https://github.com/facebookresearch/co-tracker) and [TAPNext++](https://github.com/google-deepmind/tapnet/tree/730cda1c730877cfedbe01bf87fb1cadb78a565d/tapnet/tapnextpp). | **Source only.** CoTracker online uses windows with possible within-window look-ahead and noncommercial code/weights. TAPNext++ has a causal recurrent path and Apache declarations, but its reviewed checkpoint is much larger. Neither establishes phone deployment or physical identity. | +| Barcode corroboration | Native Vision, with [ZXing-C++](https://github.com/zxing-cpp/zxing-cpp) as an Apache-2.0 alternative. | **Local synthetic native observations plus source review.** Preserve decoded string and raw payload semantics separately; raw barcode bytes were not interchangeable with encoder UTF-8. A correctly decoded code still needs association and interpretation. | + +Object masks do not supply visibility truth for each tracked physical point. Likewise, a label associated to the wrong enclosure remains wrong even when its transcription is exact. Coordinate transforms, timestamps, session/reconstruction revisions and source-image association are part of the evidence path, not optional rendering details. + +## Hybrid architecture and uploads + +**Recommended split:** keep preview, tracking recovery, aiming and capture acknowledgment on the device; send selected evidence for asynchronous interpretation; refine heavier geometry/render assets separately. A device-led deferred workflow is the robust offline alternative. A cloud-led stream is an experiment option when bandwidth and latency permit, not a prerequisite for immediate guidance. + +The local network replay used **hypothetical workloads and latencies**. It showed why durable originals need their own queue, lost acknowledgments need idempotent reconciliation, and stale guidance should be rejected by task/coordinate dependencies. Requiring every reply to mention the newest photograph can also starve useful older findings. These are protocol observations, not measured carrier performance. + +| Transport / work type | Useful resource | Decision and status | +|---|---|---| +| Durable selected images | [Background URLSession](https://developer.apple.com/documentation/foundation/urlsessionconfiguration/background(withidentifier:)); file-backed uploads. | **Native baseline; source evidence.** OS scheduling and force-quit behavior matter. Background transfer does not keep AR capture running. | +| Resumable uploads | [TUSKit](https://github.com/tus/TUSKit), [tusd](https://github.com/tus/tusd), both MIT; [Supabase tus endpoint](https://supabase.com/docs/guides/storage/uploads/resumable-uploads). | **Candidate transport; no successful recovery-transfer result.** Local work preserved build/resource and later monitoring-permission blockers. tus and Foundation's HTTPbis resume are different protocols. Service chunk rules and iOS background scheduling still need actual integration tests. | +| Current whole-bundle service | Keep an explicit completed-asset handoff from any future resumable transport. | **Source inspection at the reviewed service revision.** A ZIP/multipart request is not already a resumable or incremental reconstruction API. | +| Periodic versus stateful reconstruction | Coalesce replaceable batch jobs; preserve ordered, deduplicated accepted inputs for a stateful model. | **Proposed architecture with synthetic controls.** Reconnection needs replay/checkpoint semantics; a socket reconnect does not restore model state. | + +## What would change the recommendation + +A consented physical-phone experiment remains the highest-value missing evidence: synchronized images/poses/calibration, ordinary and depth-capable capture where available, known scene measurements, glare/blank surfaces, tracking interruption and offline recovery. Simulator and desktop success cannot establish camera/VIO behavior. + +Before that hardware is available, the useful next checks are a small move-versus-aim replay, a known-pose display/coordinate-reset replay, and a capability-limited walkthrough that keeps image-only evidence distinct from measured geometry. More model demos on the same seen clip would not resolve those integration gaps. All candidate licenses should be checked at the exact artifact and dependency versions selected for distribution; permissive code does not automatically license weights, datasets or hosted services. diff --git a/experiments/README.md b/experiments/README.md index 24debeb02..6a1786acf 100644 --- a/experiments/README.md +++ b/experiments/README.md @@ -2,6 +2,8 @@ An experiment settles one question before the team builds on the answer. For example: can an iPhone without LiDAR measure the gap between a wall and a fence to within a few inches? +The [research handoff packages](research-handoff/README.md) contain reusable capture/evidence prototypes and isolated native proposals from the September 26 investigation. Start with the [high-level report](../docs/06-research-handoff.md) for their results and limitations. + The record of an experiment is its code and the files it produced. Its README is the front door, not the report. - One folder per experiment, `experiments//`, with its own uv, pnpm or Xcode project. Its dependencies stay out of `server/`, `ios/` and `web/`. diff --git a/experiments/device-field-test/README.md b/experiments/device-field-test/README.md new file mode 100644 index 000000000..fbd383786 --- /dev/null +++ b/experiments/device-field-test/README.md @@ -0,0 +1,314 @@ +# Device field test + +## Question + +Does the House Scan TestFlight build produce a scan the server can place a battery on, from one walk +along a real wall with a real iPhone? And which behaviours that the Simulator can't check hold on a device? +Those are the tracking, the live meter tap, the close-up, haptics, the AR overlay and interruptions. + +## Method + +[RUNBOOK.md](RUNBOOK.md) lists every device-only claim from the product description on `t3/verification`, +in the order a person meets them, each with a test ID. The tester screen-records each run with the +microphone on and says the test IDs and haptics aloud. Before scanning, they tape the wall's features +relative to the meter. After the run: +- the exported scan is sent to the server again, which returns the same result the phone got; +- [`coverage_replay.py`](coverage_replay.py) replays its photos through the app's coverage rules. + +[setup-plan.html](setup-plan.html) is the plan for the first site, a mock house wall with a meter, panel, +window, eave and an installed battery (rendered in [setup-plan.png](setup-plan.png)). + +## Pass criteria + +The first and last come from the runbook's goals before run 1. The 4 in bound is Measure Lab's, applied after the run. + +- The uploaded scan covers the stretch of wall the tester walked, and the result names either a spot or a + reason that refers to something on that wall. +- Measured features are within 4 in of the tape along the wall and in height, as in Measure Lab's criteria. +- Every device-only claim in the runbook is recorded as pass, fail or not reached. + +## Result, run 1 + +Run on 2026-09-26 with TestFlight build 1.1 (workflow run +[36255760659](https://github.com/SamGu-NRX/house-scanning-master/actions/runs/36255760659), branch `beta` at +[`28cd898`](https://github.com/SamGu-NRX/house-scanning-master/commit/28cd8988187979800f74bde78ee1b1fc246d0ec3), +capture code `t3/ios-mvf` at +[`657ab28`](https://github.com/SamGu-NRX/house-scanning-master/tree/657ab283092f1a34cc2dff87d48a0871d284f9c4/ios)). +The 1 min 54 s screen recording was reviewed frame by frame, and its narration was transcribed on the +tester's laptop. It shows the same run as the scan: the share sheet reports `scan.zip` at 27.3 MB, and the +result text is the same. + +**The first criterion fails.** +- **What the phone captured.** It kept 44 photos and a meter close-up. It walked about 19 ft right of the + meter and back, 5 to 7½ ft from the wall. +- **What the scan says.** Both wall ends sit within 4 in of the meter: the exported wall baseline is + 0.66 ft long. The scan leaves out everything past its ends, so it tells the server that no wall and no + ground were observed. +- **What the server answered.** `manual_review` with no spot, asking the homeowner to "keep walking past + the left end of the scan (0 ft 4 in left of the meter)", and the same for the right. + +### What the recording shows + +"Can't get there" was tapped twice, and each tap set one end at the meter: + +- **00:22, left.** The card said "Walk slowly to your left". The tester stood about a metre from the meter + and hadn't walked or tilted down. The strip showed the wall row amber (seen once) either side of the + meter and the ground row grey. The walk switched to "Walk slowly to your right". +- **00:28–00:44, tilt prompts.** "Tilt down to show the ground" asked for the strip 3 ft 3 in, 2 ft 9 in, + 4 ft 6 in, 7 ft and 8 ft 9 in right of the meter. None of them asked for the ground in front of the meter. +- **00:53, right.** The tester stood at the right corner, about 16 ft from the meter. Just before the tap, + the strip's wall row was green (covered) for the first stretch from the meter and amber after it. The + ground row was amber all the way from the meter, never green. Right after the tap the card said "That's + the whole wall", and the strip went blank for the rest of the run. + +After that, the tester marked the AC stand-in (listed at "About 16 ft right"), the window ("About 7 ft +right", answered "It stays shut") and the gas meter ("Around your meter"). The upload took 2.9 s. The result +was "An installer will take a look", with the two "Keep walking past the … end of the scan (0 ft 4 in …)" +requests and no checks. The AR view is offered only with a spot, so it wasn't reached. + +The 20 ft "Is this the … end of the wall?" prompt never appeared. + +### How the walk was lost + +"Can't get there" during the walk sets that side's end at `GuidancePlanner.reach` +([`ScanEngine+Actions.swift`](https://github.com/SamGu-NRX/house-scanning-master/blob/ff95f1cb571e2f3ef56d9faae85880665ad0ca30/ios/HouseScan/Runtime/ScanEngine%2BActions.swift#L315-L319)). +`reach` +([`GuidancePlanner.swift`](https://github.com/SamGu-NRX/house-scanning-master/blob/ff95f1cb571e2f3ef56d9faae85880665ad0ca30/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift#L101-L117)) +counts 6-inch cells outward from the meter. It stops at the first cell whose wall and ground are not both +covered. A cell is covered when each of its three sample rows has been seen from two positions: the wall +at 0, 3.25 and 6.5 ft up, and the ground at 0, 2 and 4 ft out. So if the ground right in front of the +meter hasn't been seen twice, the tap puts the end at the meter. + +After that, the coverage map ignores everything past the ends: +- `record` stops adding sightings outside them + ([`CoverageMap.swift`](https://github.com/SamGu-NRX/house-scanning-master/blob/ff95f1cb571e2f3ef56d9faae85880665ad0ca30/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift#L332)); +- `coveredIntervals` and `groundDepthSpans` leave them out + ([L979](https://github.com/SamGu-NRX/house-scanning-master/blob/ff95f1cb571e2f3ef56d9faae85880665ad0ca30/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift#L979), + [L527](https://github.com/SamGu-NRX/house-scanning-master/blob/ff95f1cb571e2f3ef56d9faae85880665ad0ca30/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift#L527)). + +The walk still kept photos and accepted marks up to 19 ft away, and nothing on screen said they would be +dropped. The export then clamps the baseline to ±0.1 m around the meter +([`ScanEngine+Export.swift`](https://github.com/SamGu-NRX/house-scanning-master/blob/ff95f1cb571e2f3ef56d9faae85880665ad0ca30/ios/HouseScan/Runtime/ScanEngine%2BExport.swift#L84-L85)), +which is the ±0.328 ft the server echoes. This code is unchanged between the build's `657ab28` and the +branch head `ff95f1c`. + +The replay shows how long the scan was exposed to this. Replaying the 44 photos in order through the +coverage rules, with no ends marked, the covered stretch next to the meter stays at 0 ft on both sides +until photo 43: + +``` +photo x (ft) reach left reach right +k00001 0.38 0.0 ft 0.0 ft +... +k00030 19.69 0.0 ft 0.0 ft +... +k00042 6.02 0.0 ft 0.0 ft +k00043 5.54 1.0 ft 5.5 ft +``` + +At the meter, the wall rows were seen from the start. The ground 2 ft and 4 ft out was seen only by photos +41–43, taken after both ends were set, while the window and gas meter were being marked. So during the +walk, any "Can't get there" put that side's end at the meter. That matches the recording: the ground row by +the meter was amber at the right-hand tap. The runbook told the tester to use it at the left corner, only +17 in from the meter, and at the right corner if the end prompt never came. + +The replay also shows why the end prompt couldn't come: +- the ground 4 ft out was never seen twice past 5.5 ft right of the meter; +- the wall's top row, 6.5 ft up, was never seen twice past 10.5 ft. + +The prompt appears only after 20 ft of unbroken coverage. + +### Measurements + +These are the app's marks against the tape, both taken from the meter's centre line. Positive means the +app has it farther right or higher. + +| Feature | App − tape | Within 4 in | +|---|---|---| +| Window, right edge | −0.5 in | yes | +| Window, left edge | +5.4 in | no | +| Window, width | −5.9 in | no | +| Window, sill height | +1.9 in | yes | +| Gas meter stand-in, centre | 4.8 in nearer the meter | no | + +The window's width error comes almost entirely from its left edge. The gas meter is a one-tap mark with a +nominal 1 ft width. + +### Device-only claims + +The tester read screen text aloud but didn't say test IDs or haptics, so haptic claims remain unverified. +These were visible: + +| ID | Result | Evidence | +|---|---|---| +| O-1 | Not reached | The camera opened with no iOS prompt; permission was granted in an earlier run | +| F-2 | Pass | "Step a little closer to the wall" after an early tap | +| C-1 | Pass | The ring filled in under a second; the meter-number question followed | +| W-1 | Pass | Left, right, "Tilt down…" and "Take a step back" all appeared; the fog cleared after about 3 s | +| W-2 | Pass | The photo counter flashes | +| W-5 | Fail, B-16 | "Tap the ac unit" | +| W-7 | Not reached | The end prompt never appeared; see above | +| W-12 | Fail | Reproduced without a double tap: two ordinary taps on "Can't get there" gave "That's the whole wall" | +| U-1 | Measured | 2.9 s from "Looks complete" to the result | +| U-2 | Fail, known | About 2.2 s on "Sending measurements, 99%"; "Check clearances" never became active | +| R-1 to R-5 | Not reached | No spot, so no checks and no AR view | + +Not tried: W-4 (a refused mark), and runs 2 and 3. + +Other things the recording showed: +- **Marks outside the ends still count.** The AC stand-in, 16 ft past the right end, is still listed and + exported. +- **"Slow down" while standing still.** It appeared twice while the tester stood still to mark features. +- **No way to mark a battery.** The site's installed battery couldn't be marked, because the mark sheet + has no battery kind. +- **A tilt-prompt distance went backwards**, from 3 ft 3 in to 2 ft 9 in. + +## Result, run 2 (build 3.1) + +Run on 2026-09-26 with TestFlight build 3.1 (workflow run +[36272848774](https://github.com/SamGu-NRX/house-scanning-master/actions/runs/36272848774), branch `beta` at +[`ad91620`](https://github.com/SamGu-NRX/house-scanning-master/commit/ad91620f41686d18f8699fbc1040d2a754719163), +capture code `t3/ios-mvf` at `e9e405d`, server at `e106acb`). The 4 min 28 s screen recording, run A only, was reviewed +at 1 fps and at 5 fps around each tap. Its narration was transcribed on the tester's laptop. The exported scan was +lost before it was copied off the phone, so: +- the packet wasn't validated; +- the coverage wasn't replayed; +- the scene wasn't sent to the server again. + +The numbers below come from the screen and from the code. This repeats the runbook's run 1 (full flow) on the new +build; the runbook's runs 2 and 3 are still to do (#32). + +**The first criterion fails again, but the run reached a spot for the first time.** +- **What the phone did.** It walked about 17 ft right of the meter to the end of the mock wall. The photo counter + reached 136 on the walk and 145 after one server request. +- **What the scan said.** The wall ran from 7 ft 6 in left of the meter to 0 ft 6 in right. The window and the AC + stand-in lay outside it. +- **What the server answered.** `manual_review`, "An installer will take a look / 1 ft 3 in left of your meter, 1 ft of + cable", with 10 checks UNSURE. + +### What the recording shows + +"Can't get there" still sets the end at the meter: +- **00:53, left.** The ground cell just left of the meter was still amber (seen once). The left end landed at the + meter. +- **02:38.** "Can't get there" on "Tilt up to show more wall / Around 16 ft 9 in right of your meter" hatched that + stretch "Skipped". This is intended. +- **02:40, right.** At the end of the wall ("can't get there because I'm at this wall"), only one 6-inch cell next to + the meter was covered. The right end landed at 0 ft 6 in. The strip went blank for the rest of the walk (#24). + +The walk then kept asking for stretches past that end: "Tilt up to show more wall / Around 7 ft right of your meter" +and "Tilt down to show the ground / The strip along the wall, 5 ft 3 in right of your meter". None of them could fill. +After 57 s of "I don't know where it wants me to point", the tester tapped "Done with this wall" (#38). + +The first upload took about 0.7 s and returned "2 more views to finish". +- **The first view.** "Show the ground around your meter / Keep walking past the left end of the scan (0 ft 4 in left of + the meter)". Its bar stayed at 0%. +- **03:56.** "I can't get there" uploaded again, and the result came about 1.2 s later. The second view was never + shown (#39). +- **The ends had moved.** The result now asked to "Keep walking past the left end of the scan (7 ft 6 in left of the + meter)" and "…the right end of the scan (0 ft 6 in right of the meter)" (#35). + +The spot's footprint runs from 2 ft 7 in left of the meter to the meter itself. Its check line read "Clear of the +meter's working space … Measured 1 ft 3 in. The rule is 0 in, and the measurement can be off by about 1 ft 6 in." That +1 ft 3 in is an **overlap** with the 2 ft 6 in by 3 ft working space; the app drops the minus sign (#40). The overlap is +UNSURE rather than FAIL only because the meter's error, 0.79 ft, plus the wall's, 0.71 ft, exceeds it (#44). + +The AR view, "The spot an installer will check", stayed in one place relative to the meter for the 11 s the tester +walked around. But: +- it was drawn over a person standing at the wall (#41); +- it was drawn through the post at the wall's end; +- its ground tint ran past the end of the wall. + +The tester called it "very not correct". + +### How the walk was lost, and how the wall grew + +The walk-end code is unchanged since run 1: +- "Can't get there" sets the end at `GuidancePlanner.reach` + ([`ScanEngine+Actions.swift` L354–359](https://github.com/SamGu-NRX/house-scanning-master/blob/ad91620f41686d18f8699fbc1040d2a754719163/ios/HouseScan/Runtime/ScanEngine%2BActions.swift#L354-L359)). +- `reach` stops at the first cell next to the meter that isn't covered + ([`GuidancePlanner.swift` L102–118](https://github.com/SamGu-NRX/house-scanning-master/blob/ad91620f41686d18f8699fbc1040d2a754719163/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift#L102-L118)). + +New in this run: +- **The past-end request cleared the left end.** `beginServerGap` clears the end it asks past + ([`ScanEngine.swift` L1400–1411](https://github.com/SamGu-NRX/house-scanning-master/blob/ad91620f41686d18f8699fbc1040d2a754719163/ios/HouseScan/Runtime/ScanEngine.swift#L1400-L1411)). +- **"I can't get there" didn't set it again.** `skipCurrentGap` + ([L1290–1302](https://github.com/SamGu-NRX/house-scanning-master/blob/ad91620f41686d18f8699fbc1040d2a754719163/ios/HouseScan/Runtime/ScanEngine.swift#L1290-L1302)) + marks the requested cells skipped on the phone and stops further requests. It never restores the end. +- **The export then ran to the farthest cell seen from one position.** With no left end, `exportSpan` uses + `seenExtent` + ([`ScanEngine+Export.swift` L128–133](https://github.com/SamGu-NRX/house-scanning-master/blob/ad91620f41686d18f8699fbc1040d2a754719163/ios/HouseScan/Runtime/ScanEngine%2BExport.swift#L128-L133)). + That was 15 cells, 7 ft 6 in. +- **Guidance ignores the ends.** It asks for stretches past an end because `laggingBand` and `hiddenNearCamera` read + coverage around the camera without checking them + ([`GuidancePlanner.swift` L143–166](https://github.com/SamGu-NRX/house-scanning-master/blob/ad91620f41686d18f8699fbc1040d2a754719163/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift#L143-L166)). + Cells past an end never count as covered and can't be skipped. + +The server picked the spot because it ranks UNSURE candidates by the number of UNSURE checks, then cable length, then +position +([`solver.py` L1166–1167](https://github.com/SamGu-NRX/house-scanning-master/blob/ad91620f41686d18f8699fbc1040d2a754719163/server/solver.py#L1166-L1167)). +A 0 ft route that touches the meter wins ties (#44). + +None of this changes in `b7f51ba` or `49a676e` on `t3/ios-mvf`. + +### Measurements + +No tape distances were read aloud, so there is no tape comparison. The app's positions, from the meter's centre line: + +| Feature | App | +|---|---| +| AC stand-in | "About 16 ft right" (centre about 16.0 ft from the server's numbers) | +| Window | "About 7 ft right", near edge about 5.9 ft right | +| Spot | From 2 ft 7 in left to 0 ft | + +### Device-only claims + +The tester said the build and run, and "no haptic" once, but no test IDs. The other haptic claims remain unverified. + +| ID | Result | Evidence | +|---|---|---| +| O-1 | Not reached | No camera prompt; permission was granted earlier. A new Motion & Fitness prompt appeared on "Find your electric meter" (#43) | +| F-2 | Unclear | Early taps were narrated, but no "Step a little closer to the wall" appeared | +| C-1 | Pass | The ring filled in about a second; "Meter number saved" followed | +| W-1 | Pass, confusing | All instructions appeared. After the right end was set, "Around 7 ft right" requests couldn't be met | +| W-2 | Pass | The counter flashes; "there is no haptic" | +| W-5 | Fail, B-16 | "Tap the ac unit" (#25) | +| W-7 | Not reached | The wall ends before 20 ft | +| W-8 | Fail, B-06 | The wall ended at about 17 ft and only "Can't get there" was offered (#29) | +| G-2 | Pass, copy bug | "I can't get there" uploaded at once, but "2 more views to finish" showed only one (#39) | +| U-1 | Measured | 0.7 s for the first upload, 1.2 s after the gap request | +| U-2 | Fail, known | The final upload stayed on "Sending measurements, 99%"; "Check clearances" never ticked (#31) | +| R-1 | Pass (haptic unverified) | First result with a spot: "An installer will take a look", 13 check lines | +| R-3 | Stayed put, wrong spot | Steady relative to the meter; drawn through the post at the wall's end | +| R-4 | Fail, known | The battery is drawn over a person (#41) | + +Not tried: W-4, W-9 in the dark, M-2, M-3, R-2, R-5, and runs 2 and 3. The 3.1 fixes for tracking loss on the feature +list and during gap requests are not yet checked on a device. + +Other things the recording showed: +- **Marks outside the ends still count.** The window and AC are listed and exported, and the server measures them on + the wall's straight extension (#42). +- **"Slow down" during tilts and while standing still,** seven times. The gate fires above 60°/s of rotation as well + as 1.5 m/s (#26). +- **No way to mark a battery** or other equipment on the wall (#27). +- **The tilt-prompt distance went backwards again,** from 5 ft 3 in to 4 ft 9 in, 4 ft 6 in and then 4 ft, never in + front of the meter (#28). + +## What this changes + +- **For the capture app (lane A owner's call):** "Can't get there" shouldn't be able to place an end + inside a stretch the tester has walked and photographed. Possible fixes: + - place the end at the farthest point seen, or at the phone's position along the wall, instead of at + the unbroken covered reach; + - refuse the tap and say what is missing ("tilt down to show the ground by your meter"); + - at least warn when an end lands within a foot of the meter. + + Separately, the walk shouldn't keep photos and marks it will drop without saying so. +- **For guidance:** the tilt prompts asked for the ground from 2 ft 9 in right of the meter outward, never in + front of the meter. That's the stretch `reach` needs first. Asking for it before "Walk slowly to your left" + would unblock `reach` early. The strip could also show that an end will land at the meter before the tap. +- **For the next runs:** the runbook now says to fill the strip next to the meter before any "Can't get + there". Testers should say test IDs and haptics aloud, and share the scan off the phone before closing the app: a + relaunch deletes it. Runs 2 and 3 are still to do. + +Raw material stays out of git: the recording, the scan and the tape readings. diff --git a/experiments/device-field-test/RUNBOOK.md b/experiments/device-field-test/RUNBOOK.md new file mode 100644 index 000000000..f547341a0 --- /dev/null +++ b/experiments/device-field-test/RUNBOOK.md @@ -0,0 +1,113 @@ +# Device test runbook: House Scan on a real iPhone + +The Simulator can't run ARKit, so the product description on `t3/verification` marks everything that needs +the live camera as unverified. This runbook collects those items in the order a person meets them while +scanning. Every test has an ID, so a screen recording with spoken IDs can be matched back to the claim. + +Sources, at `t3/verification` +[`63350b1`](https://github.com/SamGu-NRX/house-scanning-master/tree/63350b12495e7c69d18e6a4391e053a70f432dbd/verification/product): +the "Open questions and verification" section of each screen document, and the +[bug triage](https://github.com/SamGu-NRX/house-scanning-master/blob/63350b12495e7c69d18e6a4391e053a70f432dbd/verification/product/bug-triage.md) +for the `B-` numbers. "Known bug" means the triage already predicts the failure. The run confirms it on a device. + +## Before the run + +- Borrow a tape measure, painter's tape and a marker, plus a second phone for notes and for the call test (W-10). +- Set up the phone: + - Add Screen Recording to Control Center, and long-press it to turn the microphone on. Haptics don't + show in a recording, so say "haptic" or "no haptic" and the test ID out loud. + - Charge the phone, free a few GB and turn brightness up. + - Open `https://house-scanning-server.vercel.app/health` in Safari on the site's Wi-Fi. +- Check the TestFlight build: note its number, and check onboarding shows the "About 2 min" pill. +- Tape and write down the ground truth before anyone scans. [`setup-plan.html`](setup-plan.html) shows + the readings used for the mock wall. At minimum, measure along the wall from the meter's centre line to: + - both corners; + - each opening's edges; + - any gas meter; + - any existing battery. + + Also measure the sill and eave heights and the depth of open ground in front of the wall. +- Keep the recording, the exported scan and the readings in `private/` or `data/`. They show a real + site. Commit only derived numbers. + +## Run 1: full flow, clean wall + +| ID | Screen | Do | Expect | +|---|---|---|---| +| O-1 | Onboarding | Page through with Next, tap Allow camera, then Allow | The iOS camera prompt appears. Note when | +| O-2 | Onboarding | VoiceOver on, swipe the pages | Unknown: is the page number announced? | +| O-3 | Onboarding | Reduce Motion on, tap Next and Skip | Known: Skip still slides | +| F-2 | Find meter | Tap the meter the moment the camera opens, then again | "Step a little closer to the wall"; known: no haptic on the second refusal | +| F-1 | Find meter | Pan across the wall and ground for 5 s, tap the meter | A ring blooms, a success haptic, then the close-up | +| F-3 | Find meter | Tap before the wall is detected | Question: does it pin at the wrong depth? | +| F-4 | Find meter | Point at the sky until relocalizing | Question: "Point at the meter like this." shows no picture | +| C-1 | Close-up | Hold the meter in the circle | The ring fills in about 0.6 s, a light haptic, the walk begins 1.2 s later | +| C-2 | Close-up | Put the meter at the circle's edge | The drawn circle matches the captured area (device-only check) | +| W-1 | Walk | Follow each instruction | "Walk slowly to your left", "Tilt down…", "Take a step back". Is the fog readable? | +| W-2 | Walk | Watch the photo counter | It flashes green with no haptic | +| W-3 | Walk | Mark something, pick a kind, tap | A firm haptic on accept | +| W-4 | Walk | Tap a mark on the ceiling or sky twice | A warning haptic on the first refusal only | +| W-5 | Walk | Mark an AC unit | Known B-16: "Tap the ac unit" | +| W-7 | Walk | At about 20 ft: "Is this the … end?", then Wall ends here | Does the end land at the screen centre rather than the ring? Then the end question appears | +| W-8 | Walk | A wall that ends before 20 ft | Known B-06: only "Can't get there" is offered | +| W-9 | Walk | Move fast, aim at a blank surface, try a dark area | Tracking coaching appears | +| M-1 | Feature list | Read each mark and its distance aloud | Compare with the tape | +| G-1/G-2 | Gap request | Complete it, or tap I can't get there | A success haptic and the upload about 1.2 s later, or an immediate upload | +| U-1 | Upload | Time it | "About a minute" has never been measured | +| U-2 | Upload | Watch the steps | Known: stuck on "Sending photos, 99%", then "Checking your wall" is skipped | +| R-1 | Result | Read the headline and every "Measured … The rule is …" line aloud | A success haptic | +| R-2 | Result | Drag the 3D model vertically | Unknown: does it rotate the model or scroll the page? | +| R-3 | AR view | Walk 3 steps left and right | Does the battery sit on the real wall and stay put? | +| R-4 | AR view | Someone walks in front of the spot | Known: the drawing covers them | +| R-5 | AR view | Cover the lens for 3 s | Does the drawing hide while relocalizing? | + +**Before any "Can't get there" on the walk**, stand at the meter and tilt down until the coverage strip next +to the meter is fully covered. Then check the strip is covered without a gap from the meter to where you +stand. Run 1 shows why: the tap puts the wall's end at the edge of the unbroken covered stretch, and if +that stretch is empty, the end lands at the meter. See [README.md](README.md#result-run-1). + +How to fill it: +- Stand about 7 ft out from the meter and tilt down until the ground out to 4 ft from the wall is in view. +- Wait for the photo counter to flash, take a 2 ft step sideways, and wait for another flash. Do the same + with the wall up to 6½ ft. + +Each spot must be seen from two positions at least 0.25 m apart. Only kept photos count, and a photo is +kept after about 0.5 m of movement or 15° of turn. + +## Run 2: the same wall with clutter + +Put an obstacle in front of the meter or panel and repeat run 1. Add these: + +| ID | Do | Expect | +|---|---|---| +| C-4 | On the close-up, shake gently so frames alternate sharp and blurry, for 30 s | Known B-04: the way out may never appear | +| C-3 | Then point away for 4 s or more, twice | "Can't get a clear shot" after the first failed try; the close-up is skipped after the second | +| W-13 | Walk past the obstacle, look from the side, then tap Can't see past it | "Something is in front of the wall here"; the cells behind it turn hatched. Needs a build with LiDAR occlusion (after 1.1) and a Pro iPhone | +| M-2 | Tap Add something on the feature list | Known B-09: nothing visible happens | + +## Run 3: interruptions (separate short recordings) + +Group the tests into short recordings: W-12 then M-3; W-10 then W-11; U-4 then U-3; O-4 alone. A call or a +lock can end a screen recording, and this way it takes only its own tests with it. O-4 deletes the app and +every scan on it, so export scans first. + +| ID | Do | Expect | +|---|---|---| +| W-10 | Take a phone call during the walk, then return | Unknown: does it recover? Does the recording survive? | +| W-11 | Start a mark, tap once, cover the lens for more than 20 s | Back to Find the meter with no explanation; known: the half-done mark survives | +| W-12 | Tap Can't get there twice at the start of the walk | Known: "That's the whole wall" with nothing walked | +| M-3 | On the feature list, point away for more than 20 s | Known: the list and every mark vanish with no warning | +| U-3 | Lock the phone during the upload | Unknown | +| U-4 | Airplane mode before the upload, then off, then Try again | "You're offline", then recovery; watch for raw error text (B-02) | +| O-4 | Last: delete, reinstall, tap Don't Allow at the camera prompt | Known B-03: stuck on Find the meter with no Settings button | + +## After the run + +- Save the scan's `scene.json` and replay its coverage: + + ```sh + uv run coverage_replay.py path/to/scene.json + ``` + +- Put the claims each recording settled into the table in [README.md](README.md#result-run-1), with the + build number. diff --git a/experiments/device-field-test/coverage_replay.py b/experiments/device-field-test/coverage_replay.py new file mode 100644 index 000000000..86d5db9df --- /dev/null +++ b/experiments/device-field-test/coverage_replay.py @@ -0,0 +1,128 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["numpy>=2"] +# /// +"""Replay a House Scan scene.json's kept photos through the app's coverage rules. + +For each kept photo, in order, prints how far the covered wall reaches on each side of the +meter: the same `reach` that "Can't get there" uses to place a wall end. Then prints, for +the cells either side of the meter, which photos saw each sample row. + +The rules mirror `CoverageMap` and `GuidancePlanner.reach` on `t3/ios-mvf` at ff95f1c: +- 6 in cells; +- wall rows at 0, 3.25 and 6.5 ft up; ground rows at 0, 2 and 4 ft out; +- a row counts when its two samples, a quarter and three quarters across the cell, are in view: + within 6 m, within 65 degrees of the surface normal, and 3 % inside the image edges; +- a cell is covered when every row was seen from two positions at least 0.25 m apart. + +Limits: it uses the exported (refined) wall line and meter for every photo, cannot see +tracking state, and ignores marked ends, so it shows what coverage would have been with no ends. + + uv run coverage_replay.py path/to/scene.json +""" + +import argparse +import json +from pathlib import Path + +import numpy as np + +FT = 0.3048 +CELL = 0.1524 +ROWS = {"wall": (0.0, 0.9906, 1.9812), "ground": (0.0, 0.6, 1.2)} +MAX_DISTANCE = 6.0 +MAX_ANGLE = np.radians(65) +MARGIN = 0.03 +BASELINE = 0.25 +MIN_DEPTH = 0.05 + + +def load(path: Path): + scene = json.loads(path.read_text()) + (x0, z0), (x1, z1) = scene["walls"][0]["baseline"] + direction = np.array([x1 - x0, z1 - z0]) + direction /= np.linalg.norm(direction) + along = np.array([direction[0], 0.0, direction[1]]) + outward = np.array([-direction[1], 0.0, direction[0]]) + meter = np.array(scene["meter"]["pos"]) * FT + origin = np.array([meter[0], 0.0, meter[2]]) + cameras = [] + for frame in scene["keyframes"]: + pose = np.array(frame["pose"], dtype=float).reshape(4, 4).T + pose[:3, 3] *= FT + cameras.append((frame["id"], pose, np.linalg.inv(pose), frame["intrinsics"], frame["w"], frame["h"])) + # Point the outward normal at the cameras, as the app's wall frame does. + mean_position = np.mean([c[1][:3, 3] for c in cameras], axis=0) + if np.dot(mean_position - origin, outward) < 0: + outward = -outward + return origin, along, outward, cameras + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("scene", type=Path) + parser.add_argument("--cells", type=int, default=45, help="cells to track on the right; 3 are tracked on the left") + args = parser.parse_args() + + origin, along, outward, cameras = load(args.scene) + up = np.array([0.0, 1.0, 0.0]) + + def point(band: str, s: float, offset: float) -> np.ndarray: + return origin + along * s + (up * offset if band == "wall" else outward * offset) + + def sees(camera, p: np.ndarray, normal: np.ndarray) -> bool: + _, pose, inverse, (fx, fy, cx, cy), w, h = camera + to_camera = pose[:3, 3] - p + distance = np.linalg.norm(to_camera) + if distance == 0 or distance > MAX_DISTANCE or np.dot(to_camera / distance, normal) < np.cos(MAX_ANGLE): + return False + local = inverse @ np.append(p, 1.0) + depth = -local[2] + if depth < MIN_DEPTH: + return False + u, v = cx + fx * local[0] / depth, cy - fy * local[1] / depth + return MARGIN * w <= u <= (1 - MARGIN) * w and MARGIN * h <= v <= (1 - MARGIN) * h + + def rows_seen(camera, band: str, index: int) -> list[int]: + low = index * CELL + samples = (low + CELL * 0.25, low + CELL * 0.75) + normal = outward if band == "wall" else up + return [r for r, offset in enumerate(ROWS[band]) if all(sees(camera, point(band, s, offset), normal) for s in samples)] + + indices = range(-3, args.cells) + positions = {(band, i): [[] for _ in ROWS[band]] for band in ROWS for i in indices} + covered: set[int] = set() + + def reach(step: int) -> float: + index, cells = (0 if step > 0 else -1), 0 + while index in covered: + cells, index = cells + 1, index + step + return cells * CELL / FT + + print("photo x (ft) reach left reach right") + for camera in cameras: + position = camera[1][:3, 3] + if np.dot(position - origin, outward) > 0: + for band in ROWS: + for i in indices: + for r in rows_seen(camera, band, i): + seen = positions[(band, i)][r] + if len(seen) < 2 and all(np.linalg.norm(q - position) >= BASELINE for q in seen): + seen.append(position) + for i in indices: + if all(len(row) >= 2 for band in ROWS for row in positions[(band, i)]): + covered.add(i) + x = np.dot(position - origin, along) / FT + print(f"{camera[0]} {x:7.2f} {reach(-1):8.1f} ft {reach(1):9.1f} ft") + + print("\nPhotos that saw each row of the cells either side of the meter") + for i in (-1, 0): + for band in ROWS: + for r, offset in enumerate(ROWS[band]): + who = [c[0] for c in cameras if r in rows_seen(c, band, i)] + label = f"{offset / FT:.1f} ft {'up' if band == 'wall' else 'out'}" + print(f"cell {i:+d} {band:6s} {label:11s} {', '.join(who) or '-'}") + + +if __name__ == "__main__": + main() diff --git a/experiments/device-field-test/setup-plan.html b/experiments/device-field-test/setup-plan.html new file mode 100644 index 000000000..23e91fb49 --- /dev/null +++ b/experiments/device-field-test/setup-plan.html @@ -0,0 +1,260 @@ + + + + +Mock wall: device test setup + + + + +

Mock wall: what to set up, measure and walk

+

Schematic, not to scale: the mock house wall used for run 1 on 2026-09-26. Letters A–K are the tape readings taken before scanning; test IDs are in RUNBOOK.md.

+ +
+ Painter's tape mark + Measure with the tape (letter = row in the table) + You change this + Already there, leave it +
+ +

1. Front view: standing on the turf, looking at the green meter wall

+ + + + + + + + + + + + + + wood corner + wood corner + + + + + + eave / overhang + + + + + + + + + + + METER + + + panel + + + disconnect + + + + + WINDOW + + + + Base + battery + (leave it) + + + + + + mark as "AC unit" + (stand-in, or use a box) + + + + GAS + tape paper here + + + + + + + covered table: MOVE OUT + + + + + + L + M + R + + + + + + + + + + + + + + + + A + B + C + D + E + F + G + H + J + + + F = turf upto the eave'sunderside + + + +

2. Top view: where things go and where you walk

+
+ + + + + + + + + + + + + + + concrete floor + + + turf + + + green meter wall + + + + + + + GAS + + METER + + window + + + battery + + + "AC unit" + folding table + + + + covered table + (now) + + + park it here, off the turf + for run 1 (back for run 2) + + + + K + + + + 1 + 2 + + + + + 3 + 4 + stay 6–8 ft from the wall, walk slowly + + + + + + + + +
#On the phone
1Start recording, open House Scan, go through onboarding. Stand about 6 ft in front of the meter and tap it (F-1).
2Step in until the meter fills the circle and hold still for the close-up (C-1).
3Back out to 6–8 ft. It says "walk left" first: go to the left corner, then all the way right. Tilt down when told so the turf is in view.
4Along the way: Mark something for GAS, the window and the "AC unit". At each corner, answer the end question with "turns a corner" (W-7, W-8). Then the list, upload, result and AR view.
+
+

The wall is probably under 20 ft, so "Wall ends here" may never appear (known bug B-06). If it doesn't, say so on the recording and use "Can't get there" at the corner.

+ +
+
+

3. Tape readings: write them down before anyone scans

+ + + + + + + + + + + + +
MeasureFrom → toReading
AWall lengthtape L → tape R, along the base___ ft ___ in
BMeter heightturf → meter box's bottom edge___ ft ___ in
CMeter to windowmeter centre line (tape M) → window's left frame edge___ ft ___ in
DWindow widthleft frame edge → right frame edge___ ft ___ in
ESill heightturf → window sill___ ft ___ in
FOverheadturf → eave's underside___ ft ___ in
GMeter to batterytape M → battery's near edge___ ft ___ in
HBattery sizewidth, and depth out from the wall___ × ___
JGas to meterGAS paper's centre → tape M___ ft ___ in
KTurf depthwall → concrete edge___ ft ___ in
+
+
+ +

4. Run order: one screen recording each, say the test ID out loud

+
+
Run 1: cleanCovered table parked off the turf, GAS paper up. +
  • Full flow, onboarding → AR view
  • Does the AR spot land near the real battery (G)?
  • Close-up tests C-1 to C-4
+
Run 2: clutteredCovered table back in front of the panel. +
  • Same walk as run 1
  • Expect a "show the ground" request (G-1 / G-2)
  • Compare the result with run 1
+
Run 3: break itShort separate recordings. +
  • Deny the camera (O-4, reinstall first)
  • Airplane mode before upload (U-4)
  • Teammate calls you mid-walk (W-10)
  • Cover the lens for 20 s or more (W-11)
+
+ + + diff --git a/experiments/device-field-test/setup-plan.png b/experiments/device-field-test/setup-plan.png new file mode 100644 index 000000000..90d0624bb Binary files /dev/null and b/experiments/device-field-test/setup-plan.png differ diff --git a/experiments/evals/Makefile b/experiments/evals/Makefile new file mode 100644 index 000000000..497517525 --- /dev/null +++ b/experiments/evals/Makefile @@ -0,0 +1,119 @@ +# Each target regenerates one result from a clean checkout. Data goes to ~/house-scanning-data/ +# (override with HOUSE_SCANNING_DATA); results/ holds only numbers. + +DATA ?= $(or $(HOUSE_SCANNING_DATA),$(HOME)/house-scanning-data) +ETH3D := $(DATA)/evals/eth3d +PRED := $(DATA)/evals/predictions +SCENES := facade electro +MODELS := uv run --project models python -m + +.PHONY: test drift replay recon recon-data recon-models recon-score frames sensitivity pose-priors field modern-arkit coverage coverage-options field-dryrun map3d + +test: + uv run ruff check . + uv run ruff format --check . + uv run pytest -q + +drift: + uv run python -m evals.datasets advio + uv run python -m evals.drift + +replay: + uv run python -m evals.datasets advio + uv run python -m evals.replay --sequence 20 --start 40 --end 75 + uv run python -m evals.check_replay $(DATA)/replays/advio-20-0040-0075 + +recon: recon-data recon-models recon-score + +recon-data: + uv run python -m evals.datasets eth3d + uv run python -m evals.recon prepare + +# One model at a time: the Mac is shared and each model fills most of the GPU memory. +recon-models: + uv sync --project models + for s in $(SCENES); do \ + for m in moge2 da3metric; do \ + $(MODELS) models.run --model $$m --images $(ETH3D)/$$s/model_inputs/images.txt \ + --intrinsics $(ETH3D)/$$s/model_inputs/intrinsics.json --out $(PRED)/$$s/$$m || exit 1; \ + done; \ + done + +recon-score: + uv run python -m evals.recon score + +frames: + uv run python -m evals.frames + +sensitivity: + uv run python -m evals.recon sensitivity + +# After recon: AR-like poses, MapAnything with and without them (392 px, 2 and 4 views: the most +# that fits the shared machine's per-process memory), then triangulation and scoring. +pose-priors: + uv run python -m evals.pose_priors prepare + for s in $(SCENES); do \ + $(MODELS) models.run_groups --scene-dir $(ETH3D)/$$s --sizes 2 4 --max-side 392 \ + --out $(PRED)/$$s/ma392_images || exit 1; \ + for p in exact advio_2018 modern_assumed; do \ + $(MODELS) models.run_groups --scene-dir $(ETH3D)/$$s --sizes 2 4 --max-side 392 \ + --poses-file $(ETH3D)/$$s/ar_poses/$$p.json --out $(PRED)/$$s/ma392_$$p || exit 1; \ + done; \ + done + uv run python -m evals.pose_priors score + +# One Measure Lab field session. SESSION is the zip shared from the app; with TRUTH, MAP and RULES +# (the tape survey, the team's map for `score import-measure-lab`, the rules file) it also writes +# the learned-depth results files, imports the rig's own row with the scoring harness at SCORING +# (experiments/scoring, PR #4) and scores every row in one table. +FIELD_OUT ?= $(DATA)/evals/field/results/$(basename $(notdir $(SESSION))) +RULES ?= field/rules.json +# PR #4's harness, checked out read-only at a pinned commit outside the repo. +SCORING_COMMIT := 77ce830 +SCORING_CHECKOUT := $(DATA)/evals/scoring-$(SCORING_COMMIT) +SCORING ?= $(SCORING_CHECKOUT)/experiments/scoring +STAMPED_MAP = $(FIELD_OUT)/inputs/map.json +field: + @# The pinned checkout is created only at its default place; an overridden SCORING must exist. + test -d "$(SCORING)" || { test "$(SCORING)" = "$(SCORING_CHECKOUT)/experiments/scoring" && \ + git -C ../.. worktree add --detach "$(SCORING_CHECKOUT)" $(SCORING_COMMIT); } || \ + { echo "SCORING=$(SCORING) is not a scoring harness checkout" >&2; exit 1; } + uv sync --project models + $(if $(TRUTH),uv run python -m evals.field stamp "$(SESSION)" --truth "$(TRUTH)" --map "$(MAP)" --out-dir "$(FIELD_OUT)" &&) \ + W=$$(uv run python -m evals.field prepare "$(SESSION)") && \ + $(MODELS) models.run --model moge2 --images "$$W/images.txt" --intrinsics "$$W/intrinsics.json" \ + --out "$$W/moge2" && \ + uv run python -m evals.field score "$(SESSION)" --out-dir "$(FIELD_OUT)" \ + $(if $(TRUTH),--truth "$(TRUTH)" --map "$(STAMPED_MAP)" --rules "$(RULES)") + $(if $(TRUTH),cd "$(SCORING)" && \ + uv run score import-measure-lab "$(abspath $(SESSION))" --map "$(abspath $(STAMPED_MAP))" \ + --rules "$(abspath $(RULES))" --truth "$(abspath $(TRUTH))" --out "$(abspath $(FIELD_OUT))/measure-lab.json" && \ + uv run score --rules "$(abspath $(RULES))" --truth "$(abspath $(TRUTH))" \ + --results "$(abspath $(FIELD_OUT))"/*.json --out "$(abspath $(FIELD_OUT))/scored") + +# The field kit proven end to end: synthetic readings in field/'s templates, a synthetic session +# in FIELD_SHEET.md's tap order, then the same `make field` the team runs. +DRY := $(DATA)/evals/field/dryrun +field-dryrun: + uv run python -m evals.field_dryrun $(DRY) + $(MAKE) field SESSION=$(DRY)/dryrun-session.zip TRUTH=$(DRY)/survey.json MAP=$(DRY)/map.json + +# A current iPhone's ARKit scale on MARViN's outdoor walks (pose and GPS text files only, ~3 MB). +modern-arkit: + uv run python -m evals.modern_arkit + +# After recon-data. HouseScanKit (ios/, owned by the app lane) is checked out read-only at the +# commit pinned in evals/coverage.py, outside the repo, and never edited. +coverage: + uv run python -m evals.coverage prepare + uv run python -m evals.coverage + +coverage-options: + uv run python -m evals.coverage prepare + uv run python -m evals.coverage_options + +# After coverage. The app's Map3D (ios/HouseScanKit, owned by the app lane) is checked out +# read-only at the commit pinned in evals/map3d.py, outside the repo, and never edited. +map3d: + uv run python -m evals.map3d prepare + uv run python -m evals.map3d diff --git a/experiments/evals/README.md b/experiments/evals/README.md new file mode 100644 index 000000000..ba310eca6 --- /dev/null +++ b/experiments/evals/README.md @@ -0,0 +1,411 @@ +# Evals on real data + +## Questions + +1. How far is ARKit's tracking off after walking 3, 10, 20 and 30 ft outdoors? The untested default for an AR tap is plus or minus 0.3 ft (3.6 in; `docs/00`, Conventions the code relies on). +2. How accurately can phone photos reconstruct a real building wall, with the model's own scale or with one taped distance, from one photo or several? +3. Can the phone's AR poses fix the learned models' scale, well enough for about 4 in at p90 on 1 to 3 m spans, the error that decides a 3 ft clearance? +4. Which photos are worth keeping? +5. On a real field session with a tape survey, how do the phone's AR taps and the learned-depth methods compare, and what is the phone's AR scale error? +6. How large is a current iPhone's ARKit scale error, from public data? +7. Does the app's coverage map claim wall and ground that no photo saw? + +Every dataset evaluation uses real captures. Two checks add synthetic inputs, and are labelled where they appear: section 3's pose-prior rows degrade ETH3D's true poses with simulated AR-like error, and `make field-dryrun` scores a synthetic session to prove the field command. The unit tests of the metric code also use synthetic cases. + +## Answers + +What the ETH3D numbers measure: the error in the distance between two scanned surface points, against a laser scan, reported separately for surface interiors, vertical surfaces (walls, fences) and depth edges (window frames, equipment, fence edges). Clearance endpoints are often edges, and edges score worst. Every "p90" below counts failures (no prediction, or a taped reference that no scale could match) as infinitely wrong. + +1. **ARKit drift: far worse than plus or minus 0.3 ft beyond a few feet, on the only phone measured.** On a 2018 iPhone 6s outdoors, ARKit and ARCore on the same rig disagree about a walked distance by a median 2.8 in after 3 ft (p90 8.2 in), 8.6 in after 10 ft (p90 22.3) and 25.7 in after 30 ft (p90 60.1). That is a disagreement between two trackers, not a bound on ARKit's error, but most of it is one steady offset that GPS attributes to ARKit: it reads distances 5% to 17% short. Position error, which also counts sideways drift, is larger against ADVIO's truth: p90 18.6, 55.7, 94.6 and 132.6 in after 3, 10, 20 and 30 ft. That is two to three times the server's allowance of 0.16 ft per ft, though the truth's own heading and position noise inflate it. One of four walks lost tracking entirely. +2. **Photos alone: no.** Each model's own metric scale is off. Within 6 m of the camera, MoGe-2 reads 4% to 12% long and Depth Anything 3 metric 7% to 11% short: median errors of 6 to 8 in on 1 to 3 m spans, p90 about 20 in. +3. **One photo plus one taped distance: good median, loose tail, worst on walls and edges.** A single MoGe-2 photo scaled by a taped 1 to 3 m distance is off a median 1.6 in on 1 to 3 m spans (electro, within 6 m). The p90 is 9.6 in on surfaces overall, 15.1 in on vertical surfaces and 26.9 in at edges. That settles only clear-cut placements. +4. **More photos with true poses and no rescale: no gain.** On a fixed set of points and pairs, per-photo depth averaged over 1, 2, 4 and 8 photos with the true poses gives, with one tape, medians of 1.8, 2.3, 2.5 and 2.8 in and p90s of 8.6, 11.4, 15.7 and 10.8 in on 1 to 3 m spans. Each photo carries its own scale error, and averaging mixes them. +5. **AR poses fixing scale: not to about 4 in, even at an assumed 2% pose error.** Rescaling each photo's MoGe-2 depth to points triangulated with the poses, then fusing 8 photos, gives these p90s on 1 to 3 m spans (electro, within 6 m, no tape, 95% intervals over seed groups and 5 pose-noise draws): + - exact poses: 4.0 in [3.3, 4.8] overall, 2.8 in [2.0, 4.4] on walls; + - an assumed modern phone (2% scale error, 1 cm and 0.1° of noise): 5.6 in [4.6, 6.7] overall, 5.0 in [3.8, 6.8] on walls; + - the errors measured on the 2018 phone: 14.6 in [10.6, 18.7] overall, 10.8 in [8.6, 13.1] on walls. + + These assume perfect feature matching across photos; without it, walls reach 6.3 in even with exact poses. Edges stay at 8 in or worse everywhere. The pose's scale error passes straight into every length. MapAnything given the poses fixes its scale on one scene but not the other, and leaves 10% to 20% of pairs without a prediction. + + **Verdict:** neither way reaches about 4 in at p90 with realistic pose error. An earlier single noise draw showed walls at 3.9 in; with five draws that was the lucky end of the spread. +6. **Which photos to keep: the close ones.** Keeping only photos with the wall within 6 m cuts a single photo's p90 from 11.4 to 6.9 in on 1 to 3 m spans, and from 39.0 to 14.8 in on 3 to 10 m spans. Viewing angle barely mattered. +7. **The field session: not measured yet.** `make field` (section 5) puts the phone's AR taps and three learned-depth rows in one table against tomorrow's tape survey, with the phone's AR scale error beside it. It runs end to end on the ADVIO replay and on a synthetic survey, but the numbers need the real session. +8. **A current iPhone's ARKit scale: within 2% of the only public reference, which cannot itself be checked to 2%.** On MARViN's 35 outdoor walks (iPhone 14 Pro Max, ARKit 6, 45 to 255 m each), ARKit's scale matched the dataset's COLMAP reference within 2% on 30. Scene by scene the median walk reads +0.3%, +1.2% and −1.7%. So section 3's modern_assumed 2% is plausible for this phone, though even that setting leaves walls at a p90 of 5.0 in [3.8, 6.8]. It is not proven: that reference gets its meters from its authors, not from a tape or a laser, and GPS can only check it to several percent. The phone also has LiDAR, so it may track better than the LiDAR-less phones we target. Its position error p90 over trusted walks is 8.6, 13.4 and 18.5 in after 10, 20 and 30 ft (50.0 in at 30 ft counting the three doubtful walks), inside the server's 0.16 ft per ft allowance. The field tape test stays decisive (section 6). + +9. **Does the app's coverage map claim surface no photo saw? Yes, on the one wall tested: 1.1 ft of 19.3 ft claimed (6%), against a bar of 0.5 ft.** All of it sits behind equipment standing in front of the wall (a scaffold's footings and cables), because the coverage code checks range, angle and image bounds but not occlusion (`CoverageMap.swift` lines 241 to 248 at beede15). The ground band and facade could not be tested: ETH3D's photographers stood too far from the walls for the app to credit any ground, or any of facade (section 7). Requiring views farther apart or at wider angles does not fix it without discarding good wall (6 to 8.5 ft here). A depth test against true depth does, which needs LiDAR (section 7b). + +10. **The app's 3D map (Map3D), LiDAR path, ideal depth: no wall claimed that no photo saw, where `CoverageMap` claimed 1.1 ft.** It misses 8.9 ft, 5.5 ft of it behind pilasters that stand out past its 10 cm face window. Its wall line runs 1.76° off and 4.8 in out at the meter. The non-LiDAR path, ground and overhead are untested, and its one facing claim sits below the truth's resolution (section 7c). + +## Reproduce + +From a clean checkout (macOS with Apple silicon, or Linux for everything but the models): + +```sh +cd experiments/evals +uv sync +make test # metric code against hand-computed cases, lint, format +make drift # ADVIO download (about 800 MB) -> results/advio_drift.md +make replay # the replay session -> ~/house-scanning-data/replays/ +make recon # ETH3D download (about 2.4 GB), MoGe-2 and Depth Anything 3 runs -> results/eth3d_recon.md +make sensitivity # after recon -> results/eth3d_visibility_sensitivity.md +make pose-priors # after recon -> results/pose_priors.md +make frames # after recon -> results/frames.md +make field SESSION=... TRUTH=... MAP=... # a field session, section 5; PR #4's harness is checked out at a pinned commit +make modern-arkit # MARViN pose files (about 3 MB) -> results/modern_arkit.md, section 6 +make coverage # after recon-data; the app's coverage code at a pinned commit -> results/coverage.md, section 7 +make coverage-options # after coverage -> results/coverage_options.md, section 7b +make map3d # after coverage; the app's Map3D at a pinned commit -> results/map3d.md, section 7c +``` + +Data lives in `~/house-scanning-data/` (override with `HOUSE_SCANNING_DATA`). `evals/datasets.py` checks each archive's size and sha256, unpacks only what the evals read, deletes the archive, and refuses to download with less than 6 GB free. Both datasets are licensed for non-commercial research: they measure accuracy here and are never committed, redistributed or used for training. + +Every process stays near or under 4 GB, because the machine is shared. MapAnything's 1.2 B parameters are loaded with their transformer weights in bf16 (`models/map_anything.py`), and even then only 2 and 4 views at 392 px fit (peak 4.05 GB). An earlier run at its native 518 px, with fp32 weights, read 10% to 33% short, where the 392 px run reads 17% long on electro's near walls and 14% short on facade. So its metric scale depends on input resolution, and neither run is a stable baseline. + +## Datasets + +| Dataset and license | File | URL | Bytes | sha256 | +| --- | --- | --- | --- | --- | +| ADVIO, CC BY-NC 4.0 | advio-20.zip | https://zenodo.org/record/1476931/files/advio-20.zip | 195538352 | be21154394df09d6ecebe1894062f290bb53d74a5c6ccbca4b3a69f91ee2c8a2 | +| | advio-21.zip | https://zenodo.org/record/1476931/files/advio-21.zip | 209791503 | fb8a1cf3f645bbd9ea848e66924e83ae75b5cd18d632986e5629af1eecb58570 | +| | advio-22.zip | https://zenodo.org/record/1476931/files/advio-22.zip | 254664089 | 96c7212fc9cb610a88ba9fa62ac2581f0cdae49e80007888698ee2cf5ebd56f4 | +| | advio-23.zip | https://zenodo.org/record/1476931/files/advio-23.zip | 134340011 | 726d9b80d30036a8585f6cecaae385236b689cc4b8b19be6362c6df223825dc1 | +| | iphone-04.yaml (calibration, sequences 20 to 23; copied into `evals/advio.py`) | https://raw.githubusercontent.com/AaltoVision/ADVIO/master/calibration/iphone-04.yaml | | 6f312ace74f23e41b755fa40829e30a4f96e8abd3c28e6bdbac2e5e39f960813 | +| ETH3D, CC BY-NC-SA 4.0 | facade_dslr_undistorted.7z | https://www.eth3d.net/data/facade_dslr_undistorted.7z | 1252088400 | 046e577388db0633eeb2d8d72da6a2de53857434b4a7a98e4c97485795f9ce82 | +| | facade_dslr_scan_eval.7z | https://www.eth3d.net/data/facade_dslr_scan_eval.7z | 176517224 | d686462d417fbea010d0021f918bec8b881444929ec06e3bd4e8a5230ebf59a8 | +| | facade_dslr_occlusion.7z | https://www.eth3d.net/data/facade_dslr_occlusion.7z | 144292883 | 26a9d2076ba16b7f8e24311579c47b0b7b826b180906ced20fa1cba8f3dd130d | +| | electro_dslr_undistorted.7z | https://www.eth3d.net/data/electro_dslr_undistorted.7z | 514345623 | 0d2bc31fec0032b8fb20703abba8e45ef7a395c13d2d3476adc1f4dd4ffd7d8b | +| | electro_dslr_scan_eval.7z | https://www.eth3d.net/data/electro_dslr_scan_eval.7z | 250703286 | 5ca10a73e7da0e3c511e255bba5656cca4c372dc00c1417c97b75228a5bb3bac | +| | electro_dslr_occlusion.7z | https://www.eth3d.net/data/electro_dslr_occlusion.7z | 47214879 | cc25a22de9fc27251a5178454c24785671a2b2c199756a0d03297413290830b3 | + +MARViN (iPhone 14 Pro Max; https://github.com/XRIM-Lab/MarViN) states no data license, so it is used only to measure accuracy and never redistributed. Only its pose and GPS text files are fetched, from its public Google Drive folder, and each file's sha256 is listed in [results/modern_arkit.md](results/modern_arkit.md). + +Model checkpoints (MIT and Apache-2.0) are pinned by revision and checked against Hugging Face's sha256 (`models/README.md`). + +## 1. ARKit drift outdoors (ADVIO) + +[results/advio_drift.md](results/advio_drift.md), `make drift`. + +**Data.** ADVIO's four outdoor walks (sequences 20 to 23, 3 to 6 minutes and 380 to 510 m each), recorded in 2018 on a rig carrying an iPhone 6s (ARKit 1.0, video, GPS) and a Google Pixel running ARCore. The ground truth is an inertial track pinned to fix points marked on a map. + +**Method.** From every half second of a walk, the walk continues until exactly 3, 10, 20 or 30 ft of path has been covered. The error is ARKit's straight-line displacement minus the reference's. It needs no heading alignment, and it is what a span measured by walking it would be off by. + +**Why ARKit is not scored against ADVIO's ground truth alone.** Its scale is wrong in walks 20 and 21. ARKit covers only 0.70 and 0.75 of the truth's distance there, which is why ARKit's path totals 345 m against the truth's 474 m in walk 20. GPS scales the truth by 0.835 and 0.799 (95% intervals 0.81 to 0.86 and 0.78 to 0.82), and ARCore agrees (0.80 and 0.82). In walks 22 and 23 both agree with the truth's scale. The fix points explain it: walks 20 and 21 were pinned on one map at 0.0422 m per map pixel, and 22 and 23 on another at 0.2256 m per pixel. So most of the path-length gap is the reference's error; the rest is ARKit reading 5% to 17% short (ARKit over GPS: 0.83, 0.94, 0.95 in walks 20 to 22). + +The truth also wanders between fix points. The results file splits the variance of each pairwise difference into per-tracker parts, but that split assumes the three trackers' errors are independent. Walk 22 violates it: it gives negative variances, and at 3 ft it puts ARCore's spread (16.3 in) above the truth's (8.1 in). Read the split as conditional, not as a resolution limit or a bound. + +**Results, pooled over walks 20 to 22** (walk 23: ARKit jumped at 826 m/s 5.5 s in and never recovered). |ARKit − reference|, inches, median / p90: + +| Walked | vs truth as published | vs truth rescaled to GPS | ARKit − ARCore disagreement | Signed median, ARKit − ARCore | +| --- | --- | --- | --- | --- | +| 3 ft | 9.7 / 17.8 | 4.7 / 13.1 | 2.8 / 8.2 | −2.4 | +| 10 ft | 31.6 / 55.8 | 15.4 / 40.5 | 8.6 / 22.3 | −8.1 | +| 20 ft | 60.4 / 106.0 | 30.9 / 70.1 | 16.9 / 41.5 | −15.9 | +| 30 ft | 88.2 / 156.8 | 44.5 / 92.9 | 25.7 / 60.1 | −24.8 | + +The ARCore column is the tightest comparison, but it is not a conservative estimate of ARKit's error: two trackers on one rig can share errors that cancel in their difference. + +**Position error.** Distance error only compares how far each track moved, so it cannot see sideways drift, and placement relative to the meter needs the whole displacement. Here ARKit's displacement is turned by the heading difference at each window's start, against the GPS-rescaled truth, both as tracked and with each walk's own ARKit scale divided out (0.838, 0.943 and 0.951 in walks 20 to 22). Pooled over walks 20 to 22, median / p90 inches: + +| Walked | As tracked | Scale removed | Server allowance, 0.16 ft per ft | +| --- | --- | --- | --- | +| 3 ft | 8.3 / 18.6 | 7.7 / 20.4 | 5.8 | +| 10 ft | 26.3 / 55.7 | 24.5 / 59.6 | 19.2 | +| 20 ft | 50.4 / 94.6 | 47.7 / 102.5 | 38.4 | +| 30 ft | 73.8 / 132.6 | 70.4 / 144.6 | 57.6 | + +Removing the scale barely changes the numbers, so what remains is heading and random error. Much of it is the reference's own: its random error is the largest of the three trackers', and its heading at each window's start enters directly. These numbers therefore overstate ARKit's error, but they are the only position errors this data supports. ARCore cannot serve as a position reference, because its orientation convention is undocumented. + +**Limits.** A 2018 phone with ARKit 1.0, walking briskly with the camera pointed along the path. Current phones may track better; there is no data on them here. + +## 2. Reconstruction accuracy on building walls (ETH3D) + +[results/eth3d_recon.md](results/eth3d_recon.md), `make recon`; [results/eth3d_visibility_sensitivity.md](results/eth3d_visibility_sensitivity.md), `make sensitivity`. + +**Data.** ETH3D's facade (76 photos) and electro (45 photos) scenes: building walls photographed with a 24 MP DSLR, camera poses registered to terrestrial laser scans. Photos are resized to 1024 px wide and fed to each model with their known intrinsics. + +**What is scored.** The error in the distance between two scanned points, for pairs 1 to 3 m and 3 to 10 m apart. Distances do not change under rotation or translation, so no alignment step can hide error. Scale error is the median of predicted over true length, minus one. + +**How points are chosen** (`evals/eth3d.py`, `evals/recon.py`): +- **Visibility.** Scan points are projected into each view. A point is visible when it is within 4% of the nearest scanned depth along its pixel, in a z-buffer that also holds ETH3D's occlusion splats. Points in ETH3D's masked regions are dropped: glass, and objects missing from the scan such as trees. The masks are drawn on the distorted originals, so they are stretched to the undistorted frame and dilated 13 px. +- **Cohorts.** Points where the depth jumps by more than 10% within 5 pixels are depth edges; the rest are surface interior, and interior points whose scan normal is within 17° of horizontal form the vertical cohort. +- **Fixed evaluation sets.** Each of 8 seed photos per scene fixes a set of points (those it sees; within 6 m of it for the near slice), test pairs and 25 taped reference pairs. The same set scores every method and every group size (the seed plus its 1, 3 or 7 nearest cameras facing the same way). Other photos only add predictions for those points, and in the near slice a photo contributes a point only if its own camera is within 6 m of it. +- **Failures.** A pair with no prediction, or a taped reference no scale can match, counts as infinitely wrong. The tables report the calibration success rate. + +**Checks.** Scan depths agree with ETH3D's own sparse 3-D points to a median 0.2% to 0.4%. Depth rendered from the scan itself, scored through the same pipeline, is off 0.2 to 0.8 in median on 1 to 3 m spans (p90 under 2.5 in): the evaluation's floor. Changing the visibility tolerance to 2% or 8% moves model scale errors by about 1 point and medians by about 0.5 in, but p90s by up to 20% (MoGe-2) and more for Depth Anything 3. Treat p90s as carrying roughly that much evaluation uncertainty. + +**Results at phone range: electro, within 6 m** (facade's photos were taken from 10 to 25 m). |length error|, inches, median / p90; "all photos" rows score every photo on its own evaluation set: + +| Method | Photos | Cohort | Model scale: 1-3 m | Scale error, 1-3 m pairs | One taped distance: 1-3 m | 3-10 m | +| --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo | all | surface interior | 6.8 / 19.5 | +3.5% | 1.6 / 9.6 | 3.0 / 17.8 | +| | | vertical interior | 7.1 / 24.7 | +1.0% | 1.8 / 15.1 | 3.6 / 58.2 | +| | | edges | 8.3 / 31.8 | +3.6% | 3.7 / 26.9 | 5.8 / 36.8 | +| Depth Anything 3 metric, one photo | all | surface interior | 6.0 / 20.2 | −6.6% | 2.9 / 25.3 | 6.1 / 62.6 | +| MoGe-2 per photo + true poses | 1 | surface interior | 6.9 / 14.9 | +4.4% | 1.8 / 8.7 | 3.5 / 17.5 | +| | 2 | | 6.8 / 15.0 | +2.4% | 2.3 / 11.3 | 4.0 / 21.1 | +| | 4 | | 6.1 / 14.4 | +2.5% | 2.5 / 15.7 | 4.4 / 33.1 | +| | 8 | | 5.4 / 12.5 | +0.7% | 2.8 / 10.8 | 4.3 / 21.1 | +| Scan rendered as depth (floor) | 1 | surface interior | 0.7 / 1.6 | −0.9% | 0.5 / 1.6 | 0.9 / 2.8 | + +Back-projection uses ETH3D's own intrinsics, not the centred ones MoGe-2 reports (a 0.8 to 3.6 px difference that moves medians by at most 0.1 in). + +Over all points (4 to 23 m away), MoGe-2 reads 8% short on facade and 0.5% long on electro, and Depth Anything 3 metric 10% to 15% short. With a tape, one MoGe-2 photo is off a median 2.5 to 2.9 in on 1 to 3 m spans. + +**Limits.** Two institutional buildings, not houses, photographed with a DSLR that is sharper than a phone camera. The taped distance is simulated as exact. Facade's near slice holds only 2 of its 8 groups, too few to conclude from. + +## 3. Pose priors: can AR poses fix the scale? (ETH3D) + +[results/pose_priors.md](results/pose_priors.md), `make pose-priors`. + +**Poses.** ETH3D's true poses, degraded per group of photos (`evals/ar_poses.py`). Each setting is an assumption: +- **exact:** the true poses. +- **advio_2018:** each group takes one ADVIO walk's measured ARKit scale against the GPS-rescaled truth (0.838, 0.943 or 0.951; ARCore itself reads about 4% short of GPS, so it is not the reference). It adds 5 cm and 0.2° of noise per camera, from ARKit's own 3 in spread over 3 ft and the 0.22° image-vs-pose disagreement on the replay. +- **modern_assumed:** 2% short, 1 cm and 0.1°, a guess for a current iPhone with no data behind it. + +**Methods.** +- **(a) MapAnything** given the poses and intrinsics as priors, at 392 px with 2 and 4 photos (the memory limit); the same runs without poses are its baseline. +- **(b) Triangulation rescale.** MoGe-2 depth for each photo is rescaled to the points triangulated from SIFT matches across the group with those poses (`evals/triangulate.py`: one factor per photo, the median ratio over at least 20 points that reproject within 2 px plus the pose noise), then placed with the same poses and fused. +- **Control:** MoGe-2 placed with the same poses without the rescale. + +**Results: electro, within 6 m, 1 to 3 m spans, no tape, 8 photos.** Median / p90 inches with 95% intervals. The intervals bootstrap over the seed groups (7 for surface interior, 6 for walls) and over 5 pose-noise draws for each AR-like setting; exact poses have no noise to draw. + +| (b) MoGe-2 rescaled by triangulation | Surface interior | Vertical interior (walls) | Scale error | +| --- | --- | --- | --- | +| exact poses | 1.3 [1.0, 1.5] / 4.0 [3.3, 4.8] | 1.1 [0.8, 1.4] / 2.8 [2.0, 4.4] | −0.6% | +| modern_assumed (2% short, 1 cm, 0.1°) | 2.5 [2.1, 2.9] / 5.6 [4.6, 6.7] | 2.3 [1.7, 2.8] / 5.0 [3.8, 6.8] | −3.0% | +| advio_2018 (measured on the 2018 phone) | 6.2 [4.6, 8.6] / 14.6 [10.6, 18.7] | 4.9 [3.7, 6.5] / 10.8 [8.6, 13.1] | −7.3% | + +Edges stay at 8 in or worse at p90 in every setting. The control, MoGe-2 placed with the same poses and no rescale, has a p90 of 12.5 in with exact poses. With 4 photos, (b)'s p90 more than doubles. + +These rows assume perfect cross-view correspondence: each scan point is found in every photo that sees it through the true projection, as a perfect feature matcher would. An app without a matcher would instead reproject the tapped photo's own estimate into the others. Done that way, the wall p90 with 8 photos rises from 2.8 to 6.3 in with exact poses, from 4.3 to 6.4 in with modern_assumed and from 10.3 to 14.8 in with advio_2018, on one noise draw each. Medians move by 0.2 in or less. + +(a) MapAnything given the poses, at 392 px with 4 photos, fixes most of its scale on electro (+17% becomes about +3%) but not on facade (still 12% to 16% short). It leaves 10% to 20% of pairs without a prediction, so its p90 fails. Its rows have a single noise draw: its weights were removed to free disk. + +**What decides it.** Triangulation fixes each photo's own scale error, which is why 8 rescaled photos beat 8 unscaled ones by a factor of three. But the triangulated points take their scale from the poses, so the pose's scale error passes straight into every length: 2% short in, 3% short out. A taped distance on top makes it worse, not better (p90 7.6 in with exact poses): the reference pair carries its own reconstruction error, which outweighs the scale error that is left. + +## 4. Which photos to keep (ETH3D) + +[results/frames.md](results/frames.md), `make frames`. Each photo is scored alone (MoGe-2, one taped distance, surface interior) and described by what an app can measure while capturing: the median distance to the wall points in view, and the median angle between the viewing ray and the scanned surface. Both scenes pooled, median / p90 inches: + +| Keep | Photos kept | 1-3 m spans | 3-10 m spans | +| --- | --- | --- | --- | +| every photo | 121 of 121 | 2.7 / 11.4 | 7.6 / 39.0 | +| wall within 6 m | 15 of 121 | 1.9 / 6.9 | 4.3 / 14.8 | +| wall within 8 m | 27 of 121 | 2.1 / 7.4 | 4.8 / 17.0 | +| wall within 12 m | 42 of 121 | 2.3 / 8.1 | 5.4 / 20.4 | +| wall seen within 40 degrees of head-on | 52 of 121 | 2.3 / 9.3 | 7.1 / 35.2 | +| wall seen more than 50 degrees off head-on | 40 of 121 | 2.8 / 11.7 | 7.1 / 32.8 | + +No photo here is closer to head-on than 26 degrees, so a head-on shot is untested, and all 15 photos within 6 m are from electro. + +## 5. A field session against the tape + +`make field SESSION=session.zip TRUTH=survey.json MAP=map.json RULES=rules.json SCORING=../scoring` turns one Measure Lab session (PR #7's one-hour tape protocol) and its tape survey into one table of every method, scored by the scoring harness (PR #4, `experiments/scoring`). The map is the same file `score import-measure-lab` reads. The command runs these steps (`evals/field.py`): + +1. It turns each keyframe upright and runs MoGe-2 on it. Session images are sideways sensor images, and the model expects upright photos. **Run `make field` with nothing else heavy on the machine:** MoGe-2 at its default resolution peaks at 4.07 GB, just over the shared machine's 4 GB per process. Depth maps are written as each keyframe finishes, so the peak does not grow with session length, and a 3.6 GB GPU cap stops a run that would grow instead of letting it swell. Storing its encoder in half precision to get under 4 GB failed: parts of the encoder run outside autocast and reject fp16 weights. +2. It gives every point the rig made from taps a second position: MoGe-2's depth at the tapped pixel, placed with that keyframe's AR pose. It then recomputes walls and every session measurement with Measure Lab's own formulas (`Wall.swift`, `Measurements.swift`). +3. It writes three scoring-harness results files: + - `moge2`: the model's own scale; + - `moge2-triangulated`: method (b) of section 3, each keyframe rescaled to features triangulated with the session's AR poses across it and its 7 nearest keyframes; + - `moge2-tape`: one depth scale for the session, solved so the survey's scale reference comes out at its taped length. Each point moves along its ray from its own keyframe's camera and camera positions don't scale, so it solves along the rays rather than dividing lengths; a length ratio is exact only when both taps share a keyframe. The reference must be a straight point-to-point measurement. + + They state no uncertainty and make no decisions: none of them has a validated error bar. +4. It imports the rig's own row with `score import-measure-lab` and scores every row with `score`. +5. It writes `field_report.md` with the phone's AR scale error, the number section 3's verdict hinges on: + - Only measurements the rig accepted count, as in PR #4's importer. + - The scale is a least-squares fit over straight spans of 10 ft or more, weighted by length, with a 95% bound per span and overall. The bound combines the tape's ± with an assumed 2 in of tapping error per span, which the field test itself will check (`--tap-error-in`). + - It needs an accepted span of about 30 ft (29 ft or more), and answers "within 2%" as yes, no or cannot tell, under the same strict rule as placements. + - Each session writes to its own folder, cleared on a re-run, so `score` never picks up an earlier run's rows. + +MapAnything is left out. In section 3, given poses, it was worse than (b) everywhere. It left 10% to 20% of pairs without a prediction and did not fix its scale on facade. It also cannot take a phone's full keyframe set within the 4 GB limit. + +**Checked.** A hand-computed synthetic session (`tests/test_field.py`) gets all four rows through PR #4's own `score`, including its import of the rig's row, in one table: +- The rig row reads 2% short, as planted. +- MoGe-2 with depth 10% long gives every length 10% long. +- The tape row comes out exact. +- The triangulated row reports every value as failed: one keyframe has no neighbours to triangulate against. + +**On the real ADVIO replay** ([results/field-replay/field_report.md](results/field-replay/field_report.md)), the pipeline runs through MoGe-2 and triangulation. All 79 keyframes get a scale, and MoGe-2 needs a median ×0.774 (0.66 to 1.03) to agree with the iPhone 6s's ARKit poses; ARKit itself read about 16% short on this walk. Nothing more comes out, because the replay has no taps, measurements, survey or map. + +**In the field:** [field/FIELD_SHEET.md](field/FIELD_SHEET.md) lists what to bring, mark, tape and tap, in order, with the Measure Lab numbers the map expects. `field/survey.template.json` and `field/map.template.json` name every item, so the team fills in only tape readings, typed as the tape shows them (`30 0 1/8`). `make field` converts them to feet and fills in the zip's sha256 and session id. `make field-dryrun` builds a synthetic session in the sheet's order, fills the templates, and runs `make field` with them. It recovers the planted 1.5% AR scale error (−1.50% ± 0.68%) and gets all four rows through PR #4's `score`. + +## 6. A current iPhone's ARKit scale (MARViN) + +[results/modern_arkit.md](results/modern_arkit.md), `make modern-arkit`. + +**Which data, and why.** The only public data I found with a 2020-or-newer iPhone's own ARKit poses and a separate reference trajectory, downloadable today, is MARViN (Liu et al., IEEE VRW 2024). It has an iPhone 14 Pro Max (ARKit 6) walking outdoors around three sites, 45 to 255 m per walk, one image per second, with a COLMAP reconstruction of each site as the reference. Checked and not used: +- **LaMAR** (ETH and Microsoft) needs an access request. +- **ScanNet++** needs an application. +- **ARKitScenes** (iPad Pro, indoor) ships ARKit's trajectory but not its laser-registered camera poses. +- **ADVIO**'s phone is from 2016. + +**Method.** The same windows as section 1: from every image, the walk continues until the reference has covered 3, 10, 20 or 30 ft. ARKit's straight-line displacement is compared with the reference's. A walk's scale is the median ratio over windows of 10 ft or more. Distances need no axis alignment, which matters because ARKit's poses here are in Unity's axes. + +**The reference's limits.** COLMAP from one moving camera has no scale of its own, and the dataset does not say how its reconstructions were put into meters. They could have been scaled to ARKit itself, which would hide any ARKit scale error. The phone's GPS is the only independent check shipped. Over these walks GPS is 1 to 9 m off, and it puts the reference's scale between 0.92 and 1.07 even on its 11 cleanest walks, so it cannot confirm or refute 2%. The reference's own accuracy is not published. + +**Results.** Scale error of ARKit against the reference, per walk: + +| Site | Walks | Median walk | Range | +| --- | --- | --- | --- | +| atrium | 10 | +0.3% | −50.7% to +1.0% | +| bar | 13 | +1.2% | −0.4% to +2.1% | +| church | 12 | −1.7% | −2.2% to −1.0% | + +Thirty of 35 walks are within 2%. Three atrium walks read 18% to 51% short; on them, ARKit and the reference disagree about whether the phone moved at all between images, so the reference is in doubt there. Distance errors pooled over all walks, median / p90 inches: + +| Walked | As tracked | Without the three doubtful walks | +| --- | --- | --- | +| 10 ft | 2.7 / 16.4 | 2.4 / 7.1 | +| 20 ft | 4.5 / 34.8 | 4.2 / 11.9 | +| 30 ft | 5.9 / 45.8 | 5.5 / 16.2 | + +The 3 ft row in the results file is below what this reference resolves (images 0.2 to 1.4 m apart, and reference jitter up to 0.4 m), so it is not repeated here. + +**Position error**, the same windows with ARKit's displacement turned by the heading offset at each window's start. ARKit's quaternions are (w, x, y, z) in Unity's left-handed axes, and each walk is paired with the reference by the handedness that keeps the heading offset steady: it holds to 0.6° over a median walk (worst 3.1°). Median / p90 inches: + +| Walked | As tracked | Without the three doubtful walks | Server allowance, 0.16 ft per ft | +| --- | --- | --- | --- | +| 10 ft | 3.3 / 18.7 | 3.1 / 8.6 | 19.2 | +| 20 ft | 5.4 / 37.5 | 5.1 / 13.4 | 38.4 | +| 30 ft | 7.2 / 50.0 | 6.9 / 18.5 | 57.6 | + +**Verdict.** +- **Within 2%?** Probably, for this phone; not proven. Against the only reference available, this 2022 iPhone's ARKit holds its scale within 2% on 30 of 35 outdoor walks, and each site's walks agree with each other to within about 1 to 2.5%. +- **Walk-to-walk floor.** A reference scaled to ARKit would carry one scale per site, so the spread between one site's trusted walks survives it. That spread is 0.28% [0.12, 0.36] at atrium, 0.75% [0.46, 0.94] at bar and 0.34% [0.19, 0.44] at church (1 SD, 95% interval), so a single walk's scale error is at least about 0.3% to 0.8%. +- **What it means for section 3.** A pose scale error of about 2% is the first thing walls need, and this is the first evidence from a current phone that ARKit meets it. It is not enough: at 2%, walls still come out at a p90 of 5.0 in [3.8, 6.8] (section 3). The 2018 phone's 5% to 17% does not describe current hardware. +- **What stops it being settled:** + - the reference's scale cannot be checked independently to 2%; + - one site sits at a steady −1.7%, which is the phone's error or the reference's; + - the phone has LiDAR, which ARKit may use. + +The tape test on the team's phone is still the number to trust. + +## 7. Does the app's coverage map claim surface no photo saw? (ETH3D) + +[results/coverage.md](results/coverage.md), `make coverage`. + +**Why.** Every placement check rests on "unseen is not clear": the server passes a check only over the wall and ground the app exports as observed. That export is `CoverageMap.coveredIntervals` (`ios/HouseScanKit`), whose own comment says occlusion is not modelled. Nobody had tested it against what the photos actually saw. + +**Method.** +- **The app's code, unmodified.** `ios/HouseScanKit` at commit beede15 of `t3/ios-mvf`, checked out read-only outside the repo and run by `coverage_driver/` with its default `CoverageConfig`. Every ETH3D photo is a kept keyframe with normal tracking, fed in capture order. +- **The wall, as taps would give it.** Per scene, the straight stretch of wall that the most photos see within the app's 6 m. The meter sits mid-stretch, 1.5 m up. The outward normal comes from a plane fit to the scan, the ground height from the scan at the wall's foot, and the marked ends are the stretch's ends. The world is levelled on the ground plane under the cameras, because ARKit's y is up. Poses and intrinsics go in unrotated, in ARKit's camera axes. +- **Truth.** Columns 2 cm wide along the wall, each band sampled every 5 cm up the wall or out across the ground. A photo saw a sample when all of these hold: + - it lands inside the full image; + - it is within the app's 6 m; + - the camera is on the room side of the wall; + - nothing nearer hides it. "Nearer" means more than max(10 cm, 4% of depth) in front in the laser scan's depth, with ETH3D's occlusion splats included; ETH3D's masks of objects missing from the scan (people, vegetation) also count as hiding. + + Grazing angle is not part of the truth: an oblique photo still saw the surface, and the app's 65° limit shows up only as missed length. + +**Pass criteria, fixed before the run.** +- **False-observed length**: wall claimed as observed where at least 10 cm of the band (2 samples) was seen by no photo. Must be at most 0.5 ft, one 6-in cell, per scene and band; that is what "about zero" means here. It is also reported as a share of claimed length, and split by the cause at the photos the app credited: occlusion, range, frame edge. Grazing angle cannot cause it under this truth. A stricter variant counts band seen from fewer than two positions 0.25 m apart, the app's own bar for "covered"; it is reported, not graded. +- **Missed length**: band every sample of which two photos 0.25 m apart saw, which the app did not credit. No pass bar, since it costs the homeowner extra photos, not a wrong answer. It is split by why the app turned those photos down: frame edge (including its 3% margin and the band's top or bottom out of view), range, grazing angle, or only one position. +- **Validity checks**: my replica of the app's view gates (used only to name causes) must reproduce the app's own sightings, and the wall plane must fit the scan within a few centimetres. + +**Results.** Lengths in feet along the wall. + +| Scene | Band | Claimed | False-observed (share) | Pass | False-observed, 2-position truth | Missed | +| --- | --- | --- | --- | --- | --- | --- | +| electro | wall | 19.3 | 1.1 (6%), all occlusion | no | 1.3 (7%) | 0.0 | +| electro | ground | 0.0 | 0.0 | untested | 0.0 | 18.1, all grazing angle | +| facade | both | nothing: no wall within 6 m of any photo | | untested | | | + +- **Where the 1.1 ft is.** 1.05 ft is the bottom 20 cm of the wall at the stretch's left end, behind a scaffold's wooden footings and a cable bundle standing in front of the wall. The app credits it because the photos that frame it are within range and angle; no photo sees behind the footings. The other 0.07 ft is one 2 cm column at a pilaster's edge, within the scan's resolution. +- **Ground: untested.** The app claimed no ground on electro. The ETH3D photographers stood 2.0 to 7.6 m from the wall (median 4.9 m). A camera 1.5 m up must be within 3.2 m for the ground at the wall's foot to be inside the app's 65°, so every ground view was turned down: 18.1 ft seen from two positions and not credited. At the app's intended 2.6 m standoff the ground would be claimed, so these photos cannot say whether that claim would be right. +- **Facade: untested.** The building stands beyond the app's 6 m from every photo: 99% of its wall-height scan points are 6.5 m or more from the nearest photo, with a median of 11.6 m. Within range are only stair flanks, sculptures and tree trunks, none a straight wall that a photo sees 1 m of. The app claims nothing there. +- **Validity.** The replica of the app's view gates reproduces all 337 of its sightings exactly. The flat parts of the wall fit the plane to 1.8 cm RMS. Two pilasters stand 0.36 m proud of it (3.8 ft of the 19.3), so the truth samples their front faces; with the plane alone they read as 2.8 ft of false occlusion. No claimed sample was credited only where the scan is empty. Peak memory 1.3 GB. + +**Verdict.** +- **Does coverage claim unseen wall? Yes: 1.1 ft of 19.3 on electro (6%), where "about zero" allowed 0.5 ft. It fails.** All of it is occlusion, and that is the rule, not bad luck. At commit beede15, `CoverageMap.swift` lines 241 to 248 (`sees`, inside `visibleRows`) accept a sample on range, angle and image bounds alone; nothing checks whether something stands between camera and wall. The type's own comment (lines 57 to 59) says so, and adds that "the server re-checks what matters from the images". The server's contract says the opposite: it passes a check over whatever the app reports as observed. The loss is small here only because 17 photos from many angles see around most of the scaffold. A bush, a bin or an AC unit in front of a wall is claimed as observed whenever the phone stands in front of it. +- **What would fix it (for S3):** + - Give `CameraFrame` an optional per-frame depth map: ARKit's `sceneDepth` on LiDAR phones, or depth rendered from ARKit's mesh. + - In `sees`, reject a sample when that depth at its pixel is nearer than the sample by more than max(10 cm, 4%), the tolerance used here. + - Sample more rows, or check depth along each row-to-row segment. With `rowsPerBand = 3` (lines 43 to 47) the rows are 0.99 m apart, so something mounted between them passes even a depth check at the rows. + - Phones without depth can't make this check on the device. Until one side changes, either the server re-checks occlusion from the images, as the comment assumes, or the export must not be read as "seen and clear". +- **Missed wall: none.** Everything two photos saw, the app credited. +- **Limits.** One scene, one 19 ft wall, 17 contributing DSLR photos, not a walking phone capture. The truth ignores anything within max(10 cm, 4% of depth) of the wall (downspouts, conduit), which can only have hidden more false-observed length, not less. + +### 7b. Options for occlusion (ETH3D electro) + +[results/coverage_options.md](results/coverage_options.md), `make coverage-options`. + +**Question.** Which rule change stops coverage from claiming unseen wall, and at what cost in extra photos? The same harness, wall and truth as section 7. Each option is modelled in `coverage_driver/`, around the app's code at beede15, which stays unedited: +- **Position baseline b**: `CoverageConfig.coveringBaseline` (`CoverageMap.swift` line 49, used at line 181), swept at 0.25 (today), 0.5, 1 and 2 m. No LiDAR needed. +- **Angle diversity θ**: the predicate in `record` (lines 180 and 181) also requires the two views' directions to the row's centre to differ by at least θ, swept at 0°, 15°, 30° and 45°. It is crossed with b. No LiDAR needed. At θ = 0 and b = 0.25 the driver's copy of `record` must reproduce the app's own answer exactly. +- **Depth test (LiDAR)**: `sees` (lines 241 to 248) also rejects a row sample that the laser scan, standing in for LiDAR depth, shows hidden. The same test and tolerance as the truth, and ETH3D's masks count, because LiDAR sees people and trees too. Three settings: + - true depth out to the app's 6 m, 3 rows (the ceiling); + - the same with 9 rows (`rowsPerBand`, lines 43 to 47); + - 9 rows with no depth beyond 5 m, the iPhone LiDAR's stated range. + +**Pre-registered, before any run.** An option passes when false-observed length on electro's wall is at most 0.5 ft, as in section 7. Missed length is measured against the same truth for every option: band seen from two positions at least 0.25 m apart. The recommendation is the passing option with the least missed length, preferring one that needs no LiDAR when two are within 1 ft of each other. + +**Results** on electro's 19.3 ft wall, in feet: false-observed (pass at 0.5 or less) and missed (band two photos saw that the option doesn't claim). The full grid is in the results file. + +| Option | Needs LiDAR | False-observed | Pass | Missed | +| --- | --- | --- | --- | --- | +| The app today (b = 0.25 m, θ = 0°) | no | 1.1 | no | 0.0 | +| Views ≥ 2 m apart, any angle up to 45° | no | 1.1 | no | 0.0 | +| Views ≥ 5 m apart* | no | 0.0 | yes | 6.0 | +| Views ≥ 60° apart* | no | 0.0 | yes | 8.5 | +| Depth test, true depth to 6 m, 3 or 9 rows | yes | 0.0 | yes | 3.3 | +| Depth test, 9 rows, no depth past 5 m | yes | 0.0 | yes | 7.8 | +| Depth test, 9 rows, 0.4 m of wall relief allowed* | yes | 0.1 | yes | 0.0 | + +\* Added after the pre-registered grid (b 0.25 to 2 m, θ 0 to 45°) changed nothing. + +- **Spacing or angle between views: no.** Nothing in the pre-registered grid moved the 1.1 ft. The footings hide the wall's base from every direction the photos were taken from, and seeing a cell from far-apart or differently angled views says nothing about whether something stands in front of it. The patch goes only when the rule refuses cells for want of spread: at 5 m apart (6.0 ft lost) or 60° apart (8.5 ft lost, 47% of what was seen). It is removed along with good wall, not singled out. At 75°, nothing is claimed. +- **Depth test: yes, at a price the rule's shape sets.** With true depth it claims nothing unseen. The 3.3 ft it misses is the two pilasters. A tapped wall is a plane, so a pilaster 0.36 m proud of it reads as something in front of the wall, and a real LiDAR test would do the same. Allowing 0.4 m of relief keeps them (0 missed) and still rejects the footings (0.1 ft). At 0.5 m the footings get through (1.0 ft). That margin was found on this one wall and says nothing about bushes closer than 0.4 m. Nine rows instead of three changed nothing here, because the hidden patch touches the bottom row. It would matter for something mounted between rows. +- **LiDAR's reach costs coverage.** iPhone LiDAR reads to about 5 m; photos 5 to 6 m out then earn nothing, which misses 7.8 ft on these DSLR photos. At the app's 2.6 m standoff that limit would rarely bind. +- **Ground band: still untested.** Every option keeps the 65° gate, so none claims ground from these photos. None of the datasets on disk can test it: ADVIO and MARViN have no surface truth, and facade's photos stand even farther back. ETH3D's other outdoor scenes carry the same laser truth, at 0.3 to 0.7 GB each with images (courtyard, delivery_area, meadow, playground, terrace). Whether their photographers stood within 3.2 m of a wall can only be read after downloading, so I did not. + +**Recommendation.** Stay with "unseen is not clear" and make occlusion someone's job, because no rule about how the phone moved can stand in for it: +- **LiDAR phones:** a depth test in `sees` (`CoverageMap.swift` lines 241 to 248), with rows closer than 0.99 m and an allowance for wall relief. The allowance needs field data before a number ships. +- **Phones without LiDAR:** the app cannot know what hides the wall. Either the homeowner confirms nothing stands in front of each claimed stretch (for example, by marking obstacles on a still), or checks that need clear ground treat claims from those phones as unconfirmed. + +Tightening the baseline or angle would only ask for more photos: 6 to 8.5 extra feet of wall here, with no guarantee that the hidden patch goes. + +### 7c. The app's 3D map (Map3D) against the same truth (ETH3D electro) + +[results/map3d.md](results/map3d.md), `make map3d`. + +**Question.** The app's live 3D map (`ios/HouseScanKit/Sources/HouseScanKit/Map3D`, t3/ios-map3d at 66cdcba, draft PR #21) is to replace `CoverageMap` on every phone. Does it claim wall, ground, facing or overhead space no photo saw? + +**Method.** +- **The app's code, unmodified**, checked out read-only and driven by `map3d_driver/`: `Map3D.integrate(DepthFrame)` per photo, then `coverage(along:)` and `measuredWalls()`, default `Map3DConfig`. +- **The same wall as section 7:** the map frame and the `WallFrame` coverage is read along are section 7's (meter, outward normal, ground height). +- **LiDAR path:** one depth frame per photo, 256 x 192, rendered from the laser scan at the photo's pose (nearest scan depth along -z; 0 where nothing was scanned or ETH3D masks an object the scanner missed), with no confidence. +- **Non-LiDAR path: not tested.** It needs ARKit's feature points and detected planes. COLMAP's tracks on 24-megapixel DSLR photos are far denser and more exact than ARKit's feature points, and ARKit's plane detector can't be reproduced from them without guessing. +- **Truth.** Wall and ground: section 7's laser-scan visibility, on the same wall face and ground band (1.2 m, the depth `CoverageMap` claims). Map3D's ground counts as claimed where its reach is at least 1.2 m. Facing and overhead space: a point counts as seen empty when some photo within 5 m frames it and the laser scan's depth at its pixel lies beyond it by more than the section 7 tolerance, max(10 cm, 4%), so the ray passed through it. + +**Pre-registered, before any run.** +- **False-observed** (claimed columns where at least 10 cm of what the band claims was seen by no photo) must be at most **0.5 ft** per band, as in section 7, where `CoverageMap` claimed 1.1 ft of wall. +- **Missed** (wall and ground band seen from two positions 0.25 m apart but not claimed) is reported without a bar. +- **Wall chain:** the measured wall piece through the meter against the laser's wall line (angle, offset at the meter, length), reported without a bar. + +**Results** (ideal depth: exact, dense, no sensor noise). Feet along the wall. + +| Band | Claimed | False-observed | Pass | Missed | +| --- | --- | --- | --- | --- | +| Wall, `CoverageMap` (section 7's 19.3 ft stretch) | 19.3 | 1.1 | no | 0.0 | +| Wall, Map3D, same stretch | 11.1 | 0.0 | yes | 6.9 (6.1 with a 5 m truth) | +| Wall, Map3D, everything it claims (s −9.7 to +21 ft) | 19.4 | 0.0 | yes | 8.9 of 25.2 seen, 5.5 of it on pilaster faces | +| Ground | 0.0 | 0.0 | untested | 18.1 | +| Facing | 0.5 | 0.5 (see below) | unresolved | n/a | +| Overhead | 0.0 | 0.0 | untested | n/a | + +- **Wall: Map3D claims no wall that no photo saw, where `CoverageMap` claimed 1.1 ft.** The 1.1 ft behind the scaffold's footings is left unclaimed, as the depth test of section 7b predicted. +- **The cost is missed wall.** 5.5 ft of the 8.9 ft missed is behind pilasters 0.36 m proud of the wall line. Map3D counts the face only within 0.10 m in front of the line (`faceFront`, `Map3DConfig.swift` line 85 at 66cdcba, used in `Map3DCoverage.swift` lines 73 to 86), so a pilaster hides the wall behind it. That is conservative, not an over-claim. It asks for views that cannot exist, so the gap loop could ask forever for a pilaster-backed stretch. +- **Ground and overhead: untested.** Map3D claims no ground: reach 0 everywhere, the same grazing-view limit as section 7. +- **Facing: unresolved.** Map3D's one claim is a single 6 in cell seen clear 0.1 m out from a door face. By the pre-registered metric it reads 0.52 ft, just over the bar. The truth, however, cannot resolve space that close to a surface: it needs the laser surface to lie beyond a point by 4% of the distance, 12 to 20 cm here. So the claim is neither confirmed nor refuted, and a 0.1 m reach settles no check (the server needs more than 4.83 ft). +- **Wall chain:** one piece through the meter, 28.4 ft long, where the laser's wall face (pilasters included) runs 30.8 ft. The left end is 0.3 ft short and the right 2.2 ft short. The line is 1.76° off the laser face and 4.8 in outside it at the meter, more than the server's 3.6 in default meter error. The cause is not established. +- **Not measured:** the non-LiDAR path, real LiDAR noise and dropouts, and coverage along Map3D's own chain instead of section 7's wall line. An independent review of the method also flagged a rule to check: `VoxelGrid.swift` lines 376 to 378 and 409 to 412 at 66cdcba take the nearest view distance and the best view angle from possibly different views, so a surface can pass the 5 m and 65° tests with no single view meeting both. It caused no false-observed wall here. + +## Replay session from a real walk + +`~/house-scanning-data/replays/advio-20-0040-0075.zip` holds 35 s of ADVIO walk 20 (seconds 40 to 75, a path past a brick building) in Measure Lab session format v2, for the app's replay mode. It has 79 keyframes chosen by Measure Lab's rule (0.5 m or 15° since the last), plus `ground_truth.json` with ADVIO's pose for each keyframe. `make replay` rebuilds and checks it. + +- **Images.** `frames.mov` stores unrotated 1280 × 720 sensor frames behind a portrait display tag. They are decoded by index, since seeking by time lands frames off, and undistorted with ADVIO's calibration. +- **Intrinsics.** `[1082.1, 1081.1, 641.29, 359.91]`, from the portrait OpenCV calibration rotated to landscape and shifted half a pixel (`evals/camera.py`). +- **Poses.** `arkit.csv` orientations are in the portrait device frame. The converter turns them a quarter turn about the viewing axis. Image-derived rotations agree with this reading to a median 0.22° (alternatives: 4.5° to 6.5°). +- **Check.** `check_replay` reads only the session files and measures matched features' distance from the epipolar lines the poses predict: median 3.1 px, against 61 px with the axes deliberately wrong. +- **Missing from ADVIO.** No tracking state (written as `normal` once ARKit reports a position), no capture time (`startedAt` is the publication date), no taps or measurements. The camera points along the path, not at a wall. diff --git a/experiments/evals/coverage_driver/.gitignore b/experiments/evals/coverage_driver/.gitignore new file mode 100644 index 000000000..30bcfa4ed --- /dev/null +++ b/experiments/evals/coverage_driver/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/experiments/evals/coverage_driver/Package.swift b/experiments/evals/coverage_driver/Package.swift new file mode 100644 index 000000000..ead0b7ec9 --- /dev/null +++ b/experiments/evals/coverage_driver/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.2 +// Runs the app's coverage code (HouseScanKit, a plain Swift package) on cameras given as JSON, for +// evals/coverage.py. HouseScanKit is used read-only from a checkout at a pinned commit; its path +// comes from HOUSESCANKIT_DIR, which `make coverage` sets. +import Foundation +import PackageDescription + +guard let kit = ProcessInfo.processInfo.environment["HOUSESCANKIT_DIR"] else { + fatalError("Set HOUSESCANKIT_DIR to ios/HouseScanKit in a checkout of the pinned commit (see evals/coverage.py)") +} + +let package = Package( + name: "CoverageDriver", + platforms: [.macOS(.v26)], + dependencies: [.package(path: kit)], + targets: [ + .executableTarget(name: "coverage-driver", dependencies: [.product(name: "HouseScanKit", package: "HouseScanKit")]), + ], + swiftLanguageModes: [.v6] +) diff --git a/experiments/evals/coverage_driver/Sources/coverage-driver/main.swift b/experiments/evals/coverage_driver/Sources/coverage-driver/main.swift new file mode 100644 index 000000000..9618fbc32 --- /dev/null +++ b/experiments/evals/coverage_driver/Sources/coverage-driver/main.swift @@ -0,0 +1,228 @@ +// Reads one wall and a list of keyframes (JSON on stdin), feeds every keyframe to the app's +// CoverageMap in order, and writes what the app would export as observed (JSON on stdout), plus +// each keyframe's sightings so the caller can attribute errors to the frames the app credited. +// Without a wall it writes only the config. +// +// Options for occlusion (README section 7b), modelled here so HouseScanKit stays unedited: +// - `variant.coveringBaseline` and `variant.rowsPerBand` set the app's own CoverageConfig fields. +// - `hidden` removes rows from a keyframe's sightings before the app's `record`: a depth test that +// `sees` (CoverageMap.swift lines 241-248) would make, computed by the caller. +// - `variant.minAngleDeg` feeds the same sightings to `DiverseCoverage`, a copy of `record` +// (lines 171-195) whose acceptance predicate (lines 180-181) also demands that two views' +// directions to the row's centre differ by that angle. Its answer is `diverseCovered`; at 0 +// degrees it must equal the app's `covered`. +import Foundation +import HouseScanKit +import simd + +struct Input: Decodable { + struct Wall: Decodable { + let meter: [Float] + let outward: [Float] + let groundY: Float + } + + struct Keyframe: Decodable { + let id: String + /// Camera-to-world, 16 numbers column by column, ARKit camera axes. + let pose: [Float] + /// fx, fy, cx, cy in pixels of the unrotated image; (0, 0) is its top-left corner. + let intrinsics: [Float] + let size: [Float] + } + + struct Variant: Decodable { + let coveringBaseline: Float? + let rowsPerBand: Int? + let minAngleDeg: Float? + } + + struct Hidden: Decodable { + let keyframe: String + let band: String + let index: Int + let rows: [Int] + } + + let wall: Wall? + let variant: Variant? + let hidden: [Hidden]? + let leftEnd: Float? + let rightEnd: Float? + let keyframes: [Keyframe]? +} + +struct Output: Encodable { + struct Sighting: Encodable { + let keyframe: String + let band: String + let index: Int + let rows: [Int] + } + + let config: [String: Float] + let covered: [String: [[Float]]]? + let diverseCovered: [String: [[Float]]]? + let sightings: [Sighting]? +} + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("coverage-driver: \(message)\n".utf8)) + exit(1) +} + +func vector(_ v: [Float], _ name: String) -> SIMD3 { + guard v.count == 3 else { fail("\(name) has \(v.count) numbers, expected 3") } + return SIMD3(v[0], v[1], v[2]) +} + +let input: Input +do { + input = try JSONDecoder().decode(Input.self, from: FileHandle.standardInput.readDataToEndOfFile()) +} catch { + fail("bad input: \(error)") +} +var config = CoverageConfig() +if let b = input.variant?.coveringBaseline { config.coveringBaseline = b } +if let rows = input.variant?.rowsPerBand { config.rowsPerBand = rows } +let minAngle = (input.variant?.minAngleDeg ?? 0) * .pi / 180 +let configValues: [String: Float] = [ + "cellWidth": config.cellWidth, + "wallBandHeight": config.wallBandHeight, + "groundBandDepth": config.groundBandDepth, + "maxDistance": config.maxDistance, + "maxAngleFromNormal": config.maxAngleFromNormal, + "imageMargin": config.imageMargin, + "rowsPerBand": Float(config.rowsPerBand), + "coveringBaseline": config.coveringBaseline, +] +let encoder = JSONEncoder() +encoder.outputFormatting = [.sortedKeys] + +guard let wallInput = input.wall else { + FileHandle.standardOutput.write(try encoder.encode(Output(config: configValues, covered: nil, diverseCovered: nil, sightings: nil))) + exit(0) +} +guard let wall = WallFrame( + meter: vector(wallInput.meter, "wall.meter"), + outward: vector(wallInput.outward, "wall.outward"), + groundY: wallInput.groundY +) else { fail("wall.outward has no horizontal component") } +guard let leftEnd = input.leftEnd, let rightEnd = input.rightEnd, leftEnd < rightEnd else { + fail("leftEnd and rightEnd are required, left of right") +} + +var map = CoverageMap(wall: wall, config: config) +map.setEnd(.left, at: leftEnd) +map.setEnd(.right, at: rightEnd) + +var hiddenRows: [String: [String: Set]] = [:] +for h in input.hidden ?? [] { + hiddenRows[h.keyframe, default: [:]]["\(h.band):\(h.index)", default: []].formUnion(h.rows) +} +var diverse = DiverseCoverage(map: map, minAngle: minAngle, leftEnd: leftEnd, rightEnd: rightEnd) + +var sightings: [Output.Sighting] = [] +for keyframe in input.keyframes ?? [] { + guard keyframe.intrinsics.count == 4, keyframe.size.count == 2 else { fail("\(keyframe.id): intrinsics or size malformed") } + guard let camera = CameraFrame( + columnMajorPose: keyframe.pose, + intrinsics: SIMD4(keyframe.intrinsics[0], keyframe.intrinsics[1], keyframe.intrinsics[2], keyframe.intrinsics[3]), + imageSize: SIMD2(keyframe.size[0], keyframe.size[1]) + ) else { fail("\(keyframe.id): pose is not 16 finite numbers") } + // Sightings before recording: visibleCells does not depend on what was seen before. + var seen: [CoverageMap.Sighting] = [] + for var s in map.visibleCells(from: camera) { + sightings.append(.init(keyframe: keyframe.id, band: s.band.rawValue, index: s.index, rows: s.rows.sorted())) + s.rows.subtract(hiddenRows[keyframe.id]?["\(s.band.rawValue):\(s.index)"] ?? []) + if !s.rows.isEmpty { seen.append(s) } + } + // What observe(_:trackingNormal: true) does (CoverageMap.swift lines 143-146), with the + // sightings possibly thinned by `hidden`. + map.record(seen, from: camera.position) + diverse.record(seen, from: camera.position) +} + +let output = Output( + config: configValues, + covered: Dictionary(uniqueKeysWithValues: SurfaceBand.allCases.map { band in + (band.rawValue, map.coveredIntervals(band).map { [$0.lowerBound, $0.upperBound] }) + }), + diverseCovered: Dictionary(uniqueKeysWithValues: SurfaceBand.allCases.map { band in + (band.rawValue, diverse.coveredIntervals(band).map { [$0.lowerBound, $0.upperBound] }) + }), + sightings: sightings +) +FileHandle.standardOutput.write(try encoder.encode(output)) + +/// A copy of CoverageMap.record whose acceptance predicate also demands angle diversity: a view +/// joins a row only if it is `coveringBaseline` from every view already held (the app's rule) and +/// its direction to the row's centre differs from theirs by at least `minAngle`. +struct DiverseCoverage { + let map: CoverageMap + let minAngle: Float + let leftEnd: Float + let rightEnd: Float + private var rows: [String: [[SIMD3]]] = [:] + private var covered: [SurfaceBand: Set] = [.wall: [], .ground: []] + + init(map: CoverageMap, minAngle: Float, leftEnd: Float, rightEnd: Float) { + self.map = map + self.minAngle = minAngle + self.leftEnd = leftEnd + self.rightEnd = rightEnd + } + + private func allows(_ index: Int) -> Bool { + let range = map.cellRange(index) + return range.upperBound > leftEnd && range.lowerBound < rightEnd + } + + private func rowCentre(_ band: SurfaceBand, _ index: Int, _ row: Int) -> SIMD3 { + let range = map.cellRange(index) + let s = (range.lowerBound + range.upperBound) / 2 + let offset = map.rowOffsets(band)[row] + return band == .wall ? map.wall.world(s: s, height: offset) : map.wall.world(s: s, height: 0, out: offset) + } + + private func angle(_ a: SIMD3, _ b: SIMD3) -> Float { + acos(min(1, max(-1, simd_dot(simd_normalize(a), simd_normalize(b))))) + } + + mutating func record(_ sightings: [CoverageMap.Sighting], from position: SIMD3) { + let rowCount = map.config.rowsPerBand + for sighting in sightings where allows(sighting.index) { + let key = "\(sighting.band.rawValue):\(sighting.index)" + var cell = rows[key] ?? Array(repeating: [], count: rowCount) + for row in sighting.rows where cell.indices.contains(row) { + let centre = rowCentre(sighting.band, sighting.index, row) + let held = cell[row] + guard held.count < 2, held.allSatisfy({ + simd_distance($0, position) >= map.config.coveringBaseline + && angle($0 - centre, position - centre) >= minAngle + }) else { continue } + cell[row].append(position) + } + rows[key] = cell + if cell.allSatisfy({ $0.count >= 2 }) { covered[sighting.band]?.insert(sighting.index) } + } + } + + /// Covered cells merged into stretches and clipped to the marked ends, as coveredIntervals does. + func coveredIntervals(_ band: SurfaceBand) -> [ClosedRange] { + var runs: [ClosedRange] = [] + var last: Int? + for index in (covered[band] ?? []).sorted() { + let range = map.cellRange(index) + if let l = last, l == index - 1, let run = runs.last { + runs[runs.count - 1] = run.lowerBound...range.upperBound + } else { + runs.append(range) + } + last = index + } + if let first = runs.first { runs[0] = max(first.lowerBound, leftEnd)...first.upperBound } + if let end = runs.last { runs[runs.count - 1] = end.lowerBound...min(end.upperBound, rightEnd) } + return runs + } +} diff --git a/experiments/evals/evals/__init__.py b/experiments/evals/evals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/experiments/evals/evals/advio.py b/experiments/evals/evals/advio.py new file mode 100644 index 000000000..4c32da9d9 --- /dev/null +++ b/experiments/evals/evals/advio.py @@ -0,0 +1,209 @@ +"""Read one ADVIO sequence (Cortés et al., ECCV 2018): iPhone video frames, ARKit poses, ground truth. + +Layout of an extracted `advio-XX/` folder, as the dataset README and its own scripts read it: + +- `iphone/frames.mov`: 60 fps, coded as 1280 x 720 landscape frames with a -90 degree display tag + (so players show it portrait). `iphone/frames.csv`: `t, frame_number`, numbered from 1 (video + frame `n - 1`), same timestamps as arkit.csv. +- `iphone/arkit.csv`: ARKit pose per video frame, `t, x, y, z, qw, qx, qy, qz`, orientation in the + portrait device frame (verified against the images; see `evals/camera.py`). +- `ground-truth/pose.csv`: 100 Hz, same columns. `ground-truth/fixpoints.csv`: manual fixes. + +All timestamps share one clock (seconds). The calibration is per recording batch and lives in the +dataset's GitHub repo, not in the zip; `_BATCHES` copies it with its source URL. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +from evals.geometry import quat_wxyz_to_matrix + + +@dataclass(frozen=True) +class Calibration: + """Pinhole + radial-tangential model of the portrait (720 x 1280) video, OpenCV convention: + pixel centers at integer coordinates, +x right, +y down, camera looks along +z.""" + + width: int + height: int + fx: float + fy: float + cx: float + cy: float + k1: float + k2: float + p1: float + p2: float + source: str + + def camera_matrix(self) -> np.ndarray: + return np.array([[self.fx, 0, self.cx], [0, self.fy, self.cy], [0, 0, 1]], dtype=np.float64) + + def distortion(self) -> np.ndarray: + return np.array([self.k1, self.k2, self.p1, self.p2], dtype=np.float64) + + +_CAL_URL = "https://raw.githubusercontent.com/AaltoVision/ADVIO/master/calibration/" + +# The iPhone camera was calibrated once per recording batch (calibration/README.md in the ADVIO repo). +_BATCHES = [ + ( + range(1, 13), + Calibration( + 720, + 1280, + 1077.2, + 1079.3, + 362.145, + 636.3873, + 0.0478, + 0.0339, + -0.00033, + -0.00091, + _CAL_URL + "iphone-02.yaml", + ), + ), + ( + range(13, 18), + Calibration( + 720, + 1280, + 1082.4, + 1084.4, + 364.6778, + 643.3080, + 0.0366, + 0.0803, + 0.000783, + -0.000215, + _CAL_URL + "iphone-03.yaml", + ), + ), + ( + range(18, 20), + Calibration( + 720, + 1280, + 1076.9, + 1078.5, + 360.96, + 619.31, + 0.0510, + -0.0354, + -0.0054, + 0.0473, + _CAL_URL + "iphone-01.yaml", + ), + ), + ( + range(20, 24), + Calibration( + 720, + 1280, + 1081.1, + 1082.1, + 359.59, + 640.79, + 0.0556, + -0.0454, + 0.0009, + -0.0018, + _CAL_URL + "iphone-04.yaml", + ), + ), +] + + +def calibration_for(sequence: int) -> Calibration: + for seqs, cal in _BATCHES: + if sequence in seqs: + return cal + raise ValueError(f"ADVIO has sequences 1 to 23; got {sequence}") + + +@dataclass(frozen=True) +class PoseTrack: + """Timestamps (N,), positions (N, 3) and rotation matrices (N, 3, 3), body-to-world.""" + + t: np.ndarray + p: np.ndarray + R: np.ndarray + + def __len__(self) -> int: + return len(self.t) + + def interpolate_positions(self, times: np.ndarray) -> np.ndarray: + """Linear interpolation of position; raises if any time is outside the track.""" + if times.min() < self.t[0] or times.max() > self.t[-1]: + raise ValueError( + f"times {times.min():.3f}..{times.max():.3f} outside track " + f"{self.t[0]:.3f}..{self.t[-1]:.3f}" + ) + return np.stack([np.interp(times, self.t, self.p[:, k]) for k in range(3)], axis=1) + + def nearest(self, time: float) -> int: + i = int(np.searchsorted(self.t, time)) + if i == 0: + return 0 + if i >= len(self.t): + return len(self.t) - 1 + return i if abs(self.t[i] - time) < abs(self.t[i - 1] - time) else i - 1 + + +def _read_pose_csv(path: Path) -> PoseTrack: + data = np.loadtxt(path, delimiter=",", ndmin=2) + if data.shape[1] != 8: + raise ValueError( + f"{path}: expected 8 columns (t, x, y, z, qw, qx, qy, qz), got {data.shape[1]}" + ) + order = np.argsort(data[:, 0], kind="stable") + data = data[order] + q = data[:, 4:8] + norms = np.linalg.norm(q, axis=1) + # Dropping bad rows would shift every later row against frames.csv, so refuse instead. No + # ADVIO pose file used here has one. + bad = np.flatnonzero(np.abs(norms - 1) >= 1e-2) + if len(bad): + raise ValueError( + f"{path}: {len(bad)} quaternions are not unit length (first at row {bad[0]}, norm " + f"{norms[bad[0]]:.4f})" + ) + return PoseTrack(t=data[:, 0], p=data[:, 1:4], R=quat_wxyz_to_matrix(data[:, 4:8])) + + +@dataclass(frozen=True) +class Sequence: + number: int + root: Path + frame_times: np.ndarray + arkit: PoseTrack + ground_truth: PoseTrack + calibration: Calibration + + @property + def video(self) -> Path: + return self.root / "iphone" / "frames.mov" + + +def load_sequence(root: Path) -> Sequence: + root = Path(root) + number = int(root.name.split("-")[-1]) + frames = np.loadtxt(root / "iphone" / "frames.csv", delimiter=",", ndmin=2) + frame_idx = frames[:, 1].astype(int) + if not np.array_equal(frame_idx, np.arange(1, len(frames) + 1)): + raise ValueError(f"{root}: frames.csv frame numbers are not 1..N in order") + gt_path = root / "ground-truth" / "pose.csv" + if not gt_path.exists(): + gt_path = root / "ground-truth" / "poses.csv" + return Sequence( + number=number, + root=root, + frame_times=frames[:, 0], + arkit=_read_pose_csv(root / "iphone" / "arkit.csv"), + ground_truth=_read_pose_csv(gt_path), + calibration=calibration_for(number), + ) diff --git a/experiments/evals/evals/ar_poses.py b/experiments/evals/evals/ar_poses.py new file mode 100644 index 000000000..3cd6e2546 --- /dev/null +++ b/experiments/evals/evals/ar_poses.py @@ -0,0 +1,111 @@ +"""ETH3D's true camera poses degraded to what a phone's AR tracking would report. + +Each setting is an assumption, stated with where its numbers come from: + +- `exact`: the true poses, the best any pose prior can do. +- `advio_2018`: the errors measured in `evals.drift` on ADVIO's 2018 iPhone 6s. Scale: each + group of photos takes one walk's ARKit scale against the ground truth rescaled to GPS, in turn + (`arkit_scale_vs_truth_gps` in results/advio_drift.json, walks 20 to 22: 0.838, 0.943, 0.951; + the median displacement ratio over 10 to 30 ft windows). ARCore is not the reference: on walk + 20 it reads 4% short of GPS itself (0.802 vs 0.835 of the truth). Position noise: ARKit's own + spread over 3 ft is about 3 in (three-cornered hat, walks 20 to 22), which is two independent + position errors, so 5 cm per axis per camera. Rotation noise: 0.2 degrees per axis, the median + disagreement between ARKit and image-derived rotations measured while building the replay + session (an upper bound: it includes the image estimate's own error). +- `modern_assumed`: a guess for a current iPhone, with no measurement behind it: 2% short, 1 cm + and 0.1 degrees. + +Scale error stretches every camera's offset from the group's first camera; noise is then added +independently per camera. Rotation noise turns each camera about its own centre. Each setting with +noise is drawn `NOISE_DRAWS` times (draw 0 is the one MapAnything was run on); the scale a group +gets does not change between draws. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class PoseError: + scales: tuple[float, ...] # one per group, taken in turn + position_sigma_m: float + rotation_sigma_deg: float + # How far a correct match may reproject with these poses: 2 px of feature noise plus what the + # pose noise moves a point 5 m away at the 1024 px focal length (about 560 px): 0.2 degrees is + # 2 px, 5 cm is 6 px. A tighter threshold discards consistent matches and the photo's scale. + reprojection_px: float + + +# ARKit / GPS-rescaled truth per ADVIO walk (20, 21, 22), from results/advio_drift.json; +# tests/test_pose_priors.py fails if that file changes. +ADVIO_ARKIT_SCALES = (0.8377, 0.9425, 0.9509) + +SETTINGS = { + "exact": PoseError((1.0,), 0.0, 0.0, 2.0), + "advio_2018": PoseError(ADVIO_ARKIT_SCALES, 0.05, 0.2, 8.0), + "modern_assumed": PoseError((0.98,), 0.01, 0.1, 3.0), +} +NOISE_DRAWS = 5 + + +def noise_draws(setting: str) -> range: + """Draw indices for a setting: one for noise-free poses (every draw would be identical).""" + error = SETTINGS[setting] + noisy = error.position_sigma_m > 0 or error.rotation_sigma_deg > 0 + return range(NOISE_DRAWS if noisy else 1) + + +def small_rotation(axis_angle_rad: np.ndarray) -> np.ndarray: + """Rotation matrix for a rotation vector (Rodrigues).""" + theta = float(np.linalg.norm(axis_angle_rad)) + if theta == 0: + return np.eye(3) + k = axis_angle_rad / theta + K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]]) + return np.eye(3) + np.sin(theta) * K + (1 - np.cos(theta)) * K @ K + + +def degrade( + cam_to_world: list[np.ndarray], scale: float, error: PoseError, rng: np.random.Generator +) -> list[np.ndarray]: + """AR-like poses for one group: offsets from the first camera scaled, then noise added.""" + origin = cam_to_world[0][:3, 3] + out = [] + for T in cam_to_world: + D = np.eye(4) + D[:3, 3] = origin + scale * (T[:3, 3] - origin) + D[:3, 3] += rng.normal(0.0, error.position_sigma_m, 3) + noise = small_rotation(np.radians(rng.normal(0.0, error.rotation_sigma_deg, 3))) + D[:3, :3] = T[:3, :3] @ noise + out.append(D) + return out + + +def group_poses( + views: dict, groups: dict[str, list[list[str]]], setting: str, draw: int = 0 +) -> dict[str, dict]: + """{group id: {"scale": applied scale, "poses": {view: 4x4 cam-to-world}}} for every group, + deterministic per setting and draw. Draw 0 keeps the generator the first version used, so it + reproduces the poses MapAnything was given.""" + error = SETTINGS[setting] + rng = np.random.default_rng(_seed(setting) if draw == 0 else [_seed(setting), draw]) + out: dict[str, dict] = {} + k = 0 + for n, members_list in groups.items(): + for members in members_list: + scale = error.scales[k % len(error.scales)] + k += 1 + poses = degrade([views[m].cam_to_world for m in members], scale, error, rng) + out[f"n{n}-{members[0]}"] = { + "scale": scale, + "poses": {m: T.tolist() for m, T in zip(members, poses, strict=True)}, + } + return out + + +def _seed(setting: str) -> int: + """A fixed seed per setting (Python's hash() is salted per process, so it is not used).""" + return sum(ord(c) * 31**i for i, c in enumerate(setting)) % 2**32 diff --git a/experiments/evals/evals/camera.py b/experiments/evals/evals/camera.py new file mode 100644 index 000000000..17f16d6af --- /dev/null +++ b/experiments/evals/evals/camera.py @@ -0,0 +1,62 @@ +"""Camera conventions: ADVIO's portrait calibration and poses to Measure Lab's landscape sensor frame. + +Measure Lab session format v2 (experiments/measure-lab/README.md on t3/measure-lab): +- camera frame: +x right and +y up in the unrotated landscape sensor image, looking along -z; +- intrinsics [fx, fy, cx, cy] in continuous pixel coordinates of that JPEG, (0, 0) its top-left corner. + +ADVIO stores the video as landscape frames with a -90 degree display tag, so the coded frames are +the unrotated sensor images. Its calibration was made on the portrait (displayed) frames with +OpenCV's convention (pixel centers at integers, +y down, looking along +z), and its ARKit and +ground-truth orientations are in the portrait device frame (+x right, +y up on the portrait screen, ++z out of the screen). `check_relative_rotations` verifies that reading against the images. +""" + +from __future__ import annotations + +import numpy as np + +# Columns are the Measure Lab camera axes written in the portrait device frame: +# landscape image right = portrait screen down (-y), landscape image up = portrait right (+x), +# and both look along -z. +DEVICE_TO_LANDSCAPE_CAMERA = np.array( + [ + [0.0, 1.0, 0.0], + [-1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ] +) + +# Measure Lab camera (+y up, looks along -z) to OpenCV camera (+y down, looks along +z) and back. +FLIP_YZ = np.diag([1.0, -1.0, -1.0]) + + +def landscape_intrinsics_from_portrait( + fx: float, fy: float, cx: float, cy: float, portrait_width: int +) -> tuple[float, float, float, float]: + """Portrait OpenCV intrinsics to landscape continuous-pixel intrinsics. + + The landscape sensor image is the portrait image rotated 90 degrees counter-clockwise, so in + continuous coordinates u_l = v_p and v_l = W_p - u_p. OpenCV places pixel centers at integers, + so continuous = OpenCV + 0.5. + """ + cx_c, cy_c = cx + 0.5, cy + 0.5 + return fy, fx, cy_c, portrait_width - cx_c + + +def landscape_pose(position: np.ndarray, R_device: np.ndarray) -> np.ndarray: + """4x4 camera-to-world pose in Measure Lab's camera convention from a portrait-device pose.""" + T = np.eye(4) + T[:3, :3] = R_device @ DEVICE_TO_LANDSCAPE_CAMERA + T[:3, 3] = position + return T + + +def column_major(T: np.ndarray) -> list[float]: + """16 numbers column by column (simd_float4x4 layout).""" + return [float(v) for v in np.asarray(T).T.reshape(-1)] + + +def relative_rotation_cv(R_i: np.ndarray, R_j: np.ndarray) -> np.ndarray: + """Rotation taking OpenCV camera-i coordinates to camera-j coordinates, from two Measure Lab + camera-to-world rotations. X_j = R_ij X_i + t_ij.""" + return FLIP_YZ @ R_j.T @ R_i @ FLIP_YZ diff --git a/experiments/evals/evals/check_replay.py b/experiments/evals/evals/check_replay.py new file mode 100644 index 000000000..f92eaf867 --- /dev/null +++ b/experiments/evals/evals/check_replay.py @@ -0,0 +1,105 @@ +"""Check a Measure Lab session the way a consumer reads it: from session.json and the JPEGs only. + + uv run python -m evals.check_replay ~/house-scanning-data/replays/advio-20-0040-0075 + +For pairs of nearby keyframes it matches ORB features and measures how far each match lies from +the epipolar line predicted by the two poses and intrinsics under the documented conventions. If +the pose, intrinsics or pixel convention were wrong (a swapped axis, a transposed rotation, a +portrait intrinsic on a landscape image), the median distance would be tens of pixels. +Also reports the sign conventions directly: world up in each camera should point up in the image +for a phone held upright in landscape, or left for one held in portrait (as ADVIO's was). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import cv2 +import numpy as np + +from evals.camera import FLIP_YZ + + +def _pose(kf: dict) -> np.ndarray: + return np.array(kf["pose"], dtype=np.float64).reshape(4, 4).T + + +def _K_opencv(kf: dict) -> np.ndarray: + fx, fy, cx, cy = kf["intrinsics"] + # Continuous pixel coordinates to OpenCV's integer-centred ones. + return np.array([[fx, 0, cx - 0.5], [0, fy, cy - 0.5], [0, 0, 1]]) + + +def fundamental(kf_a: dict, kf_b: dict) -> np.ndarray: + Ta, Tb = _pose(kf_a), _pose(kf_b) + # OpenCV camera b from OpenCV camera a. + R = FLIP_YZ @ Tb[:3, :3].T @ Ta[:3, :3] @ FLIP_YZ + t = FLIP_YZ @ Tb[:3, :3].T @ (Ta[:3, 3] - Tb[:3, 3]) + tx = np.array([[0, -t[2], t[1]], [t[2], 0, -t[0]], [-t[1], t[0], 0]]) + E = tx @ R + return np.linalg.inv(_K_opencv(kf_b)).T @ E @ np.linalg.inv(_K_opencv(kf_a)) + + +def epipolar_distances(F: np.ndarray, pa: np.ndarray, pb: np.ndarray) -> np.ndarray: + """Symmetric point-to-epipolar-line distance, pixels.""" + ha = np.c_[pa, np.ones(len(pa))] + hb = np.c_[pb, np.ones(len(pb))] + lb = ha @ F.T + la = hb @ F + db = np.abs((hb * lb).sum(1)) / np.linalg.norm(lb[:, :2], axis=1) + da = np.abs((ha * la).sum(1)) / np.linalg.norm(la[:, :2], axis=1) + return (da + db) / 2 + + +def check(folder: Path, gap: int = 2) -> dict: + manifest = json.loads((folder / "session.json").read_text()) + kfs = manifest["keyframes"] + orb = cv2.ORB_create(3000) + matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + medians, ups = [], [] + for a, b in zip(kfs[:-gap], kfs[gap:], strict=True): + ia = cv2.imread(str(folder / a["img"]), cv2.IMREAD_GRAYSCALE) + ib = cv2.imread(str(folder / b["img"]), cv2.IMREAD_GRAYSCALE) + if ia.shape != (a["h"], a["w"]): + raise ValueError(f"{a['img']} is {ia.shape}, manifest says {a['h']}x{a['w']}") + ka, da = orb.detectAndCompute(ia, None) + kb, db = orb.detectAndCompute(ib, None) + if da is None or db is None: + continue + m = matcher.match(da, db) + if len(m) < 30: + continue + pa = np.float32([ka[x.queryIdx].pt for x in m]) + pb = np.float32([kb[x.trainIdx].pt for x in m]) + # Keep geometrically consistent matches by an image-only fit, then score them against the + # pose-predicted geometry, so bad matches don't inflate the number. + _, inl = cv2.findFundamentalMat(pa, pb, cv2.FM_RANSAC, 1.5, 0.999) + if inl is None or inl.sum() < 20: + continue + keep = inl.ravel().astype(bool) + d = epipolar_distances(fundamental(a, b), pa[keep], pb[keep]) + medians.append(float(np.median(d))) + up_cam = _pose(a)[:3, :3].T @ np.array([0.0, 1.0, 0.0]) + ups.append(up_cam) + if not medians: + raise ValueError( + f"{folder}: no keyframe pair had enough matches and inliers to check the poses" + ) + up = np.mean(ups, axis=0) + return { + "pairs": len(medians), + "median_epipolar_px": float(np.median(medians)), + "p90_epipolar_px": float(np.percentile(medians, 90)), + "world_up_in_camera": [round(float(v), 2) for v in up], + } + + +def main() -> None: + folder = Path(sys.argv[1]).expanduser() + print(json.dumps(check(folder), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/coverage.py b/experiments/evals/evals/coverage.py new file mode 100644 index 000000000..433a6d9c3 --- /dev/null +++ b/experiments/evals/evals/coverage.py @@ -0,0 +1,882 @@ +"""Does the app's coverage map claim wall and ground that no photo saw? (README section 7) + +The app exports as observed the stretches `CoverageMap.coveredIntervals` returns (`ios/HouseScanKit` +on t3/ios-mvf), and the server passes a check only over them. This runs that code, unmodified, on +ETH3D's photos and true poses (`coverage_driver/`, a Swift executable built against a read-only +checkout of HouseScanKit at `KIT_COMMIT`), and compares its answer with what each photo saw, using +the laser scan's depth for occlusion. + +Frames. ETH3D's world is levelled on the ground plane under the cameras (ARKit's +y is up). Poses go +to the app in ARKit's camera axes (+x right, +y up, looking along -z), intrinsics unrotated, with +(0, 0) at the image's top-left corner, the convention of `CameraFrame`. + +The truth, the pass criteria and the cause labels are defined in README section 7. The only copy of +app logic here is `app_rows`, a replica of `CoverageMap.visibleRows` used to name why the app turned +a photo down; `main` refuses to report unless it reproduces the app's own sightings exactly. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np +from scipy.spatial import cKDTree + +from evals.eth3d import ( + View, + excluded_pixels, + occluder_points, + read_views, + scan_points, + up_direction, +) +from evals.paths import ETH3D_DIR, EVALS_DIR + +KIT_COMMIT = "beede15f568b3a4d694fb275caf9eaaa882c546b" +KIT_CHECKOUT = EVALS_DIR / f"housescankit-{KIT_COMMIT[:7]}" +KIT_DIR = KIT_CHECKOUT / "ios" / "HouseScanKit" +DRIVER_DIR = Path(__file__).resolve().parents[1] / "coverage_driver" +DRIVER_BIN = DRIVER_DIR / ".build" / "release" / "coverage-driver" +RESULTS = Path(__file__).resolve().parents[1] / "results" + +FEET = 0.3048 +UP = np.array([0.0, 1.0, 0.0]) +COLUMN_M = 0.02 # truth column width along the wall +SAMPLE_M = 0.05 # truth sample spacing up the wall and out across the ground +UNSEEN_MIN_SAMPLES = 2 # a column is unseen when at least this many samples (about 10 cm) are +PASS_FT = 0.5 # false-observed length allowed per scene and band: one 6 in cell +# A scan surface hides a sample when it is this much nearer. The floor covers the wall's own +# roughness; the relative part covers the 5-cell minimum filter on oblique views (at 6 m and 75 +# degrees, neighbouring cells of the wall differ by up to about 16 cm). +HIDE_ABS_M = 0.10 +HIDE_REL = 0.04 +ZBUF_WIDTH = 512 +ZBUF_WINDOW = 5 +MASK_WIDTH = 1024 +MISSING_FROM_SCAN = 2 # ETH3D mask label: objects the scanner missed, such as people and trees +METER_HEIGHT_M = 1.5 +WALL_BAND_M = (0.3, 2.0) # heights above ground used to find walls in the scan +MIN_STRETCH_M = 2.0 + + +def rotation_to_y(up: np.ndarray) -> np.ndarray: + """The smallest rotation taking the unit vector `up` to +y (Rodrigues).""" + up = up / np.linalg.norm(up) + v = np.cross(up, UP) + s, c = np.linalg.norm(v), float(up @ UP) + if s < 1e-12: + if c > 0: + return np.eye(3) + raise ValueError("up points straight down") + 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) + + +def level_world(views: list[View], scan: np.ndarray) -> tuple[np.ndarray, float, float]: + """Rotation into a world whose +y is up, the ground's height in it, and the ground fit's RMS. + + Up is the normal of a plane through the ground under each camera (the lower half of the scan + points 1 to 2.5 m below it, within 0.7 m). The mean of the cameras' own up axes, the first guess, + was 10 degrees off on facade. + """ + guess = up_direction(views) + 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) + if len(feet) < 10: + raise ValueError(f"ground found under only {len(feet)} cameras") + centre = feet.mean(axis=0) + normal = np.linalg.svd(feet - centre)[2][2] + normal *= np.sign(normal @ guess) + residual = (feet - centre) @ normal + R = rotation_to_y(normal) + return R, float((R @ centre)[1]), float(np.sqrt(np.mean(residual**2))) + + +@dataclass(frozen=True) +class Wall: + """The app's WallFrame (WallFrame.swift), plus the marked ends, in the levelled world.""" + + meter: np.ndarray + outward: np.ndarray # horizontal unit vector + ground_y: float + left: float # s of the marked ends, meters from the meter + right: float + fit_rms_m: float # RMS distance from the plane of wall-band scan points within 10 cm of it + + @property + def along(self) -> np.ndarray: + a = np.cross(-self.outward, UP) + return a / np.linalg.norm(a) + + @property + def origin(self) -> np.ndarray: + return np.array([self.meter[0], self.ground_y, self.meter[2]]) + + def world(self, s: np.ndarray, height: np.ndarray, out: np.ndarray | float = 0.0) -> np.ndarray: + s, height, out = np.broadcast_arrays(s, height, out) + return ( + self.origin + + s[..., None] * self.along + + out[..., None] * self.outward + + height[..., None] * UP + ) + + def out_of(self, points: np.ndarray) -> np.ndarray: + return (points - self.origin) @ self.outward + + def s_of(self, points: np.ndarray) -> np.ndarray: + return (points - self.origin) @ self.along + + +def straight_runs(t: np.ndarray, gap: float, min_length: float) -> list[tuple[float, float]]: + """Stretches of sorted positions `t` with no gap wider than `gap`, at least `min_length` long.""" + t = np.sort(t) + breaks = np.flatnonzero(np.diff(t) > gap) + starts = np.r_[0, breaks + 1] + ends = np.r_[breaks, len(t) - 1] + return [(t[i], t[j]) for i, j in zip(starts, ends, strict=True) if t[j] - t[i] >= min_length] + + +def wall_lines(plan: np.ndarray, rng: np.random.Generator, tol: float = 0.06) -> list[dict]: + """Straight wall stretches in plan points (N, 2): repeated RANSAC line fits, each split where + the scan has a gap over 1 m, keeping stretches of `MIN_STRETCH_M` or more. + + `tol` is 6 cm because electro's longest wall steps by about 10 cm, within what a tapped wall + line is off anyway; at 3 cm it broke into three stretches. Gaps up to 1 m are doors and glass, + which return no laser points but are part of the wall.""" + remaining = plan + lines = [] + for _ in range(20): + if len(remaining) < 200: + break + best = None + for _ in range(400): + 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) < tol + if best is None or inl.sum() > best.sum(): + best = inl + if best is None: + break + pts = remaining[best] + centre = pts.mean(axis=0) + direction = np.linalg.svd(pts - centre)[2][0] + t = (pts - centre) @ direction + for lo, hi in straight_runs(t, 1.0, MIN_STRETCH_M): + lines.append({"centre": centre, "direction": direction, "t": (lo, hi)}) + remaining = remaining[~best] + return lines + + +def to_arkit(view: View, R: np.ndarray) -> tuple[np.ndarray, list[float], list[float]]: + """Camera-to-world pose (levelled world, ARKit camera axes), intrinsics with (0, 0) at the image's + top-left corner, and image size, for `CameraFrame`.""" + pose = np.eye(4) + pose[:3, :3] = R @ view.R_wc.T @ np.diag([1.0, -1.0, -1.0]) + pose[:3, 3] = R @ view.center + K = view.K + # read_views puts pixel centres on integers (OpenCV); CameraFrame's first pixel spans [0, 1]. + intrinsics = [K[0, 0], K[1, 1], K[0, 2] + 0.5, K[1, 2] + 0.5] + return pose, intrinsics, [float(view.width), float(view.height)] + + +def app_pixels(pose: np.ndarray, intrinsics: list[float], points: np.ndarray): + """`CameraFrame.pixel(of:)`: pixel and whether the point is at least 5 cm in front.""" + local = (points - pose[:3, 3]) @ pose[:3, :3] + depth = -local[..., 2] + fx, fy, cx, cy = intrinsics + with np.errstate(divide="ignore", invalid="ignore"): + u = cx + fx * local[..., 0] / depth + v = cy - fy * local[..., 1] / depth + return u, v, depth >= 0.05 + + +def app_gates(pose, intrinsics, size, points, normal, cfg) -> dict[str, np.ndarray]: + """The three per-point tests inside `CoverageMap.visibleRows`, each as a boolean array.""" + to_camera = pose[:3, 3] - points + distance = np.linalg.norm(to_camera, axis=-1) + u, v, front = app_pixels(pose, intrinsics, points) + mx, my = size[0] * cfg["imageMargin"], size[1] * cfg["imageMargin"] + with np.errstate(invalid="ignore"): + framed = front & (u >= mx) & (v >= my) & (u <= size[0] - mx) & (v <= size[1] - my) + facing = (to_camera @ normal) / distance >= np.cos(cfg["maxAngleFromNormal"]) + return { + "range": (distance <= cfg["maxDistance"]) & (distance > 0), + "angle": facing, + "frame": framed, + } + + +def row_offsets(band: str, cfg: dict) -> np.ndarray: + extent = cfg["wallBandHeight"] if band == "wall" else cfg["groundBandDepth"] + count = max(2, int(cfg["rowsPerBand"])) + return extent * np.arange(count) / (count - 1) + + +def row_points(wall: Wall, band: str, index: int, cfg: dict) -> np.ndarray: + """The app's two samples per row of one cell, shape (rows, 2, 3).""" + w = cfg["cellWidth"] + alongs = index * w + w * np.array([0.25, 0.75]) + offs = row_offsets(band, cfg) + s = np.broadcast_to(alongs, (len(offs), 2)) + o = np.broadcast_to(offs[:, None], (len(offs), 2)) + return wall.world(s, o) if band == "wall" else wall.world(s, np.zeros_like(s), o) + + +def band_normal(wall: Wall, band: str) -> np.ndarray: + return wall.outward if band == "wall" else UP + + +def app_rows(wall: Wall, band: str, index: int, cam, cfg: dict) -> tuple[set[int], dict]: + """Replica of `CoverageMap.visibleRows` for one cell: the rows seen, and the per-point gates.""" + pose, intrinsics, size = cam + if wall.out_of(pose[:3, 3]) <= 0: + return set(), {} + gates = app_gates( + pose, intrinsics, size, row_points(wall, band, index, cfg), band_normal(wall, band), cfg + ) + ok = gates["range"] & gates["angle"] & gates["frame"] + return {int(r) for r in np.flatnonzero(ok.all(axis=1))}, gates + + +def cell_index(s: float, cfg: dict) -> int: + return int(np.floor(s / cfg["cellWidth"])) + + +def candidate_cells(wall: Wall, cam, cfg: dict) -> range: + """`CoverageMap.candidateIndices`: cells within maxDistance along the wall, inside the ends.""" + w, tol = cfg["cellWidth"], cfg["cellWidth"] * 1e-3 + s = wall.s_of(cam[0][:3, 3]) + first = cell_index(s - cfg["maxDistance"] + tol, cfg) + last = max(first, cell_index(s + cfg["maxDistance"] - tol, cfg)) + lo = max(first, cell_index(wall.left + tol, cfg)) # cells ending at or left of the end fail + hi = min(last, int(np.ceil(wall.right / w)) - 1) + return range(lo, hi + 1) + + +def replica_sightings(wall: Wall, cams: dict, cfg: dict) -> set[tuple]: + out = set() + for name, cam in cams.items(): + for band in ("wall", "ground"): + for index in candidate_cells(wall, cam, cfg): + rows, _ = app_rows(wall, band, index, cam, cfg) + if rows: + out.add((name, band, index, frozenset(rows))) + return out + + +def call_driver(payload: dict) -> dict: + done = subprocess.run( + [str(DRIVER_BIN)], input=json.dumps(payload), capture_output=True, text=True, check=False + ) + if done.returncode: + raise RuntimeError(f"coverage-driver failed: {done.stderr.strip()}") + return json.loads(done.stdout) + + +def app_config() -> dict: + """The app's default CoverageConfig, as the driver reads it.""" + return call_driver({})["config"] + + +def run_app( + wall: Wall, cams: dict, variant: dict | None = None, hidden: list | None = None +) -> dict: + """Feeds the keyframes, in order, to the app's CoverageMap through the Swift driver. `variant` + and `hidden` model the occlusion options (see the driver's header).""" + payload = { + "wall": { + "meter": wall.meter.tolist(), + "outward": wall.outward.tolist(), + "groundY": wall.ground_y, + }, + "leftEnd": wall.left, + "rightEnd": wall.right, + "keyframes": [ + {"id": n, "pose": c[0].T.ravel().tolist(), "intrinsics": c[1], "size": c[2]} + for n, c in cams.items() + ], + } + if variant: + payload["variant"] = variant + if hidden: + payload["hidden"] = hidden + return call_driver(payload) + + +def prepare() -> None: + """Checks out HouseScanKit at the pinned commit, read-only and outside the repo, and builds the + driver against it.""" + repo = Path(__file__).resolve().parents[3] + if not KIT_CHECKOUT.exists(): + have = subprocess.run( + ["git", "-C", str(repo), "cat-file", "-e", f"{KIT_COMMIT}^{{commit}}"], check=False + ) + if have.returncode: + subprocess.run(["git", "-C", str(repo), "fetch", "origin", KIT_COMMIT], check=True) + subprocess.run( + ["git", "-C", str(repo), "worktree", "add", "--detach", str(KIT_CHECKOUT), KIT_COMMIT], + check=True, + ) + subprocess.run( + ["swift", "build", "-c", "release", "--package-path", str(DRIVER_DIR)], + env={**os.environ, "HOUSESCANKIT_DIR": str(KIT_DIR)}, + check=True, + ) + + +def check_kit() -> None: + """The HouseScanKit checkout must be the pinned commit, unmodified.""" + if not KIT_DIR.is_dir(): + raise FileNotFoundError(f"{KIT_DIR} missing; run `uv run python -m evals.coverage prepare`") + head = subprocess.run( + ["git", "-C", str(KIT_CHECKOUT), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + dirty = subprocess.run( + ["git", "-C", str(KIT_CHECKOUT), "status", "--porcelain", "--", "ios/HouseScanKit"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if head != KIT_COMMIT or dirty: + raise RuntimeError( + f"{KIT_CHECKOUT} is at {head} with changes {dirty!r}; expected {KIT_COMMIT}, clean" + ) + if not DRIVER_BIN.exists(): + raise FileNotFoundError( + f"{DRIVER_BIN} missing; run `uv run python -m evals.coverage prepare`" + ) + + +# Truth + + +def depth_buffer(view: View, blockers: np.ndarray) -> np.ndarray: + """Nearest scan depth per cell of a `ZBUF_WIDTH`-wide grid over the image, holes closed by a + `ZBUF_WINDOW` minimum filter; cells with no scan point read 1e6.""" + zw = ZBUF_WIDTH + zh = round(view.height * zw / view.width) + u, v, z, ok = project(view, blockers) + cu = np.clip((u[ok] * zw / view.width).astype(np.int64), 0, zw - 1) + cv_ = np.clip((v[ok] * zh / view.height).astype(np.int64), 0, zh - 1) + buf = np.full((zh, zw), 1e6, dtype=np.float32) + np.minimum.at(buf, (cv_, cu), z[ok].astype(np.float32)) + return cv2.erode(buf, np.ones((ZBUF_WINDOW, ZBUF_WINDOW), np.uint8)) + + +def project(view: View, points: np.ndarray): + """Pixel (u, v) with (0, 0) at the image's top-left corner, depth, and whether it lands inside + the full image at least 5 cm in front.""" + Xc = points @ view.R_wc.T.astype(points.dtype) + view.t_wc.astype(points.dtype) + z = Xc[..., 2] + with np.errstate(divide="ignore", invalid="ignore"): + u = view.K[0, 0] * Xc[..., 0] / z + view.K[0, 2] + 0.5 + v = view.K[1, 1] * Xc[..., 1] / z + view.K[1, 2] + 0.5 + ok = (z > 0.05) & (u >= 0) & (u <= view.width) & (v >= 0) & (v <= view.height) + return u, v, z, ok + + +@dataclass +class PhotoTruth: + """Per sample, for one photo: why it did or did not see it.""" + + framed: np.ndarray + in_range: np.ndarray + facing: np.ndarray + hidden: np.ndarray + no_scan: np.ndarray # no scan point anywhere near the pixel, so occlusion is unknown + + @property + def saw(self) -> np.ndarray: + return self.framed & self.in_range & self.facing & ~self.hidden + + +def photo_truth( + view, R, zbuf, missing, points_lev, facing, max_distance, hide_abs: float = HIDE_ABS_M +) -> PhotoTruth: + X = points_lev @ R # levelled -> ETH3D world (R is orthonormal) + u, v, z, framed = project(view, X) + zh, zw = zbuf.shape + cu = np.clip(np.nan_to_num(u * zw / view.width).astype(np.int64), 0, zw - 1) + cv_ = np.clip(np.nan_to_num(v * zh / view.height).astype(np.int64), 0, zh - 1) + near = zbuf[cv_, cu] + mh, mw = missing.shape + mu = np.clip(np.nan_to_num(u * mw / view.width).astype(np.int64), 0, mw - 1) + mv = np.clip(np.nan_to_num(v * mh / view.height).astype(np.int64), 0, mh - 1) + hidden = framed & ((near < z - np.maximum(hide_abs, HIDE_REL * z)) | missing[mv, mu]) + distance = np.linalg.norm(X - view.center, axis=-1) + return PhotoTruth( + framed=framed, + in_range=distance <= max_distance, + facing=np.broadcast_to(facing, framed.shape), + hidden=hidden, + no_scan=framed & (near >= 1e5), + ) + + +def face_offsets(wall: Wall, scan_lev: np.ndarray, s: np.ndarray) -> np.ndarray: + """Per column, how far the wall's own face stands in front of the tapped plane (0 if on it). + + A tapped wall is a plane, but real walls have pilasters: electro's stand 0.36 m proud. Behind + one, a plane sample is inside the wall, and the pilaster would count as hiding it. A column's + face is moved forward when, in at least 75% of its 10 cm height bins from 0.3 to 1.9 m, the + front-most scan point within 0.6 m lies within 3 cm of their median: a flat, full-height face. + A bush or a free-standing object is neither, so it still hides what is behind it. + """ + rel = scan_lev - wall.origin + t, out, h = rel @ wall.along, rel @ wall.outward, rel[:, 1] + keep = (t >= s[0] - COLUMN_M) & (t <= s[-1] + COLUMN_M) & (out > -0.15) & (out < 0.6) + keep &= (h > 0.3) & (h < 1.9) + col = np.floor((t[keep] - s[0]) / COLUMN_M + 0.5).astype(np.int64) + row = np.floor((h[keep] - 0.3) / 0.1).astype(np.int64) + ok = (col >= 0) & (col < len(s)) + front = np.full((len(s), 16), -np.inf) + np.maximum.at(front, (col[ok], row[ok]), out[keep][ok]) + offsets = np.zeros(len(s)) + for c in range(len(s)): + f = front[c][np.isfinite(front[c])] + if len(f) < 12: + continue + 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 + + +def band_samples( + wall: Wall, band: str, cfg: dict, faces: np.ndarray | None = None +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Column centres s (C,), sample offsets (R,) and points (C, R, 3) over the band. Wall samples + sit on the wall's face (`faces`, per column); ground samples inside a pilaster are dropped + (returned as NaN).""" + columns = int(np.floor((wall.right - wall.left) / COLUMN_M)) + s = wall.left + COLUMN_M * (np.arange(columns) + 0.5) + extent = cfg["wallBandHeight"] if band == "wall" else cfg["groundBandDepth"] + n = round(extent / SAMPLE_M) + offs = extent * (np.arange(n) + 0.5) / n + grid_s, grid_off = np.meshgrid(s, offs, indexing="ij") + faces = np.zeros(len(s)) if faces is None else faces + if band == "wall": + pts = wall.world(grid_s, grid_off, faces[:, None]) + else: + pts = wall.world(grid_s, np.zeros_like(grid_s), grid_off) + pts[grid_off < faces[:, None]] = np.nan + return s, offs, pts + + +def two_positions(saw: np.ndarray, centres: np.ndarray, baseline: float) -> np.ndarray: + """Per sample, whether two photos at least `baseline` apart both saw it. saw: (..., photos).""" + far = np.linalg.norm(centres[:, None] - centres[None], axis=-1) >= baseline + S = saw.astype(np.float32) + return ((S @ far.astype(np.float32)) * S).sum(axis=-1) > 0 + + +def in_intervals(s: np.ndarray, intervals: list[list[float]]) -> np.ndarray: + out = np.zeros(s.shape, bool) + for lo, hi in intervals: + out |= (s >= lo) & (s <= hi) + return out + + +def majority(counts: Counter, order: tuple[str, ...]) -> str: + """Most common label; ties go to the first in `order`.""" + return max(order, key=lambda k: (counts[k], -order.index(k))) + + +FALSE_CAUSES = ("occlusion", "frame edge", "range") +MISSED_CAUSES = ("frame edge", "grazing angle", "range", "one position") + + +def false_observed_cause( + truths: list[PhotoTruth], credited: list[int], c: int, rows: np.ndarray +) -> str: + """Why the photos the app credited did not see the unseen samples of column c.""" + counts: Counter = Counter() + for i in credited: + t = truths[i] + for r in rows: + if not t.framed[c, r]: + counts["frame edge"] += 1 + elif not t.in_range[c, r]: + counts["range"] += 1 + elif t.hidden[c, r]: + counts["occlusion"] += 1 + return majority(counts, FALSE_CAUSES) + + +def deficient_rows( + sightings: list[tuple[int, set[int]]], centres: np.ndarray, rows: int, baseline: float +) -> list[int]: + """`CoverageMap.record` for one cell: rows without two positions `baseline` apart, from the + keyframes' sightings in capture order.""" + held: list[list[np.ndarray]] = [[] for _ in range(rows)] + for i, seen in sightings: + for r in seen: + if len(held[r]) < 2 and all( + np.linalg.norm(p - centres[i]) >= baseline for p in held[r] + ): + held[r].append(centres[i]) + return [r for r in range(rows) if len(held[r]) < 2] + + +def missed_cause(wall, band, index, rows, seers: dict[int, list[int]], cams: list, cfg) -> str: + """Why the app did not credit the photos that saw column c. seers: deficient row -> photos + that saw the truth sample nearest that row.""" + counts: Counter = Counter() + for r in rows: + for i in seers[r]: + seen, gates = app_rows(wall, band, index, cams[i], cfg) + if r in seen: + counts["one position"] += 1 + elif not gates or not gates["frame"][r].all(): + counts["frame edge"] += 1 + elif not gates["angle"][r].all(): + counts["grazing angle"] += 1 + else: + counts["range"] += 1 + return majority(counts, MISSED_CAUSES) + + +def evaluate_band( + wall, band, cfg, app, views, cams, truths_by_band, centres, faces, causes: bool = True +) -> dict: + """Scores one band. `cfg` is the app's default config: the truth's two-position bar stays at its + baseline whatever option produced `app`. Causes are named only for the app as it is.""" + names = [v.name for v in views] + s, offs, pts = band_samples(wall, band, cfg, faces) + absent = np.isnan(pts[..., 0]) # ground inside a pilaster: nothing there to see + truths = truths_by_band[band] + saw = np.stack([t.saw for t in truths], axis=-1) # (C, R, photos) + seen1 = saw.any(axis=-1) | absent + seen2 = two_positions(saw, centres, cfg["coveringBaseline"]) | absent + claimed = in_intervals(s, app["covered"][band]) + + by_cell: dict[int, list[tuple[int, set[int]]]] = {} + for x in app["sightings"]: + if x["band"] == band: + by_cell.setdefault(x["index"], []).append((names.index(x["keyframe"]), set(x["rows"]))) + + false_cols = claimed & ((~seen1).sum(axis=1) >= UNSEEN_MIN_SAMPLES) + false2_cols = claimed & ((~seen2).sum(axis=1) >= UNSEEN_MIN_SAMPLES) + missed_cols = ~claimed & seen2.all(axis=1) + false_causes: Counter = Counter() + missed_causes: Counter = Counter() + for c in np.flatnonzero(false_cols) if causes else []: + credited = [i for i, _ in by_cell.get(cell_index(s[c], cfg), [])] + false_causes[false_observed_cause(truths, credited, c, np.flatnonzero(~seen1[c]))] += 1 + app_offs = row_offsets(band, cfg) + for c in np.flatnonzero(missed_cols) if causes else []: + index = cell_index(s[c], cfg) + rows = deficient_rows( + by_cell.get(index, []), centres, len(app_offs), cfg["coveringBaseline"] + ) + nearest = {r: int(np.argmin(np.abs(offs - app_offs[r]))) for r in rows} + seers = {r: list(np.flatnonzero(saw[c, nearest[r]])) for r in rows} + missed_causes[ + missed_cause(wall, band, index, rows, seers, cams, cfg) if rows else "one position" + ] += 1 + + ft = COLUMN_M / FEET + claimed_ft = claimed.sum() * ft + no_scan = np.stack([t.no_scan & t.saw for t in truths], axis=-1) + only_unknown = seen1 & ~(saw & ~no_scan).any(axis=-1) + return { + "claimed_ft": claimed_ft, + "false_observed_ft": false_cols.sum() * ft, + "false_observed_share": false_cols.sum() / max(claimed.sum(), 1), + "false_observed_by_cause_ft": {k: false_causes[k] * ft for k in FALSE_CAUSES}, + "false_two_view_ft": false2_cols.sum() * ft, + "false_two_view_share": false2_cols.sum() / max(claimed.sum(), 1), + "seen_two_view_ft": seen2.all(axis=1).sum() * ft, + "missed_ft": missed_cols.sum() * ft, + "missed_by_cause_ft": {k: missed_causes[k] * ft for k in MISSED_CAUSES}, + "claimed_samples_seen_only_where_scan_is_empty": float(only_unknown[claimed].mean()) + if claimed.any() + else 0.0, + # Nothing claimed tests nothing. + "passes": bool(false_cols.sum() * ft <= PASS_FT) if claimed.any() else None, + } + + +def is_wall(band: np.ndarray, line: dict, d: np.ndarray, n: np.ndarray, ground_y: float) -> bool: + """A wall, not a stair riser or a kerb: in most 10 cm stretches along the line, scan points + within 5 cm of it span at least 1.2 m of the 0.3 to 2.0 m band.""" + centre = np.array([line["centre"][0], 0.0, line["centre"][1]]) + rel = band - centre + near = np.abs(rel @ n) < 0.05 + t, y = rel[near] @ d, band[near, 1] + lo, hi = line["t"] + keep = (t >= lo) & (t <= hi) + bins = np.floor((t[keep] - lo) / 0.1).astype(np.int64) + if not len(bins): + return False + top = np.full(bins.max() + 1, -np.inf) + bottom = np.full(bins.max() + 1, np.inf) + np.maximum.at(top, bins, y[keep]) + np.minimum.at(bottom, bins, y[keep]) + tall = (top - bottom) >= 1.2 + return tall.sum() >= 0.5 * np.ceil((hi - lo) / 0.1) + + +def pick_wall(ground_y, scan_lev, cams, cfg, rng) -> Wall | None: + """The straight wall stretch the most photos see (at least 1 m of it, within the app's range), + as taps would give it; None when no photo sees any.""" + y = scan_lev[:, 1] + band = scan_lev[(y > ground_y + WALL_BAND_M[0]) & (y < ground_y + WALL_BAND_M[1])] + plan = np.unique(np.floor(band[:, [0, 2]] / 0.05), axis=0) * 0.05 + 0.025 + best, best_score = None, -1 + for line in wall_lines(plan, rng): + d = np.array([line["direction"][0], 0.0, line["direction"][1]]) + n = np.cross(d, UP) + lo, hi = line["t"] + if not is_wall(band, line, d, n, ground_y): + continue + mid = np.array([line["centre"][0], ground_y + 1.0, line["centre"][1]]) + ts = np.arange(lo, hi, 0.1) + pts = mid + ts[:, None] * d + for sign in (1.0, -1.0): + score = 0 + for cam in cams.values(): + if (cam[0][:3, 3] - mid) @ (sign * n) <= 0: + continue + u, v, front = app_pixels(*cam[:2], pts) + dist = np.linalg.norm(pts - cam[0][:3, 3], axis=1) + with np.errstate(invalid="ignore"): + inside = ( + front + & (u >= 0) + & (v >= 0) + & (u <= cam[2][0]) + & (v <= cam[2][1]) + & (dist <= cfg["maxDistance"]) + ) + score += int(inside.sum() * 0.1 >= 1.0) + if score > best_score: + best, best_score = (line, d, sign * n), score + if best is None or best_score == 0: + return None + line, d, outward = best + lo, hi = line["t"] + centre = np.array([line["centre"][0], 0.0, line["centre"][1]]) + meter_plan = centre + d * (lo + hi) / 2 + # Ground at the foot: scan points 0.2 to 1.2 m out, along the stretch, near the ground plane. + rel = scan_lev - meter_plan + s_all, out_all = rel @ np.cross(-outward, UP), rel @ outward + along_ok = (s_all > -(hi - lo) / 2) & (s_all < (hi - lo) / 2) + foot = along_ok & (out_all > 0.2) & (out_all < 1.2) & (np.abs(scan_lev[:, 1] - ground_y) < 0.3) + local_ground = float(np.median(scan_lev[foot, 1])) + wall_pts = ( + along_ok + & (np.abs(out_all) < 0.10) + & (y > local_ground + WALL_BAND_M[0]) + & (y < local_ground + WALL_BAND_M[1]) + ) + meter = meter_plan + UP * (local_ground + METER_HEIGHT_M) + # The meter is mid-stretch, so the marked ends sit half the stretch either side of it. + half = (hi - lo) / 2 + rms = float(np.sqrt(np.mean(out_all[wall_pts] ** 2))) + return Wall(meter, outward, local_ground, -half, half, rms) + + +# Runs + + +RUNS = ("electro", "facade") + + +@dataclass +class Setup: + """One scene ready to score: the wall, the keyframes and each photo's truth and depth.""" + + scene: str + cfg: dict + views: list[View] + R: np.ndarray + cams: dict + wall: Wall + ground_rms: float + centres: np.ndarray + faces: np.ndarray + truths: dict[str, list[PhotoTruth]] + zbufs: list[np.ndarray] + missing: list[np.ndarray] + + def score(self, app: dict, causes: bool = True) -> dict: + return { + band: evaluate_band( + self.wall, + band, + self.cfg, + app, + self.views, + list(self.cams.values()), + self.truths, + self.centres, + self.faces, + causes, + ) + for band in ("wall", "ground") + } + + +def setup_scene(scene: str) -> Setup | None: + """None when no photo sees a straight wall within the app's range.""" + cfg = app_config() + views = read_views(ETH3D_DIR / scene) + scan = scan_points(scene) + R, ground_y, ground_rms = level_world(views, scan[::3].astype(np.float64)) + cams = {v.name: to_arkit(v, R) for v in views} + wall = pick_wall( + ground_y, scan[::3].astype(np.float64) @ R.T, cams, cfg, np.random.default_rng(0) + ) + if wall is None: + return None + blockers = np.concatenate([scan, occluder_points(scene)]) + centres = np.array([c[0][:3, 3] for c in cams.values()]) + columns = band_samples(wall, "wall", cfg)[0] + faces = face_offsets(wall, scan.astype(np.float64) @ R.T, columns) + samples = {band: band_samples(wall, band, cfg, faces)[2] for band in ("wall", "ground")} + truths: dict[str, list[PhotoTruth]] = {"wall": [], "ground": []} + zbufs, missings = [], [] + for v, centre in zip(views, centres, strict=True): + zbuf = depth_buffer(v, blockers) + missing = excluded_pixels( + v, MASK_WIDTH, round(v.height * MASK_WIDTH / v.width), labels=(MISSING_FROM_SCAN,) + ) + zbufs.append(zbuf) + missings.append(missing) + facing = {"wall": wall.out_of(centre) > 0, "ground": centre[1] > wall.ground_y} + for band, pts in samples.items(): + truths[band].append( + photo_truth(v, R, zbuf, missing, pts, facing[band], cfg["maxDistance"]) + ) + return Setup( + scene, cfg, views, R, cams, wall, ground_rms, centres, faces, truths, zbufs, missings + ) + + +def evaluate_scene(scene: str) -> dict: + setup = setup_scene(scene) + if setup is None: + return {"scene": scene, "photos": len(read_views(ETH3D_DIR / scene)), "wall": None} + wall, cams = setup.wall, setup.cams + app = run_app(wall, cams) + got = {(x["keyframe"], x["band"], x["index"], frozenset(x["rows"])) for x in app["sightings"]} + mismatched = len(got ^ replica_sightings(wall, cams, setup.cfg)) + seen_by = {x["keyframe"] for x in app["sightings"]} + return { + "scene": scene, + "photos": len(setup.views), + "photos_with_a_sighting": len(seen_by), + "wall_length_ft": (wall.right - wall.left) / FEET, + "wall_fit_rms_cm": 100 * wall.fit_rms_m, + "pilaster_ft": float((setup.faces > 0).sum() * COLUMN_M / FEET), + "ground_fit_rms_cm": 100 * setup.ground_rms, + "meter_levelled_m": wall.meter.round(3).tolist(), + "outward": wall.outward.round(4).tolist(), + "wall": "picked", + "sightings": len(got), + "replica_mismatches": mismatched, + "bands": setup.score(app), + } + + +def _ft(x: float) -> str: + return f"{x:.1f}" + + +def markdown(runs: list[dict]) -> str: + lines = [ + "# Does the app's coverage map claim surface no photo saw? (generated by `make coverage`)", + "", + f"HouseScanKit `CoverageMap` at {KIT_COMMIT} (t3/ios-mvf), unmodified, default " + "`CoverageConfig`. ETH3D photos with true poses, " + "every photo a kept keyframe. Lengths in feet along the wall. Definitions and pass criteria: " + "README section 7.", + "", + "| Scene | Band | Claimed | False-observed (share) | Pass (<= 0.5 ft) | Causes: occlusion / frame edge / range | False-observed, 2-position truth (share) | Seen from 2 positions | Missed | Missed causes: frame edge / grazing / range / one position |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for r in runs: + if r["wall"] is None: + continue + for band, b in r["bands"].items(): + fc, mc = b["false_observed_by_cause_ft"], b["missed_by_cause_ft"] + lines.append( + f"| {r['scene']} | {band} | {_ft(b['claimed_ft'])} | " + f"{_ft(b['false_observed_ft'])} ({100 * b['false_observed_share']:.0f}%) | " + f"{ {True: 'yes', False: 'no', None: 'untested'}[b['passes']] } | " + f"{' / '.join(_ft(fc[k]) for k in FALSE_CAUSES)} | " + f"{_ft(b['false_two_view_ft'])} ({100 * b['false_two_view_share']:.0f}%) | " + f"{_ft(b['seen_two_view_ft'])} | {_ft(b['missed_ft'])} | " + f"{' / '.join(_ft(mc[k]) for k in MISSED_CAUSES)} |" + ) + lines += [ + "", + "## Setup and validity checks", + "", + "| Scene | Photos (with a sighting) | Wall stretch | Of it, pilaster faces | Wall plane fit RMS | Ground fit RMS | App sightings | Replica mismatches | Claimed samples seen only where the scan is empty (wall / ground) |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for r in runs: + if r["wall"] is None: + lines.append( + f"| {r['scene']} | {r['photos']} (0) | none within the app's range | | | | | | |" + ) + continue + b = r["bands"] + lines.append( + f"| {r['scene']} | {r['photos']} ({r['photos_with_a_sighting']}) | " + f"{r['wall_length_ft']:.1f} ft | {r['pilaster_ft']:.1f} ft | " + f"{r['wall_fit_rms_cm']:.1f} cm | {r['ground_fit_rms_cm']:.1f} cm | " + f"{r['sightings']} | {r['replica_mismatches']} | " + f"{100 * b['wall']['claimed_samples_seen_only_where_scan_is_empty']:.1f}% / " + f"{100 * b['ground']['claimed_samples_seen_only_where_scan_is_empty']:.1f}% |" + ) + return "\n".join(lines) + "\n" + + +def main() -> None: + check_kit() + runs = [evaluate_scene(scene) for scene in RUNS] + bad = [r["scene"] for r in runs if r["wall"] and r["replica_mismatches"]] + RESULTS.mkdir(exist_ok=True) + (RESULTS / "coverage.json").write_text(json.dumps(runs, indent=1)) + if bad: + raise SystemExit(f"cause replica disagrees with the app on {bad}; causes would be wrong") + md = markdown(runs) + (RESULTS / "coverage.md").write_text(md) + print(md) + + +if __name__ == "__main__": + if sys.argv[1:] == ["prepare"]: + prepare() + elif sys.argv[1:]: + raise SystemExit("usage: python -m evals.coverage [prepare]") + else: + main() diff --git a/experiments/evals/evals/coverage_options.py b/experiments/evals/evals/coverage_options.py new file mode 100644 index 000000000..4c738987e --- /dev/null +++ b/experiments/evals/evals/coverage_options.py @@ -0,0 +1,176 @@ +"""Options for occlusion in the app's coverage map, on section 7's wall and truth (README section 7b). + +Each option runs the app's CoverageMap (HouseScanKit at `coverage.KIT_COMMIT`, unedited) through +`coverage_driver/`, which models the rule change around it: +- position baseline b and angle diversity theta: no LiDAR; the driver's copy of `record` checks + both, and at theta = 0, b = 0.25 m it must reproduce the app's own answer; +- depth test: the laser scan stands in for LiDAR depth. A keyframe's row is dropped from its + sightings when either of the row's two samples is hidden (the truth's own test) or, for the + 5 m setting, beyond the LiDAR's reach. +""" + +from __future__ import annotations + +import json + +from evals.coverage import ( + FEET, + HIDE_ABS_M, + PASS_FT, + RESULTS, + Setup, + check_kit, + photo_truth, + row_points, + run_app, + setup_scene, +) + +SCENE = "electro" +# Pre-registered: 0-45 degrees and 0.25-2 m. 60 and 75 degrees and 5 m were added after that grid +# left the false-observed length unchanged, to find where it first drops. +ANGLES_DEG = (0, 15, 30, 45, 60, 75) +BASELINES_M = (0.25, 0.5, 1.0, 2.0, 5.0) +APP_BASELINE_M = 0.25 +LIDAR_REACH_M = 5.0 # Apple's stated range for the iPhone LiDAR scanner +# (label, rows per band, depth reach, how much nearer than the tapped plane still counts as wall) +DEPTH_OPTIONS = ( + ("depth test, true depth to 6 m, 3 rows", 3, None, HIDE_ABS_M), + ("depth test, true depth to 6 m, 9 rows", 9, None, HIDE_ABS_M), + ("depth test, no depth past 5 m, 9 rows", 9, LIDAR_REACH_M, HIDE_ABS_M), +) +# Added after the run above showed the pilasters (0.36 m proud) rejected: how much wall relief a +# depth test can allow before the footings pass. Chosen on this one wall, so not a setting to ship. +RELIEF_M = (0.2, 0.3, 0.4, 0.5) + + +def depth_hidden( + setup: Setup, sightings: list[dict], rows: int, reach: float | None, relief: float +) -> list[dict]: + """Rows of each keyframe's sightings that a depth test would reject.""" + cfg = {**setup.cfg, "rowsPerBand": rows} + names = [v.name for v in setup.views] + out = [] + for x in sightings: + i = names.index(x["keyframe"]) + pts = row_points(setup.wall, x["band"], x["index"], cfg)[x["rows"]] + t = photo_truth( + setup.views[i], + setup.R, + setup.zbufs[i], + setup.missing[i], + pts, + True, + setup.cfg["maxDistance"] if reach is None else reach, + relief, + ) + bad = (t.hidden | ~t.in_range).any(axis=1) + if bad.any(): + out.append({**x, "rows": [r for r, b in zip(x["rows"], bad, strict=True) if b]}) + return out + + +def _row(label: str, bands: dict, lidar: bool) -> dict: + wall = bands["wall"] + return { + "option": label, + "needs_lidar": lidar, + "claimed_ft": wall["claimed_ft"], + "false_observed_ft": wall["false_observed_ft"], + "missed_ft": wall["missed_ft"], + "ground_claimed_ft": bands["ground"]["claimed_ft"], + "ground_false_observed_ft": bands["ground"]["false_observed_ft"], + "passes": bool(wall["false_observed_ft"] <= PASS_FT), + } + + +def evaluate() -> dict: + setup = setup_scene(SCENE) + if setup is None: + raise SystemExit(f"{SCENE}: no wall within range") + rows, grid = [], [] + for b in BASELINES_M: + for theta in ANGLES_DEG: + app = run_app(setup.wall, setup.cams, {"coveringBaseline": b, "minAngleDeg": theta}) + if theta == 0 and app["diverseCovered"] != app["covered"]: + raise SystemExit(f"b = {b}: the driver's copy of record disagrees with the app") + covered = app["covered"] if theta == 0 else app["diverseCovered"] + bands = setup.score({**app, "covered": covered}, causes=False) + label = f"b = {b:g} m, theta = {theta} deg" + r = _row(label, bands, lidar=False) + grid.append({"b_m": b, "theta_deg": theta, **r}) + if b == APP_BASELINE_M and theta == 0: + rows.append({**r, "option": "the app today (b = 0.25 m, theta = 0)"}) + + def depth_option(label: str, n_rows: int, reach: float | None, relief: float) -> dict: + first = run_app(setup.wall, setup.cams, {"rowsPerBand": n_rows}) + hidden = depth_hidden(setup, first["sightings"], n_rows, reach, relief) + app = run_app(setup.wall, setup.cams, {"rowsPerBand": n_rows}, hidden) + return _row(label, setup.score(app, causes=False), lidar=True) + + for option in DEPTH_OPTIONS: + rows.append(depth_option(*option)) + relief = [ + depth_option(f"depth test, true depth to 6 m, 9 rows, {r:g} m of relief", 9, None, r) + for r in RELIEF_M + ] + return { + "scene": SCENE, + "wall_ft": (setup.wall.right - setup.wall.left) / FEET, + "grid": grid, + "options": rows, + "relief": relief, + } + + +def markdown(res: dict) -> str: + lines = [ + "# Options for occlusion in the coverage map (generated by `make coverage-options`)", + "", + f"ETH3D {res['scene']}, the {res['wall_ft']:.1f} ft wall of section 7. Feet along the wall. " + "False-observed: claimed wall with 10 cm or more of band no photo saw (pass <= " + f"{PASS_FT} ft). Missed: wall band seen from two positions 0.25 m apart but not claimed.", + "", + "## Position baseline b and angle diversity theta (no LiDAR): false-observed / missed", + "", + "| b \\ theta | " + " | ".join(f"{t} deg" for t in ANGLES_DEG) + " |", + "| --- |" + " --- |" * len(ANGLES_DEG), + ] + for b in BASELINES_M: + cells = [ + f"{g['false_observed_ft']:.1f} / {g['missed_ft']:.1f}" + for g in res["grid"] + if g["b_m"] == b + ] + lines.append(f"| {b:g} m | " + " | ".join(cells) + " |") + lines += [ + "", + "## Options", + "", + "The last four rows allow that much wall relief in front of the tapped plane, a setting " + "chosen after seeing this wall; see README section 7b.", + "", + "| Option | Needs LiDAR | Claimed | False-observed | Pass | Missed | Ground claimed |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + for r in res["options"] + res["relief"]: + lines.append( + f"| {r['option']} | {'yes' if r['needs_lidar'] else 'no'} | {r['claimed_ft']:.1f} | " + f"{r['false_observed_ft']:.1f} | {'yes' if r['passes'] else 'no'} | " + f"{r['missed_ft']:.1f} | {r['ground_claimed_ft']:.1f} |" + ) + return "\n".join(lines) + "\n" + + +def main() -> None: + check_kit() + res = evaluate() + RESULTS.mkdir(exist_ok=True) + (RESULTS / "coverage_options.json").write_text(json.dumps(res, indent=1)) + md = markdown(res) + (RESULTS / "coverage_options.md").write_text(md) + print(md) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/datasets.py b/experiments/evals/evals/datasets.py new file mode 100644 index 000000000..b222b639b --- /dev/null +++ b/experiments/evals/evals/datasets.py @@ -0,0 +1,186 @@ +"""Download, verify and unpack the public datasets the evals read. + + uv run python -m evals.datasets advio # ADVIO sequences 20 to 23 (about 800 MB of zips) + uv run python -m evals.datasets eth3d # ETH3D facade and electro (about 2.4 GB of archives) + +Each archive is checked against its size and sha256 below, unpacked (only the files the evals use), +then deleted. A download refuses to start with less than `MIN_FREE_GB` free, because the Mac these +run on is shared. Both datasets are for non-commercial research: they measure accuracy here and are +never committed or redistributed. +""" + +from __future__ import annotations + +import argparse +import hashlib +import shutil +import urllib.request +import zipfile +from dataclasses import dataclass +from pathlib import Path + +import py7zr + +from evals.paths import ADVIO_DIR, ETH3D_DIR + +MIN_FREE_GB = 6.0 + + +@dataclass(frozen=True) +class Archive: + url: str + size: int + sha256: str + dest: Path + marker: str # a path under `dest` that exists once the archive is unpacked + members: tuple[str, ...] = () # zip members to extract (prefix match); empty means all + + @property + def name(self) -> str: + return self.url.rsplit("/", 1)[-1] + + +def _advio(n: int, size: int, sha: str, keep_video: bool = False) -> Archive: + base = f"advio-{n:02d}/" + members = (base + "iphone/", base + "ground-truth/", base + "pixel/arcore.csv") + if not keep_video: + members = ( + base + "iphone/arkit.csv", + base + "iphone/frames.csv", + base + "iphone/platform-locations.csv", + base + "ground-truth/", + base + "pixel/arcore.csv", + ) + url = f"https://zenodo.org/record/1476931/files/advio-{n:02d}.zip" + return Archive(url, size, sha, ADVIO_DIR, base + "iphone/arkit.csv", members) + + +ADVIO = [ + # Sequence 20 keeps its video: the replay session is cut from it. + _advio( + 20, + 195538352, + "be21154394df09d6ecebe1894062f290bb53d74a5c6ccbca4b3a69f91ee2c8a2", + keep_video=True, + ), + _advio(21, 209791503, "fb8a1cf3f645bbd9ea848e66924e83ae75b5cd18d632986e5629af1eecb58570"), + _advio(22, 254664089, "96c7212fc9cb610a88ba9fa62ac2581f0cdae49e80007888698ee2cf5ebd56f4"), + _advio(23, 134340011, "726d9b80d30036a8585f6cecaae385236b689cc4b8b19be6362c6df223825dc1"), +] + +_ETH = "https://www.eth3d.net/data/" +ETH3D = [ + Archive( + _ETH + "facade_dslr_undistorted.7z", + 1252088400, + "046e577388db0633eeb2d8d72da6a2de53857434b4a7a98e4c97485795f9ce82", + ETH3D_DIR, + "facade/images", + ), + Archive( + _ETH + "facade_dslr_scan_eval.7z", + 176517224, + "d686462d417fbea010d0021f918bec8b881444929ec06e3bd4e8a5230ebf59a8", + ETH3D_DIR, + "facade/dslr_scan_eval", + ), + Archive( + _ETH + "facade_dslr_occlusion.7z", + 144292883, + "26a9d2076ba16b7f8e24311579c47b0b7b826b180906ced20fa1cba8f3dd130d", + ETH3D_DIR, + "facade/occlusion", + ), + Archive( + _ETH + "electro_dslr_undistorted.7z", + 514345623, + "0d2bc31fec0032b8fb20703abba8e45ef7a395c13d2d3476adc1f4dd4ffd7d8b", + ETH3D_DIR, + "electro/images", + ), + Archive( + _ETH + "electro_dslr_scan_eval.7z", + 250703286, + "5ca10a73e7da0e3c511e255bba5656cca4c372dc00c1417c97b75228a5bb3bac", + ETH3D_DIR, + "electro/dslr_scan_eval", + ), + Archive( + _ETH + "electro_dslr_occlusion.7z", + 47214879, + "cc25a22de9fc27251a5178454c24785671a2b2c199756a0d03297413290830b3", + ETH3D_DIR, + "electro/occlusion", + ), +] + + +def sha256_of(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +DOWNLOAD_TIMEOUT_S = 60 # per socket operation: a stalled server fails instead of hanging + + +def fetch(archive: Archive) -> None: + archive.dest.mkdir(parents=True, exist_ok=True) + free_gb = shutil.disk_usage(archive.dest).free / 1e9 + need_gb = 2.5 * archive.size / 1e9 # archive plus unpacked copy, with margin + if free_gb - need_gb < MIN_FREE_GB: + raise SystemExit( + f"{archive.name}: {free_gb:.1f} GB free, need {need_gb:.1f} GB plus {MIN_FREE_GB} GB reserve" + ) + tmp = archive.dest / (archive.name + ".part") + print(f"downloading {archive.url}") + try: + with ( + urllib.request.urlopen(archive.url, timeout=DOWNLOAD_TIMEOUT_S) as resp, + tmp.open("wb") as out, + ): + shutil.copyfileobj(resp, out, 1 << 20) + except BaseException: + tmp.unlink(missing_ok=True) + raise + size = tmp.stat().st_size + digest = sha256_of(tmp) + if size != archive.size or digest != archive.sha256: + tmp.unlink() + raise SystemExit( + f"{archive.name}: got {size} bytes sha256 {digest}, expected {archive.size} {archive.sha256}" + ) + if archive.name.endswith(".zip"): + with zipfile.ZipFile(tmp) as z: + names = [ + n for n in z.namelist() if not archive.members or n.startswith(archive.members) + ] + z.extractall(archive.dest, members=names) + else: + with py7zr.SevenZipFile(tmp) as z: + z.extractall(archive.dest) + tmp.unlink() + print(f" verified and unpacked into {archive.dest}") + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("dataset", choices=["advio", "eth3d"]) + ap.add_argument( + "--force", action="store_true", help="download even if the data is already unpacked" + ) + args = ap.parse_args() + for a in ADVIO if args.dataset == "advio" else ETH3D: + marker = a.dest / a.marker + if marker.exists() and not args.force: + print(f"{a.name}: already unpacked ({marker})") + continue + fetch(a) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/drift.py b/experiments/evals/evals/drift.py new file mode 100644 index 000000000..846e322c1 --- /dev/null +++ b/experiments/evals/evals/drift.py @@ -0,0 +1,427 @@ +"""How far ARKit's tracked position is off after walking 3, 10, 20 and 30 ft outdoors (ADVIO 20 to 23). + + uv run python -m evals.drift # writes results/advio_drift.json and results/advio_drift.md + +Method. Tracks are resampled at 10 Hz on their common interval, after ARKit initialises. From every +start time (every 0.5 s) the walk continues until the reference has covered d feet of horizontal +path. The main error is the distance error: ARKit's straight-line displacement minus the +reference's. It needs no heading alignment, so no orientation error can leak in, and it is what a +span measured by walking it (a 30 ft wall, a gap to a fence) would be off by. For the ground-truth +reference the full position error is also kept, with ARKit's displacement turned into the truth's +frame by the heading difference of the two orientations at the start. + +References. The same rig carried the iPhone (ARKit, GPS), a Pixel running ARCore, and produced a +ground truth: an inertial track pinned to fix points marked on a map, so its scale is the map's. +Three references are scored: + +- `truth`: the ground truth as published; +- `truth_gps`: the ground truth rescaled by the factor that best fits the phone's GPS track + (`reference_scale_check`), because GPS and ARCore both say the map was mis-scaled in 20 and 21; +- `arcore`: the Pixel's ARCore track. ARKit minus ARCore is an inter-tracker disagreement, not a + bound on ARKit's error: two trackers on one rig see the same motion and scene, so part of their + errors can be shared and cancel in the difference. + +`three_cornered_hat` splits the variances of the pairwise differences into per-tracker variances. +That split holds only if the three trackers' errors are independent; a negative variance shows the +assumption failing for that row, so the split is reported as conditional and never as a bound. + +A sequence whose ARKit track moves faster than a person walks (over 4 m/s between 10 Hz samples) +is reported as a tracking failure, with the time it failed, instead of producing drift numbers. +""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict, dataclass +from pathlib import Path + +import numpy as np + +from evals.advio import PoseTrack, _read_pose_csv, load_sequence +from evals.paths import ADVIO_DIR + +FEET = 0.3048 +INCH = 0.0254 +DISTANCES_FT = (3, 10, 20, 30) +SAMPLE_HZ = 10.0 +START_EVERY_S = 0.5 +MAX_WALK_SPEED = 4.0 # m/s; a walker never covers 0.4 m in 0.1 s +SEQUENCES = (20, 21, 22, 23) + + +def horizontal_path_length(p: np.ndarray) -> np.ndarray: + """Cumulative distance travelled in the x-z plane (y is up), starting at 0.""" + steps = np.linalg.norm(np.diff(p[:, [0, 2]], axis=0), axis=1) + return np.concatenate([[0.0], np.cumsum(steps)]) + + +def yaw_between(R_from: np.ndarray, R_to: np.ndarray) -> np.ndarray: + """Rotation about +y closest to turning orientation R_from into R_to (the heading difference).""" + M = R_to @ R_from.T + ang = np.arctan2(M[0, 2] - M[2, 0], M[0, 0] + M[2, 2]) + c, s = np.cos(ang), np.sin(ang) + return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]]) + + +def window_pairs( + ref_p: np.ndarray, distance_m: float, start_step: int +) -> tuple[np.ndarray, np.ndarray]: + """Start indices, and the fractional index at which the reference has walked exactly + `distance_m` of horizontal path since each start (interpolated within the sample step, so a + "3 ft" window is 3 ft, not the first sample past it). Windows that run off the end are dropped.""" + walked = horizontal_path_length(ref_p) + i = np.arange(0, len(ref_p), start_step) + target = walked[i] + distance_m + j0 = np.searchsorted(walked, target) # first sample at or past the target + keep = (j0 < len(ref_p)) & (j0 > 0) + i, j0, target = i[keep], j0[keep], target[keep] + frac = (target - walked[j0 - 1]) / (walked[j0] - walked[j0 - 1]) + return i, (j0 - 1) + frac + + +def at(p: np.ndarray, j: np.ndarray) -> np.ndarray: + """Positions at fractional sample indices, linearly interpolated.""" + lo = np.floor(j).astype(int) + hi = np.minimum(lo + 1, len(p) - 1) + f = (j - lo)[:, None] + return p[lo] * (1 - f) + p[hi] * f + + +def distance_errors( + est_p: np.ndarray, ref_p: np.ndarray, i: np.ndarray, j: np.ndarray +) -> np.ndarray: + """|est(j) - est(i)| - |ref(j) - ref(i)| for each window, meters (signed); j may be fractional.""" + de = at(est_p, j) - est_p[i] + dr = at(ref_p, j) - ref_p[i] + return np.linalg.norm(de, axis=1) - np.linalg.norm(dr, axis=1) + + +def position_errors( + est_p: np.ndarray, + ref_p: np.ndarray, + est_R: np.ndarray, + ref_R: np.ndarray, + i: np.ndarray, + j: np.ndarray, + scale: float = 1.0, +) -> np.ndarray: + """|Y (est(j) - est(i)) / scale - (ref(j) - ref(i))|, with Y the heading difference at the + start. `scale` divides out the estimate's own scale error, leaving heading and random drift.""" + de = (at(est_p, j) - est_p[i]) / scale + dr = at(ref_p, j) - ref_p[i] + out = np.empty(len(i)) + for k, a in enumerate(i): + out[k] = np.linalg.norm(yaw_between(est_R[a], ref_R[a]) @ de[k] - dr[k]) + return out + + +def three_cornered_hat(var_ab: float, var_ac: float, var_bc: float) -> tuple[float, float, float]: + """Each tracker's own variance from the variances of three pairwise differences, assuming + independent errors: var_ab = a + b, var_ac = a + c, var_bc = b + c. A negative result means the + independence assumption does not hold for these data. The inputs must be plain variances: only + those add for independent errors (a median-based spread does not).""" + a = (var_ab + var_ac - var_bc) / 2 + b = var_ab - a + c = var_ac - a + return a, b, c + + +def similarity_scale_2d(src: np.ndarray, dst: np.ndarray) -> tuple[float, np.ndarray]: + """Scale of the best 2-D similarity (rotation, optional reflection, scale, shift) from src to dst. + + Reflection is allowed because a y-up world's x-z plane and (east, north) differ in handedness. + Returns (scale, residuals in dst units). + """ + mu_s, mu_d = src.mean(0), dst.mean(0) + a, b = src - mu_s, dst - mu_d + best: tuple[float, np.ndarray] | None = None + for flip in (1.0, -1.0): + af = a * np.array([1.0, flip]) + U, D, Vt = np.linalg.svd(b.T @ af) + S = np.diag([1.0, np.sign(np.linalg.det(U @ Vt))]) + R = U @ S @ Vt + s = float(np.trace(np.diag(D) @ S) / (af**2).sum()) + res = np.linalg.norm(b - s * af @ R.T, axis=1) + if best is None or res.mean() < best[1].mean(): + best = (s, res) + assert best is not None + return best + + +def gps_local_meters(lat: np.ndarray, lon: np.ndarray) -> np.ndarray: + """(east, north) meters from the first sample, equirectangular (exact enough over 1 km).""" + r = 6_371_000.0 + east = np.radians(lon - lon[0]) * r * np.cos(np.radians(lat[0])) + north = np.radians(lat - lat[0]) * r + return np.c_[east, north] + + +@dataclass +class ScaleCheck: + gps_over_truth: float + gps_interval_95: tuple[float, float] + gps_fixes: int + gps_median_accuracy_m: float + arcore_over_truth: float + arkit_over_truth: float + + +def _window_ratio( + track: PoseTrack, truth: PoseTrack, times: np.ndarray, window_s: float = 5.0 +) -> float: + """Median |track displacement| / |truth displacement| over `window_s` windows in which the + truth moved at least 3 m horizontally.""" + w = round(window_s * SAMPLE_HZ) + pa, pg = track.interpolate_positions(times), truth.interpolate_positions(times) + da = np.linalg.norm((pa[w:] - pa[:-w])[:, [0, 2]], axis=1) + dg = np.linalg.norm((pg[w:] - pg[:-w])[:, [0, 2]], axis=1) + moving = dg > 3.0 + return float(np.median(da[moving] / dg[moving])) + + +def reference_scale_check( + root: Path, + truth: PoseTrack, + arkit: PoseTrack, + arcore: PoseTrack, + times: np.ndarray, + seed: int = 0, +) -> ScaleCheck: + """How long the ground truth's distances are compared with GPS and with ARCore.""" + loc = np.loadtxt(root / "iphone" / "platform-locations.csv", delimiter=",", ndmin=2) + t, lat, lon, acc = loc[:, 0], loc[:, 1], loc[:, 2], loc[:, 3] + use = (acc <= 10.0) & (t >= truth.t[0]) & (t <= truth.t[-1]) + if use.sum() < 60: + raise ValueError(f"{root}: only {use.sum()} GPS fixes with accuracy <= 10 m") + gps = gps_local_meters(lat[use], lon[use]) + ref = truth.interpolate_positions(t[use])[:, [0, 2]] + scale, _ = similarity_scale_2d(ref, gps) + # Block bootstrap: GPS errors are correlated over tens of seconds, so resample 30 s blocks. + rng = np.random.default_rng(seed) + blocks = np.floor((t[use] - t[use][0]) / 30.0).astype(int) + ids = np.unique(blocks) + boots = [] + for _ in range(300): + idx = np.concatenate([np.flatnonzero(blocks == b) for b in rng.choice(ids, size=len(ids))]) + boots.append(similarity_scale_2d(ref[idx], gps[idx])[0]) + lo, hi = np.percentile(boots, [2.5, 97.5]) + return ScaleCheck( + gps_over_truth=scale, + gps_interval_95=(float(lo), float(hi)), + gps_fixes=int(use.sum()), + gps_median_accuracy_m=float(np.median(acc[use])), + arcore_over_truth=_window_ratio(arcore, truth, times), + arkit_over_truth=_window_ratio(arkit, truth, times), + ) + + +def tracking_failure_time(p: np.ndarray, times: np.ndarray) -> float | None: + """First time the track moves faster than a person walks, or None.""" + speed = np.linalg.norm(np.diff(p, axis=0), axis=1) * SAMPLE_HZ + bad = np.flatnonzero(speed > MAX_WALK_SPEED) + return float(times[bad[0]]) if len(bad) else None + + +def _abs_stats_in(x: np.ndarray) -> dict[str, float]: + a = np.abs(x) / INCH + return { + "n": len(a), + "median_in": round(float(np.median(a)), 2), + "p90_in": round(float(np.percentile(a, 90)), 2), + "signed_median_in": round(float(np.median(x) / INCH), 2), + } + + +def evaluate_sequence(number: int) -> dict: + root = ADVIO_DIR / f"advio-{number:02d}" + seq = load_sequence(root) + ark, truth = seq.arkit, seq.ground_truth + arcore = _read_pose_csv(root / "pixel" / "arcore.csv") + started = ark.t[np.linalg.norm(ark.p, axis=1) > 0][0] + t0 = max(started, truth.t[0], arcore.t[0]) + t1 = min(ark.t[-1], truth.t[-1], arcore.t[-1]) + times = np.arange(t0, t1, 1.0 / SAMPLE_HZ) + A = ark.interpolate_positions(times) + result: dict = {"sequence": number, "seconds": round(float(times[-1] - times[0]), 1)} + failed_at = tracking_failure_time(A, times) + if failed_at is not None: + result["arkit_tracking_failed_at_s"] = round(failed_at, 2) + result["arkit_max_speed_m_s"] = round( + float(np.max(np.linalg.norm(np.diff(A, axis=0), axis=1)) * SAMPLE_HZ), 1 + ) + return result + check = reference_scale_check(root, truth, ark, arcore, times) + result["scale_check"] = asdict(check) + refs = { + "truth": truth.interpolate_positions(times), + "truth_gps": truth.interpolate_positions(times) * check.gps_over_truth, + "arcore": arcore.interpolate_positions(times), + } + ark_R = ark.R[[ark.nearest(x) for x in times]] + truth_R = truth.R[[truth.nearest(x) for x in times]] + step = int(START_EVERY_S * SAMPLE_HZ) + result["walked_m_truth_gps"] = round(float(horizontal_path_length(refs["truth_gps"])[-1]), 1) + # ARKit's own scale on this walk: median displacement ratio over windows of 10 ft or more. + ratios = [] + for ft in DISTANCES_FT: + if ft >= 10: + i, j = window_pairs(refs["truth_gps"], ft * FEET, step) + da = np.linalg.norm(at(A, j) - A[i], axis=1) + dg = np.linalg.norm(at(refs["truth_gps"], j) - refs["truth_gps"][i], axis=1) + ratios.append(da / dg) + scale = float(np.median(np.concatenate(ratios))) + result["arkit_scale_vs_truth_gps"] = round(scale, 4) + result["distance_error"] = {} + result["position_error_truth_gps"] = {} + result["noise_split_in"] = {} + result["errors_in"] = {} + for ft in DISTANCES_FT: + d = ft * FEET + # Windows are defined on the GPS-scaled truth so every reference is scored on the same spans. + i, j = window_pairs(refs["truth_gps"], d, step) + errs = {name: distance_errors(A, P, i, j) for name, P in refs.items()} + result["distance_error"][str(ft)] = {name: _abs_stats_in(e) for name, e in errs.items()} + pe = position_errors(A, refs["truth_gps"], ark_R, truth_R, i, j) + pe_beyond = position_errors(A, refs["truth_gps"], ark_R, truth_R, i, j, scale) + result["position_error_truth_gps"][str(ft)] = _abs_stats_in(pe) + arcore_vs_truth = distance_errors(refs["arcore"], refs["truth_gps"], i, j) + va, vt, vc = three_cornered_hat( + np.var(errs["truth_gps"], ddof=1), + np.var(errs["arcore"], ddof=1), + np.var(arcore_vs_truth, ddof=1), + ) + sig = lambda v: round(float(np.sign(v) * np.sqrt(abs(v)) / INCH), 2) # noqa: E731 + result["noise_split_in"][str(ft)] = { + "arkit": sig(va), + "truth_gps": sig(vt), + "arcore": sig(vc), + } + errs["position_truth_gps"] = pe + errs["position_beyond_scale"] = pe_beyond + result["errors_in"][str(ft)] = {k: (v / INCH).round(2).tolist() for k, v in errs.items()} + return result + + +def _pooled(results: list[dict]) -> dict: + ok = [r for r in results if "errors_in" in r] + out: dict = {"sequences": [r["sequence"] for r in ok]} + for ft in DISTANCES_FT: + out[str(ft)] = {} + for ref in ("truth", "truth_gps", "arcore", "position_truth_gps", "position_beyond_scale"): + e = np.concatenate([np.asarray(r["errors_in"][str(ft)][ref]) for r in ok]) + out[str(ft)][ref] = _abs_stats_in(e * INCH) + return out + + +def _markdown(results: list[dict], pooled: dict) -> str: + lines = ["# ADVIO outdoor drift (generated by `uv run python -m evals.drift`)", ""] + lines.append("## Reference scale check") + lines.append("") + lines.append( + "| Seq | GPS / truth (95% interval) | ARCore / truth | ARKit / truth | ARKit tracking |" + ) + lines.append("| --- | --- | --- | --- | --- |") + for r in results: + if "scale_check" in r: + c = r["scale_check"] + lo, hi = c["gps_interval_95"] + lines.append( + f"| {r['sequence']} | {c['gps_over_truth']:.3f} ({lo:.3f} to {hi:.3f}) | " + f"{c['arcore_over_truth']:.3f} | {c['arkit_over_truth']:.3f} | ok, {r['seconds']:.0f} s |" + ) + else: + lines.append( + f"| {r['sequence']} | | | | failed at {r['arkit_tracking_failed_at_s']} s " + f"(jumped at {r['arkit_max_speed_m_s']} m/s) |" + ) + lines += ["", "## Distance error, pooled over the sequences where ARKit kept tracking", ""] + lines.append( + f"Sequences {', '.join(map(str, pooled['sequences']))}. |ARKit - reference| in inches." + ) + lines.append("") + lines.append( + "| Walked | vs truth as published: median / p90 | vs truth rescaled to GPS | ARKit - ARCore disagreement | signed median, ARKit - ARCore |" + ) + lines.append("| --- | --- | --- | --- | --- |") + for ft in DISTANCES_FT: + p = pooled[str(ft)] + lines.append( + f"| {ft} ft | {p['truth']['median_in']:.1f} / {p['truth']['p90_in']:.1f} | " + f"{p['truth_gps']['median_in']:.1f} / {p['truth_gps']['p90_in']:.1f} | " + f"{p['arcore']['median_in']:.1f} / {p['arcore']['p90_in']:.1f} | {p['arcore']['signed_median_in']:+.1f} |" + ) + lines += [ + "", + "## Position error against the truth rescaled to GPS, pooled", + "", + "|ARKit - truth| of the whole displacement, inches, median / p90: ARKit's displacement is " + "turned by the heading difference at the window's start, so sideways drift counts too. " + "'Scale removed' first divides each walk's own ARKit scale out (its median displacement " + "ratio over windows of 10 ft or more), leaving heading and random drift; the difference " + "between the columns is the scale's share. The last column is the server's allowance of " + "0.16 ft of error per foot. The truth's own random error is large at every distance " + "(noise split below), so these overstate ARKit's.", + "", + "| Walked | As tracked | Scale removed | 0.16 ft/ft allowance |", + "| --- | --- | --- | --- |", + ] + for ft in DISTANCES_FT: + p = pooled[str(ft)] + a, b = p["position_truth_gps"], p["position_beyond_scale"] + lines.append( + f"| {ft} ft | {a['median_in']:.1f} / {a['p90_in']:.1f} | " + f"{b['median_in']:.1f} / {b['p90_in']:.1f} | {0.16 * ft * 12:.1f} |" + ) + scales = ", ".join( + f"{r['sequence']}: {r['arkit_scale_vs_truth_gps']:.3f}" + for r in results + if "arkit_scale_vs_truth_gps" in r + ) + lines += ["", f"ARKit scale against the GPS-rescaled truth, per walk: {scales}."] + lines += ["", "## Per sequence", ""] + lines.append( + "| Seq | Walked | vs truth rescaled to GPS: median / p90 (signed) | ARKit - ARCore disagreement: median / p90 (signed) | split if errors were independent (1 sigma, in): ARKit / truth / ARCore |" + ) + lines.append("| --- | --- | --- | --- | --- |") + for r in results: + if "distance_error" not in r: + continue + for ft in DISTANCES_FT: + de = r["distance_error"][str(ft)] + ns = r["noise_split_in"][str(ft)] + g, a = de["truth_gps"], de["arcore"] + lines.append( + f"| {r['sequence']} | {ft} ft | {g['median_in']:.1f} / {g['p90_in']:.1f} ({g['signed_median_in']:+.1f}) | " + f"{a['median_in']:.1f} / {a['p90_in']:.1f} ({a['signed_median_in']:+.1f}) | " + f"{ns['arkit']:.1f} / {ns['truth_gps']:.1f} / {ns['arcore']:.1f} |" + ) + lines.append("") + lines.append( + "The split assumes the three trackers' errors are independent; a negative value shows that " + "assumption failing for the row, so treat every split value as conditional." + ) + return "\n".join(lines) + "\n" + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--sequences", type=int, nargs="+", default=list(SEQUENCES)) + ap.add_argument("--out", type=Path, default=Path(__file__).resolve().parents[1] / "results") + args = ap.parse_args() + results = [evaluate_sequence(n) for n in args.sequences] + pooled = _pooled(results) + args.out.mkdir(parents=True, exist_ok=True) + slim = [{k: v for k, v in r.items() if k != "errors_in"} for r in results] + (args.out / "advio_drift.json").write_text( + json.dumps({"sequences": slim, "pooled": pooled}, indent=1) + ) + md = _markdown(results, pooled) + (args.out / "advio_drift.md").write_text(md) + print(md) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/eth3d.py b/experiments/evals/evals/eth3d.py new file mode 100644 index 000000000..f3fcecd6c --- /dev/null +++ b/experiments/evals/evals/eth3d.py @@ -0,0 +1,341 @@ +"""ETH3D high-resolution scenes (Schöps et al., CVPR 2017): DSLR images, poses and laser scans. + +Files used, per scene (`/` after extracting the two archives in `datasets.py`): + +- `dslr_calibration_undistorted/cameras.txt`, `images.txt`, `points3D.txt`: COLMAP text model of the + undistorted images. PINHOLE cameras; each image line holds the world-to-camera rotation (QW, QX, + QY, QZ) and translation, OpenCV camera axes (+x right, +y down, +z forward), and pixel centres + at half-integers (COLMAP's convention; `read_views` converts to OpenCV's). Units are meters, + because the images were registered to the laser scans. +- `images/dslr_images_undistorted/*.JPG`: the undistorted images, about 6200 x 4130 px. +- `dslr_scan_eval/scan*.ply` and `scan_alignment.mlp`: the laser scans and the 4x4 matrix that + takes each scan into the images' world frame. + +ETH3D also publishes rendered depth maps, but they match the original distorted images, whose +camera model is not in these archives, so this code scores against the scan points directly: +`visible_scan_points` projects them into a view and keeps the ones on the visible surface. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np +from scipy.spatial import cKDTree + +from evals.geometry import quat_wxyz_to_matrix +from evals.paths import ETH3D_DIR + +SCENES = ("facade", "electro") +VOXEL_M = 0.01 + + +@dataclass(frozen=True) +class View: + name: str # e.g. "DSC_9278" + image_path: Path + width: int + height: int + K: np.ndarray # 3x3, OpenCV convention, full resolution + R_wc: np.ndarray # world-to-camera rotation + t_wc: np.ndarray # world-to-camera translation + + @property + def center(self) -> np.ndarray: + return -self.R_wc.T @ self.t_wc + + @property + def cam_to_world(self) -> np.ndarray: + T = np.eye(4) + T[:3, :3] = self.R_wc.T + T[:3, 3] = self.center + return T + + @property + def forward(self) -> np.ndarray: + """Viewing direction in the world frame.""" + return self.R_wc.T @ np.array([0.0, 0.0, 1.0]) + + def scaled_K(self, width: int, height: int) -> np.ndarray: + """Intrinsics for the image resized to width x height (OpenCV integer-centred pixels).""" + sx, sy = width / self.width, height / self.height + K = self.K.copy() + K[0, 0] *= sx + K[1, 1] *= sy + K[0, 2] = (K[0, 2] + 0.5) * sx - 0.5 + K[1, 2] = (K[1, 2] + 0.5) * sy - 0.5 + return K + + +def read_views(scene_dir: Path) -> list[View]: + cal = scene_dir / "dslr_calibration_undistorted" + cams = {} + for line in (cal / "cameras.txt").read_text().splitlines(): + if not line or line.startswith("#"): + continue + cid, model, w, h, *params = line.split() + if model != "PINHOLE": + raise ValueError(f"{cal}/cameras.txt: camera {cid} is {model}, expected PINHOLE") + fx, fy, cx, cy = map(float, params) + # COLMAP puts the first pixel's centre at (0.5, 0.5); OpenCV, used from here on, at (0, 0). + cx, cy = cx - 0.5, cy - 0.5 + cams[cid] = (int(w), int(h), np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1.0]])) + views = [] + lines = [x for x in (cal / "images.txt").read_text().splitlines() if not x.startswith("#")] + # Two lines per image: the pose line, then its 2-D observations. + for pose_line in lines[0::2]: + if not pose_line.strip(): + continue + f = pose_line.split() + q = np.array(list(map(float, f[1:5]))) + t = np.array(list(map(float, f[5:8]))) + w, h, K = cams[f[8]] + rel = f[9] + views.append( + View( + name=Path(rel).stem, + image_path=scene_dir / "images" / rel, + width=w, + height=h, + K=K, + R_wc=quat_wxyz_to_matrix(q), + t_wc=t, + ) + ) + return sorted(views, key=lambda v: v.name) + + +def read_sparse_points(scene_dir: Path) -> np.ndarray: + """COLMAP's triangulated points (N, 3), world frame.""" + rows = [] + for line in ( + (scene_dir / "dslr_calibration_undistorted" / "points3D.txt").read_text().splitlines() + ): + if line and not line.startswith("#"): + rows.append(list(map(float, line.split()[1:4]))) + return np.asarray(rows) + + +def read_alignment(mlp: Path) -> dict[str, np.ndarray]: + """scan file name -> 4x4 matrix into the images' world frame, from a MeshLab project.""" + text = mlp.read_text() + out = {} + for name, body in re.findall( + r'filename="([^"]+)".*?(.*?)', text, re.S + ): + M = np.array(body.split(), dtype=np.float64).reshape(4, 4) + out[name] = M + if not out: + raise ValueError(f"{mlp}: no MLMesh matrices found") + return out + + +def read_ply_xyz(path: Path) -> np.ndarray: + """Vertex positions from a binary little-endian PLY whose vertex properties are all float32.""" + with path.open("rb") as fh: + header = [] + while True: + line = fh.readline().decode("ascii").strip() + header.append(line) + if line == "end_header": + break + if "format binary_little_endian 1.0" not in header: + raise ValueError(f"{path}: expected binary_little_endian PLY, header {header[:3]}") + n = None + props: list[str] = [] + in_vertex = False + for line in header: + if line.startswith("element"): + in_vertex = line.split()[1] == "vertex" + if in_vertex: + n = int(line.split()[2]) + elif line.startswith("property") and in_vertex: + kind, name = line.split()[1], line.split()[2] + if kind != "float": + raise ValueError(f"{path}: vertex property {name} is {kind}, expected float") + props.append(name) + if n is None or props[:3] != ["x", "y", "z"]: + raise ValueError(f"{path}: vertex element missing or not starting with x, y, z") + data = np.fromfile(fh, dtype=" np.ndarray: + """One point per occupied voxel: the mean of the points in it.""" + keys = np.floor(points / voxel).astype(np.int64) + _, inverse, counts = np.unique(keys, axis=0, return_inverse=True, return_counts=True) + inverse = inverse.ravel() + sums = np.zeros((len(counts), 3)) + np.add.at(sums, inverse, points) + return sums / counts[:, None] + + +def scan_points(scene: str, voxel: float = VOXEL_M) -> np.ndarray: + """All of a scene's laser scans in the image world frame, voxel-downsampled, cached as .npy.""" + scene_dir = ETH3D_DIR / scene + cache = scene_dir / f"scan_points_{round(voxel * 1000)}mm.npy" + if cache.exists(): + return np.load(cache) + eval_dir = scene_dir / "dslr_scan_eval" + align = read_alignment(eval_dir / "scan_alignment.mlp") + parts = [] + for name, M in sorted(align.items()): + xyz = read_ply_xyz(eval_dir / name) + world = xyz @ M[:3, :3].T + M[:3, 3] + parts.append(voxel_downsample(world, voxel)) + pts = voxel_downsample(np.concatenate(parts), voxel).astype(np.float32) + np.save(cache, pts) + return pts + + +def occluder_points(scene: str, voxel: float = 0.02) -> np.ndarray: + """Vertices of ETH3D's occlusion splats (`occlusion/splats.ply`): surfaces the scanner missed + that still block the view, such as vegetation. They enter the visibility test as blockers only, + never as ground truth. Voxel-downsampled and cached as .npy.""" + scene_dir = ETH3D_DIR / scene + cache = scene_dir / f"occluders_{round(voxel * 1000)}mm.npy" + if cache.exists(): + return np.load(cache) + pts = voxel_downsample(read_ply_xyz(scene_dir / "occlusion" / "splats.ply"), voxel) + np.save(cache, pts.astype(np.float32)) + return pts.astype(np.float32) + + +def excluded_pixels( + view: View, width: int, height: int, dilate: int = 13, labels: tuple[int, ...] = (1, 2) +) -> np.ndarray: + """Pixels ETH3D masks out with one of `labels`, in the undistorted image resized to width x height. + + ETH3D's masks (`masks_for_images/dslr_images/*.png`, value 1 for glass, 2 for objects missing + from the scan such as trees, trams and people) are drawn on the original distorted images, whose + camera model is not in these archives. The undistorted image keeps the distorted image's whole + field of view (content reaches every border), so the mask is stretched to fill the frame: exact + at the centre and at the borders. In between, lens distortion displaces it by up to about 13 + pixels at 1024 wide, which the dilation covers. + """ + path = view.image_path.parents[2] / "masks_for_images" / "dslr_images" / f"{view.name}.png" + if not path.exists(): + # ETH3D ships no mask for an image with nothing to exclude. + return np.zeros((height, width), bool) + mask = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) + if mask is None: + raise ValueError(f"{path}: unreadable mask") + out = cv2.resize( + np.isin(mask, labels).astype(np.uint8), (width, height), interpolation=cv2.INTER_NEAREST + ) + if dilate: + out = cv2.dilate(out, np.ones((2 * dilate + 1, 2 * dilate + 1), np.uint8)) + return out.astype(bool) + + +@dataclass(frozen=True) +class VisiblePoints: + index: np.ndarray # (N,) indices into the evaluation subset of scan points + uv: np.ndarray # (N, 2) pixel coordinates in the resized image, OpenCV integer-centred + edge: np.ndarray # (N,) True on the near side of a depth edge (window, equipment, fence edges) + + +def visible_scan_points( + blockers: np.ndarray, + candidates: np.ndarray, + view: View, + width: int, + height: int, + excluded: np.ndarray | None = None, + zbuffer_scale: float = 0.5, + window: int = 5, + tolerance: float = 0.04, + edge_range: float = 0.10, +) -> VisiblePoints: + """Which `candidates` (scan points used for scoring) lie on the visible surface of a view. + + Visibility: every blocker (all scan points plus occluders) is splatted into a z-buffer at + `zbuffer_scale` of the resized image, keeping the nearest depth per cell; holes are closed with + a `window` x `window` minimum filter; a candidate is kept when its depth is within `tolerance` + (relative) of the filtered buffer, so points behind a wall seen through a gap in the splat fail. + Kept candidates where the buffer's depth varies by more than `edge_range` (relative) inside the + window are flagged `edge`: they sit on the near side of a depth edge, where a clearance is + usually measured from, and where one pixel can mix two surfaces. Candidates on `excluded` + pixels are dropped. + """ + K = view.scaled_K(width, height) + zw, zh = max(1, round(width * zbuffer_scale)), max(1, round(height * zbuffer_scale)) + + def project(pts: np.ndarray): + Xc = pts @ view.R_wc.T.astype(pts.dtype) + view.t_wc.astype(pts.dtype) + z = Xc[:, 2] + with np.errstate(divide="ignore", invalid="ignore"): + u = K[0, 0] * Xc[:, 0] / z + K[0, 2] + v = K[1, 1] * Xc[:, 1] / z + K[1, 2] + ok = (z > 0.1) & (u >= 0) & (u <= width - 1) & (v >= 0) & (v <= height - 1) + return u, v, z, ok + + u, v, z, ok = project(blockers) + u, v, z = u[ok], v[ok], z[ok] + cu = np.clip(((u + 0.5) * zbuffer_scale).astype(np.int64), 0, zw - 1) + cv_ = np.clip(((v + 0.5) * zbuffer_scale).astype(np.int64), 0, zh - 1) + zbuf = np.full((zh, zw), np.inf, dtype=np.float32) + np.minimum.at(zbuf, (cv_, cu), z.astype(np.float32)) + kernel = np.ones((window, window), np.uint8) + zmin = cv2.erode(np.where(np.isfinite(zbuf), zbuf, np.float32(1e6)), kernel) + zmax = cv2.dilate(np.where(np.isfinite(zbuf), zbuf, np.float32(0)), kernel) + + u, v, z, ok = project(candidates) + idx = np.flatnonzero(ok) + u, v, z = u[ok], v[ok], z[ok] + cu = np.clip(((u + 0.5) * zbuffer_scale).astype(np.int64), 0, zw - 1) + cv_ = np.clip(((v + 0.5) * zbuffer_scale).astype(np.int64), 0, zh - 1) + near = zmin[cv_, cu] + keep = z <= near * (1 + tolerance) + if excluded is not None: + keep &= ~excluded[np.round(v).astype(int), np.round(u).astype(int)] + edge = (zmax[cv_, cu] - near) > edge_range * near + return VisiblePoints( + index=idx[keep].astype(np.int64), uv=np.c_[u[keep], v[keep]], edge=edge[keep] + ) + + +def point_normals(points: np.ndarray, k: int = 12, chunk: int = 200_000) -> np.ndarray: + """Unit surface normal per point from the smallest principal axis of its k nearest neighbours + (sign arbitrary). Chunked so memory stays near a few hundred MB for 1.5 M points.""" + tree = cKDTree(points) + normals = np.empty_like(points, dtype=np.float64) + for start in range(0, len(points), chunk): + block = points[start : start + chunk].astype(np.float64) + _, nn = tree.query(block, k=k) + nbrs = points[nn].astype(np.float64) + centred = nbrs - nbrs.mean(axis=1, keepdims=True) + cov = np.einsum("nki,nkj->nij", centred, centred) + _, vecs = np.linalg.eigh(cov) + normals[start : start + chunk] = vecs[:, :, 0] + return normals + + +def up_direction(views: list[View]) -> np.ndarray: + """World up, as the mean of the cameras' up axes: DSLR photos are taken held level.""" + ups = np.array([v.R_wc.T @ np.array([0.0, -1.0, 0.0]) for v in views]) + up = ups.mean(axis=0) + return up / np.linalg.norm(up) + + +def resized_image(view: View, width: int) -> tuple[np.ndarray, int, int]: + """The view's image resized to `width` (height by aspect), cached as JPEG next to the scene.""" + height = round(view.height * width / view.width) + cache = view.image_path.parents[2] / f"images_{width}" / f"{view.name}.jpg" + if cache.exists(): + img = cv2.imread(str(cache)) + else: + full = cv2.imread(str(view.image_path)) + if full is None: + raise FileNotFoundError(view.image_path) + if full.shape[:2] != (view.height, view.width): + raise ValueError( + f"{view.image_path}: {full.shape[:2]} != calibration {view.height}x{view.width}" + ) + img = cv2.resize(full, (width, height), interpolation=cv2.INTER_AREA) + cache.parent.mkdir(exist_ok=True) + cv2.imwrite(str(cache), img, [cv2.IMWRITE_JPEG_QUALITY, 95]) + return img, width, height diff --git a/experiments/evals/evals/field.py b/experiments/evals/evals/field.py new file mode 100644 index 000000000..888301ecd --- /dev/null +++ b/experiments/evals/evals/field.py @@ -0,0 +1,631 @@ +"""Learned-depth rows and the AR scale error for one Measure Lab field session. + + uv run python -m evals.field prepare SESSION.zip # upright keyframes for MoGe-2 + (MoGe-2 on those keyframes: see the Makefile target `field`) + uv run python -m evals.field score SESSION.zip [--truth survey.json --map map.json --rules rules.json] + +Every point the rig made from taps (ground, wall and two-view points) gets a second position from +learned depth: MoGe-2's depth at the tapped pixel of the tapped keyframe, back-projected with that +keyframe's AR pose. Walls and measurement values are then recomputed exactly as Measure Lab +computes them (`Wall.swift`, `Measurements.swift`), so each survey measurement the team's map ties +to a session measurement gets a learned-depth value for the same quantity. Three rows: + +- `moge2`: MoGe-2's own metric scale. +- `moge2-triangulated`: each keyframe's depth rescaled to features triangulated with the session's + AR poses across it and its 7 nearest keyframes (`evals.triangulate`, method (b) of + `evals.pose_priors`). +- `moge2-tape`: one scale for the whole session, making the survey's scale reference come out at + its taped length (only if the map ties the scale reference to a session measurement). + +Rows are written as scoring-harness results files (experiments/scoring/README.md on +t3/scoring-harness). They state no uncertainty (`plus_minus_ft` null) because no error bar for them +has been validated, so they make no decisions. The AR scale error is the rig's own values against +the tape, over spans of at least 10 ft, where scale dominates tapping error. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import time +import zipfile +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + +from evals.pairs import scale_for_length +from evals.paths import EVALS_DIR +from evals.recon import camera_points, load_prediction +from evals.triangulate import view_scales + +FEET = 0.3048 +UP = np.array([0.0, 1.0, 0.0]) +ARKIT_TO_OPENCV = np.diag([1.0, -1.0, -1.0]) +NEIGHBOURS = 7 +SCALE_SPAN_MIN_FT = 10.0 +# The scale needs one long span: 2 in of tapping error is 1.7% of 10 ft but 0.6% of 30 ft. A nominal +# 30 ft span taped a little short still counts. +REQUIRED_SPAN_FT = 29.0 +TAP_ERROR_IN = 2.0 # assumed per-span tapping error until the field test measures it +SCALE_KEYS = ("straight", "horizontal", "alongWall") +FIELD_DIR = EVALS_DIR / "field" + + +# --- Session --------------------------------------------------------------------------------- + + +def unpack(session: Path) -> tuple[Path, str | None]: + """The session folder, and the zip's sha256 (the scoring harness's capture id) if zipped.""" + if session.is_dir(): + return session, None + digest = hashlib.sha256(session.read_bytes()).hexdigest() + out = FIELD_DIR / digest[:16] + with zipfile.ZipFile(session) as z: + names = [n for n in z.namelist() if not n.startswith("__MACOSX/")] + roots = [n for n in names if n.split("/")[-1] == "session.json" and n.count("/") <= 1] + if len(roots) != 1: + raise ValueError(f"{session}: expected one session.json, found {roots}") + if not (out / roots[0]).exists(): + z.extractall(out, members=names) + return (out / roots[0]).parent, digest + + +def load_session(folder: Path) -> dict: + data = json.loads((folder / "session.json").read_text()) + if data.get("format") != "measure-lab-session" or data.get("formatVersion") != 2: + raise ValueError(f"{folder}: not a Measure Lab session, format 2") + return data + + +def keyframe_pose_cv(kf: dict) -> np.ndarray: + """Camera-to-world with OpenCV camera axes, from the session's ARKit pose (column-major).""" + T = np.array(kf["pose"], dtype=np.float64).reshape(4, 4).T + T[:3, :3] = T[:3, :3] @ ARKIT_TO_OPENCV + return T + + +def keyframe_K_cv(kf: dict) -> np.ndarray: + """Intrinsics with OpenCV's integer pixel centres (the session's are continuous).""" + fx, fy, cx, cy = kf["intrinsics"] + return np.array([[fx, 0, cx - 0.5], [0, fy, cy - 0.5], [0, 0, 1.0]]) + + +# --- Upright images for the model ------------------------------------------------------------ + + +def upright_turns(T_cv: np.ndarray) -> int: + """np.rot90 turns (counter-clockwise) that put world up at the top of this keyframe's image. + + Session images are unrotated sensor images, sideways for a phone held upright, and depth + models are trained on upright photos.""" + up = T_cv[:3, :3].T @ UP # world up in OpenCV camera axes; image "up" is -y + x, y = up[0], -up[1] + if abs(y) >= abs(x): + return 0 if y > 0 else 2 + return 1 if x > 0 else 3 + + +def rotated_K(K: np.ndarray, w: int, h: int, turns: int) -> np.ndarray: + """Intrinsics of the image turned by np.rot90(image, turns), for a w x h original.""" + fx, fy, cx, cy = K[0, 0], K[1, 1], K[0, 2], K[1, 2] + new = { + 0: (fx, fy, cx, cy), + 1: (fy, fx, cy, w - 1 - cx), + 2: (fx, fy, w - 1 - cx, h - 1 - cy), + 3: (fy, fx, h - 1 - cy, cx), + }[turns % 4] + return np.array([[new[0], 0, new[2]], [0, new[1], new[3]], [0, 0, 1.0]]) + + +SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") + + +def safe_id(value: str, what: str) -> str: + """An id from the session that becomes a file name: one plain path component, never an + absolute path or `..`, so a crafted session cannot write outside the work folder.""" + if not isinstance(value, str) or not SAFE_ID.fullmatch(value) or value in (".", ".."): + raise ValueError(f"{what} {value!r} is not a plain name (letters, digits, . _ -)") + return value + + +def work_dir(folder: Path) -> Path: + return FIELD_DIR / "work" / safe_id(load_session(folder)["session"]["id"], "session id") + + +def prepare(folder: Path) -> Path: + """Upright copies of every keyframe plus the model runner's image list and intrinsics.""" + session = load_session(folder) + out = work_dir(folder) + (out / "upright").mkdir(parents=True, exist_ok=True) + listing, intrinsics, turns = [], {}, {} + for kf in session["keyframes"]: + img = cv2.imread(str(folder / kf["img"])) + if img is None: + raise FileNotFoundError(folder / kf["img"]) + k = upright_turns(keyframe_pose_cv(kf)) + path = out / "upright" / f"{safe_id(kf['id'], 'keyframe id')}.jpg" + cv2.imwrite(str(path), np.rot90(img, k), [cv2.IMWRITE_JPEG_QUALITY, 95]) + Kr = rotated_K(keyframe_K_cv(kf), img.shape[1], img.shape[0], k) + listing.append(str(path)) + intrinsics[str(path)] = [Kr[0, 0], Kr[1, 1], Kr[0, 2], Kr[1, 2]] + turns[kf["id"]] = k + (out / "images.txt").write_text("\n".join(listing) + "\n") + (out / "intrinsics.json").write_text(json.dumps(intrinsics, indent=1)) + (out / "turns.json").write_text(json.dumps(turns, indent=1)) + return out + + +def keyframe_depth(out: Path, kid: str, turns: int) -> np.ndarray: + """MoGe-2's depth for a keyframe, turned back to the session's sensor orientation.""" + depth, _, _ = load_prediction(out / "moge2" / f"{kid}.npz") + return np.rot90(depth, -turns) + + +# --- Scale per keyframe ------------------------------------------------------------------------ + + +def neighbours(poses: dict[str, np.ndarray], kid: str, n: int = NEIGHBOURS) -> list[str]: + """The keyframe and its n nearest keyframes looking the same way (within 60 degrees).""" + T = poses[kid] + fwd = T[:3, 2] + near = [k for k in poses if k != kid and poses[k][:3, 2] @ fwd > 0.5] + near.sort(key=lambda k: np.linalg.norm(poses[k][:3, 3] - T[:3, 3])) + return [kid, *near[:n]] + + +def triangulated_scales(folder: Path, out: Path, session: dict, kids: list[str]) -> dict: + """{keyframe: ScaleFit} for the given keyframes, each fitted within its neighbourhood.""" + kfs = {kf["id"]: kf for kf in session["keyframes"]} + turns = json.loads((out / "turns.json").read_text()) + poses = {k: keyframe_pose_cv(kf) for k, kf in kfs.items()} + cache: dict[str, tuple] = {} + + def inputs(k): + if k not in cache: + gray = cv2.imread(str(folder / kfs[k]["img"]), cv2.IMREAD_GRAYSCALE) + cache[k] = (gray, keyframe_K_cv(kfs[k]), keyframe_depth(out, k, turns[k])) + return cache[k] + + fits = {} + for kid in kids: + group = neighbours(poses, kid) + if len(group) < 2: + continue + fits[kid] = view_scales( + {k: inputs(k)[0] for k in group}, + {k: inputs(k)[1] for k in group}, + {k: poses[k] for k in group}, + {k: inputs(k)[2] for k in group}, + )[kid] + return fits + + +# --- Points, walls, values (Measure Lab's definitions) ------------------------------------------ + + +def tap_ray(kf: dict, pixel: list[float], depth: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """A tapped pixel as (camera centre, offset to the surface) in the world, from depth; the + offset is NaN if the depth there is invalid. Scaling depth by s moves the point to c + s r.""" + uv = np.array([[pixel[0] - 0.5, pixel[1] - 0.5]]) # continuous -> OpenCV pixel centres + pc = camera_points(depth, keyframe_K_cv(kf), uv)[0] + T = keyframe_pose_cv(kf) + return T[:3, 3], T[:3, :3] @ pc + + +def learned_points(session: dict, depth_of, scale_of) -> dict[str, tuple[np.ndarray, np.ndarray]]: + """{point id: (centre, offset)}, each the mean over the point's taps, with each tap's depth + scaled by `scale_of(keyframe)` (None: no scale, the point is NaN).""" + kfs = {kf["id"]: kf for kf in session["keyframes"]} + taps = {t["id"]: t for t in session["taps"]} + out = {} + for p in session["points"]: + centres, offsets = [], [] + for tid in p["taps"]: + tap = taps[tid] + s = scale_of(tap["keyframe"]) + c, r = tap_ray(kfs[tap["keyframe"]], tap["pixel"], depth_of(tap["keyframe"])) + centres.append(c) + offsets.append(r * s if s is not None else np.full(3, np.nan)) + out[p["id"]] = (np.mean(centres, axis=0), np.mean(offsets, axis=0)) + return out + + +def positions(points: dict, s: float = 1.0) -> dict[str, np.ndarray]: + """Point positions with every depth scaled by s: centre + s offset.""" + return {pid: c + s * r for pid, (c, r) in points.items()} + + +def tape_scale(session: dict, points: dict, measurement_id: str, key: str, meters: float) -> float: + """The one depth scale that makes the scale reference come out at its taped length. + + Scaling depth moves each point along its ray from its own keyframe's camera, and camera + positions do not scale, so this solves |dc + s dr| = tape rather than dividing lengths (exact + only when both taps share a keyframe).""" + m = {x["id"]: x for x in session["measurements"]}[measurement_id] + if key != "straight" or m["to"] not in points: + raise ValueError( + f"scale reference {measurement_id} must be a point-to-point straight distance " + f"(got {key} to {m['to']}); measure the two tape marks with Measure, straight" + ) + (ca, ra), (cb, rb) = points[m["from"]], points[m["to"]] + return scale_for_length(cb - ca, rb - ra, meters) + + +def wall_frame(c1: np.ndarray, c2: np.ndarray, camera: np.ndarray) -> dict: + """Measure Lab's wall: direction u (horizontal c1 -> c2), normal u x up toward the camera.""" + run = c2 - c1 + run[1] = 0.0 + length = float(np.linalg.norm(run)) + u = run / length + n = np.cross(u, UP) + if n @ (camera - c1) < 0: + n = -n + return {"start": c1, "end": c2, "u": u, "n": n, "length": length} + + +def values(a: np.ndarray, target, reference: dict | None) -> dict[str, float]: + """Measure Lab's `measuredValues`: point to point, or point to wall.""" + if isinstance(target, dict): + w = target + along = w["u"] @ (a - w["start"]) + ground = w["start"][1] + (w["end"][1] - w["start"][1]) * along / w["length"] + return {"gapToWall": abs(w["n"] @ (a - w["start"])), "heightAboveGround": a[1] - ground} + d = target - a + out = { + "straight": float(np.linalg.norm(d)), + "horizontal": float(np.hypot(d[0], d[2])), + "vertical": abs(float(d[1])), + } + if reference is not None: + out["alongWall"] = abs(float(reference["u"] @ d)) + return out + + +def learned_values(session: dict, points: dict[str, np.ndarray]) -> dict[str, dict[str, float]]: + """{session measurement id: values} recomputed from learned-depth points.""" + walls = {} + for w in session["walls"]: + c1, c2 = (points[c] for c in w["contacts"][:2]) + walls[w["id"]] = wall_frame(c1, c2, np.array(w["cameraPosition"], dtype=np.float64)) + out = {} + for m in session["measurements"]: + target = walls[m["to"]] if m["to"] in walls else points[m["to"]] + reference = walls.get(m.get("referenceWall")) if m.get("referenceWall") else None + out[m["id"]] = values(points[m["from"]], target, reference) + return out + + +# --- Scoring-harness files -------------------------------------------------------------------- + + +def feet(meters: float) -> float: + return round(meters / FEET, 6) + + +def results_file( + pipeline: str, + scale_source: str, + capture: str, + rules_sha256: str, + truth: dict, + mapping: dict, + recomputed: dict[str, dict[str, float]], + capture_s: float | None, + processing_s: float, +) -> dict: + rows = [] + for m in truth["measurements"]: + if m["id"] == truth["scale_reference"]: + continue # optional in a results file, and never scored + entry = mapping["measurements"].get(m["id"]) + if entry in ("absent", "unsupported"): + rows.append({"id": m["id"], "value_ft": None, "missing": entry}) + elif not isinstance(entry, dict) or "session_measurement" not in entry: + # A rig refusal, or no entry: nothing this pipeline can recompute. + rows.append({"id": m["id"], "value_ft": None, "missing": "unsupported"}) + else: + v = recomputed[entry["session_measurement"]].get(entry["key"], np.nan) + if not np.isfinite(v) or v < 0: + rows.append({"id": m["id"], "value_ft": None, "missing": "failed"}) + else: + rows.append({"id": m["id"], "value_ft": feet(v), "plus_minus_ft": None}) + return { + "format": 1, + "unit": "ft", + "pipeline": pipeline, + "capture": capture, + "rules_sha256": rules_sha256, + "scale_source": scale_source, + "measurements": rows, + "outcomes": None, + "timing": {"capture_s": capture_s, "processing_s": round(processing_s, 3)}, + } + + +def capture_seconds(session: dict) -> float | None: + """As the harness's importer computes it, so rows on one capture agree.""" + times = [m["time"] for m in session["measurements"]] + if not times: + return None + return round(max(times) - session["session"]["startedAtUptime"], 3) + + +@dataclass(frozen=True) +class ScaleEstimate: + scale: float # AR / tape + bound: float # 95% half-width on `scale` + spans: int + longest_ft: float + + +def fit_scale(ar_ft: np.ndarray, tape_ft: np.ndarray, sigma_ft: np.ndarray) -> ScaleEstimate: + """Weighted least squares for ar = scale * tape with independent errors sigma per span: + scale = sum(w a t) / sum(w t^2), w = 1 / sigma^2, variance 1 / sum(w t^2). Longer spans weigh + more, because a tapping error is about the same size on any span.""" + w = 1 / sigma_ft**2 + scale = float(np.sum(w * ar_ft * tape_ft) / np.sum(w * tape_ft**2)) + bound = float(1.96 / np.sqrt(np.sum(w * tape_ft**2))) + return ScaleEstimate(scale, bound, len(tape_ft), float(tape_ft.max())) + + +def within(error_pct: float, bound_pct: float, limit_pct: float) -> str: + """The strict rule: yes only when the error plus its bound is inside the limit.""" + if abs(error_pct) + bound_pct < limit_pct: + return "yes" + if abs(error_pct) - bound_pct > limit_pct: + return "no" + return "cannot tell" + + +def ar_scale_report(session: dict, truth: dict, mapping: dict, tap_error_in: float) -> list[str]: + """The rig's values against the tape, and the AR scale error with its bound. + + Only measurements the rig accepted count (as in the scoring harness's importer). Each span's + error is the tape's own plus-minus and an assumed tapping error of `tap_error_in` per span, in + quadrature; the tapping error is an assumption until the field test measures it. + """ + rig = {m["id"]: m for m in session["measurements"]} + tape = {m["id"]: m for m in truth["measurements"] if m["status"] == "measured"} + lines = [ + "| Survey measurement | Key | Tape (ft) | AR (ft) | AR / tape | 95% bound | Used for scale |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + ar, tp, sig = [], [], [] + for sid, entry in mapping["measurements"].items(): + if not isinstance(entry, dict) or "session_measurement" not in entry or sid not in tape: + continue + m = rig[entry["session_measurement"]] + t = tape[sid]["value_ft"] + if not m["accepted"]: + lines.append( + f"| {sid} | {entry['key']} | {t:.3f} | | | | no: the rig did not accept it |" + ) + continue + a = m["values"][entry["key"]] / FEET + sigma = float(np.hypot(tap_error_in / 12, tape[sid]["plus_minus_ft"])) + use = entry["key"] in SCALE_KEYS and t >= SCALE_SPAN_MIN_FT + reason = "yes" if use else f"no: not a straight span of {SCALE_SPAN_MIN_FT:g} ft or more" + # A zero tape value (the return-to-reference gap) has no ratio. + ratio = f"{a / t:.4f} | ±{1.96 * sigma / t:.4f}" if t > 0 else " | " + lines.append(f"| {sid} | {entry['key']} | {t:.3f} | {a:.3f} | {ratio} | {reason} |") + if use: + ar.append(a) + tp.append(t) + sig.append(sigma) + lines.append("") + if not tp or max(tp) < REQUIRED_SPAN_FT: + return [ + *lines, + f"AR scale error: not resolved. It needs an accepted span of about 30 ft " + f"(at least {REQUIRED_SPAN_FT:g} ft); see the field checklist.", + ] + est = fit_scale(np.array(ar), np.array(tp), np.array(sig)) + err, bound = 100 * (est.scale - 1), 100 * est.bound + return [ + *lines, + f"AR scale error: {err:+.2f}% ± {bound:.2f}% (95%, {est.spans} spans, longest " + f"{est.longest_ft:.1f} ft, weighted by length; tapping error assumed {tap_error_in:g} in " + f"per span). Within 2%: {within(err, bound, 2.0)}.", + ] + + +# --- Stamping the team's survey and map for one session ------------------------------------ + + +TAPE_FORMAT = re.compile(r"(\d+(?:\.\d+)?)(?:\s+(\d+(?:\.\d+)?))?(?:\s+(\d+)/(\d+))?") + + +def parse_tape(text: str) -> float: + """A tape reading as written on the sheet: feet, then optional whole inches, then an optional + fraction of an inch ("30 2 1/4", "4 11", "12"), in feet rounded to a millionth. Anything else, + including a fraction that is not last, is refused rather than partly read.""" + m = TAPE_FORMAT.fullmatch(text.strip()) + if not m: + raise ValueError(f"tape reading {text!r}: write feet, inches, fraction, e.g. '30 2 1/4'") + feet = float(m[1]) + inches = float(m[2]) if m[2] else 0.0 + if m[3]: + if int(m[4]) == 0: + raise ValueError(f"tape reading {text!r}: a fraction cannot have 0 below the line") + inches += int(m[3]) / int(m[4]) + if inches >= 12: + raise ValueError(f"tape reading {text!r}: inches must be under 12") + return round(feet + inches / 12, 6) + + +def stamp(session_path: Path, truth_path: Path, map_path: Path, out_dir: Path) -> Path: + """Makes the team's files match this session: tape readings typed as text become feet and the + zip's sha256 joins the survey's captures (both in place), and a copy of the map naming this + session goes to out_dir/inputs/map.json, which is returned.""" + folder, capture = unpack(session_path) + if capture is None: + raise ValueError( + f"{session_path}: pass the zip Measure Lab shared; its sha256 is the capture id" + ) + session_id = load_session(folder)["session"]["id"] + survey = json.loads(truth_path.read_text()) + for m in survey["measurements"]: + if m["status"] == "measured" and isinstance(m.get("value_ft"), str): + if "FILL" in m["value_ft"]: + raise ValueError( + f"{truth_path}: {m['id']} still says {m['value_ft']!r}; type the tape reading" + ) + m["value_ft"] = parse_tape(m["value_ft"]) + print(f"{m['id']}: {m['value_ft']} ft", file=sys.stderr) + if capture not in survey["captures"]: + survey["captures"].append(capture) + print(f"survey captures += {capture}", file=sys.stderr) + truth_path.write_text(json.dumps(survey, indent=2) + "\n") + mapping = json.loads(map_path.read_text()) + mapping["session"] = session_id + stamped = out_dir / "inputs" / "map.json" + stamped.parent.mkdir(parents=True, exist_ok=True) + stamped.write_text(json.dumps(mapping, indent=2) + "\n") + return stamped + + +# --- Command ---------------------------------------------------------------------------------- + + +ROW_FILES = ("moge2.json", "moge2-triangulated.json", "moge2-tape.json", "measure-lab.json") + + +def score( + session_path: Path, + truth_path, + map_path, + rules_path, + out_dir: Path, + tap_error_in: float = TAP_ERROR_IN, +) -> str: + # First: a re-run, even one that stops early below, must not leave an earlier run's rows + # for `score` to pick up. + out_dir.mkdir(parents=True, exist_ok=True) + for name in ROW_FILES: + (out_dir / name).unlink(missing_ok=True) + folder, capture = unpack(session_path) + session = load_session(folder) + out = work_dir(folder) + turns = json.loads((out / "turns.json").read_text()) + started = time.perf_counter() + + tapped = sorted({t["keyframe"] for t in session["taps"]}) + kids = tapped or [kf["id"] for kf in session["keyframes"]] + fits = triangulated_scales(folder, out, session, kids) + depth_cache: dict[str, np.ndarray] = {} + + def depth_of(k): + if k not in depth_cache: + depth_cache[k] = keyframe_depth(out, k, turns[k]) + return depth_cache[k] + + tri = [f.scale for f in fits.values() if f.scale is not None] + lines = [ + f"# Field session {session['session']['id']} (generated by `uv run python -m evals.field score`)", + "", + f"- Keyframes: {len(session['keyframes'])}; taps: {len(session['taps'])}; points: " + f"{len(session['points'])}; walls: {len(session['walls'])}; measurements: " + f"{len(session['measurements'])}.", + f"- Triangulation scale fitted for {len(tri)} of {len(fits)} " + f"{'tapped ' if tapped else ''}keyframes" + + ( + f": MoGe-2 x {np.median(tri):.3f} median (range {min(tri):.3f} to {max(tri):.3f})." + if tri + else "." + ), + ] + native = learned_points(session, depth_of, lambda k: 1.0) + triangulated = learned_points(session, depth_of, lambda k: fits[k].scale if k in fits else None) + rows = { + "moge2": ("native_metric", learned_values(session, positions(native))), + "moge2-triangulated": ("ar_poses", learned_values(session, positions(triangulated))), + } + missing = [] + if not session["measurements"]: + missing.append( + "measurements: the session has no taps or measurements, so no row has values" + ) + if truth_path is None or map_path is None or rules_path is None: + missing.append("a tape survey, its map and the rules file: no results files are written") + if capture is None: + missing.append("the session zip: its sha256 is the capture id the survey must list") + if missing: + lines += ["", "Missing:", *[f"- {m}" for m in missing]] + return "\n".join(lines) + "\n" + + truth = json.loads(Path(truth_path).read_text()) + mapping = json.loads(Path(map_path).read_text()) + rules_sha = hashlib.sha256(Path(rules_path).read_bytes()).hexdigest() + ref = mapping["measurements"].get(truth["scale_reference"]) + tape = {m["id"]: m for m in truth["measurements"]}.get(truth["scale_reference"]) + if ( + isinstance(ref, dict) + and "session_measurement" in ref + and tape + and tape["status"] == "measured" + ): + s = tape_scale( + session, native, ref["session_measurement"], ref["key"], tape["value_ft"] * FEET + ) + rows["moge2-tape"] = ("scale_reference", learned_values(session, positions(native, s))) + else: + lines.append( + "- No `moge2-tape` row: the map does not tie the survey's scale reference to a " + "session measurement." + ) + processing = time.perf_counter() - started + for name, (source, recomputed) in rows.items(): + doc = results_file( + name, + source, + capture, + rules_sha, + truth, + mapping, + recomputed, + capture_seconds(session), + processing, + ) + (out_dir / f"{name}.json").write_text(json.dumps(doc, indent=1) + "\n") + lines += [ + "", + "## AR scale error against the tape", + "", + *ar_scale_report(session, truth, mapping, tap_error_in), + ] + lines += ["", f"Results files: {', '.join(f'{n}.json' for n in rows)} in {out_dir}."] + return "\n".join(lines) + "\n" + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("step", choices=["stamp", "prepare", "score"]) + ap.add_argument("session", type=Path, help="session zip shared from Measure Lab, or its folder") + ap.add_argument("--truth", type=Path) + ap.add_argument("--map", type=Path) + ap.add_argument("--rules", type=Path) + ap.add_argument("--out-dir", type=Path, help="default: a folder per session under the data dir") + ap.add_argument("--tap-error-in", type=float, default=TAP_ERROR_IN) + args = ap.parse_args() + out_dir = args.out_dir or FIELD_DIR / "results" / args.session.stem + if args.step == "stamp": + print(stamp(args.session, args.truth, args.map, out_dir)) + return + if args.step == "prepare": + print(prepare(unpack(args.session)[0])) + return + report = score(args.session, args.truth, args.map, args.rules, out_dir, args.tap_error_in) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "field_report.md").write_text(report) + print(report) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/field_dryrun.py b/experiments/evals/evals/field_dryrun.py new file mode 100644 index 000000000..4b0206052 --- /dev/null +++ b/experiments/evals/evals/field_dryrun.py @@ -0,0 +1,213 @@ +"""A synthetic field session in FIELD_SHEET.md's tap order, with the templates filled in, so +`make field-dryrun` proves the team's exact command, templates and map on this machine. + +Scene (true meters, y up): a textured wall in the plane z = 0 facing +z and textured ground at +y = 0. Twelve keyframes 6 m out at chest height look straight at the wall, with every tapped point +in frame. The session's AR world +is the true one shrunk by `AR_SCALE` (poses, points and every rig value), so the report should find +the rig reading 1.5% short; the tape readings are the true lengths to the nearest 1/16 in. The +learned rows run real MoGe-2 on rendered images and mean nothing beyond proving the plumbing. +""" + +from __future__ import annotations + +import hashlib +import json +import sys +import zipfile +from fractions import Fraction +from pathlib import Path + +import cv2 +import numpy as np + +FIELD_KIT = Path(__file__).resolve().parents[1] / "field" +FT = 0.3048 +AR_SCALE = 0.985 +W, H, F = 640, 480, 500.0 +CAMERAS = [np.array([x, 1.5, 6.0]) for x in np.arange(-1.0, 11.0)] + +# Point id -> (true position, tool, keyframes tapped). Order and ids follow FIELD_SHEET.md. +X = np.array([4.0, 0.0, 2.0]) +POINTS = { + "P1": (X, "ground"), + "P2": (np.array([0.0, 0.0, 0.0]), "wall"), # contact A + "P3": (np.array([9.144, 0.0, 0.0]), "wall"), # contact B, 30 ft on + "P4": (np.array([5.0, 0.0, 0.0]), "wall"), # validation contact + "P5": (np.array([3.0, 1.4, 0.0]), "wallPoint"), # scale cross S1 + "P6": (np.array([4.5, 1.4, 0.0]), "wallPoint"), # scale cross S2 + "P7": (np.array([6.0, 1.3, 0.0]), "wallPoint"), # opening, left edge + "P8": (np.array([7.0, 1.3, 0.0]), "wallPoint"), # opening, right edge + "P9": (np.array([6.5, 0.9, 0.0]), "wallPoint"), # sill + "P10": (np.array([2.0, 1.2, 0.0]), "wallPoint"), # meter's bottom edge + "P11": (np.array([5.0, 0.0, 1.2]), "ground"), # fence-base mark F + "P12": (np.array([8.0, 2.6, 0.3]), "twoView"), # overhead corner + "P13": (X + np.array([0.02, 0.0, 0.01]), "ground"), # reference X again, 2 cm off + "P14": (np.array([9.144, 0.0, 0.0]), "ground"), # mark B + "P15": (np.array([0.0, 0.0, 0.0]), "ground"), # mark A + "P16": (np.array([0.0, 0.0, 1.5]), "ground"), # long-span mark L1 + "P17": (np.array([7.0, 0.0, 1.5]), "ground"), # long-span mark L2 +} +# (M id, from, to, compared quantity, survey id) +MEASUREMENTS = [ + ("M1", "P2", "P3", "alongWall", "span-30-ab"), + ("M2", "P5", "P6", "straight", "scale-ref"), + ("M3", "P7", "P8", "alongWall", "opening-width"), + ("M4", "P9", "W1", "heightAboveGround", "sill-height"), + ("M5", "P10", "W1", "heightAboveGround", "meter-height"), + ("M6", "P11", "W1", "gapToWall", "facing-gap"), + ("M7", "P12", "W1", "heightAboveGround", "overhead-height"), + ("M8", "P1", "P13", "straight", "return-gap"), + ("M9", "P14", "P15", "straight", "span-30-ba"), + ("M10", "P16", "P17", "straight", "span-20"), +] + + +def value(a: np.ndarray, b: np.ndarray | None, key: str) -> float: + """The quantity in Measure Lab's sense for this scene (wall along +x at z = 0, ground y = 0).""" + if key == "alongWall": + return abs(b[0] - a[0]) + if key == "straight": + return float(np.linalg.norm(b - a)) + if key == "heightAboveGround": + return float(a[1]) + if key == "gapToWall": + return float(a[2]) + raise ValueError(key) + + +def tape_text(meters: float) -> str: + """The reading a tape shows, to the nearest 1/16 in: '30 0 1/16'.""" + sixteenths = round(meters / FT * 12 * 16) + feet, rest = divmod(sixteenths, 12 * 16) + inches, frac = divmod(rest, 16) + return f"{feet} {inches}" + (f" {Fraction(frac, 16)}" if frac else "") + + +def pixel(X: np.ndarray, cam: np.ndarray) -> tuple[list[float], float]: + x, y, z = X - cam + return [W / 2 + F * x / -z, H / 2 - F * y / -z], -z + + +def render(cam: np.ndarray, texture: np.ndarray) -> np.ndarray: + """The textured wall and ground as seen from `cam`, looking along -z.""" + u, v = np.meshgrid(np.arange(W) + 0.5, np.arange(H) + 0.5) + d = np.stack([(u - W / 2) / F, -(v - H / 2) / F, -np.ones_like(u)], axis=-1) + t_wall = -cam[2] / d[..., 2] + t_ground = np.where(d[..., 1] < 0, -cam[1] / np.minimum(d[..., 1], -1e-9), np.inf) + hit_wall = (cam + t_wall[..., None] * d)[..., 1] >= 0 + t = np.where(hit_wall, t_wall, t_ground) + P = cam + t[..., None] * d + a, b = P[..., 0], np.where(hit_wall, P[..., 1], P[..., 2] + 20) # ground texture offset + i = np.clip(((a + 5) * 20).astype(int), 0, texture.shape[1] - 1) + j = np.clip(((b + 5) * 20).astype(int), 0, texture.shape[0] - 1) + img = texture[j, i] * np.where(hit_wall, 1.0, 0.7) + return np.clip(img, 0, 255).astype(np.uint8) + + +def session() -> dict: + scale = AR_SCALE + kfs, taps, points = [], [], [] + for k, cam in enumerate(CAMERAS, 1): + pose = np.eye(4) + pose[:3, 3] = cam * scale + kfs.append( + { + "id": f"k{k:05d}", + "img": f"keyframes/k{k:05d}.jpg", + "w": W, + "h": H, + "intrinsics": [F, F, W / 2, H / 2], + "pose": pose.T.reshape(-1).tolist(), + "timestamp": 50.0 + k, + "tracking": "normal", + } + ) + + def nearest(X: np.ndarray) -> int: + return int(np.argmin([abs(c[0] - X[0]) for c in CAMERAS])) + + for pid, (X, tool) in POINTS.items(): + views = [nearest(X)] + if tool == "twoView": + views.append(views[0] + 2) # 2 m sideways: about 25 degrees between the rays + ids = [] + for kf in views: + tid = f"T{len(taps) + 1}" + px, _ = pixel(X, CAMERAS[kf]) + taps.append( + {"id": tid, "tool": tool, "keyframe": kfs[kf]["id"], "pixel": px, "point": pid} + ) + ids.append(tid) + points.append({"id": pid, "kind": tool, "position": (X * scale).tolist(), "taps": ids}) + + pos = {pid: X for pid, (X, _) in POINTS.items()} + measurements = [] + for n, (mid, a, b, key, _) in enumerate(MEASUREMENTS): + true = value(pos[a], pos.get(b), key) + measurements.append( + { + "id": mid, + "time": 100.0 + 10 * n, + "from": a, + "to": b, + "referenceWall": "W1" if key == "alongWall" else None, + "values": {key: true * scale}, + "compared": key, + "tape": None, + "warnings": [], + "accepted": True, + } + ) + return { + "format": "measure-lab-session", + "formatVersion": 2, + "units": {"length": "meters"}, + "session": {"id": "dryrun-session", "startedAtUptime": 40.0, "deviceModel": "synthetic"}, + "keyframes": kfs, + "taps": taps, + "points": points, + "walls": [ + { + "id": "W1", + "contacts": ["P2", "P3"], + "cameraPosition": (CAMERAS[5] * scale).tolist(), + "validations": [{"point": "P4", "passes": True}], + "warnings": [], + } + ], + "measurements": measurements, + "refusals": [], + } + + +def build(out: Path) -> None: + out.mkdir(parents=True, exist_ok=True) + rng = np.random.default_rng(0) + texture = cv2.resize(rng.integers(40, 220, (60, 60)).astype(np.float32), (600, 600)) + texture += rng.normal(0, 25, (600, 600)) + folder = out / "dryrun-session" + (folder / "keyframes").mkdir(parents=True, exist_ok=True) + doc = session() + (folder / "session.json").write_text(json.dumps(doc, indent=1)) + for kf, cam in zip(doc["keyframes"], CAMERAS, strict=True): + cv2.imwrite(str(folder / kf["img"]), render(cam, texture)) + archive = out / "dryrun-session.zip" + with zipfile.ZipFile(archive, "w") as z: + for f in sorted(folder.rglob("*")): + z.write(f, f.relative_to(out)) + + pos = {pid: X for pid, (X, _) in POINTS.items()} + tape = {sid: tape_text(value(pos[a], pos.get(b), key)) for _, a, b, key, sid in MEASUREMENTS} + survey = json.loads((FIELD_KIT / "survey.template.json").read_text()) + for m in survey["measurements"]: + if m["value_ft"] == "FILL ft in": + m["value_ft"] = tape[m["id"]] + survey["candidates"][0]["location"] = "16 ft 5 in along the wall from mark A" + (out / "survey.json").write_text(json.dumps(survey, indent=2) + "\n") + (out / "map.json").write_text((FIELD_KIT / "map.template.json").read_text()) + print(f"{archive} sha256 {hashlib.sha256(archive.read_bytes()).hexdigest()}") + + +if __name__ == "__main__": + build(Path(sys.argv[1])) diff --git a/experiments/evals/evals/frames.py b/experiments/evals/evals/frames.py new file mode 100644 index 000000000..3e2b7e40b --- /dev/null +++ b/experiments/evals/evals/frames.py @@ -0,0 +1,154 @@ +"""Which photos are worth keeping? Single-photo accuracy on ETH3D against simple capture geometry. + + uv run python -m evals.frames # after `evals.recon prepare` and the MoGe-2 run + +Each photo is scored alone (MoGe-2, scaled by one taped 1 to 3 m distance, as in `evals.recon`) and +described by two things an app can measure while capturing: how far the wall is (median distance to +the scored points) and how obliquely it is seen (median angle between the viewing ray and the +wall's surface normal). Normals come from the laser scan rendered as depth, by the cross product of +neighbouring pixels. A keep-policy is scored by pooling the errors of the photos it keeps. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy as np + +from evals.eth3d import SCENES +from evals.pairs import evaluate_fixed, pool +from evals.recon import ( + PREDICTIONS, + WIDTH, + EvalSet, + Scene, + _height, + cell, + load_prediction, + predict, +) + +MODEL = "moge2" + + +def normals_from_depth(depth: np.ndarray, K: np.ndarray) -> np.ndarray: + """Unit surface normals (H, W, 3) in the camera frame from a depth map; NaN where undefined. + Oriented towards the camera (negative z component).""" + h, w = depth.shape + u, v = np.meshgrid(np.arange(w, dtype=np.float64), np.arange(h, dtype=np.float64)) + X = np.dstack([(u - K[0, 2]) / K[0, 0] * depth, (v - K[1, 2]) / K[1, 1] * depth, depth]) + dx = np.full_like(X, np.nan) + dy = np.full_like(X, np.nan) + dx[:, 1:-1] = X[:, 2:] - X[:, :-2] + dy[1:-1] = X[2:] - X[:-2] + n = np.cross(dx, dy) + n /= np.linalg.norm(n, axis=2, keepdims=True) + flip = n[..., 2] > 0 + n[flip] *= -1 + return n + + +def view_geometry(scene: Scene, name: str) -> tuple[float, float]: + """Median distance (m) to the view's scored points and median incidence angle (degrees).""" + v = scene.views[name] + index, uv, _ = scene.visible(name) + Xc = scene.cands[index] @ v.R_wc.T + v.t_wc + dist = np.linalg.norm(Xc, axis=1) + depth, K = scene.oracle_depth(name) + n = normals_from_depth(depth.astype(np.float64), K) + ui = np.clip(np.round(uv[:, 0]).astype(int), 0, WIDTH - 1) + vi = np.clip(np.round(uv[:, 1]).astype(int), 0, _height(v) - 1) + rays = Xc / dist[:, None] + cos = np.abs(np.einsum("ij,ij->i", n[vi, ui], rays)) + ang = np.degrees(np.arccos(np.clip(cos[np.isfinite(cos)], 0, 1))) + return float(np.median(dist)), float(np.median(ang)) + + +def per_view(scene: Scene) -> list[dict]: + """Each photo scored alone on its own fixed evaluation set (surface interior, all ranges).""" + rows = [] + root = PREDICTIONS / scene.name / MODEL + + def pv(n): + d, _, _ = load_prediction(root / f"{n}.npz") + return d, scene.K(n), scene.views[n].cam_to_world, True + + for name in scene.views: + ev = EvalSet(scene, name, None) + pairs = ev.pairs["surface interior"] + if not pairs: + continue + pts = predict(scene, ev, [name], pv, None) + dist, angle = view_geometry(scene, name) + raw = evaluate_fixed(pts, pairs, ev.refs) + rows.append({"view": name, "distance_m": dist, "incidence_deg": angle, "raw": raw}) + return rows + + +# Thresholds span what the ETH3D photos cover: walls 4 to 41 m away, seen 26 to 75 degrees off +# head-on (median 42), so a stricter angle than 40 degrees would keep almost nothing. +POLICIES = { + "every photo": lambda r: True, + "wall within 6 m": lambda r: r["distance_m"] <= 6, + "wall within 8 m": lambda r: r["distance_m"] <= 8, + "wall within 12 m": lambda r: r["distance_m"] <= 12, + "wall seen within 40 degrees of head-on": lambda r: r["incidence_deg"] <= 40, + "wall seen more than 50 degrees off head-on": lambda r: r["incidence_deg"] > 50, +} + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--out", type=Path, default=Path(__file__).resolve().parents[1] / "results") + args = ap.parse_args() + rows = [] + for s in SCENES: + for r in per_view(Scene(s)): + rows.append({**r, "scene": s}) + lines = [ + "# Which photos to keep (generated by `uv run python -m evals.frames`)", + "", + "MoGe-2, one photo at a time, scaled by one taped 1 to 3 m distance; both ETH3D scenes " + "pooled. |length error|, inches, median / p90.", + "", + "| Keep | Photos kept | 1-3 m spans | 3-10 m spans |", + "| --- | --- | --- | --- |", + ] + summary = {} + for label, keep in POLICIES.items(): + kept = [r for r in rows if keep(r)] + if not kept: + lines.append(f"| {label} | 0 of {len(rows)} | n/a | n/a |") + continue + p = pool([r["raw"] for r in kept])["one_known_distance"] + a, b = p["1-3m"], p["3-10m"] + summary[label] = {"kept": len(kept), "of": len(rows), "1-3m": a, "3-10m": b} + lines.append(f"| {label} | {len(kept)} of {len(rows)} | {cell(a)} | {cell(b)} |") + dist = np.array([r["distance_m"] for r in rows]) + ang = np.array([r["incidence_deg"] for r in rows]) + lines += [ + "", + f"Photo geometry: median wall distance {np.median(dist):.1f} m (range {dist.min():.1f} to " + f"{dist.max():.1f}); median incidence {np.median(ang):.0f} degrees (range {ang.min():.0f} " + f"to {ang.max():.0f}).", + ] + per = [ + {k: (round(v, 2) if isinstance(v, float) else v) for k, v in r.items() if k != "raw"} + | {"one_known_distance": pool([r["raw"]])["one_known_distance"]} + for r in rows + ] + args.out.mkdir(parents=True, exist_ok=True) + (args.out / "frames.json").write_text( + json.dumps({"policies": summary, "photos": per}, indent=1) + ) + md = "\n".join(lines) + "\n" + (args.out / "frames.md").write_text(md) + print(md) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/geometry.py b/experiments/evals/evals/geometry.py new file mode 100644 index 000000000..e89833ec5 --- /dev/null +++ b/experiments/evals/evals/geometry.py @@ -0,0 +1,96 @@ +"""Small rigid-geometry helpers shared by the evals. Every function here has a hand-computed test.""" + +from __future__ import annotations + +import numpy as np + + +def quat_wxyz_to_matrix(q: np.ndarray) -> np.ndarray: + """Unit quaternions (..., 4) in w, x, y, z order to rotation matrices (..., 3, 3).""" + q = np.asarray(q, dtype=np.float64) + q = q / np.linalg.norm(q, axis=-1, keepdims=True) + w, x, y, z = q[..., 0], q[..., 1], q[..., 2], q[..., 3] + R = np.empty((*q.shape[:-1], 3, 3)) + R[..., 0, 0] = 1 - 2 * (y * y + z * z) + R[..., 0, 1] = 2 * (x * y - w * z) + R[..., 0, 2] = 2 * (x * z + w * y) + R[..., 1, 0] = 2 * (x * y + w * z) + R[..., 1, 1] = 1 - 2 * (x * x + z * z) + R[..., 1, 2] = 2 * (y * z - w * x) + R[..., 2, 0] = 2 * (x * z - w * y) + R[..., 2, 1] = 2 * (y * z + w * x) + R[..., 2, 2] = 1 - 2 * (x * x + y * y) + return R + + +def rotation_angle_deg(Ra: np.ndarray, Rb: np.ndarray) -> float: + """Angle of the relative rotation Ra^T Rb, degrees.""" + c = (np.trace(Ra.T @ Rb) - 1) / 2 + return float(np.degrees(np.arccos(np.clip(c, -1.0, 1.0)))) + + +def rot_y(deg: float) -> np.ndarray: + """Rotation about +y (up in ARKit's world) by `deg` degrees, right-handed.""" + a = np.radians(deg) + c, s = np.cos(a), np.sin(a) + return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]]) + + +def umeyama( + src: np.ndarray, dst: np.ndarray, with_scale: bool +) -> tuple[float, np.ndarray, np.ndarray]: + """Least-squares similarity (s, R, t) minimising sum |dst - (s R src + t)|^2 (Umeyama 1991). + + src, dst: (N, 3), N >= 3 and not collinear. Returns scale 1 when `with_scale` is False. + """ + src = np.asarray(src, dtype=np.float64) + dst = np.asarray(dst, dtype=np.float64) + if src.shape != dst.shape or src.ndim != 2 or src.shape[1] != 3 or len(src) < 3: + raise ValueError(f"need matching (N>=3, 3) arrays, got {src.shape} and {dst.shape}") + mu_s, mu_d = src.mean(0), dst.mean(0) + xs, xd = src - mu_s, dst - mu_d + cov = xd.T @ xs / len(src) + U, D, Vt = np.linalg.svd(cov) + S = np.eye(3) + if np.linalg.det(U) * np.linalg.det(Vt) < 0: + S[2, 2] = -1 + R = U @ S @ Vt + var_s = (xs**2).sum() / len(src) + s = float(np.trace(np.diag(D) @ S) / var_s) if with_scale else 1.0 + t = mu_d - s * R @ mu_s + return s, R, t + + +def yaw_align(src: np.ndarray, dst: np.ndarray, up_axis: int = 1) -> tuple[np.ndarray, np.ndarray]: + """Rotation about the up axis plus translation (4 degrees of freedom) that best maps src to dst. + + Both point sets must already share the same up axis (gravity). Returns (R, t). + """ + src = np.asarray(src, dtype=np.float64) + dst = np.asarray(dst, dtype=np.float64) + h = [k for k in range(3) if k != up_axis] + mu_s, mu_d = src.mean(0), dst.mean(0) + a, b = src - mu_s, dst - mu_d + # Rotation angle in the horizontal plane that maximises sum of dot products. + # With h = (h0, h1) ordered so that the rotation is right-handed about up_axis. + if up_axis == 1: + # About +y: x' = c x + s z, z' = -s x + c z. Maximise sum b.(R a). + num = (b[:, 0] * a[:, 2] - b[:, 2] * a[:, 0]).sum() + den = (b[:, 0] * a[:, 0] + b[:, 2] * a[:, 2]).sum() + R = rot_y(np.degrees(np.arctan2(num, den))) + elif up_axis == 2: + num = (b[:, 1] * a[:, 0] - b[:, 0] * a[:, 1]).sum() + den = (b[:, 0] * a[:, 0] + b[:, 1] * a[:, 1]).sum() + ang = np.arctan2(num, den) + c, s = np.cos(ang), np.sin(ang) + R = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]) + else: + raise ValueError(f"up_axis must be 1 or 2, got {up_axis} (horizontal axes {h})") + t = mu_d - R @ mu_s + return R, t + + +def path_length(p: np.ndarray) -> np.ndarray: + """Cumulative distance travelled along a polyline (N, 3), starting at 0.""" + steps = np.linalg.norm(np.diff(p, axis=0), axis=1) + return np.concatenate([[0.0], np.cumsum(steps)]) diff --git a/experiments/evals/evals/map3d.py b/experiments/evals/evals/map3d.py new file mode 100644 index 000000000..c008ae7f1 --- /dev/null +++ b/experiments/evals/evals/map3d.py @@ -0,0 +1,485 @@ +"""The app's 3D map (Map3D, t3/ios-map3d) against the same truth as CoverageMap (README section 7c). + +The app's code runs unmodified through `map3d_driver/`, built against a read-only checkout of +HouseScanKit at `KIT_COMMIT`. Its LiDAR path is fed one depth frame per ETH3D photo, 256 x 192, +rendered from the laser scan at the photo's pose. Its coverage along section 7's wall is scored +with section 7's truth; its facing and overhead claims with a seen-empty test on the same laser +depth; its measured wall chain against the laser's wall line. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from dataclasses import replace +from pathlib import Path + +import cv2 +import numpy as np + +from evals.coverage import ( + FEET, + HIDE_ABS_M, + HIDE_REL, + MASK_WIDTH, + MISSING_FROM_SCAN, + RESULTS, + Setup, + band_samples, + face_offsets, + in_intervals, + photo_truth, + project, + setup_scene, + two_positions, +) +from evals.eth3d import excluded_pixels, occluder_points, scan_points +from evals.paths import EVALS_DIR + +KIT_COMMIT = "66cdcba" +KIT_CHECKOUT = EVALS_DIR / f"housescankit-{KIT_COMMIT}" +KIT_DIR = KIT_CHECKOUT / "ios" / "HouseScanKit" +DRIVER_DIR = Path(__file__).resolve().parents[1] / "map3d_driver" +DRIVER_BIN = DRIVER_DIR / ".build" / "release" / "map3d-driver" +WORK = EVALS_DIR / "map3d" +SCENE = "electro" +DEPTH_W, DEPTH_H = 256, 192 # ARKit's sceneDepth size +PASS_FT = 0.5 +VIEW_RANGE_M = 5.0 # Map3D's maxViewDistance, the range the seen-empty truth allows +HEADROOM_M = 1.9812 +VOXEL_M = 0.1 # Map3DConfig.voxelSize: Map3D judges space from one voxel out from the wall +OVERHEAD_DEPTH_M = 0.5588 +GROUND_CLEARANCE_M = 0.15 +MAX_WALL_GAP_M = 0.35 # Map3DConfig.maxWallGap, for the laser wall's extent + + +def prepare() -> None: + """Checks out HouseScanKit at the pinned commit, read-only, and builds the driver.""" + repo = Path(__file__).resolve().parents[3] + if not KIT_CHECKOUT.exists(): + subprocess.run(["git", "-C", str(repo), "fetch", "origin", "t3/ios-map3d"], check=True) + subprocess.run( + ["git", "-C", str(repo), "worktree", "add", "--detach", str(KIT_CHECKOUT), KIT_COMMIT], + check=True, + ) + subprocess.run( + ["swift", "build", "-c", "release", "--package-path", str(DRIVER_DIR)], + env={**os.environ, "HOUSESCANKIT_DIR": str(KIT_DIR)}, + check=True, + ) + + +def laser_depth(view, blockers: np.ndarray, missing: np.ndarray) -> np.ndarray: + """A 256 x 192 depth frame for the photo: nearest scan depth along the camera's axis, holes + closed by a 3-pixel minimum filter, 0 where nothing was scanned or where ETH3D masks an object + the scanner missed. The photo is 3:2, so pixels scale by 256 / W across and 192 / H down, as + `DepthFrame(photo:)` scales the intrinsics.""" + u, v, z, ok = project(view, blockers) + iu = (u[ok] * DEPTH_W / view.width).astype(np.int64) + iv = (v[ok] * DEPTH_H / view.height).astype(np.int64) + inside = (iu >= 0) & (iu < DEPTH_W) & (iv >= 0) & (iv < DEPTH_H) + buf = np.full((DEPTH_H, DEPTH_W), 1e6, np.float32) + np.minimum.at(buf, (iv[inside], iu[inside]), z[ok][inside].astype(np.float32)) + buf = cv2.erode(buf, np.ones((3, 3), np.uint8)) + m = cv2.resize(missing.astype(np.uint8), (DEPTH_W, DEPTH_H), interpolation=cv2.INTER_NEAREST) + buf[(buf >= 1e5) | m.astype(bool)] = 0 + return buf + + +def run_driver(setup: Setup) -> dict: + blockers = np.concatenate([scan_points(SCENE), occluder_points(SCENE)]) + WORK.mkdir(parents=True, exist_ok=True) + frames = [] + for view in setup.views: + missing = excluded_pixels( + view, + MASK_WIDTH, + round(view.height * MASK_WIDTH / view.width), + labels=(MISSING_FROM_SCAN,), + ) + path = WORK / f"{view.name}.depth.f32" + laser_depth(view, blockers, missing).astype(" np.ndarray: + """Per point (levelled world): some photo within 5 m frames it and the laser depth at its + pixel lies beyond it by more than the tolerance, so that photo's ray passed through it.""" + X = points @ setup.R # levelled -> ETH3D world + seen = np.zeros(len(points), bool) + for view, zbuf, missing in zip(setup.views, setup.zbufs, setup.missing, strict=True): + u, v, z, framed = project(view, X) + near = np.linalg.norm(X - view.center, axis=1) <= VIEW_RANGE_M + zh, zw = zbuf.shape + cu = np.clip(np.nan_to_num(u * zw / view.width).astype(np.int64), 0, zw - 1) + cv_ = np.clip(np.nan_to_num(v * zh / view.height).astype(np.int64), 0, zh - 1) + mh, mw = missing.shape + mu = np.clip(np.nan_to_num(u * mw / view.width).astype(np.int64), 0, mw - 1) + mv = np.clip(np.nan_to_num(v * mh / view.height).astype(np.int64), 0, mh - 1) + nearest = zbuf[cv_, cu] + # No scan point near the pixel is no evidence of empty space, not proof of it. + beyond = (nearest < 1e5) & (nearest > z + np.maximum(tol_abs, HIDE_REL * z)) + seen |= framed & near & beyond & ~missing[mv, mu] + return seen + + +def space_claims(setup: Setup, spans: list[dict], kind: str, tol_abs: float = HIDE_ABS_M) -> dict: + """False-observed length of Map3D's facing or overhead claims: claimed 2 cm columns where at + least two 5 cm samples of the claimed space were never seen empty.""" + wall = setup.wall + claimed_ft, bad_ft = 0.0, 0.0 + runs = [] + for sp in spans: + lo, hi = sp["s"] + reach = sp["out"] + cols = np.arange(lo + 0.01, hi, 0.02) + if kind == "facing": + outs = np.arange(VOXEL_M, reach + 1e-6, 0.05) + heights = np.arange(GROUND_CLEARANCE_M, HEADROOM_M + 1e-6, 0.05) + else: + outs = np.arange(VOXEL_M, OVERHEAD_DEPTH_M + 1e-6, 0.05) + heights = np.arange(GROUND_CLEARANCE_M, reach + 1e-6, 0.05) + if not len(cols) or not len(outs) or not len(heights): + continue + grid_s, grid_o, grid_h = np.meshgrid(cols, outs, heights, indexing="ij") + pts = wall.world(grid_s, grid_h, grid_o).reshape(-1, 3) + unseen = (~seen_empty(setup, pts, tol_abs)).reshape(len(cols), -1).sum(axis=1) + bad = unseen >= 2 + claimed_ft += len(cols) * 0.02 / FEET + bad_ft += bad.sum() * 0.02 / FEET + if bad.any(): + runs.append( + [round(lo / FEET, 2), round(hi / FEET, 2), round(bad.sum() * 0.02 / FEET, 2)] + ) + return { + "claimed_ft": round(claimed_ft, 2), + "false_observed_ft": round(bad_ft, 2), + "passes": bad_ft <= PASS_FT, + "spans_with_unseen_ft": runs, + } + + +def ground_claims(setup: Setup, spans: list[dict]) -> dict: + """Every exported ground reach, scored at its own depth: claimed 2 cm columns where at least + two 5 cm ground samples from one voxel out to the reach were seen by no photo within 5 m, + counting a sample only where the scan has a surface near it.""" + wall = setup.wall + claimed_ft, bad_ft, runs = 0.0, 0.0, [] + for sp in spans: + lo, hi = sp["s"] + cols = np.arange(lo + 0.01, hi, 0.02) + outs = np.arange(VOXEL_M, sp["out"] + 1e-6, 0.05) + if not len(cols) or not len(outs): + continue + grid_s, grid_o = np.meshgrid(cols, outs, indexing="ij") + pts = wall.world(grid_s, np.zeros_like(grid_s), grid_o).reshape(-1, 3) + seen = np.zeros(len(pts), bool) + for view, zbuf, missing, centre in zip( + setup.views, setup.zbufs, setup.missing, setup.centres, strict=True + ): + t = photo_truth( + view, setup.R, zbuf, missing, pts, centre[1] > wall.ground_y, VIEW_RANGE_M + ) + seen |= t.saw & ~t.no_scan + bad = (~seen).reshape(len(cols), -1).sum(axis=1) >= 2 + claimed_ft += len(cols) * 0.02 / FEET + bad_ft += bad.sum() * 0.02 / FEET + if bad.any(): + runs.append( + [ + round(lo / FEET, 2), + round(hi / FEET, 2), + round(sp["out"] / FEET, 2), + round(bad.sum() * 0.02 / FEET, 2), + ] + ) + return { + "claimed_ft": round(claimed_ft, 2), + "false_observed_ft": round(bad_ft, 2), + "passes": bad_ft <= PASS_FT, + "spans_with_unseen_ft_s_s_reach_bad": runs, + } + + +def retruth(setup: Setup, max_distance: float) -> Setup: + """Section 7's truth for the setup's wall and faces, with the given range.""" + samples = { + band: band_samples(setup.wall, band, setup.cfg, setup.faces)[2] + for band in ("wall", "ground") + } + truths = {"wall": [], "ground": []} + for view, zbuf, missing, centre in zip( + setup.views, setup.zbufs, setup.missing, setup.centres, strict=True + ): + facing = {"wall": setup.wall.out_of(centre) > 0, "ground": centre[1] > setup.wall.ground_y} + for band, pts in samples.items(): + truths[band].append( + photo_truth(view, setup.R, zbuf, missing, pts, facing[band], max_distance) + ) + return replace(setup, truths=truths) + + +def five_metre_truth(setup: Setup) -> Setup: + """Section 7's truth with Map3D's 5 m range instead of 6 m.""" + return retruth(setup, VIEW_RANGE_M) + + +def widened(setup: Setup, left: float, right: float) -> Setup: + """Section 7's truth over [left, right] of s instead of section 7's stretch, with the laser + face (pilasters) found again over the new columns, so every wall Map3D claims has truth.""" + wall = replace(setup.wall, left=min(left, setup.wall.left), right=max(right, setup.wall.right)) + scan = scan_points(SCENE).astype(np.float64) @ setup.R.T + faces = face_offsets(wall, scan, band_samples(wall, "wall", setup.cfg)[0]) + return retruth(replace(setup, wall=wall, faces=faces), setup.cfg["maxDistance"]) + + +def chain_vs_laser(setup: Setup, out: dict) -> dict: + """The measured wall piece through the meter against the laser's wall line (section 7's wall + plane, the scan points within 15 cm of it between 0.3 and 2 m up).""" + if out["meterIndex"] is None: + return {"chain": None} + wall = setup.wall + pose = np.array(out["poseInWorld"]).reshape(4, 4).T + piece = out["chain"][out["meterIndex"]] + a = pose[:3, :3] @ np.array([piece["start"][0], 0.0, piece["start"][1]]) + pose[:3, 3] + b = pose[:3, :3] @ np.array([piece["end"][0], 0.0, piece["end"][1]]) + pose[:3, 3] + scan = scan_points(SCENE)[::2].astype(np.float64) @ setup.R.T + rel = scan - wall.origin + s, h, o = rel @ wall.along, rel[:, 1], rel @ wall.outward + keep = (np.abs(o) < 0.15) & (h > 0.3) & (h < 2.0) + slope, offset = np.polyfit(s[keep], o[keep], 1) + # Extent: the wall face including pilasters up to 0.5 m proud, as Map3D's wall evidence has. + face = (o > -0.15) & (o < 0.5) & (h > 0.3) & (h < 2.0) + bins = np.unique(np.floor(s[face] / 0.05)) * 0.05 + gaps = np.flatnonzero(np.diff(bins) > MAX_WALL_GAP_M) + starts, ends = np.r_[bins[0], bins[gaps + 1]], np.r_[bins[gaps], bins[-1] + 0.05] + k = ( + int(np.flatnonzero((starts <= 0) & (ends >= 0))[0]) + if ((starts <= 0) & (ends >= 0)).any() + else None + ) + sa, sb = (a - wall.origin) @ wall.along, (b - wall.origin) @ wall.along + oa, ob = (a - wall.origin) @ wall.outward, (b - wall.origin) @ wall.outward + chain_angle = np.degrees(np.arctan2(ob - oa, sb - sa)) + chain_offset_at_meter = oa + (ob - oa) * (0 - sa) / (sb - sa) + return { + "pieces": len(out["chain"]), + "meter_piece_length_ft": round(float(np.linalg.norm(b - a)) / FEET, 2), + "meter_piece_s_ft": [round(float(sa) / FEET, 2), round(float(sb) / FEET, 2)], + "laser_wall_s_ft": None + if k is None + else [round(float(starts[k]) / FEET, 2), round(float(ends[k]) / FEET, 2)], + "laser_wall_length_ft": None if k is None else round(float(ends[k] - starts[k]) / FEET, 2), + "angle_error_deg": round(float(chain_angle - np.degrees(np.arctan(slope))), 2), + "offset_at_meter_in": round(float(chain_offset_at_meter - offset) / FEET * 12, 2), + } + + +def evaluate() -> dict: + setup = setup_scene(SCENE) + if setup is None: + raise SystemExit(f"{SCENE}: no wall within range") + out = run_driver(setup) + band = setup.cfg["groundBandDepth"] + covered = { + "wall": [sp["s"] for sp in out["wall"]], + "ground": [sp["s"] for sp in out["ground"] if sp["out"] >= band - 1e-4], + } + bands = setup.score({"covered": covered, "sightings": []}, causes=False) + bands5 = five_metre_truth(setup).score({"covered": covered, "sightings": []}, causes=False) + claims = [x for span in covered["wall"] for x in span] + wide = widened( + setup, min(claims, default=setup.wall.left), max(claims, default=setup.wall.right) + ) + bands_wide = wide.score({"covered": covered, "sightings": []}, causes=False) + cols = band_samples(wide.wall, "wall", setup.cfg, wide.faces)[0] + saw = np.stack([t.saw for t in wide.truths["wall"]], axis=-1) + missed = ~in_intervals(cols, covered["wall"]) & two_positions(saw, wide.centres, 0.25).all( + axis=1 + ) + missed_on_pilasters_ft = round(float((missed & (wide.faces > 0)).sum() * 0.02 / FEET), 2) + keys = ( + "claimed_ft", + "false_observed_ft", + "false_observed_share", + "missed_ft", + "seen_two_view_ft", + "claimed_samples_seen_only_where_scan_is_empty", + ) + stretch = (setup.wall.left, setup.wall.right) + s_cols = np.arange(stretch[0] + 0.01, stretch[1], 0.02) + return { + "kit_commit": KIT_COMMIT, + "wall_stretch_ft": round((stretch[1] - stretch[0]) / FEET, 1), + "bands": {b: {k: bands[b][k] for k in keys} for b in ("wall", "ground")}, + "bands_5m_truth": {b: {k: bands5[b][k] for k in keys} for b in ("wall", "ground")}, + "wall_over_all_claims": { + "s_ft": [round(wide.wall.left / FEET, 2), round(wide.wall.right / FEET, 2)], + **{k: bands_wide["wall"][k] for k in keys}, + "missed_on_pilaster_faces_ft": missed_on_pilasters_ft, + }, + "facing_3cm_truth": space_claims(setup, out["facing"], "facing", 0.03), + "ground_reach_ft": sorted({round(sp["out"] / FEET, 2) for sp in out["ground"]}), + "ground_every_reach": ground_claims(setup, out["ground"]), + "wall_claimed_total_ft": round(sum(b - a for a, b in covered["wall"]) / FEET, 2), + "wall_claimed_on_stretch_ft": round( + float(in_intervals(s_cols, covered["wall"]).sum() * 0.02 / FEET), 2 + ), + "facing": space_claims(setup, out["facing"], "facing"), + "overhead": space_claims(setup, out["overhead"], "overhead"), + "chain": chain_vs_laser(setup, out), + "allocated_mb": round(out["allocatedBytes"] / 1e6, 1), + } + + +def markdown(r: dict) -> str: + b = r["bands"] + cm = json.loads((RESULTS / "coverage.json").read_text())[0]["bands"]["wall"] + c = r["chain"] + lines = [ + "# The app's 3D map against laser visibility (generated by `make map3d`)", + "", + f"HouseScanKit Map3D at {r['kit_commit']} (t3/ios-map3d), unmodified, LiDAR path: one " + "256 x 192 depth frame per ETH3D electro photo, rendered from the laser scan. Coverage read " + f"along section 7's {r['wall_stretch_ft']} ft wall. Lengths in feet along the wall; " + f"false-observed passes at {PASS_FT} ft or less. Ideal depth: exact, dense, no sensor " + "noise, so this is an upper bound on what real LiDAR gives. Definitions: README section 7c.", + "", + "| Band | Claimed | False-observed (share) | Pass | Missed | Seen from 2 positions |", + "| --- | --- | --- | --- | --- | --- |", + f"| wall, CoverageMap (section 7) | {cm['claimed_ft']:.1f} | {cm['false_observed_ft']:.1f} " + f"({100 * cm['false_observed_share']:.0f}%) | no | {cm['missed_ft']:.1f} | {cm['seen_two_view_ft']:.1f} |", + ] + for label, bands in (("", b), (", 5 m truth", r["bands_5m_truth"])): + for band in ("wall", "ground"): + x = bands[band] + passed = ( + "untested" + if x["claimed_ft"] == 0 + else ("yes" if x["false_observed_ft"] <= PASS_FT else "no") + ) + lines.append( + f"| {band}, Map3D{label} | {x['claimed_ft']:.1f} | {x['false_observed_ft']:.1f} " + f"({100 * x['false_observed_share']:.0f}%) | {passed} | {x['missed_ft']:.1f} | " + f"{x['seen_two_view_ft']:.1f} |" + ) + w = r["wall_over_all_claims"] + lines.append( + f"| wall, Map3D, truth over all its claims (s {w['s_ft'][0]} to {w['s_ft'][1]} ft) | " + f"{w['claimed_ft']:.1f} | {w['false_observed_ft']:.1f} ({100 * w['false_observed_share']:.0f}%) | " + f"{'yes' if w['false_observed_ft'] <= PASS_FT else 'no'} | {w['missed_ft']:.1f}, of it " + f"{w['missed_on_pilaster_faces_ft']:.1f} on pilaster faces | {w['seen_two_view_ft']:.1f} |" + ) + g = r["ground_every_reach"] + passed = "untested" if g["claimed_ft"] == 0 else ("yes" if g["passes"] else "no") + lines.append( + f"| ground, Map3D, every reach at its own depth | {g['claimed_ft']:.1f} | {g['false_observed_ft']:.1f} | {passed} | n/a | n/a |" + ) + f3 = r["facing_3cm_truth"] + lines.append( + f"| facing, Map3D, 3 cm floor on the truth's tolerance | {f3['claimed_ft']:.1f} | " + f"{f3['false_observed_ft']:.1f} | see note | n/a | n/a |" + ) + for kind in ("facing", "overhead"): + x = r[kind] + passed = "untested" if x["claimed_ft"] == 0 else ("yes" if x["passes"] else "no") + lines.append( + f"| {kind}, Map3D | {x['claimed_ft']:.1f} | {x['false_observed_ft']:.1f} | {passed} | n/a | n/a |" + ) + lines += [ + "", + f"Map3D's ground reaches (ft): {r['ground_reach_ft']}. Ground counts as claimed where the " + "reach is at least 1.2 m (3.94 ft), the depth CoverageMap claims.", + "", + "Facing: Map3D's one claim is a cell seen clear 0.1 m out from a door face. The truth needs " + "the laser surface beyond a point by max(tolerance, 4% of the distance), 12 to 20 cm at " + "these ranges, so space 10 cm in front of a surface can be neither confirmed nor refuted.", + "", + "## Wall chain against the laser's wall line", + "", + ] + if c.get("chain") is None and "pieces" not in c: + lines.append("Map3D found no wall chain through the meter.") + else: + lines += [ + f"- Pieces in the chain: {c['pieces']}. The meter's piece: {c['meter_piece_length_ft']} ft, " + f"s {c['meter_piece_s_ft'][0]} to {c['meter_piece_s_ft'][1]} ft.", + f"- Laser wall through the meter (gaps up to 0.35 m bridged): {c['laser_wall_length_ft']} ft, " + f"s {c['laser_wall_s_ft']}.", + f"- Angle error {c['angle_error_deg']:+.2f} deg; offset at the meter {c['offset_at_meter_in']:+.1f} in.", + ] + lines += [ + "", + f"Map3D claimed {r['wall_claimed_total_ft']} ft of wall in all, " + f"{r['wall_claimed_on_stretch_ft']} ft of it on section 7's stretch; the row over all its " + f"claims scores every foot. Map3D's voxels took {r['allocated_mb']} MB.", + ] + return "\n".join(lines) + "\n" + + +def check_kit() -> None: + """The checkout must be the pinned commit, with HouseScanKit unmodified, and the driver built.""" + if not DRIVER_BIN.exists() or not KIT_DIR.is_dir(): + raise SystemExit("run `uv run python -m evals.map3d prepare` first") + git = ["git", "-C", str(KIT_CHECKOUT)] + head = subprocess.run( + [*git, "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + pinned = subprocess.run( + [*git, "rev-parse", f"{KIT_COMMIT}^{{commit}}"], capture_output=True, text=True, check=True + ).stdout.strip() + dirty = subprocess.run( + [*git, "status", "--porcelain", "--", "ios/HouseScanKit"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if head != pinned or dirty: + raise SystemExit( + f"{KIT_CHECKOUT} is at {head} with changes {dirty!r}; expected {pinned}, clean" + ) + + +def main() -> None: + check_kit() + r = evaluate() + RESULTS.mkdir(exist_ok=True) + (RESULTS / "map3d.json").write_text(json.dumps(r, indent=1, default=float)) + md = markdown(r) + (RESULTS / "map3d.md").write_text(md) + print(md) + + +if __name__ == "__main__": + if sys.argv[1:] == ["prepare"]: + prepare() + else: + main() diff --git a/experiments/evals/evals/modern_arkit.py b/experiments/evals/evals/modern_arkit.py new file mode 100644 index 000000000..7082563f2 --- /dev/null +++ b/experiments/evals/evals/modern_arkit.py @@ -0,0 +1,362 @@ +"""A current iPhone's ARKit scale, measured on MARViN (iPhone 14 Pro Max, ARKit 6, outdoor walks). + + uv run python -m evals.modern_arkit # fetch the pose files, write results/modern_arkit.md + +MARViN (Liu et al., IEEE VRW 2024, github.com/XRIM-Lab/MarViN) records ARKit's pose for every image +(`//ARkitPose.txt`, Unity axes) and a COLMAP reconstruction of each scene as ground +truth (`/train.txt` and `test.txt`: image, camera centre x y z, then a quaternion). Distances +between positions do not depend on axis conventions, so the comparison uses displacements only. + +From every image, the walk continues until the ground truth has covered 3, 10, 20 or 30 ft (paths +are measured in the ground truth's horizontal plane, its least-variance axis being vertical). Per +window: ARKit's straight-line displacement minus the truth's, and their ratio. A walk's scale is the +median ratio over windows of 10 ft or more; "beyond scale" is the distance error after dividing it +out. + +The limit: COLMAP from one moving camera has no scale of its own, and the dataset does not say how +its reconstructions were put into meters. The phone's GPS, the one independent reference shipped, +cannot pin that scale better than several percent over these 25 to 160 m walks (checked here). So +ARKit's scale is measured against the ground truth's scale, not against the tape. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import gdown +import numpy as np + +from evals.drift import ( + DISTANCES_FT, + FEET, + INCH, + at, + gps_local_meters, + horizontal_path_length, + similarity_scale_2d, + window_pairs, +) +from evals.geometry import quat_wxyz_to_matrix +from evals.paths import EVALS_DIR + +MARVIN_DIR = EVALS_DIR / "marvin" +FOLDER = "https://drive.google.com/drive/folders/18vPfKB4jlDwtFzytMijYOpmeqU1A9-Ie" +# The outdoor scenes whose iPhone walks have ground truth (the paper's three outdoor scenes). +SCENES = ("atrium", "bar", "church") +SCALE_MIN_FT = 10.0 +OUTLIER = ( + 0.10 # a walk this far off the reference's scale is reported, but its reference is in doubt +) +RESULTS = Path(__file__).resolve().parents[1] / "results" + + +def fetch() -> dict[str, str]: + """Download only the pose, GPS and ground-truth text files; return {path: sha256}.""" + files = gdown.download_folder(FOLDER, skip_download=True, quiet=True, remaining_ok=True) + wanted = [ + f + for f in files + if f.path.split("/")[0] in SCENES + and f.path.endswith(("ARkitPose.txt", "GPSData.txt", "train.txt", "test.txt")) + ] + hashes = {} + for f in wanted: + out = MARVIN_DIR / f.path + if not out.exists(): + out.parent.mkdir(parents=True, exist_ok=True) + if gdown.download(id=f.id, output=str(out), quiet=True) is None: + raise RuntimeError(f"Google Drive refused {f.path} ({f.id}); retry later") + hashes[f.path] = hashlib.sha256(out.read_bytes()).hexdigest() + return hashes + + +def read_table(path: Path, columns: int) -> dict[str, np.ndarray]: + rows = {} + for line in path.read_text().splitlines(): + t = line.split() + if len(t) == columns: + rows[t[0]] = np.array(t[1:], dtype=np.float64) + return rows + + +def level_frame(points: np.ndarray) -> np.ndarray: + """Rows: two horizontal axes and the vertical (least-variance) axis of a walked trajectory.""" + _, _, Vt = np.linalg.svd(points - points.mean(axis=0), full_matrices=False) + return Vt + + +def heading(v: np.ndarray) -> np.ndarray: + """Heading of y-up vectors (N, 3): the angle of their (x, z) part.""" + return np.arctan2(v[:, 0], v[:, 2]) + + +def turn(v: np.ndarray, delta: np.ndarray) -> np.ndarray: + """Turn y-up vectors about y so each heading grows by delta (radians).""" + c, s = np.cos(delta), np.sin(delta) + return np.c_[v[:, 0] * c + v[:, 2] * s, v[:, 1], v[:, 2] * c - v[:, 0] * s] + + +def circular_std_deg(a: np.ndarray) -> float: + return float(np.degrees(np.sqrt(-2 * np.log(abs(np.mean(np.exp(1j * a))))))) + + +def align_headings( + ark_p: np.ndarray, ark_q: np.ndarray, truth_p: np.ndarray, truth_fwd: np.ndarray +) -> tuple[np.ndarray, np.ndarray, float]: + """ARKit positions in a frame with the truth's handedness and vertical sign, the per-image + heading offset (truth minus ARKit, radians) and how steady it is (circular std, degrees). + + ARKit's poses are in Unity's left-handed axes (camera looking along +z) and the truth's level + frame has an arbitrary vertical sign, so the z flip that makes the heading offset steadiest is + taken; a correct pairing holds it within a degree over a whole walk. + """ + fwd = quat_wxyz_to_matrix(ark_q)[:, :, 2] + best = None + for flip in (1.0, -1.0): + f = fwd * np.array([1.0, 1.0, flip]) + delta = heading(truth_fwd) - heading(f) + spread = circular_std_deg(delta) + if best is None or spread < best[2]: + best = (flip, delta, spread) + flip, delta, spread = best + p = ark_p * np.array([1.0, 1.0, flip]) + if (p[:, 1] - p[:, 1].mean()) @ (truth_p[:, 1] - truth_p[:, 1].mean()) < 0: + p = p * np.array([1.0, -1.0, 1.0]) + return p, delta, spread + + +def walk_errors(ark: np.ndarray, truth: np.ndarray, delta: np.ndarray) -> dict: + """Per distance, windows from every image: ARKit's and the truth's straight-line displacement + (m), and the two displacements with ARKit's turned by the heading offset at the window's + start, for position error.""" + out = {} + for ft in DISTANCES_FT: + i, j = window_pairs(truth, ft * FEET, 1) + da = at(ark, j) - ark[i] + dt = at(truth, j) - truth[i] + out[ft] = { + "ark": np.linalg.norm(da, axis=1), + "truth": np.linalg.norm(dt, axis=1), + "ark_turned": turn(da, delta[i]), + "truth_vec": dt, + } + return out + + +def gps_scale(gps: dict, truth_by_image: dict, frame: np.ndarray) -> tuple[float, float] | None: + """GPS over truth scale for one walk and GPS's median fit residual (m).""" + keys = [k for k in gps if k in truth_by_image and gps[k][0] != 0] + if len(keys) < 30: + return None + en = gps_local_meters(np.array([gps[k][0] for k in keys]), np.array([gps[k][1] for k in keys])) + level = np.array([truth_by_image[k] for k in keys]) @ frame[:2].T + s, res = similarity_scale_2d(level, en) + return s, float(np.median(res)) + + +def evaluate() -> dict: + scenes = {} + for scene in SCENES: + root = MARVIN_DIR / scene + truth = {**read_table(root / "train.txt", 8), **read_table(root / "test.txt", 8)} + frame = level_frame(np.array([v[:3] for v in truth.values()])) + walks = [] + for seq in sorted(root.glob("seq*/ARkitPose.txt"), key=lambda p: int(p.parent.name[3:])): + name = seq.parent.name + ark_rows = read_table(seq, 8) + keys = sorted(k for k in ark_rows if f"{name}/{k}" in truth) + if len(keys) < 30: + continue + # y up, as `horizontal_path_length` expects. + t = np.array([truth[f"{name}/{k}"][:3] for k in keys]) @ frame.T + t = t[:, [0, 2, 1]] + # The truth's camera looks along +z of its world-to-camera rotation (COLMAP). + fwd = ( + np.array([quat_wxyz_to_matrix(truth[f"{name}/{k}"][3:])[2] for k in keys]) @ frame.T + ) + ark, delta, spread = align_headings( + np.array([ark_rows[k][:3] for k in keys]), + np.array([ark_rows[k][3:] for k in keys]), + t, + fwd[:, [0, 2, 1]], + ) + errs = walk_errors(ark, t, delta) + long = np.concatenate( + [errs[ft]["ark"] / errs[ft]["truth"] for ft in DISTANCES_FT if ft >= SCALE_MIN_FT] + ) + scale = float(np.median(long)) if len(long) else float("nan") + gps_path = root / name / "GPSData.txt" + gps = None + if gps_path.exists(): + by_image = {k: truth[f"{name}/{k}"][:3] for k in keys} + gps = gps_scale(read_table(gps_path, 4), by_image, frame) + walks.append( + { + "walk": name, + "images": len(keys), + "walked_m": float(horizontal_path_length(t)[-1]), + "scale": scale, + "heading_offset_spread_deg": round(spread, 2), + "gps_over_truth": gps, + "errors": errs, + } + ) + scenes[scene] = walks + return scenes + + +def site_spread( + scales: np.ndarray, draws: int = 10_000, seed: int = 0 +) -> tuple[float, float, float]: + """Walk-to-walk standard deviation of ARKit's scale within one site, and its 95% bootstrap + interval over walks. Walks off by more than `OUTLIER` are left out: their reference is in doubt. + """ + s = scales[np.abs(scales - 1) <= OUTLIER] + if len(s) < 3: + raise ValueError(f"{len(s)} trusted walks; need at least 3 for a spread") + rng = np.random.default_rng(seed) + boot = s[rng.integers(0, len(s), (draws, len(s)))].std(axis=1, ddof=1) + lo, hi = np.percentile(boot, [2.5, 97.5]) + return float(s.std(ddof=1)), float(lo), float(hi) + + +def _abs(x_m: np.ndarray) -> str: + a = np.abs(x_m) / INCH + return f"{np.median(a):.1f} / {np.percentile(a, 90):.1f}" + + +def markdown(scenes: dict, hashes: dict[str, str]) -> str: + lines = [ + "# A current iPhone's ARKit scale (generated by `uv run python -m evals.modern_arkit`)", + "", + "MARViN, iPhone 14 Pro Max, ARKit 6, outdoor walks, against the dataset's COLMAP ground " + "truth. Scale: ARKit / truth over windows of 10 ft or more, per walk.", + "", + "| Scene | Walks | Walked per walk (m) | ARKit scale error, median walk | Walks' range |", + "| --- | --- | --- | --- | --- |", + ] + for scene, walks in scenes.items(): + s = np.array([w["scale"] for w in walks]) + m = np.array([w["walked_m"] for w in walks]) + lines.append( + f"| {scene} | {len(walks)} | {np.median(m):.0f} | {100 * (np.median(s) - 1):+.1f}% | " + f"{100 * (s.min() - 1):+.1f}% to {100 * (s.max() - 1):+.1f}% |" + ) + all_walks = [w for walks in scenes.values() for w in walks] + s_all = np.array([w["scale"] for w in all_walks]) + lines += [ + f"| all | {len(all_walks)} | | {100 * (np.median(s_all) - 1):+.1f}% | " + f"{100 * (s_all.min() - 1):+.1f}% to {100 * (s_all.max() - 1):+.1f}% |", + "", + f"Walks within 2% of the ground truth's scale: {int(np.sum(np.abs(s_all - 1) <= 0.02))} of " + f"{len(all_walks)}.", + "", + "## Walk-to-walk spread of ARKit's scale within a site", + "", + f"Standard deviation over each site's walks within {OUTLIER:.0%} of the reference, with a " + "95% bootstrap interval over walks. A reference scaled to ARKit would carry one scale per " + "site, which shifts every walk of that site alike, so this spread survives it: a single " + "walk's scale error is at least this large, unless the reference's own scale varies walk " + "to walk in step with ARKit's.", + "", + "| Site | Walks used | Spread (1 SD) | 95% interval |", + "| --- | --- | --- | --- |", + ] + for scene, walks in scenes.items(): + s = np.array([w["scale"] for w in walks]) + sd, lo, hi = site_spread(s) + used = int(np.sum(np.abs(s - 1) <= OUTLIER)) + lines.append(f"| {scene} | {used} | {100 * sd:.2f}% | {100 * lo:.2f}% to {100 * hi:.2f}% |") + lines += [ + "", + "## Distance error after walking, pooled over all walks", + "", + "|ARKit - truth| of the straight-line displacement, inches, median / p90. 'Beyond scale' " + "divides each walk's own scale out first. The last column leaves out walks whose scale is " + f"off by more than {OUTLIER:.0%}: there ARKit and the reference disagree about whether the " + "phone moved at all between images, so the reference itself is in doubt. The 3 ft row is " + "below what this reference resolves: images are 0.2 to 1.4 m apart, and the reference " + "jitters by up to 0.4 m on those walks.", + "", + "| Walked | As tracked | Beyond scale | As tracked, without those walks |", + "| --- | --- | --- | --- |", + ] + position = {} + for ft in DISTANCES_FT: + raw, beyond, kept = [], [], [] + pos, pos_beyond, pos_kept = [], [], [] + for w in all_walks: + e = w["errors"][ft] + raw.append(e["ark"] - e["truth"]) + beyond.append(e["ark"] / w["scale"] - e["truth"]) + pe = np.linalg.norm(e["ark_turned"] - e["truth_vec"], axis=1) + pos.append(pe) + pos_beyond.append(np.linalg.norm(e["ark_turned"] / w["scale"] - e["truth_vec"], axis=1)) + if abs(w["scale"] - 1) <= OUTLIER: + kept.append(e["ark"] - e["truth"]) + pos_kept.append(pe) + lines.append( + f"| {ft} ft | {_abs(np.concatenate(raw))} | {_abs(np.concatenate(beyond))} | " + f"{_abs(np.concatenate(kept))} |" + ) + position[ft] = (pos, pos_beyond, pos_kept) + spreads = np.array([w["heading_offset_spread_deg"] for w in all_walks]) + lines += [ + "", + "## Position error after walking, pooled over all walks", + "", + "|ARKit - truth| of the whole displacement, inches, median / p90, ARKit's displacement " + "turned by the heading offset at the window's start (so sideways drift counts), as in " + "section 1. The heading offset between ARKit and the truth holds steady to " + f"{np.median(spreads):.1f} degrees (median walk; worst {spreads.max():.1f}). 'Scale " + "removed' divides each walk's own scale out first. Columns as above.", + "", + "| Walked | As tracked | Scale removed | As tracked, without those walks | 0.16 ft/ft allowance |", + "| --- | --- | --- | --- | --- |", + ] + for ft, (pos, pos_beyond, pos_kept) in position.items(): + lines.append( + f"| {ft} ft | {_abs(np.concatenate(pos))} | {_abs(np.concatenate(pos_beyond))} | " + f"{_abs(np.concatenate(pos_kept))} | {0.16 * ft * 12:.1f} |" + ) + gps = [ + (w["gps_over_truth"][0], w["gps_over_truth"][1]) for w in all_walks if w["gps_over_truth"] + ] + g = np.array([x[0] for x in gps]) + tight = np.array([x[0] for x in gps if x[1] <= 1.5]) + lines += [ + "", + "## Can GPS check the ground truth's scale?", + "", + f"GPS / truth over each walk: median {np.median(g):.3f}, range {g.min():.3f} to " + f"{g.max():.3f} ({len(g)} walks). On the {len(tight)} walks where GPS fits within 1.5 m, " + f"median {np.median(tight):.3f}, range {tight.min():.3f} to {tight.max():.3f}. GPS noise of " + "1 to 9 m over 25 to 160 m walks cannot pin the scale to 2%.", + "", + "## Files (Google Drive folder " + FOLDER + ")", + "", + "| File | sha256 |", + "| --- | --- |", + ] + lines += [f"| {p} | {h} |" for p, h in sorted(hashes.items())] + return "\n".join(lines) + "\n" + + +def main() -> None: + hashes = fetch() + scenes = evaluate() + summary = { + scene: [{k: v for k, v in w.items() if k != "errors"} for w in walks] + for scene, walks in scenes.items() + } + RESULTS.mkdir(exist_ok=True) + (RESULTS / "modern_arkit.json").write_text(json.dumps(summary, indent=1)) + md = markdown(scenes, hashes) + (RESULTS / "modern_arkit.md").write_text(md) + print(md) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/pairs.py b/experiments/evals/evals/pairs.py new file mode 100644 index 000000000..5f3dd5fc9 --- /dev/null +++ b/experiments/evals/evals/pairs.py @@ -0,0 +1,179 @@ +"""Point-pair length errors: how far off is the distance between two points the reconstruction gives? + +A placement check measures lengths (a clearance, a height, a span), so the score is the error in +the distance between two surface points, compared with the laser scan's distance between the same +two points. Distances do not change under rotation or translation, so no alignment step can hide or +add error: only scale and shape count. + +Each evaluated point carries its prediction as `c + s * r`: `r` is the predicted offset from a +known centre `c`, and `s` a global scale factor (1 when the model's metric output is used as is). +For a single image or a pose-free multi-view model `c` is zero and `r` the predicted point. For +per-frame depth placed with known camera poses, `c` is the camera centre and `r` the predicted ray +offset, because scaling a depth map moves points along the rays, not about the origin. Averaging a +point over several views keeps that form: the mean of `c_v + s r_v` is `mean(c) + s mean(r)`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +INCH = 0.0254 +BINS_M = ((1.0, 3.0), (3.0, 10.0)) + + +@dataclass(frozen=True) +class Points: + gt: np.ndarray # (N, 3) laser-scan positions + c: np.ndarray # (N, 3) prediction centre + r: np.ndarray # (N, 3) prediction offset: predicted point = c + s r + + def predicted(self, s: float = 1.0) -> np.ndarray: + return self.c + s * self.r + + +def sample_pairs( + gt: np.ndarray, lo: float, hi: float, n: int, rng: np.random.Generator, max_draws: int = 50 +) -> np.ndarray: + """Up to n random index pairs (n, 2) whose ground-truth distance is in [lo, hi).""" + found: list[np.ndarray] = [] + count = 0 + for _ in range(max_draws): + i = rng.integers(0, len(gt), size=4 * n) + j = rng.integers(0, len(gt), size=4 * n) + d = np.linalg.norm(gt[i] - gt[j], axis=1) + ok = (d >= lo) & (d < hi) & (i != j) + found.append(np.c_[i[ok], j[ok]]) + count += int(ok.sum()) + if count >= n: + break + pairs = np.concatenate(found) if found else np.empty((0, 2), dtype=int) + return pairs[:n] + + +def pair_distances(p: np.ndarray, pairs: np.ndarray) -> np.ndarray: + return np.linalg.norm(p[pairs[:, 0]] - p[pairs[:, 1]], axis=1) + + +def scale_for_length(dc: np.ndarray, dr: np.ndarray, d: float) -> float: + """Scale s > 0 with |dc + s dr| = d: the pair's centre difference dc, offset difference dr and + true length d. Of two positive roots, the one nearer 1 (the model's own scale) is taken.""" + qa, qb, qc = dr @ dr, 2 * dc @ dr, dc @ dc - d * d + if qa <= 0: + raise ValueError("pair has identical predicted offsets; no scale can match it") + disc = qb * qb - 4 * qa * qc + if disc < 0: + raise ValueError(f"no scale makes this pair {d:.3f} m long") + roots = [(-qb + sgn * np.sqrt(disc)) / (2 * qa) for sgn in (1, -1)] + positive = [x for x in roots if x > 0] + if not positive: + raise ValueError(f"no positive scale makes this pair {d:.3f} m long") + return float(min(positive, key=lambda x: abs(x - 1))) + + +def scale_for_known_distance(points: Points, pair: np.ndarray) -> float: + """Scale s that makes the predicted distance of one pair equal its true (taped) distance. + + Solves |dc + s dr| = d for s > 0, with dc and dr the pair's differences in c and r; with c = 0 + this is simply d / |dr|. With camera centres two positive roots can exist (converging rays can + shrink or flip the pair); the one nearer 1, the model's own scale, is taken. + """ + a, b = pair + d = float(np.linalg.norm(points.gt[a] - points.gt[b])) + return scale_for_length(points.c[a] - points.c[b], points.r[a] - points.r[b], d) + + +def length_errors(points: Points, pairs: np.ndarray, s: float = 1.0) -> np.ndarray: + """Predicted minus true distance for each pair, meters.""" + return pair_distances(points.predicted(s), pairs) - pair_distances(points.gt, pairs) + + +def summarize(errors_m: np.ndarray, ratios: np.ndarray | None = None) -> dict[str, float]: + """Median and p90 of |error| in inches, and the median length ratio as a percent scale error. + + Failures (a pair with no prediction, or scaled by a taped reference that could not be matched) + are infinite errors: they count against the median and p90 instead of being dropped. + """ + a = np.abs(errors_m) / INCH + finite = np.isfinite(a) + # Percentiles interpolate, and inf - inf is NaN, so failures become a finite sentinel first. + sentinel = 1e12 + q = np.where(finite, a, sentinel) + + def pct(p: float) -> float: + # Interpolation between a finite value and the sentinel gives a huge finite number, so + # check the upper neighbour: if it is a failure, the percentile is one. + if float(np.percentile(q, p, method="higher")) >= sentinel: + return float("inf") + return round(float(np.percentile(q, p)), 2) + + out = { + "pairs": len(a), + "failed_pct": round(float(100 * (1 - finite.mean())), 2) if len(a) else 0.0, + "median_in": pct(50) if len(a) else float("nan"), + "p90_in": pct(90) if len(a) else float("nan"), + } + if ratios is not None and np.isfinite(ratios).any(): + out["scale_error_pct"] = round(float((np.median(ratios[np.isfinite(ratios)]) - 1) * 100), 2) + return out + + +def bin_key(b: tuple[float, float]) -> str: + return f"{b[0]:g}-{b[1]:g}m" + + +def evaluate_fixed(points: Points, pairs: dict[str, np.ndarray], refs: np.ndarray) -> dict: + """Length errors on a fixed evaluation set, so every method and view count sees the same pairs. + + `points` rows with NaN predictions have no prediction. `pairs` maps a bin key to index pairs; + `refs` are the taped reference pairs. Returns {"none" | "one_known_distance": {bin: (errors, + ratios)}, "refs": count, "ref_failures": count}. A reference fails when either end has no + prediction or no positive scale matches it; all pairs scaled by a failed reference fail. + """ + scales: list[float | None] = [] + for ref in refs: + if not np.isfinite(points.r[ref]).all(): + scales.append(None) + continue + try: + scales.append(scale_for_known_distance(points, ref)) + except ValueError: + scales.append(None) + out: dict = {"none": {}, "one_known_distance": {}} + for key, pr in pairs.items(): + gt_d = pair_distances(points.gt, pr) + e = length_errors(points, pr) + e = np.where(np.isnan(e), np.inf, e) + out["none"][key] = (e, (e + gt_d) / gt_d) + per_ref = [] + for sc in scales: + if sc is None: + per_ref.append(np.full(len(pr), np.inf)) + else: + es = length_errors(points, pr, sc) + per_ref.append(np.where(np.isnan(es), np.inf, es)) + e1 = np.concatenate(per_ref) if per_ref else np.empty(0) + gt_rep = np.tile(gt_d, len(per_ref)) + out["one_known_distance"][key] = (e1, (e1 + gt_rep) / gt_rep) + out["refs"] = len(scales) + out["ref_failures"] = sum(sc is None for sc in scales) + return out + + +def pool(raws: list[dict]) -> dict[str, dict]: + """Concatenate `evaluate_fixed` results from several evaluation sets and summarize them.""" + out: dict[str, dict] = {} + keys = sorted({k for r in raws for k in r["none"]}) + for source in ("none", "one_known_distance"): + out[source] = {} + for key in keys: + parts = [r[source][key] for r in raws if key in r[source]] + e = np.concatenate([p[0] for p in parts]) if parts else np.empty(0) + ratios = np.concatenate([p[1] for p in parts]) if parts else np.empty(0) + out[source][key] = summarize(e, ratios) + refs = sum(r["refs"] for r in raws) + out["tape_calibration_success_pct"] = ( + round(100 * (1 - sum(r["ref_failures"] for r in raws) / refs), 1) if refs else None + ) + return out diff --git a/experiments/evals/evals/paths.py b/experiments/evals/evals/paths.py new file mode 100644 index 000000000..04b3bced2 --- /dev/null +++ b/experiments/evals/evals/paths.py @@ -0,0 +1,13 @@ +"""Where data lives. Datasets are non-commercial and stay out of git; override the root with +HOUSE_SCANNING_DATA if the default is wrong.""" + +from __future__ import annotations + +import os +from pathlib import Path + +DATA_ROOT = Path(os.environ.get("HOUSE_SCANNING_DATA", Path.home() / "house-scanning-data")) +EVALS_DIR = DATA_ROOT / "evals" +ADVIO_DIR = EVALS_DIR / "advio" +ETH3D_DIR = EVALS_DIR / "eth3d" +REPLAYS_DIR = DATA_ROOT / "replays" diff --git a/experiments/evals/evals/pose_priors.py b/experiments/evals/evals/pose_priors.py new file mode 100644 index 000000000..6d58314bf --- /dev/null +++ b/experiments/evals/evals/pose_priors.py @@ -0,0 +1,389 @@ +"""Can the phone's AR poses fix the learned models' scale? ETH3D, poses degraded to AR-like error. + + uv run python -m evals.pose_priors prepare # AR-like poses per group and noise draw + make pose-priors # all of it, including MapAnything runs + uv run python -m evals.pose_priors score # -> results/pose_priors.md + +Two ways to use the poses, at each pose setting in `evals.ar_poses`: +- (a) MapAnything given the poses and the intrinsics as priors, one joint run per group. Run at + 392 px on the long side, 2 and 4 views: its native 518 px, or 8 views, needs more than the + roughly 4 GB one process may use on this shared machine. The same runs without poses are the + baseline, so the comparison holds resolution and precision fixed. +- (b) MoGe-2 per-photo metric depth, each photo rescaled to the points triangulated from features + matched across the group with those poses (`evals.triangulate`), then placed with the same poses + and fused. +As a control, MoGe-2 depth placed with the same poses but not rescaled shows what the rescale adds. +Everything is scored on the fixed evaluation sets of `evals.recon`. + +Uncertainty. A row rests on a few seed groups (6 to 8) and, for settings with pose noise, on +`NOISE_DRAWS` draws of that noise per group. The point estimate pools every group's and draw's +errors. The 95% interval is a two-stage bootstrap: draw the groups with replacement, then for each +group drawn its noise draws with replacement, pool the errors drawn, and take the median and p90. +MapAnything's weights are no longer on this machine, so its rows keep their single noise draw. + +`prepare` writes the poses to eth3d//ar_poses/.json (draw 0, the file MapAnything +reads) and .draw.json. `score` caches each group's triangulated scale factors in +eth3d//triangulation_fits.json, keyed by a hash of the poses, intrinsics and threshold that +produced them, so rescoring does not refit and changed inputs do. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + +import cv2 +import numpy as np + +from evals.ar_poses import ADVIO_ARKIT_SCALES, SETTINGS, group_poses, noise_draws +from evals.eth3d import SCENES, read_views +from evals.pairs import INCH, evaluate_fixed, pool +from evals.paths import ETH3D_DIR +from evals.recon import ( + COHORTS, + HEADER, + PREDICTIONS, + RANGES, + WIDTH, + EvalSet, + Method, + Scene, + cell, + load_prediction, + model_frame_method, + predict, +) +from evals.triangulate import view_scales + +GROUP_SIZES = (2, 4, 8) # triangulation needs at least two photos +MODEL = "moge2" +BOOTSTRAP_REPS = 1000 +# Percentiles interpolate and inf - inf is NaN, so failures become a finite sentinel (as in +# `evals.pairs.summarize`) and any result that touches one is reported as a failure. +SENTINEL = 1e12 +# The MapAnything advio_2018 runs were given the poses of the earlier advio_2018 setting, whose +# scales were ARKit against ARCore (0.883, 0.936, 0.958); the weights are gone, so it cannot rerun. +MAPANYTHING_LABELS = {"advio_2018": "advio_2018 poses, earlier ARCore-referenced scales"} + + +def pose_file(scene: str, setting: str, draw: int = 0) -> Path: + name = setting if draw == 0 else f"{setting}.draw{draw}" + return ETH3D_DIR / scene / "ar_poses" / f"{name}.json" + + +def prepare(scene: str) -> None: + views = {v.name: v for v in read_views(ETH3D_DIR / scene)} + groups = json.loads((ETH3D_DIR / scene / "subsets.json").read_text()) + groups = {n: g for n, g in groups.items() if int(n) in GROUP_SIZES} + for setting in SETTINGS: + for draw in noise_draws(setting): + path = pose_file(scene, setting, draw) + path.parent.mkdir(exist_ok=True) + path.write_text(json.dumps(group_poses(views, groups, setting, draw))) + count = sum(map(len, groups.values())) + draws = {s: len(noise_draws(s)) for s in SETTINGS} + print(f"{scene}: AR-like poses for {count} groups, noise draws per setting {draws}") + + +class FitCache: + """Per-photo triangulation scales, stored with a hash of the inputs that produced them.""" + + def __init__(self, path: Path): + self.path = path + self.entries: dict = json.loads(path.read_text()) if path.exists() else {} + self.changed = False + + def get(self, key: str, inputs: str, fit) -> dict[str, float | None]: + entry = self.entries.get(key) + if entry is None or entry["inputs"] != inputs: + self.entries[key] = {"inputs": inputs, "scales": fit()} + self.changed = True + return self.entries[key]["scales"] + + def save(self) -> None: + if self.changed: + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(self.entries, indent=0)) + tmp.replace(self.path) + self.changed = False + + +def fit_inputs(T: dict, K: dict, max_reproj_px: float) -> str: + """What a group's scale fit depends on besides the images and depth maps on disk.""" + spec = { + "model": MODEL, + "poses": {m: np.asarray(t).tolist() for m, t in T.items()}, + "K": {m: np.asarray(k).tolist() for m, k in K.items()}, + "max_reproj_px": max_reproj_px, + } + return hashlib.sha256(json.dumps(spec, sort_keys=True).encode()).hexdigest()[:16] + + +def pose_methods(scene: Scene, fits: FitCache) -> dict[str, list[Method]]: + """{row label: one Method per noise draw}.""" + root = PREDICTIONS / scene.name + gray: dict[str, np.ndarray] = {} + depths: dict[str, np.ndarray] = {} + + def image(name): + if name not in gray: + gray[name] = cv2.imread(str(scene.dir / f"images_{WIDTH}" / f"{name}.jpg"), 0) + return gray[name] + + def depth(name): + if name not in depths: + depths[name] = load_prediction(root / MODEL / f"{name}.npz")[0] + return depths[name] + + def fused_with(setting: str, draw: int, rescale: bool) -> Method: + poses_by_group = json.loads(pose_file(scene.name, setting, draw).read_text()) + + def method(gid, members): + if gid not in poses_by_group: + return None + T = {m: np.array(poses_by_group[gid]["poses"][m]) for m in members} + scale: dict[str, float | None] = {m: 1.0 for m in members} + if rescale: + K = {m: scene.K(m) for m in members} + px = SETTINGS[setting].reprojection_px + + def fit(): + f = view_scales( + {m: image(m) for m in members}, + K, + T, + {m: depth(m) for m in members}, + max_reproj_px=px, + ) + return {m: f[m].scale for m in members} + + scale = fits.get(f"{setting}/draw{draw}/{gid}", fit_inputs(T, K, px), fit) + + def pv(name): + d, s = depth(name), scale[name] + return ( + (d * s if s is not None else np.full_like(d, np.nan)), + scene.K(name), + T[name], + True, + ) + + return pv + + return method + + methods: dict[str, list[Method]] = { + "mapanything@392, images only": [model_frame_method(root / "ma392_images")] + } + for setting in SETTINGS: + label = MAPANYTHING_LABELS.get(setting, f"{setting} poses") + methods[f"mapanything@392 + {label}"] = [model_frame_method(root / f"ma392_{setting}")] + for setting in SETTINGS: + draws = noise_draws(setting) + methods[f"MoGe-2 placed with {setting} poses"] = [ + fused_with(setting, d, rescale=False) for d in draws + ] + methods[f"MoGe-2 rescaled by triangulation, {setting} poses"] = [ + fused_with(setting, d, rescale=True) for d in draws + ] + return methods + + +def pooled_percentiles( + sorted_values: np.ndarray, sorted_unit: np.ndarray, weights: np.ndarray, qs +) -> np.ndarray: + """np.percentile (linear) of the multiset in which each value appears `weights[unit]` times. + `sorted_values` ascending, finite (failures as SENTINEL); `sorted_unit` the unit of each.""" + cum = np.cumsum(weights[sorted_unit]) + n = cum[-1] + h = np.asarray(qs, float) / 100 * (n - 1) + lo = np.floor(h) + frac = h - lo + # The k-th value (0-based) of the expanded multiset is the first whose cumulative count > k. + a = sorted_values[np.searchsorted(cum, lo, side="right")] + b = sorted_values[np.searchsorted(cum, np.minimum(lo + 1, n - 1), side="right")] + out = a + frac * (b - a) + return np.where((a >= SENTINEL) | ((b >= SENTINEL) & (frac > 0)), np.inf, out) + + +def bootstrap_interval( + units: list[list[np.ndarray]], qs=(50, 90), reps: int = BOOTSTRAP_REPS, seed: int = 0 +) -> list[tuple[float, float]]: + """95% interval of the pooled percentiles `qs` of `units[group][draw]` (|errors|, inf for a + failure). Each replicate draws the groups with replacement, then for each group drawn its noise + draws with replacement, and pools every value drawn. Interval ends are order statistics of the + replicates (no interpolation, so a replicate that fails cannot blur into a finite end).""" + values, unit_of, unit_ids = [], [], [] + u = 0 + for draws in units: + ids = [] + for v in draws: + values.append(np.asarray(v, float)) + unit_of.append(np.full(len(v), u)) + ids.append(u) + u += 1 + unit_ids.append(np.array(ids)) + v = np.concatenate(values) + unit = np.concatenate(unit_of) + v = np.where(np.isfinite(v), v, SENTINEL) + order = np.argsort(v, kind="stable") + v, unit = v[order], unit[order] + rng = np.random.default_rng(seed) + reps_out = np.empty((reps, len(qs))) + for r in range(reps): + w = np.zeros(u, np.int64) + for g in rng.integers(0, len(units), len(units)): + ids = unit_ids[g] + np.add.at(w, ids[rng.integers(0, len(ids), len(ids))], 1) + reps_out[r] = pooled_percentiles(v, unit, w, qs) + lo, hi = np.quantile(reps_out, [0.025, 0.975], axis=0, method="inverted_cdf") + return [(_round(a), _round(b)) for a, b in zip(lo, hi, strict=True)] + + +def _round(x: float) -> float: + return float(x) if not np.isfinite(x) else round(float(x), 2) + + +def summarize_groups(units: list[list[dict]]) -> dict: + """`units[group][draw]` are `evaluate_fixed` results. Pools them all (`evals.pairs.pool`) and + adds a 95% interval for the model-scale median and p90 per span bin.""" + out = pool([r for draws in units for r in draws]) + out["groups"] = len(units) + out["noise_draws"] = max(len(draws) for draws in units) + out["interval_95"] = {} + for key in out["none"]: + errs = [ + [np.abs(r["none"][key][0]) / INCH for r in draws if key in r["none"]] for draws in units + ] + errs = [e for e in errs if e] + (m_lo, m_hi), (p_lo, p_hi) = bootstrap_interval(errs) + out["interval_95"][key] = {"median_in": [m_lo, m_hi], "p90_in": [p_lo, p_hi]} + return out + + +def score_scene(scene: Scene, methods: dict[str, list[Method]], sizes=GROUP_SIZES) -> dict: + """{range: {method: {views: {cohort: summary}}}} on `evals.recon`'s fixed evaluation sets.""" + out: dict = {} + for range_name, max_range in RANGES.items(): + sets = {m[0]: EvalSet(scene, m[0], max_range) for m in scene.groups["1"]} + out[range_name] = {} + for method, draws in methods.items(): + res: dict = {} + for n in map(str, sizes): + units: dict[str, list[list[dict]]] = {c: [] for c in COHORTS} + for members in scene.groups.get(n, []): + ev = sets[members[0]] + per_cohort: dict[str, list[dict]] = {c: [] for c in COHORTS} + for factory in draws: + per_view = factory(f"n{n}-{members[0]}", members) + if per_view is None: + continue + pts = predict(scene, ev, members, per_view, max_range) + for c in COHORTS: + if ev.pairs[c]: + per_cohort[c].append(evaluate_fixed(pts, ev.pairs[c], ev.refs)) + for c, raws in per_cohort.items(): + if raws: + units[c].append(raws) + if units["surface interior"]: + res[n] = {c: summarize_groups(u) for c, u in units.items() if u} + if res: + out[range_name][method] = res + return out + + +def _with_interval(x: dict | None, iv: dict | None) -> str: + """'median [lo, hi] / p90 [lo, hi]' in inches.""" + if not x or x.get("pairs", 0) == 0 or not iv: + return cell(x) + + def num(y): + return f"{y:.1f}" if np.isfinite(y) else "inf" + + def one(v, lo, hi): + return f"{'fails' if not np.isfinite(v) else num(v)} [{num(lo)}, {num(hi)}]" + + return f"{one(x['median_in'], *iv['median_in'])} / {one(x['p90_in'], *iv['p90_in'])}" + + +def _scale(x: dict | None) -> str: + v = (x or {}).get("scale_error_pct") + return "n/a" if v is None else f"{v:+.1f}%" + + +def table(rows: list[tuple[str, str, dict]], cohort: str) -> list[str]: + lines = [ + "| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m " + "| 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for label, n, res in rows: + r = res.get(cohort) + if not r: + continue + a, b = r["none"].get("1-3m"), r["none"].get("3-10m") + iv = r["interval_95"] + ta, tb = r["one_known_distance"].get("1-3m"), r["one_known_distance"].get("3-10m") + ok = r.get("tape_calibration_success_pct") + lines.append( + f"| {label} | {n} | {r['groups']} x {r['noise_draws']} " + f"| {_with_interval(a, iv.get('1-3m'))} | {_with_interval(b, iv.get('3-10m'))} " + f"| {_scale(a)} | {_scale(b)} | {cell(ta)} | {cell(tb)} " + f"| {'n/a' if ok is None else f'{ok:.0f}%'} |" + ) + return lines + + +def markdown(results: dict) -> str: + advio = ", ".join(f"{s:.3f}" for s in ADVIO_ARKIT_SCALES) + lines = ["# Pose priors (generated by `uv run python -m evals.pose_priors score`)", "", HEADER] + lines.append( + "\nPose settings (assumptions, see `evals/ar_poses.py`): exact = the true poses; " + f"advio_2018 = scale {advio} per group (ADVIO's ARKit against the GPS-rescaled truth), " + "5 cm and 0.2 degrees of noise; modern_assumed = 2% short, 1 cm, 0.1 degrees, a guess " + "with no data." + ) + lines.append( + "\nModel-scale cells: median [95% interval] / p90 [95% interval], inches. The point " + "estimate pools every seed group and noise draw; the interval is a bootstrap over seed " + f"groups and, within each, noise draws ({BOOTSTRAP_REPS} replicates). 'Groups x noise " + "draws' counts both. MapAnything rows have one noise draw: its weights are no longer on " + "this machine. Its advio_2018 row was run on the earlier advio_2018 poses, whose scales " + "were ARKit against ARCore (0.883, 0.936, 0.958), with the same noise as draw 0 here." + ) + for scene, ranges in results.items(): + for range_name in RANGES: + rows = [(m, n, r) for m, per_n in ranges[range_name].items() for n, r in per_n.items()] + for cohort in COHORTS: + lines += ["", f"## {scene}, {range_name}, {cohort}", ""] + lines += table(rows, cohort) + return "\n".join(lines) + "\n" + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("step", choices=["prepare", "score"]) + args = ap.parse_args() + if args.step == "prepare": + for s in SCENES: + prepare(s) + return + results = {} + for s in SCENES: + scene = Scene(s) + fits = FitCache(scene.dir / "triangulation_fits.json") + results[s] = score_scene(scene, pose_methods(scene, fits)) + fits.save() + out = Path(__file__).resolve().parents[1] / "results" + (out / "pose_priors.json").write_text(json.dumps(results, indent=1)) + md = markdown(results) + (out / "pose_priors.md").write_text(md) + print(md) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/recon.py b/experiments/evals/evals/recon.py new file mode 100644 index 000000000..77ec4b13e --- /dev/null +++ b/experiments/evals/evals/recon.py @@ -0,0 +1,498 @@ +"""Score reconstructions of ETH3D's facade and electro scenes against their laser scans. + + uv run python -m evals.recon prepare # images at eval size, visibility, normals, groups + uv run python -m evals.recon score # every method with predictions on disk + uv run python -m evals.recon sensitivity # key rows at other visibility tolerances + +`prepare` writes, under ~/house-scanning-data/evals/eth3d//: +- `images_1024/.jpg`: the undistorted images at 1024 px wide, the input to every model; +- `model_inputs/images.txt` and `intrinsics.json`: the arguments the model runners take; +- `visibility_1024_tol/.npz`: the evaluation points each view sees, with edge flags; +- `normals.npy`: a surface normal per evaluation point, to find vertical surfaces; +- `subsets.json`: 8 seed views spread through the capture, each with its 1, 3 and 7 nearest cameras + that look the same way, giving groups of 1, 2, 4 and 8 views. + +Evaluation sets are fixed per seed view, so adding views or changing method never changes what is +scored: the points the seed sees (within 6 m of the seed camera for the near slice), split into +cohorts (surface interior, vertical-surface interior, depth edges), with test pairs and taped +reference pairs drawn once. Other views of a group only add predictions for those points, and in +the near slice a view contributes a point only if that view's own camera is within 6 m of it. +""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Callable +from pathlib import Path + +import cv2 +import numpy as np + +from evals.eth3d import ( + SCENES, + View, + excluded_pixels, + occluder_points, + point_normals, + read_views, + resized_image, + scan_points, + up_direction, + visible_scan_points, +) +from evals.pairs import BINS_M, Points, bin_key, evaluate_fixed, pool, sample_pairs +from evals.paths import ETH3D_DIR, EVALS_DIR +from evals.triangulate import sample_depth + +WIDTH = 1024 +EVAL_POINTS = 1_500_000 +GROUP_SIZES = (1, 2, 4, 8) +SEEDS_PER_SCENE = 8 +NEAR_M = 6.0 +RANGES = {"all points": None, "within 6 m": NEAR_M} +COHORTS = ("surface interior", "vertical interior", "edges") +VERTICAL_MAX_UP = 0.3 # |normal . up| below this: a vertical surface (within 17 degrees) +PAIRS_PER_BIN = 3000 +TAPE_REFS = 25 +TOLERANCE = 0.04 +PREDICTIONS = EVALS_DIR / "predictions" + +# per_view(view name) -> (depth HxW, K 3x3, cam-to-world 4x4, centred). `centred` True: the pose is +# metric (true or AR-like), so scaling depth moves points along rays from the camera centre; +# False: the pose is in a model's own frame and the whole reconstruction scales about its origin. +# Depth placed with the dataset's (or AR-like) poses is back-projected with the dataset's K +# (`Scene.K`): the pixels are the real camera's. MoGe-2's own K puts the principal point at the +# image centre, 0.8 to 3.6 px from ETH3D's at 1024 px wide; the model was given only the fov. +PerView = Callable[[str], tuple[np.ndarray, np.ndarray, np.ndarray, bool]] +# method(group id, members) -> PerView, or None when that method has no output for the group. +Method = Callable[[str, list[str]], PerView | None] + + +def _height(view: View) -> int: + return round(view.height * WIDTH / view.width) + + +def eval_candidates(scene: str) -> np.ndarray: + """A fixed random subset of the scan points used for scoring (all points still block views).""" + pts = scan_points(scene) + rng = np.random.default_rng(12345) + n = min(EVAL_POINTS, len(pts)) + return pts[np.sort(rng.choice(len(pts), size=n, replace=False))] + + +def view_groups(views: list[View]) -> dict[str, list[list[str]]]: + seeds = [views[i] for i in np.linspace(0, len(views) - 1, SEEDS_PER_SCENE).round().astype(int)] + groups: dict[str, list[list[str]]] = {} + for n in GROUP_SIZES: + groups[str(n)] = [] + for seed in seeds: + similar = [v for v in views if v.name != seed.name and v.forward @ seed.forward > 0.5] + similar.sort(key=lambda v: np.linalg.norm(v.center - seed.center)) + members = [seed.name] + [v.name for v in similar[: n - 1]] + if len(members) == n: + groups[str(n)].append(members) + return groups + + +def visibility_dir(scene: str, tolerance: float) -> Path: + return ETH3D_DIR / scene / f"visibility_{WIDTH}_tol{round(tolerance * 100)}" + + +def prepare(scene: str, tolerance: float = TOLERANCE) -> None: + scene_dir = ETH3D_DIR / scene + views = read_views(scene_dir) + cands = eval_candidates(scene) + blockers = np.concatenate([scan_points(scene), occluder_points(scene)]) + vis_dir = visibility_dir(scene, tolerance) + vis_dir.mkdir(exist_ok=True) + inputs = scene_dir / "model_inputs" + inputs.mkdir(exist_ok=True) + listing, intr = [], {} + for v in views: + _, w, h = resized_image(v, WIDTH) + path = scene_dir / f"images_{WIDTH}" / f"{v.name}.jpg" + listing.append(str(path)) + K = v.scaled_K(w, h) + intr[str(path)] = [K[0, 0], K[1, 1], K[0, 2], K[1, 2]] + out = vis_dir / f"{v.name}.npz" + if not out.exists(): + vis = visible_scan_points( + blockers, cands, v, w, h, excluded_pixels(v, w, h), tolerance=tolerance + ) + np.savez_compressed(out, index=vis.index, uv=vis.uv.astype(np.float32), edge=vis.edge) + (inputs / "images.txt").write_text("\n".join(listing) + "\n") + (inputs / "intrinsics.json").write_text(json.dumps(intr, indent=1)) + (scene_dir / "subsets.json").write_text(json.dumps(view_groups(views), indent=1)) + normals = scene_dir / "normals.npy" + if not normals.exists(): + np.save(normals, point_normals(cands).astype(np.float32)) + print(f"{scene}: {len(views)} views, {len(cands)} evaluation points, tolerance {tolerance}") + + +def camera_points(depth: np.ndarray, K: np.ndarray, uv: np.ndarray) -> np.ndarray: + z = sample_depth(depth, uv) + x = (uv[:, 0] - K[0, 2]) / K[0, 0] * z + y = (uv[:, 1] - K[1, 2]) / K[1, 1] * z + return np.c_[x, y, z] + + +def load_prediction(path: Path) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]: + z = np.load(path) + depth = np.where(z["valid"], z["depth"], np.nan) if "valid" in z else z["depth"] + fx, fy, cx, cy = map(float, z["intrinsics"]) + K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1.0]]) + return depth, K, z.get("cam_to_world", None) + + +class Scene: + def __init__(self, name: str, tolerance: float = TOLERANCE): + self.name = name + self.dir = ETH3D_DIR / name + self.views = {v.name: v for v in read_views(self.dir)} + self.cands = eval_candidates(name) + self.groups = json.loads((self.dir / "subsets.json").read_text()) + up = up_direction(list(self.views.values())) + self.vertical = np.abs(np.load(self.dir / "normals.npy") @ up) < VERTICAL_MAX_UP + self.vis_dir = visibility_dir(name, tolerance) + self._vis: dict[str, tuple[np.ndarray, np.ndarray, np.ndarray]] = {} + self._oracle: dict[str, tuple[np.ndarray, np.ndarray]] = {} + + def K(self, view: str) -> np.ndarray: + """The dataset's intrinsics for the view at the evaluation width.""" + v = self.views[view] + return v.scaled_K(WIDTH, _height(v)) + + def visible(self, view: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + if view not in self._vis: + z = np.load(self.vis_dir / f"{view}.npz") + self._vis[view] = (z["index"], z["uv"].astype(np.float64), z["edge"]) + return self._vis[view] + + def oracle_depth(self, view: str) -> tuple[np.ndarray, np.ndarray]: + """Depth map splatted from every scan point with the same z-buffer as the visibility test, + holes closed by a 5x5 minimum filter.""" + if view in self._oracle: + return self._oracle[view] + v = self.views[view] + h = _height(v) + K = v.scaled_K(WIDTH, h) + pts = scan_points(self.name) + Xc = pts @ v.R_wc.T + v.t_wc + z = Xc[:, 2] + with np.errstate(divide="ignore", invalid="ignore"): + u = np.round(K[0, 0] * Xc[:, 0] / z + K[0, 2]).astype(np.int64) + w = np.round(K[1, 1] * Xc[:, 1] / z + K[1, 2]).astype(np.int64) + ok = (z > 0.1) & (u >= 0) & (u < WIDTH) & (w >= 0) & (w < h) + buf = np.full((h, WIDTH), 1e6, np.float32) + np.minimum.at(buf, (w[ok], u[ok]), z[ok].astype(np.float32)) + buf = cv2.erode(buf, np.ones((5, 5), np.uint8)) + buf[buf >= 1e6] = np.nan + self._oracle[view] = (buf, K) + return buf, K + + +class EvalSet: + """What one seed view's evaluation scores: its points, cohorts, test pairs and tape pairs.""" + + def __init__(self, scene: Scene, seed: str, max_range: float | None): + index, _, edge = scene.visible(seed) + keep = np.ones(len(index), bool) + if max_range is not None: + centre = scene.views[seed].center + keep = np.linalg.norm(scene.cands[index] - centre, axis=1) <= max_range + self.index = index[keep] + self.gt = scene.cands[self.index].astype(np.float64) + edge = edge[keep] + members = { + "surface interior": ~edge, + "vertical interior": ~edge & scene.vertical[self.index], + "edges": edge, + } + # One generator per seed and range: the same draw for every method and view count. + rng = np.random.default_rng(sum(ord(c) for c in f"{scene.name}{seed}{max_range}")) + self.pairs: dict[str, dict[str, np.ndarray]] = {} + for cohort, mask in members.items(): + where = np.flatnonzero(mask) + self.pairs[cohort] = {} + if len(where) < 2: + continue + for b in BINS_M: + pr = sample_pairs(self.gt[where], *b, PAIRS_PER_BIN, rng) + if len(pr): + self.pairs[cohort][bin_key(b)] = where[pr] + interior = np.flatnonzero(members["surface interior"]) + refs = np.empty((0, 2), int) + if len(interior) > 1: + refs = sample_pairs(self.gt[interior], 1.0, 3.0, TAPE_REFS, rng) + self.refs = interior[refs] if len(refs) else np.empty((0, 2), int) + + +def predict( + scene: Scene, ev: EvalSet, members: list[str], per_view: PerView, max_range: float | None +) -> Points: + """Each evaluation point averaged over the views of the group that see it (and, with + `max_range`, whose own camera is that close to it). NaN where no view predicts it.""" + lookup = np.full(len(scene.cands), -1) + lookup[ev.index] = np.arange(len(ev.index)) + c_sum = np.zeros((len(ev.index), 3)) + r_sum = np.zeros((len(ev.index), 3)) + count = np.zeros(len(ev.index)) + for name in members: + index, uv, _ = scene.visible(name) + pos = lookup[index] + sel = pos >= 0 + if max_range is not None: + dist = np.linalg.norm(scene.cands[index] - scene.views[name].center, axis=1) + sel &= dist <= max_range + if not sel.any(): + continue + depth, K, T, centred = per_view(name) + pc = camera_points(depth, K, uv[sel]) + good = np.isfinite(pc).all(axis=1) + pos = pos[sel][good] + world = pc[good] @ T[:3, :3].T + if centred: + np.add.at(c_sum, pos, np.broadcast_to(T[:3, 3], world.shape)) + np.add.at(r_sum, pos, world) + else: + np.add.at(r_sum, pos, world + T[:3, 3]) + np.add.at(count, pos, 1) + with np.errstate(invalid="ignore", divide="ignore"): + c = c_sum / count[:, None] + r = r_sum / count[:, None] + return Points(gt=ev.gt, c=c, r=r) + + +def _evaluate_into(raws: dict, scene: Scene, ev: EvalSet, members, per_view, max_range) -> None: + pts = predict(scene, ev, members, per_view, max_range) + for cohort in COHORTS: + if ev.pairs[cohort]: + raws[cohort].append(evaluate_fixed(pts, ev.pairs[cohort], ev.refs)) + + +def score_groups(scene: Scene, methods: dict[str, Method], sizes=GROUP_SIZES) -> dict: + """{range: {method: {views: {cohort: pooled summary}}}} on the fixed per-seed evaluation sets.""" + out: dict = {} + for range_name, max_range in RANGES.items(): + sets = {m[0]: EvalSet(scene, m[0], max_range) for m in scene.groups["1"]} + out[range_name] = {} + for method, factory in methods.items(): + res: dict = {} + for n in map(str, sizes): + raws: dict[str, list] = {c: [] for c in COHORTS} + for members in scene.groups.get(n, []): + per_view = factory(f"n{n}-{members[0]}", members) + if per_view is not None: + _evaluate_into(raws, scene, sets[members[0]], members, per_view, max_range) + if raws["surface interior"]: + res[n] = {c: pool(r) | {"groups": len(r)} for c, r in raws.items() if r} + if res: + out[range_name][method] = res + return out + + +def score_single_photos(scene: Scene, model: str) -> dict: + """Every photo alone (not only the seeds), each on its own fixed evaluation set.""" + root = PREDICTIONS / scene.name / model + + def per_view(name): + d, _, _ = load_prediction(root / f"{name}.npz") + return d, scene.K(name), scene.views[name].cam_to_world, True + + out: dict = {} + for range_name, max_range in RANGES.items(): + raws: dict[str, list] = {c: [] for c in COHORTS} + for name in scene.views: + ev = EvalSet(scene, name, max_range) + if len(ev.index) > 1: + _evaluate_into(raws, scene, ev, [name], per_view, max_range) + out[range_name] = {c: pool(r) | {"photos": len(r)} for c, r in raws.items() if r} + return out + + +def true_pose_methods(scene: Scene) -> dict[str, Method]: + """The oracle and per-photo depth placed with the true poses.""" + pred_root = PREDICTIONS / scene.name + + def oracle(gid, members): + def pv(name): + d, K = scene.oracle_depth(name) + return d, K, scene.views[name].cam_to_world, True + + return pv + + methods: dict[str, Method] = {"oracle": oracle} + for model in ("moge2", "da3metric"): + if not (pred_root / model).exists(): + continue + + def fused(gid, members, model=model): + def pv(name): + d, _, _ = load_prediction(pred_root / model / f"{name}.npz") + return d, scene.K(name), scene.views[name].cam_to_world, True + + return pv + + methods[f"fused/{model}"] = fused + return methods + + +def model_frame_method(root: Path) -> Method: + """A multi-view model's own output per group, in the frame and scale it chose.""" + + def method(gid, members): + run = root / gid + if not (run / "run.json").exists(): + return None + + def pv(name): + d, K, T = load_prediction(run / f"{name}.npz") + return d, K, T, False + + return pv + + return method + + +LABELS = { + "oracle": "scan rendered as depth (evaluation floor)", + "fused/moge2": "MoGe-2 per photo + true poses", + "fused/da3metric": "Depth Anything 3 metric per photo + true poses", +} +SINGLE_LABELS = { + "moge2": "MoGe-2, one photo (every photo)", + "da3metric": "Depth Anything 3 metric, one photo (every photo)", +} + + +def cell(x: dict | None) -> str: + if not x or x.get("pairs", 0) == 0: + return "n/a" + if not np.isfinite(x["p90_in"]): + return f"{x['median_in']:.1f} / fails ({x['failed_pct']:.0f}% failed)" + return f"{x['median_in']:.1f} / {x['p90_in']:.1f}" + + +def _scale(x: dict | None) -> str: + v = (x or {}).get("scale_error_pct") + return "n/a" if v is None else f"{v:+.1f}%" + + +def table(rows: list[tuple[str, str, dict]], cohort: str) -> list[str]: + """Rows of (label, views, {cohort: pooled}) as one markdown table.""" + lines = [ + "| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m " + "| One taped distance: 1-3 m | 3-10 m | Tape calibrated |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for label, n, res in rows: + r = res.get(cohort) + if not r: + continue + a, b = r["none"].get("1-3m"), r["none"].get("3-10m") + ta, tb = r["one_known_distance"].get("1-3m"), r["one_known_distance"].get("3-10m") + ok = r.get("tape_calibration_success_pct") + lines.append( + f"| {label} | {n} | {cell(a)} | {cell(b)} | {_scale(a)} | {_scale(b)} | {cell(ta)} " + f"| {cell(tb)} | {'n/a' if ok is None else f'{ok:.0f}%'} |" + ) + return lines + + +HEADER = ( + "|Error in the distance between two scanned surface points|, inches, median / p90, against the " + "laser scan. Scale error: median of predicted / true length, minus 1, per span bin. Failed " + "pairs (no prediction, or a taped reference no scale could match) count as infinite error; " + "'fails' means more than 10% failed. Cohorts: surface interior (any scanned surface away from " + "depth edges), vertical interior (walls, fences and other vertical surfaces), edges (the near " + "side of depth edges: window frames, equipment and fence edges)." +) + + +def markdown(results: dict) -> str: + lines = ["# ETH3D reconstruction accuracy (generated by `uv run python -m evals.recon score`)"] + lines += ["", HEADER] + for scene, res in results.items(): + for range_name in RANGES: + rows = [ + (SINGLE_LABELS[m], "1", per_range[range_name]) + for m, per_range in res["single"].items() + ] + for m, per_n in res["groups"][range_name].items(): + rows += [(LABELS.get(m, m), n, r) for n, r in per_n.items()] + for cohort in COHORTS: + lines += ["", f"## {scene}, {range_name}, {cohort}", ""] + lines += table(rows, cohort) + return "\n".join(lines) + "\n" + + +def score(tolerance: float = TOLERANCE) -> dict: + results = {} + for s in SCENES: + scene = Scene(s, tolerance) + results[s] = { + "groups": score_groups(scene, true_pose_methods(scene)), + "single": { + m: score_single_photos(scene, m) + for m in ("moge2", "da3metric") + if (PREDICTIONS / s / m).exists() + }, + } + return results + + +def sensitivity() -> str: + """Key rows at visibility tolerances of 2, 4 and 8% (each prepared on first use).""" + lines = [ + "# Sensitivity to the visibility tolerance (generated by `uv run python -m evals.recon " + "sensitivity`)", + "", + "Surface interior, within 6 m. One taped distance, median / p90 inches, and the model-scale " + "error. A point counts as visible when it lies within the tolerance of the nearest scanned " + "depth along its pixel.", + "", + "| Scene | Method | Views | Tolerance | Taped 1-3 m | Taped 3-10 m | Model scale error, 3-10 m pairs |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + for s in SCENES: + for tol in (0.02, 0.04, 0.08): + prepare(s, tol) + scene = Scene(s, tol) + res = score_groups(scene, true_pose_methods(scene), sizes=(1, 2))["within 6 m"] + for m, per_n in res.items(): + for n, r in per_n.items(): + t = r["surface interior"]["one_known_distance"] + lines.append( + f"| {s} | {LABELS[m]} | {n} | {tol:.0%} | {cell(t.get('1-3m'))} | " + f"{cell(t.get('3-10m'))} | " + f"{_scale(r['surface interior']['none'].get('3-10m'))} |" + ) + return "\n".join(lines) + "\n" + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("step", choices=["prepare", "score", "sensitivity"]) + ap.add_argument("--out", type=Path, default=Path(__file__).resolve().parents[1] / "results") + args = ap.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + if args.step == "prepare": + for s in SCENES: + prepare(s) + elif args.step == "score": + results = score() + (args.out / "eth3d_recon.json").write_text(json.dumps(results, indent=1)) + md = markdown(results) + (args.out / "eth3d_recon.md").write_text(md) + print(md) + else: + md = sensitivity() + (args.out / "eth3d_visibility_sensitivity.md").write_text(md) + print(md) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/replay.py b/experiments/evals/evals/replay.py new file mode 100644 index 000000000..6435790ad --- /dev/null +++ b/experiments/evals/evals/replay.py @@ -0,0 +1,254 @@ +"""Convert a stretch of one ADVIO walk into a Measure Lab session (format v2) that the app can replay. + + uv run python -m evals.replay --sequence 20 --start 40 --end 75 + +What goes in: +- keyframes chosen by Measure Lab's own rule (0.5 m moved or 15 degrees turned since the last saved + one, `KeyframeSelector.swift`), applied to the ARKit poses; +- each keyframe's video frame, undistorted with ADVIO's calibration so the pinhole intrinsics are + exact, saved as the unrotated landscape JPEG; +- the ARKit pose converted to Measure Lab's camera convention (`evals.camera`); +- `ground_truth.json` beside session.json: ADVIO's ground-truth pose for every keyframe, so a + verification run can compare anything the app computes with the truth. The app ignores it. + +ADVIO records no ARKit tracking state, taps, points, walls or measurements. Tracking is written as +"normal" from the first frame whose ARKit position is non-zero (ARKit reports exactly zero until it +initialises); the other arrays are empty. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import zipfile +from pathlib import Path + +import cv2 +import numpy as np + +from evals.advio import Sequence, load_sequence +from evals.camera import column_major, landscape_intrinsics_from_portrait, landscape_pose +from evals.geometry import rotation_angle_deg +from evals.paths import ADVIO_DIR, REPLAYS_DIR + +SPACING_METERS = 0.5 +SPACING_DEGREES = 15.0 +JPEG_QUALITY = 90 + +# Measure Lab's gates, copied from its sources (LabSession.swift, Wall.swift, Triangulation.swift) +# so a replay applies the same thresholds as a phone session. +MEASURE_LAB_GATES = { + "trackingStableSeconds": 1.0, + "minimumGroundLookDown": 30.0, + "minimumContactSeparation": 2.0, + "maximumAngleFromWallNormal": 60.0, + "wallValidationTolerance": 2 * 0.0254, + "minimumCameraOffsetFromWall": 0.1, + "minimumRayAngle": 15.0, + "maximumRayGap": 2 * 0.0254, + "keyframeSpacingMeters": SPACING_METERS, + "keyframeSpacingDegrees": SPACING_DEGREES, +} + + +def select_keyframes( + positions: np.ndarray, rotations: np.ndarray, meters: float, degrees: float +) -> list[int]: + """Indices where the camera has moved `meters` or turned `degrees` since the last selected one. + The first index is always selected. Same rule as Measure Lab's KeyframeSelector.""" + if len(positions) == 0: + return [] + chosen = [0] + for i in range(1, len(positions)): + last = chosen[-1] + moved = float(np.linalg.norm(positions[i] - positions[last])) + if moved >= meters or rotation_angle_deg(rotations[last], rotations[i]) >= degrees: + chosen.append(i) + return chosen + + +def _undistort_landscape(frame: np.ndarray, seq: Sequence) -> np.ndarray: + """Undistort in the portrait frame the calibration was made in, keeping the same pinhole matrix, + then rotate back to the landscape sensor image.""" + cal = seq.calibration + portrait = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) + if portrait.shape[:2] != (cal.height, cal.width): + raise ValueError( + f"portrait frame is {portrait.shape[:2]}, calibration is {cal.height}x{cal.width}" + ) + K = cal.camera_matrix() + fixed = cv2.undistort(portrait, K, cal.distortion(), None, K) + return cv2.rotate(fixed, cv2.ROTATE_90_COUNTERCLOCKWISE) + + +def read_frames(video: Path, wanted: list[int]) -> dict[int, np.ndarray]: + """Decode the coded (unrotated) frames with these 0-based indices, reading sequentially so the + index is exact (seeking by time in this file is off by several frames).""" + cap = cv2.VideoCapture(str(video)) + cap.set(cv2.CAP_PROP_ORIENTATION_AUTO, 0) + todo = sorted(set(wanted)) + out: dict[int, np.ndarray] = {} + n = 0 + k = 0 + while k < len(todo): + ok = cap.grab() + if not ok: + raise ValueError(f"{video}: ended at frame {n}, still need {todo[k:]}") + if n == todo[k]: + ok, img = cap.retrieve() + if not ok: + raise ValueError(f"{video}: could not decode frame {n}") + out[n] = img + k += 1 + n += 1 + cap.release() + return out + + +def build_session(seq: Sequence, start: float, end: float, out_dir: Path) -> dict: + t = seq.frame_times + ark = seq.arkit + # Keyframe i takes video frame i and ARKit row i; that holds only if they share timestamps. + if len(ark.t) != len(t) or not np.allclose(ark.t, t): + raise ValueError(f"advio-{seq.number:02d}: arkit.csv rows do not match frames.csv times") + initialised = np.linalg.norm(ark.p, axis=1) > 0 + idx = np.flatnonzero((t >= start) & (t <= end) & initialised) + if len(idx) == 0: + raise ValueError(f"no initialised ARKit frames between {start} and {end} s") + picks = [ + int(idx[i]) + for i in select_keyframes(ark.p[idx], ark.R[idx], SPACING_METERS, SPACING_DEGREES) + ] + + cal = seq.calibration + fx, fy, cx, cy = landscape_intrinsics_from_portrait(cal.fx, cal.fy, cal.cx, cal.cy, cal.width) + session_id = f"advio-{seq.number:02d}-{int(start):04d}-{int(end):04d}" + folder = out_dir / session_id + if folder.exists(): + shutil.rmtree(folder) + (folder / "keyframes").mkdir(parents=True) + + frames = read_frames(seq.video, picks) + keyframes, truth = [], [] + gt = seq.ground_truth + for n, i in enumerate(picks, start=1): + kid = f"k{n:05d}" + img = _undistort_landscape(frames[i], seq) + h, w = img.shape[:2] + path = f"keyframes/{kid}.jpg" + if not cv2.imwrite(str(folder / path), img, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]): + raise OSError(f"could not write {folder / path}") + keyframes.append( + { + "id": kid, + "img": path, + "w": w, + "h": h, + "intrinsics": [fx, fy, cx, cy], + "pose": column_major(landscape_pose(ark.p[i], ark.R[i])), + "timestamp": float(t[i]), + "tracking": "normal", + "reason": "motion", + "depth": None, + } + ) + g = gt.nearest(float(t[i])) + truth.append( + { + "keyframe": kid, + "timestamp": float(t[i]), + "groundTruthTimestamp": float(gt.t[g]), + "pose": column_major(landscape_pose(gt.p[g], gt.R[g])), + } + ) + + first = float(t[idx[0]]) + manifest = { + "format": "measure-lab-session", + "formatVersion": 2, + "units": { + "length": "meters", + "angle": "degrees", + "time": "seconds of device uptime, the clock of ARFrame.timestamp", + "image": "pixels of the saved JPEG", + }, + "conventions": { + "world": "ARKit world frame with worldAlignment .gravity: right-handed, y up (away from gravity), origin and heading fixed where the session started", + "camera": "ARKit camera frame: +x right and +y up in the unrotated sensor image, the camera looks along -z", + "pose": "camera-to-world 4x4 matrix, 16 numbers column by column (simd_float4x4 layout)", + "intrinsics": "[fx, fy, cx, cy] in pixels of the saved, unrotated landscape JPEG", + "pixel": "[u, v] continuous image coordinates: (0, 0) is the top-left corner of the JPEG, v grows down", + "ray": "origin is the camera position; direction is a unit vector through the tapped pixel", + }, + "session": { + "id": session_id, + # ADVIO publishes no wall-clock capture time; this is the Zenodo record's publication date. + "startedAt": "2018-11-02T00:00:00Z", + "startedAtUptime": first, + "appVersion": "advio-replay (experiments/evals/evals/replay.py)", + "deviceModel": "iPhone8,1", + "systemVersion": "iOS 11 (ARKit 1.0), exact build not published", + "lidarAvailable": False, + "meshReconstructionSupported": False, + "sceneDepthEnabled": False, + }, + "gates": MEASURE_LAB_GATES, + "keyframes": keyframes, + "taps": [], + "points": [], + "walls": [], + "measurements": [], + "refusals": [], + "tracking": [{"time": first, "state": "normal"}], + # Not part of Measure Lab's format; Swift's JSONDecoder ignores unknown keys. + "provenance": { + "dataset": "ADVIO (Cortes, Solin, Rahtu, Kannala; ECCV 2018), CC BY-NC 4.0: accuracy testing only, do not redistribute", + "sequence": seq.number, + "venue": "outdoor, Espoo campus" if seq.number in (20, 21) else "outdoor", + "secondsInSequence": [start, end], + "phone": "iPhone 6s, no LiDAR", + "images": "coded frames of iphone/frames.mov (unrotated landscape), undistorted with the batch calibration", + "calibration": cal.source, + "keyframeRule": f"{SPACING_METERS} m or {SPACING_DEGREES} deg since the last keyframe, on ARKit poses", + "groundTruth": "ground_truth.json: ADVIO ground-truth pose nearest in time to each keyframe, same conventions but its own world frame (not aligned to ARKit's)", + "converter": "experiments/evals/evals/replay.py", + }, + } + (folder / "session.json").write_text(json.dumps(manifest, indent=2)) + (folder / "ground_truth.json").write_text(json.dumps({"keyframes": truth}, indent=2)) + return {"folder": folder, "keyframes": len(keyframes), "id": session_id} + + +def zip_folder(folder: Path) -> Path: + archive = folder.with_suffix(".zip") + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as z: + for f in sorted(folder.rglob("*")): + if f.is_file(): + z.write(f, f.relative_to(folder.parent)) + return archive + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--sequence", type=int, default=20) + ap.add_argument("--start", type=float, default=40.0, help="seconds into the sequence") + ap.add_argument("--end", type=float, default=75.0) + ap.add_argument("--out", type=Path, default=REPLAYS_DIR) + args = ap.parse_args() + seq = load_sequence(ADVIO_DIR / f"advio-{args.sequence:02d}") + args.out.mkdir(parents=True, exist_ok=True) + info = build_session(seq, args.start, args.end, args.out) + archive = zip_folder(info["folder"]) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + print( + f"{info['id']}: {info['keyframes']} keyframes -> {archive} ({archive.stat().st_size / 1e6:.1f} MB)" + ) + print(f"sha256 {digest}") + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/evals/triangulate.py b/experiments/evals/evals/triangulate.py new file mode 100644 index 000000000..de45003df --- /dev/null +++ b/experiments/evals/evals/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/experiments/evals/field/FIELD_SHEET.md b/experiments/evals/field/FIELD_SHEET.md new file mode 100644 index 000000000..c016a3a4f --- /dev/null +++ b/experiments/evals/field/FIELD_SHEET.md @@ -0,0 +1,71 @@ +# Field sheet: one wall, one tape, one number + +Adds to PR #7's [one-hour protocol](https://github.com/SamGu-NRX/house-scanning/blob/t3/measure-lab/experiments/measure-lab/README.md#one-hour-outdoor-protocol); follow it for everything not listed here. Measure Lab can't name measurements. It numbers them M1, M2… in the order you save them, and the map expects the order below. + +## Bring + +- An iPhone **without LiDAR** with Measure Lab. In the Session sheet, LiDAR must say No. +- A tape of at least 35 ft; the longest span is 30 ft. +- Painter's tape, a marker and chalk. +- A second phone for notes, with this sheet open. + +## Mark (painter's tape) + +1. **A and B** at the wall's base, 30 ft apart. The opening, the meter and S1/S2 must all lie **between** A and B, or the app flags them and they don't count. +2. **S1 and S2**: two crosses on the wall at chest height, 3 to 10 ft apart. +3. **X** on paving, about 6 ft from the wall. +4. **F** at the base of the fence or wall facing yours, straight across from the wall. +5. **L1 and L2** on paving, 20 ft or more apart, for example along the fence. + +## Tape (write readings as the tape shows them: `30 0 1/8`) + +| Name | From → to | +| --- | --- | +| span-30-ab | A → B, along the base | +| span-30-ba | B → A again: a second, separate reading | +| scale-ref | S1 → S2 | +| opening-width | the opening's left frame edge → its right frame edge | +| sill-height | the ground at the wall → the sill | +| meter-height | the ground at the wall → the meter's bottom edge | +| facing-gap | the wall → F, square to the wall | +| overhead-height | the ground → the eave or beam corner | +| span-20 | L1 → L2 | +| (location) | F's distance along the wall from A | + +## In the app (Session › New session) + +Rules for every step: +- **Ground and Wall taps:** look down at the spot and wait until the ground covers it. A flagged wall contact flags everything on that wall. +- **Side-walk:** walk about 6 ft sideways and back, slowly, 6 to 20 ft from the wall, with the feature in view. Don't point at the sky. +- **Measure:** open Measure, set From and To, tap the **named quantity**, type the tape reading, then Save. The banner must say **"M# saved"** with the number shown here. If it says "abstention", write down why and carry on. + +| # | Do | Measure | +| --- | --- | --- | +| 1 | Ground: X (P1) | | +| 2 | Wall: A (P2), walk the base to B, B (P3), then one more base point between them (P4) | | +| 3 | | **M1**: From P2, To P3, **Along the wall**, span-30-ab | +| 4 | On wall: S1 (P5), S2 (P6), side-walk | **M2**: the preset P5→P6, **Straight line**, scale-ref | +| 5 | On wall: opening left edge (P7), right edge (P8), on the frame, not the glass; side-walk | **M3**: the preset, **Along the wall**, opening-width | +| 6 | On wall: sill (P9) | **M4**: From P9, To **W1 · wall line**, **Height above ground**, sill-height | +| 7 | On wall: meter's bottom edge (P10), side-walk | **M5**: From P10, To W1, **Height above ground**, meter-height | +| 8 | Ground: F (P11), with the wall's base in view | **M6**: From P11, To W1, **Gap to the wall**, facing-gap | +| 9 | Two-view: overhead corner, step 3 ft sideways, tap it again (P12) | **M7**: From P12, To W1, **Height above ground**, overhead-height | +| 10 | Walk to the wall's far end and back, 6 to 20 ft out; then Ground: X again (P13) | **M8**: From P1, To P13, **Straight line**, tape `0` | +| 11 | Ground: B (P14), walk to A, Ground: A (P15) | **M9**: the preset, **Straight line**, span-30-ba | +| 12 | Ground: L1 (P16), walk, Ground: L2 (P17) | **M10**: the preset, **Straight line**, span-20 | +| 13 | Session › **Share session**, AirDrop the zip to the laptop | Note the phone model (Settings › General › About) and the iOS version | + +**Went wrong?** Keep going. An extra point shifts the later P numbers, so pick From and To by their labels. An extra or wrong saved measurement shifts every later M number, so write down the actual M numbers and fix `session_measurement` in `map.json` afterwards. + +## On the laptop (nothing else heavy running) + +```sh +cd ~/"Programming Projects/house-scanning-evals/experiments/evals" # or your checkout +D=~/house-scanning-data/evals/field/$(date +%F) && mkdir -p $D +cp field/survey.template.json $D/survey.json && cp field/map.template.json $D/map.json +# In $D/survey.json, replace each "FILL ft in" with the reading as text ("30 0 1/8"), +# and the candidate's location. Then, once per zip: +make field SESSION=$D/.zip TRUTH=$D/survey.json MAP=$D/map.json +``` + +`make field` fills in the zip's sha256 and session id itself. The AR scale error is in `field_report.md` in the results folder it prints. Proven on a synthetic session with these templates: `make field-dryrun`. diff --git a/experiments/evals/field/map.template.json b/experiments/evals/field/map.template.json new file mode 100644 index 000000000..762726f2a --- /dev/null +++ b/experiments/evals/field/map.template.json @@ -0,0 +1,55 @@ +{ + "format": 1, + "unit": "ft", + "pipeline": "measure-lab", + "session": "filled in by make field", + "notes": "M numbers follow FIELD_SHEET.md's order. plus_minus_ft_by_key is the untested +-0.3 ft default for AR taps (docs/00, Conventions), not a measured error bar.", + "plus_minus_ft_by_key": { + "straight": 0.3, + "alongWall": 0.3, + "gapToWall": 0.3, + "heightAboveGround": 0.3 + }, + "measurements": { + "span-30-ab": { + "session_measurement": "M1", + "key": "alongWall" + }, + "scale-ref": { + "session_measurement": "M2", + "key": "straight" + }, + "opening-width": { + "session_measurement": "M3", + "key": "alongWall" + }, + "sill-height": { + "session_measurement": "M4", + "key": "heightAboveGround" + }, + "meter-height": { + "session_measurement": "M5", + "key": "heightAboveGround" + }, + "facing-gap": { + "session_measurement": "M6", + "key": "gapToWall" + }, + "overhead-height": { + "session_measurement": "M7", + "key": "heightAboveGround" + }, + "return-gap": { + "session_measurement": "M8", + "key": "straight" + }, + "span-30-ba": { + "session_measurement": "M9", + "key": "straight" + }, + "span-20": { + "session_measurement": "M10", + "key": "straight" + } + } +} diff --git a/experiments/evals/field/rules.json b/experiments/evals/field/rules.json new file mode 100644 index 000000000..8f5acafc2 --- /dev/null +++ b/experiments/evals/field/rules.json @@ -0,0 +1,12 @@ +{ + "format": 1, + "unit": "ft", + "name": "Field test: one decision check, for scoring only", + "thresholds": { + "facing_gap_ft": { + "value_ft": 3, + "pass_when": "at_least", + "source": "The 3 ft fence clearance PR #7's pass criteria cite from Base's public help page" + } + } +} diff --git a/experiments/evals/field/survey.template.json b/experiments/evals/field/survey.template.json new file mode 100644 index 000000000..a36844d89 --- /dev/null +++ b/experiments/evals/field/survey.template.json @@ -0,0 +1,164 @@ +{ + "format": 1, + "unit": "ft", + "house": "field-01", + "captures": [], + "scale_reference": "scale-ref", + "candidates": [ + { + "id": "c1", + "marker": "chalk at the fence base, mark F", + "location": "FILL: distance of F along the wall from mark A" + } + ], + "measurements": [ + { + "id": "span-30-ab", + "candidate": null, + "from": "wall-base mark A", + "to": "wall-base mark B, about 30 ft along the wall", + "status": "measured", + "value_ft": "FILL ft in", + "plus_minus_ft": 0.01, + "method": "tape along the wall base", + "measured_by": [ + "tape-1", + "tape-2" + ] + }, + { + "id": "scale-ref", + "candidate": null, + "from": "scale cross S1 on the wall", + "to": "scale cross S2, 1 to 3 m away", + "status": "measured", + "value_ft": "FILL ft in", + "plus_minus_ft": 0.005, + "method": "tape", + "measured_by": [ + "tape-1", + "tape-2" + ] + }, + { + "id": "opening-width", + "candidate": null, + "from": "opening's left edge (frame, not glass)", + "to": "opening's right edge", + "status": "measured", + "value_ft": "FILL ft in", + "plus_minus_ft": 0.01, + "method": "tape", + "measured_by": [ + "tape-1", + "tape-2" + ] + }, + { + "id": "sill-height", + "candidate": null, + "from": "ground at the wall below the sill", + "to": "sill", + "status": "measured", + "value_ft": "FILL ft in", + "plus_minus_ft": 0.01, + "method": "tape, vertical", + "measured_by": [ + "tape-1", + "tape-2" + ] + }, + { + "id": "meter-height", + "candidate": null, + "from": "ground at the wall below the meter", + "to": "meter's bottom edge", + "status": "measured", + "value_ft": "FILL ft in", + "plus_minus_ft": 0.01, + "method": "tape, vertical", + "measured_by": [ + "tape-1", + "tape-2" + ] + }, + { + "id": "facing-gap", + "candidate": "c1", + "from": "wall", + "to": "fence-base mark F, perpendicular to the wall", + "status": "measured", + "value_ft": "FILL ft in", + "plus_minus_ft": 0.02, + "method": "tape, perpendicular", + "measured_by": [ + "tape-1", + "tape-2" + ] + }, + { + "id": "overhead-height", + "candidate": null, + "from": "ground below the overhead corner", + "to": "overhead corner (eave or porch beam)", + "status": "measured", + "value_ft": "FILL ft in", + "plus_minus_ft": 0.02, + "method": "tape, vertical", + "measured_by": [ + "tape-1", + "tape-2" + ] + }, + { + "id": "return-gap", + "candidate": null, + "from": "reference X, first tap", + "to": "reference X, tapped again after the walk", + "status": "measured", + "value_ft": 0, + "plus_minus_ft": 0.0, + "method": "none: the true gap is 0", + "measured_by": [ + "tape-1", + "tape-2" + ] + }, + { + "id": "span-30-ba", + "candidate": null, + "from": "wall-base mark B", + "to": "wall-base mark A, taped again from B", + "status": "measured", + "value_ft": "FILL ft in", + "plus_minus_ft": 0.01, + "method": "tape along the wall base", + "measured_by": [ + "tape-1", + "tape-2" + ] + }, + { + "id": "span-20", + "candidate": null, + "from": "long-span mark L1 on paving", + "to": "long-span mark L2, 20 ft or more away", + "status": "measured", + "value_ft": "FILL ft in", + "plus_minus_ft": 0.01, + "method": "tape", + "measured_by": [ + "tape-1", + "tape-2" + ] + } + ], + "checks": [ + { + "candidate": "c1", + "check": "facing", + "measurement": "facing-gap", + "threshold": "facing_gap_ft" + } + ] +} diff --git a/experiments/evals/map3d_driver/.gitignore b/experiments/evals/map3d_driver/.gitignore new file mode 100644 index 000000000..30bcfa4ed --- /dev/null +++ b/experiments/evals/map3d_driver/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/experiments/evals/map3d_driver/Package.swift b/experiments/evals/map3d_driver/Package.swift new file mode 100644 index 000000000..2e6f200be --- /dev/null +++ b/experiments/evals/map3d_driver/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.2 +// Runs the app's 3D map (HouseScanKit's Map3D) on depth frames given as JSON, for +// evals/map3d.py. HouseScanKit is used read-only from a checkout at a pinned commit; its path +// comes from HOUSESCANKIT_DIR, which `make map3d` sets. +import Foundation +import PackageDescription + +guard let kit = ProcessInfo.processInfo.environment["HOUSESCANKIT_DIR"] else { + fatalError("Set HOUSESCANKIT_DIR to ios/HouseScanKit in a checkout of the pinned commit (see evals/coverage.py)") +} + +let package = Package( + name: "Map3DDriver", + platforms: [.macOS(.v26)], + dependencies: [.package(path: kit)], + targets: [ + .executableTarget(name: "map3d-driver", dependencies: [.product(name: "HouseScanKit", package: "HouseScanKit")]), + ], + swiftLanguageModes: [.v6] +) diff --git a/experiments/evals/map3d_driver/Sources/map3d-driver/main.swift b/experiments/evals/map3d_driver/Sources/map3d-driver/main.swift new file mode 100644 index 000000000..2fb878225 --- /dev/null +++ b/experiments/evals/map3d_driver/Sources/map3d-driver/main.swift @@ -0,0 +1,104 @@ +// Feeds depth frames (JSON manifest on stdin, Float32 depth files on disk) to the app's Map3D, +// unmodified, and writes its coverage along a given wall and its measured wall chain (JSON on +// stdout). +import Foundation +import HouseScanKit +import simd + +struct Input: Decodable { + struct Wall: Decodable { + let meter: [Float] + let outward: [Float] + let groundY: Float + } + + struct Frame: Decodable { + let id: String + /// Camera-to-world, 16 numbers column by column, ARKit camera axes. + let pose: [Float] + /// fx, fy, cx, cy of the photo, (0, 0) at its top-left corner. + let intrinsics: [Float] + let size: [Float] + /// Float32 meters along -z, row-major, `width` x `height`, 0 where nothing was measured. + let depthFile: String + let width: Int + let height: Int + } + + let wall: Wall + let frames: [Frame] +} + +struct Span: Encodable { + let s: [Float] + let out: Float? +} + +struct Piece: Encodable { + let start: [Float] + let end: [Float] + let outward: [Float] + let support: Int +} + +struct Output: Encodable { + let poseInWorld: [Float] + let wall: [Span] + let ground: [Span] + let facing: [Span] + let overhead: [Span] + let chain: [Piece] + let meterIndex: Int? + let pieces: [Piece] + let allocatedBytes: Int +} + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("map3d-driver: \(message)\n".utf8)) + exit(1) +} + +func v3(_ v: [Float]) -> SIMD3 { SIMD3(v[0], v[1], v[2]) } + +let input: Input +do { + input = try JSONDecoder().decode(Input.self, from: FileHandle.standardInput.readDataToEndOfFile()) +} catch { + fail("bad input: \(error)") +} +guard let wall = WallFrame(meter: v3(input.wall.meter), outward: v3(input.wall.outward), groundY: input.wall.groundY), + let mapFrame = MapFrame(meter: v3(input.wall.meter), outward: v3(input.wall.outward), worldGroundY: input.wall.groundY) +else { fail("wall.outward has no horizontal component") } + +var map = Map3D(frame: mapFrame) +for f in input.frames { + guard let photo = CameraFrame( + columnMajorPose: f.pose, intrinsics: SIMD4(f.intrinsics[0], f.intrinsics[1], f.intrinsics[2], f.intrinsics[3]), + imageSize: SIMD2(f.size[0], f.size[1]) + ) else { fail("\(f.id): pose is not 16 finite numbers") } + guard let data = FileManager.default.contents(atPath: f.depthFile), data.count == f.width * f.height * 4 else { + fail("\(f.id): \(f.depthFile) is not \(f.width) x \(f.height) Float32 values") + } + let depth = data.withUnsafeBytes { Array($0.bindMemory(to: Float.self)) } + map.integrate(DepthFrame(photo: photo, width: f.width, height: f.height, depth: depth, kind: .lidar(confidence: nil))) +} + +let coverage = map.coverage(along: wall) +func pieces(_ walls: [MeasuredWall]) -> [Piece] { + walls.map { Piece(start: [$0.start.x, $0.start.y], end: [$0.end.x, $0.end.y], outward: [$0.outward.x, $0.outward.y], support: $0.support) } +} +let chain = map.measuredWalls() +let output = Output( + poseInWorld: map.frame.poseInWorld.columnMajor, + wall: coverage.wall.map { Span(s: [$0.lowerBound, $0.upperBound], out: nil) }, + ground: coverage.ground.map { Span(s: [$0.span.lowerBound, $0.span.upperBound], out: $0.out) }, + facing: coverage.facing.map { Span(s: [$0.span.lowerBound, $0.span.upperBound], out: $0.out) }, + overhead: coverage.overhead.map { Span(s: [$0.span.lowerBound, $0.span.upperBound], out: $0.out) }, + chain: pieces(chain?.walls ?? []), + meterIndex: chain?.meterIndex, + pieces: pieces(map.wallPieces()), + allocatedBytes: map.allocatedBytes +) +let encoder = JSONEncoder() +encoder.outputFormatting = [.sortedKeys] +FileHandle.standardOutput.write(try encoder.encode(output)) diff --git a/experiments/evals/models/README.md b/experiments/evals/models/README.md new file mode 100644 index 000000000..ccdf5ce65 --- /dev/null +++ b/experiments/evals/models/README.md @@ -0,0 +1,110 @@ +# Depth and geometry model runners + +Runs three commercially licensed models on a list of photos and writes metric depth in one format, so the wall-reconstruction eval (question 2 in `../README.md`) can score each against ETH3D's laser scans. + +## Install + +A separate uv project, because the models pull in torch and pinned git packages the base eval does not need. + +```sh +cd experiments/evals +uv sync --project models +uv run --project models pytest -q models/tests +``` + +Checkpoints download on first use into `$HOUSE_SCANNING_DATA/evals/hf-cache` (`HF_HOME`) and `evals/torch-cache` (`TORCH_HOME`) unless those variables are already set. A run refuses to start a download with less than 6 GB free, and checks each checkpoint's sha256 against the pinned value. + +## Run + +```sh +cd experiments/evals +uv run --project models python -m models.run --model moge2 --images images.txt [--intrinsics k.json] --out DIR +uv run --project models python -m models.run --model da3metric --images images.txt --intrinsics k.json --out DIR +uv run --project models python -m models.run --model mapanything --images images.txt [--intrinsics k.json] [--poses poses.json] --out DIR +``` + +- `--images`: one image path per line. Stems must be unique. +- `--intrinsics`: `[fx, fy, cx, cy]` per image in OpenCV pixels of that image (pixel centres at integers). Either a list in `--images` order or an object keyed by image path or stem. COLMAP and ETH3D principal points put the first pixel centre at 0.5, so subtract 0.5 from them. +- `--intrinsics-mode known|predicted`: defaults to `known` when `--intrinsics` is given. +- `--poses` (mapanything only): 4x4 camera-to-world per image, OpenCV camera (+x right, +y down, +z forward), metres. +- `--max-side N`: see the per-model notes. `--device auto|mps|cpu|cuda`, `--fp32` turns off mixed precision. + +Each run loads one model and processes images one at a time; mapanything runs all views in one joint call. + +## Output + +`DIR/.npz`: + +| key | contents | +| --- | --- | +| `depth` | float32 HxW, metres, z along the optical axis, at the input image's resolution; NaN where invalid | +| `valid` | bool HxW | +| `intrinsics` | float64 `[fx, fy, cx, cy]`, OpenCV pixels of the input image: the known ones in `known` mode, the model's otherwise | +| `cam_to_world` | mapanything only. float64 4x4, OpenCV camera, metres. Frame: the first image's camera, or the `--poses` world when poses are given | +| `intrinsics_predicted` | mapanything only. What its output rays imply, in input pixels | + +`DIR/run.json` records the model, checkpoint repo, revision and sha256, code commit, device, torch version, flags, load time and, per image, seconds, the network input size, the exact resize and crop, and a depth summary. + +Network outputs are resampled to the input grid bilinearly. A pixel is valid only if every network pixel it draws on is valid and it lies inside the region the network saw; pixels removed by a crop are invalid. + +## Models + +| `--model` | Checkpoint (revision) | License | Code (commit) | +| --- | --- | --- | --- | +| `moge2` | [`Ruicheng/moge-2-vitl-normal`](https://huggingface.co/Ruicheng/moge-2-vitl-normal) (`cb0e8bb`) | MIT | microsoft/MoGe `0744441` | +| `da3metric` | [`depth-anything/DA3METRIC-LARGE`](https://huggingface.co/depth-anything/DA3METRIC-LARGE) (`4010e39`) | Apache-2.0 | ByteDance-Seed/Depth-Anything-3 `3d835ec` | +| `mapanything` | [`facebook/map-anything-apache`](https://huggingface.co/facebook/map-anything-apache) (`00f9c24`) | Apache-2.0 | facebookresearch/map-anything `3d10cf7` | + +`facebook/map-anything` (without `-apache`) is CC-BY-NC and is not used. Full hashes are in `pyproject.toml` and each runner module. + +`pyproject.toml` replaces the three packages' requirement lists (`tool.uv.dependency-metadata`) with what inference imports. As published they require gradio, open3d, pycolmap, xformers, evo, moviepy and `numpy<2`, which the inference path never imports. + +### How each gets metres + +- **MoGe-2** outputs metric depth directly. `MoGeModel.infer` multiplies its affine point map by the predicted `metric_scale` (`moge/model/v2.py:279-283` at `0744441`) and resizes outputs to the image it was given (`v2.py:170`). Its one camera input is `fov_x`. In `known` mode the runner passes `fov_x = 2 atan(W / 2 fx)`; MoGe then fixes the focal and solves only the depth shift (`v2.py:259-264`), assuming square pixels and a centred principal point, so the saved intrinsics are MoGe's (fy = fx, centre). It runs at a fixed 3600-token budget (1120x630 for 16:9) whatever the input size. `--max-side` only shrinks the image handed to it; use it for full-size 24 MP photos. +- **DA3 metric** outputs depth for a canonical 300 px focal. The rule is `metric_depth = focal * net_output / 300` with `focal` the mean of fx and fy in pixels (`README.md:235` at `3d835ec`; `apply_metric_scaling`, `src/depth_anything_3/utils/alignment.py:118-133`). The package applies it with the intrinsics of the resized network image (`model/da3.py:379`), so the runner uses the known focal scaled to the network size. With no known focal there is no metric depth, so `da3metric` refuses to run without `--intrinsics`. Preprocessing is the package's `InputProcessor` (`upper_bound_resize`, longest side 504 or `--max-side`, sides rounded to multiples of 14, no crop). Sky is invalid: the model treats `sky < 0.3` as non-sky (`model/da3.py:161`) and fills sky with a placeholder depth. +- **MapAnything** outputs metric geometry directly: it predicts and applies a metric scale factor (reported per image as `metric_scaling_factor` in run.json), and `depth_z` is z-depth. Every view shares one network size: the package's choice for the mean aspect ratio from its 518 resolution set (518x294 for 16:9, 518x336 for 3:2), or longest side `--max-side`. The runner scales each image to cover that size and centre-crops it itself, so the crop is known exactly. Output poses are relative to the first view's camera even when `--poses` are given; the runner multiplies them by the first input pose to put them back in the input frame. + +## Smoke test on real images + +Five consecutive keyframes (`k00020` to `k00024`, about 0.5 m apart) of `~/house-scanning-data/replays/advio-20-0040-0075`, rotated upright because the replay JPEGs are sideways sensor images (sky on the left): + +```sh +uv run --project models python -m models.smoke_advio prepare --ids k00020 k00021 k00022 k00023 k00024 +# then models.run for each model with --images/--intrinsics/--poses from ~/house-scanning-data/evals/model-smoke/advio-input +uv run --project models python -m models.smoke_advio summarize ~/house-scanning-data/evals/model-smoke/ ... +``` + +"Ground" is the median valid depth in the bottom quarter of the upright image, the road just ahead. For a phone about 1.4 m up (assumed; ADVIO gives no height) and pitched 5 to 10° down (from the ARKit poses), the middle row of that band meets the road at a z-depth of about 2.3 to 2.7 m. The models report 2.4 to 3.9 m, with DA3 the farthest. + +| Run | Ground median per image (m) | Seconds per image | Network input (W x H) | +| --- | --- | --- | --- | +| moge2, known intrinsics | 3.49, 3.22, 2.90, 2.93, 2.97 | 0.95 | 630x1120 | +| moge2, predicted intrinsics (fx 1011 to 1093, true 1081) | 3.39, 3.26, 2.70, 2.96, 3.00 | 0.95 | 630x1120 | +| da3metric | 3.93, 3.51, 3.25, 3.44, 3.35 | 0.17 to 0.19 | 280x504 | +| mapanything, images only (fx about 1268) | 3.36, 3.26, 3.21, 2.90, 2.87 | 0.72 | 294x518 | +| mapanything, known intrinsics | 3.09, 3.00, 2.93, 2.66, 2.64 | 0.6 to 1.5 | 294x518 | +| mapanything, known intrinsics and ARKit poses | 2.68, 2.58, 2.44, 2.35, 2.37 | 0.7 to 0.9 | 294x518 | + +All ran on MPS (M4 Pro, torch 2.14) with mixed precision. The first image of a run took 0.1 to 2.6 s longer while kernels warmed up. Model load takes 2 to 6 s (MoGe, DA3) and 26 to 42 s (MapAnything). Timings varied up to 2x between repeats because other jobs shared the machine. None of these were measured on the ETH3D images. + +MapAnything camera baseline, k00020 to k00024, against ARKit's 2.019 m (consecutive ARKit steps 0.50 to 0.51 m): + +| Inputs | Predicted first-to-last (m) | Ratio | Consecutive steps (m) | +| --- | --- | --- | --- | +| images only | 2.511 | 1.244 | 0.55, 0.60, 0.80, 0.61 | +| + known intrinsics | 2.438 | 1.208 | 0.54, 0.57, 0.83, 0.59 | +| + known intrinsics and poses | 2.041 | 1.011 | 0.50, 0.53, 0.51, 0.51 | + +With poses given, the output poses reproduce the input ones to within 2.3 cm and 0.11°, which confirms the frame conversion; that row is not an independent measurement. + +## Memory + +The Mac these run on is shared, so each process should stay near 4 GB. MoGe-2 at its default 3600 tokens needs 3.05 GiB of GPU memory and peaks at 4.07 GB in total, flat over a session, because `run.py` writes each depth map as soon as it exists; `--mps-cap-gb` (3.6 by default) makes a run that needs more fail instead of growing. Depth Anything 3's peak was not measured; its checkpoint is 1.3 GB. MapAnything's `from_pretrained` builds the 1.2 B-parameter model in fp32 (4.9 GB) and then reads the 4.9 GB fp32 checkpoint into it. `map_anything.load` instead builds the model on the meta device and streams each tensor straight to the GPU, casting the two transformer stacks (92% of the weights) to bf16: 2.64 GB of weights. `run_groups.py` caps GPU memory at 3.8 GB (`--mps-cap-gb`). Measured peak footprint: 4.05 GB for 2- and 4-view groups at 392 px. Its native 518 px, or 8 views, runs out of memory under that cap. + +## Known issues + +- MoGe's current `main` (MoGe-3, `74fbce0`) limits its uv environments to Linux and Windows, so the pin is the last MoGe-2 commit. +- On MPS, MoGe warns `MPS Autocast only supports dtypes of torch.bfloat16, torch.float16 currently` for its fp32 post-processing block. The block casts to float32 itself, so the warning is harmless. +- MapAnything's first run failed with `TypeError: Cannot convert a MPS Tensor to float64 dtype as the MPS framework doesn't support float64` when converting its outputs; the runner now moves tensors to the CPU before converting. +- Loading MapAnything fetches DINOv2 model code from `facebookresearch/dinov2` `main` through torch hub (uniception's encoder; code only, about 4.5 MB, no weights). That fetch is not pinned. diff --git a/experiments/evals/models/__init__.py b/experiments/evals/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/experiments/evals/models/common.py b/experiments/evals/models/common.py new file mode 100644 index 000000000..bb2af509e --- /dev/null +++ b/experiments/evals/models/common.py @@ -0,0 +1,379 @@ +"""Inputs, resampling geometry and output format shared by the model runners. numpy and OpenCV +only, so the geometry is testable without torch. + +Conventions: +- intrinsics are [fx, fy, cx, cy] in pixels with OpenCV's convention: pixel (0, 0) has its centre + at (0, 0), so the image spans [-0.5, W - 0.5]; +- a network image is the input image scaled per axis by (sx, sy) and then cropped at (x0, y0), so a + point at OpenCV pixel u in the input lies at u_net = sx * (u + 0.5) - x0 - 0.5 in the network + image. `Resample` holds that map; every conversion between the two images goes through it. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +from dataclasses import dataclass, field +from pathlib import Path + +import cv2 +import numpy as np + +DATA_ROOT = Path(os.environ.get("HOUSE_SCANNING_DATA", Path.home() / "house-scanning-data")) +CACHE_ROOT = DATA_ROOT / "evals" +MIN_FREE_BYTES_FOR_DOWNLOAD = 6 * 1024**3 + + +def set_cache_dirs() -> dict[str, str]: + """Keep Hugging Face and torch hub caches under the data root, where they are easy to delete, + unless the caller already chose a place. Must run before torch or huggingface_hub is imported.""" + os.environ.setdefault("HF_HOME", str(CACHE_ROOT / "hf-cache")) + os.environ.setdefault("TORCH_HOME", str(CACHE_ROOT / "torch-cache")) + return {k: os.environ[k] for k in ("HF_HOME", "TORCH_HOME")} + + +def require_free_space_for_download(repo_id: str, filename: str, revision: str) -> None: + """Refuse to start a checkpoint download with less than 6 GB free: the disk is shared and has + run out before. A checkpoint already in the cache needs no space.""" + from huggingface_hub import try_to_load_from_cache + + cached = try_to_load_from_cache(repo_id, filename, revision=revision) + if isinstance(cached, str) and Path(cached).exists(): + return + free = shutil.disk_usage(Path.home()).free + if free < MIN_FREE_BYTES_FOR_DOWNLOAD: + raise RuntimeError( + f"{repo_id}/{filename} is not cached and only {free / 1024**3:.1f} GB is free; " + f"refusing to download with less than {MIN_FREE_BYTES_FOR_DOWNLOAD / 1024**3:.0f} GB free" + ) + + +def checkpoint_record(repo_id: str, filename: str, revision: str, sha256: str) -> dict: + """Where the checkpoint came from, after checking its sha256 against the one Hugging Face lists + for the pinned revision. (Cache blob names are not always the sha256, so it is recomputed.)""" + from huggingface_hub import try_to_load_from_cache + + cached = try_to_load_from_cache(repo_id, filename, revision=revision) + if not isinstance(cached, str): + raise RuntimeError(f"{repo_id}/{filename}@{revision} should be cached after loading") + h = hashlib.sha256() + with open(cached, "rb") as f: + while block := f.read(1 << 24): + h.update(block) + if h.hexdigest() != sha256: + raise RuntimeError(f"{cached}: sha256 {h.hexdigest()} is not the pinned {sha256}") + return { + "repo": repo_id, + "file": filename, + "revision": revision, + "sha256": sha256, + "bytes": Path(cached).stat().st_size, + } + + +# --------------------------------------------------------------------------------------------- +# Inputs + + +def read_image_list(path: Path) -> list[Path]: + """One image path per line; blank lines and lines starting with # are skipped. Relative paths + are relative to the list file. Stems name the outputs, so they must be unique.""" + images = [] + for raw in path.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + p = Path(line).expanduser() + if not p.is_absolute(): + p = path.parent / p + if not p.is_file(): + raise FileNotFoundError(f"{path}: image {p} does not exist") + images.append(p) + if not images: + raise ValueError(f"{path} lists no images") + stems = [p.stem for p in images] + duplicates = sorted({s for s in stems if stems.count(s) > 1}) + if duplicates: + raise ValueError( + f"{path}: image stems must be unique (outputs are .npz): {duplicates}" + ) + return images + + +def _per_image(path: Path, images: list[Path], what: str) -> list: + """A JSON file holding one entry per image: a list in --images order, or an object keyed by + image path (as resolved from --images) or by image stem, covering every image.""" + data = json.loads(path.read_text()) + if isinstance(data, list): + if len(data) != len(images): + raise ValueError(f"{path}: {len(data)} {what} entries for {len(images)} images") + return data + if isinstance(data, dict): + keyed = {str(Path(k).expanduser()): v for k, v in data.items()} + entries, missing = [], [] + for p in images: + if str(p) in keyed: + entries.append(keyed[str(p)]) + elif p.stem in data: + entries.append(data[p.stem]) + else: + missing.append(p.stem) + if missing: + raise ValueError(f"{path}: no {what} keyed by path or stem for images {missing}") + return entries + raise ValueError(f"{path}: expected a JSON list or object of {what}, got {type(data).__name__}") + + +def read_intrinsics(path: Path, images: list[Path]) -> list[np.ndarray]: + """[fx, fy, cx, cy] per image, in OpenCV pixels of that image.""" + out = [] + for p, entry in zip(images, _per_image(path, images, "intrinsics"), strict=True): + k = np.asarray(entry, dtype=np.float64) + if k.shape != (4,) or not np.all(np.isfinite(k)) or k[0] <= 0 or k[1] <= 0: + raise ValueError( + f"{path}: intrinsics for {p.stem} must be [fx, fy, cx, cy], got {entry}" + ) + out.append(k) + return out + + +def read_poses(path: Path, images: list[Path]) -> list[np.ndarray]: + """4x4 camera-to-world per image, OpenCV camera (+x right, +y down, +z forward), metres.""" + out = [] + for p, entry in zip(images, _per_image(path, images, "poses"), strict=True): + T = np.asarray(entry, dtype=np.float64) + if T.shape != (4, 4) or not np.all(np.isfinite(T)): + raise ValueError(f"{path}: pose for {p.stem} must be a 4x4 matrix") + if not np.allclose(T[3], [0, 0, 0, 1]) or not np.allclose( + T[:3, :3].T @ T[:3, :3], np.eye(3), atol=1e-4 + ): + raise ValueError(f"{path}: pose for {p.stem} is not a rigid transform: {T.tolist()}") + out.append(T) + return out + + +def load_rgb(path: Path) -> np.ndarray: + bgr = cv2.imread(str(path), cv2.IMREAD_COLOR) + if bgr is None: + raise ValueError(f"OpenCV could not read {path}") + return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) + + +def check_intrinsics_fit(k: np.ndarray, width: int, height: int, name: str) -> None: + """Catch intrinsics written for a different resolution: the principal point must lie inside.""" + if not (0 <= k[2] <= width - 1 and 0 <= k[3] <= height - 1): + raise ValueError( + f"{name}: principal point ({k[2]:.1f}, {k[3]:.1f}) is outside the {width}x{height} image; " + "intrinsics must be in pixels of the image as listed" + ) + + +def matrix(k: np.ndarray) -> np.ndarray: + fx, fy, cx, cy = k + return np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]]) + + +def vector(K: np.ndarray) -> np.ndarray: + return np.array([K[0, 0], K[1, 1], K[0, 2], K[1, 2]], dtype=np.float64) + + +# --------------------------------------------------------------------------------------------- +# Input image <-> network image + + +@dataclass(frozen=True) +class Resample: + """Input image scaled to (resized_w, resized_h), then cropped to (net_w, net_h) at (x0, y0).""" + + in_w: int + in_h: int + resized_w: int + resized_h: int + x0: int + y0: int + net_w: int + net_h: int + + @property + def sx(self) -> float: + return self.resized_w / self.in_w + + @property + def sy(self) -> float: + return self.resized_h / self.in_h + + def to_json(self) -> dict: + return { + "input_wh": [self.in_w, self.in_h], + "resized_wh": [self.resized_w, self.resized_h], + "crop_xy": [self.x0, self.y0], + "network_wh": [self.net_w, self.net_h], + } + + +def identity_resample(width: int, height: int) -> Resample: + return Resample(width, height, width, height, 0, 0, width, height) + + +def stretch_resample(width: int, height: int, net_w: int, net_h: int) -> Resample: + """Scale each axis to the network size, no crop (aspect ratio may change slightly).""" + return Resample(width, height, net_w, net_h, 0, 0, net_w, net_h) + + +def cover_crop_resample(width: int, height: int, net_w: int, net_h: int) -> Resample: + """Scale uniformly until the image covers the network size, then crop the centre.""" + s = max(net_w / width, net_h / height) + rw, rh = max(net_w, round(width * s)), max(net_h, round(height * s)) + return Resample(width, height, rw, rh, (rw - net_w) // 2, (rh - net_h) // 2, net_w, net_h) + + +def longest_side_resample(width: int, height: int, max_side: int) -> Resample: + """Uniform downscale so the longest side is at most max_side; never upscales.""" + s = min(1.0, max_side / max(width, height)) + rw, rh = max(1, round(width * s)), max(1, round(height * s)) + return Resample(width, height, rw, rh, 0, 0, rw, rh) + + +def patch_aligned_size(width: int, height: int, longest: int, patch: int) -> tuple[int, int]: + """Network size with the input's aspect ratio, longest side `longest`, both sides multiples of + `patch` (rounded to nearest).""" + s = longest / max(width, height) + return ( + max(patch, round(width * s / patch) * patch), + max(patch, round(height * s / patch) * patch), + ) + + +def to_network_image(rgb: np.ndarray, r: Resample) -> np.ndarray: + if rgb.shape[:2] != (r.in_h, r.in_w): + raise ValueError( + f"image is {rgb.shape[1]}x{rgb.shape[0]}, resample expects {r.in_w}x{r.in_h}" + ) + if (r.resized_w, r.resized_h) != (r.in_w, r.in_h): + down = r.resized_w * r.resized_h < r.in_w * r.in_h + rgb = cv2.resize( + rgb, + (r.resized_w, r.resized_h), + interpolation=cv2.INTER_AREA if down else cv2.INTER_CUBIC, + ) + return np.ascontiguousarray(rgb[r.y0 : r.y0 + r.net_h, r.x0 : r.x0 + r.net_w]) + + +def intrinsics_to_network(k: np.ndarray, r: Resample) -> np.ndarray: + fx, fy, cx, cy = k + return np.array( + [fx * r.sx, fy * r.sy, (cx + 0.5) * r.sx - r.x0 - 0.5, (cy + 0.5) * r.sy - r.y0 - 0.5] + ) + + +def intrinsics_to_input(k_net: np.ndarray, r: Resample) -> np.ndarray: + fx, fy, cx, cy = k_net + return np.array( + [fx / r.sx, fy / r.sy, (cx + 0.5 + r.x0) / r.sx - 0.5, (cy + 0.5 + r.y0) / r.sy - 0.5] + ) + + +def depth_to_input( + depth_net: np.ndarray, valid_net: np.ndarray, r: Resample +) -> tuple[np.ndarray, np.ndarray]: + """Resample a network-resolution depth map onto the input pixel grid. + + Depth is interpolated bilinearly. An input pixel is valid only if every network pixel the + interpolation draws on is valid and it lies inside the network image's footprint (pixels the + crop removed are invalid). Z-depth is unchanged by image scaling, so values are not rescaled. + """ + if depth_net.shape != (r.net_h, r.net_w) or valid_net.shape != depth_net.shape: + raise ValueError(f"network outputs are {depth_net.shape}, expected {(r.net_h, r.net_w)}") + valid_net = valid_net & np.isfinite(depth_net) & (depth_net > 0) + if r == identity_resample(r.in_w, r.in_h): + return np.where(valid_net, depth_net, np.nan).astype(np.float32), valid_net + u = r.sx * (np.arange(r.in_w, dtype=np.float64) + 0.5) - r.x0 - 0.5 + v = r.sy * (np.arange(r.in_h, dtype=np.float64) + 0.5) - r.y0 - 0.5 + map_x = np.broadcast_to(u[None, :], (r.in_h, r.in_w)).astype(np.float32) + map_y = np.broadcast_to(v[:, None], (r.in_h, r.in_w)).astype(np.float32) + filled = np.where(valid_net, depth_net, 0.0).astype(np.float32) + depth = cv2.remap(filled, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + weight = cv2.remap( + valid_net.astype(np.float32), + map_x, + map_y, + cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REPLICATE, + ) + inside = (map_x >= -0.5) & (map_x <= r.net_w - 0.5) & (map_y >= -0.5) & (map_y <= r.net_h - 0.5) + valid = inside & (weight > 1.0 - 1e-4) + return np.where(valid, depth, np.nan).astype(np.float32), valid + + +# --------------------------------------------------------------------------------------------- +# Runner interface + + +@dataclass(frozen=True) +class RunInputs: + images: list[Path] + intrinsics: list[np.ndarray] | None # OpenCV pixels of each input image + intrinsics_mode: str # "known": fed to the model; "predicted": the model estimates them + poses: list[np.ndarray] | None # OpenCV camera-to-world, metres + max_side: int | None + device: str + fp32: bool + + +@dataclass +class ImageResult: + path: Path + depth: np.ndarray # float32 HxW metres at input resolution, NaN where invalid + valid: np.ndarray # bool HxW + intrinsics: np.ndarray # [fx, fy, cx, cy] OpenCV pixels of the input image + seconds: float + resample: Resample + network_wh: tuple[int, int] # image size the network itself ran at + cam_to_world: np.ndarray | None = None + arrays: dict[str, np.ndarray] = field(default_factory=dict) # extra npz entries + extra: dict = field(default_factory=dict) # extra run.json fields + + +# --------------------------------------------------------------------------------------------- +# Outputs + + +def write_npz( + out_dir: Path, + stem: str, + depth: np.ndarray, + valid: np.ndarray, + intrinsics: np.ndarray, + cam_to_world: np.ndarray | None = None, + extra_arrays: dict[str, np.ndarray] | None = None, +) -> Path: + if depth.dtype != np.float32 or valid.dtype != bool or depth.shape != valid.shape: + raise ValueError(f"{stem}: depth must be float32 and valid bool of one shape") + arrays = { + "depth": depth, + "valid": valid, + "intrinsics": np.asarray(intrinsics, dtype=np.float64).reshape(4), + } + if cam_to_world is not None: + arrays["cam_to_world"] = np.asarray(cam_to_world, dtype=np.float64).reshape(4, 4) + for name, value in (extra_arrays or {}).items(): + if name in arrays: + raise ValueError(f"{stem}: extra array {name!r} would overwrite a standard one") + arrays[name] = value + path = out_dir / f"{stem}.npz" + np.savez_compressed(path, **arrays) + return path + + +def depth_summary(depth: np.ndarray, valid: np.ndarray) -> dict: + d = depth[valid] + if d.size == 0: + return {"valid_fraction": 0.0} + return { + "valid_fraction": round(float(valid.mean()), 4), + "median_m": round(float(np.median(d)), 3), + "p5_m": round(float(np.percentile(d, 5)), 3), + "p95_m": round(float(np.percentile(d, 95)), 3), + } diff --git a/experiments/evals/models/da3metric.py b/experiments/evals/models/da3metric.py new file mode 100644 index 000000000..ab868b4a9 --- /dev/null +++ b/experiments/evals/models/da3metric.py @@ -0,0 +1,133 @@ +"""Depth Anything 3 metric (DA3METRIC-LARGE): one image plus its focal length to metric depth. + +The network predicts depth for a canonical camera. The repository's rule for metres +(README.md:235 at the pinned commit) is `metric_depth = focal * net_output / 300`, with `focal` the +mean of fx and fy in pixels. Its own code applies it with the intrinsics of the image the network +saw, after resizing (`apply_metric_scaling`, src/depth_anything_3/utils/alignment.py:118-133, called +from model/da3.py:379 with the processed-resolution intrinsics). So the focal here is the known focal +scaled to the network image. The model predicts no intrinsics, so it needs known ones. + +Sky pixels are invalid. The model marks non-sky as `sky < 0.3` (model/da3.py:161) and fills sky +with the 99th-percentile depth, so those depths are not measurements. + +The network is built from the package's own config and weights rather than through +`depth_anything_3.api`, which imports export code (pycolmap, evo, moviepy) at module load. +Preprocessing is the package's `InputProcessor` with its default `upper_bound_resize`: the whole +image is resized, longest side 504 unless --max-side is given, each side rounded to a multiple of +14; nothing is cropped. +""" + +from __future__ import annotations + +import time +from collections.abc import Iterator + +import numpy as np + +from models.common import ( + ImageResult, + RunInputs, + depth_to_input, + load_rgb, + matrix, + stretch_resample, +) + +REPO = "depth-anything/DA3METRIC-LARGE" +FILENAME = "model.safetensors" +REVISION = "4010e39f3634a45bc60553321fb49fb760bd594e" +SHA256 = "bbea5b0b3ee389849cffa7ddae89de064a90abd2b055fc5aa99aac68db324776" # LFS sha256 Hugging Face lists for REVISION +CODE = { + "repo": "https://github.com/ByteDance-Seed/Depth-Anything-3", + "commit": "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4", +} +LICENSE = "Apache-2.0" +CANONICAL_FOCAL_PX = 300.0 +DEFAULT_PROCESS_RES = 504 +NON_SKY_BELOW = 0.3 + + +def load(device): + import torch + from depth_anything_3.cfg import create_object, load_config + from depth_anything_3.registry import MODEL_REGISTRY + from huggingface_hub import hf_hub_download + from safetensors.torch import load_file + + net = create_object(load_config(MODEL_REGISTRY["da3metric-large"])) + weights = load_file(hf_hub_download(REPO, FILENAME, revision=REVISION)) + # The checkpoint is saved from the `DepthAnything3` wrapper, which holds the network as `.model`. + prefix = "model." + if not all(k.startswith(prefix) for k in weights): + raise RuntimeError(f"{REPO}: expected every weight key to start with {prefix!r}") + net.load_state_dict({k[len(prefix) :]: v for k, v in weights.items()}, strict=True) + net.eval() + return net.to(device=torch.device(device)) + + +def run(net, inputs: RunInputs) -> Iterator[ImageResult]: + import torch + from depth_anything_3.utils.io.input_processor import InputProcessor + + if inputs.intrinsics_mode != "known": + raise ValueError( + "da3metric needs known intrinsics: metres = focal * output / 300 and the model does not " + "predict a focal. Pass --intrinsics." + ) + processor = InputProcessor() + process_res = inputs.max_side or DEFAULT_PROCESS_RES + device = next(net.parameters()).device + for i, path in enumerate(inputs.images): + rgb = load_rgb(path) + h, w = rgb.shape[:2] + k = inputs.intrinsics[i] + batch, _, k_proc = processor( + [rgb], + intrinsics=matrix(k)[None].astype(np.float32), + process_res=process_res, + process_res_method="upper_bound_resize", + num_workers=1, + ) + net_h, net_w = batch.shape[-2:] + r = stretch_resample(w, h, net_w, net_h) + focal_net = (k[0] * r.sx + k[1] * r.sy) / 2 + focal_theirs = float((k_proc[0, 0, 0] + k_proc[0, 1, 1]) / 2) + if abs(focal_net - focal_theirs) > 1e-3 * focal_net: + raise RuntimeError( + f"{path.name}: focal at network size {focal_net} != InputProcessor's {focal_theirs}" + ) + + start = time.perf_counter() + x = batch[None].to(device) # (1, N=1, 3, H, W) + # Same autocast as DepthAnything3.forward (api.py): fp16 unless CUDA has bf16. The depth + # head runs in fp32 inside the network regardless. + with ( + torch.inference_mode(), + torch.autocast(device_type=device.type, dtype=torch.float16, enabled=not inputs.fp32), + ): + out = net(x, None, None, export_feat_layers=[]) + raw = out["depth"].float().reshape(net_h, net_w).cpu().numpy() + sky = out["sky"].float().reshape(net_h, net_w).cpu().numpy() + if device.type == "mps": + torch.mps.synchronize() + seconds = time.perf_counter() - start + + metric = (raw * (focal_net / CANONICAL_FOCAL_PX)).astype(np.float32) + non_sky = sky < NON_SKY_BELOW + depth_in, valid_in = depth_to_input(metric, non_sky, r) + yield ImageResult( + path=path, + depth=depth_in, + valid=valid_in, + intrinsics=k.copy(), + seconds=seconds, + resample=r, + network_wh=(net_w, net_h), + extra={ + "focal_network_px": round(focal_net, 3), + "sky_fraction": round(float(1 - non_sky.mean()), 4), + }, + ) + del out, x + if device.type == "mps": + torch.mps.empty_cache() diff --git a/experiments/evals/models/map_anything.py b/experiments/evals/models/map_anything.py new file mode 100644 index 000000000..ef4400a88 --- /dev/null +++ b/experiments/evals/models/map_anything.py @@ -0,0 +1,204 @@ +"""MapAnything (Apache-2.0 checkpoint): N images, optionally with intrinsics and camera poses, to +per-view metric z-depth, intrinsics and camera-to-world poses in one shared metric frame. + +All views run jointly in one `model.infer` call, so per-image seconds are the joint time divided by +the number of views. Outputs are metric already: the model predicts a metric scale factor and +applies it (README "Image-Only Inference": `metric_scaling_factor`), and `depth_z` is z-depth in +the OpenCV camera. Poses in and out are OpenCV camera-to-world (README, "Multi-Modal Inference"). +The model's output frame is the first view's camera, with or without input poses; with --poses the +outputs are moved into the input poses' world frame. + +The model always predicts its own rays. With known intrinsics, `intrinsics` in the npz is the known +set and `intrinsics_predicted` holds what the output rays imply; on the ADVIO smoke run the +predicted fx was 1-3% above the known 1081 px. + +Every view must share one network size. By default it is MapAnything's own choice for the mean +aspect ratio (`find_closest_aspect_ratio`, resolution set 518; 1280x720 maps to 518x294). Each +image is scaled uniformly to cover that size and centre-cropped here rather than in +`preprocess_inputs`, so the crop is known exactly; `preprocess_inputs` then receives images already +at the target size and only normalises them. Input pixels the crop removed are invalid. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +import numpy as np + +from models.common import ( + ImageResult, + RunInputs, + cover_crop_resample, + depth_to_input, + intrinsics_to_input, + intrinsics_to_network, + load_rgb, + matrix, + patch_aligned_size, + to_network_image, + vector, +) + +REPO = "facebook/map-anything-apache" +FILENAME = "model.safetensors" +REVISION = "00f9c245bbcb60522d1ed7f9e9d88462c6e3f38a" +SHA256 = "fa06c0fdccefc5048e072c85935d5789b1e36b307f3859033c17f9dcb9fd5201" # LFS sha256 Hugging Face lists for REVISION +CODE = { + "repo": "https://github.com/facebookresearch/map-anything", + "commit": "3d10cf7a3016fc0f9bb13a071ee66c47b10be0d9", +} +LICENSE = "Apache-2.0" +RESOLUTION_SET = 518 +PATCH = 14 + + +# The two transformer stacks hold 92% of the 1.23 B parameters and run under bf16 autocast. The +# small encoders and heads stay fp32: parts of them run outside autocast and reject bf16 weights. +BF16_MODULES = ("encoder.", "info_sharing.") + + +def load(device): + """The model with its transformer weights in bf16, about 2.6 GB instead of 4.9. + + `from_pretrained` builds the 1.2 B-parameter model in fp32 (4.9 GB) and then reads the 4.9 GB + fp32 checkpoint into it, more than one process may use on the shared machine. Here the model is + built on the meta device (no memory) and each checkpoint tensor is moved to the device as it is + read, cast to bf16 if it belongs to `BF16_MODULES`. Those already computed in bf16 under + autocast, so only their stored weights lose precision. + """ + import torch + from huggingface_hub import hf_hub_download + from mapanything.models import MapAnything + from safetensors import safe_open + + config = json.loads(Path(hf_hub_download(REPO, "config.json", revision=REVISION)).read_text()) + # As MapAnything.from_pretrained does: skip DINOv2's own hub weights (4.5 GB of fp32 read and + # thrown away), since the checkpoint replaces them. + config["encoder_config"] = {**config["encoder_config"], "torch_hub_pretrained": False} + with torch.device("meta"): + model = MapAnything(**config) + weights = {} + # Read straight onto the device: CPU copies of fp32 tensors, once freed, stay counted against + # the process by macOS's allocator. + with safe_open( + hf_hub_download(REPO, FILENAME, revision=REVISION), "pt", device=str(device) + ) as f: + for i, key in enumerate(f.keys()): + tensor = f.get_tensor(key) + if key.startswith(BF16_MODULES): + tensor = tensor.to(torch.bfloat16) + weights[key] = tensor + if str(device).startswith("mps") and i % 50 == 49: + # Release the GPU transfer buffers loading leaves behind as it goes; the 4.05 GB + # peak in models/README.md was measured with this in place. + torch.mps.synchronize() + torch.mps.empty_cache() + # strict=False: the checkpoint stores each shared tensor once (safetensors drops aliases). + model.load_state_dict(weights, strict=False, assign=True) + unset = [n for n, t in [*model.named_parameters(), *model.named_buffers()] if t.is_meta] + if unset: + raise RuntimeError( + f"{len(unset)} MapAnything tensors missing from the checkpoint: {unset[:5]}" + ) + return model.eval() + + +def network_size(sizes: list[tuple[int, int]], max_side: int | None) -> tuple[int, int]: + from mapanything.utils.image import find_closest_aspect_ratio + + mean_aspect = sum(w / h for w, h in sizes) / len(sizes) + if max_side is None: + return find_closest_aspect_ratio(mean_aspect, RESOLUTION_SET) + w, h = ( + (max_side, max_side / mean_aspect) + if mean_aspect >= 1 + else (max_side * mean_aspect, max_side) + ) + return patch_aligned_size(round(w), round(h), max_side, PATCH) + + +def run(model, inputs: RunInputs) -> list[ImageResult]: + import torch + from mapanything.utils.image import preprocess_inputs + + rgbs = [load_rgb(p) for p in inputs.images] + net_w, net_h = network_size([(im.shape[1], im.shape[0]) for im in rgbs], inputs.max_side) + resamples, views = [], [] + for i, rgb in enumerate(rgbs): + r = cover_crop_resample(rgb.shape[1], rgb.shape[0], net_w, net_h) + view = {"img": torch.from_numpy(to_network_image(rgb, r))} + if inputs.intrinsics_mode == "known": + k_net = intrinsics_to_network(inputs.intrinsics[i], r) + view["intrinsics"] = torch.from_numpy(matrix(k_net).astype(np.float32)) + if inputs.poses is not None: + view["camera_poses"] = torch.from_numpy(inputs.poses[i].astype(np.float32)) + view["is_metric_scale"] = torch.tensor([True]) + resamples.append(r) + views.append(view) + views = preprocess_inputs(views, resize_mode="fixed_size", size=(net_w, net_h)) + for v, r in zip(views, resamples, strict=True): + if tuple(v["img"].shape[-2:]) != (r.net_h, r.net_w): + raise RuntimeError( + f"preprocess_inputs changed the image size to {tuple(v['img'].shape[-2:])}" + ) + + start = time.perf_counter() + preds = model.infer( + views, + memory_efficient_inference=True, + minibatch_size=1, + use_amp=not inputs.fp32, + amp_dtype="bf16", + apply_mask=True, + mask_edges=True, + ) + unpacked = [] + for pred in preds: + unpacked.append( + { + "depth": pred["depth_z"][0, ..., 0].float().cpu().numpy(), + "mask": pred["mask"][0, ..., 0].cpu().numpy().astype(bool), + "K": pred["intrinsics"][0].float().cpu().numpy().astype(np.float64), + "pose": pred["camera_poses"][0].float().cpu().numpy().astype(np.float64), + "scale": float(pred["metric_scaling_factor"].reshape(-1)[0]), + } + ) + if inputs.device.startswith("mps"): + torch.mps.synchronize() + seconds = (time.perf_counter() - start) / len(views) + del preds + if inputs.device.startswith("mps"): + torch.mps.empty_cache() + + results = [] + for i, (path, r, u) in enumerate(zip(inputs.images, resamples, unpacked, strict=True)): + depth_in, valid_in = depth_to_input(u["depth"], u["mask"], r) + predicted_k = intrinsics_to_input(vector(u["K"]), r) + # Output poses are relative to the first view's camera even when poses are given (checked on + # the ADVIO smoke run: first_pose @ output reproduced the input poses to 2.3 cm and 0.11 deg), + # so with input poses they are moved back into the caller's world frame. + pose = inputs.poses[0] @ u["pose"] if inputs.poses is not None else u["pose"] + known = inputs.intrinsics_mode == "known" + results.append( + ImageResult( + path=path, + depth=depth_in, + valid=valid_in, + intrinsics=inputs.intrinsics[i].copy() if known else predicted_k, + seconds=seconds, + resample=r, + network_wh=(r.net_w, r.net_h), + cam_to_world=pose, + arrays={"intrinsics_predicted": predicted_k}, + extra={ + "metric_scaling_factor": round(u["scale"], 5), + "intrinsics_predicted": [round(float(v), 4) for v in predicted_k], + "world_frame": "input poses" + if inputs.poses is not None + else "first image's camera", + }, + ) + ) + return results diff --git a/experiments/evals/models/moge2.py b/experiments/evals/models/moge2.py new file mode 100644 index 000000000..bb2624f01 --- /dev/null +++ b/experiments/evals/models/moge2.py @@ -0,0 +1,106 @@ +"""MoGe-2 (ViT-L, normal head): one image to a metric point map, depth and intrinsics. + +`MoGeModel.infer` (moge/model/v2.py at the pinned commit) already returns metric z-depth at the +resolution of the image it is given: it multiplies the affine point map by the predicted +`metric_scale` (v2.py:279-283) and resizes the heads' outputs back to the input size bilinearly +(v2.py:170). Its only camera input is `fov_x`; with it, the focal is fixed and only the depth shift +is solved (v2.py:259-264). The principal point is always the image centre and pixels are square. +""" + +from __future__ import annotations + +import math +import time +from collections.abc import Iterator + +import numpy as np + +from models.common import ( + ImageResult, + RunInputs, + depth_to_input, + intrinsics_to_input, + intrinsics_to_network, + load_rgb, + longest_side_resample, + to_network_image, +) + +REPO = "Ruicheng/moge-2-vitl-normal" +FILENAME = "model.pt" +REVISION = "cb0e8bbd6b1e243589717c78e750b1ba4c093acf" +SHA256 = "280741fd09bc3f403ccff9967784c2a391b52d2c0742ae3efdb21d9f90cc1a01" # LFS sha256 Hugging Face lists for REVISION +CODE = { + "repo": "https://github.com/microsoft/MoGe", + "commit": "07444410f1e33f402353b99d6ccd26bd31e469e8", +} +LICENSE = "MIT" +RESOLUTION_LEVEL = 9 # MoGe's default: 3600 ViT tokens, the top of its 1200-3600 range. + + +def load(device): + from moge.model.v2 import MoGeModel + + return MoGeModel.from_pretrained(REPO, revision=REVISION).to(device).eval() + + +def run(model, inputs: RunInputs) -> Iterator[ImageResult]: + import torch + + for i, path in enumerate(inputs.images): + rgb = load_rgb(path) + h, w = rgb.shape[:2] + # MoGe predicts at a fixed token budget whatever the input size; --max-side only bounds the + # size of the image handed to it (and the output upsampling), which matters for 24 MP photos. + r = longest_side_resample(w, h, inputs.max_side or max(w, h)) + net_rgb = to_network_image(rgb, r) + fov_x = None + if inputs.intrinsics_mode == "known": + fx_net = intrinsics_to_network(inputs.intrinsics[i], r)[0] + # Continuous-pixel width over the focal: MoGe's fov_x spans the full image edge to edge. + fov_x = math.degrees(2 * math.atan(r.net_w / (2 * fx_net))) + + start = time.perf_counter() + tensor = torch.from_numpy(net_rgb).to(device=model.device).permute(2, 0, 1).float() / 255 + out = model.infer( + tensor, + resolution_level=RESOLUTION_LEVEL, + fov_x=fov_x, + use_fp16=not inputs.fp32, + ) + depth = out["depth"].float().cpu().numpy() + mask = out["mask"].cpu().numpy().astype(bool) + k_norm = out["intrinsics"].float().cpu().numpy() + if inputs.device.startswith("mps"): + torch.mps.synchronize() + seconds = time.perf_counter() - start + + # utils3d normalised intrinsics: image spans [0, 1] edge to edge, so OpenCV = n * size - 0.5. + k_net = np.array( + [ + k_norm[0, 0] * r.net_w, + k_norm[1, 1] * r.net_h, + k_norm[0, 2] * r.net_w - 0.5, + k_norm[1, 2] * r.net_h - 0.5, + ] + ) + depth_in, valid_in = depth_to_input(depth, mask, r) + aspect = r.net_w / r.net_h + num_tokens = int( + model.num_tokens_range[0] + + RESOLUTION_LEVEL / 9 * (model.num_tokens_range[1] - model.num_tokens_range[0]) + ) + grid_h, grid_w = round((num_tokens / aspect) ** 0.5), round((num_tokens * aspect) ** 0.5) + yield ImageResult( + path=path, + depth=depth_in, + valid=valid_in, + intrinsics=intrinsics_to_input(k_net, r), + seconds=seconds, + resample=r, + network_wh=(grid_w * 14, grid_h * 14), + extra={"fov_x_deg": fov_x, "num_tokens": num_tokens}, + ) + del out, tensor + if inputs.device.startswith("mps"): + torch.mps.empty_cache() diff --git a/experiments/evals/models/pyproject.toml b/experiments/evals/models/pyproject.toml new file mode 100644 index 000000000..305366dd0 --- /dev/null +++ b/experiments/evals/models/pyproject.toml @@ -0,0 +1,69 @@ +[project] +name = "house-scanning-eval-models" +version = "0.1.0" +description = "Metric depth and geometry model runners (MoGe-2, Depth Anything 3 metric, MapAnything) for the wall-reconstruction eval" +requires-python = ">=3.12,<3.13" +dependencies = [ + "depth-anything-3", + "huggingface-hub>=0.34", + "mapanything", + "moge", + "numpy>=2.1", + "opencv-python-headless>=4.10,<5", + "pillow>=10", + "safetensors>=0.4", + "torch>=2.7", + "torchvision>=0.22", +] + +[dependency-groups] +dev = ["pytest>=8.3", "ruff>=0.8"] + +[tool.uv] +package = false +# MapAnything pins opencv-python-headless==4.10.0.84; MoGe and DA3 ask for opencv-python. One +# headless build serves all three (two OpenCV wheels in one env overwrite each other's cv2); +# 4.x because all three were written against it. +override-dependencies = ["opencv-python-headless>=4.10,<5", "opencv-python; sys_platform == 'never'"] + +# Each model is pinned to a commit. Their packaging lists demo, export and training extras as hard +# requirements (gradio, open3d, pycolmap, xformers, evo, moviepy, numpy<2), none of which the +# inference path imports. The metadata below replaces their requirement lists with what the +# modules these runners import actually need. +[tool.uv.sources] +# Last commit before the MoGe-3 rewrite (74fbce0), whose packaging only supports Linux and Windows. +moge = { git = "https://github.com/microsoft/MoGe", rev = "07444410f1e33f402353b99d6ccd26bd31e469e8" } +depth-anything-3 = { git = "https://github.com/ByteDance-Seed/Depth-Anything-3", rev = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4" } +mapanything = { git = "https://github.com/facebookresearch/map-anything", rev = "3d10cf7a3016fc0f9bb13a071ee66c47b10be0d9" } + +[[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", +] + +[[tool.uv.dependency-metadata]] +name = "depth-anything-3" +version = "0.0.0" +requires-dist = [ + "addict", + "einops", + "huggingface-hub", + "imageio", + "numpy", + "omegaconf", + "opencv-python-headless", + "pillow", + "safetensors", + "torch>=2", + "torchvision", + "tqdm", +] diff --git a/experiments/evals/models/run.py b/experiments/evals/models/run.py new file mode 100644 index 000000000..8314ecf7f --- /dev/null +++ b/experiments/evals/models/run.py @@ -0,0 +1,216 @@ +"""Run one depth/geometry model on a list of images and write one .npz per image plus run.json. + + cd experiments/evals + uv run --project models python -m models.run --model moge2 \\ + --images images.txt [--intrinsics intrinsics.json] [--max-side N] --out DIR + +Each .npz holds `depth` (float32 HxW metres, z along the optical axis, input resolution, NaN +where invalid), `valid` (bool HxW), `intrinsics` ([fx, fy, cx, cy] OpenCV pixels of the input image, +the ones used or predicted) and, for mapanything, `cam_to_world` (4x4 OpenCV camera, metres, in the +model's shared frame). See README.md in this folder. +""" + +from __future__ import annotations + +import argparse +import importlib +import json +import platform +import sys +import time +from pathlib import Path + +from models.common import ( + RunInputs, + check_intrinsics_fit, + checkpoint_record, + depth_summary, + read_image_list, + read_intrinsics, + read_poses, + require_free_space_for_download, + set_cache_dirs, + write_npz, +) + +MODULES = { + "moge2": "models.moge2", + "da3metric": "models.da3metric", + "mapanything": "models.map_anything", +} + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--model", required=True, choices=sorted(MODULES)) + ap.add_argument("--images", required=True, type=Path, help="text file, one image path per line") + ap.add_argument( + "--intrinsics", + type=Path, + help="JSON: [fx, fy, cx, cy] per image in OpenCV pixels of that image; a list in --images " + "order or an object keyed by image stem", + ) + ap.add_argument( + "--intrinsics-mode", + choices=["known", "predicted"], + help="feed --intrinsics to the model (known) or let it predict them (predicted); " + "default: known when --intrinsics is given", + ) + ap.add_argument( + "--poses", + type=Path, + help="mapanything only. JSON: 4x4 camera-to-world per image, OpenCV camera, metres", + ) + ap.add_argument( + "--max-side", + type=int, + help="moge2: downscale the image handed to the model to this longest side; da3metric: " + "network longest side (default 504); mapanything: network longest side (default: its " + "518 resolution set)", + ) + ap.add_argument("--device", default="auto", choices=["auto", "mps", "cpu", "cuda"]) + ap.add_argument( + "--mps-cap-gb", + type=float, + default=3.6, + help="hard cap on GPU memory after loading, so on the shared Mac a run that needs more " + "fails instead of growing; MoGe-2 at its default 3600 tokens needs 3.05 GiB", + ) + ap.add_argument("--fp32", action="store_true", help="disable mixed precision") + ap.add_argument("--out", required=True, type=Path) + args = ap.parse_args(argv) + + if args.intrinsics_mode is None: + args.intrinsics_mode = "known" if args.intrinsics else "predicted" + if args.intrinsics_mode == "known" and args.intrinsics is None: + ap.error("--intrinsics-mode known needs --intrinsics") + if args.model == "da3metric" and args.intrinsics_mode != "known": + ap.error("da3metric needs --intrinsics: its metres are focal * output / 300") + if args.poses is not None and args.model != "mapanything": + ap.error(f"--poses is only accepted by mapanything; {args.model} is single-image") + if args.max_side is not None and args.max_side < 14: + ap.error("--max-side must be at least 14") + return args + + +def pick_device(requested: str) -> str: + import torch + + if requested == "auto": + if torch.cuda.is_available(): + return "cuda" + return "mps" if torch.backends.mps.is_available() else "cpu" + if requested == "mps" and not torch.backends.mps.is_available(): + raise RuntimeError("--device mps requested but torch reports MPS unavailable") + if requested == "cuda" and not torch.cuda.is_available(): + raise RuntimeError("--device cuda requested but torch reports CUDA unavailable") + return requested + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv) + caches = set_cache_dirs() + + images = read_image_list(args.images) + intrinsics = read_intrinsics(args.intrinsics, images) if args.intrinsics else None + poses = read_poses(args.poses, images) if args.poses else None + if intrinsics is not None: + from PIL import Image + + for p, k in zip(images, intrinsics, strict=True): + with Image.open(p) as im: # reads the header only + w, h = im.size + check_intrinsics_fit(k, w, h, p.name) + + import torch + + module = importlib.import_module(MODULES[args.model]) + device = pick_device(args.device) + require_free_space_for_download(module.REPO, module.FILENAME, module.REVISION) + args.out.mkdir(parents=True, exist_ok=True) + + start = time.perf_counter() + model = module.load(device) + load_seconds = time.perf_counter() - start + if device == "mps": + torch.mps.empty_cache() + torch.mps.set_per_process_memory_fraction( + args.mps_cap_gb * 1e9 / torch.mps.recommended_max_memory() + ) + inputs = RunInputs( + images=images, + intrinsics=intrinsics, + intrinsics_mode=args.intrinsics_mode, + poses=poses, + max_side=args.max_side, + device=device, + fp32=args.fp32, + ) + # Each result is written as soon as it exists: MoGe-2 and DA3 yield one image at a time, so a + # long session never holds every depth map in memory at once. + per_image = [] + for res in module.run(model, inputs): + write_npz( + args.out, + res.path.stem, + res.depth, + res.valid, + res.intrinsics, + res.cam_to_world, + res.arrays, + ) + summary = depth_summary(res.depth, res.valid) + per_image.append( + { + "image": str(res.path), + "stem": res.path.stem, + "seconds": round(res.seconds, 3), + "network_input_wh": list(res.network_wh), + "resample": res.resample.to_json(), + "intrinsics": [round(float(v), 4) for v in res.intrinsics], + **( + {"cam_to_world": res.cam_to_world.tolist()} + if res.cam_to_world is not None + else {} + ), + "depth": summary, + **res.extra, + } + ) + print(f"{res.path.stem}: {res.seconds:.2f} s, {json.dumps(summary)}", file=sys.stderr) + + del model + if device == "mps": + torch.mps.empty_cache() + + run = { + "model": args.model, + "license": module.LICENSE, + "checkpoint": checkpoint_record( + module.REPO, module.FILENAME, module.REVISION, module.SHA256 + ), + "code": module.CODE, + "device": device, + "torch": torch.__version__, + "platform": platform.platform(), + "mixed_precision": not args.fp32, + "intrinsics_mode": args.intrinsics_mode, + "intrinsics_file": str(args.intrinsics) if args.intrinsics else None, + "poses_used": poses is not None, + "poses_file": str(args.poses) if args.poses else None, + "max_side": args.max_side, + "load_seconds": round(load_seconds, 2), + "depth_upsampling": "bilinear; a pixel is valid only if every network pixel it draws on is valid", + "caches": caches, + "images": per_image, + } + if args.model == "mapanything": + run["timing_note"] = "views run jointly; per-image seconds = joint seconds / views" + (args.out / "run.json").write_text(json.dumps(run, indent=2) + "\n") + print(f"wrote {len(per_image)} npz and run.json to {args.out}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/models/run_groups.py b/experiments/evals/models/run_groups.py new file mode 100644 index 000000000..edb34f82f --- /dev/null +++ b/experiments/evals/models/run_groups.py @@ -0,0 +1,117 @@ +"""Run MapAnything once per view group of an ETH3D scene, loading the model once. + + cd experiments/evals + uv run --project models python -m models.run_groups --scene-dir ~/house-scanning-data/evals/eth3d/facade \\ + --out ~/house-scanning-data/evals/predictions/facade/mapanything + +Groups come from the scene's `subsets.json` (written by `python -m evals.recon prepare`). Each group +of n views seeded by view S is written to `/n-/.npz`, the layout `evals.recon` +reads, plus a `run.json` with timing and the model's metric scale factor. Known intrinsics are +passed (a phone knows its own). With `--poses-file` (written by `python -m evals.pose_priors +prepare`) each group also gets its AR-like camera poses as a metric prior, and the outputs are +placed in those poses' frame; without it every group's frame and scale are the model's own. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +import numpy as np + +from models.common import RunInputs, read_intrinsics, set_cache_dirs, write_npz +from models.map_anything import load, run +from models.run import pick_device + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--scene-dir", required=True, type=Path) + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--poses-file", type=Path, help="JSON {group id: {poses: {view: 4x4}}}") + ap.add_argument("--sizes", nargs="+", default=None, help="only these group sizes") + ap.add_argument( + "--max-side", + type=int, + default=None, + help="network longest side (default: MapAnything's 518)", + ) + ap.add_argument("--device", default="auto", choices=["auto", "mps", "cpu"]) + ap.add_argument( + "--mps-cap-gb", + type=float, + default=3.8, + help="hard cap on GPU memory, so on the shared Mac a run that needs more fails instead of " + "growing: the weights take 2.64 GB, and 2- and 4-view groups at 392 px fit under 3.8", + ) + args = ap.parse_args() + set_cache_dirs() + import torch + + groups = json.loads((args.scene_dir / "subsets.json").read_text()) + if args.sizes: + groups = {n: g for n, g in groups.items() if n in args.sizes} + pose_sets = json.loads(args.poses_file.read_text()) if args.poses_file else None + images_dir = args.scene_dir / "images_1024" + intr_file = args.scene_dir / "model_inputs" / "intrinsics.json" + device = pick_device(args.device) + t0 = time.perf_counter() + model = load(device) + if device == "mps": + torch.mps.empty_cache() # loading leaves about 0.6 GB of freed blocks cached + torch.mps.set_per_process_memory_fraction( + args.mps_cap_gb * 1e9 / torch.mps.recommended_max_memory() + ) + print(f"loaded in {time.perf_counter() - t0:.1f} s on {device}", file=sys.stderr) + for n, members_list in groups.items(): + for members in members_list: + gid = f"n{n}-{members[0]}" + out = args.out / gid + if (out / "run.json").exists(): + continue + poses = None + if pose_sets is not None: + poses = [np.array(pose_sets[gid]["poses"][m]) for m in members] + images = [images_dir / f"{m}.jpg" for m in members] + inputs = RunInputs( + images=images, + intrinsics=read_intrinsics(intr_file, images), + intrinsics_mode="known", + poses=poses, + max_side=args.max_side, + device=device, + fp32=False, + ) + results = run(model, inputs) + out.mkdir(parents=True, exist_ok=True) + for res in results: + write_npz( + out, + res.path.stem, + res.depth, + res.valid, + res.intrinsics, + res.cam_to_world, + res.arrays, + ) + summary = { + "members": members, + "seconds_per_view": round(results[0].seconds, 3), + "network_wh": list(results[0].network_wh), + "weights": "transformer stacks bf16, rest fp32 (models/map_anything.py load)", + "metric_scaling_factor": [r.extra["metric_scaling_factor"] for r in results], + "poses_file": str(args.poses_file) if args.poses_file else None, + } + (out / "run.json").write_text(json.dumps(summary, indent=1) + "\n") + print(f"n={n} {members[0]}: {results[0].seconds:.2f} s/view", file=sys.stderr) + if device == "mps": + torch.mps.empty_cache() + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/models/smoke_advio.py b/experiments/evals/models/smoke_advio.py new file mode 100644 index 000000000..b97046362 --- /dev/null +++ b/experiments/evals/models/smoke_advio.py @@ -0,0 +1,163 @@ +"""Smoke-test inputs from the ADVIO replay session, and a summary of model outputs on them. + + uv run --project models python -m models.smoke_advio prepare --ids k00020 k00021 k00022 k00023 k00024 + uv run --project models python -m models.smoke_advio summarize DIR [DIR ...] + +`prepare` writes upright copies of the keyframes with matching intrinsics and camera poses. The +replay JPEGs are unrotated sensor images of a phone held upright, so the sky is on the left; depth +models are trained on upright photos. Rotating the image 90 degrees clockwise gives the upright +portrait view: continuous pixel (u, v) in the landscape image lands at (H - v, u), so +fx' = fy, fy' = fx, cx' = H - cy, cy' = cx (continuous), and the camera turns about its optical +axis. Session intrinsics are continuous pixels; the outputs are OpenCV (continuous - 0.5). + +`summarize` prints, per image, the median depth of the bottom quarter of the upright image (the +road just ahead) and, for runs with `cam_to_world`, camera-centre distances against ARKit's. +""" + +from __future__ import annotations + +import argparse +import json +from itertools import pairwise +from pathlib import Path + +import cv2 +import numpy as np + +from models.common import DATA_ROOT + +SESSION = DATA_ROOT / "replays" / "advio-20-0040-0075" +SMOKE_ROOT = DATA_ROOT / "evals" / "model-smoke" +FLIP_YZ = np.diag([1.0, -1.0, -1.0]) # ARKit camera (+y up, looks along -z) <-> OpenCV camera +# Columns: upright-portrait OpenCV camera axes in the landscape OpenCV camera. Portrait right is +# landscape up (-y), portrait down is landscape right (+x), the optical axis is shared. +LANDSCAPE_FROM_PORTRAIT = np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) +GROUND_ROWS = 0.25 + + +def prepare(ids: list[str], out: Path) -> None: + session = json.loads((SESSION / "session.json").read_text()) + by_id = {k["id"]: k for k in session["keyframes"]} + missing = [i for i in ids if i not in by_id] + if missing: + raise ValueError(f"keyframes {missing} are not in {SESSION / 'session.json'}") + img_dir = out / "images" + img_dir.mkdir(parents=True, exist_ok=True) + intrinsics, poses, arkit = {}, {}, {} + lines = [] + for kid in ids: + kf = by_id[kid] + img = cv2.imread(str(SESSION / kf["img"]), cv2.IMREAD_COLOR) + if img is None or img.shape[:2] != (kf["h"], kf["w"]): + raise ValueError(f"{kid}: image missing or not {kf['w']}x{kf['h']}") + upright = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) + path = img_dir / f"{kid}.png" + cv2.imwrite(str(path), upright) + lines.append(str(path)) + fx, fy, cx, cy = kf["intrinsics"] + intrinsics[kid] = [fy, fx, kf["h"] - cy - 0.5, cx - 0.5] + T = np.asarray(kf["pose"], dtype=np.float64).reshape(4, 4).T # column-major + cv = T.copy() + cv[:3, :3] = T[:3, :3] @ FLIP_YZ @ LANDSCAPE_FROM_PORTRAIT + poses[kid] = cv.tolist() + arkit[kid] = T[:3, 3].tolist() + (out / "images.txt").write_text("\n".join(lines) + "\n") + (out / "intrinsics.json").write_text(json.dumps(intrinsics, indent=2) + "\n") + (out / "poses.json").write_text(json.dumps(poses, indent=2) + "\n") + (out / "arkit_positions.json").write_text(json.dumps(arkit, indent=2) + "\n") + print(f"wrote {len(ids)} upright images, intrinsics.json and poses.json to {out}") + + +def summarize(run_dir: Path, inputs: Path) -> dict: + run = json.loads((run_dir / "run.json").read_text()) + arkit = json.loads((inputs / "arkit_positions.json").read_text()) + rows, centres = [], {} + for entry in run["images"]: + z = np.load(run_dir / f"{entry['stem']}.npz") + depth, valid = z["depth"], z["valid"] + h = depth.shape[0] + ground = valid[int(h * (1 - GROUND_ROWS)) :] + gd = depth[int(h * (1 - GROUND_ROWS)) :][ground] + rows.append( + { + "stem": entry["stem"], + "ground_median_m": round(float(np.median(gd)), 2) if gd.size else None, + "ground_valid_fraction": round(float(ground.mean()), 3), + "all_median_m": entry["depth"].get("median_m"), + "all_p5_p95_m": [entry["depth"].get("p5_m"), entry["depth"].get("p95_m")], + "valid_fraction": entry["depth"]["valid_fraction"], + "seconds": entry["seconds"], + "fx": round(float(z["intrinsics"][0]), 1), + } + ) + if "cam_to_world" in z: + centres[entry["stem"]] = z["cam_to_world"][:3, 3] + out = { + "model": run["model"], + "device": run["device"], + "intrinsics_mode": run["intrinsics_mode"], + "poses_used": run["poses_used"], + "images": rows, + } + if centres: + stems = [r["stem"] for r in rows] + pred = np.array([centres[s] for s in stems]) + ref = np.array([arkit[s] for s in stems]) + consecutive = [ + { + "pair": f"{a}-{b}", + "predicted_m": round(float(np.linalg.norm(pred[j] - pred[i])), 3), + "arkit_m": round(float(np.linalg.norm(ref[j] - ref[i])), 3), + } + for (i, a), (j, b) in pairwise(enumerate(stems)) + ] + span_pred = float(np.linalg.norm(pred[-1] - pred[0])) + span_ref = float(np.linalg.norm(ref[-1] - ref[0])) + out["baseline"] = { + "consecutive": consecutive, + "first_to_last_predicted_m": round(span_pred, 3), + "first_to_last_arkit_m": round(span_ref, 3), + "ratio_predicted_over_arkit": round(span_pred / span_ref, 3), + } + if centres and run["poses_used"]: + given = json.loads((inputs / "poses.json").read_text()) + agreement = [] + for entry in run["images"]: + T = np.asarray(entry["cam_to_world"]) + P = np.asarray(given[entry["stem"]]) + cos = (np.trace(T[:3, :3].T @ P[:3, :3]) - 1) / 2 + agreement.append( + { + "stem": entry["stem"], + "position_error_m": round(float(np.linalg.norm(T[:3, 3] - P[:3, 3])), 4), + "rotation_error_deg": round( + float(np.degrees(np.arccos(np.clip(cos, -1, 1)))), 3 + ), + } + ) + out["output_vs_input_poses"] = agreement + (run_dir / "smoke_summary.json").write_text(json.dumps(out, indent=2) + "\n") + return out + + +def main() -> None: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("prepare") + p.add_argument("--ids", nargs="+", required=True) + p.add_argument("--out", type=Path, default=SMOKE_ROOT / "advio-input") + s = sub.add_parser("summarize") + s.add_argument("runs", nargs="+", type=Path) + s.add_argument("--inputs", type=Path, default=SMOKE_ROOT / "advio-input") + args = ap.parse_args() + if args.cmd == "prepare": + prepare(args.ids, args.out) + else: + for run_dir in args.runs: + print(json.dumps(summarize(run_dir, args.inputs), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/experiments/evals/models/tests/test_common.py b/experiments/evals/models/tests/test_common.py new file mode 100644 index 000000000..bb5f862b0 --- /dev/null +++ b/experiments/evals/models/tests/test_common.py @@ -0,0 +1,119 @@ +"""The input <-> network image geometry has one right answer, so it is tested on synthetic cameras.""" + +from __future__ import annotations + +import json + +import numpy as np +import pytest + +from models.common import ( + cover_crop_resample, + depth_to_input, + identity_resample, + intrinsics_to_input, + intrinsics_to_network, + longest_side_resample, + patch_aligned_size, + read_intrinsics, + stretch_resample, + to_network_image, +) + +K = np.array([1082.1, 1081.1, 640.79, 359.41]) # OpenCV pixels of a 1280x720 image + + +def project(k: np.ndarray, points: np.ndarray) -> np.ndarray: + return np.stack( + [k[0] * points[:, 0] / points[:, 2] + k[2], k[1] * points[:, 1] / points[:, 2] + k[3]], + axis=1, + ) + + +@pytest.mark.parametrize( + "r", + [ + cover_crop_resample(1280, 720, 518, 294), + stretch_resample(1280, 720, 504, 280), + longest_side_resample(1280, 720, 1000), + ], +) +def test_network_intrinsics_project_like_the_resampled_image(r): + rng = np.random.default_rng(0) + points = np.column_stack( + [rng.uniform(-3, 3, 50), rng.uniform(-2, 2, 50), rng.uniform(2, 20, 50)] + ) + uv_in = project(K, points) + uv_net_expected = np.column_stack( + [r.sx * (uv_in[:, 0] + 0.5) - r.x0 - 0.5, r.sy * (uv_in[:, 1] + 0.5) - r.y0 - 0.5] + ) + np.testing.assert_allclose( + project(intrinsics_to_network(K, r), points), uv_net_expected, atol=1e-9 + ) + np.testing.assert_allclose(intrinsics_to_input(intrinsics_to_network(K, r), r), K, atol=1e-9) + + +def test_cover_crop_for_16_9_into_518x294(): + r = cover_crop_resample(1280, 720, 518, 294) + assert (r.resized_w, r.resized_h, r.x0, r.y0) == (523, 294, 2, 0) + image = np.zeros((720, 1280, 3), np.uint8) + assert to_network_image(image, r).shape == (294, 518, 3) + + +def test_patch_aligned_size(): + assert patch_aligned_size(6048, 4032, 518, 14) == (518, 350) + + +def test_constant_depth_survives_resampling_and_crop_is_invalid(): + r = cover_crop_resample(1280, 720, 518, 294) + depth, valid = depth_to_input( + np.full((294, 518), 4.0, np.float32), np.ones((294, 518), bool), r + ) + assert depth.shape == (720, 1280) and depth.dtype == np.float32 + np.testing.assert_allclose(depth[valid], 4.0, rtol=1e-6) + # The crop removed 2 resized columns on the left and 3 on the right. Input column c lies at + # 523/1280 * (c + 0.5) - 2.5 in the network image, inside [-0.5, 517.5] for c = 5 ... 1272. + assert not valid[:, :5].any() + assert valid[:, 5:1273].all() + assert not valid[:, 1273:].any() + assert np.isnan(depth[~valid]).all() + + +def test_depth_is_interpolated_linearly_and_invalid_neighbours_propagate(): + r = stretch_resample(1280, 720, 640, 360) + u = np.arange(640, dtype=np.float32) + ramp = np.broadcast_to(1.0 + 0.01 * u, (360, 640)).astype(np.float32) + valid_net = np.ones((360, 640), bool) + valid_net[100, 300] = False + depth, valid = depth_to_input(ramp, valid_net, r) + # Input column c sits at network column (c + 0.5) / 2 - 0.5. + cols = np.arange(10, 1270) + expected = 1.0 + 0.01 * ((cols + 0.5) / 2 - 0.5) + np.testing.assert_allclose(depth[400, cols], expected, atol=1e-3) + # Every input pixel that interpolates from network pixel (row 100, col 300) is invalid. + assert not valid[199:203, 599:603].any() + assert valid[195, 590] and valid[210, 610] + + +def test_identity_resample_keeps_values(): + r = identity_resample(8, 4) + d = np.arange(32, dtype=np.float32).reshape(4, 8) + 1 + depth, valid = depth_to_input(d, np.ones((4, 8), bool), r) + np.testing.assert_array_equal(depth, d) + assert valid.all() + + +def test_intrinsics_json_must_cover_every_image(tmp_path): + images = [] + for name in ("a.jpg", "b.jpg"): + (tmp_path / name).write_bytes(b"") + images.append(tmp_path / name) + path = tmp_path / "k.json" + path.write_text(json.dumps({"a": [1, 1, 0, 0]})) + with pytest.raises(ValueError, match=r"no intrinsics keyed by path or stem for images \['b'\]"): + read_intrinsics(path, images) + path.write_text(json.dumps({str(images[0]): [1, 1, 0, 0], "b": [2, 2, 0, 0]})) + assert [k[0] for k in read_intrinsics(path, images)] == [1, 2] + path.write_text(json.dumps([[1, 1, 0, 0]])) + with pytest.raises(ValueError, match="1 intrinsics entries for 2 images"): + read_intrinsics(path, images) diff --git a/experiments/evals/models/uv.lock b/experiments/evals/models/uv.lock new file mode 100644 index 000000000..09fe49d89 --- /dev/null +++ b/experiments/evals/models/uv.lock @@ -0,0 +1,1634 @@ +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 = "depth-anything-3" +version = "0.0.0" +requires-dist = ["addict", "einops", "huggingface-hub", "imageio", "numpy", "omegaconf", "opencv-python-headless", "pillow", "safetensors", "torch>=2", "torchvision", "tqdm"] + +[[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 = "absl-py" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/4f/d79676ab82f2e42fc3611618139f13a9c4c31d0cff4b486982047679a802/absl_py-2.5.0.tar.gz", hash = "sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f", size = 118119, upload-time = "2026-07-03T10:57:48.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/0a/a10b45aab35b175aded078a462dc8d0c698f5b13946e7cb0869097b78bb6/absl_py-2.5.0-py3-none-any.whl", hash = "sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba", size = 137410, upload-time = "2026-07-03T10:57:46.735Z" }, +] + +[[package]] +name = "addict" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ef/fd7649da8af11d93979831e8f1f8097e85e82d5bfeabc8c68b39175d8e75/addict-2.4.0.tar.gz", hash = "sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494", size = 9186, upload-time = "2020-11-21T16:21:31.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + +[[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 = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[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 = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[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 = "depth-anything-3" +version = "0.0.0" +source = { git = "https://github.com/ByteDance-Seed/Depth-Anything-3?rev=3d835ec1a5802d64a8b8b15f817a1ab54809bfe4#3d835ec1a5802d64a8b8b15f817a1ab54809bfe4" } +dependencies = [ + { name = "addict" }, + { name = "einops" }, + { name = "huggingface-hub" }, + { name = "imageio" }, + { name = "numpy" }, + { name = "omegaconf" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "safetensors" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "tqdm" }, +] + +[[package]] +name = "einops" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/77/850bef8d72ffb9219f0b1aac23fbc1bf7d038ee6ea666f331fa273031aa2/einops-0.8.2.tar.gz", hash = "sha256:609da665570e5e265e27283aab09e7f279ade90c4f01bcfca111f3d3e13f2827", size = 56261, upload-time = "2026-01-26T04:13:17.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" }, +] + +[[package]] +name = "filelock" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/b8/9ba8f569df649beb7058db5eb392a5f779bdbc3b82cf3942f0be439fb99e/filelock-4.0.3.tar.gz", hash = "sha256:87296d60478e14204fd9406e79831400fef76693bae2895deec236c98e87a8aa", size = 244309, upload-time = "2026-09-23T23:40:22.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6d/db8b86e168fef0cf904737e275bccb683558088ef971e92d3b41ce980699/filelock-4.0.3-py3-none-any.whl", hash = "sha256:30cd166e2aee2c7534ce2c33c6367c4cd051b8368e20e2c4eb0c34f699bacfab", size = 108444, upload-time = "2026-09-23T23:40:20.819Z" }, +] + +[[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 = "grpcio" +version = "1.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/c1/4c9a2e0e6b0aaf02781404cad2f79211f989f2c827cf672a4a48d1604d3e/grpcio-1.84.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:b5c6f20d657ae09ae4e30d9d3a21edd13f1219d58cc6f999b9d1bb63be9c1baa", size = 6415756, upload-time = "2026-09-14T06:57:39.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/131e7007bdee9acb77a8dbe8a16fa9fef75f88c1695242d8ee0993ac2d3d/grpcio-1.84.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:406583b4e8fb2282ebd392e12b963e601c1f82e07125a8c2cb5b144e7e024796", size = 12339195, upload-time = "2026-09-14T06:57:42.373Z" }, + { url = "https://files.pythonhosted.org/packages/db/d1/a7b7cda98fcab9b3d2916204a872d87371158a7a34e41768f524584fb64d/grpcio-1.84.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fbdbcd06986ede3ce584083b1dc2afe6808e8943e5cf50ad11183c03aceda25a", size = 6984468, upload-time = "2026-09-14T06:57:45.035Z" }, + { url = "https://files.pythonhosted.org/packages/19/81/c5be83e3ac9416f73c4c51fe1ea9c41a0c42fc3509e3505faa46f5046abe/grpcio-1.84.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:23e6e8e8a75cff88e0a793bfd3becea03a13e2763ae90c1ff573bc19ca5b429a", size = 7749432, upload-time = "2026-09-14T06:57:47.395Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bf/258cd7c0a7ed92745dc93c31666d462d05b702807a689744bd49fb833bde/grpcio-1.84.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b44f0a0fc7bc6677d38cc80bca1a32814ce6c8f200fb8b3c1a61c9d77eaefbf3", size = 7156115, upload-time = "2026-09-14T06:57:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4b/7f829418dbfcf91b875e55e2973f1059a95decb4f081313416317ef04ec1/grpcio-1.84.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:210e4c32f907045eb8158273e60c6ab69a3947697df6245dbda381f26c59485b", size = 7708010, upload-time = "2026-09-14T06:57:52.496Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/9932e2fec6a04205f8bf3f8f4d2020479dcdac88feb6f93822ed31bf0eba/grpcio-1.84.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a71d24f40b0cc6798feaa978c7411dc1135b7018e9fc0442db611c139bf58344", size = 8759980, upload-time = "2026-09-14T06:57:55.312Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5c/b67407c6dbc480dfc0715f6eccdb1061e7c88d85f9a330a241d357a538c5/grpcio-1.84.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f6c972474ce691aca74e58d17625450cef153dc4760364cadeb167983ea6d589", size = 8124904, upload-time = "2026-09-14T06:57:58.569Z" }, + { url = "https://files.pythonhosted.org/packages/02/37/2bfdae2df8dfcfc0df619b628e0c7153ce703adae827243f44720322ccc1/grpcio-1.84.0-cp312-cp312-win32.whl", hash = "sha256:0d532ade4486dad9b302ffa4d4683d67561051c26d17c4023322845e9fa10140", size = 4478915, upload-time = "2026-09-14T06:58:00.714Z" }, + { url = "https://files.pythonhosted.org/packages/85/2c/309268b7b39f6deb2342f634841e105623a0b67982e8b10ec516782ff1c6/grpcio-1.84.0-cp312-cp312-win_amd64.whl", hash = "sha256:49717e857899f4136d7657bf5aded61ac479110a075438290923a4d86af7cd02", size = 5253534, upload-time = "2026-09-14T06:58:03.336Z" }, +] + +[[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-eval-models" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "depth-anything-3" }, + { name = "huggingface-hub" }, + { name = "mapanything" }, + { name = "moge" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "safetensors" }, + { name = "torch" }, + { name = "torchvision" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "depth-anything-3", git = "https://github.com/ByteDance-Seed/Depth-Anything-3?rev=3d835ec1a5802d64a8b8b15f817a1ab54809bfe4" }, + { name = "huggingface-hub", specifier = ">=0.34" }, + { name = "mapanything", git = "https://github.com/facebookresearch/map-anything?rev=3d10cf7a3016fc0f9bb13a071ee66c47b10be0d9" }, + { 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 = "safetensors", specifier = ">=0.4" }, + { name = "torch", specifier = ">=2.7" }, + { name = "torchvision", specifier = ">=0.22" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[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 = "hydra-core" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "omegaconf" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/a9/fe2c936f36881e3197a8800f5bfba078d7846d54eb5d009b445eda1ee281/hydra_core-1.3.7.tar.gz", hash = "sha256:d4ff629c7e23b64a9db5fe5b027cf7c55b633b737f03be51ef41b77ed016f75e", size = 3278662, upload-time = "2026-09-14T18:02:55.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0c/21890c10cc94da917c8e47efc1c55cd3e56a1d25ab5947bdd35abaf9fdc1/hydra_core-1.3.7-py3-none-any.whl", hash = "sha256:83606c4a05c770f1e026e0489875f28989829d151799eabd8cfa8cbe82164515", size = 168790, upload-time = "2026-09-14T18:02:53.661Z" }, +] + +[[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 = "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 = "jaxtyping" +version = "0.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wadler-lindig" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/c1/091b8852bd7cbf50bd655543c8506033cf4029300c67f8c176c1286879a9/jaxtyping-0.3.11.tar.gz", hash = "sha256:b09c14acf6686feb9e0df5b0d8c6e7c5b6f8d36bf059ee54cd522a186c2ef050", size = 46489, upload-time = "2026-06-13T18:35:23.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/38/c66bbdc5047f4776c2bd3e47e5295a350e3fa44d5b8942105e71c2a876a0/jaxtyping-0.3.11-py3-none-any.whl", hash = "sha256:8a4bedc4e3f963fa82df41bd13c7ebc2bad925601eb48614c65798f21329d4e3", size = 56593, upload-time = "2026-06-13T18:35:22.01Z" }, +] + +[[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 = "joblib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/1d/537ab090f302b838943a1b56497dd53059b9a9b46a074936470173a2e207/joblib-1.6.0.tar.gz", hash = "sha256:2ccc96785b12046c08fd6d55839c12857831b54a3c1673ffadd2f04bfc4eda03", size = 327903, upload-time = "2026-08-31T09:39:04.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl", hash = "sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba", size = 306115, upload-time = "2026-08-31T09:39:02.298Z" }, +] + +[[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 = "lightning-utilities" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" }, +] + +[[package]] +name = "mapanything" +version = "1.1.4" +source = { git = "https://github.com/facebookresearch/map-anything?rev=3d10cf7a3016fc0f9bb13a071ee66c47b10be0d9#3d10cf7a3016fc0f9bb13a071ee66c47b10be0d9" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "hydra-core" }, + { name = "natsort" }, + { name = "opencv-python-headless" }, + { name = "orjson" }, + { name = "pillow-heif" }, + { name = "plyfile" }, + { name = "python-box" }, + { name = "requests" }, + { name = "rerun-sdk" }, + { name = "safetensors" }, + { name = "tensorboard" }, + { name = "tqdm" }, + { name = "trimesh" }, + { name = "uniception" }, +] + +[[package]] +name = "markdown" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/4f/700155c8c20d9e655dd0732b5fc3c7614f291b9148da271d7388e50bf774/markdown-3.11.tar.gz", hash = "sha256:180224db6aed87ba9ce1f2781ebcd5826253de8ff637112090e24b84502bbf9f", size = 485623, upload-time = "2026-09-25T13:46:23.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl", hash = "sha256:cd6c89e7eb308c8b332ed673215a52d208a43f8bacc030b1419376129408719e", size = 111296, upload-time = "2026-09-25T13:46:22.163Z" }, +] + +[[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 = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, +] + +[[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 = "narwhals" +version = "2.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/68/5351e34623d253423240ea7de3f8fc74fa8ab14b1ab3c0ec4ac8997413c9/narwhals-2.26.0.tar.gz", hash = "sha256:6b9cadca82f375c7e4cf584fdc86ca25da54827307a9c58f94547ee6104b82dd", size = 686970, upload-time = "2026-09-08T13:32:08.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b5/1b84b2c784db76d69442334bc8b8748c840f13ca53be086f4f250ad4a0bc/narwhals-2.26.0-py3-none-any.whl", hash = "sha256:29326d74f107c347fd1009bd58e38d9f7c7c5b51e6de97bc93dbc325d9038b54", size = 474034, upload-time = "2026-09-08T13:32:07.159Z" }, +] + +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, +] + +[[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 = "omegaconf" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a", size = 3298472, upload-time = "2026-06-11T05:05:12.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/0e/152509871bf30df6fc38569f52a2db9b55dd41aae957adae50a053ac7778/omegaconf-2.3.1-py3-none-any.whl", hash = "sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0", size = 79502, upload-time = "2026-06-11T05:05:09.954Z" }, +] + +[[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 = "orjson" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, +] + +[[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 = "pillow-heif" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/4c/d5319a1f276c70528ff97893afc42a300ff28029e27ca8de89bb3b271680/pillow_heif-1.8.0.tar.gz", hash = "sha256:e47c27432c6fd3d66c22f0de9f27fd379383b646c947520bc485854ce72060d0", size = 17395353, upload-time = "2026-09-22T10:04:17.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/80/4b57a70a1aa4e24932bfa7e8e56842356ddc13ce32b6fe4729c1050b0d83/pillow_heif-1.8.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:e9bf19f4163f50e52e374ac04d9084756322ed7ef2ac756c2f3ca08cc43e5c1b", size = 4781341, upload-time = "2026-09-22T10:02:45.173Z" }, + { url = "https://files.pythonhosted.org/packages/91/5c/4e37a10f2ac2a1806b879f13c81f1fdc33ba5742d5b48e1b3808e9cdf21a/pillow_heif-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbd020b4d8a6730f46fb0815efaca3eebbea36ddd030fb507aae081713179830", size = 4303432, upload-time = "2026-09-22T10:02:47.044Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ef/f8b2dbbd515301573c3020fea4571c73f47651d8dbbe4cf4b7407dc4f2e2/pillow_heif-1.8.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6610ea28ee151458dde715346cbed90ef74feb6d7c354b318b4c4a560fdb1c", size = 6394460, upload-time = "2026-09-22T10:02:48.931Z" }, + { url = "https://files.pythonhosted.org/packages/84/ab/c99d29c5311df50074d0fee3ee80bae004b200b4e89b149a275ce4d504cf/pillow_heif-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9088a295f3c64f88211c4b1d845d883cf535e8919a72a17f7860abe8c991bf2", size = 5652099, upload-time = "2026-09-22T10:02:50.705Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c0/c4bf60e58f9bda582e2ce6365de350901a01ed67c22f450eb61b1080d066/pillow_heif-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6116981fdccce5f43fabfd8fac884c0fff9fde9601d00887a70af1220b1a91b1", size = 7427104, upload-time = "2026-09-22T10:02:52.526Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/f6cb52ff29889ba743b5cf0fc65de68f191dbc3c2837ec755e310d43e9dd/pillow_heif-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8cd4043b07966fc464fb95df41f584d3e804c26115e118165fc57c8fc890cdd", size = 6686998, upload-time = "2026-09-22T10:02:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5e/97c0e837b787316ae8020e6f17134aa24e22b16027440406d02f9be7f99f/pillow_heif-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:921784d39b5e3b9bcbfcf96ff05e78bc5a15ced5d171cf4e3987d06c04da71bf", size = 6604433, upload-time = "2026-09-22T10:02:55.855Z" }, + { url = "https://files.pythonhosted.org/packages/f6/54/7b01ef1b72046d7ba2246036fe954f86cb1011bec2b77e26c9bcc9722e4a/pillow_heif-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:80b8dbf31c92aaffc25b2a1a9e86722fa6d6085f3db0e6003ad5bc0fb11905e7", size = 3868206, upload-time = "2026-09-22T10:02:57.421Z" }, +] + +[[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 = "plyfile" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/6b/986844e4b160718f0175a0f6650f9e0039e0bfbf4ad250b0903616af34d5/plyfile-1.1.5.tar.gz", hash = "sha256:c62b505062076d72ba67845066579301553baaa93c7d23d9ac1f43495158339c", size = 36237, upload-time = "2026-07-28T08:23:13.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/69/3452dd12683d722bc33758c8875fb8a812df21f3ee339293aebf97bc2340/plyfile-1.1.5-py3-none-any.whl", hash = "sha256:675c0fc22b949c49398f5ed6725a1d781f6a9f7625b42232c02198f6509c4ae3", size = 36463, upload-time = "2026-07-28T08:23:12.14Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, +] + +[[package]] +name = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[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 = "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 = "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 = "python-box" +version = "7.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/0f/34e7ee0a72f1464b4c7a2e8bafb389f230477256af586bc82bcfad85295a/python_box-7.4.1.tar.gz", hash = "sha256:e412e36c25fca8223560516d53ef6c7993591c3b0ec8bb4ec582bf7defdd79f0", size = 49859, upload-time = "2026-02-21T16:21:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/d9/d05f317b38b42253422d8483f5d7dc16d382c99ddc253e426639a0f2f235/python_box-7.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dfb91effff00d9e23486c4f0db3b19e03d602ebb7c9e20fc6a287c704fad2552", size = 1849441, upload-time = "2026-02-21T16:21:37.314Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a3/383eb3d658f36c6e531c8cf1e348ccb4b5031231df4aeb7742bb159a3166/python_box-7.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f977f00e715b030cee6ffef2322ff8ce100ffbf1dbcc4ef91099c75752d5f8", size = 4485153, upload-time = "2026-02-21T16:26:04.507Z" }, + { url = "https://files.pythonhosted.org/packages/65/f9/5de3c18415dd6f5286f00e6539c0ae3cceb1c6aaf28d1d5f17b0b568c97f/python_box-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:2ca9a18fd15326bc267e9cc7e0e6e3a0cb78d11507940f43f687adf7e156d882", size = 1295520, upload-time = "2026-02-21T16:22:26.192Z" }, + { url = "https://files.pythonhosted.org/packages/06/a6/5d3f3abf46b37aa44b1f6788d287c8b4f2319b55013191dddf25b9e6d62c/python_box-7.4.1-py3-none-any.whl", hash = "sha256:a3b0d84d003882fb6abe505b1b883b3a5dcbf226b0fe168d24bc5ff75d9826e5", size = 30402, upload-time = "2026-02-21T16:21:14.78Z" }, +] + +[[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 = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rerun-sdk" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pyarrow" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/48/6fa2fb45e816cf6cf1c60049d9475e87eb45bde87bb7d2347891c775ac8a/rerun_sdk-0.24.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6569df783ea24531e7a6960dfac142805b5b2230ef0b05bafa35981d653ded89", size = 84095723, upload-time = "2025-08-07T15:10:33.62Z" }, + { url = "https://files.pythonhosted.org/packages/76/d7/830b5f520956c1041a059f29eb26d7a0fe3b5c8aac85ad09ea5b0d06434e/rerun_sdk-0.24.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:542a0e245a15311cca5083273900248c95341b64e3051148f638efee03d64e96", size = 78384367, upload-time = "2025-08-07T15:10:40.044Z" }, + { url = "https://files.pythonhosted.org/packages/f2/37/c47e432bbd7c452d715f8e1b71363d9bda50c5d659dd213aa5dcb04d6b7b/rerun_sdk-0.24.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3dcae9bcb5d1f94f96f975179c5152f82feebff187924825e1b9c450bd5a0008", size = 86345646, upload-time = "2025-08-07T15:10:44.922Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2a/1bfdf86646bac8290a18f2997ec1afb316841f181f39e46686d0eb6f0012/rerun_sdk-0.24.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bff6772bb0219798dfb40fd95858db2942ad5c68ec278a6ac73cacab6436cea4", size = 90738031, upload-time = "2025-08-07T15:10:50.934Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5e/b3113b5aee90e4a3b6daa2a216b3866ca651432061b4f2ebd17279bbe7d1/rerun_sdk-0.24.1-cp39-abi3-win_amd64.whl", hash = "sha256:3f6a0229eab4367333bf93106fbd8ec0d0e62ae4fe5cfdb368a69dc9665b7d4f", size = 72890802, upload-time = "2025-08-07T15:10:55.797Z" }, +] + +[[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 = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/eb/eaf5e07fcc0da7149b0e084f24e54edd7441b9a89ce7e034032ae97fe3a0/scikit_learn-1.9.1.tar.gz", hash = "sha256:629cada3e33e2b9bf376cdc7614a47a4140b8aedc1d836579e359736fbd82977", size = 7786908, upload-time = "2026-09-10T18:34:04.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/a7/25f0a43d2fde306e8ef45f45121192f687b79beaf4bae8c21607c46c5e63/scikit_learn-1.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c0f8b5d09b44101cea2767f300680bada1ea27f976fe4b48b83950a4f55a49a", size = 8775209, upload-time = "2026-09-10T18:32:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/60/ea/57e57539ce175d774fc291ed091b0a6d756854b92cd92554c6bb4d0ae498/scikit_learn-1.9.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:8c14ce41d561f7749f990b41d6703fe02c4669fbc485e598e069e0a1967b488e", size = 8295541, upload-time = "2026-09-10T18:32:45.069Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/5721a174406bfba49bce20ae997b3b64cf355c3f623a2638284ab6a82156/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4c20a6c017d820faa7ac8c783e3d0c6a9a2e297bf9f55332ca17cdf7fd4d04d", size = 8871592, upload-time = "2026-09-10T18:32:46.999Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/a50162f3d29feb979ab6347c6debda506dfb525bcff3c50dd17606651c7e/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5d7b18a5b9dca241a74695f3275fa4c895a9dadc72b3d8df5fa9d1083c9b83e", size = 9166043, upload-time = "2026-09-10T18:32:49.343Z" }, + { url = "https://files.pythonhosted.org/packages/72/8d/27c054166bac671770d1ea0ef7716134fe8119c0df224a58c89fd735a61c/scikit_learn-1.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4b59abb30618121cc46b45972d6bf53a7128b4df4cd346c6ca6f4d5f9031e49c", size = 8262238, upload-time = "2026-09-10T18:32:51.679Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/493086006ea0c68ad62c40a8dece1961b61bf503f45400f133d47f56e5be/scikit_learn-1.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:d5945a2908be62350e2978344e62b56c1552c2ca4f844ebf6277c94944d647dd", size = 7902102, upload-time = "2026-09-10T18:32:53.67Z" }, +] + +[[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 = "tensorboard" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "grpcio" }, + { name = "markdown" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "setuptools" }, + { name = "tensorboard-data-server" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/4b/cd2eec9642781a8f5b2fb9994e3933a7b259ab18e9d49aeede9b5acf6311/tensorboard-2.21.0-py3-none-any.whl", hash = "sha256:7279316dcb6bd5bc391d623dea841531299cde1887310e8133bc34a996d32255", size = 5516204, upload-time = "2026-06-29T20:48:04.472Z" }, +] + +[[package]] +name = "tensorboard-data-server" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/dc/6c58154c1c65f758ea979e7139cb76993a9cfc662d14e9be3c4a667cfb77/threadpoolctl-3.7.0.tar.gz", hash = "sha256:61348cfb77d53b9242e0017029244b559b810c142ced65b4e21eeca1843959a7", size = 31961, upload-time = "2026-09-15T15:46:20.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl", hash = "sha256:cd8b60b5641b45c67bbf73c64c843235fc2d8a480c87389f52f5dbee893b86be", size = 26362, upload-time = "2026-09-15T15:46:19.168Z" }, +] + +[[package]] +name = "timm" +version = "1.0.30" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "pyyaml" }, + { name = "safetensors" }, + { name = "torch" }, + { name = "torchvision" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/ab/49041213990e48edcb6013463b18ef86d8d3c5d6ac5b408802c480443025/timm-1.0.30.tar.gz", hash = "sha256:1444a3af7e17fdba5440ac2bcf6e4b728da96dd62289ed9e1bcba31a545f5935", size = 2534738, upload-time = "2026-09-22T20:46:13.9Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/85/b897716d0fa016af23b11e6526f5ef76d2013e8bc36b9c7ce15d9200e456/timm-1.0.30-py3-none-any.whl", hash = "sha256:c8e27adf6801a2fdfb570dbbdd171f2f084756eefc673df8cfaab06bd14864f3", size = 2668917, upload-time = "2026-09-22T20:46:12.11Z" }, +] + +[[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 = "torchaudio" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/b1/77658817acacd01a72b714440c62f419efc4d90170e704e8e7a2c0918988/torchaudio-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a1cf1acc883bee9cb906a933572fed6a8a933f86ef34e9ea7d803f72317e8c1b", size = 684226, upload-time = "2026-03-23T18:13:40.023Z" }, + { url = "https://files.pythonhosted.org/packages/78/28/c7adc053039f286c2aca0038b766cbe3294e66fec6b29a820e95128f9ede/torchaudio-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:bc653defca1c16154398517a1adc98d0fb7f1dd08e58ced217558d213c2c6e29", size = 1626670, upload-time = "2026-03-23T18:13:42.162Z" }, + { url = "https://files.pythonhosted.org/packages/88/d8/d6d0f896e064aa67377484efef4911cdcc07bce2929474e1417cc0af18c2/torchaudio-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6503c0bdb29daf2e6281bb70ea2dfe2c3553b782b619eb5d73bdadd8a3f7cecf", size = 1771992, upload-time = "2026-03-23T18:13:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/941277ecc39f7a0a169d554302a1f1afd87c1d94a8aec828891916cea59a/torchaudio-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:478110f981e5d40a8d82221732c57a56c85a1d5895fb8fe646e86ee15eded3bd", size = 328663, upload-time = "2026-03-23T18:13:19.218Z" }, +] + +[[package]] +name = "torchmetrics" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightning-utilities" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/a2/c7f6ebf546f8f644edf0f999aa98ece106986a77a7b922316bf6414ff825/torchmetrics-1.9.0-py3-none-any.whl", hash = "sha256:bfdcbff3dd1d96b3374bb2496eb39f23c4b28b8a845b6a18c313688e0d2d9ca1", size = 983384, upload-time = "2026-03-09T17:41:19.756Z" }, +] + +[[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 = "trimesh" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3f/a13d797349fdd00a8586b60d6b71df849e1766e2002729697a265b27a773/trimesh-5.1.0.tar.gz", hash = "sha256:927aa8748af8b84cb969c8f53270d929e546f2beada77a2bc234b605fddf1454", size = 870501, upload-time = "2026-08-31T18:28:55.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/c8/5ede3b786ef58e0076e9ca748b2c1787183f85e5ad8133373715c31248f9/trimesh-5.1.0-py3-none-any.whl", hash = "sha256:0fa85d1d131e321b683c00747c20090200b92071298b905ea588f609eb204c89", size = 750004, upload-time = "2026-08-31T18:28:53.746Z" }, +] + +[[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 = "uniception" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "einops" }, + { name = "jaxtyping" }, + { name = "matplotlib" }, + { name = "minio" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "rerun-sdk" }, + { name = "scikit-learn" }, + { name = "termcolor" }, + { name = "timm" }, + { name = "torch" }, + { name = "torchaudio" }, + { name = "torchmetrics" }, + { name = "torchvision" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/e0/7415baa9f08b32e64151293017c65e3cff13becc619c9a11190b4474425d/uniception-0.1.7.tar.gz", hash = "sha256:730413ef4901b820f52651323156df6288ebe653615798bb6ff1da589b9cb0aa", size = 154483, upload-time = "2026-03-22T23:35:42.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/05/d5794951c6ecff3ece5e67ebe49917935dccfc3d7fd709afb481dc354dbe/uniception-0.1.7-py3-none-any.whl", hash = "sha256:947bf25e33c64f50f72d815f8f71d85b531e6fa2259369b1d7558c1693029e61", size = 211708, upload-time = "2026-03-22T23:35:40.922Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[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" }, +] + +[[package]] +name = "wadler-lindig" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/67/cbae4bf7683a64755c2c1778c418fea96d00e34395bb91743f08bd951571/wadler_lindig-0.1.7.tar.gz", hash = "sha256:81d14d3fe77d441acf3ebd7f4aefac20c74128bf460e84b512806dccf7b2cd55", size = 15842, upload-time = "2025-06-18T07:00:42.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/96/04e7b441807b26b794da5b11e59ed7f83b2cf8af202bd7eba8ad2fa6046e/wadler_lindig-0.1.7-py3-none-any.whl", hash = "sha256:e3ec83835570fd0a9509f969162aeb9c65618f998b1f42918cfc8d45122fe953", size = 20516, upload-time = "2025-06-18T07:00:41.684Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] diff --git a/experiments/evals/pyproject.toml b/experiments/evals/pyproject.toml new file mode 100644 index 000000000..99bf400e0 --- /dev/null +++ b/experiments/evals/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "house-scanning-evals" +version = "0.1.0" +description = "Real-data accuracy evals: ADVIO tracking drift and ETH3D wall reconstruction" +requires-python = ">=3.12" +dependencies = [ + "gdown==5.2.0", + "numpy>=2.1", + "opencv-python-headless>=4.10", + "py7zr>=1.1.3", + "pyyaml>=6.0", + "scipy>=1.18.1", +] + +[dependency-groups] +dev = [ + "pytest>=8.3", + "ruff>=0.8", +] + +[tool.uv] +# Modules run in place; nothing to build or install. +package = false +# setup-uv reads this in CI, so local and CI runs use the same uv minor version as uv.lock. +required-version = ">=0.11.15,<0.12" + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"] +# The formatter wraps code; long prose strings and docstrings stay on one line. +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests", "models/tests"] +pythonpath = ["."] +addopts = ["--strict-markers", "--strict-config"] diff --git a/experiments/evals/results/advio_drift.json b/experiments/evals/results/advio_drift.json new file mode 100644 index 000000000..2e3d0f24c --- /dev/null +++ b/experiments/evals/results/advio_drift.json @@ -0,0 +1,586 @@ +{ + "sequences": [ + { + "sequence": 20, + "seconds": 298.3, + "scale_check": { + "gps_over_truth": 0.8346517013544015, + "gps_interval_95": [ + 0.8126005226581617, + 0.8558532337420153 + ], + "gps_fixes": 298, + "gps_median_accuracy_m": 5.0, + "arcore_over_truth": 0.8016193221760798, + "arkit_over_truth": 0.6974453399490128 + }, + "walked_m_truth_gps": 391.9, + "arkit_scale_vs_truth_gps": 0.8377, + "distance_error": { + "3": { + "truth": { + "n": 583, + "median_in": 13.25, + "p90_in": 19.44, + "signed_median_in": -12.55 + }, + "truth_gps": { + "n": 583, + "median_in": 6.51, + "p90_in": 13.81, + "signed_median_in": -5.42 + }, + "arcore": { + "n": 583, + "median_in": 4.46, + "p90_in": 10.15, + "signed_median_in": -4.39 + } + }, + "10": { + "truth": { + "n": 580, + "median_in": 43.8, + "p90_in": 61.02, + "signed_median_in": -42.98 + }, + "truth_gps": { + "n": 580, + "median_in": 21.3, + "p90_in": 42.69, + "signed_median_in": -19.22 + }, + "arcore": { + "n": 580, + "median_in": 14.69, + "p90_in": 27.22, + "signed_median_in": -14.67 + } + }, + "20": { + "truth": { + "n": 570, + "median_in": 86.23, + "p90_in": 115.28, + "signed_median_in": -86.23 + }, + "truth_gps": { + "n": 570, + "median_in": 42.25, + "p90_in": 79.54, + "signed_median_in": -38.74 + }, + "arcore": { + "n": 570, + "median_in": 28.0, + "p90_in": 46.99, + "signed_median_in": -28.0 + } + }, + "30": { + "truth": { + "n": 565, + "median_in": 130.85, + "p90_in": 168.9, + "signed_median_in": -130.85 + }, + "truth_gps": { + "n": 565, + "median_in": 61.99, + "p90_in": 103.01, + "signed_median_in": -59.83 + }, + "arcore": { + "n": 565, + "median_in": 42.23, + "p90_in": 69.91, + "signed_median_in": -42.23 + } + } + }, + "position_error_truth_gps": { + "3": { + "n": 583, + "median_in": 8.53, + "p90_in": 19.43, + "signed_median_in": 8.53 + }, + "10": { + "n": 580, + "median_in": 26.57, + "p90_in": 52.64, + "signed_median_in": 26.57 + }, + "20": { + "n": 570, + "median_in": 50.29, + "p90_in": 89.6, + "signed_median_in": 50.29 + }, + "30": { + "n": 565, + "median_in": 74.72, + "p90_in": 123.53, + "signed_median_in": 74.72 + } + }, + "noise_split_in": { + "3": { + "arkit": 1.32, + "truth_gps": 10.48, + "arcore": 4.6 + }, + "10": { + "arkit": 2.35, + "truth_gps": 26.64, + "arcore": 10.1 + }, + "20": { + "arkit": 6.58, + "truth_gps": 40.0, + "arcore": 14.97 + }, + "30": { + "arkit": 13.0, + "truth_gps": 51.11, + "arcore": 17.26 + } + } + }, + { + "sequence": 21, + "seconds": 310.7, + "scale_check": { + "gps_over_truth": 0.7991972570910706, + "gps_interval_95": [ + 0.7767220904475162, + 0.8192865864047311 + ], + "gps_fixes": 314, + "gps_median_accuracy_m": 5.0, + "arcore_over_truth": 0.8163668151830826, + "arkit_over_truth": 0.753844727096209 + }, + "walked_m_truth_gps": 379.1, + "arkit_scale_vs_truth_gps": 0.9425, + "distance_error": { + "3": { + "truth": { + "n": 613, + "median_in": 10.99, + "p90_in": 15.89, + "signed_median_in": -10.9 + }, + "truth_gps": { + "n": 613, + "median_in": 2.92, + "p90_in": 10.01, + "signed_median_in": -1.88 + }, + "arcore": { + "n": 613, + "median_in": 2.34, + "p90_in": 5.82, + "signed_median_in": -2.09 + } + }, + "10": { + "truth": { + "n": 609, + "median_in": 37.02, + "p90_in": 50.7, + "signed_median_in": -36.79 + }, + "truth_gps": { + "n": 609, + "median_in": 9.57, + "p90_in": 30.35, + "signed_median_in": -6.66 + }, + "arcore": { + "n": 609, + "median_in": 7.88, + "p90_in": 16.49, + "signed_median_in": -7.29 + } + }, + "20": { + "truth": { + "n": 603, + "median_in": 73.83, + "p90_in": 102.25, + "signed_median_in": -73.83 + }, + "truth_gps": { + "n": 603, + "median_in": 18.97, + "p90_in": 59.42, + "signed_median_in": -13.57 + }, + "arcore": { + "n": 603, + "median_in": 14.97, + "p90_in": 33.47, + "signed_median_in": -14.97 + } + }, + "30": { + "truth": { + "n": 598, + "median_in": 110.41, + "p90_in": 154.01, + "signed_median_in": -110.41 + }, + "truth_gps": { + "n": 598, + "median_in": 27.07, + "p90_in": 76.87, + "signed_median_in": -22.37 + }, + "arcore": { + "n": 598, + "median_in": 23.1, + "p90_in": 46.14, + "signed_median_in": -23.1 + } + } + }, + "position_error_truth_gps": { + "3": { + "n": 613, + "median_in": 5.86, + "p90_in": 16.23, + "signed_median_in": 5.86 + }, + "10": { + "n": 609, + "median_in": 18.85, + "p90_in": 49.65, + "signed_median_in": 18.85 + }, + "20": { + "n": 603, + "median_in": 35.4, + "p90_in": 85.52, + "signed_median_in": 35.4 + }, + "30": { + "n": 598, + "median_in": 51.53, + "p90_in": 113.04, + "signed_median_in": 51.53 + } + }, + "noise_split_in": { + "3": { + "arkit": 4.0, + "truth_gps": 6.28, + "arcore": 1.95 + }, + "10": { + "arkit": 11.92, + "truth_gps": 16.76, + "arcore": 4.49 + }, + "20": { + "arkit": 19.3, + "truth_gps": 27.14, + "arcore": 8.26 + }, + "30": { + "arkit": 23.56, + "truth_gps": 35.29, + "arcore": 12.27 + } + } + }, + { + "sequence": 22, + "seconds": 371.4, + "scale_check": { + "gps_over_truth": 1.028210962292562, + "gps_interval_95": [ + 0.9668797280040685, + 1.0596120388555121 + ], + "gps_fixes": 276, + "gps_median_accuracy_m": 10.0, + "arcore_over_truth": 1.024916983469803, + "arkit_over_truth": 0.9752490457520531 + }, + "walked_m_truth_gps": 509.9, + "arkit_scale_vs_truth_gps": 0.9509, + "distance_error": { + "3": { + "truth": { + "n": 740, + "median_in": 4.78, + "p90_in": 15.08, + "signed_median_in": -0.23 + }, + "truth_gps": { + "n": 740, + "median_in": 5.03, + "p90_in": 14.67, + "signed_median_in": -1.21 + }, + "arcore": { + "n": 740, + "median_in": 2.07, + "p90_in": 7.09, + "signed_median_in": -1.54 + } + }, + "10": { + "truth": { + "n": 734, + "median_in": 15.31, + "p90_in": 42.41, + "signed_median_in": -1.01 + }, + "truth_gps": { + "n": 734, + "median_in": 16.09, + "p90_in": 40.16, + "signed_median_in": -4.29 + }, + "arcore": { + "n": 734, + "median_in": 5.83, + "p90_in": 17.87, + "signed_median_in": -5.09 + } + }, + "20": { + "truth": { + "n": 730, + "median_in": 29.7, + "p90_in": 66.74, + "signed_median_in": -5.97 + }, + "truth_gps": { + "n": 730, + "median_in": 33.05, + "p90_in": 61.27, + "signed_median_in": -12.55 + }, + "arcore": { + "n": 730, + "median_in": 11.4, + "p90_in": 29.85, + "signed_median_in": -9.82 + } + }, + "30": { + "truth": { + "n": 726, + "median_in": 42.17, + "p90_in": 89.24, + "signed_median_in": -8.35 + }, + "truth_gps": { + "n": 726, + "median_in": 46.68, + "p90_in": 87.2, + "signed_median_in": -18.1 + }, + "arcore": { + "n": 726, + "median_in": 17.3, + "p90_in": 41.09, + "signed_median_in": -15.3 + } + } + }, + "position_error_truth_gps": { + "3": { + "n": 740, + "median_in": 10.14, + "p90_in": 20.96, + "signed_median_in": 10.14 + }, + "10": { + "n": 734, + "median_in": 33.11, + "p90_in": 62.11, + "signed_median_in": 33.11 + }, + "20": { + "n": 730, + "median_in": 64.01, + "p90_in": 108.93, + "signed_median_in": 64.01 + }, + "30": { + "n": 726, + "median_in": 94.55, + "p90_in": 157.01, + "signed_median_in": 94.55 + } + }, + "noise_split_in": { + "3": { + "arkit": 3.3, + "truth_gps": 8.08, + "arcore": 16.28 + }, + "10": { + "arkit": 3.01, + "truth_gps": 26.12, + "arcore": 25.32 + }, + "20": { + "arkit": -10.51, + "truth_gps": 50.42, + "arcore": 36.13 + }, + "30": { + "arkit": -17.17, + "truth_gps": 73.08, + "arcore": 49.37 + } + } + }, + { + "sequence": 23, + "seconds": 201.5, + "arkit_tracking_failed_at_s": 5.55, + "arkit_max_speed_m_s": 826.5 + } + ], + "pooled": { + "sequences": [ + 20, + 21, + 22 + ], + "3": { + "truth": { + "n": 1936, + "median_in": 9.72, + "p90_in": 17.79, + "signed_median_in": -7.92 + }, + "truth_gps": { + "n": 1936, + "median_in": 4.72, + "p90_in": 13.06, + "signed_median_in": -2.48 + }, + "arcore": { + "n": 1936, + "median_in": 2.75, + "p90_in": 8.17, + "signed_median_in": -2.44 + }, + "position_truth_gps": { + "n": 1936, + "median_in": 8.3, + "p90_in": 18.63, + "signed_median_in": 8.3 + }, + "position_beyond_scale": { + "n": 1936, + "median_in": 7.72, + "p90_in": 20.35, + "signed_median_in": 7.72 + } + }, + "10": { + "truth": { + "n": 1923, + "median_in": 31.65, + "p90_in": 55.78, + "signed_median_in": -27.57 + }, + "truth_gps": { + "n": 1923, + "median_in": 15.42, + "p90_in": 40.52, + "signed_median_in": -9.12 + }, + "arcore": { + "n": 1923, + "median_in": 8.56, + "p90_in": 22.3, + "signed_median_in": -8.05 + }, + "position_truth_gps": { + "n": 1923, + "median_in": 26.3, + "p90_in": 55.7, + "signed_median_in": 26.3 + }, + "position_beyond_scale": { + "n": 1923, + "median_in": 24.47, + "p90_in": 59.65, + "signed_median_in": 24.47 + } + }, + "20": { + "truth": { + "n": 1903, + "median_in": 60.39, + "p90_in": 105.97, + "signed_median_in": -55.48 + }, + "truth_gps": { + "n": 1903, + "median_in": 30.87, + "p90_in": 70.1, + "signed_median_in": -18.94 + }, + "arcore": { + "n": 1903, + "median_in": 16.86, + "p90_in": 41.55, + "signed_median_in": -15.95 + }, + "position_truth_gps": { + "n": 1903, + "median_in": 50.37, + "p90_in": 94.61, + "signed_median_in": 50.37 + }, + "position_beyond_scale": { + "n": 1903, + "median_in": 47.69, + "p90_in": 102.46, + "signed_median_in": 47.69 + } + }, + "30": { + "truth": { + "n": 1889, + "median_in": 88.2, + "p90_in": 156.83, + "signed_median_in": -85.71 + }, + "truth_gps": { + "n": 1889, + "median_in": 44.55, + "p90_in": 92.9, + "signed_median_in": -27.86 + }, + "arcore": { + "n": 1889, + "median_in": 25.72, + "p90_in": 60.15, + "signed_median_in": -24.84 + }, + "position_truth_gps": { + "n": 1889, + "median_in": 73.8, + "p90_in": 132.58, + "signed_median_in": 73.8 + }, + "position_beyond_scale": { + "n": 1889, + "median_in": 70.37, + "p90_in": 144.56, + "signed_median_in": 70.37 + } + } + } +} \ No newline at end of file diff --git a/experiments/evals/results/advio_drift.md b/experiments/evals/results/advio_drift.md new file mode 100644 index 000000000..5d687e708 --- /dev/null +++ b/experiments/evals/results/advio_drift.md @@ -0,0 +1,53 @@ +# ADVIO outdoor drift (generated by `uv run python -m evals.drift`) + +## Reference scale check + +| Seq | GPS / truth (95% interval) | ARCore / truth | ARKit / truth | ARKit tracking | +| --- | --- | --- | --- | --- | +| 20 | 0.835 (0.813 to 0.856) | 0.802 | 0.697 | ok, 298 s | +| 21 | 0.799 (0.777 to 0.819) | 0.816 | 0.754 | ok, 311 s | +| 22 | 1.028 (0.967 to 1.060) | 1.025 | 0.975 | ok, 371 s | +| 23 | | | | failed at 5.55 s (jumped at 826.5 m/s) | + +## Distance error, pooled over the sequences where ARKit kept tracking + +Sequences 20, 21, 22. |ARKit - reference| in inches. + +| Walked | vs truth as published: median / p90 | vs truth rescaled to GPS | ARKit - ARCore disagreement | signed median, ARKit - ARCore | +| --- | --- | --- | --- | --- | +| 3 ft | 9.7 / 17.8 | 4.7 / 13.1 | 2.8 / 8.2 | -2.4 | +| 10 ft | 31.6 / 55.8 | 15.4 / 40.5 | 8.6 / 22.3 | -8.1 | +| 20 ft | 60.4 / 106.0 | 30.9 / 70.1 | 16.9 / 41.5 | -15.9 | +| 30 ft | 88.2 / 156.8 | 44.5 / 92.9 | 25.7 / 60.1 | -24.8 | + +## Position error against the truth rescaled to GPS, pooled + +|ARKit - truth| of the whole displacement, inches, median / p90: ARKit's displacement is turned by the heading difference at the window's start, so sideways drift counts too. 'Scale removed' first divides each walk's own ARKit scale out (its median displacement ratio over windows of 10 ft or more), leaving heading and random drift; the difference between the columns is the scale's share. The last column is the server's allowance of 0.16 ft of error per foot. The truth's own random error is large at every distance (noise split below), so these overstate ARKit's. + +| Walked | As tracked | Scale removed | 0.16 ft/ft allowance | +| --- | --- | --- | --- | +| 3 ft | 8.3 / 18.6 | 7.7 / 20.4 | 5.8 | +| 10 ft | 26.3 / 55.7 | 24.5 / 59.6 | 19.2 | +| 20 ft | 50.4 / 94.6 | 47.7 / 102.5 | 38.4 | +| 30 ft | 73.8 / 132.6 | 70.4 / 144.6 | 57.6 | + +ARKit scale against the GPS-rescaled truth, per walk: 20: 0.838, 21: 0.943, 22: 0.951. + +## Per sequence + +| Seq | Walked | vs truth rescaled to GPS: median / p90 (signed) | ARKit - ARCore disagreement: median / p90 (signed) | split if errors were independent (1 sigma, in): ARKit / truth / ARCore | +| --- | --- | --- | --- | --- | +| 20 | 3 ft | 6.5 / 13.8 (-5.4) | 4.5 / 10.2 (-4.4) | 1.3 / 10.5 / 4.6 | +| 20 | 10 ft | 21.3 / 42.7 (-19.2) | 14.7 / 27.2 (-14.7) | 2.4 / 26.6 / 10.1 | +| 20 | 20 ft | 42.2 / 79.5 (-38.7) | 28.0 / 47.0 (-28.0) | 6.6 / 40.0 / 15.0 | +| 20 | 30 ft | 62.0 / 103.0 (-59.8) | 42.2 / 69.9 (-42.2) | 13.0 / 51.1 / 17.3 | +| 21 | 3 ft | 2.9 / 10.0 (-1.9) | 2.3 / 5.8 (-2.1) | 4.0 / 6.3 / 1.9 | +| 21 | 10 ft | 9.6 / 30.4 (-6.7) | 7.9 / 16.5 (-7.3) | 11.9 / 16.8 / 4.5 | +| 21 | 20 ft | 19.0 / 59.4 (-13.6) | 15.0 / 33.5 (-15.0) | 19.3 / 27.1 / 8.3 | +| 21 | 30 ft | 27.1 / 76.9 (-22.4) | 23.1 / 46.1 (-23.1) | 23.6 / 35.3 / 12.3 | +| 22 | 3 ft | 5.0 / 14.7 (-1.2) | 2.1 / 7.1 (-1.5) | 3.3 / 8.1 / 16.3 | +| 22 | 10 ft | 16.1 / 40.2 (-4.3) | 5.8 / 17.9 (-5.1) | 3.0 / 26.1 / 25.3 | +| 22 | 20 ft | 33.0 / 61.3 (-12.6) | 11.4 / 29.9 (-9.8) | -10.5 / 50.4 / 36.1 | +| 22 | 30 ft | 46.7 / 87.2 (-18.1) | 17.3 / 41.1 (-15.3) | -17.2 / 73.1 / 49.4 | + +The split assumes the three trackers' errors are independent; a negative value shows that assumption failing for the row, so treat every split value as conditional. diff --git a/experiments/evals/results/coverage.json b/experiments/evals/results/coverage.json new file mode 100644 index 000000000..fc32688d8 --- /dev/null +++ b/experiments/evals/results/coverage.json @@ -0,0 +1,75 @@ +[ + { + "scene": "electro", + "photos": 45, + "photos_with_a_sighting": 17, + "wall_length_ft": 19.311015888810974, + "wall_fit_rms_cm": 1.8475893162210864, + "pilaster_ft": 3.805774278215223, + "ground_fit_rms_cm": 0.49495511252058655, + "meter_levelled_m": [ + 2.213, + -3.426, + -6.126 + ], + "outward": [ + 0.485, + -0.0, + 0.8745 + ], + "wall": "picked", + "sightings": 337, + "replica_mismatches": 0, + "bands": { + "wall": { + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "false_observed_share": 0.05782312925170068, + "false_observed_by_cause_ft": { + "occlusion": 1.1154855643044619, + "frame edge": 0.0, + "range": 0.0 + }, + "false_two_view_ft": 1.3123359580052494, + "false_two_view_share": 0.06802721088435375, + "seen_two_view_ft": 17.979002624671917, + "missed_ft": 0.0, + "missed_by_cause_ft": { + "frame edge": 0.0, + "grazing angle": 0.0, + "range": 0.0, + "one position": 0.0 + }, + "claimed_samples_seen_only_where_scan_is_empty": 0.0, + "passes": false + }, + "ground": { + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "false_observed_share": 0.0, + "false_observed_by_cause_ft": { + "occlusion": 0.0, + "frame edge": 0.0, + "range": 0.0 + }, + "false_two_view_ft": 0.0, + "false_two_view_share": 0.0, + "seen_two_view_ft": 18.11023622047244, + "missed_ft": 18.11023622047244, + "missed_by_cause_ft": { + "frame edge": 0.0, + "grazing angle": 18.11023622047244, + "range": 0.0, + "one position": 0.0 + }, + "claimed_samples_seen_only_where_scan_is_empty": 0.0, + "passes": null + } + } + }, + { + "scene": "facade", + "photos": 76, + "wall": null + } +] \ No newline at end of file diff --git a/experiments/evals/results/coverage.md b/experiments/evals/results/coverage.md new file mode 100644 index 000000000..f9df72f4d --- /dev/null +++ b/experiments/evals/results/coverage.md @@ -0,0 +1,15 @@ +# Does the app's coverage map claim surface no photo saw? (generated by `make coverage`) + +HouseScanKit `CoverageMap` at beede15f568b3a4d694fb275caf9eaaa882c546b (t3/ios-mvf), unmodified, default `CoverageConfig`. ETH3D photos with true poses, every photo a kept keyframe. Lengths in feet along the wall. Definitions and pass criteria: README section 7. + +| Scene | Band | Claimed | False-observed (share) | Pass (<= 0.5 ft) | Causes: occlusion / frame edge / range | False-observed, 2-position truth (share) | Seen from 2 positions | Missed | Missed causes: frame edge / grazing / range / one position | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| electro | wall | 19.3 | 1.1 (6%) | no | 1.1 / 0.0 / 0.0 | 1.3 (7%) | 18.0 | 0.0 | 0.0 / 0.0 / 0.0 / 0.0 | +| electro | ground | 0.0 | 0.0 (0%) | untested | 0.0 / 0.0 / 0.0 | 0.0 (0%) | 18.1 | 18.1 | 0.0 / 18.1 / 0.0 / 0.0 | + +## Setup and validity checks + +| Scene | Photos (with a sighting) | Wall stretch | Of it, pilaster faces | Wall plane fit RMS | Ground fit RMS | App sightings | Replica mismatches | Claimed samples seen only where the scan is empty (wall / ground) | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| electro | 45 (17) | 19.3 ft | 3.8 ft | 1.8 cm | 0.5 cm | 337 | 0 | 0.0% / 0.0% | +| facade | 76 (0) | none within the app's range | | | | | | | diff --git a/experiments/evals/results/coverage_options.json b/experiments/evals/results/coverage_options.json new file mode 100644 index 000000000..04b758f14 --- /dev/null +++ b/experiments/evals/results/coverage_options.json @@ -0,0 +1,450 @@ +{ + "scene": "electro", + "wall_ft": 19.311015888810974, + "grid": [ + { + "b_m": 0.25, + "theta_deg": 0, + "option": "b = 0.25 m, theta = 0 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 0.25, + "theta_deg": 15, + "option": "b = 0.25 m, theta = 15 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 0.25, + "theta_deg": 30, + "option": "b = 0.25 m, theta = 30 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 0.25, + "theta_deg": 45, + "option": "b = 0.25 m, theta = 45 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 0.25, + "theta_deg": 60, + "option": "b = 0.25 m, theta = 60 deg", + "needs_lidar": false, + "claimed_ft": 9.448818897637794, + "false_observed_ft": 0.0, + "missed_ft": 8.530183727034121, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 0.25, + "theta_deg": 75, + "option": "b = 0.25 m, theta = 75 deg", + "needs_lidar": false, + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "missed_ft": 17.979002624671917, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 0.5, + "theta_deg": 0, + "option": "b = 0.5 m, theta = 0 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 0.5, + "theta_deg": 15, + "option": "b = 0.5 m, theta = 15 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 0.5, + "theta_deg": 30, + "option": "b = 0.5 m, theta = 30 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 0.5, + "theta_deg": 45, + "option": "b = 0.5 m, theta = 45 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 0.5, + "theta_deg": 60, + "option": "b = 0.5 m, theta = 60 deg", + "needs_lidar": false, + "claimed_ft": 9.448818897637794, + "false_observed_ft": 0.0, + "missed_ft": 8.530183727034121, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 0.5, + "theta_deg": 75, + "option": "b = 0.5 m, theta = 75 deg", + "needs_lidar": false, + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "missed_ft": 17.979002624671917, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 1.0, + "theta_deg": 0, + "option": "b = 1 m, theta = 0 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 1.0, + "theta_deg": 15, + "option": "b = 1 m, theta = 15 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 1.0, + "theta_deg": 30, + "option": "b = 1 m, theta = 30 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 1.0, + "theta_deg": 45, + "option": "b = 1 m, theta = 45 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 1.0, + "theta_deg": 60, + "option": "b = 1 m, theta = 60 deg", + "needs_lidar": false, + "claimed_ft": 9.448818897637794, + "false_observed_ft": 0.0, + "missed_ft": 8.530183727034121, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 1.0, + "theta_deg": 75, + "option": "b = 1 m, theta = 75 deg", + "needs_lidar": false, + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "missed_ft": 17.979002624671917, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 2.0, + "theta_deg": 0, + "option": "b = 2 m, theta = 0 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 2.0, + "theta_deg": 15, + "option": "b = 2 m, theta = 15 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 2.0, + "theta_deg": 30, + "option": "b = 2 m, theta = 30 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 2.0, + "theta_deg": 45, + "option": "b = 2 m, theta = 45 deg", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "b_m": 2.0, + "theta_deg": 60, + "option": "b = 2 m, theta = 60 deg", + "needs_lidar": false, + "claimed_ft": 9.448818897637794, + "false_observed_ft": 0.0, + "missed_ft": 8.530183727034121, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 2.0, + "theta_deg": 75, + "option": "b = 2 m, theta = 75 deg", + "needs_lidar": false, + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "missed_ft": 17.979002624671917, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 5.0, + "theta_deg": 0, + "option": "b = 5 m, theta = 0 deg", + "needs_lidar": false, + "claimed_ft": 11.94225721784777, + "false_observed_ft": 0.0, + "missed_ft": 6.036745406824147, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 5.0, + "theta_deg": 15, + "option": "b = 5 m, theta = 15 deg", + "needs_lidar": false, + "claimed_ft": 11.94225721784777, + "false_observed_ft": 0.0, + "missed_ft": 6.036745406824147, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 5.0, + "theta_deg": 30, + "option": "b = 5 m, theta = 30 deg", + "needs_lidar": false, + "claimed_ft": 11.94225721784777, + "false_observed_ft": 0.0, + "missed_ft": 6.036745406824147, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 5.0, + "theta_deg": 45, + "option": "b = 5 m, theta = 45 deg", + "needs_lidar": false, + "claimed_ft": 11.94225721784777, + "false_observed_ft": 0.0, + "missed_ft": 6.036745406824147, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 5.0, + "theta_deg": 60, + "option": "b = 5 m, theta = 60 deg", + "needs_lidar": false, + "claimed_ft": 9.448818897637794, + "false_observed_ft": 0.0, + "missed_ft": 8.530183727034121, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "b_m": 5.0, + "theta_deg": 75, + "option": "b = 5 m, theta = 75 deg", + "needs_lidar": false, + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "missed_ft": 17.979002624671917, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + } + ], + "options": [ + { + "option": "the app today (b = 0.25 m, theta = 0)", + "needs_lidar": false, + "claimed_ft": 19.291338582677167, + "false_observed_ft": 1.1154855643044619, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + }, + { + "option": "depth test, true depth to 6 m, 3 rows", + "needs_lidar": true, + "claimed_ft": 14.63254593175853, + "false_observed_ft": 0.0, + "missed_ft": 3.3464566929133857, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "option": "depth test, true depth to 6 m, 9 rows", + "needs_lidar": true, + "claimed_ft": 14.63254593175853, + "false_observed_ft": 0.0, + "missed_ft": 3.3464566929133857, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "option": "depth test, no depth past 5 m, 9 rows", + "needs_lidar": true, + "claimed_ft": 10.170603674540683, + "false_observed_ft": 0.0, + "missed_ft": 7.808398950131234, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + } + ], + "relief": [ + { + "option": "depth test, true depth to 6 m, 9 rows, 0.2 m of relief", + "needs_lidar": true, + "claimed_ft": 14.63254593175853, + "false_observed_ft": 0.0, + "missed_ft": 3.3464566929133857, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "option": "depth test, true depth to 6 m, 9 rows, 0.3 m of relief", + "needs_lidar": true, + "claimed_ft": 15.091863517060368, + "false_observed_ft": 0.0, + "missed_ft": 2.8871391076115485, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "option": "depth test, true depth to 6 m, 9 rows, 0.4 m of relief", + "needs_lidar": true, + "claimed_ft": 18.11023622047244, + "false_observed_ft": 0.06561679790026247, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": true + }, + { + "option": "depth test, true depth to 6 m, 9 rows, 0.5 m of relief", + "needs_lidar": true, + "claimed_ft": 19.16010498687664, + "false_observed_ft": 0.984251968503937, + "missed_ft": 0.0, + "ground_claimed_ft": 0.0, + "ground_false_observed_ft": 0.0, + "passes": false + } + ] +} \ No newline at end of file diff --git a/experiments/evals/results/coverage_options.md b/experiments/evals/results/coverage_options.md new file mode 100644 index 000000000..88b5f3cec --- /dev/null +++ b/experiments/evals/results/coverage_options.md @@ -0,0 +1,28 @@ +# Options for occlusion in the coverage map (generated by `make coverage-options`) + +ETH3D electro, the 19.3 ft wall of section 7. Feet along the wall. False-observed: claimed wall with 10 cm or more of band no photo saw (pass <= 0.5 ft). Missed: wall band seen from two positions 0.25 m apart but not claimed. + +## Position baseline b and angle diversity theta (no LiDAR): false-observed / missed + +| b \ theta | 0 deg | 15 deg | 30 deg | 45 deg | 60 deg | 75 deg | +| --- | --- | --- | --- | --- | --- | --- | +| 0.25 m | 1.1 / 0.0 | 1.1 / 0.0 | 1.1 / 0.0 | 1.1 / 0.0 | 0.0 / 8.5 | 0.0 / 18.0 | +| 0.5 m | 1.1 / 0.0 | 1.1 / 0.0 | 1.1 / 0.0 | 1.1 / 0.0 | 0.0 / 8.5 | 0.0 / 18.0 | +| 1 m | 1.1 / 0.0 | 1.1 / 0.0 | 1.1 / 0.0 | 1.1 / 0.0 | 0.0 / 8.5 | 0.0 / 18.0 | +| 2 m | 1.1 / 0.0 | 1.1 / 0.0 | 1.1 / 0.0 | 1.1 / 0.0 | 0.0 / 8.5 | 0.0 / 18.0 | +| 5 m | 0.0 / 6.0 | 0.0 / 6.0 | 0.0 / 6.0 | 0.0 / 6.0 | 0.0 / 8.5 | 0.0 / 18.0 | + +## Options + +The last four rows allow that much wall relief in front of the tapped plane, a setting chosen after seeing this wall; see README section 7b. + +| Option | Needs LiDAR | Claimed | False-observed | Pass | Missed | Ground claimed | +| --- | --- | --- | --- | --- | --- | --- | +| the app today (b = 0.25 m, theta = 0) | no | 19.3 | 1.1 | no | 0.0 | 0.0 | +| depth test, true depth to 6 m, 3 rows | yes | 14.6 | 0.0 | yes | 3.3 | 0.0 | +| depth test, true depth to 6 m, 9 rows | yes | 14.6 | 0.0 | yes | 3.3 | 0.0 | +| depth test, no depth past 5 m, 9 rows | yes | 10.2 | 0.0 | yes | 7.8 | 0.0 | +| depth test, true depth to 6 m, 9 rows, 0.2 m of relief | yes | 14.6 | 0.0 | yes | 3.3 | 0.0 | +| depth test, true depth to 6 m, 9 rows, 0.3 m of relief | yes | 15.1 | 0.0 | yes | 2.9 | 0.0 | +| depth test, true depth to 6 m, 9 rows, 0.4 m of relief | yes | 18.1 | 0.1 | yes | 0.0 | 0.0 | +| depth test, true depth to 6 m, 9 rows, 0.5 m of relief | yes | 19.2 | 1.0 | no | 0.0 | 0.0 | diff --git a/experiments/evals/results/eth3d_recon.json b/experiments/evals/results/eth3d_recon.json new file mode 100644 index 000000000..3d2b74609 --- /dev/null +++ b/experiments/evals/results/eth3d_recon.json @@ -0,0 +1,6214 @@ +{ + "facade": { + "groups": { + "all points": { + "oracle": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.23, + "p90_in": 1.57, + "scale_error_pct": -0.21 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.63, + "p90_in": 2.9, + "scale_error_pct": -0.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.29, + "p90_in": 2.69, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.73, + "p90_in": 6.15, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.2, + "p90_in": 0.92, + "scale_error_pct": -0.21 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.56, + "p90_in": 1.98, + "scale_error_pct": -0.19 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.21, + "p90_in": 2.16, + "scale_error_pct": 0.02 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.53, + "p90_in": 5.6, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.49, + "p90_in": 3.38, + "scale_error_pct": -0.26 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.01, + "p90_in": 5.4, + "scale_error_pct": -0.23 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.64, + "p90_in": 4.15, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 1.42, + "p90_in": 7.74, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.24, + "p90_in": 1.65, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.64, + "p90_in": 3.01, + "scale_error_pct": -0.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.3, + "p90_in": 2.8, + "scale_error_pct": 0.02 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.74, + "p90_in": 6.19, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.21, + "p90_in": 0.99, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.57, + "p90_in": 2.05, + "scale_error_pct": -0.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.22, + "p90_in": 2.29, + "scale_error_pct": 0.02 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.53, + "p90_in": 5.69, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.5, + "p90_in": 3.64, + "scale_error_pct": -0.26 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.0, + "p90_in": 5.65, + "scale_error_pct": -0.24 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 4.34, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 1.42, + "p90_in": 7.77, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.23, + "p90_in": 1.6, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.62, + "p90_in": 2.84, + "scale_error_pct": -0.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 0.31, + "p90_in": 2.89, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 0.74, + "p90_in": 6.59, + "scale_error_pct": 0.03 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.2, + "p90_in": 0.96, + "scale_error_pct": -0.21 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.56, + "p90_in": 1.87, + "scale_error_pct": -0.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 0.22, + "p90_in": 2.51, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 0.54, + "p90_in": 6.06, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.47, + "p90_in": 3.51, + "scale_error_pct": -0.25 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.96, + "p90_in": 5.32, + "scale_error_pct": -0.23 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 0.63, + "p90_in": 4.21, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 1.35, + "p90_in": 7.92, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.24, + "p90_in": 1.6, + "scale_error_pct": -0.23 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 2.82, + "scale_error_pct": -0.22 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.31, + "p90_in": 2.94, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.75, + "p90_in": 6.68, + "scale_error_pct": 0.04 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.21, + "p90_in": 0.98, + "scale_error_pct": -0.23 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.59, + "p90_in": 1.86, + "scale_error_pct": -0.22 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.23, + "p90_in": 2.62, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.55, + "p90_in": 6.14, + "scale_error_pct": 0.03 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.49, + "p90_in": 3.53, + "scale_error_pct": -0.28 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.99, + "p90_in": 5.32, + "scale_error_pct": -0.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.66, + "p90_in": 4.34, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 1.37, + "p90_in": 8.24, + "scale_error_pct": 0.01 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + } + } + }, + "fused/moge2": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.22, + "median_in": 5.63, + "p90_in": 11.57, + "scale_error_pct": -5.58 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.31, + "median_in": 20.21, + "p90_in": 47.28, + "scale_error_pct": -6.97 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.72, + "median_in": 3.02, + "p90_in": 12.12, + "scale_error_pct": -0.16 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.81, + "median_in": 10.32, + "p90_in": 44.76, + "scale_error_pct": -1.23 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 5.55, + "p90_in": 10.92, + "scale_error_pct": -5.92 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 19.26, + "p90_in": 46.21, + "scale_error_pct": -7.07 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.51, + "median_in": 2.77, + "p90_in": 10.95, + "scale_error_pct": -0.42 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.51, + "median_in": 9.69, + "p90_in": 43.92, + "scale_error_pct": -1.26 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 5.32, + "p90_in": 25.3, + "scale_error_pct": -1.8 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.1, + "median_in": 15.1, + "p90_in": 51.07, + "scale_error_pct": -3.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.52, + "median_in": 4.93, + "p90_in": 28.19, + "scale_error_pct": 3.57 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.6, + "median_in": 14.01, + "p90_in": 55.42, + "scale_error_pct": 2.24 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.19, + "median_in": 5.93, + "p90_in": 11.96, + "scale_error_pct": -6.16 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.22, + "median_in": 20.42, + "p90_in": 47.68, + "scale_error_pct": -7.32 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.68, + "median_in": 3.04, + "p90_in": 12.62, + "scale_error_pct": -0.17 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.72, + "median_in": 9.82, + "p90_in": 43.65, + "scale_error_pct": -1.23 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.9, + "p90_in": 11.25, + "scale_error_pct": -6.47 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 19.57, + "p90_in": 46.74, + "scale_error_pct": -7.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 2.78, + "p90_in": 11.26, + "scale_error_pct": -0.44 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.51, + "median_in": 9.21, + "p90_in": 42.66, + "scale_error_pct": -1.24 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.51, + "p90_in": 26.11, + "scale_error_pct": -2.26 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.03, + "median_in": 16.03, + "p90_in": 48.48, + "scale_error_pct": -3.98 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 5.01, + "p90_in": 30.48, + "scale_error_pct": 3.92 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.53, + "median_in": 13.78, + "p90_in": 55.8, + "scale_error_pct": 2.18 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.15, + "median_in": 5.7, + "p90_in": 11.63, + "scale_error_pct": -5.87 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.18, + "median_in": 19.2, + "p90_in": 46.86, + "scale_error_pct": -6.97 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.65, + "median_in": 3.34, + "p90_in": 12.34, + "scale_error_pct": -0.32 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.68, + "median_in": 10.79, + "p90_in": 42.09, + "scale_error_pct": -1.62 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.61, + "p90_in": 10.92, + "scale_error_pct": -6.16 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 18.28, + "p90_in": 46.46, + "scale_error_pct": -7.0 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 3.08, + "p90_in": 11.1, + "scale_error_pct": -0.62 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.51, + "median_in": 10.29, + "p90_in": 40.99, + "scale_error_pct": -1.66 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.09, + "p90_in": 24.59, + "scale_error_pct": -2.27 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 14.79, + "p90_in": 45.42, + "scale_error_pct": -3.87 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 5.18, + "p90_in": 28.49, + "scale_error_pct": 4.06 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.52, + "median_in": 13.74, + "p90_in": 52.73, + "scale_error_pct": 2.02 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.13, + "median_in": 5.92, + "p90_in": 11.81, + "scale_error_pct": -5.98 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.15, + "median_in": 19.91, + "p90_in": 46.13, + "scale_error_pct": -7.24 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 2.13, + "median_in": 3.49, + "p90_in": 14.43, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 2.15, + "median_in": 11.03, + "p90_in": 49.0, + "scale_error_pct": -1.36 + } + }, + "tape_calibration_success_pct": 98.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.86, + "p90_in": 11.24, + "scale_error_pct": -6.32 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 19.06, + "p90_in": 46.46, + "scale_error_pct": -7.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 2.0, + "median_in": 3.21, + "p90_in": 13.02, + "scale_error_pct": -0.31 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 2.01, + "median_in": 10.57, + "p90_in": 48.53, + "scale_error_pct": -1.39 + } + }, + "tape_calibration_success_pct": 98.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.26, + "p90_in": 28.23, + "scale_error_pct": -2.44 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 15.33, + "p90_in": 53.18, + "scale_error_pct": -4.02 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 2.0, + "median_in": 5.67, + "p90_in": 35.99, + "scale_error_pct": 4.52 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 2.02, + "median_in": 14.67, + "p90_in": 65.25, + "scale_error_pct": 2.26 + } + }, + "tape_calibration_success_pct": 98.0, + "groups": 8 + } + } + }, + "fused/da3metric": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 1.2, + "median_in": 11.45, + "p90_in": 20.87, + "scale_error_pct": -14.79 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 1.56, + "median_in": 39.19, + "p90_in": 81.47, + "scale_error_pct": -16.29 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.68, + "median_in": 3.65, + "p90_in": 13.91, + "scale_error_pct": -0.19 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 2.03, + "median_in": 12.09, + "p90_in": 54.69, + "scale_error_pct": -1.24 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.17, + "median_in": 11.68, + "p90_in": 20.69, + "scale_error_pct": -15.35 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.12, + "median_in": 39.25, + "p90_in": 79.31, + "scale_error_pct": -17.01 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.67, + "median_in": 3.44, + "p90_in": 12.46, + "scale_error_pct": -0.47 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.62, + "median_in": 11.26, + "p90_in": 51.19, + "scale_error_pct": -1.49 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.72, + "median_in": 9.8, + "p90_in": 29.18, + "scale_error_pct": -8.51 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 1.06, + "median_in": 30.43, + "p90_in": 71.9, + "scale_error_pct": -11.64 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.22, + "median_in": 6.82, + "p90_in": 42.7, + "scale_error_pct": 6.07 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.55, + "median_in": 19.53, + "p90_in": 81.99, + "scale_error_pct": 3.33 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.9, + "median_in": 11.28, + "p90_in": 21.23, + "scale_error_pct": -14.28 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 1.24, + "median_in": 38.14, + "p90_in": 82.67, + "scale_error_pct": -15.91 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.38, + "median_in": 4.01, + "p90_in": 16.92, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.71, + "median_in": 13.29, + "p90_in": 59.16, + "scale_error_pct": -1.15 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.09, + "median_in": 11.56, + "p90_in": 21.07, + "scale_error_pct": -14.87 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.05, + "median_in": 38.41, + "p90_in": 81.04, + "scale_error_pct": -16.61 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.59, + "median_in": 3.74, + "p90_in": 15.35, + "scale_error_pct": -0.3 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.55, + "median_in": 12.49, + "p90_in": 56.14, + "scale_error_pct": -1.46 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.41, + "median_in": 9.19, + "p90_in": 29.09, + "scale_error_pct": -8.07 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.77, + "median_in": 29.43, + "p90_in": 70.76, + "scale_error_pct": -10.78 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.9, + "median_in": 7.96, + "p90_in": 45.5, + "scale_error_pct": 7.93 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.26, + "median_in": 21.85, + "p90_in": 87.66, + "scale_error_pct": 4.64 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.59, + "median_in": 10.99, + "p90_in": 20.65, + "scale_error_pct": -13.99 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.82, + "median_in": 36.57, + "p90_in": 77.73, + "scale_error_pct": -15.4 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 2.05, + "median_in": 4.11, + "p90_in": 15.78, + "scale_error_pct": -0.15 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 2.28, + "median_in": 13.46, + "p90_in": 55.46, + "scale_error_pct": -1.34 + } + }, + "tape_calibration_success_pct": 98.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.04, + "median_in": 11.2, + "p90_in": 20.44, + "scale_error_pct": -14.6 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 37.09, + "p90_in": 77.66, + "scale_error_pct": -16.18 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.53, + "median_in": 3.87, + "p90_in": 14.38, + "scale_error_pct": -0.47 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.52, + "median_in": 12.88, + "p90_in": 53.93, + "scale_error_pct": -1.77 + } + }, + "tape_calibration_success_pct": 98.5, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.22, + "median_in": 8.99, + "p90_in": 26.5, + "scale_error_pct": -7.86 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.44, + "median_in": 27.78, + "p90_in": 67.21, + "scale_error_pct": -10.26 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.71, + "median_in": 7.59, + "p90_in": 44.5, + "scale_error_pct": 7.08 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.91, + "median_in": 20.62, + "p90_in": 83.55, + "scale_error_pct": 4.37 + } + }, + "tape_calibration_success_pct": 98.5, + "groups": 8 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.45, + "median_in": 10.65, + "p90_in": 19.94, + "scale_error_pct": -13.2 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.66, + "median_in": 36.05, + "p90_in": 76.97, + "scale_error_pct": -15.05 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.93, + "median_in": 4.22, + "p90_in": 15.45, + "scale_error_pct": -0.14 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 2.14, + "median_in": 13.68, + "p90_in": 55.96, + "scale_error_pct": -1.34 + } + }, + "tape_calibration_success_pct": 98.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.04, + "median_in": 10.75, + "p90_in": 19.68, + "scale_error_pct": -13.78 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 36.22, + "p90_in": 77.08, + "scale_error_pct": -15.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.54, + "median_in": 4.03, + "p90_in": 14.25, + "scale_error_pct": -0.43 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.52, + "median_in": 13.12, + "p90_in": 54.83, + "scale_error_pct": -1.75 + } + }, + "tape_calibration_success_pct": 98.5, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.18, + "median_in": 9.29, + "p90_in": 28.54, + "scale_error_pct": -8.4 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.36, + "median_in": 28.17, + "p90_in": 69.57, + "scale_error_pct": -10.31 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.68, + "median_in": 7.4, + "p90_in": 43.7, + "scale_error_pct": 6.24 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.84, + "median_in": 19.71, + "p90_in": 78.52, + "scale_error_pct": 3.57 + } + }, + "tape_calibration_success_pct": 98.5, + "groups": 8 + } + } + } + }, + "within 6 m": { + "oracle": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 0.81, + "p90_in": 1.42, + "scale_error_pct": -1.28 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 1.6, + "p90_in": 2.12, + "scale_error_pct": -1.15 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.25, + "p90_in": 1.61, + "scale_error_pct": 0.02 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.46, + "p90_in": 2.48, + "scale_error_pct": 0.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.04, + "p90_in": 0.37, + "scale_error_pct": -0.04 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.08, + "p90_in": 0.56, + "scale_error_pct": -0.05 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.53, + "p90_in": 2.05, + "scale_error_pct": 0.42 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.97, + "p90_in": 3.95, + "scale_error_pct": 0.4 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.56, + "p90_in": 1.38, + "scale_error_pct": -0.48 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.41, + "p90_in": 3.17, + "scale_error_pct": -0.69 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.66, + "p90_in": 1.8, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.64, + "p90_in": 4.54, + "scale_error_pct": -0.28 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 0.82, + "p90_in": 1.44, + "scale_error_pct": -1.28 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 1.61, + "p90_in": 2.13, + "scale_error_pct": -1.16 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.26, + "p90_in": 1.71, + "scale_error_pct": 0.02 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.48, + "p90_in": 2.59, + "scale_error_pct": 0.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.04, + "p90_in": 0.38, + "scale_error_pct": -0.04 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.08, + "p90_in": 0.57, + "scale_error_pct": -0.06 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.54, + "p90_in": 2.13, + "scale_error_pct": 0.4 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.99, + "p90_in": 3.98, + "scale_error_pct": 0.38 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.57, + "p90_in": 1.45, + "scale_error_pct": -0.44 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.42, + "p90_in": 3.21, + "scale_error_pct": -0.7 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.68, + "p90_in": 1.91, + "scale_error_pct": 0.02 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.7, + "p90_in": 4.72, + "scale_error_pct": -0.28 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 0.82, + "p90_in": 1.43, + "scale_error_pct": -1.28 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 1.59, + "p90_in": 2.1, + "scale_error_pct": -1.13 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.25, + "p90_in": 1.7, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.47, + "p90_in": 2.57, + "scale_error_pct": 0.07 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.05, + "p90_in": 0.34, + "scale_error_pct": -0.06 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.1, + "p90_in": 0.61, + "scale_error_pct": -0.07 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.53, + "p90_in": 2.16, + "scale_error_pct": 0.31 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.04, + "p90_in": 4.06, + "scale_error_pct": 0.29 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.55, + "p90_in": 1.38, + "scale_error_pct": -0.45 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.55, + "p90_in": 3.27, + "scale_error_pct": -0.78 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.66, + "p90_in": 1.87, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.78, + "p90_in": 4.69, + "scale_error_pct": -0.32 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 0.81, + "p90_in": 1.43, + "scale_error_pct": -1.28 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 1.57, + "p90_in": 2.08, + "scale_error_pct": -1.1 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.24, + "p90_in": 1.75, + "scale_error_pct": 0.02 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.46, + "p90_in": 2.77, + "scale_error_pct": 0.07 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.05, + "p90_in": 0.31, + "scale_error_pct": -0.06 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.11, + "p90_in": 0.6, + "scale_error_pct": -0.08 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.44, + "p90_in": 2.07, + "scale_error_pct": 0.2 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.94, + "p90_in": 4.11, + "scale_error_pct": 0.22 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 0.51, + "p90_in": 1.33, + "scale_error_pct": -0.41 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.62, + "p90_in": 3.33, + "scale_error_pct": -0.83 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 1.83, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.93, + "p90_in": 5.33, + "scale_error_pct": -0.38 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + } + }, + "fused/moge2": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 8.43, + "p90_in": 20.14, + "scale_error_pct": 10.01 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 15.95, + "p90_in": 30.25, + "scale_error_pct": 9.77 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 6.27, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.3, + "p90_in": 10.11, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 16.31, + "p90_in": 25.65, + "scale_error_pct": 21.36 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 26.63, + "p90_in": 32.89, + "scale_error_pct": 17.96 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.34, + "p90_in": 8.75, + "scale_error_pct": 2.52 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.73, + "p90_in": 11.22, + "scale_error_pct": -0.52 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 10.44, + "p90_in": 19.19, + "scale_error_pct": 17.79 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 30.75, + "p90_in": 37.47, + "scale_error_pct": 16.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.35, + "p90_in": 6.73, + "scale_error_pct": 0.41 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.49, + "p90_in": 14.59, + "scale_error_pct": -1.33 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 8.46, + "p90_in": 19.42, + "scale_error_pct": 10.02 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 15.98, + "p90_in": 29.76, + "scale_error_pct": 9.74 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 6.31, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.3, + "p90_in": 10.68, + "scale_error_pct": 0.15 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 15.83, + "p90_in": 24.19, + "scale_error_pct": 20.71 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 26.12, + "p90_in": 32.33, + "scale_error_pct": 17.68 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.36, + "p90_in": 8.69, + "scale_error_pct": 2.22 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.87, + "p90_in": 12.11, + "scale_error_pct": -0.56 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 10.23, + "p90_in": 18.55, + "scale_error_pct": 17.31 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 30.86, + "p90_in": 38.1, + "scale_error_pct": 16.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.36, + "p90_in": 6.78, + "scale_error_pct": 0.41 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.7, + "p90_in": 15.99, + "scale_error_pct": -1.34 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 8.3, + "p90_in": 19.43, + "scale_error_pct": 9.76 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 15.64, + "p90_in": 28.43, + "scale_error_pct": 9.57 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.66, + "p90_in": 6.66, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.3, + "p90_in": 11.19, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 15.81, + "p90_in": 23.32, + "scale_error_pct": 20.41 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 25.41, + "p90_in": 31.08, + "scale_error_pct": 17.29 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.43, + "p90_in": 8.76, + "scale_error_pct": 2.07 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.01, + "p90_in": 13.12, + "scale_error_pct": -0.71 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 9.56, + "p90_in": 18.48, + "scale_error_pct": 16.88 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 30.2, + "p90_in": 37.73, + "scale_error_pct": 16.12 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.38, + "p90_in": 7.18, + "scale_error_pct": 0.49 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.95, + "p90_in": 17.98, + "scale_error_pct": -1.39 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 8.23, + "p90_in": 19.1, + "scale_error_pct": 9.43 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 15.14, + "p90_in": 28.41, + "scale_error_pct": 9.39 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.72, + "p90_in": 9.97, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.39, + "p90_in": 17.06, + "scale_error_pct": 0.16 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 15.36, + "p90_in": 22.55, + "scale_error_pct": 20.32 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 25.26, + "p90_in": 30.98, + "scale_error_pct": 17.26 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.05, + "p90_in": 13.49, + "scale_error_pct": 2.76 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.63, + "p90_in": 28.42, + "scale_error_pct": -0.81 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 8.91, + "p90_in": 17.37, + "scale_error_pct": 16.17 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 30.58, + "p90_in": 37.59, + "scale_error_pct": 16.51 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.88, + "p90_in": 9.38, + "scale_error_pct": 0.27 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 7.47, + "p90_in": 41.77, + "scale_error_pct": -1.63 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + } + }, + "fused/da3metric": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 4.56, + "p90_in": 9.99, + "scale_error_pct": -6.68 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 11.96, + "p90_in": 15.99, + "scale_error_pct": -9.24 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.03, + "p90_in": 6.25, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.54, + "p90_in": 11.58, + "scale_error_pct": -0.17 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.48, + "p90_in": 4.51, + "scale_error_pct": 0.29 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.79, + "p90_in": 4.6, + "scale_error_pct": -0.92 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.78, + "p90_in": 8.09, + "scale_error_pct": -0.57 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.88, + "p90_in": 14.6, + "scale_error_pct": -1.66 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.12, + "p90_in": 70.25, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.79, + "p90_in": 4.61, + "scale_error_pct": -0.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.74, + "p90_in": 66.27, + "scale_error_pct": -0.26 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.78, + "p90_in": 26.47, + "scale_error_pct": -1.07 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 5.14, + "p90_in": 10.15, + "scale_error_pct": -7.55 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 12.15, + "p90_in": 16.0, + "scale_error_pct": -9.51 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.17, + "p90_in": 8.67, + "scale_error_pct": 0.04 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.7, + "p90_in": 16.27, + "scale_error_pct": -0.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 2.25, + "p90_in": 6.69, + "scale_error_pct": 1.09 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 2.53, + "p90_in": 7.33, + "scale_error_pct": -1.27 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.07, + "p90_in": 11.09, + "scale_error_pct": 0.48 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.34, + "p90_in": 20.33, + "scale_error_pct": -1.73 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.5, + "p90_in": 66.03, + "scale_error_pct": -0.14 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 3.35, + "p90_in": 6.7, + "scale_error_pct": -1.7 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.48, + "p90_in": 61.61, + "scale_error_pct": 0.71 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 7.98, + "p90_in": 30.76, + "scale_error_pct": -1.41 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 5.24, + "p90_in": 10.18, + "scale_error_pct": -7.41 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 12.14, + "p90_in": 15.99, + "scale_error_pct": -9.51 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.16, + "p90_in": 9.15, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.67, + "p90_in": 16.83, + "scale_error_pct": -0.04 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 2.47, + "p90_in": 7.0, + "scale_error_pct": 1.62 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 2.41, + "p90_in": 6.96, + "scale_error_pct": -0.79 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.31, + "p90_in": 11.76, + "scale_error_pct": 0.83 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.63, + "p90_in": 21.33, + "scale_error_pct": -1.4 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.33, + "p90_in": 68.88, + "scale_error_pct": 0.89 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 2.98, + "p90_in": 8.14, + "scale_error_pct": -1.31 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.53, + "p90_in": 66.12, + "scale_error_pct": 1.5 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 9.11, + "p90_in": 33.6, + "scale_error_pct": -0.86 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 5.02, + "p90_in": 9.96, + "scale_error_pct": -7.85 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 11.96, + "p90_in": 16.0, + "scale_error_pct": -9.51 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.1, + "p90_in": 8.7, + "scale_error_pct": 0.13 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.53, + "p90_in": 17.33, + "scale_error_pct": 0.07 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 2.44, + "p90_in": 5.66, + "scale_error_pct": -0.64 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 3.99, + "p90_in": 7.93, + "scale_error_pct": -2.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.74, + "p90_in": 11.19, + "scale_error_pct": 1.02 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.33, + "p90_in": 24.32, + "scale_error_pct": -0.54 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.57, + "p90_in": 53.33, + "scale_error_pct": 0.4 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 5.06, + "p90_in": 8.65, + "scale_error_pct": -2.57 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.22, + "p90_in": 53.75, + "scale_error_pct": 2.48 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 8.59, + "p90_in": 37.6, + "scale_error_pct": 0.43 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1 + } + } + } + } + }, + "single": { + "moge2": { + "all points": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 228000, + "failed_pct": 0.15, + "median_in": 6.29, + "p90_in": 13.25, + "scale_error_pct": -6.61 + }, + "3-10m": { + "pairs": 228000, + "failed_pct": 0.25, + "median_in": 21.88, + "p90_in": 52.55, + "scale_error_pct": -7.9 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 5700000, + "failed_pct": 0.46, + "median_in": 2.92, + "p90_in": 12.33, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 5700000, + "failed_pct": 0.57, + "median_in": 9.21, + "p90_in": 45.35, + "scale_error_pct": -0.75 + } + }, + "tape_calibration_success_pct": 99.7, + "photos": 76 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 228000, + "failed_pct": 0.0, + "median_in": 6.32, + "p90_in": 12.89, + "scale_error_pct": -7.0 + }, + "3-10m": { + "pairs": 228000, + "failed_pct": 0.01, + "median_in": 21.57, + "p90_in": 54.08, + "scale_error_pct": -8.14 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 5700000, + "failed_pct": 0.32, + "median_in": 2.75, + "p90_in": 11.29, + "scale_error_pct": -0.19 + }, + "3-10m": { + "pairs": 5700000, + "failed_pct": 0.32, + "median_in": 8.93, + "p90_in": 46.11, + "scale_error_pct": -0.86 + } + }, + "tape_calibration_success_pct": 99.7, + "photos": 76 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 228000, + "failed_pct": 0.04, + "median_in": 5.65, + "p90_in": 20.82, + "scale_error_pct": -2.87 + }, + "3-10m": { + "pairs": 225783, + "failed_pct": 0.08, + "median_in": 15.84, + "p90_in": 48.4, + "scale_error_pct": -4.67 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 5700000, + "failed_pct": 0.35, + "median_in": 4.43, + "p90_in": 23.78, + "scale_error_pct": 3.3 + }, + "3-10m": { + "pairs": 5644575, + "failed_pct": 0.4, + "median_in": 11.67, + "p90_in": 50.67, + "scale_error_pct": 1.82 + } + }, + "tape_calibration_success_pct": 99.7, + "photos": 76 + } + }, + "within 6 m": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 84000, + "failed_pct": 0.0, + "median_in": 7.72, + "p90_in": 19.98, + "scale_error_pct": 10.35 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 16.9, + "p90_in": 31.59, + "scale_error_pct": 11.74 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2100000, + "failed_pct": 0.0, + "median_in": 0.94, + "p90_in": 6.49, + "scale_error_pct": 0.05 + }, + "3-10m": { + "pairs": 1875000, + "failed_pct": 0.0, + "median_in": 2.2, + "p90_in": 12.12, + "scale_error_pct": -0.09 + } + }, + "tape_calibration_success_pct": 100.0, + "photos": 28 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 36000, + "failed_pct": 0.0, + "median_in": 14.9, + "p90_in": 25.37, + "scale_error_pct": 20.89 + }, + "3-10m": { + "pairs": 33000, + "failed_pct": 0.0, + "median_in": 26.08, + "p90_in": 37.03, + "scale_error_pct": 17.5 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 900000, + "failed_pct": 0.0, + "median_in": 2.7, + "p90_in": 8.24, + "scale_error_pct": 1.66 + }, + "3-10m": { + "pairs": 825000, + "failed_pct": 0.0, + "median_in": 4.77, + "p90_in": 13.04, + "scale_error_pct": -1.2 + } + }, + "tape_calibration_success_pct": 100.0, + "photos": 12 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 36000, + "failed_pct": 0.0, + "median_in": 11.18, + "p90_in": 24.32, + "scale_error_pct": 19.4 + }, + "3-10m": { + "pairs": 33000, + "failed_pct": 0.0, + "median_in": 30.19, + "p90_in": 43.72, + "scale_error_pct": 16.66 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 900000, + "failed_pct": 0.0, + "median_in": 2.95, + "p90_in": 9.28, + "scale_error_pct": 0.54 + }, + "3-10m": { + "pairs": 825000, + "failed_pct": 0.0, + "median_in": 5.78, + "p90_in": 17.59, + "scale_error_pct": -1.33 + } + }, + "tape_calibration_success_pct": 100.0, + "photos": 12 + } + } + }, + "da3metric": { + "all points": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 228000, + "failed_pct": 0.61, + "median_in": 10.69, + "p90_in": 20.05, + "scale_error_pct": -13.77 + }, + "3-10m": { + "pairs": 228000, + "failed_pct": 0.96, + "median_in": 36.59, + "p90_in": 78.02, + "scale_error_pct": -15.14 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 5700000, + "failed_pct": 1.13, + "median_in": 3.79, + "p90_in": 14.76, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 5700000, + "failed_pct": 1.48, + "median_in": 12.02, + "p90_in": 56.52, + "scale_error_pct": -0.7 + } + }, + "tape_calibration_success_pct": 99.5, + "photos": 76 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 228000, + "failed_pct": 0.09, + "median_in": 10.83, + "p90_in": 19.86, + "scale_error_pct": -14.11 + }, + "3-10m": { + "pairs": 228000, + "failed_pct": 0.1, + "median_in": 37.01, + "p90_in": 78.1, + "scale_error_pct": -15.51 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 5700000, + "failed_pct": 0.62, + "median_in": 3.58, + "p90_in": 13.66, + "scale_error_pct": -0.16 + }, + "3-10m": { + "pairs": 5700000, + "failed_pct": 0.62, + "median_in": 11.51, + "p90_in": 56.8, + "scale_error_pct": -0.91 + } + }, + "tape_calibration_success_pct": 99.5, + "photos": 76 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 228000, + "failed_pct": 1.03, + "median_in": 9.53, + "p90_in": 27.72, + "scale_error_pct": -8.89 + }, + "3-10m": { + "pairs": 225783, + "failed_pct": 1.26, + "median_in": 26.35, + "p90_in": 68.81, + "scale_error_pct": -10.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 5700000, + "failed_pct": 1.55, + "median_in": 6.9, + "p90_in": 41.46, + "scale_error_pct": 5.68 + }, + "3-10m": { + "pairs": 5644575, + "failed_pct": 1.79, + "median_in": 17.47, + "p90_in": 77.24, + "scale_error_pct": 3.9 + } + }, + "tape_calibration_success_pct": 99.5, + "photos": 76 + } + }, + "within 6 m": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 84000, + "failed_pct": 0.0, + "median_in": 6.54, + "p90_in": 18.16, + "scale_error_pct": -8.19 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 16.38, + "p90_in": 36.52, + "scale_error_pct": -10.91 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2100000, + "failed_pct": 0.0, + "median_in": 2.13, + "p90_in": 10.88, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 1875000, + "failed_pct": 0.0, + "median_in": 4.53, + "p90_in": 19.67, + "scale_error_pct": -1.52 + } + }, + "tape_calibration_success_pct": 100.0, + "photos": 28 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 36000, + "failed_pct": 0.0, + "median_in": 3.46, + "p90_in": 9.31, + "scale_error_pct": 0.2 + }, + "3-10m": { + "pairs": 33000, + "failed_pct": 0.0, + "median_in": 4.82, + "p90_in": 12.97, + "scale_error_pct": -2.47 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 900000, + "failed_pct": 0.0, + "median_in": 4.11, + "p90_in": 11.85, + "scale_error_pct": 2.02 + }, + "3-10m": { + "pairs": 825000, + "failed_pct": 0.0, + "median_in": 6.62, + "p90_in": 18.67, + "scale_error_pct": -1.9 + } + }, + "tape_calibration_success_pct": 100.0, + "photos": 12 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 36000, + "failed_pct": 0.0, + "median_in": 3.82, + "p90_in": 39.43, + "scale_error_pct": 1.22 + }, + "3-10m": { + "pairs": 33000, + "failed_pct": 0.0, + "median_in": 7.96, + "p90_in": 19.69, + "scale_error_pct": -2.91 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 900000, + "failed_pct": 0.0, + "median_in": 4.57, + "p90_in": 38.42, + "scale_error_pct": 3.13 + }, + "3-10m": { + "pairs": 825000, + "failed_pct": 0.0, + "median_in": 8.8, + "p90_in": 29.05, + "scale_error_pct": -1.24 + } + }, + "tape_calibration_success_pct": 100.0, + "photos": 12 + } + } + } + } + }, + "electro": { + "groups": { + "all points": { + "oracle": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.67, + "p90_in": 2.35, + "scale_error_pct": -0.61 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.4, + "p90_in": 5.24, + "scale_error_pct": -0.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.74, + "p90_in": 2.82, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 1.63, + "p90_in": 6.59, + "scale_error_pct": -0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.28, + "p90_in": 1.44, + "scale_error_pct": -0.31 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.8, + "p90_in": 4.54, + "scale_error_pct": -0.37 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.53, + "p90_in": 2.46, + "scale_error_pct": 0.11 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 1.33, + "p90_in": 6.19, + "scale_error_pct": 0.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.39, + "p90_in": 1.54, + "scale_error_pct": -0.45 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.0, + "median_in": 0.94, + "p90_in": 4.21, + "scale_error_pct": -0.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.67, + "p90_in": 2.6, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 0.0, + "median_in": 1.5, + "p90_in": 5.56, + "scale_error_pct": -0.01 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.68, + "p90_in": 2.28, + "scale_error_pct": -0.61 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.45, + "p90_in": 4.95, + "scale_error_pct": -0.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.74, + "p90_in": 2.89, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 1.61, + "p90_in": 6.64, + "scale_error_pct": -0.01 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.3, + "p90_in": 1.23, + "scale_error_pct": -0.35 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.94, + "p90_in": 4.18, + "scale_error_pct": -0.44 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.56, + "p90_in": 2.48, + "scale_error_pct": 0.14 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 1.43, + "p90_in": 6.27, + "scale_error_pct": 0.11 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.43, + "p90_in": 1.85, + "scale_error_pct": -0.5 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.0, + "median_in": 1.04, + "p90_in": 3.88, + "scale_error_pct": -0.62 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.72, + "p90_in": 2.73, + "scale_error_pct": 0.07 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 0.0, + "median_in": 1.56, + "p90_in": 5.91, + "scale_error_pct": -0.0 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.67, + "p90_in": 2.18, + "scale_error_pct": -0.59 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.45, + "p90_in": 4.9, + "scale_error_pct": -0.59 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.74, + "p90_in": 2.82, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 1.59, + "p90_in": 6.67, + "scale_error_pct": -0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.3, + "p90_in": 1.29, + "scale_error_pct": -0.35 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.86, + "p90_in": 4.44, + "scale_error_pct": -0.44 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.61, + "p90_in": 2.41, + "scale_error_pct": 0.11 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 1.63, + "p90_in": 6.22, + "scale_error_pct": 0.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.41, + "p90_in": 1.83, + "scale_error_pct": -0.5 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.0, + "median_in": 1.01, + "p90_in": 3.79, + "scale_error_pct": -0.61 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.76, + "p90_in": 2.62, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 0.0, + "median_in": 1.69, + "p90_in": 5.82, + "scale_error_pct": -0.04 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 1.92, + "scale_error_pct": -0.59 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 1.4, + "p90_in": 4.09, + "scale_error_pct": -0.62 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.71, + "p90_in": 2.36, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 1.42, + "p90_in": 5.07, + "scale_error_pct": -0.03 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.3, + "p90_in": 1.03, + "scale_error_pct": -0.35 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.85, + "p90_in": 3.09, + "scale_error_pct": -0.43 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.59, + "p90_in": 2.09, + "scale_error_pct": 0.2 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 1.51, + "p90_in": 5.21, + "scale_error_pct": 0.15 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.48, + "p90_in": 1.94, + "scale_error_pct": -0.56 + }, + "3-10m": { + "pairs": 20816, + "failed_pct": 0.0, + "median_in": 1.1, + "p90_in": 3.31, + "scale_error_pct": -0.65 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.76, + "p90_in": 2.55, + "scale_error_pct": 0.09 + }, + "3-10m": { + "pairs": 520400, + "failed_pct": 0.0, + "median_in": 1.52, + "p90_in": 4.67, + "scale_error_pct": 0.01 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + } + } + }, + "fused/moge2": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.07, + "median_in": 7.63, + "p90_in": 17.94, + "scale_error_pct": 4.09 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.12, + "median_in": 21.93, + "p90_in": 49.22, + "scale_error_pct": 5.08 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.07, + "median_in": 2.87, + "p90_in": 13.23, + "scale_error_pct": -0.1 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.12, + "median_in": 6.86, + "p90_in": 37.27, + "scale_error_pct": -0.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.1, + "median_in": 7.28, + "p90_in": 17.88, + "scale_error_pct": 4.29 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.2, + "median_in": 22.76, + "p90_in": 56.19, + "scale_error_pct": 7.19 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.1, + "median_in": 2.54, + "p90_in": 12.37, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.2, + "median_in": 7.08, + "p90_in": 38.08, + "scale_error_pct": 0.32 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.31, + "median_in": 8.79, + "p90_in": 33.05, + "scale_error_pct": 6.58 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.17, + "median_in": 19.01, + "p90_in": 54.82, + "scale_error_pct": 5.92 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.31, + "median_in": 4.1, + "p90_in": 36.99, + "scale_error_pct": 0.28 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 0.17, + "median_in": 6.96, + "p90_in": 45.46, + "scale_error_pct": -0.1 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.06, + "median_in": 6.26, + "p90_in": 16.61, + "scale_error_pct": 2.19 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.11, + "median_in": 17.46, + "p90_in": 42.18, + "scale_error_pct": 3.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.06, + "median_in": 3.16, + "p90_in": 13.99, + "scale_error_pct": -0.16 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.11, + "median_in": 7.0, + "p90_in": 37.37, + "scale_error_pct": -0.26 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.08, + "median_in": 6.08, + "p90_in": 16.25, + "scale_error_pct": 1.74 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.16, + "median_in": 17.57, + "p90_in": 45.45, + "scale_error_pct": 4.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.08, + "median_in": 2.79, + "p90_in": 13.44, + "scale_error_pct": -0.3 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.16, + "median_in": 6.97, + "p90_in": 38.54, + "scale_error_pct": -0.03 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.31, + "median_in": 7.64, + "p90_in": 30.56, + "scale_error_pct": 5.59 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.17, + "median_in": 15.33, + "p90_in": 44.69, + "scale_error_pct": 4.31 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.31, + "median_in": 4.33, + "p90_in": 32.65, + "scale_error_pct": 0.63 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 0.17, + "median_in": 7.05, + "p90_in": 46.43, + "scale_error_pct": -0.06 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 5.91, + "p90_in": 16.79, + "scale_error_pct": 2.54 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.03, + "median_in": 16.5, + "p90_in": 49.78, + "scale_error_pct": 3.69 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.01, + "median_in": 3.16, + "p90_in": 15.15, + "scale_error_pct": -0.19 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.03, + "median_in": 6.87, + "p90_in": 39.4, + "scale_error_pct": -0.0 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 5.59, + "p90_in": 16.16, + "scale_error_pct": 1.26 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.05, + "median_in": 16.49, + "p90_in": 52.43, + "scale_error_pct": 4.17 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.02, + "median_in": 2.83, + "p90_in": 14.72, + "scale_error_pct": -0.3 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.05, + "median_in": 6.93, + "p90_in": 41.08, + "scale_error_pct": 0.03 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.11, + "median_in": 6.42, + "p90_in": 21.34, + "scale_error_pct": 4.83 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.06, + "median_in": 13.29, + "p90_in": 37.59, + "scale_error_pct": 4.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.11, + "median_in": 4.65, + "p90_in": 27.06, + "scale_error_pct": 0.65 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 1.07, + "median_in": 7.65, + "p90_in": 47.82, + "scale_error_pct": 0.82 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 5.05, + "p90_in": 12.74, + "scale_error_pct": 1.11 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.01, + "median_in": 13.58, + "p90_in": 25.74, + "scale_error_pct": 1.95 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 3.52, + "p90_in": 13.2, + "scale_error_pct": -0.33 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.59, + "median_in": 7.07, + "p90_in": 29.8, + "scale_error_pct": -0.14 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 4.85, + "p90_in": 12.18, + "scale_error_pct": -0.52 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 13.26, + "p90_in": 24.26, + "scale_error_pct": 2.9 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 3.29, + "p90_in": 13.45, + "scale_error_pct": -1.05 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 7.82, + "p90_in": 30.05, + "scale_error_pct": -0.24 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 6.48, + "p90_in": 15.62, + "scale_error_pct": 3.76 + }, + "3-10m": { + "pairs": 20816, + "failed_pct": 0.01, + "median_in": 11.05, + "p90_in": 24.08, + "scale_error_pct": 3.77 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 4.76, + "p90_in": 16.26, + "scale_error_pct": 0.13 + }, + "3-10m": { + "pairs": 520400, + "failed_pct": 0.59, + "median_in": 7.3, + "p90_in": 27.99, + "scale_error_pct": 0.81 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + } + } + }, + "fused/da3metric": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.36, + "median_in": 7.6, + "p90_in": 18.98, + "scale_error_pct": -7.89 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.64, + "median_in": 22.01, + "p90_in": 50.63, + "scale_error_pct": -8.35 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.35, + "median_in": 3.73, + "p90_in": 20.55, + "scale_error_pct": 0.04 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.63, + "median_in": 9.72, + "p90_in": 48.66, + "scale_error_pct": -0.74 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.62, + "median_in": 7.32, + "p90_in": 19.16, + "scale_error_pct": -9.7 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.97, + "median_in": 26.24, + "p90_in": 59.43, + "scale_error_pct": -11.52 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.61, + "median_in": 3.38, + "p90_in": 19.19, + "scale_error_pct": -0.87 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.94, + "median_in": 11.38, + "p90_in": 53.88, + "scale_error_pct": -2.48 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 1.71, + "median_in": 9.47, + "p90_in": 37.53, + "scale_error_pct": -0.93 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.94, + "median_in": 22.95, + "p90_in": 113.43, + "scale_error_pct": -2.1 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 2.7, + "median_in": 7.56, + "p90_in": 57.88, + "scale_error_pct": 5.44 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 1.94, + "median_in": 15.37, + "p90_in": 148.02, + "scale_error_pct": 3.41 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.25, + "median_in": 7.65, + "p90_in": 19.63, + "scale_error_pct": -8.05 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.56, + "median_in": 21.61, + "p90_in": 55.88, + "scale_error_pct": -8.49 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.75, + "median_in": 3.95, + "p90_in": 18.27, + "scale_error_pct": 0.16 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.05, + "median_in": 9.56, + "p90_in": 41.28, + "scale_error_pct": -0.32 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.38, + "median_in": 7.28, + "p90_in": 20.26, + "scale_error_pct": -10.12 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.82, + "median_in": 28.03, + "p90_in": 65.28, + "scale_error_pct": -13.29 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.88, + "median_in": 3.56, + "p90_in": 17.34, + "scale_error_pct": -0.94 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.3, + "median_in": 11.32, + "p90_in": 51.12, + "scale_error_pct": -2.27 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 1.34, + "median_in": 9.51, + "p90_in": 43.13, + "scale_error_pct": -0.73 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.57, + "median_in": 24.27, + "p90_in": 96.74, + "scale_error_pct": -1.28 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.84, + "median_in": 8.12, + "p90_in": 62.49, + "scale_error_pct": 6.44 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 1.07, + "median_in": 16.73, + "p90_in": 128.58, + "scale_error_pct": 4.35 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.1, + "median_in": 7.18, + "p90_in": 20.51, + "scale_error_pct": -7.53 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.28, + "median_in": 20.96, + "p90_in": 59.26, + "scale_error_pct": -8.46 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.1, + "median_in": 4.28, + "p90_in": 21.81, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.28, + "median_in": 9.95, + "p90_in": 51.41, + "scale_error_pct": -0.36 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.15, + "median_in": 7.02, + "p90_in": 20.47, + "scale_error_pct": -9.57 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.4, + "median_in": 26.57, + "p90_in": 69.41, + "scale_error_pct": -13.5 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.15, + "median_in": 3.73, + "p90_in": 20.88, + "scale_error_pct": -1.12 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.4, + "median_in": 11.0, + "p90_in": 62.68, + "scale_error_pct": -2.34 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.63, + "median_in": 7.99, + "p90_in": 32.24, + "scale_error_pct": -0.99 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.28, + "median_in": 20.47, + "p90_in": 78.27, + "scale_error_pct": -2.04 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.63, + "median_in": 7.64, + "p90_in": 51.99, + "scale_error_pct": 5.6 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 1.29, + "median_in": 16.25, + "p90_in": 112.97, + "scale_error_pct": 3.41 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.04, + "median_in": 6.66, + "p90_in": 17.91, + "scale_error_pct": -7.01 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.09, + "median_in": 19.81, + "p90_in": 50.94, + "scale_error_pct": -7.99 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.61, + "median_in": 3.9, + "p90_in": 19.02, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.66, + "median_in": 8.77, + "p90_in": 45.61, + "scale_error_pct": -0.26 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.01, + "median_in": 7.26, + "p90_in": 18.65, + "scale_error_pct": -9.39 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.04, + "median_in": 25.45, + "p90_in": 59.67, + "scale_error_pct": -12.81 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.59, + "median_in": 3.72, + "p90_in": 20.43, + "scale_error_pct": -1.64 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.61, + "median_in": 11.28, + "p90_in": 54.94, + "scale_error_pct": -3.08 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.15, + "median_in": 7.27, + "p90_in": 28.64, + "scale_error_pct": -2.88 + }, + "3-10m": { + "pairs": 20816, + "failed_pct": 0.11, + "median_in": 18.01, + "p90_in": 58.55, + "scale_error_pct": -2.39 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.72, + "median_in": 6.17, + "p90_in": 41.49, + "scale_error_pct": 3.86 + }, + "3-10m": { + "pairs": 520400, + "failed_pct": 0.68, + "median_in": 13.84, + "p90_in": 65.41, + "scale_error_pct": 3.25 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + } + } + } + }, + "within 6 m": { + "oracle": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.71, + "p90_in": 1.61, + "scale_error_pct": -0.93 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.31, + "p90_in": 2.38, + "scale_error_pct": -0.8 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.54, + "p90_in": 1.57, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.93, + "p90_in": 2.76, + "scale_error_pct": -0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.13, + "p90_in": 0.95, + "scale_error_pct": -0.17 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.3, + "p90_in": 1.95, + "scale_error_pct": -0.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.39, + "p90_in": 1.51, + "scale_error_pct": 0.15 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.76, + "p90_in": 2.77, + "scale_error_pct": 0.11 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.3, + "p90_in": 1.1, + "scale_error_pct": -0.36 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 0.0, + "median_in": 0.49, + "p90_in": 1.66, + "scale_error_pct": -0.35 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.52, + "p90_in": 1.68, + "scale_error_pct": 0.12 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 0.0, + "median_in": 1.03, + "p90_in": 2.83, + "scale_error_pct": 0.11 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 1.51, + "scale_error_pct": -0.81 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.28, + "p90_in": 2.34, + "scale_error_pct": -0.77 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.49, + "p90_in": 1.57, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.92, + "p90_in": 2.82, + "scale_error_pct": -0.04 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.15, + "p90_in": 0.85, + "scale_error_pct": -0.18 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.32, + "p90_in": 1.82, + "scale_error_pct": -0.21 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.41, + "p90_in": 1.52, + "scale_error_pct": 0.14 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.81, + "p90_in": 2.79, + "scale_error_pct": 0.09 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.31, + "p90_in": 1.1, + "scale_error_pct": -0.38 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 0.0, + "median_in": 0.48, + "p90_in": 1.62, + "scale_error_pct": -0.34 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.51, + "p90_in": 1.71, + "scale_error_pct": 0.12 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 0.0, + "median_in": 1.01, + "p90_in": 2.81, + "scale_error_pct": 0.12 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 0.57, + "p90_in": 1.29, + "scale_error_pct": -0.68 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.13, + "p90_in": 2.23, + "scale_error_pct": -0.67 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.43, + "p90_in": 1.62, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.0, + "median_in": 0.8, + "p90_in": 3.12, + "scale_error_pct": -0.05 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.17, + "p90_in": 0.77, + "scale_error_pct": -0.21 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.36, + "p90_in": 1.64, + "scale_error_pct": -0.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.4, + "p90_in": 1.62, + "scale_error_pct": 0.09 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.81, + "p90_in": 2.99, + "scale_error_pct": 0.06 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.31, + "p90_in": 1.05, + "scale_error_pct": -0.4 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 0.0, + "median_in": 0.56, + "p90_in": 1.63, + "scale_error_pct": -0.41 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.49, + "p90_in": 1.77, + "scale_error_pct": 0.1 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 0.0, + "median_in": 0.96, + "p90_in": 3.01, + "scale_error_pct": 0.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 0.53, + "p90_in": 1.21, + "scale_error_pct": -0.56 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 1.03, + "p90_in": 2.02, + "scale_error_pct": -0.6 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.45, + "p90_in": 1.74, + "scale_error_pct": -0.06 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.0, + "median_in": 0.79, + "p90_in": 3.62, + "scale_error_pct": -0.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 0.0, + "median_in": 0.15, + "p90_in": 0.53, + "scale_error_pct": -0.18 + }, + "3-10m": { + "pairs": 18000, + "failed_pct": 0.0, + "median_in": 0.28, + "p90_in": 0.86, + "scale_error_pct": -0.18 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 0.0, + "median_in": 0.4, + "p90_in": 1.66, + "scale_error_pct": 0.07 + }, + "3-10m": { + "pairs": 450000, + "failed_pct": 0.0, + "median_in": 0.85, + "p90_in": 3.2, + "scale_error_pct": 0.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 6 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 0.0, + "median_in": 0.29, + "p90_in": 1.02, + "scale_error_pct": -0.37 + }, + "3-10m": { + "pairs": 17786, + "failed_pct": 0.0, + "median_in": 0.53, + "p90_in": 1.55, + "scale_error_pct": -0.39 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 0.0, + "median_in": 0.51, + "p90_in": 1.89, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 444650, + "failed_pct": 0.0, + "median_in": 1.01, + "p90_in": 3.22, + "scale_error_pct": 0.01 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 6 + } + } + }, + "fused/moge2": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.7, + "median_in": 6.88, + "p90_in": 14.89, + "scale_error_pct": 4.39 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 1.83, + "median_in": 15.5, + "p90_in": 30.4, + "scale_error_pct": 4.77 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.7, + "median_in": 1.78, + "p90_in": 8.65, + "scale_error_pct": -0.15 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.83, + "median_in": 3.5, + "p90_in": 17.52, + "scale_error_pct": -0.23 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 1.07, + "median_in": 5.39, + "p90_in": 14.82, + "scale_error_pct": 3.53 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 2.72, + "median_in": 13.51, + "p90_in": 33.47, + "scale_error_pct": 4.51 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.07, + "median_in": 1.82, + "p90_in": 9.91, + "scale_error_pct": -0.25 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 2.72, + "median_in": 4.07, + "p90_in": 29.1, + "scale_error_pct": 0.05 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 1.01, + "median_in": 7.83, + "p90_in": 35.53, + "scale_error_pct": 3.6 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 0.58, + "median_in": 15.09, + "p90_in": 68.66, + "scale_error_pct": 4.67 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.01, + "median_in": 2.82, + "p90_in": 26.09, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 0.58, + "median_in": 4.49, + "p90_in": 52.4, + "scale_error_pct": -0.07 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.49, + "median_in": 6.81, + "p90_in": 14.99, + "scale_error_pct": 2.42 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 1.68, + "median_in": 14.9, + "p90_in": 28.73, + "scale_error_pct": 3.75 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.49, + "median_in": 2.3, + "p90_in": 11.34, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.68, + "median_in": 3.96, + "p90_in": 21.1, + "scale_error_pct": 0.04 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.99, + "median_in": 5.21, + "p90_in": 15.32, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 2.6, + "median_in": 13.27, + "p90_in": 31.63, + "scale_error_pct": 2.53 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.99, + "median_in": 2.4, + "p90_in": 12.78, + "scale_error_pct": -0.26 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 2.6, + "median_in": 5.11, + "p90_in": 32.19, + "scale_error_pct": 0.16 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 1.0, + "median_in": 8.47, + "p90_in": 33.83, + "scale_error_pct": 1.22 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 0.58, + "median_in": 15.12, + "p90_in": 49.92, + "scale_error_pct": 2.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.0, + "median_in": 3.85, + "p90_in": 27.29, + "scale_error_pct": 0.75 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 0.58, + "median_in": 5.53, + "p90_in": 38.06, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.22, + "median_in": 6.14, + "p90_in": 14.4, + "scale_error_pct": 2.54 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 1.16, + "median_in": 13.88, + "p90_in": 25.94, + "scale_error_pct": 4.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.72, + "median_in": 2.47, + "p90_in": 15.68, + "scale_error_pct": 0.19 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.66, + "median_in": 4.37, + "p90_in": 33.08, + "scale_error_pct": 0.56 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.45, + "median_in": 5.6, + "p90_in": 14.98, + "scale_error_pct": -0.13 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 2.03, + "median_in": 13.66, + "p90_in": 30.36, + "scale_error_pct": 4.29 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.02, + "median_in": 2.37, + "p90_in": 19.75, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 2.6, + "median_in": 5.26, + "p90_in": 52.6, + "scale_error_pct": 1.1 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.44, + "median_in": 7.47, + "p90_in": 20.55, + "scale_error_pct": 2.81 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 0.3, + "median_in": 13.34, + "p90_in": 34.63, + "scale_error_pct": 3.31 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.01, + "median_in": 4.15, + "p90_in": 26.44, + "scale_error_pct": 0.8 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 0.88, + "median_in": 6.44, + "p90_in": 50.01, + "scale_error_pct": 1.03 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 5.4, + "p90_in": 12.49, + "scale_error_pct": 0.68 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.01, + "median_in": 12.4, + "p90_in": 22.62, + "scale_error_pct": 3.0 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 2.78, + "p90_in": 10.79, + "scale_error_pct": 0.11 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.59, + "median_in": 4.27, + "p90_in": 21.13, + "scale_error_pct": 0.18 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 0.11, + "median_in": 4.85, + "p90_in": 12.45, + "scale_error_pct": -1.26 + }, + "3-10m": { + "pairs": 18000, + "failed_pct": 0.23, + "median_in": 11.55, + "p90_in": 25.82, + "scale_error_pct": 2.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 0.77, + "median_in": 2.77, + "p90_in": 10.03, + "scale_error_pct": 0.16 + }, + "3-10m": { + "pairs": 450000, + "failed_pct": 0.9, + "median_in": 5.47, + "p90_in": 25.64, + "scale_error_pct": 1.4 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 6 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 0.08, + "median_in": 6.37, + "p90_in": 14.58, + "scale_error_pct": 1.25 + }, + "3-10m": { + "pairs": 17786, + "failed_pct": 0.16, + "median_in": 7.78, + "p90_in": 18.54, + "scale_error_pct": -0.32 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 0.74, + "median_in": 4.03, + "p90_in": 15.29, + "scale_error_pct": 1.79 + }, + "3-10m": { + "pairs": 444650, + "failed_pct": 0.84, + "median_in": 6.14, + "p90_in": 25.18, + "scale_error_pct": 0.42 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 6 + } + } + }, + "fused/da3metric": { + "1": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 2.36, + "median_in": 6.53, + "p90_in": 19.28, + "scale_error_pct": -5.9 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 4.59, + "median_in": 14.92, + "p90_in": 42.72, + "scale_error_pct": -7.6 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 2.36, + "median_in": 3.34, + "p90_in": 24.77, + "scale_error_pct": -0.16 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 4.59, + "median_in": 7.4, + "p90_in": 52.22, + "scale_error_pct": -1.07 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 4.99, + "median_in": 6.38, + "p90_in": 27.82, + "scale_error_pct": -6.78 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 6.39, + "median_in": 15.4, + "p90_in": 52.92, + "scale_error_pct": -7.44 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 4.99, + "median_in": 2.52, + "p90_in": 34.67, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 6.39, + "median_in": 6.83, + "p90_in": 118.05, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 3.9, + "median_in": 9.15, + "p90_in": 40.85, + "scale_error_pct": -1.42 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 2.46, + "median_in": 19.8, + "p90_in": 142.77, + "scale_error_pct": -1.65 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 3.9, + "median_in": 5.71, + "p90_in": 66.86, + "scale_error_pct": 3.98 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 2.46, + "median_in": 10.79, + "p90_in": 171.59, + "scale_error_pct": 3.68 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + } + }, + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 1.28, + "median_in": 5.71, + "p90_in": 17.72, + "scale_error_pct": -5.58 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 3.58, + "median_in": 13.55, + "p90_in": 38.16, + "scale_error_pct": -7.37 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.28, + "median_in": 3.61, + "p90_in": 21.63, + "scale_error_pct": 0.07 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 3.58, + "median_in": 7.14, + "p90_in": 44.43, + "scale_error_pct": -0.39 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 3.43, + "median_in": 5.84, + "p90_in": 20.74, + "scale_error_pct": -6.87 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 4.98, + "median_in": 13.79, + "p90_in": 41.85, + "scale_error_pct": -7.34 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 3.43, + "median_in": 2.84, + "p90_in": 27.74, + "scale_error_pct": -0.24 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 4.98, + "median_in": 7.66, + "p90_in": 76.97, + "scale_error_pct": 0.25 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 3.84, + "median_in": 8.33, + "p90_in": 37.07, + "scale_error_pct": -1.85 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 2.46, + "median_in": 18.8, + "p90_in": 103.09, + "scale_error_pct": -1.69 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 3.84, + "median_in": 6.2, + "p90_in": 55.15, + "scale_error_pct": 4.59 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 2.46, + "median_in": 11.27, + "p90_in": 132.07, + "scale_error_pct": 3.93 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7 + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.78, + "median_in": 5.6, + "p90_in": 18.1, + "scale_error_pct": -5.66 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 2.98, + "median_in": 13.14, + "p90_in": 36.05, + "scale_error_pct": -7.01 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.77, + "median_in": 3.99, + "p90_in": 25.19, + "scale_error_pct": 0.25 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 3.98, + "median_in": 7.76, + "p90_in": 51.39, + "scale_error_pct": -0.17 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 2.41, + "median_in": 5.64, + "p90_in": 20.81, + "scale_error_pct": -6.49 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 4.04, + "median_in": 12.69, + "p90_in": 38.37, + "scale_error_pct": -6.5 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 2.98, + "median_in": 3.27, + "p90_in": 30.94, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 4.6, + "median_in": 8.17, + "p90_in": 76.77, + "scale_error_pct": 0.42 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 1.96, + "median_in": 7.32, + "p90_in": 23.66, + "scale_error_pct": -1.72 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 1.59, + "median_in": 14.85, + "p90_in": 74.39, + "scale_error_pct": -2.01 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 2.51, + "median_in": 6.01, + "p90_in": 44.59, + "scale_error_pct": 4.3 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 2.17, + "median_in": 10.7, + "p90_in": 109.42, + "scale_error_pct": 3.46 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7 + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.06, + "median_in": 5.06, + "p90_in": 14.67, + "scale_error_pct": -5.63 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.24, + "median_in": 11.89, + "p90_in": 33.12, + "scale_error_pct": -6.85 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 4.63, + "median_in": 3.57, + "p90_in": 20.15, + "scale_error_pct": 0.52 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 4.8, + "median_in": 6.46, + "p90_in": 38.85, + "scale_error_pct": 0.05 + } + }, + "tape_calibration_success_pct": 95.4, + "groups": 7 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 0.44, + "median_in": 4.85, + "p90_in": 17.73, + "scale_error_pct": -5.78 + }, + "3-10m": { + "pairs": 18000, + "failed_pct": 0.66, + "median_in": 10.36, + "p90_in": 36.45, + "scale_error_pct": -5.75 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 3.1, + "median_in": 2.96, + "p90_in": 20.58, + "scale_error_pct": 0.18 + }, + "3-10m": { + "pairs": 450000, + "failed_pct": 3.32, + "median_in": 6.82, + "p90_in": 38.05, + "scale_error_pct": 0.38 + } + }, + "tape_calibration_success_pct": 97.3, + "groups": 6 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 1.08, + "median_in": 6.08, + "p90_in": 18.96, + "scale_error_pct": -2.93 + }, + "3-10m": { + "pairs": 17786, + "failed_pct": 1.07, + "median_in": 8.43, + "p90_in": 33.23, + "scale_error_pct": -3.35 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 3.73, + "median_in": 5.0, + "p90_in": 26.84, + "scale_error_pct": 3.56 + }, + "3-10m": { + "pairs": 444650, + "failed_pct": 3.76, + "median_in": 8.44, + "p90_in": 47.01, + "scale_error_pct": 2.49 + } + }, + "tape_calibration_success_pct": 97.3, + "groups": 6 + } + } + } + } + }, + "single": { + "moge2": { + "all points": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 135000, + "failed_pct": 0.11, + "median_in": 6.81, + "p90_in": 19.13, + "scale_error_pct": 0.94 + }, + "3-10m": { + "pairs": 135000, + "failed_pct": 0.17, + "median_in": 18.42, + "p90_in": 52.59, + "scale_error_pct": 0.55 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3375000, + "failed_pct": 0.29, + "median_in": 2.46, + "p90_in": 9.72, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 3375000, + "failed_pct": 0.35, + "median_in": 5.86, + "p90_in": 25.35, + "scale_error_pct": -0.19 + } + }, + "tape_calibration_success_pct": 99.8, + "photos": 45 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 135000, + "failed_pct": 0.18, + "median_in": 6.55, + "p90_in": 19.19, + "scale_error_pct": 0.33 + }, + "3-10m": { + "pairs": 133158, + "failed_pct": 0.33, + "median_in": 17.8, + "p90_in": 56.07, + "scale_error_pct": -0.12 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3375000, + "failed_pct": 0.36, + "median_in": 2.47, + "p90_in": 9.67, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 3328950, + "failed_pct": 0.51, + "median_in": 5.83, + "p90_in": 25.36, + "scale_error_pct": -0.05 + } + }, + "tape_calibration_success_pct": 99.8, + "photos": 45 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 135000, + "failed_pct": 0.64, + "median_in": 8.01, + "p90_in": 24.92, + "scale_error_pct": 4.29 + }, + "3-10m": { + "pairs": 132648, + "failed_pct": 0.3, + "median_in": 17.1, + "p90_in": 63.42, + "scale_error_pct": 2.68 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3375000, + "failed_pct": 0.81, + "median_in": 4.44, + "p90_in": 25.05, + "scale_error_pct": 1.11 + }, + "3-10m": { + "pairs": 3316200, + "failed_pct": 0.48, + "median_in": 7.23, + "p90_in": 37.39, + "scale_error_pct": 0.53 + } + }, + "tape_calibration_success_pct": 99.8, + "photos": 45 + } + }, + "within 6 m": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 135000, + "failed_pct": 1.47, + "median_in": 6.78, + "p90_in": 19.46, + "scale_error_pct": 3.47 + }, + "3-10m": { + "pairs": 135000, + "failed_pct": 2.27, + "median_in": 15.06, + "p90_in": 41.83, + "scale_error_pct": 4.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3375000, + "failed_pct": 3.28, + "median_in": 1.58, + "p90_in": 9.57, + "scale_error_pct": -0.03 + }, + "3-10m": { + "pairs": 3375000, + "failed_pct": 4.01, + "median_in": 3.0, + "p90_in": 17.8, + "scale_error_pct": -0.01 + } + }, + "tape_calibration_success_pct": 97.9, + "photos": 45 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 102000, + "failed_pct": 3.4, + "median_in": 7.12, + "p90_in": 24.74, + "scale_error_pct": 1.01 + }, + "3-10m": { + "pairs": 99955, + "failed_pct": 7.75, + "median_in": 15.62, + "p90_in": 64.2, + "scale_error_pct": 1.57 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2550000, + "failed_pct": 5.38, + "median_in": 1.8, + "p90_in": 15.11, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 2498875, + "failed_pct": 9.09, + "median_in": 3.63, + "p90_in": 58.16, + "scale_error_pct": 0.27 + } + }, + "tape_calibration_success_pct": 97.2, + "photos": 34 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 102000, + "failed_pct": 0.97, + "median_in": 8.34, + "p90_in": 31.78, + "scale_error_pct": 3.6 + }, + "3-10m": { + "pairs": 99802, + "failed_pct": 0.71, + "median_in": 14.99, + "p90_in": 52.99, + "scale_error_pct": 4.15 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2550000, + "failed_pct": 3.74, + "median_in": 3.67, + "p90_in": 26.88, + "scale_error_pct": 0.61 + }, + "3-10m": { + "pairs": 2495050, + "failed_pct": 3.58, + "median_in": 5.82, + "p90_in": 36.78, + "scale_error_pct": 0.85 + } + }, + "tape_calibration_success_pct": 97.2, + "photos": 34 + } + } + }, + "da3metric": { + "all points": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 135000, + "failed_pct": 0.48, + "median_in": 7.55, + "p90_in": 24.88, + "scale_error_pct": -8.62 + }, + "3-10m": { + "pairs": 135000, + "failed_pct": 0.81, + "median_in": 21.51, + "p90_in": 72.6, + "scale_error_pct": -9.66 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3375000, + "failed_pct": 1.09, + "median_in": 3.44, + "p90_in": 15.3, + "scale_error_pct": -0.07 + }, + "3-10m": { + "pairs": 3375000, + "failed_pct": 1.41, + "median_in": 8.63, + "p90_in": 38.01, + "scale_error_pct": -0.69 + } + }, + "tape_calibration_success_pct": 99.4, + "photos": 45 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 135000, + "failed_pct": 0.83, + "median_in": 8.08, + "p90_in": 26.71, + "scale_error_pct": -10.0 + }, + "3-10m": { + "pairs": 133158, + "failed_pct": 1.45, + "median_in": 22.6, + "p90_in": 78.45, + "scale_error_pct": -11.46 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3375000, + "failed_pct": 1.44, + "median_in": 3.47, + "p90_in": 15.35, + "scale_error_pct": -0.68 + }, + "3-10m": { + "pairs": 3328950, + "failed_pct": 2.05, + "median_in": 8.88, + "p90_in": 41.36, + "scale_error_pct": -1.07 + } + }, + "tape_calibration_success_pct": 99.4, + "photos": 45 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 135000, + "failed_pct": 2.69, + "median_in": 7.31, + "p90_in": 39.35, + "scale_error_pct": -2.21 + }, + "3-10m": { + "pairs": 132648, + "failed_pct": 1.54, + "median_in": 16.94, + "p90_in": 93.0, + "scale_error_pct": -4.39 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3375000, + "failed_pct": 3.23, + "median_in": 8.74, + "p90_in": 61.86, + "scale_error_pct": 6.66 + }, + "3-10m": { + "pairs": 3316200, + "failed_pct": 2.13, + "median_in": 13.65, + "p90_in": 95.38, + "scale_error_pct": 3.6 + } + }, + "tape_calibration_success_pct": 99.4, + "photos": 45 + } + }, + "within 6 m": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 135000, + "failed_pct": 3.72, + "median_in": 6.03, + "p90_in": 20.2, + "scale_error_pct": -6.63 + }, + "3-10m": { + "pairs": 135000, + "failed_pct": 5.07, + "median_in": 13.94, + "p90_in": 43.43, + "scale_error_pct": -7.37 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3375000, + "failed_pct": 5.89, + "median_in": 2.91, + "p90_in": 25.31, + "scale_error_pct": -0.12 + }, + "3-10m": { + "pairs": 3375000, + "failed_pct": 7.29, + "median_in": 6.09, + "p90_in": 62.64, + "scale_error_pct": -0.71 + } + }, + "tape_calibration_success_pct": 96.6, + "photos": 45 + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 102000, + "failed_pct": 7.74, + "median_in": 6.23, + "p90_in": 35.3, + "scale_error_pct": -7.17 + }, + "3-10m": { + "pairs": 99955, + "failed_pct": 12.84, + "median_in": 14.58, + "p90_in": Infinity, + "scale_error_pct": -7.31 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2550000, + "failed_pct": 9.84, + "median_in": 2.93, + "p90_in": 171.29, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 2498875, + "failed_pct": 14.35, + "median_in": 6.65, + "p90_in": Infinity, + "scale_error_pct": 0.22 + } + }, + "tape_calibration_success_pct": 95.5, + "photos": 34 + }, + "edges": { + "none": { + "1-3m": { + "pairs": 102000, + "failed_pct": 4.27, + "median_in": 6.25, + "p90_in": 34.96, + "scale_error_pct": -1.23 + }, + "3-10m": { + "pairs": 99802, + "failed_pct": 3.11, + "median_in": 9.51, + "p90_in": 73.11, + "scale_error_pct": -0.15 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2550000, + "failed_pct": 8.24, + "median_in": 7.1, + "p90_in": 117.42, + "scale_error_pct": 5.52 + }, + "3-10m": { + "pairs": 2495050, + "failed_pct": 7.46, + "median_in": 12.41, + "p90_in": 185.2, + "scale_error_pct": 5.94 + } + }, + "tape_calibration_success_pct": 95.5, + "photos": 34 + } + } + } + } + } +} \ No newline at end of file diff --git a/experiments/evals/results/eth3d_recon.md b/experiments/evals/results/eth3d_recon.md new file mode 100644 index 000000000..9e1dd4a24 --- /dev/null +++ b/experiments/evals/results/eth3d_recon.md @@ -0,0 +1,231 @@ +# ETH3D reconstruction accuracy (generated by `uv run python -m evals.recon score`) + +|Error in the distance between two scanned surface points|, inches, median / p90, against the laser scan. Scale error: median of predicted / true length, minus 1, per span bin. Failed pairs (no prediction, or a taped reference no scale could match) count as infinite error; 'fails' means more than 10% failed. Cohorts: surface interior (any scanned surface away from depth edges), vertical interior (walls, fences and other vertical surfaces), edges (the near side of depth edges: window frames, equipment and fence edges). + +## facade, all points, surface interior + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 6.3 / 13.2 | 21.9 / 52.5 | -6.6% | -7.9% | 2.9 / 12.3 | 9.2 / 45.4 | 100% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 10.7 / 20.1 | 36.6 / 78.0 | -13.8% | -15.1% | 3.8 / 14.8 | 12.0 / 56.5 | 100% | +| scan rendered as depth (evaluation floor) | 1 | 0.2 / 1.6 | 0.6 / 2.9 | -0.2% | -0.2% | 0.3 / 2.7 | 0.7 / 6.2 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.2 / 1.6 | 0.6 / 3.0 | -0.2% | -0.2% | 0.3 / 2.8 | 0.7 / 6.2 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.2 / 1.6 | 0.6 / 2.8 | -0.2% | -0.2% | 0.3 / 2.9 | 0.7 / 6.6 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.2 / 1.6 | 0.7 / 2.8 | -0.2% | -0.2% | 0.3 / 2.9 | 0.8 / 6.7 | 100% | +| MoGe-2 per photo + true poses | 1 | 5.6 / 11.6 | 20.2 / 47.3 | -5.6% | -7.0% | 3.0 / 12.1 | 10.3 / 44.8 | 100% | +| MoGe-2 per photo + true poses | 2 | 5.9 / 12.0 | 20.4 / 47.7 | -6.2% | -7.3% | 3.0 / 12.6 | 9.8 / 43.6 | 100% | +| MoGe-2 per photo + true poses | 4 | 5.7 / 11.6 | 19.2 / 46.9 | -5.9% | -7.0% | 3.3 / 12.3 | 10.8 / 42.1 | 100% | +| MoGe-2 per photo + true poses | 8 | 5.9 / 11.8 | 19.9 / 46.1 | -6.0% | -7.2% | 3.5 / 14.4 | 11.0 / 49.0 | 98% | +| Depth Anything 3 metric per photo + true poses | 1 | 11.4 / 20.9 | 39.2 / 81.5 | -14.8% | -16.3% | 3.6 / 13.9 | 12.1 / 54.7 | 100% | +| Depth Anything 3 metric per photo + true poses | 2 | 11.3 / 21.2 | 38.1 / 82.7 | -14.3% | -15.9% | 4.0 / 16.9 | 13.3 / 59.2 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 11.0 / 20.6 | 36.6 / 77.7 | -14.0% | -15.4% | 4.1 / 15.8 | 13.5 / 55.5 | 98% | +| Depth Anything 3 metric per photo + true poses | 8 | 10.7 / 19.9 | 36.0 / 77.0 | -13.2% | -15.1% | 4.2 / 15.4 | 13.7 / 56.0 | 98% | + +## facade, all points, vertical interior + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 6.3 / 12.9 | 21.6 / 54.1 | -7.0% | -8.1% | 2.8 / 11.3 | 8.9 / 46.1 | 100% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 10.8 / 19.9 | 37.0 / 78.1 | -14.1% | -15.5% | 3.6 / 13.7 | 11.5 / 56.8 | 100% | +| scan rendered as depth (evaluation floor) | 1 | 0.2 / 0.9 | 0.6 / 2.0 | -0.2% | -0.2% | 0.2 / 2.2 | 0.5 / 5.6 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.2 / 1.0 | 0.6 / 2.0 | -0.2% | -0.2% | 0.2 / 2.3 | 0.5 / 5.7 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.2 / 1.0 | 0.6 / 1.9 | -0.2% | -0.2% | 0.2 / 2.5 | 0.5 / 6.1 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.2 / 1.0 | 0.6 / 1.9 | -0.2% | -0.2% | 0.2 / 2.6 | 0.6 / 6.1 | 100% | +| MoGe-2 per photo + true poses | 1 | 5.5 / 10.9 | 19.3 / 46.2 | -5.9% | -7.1% | 2.8 / 10.9 | 9.7 / 43.9 | 100% | +| MoGe-2 per photo + true poses | 2 | 5.9 / 11.2 | 19.6 / 46.7 | -6.5% | -7.4% | 2.8 / 11.3 | 9.2 / 42.7 | 100% | +| MoGe-2 per photo + true poses | 4 | 5.6 / 10.9 | 18.3 / 46.5 | -6.2% | -7.0% | 3.1 / 11.1 | 10.3 / 41.0 | 100% | +| MoGe-2 per photo + true poses | 8 | 5.9 / 11.2 | 19.1 / 46.5 | -6.3% | -7.3% | 3.2 / 13.0 | 10.6 / 48.5 | 98% | +| Depth Anything 3 metric per photo + true poses | 1 | 11.7 / 20.7 | 39.2 / 79.3 | -15.3% | -17.0% | 3.4 / 12.5 | 11.3 / 51.2 | 100% | +| Depth Anything 3 metric per photo + true poses | 2 | 11.6 / 21.1 | 38.4 / 81.0 | -14.9% | -16.6% | 3.7 / 15.3 | 12.5 / 56.1 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 11.2 / 20.4 | 37.1 / 77.7 | -14.6% | -16.2% | 3.9 / 14.4 | 12.9 / 53.9 | 98% | +| Depth Anything 3 metric per photo + true poses | 8 | 10.8 / 19.7 | 36.2 / 77.1 | -13.8% | -15.6% | 4.0 / 14.2 | 13.1 / 54.8 | 98% | + +## facade, all points, edges + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 5.7 / 20.8 | 15.8 / 48.4 | -2.9% | -4.7% | 4.4 / 23.8 | 11.7 / 50.7 | 100% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 9.5 / 27.7 | 26.4 / 68.8 | -8.9% | -10.4% | 6.9 / 41.5 | 17.5 / 77.2 | 100% | +| scan rendered as depth (evaluation floor) | 1 | 0.5 / 3.4 | 1.0 / 5.4 | -0.3% | -0.2% | 0.6 / 4.2 | 1.4 / 7.7 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.5 / 3.6 | 1.0 / 5.7 | -0.3% | -0.2% | 0.7 / 4.3 | 1.4 / 7.8 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.5 / 3.5 | 1.0 / 5.3 | -0.2% | -0.2% | 0.6 / 4.2 | 1.4 / 7.9 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.5 / 3.5 | 1.0 / 5.3 | -0.3% | -0.2% | 0.7 / 4.3 | 1.4 / 8.2 | 100% | +| MoGe-2 per photo + true poses | 1 | 5.3 / 25.3 | 15.1 / 51.1 | -1.8% | -3.2% | 4.9 / 28.2 | 14.0 / 55.4 | 100% | +| MoGe-2 per photo + true poses | 2 | 5.5 / 26.1 | 16.0 / 48.5 | -2.3% | -4.0% | 5.0 / 30.5 | 13.8 / 55.8 | 100% | +| MoGe-2 per photo + true poses | 4 | 5.1 / 24.6 | 14.8 / 45.4 | -2.3% | -3.9% | 5.2 / 28.5 | 13.7 / 52.7 | 100% | +| MoGe-2 per photo + true poses | 8 | 5.3 / 28.2 | 15.3 / 53.2 | -2.4% | -4.0% | 5.7 / 36.0 | 14.7 / 65.2 | 98% | +| Depth Anything 3 metric per photo + true poses | 1 | 9.8 / 29.2 | 30.4 / 71.9 | -8.5% | -11.6% | 6.8 / 42.7 | 19.5 / 82.0 | 100% | +| Depth Anything 3 metric per photo + true poses | 2 | 9.2 / 29.1 | 29.4 / 70.8 | -8.1% | -10.8% | 8.0 / 45.5 | 21.9 / 87.7 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 9.0 / 26.5 | 27.8 / 67.2 | -7.9% | -10.3% | 7.6 / 44.5 | 20.6 / 83.5 | 98% | +| Depth Anything 3 metric per photo + true poses | 8 | 9.3 / 28.5 | 28.2 / 69.6 | -8.4% | -10.3% | 7.4 / 43.7 | 19.7 / 78.5 | 98% | + +## facade, within 6 m, surface interior + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 7.7 / 20.0 | 16.9 / 31.6 | +10.3% | +11.7% | 0.9 / 6.5 | 2.2 / 12.1 | 100% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 6.5 / 18.2 | 16.4 / 36.5 | -8.2% | -10.9% | 2.1 / 10.9 | 4.5 / 19.7 | 100% | +| scan rendered as depth (evaluation floor) | 1 | 0.8 / 1.4 | 1.6 / 2.1 | -1.3% | -1.1% | 0.2 / 1.6 | 0.5 / 2.5 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.8 / 1.4 | 1.6 / 2.1 | -1.3% | -1.2% | 0.3 / 1.7 | 0.5 / 2.6 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.8 / 1.4 | 1.6 / 2.1 | -1.3% | -1.1% | 0.2 / 1.7 | 0.5 / 2.6 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.8 / 1.4 | 1.6 / 2.1 | -1.3% | -1.1% | 0.2 / 1.8 | 0.5 / 2.8 | 100% | +| MoGe-2 per photo + true poses | 1 | 8.4 / 20.1 | 15.9 / 30.2 | +10.0% | +9.8% | 0.7 / 6.3 | 1.3 / 10.1 | 100% | +| MoGe-2 per photo + true poses | 2 | 8.5 / 19.4 | 16.0 / 29.8 | +10.0% | +9.7% | 0.7 / 6.3 | 1.3 / 10.7 | 100% | +| MoGe-2 per photo + true poses | 4 | 8.3 / 19.4 | 15.6 / 28.4 | +9.8% | +9.6% | 0.7 / 6.7 | 1.3 / 11.2 | 100% | +| MoGe-2 per photo + true poses | 8 | 8.2 / 19.1 | 15.1 / 28.4 | +9.4% | +9.4% | 0.7 / 10.0 | 1.4 / 17.1 | 100% | +| Depth Anything 3 metric per photo + true poses | 1 | 4.6 / 10.0 | 12.0 / 16.0 | -6.7% | -9.2% | 1.0 / 6.2 | 1.5 / 11.6 | 100% | +| Depth Anything 3 metric per photo + true poses | 2 | 5.1 / 10.2 | 12.2 / 16.0 | -7.5% | -9.5% | 1.2 / 8.7 | 1.7 / 16.3 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 5.2 / 10.2 | 12.1 / 16.0 | -7.4% | -9.5% | 1.2 / 9.2 | 1.7 / 16.8 | 100% | +| Depth Anything 3 metric per photo + true poses | 8 | 5.0 / 10.0 | 12.0 / 16.0 | -7.8% | -9.5% | 1.1 / 8.7 | 1.5 / 17.3 | 100% | + +## facade, within 6 m, vertical interior + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 14.9 / 25.4 | 26.1 / 37.0 | +20.9% | +17.5% | 2.7 / 8.2 | 4.8 / 13.0 | 100% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 3.5 / 9.3 | 4.8 / 13.0 | +0.2% | -2.5% | 4.1 / 11.8 | 6.6 / 18.7 | 100% | +| scan rendered as depth (evaluation floor) | 1 | 0.0 / 0.4 | 0.1 / 0.6 | -0.0% | -0.1% | 0.5 / 2.0 | 1.0 / 4.0 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.0 / 0.4 | 0.1 / 0.6 | -0.0% | -0.1% | 0.5 / 2.1 | 1.0 / 4.0 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.1 / 0.3 | 0.1 / 0.6 | -0.1% | -0.1% | 0.5 / 2.2 | 1.0 / 4.1 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.1 / 0.3 | 0.1 / 0.6 | -0.1% | -0.1% | 0.4 / 2.1 | 0.9 / 4.1 | 100% | +| MoGe-2 per photo + true poses | 1 | 16.3 / 25.6 | 26.6 / 32.9 | +21.4% | +18.0% | 3.3 / 8.8 | 4.7 / 11.2 | 100% | +| MoGe-2 per photo + true poses | 2 | 15.8 / 24.2 | 26.1 / 32.3 | +20.7% | +17.7% | 3.4 / 8.7 | 4.9 / 12.1 | 100% | +| MoGe-2 per photo + true poses | 4 | 15.8 / 23.3 | 25.4 / 31.1 | +20.4% | +17.3% | 3.4 / 8.8 | 5.0 / 13.1 | 100% | +| MoGe-2 per photo + true poses | 8 | 15.4 / 22.6 | 25.3 / 31.0 | +20.3% | +17.3% | 5.0 / 13.5 | 6.6 / 28.4 | 100% | +| Depth Anything 3 metric per photo + true poses | 1 | 1.5 / 4.5 | 1.8 / 4.6 | +0.3% | -0.9% | 2.8 / 8.1 | 4.9 / 14.6 | 100% | +| Depth Anything 3 metric per photo + true poses | 2 | 2.2 / 6.7 | 2.5 / 7.3 | +1.1% | -1.3% | 4.1 / 11.1 | 6.3 / 20.3 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 2.5 / 7.0 | 2.4 / 7.0 | +1.6% | -0.8% | 4.3 / 11.8 | 6.6 / 21.3 | 100% | +| Depth Anything 3 metric per photo + true poses | 8 | 2.4 / 5.7 | 4.0 / 7.9 | -0.6% | -2.6% | 3.7 / 11.2 | 5.3 / 24.3 | 100% | + +## facade, within 6 m, edges + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 11.2 / 24.3 | 30.2 / 43.7 | +19.4% | +16.7% | 3.0 / 9.3 | 5.8 / 17.6 | 100% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 3.8 / 39.4 | 8.0 / 19.7 | +1.2% | -2.9% | 4.6 / 38.4 | 8.8 / 29.1 | 100% | +| scan rendered as depth (evaluation floor) | 1 | 0.6 / 1.4 | 1.4 / 3.2 | -0.5% | -0.7% | 0.7 / 1.8 | 1.6 / 4.5 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.6 / 1.4 | 1.4 / 3.2 | -0.4% | -0.7% | 0.7 / 1.9 | 1.7 / 4.7 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.6 / 1.4 | 1.6 / 3.3 | -0.5% | -0.8% | 0.7 / 1.9 | 1.8 / 4.7 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.5 / 1.3 | 1.6 / 3.3 | -0.4% | -0.8% | 0.7 / 1.8 | 1.9 / 5.3 | 100% | +| MoGe-2 per photo + true poses | 1 | 10.4 / 19.2 | 30.8 / 37.5 | +17.8% | +16.2% | 2.4 / 6.7 | 5.5 / 14.6 | 100% | +| MoGe-2 per photo + true poses | 2 | 10.2 / 18.6 | 30.9 / 38.1 | +17.3% | +16.3% | 2.4 / 6.8 | 5.7 / 16.0 | 100% | +| MoGe-2 per photo + true poses | 4 | 9.6 / 18.5 | 30.2 / 37.7 | +16.9% | +16.1% | 2.4 / 7.2 | 6.0 / 18.0 | 100% | +| MoGe-2 per photo + true poses | 8 | 8.9 / 17.4 | 30.6 / 37.6 | +16.2% | +16.5% | 2.9 / 9.4 | 7.5 / 41.8 | 100% | +| Depth Anything 3 metric per photo + true poses | 1 | 1.1 / 70.2 | 1.8 / 4.6 | -0.0% | -0.7% | 2.7 / 66.3 | 6.8 / 26.5 | 100% | +| Depth Anything 3 metric per photo + true poses | 2 | 1.5 / 66.0 | 3.4 / 6.7 | -0.1% | -1.7% | 3.5 / 61.6 | 8.0 / 30.8 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 1.3 / 68.9 | 3.0 / 8.1 | +0.9% | -1.3% | 3.5 / 66.1 | 9.1 / 33.6 | 100% | +| Depth Anything 3 metric per photo + true poses | 8 | 1.6 / 53.3 | 5.1 / 8.7 | +0.4% | -2.6% | 3.2 / 53.8 | 8.6 / 37.6 | 100% | + +## electro, all points, surface interior + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 6.8 / 19.1 | 18.4 / 52.6 | +0.9% | +0.6% | 2.5 / 9.7 | 5.9 / 25.4 | 100% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 7.5 / 24.9 | 21.5 / 72.6 | -8.6% | -9.7% | 3.4 / 15.3 | 8.6 / 38.0 | 99% | +| scan rendered as depth (evaluation floor) | 1 | 0.7 / 2.4 | 1.4 / 5.2 | -0.6% | -0.6% | 0.7 / 2.8 | 1.6 / 6.6 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.7 / 2.3 | 1.4 / 5.0 | -0.6% | -0.6% | 0.7 / 2.9 | 1.6 / 6.6 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.7 / 2.2 | 1.4 / 4.9 | -0.6% | -0.6% | 0.7 / 2.8 | 1.6 / 6.7 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.7 / 1.9 | 1.4 / 4.1 | -0.6% | -0.6% | 0.7 / 2.4 | 1.4 / 5.1 | 100% | +| MoGe-2 per photo + true poses | 1 | 7.6 / 17.9 | 21.9 / 49.2 | +4.1% | +5.1% | 2.9 / 13.2 | 6.9 / 37.3 | 100% | +| MoGe-2 per photo + true poses | 2 | 6.3 / 16.6 | 17.5 / 42.2 | +2.2% | +3.4% | 3.2 / 14.0 | 7.0 / 37.4 | 100% | +| MoGe-2 per photo + true poses | 4 | 5.9 / 16.8 | 16.5 / 49.8 | +2.5% | +3.7% | 3.2 / 15.2 | 6.9 / 39.4 | 99% | +| MoGe-2 per photo + true poses | 8 | 5.0 / 12.7 | 13.6 / 25.7 | +1.1% | +1.9% | 3.5 / 13.2 | 7.1 / 29.8 | 99% | +| Depth Anything 3 metric per photo + true poses | 1 | 7.6 / 19.0 | 22.0 / 50.6 | -7.9% | -8.3% | 3.7 / 20.6 | 9.7 / 48.7 | 99% | +| Depth Anything 3 metric per photo + true poses | 2 | 7.7 / 19.6 | 21.6 / 55.9 | -8.1% | -8.5% | 4.0 / 18.3 | 9.6 / 41.3 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 7.2 / 20.5 | 21.0 / 59.3 | -7.5% | -8.5% | 4.3 / 21.8 | 9.9 / 51.4 | 99% | +| Depth Anything 3 metric per photo + true poses | 8 | 6.7 / 17.9 | 19.8 / 50.9 | -7.0% | -8.0% | 3.9 / 19.0 | 8.8 / 45.6 | 99% | + +## electro, all points, vertical interior + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 6.5 / 19.2 | 17.8 / 56.1 | +0.3% | -0.1% | 2.5 / 9.7 | 5.8 / 25.4 | 100% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 8.1 / 26.7 | 22.6 / 78.5 | -10.0% | -11.5% | 3.5 / 15.3 | 8.9 / 41.4 | 99% | +| scan rendered as depth (evaluation floor) | 1 | 0.3 / 1.4 | 0.8 / 4.5 | -0.3% | -0.4% | 0.5 / 2.5 | 1.3 / 6.2 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.3 / 1.2 | 0.9 / 4.2 | -0.3% | -0.4% | 0.6 / 2.5 | 1.4 / 6.3 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.3 / 1.3 | 0.9 / 4.4 | -0.3% | -0.4% | 0.6 / 2.4 | 1.6 / 6.2 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.3 / 1.0 | 0.8 / 3.1 | -0.3% | -0.4% | 0.6 / 2.1 | 1.5 / 5.2 | 100% | +| MoGe-2 per photo + true poses | 1 | 7.3 / 17.9 | 22.8 / 56.2 | +4.3% | +7.2% | 2.5 / 12.4 | 7.1 / 38.1 | 100% | +| MoGe-2 per photo + true poses | 2 | 6.1 / 16.2 | 17.6 / 45.5 | +1.7% | +4.7% | 2.8 / 13.4 | 7.0 / 38.5 | 100% | +| MoGe-2 per photo + true poses | 4 | 5.6 / 16.2 | 16.5 / 52.4 | +1.3% | +4.2% | 2.8 / 14.7 | 6.9 / 41.1 | 99% | +| MoGe-2 per photo + true poses | 8 | 4.8 / 12.2 | 13.3 / 24.3 | -0.5% | +2.9% | 3.3 / 13.4 | 7.8 / 30.1 | 99% | +| Depth Anything 3 metric per photo + true poses | 1 | 7.3 / 19.2 | 26.2 / 59.4 | -9.7% | -11.5% | 3.4 / 19.2 | 11.4 / 53.9 | 99% | +| Depth Anything 3 metric per photo + true poses | 2 | 7.3 / 20.3 | 28.0 / 65.3 | -10.1% | -13.3% | 3.6 / 17.3 | 11.3 / 51.1 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 7.0 / 20.5 | 26.6 / 69.4 | -9.6% | -13.5% | 3.7 / 20.9 | 11.0 / 62.7 | 99% | +| Depth Anything 3 metric per photo + true poses | 8 | 7.3 / 18.6 | 25.4 / 59.7 | -9.4% | -12.8% | 3.7 / 20.4 | 11.3 / 54.9 | 99% | + +## electro, all points, edges + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 8.0 / 24.9 | 17.1 / 63.4 | +4.3% | +2.7% | 4.4 / 25.1 | 7.2 / 37.4 | 100% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 7.3 / 39.4 | 16.9 / 93.0 | -2.2% | -4.4% | 8.7 / 61.9 | 13.7 / 95.4 | 99% | +| scan rendered as depth (evaluation floor) | 1 | 0.4 / 1.5 | 0.9 / 4.2 | -0.5% | -0.6% | 0.7 / 2.6 | 1.5 / 5.6 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.4 / 1.9 | 1.0 / 3.9 | -0.5% | -0.6% | 0.7 / 2.7 | 1.6 / 5.9 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.4 / 1.8 | 1.0 / 3.8 | -0.5% | -0.6% | 0.8 / 2.6 | 1.7 / 5.8 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.5 / 1.9 | 1.1 / 3.3 | -0.6% | -0.7% | 0.8 / 2.5 | 1.5 / 4.7 | 100% | +| MoGe-2 per photo + true poses | 1 | 8.8 / 33.0 | 19.0 / 54.8 | +6.6% | +5.9% | 4.1 / 37.0 | 7.0 / 45.5 | 100% | +| MoGe-2 per photo + true poses | 2 | 7.6 / 30.6 | 15.3 / 44.7 | +5.6% | +4.3% | 4.3 / 32.6 | 7.0 / 46.4 | 100% | +| MoGe-2 per photo + true poses | 4 | 6.4 / 21.3 | 13.3 / 37.6 | +4.8% | +4.7% | 4.7 / 27.1 | 7.7 / 47.8 | 99% | +| MoGe-2 per photo + true poses | 8 | 6.5 / 15.6 | 11.1 / 24.1 | +3.8% | +3.8% | 4.8 / 16.3 | 7.3 / 28.0 | 99% | +| Depth Anything 3 metric per photo + true poses | 1 | 9.5 / 37.5 | 22.9 / 113.4 | -0.9% | -2.1% | 7.6 / 57.9 | 15.4 / 148.0 | 99% | +| Depth Anything 3 metric per photo + true poses | 2 | 9.5 / 43.1 | 24.3 / 96.7 | -0.7% | -1.3% | 8.1 / 62.5 | 16.7 / 128.6 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 8.0 / 32.2 | 20.5 / 78.3 | -1.0% | -2.0% | 7.6 / 52.0 | 16.2 / 113.0 | 99% | +| Depth Anything 3 metric per photo + true poses | 8 | 7.3 / 28.6 | 18.0 / 58.5 | -2.9% | -2.4% | 6.2 / 41.5 | 13.8 / 65.4 | 99% | + +## electro, within 6 m, surface interior + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 6.8 / 19.5 | 15.1 / 41.8 | +3.5% | +4.3% | 1.6 / 9.6 | 3.0 / 17.8 | 98% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 6.0 / 20.2 | 13.9 / 43.4 | -6.6% | -7.4% | 2.9 / 25.3 | 6.1 / 62.6 | 97% | +| scan rendered as depth (evaluation floor) | 1 | 0.7 / 1.6 | 1.3 / 2.4 | -0.9% | -0.8% | 0.5 / 1.6 | 0.9 / 2.8 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.7 / 1.5 | 1.3 / 2.3 | -0.8% | -0.8% | 0.5 / 1.6 | 0.9 / 2.8 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.6 / 1.3 | 1.1 / 2.2 | -0.7% | -0.7% | 0.4 / 1.6 | 0.8 / 3.1 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.5 / 1.2 | 1.0 / 2.0 | -0.6% | -0.6% | 0.5 / 1.7 | 0.8 / 3.6 | 100% | +| MoGe-2 per photo + true poses | 1 | 6.9 / 14.9 | 15.5 / 30.4 | +4.4% | +4.8% | 1.8 / 8.7 | 3.5 / 17.5 | 100% | +| MoGe-2 per photo + true poses | 2 | 6.8 / 15.0 | 14.9 / 28.7 | +2.4% | +3.8% | 2.3 / 11.3 | 4.0 / 21.1 | 100% | +| MoGe-2 per photo + true poses | 4 | 6.1 / 14.4 | 13.9 / 25.9 | +2.5% | +4.4% | 2.5 / 15.7 | 4.4 / 33.1 | 100% | +| MoGe-2 per photo + true poses | 8 | 5.4 / 12.5 | 12.4 / 22.6 | +0.7% | +3.0% | 2.8 / 10.8 | 4.3 / 21.1 | 99% | +| Depth Anything 3 metric per photo + true poses | 1 | 6.5 / 19.3 | 14.9 / 42.7 | -5.9% | -7.6% | 3.3 / 24.8 | 7.4 / 52.2 | 100% | +| Depth Anything 3 metric per photo + true poses | 2 | 5.7 / 17.7 | 13.6 / 38.2 | -5.6% | -7.4% | 3.6 / 21.6 | 7.1 / 44.4 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 5.6 / 18.1 | 13.1 / 36.0 | -5.7% | -7.0% | 4.0 / 25.2 | 7.8 / 51.4 | 99% | +| Depth Anything 3 metric per photo + true poses | 8 | 5.1 / 14.7 | 11.9 / 33.1 | -5.6% | -6.8% | 3.6 / 20.1 | 6.5 / 38.9 | 95% | + +## electro, within 6 m, vertical interior + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 7.1 / 24.7 | 15.6 / 64.2 | +1.0% | +1.6% | 1.8 / 15.1 | 3.6 / 58.2 | 97% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 6.2 / 35.3 | 14.6 / fails (13% failed) | -7.2% | -7.3% | 2.9 / 171.3 | 6.7 / fails (14% failed) | 96% | +| scan rendered as depth (evaluation floor) | 1 | 0.1 / 0.9 | 0.3 / 1.9 | -0.2% | -0.2% | 0.4 / 1.5 | 0.8 / 2.8 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.1 / 0.8 | 0.3 / 1.8 | -0.2% | -0.2% | 0.4 / 1.5 | 0.8 / 2.8 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.2 / 0.8 | 0.4 / 1.6 | -0.2% | -0.2% | 0.4 / 1.6 | 0.8 / 3.0 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.1 / 0.5 | 0.3 / 0.9 | -0.2% | -0.2% | 0.4 / 1.7 | 0.8 / 3.2 | 100% | +| MoGe-2 per photo + true poses | 1 | 5.4 / 14.8 | 13.5 / 33.5 | +3.5% | +4.5% | 1.8 / 9.9 | 4.1 / 29.1 | 100% | +| MoGe-2 per photo + true poses | 2 | 5.2 / 15.3 | 13.3 / 31.6 | -0.1% | +2.5% | 2.4 / 12.8 | 5.1 / 32.2 | 100% | +| MoGe-2 per photo + true poses | 4 | 5.6 / 15.0 | 13.7 / 30.4 | -0.1% | +4.3% | 2.4 / 19.8 | 5.3 / 52.6 | 99% | +| MoGe-2 per photo + true poses | 8 | 4.8 / 12.4 | 11.6 / 25.8 | -1.3% | +2.7% | 2.8 / 10.0 | 5.5 / 25.6 | 99% | +| Depth Anything 3 metric per photo + true poses | 1 | 6.4 / 27.8 | 15.4 / 52.9 | -6.8% | -7.4% | 2.5 / 34.7 | 6.8 / 118.0 | 100% | +| Depth Anything 3 metric per photo + true poses | 2 | 5.8 / 20.7 | 13.8 / 41.9 | -6.9% | -7.3% | 2.8 / 27.7 | 7.7 / 77.0 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 5.6 / 20.8 | 12.7 / 38.4 | -6.5% | -6.5% | 3.3 / 30.9 | 8.2 / 76.8 | 99% | +| Depth Anything 3 metric per photo + true poses | 8 | 4.8 / 17.7 | 10.4 / 36.5 | -5.8% | -5.8% | 3.0 / 20.6 | 6.8 / 38.0 | 97% | + +## electro, within 6 m, edges + +| Method | Views | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| MoGe-2, one photo (every photo) | 1 | 8.3 / 31.8 | 15.0 / 53.0 | +3.6% | +4.2% | 3.7 / 26.9 | 5.8 / 36.8 | 97% | +| Depth Anything 3 metric, one photo (every photo) | 1 | 6.2 / 35.0 | 9.5 / 73.1 | -1.2% | -0.1% | 7.1 / 117.4 | 12.4 / 185.2 | 96% | +| scan rendered as depth (evaluation floor) | 1 | 0.3 / 1.1 | 0.5 / 1.7 | -0.4% | -0.3% | 0.5 / 1.7 | 1.0 / 2.8 | 100% | +| scan rendered as depth (evaluation floor) | 2 | 0.3 / 1.1 | 0.5 / 1.6 | -0.4% | -0.3% | 0.5 / 1.7 | 1.0 / 2.8 | 100% | +| scan rendered as depth (evaluation floor) | 4 | 0.3 / 1.1 | 0.6 / 1.6 | -0.4% | -0.4% | 0.5 / 1.8 | 1.0 / 3.0 | 100% | +| scan rendered as depth (evaluation floor) | 8 | 0.3 / 1.0 | 0.5 / 1.6 | -0.4% | -0.4% | 0.5 / 1.9 | 1.0 / 3.2 | 100% | +| MoGe-2 per photo + true poses | 1 | 7.8 / 35.5 | 15.1 / 68.7 | +3.6% | +4.7% | 2.8 / 26.1 | 4.5 / 52.4 | 100% | +| MoGe-2 per photo + true poses | 2 | 8.5 / 33.8 | 15.1 / 49.9 | +1.2% | +2.4% | 3.9 / 27.3 | 5.5 / 38.1 | 100% | +| MoGe-2 per photo + true poses | 4 | 7.5 / 20.6 | 13.3 / 34.6 | +2.8% | +3.3% | 4.2 / 26.4 | 6.4 / 50.0 | 99% | +| MoGe-2 per photo + true poses | 8 | 6.4 / 14.6 | 7.8 / 18.5 | +1.2% | -0.3% | 4.0 / 15.3 | 6.1 / 25.2 | 99% | +| Depth Anything 3 metric per photo + true poses | 1 | 9.2 / 40.9 | 19.8 / 142.8 | -1.4% | -1.6% | 5.7 / 66.9 | 10.8 / 171.6 | 100% | +| Depth Anything 3 metric per photo + true poses | 2 | 8.3 / 37.1 | 18.8 / 103.1 | -1.9% | -1.7% | 6.2 / 55.1 | 11.3 / 132.1 | 100% | +| Depth Anything 3 metric per photo + true poses | 4 | 7.3 / 23.7 | 14.8 / 74.4 | -1.7% | -2.0% | 6.0 / 44.6 | 10.7 / 109.4 | 99% | +| Depth Anything 3 metric per photo + true poses | 8 | 6.1 / 19.0 | 8.4 / 33.2 | -2.9% | -3.4% | 5.0 / 26.8 | 8.4 / 47.0 | 97% | diff --git a/experiments/evals/results/eth3d_visibility_sensitivity.md b/experiments/evals/results/eth3d_visibility_sensitivity.md new file mode 100644 index 000000000..c28e947c5 --- /dev/null +++ b/experiments/evals/results/eth3d_visibility_sensitivity.md @@ -0,0 +1,42 @@ +# Sensitivity to the visibility tolerance (generated by `uv run python -m evals.recon sensitivity`) + +Surface interior, within 6 m. One taped distance, median / p90 inches, and the model-scale error. A point counts as visible when it lies within the tolerance of the nearest scanned depth along its pixel. + +| Scene | Method | Views | Tolerance | Taped 1-3 m | Taped 3-10 m | Model scale error, 3-10 m pairs | +| --- | --- | --- | --- | --- | --- | --- | +| facade | scan rendered as depth (evaluation floor) | 1 | 2% | 0.3 / 1.2 | 0.6 / 1.9 | -0.8% | +| facade | scan rendered as depth (evaluation floor) | 2 | 2% | 0.3 / 1.2 | 0.6 / 2.0 | -0.8% | +| facade | MoGe-2 per photo + true poses | 1 | 2% | 0.9 / 6.7 | 1.5 / 12.1 | +10.4% | +| facade | MoGe-2 per photo + true poses | 2 | 2% | 1.0 / 6.9 | 1.5 / 12.4 | +10.3% | +| facade | Depth Anything 3 metric per photo + true poses | 1 | 2% | 1.6 / 5.5 | 2.6 / 10.0 | -9.1% | +| facade | Depth Anything 3 metric per photo + true poses | 2 | 2% | 1.8 / 7.3 | 2.9 / 13.2 | -9.4% | +| facade | scan rendered as depth (evaluation floor) | 1 | 4% | 0.2 / 1.6 | 0.5 / 2.5 | -1.1% | +| facade | scan rendered as depth (evaluation floor) | 2 | 4% | 0.3 / 1.7 | 0.5 / 2.6 | -1.2% | +| facade | MoGe-2 per photo + true poses | 1 | 4% | 0.7 / 6.3 | 1.3 / 10.1 | +9.8% | +| facade | MoGe-2 per photo + true poses | 2 | 4% | 0.7 / 6.3 | 1.3 / 10.7 | +9.7% | +| facade | Depth Anything 3 metric per photo + true poses | 1 | 4% | 1.0 / 6.2 | 1.5 / 11.6 | -9.2% | +| facade | Depth Anything 3 metric per photo + true poses | 2 | 4% | 1.2 / 8.7 | 1.7 / 16.3 | -9.5% | +| facade | scan rendered as depth (evaluation floor) | 1 | 8% | 0.2 / 1.4 | 0.4 / 2.0 | -1.2% | +| facade | scan rendered as depth (evaluation floor) | 2 | 8% | 0.2 / 1.5 | 0.4 / 2.2 | -1.2% | +| facade | MoGe-2 per photo + true poses | 1 | 8% | 0.7 / 6.5 | 1.3 / 11.0 | +9.8% | +| facade | MoGe-2 per photo + true poses | 2 | 8% | 0.7 / 6.1 | 1.3 / 10.4 | +9.8% | +| facade | Depth Anything 3 metric per photo + true poses | 1 | 8% | 0.9 / 4.7 | 1.4 / 6.8 | -8.4% | +| facade | Depth Anything 3 metric per photo + true poses | 2 | 8% | 1.1 / 6.9 | 1.6 / 10.8 | -9.1% | +| electro | scan rendered as depth (evaluation floor) | 1 | 2% | 0.2 / 0.9 | 0.4 / 1.6 | -0.5% | +| electro | scan rendered as depth (evaluation floor) | 2 | 2% | 0.2 / 0.9 | 0.4 / 1.5 | -0.4% | +| electro | MoGe-2 per photo + true poses | 1 | 2% | 1.4 / 9.7 | 3.2 / 21.2 | +4.8% | +| electro | MoGe-2 per photo + true poses | 2 | 2% | 1.9 / 11.0 | 3.5 / 22.8 | +3.3% | +| electro | Depth Anything 3 metric per photo + true poses | 1 | 2% | 2.4 / 29.7 | 5.2 / 118.5 | -8.6% | +| electro | Depth Anything 3 metric per photo + true poses | 2 | 2% | 2.6 / 25.5 | 4.8 / 77.3 | -8.3% | +| electro | scan rendered as depth (evaluation floor) | 1 | 4% | 0.5 / 1.6 | 0.9 / 2.8 | -0.8% | +| electro | scan rendered as depth (evaluation floor) | 2 | 4% | 0.5 / 1.6 | 0.9 / 2.8 | -0.8% | +| electro | MoGe-2 per photo + true poses | 1 | 4% | 1.8 / 8.7 | 3.5 / 17.5 | +4.8% | +| electro | MoGe-2 per photo + true poses | 2 | 4% | 2.3 / 11.3 | 4.0 / 21.1 | +3.8% | +| electro | Depth Anything 3 metric per photo + true poses | 1 | 4% | 3.3 / 24.8 | 7.4 / 52.2 | -7.6% | +| electro | Depth Anything 3 metric per photo + true poses | 2 | 4% | 3.6 / 21.6 | 7.1 / 44.4 | -7.4% | +| electro | scan rendered as depth (evaluation floor) | 1 | 8% | 0.6 / 1.7 | 1.0 / 2.9 | -0.8% | +| electro | scan rendered as depth (evaluation floor) | 2 | 8% | 0.5 / 1.8 | 1.0 / 3.0 | -0.8% | +| electro | MoGe-2 per photo + true poses | 1 | 8% | 1.8 / 10.0 | 3.3 / 19.7 | +4.8% | +| electro | MoGe-2 per photo + true poses | 2 | 8% | 2.4 / 13.9 | 3.9 / 26.2 | +3.6% | +| electro | Depth Anything 3 metric per photo + true poses | 1 | 8% | 3.3 / 29.7 | 7.0 / 69.1 | -7.5% | +| electro | Depth Anything 3 metric per photo + true poses | 2 | 8% | 3.6 / 25.8 | 6.8 / 57.2 | -7.3% | diff --git a/experiments/evals/results/field-replay/field_report.md b/experiments/evals/results/field-replay/field_report.md new file mode 100644 index 000000000..64e42cc75 --- /dev/null +++ b/experiments/evals/results/field-replay/field_report.md @@ -0,0 +1,8 @@ +# Field session advio-20-0040-0075 (generated by `uv run python -m evals.field score`) + +- Keyframes: 79; taps: 0; points: 0; walls: 0; measurements: 0. +- Triangulation scale fitted for 79 of 79 keyframes: MoGe-2 x 0.774 median (range 0.663 to 1.034). + +Missing: +- measurements: the session has no taps or measurements, so no row has values +- a tape survey, its map and the rules file: no results files are written diff --git a/experiments/evals/results/frames.json b/experiments/evals/results/frames.json new file mode 100644 index 000000000..f06a9e326 --- /dev/null +++ b/experiments/evals/results/frames.json @@ -0,0 +1,2776 @@ +{ + "policies": { + "every photo": { + "kept": 121, + "of": 121, + "1-3m": { + "pairs": 9075000, + "failed_pct": 0.4, + "median_in": 2.73, + "p90_in": 11.45, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 9075000, + "failed_pct": 0.49, + "median_in": 7.62, + "p90_in": 38.97, + "scale_error_pct": -0.51 + } + }, + "wall within 6 m": { + "kept": 15, + "of": 121, + "1-3m": { + "pairs": 1125000, + "failed_pct": 0.39, + "median_in": 1.94, + "p90_in": 6.86, + "scale_error_pct": -0.09 + }, + "3-10m": { + "pairs": 1125000, + "failed_pct": 0.5, + "median_in": 4.29, + "p90_in": 14.82, + "scale_error_pct": -0.04 + } + }, + "wall within 8 m": { + "kept": 27, + "of": 121, + "1-3m": { + "pairs": 2025000, + "failed_pct": 0.22, + "median_in": 2.1, + "p90_in": 7.38, + "scale_error_pct": -0.14 + }, + "3-10m": { + "pairs": 2025000, + "failed_pct": 0.28, + "median_in": 4.8, + "p90_in": 17.03, + "scale_error_pct": -0.22 + } + }, + "wall within 12 m": { + "kept": 42, + "of": 121, + "1-3m": { + "pairs": 3150000, + "failed_pct": 0.19, + "median_in": 2.29, + "p90_in": 8.09, + "scale_error_pct": -0.11 + }, + "3-10m": { + "pairs": 3150000, + "failed_pct": 0.25, + "median_in": 5.41, + "p90_in": 20.42, + "scale_error_pct": -0.19 + } + }, + "wall seen within 40 degrees of head-on": { + "kept": 52, + "of": 121, + "1-3m": { + "pairs": 3900000, + "failed_pct": 0.28, + "median_in": 2.31, + "p90_in": 9.3, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 3900000, + "failed_pct": 0.36, + "median_in": 7.07, + "p90_in": 35.2, + "scale_error_pct": -0.63 + } + }, + "wall seen more than 50 degrees off head-on": { + "kept": 40, + "of": 121, + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.54, + "median_in": 2.83, + "p90_in": 11.67, + "scale_error_pct": -0.08 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.64, + "median_in": 7.12, + "p90_in": 32.78, + "scale_error_pct": -0.25 + } + } + }, + "photos": [ + { + "view": "DSC_0324", + "distance_m": 17.13, + "incidence_deg": 31.58, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.27, + "median_in": 1.37, + "p90_in": 5.25, + "scale_error_pct": -0.06 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.27, + "median_in": 4.53, + "p90_in": 24.68, + "scale_error_pct": -0.7 + } + } + }, + { + "view": "DSC_0325", + "distance_m": 16.58, + "incidence_deg": 31.64, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.1, + "median_in": 1.31, + "p90_in": 5.58, + "scale_error_pct": 0.21 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 4.27, + "p90_in": 24.88, + "scale_error_pct": -0.18 + } + } + }, + { + "view": "DSC_0326", + "distance_m": 16.59, + "incidence_deg": 32.05, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.07, + "median_in": 1.79, + "p90_in": 5.09, + "scale_error_pct": 0.41 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.2, + "median_in": 5.05, + "p90_in": 14.17, + "scale_error_pct": 0.16 + } + } + }, + { + "view": "DSC_0327", + "distance_m": 16.13, + "incidence_deg": 34.98, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.1, + "median_in": 1.48, + "p90_in": 4.26, + "scale_error_pct": 0.08 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 3.99, + "p90_in": 11.39, + "scale_error_pct": -0.0 + } + } + }, + { + "view": "DSC_0328", + "distance_m": 14.45, + "incidence_deg": 39.58, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 1.33, + "p90_in": 3.9, + "scale_error_pct": 0.08 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.51, + "p90_in": 9.27, + "scale_error_pct": 0.31 + } + } + }, + { + "view": "DSC_0329", + "distance_m": 13.3, + "incidence_deg": 40.13, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.45, + "p90_in": 6.45, + "scale_error_pct": -0.87 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.94, + "p90_in": 18.2, + "scale_error_pct": -1.57 + } + } + }, + { + "view": "DSC_0330", + "distance_m": 14.55, + "incidence_deg": 38.12, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.33, + "p90_in": 6.48, + "scale_error_pct": -0.1 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 7.01, + "p90_in": 18.68, + "scale_error_pct": -1.18 + } + } + }, + { + "view": "DSC_0331", + "distance_m": 16.22, + "incidence_deg": 36.64, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 1.47, + "p90_in": 4.24, + "scale_error_pct": 0.43 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.07, + "median_in": 4.12, + "p90_in": 11.91, + "scale_error_pct": 0.3 + } + } + }, + { + "view": "DSC_0332", + "distance_m": 18.67, + "incidence_deg": 35.86, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.35, + "p90_in": 3.76, + "scale_error_pct": 0.65 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.27, + "median_in": 3.6, + "p90_in": 9.86, + "scale_error_pct": 0.28 + } + } + }, + { + "view": "DSC_0333", + "distance_m": 19.43, + "incidence_deg": 36.41, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.1, + "median_in": 1.94, + "p90_in": 5.4, + "scale_error_pct": -0.56 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.3, + "median_in": 6.34, + "p90_in": 19.18, + "scale_error_pct": -1.33 + } + } + }, + { + "view": "DSC_0334", + "distance_m": 20.46, + "incidence_deg": 37.68, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 2.48, + "p90_in": 6.99, + "scale_error_pct": -0.25 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.37, + "median_in": 8.16, + "p90_in": 22.56, + "scale_error_pct": -0.55 + } + } + }, + { + "view": "DSC_0335", + "distance_m": 21.12, + "incidence_deg": 36.95, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 2.99, + "p90_in": 8.91, + "scale_error_pct": -0.38 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.23, + "median_in": 10.04, + "p90_in": 29.62, + "scale_error_pct": -1.19 + } + } + }, + { + "view": "DSC_0336", + "distance_m": 21.48, + "incidence_deg": 36.61, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.2, + "median_in": 3.11, + "p90_in": 8.66, + "scale_error_pct": -0.1 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.4, + "median_in": 11.12, + "p90_in": 33.73, + "scale_error_pct": -0.6 + } + } + }, + { + "view": "DSC_0337", + "distance_m": 21.5, + "incidence_deg": 37.0, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.2, + "median_in": 2.71, + "p90_in": 7.17, + "scale_error_pct": 0.42 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.43, + "median_in": 9.67, + "p90_in": 29.81, + "scale_error_pct": -0.35 + } + } + }, + { + "view": "DSC_0338", + "distance_m": 21.36, + "incidence_deg": 36.59, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 4.26, + "median_in": 3.23, + "p90_in": 9.65, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 4.38, + "median_in": 11.2, + "p90_in": 42.41, + "scale_error_pct": -1.51 + } + } + }, + { + "view": "DSC_0339", + "distance_m": 21.95, + "incidence_deg": 37.93, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.17, + "median_in": 5.26, + "p90_in": 19.79, + "scale_error_pct": -1.44 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.27, + "median_in": 20.6, + "p90_in": 75.26, + "scale_error_pct": -6.07 + } + } + }, + { + "view": "DSC_0340", + "distance_m": 21.76, + "incidence_deg": 37.82, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.33, + "median_in": 3.29, + "p90_in": 10.53, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.23, + "median_in": 11.02, + "p90_in": 50.94, + "scale_error_pct": -0.85 + } + } + }, + { + "view": "DSC_0341", + "distance_m": 21.15, + "incidence_deg": 38.46, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.33, + "median_in": 5.1, + "p90_in": 18.32, + "scale_error_pct": 0.36 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.7, + "median_in": 19.65, + "p90_in": 71.73, + "scale_error_pct": -0.39 + } + } + }, + { + "view": "DSC_0342", + "distance_m": 20.95, + "incidence_deg": 37.65, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.33, + "median_in": 3.4, + "p90_in": 15.09, + "scale_error_pct": 0.49 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.47, + "median_in": 12.21, + "p90_in": 51.18, + "scale_error_pct": -1.08 + } + } + }, + { + "view": "DSC_0343", + "distance_m": 21.98, + "incidence_deg": 38.14, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.33, + "median_in": 3.09, + "p90_in": 12.33, + "scale_error_pct": 0.87 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.37, + "median_in": 10.32, + "p90_in": 41.18, + "scale_error_pct": -0.38 + } + } + }, + { + "view": "DSC_0344", + "distance_m": 21.73, + "incidence_deg": 38.17, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.47, + "median_in": 2.58, + "p90_in": 8.14, + "scale_error_pct": -0.55 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.57, + "median_in": 8.69, + "p90_in": 39.71, + "scale_error_pct": -1.59 + } + } + }, + { + "view": "DSC_0345", + "distance_m": 21.33, + "incidence_deg": 37.98, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.2, + "median_in": 3.48, + "p90_in": 10.15, + "scale_error_pct": 1.13 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.47, + "median_in": 11.8, + "p90_in": 40.31, + "scale_error_pct": -0.51 + } + } + }, + { + "view": "DSC_0346", + "distance_m": 21.14, + "incidence_deg": 37.9, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.5, + "median_in": 2.93, + "p90_in": 9.41, + "scale_error_pct": -1.35 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.37, + "median_in": 10.9, + "p90_in": 42.65, + "scale_error_pct": -3.03 + } + } + }, + { + "view": "DSC_0347", + "distance_m": 21.27, + "incidence_deg": 39.26, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.33, + "median_in": 3.86, + "p90_in": 15.2, + "scale_error_pct": -1.14 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.4, + "median_in": 13.96, + "p90_in": 58.96, + "scale_error_pct": -2.67 + } + } + }, + { + "view": "DSC_0348", + "distance_m": 20.97, + "incidence_deg": 41.58, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.23, + "median_in": 4.56, + "p90_in": 13.12, + "scale_error_pct": 0.95 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.4, + "median_in": 14.8, + "p90_in": 44.47, + "scale_error_pct": -0.49 + } + } + }, + { + "view": "DSC_0349", + "distance_m": 20.25, + "incidence_deg": 40.51, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.37, + "median_in": 4.41, + "p90_in": 12.86, + "scale_error_pct": 0.82 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.33, + "median_in": 14.73, + "p90_in": 46.91, + "scale_error_pct": -0.2 + } + } + }, + { + "view": "DSC_0350", + "distance_m": 19.9, + "incidence_deg": 42.89, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.27, + "median_in": 5.24, + "p90_in": 13.59, + "scale_error_pct": 1.13 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.27, + "median_in": 18.2, + "p90_in": 50.65, + "scale_error_pct": -0.16 + } + } + }, + { + "view": "DSC_0351", + "distance_m": 19.73, + "incidence_deg": 45.31, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.2, + "median_in": 6.74, + "p90_in": 17.45, + "scale_error_pct": 1.16 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.3, + "median_in": 21.31, + "p90_in": 57.99, + "scale_error_pct": 0.22 + } + } + }, + { + "view": "DSC_0352", + "distance_m": 20.01, + "incidence_deg": 47.95, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.1, + "median_in": 6.03, + "p90_in": 15.39, + "scale_error_pct": -0.79 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.27, + "median_in": 20.41, + "p90_in": 53.02, + "scale_error_pct": -1.89 + } + } + }, + { + "view": "DSC_0353", + "distance_m": 17.77, + "incidence_deg": 42.15, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.88, + "p90_in": 13.52, + "scale_error_pct": 1.89 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 16.66, + "p90_in": 47.82, + "scale_error_pct": 0.6 + } + } + }, + { + "view": "DSC_0354", + "distance_m": 17.47, + "incidence_deg": 39.3, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.17, + "median_in": 4.32, + "p90_in": 12.45, + "scale_error_pct": 0.56 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.3, + "median_in": 13.91, + "p90_in": 38.48, + "scale_error_pct": -0.22 + } + } + }, + { + "view": "DSC_0355", + "distance_m": 17.41, + "incidence_deg": 40.77, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.23, + "median_in": 3.59, + "p90_in": 9.92, + "scale_error_pct": -1.04 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.5, + "median_in": 11.84, + "p90_in": 30.33, + "scale_error_pct": -1.88 + } + } + }, + { + "view": "DSC_0356", + "distance_m": 17.16, + "incidence_deg": 41.42, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.3, + "median_in": 3.59, + "p90_in": 10.02, + "scale_error_pct": -2.07 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.5, + "median_in": 11.65, + "p90_in": 30.9, + "scale_error_pct": -2.94 + } + } + }, + { + "view": "DSC_0357", + "distance_m": 15.88, + "incidence_deg": 39.44, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.67, + "p90_in": 10.58, + "scale_error_pct": -0.92 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 10.54, + "p90_in": 31.28, + "scale_error_pct": -1.37 + } + } + }, + { + "view": "DSC_0358", + "distance_m": 16.72, + "incidence_deg": 38.08, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 2.17, + "p90_in": 8.24, + "scale_error_pct": 0.37 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.23, + "median_in": 5.71, + "p90_in": 18.2, + "scale_error_pct": 0.29 + } + } + }, + { + "view": "DSC_0359", + "distance_m": 17.09, + "incidence_deg": 35.46, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 2.28, + "p90_in": 6.73, + "scale_error_pct": 0.42 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 6.1, + "p90_in": 17.94, + "scale_error_pct": 0.39 + } + } + }, + { + "view": "DSC_0360", + "distance_m": 16.18, + "incidence_deg": 32.93, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.61, + "p90_in": 4.53, + "scale_error_pct": -0.03 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.1, + "median_in": 4.74, + "p90_in": 11.76, + "scale_error_pct": 0.21 + } + } + }, + { + "view": "DSC_0361", + "distance_m": 14.73, + "incidence_deg": 32.48, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 1.28, + "p90_in": 4.66, + "scale_error_pct": -0.16 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 3.97, + "p90_in": 17.5, + "scale_error_pct": -0.61 + } + } + }, + { + "view": "DSC_0362", + "distance_m": 14.63, + "incidence_deg": 32.72, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.07, + "median_in": 1.31, + "p90_in": 3.83, + "scale_error_pct": -0.18 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 4.41, + "p90_in": 17.95, + "scale_error_pct": -0.99 + } + } + }, + { + "view": "DSC_0363", + "distance_m": 13.59, + "incidence_deg": 31.5, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 1.31, + "p90_in": 3.79, + "scale_error_pct": -0.06 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.62, + "p90_in": 17.2, + "scale_error_pct": -1.29 + } + } + }, + { + "view": "DSC_0364", + "distance_m": 15.65, + "incidence_deg": 38.08, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.07, + "median_in": 2.59, + "p90_in": 7.43, + "scale_error_pct": 0.37 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 8.53, + "p90_in": 25.4, + "scale_error_pct": -1.72 + } + } + }, + { + "view": "DSC_0365", + "distance_m": 17.75, + "incidence_deg": 46.38, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 6.01, + "p90_in": 16.31, + "scale_error_pct": -0.53 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 21.51, + "p90_in": 67.77, + "scale_error_pct": -2.1 + } + } + }, + { + "view": "DSC_0366", + "distance_m": 10.65, + "incidence_deg": 70.81, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.75, + "p90_in": 18.74, + "scale_error_pct": -2.26 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 18.99, + "p90_in": 100.36, + "scale_error_pct": -2.71 + } + } + }, + { + "view": "DSC_0386", + "distance_m": 19.53, + "incidence_deg": 33.94, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.07, + "median_in": 2.61, + "p90_in": 14.61, + "scale_error_pct": -0.83 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.1, + "median_in": 9.2, + "p90_in": 48.06, + "scale_error_pct": -2.15 + } + } + }, + { + "view": "DSC_0387", + "distance_m": 17.23, + "incidence_deg": 25.92, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.3, + "median_in": 1.98, + "p90_in": 6.11, + "scale_error_pct": -1.07 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.6, + "median_in": 9.33, + "p90_in": 27.21, + "scale_error_pct": -3.05 + } + } + }, + { + "view": "DSC_0388", + "distance_m": 18.06, + "incidence_deg": 34.96, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.17, + "median_in": 3.06, + "p90_in": 11.4, + "scale_error_pct": 1.59 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.27, + "median_in": 7.8, + "p90_in": 32.47, + "scale_error_pct": 0.65 + } + } + }, + { + "view": "DSC_0389", + "distance_m": 17.54, + "incidence_deg": 35.93, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 2.16, + "p90_in": 5.93, + "scale_error_pct": -0.03 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.37, + "median_in": 5.91, + "p90_in": 16.94, + "scale_error_pct": -0.36 + } + } + }, + { + "view": "DSC_0390", + "distance_m": 15.25, + "incidence_deg": 33.32, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.91, + "p90_in": 5.69, + "scale_error_pct": 0.45 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.71, + "p90_in": 14.25, + "scale_error_pct": 0.22 + } + } + }, + { + "view": "DSC_0391", + "distance_m": 17.39, + "incidence_deg": 33.38, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.15, + "p90_in": 5.83, + "scale_error_pct": 0.66 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 5.7, + "p90_in": 15.75, + "scale_error_pct": 0.16 + } + } + }, + { + "view": "DSC_0392", + "distance_m": 15.35, + "incidence_deg": 34.46, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.81, + "p90_in": 5.55, + "scale_error_pct": -0.2 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.96, + "p90_in": 11.56, + "scale_error_pct": -0.64 + } + } + }, + { + "view": "DSC_0393", + "distance_m": 14.52, + "incidence_deg": 33.01, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.43, + "p90_in": 7.49, + "scale_error_pct": 0.28 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.3, + "p90_in": 18.53, + "scale_error_pct": 0.19 + } + } + }, + { + "view": "DSC_0394", + "distance_m": 22.31, + "incidence_deg": 46.21, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.73, + "median_in": 5.12, + "p90_in": 13.81, + "scale_error_pct": -0.09 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.7, + "median_in": 14.61, + "p90_in": 41.25, + "scale_error_pct": -0.21 + } + } + }, + { + "view": "DSC_0395", + "distance_m": 19.73, + "incidence_deg": 47.2, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 7.16, + "p90_in": 21.87, + "scale_error_pct": 0.28 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.53, + "median_in": 19.45, + "p90_in": 70.52, + "scale_error_pct": 0.1 + } + } + }, + { + "view": "DSC_0396", + "distance_m": 17.55, + "incidence_deg": 50.04, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 4.13, + "median_in": 6.6, + "p90_in": 23.22, + "scale_error_pct": -1.14 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 4.29, + "median_in": 21.19, + "p90_in": 84.12, + "scale_error_pct": -1.91 + } + } + }, + { + "view": "DSC_0397", + "distance_m": 13.32, + "incidence_deg": 37.66, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.97, + "p90_in": 12.35, + "scale_error_pct": 0.47 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 8.82, + "p90_in": 85.5, + "scale_error_pct": -0.63 + } + } + }, + { + "view": "DSC_0398", + "distance_m": 14.85, + "incidence_deg": 39.76, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.65, + "p90_in": 15.75, + "scale_error_pct": -0.44 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 21.4, + "p90_in": 91.86, + "scale_error_pct": -2.02 + } + } + }, + { + "view": "DSC_0399", + "distance_m": 14.79, + "incidence_deg": 38.81, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.17, + "p90_in": 16.68, + "scale_error_pct": 0.32 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 21.63, + "p90_in": 79.9, + "scale_error_pct": -1.08 + } + } + }, + { + "view": "DSC_0400", + "distance_m": 14.73, + "incidence_deg": 39.28, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.88, + "p90_in": 18.13, + "scale_error_pct": 0.08 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 24.1, + "p90_in": 102.19, + "scale_error_pct": -1.34 + } + } + }, + { + "view": "DSC_0401", + "distance_m": 15.42, + "incidence_deg": 39.13, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.19, + "p90_in": 10.93, + "scale_error_pct": 0.6 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 15.66, + "p90_in": 44.8, + "scale_error_pct": -1.38 + } + } + }, + { + "view": "DSC_0402", + "distance_m": 13.99, + "incidence_deg": 41.22, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.43, + "p90_in": 19.56, + "scale_error_pct": -1.59 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 21.45, + "p90_in": 107.58, + "scale_error_pct": -3.34 + } + } + }, + { + "view": "DSC_0404", + "distance_m": 14.04, + "incidence_deg": 42.47, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.03, + "p90_in": 15.64, + "scale_error_pct": 0.98 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 9.56, + "p90_in": 105.21, + "scale_error_pct": 0.16 + } + } + }, + { + "view": "DSC_0405", + "distance_m": 12.51, + "incidence_deg": 44.78, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.86, + "p90_in": 11.64, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 8.64, + "p90_in": 91.35, + "scale_error_pct": -1.02 + } + } + }, + { + "view": "DSC_0406", + "distance_m": 15.52, + "incidence_deg": 42.06, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.03, + "p90_in": 17.94, + "scale_error_pct": 0.7 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 17.68, + "p90_in": 90.0, + "scale_error_pct": -0.17 + } + } + }, + { + "view": "DSC_0407", + "distance_m": 17.29, + "incidence_deg": 41.52, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.15, + "p90_in": 13.7, + "scale_error_pct": -0.35 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 19.1, + "p90_in": 72.58, + "scale_error_pct": -1.68 + } + } + }, + { + "view": "DSC_0408", + "distance_m": 20.2, + "incidence_deg": 40.05, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 4.65, + "p90_in": 12.76, + "scale_error_pct": 0.66 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.2, + "median_in": 16.15, + "p90_in": 53.04, + "scale_error_pct": -1.65 + } + } + }, + { + "view": "DSC_0409", + "distance_m": 21.69, + "incidence_deg": 38.87, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 3.47, + "p90_in": 10.33, + "scale_error_pct": -0.43 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.3, + "median_in": 11.93, + "p90_in": 46.85, + "scale_error_pct": -2.09 + } + } + }, + { + "view": "DSC_0410", + "distance_m": 22.72, + "incidence_deg": 37.37, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.3, + "median_in": 2.84, + "p90_in": 9.25, + "scale_error_pct": 0.26 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.67, + "median_in": 9.86, + "p90_in": 41.58, + "scale_error_pct": -1.07 + } + } + }, + { + "view": "DSC_0411", + "distance_m": 22.49, + "incidence_deg": 34.17, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.33, + "median_in": 3.26, + "p90_in": 12.89, + "scale_error_pct": 0.74 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.23, + "median_in": 11.35, + "p90_in": 43.41, + "scale_error_pct": -0.33 + } + } + }, + { + "view": "DSC_0412", + "distance_m": 23.29, + "incidence_deg": 32.16, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 4.13, + "median_in": 5.18, + "p90_in": 23.03, + "scale_error_pct": 1.66 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 4.35, + "median_in": 17.42, + "p90_in": 69.25, + "scale_error_pct": -0.02 + } + } + }, + { + "view": "DSC_0413", + "distance_m": 29.07, + "incidence_deg": 49.12, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.1, + "median_in": 6.58, + "p90_in": 18.1, + "scale_error_pct": -0.24 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.33, + "median_in": 20.91, + "p90_in": 61.76, + "scale_error_pct": -2.05 + } + } + }, + { + "view": "DSC_0414", + "distance_m": 25.02, + "incidence_deg": 44.94, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.17, + "median_in": 6.86, + "p90_in": 17.57, + "scale_error_pct": 0.2 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.57, + "median_in": 20.66, + "p90_in": 56.75, + "scale_error_pct": -0.09 + } + } + }, + { + "view": "DSC_0415", + "distance_m": 23.5, + "incidence_deg": 46.37, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 4.22, + "median_in": 6.53, + "p90_in": 16.87, + "scale_error_pct": 0.55 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 4.7, + "median_in": 20.18, + "p90_in": 48.17, + "scale_error_pct": -0.38 + } + } + }, + { + "view": "DSC_0419", + "distance_m": 13.89, + "incidence_deg": 32.03, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.37, + "p90_in": 4.65, + "scale_error_pct": 0.12 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.07, + "median_in": 4.04, + "p90_in": 17.29, + "scale_error_pct": -0.27 + } + } + }, + { + "view": "DSC_0420", + "distance_m": 13.39, + "incidence_deg": 31.66, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.23, + "p90_in": 4.38, + "scale_error_pct": 0.04 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 4.34, + "p90_in": 20.41, + "scale_error_pct": -0.72 + } + } + }, + { + "view": "DSC_0421", + "distance_m": 38.9, + "incidence_deg": 58.92, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 4.58, + "median_in": 4.85, + "p90_in": 18.32, + "scale_error_pct": -1.05 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 5.44, + "median_in": 15.67, + "p90_in": 66.63, + "scale_error_pct": -2.3 + } + } + }, + { + "view": "DSC_0422", + "distance_m": 40.26, + "incidence_deg": 40.19, + "scene": "facade", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 4.67, + "median_in": 4.23, + "p90_in": 22.91, + "scale_error_pct": -0.19 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 4.7, + "median_in": 14.88, + "p90_in": 64.78, + "scale_error_pct": -1.05 + } + } + }, + { + "view": "DSC_9257", + "distance_m": 7.12, + "incidence_deg": 60.78, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.8, + "p90_in": 9.46, + "scale_error_pct": -0.79 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.64, + "p90_in": 23.39, + "scale_error_pct": 0.02 + } + } + }, + { + "view": "DSC_9258", + "distance_m": 9.09, + "incidence_deg": 65.82, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.14, + "p90_in": 9.3, + "scale_error_pct": -1.58 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 8.65, + "p90_in": 24.37, + "scale_error_pct": -2.38 + } + } + }, + { + "view": "DSC_9259", + "distance_m": 8.74, + "incidence_deg": 66.94, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.19, + "p90_in": 8.42, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.58, + "p90_in": 15.22, + "scale_error_pct": -0.37 + } + } + }, + { + "view": "DSC_9260", + "distance_m": 8.35, + "incidence_deg": 65.56, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.42, + "p90_in": 6.95, + "scale_error_pct": 0.83 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.6, + "p90_in": 14.53, + "scale_error_pct": 0.28 + } + } + }, + { + "view": "DSC_9261", + "distance_m": 8.3, + "incidence_deg": 63.14, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.94, + "p90_in": 4.91, + "scale_error_pct": -0.58 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.21, + "p90_in": 12.05, + "scale_error_pct": -0.86 + } + } + }, + { + "view": "DSC_9262", + "distance_m": 8.71, + "incidence_deg": 62.67, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.44, + "p90_in": 5.56, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.47, + "p90_in": 12.75, + "scale_error_pct": -0.14 + } + } + }, + { + "view": "DSC_9263", + "distance_m": 9.88, + "incidence_deg": 65.33, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 3.08, + "p90_in": 10.06, + "scale_error_pct": 1.29 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.57, + "median_in": 8.96, + "p90_in": 29.56, + "scale_error_pct": 1.59 + } + } + }, + { + "view": "DSC_9264", + "distance_m": 10.47, + "incidence_deg": 66.11, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.73, + "median_in": 4.0, + "p90_in": 13.46, + "scale_error_pct": -0.65 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.57, + "median_in": 11.0, + "p90_in": 38.6, + "scale_error_pct": -0.93 + } + } + }, + { + "view": "DSC_9265", + "distance_m": 5.12, + "incidence_deg": 59.52, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.97, + "median_in": 2.11, + "p90_in": 7.14, + "scale_error_pct": -0.28 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 1.93, + "median_in": 5.87, + "p90_in": 16.54, + "scale_error_pct": -0.41 + } + } + }, + { + "view": "DSC_9266", + "distance_m": 4.73, + "incidence_deg": 63.46, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 4.7, + "median_in": 2.33, + "p90_in": 6.68, + "scale_error_pct": -0.77 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 5.09, + "median_in": 4.32, + "p90_in": 11.78, + "scale_error_pct": -0.99 + } + } + }, + { + "view": "DSC_9267", + "distance_m": 4.77, + "incidence_deg": 71.07, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 2.82, + "p90_in": 7.06, + "scale_error_pct": -1.18 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 4.27, + "p90_in": 11.95, + "scale_error_pct": -0.65 + } + } + }, + { + "view": "DSC_9268", + "distance_m": 7.37, + "incidence_deg": 69.04, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.34, + "p90_in": 10.13, + "scale_error_pct": 0.67 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.99, + "p90_in": 22.68, + "scale_error_pct": -1.33 + } + } + }, + { + "view": "DSC_9269", + "distance_m": 7.6, + "incidence_deg": 43.81, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.02, + "p90_in": 6.54, + "scale_error_pct": 0.11 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.08, + "p90_in": 11.51, + "scale_error_pct": -0.02 + } + } + }, + { + "view": "DSC_9270", + "distance_m": 7.35, + "incidence_deg": 41.51, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.15, + "p90_in": 8.25, + "scale_error_pct": -0.43 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.14, + "p90_in": 15.08, + "scale_error_pct": -0.63 + } + } + }, + { + "view": "DSC_9271", + "distance_m": 6.77, + "incidence_deg": 59.67, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.04, + "p90_in": 6.2, + "scale_error_pct": -0.41 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.82, + "p90_in": 12.35, + "scale_error_pct": 0.11 + } + } + }, + { + "view": "DSC_9272", + "distance_m": 6.31, + "incidence_deg": 42.38, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.41, + "p90_in": 5.41, + "scale_error_pct": -0.21 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.89, + "p90_in": 11.69, + "scale_error_pct": -0.11 + } + } + }, + { + "view": "DSC_9273", + "distance_m": 9.06, + "incidence_deg": 66.36, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.03, + "p90_in": 7.36, + "scale_error_pct": 0.56 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.58, + "p90_in": 17.25, + "scale_error_pct": 1.26 + } + } + }, + { + "view": "DSC_9274", + "distance_m": 5.41, + "incidence_deg": 40.9, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.07, + "p90_in": 4.06, + "scale_error_pct": 0.14 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.74, + "p90_in": 7.79, + "scale_error_pct": 0.48 + } + } + }, + { + "view": "DSC_9275", + "distance_m": 6.48, + "incidence_deg": 74.88, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 2.17, + "p90_in": 6.72, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 6.49, + "p90_in": 20.94, + "scale_error_pct": -0.61 + } + } + }, + { + "view": "DSC_9276", + "distance_m": 5.05, + "incidence_deg": 62.49, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 3.15, + "p90_in": 12.34, + "scale_error_pct": -0.11 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.1, + "median_in": 8.83, + "p90_in": 32.97, + "scale_error_pct": -0.77 + } + } + }, + { + "view": "DSC_9277", + "distance_m": 10.74, + "incidence_deg": 57.2, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.61, + "p90_in": 5.02, + "scale_error_pct": 0.14 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.9, + "p90_in": 12.61, + "scale_error_pct": 0.15 + } + } + }, + { + "view": "DSC_9278", + "distance_m": 8.4, + "incidence_deg": 58.22, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.6, + "p90_in": 5.35, + "scale_error_pct": 0.27 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.49, + "p90_in": 12.7, + "scale_error_pct": 0.11 + } + } + }, + { + "view": "DSC_9279", + "distance_m": 5.48, + "incidence_deg": 42.46, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.66, + "p90_in": 5.4, + "scale_error_pct": -0.03 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.67, + "p90_in": 9.99, + "scale_error_pct": 0.3 + } + } + }, + { + "view": "DSC_9280", + "distance_m": 11.87, + "incidence_deg": 53.69, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.67, + "median_in": 3.58, + "p90_in": 10.02, + "scale_error_pct": 0.41 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.9, + "median_in": 12.1, + "p90_in": 34.76, + "scale_error_pct": 2.57 + } + } + }, + { + "view": "DSC_9281", + "distance_m": 6.79, + "incidence_deg": 72.57, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.08, + "p90_in": 12.52, + "scale_error_pct": -1.53 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 12.36, + "p90_in": 32.37, + "scale_error_pct": -3.85 + } + } + }, + { + "view": "DSC_9282", + "distance_m": 6.01, + "incidence_deg": 55.42, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.91, + "p90_in": 5.49, + "scale_error_pct": -0.06 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.73, + "p90_in": 12.85, + "scale_error_pct": 0.28 + } + } + }, + { + "view": "DSC_9283", + "distance_m": 5.26, + "incidence_deg": 39.52, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 0.75, + "p90_in": 2.29, + "scale_error_pct": -0.08 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.49, + "p90_in": 4.33, + "scale_error_pct": -0.29 + } + } + }, + { + "view": "DSC_9284", + "distance_m": 7.36, + "incidence_deg": 66.97, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.57, + "p90_in": 8.3, + "scale_error_pct": -0.46 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.24, + "p90_in": 18.59, + "scale_error_pct": -1.75 + } + } + }, + { + "view": "DSC_9285", + "distance_m": 10.36, + "incidence_deg": 65.16, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.6, + "median_in": 3.91, + "p90_in": 11.18, + "scale_error_pct": 0.25 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.8, + "median_in": 11.51, + "p90_in": 34.45, + "scale_error_pct": -0.62 + } + } + }, + { + "view": "DSC_9287", + "distance_m": 6.72, + "incidence_deg": 73.13, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.52, + "p90_in": 9.24, + "scale_error_pct": -0.27 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 9.47, + "p90_in": 23.74, + "scale_error_pct": -1.59 + } + } + }, + { + "view": "DSC_9289", + "distance_m": 16.22, + "incidence_deg": 67.64, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.17, + "median_in": 10.72, + "p90_in": 53.41, + "scale_error_pct": 8.54 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.53, + "median_in": 33.27, + "p90_in": 117.69, + "scale_error_pct": -0.9 + } + } + }, + { + "view": "DSC_9290", + "distance_m": 17.26, + "incidence_deg": 62.95, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.37, + "median_in": 15.52, + "p90_in": 63.39, + "scale_error_pct": -0.13 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.17, + "median_in": 44.66, + "p90_in": 140.55, + "scale_error_pct": -7.02 + } + } + }, + { + "view": "DSC_9291", + "distance_m": 17.4, + "incidence_deg": 64.38, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 10.83, + "p90_in": 28.91, + "scale_error_pct": -0.77 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 29.74, + "p90_in": 78.38, + "scale_error_pct": -2.88 + } + } + }, + { + "view": "DSC_9292", + "distance_m": 15.5, + "incidence_deg": 62.24, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 4.45, + "median_in": 5.49, + "p90_in": 50.94, + "scale_error_pct": 0.1 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 4.32, + "median_in": 14.27, + "p90_in": 54.99, + "scale_error_pct": 2.07 + } + } + }, + { + "view": "DSC_9293", + "distance_m": 10.5, + "incidence_deg": 66.27, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.25, + "p90_in": 9.48, + "scale_error_pct": 0.66 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 8.11, + "p90_in": 22.55, + "scale_error_pct": -0.64 + } + } + }, + { + "view": "DSC_9294", + "distance_m": 6.42, + "incidence_deg": 54.85, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.95, + "p90_in": 5.85, + "scale_error_pct": 0.12 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.41, + "p90_in": 13.75, + "scale_error_pct": 0.22 + } + } + }, + { + "view": "DSC_9295", + "distance_m": 5.0, + "incidence_deg": 43.32, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.01, + "p90_in": 7.27, + "scale_error_pct": -0.33 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.74, + "p90_in": 12.45, + "scale_error_pct": -1.65 + } + } + }, + { + "view": "DSC_9296", + "distance_m": 5.27, + "incidence_deg": 51.77, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.45, + "p90_in": 4.91, + "scale_error_pct": -0.25 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.48, + "p90_in": 11.42, + "scale_error_pct": -1.94 + } + } + }, + { + "view": "DSC_9297", + "distance_m": 3.99, + "incidence_deg": 44.51, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.32, + "p90_in": 6.5, + "scale_error_pct": -0.38 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.72, + "p90_in": 10.61, + "scale_error_pct": -1.64 + } + } + }, + { + "view": "DSC_9298", + "distance_m": 11.63, + "incidence_deg": 66.89, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.01, + "p90_in": 10.55, + "scale_error_pct": -1.23 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.23, + "median_in": 10.83, + "p90_in": 26.94, + "scale_error_pct": -2.03 + } + } + }, + { + "view": "DSC_9299", + "distance_m": 5.87, + "incidence_deg": 63.81, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.49, + "p90_in": 7.69, + "scale_error_pct": 0.6 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.85, + "p90_in": 16.84, + "scale_error_pct": 1.5 + } + } + }, + { + "view": "DSC_9300", + "distance_m": 5.93, + "incidence_deg": 68.35, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.91, + "p90_in": 8.6, + "scale_error_pct": 0.49 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.07, + "median_in": 7.05, + "p90_in": 20.73, + "scale_error_pct": 1.41 + } + } + }, + { + "view": "DSC_9301", + "distance_m": 5.9, + "incidence_deg": 69.5, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 2.1, + "p90_in": 5.91, + "scale_error_pct": 0.07 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.07, + "median_in": 5.26, + "p90_in": 13.81, + "scale_error_pct": 1.13 + } + } + }, + { + "view": "DSC_9302", + "distance_m": 5.72, + "incidence_deg": 69.95, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 1.56, + "p90_in": 4.58, + "scale_error_pct": 0.22 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.1, + "median_in": 3.68, + "p90_in": 10.86, + "scale_error_pct": 1.1 + } + } + }, + { + "view": "DSC_9303", + "distance_m": 5.37, + "incidence_deg": 70.85, + "scene": "electro", + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.03, + "median_in": 2.71, + "p90_in": 10.48, + "scale_error_pct": 0.17 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.13, + "median_in": 6.62, + "p90_in": 30.32, + "scale_error_pct": 2.12 + } + } + } + ] +} \ No newline at end of file diff --git a/experiments/evals/results/frames.md b/experiments/evals/results/frames.md new file mode 100644 index 000000000..cced792d1 --- /dev/null +++ b/experiments/evals/results/frames.md @@ -0,0 +1,14 @@ +# Which photos to keep (generated by `uv run python -m evals.frames`) + +MoGe-2, one photo at a time, scaled by one taped 1 to 3 m distance; both ETH3D scenes pooled. |length error|, inches, median / p90. + +| Keep | Photos kept | 1-3 m spans | 3-10 m spans | +| --- | --- | --- | --- | +| every photo | 121 of 121 | 2.7 / 11.4 | 7.6 / 39.0 | +| wall within 6 m | 15 of 121 | 1.9 / 6.9 | 4.3 / 14.8 | +| wall within 8 m | 27 of 121 | 2.1 / 7.4 | 4.8 / 17.0 | +| wall within 12 m | 42 of 121 | 2.3 / 8.1 | 5.4 / 20.4 | +| wall seen within 40 degrees of head-on | 52 of 121 | 2.3 / 9.3 | 7.1 / 35.2 | +| wall seen more than 50 degrees off head-on | 40 of 121 | 2.8 / 11.7 | 7.1 / 32.8 | + +Photo geometry: median wall distance 15.4 m (range 4.0 to 40.3); median incidence 42 degrees (range 26 to 75). diff --git a/experiments/evals/results/map3d.json b/experiments/evals/results/map3d.json new file mode 100644 index 000000000..b4349ef31 --- /dev/null +++ b/experiments/evals/results/map3d.json @@ -0,0 +1,110 @@ +{ + "kit_commit": "66cdcba", + "wall_stretch_ft": 19.3, + "bands": { + "wall": { + "claimed_ft": 11.089238845144356, + "false_observed_ft": 0.0, + "false_observed_share": 0.0, + "missed_ft": 6.889763779527559, + "seen_two_view_ft": 17.979002624671917, + "claimed_samples_seen_only_where_scan_is_empty": 0.0 + }, + "ground": { + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "false_observed_share": 0.0, + "missed_ft": 18.11023622047244, + "seen_two_view_ft": 18.11023622047244, + "claimed_samples_seen_only_where_scan_is_empty": 0.0 + } + }, + "bands_5m_truth": { + "wall": { + "claimed_ft": 11.089238845144356, + "false_observed_ft": 0.0, + "false_observed_share": 0.0, + "missed_ft": 6.102362204724409, + "seen_two_view_ft": 16.076115485564305, + "claimed_samples_seen_only_where_scan_is_empty": 0.0 + }, + "ground": { + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "false_observed_share": 0.0, + "missed_ft": 16.46981627296588, + "seen_two_view_ft": 16.46981627296588, + "claimed_samples_seen_only_where_scan_is_empty": 0.0 + } + }, + "wall_over_all_claims": { + "s_ft": [ + -9.66, + 21.0 + ], + "claimed_ft": 19.42257217847769, + "false_observed_ft": 0.0, + "false_observed_share": 0.0, + "missed_ft": 8.858267716535433, + "seen_two_view_ft": 25.196850393700785, + "claimed_samples_seen_only_where_scan_is_empty": 0.0, + "missed_on_pilaster_faces_ft": 5.51 + }, + "facing_3cm_truth": { + "claimed_ft": 0.52, + "false_observed_ft": 0.52, + "passes": 0.0, + "spans_with_unseen_ft": [ + [ + 8.5, + 9.0, + 0.52 + ] + ] + }, + "ground_reach_ft": [ + 0.0 + ], + "ground_every_reach": { + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "passes": true, + "spans_with_unseen_ft_s_s_reach_bad": [] + }, + "wall_claimed_total_ft": 19.5, + "wall_claimed_on_stretch_ft": 11.09, + "facing": { + "claimed_ft": 0.52, + "false_observed_ft": 0.52, + "passes": 0.0, + "spans_with_unseen_ft": [ + [ + 8.5, + 9.0, + 0.52 + ] + ] + }, + "overhead": { + "claimed_ft": 0.0, + "false_observed_ft": 0.0, + "passes": true, + "spans_with_unseen_ft": [] + }, + "chain": { + "pieces": 1, + "meter_piece_length_ft": 28.44, + "meter_piece_s_ft": [ + -5.96, + 22.46 + ], + "laser_wall_s_ft": [ + -6.23, + 24.61 + ], + "laser_wall_length_ft": 30.84, + "angle_error_deg": -1.76, + "offset_at_meter_in": 4.85 + }, + "allocated_mb": 8.7 +} \ No newline at end of file diff --git a/experiments/evals/results/map3d.md b/experiments/evals/results/map3d.md new file mode 100644 index 000000000..0aa3c5614 --- /dev/null +++ b/experiments/evals/results/map3d.md @@ -0,0 +1,28 @@ +# The app's 3D map against laser visibility (generated by `make map3d`) + +HouseScanKit Map3D at 66cdcba (t3/ios-map3d), unmodified, LiDAR path: one 256 x 192 depth frame per ETH3D electro photo, rendered from the laser scan. Coverage read along section 7's 19.3 ft wall. Lengths in feet along the wall; false-observed passes at 0.5 ft or less. Ideal depth: exact, dense, no sensor noise, so this is an upper bound on what real LiDAR gives. Definitions: README section 7c. + +| Band | Claimed | False-observed (share) | Pass | Missed | Seen from 2 positions | +| --- | --- | --- | --- | --- | --- | +| wall, CoverageMap (section 7) | 19.3 | 1.1 (6%) | no | 0.0 | 18.0 | +| wall, Map3D | 11.1 | 0.0 (0%) | yes | 6.9 | 18.0 | +| ground, Map3D | 0.0 | 0.0 (0%) | untested | 18.1 | 18.1 | +| wall, Map3D, 5 m truth | 11.1 | 0.0 (0%) | yes | 6.1 | 16.1 | +| ground, Map3D, 5 m truth | 0.0 | 0.0 (0%) | untested | 16.5 | 16.5 | +| wall, Map3D, truth over all its claims (s -9.66 to 21.0 ft) | 19.4 | 0.0 (0%) | yes | 8.9, of it 5.5 on pilaster faces | 25.2 | +| ground, Map3D, every reach at its own depth | 0.0 | 0.0 | untested | n/a | n/a | +| facing, Map3D, 3 cm floor on the truth's tolerance | 0.5 | 0.5 | see note | n/a | n/a | +| facing, Map3D | 0.5 | 0.5 | no | n/a | n/a | +| overhead, Map3D | 0.0 | 0.0 | untested | n/a | n/a | + +Map3D's ground reaches (ft): [0.0]. Ground counts as claimed where the reach is at least 1.2 m (3.94 ft), the depth CoverageMap claims. + +Facing: Map3D's one claim is a cell seen clear 0.1 m out from a door face. The truth needs the laser surface beyond a point by max(tolerance, 4% of the distance), 12 to 20 cm at these ranges, so space 10 cm in front of a surface can be neither confirmed nor refuted. + +## Wall chain against the laser's wall line + +- Pieces in the chain: 1. The meter's piece: 28.44 ft, s -5.96 to 22.46 ft. +- Laser wall through the meter (gaps up to 0.35 m bridged): 30.84 ft, s [-6.23, 24.61]. +- Angle error -1.76 deg; offset at the meter +4.8 in. + +Map3D claimed 19.5 ft of wall in all, 11.09 ft of it on section 7's stretch; the row over all its claims scores every foot. Map3D's voxels took 8.7 MB. diff --git a/experiments/evals/results/modern_arkit.json b/experiments/evals/results/modern_arkit.json new file mode 100644 index 000000000..33c81fdf7 --- /dev/null +++ b/experiments/evals/results/modern_arkit.json @@ -0,0 +1,393 @@ +{ + "atrium": [ + { + "walk": "seq1", + "images": 210, + "walked_m": 94.41016428310266, + "scale": 1.0023602932316809, + "heading_offset_spread_deg": 0.51, + "gps_over_truth": [ + 0.9544334598959915, + 6.180105581277903 + ] + }, + { + "walk": "seq2", + "images": 218, + "walked_m": 84.96580536604357, + "scale": 1.0052688092531439, + "heading_offset_spread_deg": 0.31, + "gps_over_truth": [ + 0.15516691381792894, + 4.808667819792621 + ] + }, + { + "walk": "seq3", + "images": 220, + "walked_m": 86.31645591411336, + "scale": 1.003246589937209, + "heading_offset_spread_deg": 2.19, + "gps_over_truth": [ + 0.8219571884447006, + 4.981361191410862 + ] + }, + { + "walk": "seq4", + "images": 211, + "walked_m": 80.37055536221209, + "scale": 1.003131331974088, + "heading_offset_spread_deg": 1.09, + "gps_over_truth": [ + 0.6810396658344083, + 5.3425497760692915 + ] + }, + { + "walk": "seq5", + "images": 217, + "walked_m": 92.65937612940037, + "scale": 1.005627200567819, + "heading_offset_spread_deg": 0.67, + "gps_over_truth": [ + 0.5960577804888686, + 8.916284685405333 + ] + }, + { + "walk": "seq6", + "images": 224, + "walked_m": 97.08311813609826, + "scale": 1.0102453876770578, + "heading_offset_spread_deg": 0.6, + "gps_over_truth": [ + 0.37872463093575653, + 7.248418603314583 + ] + }, + { + "walk": "seq7", + "images": 207, + "walked_m": 48.855792152413514, + "scale": 0.8227205626621225, + "heading_offset_spread_deg": 2.32, + "gps_over_truth": [ + 0.9195251915966183, + 3.654793997032829 + ] + }, + { + "walk": "seq8", + "images": 210, + "walked_m": 44.89448219467613, + "scale": 0.5862882462447312, + "heading_offset_spread_deg": 2.7, + "gps_over_truth": [ + 0.7097237167859158, + 7.711446333848967 + ] + }, + { + "walk": "seq9", + "images": 210, + "walked_m": 65.93007454020871, + "scale": 1.0078025001937108, + "heading_offset_spread_deg": 1.1, + "gps_over_truth": [ + 0.8868285367370585, + 7.54096921857418 + ] + }, + { + "walk": "seq10", + "images": 208, + "walked_m": 48.40964088676492, + "scale": 0.49319206199023025, + "heading_offset_spread_deg": 1.98, + "gps_over_truth": [ + 0.4903890825998695, + 3.010050382848813 + ] + } + ], + "bar": [ + { + "walk": "seq1", + "images": 216, + "walked_m": 175.86654543309672, + "scale": 1.0119566167379175, + "heading_offset_spread_deg": 0.51, + "gps_over_truth": [ + 0.9579480610937561, + 1.3772267742039346 + ] + }, + { + "walk": "seq2", + "images": 212, + "walked_m": 127.19432929579634, + "scale": 1.0172815131096447, + "heading_offset_spread_deg": 0.28, + "gps_over_truth": [ + 0.9779587230528283, + 1.3838165071294877 + ] + }, + { + "walk": "seq3", + "images": 198, + "walked_m": 141.89159529119655, + "scale": 1.0212684332194755, + "heading_offset_spread_deg": 0.33, + "gps_over_truth": [ + 0.9356655122238959, + 2.3704164161463037 + ] + }, + { + "walk": "seq4", + "images": 201, + "walked_m": 124.64806966097674, + "scale": 1.0121136482692539, + "heading_offset_spread_deg": 0.3, + "gps_over_truth": [ + 0.6604146747410872, + 5.097564074125843 + ] + }, + { + "walk": "seq5", + "images": 201, + "walked_m": 143.10773950826143, + "scale": 1.0009616239552566, + "heading_offset_spread_deg": 0.52, + "gps_over_truth": [ + 0.6369285079875624, + 5.36528822706711 + ] + }, + { + "walk": "seq6", + "images": 201, + "walked_m": 138.4959665395486, + "scale": 1.0010668096557958, + "heading_offset_spread_deg": 0.39, + "gps_over_truth": [ + 0.8089766337375268, + 6.387337161265863 + ] + }, + { + "walk": "seq7", + "images": 202, + "walked_m": 170.17932795219318, + "scale": 1.0167883077362732, + "heading_offset_spread_deg": 0.57, + "gps_over_truth": [ + 0.6847391281434964, + 5.809812301734013 + ] + }, + { + "walk": "seq8", + "images": 208, + "walked_m": 209.46759929422564, + "scale": 1.0189493528347173, + "heading_offset_spread_deg": 0.86, + "gps_over_truth": [ + 1.0645516349542963, + 1.3517126378212698 + ] + }, + { + "walk": "seq9", + "images": 203, + "walked_m": 173.55071373965527, + "scale": 1.0066311229670113, + "heading_offset_spread_deg": 0.6, + "gps_over_truth": [ + 0.9740703092115618, + 2.144422536158645 + ] + }, + { + "walk": "seq10", + "images": 210, + "walked_m": 192.4238101626739, + "scale": 1.0083759873921443, + "heading_offset_spread_deg": 0.61, + "gps_over_truth": [ + 0.993729984618413, + 0.9201707340885078 + ] + }, + { + "walk": "seq11", + "images": 207, + "walked_m": 175.9338154180696, + "scale": 1.010333121129963, + "heading_offset_spread_deg": 0.61, + "gps_over_truth": [ + 0.9091302556542299, + 1.9019401878929387 + ] + }, + { + "walk": "seq12", + "images": 210, + "walked_m": 191.96990076498213, + "scale": 0.996197045053592, + "heading_offset_spread_deg": 3.14, + "gps_over_truth": [ + 0.8011488936700454, + 5.267189011800247 + ] + }, + { + "walk": "seq13", + "images": 203, + "walked_m": 114.02980056127909, + "scale": 1.0119872935503242, + "heading_offset_spread_deg": 0.76, + "gps_over_truth": [ + 0.7947689756051507, + 4.285617655990388 + ] + } + ], + "church": [ + { + "walk": "seq1", + "images": 207, + "walked_m": 231.99872887418232, + "scale": 0.9848750055548867, + "heading_offset_spread_deg": 0.49, + "gps_over_truth": [ + 0.9799948771309479, + 1.3515356720807354 + ] + }, + { + "walk": "seq2", + "images": 200, + "walked_m": 160.92811256928104, + "scale": 0.9902665052054505, + "heading_offset_spread_deg": 0.25, + "gps_over_truth": [ + 0.9377087263498249, + 1.1972932900317899 + ] + }, + { + "walk": "seq3", + "images": 201, + "walked_m": 218.24603230682933, + "scale": 0.9800607376538126, + "heading_offset_spread_deg": 0.63, + "gps_over_truth": [ + 0.9782533175523683, + 0.9938601410726303 + ] + }, + { + "walk": "seq4", + "images": 204, + "walked_m": 151.4563413261829, + "scale": 0.9778316135150016, + "heading_offset_spread_deg": 1.14, + "gps_over_truth": [ + 0.9898721574350746, + 0.9009096876438962 + ] + }, + { + "walk": "seq5", + "images": 204, + "walked_m": 231.87872958177226, + "scale": 0.9821881576545499, + "heading_offset_spread_deg": 0.71, + "gps_over_truth": [ + 0.9668839999665497, + 3.902258984045079 + ] + }, + { + "walk": "seq6", + "images": 207, + "walked_m": 145.27615052372002, + "scale": 0.9823983125136755, + "heading_offset_spread_deg": 0.36, + "gps_over_truth": [ + 0.9550968067771002, + 1.70942980633456 + ] + }, + { + "walk": "seq7", + "images": 210, + "walked_m": 191.28715718075017, + "scale": 0.9873264572724576, + "heading_offset_spread_deg": 0.24, + "gps_over_truth": [ + 0.9963768132994598, + 4.812542566599604 + ] + }, + { + "walk": "seq8", + "images": 212, + "walked_m": 235.22587305325493, + "scale": 0.9819571546728899, + "heading_offset_spread_deg": 0.95, + "gps_over_truth": [ + 0.9493346479478224, + 1.5393253837100358 + ] + }, + { + "walk": "seq9", + "images": 205, + "walked_m": 192.89081292348538, + "scale": 0.9842011055064939, + "heading_offset_spread_deg": 0.41, + "gps_over_truth": [ + 0.953053003649021, + 1.4336826429734981 + ] + }, + { + "walk": "seq10", + "images": 210, + "walked_m": 198.5111834322479, + "scale": 0.982038687843804, + "heading_offset_spread_deg": 0.51, + "gps_over_truth": [ + 0.9937814452260965, + 1.5063768901615515 + ] + }, + { + "walk": "seq11", + "images": 206, + "walked_m": 189.39173346415305, + "scale": 0.9868560546713097, + "heading_offset_spread_deg": 0.44, + "gps_over_truth": [ + 0.9480531325568134, + 0.9878992016179327 + ] + }, + { + "walk": "seq12", + "images": 230, + "walked_m": 255.20136533919222, + "scale": 0.9845775222021418, + "heading_offset_spread_deg": 0.34, + "gps_over_truth": [ + 0.9225200677831688, + 1.478394025204613 + ] + } + ] +} \ No newline at end of file diff --git a/experiments/evals/results/modern_arkit.md b/experiments/evals/results/modern_arkit.md new file mode 100644 index 000000000..3ebceb6d0 --- /dev/null +++ b/experiments/evals/results/modern_arkit.md @@ -0,0 +1,142 @@ +# A current iPhone's ARKit scale (generated by `uv run python -m evals.modern_arkit`) + +MARViN, iPhone 14 Pro Max, ARKit 6, outdoor walks, against the dataset's COLMAP ground truth. Scale: ARKit / truth over windows of 10 ft or more, per walk. + +| Scene | Walks | Walked per walk (m) | ARKit scale error, median walk | Walks' range | +| --- | --- | --- | --- | --- | +| atrium | 10 | 83 | +0.3% | -50.7% to +1.0% | +| bar | 13 | 170 | +1.2% | -0.4% to +2.1% | +| church | 12 | 196 | -1.7% | -2.2% to -1.0% | +| all | 35 | | +0.1% | -50.7% to +2.1% | + +Walks within 2% of the ground truth's scale: 30 of 35. + +## Walk-to-walk spread of ARKit's scale within a site + +Standard deviation over each site's walks within 10% of the reference, with a 95% bootstrap interval over walks. A reference scaled to ARKit would carry one scale per site, which shifts every walk of that site alike, so this spread survives it: a single walk's scale error is at least this large, unless the reference's own scale varies walk to walk in step with ARKit's. + +| Site | Walks used | Spread (1 SD) | 95% interval | +| --- | --- | --- | --- | +| atrium | 7 | 0.28% | 0.12% to 0.36% | +| bar | 13 | 0.75% | 0.46% to 0.94% | +| church | 12 | 0.34% | 0.19% to 0.44% | + +## Distance error after walking, pooled over all walks + +|ARKit - truth| of the straight-line displacement, inches, median / p90. 'Beyond scale' divides each walk's own scale out first. The last column leaves out walks whose scale is off by more than 10%: there ARKit and the reference disagree about whether the phone moved at all between images, so the reference itself is in doubt. The 3 ft row is below what this reference resolves: images are 0.2 to 1.4 m apart, and the reference jitters by up to 0.4 m on those walks. + +| Walked | As tracked | Beyond scale | As tracked, without those walks | +| --- | --- | --- | --- | +| 3 ft | 1.1 / 5.2 | 1.0 / 7.2 | 1.0 / 3.1 | +| 10 ft | 2.7 / 16.4 | 2.1 / 21.1 | 2.4 / 7.1 | +| 20 ft | 4.5 / 34.8 | 3.2 / 30.7 | 4.2 / 11.9 | +| 30 ft | 5.9 / 45.8 | 3.9 / 38.9 | 5.5 / 16.2 | + +## Position error after walking, pooled over all walks + +|ARKit - truth| of the whole displacement, inches, median / p90, ARKit's displacement turned by the heading offset at the window's start (so sideways drift counts), as in section 1. The heading offset between ARKit and the truth holds steady to 0.6 degrees (median walk; worst 3.1). 'Scale removed' divides each walk's own scale out first. Columns as above. + +| Walked | As tracked | Scale removed | As tracked, without those walks | 0.16 ft/ft allowance | +| --- | --- | --- | --- | --- | +| 3 ft | 1.5 / 6.9 | 1.4 / 8.8 | 1.4 / 4.0 | 5.8 | +| 10 ft | 3.3 / 18.7 | 2.8 / 22.6 | 3.1 / 8.6 | 19.2 | +| 20 ft | 5.4 / 37.5 | 4.4 / 35.0 | 5.1 / 13.4 | 38.4 | +| 30 ft | 7.2 / 50.0 | 5.5 / 45.1 | 6.9 / 18.5 | 57.6 | + +## Can GPS check the ground truth's scale? + +GPS / truth over each walk: median 0.936, range 0.155 to 1.065 (35 walks). On the 11 walks where GPS fits within 1.5 m, median 0.978, range 0.923 to 1.065. GPS noise of 1 to 9 m over 25 to 160 m walks cannot pin the scale to 2%. + +## Files (Google Drive folder https://drive.google.com/drive/folders/18vPfKB4jlDwtFzytMijYOpmeqU1A9-Ie) + +| File | sha256 | +| --- | --- | +| atrium/seq1/ARkitPose.txt | 94da66e774d2e9f3fb00c00de594405ad2b315c905dfdeff353949cd445b925e | +| atrium/seq1/GPSData.txt | 76d261047acae6775d8a9a5ca8c1a146459798a4dd8410d71f585b2047ac0f5d | +| atrium/seq10/ARkitPose.txt | 41baf11f1dd0ba826906f3baf9586aca9370f811e5ee2176b17919aa85723d2b | +| atrium/seq10/GPSData.txt | 734de27d016d37f15e05715fd2eb7ec36a8c772243539120d1b1a1932a993ce1 | +| atrium/seq11/GPSData.txt | b1c63742812e6ae71e6535a3083c67622a2016f548f71a0ce1779d78a9f0361d | +| atrium/seq12/GPSData.txt | 184727caaf7b8c1af1b9f683498c6f9a4bb2fa561270fa63bb6fbf3c047ded5c | +| atrium/seq13/GPSData.txt | 8f8d6b794c111c19b491f0046e97ed89a194695107c5529ccec6872429176599 | +| atrium/seq14/GPSData.txt | 29c86afd50b8e7abbd2902bce3f947a5b28dcb15d8c1631afbd16596b5860339 | +| atrium/seq2/ARkitPose.txt | 7469a46401fd7960b80e7bda0f753a15ab8674ad212812dd493dfb3f62ed682c | +| atrium/seq2/GPSData.txt | 25149d03aecb8897d63a6d4f86eb539983b1648b531247fecddf094256f3f549 | +| atrium/seq3/ARkitPose.txt | 27d89f410162bcf8428b6ba9a186931a0894d80ad9831bcf032b7f9dba8da084 | +| atrium/seq3/GPSData.txt | 42c746578bc1a63c890fb138c87ee1ee4db8c5cce41c066aaa26b42e0a9ab77c | +| atrium/seq4/ARkitPose.txt | 911a388e02f4d4515f8bb389bd1d7bdab4ca222632571a5996b9e86d76eeaa23 | +| atrium/seq4/GPSData.txt | 59c026b0944ac370cb5ab0334a823597d07d2ae7817eb82d096946228d7d090f | +| atrium/seq5/ARkitPose.txt | 6b294f77be6e5cccf6b3e57e5b3b2bb9f0632d6c13d45864ede4767c69791c33 | +| atrium/seq5/GPSData.txt | 558c2ff5338a860e4c9cf1abc0085b31868387c59a13b78db33d4d39d38caaa7 | +| atrium/seq6/ARkitPose.txt | c55f9ec4f0654018154058c95aaa3f3c209f4d73cde351412139c4d23ed23540 | +| atrium/seq6/GPSData.txt | 6fb8e236172a7f4d890ee05dd4ce43719171fc75e95d2a2633c54ad576124360 | +| atrium/seq7/ARkitPose.txt | b53ce3320bc15952a9ea7fb1c7997e8af5932e02662314e3fe4f1eec33a1b545 | +| atrium/seq7/GPSData.txt | 765226f20c87c0ae5cc10b1d913e1083f48d2ee3b3e8330f5b9958bcd28000a1 | +| atrium/seq8/ARkitPose.txt | 1a6d28c06a6526f1c63bb394e694375eab654c827a5abe69227a16cc0df86761 | +| atrium/seq8/GPSData.txt | cc01043cfa6dd91049336fd2f7f7113a494f3e6c9500675ef017540f1e753b2e | +| atrium/seq9/ARkitPose.txt | ce39b215a2af7975da9ea8cfbde4d8b23a99151b44432b42efc2fa5918641efe | +| atrium/seq9/GPSData.txt | d58ed7f01167bd7c58b610c0bacfcf3a0f61874d6dea877249ef5f0072f8d95b | +| atrium/test.txt | 68c164ce7b54cb643459de211565d0c7c79a35a3095ba04a9211d5ce1bc82a17 | +| atrium/train.txt | bfa42bcf326234e6081020b8754607c23a7ea423762ad645e7bfb558e914059b | +| bar/seq1/ARkitPose.txt | 4b9415baf9d5bb87afba356c84a3c6f5b9efec7c3cc26a52326bfc1795b206c2 | +| bar/seq1/GPSData.txt | 1937cf62c7078cf2230d4061ef99c922387e74b89df39209a0e290cab3d782c7 | +| bar/seq10/ARkitPose.txt | 570e043a3634e27ee1bb27d43eb49578f465718ef85a7ac866121d0ccb9b0ea9 | +| bar/seq10/GPSData.txt | 56fb8535f1e18915e86e983175885828b2cd38b33e45d0d095b86319a57864eb | +| bar/seq11/ARkitPose.txt | 3fec8a1c23a65a0fadeb82bd968aead5f9093a7f64f9216ef2bb9f8338d3e1e7 | +| bar/seq11/GPSData.txt | 8c65dd00fb34fa63a0fb3b8c57c1433b4d3d1232810337bf17d86be0ab71f999 | +| bar/seq12/ARkitPose.txt | 9c8187fc1f22331953d1ceb34a0b8b4cf53854631301c31ad391b17b768379fc | +| bar/seq12/GPSData.txt | 3501a0ef8a23e9a960acc497dbe329f1ed6c0357aca52816ecc12aa3d03920f8 | +| bar/seq13/ARkitPose.txt | dfa84dc040000f4441e7db0293923b8c39109b32d0cdb9b2af4344cbd187f41c | +| bar/seq13/GPSData.txt | cef05123f41f021461165957dc197c743e556d43ef365af7a69946d5797be084 | +| bar/seq14/GPSData.txt | 716725e25fc37542bc95ba100c78c534432ffafb3000921f81e1dcd1a245fa0e | +| bar/seq15/GPSData.txt | 8858f21f1787802ae215825c5c206e036850cce027aafcd99bd38ad619f6c056 | +| bar/seq16/GPSData.txt | a420e9bd516fef1fd837cc37d3cee81547312e81225b751ab7bf3fb5af21eddd | +| bar/seq17/GPSData.txt | 153c3f5e378bd4dcdb5511ab9a6d4f540197a7adb58c14b45962e74f43226d63 | +| bar/seq2/ARkitPose.txt | 6618f45531685dfa7bfd4dd264c2b402cdede9bf2ce15c28f78e6b8dc2b3bf7b | +| bar/seq2/GPSData.txt | b5f819c7c5332a7c57393c2015a695b1f41a7890484a7ae04b8b91596fe49007 | +| bar/seq3/ARkitPose.txt | 2689949d5a07e89ae767056960165ed413cbf7cf782199d96e13d63f2db7abf9 | +| bar/seq3/GPSData.txt | 50a69113c0ee71734d0c1124981d985f07f2b208b746c961ef0bc462c62a3edf | +| bar/seq4/ARkitPose.txt | 73e39c3ff8a7cd86572d98d31dc4881a9cc61a224e3b9504d5a5f6b93652d2d8 | +| bar/seq4/GPSData.txt | cde25642c60015970c7dc3f8970d9b895267f816f2000b1d8129858f3f0bbcb3 | +| bar/seq5/ARkitPose.txt | 39883912564585f767d6da8ab45386eebd885708d62635a2d3fcfb7fb77c547b | +| bar/seq5/GPSData.txt | e326cd4de1435599e74e9f2623686c1a7b05422a37360da4bc7106dfbc8a73d2 | +| bar/seq6/ARkitPose.txt | 3045d393a58b7def77f704cc4c06cc45bf3c0b85d2b12871c69cfa3eb1f75a43 | +| bar/seq6/GPSData.txt | 8f3c9f0de0184c013978493d50fe1112f279bd9a068b9941352a09191f99e7be | +| bar/seq7/ARkitPose.txt | e5b2008f9302555809f9e953a73799bba5960ac178aa42b4ad14899c850ae29d | +| bar/seq7/GPSData.txt | fd81c401d52d442a84a4818a6703860b60f36be37c12740384f620414735063e | +| bar/seq8/ARkitPose.txt | 6f14ebaae0d2aef7b37d91245571587667f65d2907805399288bd32b8f929f07 | +| bar/seq8/GPSData.txt | bd951b540308c9e36ffc2a078f326499c224be6f962b2ec72ae3b2eb7fccef88 | +| bar/seq9/ARkitPose.txt | c27c312f398aa31b37e5be99093d64fea0282b864e526ab4db25ea57dd388f34 | +| bar/seq9/GPSData.txt | bc3ab47836d4ec52449a877f363853158dbf0720bfd4e8efc0876f27851842f9 | +| bar/test.txt | 1008f38c7de007f48da9ec91e90e22083073978353050b37ce88d183b98979a2 | +| bar/train.txt | 16c7124a4b4b980108c91eeb61a8174c6dcdc7b040177eb88ad19cbb96ad89a5 | +| church/seq1/ARkitPose.txt | 0daaeabc4456d005b9f799ce349b4acd1514c6f8713d91e9e20515c6431a161c | +| church/seq1/GPSData.txt | a0a172b7a5420cbea3e9f7913d344b3595f759d258a0cf7924a5b53dd60521ed | +| church/seq10/ARkitPose.txt | 5e3c0bb89bf231020ff067265a40a86291ae5986e5c79dfc6b3ab26805919526 | +| church/seq10/GPSData.txt | 25857d4b94788499db005343bbae06edfebe7745ed00261af928ac1dcf5d6af5 | +| church/seq11/ARkitPose.txt | 37f2450e410e65de942a5c6a89f8b0dc19a655a84f2dc944a61db6a21066c820 | +| church/seq11/GPSData.txt | 220f94d2a79d93a8a3a3f9f7e294d9189baa2440e18580af2476d9ace9009aae | +| church/seq12/ARkitPose.txt | 1036da4acda117b876a789e9cafdae599344abe227a178c2a958d15bc261e0f2 | +| church/seq12/GPSData.txt | b79a9c2689d65a3e98eb3cf5b27d5317cbb806515cddd2b7660303a420961ae9 | +| church/seq13/GPSData.txt | 1d8b4818aca106f3886996914fac519fa470d279c8f8b08f98ff602a67af7330 | +| church/seq14/GPSData.txt | fa6e9b0ad02e2b9a79a2a1b1e485708b28d7a929eca6a3cad17628c7a5fe4e00 | +| church/seq15/GPSData.txt | c7ac35d0715ee0155371d2bec9b9a60eb44409552b9ad2a62b7cf3bf3f78b944 | +| church/seq16/GPSData.txt | 4290ce22af745fb8755eb72e8a54338f14a1f82561b03fe782a4aca2acce1266 | +| church/seq17/GPSData.txt | 13ec050e4b7ac8d04367d6173430bf47011a266bdd1430877b950de3c460b64c | +| church/seq2/ARkitPose.txt | 6c7f3ae229ab7856909027e89611d1a8dad7eef9dfa6e430370b2a83baf35295 | +| church/seq2/GPSData.txt | 16972e7ecf964898b0103b21102c37d3a8973f4a7ef02951dc9ec31535cfba7e | +| church/seq3/ARkitPose.txt | 5dd90bbabb79b15724c3029d81aad7ed0c7cd4d00f84d82b47cac7cf2e647c1e | +| church/seq3/GPSData.txt | 0407b87909c7e750dfb81eba801385cb0d4d1cd30162e469d56d9e5b85c3d835 | +| church/seq4/ARkitPose.txt | 615337c38105aa674db0edf9c666408299f7444a19596b40c4760b7bb0ff7881 | +| church/seq4/GPSData.txt | dc79dc391d336ab6a0ef04da95adba2147449ebe81a5c994a42ee521fd63eeb2 | +| church/seq5/ARkitPose.txt | a1af901cbecb76a3eed32e24b56aa01cd16694fb4267403dbfde682fc827bfcf | +| church/seq5/GPSData.txt | 23ecb699d4e5346c61f67aec2afb98f6a9d11506b13846b5ce855fe9ca2a3524 | +| church/seq6/ARkitPose.txt | 7055a979c8b4674e187f17461d744e42baa78dc795effbedf93f6e870bc19bf3 | +| church/seq6/GPSData.txt | ed8cfb2b86e5594dd6d255bec45416cc1bbfc36c8261150726602a2370413b86 | +| church/seq7/ARkitPose.txt | 761c9183821dcc218031211d843cb21b0ae77c2ffb3f3cd4042f4f88434693c6 | +| church/seq7/GPSData.txt | 49254c1ca0ce0a95420e4ecdedbc11b40734c9fb1a389a382a426507405361ab | +| church/seq8/ARkitPose.txt | f7d0194a16b229351e9cbf6cdf090d56c0703216b962afd4b93c16c615bb5c58 | +| church/seq8/GPSData.txt | bdb6f15e445401b5694a6701fb66a0cd8dfea6c3747fe7a73fee91ca00fb3682 | +| church/seq9/ARkitPose.txt | bacdf1795e6de3e19d51ca0a51b712ff7355c0b6ea45fa8d4ada513867743bb1 | +| church/seq9/GPSData.txt | a29cf3011f8afe8dd69195076d866dc46918b6d92c4e5226d5674d8b61b9aa38 | +| church/test.txt | e4aae74db2397457d99ee8ba4a8b0d8553bca6f0abf5ce258d795216620afcdf | +| church/train.txt | 9119e3d102dc8dd1cd0741ee7e9eb36e522f46be94f84a711b51695ace5061db | diff --git a/experiments/evals/results/pose_priors.json b/experiments/evals/results/pose_priors.json new file mode 100644 index 000000000..91f240155 --- /dev/null +++ b/experiments/evals/results/pose_priors.json @@ -0,0 +1,18710 @@ +{ + "facade": { + "all points": { + "mapanything@392, images only": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 8.42, + "median_in": 11.36, + "p90_in": 30.24, + "scale_error_pct": -13.27 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 10.8, + "median_in": 39.42, + "p90_in": Infinity, + "scale_error_pct": -14.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 14.26, + "median_in": 6.22, + "p90_in": Infinity, + "scale_error_pct": -0.24 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 16.5, + "median_in": 19.68, + "p90_in": Infinity, + "scale_error_pct": -1.51 + } + }, + "tape_calibration_success_pct": 93.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.94, + 14.58 + ], + "p90_in": [ + 22.53, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 31.56, + 49.77 + ], + "p90_in": [ + 93.26, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 4.97, + "median_in": 11.14, + "p90_in": 24.8, + "scale_error_pct": -13.76 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 6.83, + "median_in": 37.34, + "p90_in": 95.49, + "scale_error_pct": -15.01 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 11.08, + "median_in": 5.52, + "p90_in": Infinity, + "scale_error_pct": -0.57 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 12.83, + "median_in": 17.6, + "p90_in": Infinity, + "scale_error_pct": -1.61 + } + }, + "tape_calibration_success_pct": 93.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.47, + 14.61 + ], + "p90_in": [ + 19.25, + 28.75 + ] + }, + "3-10m": { + "median_in": [ + 29.47, + 47.5 + ], + "p90_in": [ + 73.01, + 128.55 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 84.82, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -10.63 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 91.11, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -12.44 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 85.76, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.36 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 91.77, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.73 + } + }, + "tape_calibration_success_pct": 93.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 5.21, + "median_in": 10.86, + "p90_in": 24.01, + "scale_error_pct": -12.68 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 6.55, + "median_in": 36.94, + "p90_in": 87.33, + "scale_error_pct": -14.1 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 9.89, + "median_in": 5.56, + "p90_in": 62.2, + "scale_error_pct": -0.24 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 11.13, + "median_in": 16.79, + "p90_in": Infinity, + "scale_error_pct": -1.52 + } + }, + "tape_calibration_success_pct": 95.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.75, + 13.8 + ], + "p90_in": [ + 19.62, + 28.75 + ] + }, + "3-10m": { + "median_in": [ + 30.8, + 46.33 + ], + "p90_in": [ + 67.81, + 108.06 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 2.47, + "median_in": 10.71, + "p90_in": 22.16, + "scale_error_pct": -13.12 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 3.58, + "median_in": 35.22, + "p90_in": 77.92, + "scale_error_pct": -14.32 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 7.31, + "median_in": 5.05, + "p90_in": 22.81, + "scale_error_pct": -0.58 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 8.35, + "median_in": 15.31, + "p90_in": 83.08, + "scale_error_pct": -1.62 + } + }, + "tape_calibration_success_pct": 95.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.45, + 13.88 + ], + "p90_in": [ + 17.85, + 25.12 + ] + }, + "3-10m": { + "median_in": [ + 29.08, + 44.58 + ], + "p90_in": [ + 60.25, + 95.08 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 80.44, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -9.07 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 86.72, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -13.31 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 81.29, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.74 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 87.4, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 2.26 + } + }, + "tape_calibration_success_pct": 95.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 8.35, + "median_in": 12.09, + "p90_in": 29.46, + "scale_error_pct": -14.18 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 10.82, + "median_in": 41.43, + "p90_in": Infinity, + "scale_error_pct": -15.72 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 13.8, + "median_in": 5.7, + "p90_in": Infinity, + "scale_error_pct": -0.27 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 16.11, + "median_in": 18.08, + "p90_in": Infinity, + "scale_error_pct": -1.53 + } + }, + "tape_calibration_success_pct": 94.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 9.02, + 16.23 + ], + "p90_in": [ + 24.21, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 31.61, + 53.57 + ], + "p90_in": [ + 95.85, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 5.09, + "median_in": 11.81, + "p90_in": 25.66, + "scale_error_pct": -14.65 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 7.02, + "median_in": 39.21, + "p90_in": 96.81, + "scale_error_pct": -16.53 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 10.75, + "median_in": 5.1, + "p90_in": Infinity, + "scale_error_pct": -0.59 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 12.56, + "median_in": 16.04, + "p90_in": Infinity, + "scale_error_pct": -1.63 + } + }, + "tape_calibration_success_pct": 94.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.59, + 16.36 + ], + "p90_in": [ + 21.86, + 28.93 + ] + }, + "3-10m": { + "median_in": [ + 29.76, + 51.45 + ], + "p90_in": [ + 75.97, + 129.64 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 85.3, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -12.66 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 91.55, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -13.39 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 86.22, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.56 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 92.15, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.19 + } + }, + "tape_calibration_success_pct": 94.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 5.37, + "median_in": 9.59, + "p90_in": 20.84, + "scale_error_pct": -11.44 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 6.5, + "median_in": 31.87, + "p90_in": 73.9, + "scale_error_pct": -12.16 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 10.53, + "median_in": 5.14, + "p90_in": Infinity, + "scale_error_pct": -0.41 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 11.56, + "median_in": 15.5, + "p90_in": Infinity, + "scale_error_pct": -1.51 + } + }, + "tape_calibration_success_pct": 94.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 7.75, + 11.85 + ], + "p90_in": [ + 18.25, + 23.02 + ] + }, + "3-10m": { + "median_in": [ + 27.37, + 38.0 + ], + "p90_in": [ + 58.86, + 105.75 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 2.55, + "median_in": 9.49, + "p90_in": 18.99, + "scale_error_pct": -11.71 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 3.63, + "median_in": 30.23, + "p90_in": 64.63, + "scale_error_pct": -12.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 7.89, + "median_in": 4.67, + "p90_in": 22.59, + "scale_error_pct": -0.69 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 8.89, + "median_in": 14.0, + "p90_in": 90.46, + "scale_error_pct": -1.56 + } + }, + "tape_calibration_success_pct": 94.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 7.58, + 11.75 + ], + "p90_in": [ + 15.76, + 20.64 + ] + }, + "3-10m": { + "median_in": [ + 26.06, + 35.94 + ], + "p90_in": [ + 51.46, + 89.89 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 81.08, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -9.93 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 87.2, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -11.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 81.88, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.48 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 87.88, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 0.84 + } + }, + "tape_calibration_success_pct": 94.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 8.38, + "median_in": 12.1, + "p90_in": 30.15, + "scale_error_pct": -14.09 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 10.81, + "median_in": 41.41, + "p90_in": Infinity, + "scale_error_pct": -15.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 14.31, + "median_in": 5.86, + "p90_in": Infinity, + "scale_error_pct": -0.2 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 16.55, + "median_in": 18.69, + "p90_in": Infinity, + "scale_error_pct": -1.45 + } + }, + "tape_calibration_success_pct": 93.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.83, + 16.33 + ], + "p90_in": [ + 24.38, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 31.22, + 54.45 + ], + "p90_in": [ + 96.67, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 5.09, + "median_in": 11.85, + "p90_in": 25.77, + "scale_error_pct": -14.68 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 7.03, + "median_in": 39.2, + "p90_in": 97.06, + "scale_error_pct": -16.67 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 11.24, + "median_in": 5.24, + "p90_in": Infinity, + "scale_error_pct": -0.5 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 13.04, + "median_in": 16.65, + "p90_in": Infinity, + "scale_error_pct": -1.55 + } + }, + "tape_calibration_success_pct": 93.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.42, + 16.4 + ], + "p90_in": [ + 21.91, + 28.91 + ] + }, + "3-10m": { + "median_in": [ + 29.38, + 52.34 + ], + "p90_in": [ + 77.68, + 130.32 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 85.52, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -13.12 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 91.52, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -13.41 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 86.43, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.45 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 92.13, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.3 + } + }, + "tape_calibration_success_pct": 93.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 5.58, + "median_in": 10.65, + "p90_in": 21.66, + "scale_error_pct": -12.86 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 6.71, + "median_in": 35.3, + "p90_in": 76.05, + "scale_error_pct": -13.44 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 10.71, + "median_in": 5.12, + "p90_in": Infinity, + "scale_error_pct": -0.32 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 11.75, + "median_in": 15.29, + "p90_in": Infinity, + "scale_error_pct": -1.29 + } + }, + "tape_calibration_success_pct": 94.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.95, + 12.91 + ], + "p90_in": [ + 19.5, + 23.93 + ] + }, + "3-10m": { + "median_in": [ + 31.05, + 40.66 + ], + "p90_in": [ + 62.49, + 107.02 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 2.8, + "median_in": 10.45, + "p90_in": 19.99, + "scale_error_pct": -13.08 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 3.88, + "median_in": 33.58, + "p90_in": 66.49, + "scale_error_pct": -13.49 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 8.11, + "median_in": 4.69, + "p90_in": 22.66, + "scale_error_pct": -0.57 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 9.12, + "median_in": 13.89, + "p90_in": 92.71, + "scale_error_pct": -1.34 + } + }, + "tape_calibration_success_pct": 94.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.71, + 12.82 + ], + "p90_in": [ + 17.16, + 21.23 + ] + }, + "3-10m": { + "median_in": [ + 29.76, + 38.57 + ], + "p90_in": [ + 56.03, + 92.73 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 81.64, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -10.87 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 87.88, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -12.19 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 82.47, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.59 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 88.55, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 0.94 + } + }, + "tape_calibration_success_pct": 94.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 8.36, + "median_in": 12.21, + "p90_in": 29.61, + "scale_error_pct": -14.32 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 10.84, + "median_in": 41.84, + "p90_in": Infinity, + "scale_error_pct": -15.87 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 13.81, + "median_in": 5.71, + "p90_in": Infinity, + "scale_error_pct": -0.27 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 16.12, + "median_in": 18.04, + "p90_in": Infinity, + "scale_error_pct": -1.51 + } + }, + "tape_calibration_success_pct": 94.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 9.15, + 16.34 + ], + "p90_in": [ + 24.36, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 31.99, + 53.9 + ], + "p90_in": [ + 96.29, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 5.08, + "median_in": 11.92, + "p90_in": 25.81, + "scale_error_pct": -14.81 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 7.03, + "median_in": 39.57, + "p90_in": 96.9, + "scale_error_pct": -16.7 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 10.74, + "median_in": 5.11, + "p90_in": Infinity, + "scale_error_pct": -0.59 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 12.57, + "median_in": 16.02, + "p90_in": Infinity, + "scale_error_pct": -1.63 + } + }, + "tape_calibration_success_pct": 94.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.73, + 16.43 + ], + "p90_in": [ + 21.93, + 29.05 + ] + }, + "3-10m": { + "median_in": [ + 30.21, + 51.96 + ], + "p90_in": [ + 76.43, + 130.79 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 85.33, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -12.95 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 91.55, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -13.45 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 86.24, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.76 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 92.14, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.15 + } + }, + "tape_calibration_success_pct": 94.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 5.43, + "median_in": 9.91, + "p90_in": 21.31, + "scale_error_pct": -11.84 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 6.55, + "median_in": 33.13, + "p90_in": 75.37, + "scale_error_pct": -12.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 10.57, + "median_in": 5.1, + "p90_in": Infinity, + "scale_error_pct": -0.41 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 11.62, + "median_in": 15.4, + "p90_in": Infinity, + "scale_error_pct": -1.48 + } + }, + "tape_calibration_success_pct": 94.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.11, + 12.11 + ], + "p90_in": [ + 18.67, + 23.6 + ] + }, + "3-10m": { + "median_in": [ + 28.32, + 39.19 + ], + "p90_in": [ + 59.94, + 106.94 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 2.59, + "median_in": 9.8, + "p90_in": 19.39, + "scale_error_pct": -12.09 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 3.64, + "median_in": 31.38, + "p90_in": 66.09, + "scale_error_pct": -12.61 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 7.93, + "median_in": 4.64, + "p90_in": 22.48, + "scale_error_pct": -0.67 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 8.91, + "median_in": 13.9, + "p90_in": 90.0, + "scale_error_pct": -1.53 + } + }, + "tape_calibration_success_pct": 94.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 7.94, + 12.11 + ], + "p90_in": [ + 16.1, + 21.12 + ] + }, + "3-10m": { + "median_in": [ + 27.1, + 36.99 + ], + "p90_in": [ + 52.47, + 91.15 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 81.34, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -10.64 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 87.5, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -12.26 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 82.13, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.39 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 88.16, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 0.81 + } + }, + "tape_calibration_success_pct": 94.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "MoGe-2 placed with exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.19, + "median_in": 5.93, + "p90_in": 11.96, + "scale_error_pct": -6.16 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.22, + "median_in": 20.42, + "p90_in": 47.68, + "scale_error_pct": -7.32 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.68, + "median_in": 3.04, + "p90_in": 12.62, + "scale_error_pct": -0.17 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.72, + "median_in": 9.82, + "p90_in": 43.65, + "scale_error_pct": -1.23 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.04, + 7.0 + ], + "p90_in": [ + 10.01, + 14.14 + ] + }, + "3-10m": { + "median_in": [ + 17.16, + 23.19 + ], + "p90_in": [ + 37.93, + 56.5 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.9, + "p90_in": 11.25, + "scale_error_pct": -6.47 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 19.57, + "p90_in": 46.74, + "scale_error_pct": -7.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 2.78, + "p90_in": 11.26, + "scale_error_pct": -0.44 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.51, + "median_in": 9.21, + "p90_in": 42.66, + "scale_error_pct": -1.24 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.0, + 6.85 + ], + "p90_in": [ + 9.56, + 13.43 + ] + }, + "3-10m": { + "median_in": [ + 15.3, + 22.46 + ], + "p90_in": [ + 35.56, + 58.01 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.51, + "p90_in": 26.11, + "scale_error_pct": -2.26 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.03, + "median_in": 16.03, + "p90_in": 48.48, + "scale_error_pct": -3.98 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 5.01, + "p90_in": 30.48, + "scale_error_pct": 3.92 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.53, + "median_in": 13.78, + "p90_in": 55.8, + "scale_error_pct": 2.18 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 4.3, + 7.67 + ], + "p90_in": [ + 14.83, + 36.74 + ] + }, + "3-10m": { + "median_in": [ + 12.52, + 20.28 + ], + "p90_in": [ + 34.94, + 59.76 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.15, + "median_in": 5.7, + "p90_in": 11.63, + "scale_error_pct": -5.87 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.18, + "median_in": 19.2, + "p90_in": 46.86, + "scale_error_pct": -6.97 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.65, + "median_in": 3.34, + "p90_in": 12.34, + "scale_error_pct": -0.32 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.68, + "median_in": 10.79, + "p90_in": 42.09, + "scale_error_pct": -1.62 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.0, + 6.5 + ], + "p90_in": [ + 10.07, + 13.83 + ] + }, + "3-10m": { + "median_in": [ + 16.51, + 21.64 + ], + "p90_in": [ + 38.16, + 53.64 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.61, + "p90_in": 10.92, + "scale_error_pct": -6.16 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 18.28, + "p90_in": 46.46, + "scale_error_pct": -7.0 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 3.08, + "p90_in": 11.1, + "scale_error_pct": -0.62 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.51, + "median_in": 10.29, + "p90_in": 40.99, + "scale_error_pct": -1.66 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 4.91, + 6.36 + ], + "p90_in": [ + 9.52, + 12.91 + ] + }, + "3-10m": { + "median_in": [ + 15.01, + 21.29 + ], + "p90_in": [ + 35.96, + 56.11 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.09, + "p90_in": 24.59, + "scale_error_pct": -2.27 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 14.79, + "p90_in": 45.42, + "scale_error_pct": -3.87 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 5.18, + "p90_in": 28.49, + "scale_error_pct": 4.06 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.52, + "median_in": 13.74, + "p90_in": 52.73, + "scale_error_pct": 2.02 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.93, + 7.23 + ], + "p90_in": [ + 14.67, + 33.47 + ] + }, + "3-10m": { + "median_in": [ + 11.98, + 18.19 + ], + "p90_in": [ + 34.15, + 55.39 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.13, + "median_in": 5.92, + "p90_in": 11.81, + "scale_error_pct": -5.98 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.15, + "median_in": 19.91, + "p90_in": 46.13, + "scale_error_pct": -7.24 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 2.13, + "median_in": 3.49, + "p90_in": 14.43, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 2.15, + "median_in": 11.03, + "p90_in": 49.0, + "scale_error_pct": -1.36 + } + }, + "tape_calibration_success_pct": 98.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.24, + 6.65 + ], + "p90_in": [ + 10.7, + 12.96 + ] + }, + "3-10m": { + "median_in": [ + 17.49, + 21.95 + ], + "p90_in": [ + 39.81, + 55.76 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.86, + "p90_in": 11.24, + "scale_error_pct": -6.32 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 19.06, + "p90_in": 46.46, + "scale_error_pct": -7.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 2.0, + "median_in": 3.21, + "p90_in": 13.02, + "scale_error_pct": -0.31 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 2.01, + "median_in": 10.57, + "p90_in": 48.53, + "scale_error_pct": -1.39 + } + }, + "tape_calibration_success_pct": 98.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.26, + 6.49 + ], + "p90_in": [ + 10.31, + 12.0 + ] + }, + "3-10m": { + "median_in": [ + 15.71, + 21.66 + ], + "p90_in": [ + 38.09, + 58.95 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 5.26, + "p90_in": 28.23, + "scale_error_pct": -2.44 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 15.33, + "p90_in": 53.18, + "scale_error_pct": -4.02 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 2.0, + "median_in": 5.67, + "p90_in": 35.99, + "scale_error_pct": 4.52 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 2.02, + "median_in": 14.67, + "p90_in": 65.25, + "scale_error_pct": 2.26 + } + }, + "tape_calibration_success_pct": 98.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.94, + 7.98 + ], + "p90_in": [ + 14.88, + 43.75 + ] + }, + "3-10m": { + "median_in": [ + 11.85, + 19.3 + ], + "p90_in": [ + 35.06, + 73.66 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.19, + "median_in": 2.27, + "p90_in": 8.33, + "scale_error_pct": -1.36 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.22, + "median_in": 8.24, + "p90_in": 36.36, + "scale_error_pct": -2.44 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.68, + "median_in": 3.01, + "p90_in": 12.53, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.72, + "median_in": 10.18, + "p90_in": 43.53, + "scale_error_pct": -1.25 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.49, + 3.53 + ], + "p90_in": [ + 6.06, + 11.4 + ] + }, + "3-10m": { + "median_in": [ + 5.06, + 12.04 + ], + "p90_in": [ + 24.51, + 48.17 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 2.1, + "p90_in": 7.33, + "scale_error_pct": -1.58 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 7.47, + "p90_in": 35.51, + "scale_error_pct": -2.36 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 2.75, + "p90_in": 11.3, + "scale_error_pct": -0.45 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.51, + "median_in": 9.59, + "p90_in": 42.74, + "scale_error_pct": -1.26 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.33, + 3.38 + ], + "p90_in": [ + 5.29, + 10.16 + ] + }, + "3-10m": { + "median_in": [ + 4.09, + 11.56 + ], + "p90_in": [ + 21.61, + 56.76 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 3.36, + "p90_in": 26.9, + "scale_error_pct": 1.94 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.03, + "median_in": 10.49, + "p90_in": 47.0, + "scale_error_pct": 0.93 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 4.97, + "p90_in": 30.06, + "scale_error_pct": 4.03 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.53, + "median_in": 13.92, + "p90_in": 55.96, + "scale_error_pct": 2.5 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.97, + 5.34 + ], + "p90_in": [ + 14.48, + 42.41 + ] + }, + "3-10m": { + "median_in": [ + 7.29, + 15.22 + ], + "p90_in": [ + 32.61, + 61.94 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.15, + "median_in": 1.78, + "p90_in": 7.74, + "scale_error_pct": -0.25 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.18, + "median_in": 5.91, + "p90_in": 32.6, + "scale_error_pct": -1.03 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.15, + "median_in": 3.15, + "p90_in": 12.41, + "scale_error_pct": -0.15 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.18, + "median_in": 10.69, + "p90_in": 43.25, + "scale_error_pct": -1.31 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.27, + 2.39 + ], + "p90_in": [ + 5.9, + 10.37 + ] + }, + "3-10m": { + "median_in": [ + 3.98, + 8.3 + ], + "p90_in": [ + 23.49, + 44.68 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.59, + "p90_in": 6.46, + "scale_error_pct": -0.37 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 5.31, + "p90_in": 32.47, + "scale_error_pct": -0.93 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.0, + "median_in": 2.89, + "p90_in": 11.24, + "scale_error_pct": -0.37 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.01, + "median_in": 10.13, + "p90_in": 42.54, + "scale_error_pct": -1.32 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.12, + 2.21 + ], + "p90_in": [ + 5.05, + 8.27 + ] + }, + "3-10m": { + "median_in": [ + 3.51, + 8.01 + ], + "p90_in": [ + 20.37, + 52.79 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 3.5, + "p90_in": 27.12, + "scale_error_pct": 3.32 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 10.67, + "p90_in": 46.08, + "scale_error_pct": 2.07 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.0, + "median_in": 4.98, + "p90_in": 29.62, + "scale_error_pct": 3.91 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.02, + "median_in": 13.74, + "p90_in": 53.62, + "scale_error_pct": 2.2 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.53, + 5.33 + ], + "p90_in": [ + 15.76, + 43.17 + ] + }, + "3-10m": { + "median_in": [ + 8.37, + 14.4 + ], + "p90_in": [ + 34.41, + 60.74 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.13, + "median_in": 1.66, + "p90_in": 7.52, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.15, + "median_in": 5.35, + "p90_in": 30.27, + "scale_error_pct": -0.61 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.63, + "median_in": 2.92, + "p90_in": 12.15, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.65, + "median_in": 9.8, + "p90_in": 43.35, + "scale_error_pct": -1.2 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.27, + 2.04 + ], + "p90_in": [ + 5.84, + 9.39 + ] + }, + "3-10m": { + "median_in": [ + 3.98, + 6.65 + ], + "p90_in": [ + 20.94, + 46.33 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 1.49, + "p90_in": 6.15, + "scale_error_pct": -0.11 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 4.82, + "p90_in": 29.87, + "scale_error_pct": -0.47 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 2.69, + "p90_in": 10.95, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.51, + "median_in": 9.24, + "p90_in": 42.66, + "scale_error_pct": -1.19 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.13, + 1.9 + ], + "p90_in": [ + 4.82, + 7.35 + ] + }, + "3-10m": { + "median_in": [ + 3.4, + 6.29 + ], + "p90_in": [ + 18.27, + 53.92 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.0, + "median_in": 3.7, + "p90_in": 29.3, + "scale_error_pct": 3.66 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 11.0, + "p90_in": 51.22, + "scale_error_pct": 2.43 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.5, + "median_in": 5.12, + "p90_in": 32.95, + "scale_error_pct": 4.12 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.52, + "median_in": 13.7, + "p90_in": 60.83, + "scale_error_pct": 2.27 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.71, + 5.77 + ], + "p90_in": [ + 15.44, + 52.71 + ] + }, + "3-10m": { + "median_in": [ + 8.99, + 14.58 + ], + "p90_in": [ + 31.87, + 82.15 + ] + } + } + } + } + }, + "MoGe-2 placed with advio_2018 poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.19, + "median_in": 5.98, + "p90_in": 12.08, + "scale_error_pct": -6.19 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.22, + "median_in": 20.47, + "p90_in": 47.82, + "scale_error_pct": -7.35 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.68, + "median_in": 3.1, + "p90_in": 12.65, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.72, + "median_in": 10.06, + "p90_in": 43.48, + "scale_error_pct": -1.29 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.08, + 7.06 + ], + "p90_in": [ + 10.23, + 14.14 + ] + }, + "3-10m": { + "median_in": [ + 17.11, + 23.21 + ], + "p90_in": [ + 38.23, + 56.29 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.91, + "p90_in": 11.35, + "scale_error_pct": -6.47 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 19.64, + "p90_in": 46.82, + "scale_error_pct": -7.4 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.5, + "median_in": 2.83, + "p90_in": 11.29, + "scale_error_pct": -0.47 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.51, + "median_in": 9.45, + "p90_in": 42.56, + "scale_error_pct": -1.3 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.04, + 6.9 + ], + "p90_in": [ + 9.78, + 13.49 + ] + }, + "3-10m": { + "median_in": [ + 15.04, + 22.48 + ], + "p90_in": [ + 35.95, + 58.11 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.52, + "p90_in": 26.24, + "scale_error_pct": -2.28 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.03, + "median_in": 16.03, + "p90_in": 48.41, + "scale_error_pct": -4.05 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.5, + "median_in": 5.11, + "p90_in": 30.48, + "scale_error_pct": 4.01 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.53, + "median_in": 13.81, + "p90_in": 55.9, + "scale_error_pct": 2.16 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.27, + 7.68 + ], + "p90_in": [ + 14.99, + 35.22 + ] + }, + "3-10m": { + "median_in": [ + 12.93, + 20.01 + ], + "p90_in": [ + 35.44, + 58.53 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.15, + "median_in": 5.77, + "p90_in": 11.6, + "scale_error_pct": -6.05 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.18, + "median_in": 19.38, + "p90_in": 48.64, + "scale_error_pct": -7.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.25, + "median_in": 3.46, + "p90_in": 12.14, + "scale_error_pct": -0.39 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.28, + "median_in": 10.96, + "p90_in": 43.03, + "scale_error_pct": -1.68 + } + }, + "tape_calibration_success_pct": 99.9, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.08, + 6.54 + ], + "p90_in": [ + 10.14, + 13.41 + ] + }, + "3-10m": { + "median_in": [ + 16.58, + 22.27 + ], + "p90_in": [ + 38.39, + 56.03 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.7, + "p90_in": 10.91, + "scale_error_pct": -6.35 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 18.54, + "p90_in": 47.98, + "scale_error_pct": -7.26 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.1, + "median_in": 3.2, + "p90_in": 10.96, + "scale_error_pct": -0.69 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.11, + "median_in": 10.42, + "p90_in": 41.88, + "scale_error_pct": -1.74 + } + }, + "tape_calibration_success_pct": 99.9, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.0, + 6.39 + ], + "p90_in": [ + 9.68, + 12.69 + ] + }, + "3-10m": { + "median_in": [ + 15.39, + 21.87 + ], + "p90_in": [ + 36.44, + 58.48 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.11, + "p90_in": 25.58, + "scale_error_pct": -1.86 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 14.97, + "p90_in": 45.77, + "scale_error_pct": -3.9 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.1, + "median_in": 5.38, + "p90_in": 29.85, + "scale_error_pct": 4.35 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.12, + "median_in": 13.82, + "p90_in": 52.89, + "scale_error_pct": 1.98 + } + }, + "tape_calibration_success_pct": 99.9, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.94, + 7.12 + ], + "p90_in": [ + 15.04, + 35.39 + ] + }, + "3-10m": { + "median_in": [ + 12.08, + 18.11 + ], + "p90_in": [ + 34.31, + 55.23 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.13, + "median_in": 5.99, + "p90_in": 11.72, + "scale_error_pct": -6.24 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.15, + "median_in": 20.09, + "p90_in": 46.0, + "scale_error_pct": -7.45 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 1.33, + "median_in": 3.58, + "p90_in": 14.05, + "scale_error_pct": -0.17 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.35, + "median_in": 11.35, + "p90_in": 47.77, + "scale_error_pct": -1.55 + } + }, + "tape_calibration_success_pct": 98.8, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.37, + 6.73 + ], + "p90_in": [ + 10.56, + 12.88 + ] + }, + "3-10m": { + "median_in": [ + 17.59, + 22.21 + ], + "p90_in": [ + 39.6, + 55.6 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.96, + "p90_in": 11.2, + "scale_error_pct": -6.57 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 19.24, + "p90_in": 46.34, + "scale_error_pct": -7.51 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 1.2, + "median_in": 3.33, + "p90_in": 12.83, + "scale_error_pct": -0.49 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.21, + "median_in": 10.84, + "p90_in": 47.03, + "scale_error_pct": -1.61 + } + }, + "tape_calibration_success_pct": 98.8, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.36, + 6.57 + ], + "p90_in": [ + 10.22, + 12.0 + ] + }, + "3-10m": { + "median_in": [ + 15.74, + 21.83 + ], + "p90_in": [ + 37.94, + 59.7 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.35, + "p90_in": 27.75, + "scale_error_pct": -2.41 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 15.38, + "p90_in": 52.73, + "scale_error_pct": -4.09 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 1.2, + "median_in": 5.76, + "p90_in": 34.57, + "scale_error_pct": 4.63 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.22, + "median_in": 14.75, + "p90_in": 63.99, + "scale_error_pct": 2.21 + } + }, + "tape_calibration_success_pct": 98.8, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.99, + 7.86 + ], + "p90_in": [ + 15.26, + 44.06 + ] + }, + "3-10m": { + "median_in": [ + 11.86, + 19.02 + ], + "p90_in": [ + 34.85, + 77.17 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, advio_2018 poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 7.66, + "median_in": 11.93, + "p90_in": 39.61, + "scale_error_pct": -9.23 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 7.69, + "median_in": 39.55, + "p90_in": 128.67, + "scale_error_pct": -11.86 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 7.95, + "median_in": 3.42, + "p90_in": 22.66, + "scale_error_pct": -0.28 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 7.99, + "median_in": 11.51, + "p90_in": 86.42, + "scale_error_pct": -1.32 + } + }, + "tape_calibration_success_pct": 92.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 7.19, + 16.58 + ], + "p90_in": [ + 24.6, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 26.01, + 54.46 + ], + "p90_in": [ + 83.39, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 7.5, + "median_in": 11.8, + "p90_in": 38.3, + "scale_error_pct": -9.67 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 7.51, + "median_in": 38.59, + "p90_in": 122.94, + "scale_error_pct": -12.28 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 7.8, + "median_in": 3.14, + "p90_in": 19.51, + "scale_error_pct": -0.51 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 7.81, + "median_in": 10.91, + "p90_in": 88.73, + "scale_error_pct": -1.36 + } + }, + "tape_calibration_success_pct": 92.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 6.75, + 16.72 + ], + "p90_in": [ + 24.25, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 25.21, + 53.41 + ], + "p90_in": [ + 80.34, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 7.5, + "median_in": 12.03, + "p90_in": 69.23, + "scale_error_pct": -4.09 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 7.53, + "median_in": 35.45, + "p90_in": 156.46, + "scale_error_pct": -7.89 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 7.8, + "median_in": 5.52, + "p90_in": 67.66, + "scale_error_pct": 3.9 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 7.83, + "median_in": 15.07, + "p90_in": 102.94, + "scale_error_pct": 2.46 + } + }, + "tape_calibration_success_pct": 92.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 8.57, + 17.02 + ], + "p90_in": [ + 26.76, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 25.04, + 48.36 + ], + "p90_in": [ + 74.14, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.15, + "median_in": 8.71, + "p90_in": 19.62, + "scale_error_pct": -9.75 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.18, + "median_in": 28.73, + "p90_in": 71.4, + "scale_error_pct": -11.95 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.45, + "median_in": 4.21, + "p90_in": 16.48, + "scale_error_pct": -0.66 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.48, + "median_in": 13.41, + "p90_in": 52.47, + "scale_error_pct": -2.33 + } + }, + "tape_calibration_success_pct": 99.7, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.43, + 11.8 + ], + "p90_in": [ + 15.58, + 22.0 + ] + }, + "3-10m": { + "median_in": [ + 17.19, + 38.74 + ], + "p90_in": [ + 57.02, + 80.7 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 8.87, + "p90_in": 19.61, + "scale_error_pct": -10.51 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 28.33, + "p90_in": 70.93, + "scale_error_pct": -12.36 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.3, + "median_in": 3.93, + "p90_in": 15.27, + "scale_error_pct": -0.95 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.31, + "median_in": 12.92, + "p90_in": 51.4, + "scale_error_pct": -2.47 + } + }, + "tape_calibration_success_pct": 99.7, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.4, + 12.17 + ], + "p90_in": [ + 15.56, + 22.05 + ] + }, + "3-10m": { + "median_in": [ + 16.96, + 37.79 + ], + "p90_in": [ + 56.21, + 83.3 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 7.55, + "p90_in": 23.27, + "scale_error_pct": -3.21 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 24.22, + "p90_in": 62.49, + "scale_error_pct": -7.22 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.3, + "median_in": 6.09, + "p90_in": 32.23, + "scale_error_pct": 4.02 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.32, + "median_in": 15.55, + "p90_in": 60.52, + "scale_error_pct": 1.36 + } + }, + "tape_calibration_success_pct": 99.7, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.19, + 9.83 + ], + "p90_in": [ + 18.29, + 28.97 + ] + }, + "3-10m": { + "median_in": [ + 15.77, + 32.4 + ], + "p90_in": [ + 48.75, + 71.52 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.13, + "median_in": 6.5, + "p90_in": 17.33, + "scale_error_pct": -7.09 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.15, + "median_in": 22.53, + "p90_in": 64.19, + "scale_error_pct": -8.72 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.93, + "median_in": 4.07, + "p90_in": 16.07, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.95, + "median_in": 12.64, + "p90_in": 51.04, + "scale_error_pct": -1.52 + } + }, + "tape_calibration_success_pct": 99.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.58, + 9.57 + ], + "p90_in": [ + 11.91, + 20.37 + ] + }, + "3-10m": { + "median_in": [ + 15.33, + 33.78 + ], + "p90_in": [ + 38.85, + 81.28 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 6.57, + "p90_in": 17.22, + "scale_error_pct": -7.51 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 21.81, + "p90_in": 63.38, + "scale_error_pct": -8.78 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.8, + "median_in": 3.79, + "p90_in": 14.79, + "scale_error_pct": -0.29 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.81, + "median_in": 12.16, + "p90_in": 50.65, + "scale_error_pct": -1.6 + } + }, + "tape_calibration_success_pct": 99.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.42, + 9.96 + ], + "p90_in": [ + 11.29, + 20.25 + ] + }, + "3-10m": { + "median_in": [ + 14.55, + 33.1 + ], + "p90_in": [ + 37.22, + 91.36 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 6.4, + "p90_in": 25.42, + "scale_error_pct": -3.06 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 18.74, + "p90_in": 68.38, + "scale_error_pct": -5.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.8, + "median_in": 6.39, + "p90_in": 37.61, + "scale_error_pct": 5.01 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.82, + "median_in": 15.95, + "p90_in": 67.86, + "scale_error_pct": 1.9 + } + }, + "tape_calibration_success_pct": 99.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.77, + 9.0 + ], + "p90_in": [ + 16.57, + 49.16 + ] + }, + "3-10m": { + "median_in": [ + 13.36, + 28.66 + ], + "p90_in": [ + 46.97, + 90.06 + ] + } + } + } + } + }, + "MoGe-2 placed with modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.19, + "median_in": 5.94, + "p90_in": 11.99, + "scale_error_pct": -6.16 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.22, + "median_in": 20.44, + "p90_in": 47.75, + "scale_error_pct": -7.32 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.68, + "median_in": 3.09, + "p90_in": 12.64, + "scale_error_pct": -0.19 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.72, + "median_in": 9.93, + "p90_in": 43.7, + "scale_error_pct": -1.25 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.08, + 6.99 + ], + "p90_in": [ + 10.15, + 14.06 + ] + }, + "3-10m": { + "median_in": [ + 17.11, + 23.2 + ], + "p90_in": [ + 37.98, + 56.25 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.9, + "p90_in": 11.28, + "scale_error_pct": -6.47 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 19.57, + "p90_in": 46.8, + "scale_error_pct": -7.39 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.5, + "median_in": 2.83, + "p90_in": 11.29, + "scale_error_pct": -0.45 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.51, + "median_in": 9.33, + "p90_in": 42.72, + "scale_error_pct": -1.27 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.05, + 6.88 + ], + "p90_in": [ + 9.72, + 13.39 + ] + }, + "3-10m": { + "median_in": [ + 15.14, + 22.47 + ], + "p90_in": [ + 35.65, + 58.06 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.57, + "p90_in": 26.11, + "scale_error_pct": -2.23 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.03, + "median_in": 16.04, + "p90_in": 48.48, + "scale_error_pct": -3.99 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.5, + "median_in": 5.05, + "p90_in": 30.47, + "scale_error_pct": 3.93 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.53, + "median_in": 13.83, + "p90_in": 55.79, + "scale_error_pct": 2.17 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.3, + 7.68 + ], + "p90_in": [ + 14.82, + 35.7 + ] + }, + "3-10m": { + "median_in": [ + 12.76, + 20.0 + ], + "p90_in": [ + 35.34, + 58.73 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.15, + "median_in": 5.71, + "p90_in": 11.62, + "scale_error_pct": -5.91 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.18, + "median_in": 19.3, + "p90_in": 46.99, + "scale_error_pct": -7.02 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.65, + "median_in": 3.36, + "p90_in": 12.38, + "scale_error_pct": -0.36 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.68, + "median_in": 10.84, + "p90_in": 42.51, + "scale_error_pct": -1.66 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.0, + 6.49 + ], + "p90_in": [ + 10.09, + 13.65 + ] + }, + "3-10m": { + "median_in": [ + 16.55, + 21.86 + ], + "p90_in": [ + 38.11, + 53.72 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.62, + "p90_in": 10.92, + "scale_error_pct": -6.19 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 18.43, + "p90_in": 46.74, + "scale_error_pct": -7.04 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.5, + "median_in": 3.09, + "p90_in": 11.14, + "scale_error_pct": -0.67 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.51, + "median_in": 10.32, + "p90_in": 41.51, + "scale_error_pct": -1.7 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.93, + 6.3 + ], + "p90_in": [ + 9.61, + 12.92 + ] + }, + "3-10m": { + "median_in": [ + 14.92, + 21.51 + ], + "p90_in": [ + 35.82, + 57.0 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.11, + "p90_in": 24.62, + "scale_error_pct": -2.18 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 14.82, + "p90_in": 45.45, + "scale_error_pct": -3.85 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.5, + "median_in": 5.22, + "p90_in": 28.55, + "scale_error_pct": 4.07 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.52, + "median_in": 13.72, + "p90_in": 52.85, + "scale_error_pct": 1.98 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.91, + 7.15 + ], + "p90_in": [ + 14.6, + 33.59 + ] + }, + "3-10m": { + "median_in": [ + 11.86, + 18.12 + ], + "p90_in": [ + 34.26, + 55.41 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.13, + "median_in": 5.93, + "p90_in": 11.73, + "scale_error_pct": -6.06 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.15, + "median_in": 19.99, + "p90_in": 46.0, + "scale_error_pct": -7.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 1.93, + "median_in": 3.44, + "p90_in": 14.17, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.95, + "median_in": 10.92, + "p90_in": 48.16, + "scale_error_pct": -1.4 + } + }, + "tape_calibration_success_pct": 98.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.29, + 6.68 + ], + "p90_in": [ + 10.58, + 12.78 + ] + }, + "3-10m": { + "median_in": [ + 17.48, + 22.01 + ], + "p90_in": [ + 39.7, + 55.53 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.87, + "p90_in": 11.2, + "scale_error_pct": -6.4 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 19.11, + "p90_in": 46.28, + "scale_error_pct": -7.36 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 1.8, + "median_in": 3.16, + "p90_in": 12.82, + "scale_error_pct": -0.36 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.81, + "median_in": 10.43, + "p90_in": 47.65, + "scale_error_pct": -1.44 + } + }, + "tape_calibration_success_pct": 98.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.27, + 6.48 + ], + "p90_in": [ + 10.2, + 11.97 + ] + }, + "3-10m": { + "median_in": [ + 15.56, + 21.68 + ], + "p90_in": [ + 37.96, + 59.69 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 5.27, + "p90_in": 28.03, + "scale_error_pct": -2.38 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 15.34, + "p90_in": 53.09, + "scale_error_pct": -4.03 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 1.8, + "median_in": 5.67, + "p90_in": 35.56, + "scale_error_pct": 4.59 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.82, + "median_in": 14.6, + "p90_in": 64.72, + "scale_error_pct": 2.25 + } + }, + "tape_calibration_success_pct": 98.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.82, + 7.87 + ], + "p90_in": [ + 15.44, + 44.29 + ] + }, + "3-10m": { + "median_in": [ + 11.6, + 18.99 + ], + "p90_in": [ + 35.15, + 76.27 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 2.69, + "median_in": 5.2, + "p90_in": 15.5, + "scale_error_pct": -3.34 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 2.72, + "median_in": 17.11, + "p90_in": 56.33, + "scale_error_pct": -4.88 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 3.18, + "median_in": 3.12, + "p90_in": 14.61, + "scale_error_pct": -0.25 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 3.21, + "median_in": 10.58, + "p90_in": 50.84, + "scale_error_pct": -1.26 + } + }, + "tape_calibration_success_pct": 97.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.94, + 6.78 + ], + "p90_in": [ + 11.67, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 13.06, + 23.19 + ], + "p90_in": [ + 40.21, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 2.5, + "median_in": 5.01, + "p90_in": 14.93, + "scale_error_pct": -3.62 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 2.51, + "median_in": 16.37, + "p90_in": 54.99, + "scale_error_pct": -4.96 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 3.0, + "median_in": 2.85, + "p90_in": 13.2, + "scale_error_pct": -0.48 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 3.01, + "median_in": 9.98, + "p90_in": 50.02, + "scale_error_pct": -1.28 + } + }, + "tape_calibration_success_pct": 97.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.74, + 6.74 + ], + "p90_in": [ + 10.95, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 12.4, + 22.79 + ], + "p90_in": [ + 38.2, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 2.5, + "median_in": 6.24, + "p90_in": 33.95, + "scale_error_pct": 1.3 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 2.53, + "median_in": 17.27, + "p90_in": 65.28, + "scale_error_pct": -1.04 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 3.0, + "median_in": 5.18, + "p90_in": 37.1, + "scale_error_pct": 3.98 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 3.03, + "median_in": 14.37, + "p90_in": 65.2, + "scale_error_pct": 2.52 + } + }, + "tape_calibration_success_pct": 97.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.69, + 8.35 + ], + "p90_in": [ + 16.23, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 12.94, + 24.95 + ], + "p90_in": [ + 43.33, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.15, + "median_in": 3.01, + "p90_in": 10.45, + "scale_error_pct": -2.21 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.18, + "median_in": 10.19, + "p90_in": 39.33, + "scale_error_pct": -3.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.85, + "median_in": 3.74, + "p90_in": 14.28, + "scale_error_pct": -0.33 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.88, + "median_in": 12.29, + "p90_in": 46.8, + "scale_error_pct": -1.69 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.59, + 3.63 + ], + "p90_in": [ + 8.75, + 12.27 + ] + }, + "3-10m": { + "median_in": [ + 8.23, + 12.86 + ], + "p90_in": [ + 31.34, + 51.07 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 2.83, + "p90_in": 9.26, + "scale_error_pct": -2.46 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 9.5, + "p90_in": 38.89, + "scale_error_pct": -3.23 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.7, + "median_in": 3.46, + "p90_in": 13.1, + "scale_error_pct": -0.58 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.71, + "median_in": 11.78, + "p90_in": 45.87, + "scale_error_pct": -1.73 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.38, + 3.44 + ], + "p90_in": [ + 7.75, + 10.98 + ] + }, + "3-10m": { + "median_in": [ + 7.22, + 12.32 + ], + "p90_in": [ + 28.33, + 59.4 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 4.12, + "p90_in": 26.78, + "scale_error_pct": 2.06 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 11.28, + "p90_in": 47.82, + "scale_error_pct": 0.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.7, + "median_in": 5.66, + "p90_in": 30.31, + "scale_error_pct": 4.12 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.72, + "median_in": 14.81, + "p90_in": 55.9, + "scale_error_pct": 1.99 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.85, + 6.19 + ], + "p90_in": [ + 15.27, + 41.4 + ] + }, + "3-10m": { + "median_in": [ + 8.3, + 14.81 + ], + "p90_in": [ + 34.61, + 62.6 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.13, + "median_in": 2.51, + "p90_in": 8.58, + "scale_error_pct": -1.6 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.15, + "median_in": 8.18, + "p90_in": 34.84, + "scale_error_pct": -2.67 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.93, + "median_in": 3.32, + "p90_in": 13.16, + "scale_error_pct": -0.06 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.95, + "median_in": 10.71, + "p90_in": 45.37, + "scale_error_pct": -1.33 + } + }, + "tape_calibration_success_pct": 99.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.14, + 2.94 + ], + "p90_in": [ + 7.13, + 9.79 + ] + }, + "3-10m": { + "median_in": [ + 6.67, + 10.07 + ], + "p90_in": [ + 26.27, + 49.89 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 2.34, + "p90_in": 7.6, + "scale_error_pct": -1.88 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 7.48, + "p90_in": 34.71, + "scale_error_pct": -2.64 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.8, + "median_in": 3.08, + "p90_in": 11.89, + "scale_error_pct": -0.31 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.81, + "median_in": 10.21, + "p90_in": 44.83, + "scale_error_pct": -1.36 + } + }, + "tape_calibration_success_pct": 99.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 1.94, + 2.78 + ], + "p90_in": [ + 6.41, + 8.58 + ] + }, + "3-10m": { + "median_in": [ + 5.92, + 9.33 + ], + "p90_in": [ + 24.13, + 60.27 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.0, + "median_in": 3.58, + "p90_in": 27.59, + "scale_error_pct": 2.19 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 9.95, + "p90_in": 49.83, + "scale_error_pct": 0.27 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.8, + "median_in": 5.66, + "p90_in": 34.54, + "scale_error_pct": 4.69 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.82, + "median_in": 14.46, + "p90_in": 63.22, + "scale_error_pct": 2.28 + } + }, + "tape_calibration_success_pct": 99.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.64, + 5.55 + ], + "p90_in": [ + 15.04, + 49.59 + ] + }, + "3-10m": { + "median_in": [ + 7.36, + 13.27 + ], + "p90_in": [ + 30.78, + 81.52 + ] + } + } + } + } + } + }, + "within 6 m": { + "mapanything@392, images only": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 29.77, + "median_in": 10.22, + "p90_in": Infinity, + "scale_error_pct": 6.2 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 44.98, + "median_in": 27.66, + "p90_in": Infinity, + "scale_error_pct": 13.26 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 44.16, + "median_in": 4.13, + "p90_in": Infinity, + "scale_error_pct": -0.11 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 57.81, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.66 + } + }, + "tape_calibration_success_pct": 78.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.31, + 17.11 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 26.95, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 6.67, + "median_in": 11.82, + "p90_in": 18.55, + "scale_error_pct": 15.19 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 8.87, + "median_in": 22.91, + "p90_in": 32.76, + "scale_error_pct": 15.4 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 36.53, + "median_in": 4.03, + "p90_in": Infinity, + "scale_error_pct": -1.32 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 38.03, + "median_in": 6.9, + "p90_in": Infinity, + "scale_error_pct": -1.02 + } + }, + "tape_calibration_success_pct": 68.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 11.82, + 11.82 + ], + "p90_in": [ + 18.55, + 18.55 + ] + }, + "3-10m": { + "median_in": [ + 22.91, + 22.91 + ], + "p90_in": [ + 32.76, + 32.76 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 78.43, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 15.75 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 77.33, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 16.09 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 85.33, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.8 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 84.59, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.48 + } + }, + "tape_calibration_success_pct": 68.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 25.88, + "median_in": 8.4, + "p90_in": Infinity, + "scale_error_pct": 4.51 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 41.02, + "median_in": 24.74, + "p90_in": Infinity, + "scale_error_pct": 11.62 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 42.34, + "median_in": 4.43, + "p90_in": Infinity, + "scale_error_pct": -0.41 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 53.79, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.95 + } + }, + "tape_calibration_success_pct": 78.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.85, + 14.71 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 24.41, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 2.2, + "median_in": 9.78, + "p90_in": 16.04, + "scale_error_pct": 13.53 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 4.2, + "median_in": 21.01, + "p90_in": 28.31, + "scale_error_pct": 14.33 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 21.76, + "median_in": 3.86, + "p90_in": Infinity, + "scale_error_pct": -2.58 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 23.36, + "median_in": 5.49, + "p90_in": Infinity, + "scale_error_pct": -1.73 + } + }, + "tape_calibration_success_pct": 80.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 9.78, + 9.78 + ], + "p90_in": [ + 16.04, + 16.04 + ] + }, + "3-10m": { + "median_in": [ + 21.01, + 21.01 + ], + "p90_in": [ + 28.31, + 28.31 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 73.37, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 14.08 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 71.9, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 15.5 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 78.69, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -1.82 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 77.52, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.53 + } + }, + "tape_calibration_success_pct": 80.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 28.95, + "median_in": 8.28, + "p90_in": Infinity, + "scale_error_pct": 3.82 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 39.12, + "median_in": 19.98, + "p90_in": Infinity, + "scale_error_pct": 9.41 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 46.87, + "median_in": 5.46, + "p90_in": Infinity, + "scale_error_pct": -0.27 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 55.13, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.89 + } + }, + "tape_calibration_success_pct": 74.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.49, + 13.88 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 2.57, + 21.3 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 6.23, + "median_in": 9.44, + "p90_in": 15.43, + "scale_error_pct": 12.3 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 8.63, + "median_in": 18.25, + "p90_in": 25.33, + "scale_error_pct": 12.21 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 36.24, + "median_in": 4.48, + "p90_in": Infinity, + "scale_error_pct": -1.46 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 37.87, + "median_in": 7.32, + "p90_in": Infinity, + "scale_error_pct": -1.33 + } + }, + "tape_calibration_success_pct": 68.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 9.44, + 9.44 + ], + "p90_in": [ + 15.43, + 15.43 + ] + }, + "3-10m": { + "median_in": [ + 18.25, + 18.25 + ], + "p90_in": [ + 25.33, + 25.33 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 78.3, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 12.18 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 77.47, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 12.72 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 85.24, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -1.5 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 84.68, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.92 + } + }, + "tape_calibration_success_pct": 68.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 24.75, + "median_in": 3.37, + "p90_in": Infinity, + "scale_error_pct": 1.87 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 37.88, + "median_in": 9.33, + "p90_in": Infinity, + "scale_error_pct": 3.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 39.55, + "median_in": 3.15, + "p90_in": Infinity, + "scale_error_pct": -0.16 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 50.61, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.62 + } + }, + "tape_calibration_success_pct": 80.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 0.67, + 6.42 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 1.13, + 9.77 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 2.37, + "median_in": 4.45, + "p90_in": 7.69, + "scale_error_pct": 5.91 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 4.3, + "median_in": 8.77, + "p90_in": 12.24, + "scale_error_pct": 5.88 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 25.8, + "median_in": 2.96, + "p90_in": Infinity, + "scale_error_pct": -0.51 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 27.27, + "median_in": 4.67, + "p90_in": Infinity, + "scale_error_pct": -0.26 + } + }, + "tape_calibration_success_pct": 76.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 4.45, + 4.45 + ], + "p90_in": [ + 7.69, + 7.69 + ] + }, + "3-10m": { + "median_in": [ + 8.77, + 8.77 + ], + "p90_in": [ + 12.24, + 12.24 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 73.47, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.98 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 71.3, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 6.68 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 79.83, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.44 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 78.19, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 0.63 + } + }, + "tape_calibration_success_pct": 76.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 28.15, + "median_in": 7.68, + "p90_in": Infinity, + "scale_error_pct": 2.39 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 38.63, + "median_in": 19.1, + "p90_in": Infinity, + "scale_error_pct": 8.84 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 44.54, + "median_in": 4.42, + "p90_in": Infinity, + "scale_error_pct": -0.18 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 53.51, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.82 + } + }, + "tape_calibration_success_pct": 76.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 0.75, + 13.65 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 1.1, + 20.72 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 6.33, + "median_in": 9.29, + "p90_in": 14.96, + "scale_error_pct": 11.96 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 9.03, + "median_in": 17.69, + "p90_in": 24.5, + "scale_error_pct": 11.82 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 36.31, + "median_in": 4.43, + "p90_in": Infinity, + "scale_error_pct": -1.27 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 38.14, + "median_in": 7.34, + "p90_in": Infinity, + "scale_error_pct": -1.29 + } + }, + "tape_calibration_success_pct": 68.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 9.29, + 9.29 + ], + "p90_in": [ + 14.96, + 14.96 + ] + }, + "3-10m": { + "median_in": [ + 17.69, + 17.69 + ], + "p90_in": [ + 24.5, + 24.5 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 78.3, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 11.12 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 77.47, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 12.05 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 85.24, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -1.97 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 84.68, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -1.01 + } + }, + "tape_calibration_success_pct": 68.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 23.77, + "median_in": 3.88, + "p90_in": Infinity, + "scale_error_pct": 1.35 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 37.88, + "median_in": 12.85, + "p90_in": Infinity, + "scale_error_pct": 5.15 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 42.06, + "median_in": 4.18, + "p90_in": Infinity, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 52.79, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.65 + } + }, + "tape_calibration_success_pct": 76.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 0.82, + 7.65 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 2.65, + 13.25 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 2.2, + "median_in": 4.96, + "p90_in": 9.54, + "scale_error_pct": 6.73 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 4.0, + "median_in": 11.53, + "p90_in": 16.08, + "scale_error_pct": 7.7 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 25.67, + "median_in": 3.87, + "p90_in": Infinity, + "scale_error_pct": -1.77 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 27.04, + "median_in": 6.14, + "p90_in": Infinity, + "scale_error_pct": -0.67 + } + }, + "tape_calibration_success_pct": 76.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 4.96, + 4.96 + ], + "p90_in": [ + 9.54, + 9.54 + ] + }, + "3-10m": { + "median_in": [ + 11.53, + 11.53 + ], + "p90_in": [ + 16.08, + 16.08 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 73.2, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 7.77 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 71.1, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 8.98 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 79.63, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.58 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 78.04, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 0.75 + } + }, + "tape_calibration_success_pct": 76.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 28.15, + "median_in": 7.63, + "p90_in": Infinity, + "scale_error_pct": 3.31 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 38.02, + "median_in": 18.75, + "p90_in": Infinity, + "scale_error_pct": 8.51 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 47.86, + "median_in": 6.25, + "p90_in": Infinity, + "scale_error_pct": -0.29 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 55.44, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.95 + } + }, + "tape_calibration_success_pct": 72.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.25, + 13.45 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 2.07, + 20.46 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 6.23, + "median_in": 9.06, + "p90_in": 14.89, + "scale_error_pct": 11.81 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 9.0, + "median_in": 17.5, + "p90_in": 24.35, + "scale_error_pct": 11.66 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 36.24, + "median_in": 4.55, + "p90_in": Infinity, + "scale_error_pct": -1.52 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 38.12, + "median_in": 7.36, + "p90_in": Infinity, + "scale_error_pct": -1.44 + } + }, + "tape_calibration_success_pct": 68.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 9.06, + 9.06 + ], + "p90_in": [ + 14.89, + 14.89 + ] + }, + "3-10m": { + "median_in": [ + 17.5, + 17.5 + ], + "p90_in": [ + 24.35, + 24.35 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 78.3, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 11.43 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 77.47, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 12.09 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 85.24, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -1.95 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 84.68, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -1.07 + } + }, + "tape_calibration_success_pct": 68.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 23.45, + "median_in": 2.72, + "p90_in": Infinity, + "scale_error_pct": 1.59 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 35.58, + "median_in": 7.68, + "p90_in": Infinity, + "scale_error_pct": 2.12 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 38.46, + "median_in": 2.98, + "p90_in": Infinity, + "scale_error_pct": -0.09 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 48.68, + "median_in": 12.47, + "p90_in": Infinity, + "scale_error_pct": -0.59 + } + }, + "tape_calibration_success_pct": 80.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 0.64, + 5.76 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 1.0, + 8.46 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 2.37, + "median_in": 3.94, + "p90_in": 7.06, + "scale_error_pct": 5.2 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 4.3, + "median_in": 7.64, + "p90_in": 11.05, + "scale_error_pct": 5.11 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 25.8, + "median_in": 3.01, + "p90_in": Infinity, + "scale_error_pct": -0.51 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 27.27, + "median_in": 4.84, + "p90_in": Infinity, + "scale_error_pct": -0.32 + } + }, + "tape_calibration_success_pct": 76.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.94, + 3.94 + ], + "p90_in": [ + 7.06, + 7.06 + ] + }, + "3-10m": { + "median_in": [ + 7.64, + 7.64 + ], + "p90_in": [ + 11.05, + 11.05 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 73.67, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.01 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 71.47, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.79 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 79.99, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -0.65 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 78.31, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 0.54 + } + }, + "tape_calibration_success_pct": 76.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "MoGe-2 placed with exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 8.46, + "p90_in": 19.42, + "scale_error_pct": 10.02 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 15.98, + "p90_in": 29.76, + "scale_error_pct": 9.74 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 6.31, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.3, + "p90_in": 10.68, + "scale_error_pct": 0.15 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.76, + 13.8 + ], + "p90_in": [ + 8.91, + 22.81 + ] + }, + "3-10m": { + "median_in": [ + 11.83, + 24.28 + ], + "p90_in": [ + 14.74, + 32.42 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 15.83, + "p90_in": 24.19, + "scale_error_pct": 20.71 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 26.12, + "p90_in": 32.33, + "scale_error_pct": 17.68 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.36, + "p90_in": 8.69, + "scale_error_pct": 2.22 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.87, + "p90_in": 12.11, + "scale_error_pct": -0.56 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 15.83, + 15.83 + ], + "p90_in": [ + 24.19, + 24.19 + ] + }, + "3-10m": { + "median_in": [ + 26.12, + 26.12 + ], + "p90_in": [ + 32.33, + 32.33 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 10.23, + "p90_in": 18.55, + "scale_error_pct": 17.31 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 30.86, + "p90_in": 38.1, + "scale_error_pct": 16.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.36, + "p90_in": 6.78, + "scale_error_pct": 0.41 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.7, + "p90_in": 15.99, + "scale_error_pct": -1.34 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 10.23, + 10.23 + ], + "p90_in": [ + 18.55, + 18.55 + ] + }, + "3-10m": { + "median_in": [ + 30.86, + 30.86 + ], + "p90_in": [ + 38.1, + 38.1 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 8.3, + "p90_in": 19.43, + "scale_error_pct": 9.76 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 15.64, + "p90_in": 28.43, + "scale_error_pct": 9.57 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.66, + "p90_in": 6.66, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.3, + "p90_in": 11.19, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.76, + 13.64 + ], + "p90_in": [ + 8.91, + 22.38 + ] + }, + "3-10m": { + "median_in": [ + 11.83, + 23.87 + ], + "p90_in": [ + 14.74, + 30.6 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 15.81, + "p90_in": 23.32, + "scale_error_pct": 20.41 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 25.41, + "p90_in": 31.08, + "scale_error_pct": 17.29 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.43, + "p90_in": 8.76, + "scale_error_pct": 2.07 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.01, + "p90_in": 13.12, + "scale_error_pct": -0.71 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 15.81, + 15.81 + ], + "p90_in": [ + 23.32, + 23.32 + ] + }, + "3-10m": { + "median_in": [ + 25.41, + 25.41 + ], + "p90_in": [ + 31.08, + 31.08 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 9.56, + "p90_in": 18.48, + "scale_error_pct": 16.88 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 30.2, + "p90_in": 37.73, + "scale_error_pct": 16.12 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.38, + "p90_in": 7.18, + "scale_error_pct": 0.49 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.95, + "p90_in": 17.98, + "scale_error_pct": -1.39 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 9.56, + 9.56 + ], + "p90_in": [ + 18.48, + 18.48 + ] + }, + "3-10m": { + "median_in": [ + 30.2, + 30.2 + ], + "p90_in": [ + 37.73, + 37.73 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 8.23, + "p90_in": 19.1, + "scale_error_pct": 9.43 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 15.14, + "p90_in": 28.41, + "scale_error_pct": 9.39 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.72, + "p90_in": 9.97, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.39, + "p90_in": 17.06, + "scale_error_pct": 0.16 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.76, + 13.49 + ], + "p90_in": [ + 8.91, + 21.94 + ] + }, + "3-10m": { + "median_in": [ + 11.83, + 23.85 + ], + "p90_in": [ + 14.74, + 30.47 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 15.36, + "p90_in": 22.55, + "scale_error_pct": 20.32 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 25.26, + "p90_in": 30.98, + "scale_error_pct": 17.26 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.05, + "p90_in": 13.49, + "scale_error_pct": 2.76 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 6.63, + "p90_in": 28.42, + "scale_error_pct": -0.81 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 15.36, + 15.36 + ], + "p90_in": [ + 22.55, + 22.55 + ] + }, + "3-10m": { + "median_in": [ + 25.26, + 25.26 + ], + "p90_in": [ + 30.98, + 30.98 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 8.91, + "p90_in": 17.37, + "scale_error_pct": 16.17 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 30.58, + "p90_in": 37.59, + "scale_error_pct": 16.51 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.88, + "p90_in": 9.38, + "scale_error_pct": 0.27 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 7.47, + "p90_in": 41.77, + "scale_error_pct": -1.63 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.91, + 8.91 + ], + "p90_in": [ + 17.37, + 17.37 + ] + }, + "3-10m": { + "median_in": [ + 30.58, + 30.58 + ], + "p90_in": [ + 37.59, + 37.59 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 5.53, + "p90_in": 10.78, + "scale_error_pct": 10.41 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 12.9, + "p90_in": 17.82, + "scale_error_pct": 10.68 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 6.33, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.31, + "p90_in": 10.71, + "scale_error_pct": 0.15 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.21, + 7.67 + ], + "p90_in": [ + 7.96, + 11.71 + ] + }, + "3-10m": { + "median_in": [ + 2.9, + 15.56 + ], + "p90_in": [ + 7.12, + 19.31 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 3.39, + "p90_in": 9.17, + "scale_error_pct": 4.67 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 3.06, + "p90_in": 7.63, + "scale_error_pct": 1.78 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.37, + "p90_in": 8.73, + "scale_error_pct": 2.23 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 4.89, + "p90_in": 12.16, + "scale_error_pct": -0.55 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.39, + 3.39 + ], + "p90_in": [ + 9.17, + 9.17 + ] + }, + "3-10m": { + "median_in": [ + 3.06, + 3.06 + ], + "p90_in": [ + 7.63, + 7.63 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.34, + "p90_in": 7.02, + "scale_error_pct": 1.72 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 1.51, + "p90_in": 4.91, + "scale_error_pct": 0.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.36, + "p90_in": 6.81, + "scale_error_pct": 0.42 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.72, + "p90_in": 16.05, + "scale_error_pct": -1.34 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.34, + 1.34 + ], + "p90_in": [ + 7.02, + 7.02 + ] + }, + "3-10m": { + "median_in": [ + 1.51, + 1.51 + ], + "p90_in": [ + 4.91, + 4.91 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 7.5, + "p90_in": 13.02, + "scale_error_pct": 11.68 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 14.67, + "p90_in": 19.34, + "scale_error_pct": 11.74 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.69, + "p90_in": 7.61, + "scale_error_pct": 0.08 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.32, + "p90_in": 12.09, + "scale_error_pct": 0.19 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 6.21, + 8.29 + ], + "p90_in": [ + 12.64, + 13.71 + ] + }, + "3-10m": { + "median_in": [ + 8.95, + 16.78 + ], + "p90_in": [ + 14.62, + 20.8 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 8.21, + "p90_in": 14.9, + "scale_error_pct": 10.57 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 11.24, + "p90_in": 15.57, + "scale_error_pct": 7.42 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.82, + "p90_in": 10.57, + "scale_error_pct": 3.23 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.04, + "p90_in": 14.8, + "scale_error_pct": 0.04 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.21, + 8.21 + ], + "p90_in": [ + 14.9, + 14.9 + ] + }, + "3-10m": { + "median_in": [ + 11.24, + 11.24 + ], + "p90_in": [ + 15.57, + 15.57 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 4.12, + "p90_in": 10.29, + "scale_error_pct": 7.2 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 11.41, + "p90_in": 16.04, + "scale_error_pct": 6.24 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.47, + "p90_in": 8.07, + "scale_error_pct": 0.52 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.73, + "p90_in": 20.0, + "scale_error_pct": -1.05 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 4.12, + 4.12 + ], + "p90_in": [ + 10.29, + 10.29 + ] + }, + "3-10m": { + "median_in": [ + 11.41, + 11.41 + ], + "p90_in": [ + 16.04, + 16.04 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 7.72, + "p90_in": 13.05, + "scale_error_pct": 12.28 + }, + "3-10m": { + "pairs": 6000, + "failed_pct": 0.0, + "median_in": 15.25, + "p90_in": 20.22, + "scale_error_pct": 12.34 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 0.7, + "p90_in": 7.27, + "scale_error_pct": 0.0 + }, + "3-10m": { + "pairs": 150000, + "failed_pct": 0.0, + "median_in": 1.35, + "p90_in": 13.01, + "scale_error_pct": 0.09 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 6.6, + 8.74 + ], + "p90_in": [ + 12.34, + 13.32 + ] + }, + "3-10m": { + "median_in": [ + 9.54, + 17.67 + ], + "p90_in": [ + 14.15, + 21.91 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 7.56, + "p90_in": 12.66, + "scale_error_pct": 9.83 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 11.16, + "p90_in": 14.79, + "scale_error_pct": 7.68 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 3.51, + "p90_in": 9.04, + "scale_error_pct": 1.95 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 5.18, + "p90_in": 15.86, + "scale_error_pct": -1.27 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 7.56, + 7.56 + ], + "p90_in": [ + 12.66, + 12.66 + ] + }, + "3-10m": { + "median_in": [ + 11.16, + 11.16 + ], + "p90_in": [ + 14.79, + 14.79 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 3.99, + "p90_in": 9.75, + "scale_error_pct": 6.97 + }, + "3-10m": { + "pairs": 3000, + "failed_pct": 0.0, + "median_in": 12.05, + "p90_in": 16.06, + "scale_error_pct": 6.72 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 2.34, + "p90_in": 6.84, + "scale_error_pct": -0.45 + }, + "3-10m": { + "pairs": 75000, + "failed_pct": 0.0, + "median_in": 7.08, + "p90_in": 22.96, + "scale_error_pct": -2.58 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.99, + 3.99 + ], + "p90_in": [ + 9.75, + 9.75 + ] + }, + "3-10m": { + "median_in": [ + 12.05, + 12.05 + ], + "p90_in": [ + 16.06, + 16.06 + ] + } + } + } + } + }, + "MoGe-2 placed with advio_2018 poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 8.46, + "p90_in": 19.51, + "scale_error_pct": 9.97 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 15.96, + "p90_in": 29.88, + "scale_error_pct": 9.76 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.66, + "p90_in": 6.55, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.31, + "p90_in": 11.04, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.76, + 13.8 + ], + "p90_in": [ + 8.91, + 23.12 + ] + }, + "3-10m": { + "median_in": [ + 11.83, + 24.3 + ], + "p90_in": [ + 14.74, + 32.83 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 15.83, + "p90_in": 24.42, + "scale_error_pct": 20.72 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 26.04, + "p90_in": 32.64, + "scale_error_pct": 17.55 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.45, + "p90_in": 9.14, + "scale_error_pct": 2.17 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 4.99, + "p90_in": 12.81, + "scale_error_pct": -0.6 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 15.68, + 16.06 + ], + "p90_in": [ + 23.93, + 25.32 + ] + }, + "3-10m": { + "median_in": [ + 25.83, + 26.27 + ], + "p90_in": [ + 31.93, + 33.6 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 10.28, + "p90_in": 18.93, + "scale_error_pct": 17.45 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 30.8, + "p90_in": 38.23, + "scale_error_pct": 16.28 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 2.49, + "p90_in": 7.41, + "scale_error_pct": 0.45 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.89, + "p90_in": 16.72, + "scale_error_pct": -1.48 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 10.1, + 10.54 + ], + "p90_in": [ + 18.5, + 19.53 + ] + }, + "3-10m": { + "median_in": [ + 30.58, + 31.04 + ], + "p90_in": [ + 37.86, + 38.67 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 8.27, + "p90_in": 19.38, + "scale_error_pct": 9.6 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 15.56, + "p90_in": 28.28, + "scale_error_pct": 9.6 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.67, + "p90_in": 6.6, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.28, + "p90_in": 11.45, + "scale_error_pct": 0.12 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.76, + 13.64 + ], + "p90_in": [ + 8.91, + 22.41 + ] + }, + "3-10m": { + "median_in": [ + 11.83, + 23.78 + ], + "p90_in": [ + 14.74, + 30.67 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 15.69, + "p90_in": 23.32, + "scale_error_pct": 20.31 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 24.97, + "p90_in": 31.19, + "scale_error_pct": 16.84 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.35, + "p90_in": 8.63, + "scale_error_pct": 1.72 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 4.73, + "p90_in": 13.66, + "scale_error_pct": -0.78 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 15.48, + 15.94 + ], + "p90_in": [ + 22.88, + 23.86 + ] + }, + "3-10m": { + "median_in": [ + 24.73, + 25.28 + ], + "p90_in": [ + 30.74, + 31.51 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 9.27, + "p90_in": 18.27, + "scale_error_pct": 16.64 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 30.13, + "p90_in": 37.24, + "scale_error_pct": 16.14 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 2.74, + "p90_in": 7.51, + "scale_error_pct": -0.46 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.59, + "p90_in": 18.33, + "scale_error_pct": -1.43 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 9.13, + 9.44 + ], + "p90_in": [ + 18.01, + 18.53 + ] + }, + "3-10m": { + "median_in": [ + 30.01, + 30.24 + ], + "p90_in": [ + 36.94, + 37.84 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 7.85, + "p90_in": 17.98, + "scale_error_pct": 8.97 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 14.42, + "p90_in": 27.06, + "scale_error_pct": 9.08 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.69, + "p90_in": 7.05, + "scale_error_pct": 0.02 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.37, + "p90_in": 13.59, + "scale_error_pct": 0.15 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.76, + 12.93 + ], + "p90_in": [ + 8.91, + 20.7 + ] + }, + "3-10m": { + "median_in": [ + 11.83, + 22.55 + ], + "p90_in": [ + 14.74, + 29.52 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 13.54, + "p90_in": 20.57, + "scale_error_pct": 17.36 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 23.75, + "p90_in": 30.41, + "scale_error_pct": 16.22 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.22, + "p90_in": 8.73, + "scale_error_pct": 1.63 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.58, + "p90_in": 15.95, + "scale_error_pct": -0.91 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 13.21, + 13.88 + ], + "p90_in": [ + 20.18, + 20.93 + ] + }, + "3-10m": { + "median_in": [ + 23.55, + 23.97 + ], + "p90_in": [ + 30.23, + 30.64 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 6.99, + "p90_in": 16.59, + "scale_error_pct": 12.92 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 30.67, + "p90_in": 38.66, + "scale_error_pct": 16.23 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.58, + "p90_in": 7.87, + "scale_error_pct": -2.17 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 7.54, + "p90_in": 21.01, + "scale_error_pct": -1.44 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 6.82, + 7.27 + ], + "p90_in": [ + 16.22, + 16.99 + ] + }, + "3-10m": { + "median_in": [ + 30.57, + 30.77 + ], + "p90_in": [ + 38.49, + 38.87 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, advio_2018 poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 10.0, + "median_in": 12.48, + "p90_in": Infinity, + "scale_error_pct": 13.61 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 10.0, + "median_in": 25.01, + "p90_in": Infinity, + "scale_error_pct": 13.99 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 10.0, + "median_in": 1.27, + "p90_in": Infinity, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 10.0, + "median_in": 2.21, + "p90_in": Infinity, + "scale_error_pct": 0.12 + } + }, + "tape_calibration_success_pct": 90.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.4, + 31.25 + ], + "p90_in": [ + 17.69, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 6.44, + 61.12 + ], + "p90_in": [ + 30.35, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 16.47, + "p90_in": 62.43, + "scale_error_pct": 22.41 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 28.08, + "p90_in": 107.92, + "scale_error_pct": 18.96 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.76, + "p90_in": 10.73, + "scale_error_pct": 2.33 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.39, + "p90_in": 14.8, + "scale_error_pct": -0.45 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.61, + 44.52 + ], + "p90_in": [ + 17.05, + 81.47 + ] + }, + "3-10m": { + "median_in": [ + 3.51, + 93.2 + ], + "p90_in": [ + 28.08, + 126.95 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 11.5, + "p90_in": 42.99, + "scale_error_pct": 19.08 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 33.35, + "p90_in": 136.83, + "scale_error_pct": 17.59 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 2.7, + "p90_in": 8.74, + "scale_error_pct": 0.68 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 6.14, + "p90_in": 18.52, + "scale_error_pct": -1.24 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.05, + 33.37 + ], + "p90_in": [ + 11.87, + 59.25 + ] + }, + "3-10m": { + "median_in": [ + 2.32, + 114.84 + ], + "p90_in": [ + 33.35, + 159.46 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 4.1, + "p90_in": 11.06, + "scale_error_pct": 3.52 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 8.0, + "p90_in": 21.99, + "scale_error_pct": 3.1 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.66, + "p90_in": 8.46, + "scale_error_pct": 0.02 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.27, + "p90_in": 14.1, + "scale_error_pct": 0.25 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.73, + 5.87 + ], + "p90_in": [ + 6.39, + 15.68 + ] + }, + "3-10m": { + "median_in": [ + 5.85, + 9.92 + ], + "p90_in": [ + 10.62, + 27.17 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 4.79, + "p90_in": 11.19, + "scale_error_pct": 5.84 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 7.95, + "p90_in": 14.76, + "scale_error_pct": 4.8 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.81, + "p90_in": 13.44, + "scale_error_pct": 1.26 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.15, + "p90_in": 20.62, + "scale_error_pct": 0.05 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.81, + 6.05 + ], + "p90_in": [ + 8.24, + 17.75 + ] + }, + "3-10m": { + "median_in": [ + 6.16, + 9.41 + ], + "p90_in": [ + 12.12, + 18.69 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 3.85, + "p90_in": 12.09, + "scale_error_pct": 5.81 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 10.5, + "p90_in": 18.14, + "scale_error_pct": 4.91 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.18, + "p90_in": 11.56, + "scale_error_pct": 1.17 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 6.48, + "p90_in": 26.23, + "scale_error_pct": -0.16 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.21, + 4.6 + ], + "p90_in": [ + 9.14, + 14.93 + ] + }, + "3-10m": { + "median_in": [ + 8.34, + 12.31 + ], + "p90_in": [ + 16.14, + 20.13 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 4.38, + "p90_in": 9.24, + "scale_error_pct": 2.83 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 10.17, + "p90_in": 20.53, + "scale_error_pct": 2.79 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.7, + "p90_in": 7.78, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.4, + "p90_in": 13.75, + "scale_error_pct": 0.18 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.38, + 5.94 + ], + "p90_in": [ + 6.17, + 11.78 + ] + }, + "3-10m": { + "median_in": [ + 7.91, + 15.8 + ], + "p90_in": [ + 10.75, + 24.56 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 4.39, + "p90_in": 9.77, + "scale_error_pct": -5.99 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 12.6, + "p90_in": 19.83, + "scale_error_pct": -8.7 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.41, + "p90_in": 9.67, + "scale_error_pct": 1.75 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.95, + "p90_in": 16.07, + "scale_error_pct": -0.67 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.27, + 5.69 + ], + "p90_in": [ + 8.03, + 10.84 + ] + }, + "3-10m": { + "median_in": [ + 9.88, + 14.55 + ], + "p90_in": [ + 17.28, + 21.45 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 5.27, + "p90_in": 9.31, + "scale_error_pct": -8.96 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 17.88, + "p90_in": 26.13, + "scale_error_pct": -9.76 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.61, + "p90_in": 8.54, + "scale_error_pct": -2.52 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 8.04, + "p90_in": 21.42, + "scale_error_pct": -1.48 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.55, + 5.9 + ], + "p90_in": [ + 8.53, + 9.87 + ] + }, + "3-10m": { + "median_in": [ + 14.85, + 20.26 + ], + "p90_in": [ + 23.36, + 27.44 + ] + } + } + } + } + }, + "MoGe-2 placed with modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 8.46, + "p90_in": 19.44, + "scale_error_pct": 10.01 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 15.97, + "p90_in": 29.78, + "scale_error_pct": 9.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.65, + "p90_in": 6.28, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.3, + "p90_in": 10.61, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.76, + 13.8 + ], + "p90_in": [ + 8.91, + 22.81 + ] + }, + "3-10m": { + "median_in": [ + 11.83, + 24.29 + ], + "p90_in": [ + 14.74, + 32.45 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 15.81, + "p90_in": 24.16, + "scale_error_pct": 20.71 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 26.09, + "p90_in": 32.32, + "scale_error_pct": 17.67 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.33, + "p90_in": 8.64, + "scale_error_pct": 2.21 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 4.85, + "p90_in": 12.01, + "scale_error_pct": -0.56 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 15.77, + 15.84 + ], + "p90_in": [ + 24.03, + 24.33 + ] + }, + "3-10m": { + "median_in": [ + 26.06, + 26.16 + ], + "p90_in": [ + 32.23, + 32.46 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 10.22, + "p90_in": 18.55, + "scale_error_pct": 17.31 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 30.87, + "p90_in": 38.2, + "scale_error_pct": 16.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 2.34, + "p90_in": 6.76, + "scale_error_pct": 0.37 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.69, + "p90_in": 15.79, + "scale_error_pct": -1.36 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 10.13, + 10.29 + ], + "p90_in": [ + 18.44, + 18.69 + ] + }, + "3-10m": { + "median_in": [ + 30.8, + 30.9 + ], + "p90_in": [ + 38.08, + 38.28 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 8.29, + "p90_in": 19.41, + "scale_error_pct": 9.69 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 15.56, + "p90_in": 28.41, + "scale_error_pct": 9.56 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.66, + "p90_in": 6.53, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.3, + "p90_in": 11.09, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.76, + 13.63 + ], + "p90_in": [ + 8.91, + 22.33 + ] + }, + "3-10m": { + "median_in": [ + 11.83, + 23.85 + ], + "p90_in": [ + 14.74, + 30.59 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 15.77, + "p90_in": 23.28, + "scale_error_pct": 20.4 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 25.33, + "p90_in": 31.07, + "scale_error_pct": 17.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.35, + "p90_in": 8.53, + "scale_error_pct": 2.02 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 4.91, + "p90_in": 12.86, + "scale_error_pct": -0.69 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 15.73, + 15.82 + ], + "p90_in": [ + 23.16, + 23.38 + ] + }, + "3-10m": { + "median_in": [ + 25.27, + 25.4 + ], + "p90_in": [ + 30.97, + 31.12 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 9.48, + "p90_in": 18.41, + "scale_error_pct": 16.77 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 30.23, + "p90_in": 37.73, + "scale_error_pct": 16.15 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 2.42, + "p90_in": 7.05, + "scale_error_pct": 0.29 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.86, + "p90_in": 17.63, + "scale_error_pct": -1.38 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 9.38, + 9.57 + ], + "p90_in": [ + 18.24, + 18.58 + ] + }, + "3-10m": { + "median_in": [ + 30.16, + 30.28 + ], + "p90_in": [ + 37.51, + 37.8 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 8.21, + "p90_in": 18.89, + "scale_error_pct": 9.37 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 15.06, + "p90_in": 28.19, + "scale_error_pct": 9.34 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.72, + "p90_in": 9.45, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.39, + "p90_in": 16.34, + "scale_error_pct": 0.16 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.76, + 13.36 + ], + "p90_in": [ + 8.91, + 21.65 + ] + }, + "3-10m": { + "median_in": [ + 11.83, + 23.77 + ], + "p90_in": [ + 14.74, + 30.28 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 15.13, + "p90_in": 22.26, + "scale_error_pct": 20.04 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 25.1, + "p90_in": 30.84, + "scale_error_pct": 17.13 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 4.78, + "p90_in": 12.65, + "scale_error_pct": 2.65 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 6.43, + "p90_in": 25.85, + "scale_error_pct": -0.81 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 15.09, + 15.16 + ], + "p90_in": [ + 22.17, + 22.31 + ] + }, + "3-10m": { + "median_in": [ + 25.06, + 25.13 + ], + "p90_in": [ + 30.78, + 30.9 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 8.72, + "p90_in": 17.07, + "scale_error_pct": 15.84 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 30.58, + "p90_in": 37.74, + "scale_error_pct": 16.5 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 2.83, + "p90_in": 8.97, + "scale_error_pct": 0.0 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 7.68, + "p90_in": 38.05, + "scale_error_pct": -1.61 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 8.65, + 8.81 + ], + "p90_in": [ + 16.96, + 17.21 + ] + }, + "3-10m": { + "median_in": [ + 30.55, + 30.61 + ], + "p90_in": [ + 37.62, + 37.82 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 10.0, + "median_in": 6.83, + "p90_in": Infinity, + "scale_error_pct": 3.68 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 10.0, + "median_in": 14.93, + "p90_in": Infinity, + "scale_error_pct": 2.57 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 10.0, + "median_in": 1.19, + "p90_in": Infinity, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 10.0, + "median_in": 2.1, + "p90_in": Infinity, + "scale_error_pct": 0.1 + } + }, + "tape_calibration_success_pct": 90.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.43, + 11.25 + ], + "p90_in": [ + 12.22, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.23, + 22.02 + ], + "p90_in": [ + 19.52, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 7.12, + "p90_in": 14.1, + "scale_error_pct": 5.97 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 13.44, + "p90_in": 20.06, + "scale_error_pct": 4.28 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.29, + "p90_in": 8.63, + "scale_error_pct": 2.2 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 4.72, + "p90_in": 11.96, + "scale_error_pct": -0.55 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.41, + 8.91 + ], + "p90_in": [ + 11.34, + 16.24 + ] + }, + "3-10m": { + "median_in": [ + 9.43, + 15.69 + ], + "p90_in": [ + 17.48, + 22.36 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 5.17, + "p90_in": 10.36, + "scale_error_pct": 4.01 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 16.6, + "p90_in": 25.7, + "scale_error_pct": 2.99 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 2.31, + "p90_in": 6.81, + "scale_error_pct": 0.32 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.59, + "p90_in": 15.7, + "scale_error_pct": -1.35 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.07, + 5.83 + ], + "p90_in": [ + 8.51, + 11.99 + ] + }, + "3-10m": { + "median_in": [ + 8.36, + 21.29 + ], + "p90_in": [ + 19.1, + 28.59 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 6.0, + "p90_in": 12.64, + "scale_error_pct": 9.21 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 9.71, + "p90_in": 19.32, + "scale_error_pct": 6.47 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.7, + "p90_in": 7.87, + "scale_error_pct": 0.07 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.34, + "p90_in": 12.58, + "scale_error_pct": 0.18 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.81, + 7.55 + ], + "p90_in": [ + 10.62, + 14.41 + ] + }, + "3-10m": { + "median_in": [ + 6.65, + 17.01 + ], + "p90_in": [ + 12.75, + 22.14 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 7.26, + "p90_in": 14.67, + "scale_error_pct": 9.6 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 9.4, + "p90_in": 16.06, + "scale_error_pct": 6.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.88, + "p90_in": 11.63, + "scale_error_pct": 2.99 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 5.42, + "p90_in": 15.6, + "scale_error_pct": -0.13 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 6.31, + 8.71 + ], + "p90_in": [ + 11.6, + 18.25 + ] + }, + "3-10m": { + "median_in": [ + 7.94, + 11.4 + ], + "p90_in": [ + 12.18, + 19.31 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 3.59, + "p90_in": 10.24, + "scale_error_pct": 6.22 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 9.68, + "p90_in": 16.22, + "scale_error_pct": 5.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 2.75, + "p90_in": 8.5, + "scale_error_pct": 0.2 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 6.41, + "p90_in": 19.86, + "scale_error_pct": -1.21 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.87, + 4.5 + ], + "p90_in": [ + 8.74, + 11.59 + ] + }, + "3-10m": { + "median_in": [ + 7.72, + 12.24 + ], + "p90_in": [ + 12.65, + 17.89 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 6.83, + "p90_in": 11.72, + "scale_error_pct": 10.51 + }, + "3-10m": { + "pairs": 30000, + "failed_pct": 0.0, + "median_in": 13.3, + "p90_in": 18.19, + "scale_error_pct": 10.33 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 0.69, + "p90_in": 7.21, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 750000, + "failed_pct": 0.0, + "median_in": 1.34, + "p90_in": 12.51, + "scale_error_pct": 0.12 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 2, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.53, + 7.85 + ], + "p90_in": [ + 11.11, + 12.18 + ] + }, + "3-10m": { + "median_in": [ + 7.58, + 16.03 + ], + "p90_in": [ + 12.45, + 19.99 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 6.64, + "p90_in": 11.81, + "scale_error_pct": 8.62 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 9.62, + "p90_in": 13.44, + "scale_error_pct": 6.56 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 3.37, + "p90_in": 9.2, + "scale_error_pct": 2.11 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 4.96, + "p90_in": 15.0, + "scale_error_pct": -0.84 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 6.11, + 7.12 + ], + "p90_in": [ + 10.79, + 13.26 + ] + }, + "3-10m": { + "median_in": [ + 8.85, + 10.36 + ], + "p90_in": [ + 12.44, + 14.09 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 3.45, + "p90_in": 9.19, + "scale_error_pct": 5.8 + }, + "3-10m": { + "pairs": 15000, + "failed_pct": 0.0, + "median_in": 10.13, + "p90_in": 14.55, + "scale_error_pct": 5.62 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 2.39, + "p90_in": 7.2, + "scale_error_pct": -0.42 + }, + "3-10m": { + "pairs": 375000, + "failed_pct": 0.0, + "median_in": 6.65, + "p90_in": 21.1, + "scale_error_pct": -2.08 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 1, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.24, + 3.7 + ], + "p90_in": [ + 8.48, + 10.07 + ] + }, + "3-10m": { + "median_in": [ + 9.07, + 11.35 + ], + "p90_in": [ + 13.09, + 15.51 + ] + } + } + } + } + } + } + }, + "electro": { + "all points": { + "mapanything@392, images only": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 19.64, + "median_in": 14.53, + "p90_in": Infinity, + "scale_error_pct": 12.88 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 24.37, + "median_in": 37.01, + "p90_in": Infinity, + "scale_error_pct": 12.94 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 32.21, + "median_in": 7.84, + "p90_in": Infinity, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 35.82, + "median_in": 22.54, + "p90_in": Infinity, + "scale_error_pct": -0.61 + } + }, + "tape_calibration_success_pct": 80.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 10.56, + 20.58 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 28.49, + 48.36 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 14.34, + "median_in": 13.91, + "p90_in": Infinity, + "scale_error_pct": 12.57 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 19.75, + "median_in": 32.6, + "p90_in": Infinity, + "scale_error_pct": 13.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 28.59, + "median_in": 6.86, + "p90_in": Infinity, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 32.34, + "median_in": 19.63, + "p90_in": Infinity, + "scale_error_pct": -0.34 + } + }, + "tape_calibration_success_pct": 80.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.55, + 19.31 + ], + "p90_in": [ + 28.81, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 23.47, + 41.11 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 79.03, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 15.19 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 83.78, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 9.98 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 83.14, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.66 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 87.41, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.65 + } + }, + "tape_calibration_success_pct": 80.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 13.44, + "median_in": 15.99, + "p90_in": Infinity, + "scale_error_pct": 16.08 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 17.85, + "median_in": 40.09, + "p90_in": Infinity, + "scale_error_pct": 15.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 22.35, + "median_in": 6.38, + "p90_in": Infinity, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 26.08, + "median_in": 16.88, + "p90_in": Infinity, + "scale_error_pct": -0.59 + } + }, + "tape_calibration_success_pct": 88.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 11.84, + 19.45 + ], + "p90_in": [ + 34.31, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 29.75, + 47.75 + ], + "p90_in": [ + 205.98, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 8.7, + "median_in": 14.96, + "p90_in": 39.79, + "scale_error_pct": 15.39 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 11.15, + "median_in": 33.21, + "p90_in": Infinity, + "scale_error_pct": 14.41 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 18.34, + "median_in": 5.29, + "p90_in": Infinity, + "scale_error_pct": 0.25 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 20.25, + "median_in": 15.24, + "p90_in": Infinity, + "scale_error_pct": -0.36 + } + }, + "tape_calibration_success_pct": 88.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 10.02, + 19.59 + ], + "p90_in": [ + 25.96, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 22.88, + 40.26 + ], + "p90_in": [ + 62.47, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 67.18, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 23.26 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 76.37, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 12.91 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 71.05, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.9 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 79.04, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 0.56 + } + }, + "tape_calibration_success_pct": 88.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 19.58, + "median_in": 5.66, + "p90_in": Infinity, + "scale_error_pct": 3.09 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 24.25, + "median_in": 17.27, + "p90_in": Infinity, + "scale_error_pct": 3.12 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 32.42, + "median_in": 7.47, + "p90_in": Infinity, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 35.84, + "median_in": 20.42, + "p90_in": Infinity, + "scale_error_pct": -0.44 + } + }, + "tape_calibration_success_pct": 80.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.57, + 11.45 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 10.79, + 29.94 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 14.68, + "median_in": 4.18, + "p90_in": Infinity, + "scale_error_pct": 3.18 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 19.57, + "median_in": 13.0, + "p90_in": Infinity, + "scale_error_pct": 3.28 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 28.87, + "median_in": 6.36, + "p90_in": Infinity, + "scale_error_pct": 0.16 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 32.35, + "median_in": 17.13, + "p90_in": Infinity, + "scale_error_pct": 0.13 + } + }, + "tape_calibration_success_pct": 80.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.83, + 8.53 + ], + "p90_in": [ + 12.64, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 7.83, + 22.75 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 77.53, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.39 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 83.28, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.64 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 82.6, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.78 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 87.31, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.67 + } + }, + "tape_calibration_success_pct": 80.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 13.35, + "median_in": 5.51, + "p90_in": Infinity, + "scale_error_pct": 3.56 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 17.91, + "median_in": 13.99, + "p90_in": Infinity, + "scale_error_pct": 2.71 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 24.02, + "median_in": 6.19, + "p90_in": Infinity, + "scale_error_pct": -0.21 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 27.77, + "median_in": 16.11, + "p90_in": Infinity, + "scale_error_pct": -0.81 + } + }, + "tape_calibration_success_pct": 86.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.76, + 8.22 + ], + "p90_in": [ + 14.43, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 9.77, + 22.4 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 8.65, + "median_in": 4.48, + "p90_in": 23.59, + "scale_error_pct": 3.78 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 11.3, + "median_in": 12.43, + "p90_in": Infinity, + "scale_error_pct": 3.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 20.1, + "median_in": 4.81, + "p90_in": Infinity, + "scale_error_pct": 0.17 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 22.1, + "median_in": 13.33, + "p90_in": Infinity, + "scale_error_pct": -0.3 + } + }, + "tape_calibration_success_pct": 86.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.06, + 7.31 + ], + "p90_in": [ + 9.45, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 7.59, + 18.61 + ], + "p90_in": [ + 23.18, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 66.58, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 6.2 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 74.87, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.41 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 71.41, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.5 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 78.31, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.5 + } + }, + "tape_calibration_success_pct": 86.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 504.45, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 19.59, + "median_in": 5.92, + "p90_in": Infinity, + "scale_error_pct": -0.74 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 24.37, + "median_in": 17.88, + "p90_in": Infinity, + "scale_error_pct": -0.77 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 32.87, + "median_in": 7.74, + "p90_in": Infinity, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 36.43, + "median_in": 21.44, + "p90_in": Infinity, + "scale_error_pct": -0.41 + } + }, + "tape_calibration_success_pct": 79.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.72, + 11.92 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 8.73, + 35.0 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 14.67, + "median_in": 4.53, + "p90_in": Infinity, + "scale_error_pct": -1.05 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 19.78, + "median_in": 13.68, + "p90_in": Infinity, + "scale_error_pct": -0.97 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 29.31, + "median_in": 6.46, + "p90_in": Infinity, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 32.98, + "median_in": 17.95, + "p90_in": Infinity, + "scale_error_pct": -0.2 + } + }, + "tape_calibration_success_pct": 79.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.06, + 8.04 + ], + "p90_in": [ + 13.61, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 6.9, + 28.3 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 77.14, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.89 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 83.2, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.14 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 82.63, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.35 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 87.46, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.75 + } + }, + "tape_calibration_success_pct": 79.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 13.14, + "median_in": 6.51, + "p90_in": Infinity, + "scale_error_pct": -2.97 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 17.66, + "median_in": 18.91, + "p90_in": Infinity, + "scale_error_pct": -4.41 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 23.45, + "median_in": 5.97, + "p90_in": Infinity, + "scale_error_pct": -0.21 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 27.16, + "median_in": 15.49, + "p90_in": Infinity, + "scale_error_pct": -0.83 + } + }, + "tape_calibration_success_pct": 87.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.18, + 10.83 + ], + "p90_in": [ + 17.17, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 9.43, + 29.8 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 8.27, + "median_in": 5.13, + "p90_in": 24.9, + "scale_error_pct": -2.48 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 10.98, + "median_in": 14.49, + "p90_in": Infinity, + "scale_error_pct": -4.15 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 19.38, + "median_in": 4.77, + "p90_in": Infinity, + "scale_error_pct": 0.13 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 21.4, + "median_in": 12.93, + "p90_in": Infinity, + "scale_error_pct": -0.32 + } + }, + "tape_calibration_success_pct": 87.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.77, + 9.29 + ], + "p90_in": [ + 10.17, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 6.71, + 25.85 + ], + "p90_in": [ + 26.37, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 67.24, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 0.59 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 74.99, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": -1.33 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 71.68, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.22 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 78.22, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.1 + } + }, + "tape_calibration_success_pct": 87.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 19.6, + "median_in": 4.99, + "p90_in": Infinity, + "scale_error_pct": 1.32 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 24.27, + "median_in": 16.51, + "p90_in": Infinity, + "scale_error_pct": 1.13 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 31.84, + "median_in": 7.34, + "p90_in": Infinity, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 35.35, + "median_in": 20.15, + "p90_in": Infinity, + "scale_error_pct": -0.46 + } + }, + "tape_calibration_success_pct": 81.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.04, + 11.69 + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 7.58, + 31.09 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 14.72, + "median_in": 3.7, + "p90_in": Infinity, + "scale_error_pct": 1.54 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 19.6, + "median_in": 11.04, + "p90_in": Infinity, + "scale_error_pct": 1.6 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 28.21, + "median_in": 6.23, + "p90_in": Infinity, + "scale_error_pct": 0.08 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 31.76, + "median_in": 16.75, + "p90_in": Infinity, + "scale_error_pct": 0.06 + } + }, + "tape_calibration_success_pct": 81.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.34, + 8.3 + ], + "p90_in": [ + 11.68, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 5.56, + 22.45 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 77.61, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.06 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 83.42, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 82.4, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 3.65 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 87.18, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.46 + } + }, + "tape_calibration_success_pct": 81.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 13.25, + "median_in": 5.14, + "p90_in": Infinity, + "scale_error_pct": 1.96 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 17.82, + "median_in": 13.3, + "p90_in": Infinity, + "scale_error_pct": 1.26 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 23.95, + "median_in": 6.16, + "p90_in": Infinity, + "scale_error_pct": -0.16 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 27.72, + "median_in": 16.01, + "p90_in": Infinity, + "scale_error_pct": -0.79 + } + }, + "tape_calibration_success_pct": 86.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.0, + 8.06 + ], + "p90_in": [ + 14.82, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 7.84, + 22.87 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 8.61, + "median_in": 3.77, + "p90_in": 22.69, + "scale_error_pct": 2.21 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 11.25, + "median_in": 11.33, + "p90_in": Infinity, + "scale_error_pct": 1.85 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 20.08, + "median_in": 4.84, + "p90_in": Infinity, + "scale_error_pct": 0.25 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 22.06, + "median_in": 13.37, + "p90_in": Infinity, + "scale_error_pct": -0.28 + } + }, + "tape_calibration_success_pct": 86.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.14, + 7.16 + ], + "p90_in": [ + 8.95, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 5.55, + 18.56 + ], + "p90_in": [ + 21.63, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 66.74, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.73 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 74.51, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 2.85 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 71.55, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.77 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 78.02, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.71 + } + }, + "tape_calibration_success_pct": 86.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 561.65, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "MoGe-2 placed with exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.06, + "median_in": 6.26, + "p90_in": 16.61, + "scale_error_pct": 2.19 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.11, + "median_in": 17.46, + "p90_in": 42.18, + "scale_error_pct": 3.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.06, + "median_in": 3.16, + "p90_in": 13.99, + "scale_error_pct": -0.16 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.11, + "median_in": 7.0, + "p90_in": 37.37, + "scale_error_pct": -0.26 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.96, + 9.28 + ], + "p90_in": [ + 11.29, + 21.92 + ] + }, + "3-10m": { + "median_in": [ + 12.93, + 24.03 + ], + "p90_in": [ + 27.11, + 74.76 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.08, + "median_in": 6.08, + "p90_in": 16.25, + "scale_error_pct": 1.74 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.16, + "median_in": 17.57, + "p90_in": 45.45, + "scale_error_pct": 4.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.08, + "median_in": 2.79, + "p90_in": 13.44, + "scale_error_pct": -0.3 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.16, + "median_in": 6.97, + "p90_in": 38.54, + "scale_error_pct": -0.03 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.45, + 9.0 + ], + "p90_in": [ + 10.65, + 21.24 + ] + }, + "3-10m": { + "median_in": [ + 14.21, + 25.0 + ], + "p90_in": [ + 25.45, + 76.14 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.31, + "median_in": 7.64, + "p90_in": 30.56, + "scale_error_pct": 5.59 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.17, + "median_in": 15.33, + "p90_in": 44.69, + "scale_error_pct": 4.31 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.31, + "median_in": 4.33, + "p90_in": 32.65, + "scale_error_pct": 0.63 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 0.17, + "median_in": 7.05, + "p90_in": 46.43, + "scale_error_pct": -0.06 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 6.04, + 10.78 + ], + "p90_in": [ + 15.01, + 47.96 + ] + }, + "3-10m": { + "median_in": [ + 11.67, + 21.42 + ], + "p90_in": [ + 24.05, + 70.59 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 5.91, + "p90_in": 16.79, + "scale_error_pct": 2.54 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.03, + "median_in": 16.5, + "p90_in": 49.78, + "scale_error_pct": 3.69 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.01, + "median_in": 3.16, + "p90_in": 15.15, + "scale_error_pct": -0.19 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.03, + "median_in": 6.87, + "p90_in": 39.4, + "scale_error_pct": -0.0 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 4.26, + 8.91 + ], + "p90_in": [ + 10.52, + 23.9 + ] + }, + "3-10m": { + "median_in": [ + 11.88, + 23.13 + ], + "p90_in": [ + 25.48, + 81.97 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.02, + "median_in": 5.59, + "p90_in": 16.16, + "scale_error_pct": 1.26 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.05, + "median_in": 16.49, + "p90_in": 52.43, + "scale_error_pct": 4.17 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.02, + "median_in": 2.83, + "p90_in": 14.72, + "scale_error_pct": -0.3 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.05, + "median_in": 6.93, + "p90_in": 41.08, + "scale_error_pct": 0.03 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.46, + 8.89 + ], + "p90_in": [ + 9.51, + 23.0 + ] + }, + "3-10m": { + "median_in": [ + 11.7, + 24.11 + ], + "p90_in": [ + 23.34, + 84.95 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.11, + "median_in": 6.42, + "p90_in": 21.34, + "scale_error_pct": 4.83 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.06, + "median_in": 13.29, + "p90_in": 37.59, + "scale_error_pct": 4.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 1.11, + "median_in": 4.65, + "p90_in": 27.06, + "scale_error_pct": 0.65 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 1.07, + "median_in": 7.65, + "p90_in": 47.82, + "scale_error_pct": 0.82 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.26, + 8.57 + ], + "p90_in": [ + 13.83, + 36.63 + ] + }, + "3-10m": { + "median_in": [ + 10.38, + 17.95 + ], + "p90_in": [ + 21.94, + 66.42 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 5.05, + "p90_in": 12.74, + "scale_error_pct": 1.11 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.01, + "median_in": 13.58, + "p90_in": 25.74, + "scale_error_pct": 1.95 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 3.52, + "p90_in": 13.2, + "scale_error_pct": -0.33 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.59, + "median_in": 7.07, + "p90_in": 29.8, + "scale_error_pct": -0.14 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.52, + 6.79 + ], + "p90_in": [ + 10.83, + 14.41 + ] + }, + "3-10m": { + "median_in": [ + 9.98, + 16.81 + ], + "p90_in": [ + 22.09, + 31.35 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 4.85, + "p90_in": 12.18, + "scale_error_pct": -0.52 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 13.26, + "p90_in": 24.26, + "scale_error_pct": 2.9 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 3.29, + "p90_in": 13.45, + "scale_error_pct": -1.05 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 7.82, + "p90_in": 30.05, + "scale_error_pct": -0.24 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.9, + 6.81 + ], + "p90_in": [ + 9.64, + 14.3 + ] + }, + "3-10m": { + "median_in": [ + 10.32, + 16.66 + ], + "p90_in": [ + 19.59, + 28.57 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 6.48, + "p90_in": 15.62, + "scale_error_pct": 3.76 + }, + "3-10m": { + "pairs": 20816, + "failed_pct": 0.01, + "median_in": 11.05, + "p90_in": 24.08, + "scale_error_pct": 3.77 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 4.76, + "p90_in": 16.26, + "scale_error_pct": 0.13 + }, + "3-10m": { + "pairs": 520400, + "failed_pct": 0.59, + "median_in": 7.3, + "p90_in": 27.99, + "scale_error_pct": 0.81 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 5.38, + 7.48 + ], + "p90_in": [ + 11.19, + 18.84 + ] + }, + "3-10m": { + "median_in": [ + 9.47, + 12.49 + ], + "p90_in": [ + 16.77, + 28.05 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 12.56, + "median_in": 2.08, + "p90_in": Infinity, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 12.61, + "median_in": 5.19, + "p90_in": Infinity, + "scale_error_pct": 0.61 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 12.56, + "median_in": 3.45, + "p90_in": Infinity, + "scale_error_pct": -0.04 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 12.61, + "median_in": 8.72, + "p90_in": Infinity, + "scale_error_pct": 0.17 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.3, + 5.83 + ], + "p90_in": [ + 5.11, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 3.25, + 17.81 + ], + "p90_in": [ + 10.69, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 12.58, + "median_in": 1.59, + "p90_in": Infinity, + "scale_error_pct": -0.04 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 12.66, + "median_in": 5.89, + "p90_in": Infinity, + "scale_error_pct": 1.41 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 12.58, + "median_in": 3.07, + "p90_in": Infinity, + "scale_error_pct": 0.14 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 12.66, + "median_in": 9.18, + "p90_in": Infinity, + "scale_error_pct": 0.92 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 0.96, + 4.87 + ], + "p90_in": [ + 3.87, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 3.22, + 23.93 + ], + "p90_in": [ + 11.98, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 12.81, + "median_in": 3.27, + "p90_in": Infinity, + "scale_error_pct": 0.27 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 11.99, + "median_in": 5.01, + "p90_in": Infinity, + "scale_error_pct": 0.09 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 12.81, + "median_in": 5.17, + "p90_in": Infinity, + "scale_error_pct": 0.64 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 11.99, + "median_in": 9.01, + "p90_in": Infinity, + "scale_error_pct": 0.16 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.08, + 24.38 + ], + "p90_in": [ + 10.97, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 1.67, + 22.41 + ], + "p90_in": [ + 12.78, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.01, + "median_in": 1.94, + "p90_in": 9.06, + "scale_error_pct": -0.56 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.04, + "median_in": 5.02, + "p90_in": 23.66, + "scale_error_pct": -0.24 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.01, + "median_in": 3.03, + "p90_in": 13.21, + "scale_error_pct": -0.15 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.04, + "median_in": 7.19, + "p90_in": 32.51, + "scale_error_pct": -0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.34, + 3.26 + ], + "p90_in": [ + 5.25, + 12.8 + ] + }, + "3-10m": { + "median_in": [ + 3.43, + 8.26 + ], + "p90_in": [ + 9.69, + 42.06 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.05, + "median_in": 1.67, + "p90_in": 8.69, + "scale_error_pct": -0.62 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 0.05, + "median_in": 6.15, + "p90_in": 28.05, + "scale_error_pct": 0.07 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.05, + "median_in": 2.69, + "p90_in": 12.51, + "scale_error_pct": -0.06 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 0.05, + "median_in": 7.61, + "p90_in": 32.44, + "scale_error_pct": 0.26 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.24, + 2.62 + ], + "p90_in": [ + 4.77, + 11.78 + ] + }, + "3-10m": { + "median_in": [ + 3.55, + 10.34 + ], + "p90_in": [ + 9.79, + 44.88 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.11, + "median_in": 3.01, + "p90_in": 22.98, + "scale_error_pct": -0.11 + }, + "3-10m": { + "pairs": 23816, + "failed_pct": 0.06, + "median_in": 6.19, + "p90_in": 31.51, + "scale_error_pct": -0.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.11, + "median_in": 4.51, + "p90_in": 25.91, + "scale_error_pct": 0.21 + }, + "3-10m": { + "pairs": 595400, + "failed_pct": 0.06, + "median_in": 7.5, + "p90_in": 40.56, + "scale_error_pct": -0.1 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.02, + 6.71 + ], + "p90_in": [ + 10.62, + 56.52 + ] + }, + "3-10m": { + "median_in": [ + 1.72, + 9.89 + ], + "p90_in": [ + 11.26, + 62.03 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 1.56, + "p90_in": 6.67, + "scale_error_pct": -0.65 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.01, + "median_in": 3.4, + "p90_in": 12.68, + "scale_error_pct": -0.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 2.56, + "p90_in": 10.9, + "scale_error_pct": -0.29 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.59, + "median_in": 5.56, + "p90_in": 24.95, + "scale_error_pct": -0.03 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.16, + 2.11 + ], + "p90_in": [ + 4.85, + 8.58 + ] + }, + "3-10m": { + "median_in": [ + 2.55, + 4.58 + ], + "p90_in": [ + 7.23, + 21.55 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 1.24, + "p90_in": 6.29, + "scale_error_pct": -0.55 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 3.5, + "p90_in": 14.84, + "scale_error_pct": -0.24 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 2.22, + "p90_in": 10.59, + "scale_error_pct": -0.26 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 5.82, + "p90_in": 25.58, + "scale_error_pct": -0.02 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 0.92, + 1.9 + ], + "p90_in": [ + 3.57, + 10.63 + ] + }, + "3-10m": { + "median_in": [ + 2.35, + 5.19 + ], + "p90_in": [ + 6.65, + 25.02 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 2.27, + "p90_in": 11.3, + "scale_error_pct": -0.89 + }, + "3-10m": { + "pairs": 20816, + "failed_pct": 0.01, + "median_in": 3.39, + "p90_in": 13.71, + "scale_error_pct": -0.6 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 3.5, + "p90_in": 15.05, + "scale_error_pct": -0.4 + }, + "3-10m": { + "pairs": 520400, + "failed_pct": 0.59, + "median_in": 5.45, + "p90_in": 24.1, + "scale_error_pct": -0.1 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.47, + 3.23 + ], + "p90_in": [ + 7.9, + 15.47 + ] + }, + "3-10m": { + "median_in": [ + 1.73, + 5.14 + ], + "p90_in": [ + 8.41, + 18.9 + ] + } + } + } + } + }, + "MoGe-2 placed with advio_2018 poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.06, + "median_in": 6.3, + "p90_in": 16.84, + "scale_error_pct": 1.85 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.11, + "median_in": 17.2, + "p90_in": 41.76, + "scale_error_pct": 2.78 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.06, + "median_in": 3.41, + "p90_in": 14.78, + "scale_error_pct": -0.2 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.11, + "median_in": 7.35, + "p90_in": 39.21, + "scale_error_pct": -0.38 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.07, + 9.28 + ], + "p90_in": [ + 11.43, + 21.98 + ] + }, + "3-10m": { + "median_in": [ + 12.48, + 23.63 + ], + "p90_in": [ + 26.29, + 75.17 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.08, + "median_in": 6.11, + "p90_in": 16.53, + "scale_error_pct": 1.92 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.16, + "median_in": 17.67, + "p90_in": 45.73, + "scale_error_pct": 4.75 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.08, + "median_in": 2.95, + "p90_in": 13.76, + "scale_error_pct": -0.12 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.16, + "median_in": 7.42, + "p90_in": 41.25, + "scale_error_pct": 0.15 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.49, + 9.01 + ], + "p90_in": [ + 10.68, + 21.32 + ] + }, + "3-10m": { + "median_in": [ + 14.17, + 25.77 + ], + "p90_in": [ + 25.53, + 76.71 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.31, + "median_in": 7.66, + "p90_in": 30.58, + "scale_error_pct": 5.59 + }, + "3-10m": { + "pairs": 119080, + "failed_pct": 0.17, + "median_in": 15.75, + "p90_in": 44.58, + "scale_error_pct": 4.42 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.31, + "median_in": 4.61, + "p90_in": 34.11, + "scale_error_pct": 0.93 + }, + "3-10m": { + "pairs": 2977000, + "failed_pct": 0.17, + "median_in": 7.37, + "p90_in": 48.73, + "scale_error_pct": 0.1 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 6.2, + 11.2 + ], + "p90_in": [ + 15.46, + 48.32 + ] + }, + "3-10m": { + "median_in": [ + 11.73, + 21.5 + ], + "p90_in": [ + 24.07, + 70.44 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 5.95, + "p90_in": 17.36, + "scale_error_pct": 1.76 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.03, + "median_in": 16.23, + "p90_in": 49.63, + "scale_error_pct": 3.09 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.81, + "median_in": 3.81, + "p90_in": 16.23, + "scale_error_pct": -0.54 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.83, + "median_in": 7.97, + "p90_in": 40.68, + "scale_error_pct": -0.31 + } + }, + "tape_calibration_success_pct": 99.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.19, + 9.24 + ], + "p90_in": [ + 11.11, + 23.87 + ] + }, + "3-10m": { + "median_in": [ + 11.17, + 23.85 + ], + "p90_in": [ + 25.7, + 81.51 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 5.64, + "p90_in": 16.38, + "scale_error_pct": 1.44 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.05, + "median_in": 16.34, + "p90_in": 52.42, + "scale_error_pct": 4.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.82, + "median_in": 3.37, + "p90_in": 15.48, + "scale_error_pct": -0.39 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.85, + "median_in": 8.12, + "p90_in": 41.71, + "scale_error_pct": 0.03 + } + }, + "tape_calibration_success_pct": 99.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.44, + 9.13 + ], + "p90_in": [ + 10.25, + 22.99 + ] + }, + "3-10m": { + "median_in": [ + 11.69, + 25.04 + ], + "p90_in": [ + 24.41, + 84.96 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.11, + "median_in": 6.6, + "p90_in": 21.39, + "scale_error_pct": 5.0 + }, + "3-10m": { + "pairs": 119080, + "failed_pct": 0.06, + "median_in": 13.97, + "p90_in": 38.63, + "scale_error_pct": 5.11 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.91, + "median_in": 5.22, + "p90_in": 27.21, + "scale_error_pct": 0.84 + }, + "3-10m": { + "pairs": 2977000, + "failed_pct": 0.87, + "median_in": 8.88, + "p90_in": 47.08, + "scale_error_pct": 1.07 + } + }, + "tape_calibration_success_pct": 99.2, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.34, + 8.86 + ], + "p90_in": [ + 14.31, + 37.34 + ] + }, + "3-10m": { + "median_in": [ + 10.78, + 19.87 + ], + "p90_in": [ + 22.23, + 66.38 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 5.25, + "p90_in": 13.88, + "scale_error_pct": 0.05 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.01, + "median_in": 13.77, + "p90_in": 26.05, + "scale_error_pct": 0.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 4.28, + "p90_in": 15.15, + "scale_error_pct": -0.57 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.59, + "median_in": 8.48, + "p90_in": 33.29, + "scale_error_pct": -0.49 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.03, + 7.2 + ], + "p90_in": [ + 12.01, + 15.23 + ] + }, + "3-10m": { + "median_in": [ + 10.96, + 17.48 + ], + "p90_in": [ + 22.65, + 30.96 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 4.96, + "p90_in": 13.23, + "scale_error_pct": -0.79 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 13.84, + "p90_in": 25.23, + "scale_error_pct": 2.78 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 3.83, + "p90_in": 15.25, + "scale_error_pct": -0.81 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 8.4, + "p90_in": 34.45, + "scale_error_pct": 0.12 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.52, + 7.18 + ], + "p90_in": [ + 10.27, + 15.05 + ] + }, + "3-10m": { + "median_in": [ + 10.56, + 17.84 + ], + "p90_in": [ + 20.96, + 28.76 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 6.92, + "p90_in": 15.86, + "scale_error_pct": 3.75 + }, + "3-10m": { + "pairs": 104080, + "failed_pct": 0.01, + "median_in": 11.64, + "p90_in": 25.83, + "scale_error_pct": 3.74 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 4.92, + "p90_in": 17.71, + "scale_error_pct": 0.68 + }, + "3-10m": { + "pairs": 2602000, + "failed_pct": 0.59, + "median_in": 7.96, + "p90_in": 31.4, + "scale_error_pct": 0.95 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.74, + 8.11 + ], + "p90_in": [ + 12.17, + 19.03 + ] + }, + "3-10m": { + "median_in": [ + 10.22, + 14.2 + ], + "p90_in": [ + 17.19, + 30.45 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, advio_2018 poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 12.56, + "median_in": 10.82, + "p90_in": Infinity, + "scale_error_pct": -9.73 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 12.61, + "median_in": 29.39, + "p90_in": Infinity, + "scale_error_pct": -9.76 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 12.56, + "median_in": 4.14, + "p90_in": Infinity, + "scale_error_pct": -0.09 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 12.61, + "median_in": 10.35, + "p90_in": Infinity, + "scale_error_pct": 0.11 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 7.77, + 17.47 + ], + "p90_in": [ + 17.29, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 19.67, + 50.97 + ], + "p90_in": [ + 45.21, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 12.58, + "median_in": 10.38, + "p90_in": Infinity, + "scale_error_pct": -9.98 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 12.66, + "median_in": 24.53, + "p90_in": Infinity, + "scale_error_pct": -7.78 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 12.58, + "median_in": 3.56, + "p90_in": Infinity, + "scale_error_pct": 0.21 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 12.66, + "median_in": 10.12, + "p90_in": Infinity, + "scale_error_pct": 1.04 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 7.36, + 16.65 + ], + "p90_in": [ + 16.05, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 15.14, + 49.5 + ], + "p90_in": [ + 40.51, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 12.81, + "median_in": 11.45, + "p90_in": Infinity, + "scale_error_pct": -7.78 + }, + "3-10m": { + "pairs": 119080, + "failed_pct": 11.99, + "median_in": 24.1, + "p90_in": Infinity, + "scale_error_pct": -9.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 12.81, + "median_in": 5.74, + "p90_in": Infinity, + "scale_error_pct": 0.82 + }, + "3-10m": { + "pairs": 2977000, + "failed_pct": 11.99, + "median_in": 9.94, + "p90_in": Infinity, + "scale_error_pct": 0.27 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 7.99, + 28.97 + ], + "p90_in": [ + 18.98, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 16.05, + 53.38 + ], + "p90_in": [ + 39.43, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 9.73, + "p90_in": 27.67, + "scale_error_pct": -9.59 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.04, + "median_in": 26.21, + "p90_in": 65.58, + "scale_error_pct": -10.16 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.01, + "median_in": 4.76, + "p90_in": 21.6, + "scale_error_pct": -0.38 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.04, + "median_in": 10.44, + "p90_in": 47.8, + "scale_error_pct": -0.57 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 6.31, + 14.13 + ], + "p90_in": [ + 16.47, + 64.4 + ] + }, + "3-10m": { + "median_in": [ + 15.81, + 37.1 + ], + "p90_in": [ + 45.84, + 124.61 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.05, + "median_in": 9.22, + "p90_in": 26.99, + "scale_error_pct": -9.17 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.05, + "median_in": 26.53, + "p90_in": 63.98, + "scale_error_pct": -9.96 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.05, + "median_in": 4.03, + "p90_in": 20.04, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.05, + "median_in": 10.76, + "p90_in": 49.8, + "scale_error_pct": 0.13 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.52, + 13.95 + ], + "p90_in": [ + 16.26, + 55.85 + ] + }, + "3-10m": { + "median_in": [ + 14.52, + 35.18 + ], + "p90_in": [ + 42.66, + 114.76 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.11, + "median_in": 10.68, + "p90_in": 30.59, + "scale_error_pct": -8.3 + }, + "3-10m": { + "pairs": 119080, + "failed_pct": 0.06, + "median_in": 23.72, + "p90_in": 66.59, + "scale_error_pct": -9.05 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.11, + "median_in": 5.8, + "p90_in": 32.64, + "scale_error_pct": 0.45 + }, + "3-10m": { + "pairs": 2977000, + "failed_pct": 0.06, + "median_in": 10.42, + "p90_in": 57.64, + "scale_error_pct": 0.26 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 7.4, + 15.59 + ], + "p90_in": [ + 20.04, + 63.77 + ] + }, + "3-10m": { + "median_in": [ + 12.72, + 35.11 + ], + "p90_in": [ + 41.51, + 99.58 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 6.29, + "p90_in": 17.61, + "scale_error_pct": -7.07 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.01, + "median_in": 14.14, + "p90_in": 37.06, + "scale_error_pct": -6.54 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 4.14, + "p90_in": 15.6, + "scale_error_pct": -0.34 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.59, + "median_in": 8.56, + "p90_in": 33.87, + "scale_error_pct": -0.14 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.49, + 9.71 + ], + "p90_in": [ + 11.47, + 21.44 + ] + }, + "3-10m": { + "median_in": [ + 9.77, + 24.52 + ], + "p90_in": [ + 24.05, + 48.83 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 6.01, + "p90_in": 17.4, + "scale_error_pct": -7.24 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 13.16, + "p90_in": 35.01, + "scale_error_pct": -6.53 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 3.56, + "p90_in": 14.58, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 7.93, + "p90_in": 33.84, + "scale_error_pct": 0.56 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.78, + 9.83 + ], + "p90_in": [ + 10.35, + 21.41 + ] + }, + "3-10m": { + "median_in": [ + 9.05, + 22.71 + ], + "p90_in": [ + 20.81, + 44.89 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 6.18, + "p90_in": 19.69, + "scale_error_pct": -5.94 + }, + "3-10m": { + "pairs": 104080, + "failed_pct": 0.01, + "median_in": 12.01, + "p90_in": 38.53, + "scale_error_pct": -6.12 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 4.5, + "p90_in": 18.18, + "scale_error_pct": 0.21 + }, + "3-10m": { + "pairs": 2602000, + "failed_pct": 0.59, + "median_in": 7.74, + "p90_in": 32.11, + "scale_error_pct": 0.35 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.24, + 9.53 + ], + "p90_in": [ + 11.7, + 24.52 + ] + }, + "3-10m": { + "median_in": [ + 8.31, + 21.43 + ], + "p90_in": [ + 18.68, + 58.42 + ] + } + } + } + } + }, + "MoGe-2 placed with modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.06, + "median_in": 6.22, + "p90_in": 16.6, + "scale_error_pct": 2.08 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.11, + "median_in": 17.4, + "p90_in": 42.11, + "scale_error_pct": 3.32 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.06, + "median_in": 3.17, + "p90_in": 14.02, + "scale_error_pct": -0.17 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.11, + "median_in": 7.05, + "p90_in": 37.41, + "scale_error_pct": -0.28 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.92, + 9.29 + ], + "p90_in": [ + 11.26, + 21.93 + ] + }, + "3-10m": { + "median_in": [ + 12.85, + 24.16 + ], + "p90_in": [ + 26.39, + 75.09 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.08, + "median_in": 6.06, + "p90_in": 16.26, + "scale_error_pct": 1.78 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.16, + "median_in": 17.53, + "p90_in": 45.48, + "scale_error_pct": 4.74 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.08, + "median_in": 2.8, + "p90_in": 13.37, + "scale_error_pct": -0.28 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 0.16, + "median_in": 7.03, + "p90_in": 38.66, + "scale_error_pct": -0.0 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.44, + 8.98 + ], + "p90_in": [ + 10.58, + 21.31 + ] + }, + "3-10m": { + "median_in": [ + 14.06, + 25.78 + ], + "p90_in": [ + 25.44, + 76.66 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.31, + "median_in": 7.63, + "p90_in": 30.51, + "scale_error_pct": 5.53 + }, + "3-10m": { + "pairs": 119080, + "failed_pct": 0.17, + "median_in": 15.38, + "p90_in": 44.59, + "scale_error_pct": 4.35 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.31, + "median_in": 4.37, + "p90_in": 32.85, + "scale_error_pct": 0.67 + }, + "3-10m": { + "pairs": 2977000, + "failed_pct": 0.17, + "median_in": 7.09, + "p90_in": 46.58, + "scale_error_pct": -0.04 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 6.15, + 10.93 + ], + "p90_in": [ + 15.2, + 48.01 + ] + }, + "3-10m": { + "median_in": [ + 11.66, + 21.55 + ], + "p90_in": [ + 23.93, + 70.31 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.01, + "median_in": 5.86, + "p90_in": 16.81, + "scale_error_pct": 2.38 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.03, + "median_in": 16.41, + "p90_in": 49.69, + "scale_error_pct": 3.52 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 1.01, + "median_in": 3.21, + "p90_in": 15.21, + "scale_error_pct": -0.28 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.03, + "median_in": 6.98, + "p90_in": 39.5, + "scale_error_pct": -0.1 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.21, + 8.96 + ], + "p90_in": [ + 10.59, + 23.88 + ] + }, + "3-10m": { + "median_in": [ + 11.64, + 23.36 + ], + "p90_in": [ + 25.44, + 81.86 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.02, + "median_in": 5.6, + "p90_in": 16.17, + "scale_error_pct": 1.34 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 0.05, + "median_in": 16.52, + "p90_in": 52.43, + "scale_error_pct": 4.21 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 1.02, + "median_in": 2.89, + "p90_in": 14.73, + "scale_error_pct": -0.31 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.05, + "median_in": 7.07, + "p90_in": 41.18, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.35, + 8.95 + ], + "p90_in": [ + 9.63, + 23.07 + ] + }, + "3-10m": { + "median_in": [ + 11.71, + 24.76 + ], + "p90_in": [ + 23.95, + 84.99 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.11, + "median_in": 6.43, + "p90_in": 21.37, + "scale_error_pct": 4.83 + }, + "3-10m": { + "pairs": 119080, + "failed_pct": 0.06, + "median_in": 13.36, + "p90_in": 37.8, + "scale_error_pct": 4.8 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 1.11, + "median_in": 4.71, + "p90_in": 26.99, + "scale_error_pct": 0.66 + }, + "3-10m": { + "pairs": 2977000, + "failed_pct": 1.07, + "median_in": 7.88, + "p90_in": 47.73, + "scale_error_pct": 0.84 + } + }, + "tape_calibration_success_pct": 99.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.25, + 8.63 + ], + "p90_in": [ + 13.77, + 36.67 + ] + }, + "3-10m": { + "median_in": [ + 10.61, + 18.65 + ], + "p90_in": [ + 21.34, + 66.5 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 5.06, + "p90_in": 12.84, + "scale_error_pct": 0.75 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.01, + "median_in": 13.47, + "p90_in": 25.66, + "scale_error_pct": 1.52 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 3.58, + "p90_in": 13.43, + "scale_error_pct": -0.49 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.59, + "median_in": 7.23, + "p90_in": 29.96, + "scale_error_pct": -0.28 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.64, + 6.95 + ], + "p90_in": [ + 11.04, + 14.5 + ] + }, + "3-10m": { + "median_in": [ + 10.23, + 17.01 + ], + "p90_in": [ + 22.36, + 31.15 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 4.88, + "p90_in": 12.33, + "scale_error_pct": -0.65 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 13.39, + "p90_in": 24.41, + "scale_error_pct": 2.87 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 3.35, + "p90_in": 13.6, + "scale_error_pct": -1.03 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 7.73, + "p90_in": 30.53, + "scale_error_pct": -0.16 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.08, + 7.07 + ], + "p90_in": [ + 9.55, + 14.44 + ] + }, + "3-10m": { + "median_in": [ + 10.43, + 17.18 + ], + "p90_in": [ + 19.82, + 28.67 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 6.61, + "p90_in": 15.6, + "scale_error_pct": 3.73 + }, + "3-10m": { + "pairs": 104080, + "failed_pct": 0.01, + "median_in": 11.16, + "p90_in": 24.27, + "scale_error_pct": 3.79 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 4.72, + "p90_in": 16.39, + "scale_error_pct": 0.23 + }, + "3-10m": { + "pairs": 2602000, + "failed_pct": 0.59, + "median_in": 7.31, + "p90_in": 28.26, + "scale_error_pct": 0.83 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 5.53, + 7.64 + ], + "p90_in": [ + 11.26, + 18.86 + ] + }, + "3-10m": { + "median_in": [ + 9.76, + 12.96 + ], + "p90_in": [ + 16.55, + 28.45 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 12.56, + "median_in": 3.82, + "p90_in": Infinity, + "scale_error_pct": -2.03 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 12.61, + "median_in": 10.35, + "p90_in": Infinity, + "scale_error_pct": -1.75 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 12.56, + "median_in": 3.54, + "p90_in": Infinity, + "scale_error_pct": -0.07 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 12.61, + "median_in": 8.89, + "p90_in": Infinity, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.36, + 7.71 + ], + "p90_in": [ + 7.7, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 5.92, + 24.73 + ], + "p90_in": [ + 18.76, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 12.58, + "median_in": 3.04, + "p90_in": Infinity, + "scale_error_pct": -2.0 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 12.66, + "median_in": 10.34, + "p90_in": Infinity, + "scale_error_pct": -0.5 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 12.58, + "median_in": 3.1, + "p90_in": Infinity, + "scale_error_pct": 0.16 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 12.66, + "median_in": 9.24, + "p90_in": Infinity, + "scale_error_pct": 0.93 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 1.9, + 7.01 + ], + "p90_in": [ + 6.48, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.6, + 26.43 + ], + "p90_in": [ + 20.19, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 12.81, + "median_in": 4.83, + "p90_in": Infinity, + "scale_error_pct": -0.78 + }, + "3-10m": { + "pairs": 119080, + "failed_pct": 11.99, + "median_in": 8.46, + "p90_in": Infinity, + "scale_error_pct": -1.28 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 12.81, + "median_in": 5.31, + "p90_in": Infinity, + "scale_error_pct": 0.62 + }, + "3-10m": { + "pairs": 2977000, + "failed_pct": 11.99, + "median_in": 9.18, + "p90_in": Infinity, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.58, + 24.63 + ], + "p90_in": [ + 11.32, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.6, + 29.92 + ], + "p90_in": [ + 17.98, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 2.51, + "median_in": 3.14, + "p90_in": 12.52, + "scale_error_pct": -2.72 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 2.54, + "median_in": 6.59, + "p90_in": 32.29, + "scale_error_pct": -2.44 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 2.51, + "median_in": 3.33, + "p90_in": 16.07, + "scale_error_pct": -0.17 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 2.54, + "median_in": 7.59, + "p90_in": 40.43, + "scale_error_pct": -0.07 + } + }, + "tape_calibration_success_pct": 97.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.19, + 4.88 + ], + "p90_in": [ + 6.92, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.07, + 11.77 + ], + "p90_in": [ + 14.04, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 2.54, + "median_in": 2.76, + "p90_in": 12.37, + "scale_error_pct": -2.73 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 2.55, + "median_in": 6.86, + "p90_in": 35.19, + "scale_error_pct": -2.24 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 2.54, + "median_in": 3.0, + "p90_in": 15.37, + "scale_error_pct": 0.0 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 2.55, + "median_in": 7.79, + "p90_in": 40.44, + "scale_error_pct": 0.17 + } + }, + "tape_calibration_success_pct": 97.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 1.82, + 4.43 + ], + "p90_in": [ + 6.39, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 3.84, + 13.14 + ], + "p90_in": [ + 13.72, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 2.61, + "median_in": 3.89, + "p90_in": 27.32, + "scale_error_pct": -2.29 + }, + "3-10m": { + "pairs": 119080, + "failed_pct": 2.58, + "median_in": 7.45, + "p90_in": 42.55, + "scale_error_pct": -2.43 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 2.61, + "median_in": 4.79, + "p90_in": 33.99, + "scale_error_pct": 0.2 + }, + "3-10m": { + "pairs": 2977000, + "failed_pct": 2.58, + "median_in": 8.03, + "p90_in": 55.25, + "scale_error_pct": -0.05 + } + }, + "tape_calibration_success_pct": 97.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.56, + 7.69 + ], + "p90_in": [ + 11.67, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.53, + 13.33 + ], + "p90_in": [ + 16.46, + Infinity + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 2.73, + "p90_in": 7.61, + "scale_error_pct": -2.91 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.01, + "median_in": 5.94, + "p90_in": 14.9, + "scale_error_pct": -2.65 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 2.59, + "p90_in": 9.88, + "scale_error_pct": -0.42 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.59, + "median_in": 5.67, + "p90_in": 23.04, + "scale_error_pct": -0.14 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.29, + 3.33 + ], + "p90_in": [ + 5.86, + 9.19 + ] + }, + "3-10m": { + "median_in": [ + 4.62, + 7.68 + ], + "p90_in": [ + 10.55, + 20.15 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 2.42, + "p90_in": 7.4, + "scale_error_pct": -2.88 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 5.63, + "p90_in": 14.95, + "scale_error_pct": -2.72 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 2.26, + "p90_in": 9.37, + "scale_error_pct": -0.2 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 5.63, + "p90_in": 22.56, + "scale_error_pct": 0.09 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 1.83, + 3.34 + ], + "p90_in": [ + 4.8, + 9.74 + ] + }, + "3-10m": { + "median_in": [ + 4.37, + 7.24 + ], + "p90_in": [ + 10.23, + 21.39 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 3.7, + "p90_in": 11.64, + "scale_error_pct": -3.15 + }, + "3-10m": { + "pairs": 104080, + "failed_pct": 0.01, + "median_in": 6.11, + "p90_in": 17.51, + "scale_error_pct": -2.96 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.57, + "median_in": 3.51, + "p90_in": 14.49, + "scale_error_pct": -0.33 + }, + "3-10m": { + "pairs": 2602000, + "failed_pct": 0.59, + "median_in": 5.6, + "p90_in": 23.37, + "scale_error_pct": -0.15 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.01, + 4.66 + ], + "p90_in": [ + 8.64, + 14.69 + ] + }, + "3-10m": { + "median_in": [ + 5.0, + 8.02 + ], + "p90_in": [ + 11.88, + 23.26 + ] + } + } + } + } + } + }, + "within 6 m": { + "mapanything@392, images only": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 16.0, + "median_in": 14.69, + "p90_in": Infinity, + "scale_error_pct": 13.86 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 17.89, + "median_in": 29.52, + "p90_in": Infinity, + "scale_error_pct": 14.2 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 26.26, + "median_in": 4.65, + "p90_in": Infinity, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 27.58, + "median_in": 10.06, + "p90_in": Infinity, + "scale_error_pct": -0.03 + } + }, + "tape_calibration_success_pct": 84.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.96, + 25.21 + ], + "p90_in": [ + 27.07, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 18.84, + 45.83 + ], + "p90_in": [ + 50.93, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 20.81, + "median_in": 15.17, + "p90_in": Infinity, + "scale_error_pct": 11.93 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 26.6, + "median_in": 29.91, + "p90_in": Infinity, + "scale_error_pct": 11.45 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 32.04, + "median_in": 5.39, + "p90_in": Infinity, + "scale_error_pct": 0.57 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 36.89, + "median_in": 14.0, + "p90_in": Infinity, + "scale_error_pct": 1.27 + } + }, + "tape_calibration_success_pct": 81.7, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 8.38, + 41.5 + ], + "p90_in": [ + 31.77, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 18.1, + 126.58 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 80.74, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 30.08 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 77.7, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 29.9 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 84.26, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.31 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 83.8, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.42 + } + }, + "tape_calibration_success_pct": 81.7, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 9.96, + "median_in": 15.66, + "p90_in": 69.59, + "scale_error_pct": 17.14 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 12.5, + "median_in": 31.5, + "p90_in": Infinity, + "scale_error_pct": 16.77 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 18.19, + "median_in": 3.41, + "p90_in": Infinity, + "scale_error_pct": 0.22 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 20.16, + "median_in": 7.12, + "p90_in": Infinity, + "scale_error_pct": 0.19 + } + }, + "tape_calibration_success_pct": 89.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 11.95, + 21.27 + ], + "p90_in": [ + 23.09, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 26.21, + 42.82 + ], + "p90_in": [ + 42.74, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 13.92, + "median_in": 16.83, + "p90_in": Infinity, + "scale_error_pct": 17.21 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 20.26, + "median_in": 29.51, + "p90_in": Infinity, + "scale_error_pct": 16.42 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 23.14, + "median_in": 4.46, + "p90_in": Infinity, + "scale_error_pct": 0.95 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 28.53, + "median_in": 9.95, + "p90_in": Infinity, + "scale_error_pct": 0.92 + } + }, + "tape_calibration_success_pct": 88.0, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 12.64, + 24.18 + ], + "p90_in": [ + 28.59, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 24.76, + 52.5 + ], + "p90_in": [ + 59.02, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 68.46, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 29.88 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 72.42, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 25.63 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 71.77, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.5 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 76.92, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 0.36 + } + }, + "tape_calibration_success_pct": 88.0, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 16.0, + "median_in": 4.4, + "p90_in": Infinity, + "scale_error_pct": 3.5 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 18.39, + "median_in": 9.89, + "p90_in": Infinity, + "scale_error_pct": 3.43 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 25.97, + "median_in": 4.25, + "p90_in": Infinity, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 27.59, + "median_in": 9.17, + "p90_in": Infinity, + "scale_error_pct": -0.33 + } + }, + "tape_calibration_success_pct": 84.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.19, + 13.71 + ], + "p90_in": [ + 9.97, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.41, + 30.05 + ], + "p90_in": [ + 19.0, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 21.02, + "median_in": 4.61, + "p90_in": Infinity, + "scale_error_pct": 3.37 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 27.45, + "median_in": 14.61, + "p90_in": Infinity, + "scale_error_pct": 4.15 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 31.77, + "median_in": 4.92, + "p90_in": Infinity, + "scale_error_pct": 0.71 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 36.94, + "median_in": 12.34, + "p90_in": Infinity, + "scale_error_pct": 1.49 + } + }, + "tape_calibration_success_pct": 81.7, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.04, + 21.07 + ], + "p90_in": [ + 19.4, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.8, + 92.62 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 79.66, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 8.31 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 77.49, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 11.23 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 83.67, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.53 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 84.03, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.06 + } + }, + "tape_calibration_success_pct": 81.7, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 10.33, + "median_in": 4.14, + "p90_in": Infinity, + "scale_error_pct": 4.17 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 13.24, + "median_in": 7.86, + "p90_in": Infinity, + "scale_error_pct": 3.5 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 18.99, + "median_in": 3.64, + "p90_in": Infinity, + "scale_error_pct": 0.14 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 21.21, + "median_in": 7.94, + "p90_in": Infinity, + "scale_error_pct": -0.04 + } + }, + "tape_calibration_success_pct": 89.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.34, + 6.66 + ], + "p90_in": [ + 8.29, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.22, + 12.99 + ], + "p90_in": [ + 15.19, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 13.92, + "median_in": 3.9, + "p90_in": Infinity, + "scale_error_pct": 3.78 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 19.92, + "median_in": 7.87, + "p90_in": Infinity, + "scale_error_pct": 3.61 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 23.62, + "median_in": 4.36, + "p90_in": Infinity, + "scale_error_pct": 1.05 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 28.65, + "median_in": 9.77, + "p90_in": Infinity, + "scale_error_pct": 1.45 + } + }, + "tape_calibration_success_pct": 87.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.09, + 10.84 + ], + "p90_in": [ + 8.57, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.3, + 22.46 + ], + "p90_in": [ + 20.16, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 68.67, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 9.52 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 71.4, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 6.21 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 72.04, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 6.81 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 75.96, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 2.87 + } + }, + "tape_calibration_success_pct": 87.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 16.17, + "median_in": 4.76, + "p90_in": Infinity, + "scale_error_pct": -0.46 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 18.61, + "median_in": 11.02, + "p90_in": Infinity, + "scale_error_pct": -0.88 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 26.2, + "median_in": 4.41, + "p90_in": Infinity, + "scale_error_pct": 0.03 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 27.83, + "median_in": 9.65, + "p90_in": Infinity, + "scale_error_pct": -0.29 + } + }, + "tape_calibration_success_pct": 83.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.85, + 13.68 + ], + "p90_in": [ + 13.05, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 5.5, + 29.07 + ], + "p90_in": [ + 32.14, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 21.2, + "median_in": 6.22, + "p90_in": Infinity, + "scale_error_pct": 1.35 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 27.68, + "median_in": 13.63, + "p90_in": Infinity, + "scale_error_pct": 3.17 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 32.0, + "median_in": 4.95, + "p90_in": Infinity, + "scale_error_pct": 0.66 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 37.12, + "median_in": 12.72, + "p90_in": Infinity, + "scale_error_pct": 1.46 + } + }, + "tape_calibration_success_pct": 81.1, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.96, + 18.77 + ], + "p90_in": [ + 18.18, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 3.97, + 456.29 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 79.31, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.9 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 77.27, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 83.6, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.77 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 84.16, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.09 + } + }, + "tape_calibration_success_pct": 81.1, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 10.61, + "median_in": 4.97, + "p90_in": Infinity, + "scale_error_pct": -2.07 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 13.42, + "median_in": 11.45, + "p90_in": Infinity, + "scale_error_pct": -3.12 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 19.69, + "median_in": 3.68, + "p90_in": Infinity, + "scale_error_pct": 0.2 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 21.82, + "median_in": 7.89, + "p90_in": Infinity, + "scale_error_pct": -0.08 + } + }, + "tape_calibration_success_pct": 88.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.31, + 9.67 + ], + "p90_in": [ + 10.58, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 5.36, + 19.41 + ], + "p90_in": [ + 19.94, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 13.99, + "median_in": 6.38, + "p90_in": Infinity, + "scale_error_pct": -1.5 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 19.77, + "median_in": 12.0, + "p90_in": Infinity, + "scale_error_pct": -2.25 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 24.12, + "median_in": 4.49, + "p90_in": Infinity, + "scale_error_pct": 1.1 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 28.91, + "median_in": 9.91, + "p90_in": Infinity, + "scale_error_pct": 1.71 + } + }, + "tape_calibration_success_pct": 86.9, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.03, + 11.53 + ], + "p90_in": [ + 13.53, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 6.46, + 24.24 + ], + "p90_in": [ + 28.45, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 68.73, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 8.97 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 71.29, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.03 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 72.27, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 7.16 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 75.86, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 2.81 + } + }, + "tape_calibration_success_pct": 86.9, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "mapanything@392 + modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 16.08, + "median_in": 3.98, + "p90_in": Infinity, + "scale_error_pct": 1.68 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 18.41, + "median_in": 8.53, + "p90_in": Infinity, + "scale_error_pct": 1.69 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 26.01, + "median_in": 4.28, + "p90_in": Infinity, + "scale_error_pct": -0.0 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 27.61, + "median_in": 9.19, + "p90_in": Infinity, + "scale_error_pct": -0.35 + } + }, + "tape_calibration_success_pct": 84.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.38, + 14.09 + ], + "p90_in": [ + 9.01, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.66, + 31.12 + ], + "p90_in": [ + 20.54, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 21.15, + "median_in": 4.77, + "p90_in": Infinity, + "scale_error_pct": 1.63 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 27.48, + "median_in": 12.11, + "p90_in": Infinity, + "scale_error_pct": 2.72 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 31.83, + "median_in": 4.91, + "p90_in": Infinity, + "scale_error_pct": 0.69 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 36.94, + "median_in": 12.31, + "p90_in": Infinity, + "scale_error_pct": 1.44 + } + }, + "tape_calibration_success_pct": 81.7, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.7, + 21.57 + ], + "p90_in": [ + 19.65, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.82, + 109.74 + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 79.79, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 7.46 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 77.6, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 11.04 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 83.76, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 4.5 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 84.11, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 1.13 + } + }, + "tape_calibration_success_pct": 81.7, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 10.39, + "median_in": 3.43, + "p90_in": Infinity, + "scale_error_pct": 2.58 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 13.29, + "median_in": 6.78, + "p90_in": Infinity, + "scale_error_pct": 1.93 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 19.04, + "median_in": 3.63, + "p90_in": Infinity, + "scale_error_pct": 0.16 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 21.25, + "median_in": 7.95, + "p90_in": Infinity, + "scale_error_pct": -0.11 + } + }, + "tape_calibration_success_pct": 89.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.8, + 6.26 + ], + "p90_in": [ + 7.93, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 2.86, + 12.03 + ], + "p90_in": [ + 14.37, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 14.0, + "median_in": 2.96, + "p90_in": Infinity, + "scale_error_pct": 2.11 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 19.99, + "median_in": 6.67, + "p90_in": Infinity, + "scale_error_pct": 2.09 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 23.7, + "median_in": 4.34, + "p90_in": Infinity, + "scale_error_pct": 1.04 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 28.69, + "median_in": 9.75, + "p90_in": Infinity, + "scale_error_pct": 1.42 + } + }, + "tape_calibration_success_pct": 87.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.36, + 9.89 + ], + "p90_in": [ + 8.47, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 2.24, + 20.65 + ], + "p90_in": [ + 19.11, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 68.39, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 8.83 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 71.12, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 5.0 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 71.78, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 7.01 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 75.68, + "median_in": Infinity, + "p90_in": Infinity, + "scale_error_pct": 2.96 + } + }, + "tape_calibration_success_pct": 87.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + }, + "3-10m": { + "median_in": [ + Infinity, + Infinity + ], + "p90_in": [ + Infinity, + Infinity + ] + } + } + } + } + }, + "MoGe-2 placed with exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.49, + "median_in": 6.81, + "p90_in": 14.99, + "scale_error_pct": 2.42 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 1.68, + "median_in": 14.9, + "p90_in": 28.73, + "scale_error_pct": 3.75 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.49, + "median_in": 2.3, + "p90_in": 11.34, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.68, + "median_in": 3.96, + "p90_in": 21.1, + "scale_error_pct": 0.04 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.94, + 9.42 + ], + "p90_in": [ + 11.44, + 19.17 + ] + }, + "3-10m": { + "median_in": [ + 5.68, + 19.76 + ], + "p90_in": [ + 20.31, + 32.34 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.99, + "median_in": 5.21, + "p90_in": 15.32, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 2.6, + "median_in": 13.27, + "p90_in": 31.63, + "scale_error_pct": 2.53 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.99, + "median_in": 2.4, + "p90_in": 12.78, + "scale_error_pct": -0.26 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 2.6, + "median_in": 5.11, + "p90_in": 32.19, + "scale_error_pct": 0.16 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.97, + 8.39 + ], + "p90_in": [ + 9.94, + 19.04 + ] + }, + "3-10m": { + "median_in": [ + 5.8, + 20.02 + ], + "p90_in": [ + 20.5, + 40.56 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 1.0, + "median_in": 8.47, + "p90_in": 33.83, + "scale_error_pct": 1.22 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 0.58, + "median_in": 15.12, + "p90_in": 49.92, + "scale_error_pct": 2.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.0, + "median_in": 3.85, + "p90_in": 27.29, + "scale_error_pct": 0.75 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 0.58, + "median_in": 5.53, + "p90_in": 38.06, + "scale_error_pct": 0.14 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.22, + 13.06 + ], + "p90_in": [ + 13.81, + 50.64 + ] + }, + "3-10m": { + "median_in": [ + 5.14, + 22.74 + ], + "p90_in": [ + 19.43, + 68.72 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.22, + "median_in": 6.14, + "p90_in": 14.4, + "scale_error_pct": 2.54 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 1.16, + "median_in": 13.88, + "p90_in": 25.94, + "scale_error_pct": 4.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.72, + "median_in": 2.47, + "p90_in": 15.68, + "scale_error_pct": 0.19 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.66, + "median_in": 4.37, + "p90_in": 33.08, + "scale_error_pct": 0.56 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.74, + 8.91 + ], + "p90_in": [ + 10.45, + 21.0 + ] + }, + "3-10m": { + "median_in": [ + 8.39, + 18.13 + ], + "p90_in": [ + 19.43, + 29.93 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.45, + "median_in": 5.6, + "p90_in": 14.98, + "scale_error_pct": -0.13 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 2.03, + "median_in": 13.66, + "p90_in": 30.36, + "scale_error_pct": 4.29 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.02, + "median_in": 2.37, + "p90_in": 19.75, + "scale_error_pct": -0.05 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 2.6, + "median_in": 5.26, + "p90_in": 52.6, + "scale_error_pct": 1.1 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.9, + 8.64 + ], + "p90_in": [ + 9.74, + 23.68 + ] + }, + "3-10m": { + "median_in": [ + 8.78, + 19.58 + ], + "p90_in": [ + 21.48, + 48.44 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.44, + "median_in": 7.47, + "p90_in": 20.55, + "scale_error_pct": 2.81 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 0.3, + "median_in": 13.34, + "p90_in": 34.63, + "scale_error_pct": 3.31 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.01, + "median_in": 4.15, + "p90_in": 26.44, + "scale_error_pct": 0.8 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 0.88, + "median_in": 6.44, + "p90_in": 50.01, + "scale_error_pct": 1.03 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.88, + 11.27 + ], + "p90_in": [ + 13.62, + 41.82 + ] + }, + "3-10m": { + "median_in": [ + 6.21, + 17.22 + ], + "p90_in": [ + 18.01, + 58.72 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 5.4, + "p90_in": 12.49, + "scale_error_pct": 0.68 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.01, + "median_in": 12.4, + "p90_in": 22.62, + "scale_error_pct": 3.0 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 2.78, + "p90_in": 10.79, + "scale_error_pct": 0.11 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.59, + "median_in": 4.27, + "p90_in": 21.13, + "scale_error_pct": 0.18 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.27, + 8.17 + ], + "p90_in": [ + 8.82, + 14.95 + ] + }, + "3-10m": { + "median_in": [ + 6.76, + 17.09 + ], + "p90_in": [ + 16.36, + 25.79 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 0.11, + "median_in": 4.85, + "p90_in": 12.45, + "scale_error_pct": -1.26 + }, + "3-10m": { + "pairs": 18000, + "failed_pct": 0.23, + "median_in": 11.55, + "p90_in": 25.82, + "scale_error_pct": 2.73 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 0.77, + "median_in": 2.77, + "p90_in": 10.03, + "scale_error_pct": 0.16 + }, + "3-10m": { + "pairs": 450000, + "failed_pct": 0.9, + "median_in": 5.47, + "p90_in": 25.64, + "scale_error_pct": 1.4 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 6, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 2.72, + 8.29 + ], + "p90_in": [ + 7.74, + 15.56 + ] + }, + "3-10m": { + "median_in": [ + 6.91, + 18.23 + ], + "p90_in": [ + 15.98, + 32.65 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 0.08, + "median_in": 6.37, + "p90_in": 14.58, + "scale_error_pct": 1.25 + }, + "3-10m": { + "pairs": 17786, + "failed_pct": 0.16, + "median_in": 7.78, + "p90_in": 18.54, + "scale_error_pct": -0.32 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 0.74, + "median_in": 4.03, + "p90_in": 15.29, + "scale_error_pct": 1.79 + }, + "3-10m": { + "pairs": 444650, + "failed_pct": 0.84, + "median_in": 6.14, + "p90_in": 25.18, + "scale_error_pct": 0.42 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 6, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 3.1, + 10.07 + ], + "p90_in": [ + 11.78, + 18.99 + ] + }, + "3-10m": { + "median_in": [ + 2.83, + 15.04 + ], + "p90_in": [ + 13.61, + 25.21 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, exact poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 12.99, + "median_in": 2.1, + "p90_in": Infinity, + "scale_error_pct": 0.47 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 14.18, + "median_in": 4.13, + "p90_in": Infinity, + "scale_error_pct": 0.38 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 12.99, + "median_in": 2.07, + "p90_in": Infinity, + "scale_error_pct": -0.11 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 14.18, + "median_in": 3.94, + "p90_in": Infinity, + "scale_error_pct": -0.19 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.24, + 8.51 + ], + "p90_in": [ + 3.42, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 2.44, + 28.31 + ], + "p90_in": [ + 5.48, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 15.27, + "median_in": 1.51, + "p90_in": Infinity, + "scale_error_pct": -0.4 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 16.89, + "median_in": 3.13, + "p90_in": Infinity, + "scale_error_pct": 0.88 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 15.27, + "median_in": 2.34, + "p90_in": Infinity, + "scale_error_pct": -0.23 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 16.89, + "median_in": 5.04, + "p90_in": Infinity, + "scale_error_pct": 0.23 + } + }, + "tape_calibration_success_pct": 85.7, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 0.92, + 27.35 + ], + "p90_in": [ + 2.3, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 2.09, + 60.82 + ], + "p90_in": [ + 6.9, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 15.29, + "median_in": 2.96, + "p90_in": Infinity, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 13.99, + "median_in": 3.83, + "p90_in": Infinity, + "scale_error_pct": -0.6 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 15.29, + "median_in": 3.79, + "p90_in": Infinity, + "scale_error_pct": 0.33 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 13.99, + "median_in": 5.69, + "p90_in": Infinity, + "scale_error_pct": -0.09 + } + }, + "tape_calibration_success_pct": 85.7, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.25, + 60.91 + ], + "p90_in": [ + 7.16, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 2.08, + 85.79 + ], + "p90_in": [ + 8.42, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 24000, + "failed_pct": 0.23, + "median_in": 1.61, + "p90_in": 8.93, + "scale_error_pct": -0.21 + }, + "3-10m": { + "pairs": 24000, + "failed_pct": 1.16, + "median_in": 3.27, + "p90_in": 20.54, + "scale_error_pct": -0.26 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 600000, + "failed_pct": 0.73, + "median_in": 1.97, + "p90_in": 12.97, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 600000, + "failed_pct": 1.66, + "median_in": 3.77, + "p90_in": 27.23, + "scale_error_pct": -0.11 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 8, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.09, + 2.71 + ], + "p90_in": [ + 3.33, + 34.21 + ] + }, + "3-10m": { + "median_in": [ + 1.69, + 6.84 + ], + "p90_in": [ + 5.16, + 74.59 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.49, + "median_in": 1.45, + "p90_in": 10.89, + "scale_error_pct": -0.86 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 2.05, + "median_in": 2.74, + "p90_in": 53.14, + "scale_error_pct": -0.18 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.06, + "median_in": 1.91, + "p90_in": 16.24, + "scale_error_pct": -0.17 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 2.62, + "median_in": 4.65, + "p90_in": 43.4, + "scale_error_pct": 0.29 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 0.94, + 2.61 + ], + "p90_in": [ + 2.37, + 41.16 + ] + }, + "3-10m": { + "median_in": [ + 1.46, + 10.35 + ], + "p90_in": [ + 5.2, + 79.59 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.44, + "median_in": 2.15, + "p90_in": 24.85, + "scale_error_pct": -0.67 + }, + "3-10m": { + "pairs": 20786, + "failed_pct": 0.3, + "median_in": 3.33, + "p90_in": 53.66, + "scale_error_pct": -0.99 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 1.01, + "median_in": 3.16, + "p90_in": 23.54, + "scale_error_pct": -0.27 + }, + "3-10m": { + "pairs": 519650, + "failed_pct": 0.88, + "median_in": 5.08, + "p90_in": 41.0, + "scale_error_pct": -0.33 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.19, + 6.5 + ], + "p90_in": [ + 5.7, + 67.46 + ] + }, + "3-10m": { + "median_in": [ + 1.93, + 10.47 + ], + "p90_in": [ + 7.58, + 84.11 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 21000, + "failed_pct": 0.0, + "median_in": 1.27, + "p90_in": 4.05, + "scale_error_pct": -0.61 + }, + "3-10m": { + "pairs": 21000, + "failed_pct": 0.01, + "median_in": 2.16, + "p90_in": 6.2, + "scale_error_pct": -0.69 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 525000, + "failed_pct": 0.57, + "median_in": 1.83, + "p90_in": 7.59, + "scale_error_pct": -0.18 + }, + "3-10m": { + "pairs": 525000, + "failed_pct": 0.59, + "median_in": 3.23, + "p90_in": 14.27, + "scale_error_pct": -0.4 + } + }, + "tape_calibration_success_pct": 99.4, + "groups": 7, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.04, + 1.5 + ], + "p90_in": [ + 3.27, + 4.83 + ] + }, + "3-10m": { + "median_in": [ + 1.52, + 3.22 + ], + "p90_in": [ + 3.85, + 8.68 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 0.11, + "median_in": 1.08, + "p90_in": 2.81, + "scale_error_pct": -0.72 + }, + "3-10m": { + "pairs": 18000, + "failed_pct": 0.23, + "median_in": 2.09, + "p90_in": 8.3, + "scale_error_pct": -0.49 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 0.77, + "median_in": 1.51, + "p90_in": 6.59, + "scale_error_pct": -0.01 + }, + "3-10m": { + "pairs": 450000, + "failed_pct": 0.9, + "median_in": 3.15, + "p90_in": 16.25, + "scale_error_pct": 0.11 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 6, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 0.83, + 1.43 + ], + "p90_in": [ + 1.97, + 4.37 + ] + }, + "3-10m": { + "median_in": [ + 1.52, + 4.02 + ], + "p90_in": [ + 3.31, + 18.97 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 18000, + "failed_pct": 0.08, + "median_in": 1.56, + "p90_in": 8.17, + "scale_error_pct": -0.82 + }, + "3-10m": { + "pairs": 17786, + "failed_pct": 0.16, + "median_in": 2.2, + "p90_in": 12.26, + "scale_error_pct": -1.06 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 450000, + "failed_pct": 0.74, + "median_in": 2.37, + "p90_in": 11.0, + "scale_error_pct": -0.31 + }, + "3-10m": { + "pairs": 444650, + "failed_pct": 0.84, + "median_in": 3.61, + "p90_in": 17.45, + "scale_error_pct": -0.49 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 6, + "noise_draws": 1, + "interval_95": { + "1-3m": { + "median_in": [ + 1.05, + 2.91 + ], + "p90_in": [ + 2.83, + 13.04 + ] + }, + "3-10m": { + "median_in": [ + 1.48, + 5.21 + ], + "p90_in": [ + 3.34, + 15.72 + ] + } + } + } + } + }, + "MoGe-2 placed with advio_2018 poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.49, + "median_in": 6.81, + "p90_in": 15.44, + "scale_error_pct": 1.94 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 1.68, + "median_in": 15.07, + "p90_in": 28.65, + "scale_error_pct": 3.16 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.49, + "median_in": 2.71, + "p90_in": 13.13, + "scale_error_pct": -0.19 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.68, + "median_in": 4.69, + "p90_in": 24.23, + "scale_error_pct": -0.38 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.11, + 9.48 + ], + "p90_in": [ + 11.52, + 19.39 + ] + }, + "3-10m": { + "median_in": [ + 7.94, + 19.69 + ], + "p90_in": [ + 20.21, + 32.34 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.99, + "median_in": 5.41, + "p90_in": 15.29, + "scale_error_pct": -0.67 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 2.6, + "median_in": 13.61, + "p90_in": 31.85, + "scale_error_pct": 2.35 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.99, + "median_in": 2.7, + "p90_in": 14.63, + "scale_error_pct": -0.22 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 2.6, + "median_in": 5.6, + "p90_in": 37.04, + "scale_error_pct": 0.11 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.17, + 8.55 + ], + "p90_in": [ + 10.29, + 19.07 + ] + }, + "3-10m": { + "median_in": [ + 5.78, + 21.85 + ], + "p90_in": [ + 20.94, + 42.81 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 1.0, + "median_in": 8.61, + "p90_in": 34.03, + "scale_error_pct": 1.21 + }, + "3-10m": { + "pairs": 103930, + "failed_pct": 0.58, + "median_in": 15.43, + "p90_in": 49.41, + "scale_error_pct": 1.58 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 1.0, + "median_in": 4.29, + "p90_in": 28.86, + "scale_error_pct": 0.88 + }, + "3-10m": { + "pairs": 2598250, + "failed_pct": 0.58, + "median_in": 6.2, + "p90_in": 40.0, + "scale_error_pct": 0.23 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.77, + 13.21 + ], + "p90_in": [ + 14.36, + 50.74 + ] + }, + "3-10m": { + "median_in": [ + 4.27, + 23.22 + ], + "p90_in": [ + 20.45, + 68.34 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.22, + "median_in": 6.06, + "p90_in": 15.37, + "scale_error_pct": 0.39 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 1.16, + "median_in": 13.64, + "p90_in": 26.25, + "scale_error_pct": 3.87 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.22, + "median_in": 3.21, + "p90_in": 20.91, + "scale_error_pct": 0.13 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.16, + "median_in": 5.65, + "p90_in": 42.14, + "scale_error_pct": 0.67 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.8, + 9.0 + ], + "p90_in": [ + 10.23, + 21.62 + ] + }, + "3-10m": { + "median_in": [ + 8.44, + 18.62 + ], + "p90_in": [ + 19.16, + 29.95 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.45, + "median_in": 5.93, + "p90_in": 15.06, + "scale_error_pct": -0.55 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 2.03, + "median_in": 13.56, + "p90_in": 30.12, + "scale_error_pct": 4.09 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.45, + "median_in": 2.67, + "p90_in": 23.08, + "scale_error_pct": 0.42 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 2.03, + "median_in": 6.14, + "p90_in": 56.81, + "scale_error_pct": 1.6 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.19, + 8.94 + ], + "p90_in": [ + 10.19, + 23.12 + ] + }, + "3-10m": { + "median_in": [ + 8.55, + 19.85 + ], + "p90_in": [ + 21.45, + 46.09 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.44, + "median_in": 7.94, + "p90_in": 20.51, + "scale_error_pct": 2.61 + }, + "3-10m": { + "pairs": 103930, + "failed_pct": 0.3, + "median_in": 13.36, + "p90_in": 35.72, + "scale_error_pct": 2.88 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.44, + "median_in": 4.82, + "p90_in": 30.56, + "scale_error_pct": 1.7 + }, + "3-10m": { + "pairs": 2598250, + "failed_pct": 0.3, + "median_in": 7.66, + "p90_in": 51.45, + "scale_error_pct": 2.07 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.06, + 11.39 + ], + "p90_in": [ + 14.31, + 42.23 + ] + }, + "3-10m": { + "median_in": [ + 5.9, + 17.65 + ], + "p90_in": [ + 18.06, + 59.44 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 5.89, + "p90_in": 14.01, + "scale_error_pct": -1.73 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.01, + "median_in": 13.0, + "p90_in": 24.22, + "scale_error_pct": 1.78 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.0, + "median_in": 4.29, + "p90_in": 16.7, + "scale_error_pct": 0.01 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.01, + "median_in": 7.3, + "p90_in": 32.34, + "scale_error_pct": 0.18 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.17, + 8.71 + ], + "p90_in": [ + 10.14, + 16.76 + ] + }, + "3-10m": { + "median_in": [ + 8.58, + 18.25 + ], + "p90_in": [ + 18.11, + 27.45 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 90000, + "failed_pct": 0.11, + "median_in": 5.54, + "p90_in": 12.96, + "scale_error_pct": -2.31 + }, + "3-10m": { + "pairs": 90000, + "failed_pct": 0.23, + "median_in": 11.21, + "p90_in": 26.67, + "scale_error_pct": 2.28 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2250000, + "failed_pct": 0.11, + "median_in": 3.46, + "p90_in": 13.95, + "scale_error_pct": 0.34 + }, + "3-10m": { + "pairs": 2250000, + "failed_pct": 0.23, + "median_in": 6.51, + "p90_in": 34.62, + "scale_error_pct": 1.53 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 6, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.22, + 8.45 + ], + "p90_in": [ + 8.32, + 16.6 + ] + }, + "3-10m": { + "median_in": [ + 6.59, + 18.38 + ], + "p90_in": [ + 14.26, + 32.47 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 90000, + "failed_pct": 0.08, + "median_in": 6.68, + "p90_in": 14.88, + "scale_error_pct": 0.99 + }, + "3-10m": { + "pairs": 88930, + "failed_pct": 0.16, + "median_in": 8.2, + "p90_in": 18.68, + "scale_error_pct": -0.9 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2250000, + "failed_pct": 0.08, + "median_in": 4.72, + "p90_in": 19.94, + "scale_error_pct": 2.39 + }, + "3-10m": { + "pairs": 2223250, + "failed_pct": 0.16, + "median_in": 7.14, + "p90_in": 31.8, + "scale_error_pct": 0.33 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 6, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.4, + 10.42 + ], + "p90_in": [ + 11.88, + 19.33 + ] + }, + "3-10m": { + "median_in": [ + 2.98, + 15.27 + ], + "p90_in": [ + 13.06, + 23.82 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, advio_2018 poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 12.99, + "median_in": 10.7, + "p90_in": Infinity, + "scale_error_pct": -8.74 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 14.18, + "median_in": 23.7, + "p90_in": Infinity, + "scale_error_pct": -8.78 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 12.99, + "median_in": 2.91, + "p90_in": Infinity, + "scale_error_pct": -0.26 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 14.18, + "median_in": 5.5, + "p90_in": Infinity, + "scale_error_pct": -0.32 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 7.66, + 20.15 + ], + "p90_in": [ + 17.29, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 15.55, + 43.2 + ], + "p90_in": [ + 36.84, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 15.27, + "median_in": 11.22, + "p90_in": Infinity, + "scale_error_pct": -9.17 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 16.89, + "median_in": 21.73, + "p90_in": Infinity, + "scale_error_pct": -7.43 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 15.27, + "median_in": 3.14, + "p90_in": Infinity, + "scale_error_pct": -0.23 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 16.89, + "median_in": 6.24, + "p90_in": Infinity, + "scale_error_pct": 0.39 + } + }, + "tape_calibration_success_pct": 85.7, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 8.18, + 30.1 + ], + "p90_in": [ + 18.36, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 14.15, + 74.63 + ], + "p90_in": [ + 35.16, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 15.29, + "median_in": 11.91, + "p90_in": Infinity, + "scale_error_pct": -8.6 + }, + "3-10m": { + "pairs": 103930, + "failed_pct": 13.99, + "median_in": 22.24, + "p90_in": Infinity, + "scale_error_pct": -9.19 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 15.29, + "median_in": 4.45, + "p90_in": Infinity, + "scale_error_pct": 0.8 + }, + "3-10m": { + "pairs": 2598250, + "failed_pct": 13.99, + "median_in": 6.85, + "p90_in": Infinity, + "scale_error_pct": 0.29 + } + }, + "tape_calibration_success_pct": 85.7, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 8.28, + 57.06 + ], + "p90_in": [ + 20.34, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 14.57, + 74.86 + ], + "p90_in": [ + 36.23, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.23, + "median_in": 10.33, + "p90_in": 30.73, + "scale_error_pct": -9.02 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 1.16, + "median_in": 22.06, + "p90_in": 61.94, + "scale_error_pct": -8.82 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.23, + "median_in": 4.47, + "p90_in": 28.51, + "scale_error_pct": -0.1 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.16, + "median_in": 7.78, + "p90_in": 60.13, + "scale_error_pct": 0.02 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 6.08, + 15.23 + ], + "p90_in": [ + 18.04, + 70.29 + ] + }, + "3-10m": { + "median_in": [ + 13.28, + 32.49 + ], + "p90_in": [ + 36.65, + 133.98 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.49, + "median_in": 11.22, + "p90_in": 34.06, + "scale_error_pct": -10.47 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 2.05, + "median_in": 24.15, + "p90_in": 76.71, + "scale_error_pct": -10.42 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.49, + "median_in": 3.57, + "p90_in": 30.36, + "scale_error_pct": 0.1 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 2.05, + "median_in": 8.81, + "p90_in": 67.22, + "scale_error_pct": 0.92 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 6.61, + 16.71 + ], + "p90_in": [ + 19.05, + 67.41 + ] + }, + "3-10m": { + "median_in": [ + 17.21, + 34.97 + ], + "p90_in": [ + 37.09, + 143.39 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.44, + "median_in": 11.63, + "p90_in": 33.76, + "scale_error_pct": -10.16 + }, + "3-10m": { + "pairs": 103930, + "failed_pct": 0.3, + "median_in": 22.97, + "p90_in": 63.53, + "scale_error_pct": -10.42 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.44, + "median_in": 5.27, + "p90_in": 34.33, + "scale_error_pct": 0.69 + }, + "3-10m": { + "pairs": 2598250, + "failed_pct": 0.3, + "median_in": 8.56, + "p90_in": 59.53, + "scale_error_pct": 0.15 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 7.93, + 18.18 + ], + "p90_in": [ + 20.8, + 70.18 + ] + }, + "3-10m": { + "median_in": [ + 14.68, + 37.36 + ], + "p90_in": [ + 32.47, + 100.54 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 6.15, + "p90_in": 14.59, + "scale_error_pct": -7.27 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.01, + "median_in": 12.96, + "p90_in": 27.87, + "scale_error_pct": -7.36 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.34, + "median_in": 4.01, + "p90_in": 18.54, + "scale_error_pct": -0.31 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.36, + "median_in": 6.82, + "p90_in": 35.84, + "scale_error_pct": -0.51 + } + }, + "tape_calibration_success_pct": 99.7, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.57, + 8.56 + ], + "p90_in": [ + 10.59, + 18.67 + ] + }, + "3-10m": { + "median_in": [ + 9.6, + 19.57 + ], + "p90_in": [ + 18.59, + 33.6 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 90000, + "failed_pct": 0.11, + "median_in": 4.89, + "p90_in": 10.8, + "scale_error_pct": -5.62 + }, + "3-10m": { + "pairs": 90000, + "failed_pct": 0.23, + "median_in": 10.03, + "p90_in": 18.41, + "scale_error_pct": -5.17 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2250000, + "failed_pct": 0.37, + "median_in": 3.12, + "p90_in": 15.2, + "scale_error_pct": 0.06 + }, + "3-10m": { + "pairs": 2250000, + "failed_pct": 0.5, + "median_in": 5.91, + "p90_in": 35.59, + "scale_error_pct": 0.37 + } + }, + "tape_calibration_success_pct": 99.7, + "groups": 6, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.72, + 6.55 + ], + "p90_in": [ + 8.62, + 13.06 + ] + }, + "3-10m": { + "median_in": [ + 7.08, + 12.55 + ], + "p90_in": [ + 15.18, + 26.86 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 90000, + "failed_pct": 0.08, + "median_in": 5.54, + "p90_in": 13.41, + "scale_error_pct": -5.77 + }, + "3-10m": { + "pairs": 88930, + "failed_pct": 0.16, + "median_in": 10.53, + "p90_in": 20.48, + "scale_error_pct": -6.75 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2250000, + "failed_pct": 0.34, + "median_in": 4.01, + "p90_in": 20.45, + "scale_error_pct": -0.13 + }, + "3-10m": { + "pairs": 2223250, + "failed_pct": 0.43, + "median_in": 6.39, + "p90_in": 31.31, + "scale_error_pct": -0.74 + } + }, + "tape_calibration_success_pct": 99.7, + "groups": 6, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.81, + 7.42 + ], + "p90_in": [ + 9.13, + 17.12 + ] + }, + "3-10m": { + "median_in": [ + 7.75, + 14.38 + ], + "p90_in": [ + 14.26, + 23.75 + ] + } + } + } + } + }, + "MoGe-2 placed with modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.49, + "median_in": 6.74, + "p90_in": 15.0, + "scale_error_pct": 2.3 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 1.68, + "median_in": 14.85, + "p90_in": 28.68, + "scale_error_pct": 3.67 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.49, + "median_in": 2.25, + "p90_in": 11.54, + "scale_error_pct": -0.04 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.68, + "median_in": 3.96, + "p90_in": 21.43, + "scale_error_pct": -0.04 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.71, + 9.43 + ], + "p90_in": [ + 11.21, + 19.19 + ] + }, + "3-10m": { + "median_in": [ + 5.56, + 19.62 + ], + "p90_in": [ + 19.93, + 32.38 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.99, + "median_in": 5.22, + "p90_in": 15.28, + "scale_error_pct": -0.21 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 2.6, + "median_in": 13.28, + "p90_in": 31.67, + "scale_error_pct": 2.54 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.99, + "median_in": 2.37, + "p90_in": 13.01, + "scale_error_pct": -0.24 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 2.6, + "median_in": 5.13, + "p90_in": 32.62, + "scale_error_pct": 0.18 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.1, + 8.49 + ], + "p90_in": [ + 10.09, + 19.05 + ] + }, + "3-10m": { + "median_in": [ + 5.87, + 21.93 + ], + "p90_in": [ + 20.88, + 39.85 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 1.0, + "median_in": 8.53, + "p90_in": 33.76, + "scale_error_pct": 1.25 + }, + "3-10m": { + "pairs": 103930, + "failed_pct": 0.58, + "median_in": 15.14, + "p90_in": 49.53, + "scale_error_pct": 2.18 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 1.0, + "median_in": 3.89, + "p90_in": 27.33, + "scale_error_pct": 0.85 + }, + "3-10m": { + "pairs": 2598250, + "failed_pct": 0.58, + "median_in": 5.6, + "p90_in": 38.11, + "scale_error_pct": 0.19 + } + }, + "tape_calibration_success_pct": 100.0, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.41, + 13.24 + ], + "p90_in": [ + 14.23, + 50.64 + ] + }, + "3-10m": { + "median_in": [ + 4.68, + 23.13 + ], + "p90_in": [ + 20.29, + 68.33 + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 0.22, + "median_in": 6.09, + "p90_in": 14.47, + "scale_error_pct": 2.04 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 1.16, + "median_in": 13.82, + "p90_in": 25.9, + "scale_error_pct": 4.29 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 0.62, + "median_in": 2.53, + "p90_in": 16.06, + "scale_error_pct": 0.14 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 1.56, + "median_in": 4.48, + "p90_in": 33.38, + "scale_error_pct": 0.51 + } + }, + "tape_calibration_success_pct": 99.6, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.62, + 8.96 + ], + "p90_in": [ + 9.97, + 21.07 + ] + }, + "3-10m": { + "median_in": [ + 8.28, + 18.43 + ], + "p90_in": [ + 19.11, + 30.17 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.45, + "median_in": 5.62, + "p90_in": 14.97, + "scale_error_pct": -0.24 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 2.03, + "median_in": 13.66, + "p90_in": 30.32, + "scale_error_pct": 4.3 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.91, + "median_in": 2.37, + "p90_in": 19.5, + "scale_error_pct": 0.09 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 2.49, + "median_in": 5.29, + "p90_in": 52.31, + "scale_error_pct": 1.17 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.94, + 8.86 + ], + "p90_in": [ + 9.94, + 23.82 + ] + }, + "3-10m": { + "median_in": [ + 8.92, + 19.96 + ], + "p90_in": [ + 21.61, + 46.64 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.44, + "median_in": 7.48, + "p90_in": 20.53, + "scale_error_pct": 2.76 + }, + "3-10m": { + "pairs": 103930, + "failed_pct": 0.3, + "median_in": 13.34, + "p90_in": 34.93, + "scale_error_pct": 3.17 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.9, + "median_in": 4.24, + "p90_in": 26.56, + "scale_error_pct": 1.02 + }, + "3-10m": { + "pairs": 2598250, + "failed_pct": 0.76, + "median_in": 6.53, + "p90_in": 49.41, + "scale_error_pct": 1.21 + } + }, + "tape_calibration_success_pct": 99.5, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 4.02, + 11.23 + ], + "p90_in": [ + 13.99, + 41.33 + ] + }, + "3-10m": { + "median_in": [ + 6.23, + 17.33 + ], + "p90_in": [ + 18.09, + 58.44 + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 5.39, + "p90_in": 12.7, + "scale_error_pct": -0.13 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.01, + "median_in": 12.34, + "p90_in": 22.81, + "scale_error_pct": 2.62 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.23, + "median_in": 3.11, + "p90_in": 11.8, + "scale_error_pct": -0.02 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.24, + "median_in": 5.16, + "p90_in": 22.41, + "scale_error_pct": 0.12 + } + }, + "tape_calibration_success_pct": 99.8, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 3.59, + 8.25 + ], + "p90_in": [ + 8.97, + 15.22 + ] + }, + "3-10m": { + "median_in": [ + 7.75, + 17.26 + ], + "p90_in": [ + 16.94, + 26.14 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 90000, + "failed_pct": 0.11, + "median_in": 5.12, + "p90_in": 12.5, + "scale_error_pct": -1.59 + }, + "3-10m": { + "pairs": 90000, + "failed_pct": 0.23, + "median_in": 11.4, + "p90_in": 25.92, + "scale_error_pct": 2.6 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2250000, + "failed_pct": 0.37, + "median_in": 2.99, + "p90_in": 10.82, + "scale_error_pct": 0.24 + }, + "3-10m": { + "pairs": 2250000, + "failed_pct": 0.5, + "median_in": 5.75, + "p90_in": 26.88, + "scale_error_pct": 1.46 + } + }, + "tape_calibration_success_pct": 99.7, + "groups": 6, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.9, + 8.3 + ], + "p90_in": [ + 7.29, + 15.61 + ] + }, + "3-10m": { + "median_in": [ + 6.52, + 17.84 + ], + "p90_in": [ + 14.53, + 32.7 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 90000, + "failed_pct": 0.08, + "median_in": 6.46, + "p90_in": 14.58, + "scale_error_pct": 1.04 + }, + "3-10m": { + "pairs": 88930, + "failed_pct": 0.16, + "median_in": 7.81, + "p90_in": 18.55, + "scale_error_pct": -0.5 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2250000, + "failed_pct": 0.34, + "median_in": 4.24, + "p90_in": 16.13, + "scale_error_pct": 1.97 + }, + "3-10m": { + "pairs": 2223250, + "failed_pct": 0.43, + "median_in": 6.48, + "p90_in": 25.61, + "scale_error_pct": 0.45 + } + }, + "tape_calibration_success_pct": 99.7, + "groups": 6, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.96, + 10.21 + ], + "p90_in": [ + 11.39, + 18.89 + ] + }, + "3-10m": { + "median_in": [ + 2.71, + 15.12 + ], + "p90_in": [ + 12.85, + 23.98 + ] + } + } + } + } + }, + "MoGe-2 rescaled by triangulation, modern_assumed poses": { + "2": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 12.99, + "median_in": 3.45, + "p90_in": Infinity, + "scale_error_pct": -1.2 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 14.18, + "median_in": 8.06, + "p90_in": Infinity, + "scale_error_pct": -1.04 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 12.99, + "median_in": 2.17, + "p90_in": Infinity, + "scale_error_pct": -0.13 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 14.18, + "median_in": 4.15, + "p90_in": Infinity, + "scale_error_pct": -0.21 + } + }, + "tape_calibration_success_pct": 87.5, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 1.89, + 13.03 + ], + "p90_in": [ + 6.9, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 3.54, + 32.35 + ], + "p90_in": [ + 14.33, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 15.27, + "median_in": 3.53, + "p90_in": Infinity, + "scale_error_pct": -1.68 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 16.89, + "median_in": 10.09, + "p90_in": Infinity, + "scale_error_pct": 0.13 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 15.27, + "median_in": 2.4, + "p90_in": Infinity, + "scale_error_pct": -0.15 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 16.89, + "median_in": 5.2, + "p90_in": Infinity, + "scale_error_pct": 0.35 + } + }, + "tape_calibration_success_pct": 85.7, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 1.75, + 30.21 + ], + "p90_in": [ + 7.32, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 3.31, + 65.29 + ], + "p90_in": [ + 17.32, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 15.29, + "median_in": 5.12, + "p90_in": Infinity, + "scale_error_pct": -1.29 + }, + "3-10m": { + "pairs": 103930, + "failed_pct": 13.99, + "median_in": 9.76, + "p90_in": Infinity, + "scale_error_pct": -1.34 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 15.29, + "median_in": 3.78, + "p90_in": Infinity, + "scale_error_pct": 0.39 + }, + "3-10m": { + "pairs": 2598250, + "failed_pct": 13.99, + "median_in": 5.72, + "p90_in": Infinity, + "scale_error_pct": -0.01 + } + }, + "tape_calibration_success_pct": 85.7, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.63, + 63.81 + ], + "p90_in": [ + 11.15, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 3.99, + 89.5 + ], + "p90_in": [ + 17.47, + Infinity + ] + } + } + } + }, + "4": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 120000, + "failed_pct": 2.73, + "median_in": 2.84, + "p90_in": 14.58, + "scale_error_pct": -2.34 + }, + "3-10m": { + "pairs": 120000, + "failed_pct": 3.66, + "median_in": 5.43, + "p90_in": 35.03, + "scale_error_pct": -2.27 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 3000000, + "failed_pct": 3.23, + "median_in": 2.49, + "p90_in": 20.52, + "scale_error_pct": -0.06 + }, + "3-10m": { + "pairs": 3000000, + "failed_pct": 4.16, + "median_in": 4.61, + "p90_in": 44.74, + "scale_error_pct": -0.02 + } + }, + "tape_calibration_success_pct": 97.0, + "groups": 8, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 1.89, + 4.51 + ], + "p90_in": [ + 5.4, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 3.25, + 10.4 + ], + "p90_in": [ + 9.46, + Infinity + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 3.34, + "median_in": 3.11, + "p90_in": 19.36, + "scale_error_pct": -3.01 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 4.9, + "median_in": 5.72, + "p90_in": 69.53, + "scale_error_pct": -2.22 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 3.91, + "median_in": 2.34, + "p90_in": 27.16, + "scale_error_pct": -0.07 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 5.47, + "median_in": 5.19, + "p90_in": 62.63, + "scale_error_pct": 0.45 + } + }, + "tape_calibration_success_pct": 96.6, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.03, + 4.92 + ], + "p90_in": [ + 5.55, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 3.69, + 12.24 + ], + "p90_in": [ + 11.04, + Infinity + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 3.3, + "median_in": 3.69, + "p90_in": 31.64, + "scale_error_pct": -2.86 + }, + "3-10m": { + "pairs": 103930, + "failed_pct": 3.18, + "median_in": 6.38, + "p90_in": 59.1, + "scale_error_pct": -2.99 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 3.87, + "median_in": 3.46, + "p90_in": 34.3, + "scale_error_pct": -0.13 + }, + "3-10m": { + "pairs": 2598250, + "failed_pct": 3.76, + "median_in": 5.5, + "p90_in": 60.56, + "scale_error_pct": -0.17 + } + }, + "tape_calibration_success_pct": 96.6, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.84, + 6.97 + ], + "p90_in": [ + 7.32, + Infinity + ] + }, + "3-10m": { + "median_in": [ + 4.52, + 15.34 + ], + "p90_in": [ + 11.02, + Infinity + ] + } + } + } + }, + "8": { + "surface interior": { + "none": { + "1-3m": { + "pairs": 105000, + "failed_pct": 0.0, + "median_in": 2.45, + "p90_in": 5.64, + "scale_error_pct": -2.96 + }, + "3-10m": { + "pairs": 105000, + "failed_pct": 0.01, + "median_in": 4.99, + "p90_in": 10.81, + "scale_error_pct": -3.03 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2625000, + "failed_pct": 0.69, + "median_in": 2.09, + "p90_in": 8.94, + "scale_error_pct": -0.28 + }, + "3-10m": { + "pairs": 2625000, + "failed_pct": 0.7, + "median_in": 3.91, + "p90_in": 17.58, + "scale_error_pct": -0.41 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 7, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.06, + 2.86 + ], + "p90_in": [ + 4.63, + 6.66 + ] + }, + "3-10m": { + "median_in": [ + 3.77, + 6.34 + ], + "p90_in": [ + 7.42, + 14.09 + ] + } + } + }, + "vertical interior": { + "none": { + "1-3m": { + "pairs": 90000, + "failed_pct": 0.11, + "median_in": 2.31, + "p90_in": 4.97, + "scale_error_pct": -2.91 + }, + "3-10m": { + "pairs": 90000, + "failed_pct": 0.23, + "median_in": 4.64, + "p90_in": 12.24, + "scale_error_pct": -2.69 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2250000, + "failed_pct": 0.77, + "median_in": 1.72, + "p90_in": 7.99, + "scale_error_pct": 0.17 + }, + "3-10m": { + "pairs": 2250000, + "failed_pct": 0.9, + "median_in": 3.76, + "p90_in": 19.69, + "scale_error_pct": 0.33 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 6, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 1.68, + 2.8 + ], + "p90_in": [ + 3.77, + 6.77 + ] + }, + "3-10m": { + "median_in": [ + 3.45, + 6.32 + ], + "p90_in": [ + 6.34, + 21.25 + ] + } + } + }, + "edges": { + "none": { + "1-3m": { + "pairs": 90000, + "failed_pct": 0.08, + "median_in": 3.14, + "p90_in": 9.61, + "scale_error_pct": -3.15 + }, + "3-10m": { + "pairs": 88930, + "failed_pct": 0.16, + "median_in": 5.49, + "p90_in": 14.76, + "scale_error_pct": -3.52 + } + }, + "one_known_distance": { + "1-3m": { + "pairs": 2250000, + "failed_pct": 0.74, + "median_in": 2.66, + "p90_in": 13.5, + "scale_error_pct": 0.04 + }, + "3-10m": { + "pairs": 2223250, + "failed_pct": 0.84, + "median_in": 3.95, + "p90_in": 19.47, + "scale_error_pct": -0.5 + } + }, + "tape_calibration_success_pct": 99.3, + "groups": 6, + "noise_draws": 5, + "interval_95": { + "1-3m": { + "median_in": [ + 2.73, + 3.8 + ], + "p90_in": [ + 4.91, + 15.01 + ] + }, + "3-10m": { + "median_in": [ + 4.68, + 7.55 + ], + "p90_in": [ + 7.22, + 20.26 + ] + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/experiments/evals/results/pose_priors.md b/experiments/evals/results/pose_priors.md new file mode 100644 index 000000000..58246f74b --- /dev/null +++ b/experiments/evals/results/pose_priors.md @@ -0,0 +1,379 @@ +# Pose priors (generated by `uv run python -m evals.pose_priors score`) + +|Error in the distance between two scanned surface points|, inches, median / p90, against the laser scan. Scale error: median of predicted / true length, minus 1, per span bin. Failed pairs (no prediction, or a taped reference no scale could match) count as infinite error; 'fails' means more than 10% failed. Cohorts: surface interior (any scanned surface away from depth edges), vertical interior (walls, fences and other vertical surfaces), edges (the near side of depth edges: window frames, equipment and fence edges). + +Pose settings (assumptions, see `evals/ar_poses.py`): exact = the true poses; advio_2018 = scale 0.838, 0.943, 0.951 per group (ADVIO's ARKit against the GPS-rescaled truth), 5 cm and 0.2 degrees of noise; modern_assumed = 2% short, 1 cm, 0.1 degrees, a guess with no data. + +Model-scale cells: median [95% interval] / p90 [95% interval], inches. The point estimate pools every seed group and noise draw; the interval is a bootstrap over seed groups and, within each, noise draws (1000 replicates). 'Groups x noise draws' counts both. MapAnything rows have one noise draw: its weights are no longer on this machine. Its advio_2018 row was run on the earlier advio_2018 poses, whose scales were ARKit against ARCore (0.883, 0.936, 0.958), with the same noise as draw 0 here. + +## facade, all points, surface interior + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 8 x 1 | 11.4 [8.9, 14.6] / 30.2 [22.5, inf] | 39.4 [31.6, 49.8] / fails [93.3, inf] | -13.3% | -14.6% | 6.2 / fails (14% failed) | 19.7 / fails (16% failed) | 94% | +| mapanything@392, images only | 4 | 8 x 1 | 10.9 [8.8, 13.8] / 24.0 [19.6, 28.8] | 36.9 [30.8, 46.3] / 87.3 [67.8, 108.1] | -12.7% | -14.1% | 5.6 / 62.2 | 16.8 / fails (11% failed) | 95% | +| mapanything@392 + exact poses | 2 | 8 x 1 | 12.1 [9.0, 16.2] / 29.5 [24.2, inf] | 41.4 [31.6, 53.6] / fails [95.8, inf] | -14.2% | -15.7% | 5.7 / fails (14% failed) | 18.1 / fails (16% failed) | 94% | +| mapanything@392 + exact poses | 4 | 8 x 1 | 9.6 [7.8, 11.8] / 20.8 [18.2, 23.0] | 31.9 [27.4, 38.0] / 73.9 [58.9, 105.8] | -11.4% | -12.2% | 5.1 / fails (11% failed) | 15.5 / fails (12% failed) | 94% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 8 x 1 | 12.1 [8.8, 16.3] / 30.1 [24.4, inf] | 41.4 [31.2, 54.5] / fails [96.7, inf] | -14.1% | -15.7% | 5.9 / fails (14% failed) | 18.7 / fails (17% failed) | 94% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 8 x 1 | 10.7 [8.9, 12.9] / 21.7 [19.5, 23.9] | 35.3 [31.1, 40.7] / 76.0 [62.5, 107.0] | -12.9% | -13.4% | 5.1 / fails (11% failed) | 15.3 / fails (12% failed) | 94% | +| mapanything@392 + modern_assumed poses | 2 | 8 x 1 | 12.2 [9.2, 16.3] / 29.6 [24.4, inf] | 41.8 [32.0, 53.9] / fails [96.3, inf] | -14.3% | -15.9% | 5.7 / fails (14% failed) | 18.0 / fails (16% failed) | 94% | +| mapanything@392 + modern_assumed poses | 4 | 8 x 1 | 9.9 [8.1, 12.1] / 21.3 [18.7, 23.6] | 33.1 [28.3, 39.2] / 75.4 [59.9, 106.9] | -11.8% | -12.6% | 5.1 / fails (11% failed) | 15.4 / fails (12% failed) | 94% | +| MoGe-2 placed with exact poses | 2 | 8 x 1 | 5.9 [5.0, 7.0] / 12.0 [10.0, 14.1] | 20.4 [17.2, 23.2] / 47.7 [37.9, 56.5] | -6.2% | -7.3% | 3.0 / 12.6 | 9.8 / 43.6 | 100% | +| MoGe-2 placed with exact poses | 4 | 8 x 1 | 5.7 [5.0, 6.5] / 11.6 [10.1, 13.8] | 19.2 [16.5, 21.6] / 46.9 [38.2, 53.6] | -5.9% | -7.0% | 3.3 / 12.3 | 10.8 / 42.1 | 100% | +| MoGe-2 placed with exact poses | 8 | 8 x 1 | 5.9 [5.2, 6.7] / 11.8 [10.7, 13.0] | 19.9 [17.5, 21.9] / 46.1 [39.8, 55.8] | -6.0% | -7.2% | 3.5 / 14.4 | 11.0 / 49.0 | 98% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 8 x 1 | 2.3 [1.5, 3.5] / 8.3 [6.1, 11.4] | 8.2 [5.1, 12.0] / 36.4 [24.5, 48.2] | -1.4% | -2.4% | 3.0 / 12.5 | 10.2 / 43.5 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 8 x 1 | 1.8 [1.3, 2.4] / 7.7 [5.9, 10.4] | 5.9 [4.0, 8.3] / 32.6 [23.5, 44.7] | -0.2% | -1.0% | 3.1 / 12.4 | 10.7 / 43.2 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 8 x 1 | 1.7 [1.3, 2.0] / 7.5 [5.8, 9.4] | 5.3 [4.0, 6.7] / 30.3 [20.9, 46.3] | +0.0% | -0.6% | 2.9 / 12.2 | 9.8 / 43.4 | 100% | +| MoGe-2 placed with advio_2018 poses | 2 | 8 x 5 | 6.0 [5.1, 7.1] / 12.1 [10.2, 14.1] | 20.5 [17.1, 23.2] / 47.8 [38.2, 56.3] | -6.2% | -7.3% | 3.1 / 12.7 | 10.1 / 43.5 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 8 x 5 | 5.8 [5.1, 6.5] / 11.6 [10.1, 13.4] | 19.4 [16.6, 22.3] / 48.6 [38.4, 56.0] | -6.0% | -7.2% | 3.5 / 12.1 | 11.0 / 43.0 | 100% | +| MoGe-2 placed with advio_2018 poses | 8 | 8 x 5 | 6.0 [5.4, 6.7] / 11.7 [10.6, 12.9] | 20.1 [17.6, 22.2] / 46.0 [39.6, 55.6] | -6.2% | -7.5% | 3.6 / 14.1 | 11.3 / 47.8 | 99% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 8 x 5 | 11.9 [7.2, 16.6] / 39.6 [24.6, inf] | 39.5 [26.0, 54.5] / 128.7 [83.4, inf] | -9.2% | -11.9% | 3.4 / 22.7 | 11.5 / 86.4 | 92% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 8 x 5 | 8.7 [5.4, 11.8] / 19.6 [15.6, 22.0] | 28.7 [17.2, 38.7] / 71.4 [57.0, 80.7] | -9.8% | -11.9% | 4.2 / 16.5 | 13.4 / 52.5 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 8 x 5 | 6.5 [4.6, 9.6] / 17.3 [11.9, 20.4] | 22.5 [15.3, 33.8] / 64.2 [38.9, 81.3] | -7.1% | -8.7% | 4.1 / 16.1 | 12.6 / 51.0 | 99% | +| MoGe-2 placed with modern_assumed poses | 2 | 8 x 5 | 5.9 [5.1, 7.0] / 12.0 [10.2, 14.1] | 20.4 [17.1, 23.2] / 47.8 [38.0, 56.2] | -6.2% | -7.3% | 3.1 / 12.6 | 9.9 / 43.7 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 8 x 5 | 5.7 [5.0, 6.5] / 11.6 [10.1, 13.7] | 19.3 [16.6, 21.9] / 47.0 [38.1, 53.7] | -5.9% | -7.0% | 3.4 / 12.4 | 10.8 / 42.5 | 100% | +| MoGe-2 placed with modern_assumed poses | 8 | 8 x 5 | 5.9 [5.3, 6.7] / 11.7 [10.6, 12.8] | 20.0 [17.5, 22.0] / 46.0 [39.7, 55.5] | -6.1% | -7.3% | 3.4 / 14.2 | 10.9 / 48.2 | 98% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 8 x 5 | 5.2 [3.9, 6.8] / 15.5 [11.7, inf] | 17.1 [13.1, 23.2] / 56.3 [40.2, inf] | -3.3% | -4.9% | 3.1 / 14.6 | 10.6 / 50.8 | 97% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 8 x 5 | 3.0 [2.6, 3.6] / 10.4 [8.8, 12.3] | 10.2 [8.2, 12.9] / 39.3 [31.3, 51.1] | -2.2% | -3.2% | 3.7 / 14.3 | 12.3 / 46.8 | 99% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 8 x 5 | 2.5 [2.1, 2.9] / 8.6 [7.1, 9.8] | 8.2 [6.7, 10.1] / 34.8 [26.3, 49.9] | -1.6% | -2.7% | 3.3 / 13.2 | 10.7 / 45.4 | 99% | + +## facade, all points, vertical interior + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 8 x 1 | 11.1 [8.5, 14.6] / 24.8 [19.2, 28.8] | 37.3 [29.5, 47.5] / 95.5 [73.0, 128.6] | -13.8% | -15.0% | 5.5 / fails (11% failed) | 17.6 / fails (13% failed) | 94% | +| mapanything@392, images only | 4 | 8 x 1 | 10.7 [8.4, 13.9] / 22.2 [17.9, 25.1] | 35.2 [29.1, 44.6] / 77.9 [60.2, 95.1] | -13.1% | -14.3% | 5.0 / 22.8 | 15.3 / 83.1 | 95% | +| mapanything@392 + exact poses | 2 | 8 x 1 | 11.8 [8.6, 16.4] / 25.7 [21.9, 28.9] | 39.2 [29.8, 51.5] / 96.8 [76.0, 129.6] | -14.7% | -16.5% | 5.1 / fails (11% failed) | 16.0 / fails (13% failed) | 94% | +| mapanything@392 + exact poses | 4 | 8 x 1 | 9.5 [7.6, 11.8] / 19.0 [15.8, 20.6] | 30.2 [26.1, 35.9] / 64.6 [51.5, 89.9] | -11.7% | -12.2% | 4.7 / 22.6 | 14.0 / 90.5 | 94% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 8 x 1 | 11.8 [8.4, 16.4] / 25.8 [21.9, 28.9] | 39.2 [29.4, 52.3] / 97.1 [77.7, 130.3] | -14.7% | -16.7% | 5.2 / fails (11% failed) | 16.6 / fails (13% failed) | 94% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 8 x 1 | 10.4 [8.7, 12.8] / 20.0 [17.2, 21.2] | 33.6 [29.8, 38.6] / 66.5 [56.0, 92.7] | -13.1% | -13.5% | 4.7 / 22.7 | 13.9 / 92.7 | 94% | +| mapanything@392 + modern_assumed poses | 2 | 8 x 1 | 11.9 [8.7, 16.4] / 25.8 [21.9, 29.1] | 39.6 [30.2, 52.0] / 96.9 [76.4, 130.8] | -14.8% | -16.7% | 5.1 / fails (11% failed) | 16.0 / fails (13% failed) | 94% | +| mapanything@392 + modern_assumed poses | 4 | 8 x 1 | 9.8 [7.9, 12.1] / 19.4 [16.1, 21.1] | 31.4 [27.1, 37.0] / 66.1 [52.5, 91.2] | -12.1% | -12.6% | 4.6 / 22.5 | 13.9 / 90.0 | 94% | +| MoGe-2 placed with exact poses | 2 | 8 x 1 | 5.9 [5.0, 6.8] / 11.2 [9.6, 13.4] | 19.6 [15.3, 22.5] / 46.7 [35.6, 58.0] | -6.5% | -7.4% | 2.8 / 11.3 | 9.2 / 42.7 | 100% | +| MoGe-2 placed with exact poses | 4 | 8 x 1 | 5.6 [4.9, 6.4] / 10.9 [9.5, 12.9] | 18.3 [15.0, 21.3] / 46.5 [36.0, 56.1] | -6.2% | -7.0% | 3.1 / 11.1 | 10.3 / 41.0 | 100% | +| MoGe-2 placed with exact poses | 8 | 8 x 1 | 5.9 [5.3, 6.5] / 11.2 [10.3, 12.0] | 19.1 [15.7, 21.7] / 46.5 [38.1, 59.0] | -6.3% | -7.3% | 3.2 / 13.0 | 10.6 / 48.5 | 98% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 8 x 1 | 2.1 [1.3, 3.4] / 7.3 [5.3, 10.2] | 7.5 [4.1, 11.6] / 35.5 [21.6, 56.8] | -1.6% | -2.4% | 2.8 / 11.3 | 9.6 / 42.7 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 8 x 1 | 1.6 [1.1, 2.2] / 6.5 [5.0, 8.3] | 5.3 [3.5, 8.0] / 32.5 [20.4, 52.8] | -0.4% | -0.9% | 2.9 / 11.2 | 10.1 / 42.5 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 8 x 1 | 1.5 [1.1, 1.9] / 6.2 [4.8, 7.3] | 4.8 [3.4, 6.3] / 29.9 [18.3, 53.9] | -0.1% | -0.5% | 2.7 / 10.9 | 9.2 / 42.7 | 100% | +| MoGe-2 placed with advio_2018 poses | 2 | 8 x 5 | 5.9 [5.0, 6.9] / 11.3 [9.8, 13.5] | 19.6 [15.0, 22.5] / 46.8 [36.0, 58.1] | -6.5% | -7.4% | 2.8 / 11.3 | 9.4 / 42.6 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 8 x 5 | 5.7 [5.0, 6.4] / 10.9 [9.7, 12.7] | 18.5 [15.4, 21.9] / 48.0 [36.4, 58.5] | -6.3% | -7.3% | 3.2 / 11.0 | 10.4 / 41.9 | 100% | +| MoGe-2 placed with advio_2018 poses | 8 | 8 x 5 | 6.0 [5.4, 6.6] / 11.2 [10.2, 12.0] | 19.2 [15.7, 21.8] / 46.3 [37.9, 59.7] | -6.6% | -7.5% | 3.3 / 12.8 | 10.8 / 47.0 | 99% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 8 x 5 | 11.8 [6.8, 16.7] / 38.3 [24.2, inf] | 38.6 [25.2, 53.4] / 122.9 [80.3, inf] | -9.7% | -12.3% | 3.1 / 19.5 | 10.9 / 88.7 | 92% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 8 x 5 | 8.9 [5.4, 12.2] / 19.6 [15.6, 22.1] | 28.3 [17.0, 37.8] / 70.9 [56.2, 83.3] | -10.5% | -12.4% | 3.9 / 15.3 | 12.9 / 51.4 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 8 x 5 | 6.6 [4.4, 10.0] / 17.2 [11.3, 20.2] | 21.8 [14.6, 33.1] / 63.4 [37.2, 91.4] | -7.5% | -8.8% | 3.8 / 14.8 | 12.2 / 50.6 | 99% | +| MoGe-2 placed with modern_assumed poses | 2 | 8 x 5 | 5.9 [5.0, 6.9] / 11.3 [9.7, 13.4] | 19.6 [15.1, 22.5] / 46.8 [35.6, 58.1] | -6.5% | -7.4% | 2.8 / 11.3 | 9.3 / 42.7 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 8 x 5 | 5.6 [4.9, 6.3] / 10.9 [9.6, 12.9] | 18.4 [14.9, 21.5] / 46.7 [35.8, 57.0] | -6.2% | -7.0% | 3.1 / 11.1 | 10.3 / 41.5 | 100% | +| MoGe-2 placed with modern_assumed poses | 8 | 8 x 5 | 5.9 [5.3, 6.5] / 11.2 [10.2, 12.0] | 19.1 [15.6, 21.7] / 46.3 [38.0, 59.7] | -6.4% | -7.4% | 3.2 / 12.8 | 10.4 / 47.6 | 98% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 8 x 5 | 5.0 [3.7, 6.7] / 14.9 [10.9, inf] | 16.4 [12.4, 22.8] / 55.0 [38.2, inf] | -3.6% | -5.0% | 2.9 / 13.2 | 10.0 / 50.0 | 97% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 8 x 5 | 2.8 [2.4, 3.4] / 9.3 [7.8, 11.0] | 9.5 [7.2, 12.3] / 38.9 [28.3, 59.4] | -2.5% | -3.2% | 3.5 / 13.1 | 11.8 / 45.9 | 99% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 8 x 5 | 2.3 [1.9, 2.8] / 7.6 [6.4, 8.6] | 7.5 [5.9, 9.3] / 34.7 [24.1, 60.3] | -1.9% | -2.6% | 3.1 / 11.9 | 10.2 / 44.8 | 99% | + +## facade, all points, edges + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | -10.6% | -12.4% | inf / fails (86% failed) | inf / fails (92% failed) | 94% | +| mapanything@392, images only | 4 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | -9.1% | -13.3% | inf / fails (81% failed) | inf / fails (87% failed) | 95% | +| mapanything@392 + exact poses | 2 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | -12.7% | -13.4% | inf / fails (86% failed) | inf / fails (92% failed) | 94% | +| mapanything@392 + exact poses | 4 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | -9.9% | -11.6% | inf / fails (82% failed) | inf / fails (88% failed) | 94% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | -13.1% | -13.4% | inf / fails (86% failed) | inf / fails (92% failed) | 94% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | -10.9% | -12.2% | inf / fails (82% failed) | inf / fails (89% failed) | 94% | +| mapanything@392 + modern_assumed poses | 2 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | -12.9% | -13.4% | inf / fails (86% failed) | inf / fails (92% failed) | 94% | +| mapanything@392 + modern_assumed poses | 4 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | -10.6% | -12.3% | inf / fails (82% failed) | inf / fails (88% failed) | 94% | +| MoGe-2 placed with exact poses | 2 | 8 x 1 | 5.5 [4.3, 7.7] / 26.1 [14.8, 36.7] | 16.0 [12.5, 20.3] / 48.5 [34.9, 59.8] | -2.3% | -4.0% | 5.0 / 30.5 | 13.8 / 55.8 | 100% | +| MoGe-2 placed with exact poses | 4 | 8 x 1 | 5.1 [3.9, 7.2] / 24.6 [14.7, 33.5] | 14.8 [12.0, 18.2] / 45.4 [34.1, 55.4] | -2.3% | -3.9% | 5.2 / 28.5 | 13.7 / 52.7 | 100% | +| MoGe-2 placed with exact poses | 8 | 8 x 1 | 5.3 [3.9, 8.0] / 28.2 [14.9, 43.8] | 15.3 [11.8, 19.3] / 53.2 [35.1, 73.7] | -2.4% | -4.0% | 5.7 / 36.0 | 14.7 / 65.2 | 98% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 8 x 1 | 3.4 [2.0, 5.3] / 26.9 [14.5, 42.4] | 10.5 [7.3, 15.2] / 47.0 [32.6, 61.9] | +1.9% | +0.9% | 5.0 / 30.1 | 13.9 / 56.0 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 8 x 1 | 3.5 [2.5, 5.3] / 27.1 [15.8, 43.2] | 10.7 [8.4, 14.4] / 46.1 [34.4, 60.7] | +3.3% | +2.1% | 5.0 / 29.6 | 13.7 / 53.6 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 8 x 1 | 3.7 [2.7, 5.8] / 29.3 [15.4, 52.7] | 11.0 [9.0, 14.6] / 51.2 [31.9, 82.2] | +3.7% | +2.4% | 5.1 / 33.0 | 13.7 / 60.8 | 100% | +| MoGe-2 placed with advio_2018 poses | 2 | 8 x 5 | 5.5 [4.3, 7.7] / 26.2 [15.0, 35.2] | 16.0 [12.9, 20.0] / 48.4 [35.4, 58.5] | -2.3% | -4.0% | 5.1 / 30.5 | 13.8 / 55.9 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 8 x 5 | 5.1 [3.9, 7.1] / 25.6 [15.0, 35.4] | 15.0 [12.1, 18.1] / 45.8 [34.3, 55.2] | -1.9% | -3.9% | 5.4 / 29.9 | 13.8 / 52.9 | 100% | +| MoGe-2 placed with advio_2018 poses | 8 | 8 x 5 | 5.3 [4.0, 7.9] / 27.8 [15.3, 44.1] | 15.4 [11.9, 19.0] / 52.7 [34.9, 77.2] | -2.4% | -4.1% | 5.8 / 34.6 | 14.8 / 64.0 | 99% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 8 x 5 | 12.0 [8.6, 17.0] / 69.2 [26.8, inf] | 35.5 [25.0, 48.4] / 156.5 [74.1, inf] | -4.1% | -7.9% | 5.5 / 67.7 | 15.1 / 102.9 | 92% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 8 x 5 | 7.5 [5.2, 9.8] / 23.3 [18.3, 29.0] | 24.2 [15.8, 32.4] / 62.5 [48.8, 71.5] | -3.2% | -7.2% | 6.1 / 32.2 | 15.6 / 60.5 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 8 x 5 | 6.4 [4.8, 9.0] / 25.4 [16.6, 49.2] | 18.7 [13.4, 28.7] / 68.4 [47.0, 90.1] | -3.1% | -5.4% | 6.4 / 37.6 | 15.9 / 67.9 | 99% | +| MoGe-2 placed with modern_assumed poses | 2 | 8 x 5 | 5.6 [4.3, 7.7] / 26.1 [14.8, 35.7] | 16.0 [12.8, 20.0] / 48.5 [35.3, 58.7] | -2.2% | -4.0% | 5.0 / 30.5 | 13.8 / 55.8 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 8 x 5 | 5.1 [3.9, 7.2] / 24.6 [14.6, 33.6] | 14.8 [11.9, 18.1] / 45.5 [34.3, 55.4] | -2.2% | -3.9% | 5.2 / 28.6 | 13.7 / 52.9 | 100% | +| MoGe-2 placed with modern_assumed poses | 8 | 8 x 5 | 5.3 [3.8, 7.9] / 28.0 [15.4, 44.3] | 15.3 [11.6, 19.0] / 53.1 [35.1, 76.3] | -2.4% | -4.0% | 5.7 / 35.6 | 14.6 / 64.7 | 98% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 8 x 5 | 6.2 [4.7, 8.3] / 34.0 [16.2, inf] | 17.3 [12.9, 24.9] / 65.3 [43.3, inf] | +1.3% | -1.0% | 5.2 / 37.1 | 14.4 / 65.2 | 97% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 8 x 5 | 4.1 [2.9, 6.2] / 26.8 [15.3, 41.4] | 11.3 [8.3, 14.8] / 47.8 [34.6, 62.6] | +2.1% | +0.2% | 5.7 / 30.3 | 14.8 / 55.9 | 99% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 8 x 5 | 3.6 [2.6, 5.5] / 27.6 [15.0, 49.6] | 9.9 [7.4, 13.3] / 49.8 [30.8, 81.5] | +2.2% | +0.3% | 5.7 / 34.5 | 14.5 / 63.2 | 99% | + +## facade, within 6 m, surface interior + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 2 x 1 | 10.2 [3.3, 17.1] / fails [inf, inf] | 27.7 [26.9, inf] / fails [inf, inf] | +6.2% | +13.3% | 4.1 / fails (44% failed) | inf / fails (58% failed) | 78% | +| mapanything@392, images only | 4 | 2 x 1 | 8.4 [1.9, 14.7] / fails [inf, inf] | 24.7 [24.4, inf] / fails [inf, inf] | +4.5% | +11.6% | 4.4 / fails (42% failed) | inf / fails (54% failed) | 78% | +| mapanything@392 + exact poses | 2 | 2 x 1 | 8.3 [1.5, 13.9] / fails [inf, inf] | 20.0 [2.6, 21.3] / fails [inf, inf] | +3.8% | +9.4% | 5.5 / fails (47% failed) | inf / fails (55% failed) | 74% | +| mapanything@392 + exact poses | 4 | 2 x 1 | 3.4 [0.7, 6.4] / fails [inf, inf] | 9.3 [1.1, 9.8] / fails [inf, inf] | +1.9% | +3.2% | 3.1 / fails (40% failed) | inf / fails (51% failed) | 80% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 2 x 1 | 7.7 [0.8, 13.7] / fails [inf, inf] | 19.1 [1.1, 20.7] / fails [inf, inf] | +2.4% | +8.8% | 4.4 / fails (45% failed) | inf / fails (54% failed) | 76% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 2 x 1 | 3.9 [0.8, 7.7] / fails [inf, inf] | 12.8 [2.6, 13.2] / fails [inf, inf] | +1.4% | +5.2% | 4.2 / fails (42% failed) | inf / fails (53% failed) | 76% | +| mapanything@392 + modern_assumed poses | 2 | 2 x 1 | 7.6 [1.2, 13.4] / fails [inf, inf] | 18.8 [2.1, 20.5] / fails [inf, inf] | +3.3% | +8.5% | 6.2 / fails (48% failed) | inf / fails (55% failed) | 72% | +| mapanything@392 + modern_assumed poses | 4 | 2 x 1 | 2.7 [0.6, 5.8] / fails [inf, inf] | 7.7 [1.0, 8.5] / fails [inf, inf] | +1.6% | +2.1% | 3.0 / fails (38% failed) | 12.5 / fails (49% failed) | 80% | +| MoGe-2 placed with exact poses | 2 | 2 x 1 | 8.5 [5.8, 13.8] / 19.4 [8.9, 22.8] | 16.0 [11.8, 24.3] / 29.8 [14.7, 32.4] | +10.0% | +9.7% | 0.7 / 6.3 | 1.3 / 10.7 | 100% | +| MoGe-2 placed with exact poses | 4 | 2 x 1 | 8.3 [5.8, 13.6] / 19.4 [8.9, 22.4] | 15.6 [11.8, 23.9] / 28.4 [14.7, 30.6] | +9.8% | +9.6% | 0.7 / 6.7 | 1.3 / 11.2 | 100% | +| MoGe-2 placed with exact poses | 8 | 2 x 1 | 8.2 [5.8, 13.5] / 19.1 [8.9, 21.9] | 15.1 [11.8, 23.9] / 28.4 [14.7, 30.5] | +9.4% | +9.4% | 0.7 / 10.0 | 1.4 / 17.1 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 2 x 1 | 5.5 [2.2, 7.7] / 10.8 [8.0, 11.7] | 12.9 [2.9, 15.6] / 17.8 [7.1, 19.3] | +10.4% | +10.7% | 0.7 / 6.3 | 1.3 / 10.7 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 2 x 1 | 7.5 [6.2, 8.3] / 13.0 [12.6, 13.7] | 14.7 [8.9, 16.8] / 19.3 [14.6, 20.8] | +11.7% | +11.7% | 0.7 / 7.6 | 1.3 / 12.1 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 2 x 1 | 7.7 [6.6, 8.7] / 13.1 [12.3, 13.3] | 15.2 [9.5, 17.7] / 20.2 [14.2, 21.9] | +12.3% | +12.3% | 0.7 / 7.3 | 1.4 / 13.0 | 100% | +| MoGe-2 placed with advio_2018 poses | 2 | 2 x 5 | 8.5 [5.8, 13.8] / 19.5 [8.9, 23.1] | 16.0 [11.8, 24.3] / 29.9 [14.7, 32.8] | +10.0% | +9.8% | 0.7 / 6.5 | 1.3 / 11.0 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 2 x 5 | 8.3 [5.8, 13.6] / 19.4 [8.9, 22.4] | 15.6 [11.8, 23.8] / 28.3 [14.7, 30.7] | +9.6% | +9.6% | 0.7 / 6.6 | 1.3 / 11.4 | 100% | +| MoGe-2 placed with advio_2018 poses | 8 | 2 x 5 | 7.8 [5.8, 12.9] / 18.0 [8.9, 20.7] | 14.4 [11.8, 22.6] / 27.1 [14.7, 29.5] | +9.0% | +9.1% | 0.7 / 7.0 | 1.4 / 13.6 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 2 x 5 | 12.5 [5.4, 31.2] / fails [17.7, inf] | 25.0 [6.4, 61.1] / fails [30.4, inf] | +13.6% | +14.0% | 1.3 / fails (10% failed) | 2.2 / fails (10% failed) | 90% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 2 x 5 | 4.1 [2.7, 5.9] / 11.1 [6.4, 15.7] | 8.0 [5.8, 9.9] / 22.0 [10.6, 27.2] | +3.5% | +3.1% | 0.7 / 8.5 | 1.3 / 14.1 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 2 x 5 | 4.4 [3.4, 5.9] / 9.2 [6.2, 11.8] | 10.2 [7.9, 15.8] / 20.5 [10.8, 24.6] | +2.8% | +2.8% | 0.7 / 7.8 | 1.4 / 13.8 | 100% | +| MoGe-2 placed with modern_assumed poses | 2 | 2 x 5 | 8.5 [5.8, 13.8] / 19.4 [8.9, 22.8] | 16.0 [11.8, 24.3] / 29.8 [14.7, 32.5] | +10.0% | +9.7% | 0.7 / 6.3 | 1.3 / 10.6 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 2 x 5 | 8.3 [5.8, 13.6] / 19.4 [8.9, 22.3] | 15.6 [11.8, 23.9] / 28.4 [14.7, 30.6] | +9.7% | +9.6% | 0.7 / 6.5 | 1.3 / 11.1 | 100% | +| MoGe-2 placed with modern_assumed poses | 8 | 2 x 5 | 8.2 [5.8, 13.4] / 18.9 [8.9, 21.6] | 15.1 [11.8, 23.8] / 28.2 [14.7, 30.3] | +9.4% | +9.3% | 0.7 / 9.4 | 1.4 / 16.3 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 2 x 5 | 6.8 [2.4, 11.2] / fails [12.2, inf] | 14.9 [4.2, 22.0] / fails [19.5, inf] | +3.7% | +2.6% | 1.2 / fails (10% failed) | 2.1 / fails (10% failed) | 90% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 2 x 5 | 6.0 [4.8, 7.5] / 12.6 [10.6, 14.4] | 9.7 [6.7, 17.0] / 19.3 [12.8, 22.1] | +9.2% | +6.5% | 0.7 / 7.9 | 1.3 / 12.6 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 2 x 5 | 6.8 [5.5, 7.8] / 11.7 [11.1, 12.2] | 13.3 [7.6, 16.0] / 18.2 [12.4, 20.0] | +10.5% | +10.3% | 0.7 / 7.2 | 1.3 / 12.5 | 100% | + +## facade, within 6 m, vertical interior + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 1 x 1 | 11.8 [11.8, 11.8] / 18.6 [18.6, 18.6] | 22.9 [22.9, 22.9] / 32.8 [32.8, 32.8] | +15.2% | +15.4% | 4.0 / fails (37% failed) | 6.9 / fails (38% failed) | 68% | +| mapanything@392, images only | 4 | 1 x 1 | 9.8 [9.8, 9.8] / 16.0 [16.0, 16.0] | 21.0 [21.0, 21.0] / 28.3 [28.3, 28.3] | +13.5% | +14.3% | 3.9 / fails (22% failed) | 5.5 / fails (23% failed) | 80% | +| mapanything@392 + exact poses | 2 | 1 x 1 | 9.4 [9.4, 9.4] / 15.4 [15.4, 15.4] | 18.2 [18.2, 18.2] / 25.3 [25.3, 25.3] | +12.3% | +12.2% | 4.5 / fails (36% failed) | 7.3 / fails (38% failed) | 68% | +| mapanything@392 + exact poses | 4 | 1 x 1 | 4.5 [4.5, 4.5] / 7.7 [7.7, 7.7] | 8.8 [8.8, 8.8] / 12.2 [12.2, 12.2] | +5.9% | +5.9% | 3.0 / fails (26% failed) | 4.7 / fails (27% failed) | 76% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 1 x 1 | 9.3 [9.3, 9.3] / 15.0 [15.0, 15.0] | 17.7 [17.7, 17.7] / 24.5 [24.5, 24.5] | +12.0% | +11.8% | 4.4 / fails (36% failed) | 7.3 / fails (38% failed) | 68% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 1 x 1 | 5.0 [5.0, 5.0] / 9.5 [9.5, 9.5] | 11.5 [11.5, 11.5] / 16.1 [16.1, 16.1] | +6.7% | +7.7% | 3.9 / fails (26% failed) | 6.1 / fails (27% failed) | 76% | +| mapanything@392 + modern_assumed poses | 2 | 1 x 1 | 9.1 [9.1, 9.1] / 14.9 [14.9, 14.9] | 17.5 [17.5, 17.5] / 24.4 [24.4, 24.4] | +11.8% | +11.7% | 4.5 / fails (36% failed) | 7.4 / fails (38% failed) | 68% | +| mapanything@392 + modern_assumed poses | 4 | 1 x 1 | 3.9 [3.9, 3.9] / 7.1 [7.1, 7.1] | 7.6 [7.6, 7.6] / 11.1 [11.1, 11.1] | +5.2% | +5.1% | 3.0 / fails (26% failed) | 4.8 / fails (27% failed) | 76% | +| MoGe-2 placed with exact poses | 2 | 1 x 1 | 15.8 [15.8, 15.8] / 24.2 [24.2, 24.2] | 26.1 [26.1, 26.1] / 32.3 [32.3, 32.3] | +20.7% | +17.7% | 3.4 / 8.7 | 4.9 / 12.1 | 100% | +| MoGe-2 placed with exact poses | 4 | 1 x 1 | 15.8 [15.8, 15.8] / 23.3 [23.3, 23.3] | 25.4 [25.4, 25.4] / 31.1 [31.1, 31.1] | +20.4% | +17.3% | 3.4 / 8.8 | 5.0 / 13.1 | 100% | +| MoGe-2 placed with exact poses | 8 | 1 x 1 | 15.4 [15.4, 15.4] / 22.6 [22.6, 22.6] | 25.3 [25.3, 25.3] / 31.0 [31.0, 31.0] | +20.3% | +17.3% | 5.0 / 13.5 | 6.6 / 28.4 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 1 x 1 | 3.4 [3.4, 3.4] / 9.2 [9.2, 9.2] | 3.1 [3.1, 3.1] / 7.6 [7.6, 7.6] | +4.7% | +1.8% | 3.4 / 8.7 | 4.9 / 12.2 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 1 x 1 | 8.2 [8.2, 8.2] / 14.9 [14.9, 14.9] | 11.2 [11.2, 11.2] / 15.6 [15.6, 15.6] | +10.6% | +7.4% | 3.8 / 10.6 | 5.0 / 14.8 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 1 x 1 | 7.6 [7.6, 7.6] / 12.7 [12.7, 12.7] | 11.2 [11.2, 11.2] / 14.8 [14.8, 14.8] | +9.8% | +7.7% | 3.5 / 9.0 | 5.2 / 15.9 | 100% | +| MoGe-2 placed with advio_2018 poses | 2 | 1 x 5 | 15.8 [15.7, 16.1] / 24.4 [23.9, 25.3] | 26.0 [25.8, 26.3] / 32.6 [31.9, 33.6] | +20.7% | +17.6% | 3.5 / 9.1 | 5.0 / 12.8 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 1 x 5 | 15.7 [15.5, 15.9] / 23.3 [22.9, 23.9] | 25.0 [24.7, 25.3] / 31.2 [30.7, 31.5] | +20.3% | +16.8% | 3.4 / 8.6 | 4.7 / 13.7 | 100% | +| MoGe-2 placed with advio_2018 poses | 8 | 1 x 5 | 13.5 [13.2, 13.9] / 20.6 [20.2, 20.9] | 23.8 [23.6, 24.0] / 30.4 [30.2, 30.6] | +17.4% | +16.2% | 3.2 / 8.7 | 5.6 / 15.9 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 1 x 5 | 16.5 [3.6, 44.5] / 62.4 [17.1, 81.5] | 28.1 [3.5, 93.2] / 107.9 [28.1, 127.0] | +22.4% | +19.0% | 3.8 / 10.7 | 5.4 / 14.8 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 1 x 5 | 4.8 [3.8, 6.0] / 11.2 [8.2, 17.8] | 8.0 [6.2, 9.4] / 14.8 [12.1, 18.7] | +5.8% | +4.8% | 3.8 / 13.4 | 5.2 / 20.6 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 1 x 5 | 4.4 [3.3, 5.7] / 9.8 [8.0, 10.8] | 12.6 [9.9, 14.6] / 19.8 [17.3, 21.4] | -6.0% | -8.7% | 3.4 / 9.7 | 6.0 / 16.1 | 100% | +| MoGe-2 placed with modern_assumed poses | 2 | 1 x 5 | 15.8 [15.8, 15.8] / 24.2 [24.0, 24.3] | 26.1 [26.1, 26.2] / 32.3 [32.2, 32.5] | +20.7% | +17.7% | 3.3 / 8.6 | 4.8 / 12.0 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 1 x 5 | 15.8 [15.7, 15.8] / 23.3 [23.2, 23.4] | 25.3 [25.3, 25.4] / 31.1 [31.0, 31.1] | +20.4% | +17.2% | 3.4 / 8.5 | 4.9 / 12.9 | 100% | +| MoGe-2 placed with modern_assumed poses | 8 | 1 x 5 | 15.1 [15.1, 15.2] / 22.3 [22.2, 22.3] | 25.1 [25.1, 25.1] / 30.8 [30.8, 30.9] | +20.0% | +17.1% | 4.8 / 12.7 | 6.4 / 25.9 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 1 x 5 | 7.1 [5.4, 8.9] / 14.1 [11.3, 16.2] | 13.4 [9.4, 15.7] / 20.1 [17.5, 22.4] | +6.0% | +4.3% | 3.3 / 8.6 | 4.7 / 12.0 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 1 x 5 | 7.3 [6.3, 8.7] / 14.7 [11.6, 18.2] | 9.4 [7.9, 11.4] / 16.1 [12.2, 19.3] | +9.6% | +6.4% | 3.9 / 11.6 | 5.4 / 15.6 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 1 x 5 | 6.6 [6.1, 7.1] / 11.8 [10.8, 13.3] | 9.6 [8.8, 10.4] / 13.4 [12.4, 14.1] | +8.6% | +6.6% | 3.4 / 9.2 | 5.0 / 15.0 | 100% | + +## facade, within 6 m, edges + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 1 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +15.8% | +16.1% | inf / fails (85% failed) | inf / fails (85% failed) | 68% | +| mapanything@392, images only | 4 | 1 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +14.1% | +15.5% | inf / fails (79% failed) | inf / fails (78% failed) | 80% | +| mapanything@392 + exact poses | 2 | 1 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +12.2% | +12.7% | inf / fails (85% failed) | inf / fails (85% failed) | 68% | +| mapanything@392 + exact poses | 4 | 1 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +6.0% | +6.7% | inf / fails (80% failed) | inf / fails (78% failed) | 76% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 1 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +11.1% | +12.1% | inf / fails (85% failed) | inf / fails (85% failed) | 68% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 1 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +7.8% | +9.0% | inf / fails (80% failed) | inf / fails (78% failed) | 76% | +| mapanything@392 + modern_assumed poses | 2 | 1 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +11.4% | +12.1% | inf / fails (85% failed) | inf / fails (85% failed) | 68% | +| mapanything@392 + modern_assumed poses | 4 | 1 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +5.0% | +5.8% | inf / fails (80% failed) | inf / fails (78% failed) | 76% | +| MoGe-2 placed with exact poses | 2 | 1 x 1 | 10.2 [10.2, 10.2] / 18.6 [18.6, 18.6] | 30.9 [30.9, 30.9] / 38.1 [38.1, 38.1] | +17.3% | +16.3% | 2.4 / 6.8 | 5.7 / 16.0 | 100% | +| MoGe-2 placed with exact poses | 4 | 1 x 1 | 9.6 [9.6, 9.6] / 18.5 [18.5, 18.5] | 30.2 [30.2, 30.2] / 37.7 [37.7, 37.7] | +16.9% | +16.1% | 2.4 / 7.2 | 6.0 / 18.0 | 100% | +| MoGe-2 placed with exact poses | 8 | 1 x 1 | 8.9 [8.9, 8.9] / 17.4 [17.4, 17.4] | 30.6 [30.6, 30.6] / 37.6 [37.6, 37.6] | +16.2% | +16.5% | 2.9 / 9.4 | 7.5 / 41.8 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 1 x 1 | 1.3 [1.3, 1.3] / 7.0 [7.0, 7.0] | 1.5 [1.5, 1.5] / 4.9 [4.9, 4.9] | +1.7% | +0.6% | 2.4 / 6.8 | 5.7 / 16.1 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 1 x 1 | 4.1 [4.1, 4.1] / 10.3 [10.3, 10.3] | 11.4 [11.4, 11.4] / 16.0 [16.0, 16.0] | +7.2% | +6.2% | 2.5 / 8.1 | 5.7 / 20.0 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 1 x 1 | 4.0 [4.0, 4.0] / 9.8 [9.8, 9.8] | 12.1 [12.1, 12.1] / 16.1 [16.1, 16.1] | +7.0% | +6.7% | 2.3 / 6.8 | 7.1 / 23.0 | 100% | +| MoGe-2 placed with advio_2018 poses | 2 | 1 x 5 | 10.3 [10.1, 10.5] / 18.9 [18.5, 19.5] | 30.8 [30.6, 31.0] / 38.2 [37.9, 38.7] | +17.4% | +16.3% | 2.5 / 7.4 | 5.9 / 16.7 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 1 x 5 | 9.3 [9.1, 9.4] / 18.3 [18.0, 18.5] | 30.1 [30.0, 30.2] / 37.2 [36.9, 37.8] | +16.6% | +16.1% | 2.7 / 7.5 | 5.6 / 18.3 | 100% | +| MoGe-2 placed with advio_2018 poses | 8 | 1 x 5 | 7.0 [6.8, 7.3] / 16.6 [16.2, 17.0] | 30.7 [30.6, 30.8] / 38.7 [38.5, 38.9] | +12.9% | +16.2% | 3.6 / 7.9 | 7.5 / 21.0 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 1 x 5 | 11.5 [2.0, 33.4] / 43.0 [11.9, 59.2] | 33.4 [2.3, 114.8] / 136.8 [33.4, 159.5] | +19.1% | +17.6% | 2.7 / 8.7 | 6.1 / 18.5 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 1 x 5 | 3.9 [3.2, 4.6] / 12.1 [9.1, 14.9] | 10.5 [8.3, 12.3] / 18.1 [16.1, 20.1] | +5.8% | +4.9% | 3.2 / 11.6 | 6.5 / 26.2 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 1 x 5 | 5.3 [4.5, 5.9] / 9.3 [8.5, 9.9] | 17.9 [14.8, 20.3] / 26.1 [23.4, 27.4] | -9.0% | -9.8% | 3.6 / 8.5 | 8.0 / 21.4 | 100% | +| MoGe-2 placed with modern_assumed poses | 2 | 1 x 5 | 10.2 [10.1, 10.3] / 18.6 [18.4, 18.7] | 30.9 [30.8, 30.9] / 38.2 [38.1, 38.3] | +17.3% | +16.3% | 2.3 / 6.8 | 5.7 / 15.8 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 1 x 5 | 9.5 [9.4, 9.6] / 18.4 [18.2, 18.6] | 30.2 [30.2, 30.3] / 37.7 [37.5, 37.8] | +16.8% | +16.1% | 2.4 / 7.0 | 5.9 / 17.6 | 100% | +| MoGe-2 placed with modern_assumed poses | 8 | 1 x 5 | 8.7 [8.7, 8.8] / 17.1 [17.0, 17.2] | 30.6 [30.6, 30.6] / 37.7 [37.6, 37.8] | +15.8% | +16.5% | 2.8 / 9.0 | 7.7 / 38.0 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 1 x 5 | 5.2 [4.1, 5.8] / 10.4 [8.5, 12.0] | 16.6 [8.4, 21.3] / 25.7 [19.1, 28.6] | +4.0% | +3.0% | 2.3 / 6.8 | 5.6 / 15.7 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 1 x 5 | 3.6 [2.9, 4.5] / 10.2 [8.7, 11.6] | 9.7 [7.7, 12.2] / 16.2 [12.7, 17.9] | +6.2% | +5.2% | 2.8 / 8.5 | 6.4 / 19.9 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 1 x 5 | 3.5 [3.2, 3.7] / 9.2 [8.5, 10.1] | 10.1 [9.1, 11.3] / 14.6 [13.1, 15.5] | +5.8% | +5.6% | 2.4 / 7.2 | 6.7 / 21.1 | 100% | + +## electro, all points, surface interior + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 8 x 1 | 14.5 [10.6, 20.6] / fails [inf, inf] | 37.0 [28.5, 48.4] / fails [inf, inf] | +12.9% | +12.9% | 7.8 / fails (32% failed) | 22.5 / fails (36% failed) | 80% | +| mapanything@392, images only | 4 | 8 x 1 | 16.0 [11.8, 19.4] / fails [34.3, inf] | 40.1 [29.8, 47.8] / fails [206.0, inf] | +16.1% | +15.7% | 6.4 / fails (22% failed) | 16.9 / fails (26% failed) | 88% | +| mapanything@392 + exact poses | 2 | 8 x 1 | 5.7 [3.6, 11.4] / fails [inf, inf] | 17.3 [10.8, 29.9] / fails [inf, inf] | +3.1% | +3.1% | 7.5 / fails (32% failed) | 20.4 / fails (36% failed) | 80% | +| mapanything@392 + exact poses | 4 | 8 x 1 | 5.5 [3.8, 8.2] / fails [14.4, inf] | 14.0 [9.8, 22.4] / fails [inf, inf] | +3.6% | +2.7% | 6.2 / fails (24% failed) | 16.1 / fails (28% failed) | 86% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 8 x 1 | 5.9 [3.7, 11.9] / fails [inf, inf] | 17.9 [8.7, 35.0] / fails [inf, inf] | -0.7% | -0.8% | 7.7 / fails (33% failed) | 21.4 / fails (36% failed) | 80% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 8 x 1 | 6.5 [3.2, 10.8] / fails [17.2, inf] | 18.9 [9.4, 29.8] / fails [inf, inf] | -3.0% | -4.4% | 6.0 / fails (23% failed) | 15.5 / fails (27% failed) | 87% | +| mapanything@392 + modern_assumed poses | 2 | 8 x 1 | 5.0 [3.0, 11.7] / fails [inf, inf] | 16.5 [7.6, 31.1] / fails [inf, inf] | +1.3% | +1.1% | 7.3 / fails (32% failed) | 20.1 / fails (35% failed) | 81% | +| mapanything@392 + modern_assumed poses | 4 | 8 x 1 | 5.1 [3.0, 8.1] / fails [14.8, inf] | 13.3 [7.8, 22.9] / fails [inf, inf] | +2.0% | +1.3% | 6.2 / fails (24% failed) | 16.0 / fails (28% failed) | 86% | +| MoGe-2 placed with exact poses | 2 | 8 x 1 | 6.3 [4.0, 9.3] / 16.6 [11.3, 21.9] | 17.5 [12.9, 24.0] / 42.2 [27.1, 74.8] | +2.2% | +3.4% | 3.2 / 14.0 | 7.0 / 37.4 | 100% | +| MoGe-2 placed with exact poses | 4 | 8 x 1 | 5.9 [4.3, 8.9] / 16.8 [10.5, 23.9] | 16.5 [11.9, 23.1] / 49.8 [25.5, 82.0] | +2.5% | +3.7% | 3.2 / 15.2 | 6.9 / 39.4 | 99% | +| MoGe-2 placed with exact poses | 8 | 7 x 1 | 5.0 [3.5, 6.8] / 12.7 [10.8, 14.4] | 13.6 [10.0, 16.8] / 25.7 [22.1, 31.4] | +1.1% | +1.9% | 3.5 / 13.2 | 7.1 / 29.8 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 8 x 1 | 2.1 [1.3, 5.8] / fails [5.1, inf] | 5.2 [3.2, 17.8] / fails [10.7, inf] | +0.0% | +0.6% | 3.5 / fails (13% failed) | 8.7 / fails (13% failed) | 88% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 8 x 1 | 1.9 [1.3, 3.3] / 9.1 [5.2, 12.8] | 5.0 [3.4, 8.3] / 23.7 [9.7, 42.1] | -0.6% | -0.2% | 3.0 / 13.2 | 7.2 / 32.5 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 7 x 1 | 1.6 [1.2, 2.1] / 6.7 [4.8, 8.6] | 3.4 [2.5, 4.6] / 12.7 [7.2, 21.6] | -0.7% | -0.3% | 2.6 / 10.9 | 5.6 / 24.9 | 99% | +| MoGe-2 placed with advio_2018 poses | 2 | 8 x 5 | 6.3 [4.1, 9.3] / 16.8 [11.4, 22.0] | 17.2 [12.5, 23.6] / 41.8 [26.3, 75.2] | +1.9% | +2.8% | 3.4 / 14.8 | 7.3 / 39.2 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 8 x 5 | 6.0 [4.2, 9.2] / 17.4 [11.1, 23.9] | 16.2 [11.2, 23.9] / 49.6 [25.7, 81.5] | +1.8% | +3.1% | 3.8 / 16.2 | 8.0 / 40.7 | 99% | +| MoGe-2 placed with advio_2018 poses | 8 | 7 x 5 | 5.2 [4.0, 7.2] / 13.9 [12.0, 15.2] | 13.8 [11.0, 17.5] / 26.1 [22.6, 31.0] | +0.1% | +0.6% | 4.3 / 15.2 | 8.5 / 33.3 | 99% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 8 x 5 | 10.8 [7.8, 17.5] / fails [17.3, inf] | 29.4 [19.7, 51.0] / fails [45.2, inf] | -9.7% | -9.8% | 4.1 / fails (13% failed) | 10.3 / fails (13% failed) | 88% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 8 x 5 | 9.7 [6.3, 14.1] / 27.7 [16.5, 64.4] | 26.2 [15.8, 37.1] / 65.6 [45.8, 124.6] | -9.6% | -10.2% | 4.8 / 21.6 | 10.4 / 47.8 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 7 x 5 | 6.3 [4.5, 9.7] / 17.6 [11.5, 21.4] | 14.1 [9.8, 24.5] / 37.1 [24.1, 48.8] | -7.1% | -6.5% | 4.1 / 15.6 | 8.6 / 33.9 | 99% | +| MoGe-2 placed with modern_assumed poses | 2 | 8 x 5 | 6.2 [3.9, 9.3] / 16.6 [11.3, 21.9] | 17.4 [12.8, 24.2] / 42.1 [26.4, 75.1] | +2.1% | +3.3% | 3.2 / 14.0 | 7.0 / 37.4 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 8 x 5 | 5.9 [4.2, 9.0] / 16.8 [10.6, 23.9] | 16.4 [11.6, 23.4] / 49.7 [25.4, 81.9] | +2.4% | +3.5% | 3.2 / 15.2 | 7.0 / 39.5 | 99% | +| MoGe-2 placed with modern_assumed poses | 8 | 7 x 5 | 5.1 [3.6, 7.0] / 12.8 [11.0, 14.5] | 13.5 [10.2, 17.0] / 25.7 [22.4, 31.1] | +0.8% | +1.5% | 3.6 / 13.4 | 7.2 / 30.0 | 99% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 8 x 5 | 3.8 [2.4, 7.7] / fails [7.7, inf] | 10.3 [5.9, 24.7] / fails [18.8, inf] | -2.0% | -1.8% | 3.5 / fails (13% failed) | 8.9 / fails (13% failed) | 88% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 8 x 5 | 3.1 [2.2, 4.9] / 12.5 [6.9, inf] | 6.6 [4.1, 11.8] / 32.3 [14.0, inf] | -2.7% | -2.4% | 3.3 / 16.1 | 7.6 / 40.4 | 98% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 7 x 5 | 2.7 [2.3, 3.3] / 7.6 [5.9, 9.2] | 5.9 [4.6, 7.7] / 14.9 [10.6, 20.1] | -2.9% | -2.6% | 2.6 / 9.9 | 5.7 / 23.0 | 99% | + +## electro, all points, vertical interior + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 8 x 1 | 13.9 [8.6, 19.3] / fails [28.8, inf] | 32.6 [23.5, 41.1] / fails [inf, inf] | +12.6% | +13.2% | 6.9 / fails (29% failed) | 19.6 / fails (32% failed) | 80% | +| mapanything@392, images only | 4 | 8 x 1 | 15.0 [10.0, 19.6] / 39.8 [26.0, inf] | 33.2 [22.9, 40.3] / fails [62.5, inf] | +15.4% | +14.4% | 5.3 / fails (18% failed) | 15.2 / fails (20% failed) | 88% | +| mapanything@392 + exact poses | 2 | 8 x 1 | 4.2 [2.8, 8.5] / fails [12.6, inf] | 13.0 [7.8, 22.8] / fails [inf, inf] | +3.2% | +3.3% | 6.4 / fails (29% failed) | 17.1 / fails (32% failed) | 80% | +| mapanything@392 + exact poses | 4 | 8 x 1 | 4.5 [3.1, 7.3] / 23.6 [9.4, inf] | 12.4 [7.6, 18.6] / fails [23.2, inf] | +3.8% | +3.3% | 4.8 / fails (20% failed) | 13.3 / fails (22% failed) | 86% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 8 x 1 | 4.5 [3.1, 8.0] / fails [13.6, inf] | 13.7 [6.9, 28.3] / fails [inf, inf] | -1.1% | -1.0% | 6.5 / fails (29% failed) | 17.9 / fails (33% failed) | 80% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 8 x 1 | 5.1 [1.8, 9.3] / 24.9 [10.2, inf] | 14.5 [6.7, 25.9] / fails [26.4, inf] | -2.5% | -4.2% | 4.8 / fails (19% failed) | 12.9 / fails (21% failed) | 87% | +| mapanything@392 + modern_assumed poses | 2 | 8 x 1 | 3.7 [2.3, 8.3] / fails [11.7, inf] | 11.0 [5.6, 22.4] / fails [inf, inf] | +1.5% | +1.6% | 6.2 / fails (28% failed) | 16.8 / fails (32% failed) | 81% | +| mapanything@392 + modern_assumed poses | 4 | 8 x 1 | 3.8 [2.1, 7.2] / 22.7 [8.9, inf] | 11.3 [5.5, 18.6] / fails [21.6, inf] | +2.2% | +1.9% | 4.8 / fails (20% failed) | 13.4 / fails (22% failed) | 86% | +| MoGe-2 placed with exact poses | 2 | 8 x 1 | 6.1 [3.5, 9.0] / 16.2 [10.7, 21.2] | 17.6 [14.2, 25.0] / 45.5 [25.4, 76.1] | +1.7% | +4.7% | 2.8 / 13.4 | 7.0 / 38.5 | 100% | +| MoGe-2 placed with exact poses | 4 | 8 x 1 | 5.6 [3.5, 8.9] / 16.2 [9.5, 23.0] | 16.5 [11.7, 24.1] / 52.4 [23.3, 85.0] | +1.3% | +4.2% | 2.8 / 14.7 | 6.9 / 41.1 | 99% | +| MoGe-2 placed with exact poses | 8 | 7 x 1 | 4.8 [2.9, 6.8] / 12.2 [9.6, 14.3] | 13.3 [10.3, 16.7] / 24.3 [19.6, 28.6] | -0.5% | +2.9% | 3.3 / 13.4 | 7.8 / 30.1 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 8 x 1 | 1.6 [1.0, 4.9] / fails [3.9, inf] | 5.9 [3.2, 23.9] / fails [12.0, inf] | -0.0% | +1.4% | 3.1 / fails (13% failed) | 9.2 / fails (13% failed) | 88% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 8 x 1 | 1.7 [1.2, 2.6] / 8.7 [4.8, 11.8] | 6.2 [3.5, 10.3] / 28.1 [9.8, 44.9] | -0.6% | +0.1% | 2.7 / 12.5 | 7.6 / 32.4 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 7 x 1 | 1.2 [0.9, 1.9] / 6.3 [3.6, 10.6] | 3.5 [2.4, 5.2] / 14.8 [6.7, 25.0] | -0.6% | -0.2% | 2.2 / 10.6 | 5.8 / 25.6 | 99% | +| MoGe-2 placed with advio_2018 poses | 2 | 8 x 5 | 6.1 [3.5, 9.0] / 16.5 [10.7, 21.3] | 17.7 [14.2, 25.8] / 45.7 [25.5, 76.7] | +1.9% | +4.8% | 3.0 / 13.8 | 7.4 / 41.2 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 8 x 5 | 5.6 [3.4, 9.1] / 16.4 [10.2, 23.0] | 16.3 [11.7, 25.0] / 52.4 [24.4, 85.0] | +1.4% | +4.2% | 3.4 / 15.5 | 8.1 / 41.7 | 99% | +| MoGe-2 placed with advio_2018 poses | 8 | 7 x 5 | 5.0 [3.5, 7.2] / 13.2 [10.3, 15.1] | 13.8 [10.6, 17.8] / 25.2 [21.0, 28.8] | -0.8% | +2.8% | 3.8 / 15.2 | 8.4 / 34.5 | 99% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 8 x 5 | 10.4 [7.4, 16.6] / fails [16.1, inf] | 24.5 [15.1, 49.5] / fails [40.5, inf] | -10.0% | -7.8% | 3.6 / fails (13% failed) | 10.1 / fails (13% failed) | 88% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 8 x 5 | 9.2 [5.5, 13.9] / 27.0 [16.3, 55.9] | 26.5 [14.5, 35.2] / 64.0 [42.7, 114.8] | -9.2% | -10.0% | 4.0 / 20.0 | 10.8 / 49.8 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 7 x 5 | 6.0 [3.8, 9.8] / 17.4 [10.3, 21.4] | 13.2 [9.1, 22.7] / 35.0 [20.8, 44.9] | -7.2% | -6.5% | 3.6 / 14.6 | 7.9 / 33.8 | 99% | +| MoGe-2 placed with modern_assumed poses | 2 | 8 x 5 | 6.1 [3.4, 9.0] / 16.3 [10.6, 21.3] | 17.5 [14.1, 25.8] / 45.5 [25.4, 76.7] | +1.8% | +4.7% | 2.8 / 13.4 | 7.0 / 38.7 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 8 x 5 | 5.6 [3.4, 8.9] / 16.2 [9.6, 23.1] | 16.5 [11.7, 24.8] / 52.4 [23.9, 85.0] | +1.3% | +4.2% | 2.9 / 14.7 | 7.1 / 41.2 | 99% | +| MoGe-2 placed with modern_assumed poses | 8 | 7 x 5 | 4.9 [3.1, 7.1] / 12.3 [9.6, 14.4] | 13.4 [10.4, 17.2] / 24.4 [19.8, 28.7] | -0.7% | +2.9% | 3.4 / 13.6 | 7.7 / 30.5 | 99% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 8 x 5 | 3.0 [1.9, 7.0] / fails [6.5, inf] | 10.3 [4.6, 26.4] / fails [20.2, inf] | -2.0% | -0.5% | 3.1 / fails (13% failed) | 9.2 / fails (13% failed) | 88% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 8 x 5 | 2.8 [1.8, 4.4] / 12.4 [6.4, inf] | 6.9 [3.8, 13.1] / 35.2 [13.7, inf] | -2.7% | -2.2% | 3.0 / 15.4 | 7.8 / 40.4 | 98% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 7 x 5 | 2.4 [1.8, 3.3] / 7.4 [4.8, 9.7] | 5.6 [4.4, 7.2] / 14.9 [10.2, 21.4] | -2.9% | -2.7% | 2.3 / 9.4 | 5.6 / 22.6 | 99% | + +## electro, all points, edges + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +15.2% | +10.0% | inf / fails (83% failed) | inf / fails (87% failed) | 80% | +| mapanything@392, images only | 4 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +23.3% | +12.9% | inf / fails (71% failed) | inf / fails (79% failed) | 88% | +| mapanything@392 + exact poses | 2 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +5.4% | +5.6% | inf / fails (83% failed) | inf / fails (87% failed) | 80% | +| mapanything@392 + exact poses | 4 | 8 x 1 | fails [504.4, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +6.2% | +4.4% | inf / fails (71% failed) | inf / fails (78% failed) | 86% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +3.9% | +4.1% | inf / fails (83% failed) | inf / fails (87% failed) | 80% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +0.6% | -1.3% | inf / fails (72% failed) | inf / fails (78% failed) | 87% | +| mapanything@392 + modern_assumed poses | 2 | 8 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +3.1% | +3.3% | inf / fails (82% failed) | inf / fails (87% failed) | 81% | +| mapanything@392 + modern_assumed poses | 4 | 8 x 1 | fails [561.6, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +4.7% | +2.9% | inf / fails (72% failed) | inf / fails (78% failed) | 86% | +| MoGe-2 placed with exact poses | 2 | 8 x 1 | 7.6 [6.0, 10.8] / 30.6 [15.0, 48.0] | 15.3 [11.7, 21.4] / 44.7 [24.1, 70.6] | +5.6% | +4.3% | 4.3 / 32.6 | 7.0 / 46.4 | 100% | +| MoGe-2 placed with exact poses | 4 | 8 x 1 | 6.4 [5.3, 8.6] / 21.3 [13.8, 36.6] | 13.3 [10.4, 17.9] / 37.6 [21.9, 66.4] | +4.8% | +4.7% | 4.7 / 27.1 | 7.7 / 47.8 | 99% | +| MoGe-2 placed with exact poses | 8 | 7 x 1 | 6.5 [5.4, 7.5] / 15.6 [11.2, 18.8] | 11.1 [9.5, 12.5] / 24.1 [16.8, 28.1] | +3.8% | +3.8% | 4.8 / 16.3 | 7.3 / 28.0 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 8 x 1 | 3.3 [1.1, 24.4] / fails [11.0, inf] | 5.0 [1.7, 22.4] / fails [12.8, inf] | +0.3% | +0.1% | 5.2 / fails (13% failed) | 9.0 / fails (12% failed) | 88% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 8 x 1 | 3.0 [1.0, 6.7] / 23.0 [10.6, 56.5] | 6.2 [1.7, 9.9] / 31.5 [11.3, 62.0] | -0.1% | -0.3% | 4.5 / 25.9 | 7.5 / 40.6 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 7 x 1 | 2.3 [1.5, 3.2] / 11.3 [7.9, 15.5] | 3.4 [1.7, 5.1] / 13.7 [8.4, 18.9] | -0.9% | -0.6% | 3.5 / 15.1 | 5.5 / 24.1 | 99% | +| MoGe-2 placed with advio_2018 poses | 2 | 8 x 5 | 7.7 [6.2, 11.2] / 30.6 [15.5, 48.3] | 15.8 [11.7, 21.5] / 44.6 [24.1, 70.4] | +5.6% | +4.4% | 4.6 / 34.1 | 7.4 / 48.7 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 8 x 5 | 6.6 [5.3, 8.9] / 21.4 [14.3, 37.3] | 14.0 [10.8, 19.9] / 38.6 [22.2, 66.4] | +5.0% | +5.1% | 5.2 / 27.2 | 8.9 / 47.1 | 99% | +| MoGe-2 placed with advio_2018 poses | 8 | 7 x 5 | 6.9 [5.7, 8.1] / 15.9 [12.2, 19.0] | 11.6 [10.2, 14.2] / 25.8 [17.2, 30.4] | +3.8% | +3.7% | 4.9 / 17.7 | 8.0 / 31.4 | 99% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 8 x 5 | 11.4 [8.0, 29.0] / fails [19.0, inf] | 24.1 [16.1, 53.4] / fails [39.4, inf] | -7.8% | -9.3% | 5.7 / fails (13% failed) | 9.9 / fails (12% failed) | 88% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 8 x 5 | 10.7 [7.4, 15.6] / 30.6 [20.0, 63.8] | 23.7 [12.7, 35.1] / 66.6 [41.5, 99.6] | -8.3% | -9.1% | 5.8 / 32.6 | 10.4 / 57.6 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 7 x 5 | 6.2 [4.2, 9.5] / 19.7 [11.7, 24.5] | 12.0 [8.3, 21.4] / 38.5 [18.7, 58.4] | -5.9% | -6.1% | 4.5 / 18.2 | 7.7 / 32.1 | 99% | +| MoGe-2 placed with modern_assumed poses | 2 | 8 x 5 | 7.6 [6.2, 10.9] / 30.5 [15.2, 48.0] | 15.4 [11.7, 21.6] / 44.6 [23.9, 70.3] | +5.5% | +4.3% | 4.4 / 32.9 | 7.1 / 46.6 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 8 x 5 | 6.4 [5.2, 8.6] / 21.4 [13.8, 36.7] | 13.4 [10.6, 18.6] / 37.8 [21.3, 66.5] | +4.8% | +4.8% | 4.7 / 27.0 | 7.9 / 47.7 | 99% | +| MoGe-2 placed with modern_assumed poses | 8 | 7 x 5 | 6.6 [5.5, 7.6] / 15.6 [11.3, 18.9] | 11.2 [9.8, 13.0] / 24.3 [16.6, 28.4] | +3.7% | +3.8% | 4.7 / 16.4 | 7.3 / 28.3 | 99% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 8 x 5 | 4.8 [2.6, 24.6] / fails [11.3, inf] | 8.5 [4.6, 29.9] / fails [18.0, inf] | -0.8% | -1.3% | 5.3 / fails (13% failed) | 9.2 / fails (12% failed) | 88% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 8 x 5 | 3.9 [2.6, 7.7] / 27.3 [11.7, inf] | 7.5 [4.5, 13.3] / 42.5 [16.5, inf] | -2.3% | -2.4% | 4.8 / 34.0 | 8.0 / 55.2 | 98% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 7 x 5 | 3.7 [3.0, 4.7] / 11.6 [8.6, 14.7] | 6.1 [5.0, 8.0] / 17.5 [11.9, 23.3] | -3.1% | -3.0% | 3.5 / 14.5 | 5.6 / 23.4 | 99% | + +## electro, within 6 m, surface interior + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 8 x 1 | 14.7 [9.0, 25.2] / fails [27.1, inf] | 29.5 [18.8, 45.8] / fails [50.9, inf] | +13.9% | +14.2% | 4.7 / fails (26% failed) | 10.1 / fails (28% failed) | 84% | +| mapanything@392, images only | 4 | 8 x 1 | 15.7 [11.9, 21.3] / 69.6 [23.1, inf] | 31.5 [26.2, 42.8] / fails [42.7, inf] | +17.1% | +16.8% | 3.4 / fails (18% failed) | 7.1 / fails (20% failed) | 90% | +| mapanything@392 + exact poses | 2 | 8 x 1 | 4.4 [2.2, 13.7] / fails [10.0, inf] | 9.9 [4.4, 30.1] / fails [19.0, inf] | +3.5% | +3.4% | 4.2 / fails (26% failed) | 9.2 / fails (28% failed) | 84% | +| mapanything@392 + exact poses | 4 | 8 x 1 | 4.1 [2.3, 6.7] / fails [8.3, inf] | 7.9 [4.2, 13.0] / fails [15.2, inf] | +4.2% | +3.5% | 3.6 / fails (19% failed) | 7.9 / fails (21% failed) | 89% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 8 x 1 | 4.8 [2.9, 13.7] / fails [13.1, inf] | 11.0 [5.5, 29.1] / fails [32.1, inf] | -0.5% | -0.9% | 4.4 / fails (26% failed) | 9.7 / fails (28% failed) | 84% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 8 x 1 | 5.0 [2.3, 9.7] / fails [10.6, inf] | 11.4 [5.4, 19.4] / fails [19.9, inf] | -2.1% | -3.1% | 3.7 / fails (20% failed) | 7.9 / fails (22% failed) | 88% | +| mapanything@392 + modern_assumed poses | 2 | 8 x 1 | 4.0 [2.4, 14.1] / fails [9.0, inf] | 8.5 [4.7, 31.1] / fails [20.5, inf] | +1.7% | +1.7% | 4.3 / fails (26% failed) | 9.2 / fails (28% failed) | 84% | +| mapanything@392 + modern_assumed poses | 4 | 8 x 1 | 3.4 [1.8, 6.3] / fails [7.9, inf] | 6.8 [2.9, 12.0] / fails [14.4, inf] | +2.6% | +1.9% | 3.6 / fails (19% failed) | 8.0 / fails (21% failed) | 89% | +| MoGe-2 placed with exact poses | 2 | 8 x 1 | 6.8 [2.9, 9.4] / 15.0 [11.4, 19.2] | 14.9 [5.7, 19.8] / 28.7 [20.3, 32.3] | +2.4% | +3.8% | 2.3 / 11.3 | 4.0 / 21.1 | 100% | +| MoGe-2 placed with exact poses | 4 | 8 x 1 | 6.1 [3.7, 8.9] / 14.4 [10.4, 21.0] | 13.9 [8.4, 18.1] / 25.9 [19.4, 29.9] | +2.5% | +4.4% | 2.5 / 15.7 | 4.4 / 33.1 | 100% | +| MoGe-2 placed with exact poses | 8 | 7 x 1 | 5.4 [3.3, 8.2] / 12.5 [8.8, 14.9] | 12.4 [6.8, 17.1] / 22.6 [16.4, 25.8] | +0.7% | +3.0% | 2.8 / 10.8 | 4.3 / 21.1 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 8 x 1 | 2.1 [1.2, 8.5] / fails [3.4, inf] | 4.1 [2.4, 28.3] / fails [5.5, inf] | +0.5% | +0.4% | 2.1 / fails (13% failed) | 3.9 / fails (14% failed) | 88% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 8 x 1 | 1.6 [1.1, 2.7] / 8.9 [3.3, 34.2] | 3.3 [1.7, 6.8] / 20.5 [5.2, 74.6] | -0.2% | -0.3% | 2.0 / 13.0 | 3.8 / 27.2 | 100% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 7 x 1 | 1.3 [1.0, 1.5] / 4.0 [3.3, 4.8] | 2.2 [1.5, 3.2] / 6.2 [3.9, 8.7] | -0.6% | -0.7% | 1.8 / 7.6 | 3.2 / 14.3 | 99% | +| MoGe-2 placed with advio_2018 poses | 2 | 8 x 5 | 6.8 [3.1, 9.5] / 15.4 [11.5, 19.4] | 15.1 [7.9, 19.7] / 28.6 [20.2, 32.3] | +1.9% | +3.2% | 2.7 / 13.1 | 4.7 / 24.2 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 8 x 5 | 6.1 [3.8, 9.0] / 15.4 [10.2, 21.6] | 13.6 [8.4, 18.6] / 26.2 [19.2, 29.9] | +0.4% | +3.9% | 3.2 / 20.9 | 5.7 / 42.1 | 100% | +| MoGe-2 placed with advio_2018 poses | 8 | 7 x 5 | 5.9 [4.2, 8.7] / 14.0 [10.1, 16.8] | 13.0 [8.6, 18.2] / 24.2 [18.1, 27.4] | -1.7% | +1.8% | 4.3 / 16.7 | 7.3 / 32.3 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 8 x 5 | 10.7 [7.7, 20.1] / fails [17.3, inf] | 23.7 [15.6, 43.2] / fails [36.8, inf] | -8.7% | -8.8% | 2.9 / fails (13% failed) | 5.5 / fails (14% failed) | 88% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 8 x 5 | 10.3 [6.1, 15.2] / 30.7 [18.0, 70.3] | 22.1 [13.3, 32.5] / 61.9 [36.6, 134.0] | -9.0% | -8.8% | 4.5 / 28.5 | 7.8 / 60.1 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 7 x 5 | 6.2 [4.6, 8.6] / 14.6 [10.6, 18.7] | 13.0 [9.6, 19.6] / 27.9 [18.6, 33.6] | -7.3% | -7.4% | 4.0 / 18.5 | 6.8 / 35.8 | 100% | +| MoGe-2 placed with modern_assumed poses | 2 | 8 x 5 | 6.7 [2.7, 9.4] / 15.0 [11.2, 19.2] | 14.8 [5.6, 19.6] / 28.7 [19.9, 32.4] | +2.3% | +3.7% | 2.2 / 11.5 | 4.0 / 21.4 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 8 x 5 | 6.1 [3.6, 9.0] / 14.5 [10.0, 21.1] | 13.8 [8.3, 18.4] / 25.9 [19.1, 30.2] | +2.0% | +4.3% | 2.5 / 16.1 | 4.5 / 33.4 | 100% | +| MoGe-2 placed with modern_assumed poses | 8 | 7 x 5 | 5.4 [3.6, 8.2] / 12.7 [9.0, 15.2] | 12.3 [7.8, 17.3] / 22.8 [16.9, 26.1] | -0.1% | +2.6% | 3.1 / 11.8 | 5.2 / 22.4 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 8 x 5 | 3.5 [1.9, 13.0] / fails [6.9, inf] | 8.1 [3.5, 32.4] / fails [14.3, inf] | -1.2% | -1.0% | 2.2 / fails (13% failed) | 4.2 / fails (14% failed) | 88% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 8 x 5 | 2.8 [1.9, 4.5] / 14.6 [5.4, inf] | 5.4 [3.2, 10.4] / 35.0 [9.5, inf] | -2.3% | -2.3% | 2.5 / 20.5 | 4.6 / 44.7 | 97% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 7 x 5 | 2.5 [2.1, 2.9] / 5.6 [4.6, 6.7] | 5.0 [3.8, 6.3] / 10.8 [7.4, 14.1] | -3.0% | -3.0% | 2.1 / 8.9 | 3.9 / 17.6 | 99% | + +## electro, within 6 m, vertical interior + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 7 x 1 | 15.2 [8.4, 41.5] / fails [31.8, inf] | 29.9 [18.1, 126.6] / fails [inf, inf] | +11.9% | +11.4% | 5.4 / fails (32% failed) | 14.0 / fails (37% failed) | 82% | +| mapanything@392, images only | 4 | 7 x 1 | 16.8 [12.6, 24.2] / fails [28.6, inf] | 29.5 [24.8, 52.5] / fails [59.0, inf] | +17.2% | +16.4% | 4.5 / fails (23% failed) | 9.9 / fails (29% failed) | 88% | +| mapanything@392 + exact poses | 2 | 7 x 1 | 4.6 [2.0, 21.1] / fails [19.4, inf] | 14.6 [4.8, 92.6] / fails [inf, inf] | +3.4% | +4.2% | 4.9 / fails (32% failed) | 12.3 / fails (37% failed) | 82% | +| mapanything@392 + exact poses | 4 | 7 x 1 | 3.9 [2.1, 10.8] / fails [8.6, inf] | 7.9 [4.3, 22.5] / fails [20.2, inf] | +3.8% | +3.6% | 4.4 / fails (24% failed) | 9.8 / fails (29% failed) | 87% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 7 x 1 | 6.2 [3.0, 18.8] / fails [18.2, inf] | 13.6 [4.0, 456.3] / fails [inf, inf] | +1.4% | +3.2% | 5.0 / fails (32% failed) | 12.7 / fails (37% failed) | 81% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 7 x 1 | 6.4 [3.0, 11.5] / fails [13.5, inf] | 12.0 [6.5, 24.2] / fails [28.4, inf] | -1.5% | -2.2% | 4.5 / fails (24% failed) | 9.9 / fails (29% failed) | 87% | +| mapanything@392 + modern_assumed poses | 2 | 7 x 1 | 4.8 [1.7, 21.6] / fails [19.6, inf] | 12.1 [4.8, 109.7] / fails [inf, inf] | +1.6% | +2.7% | 4.9 / fails (32% failed) | 12.3 / fails (37% failed) | 82% | +| mapanything@392 + modern_assumed poses | 4 | 7 x 1 | 3.0 [1.4, 9.9] / fails [8.5, inf] | 6.7 [2.2, 20.6] / fails [19.1, inf] | +2.1% | +2.1% | 4.3 / fails (24% failed) | 9.8 / fails (29% failed) | 87% | +| MoGe-2 placed with exact poses | 2 | 7 x 1 | 5.2 [2.0, 8.4] / 15.3 [9.9, 19.0] | 13.3 [5.8, 20.0] / 31.6 [20.5, 40.6] | -0.1% | +2.5% | 2.4 / 12.8 | 5.1 / 32.2 | 100% | +| MoGe-2 placed with exact poses | 4 | 7 x 1 | 5.6 [2.9, 8.6] / 15.0 [9.7, 23.7] | 13.7 [8.8, 19.6] / 30.4 [21.5, 48.4] | -0.1% | +4.3% | 2.4 / 19.8 | 5.3 / 52.6 | 99% | +| MoGe-2 placed with exact poses | 8 | 6 x 1 | 4.8 [2.7, 8.3] / 12.4 [7.7, 15.6] | 11.6 [6.9, 18.2] / 25.8 [16.0, 32.6] | -1.3% | +2.7% | 2.8 / 10.0 | 5.5 / 25.6 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 7 x 1 | 1.5 [0.9, 27.4] / fails [2.3, inf] | 3.1 [2.1, 60.8] / fails [6.9, inf] | -0.4% | +0.9% | 2.3 / fails (15% failed) | 5.0 / fails (17% failed) | 86% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 7 x 1 | 1.4 [0.9, 2.6] / 10.9 [2.4, 41.2] | 2.7 [1.5, 10.3] / 53.1 [5.2, 79.6] | -0.9% | -0.2% | 1.9 / 16.2 | 4.7 / 43.4 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 6 x 1 | 1.1 [0.8, 1.4] / 2.8 [2.0, 4.4] | 2.1 [1.5, 4.0] / 8.3 [3.3, 19.0] | -0.7% | -0.5% | 1.5 / 6.6 | 3.1 / 16.2 | 99% | +| MoGe-2 placed with advio_2018 poses | 2 | 7 x 5 | 5.4 [2.2, 8.6] / 15.3 [10.3, 19.1] | 13.6 [5.8, 21.9] / 31.9 [20.9, 42.8] | -0.7% | +2.4% | 2.7 / 14.6 | 5.6 / 37.0 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 7 x 5 | 5.9 [3.2, 8.9] / 15.1 [10.2, 23.1] | 13.6 [8.6, 19.9] / 30.1 [21.4, 46.1] | -0.6% | +4.1% | 2.7 / 23.1 | 6.1 / 56.8 | 100% | +| MoGe-2 placed with advio_2018 poses | 8 | 6 x 5 | 5.5 [3.2, 8.4] / 13.0 [8.3, 16.6] | 11.2 [6.6, 18.4] / 26.7 [14.3, 32.5] | -2.3% | +2.3% | 3.5 / 13.9 | 6.5 / 34.6 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 7 x 5 | 11.2 [8.2, 30.1] / fails [18.4, inf] | 21.7 [14.2, 74.6] / fails [35.2, inf] | -9.2% | -7.4% | 3.1 / fails (15% failed) | 6.2 / fails (17% failed) | 86% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 7 x 5 | 11.2 [6.6, 16.7] / 34.1 [19.1, 67.4] | 24.1 [17.2, 35.0] / 76.7 [37.1, 143.4] | -10.5% | -10.4% | 3.6 / 30.4 | 8.8 / 67.2 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 6 x 5 | 4.9 [3.7, 6.5] / 10.8 [8.6, 13.1] | 10.0 [7.1, 12.6] / 18.4 [15.2, 26.9] | -5.6% | -5.2% | 3.1 / 15.2 | 5.9 / 35.6 | 100% | +| MoGe-2 placed with modern_assumed poses | 2 | 7 x 5 | 5.2 [2.1, 8.5] / 15.3 [10.1, 19.1] | 13.3 [5.9, 21.9] / 31.7 [20.9, 39.9] | -0.2% | +2.5% | 2.4 / 13.0 | 5.1 / 32.6 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 7 x 5 | 5.6 [2.9, 8.9] / 15.0 [9.9, 23.8] | 13.7 [8.9, 20.0] / 30.3 [21.6, 46.6] | -0.2% | +4.3% | 2.4 / 19.5 | 5.3 / 52.3 | 100% | +| MoGe-2 placed with modern_assumed poses | 8 | 6 x 5 | 5.1 [2.9, 8.3] / 12.5 [7.3, 15.6] | 11.4 [6.5, 17.8] / 25.9 [14.5, 32.7] | -1.6% | +2.6% | 3.0 / 10.8 | 5.8 / 26.9 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 7 x 5 | 3.5 [1.8, 30.2] / fails [7.3, inf] | 10.1 [3.3, 65.3] / fails [17.3, inf] | -1.7% | +0.1% | 2.4 / fails (15% failed) | 5.2 / fails (17% failed) | 86% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 7 x 5 | 3.1 [2.0, 4.9] / 19.4 [5.5, inf] | 5.7 [3.7, 12.2] / 69.5 [11.0, inf] | -3.0% | -2.2% | 2.3 / 27.2 | 5.2 / 62.6 | 97% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 6 x 5 | 2.3 [1.7, 2.8] / 5.0 [3.8, 6.8] | 4.6 [3.5, 6.3] / 12.2 [6.3, 21.2] | -2.9% | -2.7% | 1.7 / 8.0 | 3.8 / 19.7 | 99% | + +## electro, within 6 m, edges + +| Method | Views | Groups x noise draws | Model scale: 1-3 m | 3-10 m | Scale error: 1-3 m | 3-10 m | One taped distance: 1-3 m | 3-10 m | Tape calibrated | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| mapanything@392, images only | 2 | 7 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +30.1% | +29.9% | inf / fails (84% failed) | inf / fails (84% failed) | 82% | +| mapanything@392, images only | 4 | 7 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +29.9% | +25.6% | inf / fails (72% failed) | inf / fails (77% failed) | 88% | +| mapanything@392 + exact poses | 2 | 7 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +8.3% | +11.2% | inf / fails (84% failed) | inf / fails (84% failed) | 82% | +| mapanything@392 + exact poses | 4 | 7 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +9.5% | +6.2% | inf / fails (72% failed) | inf / fails (76% failed) | 87% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 2 | 7 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +4.9% | +5.4% | inf / fails (84% failed) | inf / fails (84% failed) | 81% | +| mapanything@392 + advio_2018 poses, earlier ARCore-referenced scales | 4 | 7 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +9.0% | +5.0% | inf / fails (72% failed) | inf / fails (76% failed) | 87% | +| mapanything@392 + modern_assumed poses | 2 | 7 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +7.5% | +11.0% | inf / fails (84% failed) | inf / fails (84% failed) | 82% | +| mapanything@392 + modern_assumed poses | 4 | 7 x 1 | fails [inf, inf] / fails [inf, inf] | fails [inf, inf] / fails [inf, inf] | +8.8% | +5.0% | inf / fails (72% failed) | inf / fails (76% failed) | 87% | +| MoGe-2 placed with exact poses | 2 | 7 x 1 | 8.5 [3.2, 13.1] / 33.8 [13.8, 50.6] | 15.1 [5.1, 22.7] / 49.9 [19.4, 68.7] | +1.2% | +2.4% | 3.9 / 27.3 | 5.5 / 38.1 | 100% | +| MoGe-2 placed with exact poses | 4 | 7 x 1 | 7.5 [3.9, 11.3] / 20.6 [13.6, 41.8] | 13.3 [6.2, 17.2] / 34.6 [18.0, 58.7] | +2.8% | +3.3% | 4.2 / 26.4 | 6.4 / 50.0 | 99% | +| MoGe-2 placed with exact poses | 8 | 6 x 1 | 6.4 [3.1, 10.1] / 14.6 [11.8, 19.0] | 7.8 [2.8, 15.0] / 18.5 [13.6, 25.2] | +1.2% | -0.3% | 4.0 / 15.3 | 6.1 / 25.2 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 2 | 7 x 1 | 3.0 [1.2, 60.9] / fails [7.2, inf] | 3.8 [2.1, 85.8] / fails [8.4, inf] | -0.0% | -0.6% | 3.8 / fails (15% failed) | 5.7 / fails (14% failed) | 86% | +| MoGe-2 rescaled by triangulation, exact poses | 4 | 7 x 1 | 2.1 [1.2, 6.5] / 24.9 [5.7, 67.5] | 3.3 [1.9, 10.5] / 53.7 [7.6, 84.1] | -0.7% | -1.0% | 3.2 / 23.5 | 5.1 / 41.0 | 99% | +| MoGe-2 rescaled by triangulation, exact poses | 8 | 6 x 1 | 1.6 [1.1, 2.9] / 8.2 [2.8, 13.0] | 2.2 [1.5, 5.2] / 12.3 [3.3, 15.7] | -0.8% | -1.1% | 2.4 / 11.0 | 3.6 / 17.4 | 99% | +| MoGe-2 placed with advio_2018 poses | 2 | 7 x 5 | 8.6 [3.8, 13.2] / 34.0 [14.4, 50.7] | 15.4 [4.3, 23.2] / 49.4 [20.4, 68.3] | +1.2% | +1.6% | 4.3 / 28.9 | 6.2 / 40.0 | 100% | +| MoGe-2 placed with advio_2018 poses | 4 | 7 x 5 | 7.9 [4.1, 11.4] / 20.5 [14.3, 42.2] | 13.4 [5.9, 17.6] / 35.7 [18.1, 59.4] | +2.6% | +2.9% | 4.8 / 30.6 | 7.7 / 51.5 | 100% | +| MoGe-2 placed with advio_2018 poses | 8 | 6 x 5 | 6.7 [3.4, 10.4] / 14.9 [11.9, 19.3] | 8.2 [3.0, 15.3] / 18.7 [13.1, 23.8] | +1.0% | -0.9% | 4.7 / 19.9 | 7.1 / 31.8 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 2 | 7 x 5 | 11.9 [8.3, 57.1] / fails [20.3, inf] | 22.2 [14.6, 74.9] / fails [36.2, inf] | -8.6% | -9.2% | 4.5 / fails (15% failed) | 6.8 / fails (14% failed) | 86% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 4 | 7 x 5 | 11.6 [7.9, 18.2] / 33.8 [20.8, 70.2] | 23.0 [14.7, 37.4] / 63.5 [32.5, 100.5] | -10.2% | -10.4% | 5.3 / 34.3 | 8.6 / 59.5 | 100% | +| MoGe-2 rescaled by triangulation, advio_2018 poses | 8 | 6 x 5 | 5.5 [3.8, 7.4] / 13.4 [9.1, 17.1] | 10.5 [7.8, 14.4] / 20.5 [14.3, 23.8] | -5.8% | -6.8% | 4.0 / 20.4 | 6.4 / 31.3 | 100% | +| MoGe-2 placed with modern_assumed poses | 2 | 7 x 5 | 8.5 [3.4, 13.2] / 33.8 [14.2, 50.6] | 15.1 [4.7, 23.1] / 49.5 [20.3, 68.3] | +1.2% | +2.2% | 3.9 / 27.3 | 5.6 / 38.1 | 100% | +| MoGe-2 placed with modern_assumed poses | 4 | 7 x 5 | 7.5 [4.0, 11.2] / 20.5 [14.0, 41.3] | 13.3 [6.2, 17.3] / 34.9 [18.1, 58.4] | +2.8% | +3.2% | 4.2 / 26.6 | 6.5 / 49.4 | 100% | +| MoGe-2 placed with modern_assumed poses | 8 | 6 x 5 | 6.5 [3.0, 10.2] / 14.6 [11.4, 18.9] | 7.8 [2.7, 15.1] / 18.6 [12.8, 24.0] | +1.0% | -0.5% | 4.2 / 16.1 | 6.5 / 25.6 | 100% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 2 | 7 x 5 | 5.1 [2.6, 63.8] / fails [11.2, inf] | 9.8 [4.0, 89.5] / fails [17.5, inf] | -1.3% | -1.3% | 3.8 / fails (15% failed) | 5.7 / fails (14% failed) | 86% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 4 | 7 x 5 | 3.7 [2.8, 7.0] / 31.6 [7.3, inf] | 6.4 [4.5, 15.3] / 59.1 [11.0, inf] | -2.9% | -3.0% | 3.5 / 34.3 | 5.5 / 60.6 | 97% | +| MoGe-2 rescaled by triangulation, modern_assumed poses | 8 | 6 x 5 | 3.1 [2.7, 3.8] / 9.6 [4.9, 15.0] | 5.5 [4.7, 7.5] / 14.8 [7.2, 20.3] | -3.1% | -3.5% | 2.7 / 13.5 | 4.0 / 19.5 | 99% | diff --git a/experiments/evals/tests/test_camera_geometry.py b/experiments/evals/tests/test_camera_geometry.py new file mode 100644 index 000000000..acc378395 --- /dev/null +++ b/experiments/evals/tests/test_camera_geometry.py @@ -0,0 +1,140 @@ +"""Hand-computed cases for the geometry and camera-convention code.""" + +import numpy as np +import pytest + +from evals.camera import ( + DEVICE_TO_LANDSCAPE_CAMERA, + column_major, + landscape_intrinsics_from_portrait, + landscape_pose, + relative_rotation_cv, +) +from evals.check_replay import epipolar_distances, fundamental +from evals.geometry import ( + path_length, + quat_wxyz_to_matrix, + rot_y, + rotation_angle_deg, + umeyama, + yaw_align, +) +from evals.replay import select_keyframes + + +def test_quaternion_90_about_z_maps_x_to_y(): + s = np.sqrt(0.5) + R = quat_wxyz_to_matrix(np.array([s, 0, 0, s])) + np.testing.assert_allclose(R @ [1, 0, 0], [0, 1, 0], atol=1e-12) + np.testing.assert_allclose(R @ [0, 1, 0], [-1, 0, 0], atol=1e-12) + + +def test_rotation_angle(): + assert rotation_angle_deg(np.eye(3), rot_y(30)) == pytest.approx(30) + assert rotation_angle_deg(rot_y(10), rot_y(-20)) == pytest.approx(30) + + +def test_rot_y_right_handed(): + # +90 about +y takes +z to +x. + np.testing.assert_allclose(rot_y(90) @ [0, 0, 1], [1, 0, 0], atol=1e-12) + + +def test_landscape_intrinsics_hand_computed(): + # Portrait 720 wide, OpenCV principal point (359.5, 639.5) is the exact centre; the landscape + # image is 1280 x 720 and its continuous centre is (640, 360). Focal lengths swap. + fx, fy, cx, cy = landscape_intrinsics_from_portrait(1000.0, 1010.0, 359.5, 639.5, 720) + assert (fx, fy, cx, cy) == (1010.0, 1000.0, 640.0, 360.0) + # Off-centre: portrait (OpenCV) cx 349.5 is 10 px left of centre, cy 649.5 is 10 px below. + # Landscape u = portrait v, so cx_l = 650 (10 px right); landscape v = 720 - portrait u, so + # cy_l = 720 - 350 = 370 (10 px down). A clockwise rotation would give cy_l = 350 instead. + _, _, cx2, cy2 = landscape_intrinsics_from_portrait(1000.0, 1010.0, 349.5, 649.5, 720) + assert (cx2, cy2) == (650.0, 370.0) + + +def test_landscape_camera_axes_for_upright_portrait_phone(): + # Phone upright in portrait, screen facing the user, looking along world -z: device frame is the + # world frame. The landscape image's right edge is the portrait screen's bottom (world -y) and + # its up is the portrait screen's right (world +x). The camera still looks along world -z. + T = landscape_pose(np.zeros(3), np.eye(3)) + R = T[:3, :3] + np.testing.assert_allclose(R[:, 0], [0, -1, 0]) + np.testing.assert_allclose(R[:, 1], [1, 0, 0]) + np.testing.assert_allclose(R[:, 2], [0, 0, 1]) + assert np.linalg.det(DEVICE_TO_LANDSCAPE_CAMERA) == pytest.approx(1) + + +def test_column_major_layout(): + T = np.arange(16, dtype=float).reshape(4, 4) + # Column by column: first four numbers are the first column. + assert column_major(T)[:4] == [0.0, 4.0, 8.0, 12.0] + + +def test_relative_rotation_identity_and_yaw(): + np.testing.assert_allclose(relative_rotation_cv(np.eye(3), np.eye(3)), np.eye(3)) + # Camera j is camera i turned 10 degrees left about world +y (Measure Lab frames). A point + # straight ahead of camera i (OpenCV +z) then sits to camera j's right (+x in OpenCV), since j + # turned left. Checking the full matrix pins direction and the y/z flip, not just the angle. + R = relative_rotation_cv(np.eye(3), rot_y(10)) + ahead_in_j = R @ np.array([0.0, 0.0, 1.0]) + a = np.radians(10) + np.testing.assert_allclose(ahead_in_j, [np.sin(a), 0.0, np.cos(a)], atol=1e-12) + np.testing.assert_allclose(R @ np.array([0.0, 1.0, 0.0]), [0.0, 1.0, 0.0], atol=1e-12) + + +def _kf(T: np.ndarray, intr=(500.0, 500.0, 320.0, 240.0)) -> dict: + return {"pose": column_major(T), "intrinsics": list(intr)} + + +def test_fundamental_zero_for_true_correspondence(): + # Camera a at origin, camera b 1 m to the right (+x), both looking along -z (Measure Lab frame). + Ta, Tb = np.eye(4), np.eye(4) + Tb[0, 3] = 1.0 + X = np.array([0.3, -0.2, -4.0]) # world point 4 m in front + + def project(T, X): + Xc = T[:3, :3].T @ (X - T[:3, 3]) + # Measure Lab: u = fx x/(-z) + cx, v = -fy y/(-z) + cy (continuous) -> OpenCV subtract 0.5 + u = 500 * Xc[0] / -Xc[2] + 320 - 0.5 + v = -500 * Xc[1] / -Xc[2] + 240 - 0.5 + return np.array([[u, v]]) + + F = fundamental(_kf(Ta), _kf(Tb)) + d = epipolar_distances(F, project(Ta, X), project(Tb, X)) + assert d[0] == pytest.approx(0, abs=1e-9) + # Moving the point in b off the (horizontal) epipolar line by 7 px vertically gives 7 px in b. + pb = project(Tb, X) + np.array([0.0, 7.0]) + d2 = epipolar_distances(F, project(Ta, X), pb) + assert d2[0] == pytest.approx(7, rel=1e-6) + + +def test_umeyama_recovers_similarity(): + rng = np.random.default_rng(0) + src = rng.normal(size=(20, 3)) + R = rot_y(33) + dst = 2.5 * src @ R.T + [1, 2, 3] + s, R_est, t = umeyama(src, dst, with_scale=True) + assert s == pytest.approx(2.5) + np.testing.assert_allclose(R_est, R, atol=1e-9) + np.testing.assert_allclose(t, [1, 2, 3], atol=1e-9) + + +def test_yaw_align_hand_case(): + src = np.array([[1.0, 0, 0], [0, 0, 1], [0, 1, 0], [2, 0, 2]]) + dst = src @ rot_y(-40).T + [5, 0, -1] + R, t = yaw_align(src, dst, up_axis=1) + np.testing.assert_allclose(R, rot_y(-40), atol=1e-12) + np.testing.assert_allclose(t, [5, 0, -1], atol=1e-12) + + +def test_path_length(): + p = np.array([[0, 0, 0], [3, 4, 0], [3, 4, 2]], dtype=float) + np.testing.assert_allclose(path_length(p), [0, 5, 7]) + + +def test_select_keyframes_spacing_rule(): + p = np.array([[0, 0, 0], [0.3, 0, 0], [0.5, 0, 0], [0.6, 0, 0], [1.0, 0, 0]], dtype=float) + R = np.stack([np.eye(3)] * 4 + [rot_y(0)]) + assert select_keyframes(p, R, 0.5, 15) == [0, 2, 4] + # A 20 degree turn in place is enough on its own. + R2 = np.stack([np.eye(3), rot_y(20)]) + assert select_keyframes(np.zeros((2, 3)), R2, 0.5, 15) == [0, 1] diff --git a/experiments/evals/tests/test_coverage.py b/experiments/evals/tests/test_coverage.py new file mode 100644 index 000000000..84dffb788 --- /dev/null +++ b/experiments/evals/tests/test_coverage.py @@ -0,0 +1,186 @@ +"""Hand-computed cases for the coverage eval's geometry, truth and cause bookkeeping.""" + +from collections import Counter + +import numpy as np + +from evals.coverage import ( + Wall, + app_gates, + app_pixels, + deficient_rows, + face_offsets, + in_intervals, + majority, + photo_truth, + project, + rotation_to_y, + straight_runs, + to_arkit, + two_positions, +) +from evals.eth3d import View + +CFG = { + "cellWidth": 0.1524, + "wallBandHeight": 1.9812, + "groundBandDepth": 1.2, + "maxDistance": 6.0, + "maxAngleFromNormal": np.radians(65), + "imageMargin": 0.03, + "rowsPerBand": 3, + "coveringBaseline": 0.25, +} + + +def test_rotation_to_y_levels_a_tilted_up(): + up = np.array([np.sin(np.radians(10)), np.cos(np.radians(10)), 0.0]) + R = rotation_to_y(up) + np.testing.assert_allclose(R @ up, [0, 1, 0], atol=1e-12) + np.testing.assert_allclose(R @ R.T, np.eye(3), atol=1e-12) + # A vector perpendicular to the tilt plane is untouched. + np.testing.assert_allclose(R @ [0, 0, 1.0], [0, 0, 1], atol=1e-12) + + +def _view() -> View: + # Identity world-to-camera: OpenCV camera at the origin looking along +z (y down). + K = np.array([[100.0, 0, 49.5], [0, 100.0, 29.5], [0, 0, 1]]) # integer-centred, 100 x 60 px + return View("v", None, 100, 60, K, np.eye(3), np.zeros(3)) + + +def test_arkit_pose_projects_like_the_dataset_camera(): + view = _view() + pose, intrinsics, size = to_arkit(view, np.eye(3)) + assert intrinsics == [100.0, 100.0, 50.0, 30.0] + assert size == [100.0, 60.0] + # (1, 0.5, 5) m: OpenCV pixel centre (49.5 + 20, 29.5 + 10) = (69.5, 39.5), so continuous (70, 40). + point = np.array([[1.0, 0.5, 5.0]]) + u, v, front = app_pixels(pose, intrinsics, point) + assert front[0] + np.testing.assert_allclose([u[0], v[0]], [70.0, 40.0]) + pu, pv, _, ok = project(view, point) + assert ok[0] + np.testing.assert_allclose([pu[0], pv[0]], [70.0, 40.0]) + + +def _wall() -> Wall: + # Wall face on z = 0 facing +z, ground at y = 0, meter at x = 0. + return Wall(np.array([0.0, 1.5, 0.0]), np.array([0.0, 0.0, 1.0]), 0.0, -3.0, 3.0, 0.0) + + +def test_wall_frame_matches_the_apps_axes(): + wall = _wall() + # along = cross(-outward, up) = cross((0,0,-1), (0,1,0)) = (1, 0, 0): +s is to the right facing the wall. + np.testing.assert_allclose(wall.along, [1, 0, 0]) + np.testing.assert_allclose(wall.world(np.array(2.0), np.array(1.0), 0.5), [2.0, 1.0, 0.5]) + + +def test_app_gates_range_angle_and_frame(): + # ARKit camera 2 m out at height 1 m, looking straight at the wall (-z): identity rotation. + pose = np.eye(4) + pose[:3, 3] = [0.0, 1.0, 2.0] + intrinsics, size = [100.0, 100.0, 50.0, 30.0], [100.0, 60.0] + points = np.array( + [ + [0.0, 1.0, 0.0], # dead ahead: passes all + [0.0, 1.0, -5.0], # 7 m away: out of range (still in front, still framed) + [1.2, 1.0, 0.0], # 31 degrees off the normal but at u = 50 + 60 = 110: out of frame + ] + ) + g = app_gates(pose, intrinsics, size, points, np.array([0.0, 0.0, 1.0]), CFG) + assert g["range"].tolist() == [True, False, True] + assert g["frame"].tolist() == [True, True, False] + assert g["angle"].tolist() == [True, True, True] + # A wall point 5 m to the side is 68 degrees off the normal: past the 65 degree limit. + far_side = app_gates( + pose, intrinsics, size, np.array([[5.0, 1.0, 0.0]]), np.array([0.0, 0.0, 1.0]), CFG + ) + assert not far_side["angle"][0] + + +def test_straight_runs_split_at_gaps(): + t = np.r_[np.arange(0, 2.5, 0.1), np.arange(4.0, 5.0, 0.1), np.arange(6.2, 9.0, 0.1)] + runs = straight_runs(t, gap=1.0, min_length=2.0) + # 0..2.4 and 4.0..4.9 are 1.1 m apart (split); 4.9 to 6.2 is 1.3 m (split); 4.0..4.9 is too short. + assert [(round(a, 1), round(b, 1)) for a, b in runs] == [(0.0, 2.4), (6.2, 8.9)] + + +def test_two_positions_needs_a_quarter_metre_between_photos(): + centres = np.array([[0.0, 0, 0], [0.1, 0, 0], [0.5, 0, 0]]) + saw = np.array([[True, True, False], [True, False, True], [False, False, True]]) + # Photos 0 and 1 are 0.1 m apart (not enough); 0 and 2 are 0.5 m (enough); one photo is not. + assert two_positions(saw, centres, 0.25).tolist() == [False, True, False] + + +def test_deficient_rows_replays_the_apps_greedy_record(): + centres = np.array([[0.0, 0, 0], [0.1, 0, 0], [0.4, 0, 0], [0.2, 0, 0]]) + # Row 0 seen from 0 then 2 (0.4 m apart): covered. Row 1 from 0, 1 (too close), 3 (0.2 m): not. + # Row 2 never seen. + sightings = [(0, {0, 1}), (1, {1}), (2, {0}), (3, {1})] + assert deficient_rows(sightings, centres, 3, 0.25) == [1, 2] + + +def test_majority_breaks_ties_in_listed_order(): + assert ( + majority(Counter({"range": 2, "occlusion": 2}), ("occlusion", "frame edge", "range")) + == "occlusion" + ) + assert ( + majority(Counter({"range": 3, "occlusion": 2}), ("occlusion", "frame edge", "range")) + == "range" + ) + assert majority(Counter(), ("occlusion", "range")) == "occlusion" + + +def test_in_intervals_is_inclusive(): + s = np.array([-1.0, 0.0, 0.5, 1.0, 1.5]) + assert in_intervals(s, [[0.0, 1.0]]).tolist() == [False, True, True, True, False] + + +def test_face_offsets_find_a_pilaster_but_not_a_bush(): + wall = _wall() + columns = -1.0 + 0.02 * (np.arange(100) + 0.5) # s from -1 to 1 + rng = np.random.default_rng(0) + xs, ys = np.meshgrid(np.arange(-1.2, 1.2, 0.01), np.arange(0.0, 2.0, 0.02)) + face = np.c_[xs.ravel(), ys.ravel(), np.zeros(xs.size)] + # Pilaster 0.36 m proud over s in [0.2, 0.6], full height. + on_pilaster = (face[:, 0] >= 0.2) & (face[:, 0] <= 0.6) + face[on_pilaster, 2] = 0.36 + # Bush over s in [-0.8, -0.4]: scattered points 0.1 to 0.5 m out, up to 1.2 m high. + bush = np.c_[ + rng.uniform(-0.8, -0.4, 4000), rng.uniform(0.0, 1.2, 4000), rng.uniform(0.1, 0.5, 4000) + ] + offsets = face_offsets(wall, np.r_[face, bush], columns) + pilaster_cols = (columns > 0.22) & (columns < 0.58) + np.testing.assert_allclose(offsets[pilaster_cols], 0.36, atol=1e-9) + bush_cols = (columns > -0.78) & (columns < -0.42) + assert (offsets[bush_cols] == 0).all() + assert (offsets[(columns > -0.3) & (columns < 0.1)] == 0).all() + + +def test_photo_truth_relief_allowance(): + # A sample 5 m ahead; the scan's nearest surface there is 4.7 m, 0.3 m nearer. + view = _view() + zbuf = np.full((6, 10), 4.7, np.float32) + missing = np.zeros((6, 10), bool) + point = np.array([[0.0, 0.0, 5.0]]) + args = (view, np.eye(3), zbuf, missing, point, True, 6.0) + # 4% of 5 m is 0.2 m, so a 0.2 m floor hides it and a 0.4 m allowance does not. + assert photo_truth(*args, hide_abs=0.2).hidden[0] + assert not photo_truth(*args, hide_abs=0.4).hidden[0] + assert photo_truth(*args, hide_abs=0.4).saw[0] + + +def test_laser_depth_scales_a_3_by_2_photo_to_256_by_192(): + from evals.map3d import laser_depth + + # A 600 x 400 photo (3:2); a point 4 m straight ahead lands at photo pixel (300, 200), which is + # depth pixel (128, 96) once x scales by 256/600 and y by 192/400. + K = np.array([[500.0, 0, 299.5], [0, 500.0, 199.5], [0, 0, 1]]) + view = View("v", None, 600, 400, K, np.eye(3), np.zeros(3)) + d = laser_depth(view, np.array([[0.0, 0.0, 4.0]], np.float32), np.zeros((400, 600), bool)) + assert d.shape == (192, 256) + assert d[96, 128] == 4.0 + assert (d > 0).sum() == 9 # the 3 x 3 minimum filter spreads the one return + masked = laser_depth(view, np.array([[0.0, 0.0, 4.0]], np.float32), np.ones((400, 600), bool)) + assert (masked == 0).all() # an object the scanner missed gives no measurement diff --git a/experiments/evals/tests/test_drift.py b/experiments/evals/tests/test_drift.py new file mode 100644 index 000000000..2bb2b9611 --- /dev/null +++ b/experiments/evals/tests/test_drift.py @@ -0,0 +1,118 @@ +"""Hand-computed cases for the drift metric code.""" + +import numpy as np +import pytest + +from evals.drift import ( + distance_errors, + gps_local_meters, + horizontal_path_length, + position_errors, + similarity_scale_2d, + three_cornered_hat, + tracking_failure_time, + window_pairs, + yaw_between, +) +from evals.geometry import rot_y + + +def _straight_walk(n: int, step: float) -> np.ndarray: + """Walk along +x at `step` meters per sample, y up.""" + p = np.zeros((n, 3)) + p[:, 0] = np.arange(n) * step + return p + + +def test_horizontal_path_ignores_height(): + p = np.array([[0, 0, 0], [3, 5, 4], [3, 9, 4]], dtype=float) + np.testing.assert_allclose(horizontal_path_length(p), [0, 5, 5]) + + +def test_window_pairs_hand_case(): + ref = _straight_walk(10, 0.5) # 0, 0.5, ..., 4.5 m + i, j = window_pairs(ref, 1.0, 3) # starts 0, 3, 6, 9; 1 m is exactly two samples later + np.testing.assert_array_equal(i, [0, 3, 6]) + np.testing.assert_allclose(j, [2, 5, 8]) + + +def test_window_ends_between_samples(): + ref = _straight_walk(10, 0.5) + # 0.75 m lands halfway between samples 1 and 2, not on the first sample past it. + i, j = window_pairs(ref, 0.75, 100) + np.testing.assert_allclose(j, [1.5]) + est = ref * 0.8 + np.testing.assert_allclose(distance_errors(est, ref, i, j), [-0.15]) + + +def test_distance_error_for_a_short_reading_track(): + ref = _straight_walk(31, 0.1) # 3 m + est = ref * 0.9 # reads 10 % short + i, j = window_pairs(ref, 3.0, 100) + np.testing.assert_allclose(distance_errors(est, ref, i, j), [-0.3]) + + +def test_distance_error_is_blind_to_heading(): + ref = _straight_walk(21, 0.1) + est = ref @ rot_y(40).T # same walk, different world heading + i, j = window_pairs(ref, 2.0, 100) + np.testing.assert_allclose(distance_errors(est, ref, i, j), [0.0], atol=1e-12) + + +def test_position_error_uses_start_heading(): + ref = _straight_walk(21, 0.1) + est = ref @ rot_y(40).T + # Orientations say the estimate's world is turned 40 degrees: after undoing it the walks match. + est_R = np.stack([rot_y(40)] * 21) + ref_R = np.stack([np.eye(3)] * 21) + i, j = window_pairs(ref, 2.0, 100) + np.testing.assert_allclose(position_errors(est, ref, est_R, ref_R, i, j), [0.0], atol=1e-12) + # With no heading information the same walk is off by the chord of a 40 degree turn over 2 m. + same = np.stack([np.eye(3)] * 21) + chord = 2 * 2.0 * np.sin(np.radians(20)) + np.testing.assert_allclose(position_errors(est, ref, same, same, i, j), [chord]) + + +def test_yaw_between_extracts_heading_only(): + tilt = np.array([[1, 0, 0], [0, np.cos(0.3), -np.sin(0.3)], [0, np.sin(0.3), np.cos(0.3)]]) + Y = yaw_between(tilt, rot_y(25) @ tilt) + np.testing.assert_allclose(Y, rot_y(25), atol=1e-12) + + +def test_three_cornered_hat_algebra(): + # Own variances 1, 4, 9: pairwise difference variances 5, 10, 13. + assert three_cornered_hat(5.0, 10.0, 13.0) == pytest.approx((1.0, 4.0, 9.0)) + + +def test_similarity_scale_with_reflection(): + src = np.array([[0.0, 0], [10, 0], [10, 5], [0, 5]]) + # Scale 0.8, mirrored (x-z plane vs east-north), rotated 30 degrees, shifted. + c, s = np.cos(np.radians(30)), np.sin(np.radians(30)) + R = np.array([[c, -s], [s, c]]) + dst = 0.8 * (src * [1, -1]) @ R.T + [3, -7] + scale, res = similarity_scale_2d(src, dst) + assert scale == pytest.approx(0.8) + assert res.max() == pytest.approx(0, abs=1e-9) + + +def test_gps_local_meters_one_degree_north(): + en = gps_local_meters(np.array([60.0, 61.0]), np.array([25.0, 25.0])) + assert en[1, 0] == pytest.approx(0) + assert en[1, 1] == pytest.approx(6_371_000 * np.pi / 180) + + +def test_tracking_failure_time(): + p = _straight_walk(5, 0.15) # 1.5 m/s at 10 Hz + t = np.arange(5) / 10 + assert tracking_failure_time(p, t) is None + p[3:, 0] += 2.0 # a 2 m jump between samples 2 and 3 + assert tracking_failure_time(p, t) == pytest.approx(0.2) + + +def test_position_error_with_scale_removed(): + ref = _straight_walk(21, 0.1) + est = 0.9 * ref # 10% short, same heading + R = np.stack([np.eye(3)] * 21) + i, j = window_pairs(ref, 2.0, 100) + np.testing.assert_allclose(position_errors(est, ref, R, R, i, j), [0.2]) + np.testing.assert_allclose(position_errors(est, ref, R, R, i, j, 0.9), [0.0], atol=1e-12) diff --git a/experiments/evals/tests/test_field.py b/experiments/evals/tests/test_field.py new file mode 100644 index 000000000..c4198dcb4 --- /dev/null +++ b/experiments/evals/tests/test_field.py @@ -0,0 +1,321 @@ +"""Hand-computed cases for the field-session pipeline, on a synthetic session and survey. + +Scene (ARKit world, y up): a wall in the plane z = 0 facing +z, ground at y = 0. Ground contacts +at (0, 0, 0) and (10, 0, 0), a meter on the wall at (2, 1.5, 0), a fence foot at (3, 0, 3). Two +keyframes look along -z with fx = fy = 500 and centre (320, 240): k00001 at (3, 1.5, 12) taps the +first contact and the fence foot, k00002 at (6, 1.5, 12) taps the second contact and the meter. +""" + +import hashlib +import json +import zipfile + +import cv2 +import numpy as np +import pytest + +from evals import field +from evals.field import rotated_K, upright_turns + +FT = 0.3048 +CAMERAS = {"k00001": np.array([3.0, 1.5, 12.0]), "k00002": np.array([6.0, 1.5, 12.0])} +POINTS = { + "p1": np.array([0.0, 0.0, 0.0]), + "p2": np.array([10.0, 0.0, 0.0]), + "p3": np.array([2.0, 1.5, 0.0]), + "p4": np.array([3.0, 0.0, 3.0]), +} +TAPPED_FROM = {"p1": "k00001", "p4": "k00001", "p2": "k00002", "p3": "k00002"} + + +def _pixel(X, kid): + """Continuous pixel of a world point in a keyframe (identity rotation).""" + x, y, z = X - CAMERAS[kid] + return [320 + 500 * x / -z, 240 - 500 * y / -z], -z + + +def _session(): + taps, points = [], [] + for i, (pid, X) in enumerate(POINTS.items()): + pixel, _ = _pixel(X, TAPPED_FROM[pid]) + taps.append({"id": f"t{i}", "keyframe": TAPPED_FROM[pid], "pixel": pixel, "point": pid}) + points.append({"id": pid, "kind": "ground", "position": X.tolist(), "taps": [f"t{i}"]}) + + def keyframe(kid): + pose = np.eye(4) + pose[:3, 3] = CAMERAS[kid] + return { + "id": kid, + "img": f"keyframes/{kid}.jpg", + "w": 640, + "h": 480, + "intrinsics": [500.0, 500.0, 320.0, 240.0], + "pose": pose.T.reshape(-1).tolist(), + "timestamp": 50.0, + } + + def m(mid, a, b, key, value, ref=None): + return { + "id": mid, + "time": 100.0, + "from": a, + "to": b, + "referenceWall": ref, + "values": {key: value}, + "compared": key, + "accepted": True, + } + + # The rig reads 2% short on every value. + return { + "format": "measure-lab-session", + "formatVersion": 2, + "units": {"length": "meters"}, + "session": {"id": "synthetic-field", "startedAtUptime": 40.0}, + "keyframes": [keyframe(k) for k in CAMERAS], + "taps": taps, + "points": points, + "walls": [ + {"id": "w1", "contacts": ["p1", "p2"], "cameraPosition": CAMERAS["k00001"].tolist()} + ], + "measurements": [ + m("m1", "p1", "p2", "alongWall", 10 * 0.98, "w1"), + m("m2", "p3", "w1", "heightAboveGround", 1.5 * 0.98), + m("m3", "p4", "w1", "gapToWall", 3 * 0.98), + m("m4", "p3", "p1", "straight", 2.5 * 0.98), + ], + "refusals": [], + } + + +def _depth(kid, scale): + """A keyframe's depth: true z-depth x scale in a small patch around each pixel it taps.""" + d = np.full((480, 640), np.nan) + for pid, X in POINTS.items(): + if TAPPED_FROM[pid] != kid: + continue + (u, v), z = _pixel(X, kid) + cu, cv_ = int(u - 0.5), int(v - 0.5) + d[cv_ - 2 : cv_ + 3, cu - 2 : cu + 3] = z * scale + return d + + +@pytest.fixture +def case(tmp_path, monkeypatch): + monkeypatch.setattr(field, "FIELD_DIR", tmp_path / "field") + session = _session() + folder = tmp_path / "synthetic-field" + (folder / "keyframes").mkdir(parents=True) + (folder / "session.json").write_text(json.dumps(session)) + noise = np.random.default_rng(0).integers(0, 255, (480, 640), np.uint8) + for kid in CAMERAS: + cv2.imwrite(str(folder / "keyframes" / f"{kid}.jpg"), noise) + archive = tmp_path / "session.zip" + with zipfile.ZipFile(archive, "w") as z: + for f in sorted(folder.rglob("*")): + z.write(f, f.relative_to(tmp_path)) + capture = hashlib.sha256(archive.read_bytes()).hexdigest() + # MoGe-2's output, as the runner writes it, reading 10% long (turns 0: already upright). + work = tmp_path / "field" / "work" / "synthetic-field" + (work / "moge2").mkdir(parents=True) + (work / "turns.json").write_text(json.dumps(dict.fromkeys(CAMERAS, 0))) + for kid in CAMERAS: + np.savez( + work / "moge2" / f"{kid}.npz", + depth=_depth(kid, 1.1).astype(np.float32), + valid=np.isfinite(_depth(kid, 1.1)), + intrinsics=np.array([500.0, 500.0, 319.5, 239.5]), + ) + + def measured(mid, meters): + return { + "id": mid, + "candidate": None, + "from": "a", + "to": "b", + "status": "measured", + "value_ft": round(meters / FT, 6), + "plus_minus_ft": 0.01, + "method": "tape", + "measured_by": ["s"], + } + + truth = { + "format": 1, + "unit": "ft", + "house": "synthetic", + "captures": [capture], + "scale_reference": "scale", + "candidates": [], + "measurements": [ + measured("scale", 2.5), + measured("wall-length", 10.0), + measured("meter-height", 1.5), + measured("facing", 3.0), + { + "id": "pool", + "candidate": None, + "from": "a", + "to": "b", + "status": "absent", + "method": "look", + "measured_by": ["s"], + }, + ], + "checks": [], + } + mapping = { + "format": 1, + "unit": "ft", + "pipeline": "measure-lab", + "session": "synthetic-field", + "plus_minus_ft_by_key": {"straight": 0.3}, + "measurements": { + "scale": {"session_measurement": "m4", "key": "straight"}, + "wall-length": {"session_measurement": "m1", "key": "alongWall"}, + "meter-height": {"session_measurement": "m2", "key": "heightAboveGround"}, + "facing": {"session_measurement": "m3", "key": "gapToWall"}, + "pool": "absent", + }, + } + + paths = {} + for name, doc in (("truth", truth), ("map", mapping), ("rules", {"format": 1})): + paths[name] = tmp_path / f"{name}.json" + paths[name].write_text(json.dumps(doc)) + return archive, paths, capture, tmp_path / "results" + + +def _values(doc): + return {m["id"]: m["value_ft"] for m in doc["measurements"]} + + +def test_rows_recompute_the_rigs_measurements_from_depth(case): + archive, paths, capture, out = case + report = field.score(archive, paths["truth"], paths["map"], paths["rules"], out) + native = json.loads((out / "moge2.json").read_text()) + tape = json.loads((out / "moge2-tape.json").read_text()) + tri = json.loads((out / "moge2-triangulated.json").read_text()) + # Depth 10% long, each keyframe scaling about its own camera: the first contact (from k00001 at + # x = 3) lands at x = 3 - 1.1 * 3 = -0.3, the second (from k00002 at x = 6) at 6 + 1.1 * 4 = + # 10.4, so the wall reads 10.7 m, not 11.0. The meter and the fence keep a 10% error against + # a wall that moved with them: heights and gaps scale about the camera the same way. + assert _values(native)["wall-length"] == pytest.approx(10.7 / FT, abs=1e-5) + # The taped 2.5 m reference joins taps on different keyframes; solving along the rays gives + # depth x 1/1.1, which puts every point back (to float32 depth storage, about 0.01 mm); a + # length ratio would leave the wall about 1 in off. + for k, meters in {"wall-length": 10.0, "meter-height": 1.5, "facing": 3.0}.items(): + assert _values(tape)[k] == pytest.approx(meters / FT, abs=1e-4) + # Each keyframe's only neighbour shares no texture with it, so triangulation fits nothing. + assert all(m.get("missing") == "failed" for m in tri["measurements"] if m["id"] != "pool") + # Format: capture id, no scale reference row, absent kept, no uncertainty, no decisions. + assert native["capture"] == capture and native["outcomes"] is None + assert "scale" not in _values(native) + assert {"id": "pool", "value_ft": None, "missing": "absent"} in native["measurements"] + assert all(m.get("plus_minus_ft") is None for m in native["measurements"]) + assert native["timing"]["capture_s"] == 60.0 + assert native["rules_sha256"] == hashlib.sha256(paths["rules"].read_bytes()).hexdigest() + # AR scale: the rig read 2% short on the only accepted straight span of 29 ft or more, the + # 32.8 ft wall. Its bound: 1.96 * hypot(2 in, 0.01 ft) / 32.81 ft = 1.00%, so 2% is not decided. + assert "AR scale error: -2.00% ± 1.00% (95%, 1 spans, longest 32.8 ft" in report + assert "Within 2%: cannot tell." in report + + +def test_scale_report_skips_measurements_the_rig_did_not_accept(case): + archive, paths, _, _ = case + session = field.load_session(archive.parent / "synthetic-field") + session["measurements"][0]["accepted"] = False # the wall span + lines = field.ar_scale_report( + session, json.loads(paths["truth"].read_text()), json.loads(paths["map"].read_text()), 2.0 + ) + assert any("no: the rig did not accept it" in x for x in lines) + assert lines[-1].startswith("AR scale error: not resolved") + + +def test_fit_scale_weights_long_spans(): + # Spans of 30 ft and 10 ft, each with 1 in of error: 30 ft read 1% short, 10 ft 10% short. + # Weighted by t^2: scale = (29.7*30 + 9*10) / (900 + 100) = 0.981. + est = field.fit_scale(np.array([29.7, 9.0]), np.array([30.0, 10.0]), np.full(2, 1 / 12)) + assert est.scale == pytest.approx(0.981) + assert est.bound == pytest.approx(1.96 / np.sqrt(1000 * 144)) + assert field.within(1.0, 0.5, 2.0) == "yes" + assert field.within(3.0, 0.5, 2.0) == "no" + assert field.within(1.8, 0.5, 2.0) == "cannot tell" + + +@pytest.mark.parametrize("turns", [0, 1, 2, 3]) +def test_rotated_K_follows_np_rot90(turns): + K = np.array([[500.0, 0, 300.0], [0, 510.0, 200.0], [0, 0, 1]]) + w, h = 640, 480 + img = np.zeros((h, w)) + img[150, 400] = 1 # pixel (u, v) = (400, 150) + rot = np.rot90(img, turns) + v2, u2 = np.argwhere(rot == 1)[0] + # The same ray in the turned camera: turning the image turns the camera about its axis. + x, y = (400 - 300) / 500, (150 - 200) / 510 + x2, y2 = {0: (x, y), 1: (y, -x), 2: (-x, -y), 3: (-y, x)}[turns] + Kr = rotated_K(K, w, h, turns) + assert Kr[0, 0] * x2 + Kr[0, 2] == pytest.approx(u2) + assert Kr[1, 1] * y2 + Kr[1, 2] == pytest.approx(v2) + + +def test_upright_turns_for_a_portrait_phone(): + # ARKit identity pose: the landscape image is already upright (0 turns). Rolled a quarter turn + # so world up points to image right, one counter-clockwise turn (1) puts it on top; rolled the + # other way, as a phone held upright in portrait is, one clockwise turn (3) does. + T = np.eye(4) + T[:3, :3] = np.diag([1.0, -1.0, -1.0]) # OpenCV axes of the ARKit identity pose + assert upright_turns(T) == 0 + roll = np.array([[0.0, -1, 0], [1, 0, 0], [0, 0, 1]]) # camera +x now points world up + T[:3, :3] = roll @ np.diag([1.0, -1.0, -1.0]) + assert upright_turns(T) == 1 + T[:3, :3] = roll.T @ np.diag([1.0, -1.0, -1.0]) + assert upright_turns(T) == 3 + + +@pytest.mark.parametrize( + ("text", "feet"), + [("30 0 1/8", 30.010417), ("4 11", 4.916667), ("12", 12.0), ("0", 0.0), ("2 3/4", 2.0625)], +) +def test_parse_tape_reads_feet_inches_fraction(text, feet): + # "2 3/4" is 2 ft and 3/4 in. + assert field.parse_tape(text) == pytest.approx(feet, abs=1e-6) + + +@pytest.mark.parametrize("text", ["30 12", "FILL ft in", "", "3 4 1/2 x"]) +def test_parse_tape_refuses_what_it_cannot_read(text): + with pytest.raises(ValueError): + field.parse_tape(text) + + +def test_stamp_fills_in_capture_session_and_feet(case, tmp_path): + archive, paths, capture, out = case + survey = json.loads(paths["truth"].read_text()) + survey["captures"] = [] + survey["measurements"][1]["value_ft"] = "32 9 3/4" + paths["truth"].write_text(json.dumps(survey)) + mapping = json.loads(paths["map"].read_text()) + mapping["session"] = "placeholder" + paths["map"].write_text(json.dumps(mapping)) + stamped = field.stamp(archive, paths["truth"], paths["map"], out) + survey = json.loads(paths["truth"].read_text()) + assert survey["captures"] == [capture] + assert survey["measurements"][1]["value_ft"] == pytest.approx(32 + 9.75 / 12, abs=1e-6) + assert json.loads(stamped.read_text())["session"] == "synthetic-field" + # Idempotent: a second stamp adds nothing. + field.stamp(archive, paths["truth"], paths["map"], out) + assert json.loads(paths["truth"].read_text())["captures"] == [capture] + + +@pytest.mark.parametrize("text", ["30 2 5", "30 1/4 2", "1 1/0", "1/2", "30 -2"]) +def test_parse_tape_refuses_partial_or_misordered_readings(text): + # "30 2 5" would drop the 5; "30 1/4 2" would read the fraction as inches then drop it. + with pytest.raises(ValueError): + field.parse_tape(text) + + +@pytest.mark.parametrize("bad", ["../escape", "/abs", "..", "a/b", "", "sp ace"]) +def test_safe_id_refuses_path_components(bad): + with pytest.raises(ValueError): + field.safe_id(bad, "session id") + assert field.safe_id("synthetic-field_1.2", "session id") == "synthetic-field_1.2" diff --git a/experiments/evals/tests/test_fixed_sets.py b/experiments/evals/tests/test_fixed_sets.py new file mode 100644 index 000000000..2f9f91e9e --- /dev/null +++ b/experiments/evals/tests/test_fixed_sets.py @@ -0,0 +1,86 @@ +"""Hand-computed cases for AR-like pose degradation, normals, and the fixed-set prediction logic.""" + +from dataclasses import dataclass + +import numpy as np +import pytest + +from evals.ar_poses import PoseError, degrade, small_rotation +from evals.eth3d import point_normals +from evals.geometry import rotation_angle_deg +from evals.recon import predict + + +def test_degrade_scales_offsets_from_first_camera(): + T = [np.eye(4), np.eye(4)] + T[1][:3, 3] = [2.0, 0.0, 4.0] + no_noise = PoseError((0.9,), 0.0, 0.0, 2.0) + out = degrade(T, 0.9, no_noise, np.random.default_rng(0)) + np.testing.assert_allclose(out[0][:3, 3], [0, 0, 0]) + np.testing.assert_allclose(out[1][:3, 3], [1.8, 0.0, 3.6]) + np.testing.assert_allclose(out[1][:3, :3], np.eye(3)) + + +def test_small_rotation_angle(): + R = small_rotation(np.radians([0.0, 0.0, 30.0])) + assert rotation_angle_deg(np.eye(3), R) == pytest.approx(30.0) + np.testing.assert_allclose(R @ [1, 0, 0], [np.cos(np.pi / 6), np.sin(np.pi / 6), 0], atol=1e-12) + + +def test_point_normals_of_a_plane(): + xs, ys = np.meshgrid(np.arange(10.0), np.arange(10.0)) + plane = np.c_[xs.ravel(), ys.ravel(), np.zeros(100)] * 0.1 + n = point_normals(plane, k=8) + np.testing.assert_allclose(np.abs(n[:, 2]), 1.0, atol=1e-9) + + +@dataclass +class _View: + center: np.ndarray + + +class _Scene: + """Two cameras on the z axis, about 2 m and 10 m from three points at z = 5 m.""" + + def __init__(self): + self.cands = np.array([[0.0, 0, 5], [1.0, 0, 5], [2.0, 0, 5]]) + self.views = {"a": _View(np.array([0.0, 0, 3])), "b": _View(np.array([0.0, 0, -5]))} + # Both views see points 0 and 1; only b sees point 2. uv is unused by the stub depth below. + self._vis = { + "a": (np.array([0, 1]), np.zeros((2, 2))), + "b": (np.array([0, 1, 2]), np.zeros((3, 2))), + } + + def visible(self, name): + index, uv = self._vis[name] + return index, uv, np.zeros(len(index), bool) + + +@dataclass +class _EvalSet: + index: np.ndarray + gt: np.ndarray + + +def test_predict_filters_each_view_by_its_own_range(monkeypatch): + scene = _Scene() + ev = _EvalSet(index=np.array([0, 1, 2]), gt=scene.cands) + # Each view "predicts" the true point plus its own offset: a +1 m in x, b +3 m in x. + offsets = {"a": np.array([1.0, 0, 0]), "b": np.array([3.0, 0, 0])} + + def fake_camera_points(depth, K, uv): # returns camera-frame points = world (identity pose) + return depth[: len(uv)] + + monkeypatch.setattr("evals.recon.camera_points", fake_camera_points) + + def per_view(name): + index, _, _ = scene.visible(name) + return scene.cands[index] + offsets[name], np.eye(3), np.eye(4), False + + # No range limit: points 0 and 1 average a and b (+2 m), point 2 only b (+3 m). + pts = predict(scene, ev, ["a", "b"], per_view, None) + np.testing.assert_allclose(pts.r[:, 0] - scene.cands[:, 0], [2.0, 2.0, 3.0]) + # Within 6 m: camera b is 10 m from every point, so only a contributes; point 2 has none. + pts = predict(scene, ev, ["a", "b"], per_view, 6.0) + np.testing.assert_allclose(pts.r[:2, 0] - scene.cands[:2, 0], [1.0, 1.0]) + assert np.isnan(pts.r[2]).all() diff --git a/experiments/evals/tests/test_modern_arkit.py b/experiments/evals/tests/test_modern_arkit.py new file mode 100644 index 000000000..dbb566480 --- /dev/null +++ b/experiments/evals/tests/test_modern_arkit.py @@ -0,0 +1,67 @@ +"""Hand-computed cases for the modern-iPhone ARKit scale measurement.""" + +import numpy as np +import pytest + +from evals.geometry import rot_y +from evals.modern_arkit import level_frame, site_spread, walk_errors + + +def test_level_frame_finds_the_vertical_of_a_tilted_walk(): + # A walk on the plane z = 0.5 x (tilted), with the plane normal (-0.5, 0, 1) / |.|. + xs, ys = np.meshgrid(np.linspace(0, 20, 21), np.linspace(0, 10, 11)) + pts = np.c_[xs.ravel(), ys.ravel(), 0.5 * xs.ravel()] + normal = np.array([-0.5, 0.0, 1.0]) / np.hypot(0.5, 1.0) + assert abs(level_frame(pts)[2] @ normal) == pytest.approx(1.0) + + +def test_walk_errors_of_a_tracker_reading_two_percent_short(): + truth = np.c_[np.arange(0, 20.0, 0.25), np.zeros(80), np.zeros(80)] # 20 m along x, y up + ark = 0.98 * truth @ rot_y(70).T # 2% short, in its own world heading + out = walk_errors(ark, truth, np.zeros(len(truth))) + for ft, w in out.items(): + assert len(w["truth"]) > 0 + np.testing.assert_allclose(w["truth"], ft * 0.3048, rtol=1e-12) + np.testing.assert_allclose(w["ark"] / w["truth"], 0.98, rtol=1e-12) + + +def test_turn_and_heading(): + from evals.modern_arkit import heading, turn + + v = np.array([[0.0, 0.0, 1.0]]) # heading 0 (along +z) + t = turn(v, np.array([np.pi / 2])) + np.testing.assert_allclose(t, [[1.0, 0.0, 0.0]], atol=1e-12) # heading +90 degrees (along +x) + assert heading(t)[0] == pytest.approx(np.pi / 2) + + +def test_align_headings_recovers_a_mirrored_world(): + from evals.modern_arkit import align_headings + + # Truth walks along +x looking along +x. ARKit's world is the truth's mirrored in z (Unity's + # left-handed axes) and turned 40 degrees; its identity quaternion looks along +z. + truth = np.c_[np.arange(10.0), np.zeros(10), np.zeros(10)] + truth_fwd = np.tile([1.0, 0.0, 0.0], (10, 1)) + R = rot_y(40) + ark = (truth @ R.T) * np.array([1.0, 1.0, -1.0]) + # Camera forward in ARKit's (mirrored) world: +x turned the same way, then mirrored. + fwd = (R @ np.array([1.0, 0.0, 0.0])) * np.array([1.0, 1.0, -1.0]) + # A quaternion (w, x, y, z) whose rotation takes +z to `fwd`: a turn about y. + ang = np.arctan2(fwd[0], fwd[2]) + q = np.tile([np.cos(ang / 2), 0.0, np.sin(ang / 2), 0.0], (10, 1)) + p, delta, spread = align_headings(ark, q, truth, truth_fwd) + assert spread == pytest.approx(0.0, abs=1e-6) + turned = np.c_[ + p[:, 0] * np.cos(delta) + p[:, 2] * np.sin(delta), + p[:, 1], + p[:, 2] * np.cos(delta) - p[:, 0] * np.sin(delta), + ] + np.testing.assert_allclose(turned - turned[0], truth - truth[0], atol=1e-9) + + +def test_site_spread_leaves_out_doubtful_walks(): + # 1.00, 1.02, 1.04 have sample SD 0.02; the 0.60 walk is beyond OUTLIER and ignored. + sd, lo, hi = site_spread(np.array([1.00, 1.02, 1.04, 0.60])) + assert sd == pytest.approx(0.02) + assert 0.0 <= lo <= sd <= hi + with pytest.raises(ValueError): + site_spread(np.array([1.0, 1.01, 0.5])) diff --git a/experiments/evals/tests/test_pairs.py b/experiments/evals/tests/test_pairs.py new file mode 100644 index 000000000..72b0df13f --- /dev/null +++ b/experiments/evals/tests/test_pairs.py @@ -0,0 +1,167 @@ +"""Hand-computed cases for the point-pair metric.""" + +import numpy as np +import pytest + +from evals.frames import normals_from_depth +from evals.pairs import ( + INCH, + Points, + evaluate_fixed, + length_errors, + pool, + sample_pairs, + scale_for_known_distance, + summarize, +) +from evals.triangulate import sample_depth + + +def _line_points(xs, pred_scale=1.0, c=None): + gt = np.array([[x, 0.0, 0.0] for x in xs]) + r = gt * pred_scale + c = np.zeros_like(gt) if c is None else c + return Points(gt=gt, c=c, r=r) + + +def test_sample_pairs_respects_bin(): + gt = np.array([[0.0, 0, 0], [2.0, 0, 0], [5.0, 0, 0], [5.5, 0, 0]]) + rng = np.random.default_rng(1) + pairs = sample_pairs(gt, 1.0, 3.0, 50, rng) + d = np.linalg.norm(gt[pairs[:, 0]] - gt[pairs[:, 1]], axis=1) + assert len(pairs) == 50 + assert np.all((d >= 1.0) & (d < 3.0)) + # Only {0,1} (2 m) and {1,2} (3 m, excluded: upper bound open) qualify, so only 0-1 pairs appear. + assert set(map(frozenset, pairs.tolist())) == {frozenset({0, 1})} + + +def test_length_error_of_a_scaled_reconstruction(): + pts = _line_points([0.0, 2.0, 7.0], pred_scale=1.1) + pairs = np.array([[0, 1], [0, 2]]) + np.testing.assert_allclose(length_errors(pts, pairs), [0.2, 0.7]) + # Rescaled by 1/1.1 the errors vanish. + np.testing.assert_allclose(length_errors(pts, pairs, s=1 / 1.1), [0.0, 0.0], atol=1e-12) + + +def test_known_distance_scale_without_centres(): + pts = _line_points([0.0, 2.0], pred_scale=1.25) + assert scale_for_known_distance(pts, np.array([0, 1])) == pytest.approx(0.8) + + +def test_known_distance_scale_with_camera_centres(): + # Cameras at x = 0 and x = 3 m see points (1, 0, 2) and (2, 0, 2), 1 m apart, with depth + # predicted 1.2x too far. Predicted separation along x is |-3 + 2.4 s|, so s = 2/2.4 or 4/2.4 + # both give 1 m; the root nearer 1 (0.833 = 1/1.2) is the right one and is chosen. + c = np.array([[0.0, 0, 0], [3.0, 0, 0]]) + gt = np.array([[1.0, 0, 2], [2.0, 0, 2]]) + pts = Points(gt=gt, c=c, r=(gt - c) * 1.2) + s = scale_for_known_distance(pts, np.array([0, 1])) + assert s == pytest.approx(1 / 1.2) + np.testing.assert_allclose(pts.predicted(s), gt, atol=1e-12) + + +def test_known_distance_picks_root_nearer_one_not_smaller(): + # Same cameras, depth predicted 0.6x (too near): roots are 2/1.2 = 1.667 and 4/1.2 = 3.333. + # "Nearest 1" gives 1.667 (= 1/0.6, correct); "smallest positive" would too, so also check a + # case where the smaller root is wrong: depth 2.4x too far gives roots 0.417 and 0.833; the + # true scale 1/2.4 = 0.417 is the smaller one, and nearer-to-1 picks 0.833. That is the + # documented behaviour: without other evidence the model's own scale is trusted. + c = np.array([[0.0, 0, 0], [3.0, 0, 0]]) + gt = np.array([[1.0, 0, 2], [2.0, 0, 2]]) + near = Points(gt=gt, c=c, r=(gt - c) * 0.6) + assert scale_for_known_distance(near, np.array([0, 1])) == pytest.approx(1 / 0.6) + far = Points(gt=gt, c=c, r=(gt - c) * 2.4) + assert scale_for_known_distance(far, np.array([0, 1])) == pytest.approx(2 / 2.4) + + +def test_summarize_in_inches_and_scale_error(): + e = np.array([1, -2, 3, -4, 5], dtype=float) * INCH + ratios = np.array([1.00, 1.02, 1.04, 1.06, 1.08]) + s = summarize(e, ratios) + assert s["median_in"] == pytest.approx(3.0) + assert s["p90_in"] == pytest.approx(4.6) # numpy linear percentile of [1,2,3,4,5] + assert s["scale_error_pct"] == pytest.approx(4.0) + assert s["failed_pct"] == 0.0 + # With 2 of 5 pairs failed the p90 is a failure (infinite), not NaN. + e2 = np.array([1, 2, 3, np.inf, np.inf]) * INCH + s2 = summarize(e2) + assert s2["median_in"] == pytest.approx(3.0) + assert s2["p90_in"] == float("inf") + assert s2["failed_pct"] == pytest.approx(40.0) + + +def test_bilinear_depth_sampling(): + # Rows are y, columns are x: d[y, x]. + d = np.array([[1.0, 2.0], [3.0, 4.0], [np.nan, 5.0]]) + uv = np.array([[0.5, 0.5], [0.0, 0.0], [0.25, 1.5], [1.5, 0.0], [0.25, 0.75]]) + out = sample_depth(d, uv) + assert out[0] == pytest.approx(2.5) + assert out[1] == pytest.approx(1.0) + assert np.isnan(out[2]) # touches the NaN pixel + assert np.isnan(out[3]) # outside the image + # x = 0.25 along a row adds 0.25, y = 0.75 down a column adds 1.5: 1 + 0.25 + 1.5. + assert out[4] == pytest.approx(2.75) + + +def test_normals_from_depth_plane_cases(): + K = np.array([[100.0, 0, 20], [0, 100.0, 15], [0, 0, 1]]) + # A wall 5 m straight ahead: normal points back at the camera, (0, 0, -1). + flat = np.full((31, 41), 5.0) + n = normals_from_depth(flat, K) + np.testing.assert_allclose(n[15, 20], [0, 0, -1], atol=1e-9) + # A wall turned 45 degrees about the vertical axis: z = 5 + x, so the normal is (1, 0, -1)/sqrt2 + # (oriented to the camera). Depth along each pixel ray: z = 5 / (1 - (u - cx) / fx). + u = np.arange(41, dtype=float)[None, :].repeat(31, axis=0) + tilted = 5.0 / (1 - (u - 20) / 100.0) + n = normals_from_depth(tilted, K) + np.testing.assert_allclose(n[15, 20], np.array([1.0, 0, -1]) / np.sqrt(2), atol=1e-6) + assert np.isnan(n[0, 0]).all() # border pixels have no neighbours on both sides + + +def test_failures_count_against_the_metric(): + # Four points on a line, predicted 10% long; point 3 has no prediction. + gt = np.array([[0.0, 0, 0], [2.0, 0, 0], [4.0, 0, 0], [6.0, 0, 0]]) + r = gt * 1.1 + r[3] = np.nan + pts = Points(gt=gt, c=np.zeros_like(gt), r=r) + pairs = {"1-3m": np.array([[0, 1], [1, 2], [2, 3]])} + refs = np.array([[0, 1], [2, 3]]) # the second reference touches the missing point + res = evaluate_fixed(pts, pairs, refs) + assert res["refs"] == 2 and res["ref_failures"] == 1 + e_none = res["none"]["1-3m"][0] + np.testing.assert_allclose(e_none[:2], [0.2, 0.2]) + assert np.isinf(e_none[2]) + # Taped: the good reference rescales by 1/1.1 (errors 0, 0, missing); the failed one fails all. + e_tape = res["one_known_distance"]["1-3m"][0] + np.testing.assert_allclose(e_tape[:2], [0.0, 0.0], atol=1e-12) + assert np.isinf(e_tape[2:]).all() + summary = pool([res]) + assert summary["tape_calibration_success_pct"] == 50.0 + assert summary["one_known_distance"]["1-3m"]["failed_pct"] == pytest.approx( + 100 * 4 / 6, abs=0.01 + ) + + +def test_reference_with_no_positive_scale_fails(): + # Camera centres 5 m apart across the pair's direction: the predicted pair is (2s, 5, 0) apart, + # at least 5 m for any scale s, so a 2 m tape cannot be matched. + gt = np.array([[0.0, 0, 0], [2.0, 0, 0]]) + c = np.array([[0.0, 0, 0], [0.0, 5, 0]]) + pts = Points(gt=gt, c=c, r=np.array([[0.0, 0, 0], [2.0, 0, 0]])) + with pytest.raises(ValueError): + scale_for_known_distance(pts, np.array([0, 1])) + res = evaluate_fixed(pts, {"1-3m": np.array([[0, 1]])}, np.array([[0, 1]])) + assert res["ref_failures"] == 1 + + +def test_a_percentile_touching_a_failure_is_a_failure(): + inch = 0.0254 + # Nine good pairs and one failure: p90 falls between the ninth value and the failure, which + # linear interpolation used to turn into a huge finite number. + errors = np.r_[np.arange(1, 10) * inch, np.inf] + s = summarize(errors) + assert s["p90_in"] == float("inf") + assert s["median_in"] == pytest.approx(5.5, abs=1e-6) + # Twenty pairs, one failed: p90 lies wholly among the finite values. + s = summarize(np.r_[np.arange(1, 20) * inch, np.inf]) + assert s["p90_in"] == pytest.approx(18.1, abs=1e-6) diff --git a/experiments/evals/tests/test_pose_priors.py b/experiments/evals/tests/test_pose_priors.py new file mode 100644 index 000000000..c220ae63d --- /dev/null +++ b/experiments/evals/tests/test_pose_priors.py @@ -0,0 +1,135 @@ +"""Hand-computed cases for the pose-prior uncertainty: pooled percentiles, the group and noise-draw +bootstrap, pooling over draws, the ADVIO scales, and the fit cache.""" + +import json +from pathlib import Path + +import numpy as np +import pytest + +from evals.ar_poses import ADVIO_ARKIT_SCALES, SETTINGS, _seed, degrade, group_poses, noise_draws +from evals.pairs import INCH +from evals.pose_priors import ( + SENTINEL, + FitCache, + bootstrap_interval, + pooled_percentiles, + summarize_groups, +) + +RESULTS = Path(__file__).resolve().parents[1] / "results" + + +def test_pooled_percentiles_repeat_each_value_by_its_weight(): + # Weights 2, 0, 1, 3 on 1, 2, 3, 4 expand to [1, 1, 3, 4, 4, 4]: + # median at position 2.5 -> (3 + 4) / 2; p90 at 4.5 -> 4; p0 -> 1. + v = np.array([1.0, 2.0, 3.0, 4.0]) + out = pooled_percentiles(v, np.arange(4), np.array([2, 0, 1, 3]), (50, 90, 0)) + np.testing.assert_allclose(out, [3.5, 4.0, 1.0]) + + +def test_pooled_percentile_touching_a_failure_is_a_failure(): + # [1, failed]: the median interpolates halfway into the failure; p0 is the finite value. + out = pooled_percentiles(np.array([1.0, SENTINEL]), np.arange(2), np.array([1, 1]), (50, 0)) + assert np.isinf(out[0]) and out[1] == 1.0 + + +def test_one_group_one_draw_has_no_spread(): + # Every replicate is [1, 2, 3]: median 2, p90 at position 1.8 -> 2.8. + (m, p) = bootstrap_interval([[np.array([1.0, 2.0, 3.0])]], reps=50) + assert m == (2.0, 2.0) and p == (2.8, 2.8) + + +def test_groups_are_resampled(): + # Replicates: AA -> 1 (p = 1/4), BB -> 3 (1/4), AB -> [1, 1, 3, 3]: median 2, p90 3 (1/2). + # With 25% of the mass at each end, the 2.5% and 97.5% order statistics are 1 and 3. + (m, p) = bootstrap_interval([[np.array([1.0, 1.0])], [np.array([3.0, 3.0])]]) + assert m == (1.0, 3.0) and p == (1.0, 3.0) + + +def test_noise_draws_are_resampled_within_a_group(): + # One group, two draws: the same three outcomes as two groups with one draw each. + (m, _) = bootstrap_interval([[np.array([1.0, 1.0]), np.array([3.0, 3.0])]]) + assert m == (1.0, 3.0) + # Identical draws carry no noise-draw spread. + (m, _) = bootstrap_interval([[np.array([1.0, 3.0]), np.array([1.0, 3.0])]], reps=50) + assert m == (2.0, 2.0) + + +def test_failures_make_the_interval_end_infinite(): + # Group B fails every pair: BB and AB replicates have an infinite p90. + (_, p) = bootstrap_interval([[np.array([1.0, 1.0])], [np.array([np.inf, np.inf])]]) + assert p[0] == 1.0 and np.isinf(p[1]) + + +def _raw(errors_in): + e = np.asarray(errors_in, float) * INCH + ratios = 1 + e / 2.0 # every pair 2 m long + return { + "none": {"1-3m": (e, ratios)}, + "one_known_distance": {"1-3m": (e, ratios)}, + "refs": 0, + "ref_failures": 0, + } + + +def test_summary_pools_every_draw_of_every_group(): + # One group, two noise draws of errors 1 and 3 in: pooled median 2 in, not a mean of medians + # computed some other way; one group -> no spread from groups, only from draws. + s = summarize_groups([[_raw([1.0, 1.0]), _raw([3.0, 3.0])]]) + assert s["groups"] == 1 and s["noise_draws"] == 2 + assert s["none"]["1-3m"]["median_in"] == 2.0 + assert s["interval_95"]["1-3m"]["median_in"] == [1.0, 3.0] + # Scale error: ratios 1 + 0.0254/2 and 1 + 0.0762/2, median 1 + 0.0254 -> +2.54%. + assert s["none"]["1-3m"]["scale_error_pct"] == pytest.approx(2.54) + + +def test_advio_scales_are_the_drift_results(): + drift = json.loads((RESULTS / "advio_drift.json").read_text()) + measured = {r["sequence"]: r["arkit_scale_vs_truth_gps"] for r in drift["sequences"][:3]} + assert list(measured) == [20, 21, 22] + np.testing.assert_allclose(ADVIO_ARKIT_SCALES, list(measured.values()), atol=5e-5) + assert SETTINGS["advio_2018"].scales == ADVIO_ARKIT_SCALES + + +def test_noise_free_settings_get_one_draw(): + assert len(noise_draws("exact")) == 1 + assert len(noise_draws("advio_2018")) == len(noise_draws("modern_assumed")) == 5 + + +class _V: + def __init__(self, x): + self.cam_to_world = np.eye(4) + self.cam_to_world[0, 3] = x + + +def test_draw_zero_keeps_the_original_generator_and_other_draws_differ(): + views = {"a": _V(0.0), "b": _V(2.0)} + groups = {"2": [["a", "b"]]} + d0 = group_poses(views, groups, "modern_assumed", 0)["n2-a"] + ref = degrade( + [views["a"].cam_to_world, views["b"].cam_to_world], + 0.98, + SETTINGS["modern_assumed"], + np.random.default_rng(_seed("modern_assumed")), + ) + np.testing.assert_allclose(d0["poses"]["b"], ref[1]) + d1 = group_poses(views, groups, "modern_assumed", 1)["n2-a"] + assert d1["scale"] == d0["scale"] == 0.98 + assert not np.allclose(d1["poses"]["b"], d0["poses"]["b"]) + + +def test_fit_cache_refits_only_when_inputs_change(tmp_path): + calls = [] + + def fit(): + calls.append(1) + return {"a": 0.9} + + cache = FitCache(tmp_path / "fits.json") + assert cache.get("k", "h1", fit) == {"a": 0.9} + cache.save() + again = FitCache(tmp_path / "fits.json") + assert again.get("k", "h1", fit) == {"a": 0.9} and len(calls) == 1 + again.get("k", "h2", fit) + assert len(calls) == 2 diff --git a/experiments/evals/tests/test_triangulate.py b/experiments/evals/tests/test_triangulate.py new file mode 100644 index 000000000..017a94e35 --- /dev/null +++ b/experiments/evals/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 evals.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/experiments/evals/uv.lock b/experiments/evals/uv.lock new file mode 100644 index 000000000..ef6c0dd33 --- /dev/null +++ b/experiments/evals/uv.lock @@ -0,0 +1,1028 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "backports-zstd" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/f0/9ba1b05811aa5f5434f69768253129460a5744e1814f359efba39a01ce20/backports_zstd-1.7.0.tar.gz", hash = "sha256:1a967189c1822b6e85a2e550fdfc88a3272c17633ea0a4732dac5911a8034f2b", size = 1003722, upload-time = "2026-08-15T17:26:43.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/23/240495dec973dcfb34816248956ca8d05b32fb75936c226c1cf497b83b83/backports_zstd-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5548a857bb0fcc5449cc3687353547396c6b1ecd4bd882f1cd34fa8d29e70ca", size = 439047, upload-time = "2026-08-15T17:25:38.084Z" }, + { url = "https://files.pythonhosted.org/packages/6d/24/5556959c7d03bfee5ff14d7f07dd9bf8de737c69f81d823a32784ab39c34/backports_zstd-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bab192b934fdf5a03df4752556d9c8af2d058163fdfbafd4a253cdfe25449a6f", size = 367666, upload-time = "2026-08-15T17:25:39.233Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/557db98001c4a7202beed19e8bd42603a2315b80fd5def7e21a0b048ec3b/backports_zstd-1.7.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:8344260bed9842c415a93d9bfe23ea834e5f27758827d56933d8c0d06db507a2", size = 508475, upload-time = "2026-08-15T17:25:40.367Z" }, + { url = "https://files.pythonhosted.org/packages/40/4b/820acbc2c1d1d945aedca0c0d22546a948630ffb186df523098fbd669a95/backports_zstd-1.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c55e55e1e9dee312bc5e186386e6aa5207482a6d2242bd7c14709ded254f87f", size = 478240, upload-time = "2026-08-15T17:25:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/f9/76/77fa9b385e79d4c106ce15d66681978f39a844b0eb5db02682687246b716/backports_zstd-1.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cf609af3735c7e697ccd13f6b0c88da57c201b6ea63c6afbfe81d6f9b50e298c", size = 583611, upload-time = "2026-08-15T17:25:43.104Z" }, + { url = "https://files.pythonhosted.org/packages/95/a4/fbb7c73336f3279dad36da94382a59755100b656301ea836ebaa42736581/backports_zstd-1.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:676a37971f676830d4f90cee8fdf4e438781596fb2f2d1984ac76c9b3eb39a69", size = 642496, upload-time = "2026-08-15T17:25:44.322Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/121917bd2671bc3f1507c25503c0554f0b52483edcca4e6210e6d22228df/backports_zstd-1.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:470895d0bcddc850766e593d1b26764fb138c2feed149f515a2627ef9587d54c", size = 496195, upload-time = "2026-08-15T17:25:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/de/c7/c6379a0d734bea1c7f14d07c23258108cc92b994654e25cfe3a3e88cd785/backports_zstd-1.7.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02f2f6649a342d0901ddb35596ddadb7c3bb1cf6bb54d691e5e0285f1fa0674f", size = 570964, upload-time = "2026-08-15T17:25:46.648Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a4/372c3dd3017c3f93cda0acbc282f8073b70efdc1b56d1fdeebe023660725/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:132ba81fad59d44958b7d10da31545e7128c469cfbc2e268d0eaab96daa64175", size = 484276, upload-time = "2026-08-15T17:25:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/83fa7bdd5e1d808203b9143848fdf7e15de399b8119a0d4378b2aea9be78/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a3e1c6ce0b232ee6703ed24ee126e8186107f5a4e56edbd21cd1aa5a8c6bfd12", size = 511963, upload-time = "2026-08-15T17:25:49.666Z" }, + { url = "https://files.pythonhosted.org/packages/56/d2/d4ed32c353148acc18f3b665ab24a677b9c49d3640244424c5d6046400c5/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d7a7cb964eb8d1bb5d039970b16fe54802ea47dc935ae96d9874844a126bf8ff", size = 588025, upload-time = "2026-08-15T17:25:51.288Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5a/df8b5b848e8dfdec6edca55f22067ffbafa081d81aec1313e28155c3fea3/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:12a9842a2ec2854cbec7f252ab29d44c2b772788a9bbafded743ca4bf73b115f", size = 568223, upload-time = "2026-08-15T17:25:52.633Z" }, + { url = "https://files.pythonhosted.org/packages/43/8c/f970f15e7fdbf8a251f121c91364fa68bbc2dfab4d5eca058427dec63397/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:138154eea8ced84394991bf0e819dba6b690306a178dd528c28eee724b7d4aec", size = 632937, upload-time = "2026-08-15T17:25:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/e36c18c87a74421954502d123ff7027e0a63a7624dffa99ec0f7474deff9/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:468b636ed365627b364c94be1c35a52858e13b5bc1fa3f068bbc71b1af65f3d7", size = 500735, upload-time = "2026-08-15T17:25:56.064Z" }, + { url = "https://files.pythonhosted.org/packages/1c/57/fc72280334d2aa94238c5882052263bd7796c1fa924044353c30d058e0c3/backports_zstd-1.7.0-cp312-cp312-win32.whl", hash = "sha256:f026fe2e89b7ff01ba6ebec6abaff34c6063919151a32afb68714cf139e17c50", size = 292394, upload-time = "2026-08-15T17:25:57.469Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/6cea747bdeef34cd12482b17e604b832fdb0962987132b99496f1a6c3f82/backports_zstd-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:2ea62ba2f1a6e6c9e6dc108921f9ae881969ca72e073162fa488d0de3eb2713f", size = 329876, upload-time = "2026-08-15T17:25:58.798Z" }, + { url = "https://files.pythonhosted.org/packages/64/28/b4a17c07d5a50a45cb04592960d1593cdf3b3728968371f332aa3643b804/backports_zstd-1.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:cefb983345c55ccaa20423a4eb96434730e6d640ffa2db9b60e5bedb0fbef94e", size = 292461, upload-time = "2026-08-15T17:25:59.928Z" }, + { url = "https://files.pythonhosted.org/packages/6f/24/32b3358ae3a4df0ebad85ebbce721818c6d76a836119bee76089d103e951/backports_zstd-1.7.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:a3fbcbf819bee2b06b8666b13742098d0f40663ee34e64a12bc360ec0f5e3d89", size = 400913, upload-time = "2026-08-15T17:26:01.089Z" }, + { url = "https://files.pythonhosted.org/packages/af/f3/39ef7dd75eb1e699e25a19212737a73d3c030a0c9fd1d0ed1572b5f8e493/backports_zstd-1.7.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:efee02f18e04c2e9e6d694c5cf9b7457c4bda3ea96f48b1ee69769e06bb9d89f", size = 454915, upload-time = "2026-08-15T17:26:02.294Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/8209081e094aa98b2f28bac388619c85b1a44aed813d6b3c54d1da79d19a/backports_zstd-1.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ecc95fa0e91d92951d74468e7789afdf91d9e702f40af2d0fcbf0ded4d0f650a", size = 357992, upload-time = "2026-08-15T17:26:03.552Z" }, + { url = "https://files.pythonhosted.org/packages/b3/65/64025302bae4ba924d613e404c6120bf194b5636786960ece274622a4a3e/backports_zstd-1.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34154d82fc0246738159084d146401073f9ac9cfd755b66bb8853ca06037810c", size = 366686, upload-time = "2026-08-15T17:26:04.812Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b9/c4d24d113d28b774662152c462d38d28109741d6d45c1aea7834371741dc/backports_zstd-1.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44b687b1c0be5cb279693d2682f91ff84c559d679b2ef2fbe501fe4b2db2c4bb", size = 447221, upload-time = "2026-08-15T17:26:05.979Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/8db55c7f77aec60879844a879ac026065d8f03aab74080701acc060c4168/backports_zstd-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dcdbd368659f46b570114eeea36b75347716523870d71f6bc5d7801862aefd6e", size = 438571, upload-time = "2026-08-15T17:26:07.421Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f8/72930ae4bb7bf6b9d6c7c31bce7b3e5751c062269a4ee718066e25f1973b/backports_zstd-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eda97fa535d4651a4ccdeed4ee7dde3978369046abc8a7456a7117d4271f9333", size = 367041, upload-time = "2026-08-15T17:26:08.537Z" }, + { url = "https://files.pythonhosted.org/packages/17/9b/7289dc191b34279d8f176bf5b181c3b26f8e049d14a2c0a2637650f034e5/backports_zstd-1.7.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:7e3999b5141d7f85171822d06112f70f7f317d162f0120530dd2c7a28dbf8add", size = 507676, upload-time = "2026-08-15T17:26:09.909Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4d/6dd730b79ab96532e23fe851003545b4cc79e50c5b4c79ffcbe1b724eec4/backports_zstd-1.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69367726f4075c2574746f5883b0dc045805c5b02a81fdf8c829c26d33969de3", size = 477744, upload-time = "2026-08-15T17:26:11.038Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/11687e5019d56ea47893cf2ba59a6b4884a4e2d1496d0e653aed373b973f/backports_zstd-1.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15e97edfd173ade365c01bac7d9d297fa906686015cdbcb5f32a0d410887826b", size = 583215, upload-time = "2026-08-15T17:26:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c7/5a8c58542469ab31680c403b844770c119a976fd4cf1000fd7d53e7d0f77/backports_zstd-1.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:32a94cdcf16b44395cd55086ea38877395ca6bf3362cb507b0eb86db2a45a6a4", size = 644125, upload-time = "2026-08-15T17:26:13.651Z" }, + { url = "https://files.pythonhosted.org/packages/11/35/be5485e65df95b86c4981ad4a577b505cfeec6b700a46a86e2e3175ac718/backports_zstd-1.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3f4887a8a1fd1290017fe5a1d29a7d1dc5c57f9477fbd64f119316a7e3ae769", size = 492714, upload-time = "2026-08-15T17:26:14.838Z" }, + { url = "https://files.pythonhosted.org/packages/96/8b/a0603458ca08e4a56f09ae58588ce3c0453425e753df704d9aeaabb66ae5/backports_zstd-1.7.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e590313ce156f1d8986dff3107e8ed1651d6d106a56b3a95f965ff8d845ba979", size = 567953, upload-time = "2026-08-15T17:26:16.276Z" }, + { url = "https://files.pythonhosted.org/packages/02/87/2296db4c3c578947c35ccd8dcdf7992316d7e1f5f43cc829c062b3ed9319/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:565270b0d6497970fa97a0df59593ae0d225e4176678bbce851d39e5f8aa422b", size = 483564, upload-time = "2026-08-15T17:26:17.493Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d8/f53a79e6bf3cdb7ae08f95220c80bd0d606f3d6c3482995deaf21d024fb9/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:37ef23c6c522fe935726c8fba6344350973c4a23b06d10194d90d0868b09ff7a", size = 511193, upload-time = "2026-08-15T17:26:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/31/ea/d4e2eb159cd5813debd5a34d0644caff5fe7cf2e569bf5b02a82934aeee7/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b3975330159f1efdd1fba76afe1c7b84f66f26e2bf209b32630fb148d647e0d5", size = 587748, upload-time = "2026-08-15T17:26:20.148Z" }, + { url = "https://files.pythonhosted.org/packages/81/d2/b5ec9709660fb1c193508215d9c30e781fac406183faac7c3c36b1c583a9/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b40bc8cd0a86cbbe8263a9c3a2bf2e34897483516c6d799725412a19524c32e3", size = 565808, upload-time = "2026-08-15T17:26:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/bd/13/004735cc4536483cbd973a60346a9dbc7bb977b13c28b55a11da14bb0a1e/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f37e12ef10747f76901b1f20ef70d33221e861de177dba5ba08552242c6fd4bd", size = 634520, upload-time = "2026-08-15T17:26:22.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/28/05b11f7084d1100491cf7c60962aafd900c3dd01b1fc1ce85914476cdae0/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5992143b2a8b71b4d17afed20cce2df50f8718228e31d6e716493b1fe9201712", size = 497098, upload-time = "2026-08-15T17:26:24.181Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a5/bdc98d039ddbd5815fc1dd71912bbfb9f820a46ec12004ead51c8d60ea50/backports_zstd-1.7.0-cp313-cp313-win32.whl", hash = "sha256:31ae30d216ffae9243dfa607bcb995f94a70de5765bb8fae1e35ea1ad6497959", size = 291974, upload-time = "2026-08-15T17:26:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/13/7d/fb0da7351e8b152d5149127594972922829281c316618df37a7e724f2eb9/backports_zstd-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:8086b4a7443bb2863f7ef8edb317b715d5f3ccec6c5512619bd23d57661ba1b7", size = 329550, upload-time = "2026-08-15T17:26:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/37/f9/109ac272d650483fbdfa611c0040253a405f640604fbc90acc8076c6d37f/backports_zstd-1.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:7eaceeec75e1dbdce40b81fb0ed1ffdb7ce492d970db7f8aabd6a95ccd6c3dd3", size = 292174, upload-time = "2026-08-15T17:26:27.819Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/97/7845739a36828ffe751a1c6b240692f552fd7ecf65026c51326c0a4aa369/brotlicffi-1.2.0.2.tar.gz", hash = "sha256:5e0fbd13644cf1f6015e75fa5e0ad8fdce1048d9c9ff90b0ce826174b249ee35", size = 478755, upload-time = "2026-08-21T17:29:18.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/a2/edda4f3fc7143434402eacad1e91433fe68ae648c22738eeddb6138638ba/brotlicffi-1.2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad05ca993234cf947f0ad71b1c8bc0af3d74e0410b1e2c32bb99de0cef6a994b", size = 438789, upload-time = "2026-08-21T17:28:55.708Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/506dc8edabb3cf9339c89f1ecc80a218aa166bb83b9f2e9cc1da67314072/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0636cb5a85f31c36e08953d09a226cb788be900b976f81302895e3cf35d5e707", size = 1541246, upload-time = "2026-08-21T17:28:57.669Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d6/74cee9f9fbea8c42030a81056c64e092030a95bd2756ea83da1d1e8f5f29/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97bae40d45ebc2a6ac7b1c9b30825496a257192194b672ef5869e2df93467f69", size = 1542129, upload-time = "2026-08-21T17:28:59.502Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/c32630b042ec2a13e8342e6ecb6b9d3531b1be4647b733d6fd365976041c/brotlicffi-1.2.0.2-cp314-cp314t-win32.whl", hash = "sha256:8f3f9bd61293dc48359763e693951393f39656086315067cf97e23e23e8911ab", size = 346840, upload-time = "2026-08-21T17:29:01.085Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0b/83cac3075721fe4c253ea1cc5310cb687c2f7d987e0fd60eb3ed769c24c0/brotlicffi-1.2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:908add8a9c0eea00f5de799dc6de9f6d205d9ee11afabc7c03d6812c481200e2", size = 386079, upload-time = "2026-08-21T17:29:02.667Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/c27f24b8334f65f2492601c7764338f156cb904d2ffe0061e6004a76d9cc/brotlicffi-1.2.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5a8ffa154f16660ab818d78045b55fa6f9970f1ca4c38998766e99c672071cb", size = 438885, upload-time = "2026-08-21T17:29:04.113Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/d8fd1a4d09b7ab563b89380395e09151d2ef1344be31594df6a6987d4028/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6b1af7b7a8ce788354f2c603651ada0fba166ec31ab879e2eec462a3e6dbf4", size = 1534365, upload-time = "2026-08-21T17:29:05.878Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/076419ed6c2c6aa3eaac6fd6b076502b4be89d50625fcdc513cd4aeca718/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22916101de0e7ff535f2edf54b52a85591853b8ae9a98737643defdd3c063a3a", size = 1536851, upload-time = "2026-08-21T17:29:07.599Z" }, + { url = "https://files.pythonhosted.org/packages/35/dd/31ae9945cbd605339fb51c9a609f7dbb182cd361adeabc1d470142357206/brotlicffi-1.2.0.2-cp39-abi3-win32.whl", hash = "sha256:df1d34c4ad9adbf7f63a6b42f7d0e4dfd259c88141b85145b57abecc1abc3b24", size = 342379, upload-time = "2026-08-21T17:29:09.05Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/afd54e744df93b51cc29f6a19beccf9998b25743d7177697390de10479d1/brotlicffi-1.2.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:489ca4da3ee65926d72bf01584b61088a9da6bdd1bb01b2040901e1beaffa8f0", size = 379761, upload-time = "2026-08-21T17:29:10.687Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[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 = "filelock" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/b8/9ba8f569df649beb7058db5eb392a5f779bdbc3b82cf3942f0be439fb99e/filelock-4.0.3.tar.gz", hash = "sha256:87296d60478e14204fd9406e79831400fef76693bae2895deec236c98e87a8aa", size = 244309, upload-time = "2026-09-23T23:40:22.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6d/db8b86e168fef0cf904737e275bccb683558088ef971e92d3b41ce980699/filelock-4.0.3-py3-none-any.whl", hash = "sha256:30cd166e2aee2c7534ce2c33c6367c4cd051b8368e20e2c4eb0c34f699bacfab", size = 108444, upload-time = "2026-09-23T23:40:20.819Z" }, +] + +[[package]] +name = "gdown" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "filelock" }, + { name = "requests", extra = ["socks"] }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/6a/37e6b70c5bda3161e40265861e63b64a86bfc6ca6a8f1c35328a675c84fd/gdown-5.2.0.tar.gz", hash = "sha256:2145165062d85520a3cd98b356c9ed522c5e7984d408535409fd46f94defc787", size = 284647, upload-time = "2024-05-12T06:45:12.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/70/e07c381e6488a77094f04c85c9caf1c8008cdc30778f7019bc52e5285ef0/gdown-5.2.0-py3-none-any.whl", hash = "sha256:33083832d82b1101bdd0e9df3edd0fbc0e1c5f14c9d8c38d2a35bf1683b526d6", size = 18235, upload-time = "2024-05-12T06:45:10.017Z" }, +] + +[[package]] +name = "house-scanning-evals" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "gdown" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "py7zr" }, + { name = "pyyaml" }, + { name = "scipy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "gdown", specifier = "==5.2.0" }, + { name = "numpy", specifier = ">=2.1" }, + { name = "opencv-python-headless", specifier = ">=4.10" }, + { name = "py7zr", specifier = ">=1.1.3" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "scipy", specifier = ">=1.18.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[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 = "inflate64" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/f3/41bb2901543abe7aad0b0b0284ae5854bb75f848cf406bf8a046bf525f67/inflate64-1.0.4.tar.gz", hash = "sha256:b398c686960c029777afc0ed281a86f66adb956cfc3fbf6667cc6453f7b407ce", size = 902542, upload-time = "2025-11-28T10:55:52.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/33/5cfa7468960de1be0833e7e41adf5b7804a0aef2fb46f3679df3876bf3ab/inflate64-1.0.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c8009e4a4918ee6c8cbc49e58fe159464895064cfdf0565fed3f49ca81e45272", size = 58619, upload-time = "2025-11-28T10:54:48.315Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0a/583c7c2832da36e986c5758d0afb6f5944599e55c5b798b066a9ef63e581/inflate64-1.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d173a7a0e865bb7d19685c5b1ad2994712b8361b24136d7e94abeff58505647", size = 35865, upload-time = "2025-11-28T10:54:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/9c6acfbe900e5c8698132244c68036b0455bd2169f46e356c83dc0366f11/inflate64-1.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8bad992f2d034f5f7e36208e54502d1b0829ce772c898e5dc59109833420148a", size = 36019, upload-time = "2025-11-28T10:54:50.813Z" }, + { url = "https://files.pythonhosted.org/packages/7f/66/c0c3d3b4b863aab2c2ce631d219a8eb3b95b78acd5f40d3212f071e693db/inflate64-1.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6bfcf806912ced77a21394f7363805ecacd626b79f93cba87d505a48e88ede78", size = 98765, upload-time = "2025-11-28T10:54:52.273Z" }, + { url = "https://files.pythonhosted.org/packages/37/00/1a2351a85d36b26c5b2b8cfbb37ad86084c98f592dd7590f8577d8b33993/inflate64-1.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62d1aac3aba094ae42e27ce7581b414c90f218248be0953b6aeb11a127225e5d", size = 100594, upload-time = "2025-11-28T10:54:53.684Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3e/5d18ff5b86aaaf54117e1bb6ce15cb17163f56035f9c480e609d35f258ab/inflate64-1.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8065166f355122484f004225b379d403346bdae69ec624786a9334f025580675", size = 96745, upload-time = "2025-11-28T10:54:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/306d5d6aca1e04e596d2a504a59ff9a900623a6ec852f38aab99f384562d/inflate64-1.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94a95f32087d223d2e119ff5c7c264109e8d4cb7e421e7a688a899a6fe021b38", size = 99795, upload-time = "2025-11-28T10:54:56.485Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ec/d4caa4bf3c9e520c15e900fee5a00fc523953843e14aa378ca1abfb2b4ea/inflate64-1.0.4-cp312-cp312-win32.whl", hash = "sha256:ad4fa490bb7dc2a4640a3adaa2d5950f4a465ba034bbcf184c2103646e58ad97", size = 32956, upload-time = "2025-11-28T10:54:57.642Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/c0de4e9bdf12e449360b710e9ab5b5248804610f382b538773cbd07b72bb/inflate64-1.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:2c6befdf83d088a6e0d10d0873a9d4bfde2ce00ad7a52c8189cf303306f98030", size = 35577, upload-time = "2025-11-28T10:54:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/03a5ef74ad3869b3c5af3b09216321f5a1a5a45265f7bd6d5abc669c7622/inflate64-1.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:2b263c619469f90a75f29c421c53d31b208ad494a078235a8f6db2bc96583fdc", size = 33465, upload-time = "2025-11-28T10:55:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/c2/55/b7de7ae318a4f233f892c4f7c8b7e0e8643abe3fdcc53ed35020a9fe3f47/inflate64-1.0.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3f37540d0e64884a935fd62a7d17e40ab69f05ec63e815483b6513675d01bef", size = 58633, upload-time = "2025-11-28T10:55:01.931Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5f/6f89c8524503fd7a9ca2bd91fe60d7291b3f684e9d41edb38ef49e10fc3d/inflate64-1.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4d24112180c95d12f279cade9a1e21f8be7f4790c4109c293292edf87d061992", size = 35864, upload-time = "2025-11-28T10:55:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/7d/14/9eadd59244b38cf85ccd0ca43d1296c50b3a33aa37be4fb68a1928efa58c/inflate64-1.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5c098dab17821f466fc6e6a3d78fc6e0295bb51458015f03416b1d58d6a8df4f", size = 36019, upload-time = "2025-11-28T10:55:04.126Z" }, + { url = "https://files.pythonhosted.org/packages/ca/04/399e82d8f5003dd92c8a0c5c1a9a8ce0919114710a496cbe88848bff3a72/inflate64-1.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a984b9287ff0fb596eb058d66a9e94530556afd2b7c054b44f2e0aeeff894e8f", size = 98973, upload-time = "2025-11-28T10:55:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/038dc2593bbc4272d87eac8c9f75692267d47f834ced888f6d81995df606/inflate64-1.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f62a13d0327631778fa2a47c308ae2b07b2659b7bb8564783259ac65949f8c0c", size = 100777, upload-time = "2025-11-28T10:55:06.41Z" }, + { url = "https://files.pythonhosted.org/packages/74/4a/f6d3031dd3578510894a41bfe1ac149228970ce1629a43f20e0c5abbe8d1/inflate64-1.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:513201336fb3b0b7e2aee5dbbbe30a9f1b23291738b5ceb80076fc285f2ec2f1", size = 96967, upload-time = "2025-11-28T10:55:07.594Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/7ab3bde4e176609ae0e607a8a9bb38d201885275664a6d574299f5bf7850/inflate64-1.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84ce3a97272ba745fce52b38363855c7201968f6402a794bbade774e64c657b9", size = 99997, upload-time = "2025-11-28T10:55:08.85Z" }, + { url = "https://files.pythonhosted.org/packages/39/59/8256b3e802e203c8645e0b32b25e6bd94508ad572593f0cdf8234db3879a/inflate64-1.0.4-cp313-cp313-win32.whl", hash = "sha256:332051a9d7e50579b90a3f555d68f53414b06f636c9ffe82e97c0baae3c8fbcc", size = 32958, upload-time = "2025-11-28T10:55:10.343Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4f/5784ee1eb8260f2310e24ef2883f1f494f9332bcfde4ed14ee780372149e/inflate64-1.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:3983f53b590ff7d0ba243f664ce852aca882482f30f7a8eab33e10d769336d0c", size = 35578, upload-time = "2025-11-28T10:55:11.389Z" }, + { url = "https://files.pythonhosted.org/packages/39/e8/8d927770ce25dc9764c8104207a80653d65471d0a6a8f9ead350016e4586/inflate64-1.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:118d8286f085e99a14341c76ef9fbffd56619ccc80318a9a204aea3dbfa71470", size = 33465, upload-time = "2025-11-28T10:55:12.824Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fb/ec9d10f44f2fc7666ad5d70acae2b8a1941e8e08ccae1fad0820f7796be3/inflate64-1.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4f61925b2d4248eac2ebb15350a80aaa0d1f7f1dc770bd5ebbbb3b0db4a6a416", size = 58727, upload-time = "2025-11-28T10:55:13.834Z" }, + { url = "https://files.pythonhosted.org/packages/81/80/24ba0d2ee14e07e275e9c5b058e59a8a58f8ef42dd51a78ebbfd7c857ac4/inflate64-1.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c1acf18b08b32981a4a11ec5a112b8ad5d7c7a5b15cb5bdbdb5b19201e9aa180", size = 35945, upload-time = "2025-11-28T10:55:15.225Z" }, + { url = "https://files.pythonhosted.org/packages/70/b8/073a79716e093db973b8823bdfb02e10fbdf65642dbe1fa3cda24832aeb2/inflate64-1.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abddae8920b2eaef824254e14b8d4ff54afbe6194a1bbe9816584859f0c1244d", size = 36060, upload-time = "2025-11-28T10:55:16.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f0/87d3c317ed0acd94f5e9b3b1b9e9000228ea2af0cb4618c62cbfc816da34/inflate64-1.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b303132cc562a906543a56f35c4e164e3880da6ff041cb4a7b1df9f9d2b4bb69", size = 98995, upload-time = "2025-11-28T10:55:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/0b6035302e9c33f004240a50cb6e2e1fc7bb1f2b415b02d939c551bdd06b/inflate64-1.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f0993214dea0738c557fa56c13cd9083aef0097a201d726c21984ad7f577514", size = 100781, upload-time = "2025-11-28T10:55:18.597Z" }, + { url = "https://files.pythonhosted.org/packages/2b/05/5f383c615ec0f01bcbbc699a71da167623e494083ab7ed0df86b4bddf125/inflate64-1.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a6baedc3288d7a4ff588951d3a9a97a5391dceed6255ff5b16e42cae7274bfa9", size = 97038, upload-time = "2025-11-28T10:55:20.156Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9a/c1482718c717c49c67490c42b4fdced9476e894eaebd52193e488c12e188/inflate64-1.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a846ce1f38845b20bef2625af1b512be83416d97824539524c5a34e7a729aec7", size = 100016, upload-time = "2025-11-28T10:55:21.337Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7f/700ede7474e72a1c0e2e8fe36624cd0225ca8b2875eca33d64aa2de75f4d/inflate64-1.0.4-cp314-cp314-win32.whl", hash = "sha256:eef87908c780439393d577a155868317f0a275b47b417db9f47d8633ec791745", size = 33691, upload-time = "2025-11-28T10:55:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/f2972df8cceecc9bf3afa3353d517ffc7125285198c844588e9aaf98f5d0/inflate64-1.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fb2fdd63ef3933b67af98b3f2ee2f57e7787278041d7ba4821382fedd729b68a", size = 36304, upload-time = "2025-11-28T10:55:24.005Z" }, + { url = "https://files.pythonhosted.org/packages/29/77/16200aced67215119fb30ec9a5889b48289ee2fa5ce5137623a9ad41b2c4/inflate64-1.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:2e129669a0243ac7816fd526946ee01c25688fe81623a6d6bc95b3156d80f4fb", size = 34491, upload-time = "2025-11-28T10:55:25.068Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/b466ec7666c40912ea81a305a8a2b75f5998e6cec1d3d75e067d76203731/inflate64-1.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:b17bf665d948dc4edeea0cd17752415d0cd7240c882b9c7e136ad4cc4321e9d4", size = 59300, upload-time = "2025-11-28T10:55:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/16e1168ac80f39894e6cb18b439eec63fec42cbced239aebfe12081b6ec7/inflate64-1.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6751758301936fbb38fa38eb5312e14e27b6a1abf568f83c17557fab2694373d", size = 36258, upload-time = "2025-11-28T10:55:27.295Z" }, + { url = "https://files.pythonhosted.org/packages/25/42/c463b42fd8a7947b4445fbaf57c265bb7f3114362fb7aee6884ffd8b5341/inflate64-1.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6a4136752aa2a544301059d8f13780aeb88c34d60770258436a87dacd3fc304", size = 36296, upload-time = "2025-11-28T10:55:28.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/f1/cf6121926e405020e9e7bccb78ec7781fbc87500ec67368e7d9e866758be/inflate64-1.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:938ebc6b28578bfd365d1a9fdb18b7faab08321babeb2198e8025d07d8dc7fb5", size = 106788, upload-time = "2025-11-28T10:55:29.797Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dd/b653f9962497cf4d3520d69272894c37fd76f86a0e04bb3bd9f32827dc2f/inflate64-1.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61f51f80fa6f367288343c1a2cd20a42af454883087064e9274fd2a8c3a5a200", size = 107959, upload-time = "2025-11-28T10:55:31.306Z" }, + { url = "https://files.pythonhosted.org/packages/60/ae/65d88109b63611c9b6c29008201107127cf2603d186ab99beec39d85f38e/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:172b51da7bbfa66b33f0a5405e944807b9949e92cf4cd9f983c07af8152766df", size = 104213, upload-time = "2025-11-28T10:55:32.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/94/c17de2f55b9fb1269bca4657f9089efe4ba0f3d4b652f07b34dfc69f69a2/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ca9a2985afd5a14fb48cd126a67e5944ccb7a0a6bdec58c4f796c8c88a84539", size = 106629, upload-time = "2025-11-28T10:55:33.735Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/8bbc587bb2ad09ab7edf1f9215b2c5faf4fa7ee7c071daefe9ed55e28814/inflate64-1.0.4-cp314-cp314t-win32.whl", hash = "sha256:f8964ceaabea294bc20abc9ef408c6aae978a75c25c83168a76cd87a37c38938", size = 33865, upload-time = "2025-11-28T10:55:35.335Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/8bf6f412f93c8b6dc14866a021b83321331fbdd17f6ab902a24dbf88773d/inflate64-1.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:7d13b04cba65c12d21e65eaa77da9484e265e8e821b26e0761d1455ad3a878d9", size = 36943, upload-time = "2025-11-28T10:55:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/7d/85/33447bb3c4e3c0ae7b1fde3aadc52a18b2b0193cfcf4f585977e924c6463/inflate64-1.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9ae3ee727235a06dc3cd353ee5761fdd8e3b56ad119c711f61680528972a6ced", size = 34846, upload-time = "2025-11-28T10:55:38.433Z" }, +] + +[[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 = "multivolumefile" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/f0/a7786212b5a4cb9ba05ae84a2bbd11d1d0279523aea0424b6d981d652a14/multivolumefile-0.2.3.tar.gz", hash = "sha256:a0648d0aafbc96e59198d5c17e9acad7eb531abea51035d08ce8060dcad709d6", size = 77984, upload-time = "2021-04-29T12:18:39.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/31/ec5f46fd4c83185b806aa9c736e228cb780f13990a9cf4da0beb70025fcc/multivolumefile-0.2.3-py3-none-any.whl", hash = "sha256:237f4353b60af1703087cf7725755a1f6fcaeeea48421e1896940cd1c920d678", size = 17037, upload-time = "2021-04-29T12:18:38.886Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "py7zr" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-zstd", marker = "python_full_version < '3.14'" }, + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation == 'PyPy'" }, + { name = "inflate64" }, + { name = "multivolumefile" }, + { name = "psutil", marker = "sys_platform != 'cygwin'" }, + { name = "pybcj" }, + { name = "pycryptodomex" }, + { name = "pyppmd" }, + { name = "texttable" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/3f/ac248f25f3901d3d6cf80ac99d5bac6fd1e5e6543c1812c79b02b6074c60/py7zr-1.1.3.tar.gz", hash = "sha256:8d51894abb38355bf14881088bb97f01fe4cb5b14ebe22f66d6297668c7e1a74", size = 71695, upload-time = "2026-06-19T09:28:29.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/40/dc7f804abd0ea167ea8d4d9b3f5455321aaf671d040fcef647e9e952e714/py7zr-1.1.3-py3-none-any.whl", hash = "sha256:17934a35089e026dec6c72ee275d9b841e646881ef822d618e805c3006661d9a", size = 72242, upload-time = "2026-06-19T09:28:27.512Z" }, +] + +[[package]] +name = "pybcj" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/c9/e2de4f98c21f2ff9e58047395b6a8e17701a0f2977f0e63f80948f61647f/pybcj-1.0.8.tar.gz", hash = "sha256:6818270692912d47e81ccd777b7d0ed2ad3a19ef7aaead3735b38048c80bf368", size = 31763, upload-time = "2026-07-16T22:47:24.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/32/63b96b702fc4848834ce552302fbdccad92f67886eaf3fbbed530569f4a1/pybcj-1.0.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:748af4545040da68dfae8cdf9f902ac7c29f209aaf6450a970abe0302d1b0f58", size = 32798, upload-time = "2026-07-16T22:46:40.083Z" }, + { url = "https://files.pythonhosted.org/packages/a2/21/0b501901021499e41afe7d0148c94ee26dcf045d3e1389bf1b42a61d11a6/pybcj-1.0.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:190e21c3bdb6b28c63999889668a3f80f5d7eb13b957f2dfda53c9ddeb3d946a", size = 24673, upload-time = "2026-07-16T22:46:41.288Z" }, + { url = "https://files.pythonhosted.org/packages/b3/97/c05d5800d742f5162bd06ef790dfcf73128431328f16abe7a0e2109d8ca5/pybcj-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:384b25dfe66868329a3872c6eae668033e009e4e86754eba3a70b656c9eb4208", size = 24958, upload-time = "2026-07-16T22:46:42.469Z" }, + { url = "https://files.pythonhosted.org/packages/05/0f/9db13432a6f8878f90c108a68fda46e832eea53585cb5dee8bdbc9472467/pybcj-1.0.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b480efcd66cfb4533ede6c3d67224ff477c8dd661613826edff9c95921a562d6", size = 53297, upload-time = "2026-07-16T22:46:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/76/59/5629811698e6cfaea6cf159cf86fa471904ceb60448dc8b80233bba2e392/pybcj-1.0.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a50b58f9b9d9978c55e3219b6816a1021a778b028a810b36aa754407591cb5ae", size = 52332, upload-time = "2026-07-16T22:46:45.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/c5bd982957b5fd785f02dffcb3f6f45a93143e1e8ccc8224e19b3915fc84/pybcj-1.0.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1997badf9e59221470f84e193ac8c1bab158acfe6b058ffdc28c331807967ff9", size = 51192, upload-time = "2026-07-16T22:46:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/7a/79/1d421d2d45e565a0f8eab8ce6f795e31beca96f658c1e483dac64a466a28/pybcj-1.0.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f0818cf48fbfddbae487536f3fcd69cfadfb7293630ab8658b66e89a0836d6a", size = 50849, upload-time = "2026-07-16T22:46:48.006Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/daddd6edc01689b1cb75733bd3098231bae20c9e77d7022dc0b1c40e56fd/pybcj-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:26e3e4d287f306996902289fc0e88b36d0fc817802f4fc4537b40a6518726585", size = 24956, upload-time = "2026-07-16T22:46:49.159Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fc/9c668e2cd1432f7ede31bc131ab336c8602659374bc731bfaca6cf208bbe/pybcj-1.0.8-cp312-cp312-win_arm64.whl", hash = "sha256:82d2e46eff6c3fe8d16f6515f9ab0598eb960af9a2218907c5e0e9f993d7a666", size = 24251, upload-time = "2026-07-16T22:46:50.33Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/9d8822f1b97813f21b5907b351edb0f607c9736e21637812edf407a2dae6/pybcj-1.0.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f0e496c49dce294fb41a2221e7b3f96156bb940d8cfe359d441909f1cf241ea", size = 32799, upload-time = "2026-07-16T22:46:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/248154c815e683c0f991c471bedb7476d7c91fd8a56918f1ef66d2341ea5/pybcj-1.0.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0657c9534d480a43b11743cad4209d140d09d417adaf19213ff395686ed08812", size = 24673, upload-time = "2026-07-16T22:46:52.799Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f7/f168c7b091fc41fc7ef6d9ed6cde2ec10bb4814f14ad21b493881476636e/pybcj-1.0.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:784443d0c075871689692679ef01cfe85eca789cc8d091f842fa02f62dead9d5", size = 24955, upload-time = "2026-07-16T22:46:54.069Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/7b802ee7c7730521a102dadbdf944943795b3e9ca3ca665dbbdd1213f781/pybcj-1.0.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9195ce382f21e13df7ecc0df8fb42c5f391a7ac516066af16b023e6bd2f86046", size = 53344, upload-time = "2026-07-16T22:46:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cf/c4b0248051cdb7af7f38223db63427175e755db1f1a7905e781e923f6d53/pybcj-1.0.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5e67ec32176985bbf941185319a3fe385527b6826d457a8ec67dd43b5f0b5fa", size = 52410, upload-time = "2026-07-16T22:46:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/52/77/9afed44c590a19940e04fdf38b7f55e7db3a8b35e3f6320340d99be25ca8/pybcj-1.0.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20b657e4fe3e701a0f64a7c1224694820687334b17593d74a878f7124f10b24f", size = 51282, upload-time = "2026-07-16T22:46:57.748Z" }, + { url = "https://files.pythonhosted.org/packages/10/4b/7eaf8cf0ad598cc2035a24186a3e2bf45ec798279dad58484d0eb940d4b3/pybcj-1.0.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5b4c66b5358cd241b6ff81a8fafd5806e816d378c810a796ad51865be5533f7", size = 50935, upload-time = "2026-07-16T22:46:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/39/f4/04d46f5059af00d2fae6086a7f47ebc23e553900ca75ea2a2e276d3a436c/pybcj-1.0.8-cp313-cp313-win_amd64.whl", hash = "sha256:e47d9c655d276200e5d08f7b550097dacc826364b44931f0fbdc0da2e4bdf448", size = 24956, upload-time = "2026-07-16T22:47:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/63/cc/387af6844cdc53be20a9271998bc4a15a96360f06ce8829193d4a45f6b19/pybcj-1.0.8-cp313-cp313-win_arm64.whl", hash = "sha256:f561988aef99f07e991d0708ca6fe86e766d545c0a20e58bdbd38615a615e419", size = 24249, upload-time = "2026-07-16T22:47:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9a/6ae6062f0a7707a6c818f69653af34be90dffec0450c6a39790a1a7d918e/pybcj-1.0.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b8d80571db5f89944aced42378b208afe38339008027ec56303e11628615a906", size = 33353, upload-time = "2026-07-16T22:47:02.452Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/182735114b34301326ad197daae729e1b2a67c2be8e4ee661acabb1c4e04/pybcj-1.0.8-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:07eac7a1a8804230060fe56a5d828c620ee2d70acd31aef80705a01627da5946", size = 24737, upload-time = "2026-07-16T22:47:03.754Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0a/0d97486364bdf079631fc9b72b9cad42579fe2f0ce575fc67208037ce041/pybcj-1.0.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af962c96a799ee7b13b9747a73240ba7f73e8643c276492f10e1dc60b564b295", size = 24980, upload-time = "2026-07-16T22:47:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/b7/28/f50bbf5c154f6eebca31789228b547e135fe541d46c85a026cadb842e8f2/pybcj-1.0.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:067982ad967ce68ae28c13cc27f96d8b65b5f497819dd4ad5c4f05ffc2fb2ae3", size = 53385, upload-time = "2026-07-16T22:47:06.039Z" }, + { url = "https://files.pythonhosted.org/packages/8e/4f/9f226e64cf82b59d9f803e59f32496edfcd52a40305378eb1dd1f78bb18d/pybcj-1.0.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f23a36e978f4620db473b08239ab56dc57a479c83fd5d3808bc95e5bdbc1a703", size = 52372, upload-time = "2026-07-16T22:47:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d7/b52cbdaee0e41fd44edcf032dbcfe5bce7a4b0a8f9fe3fb38ec402fe99a7/pybcj-1.0.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ba3b8f1efd1ebbe3ee99a599c6ea745877a41675a0ade763903b644ccd58da97", size = 51326, upload-time = "2026-07-16T22:47:08.53Z" }, + { url = "https://files.pythonhosted.org/packages/52/16/cafe2644f876639ae85c0a6fbe262c11f8065dd4b806eb4f6ace190076a3/pybcj-1.0.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09c3fe78d325a499fdd0a003b04b0009a3921666dc590d1b7a496dc365eef3a9", size = 50874, upload-time = "2026-07-16T22:47:09.752Z" }, + { url = "https://files.pythonhosted.org/packages/87/c4/767af5f90007cf5b138b2b4b40cf34998ca143fb22210c9d718d04eb2b22/pybcj-1.0.8-cp314-cp314-win_amd64.whl", hash = "sha256:0074af187b1acce9e31717fc9aa42bc36b67f1a44088a8b44f4f4792af688ee6", size = 25225, upload-time = "2026-07-16T22:47:11.144Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/bdbe671d8156ef0f3d8a5ac4d0bccc98bf1d573e1bb10ff6225383211351/pybcj-1.0.8-cp314-cp314-win_arm64.whl", hash = "sha256:458a5bff57f36fba7af822df64da335fa1781d592dd7f802f347ae1f75939aa0", size = 24460, upload-time = "2026-07-16T22:47:12.479Z" }, + { url = "https://files.pythonhosted.org/packages/c3/13/45ec85efc37d71b3def7ad755d9ab964c1a00e9ebc02fd1cd6b199f2535f/pybcj-1.0.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:083b41b59672d4aa893b44769bed7a41abfde43211b27d47a0d4d66a59fdafad", size = 33916, upload-time = "2026-07-16T22:47:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c8/0988ce2bd104c24285a967e2dbddf9c07e0055354fae6a9ccace4202ef66/pybcj-1.0.8-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d432a32bfc23475b7fd62a5eb1c3243a8329ff95aa0e3c941d57902dc6e7c87", size = 25026, upload-time = "2026-07-16T22:47:14.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/37/ba7823ed5831d656eb7e841e026492d8540b59235210b68ba885c8bd0219/pybcj-1.0.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cb1601253a15abfaba42e0bdbde4d459d2afcafdc97dee0d9b698e589167fd8d", size = 25268, upload-time = "2026-07-16T22:47:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/74/1e/572c7e32da9bca384aa28401c09c153eb5bf1b0c64e249e9f13fbbdfadae/pybcj-1.0.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78498748ebea7e1483f9cfdeac4a08687f6e2773e3a4223c420ab1beae95d75d", size = 59685, upload-time = "2026-07-16T22:47:17.227Z" }, + { url = "https://files.pythonhosted.org/packages/67/09/737dcd04aa137c91e33b89b8d68b763419c39feff14c9cf5eeb945954a6b/pybcj-1.0.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3817997e73d60ccbc731a47e007fe02213eee816cfe6070eb8347c6286aa3cc2", size = 57872, upload-time = "2026-07-16T22:47:18.476Z" }, + { url = "https://files.pythonhosted.org/packages/53/31/519a41bf9ea3c58368bd72e6bd3a2eaf735aef206e715e87e2d0c4f5bb6f/pybcj-1.0.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2a9146d8f3df70305dba6cb9c3d25f285a9a69309e4941333962f5408a244be", size = 56908, upload-time = "2026-07-16T22:47:19.85Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/fdc7b48e1b27f5cd44f554ca730e1928074d51db5bc640ec92323c46a689/pybcj-1.0.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5824e498e2dfafc19a2de2bdf54e5c2857144d84983ac8e13e54b1f7b78e3f50", size = 56063, upload-time = "2026-07-16T22:47:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c2/3d3d99065816de8acafc77917ac4c0b8e8df74f407ec2955a26f8b641a67/pybcj-1.0.8-cp314-cp314t-win_amd64.whl", hash = "sha256:331c21f0636150d4b846e0749cb0e8d6cbd133692368b391f9ba75dc02d5c51f", size = 25481, upload-time = "2026-07-16T22:47:22.357Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/0901ba3b67842dbf4263c832ab8b05d6441eabfd97bec1d77d27b90a7a4c/pybcj-1.0.8-cp314-cp314t-win_arm64.whl", hash = "sha256:935e0588be20a2b7c3554a6415d1fdb66acfde037df718b7927033b658428518", size = 24893, upload-time = "2026-07-16T22:47:23.547Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, +] + +[[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 = "pyppmd" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/d7/803232913cab9163a1a97ecf2236cd7135903c46ac8d49613448d88e8759/pyppmd-1.3.1.tar.gz", hash = "sha256:ced527f08ade4408c1bfc5264e9f97ffac8d221c9d13eca4f35ec1ec0c7b6b2e", size = 1351815, upload-time = "2025-11-27T22:08:44.175Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/89/696046e53c7aea98bb563aed3f15c3e2fa20c33e3d6c9de3c20992c586cc/pyppmd-1.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3faa58ab2ebe3b13ec23b1904639d687fb727270d2962fd2d239ca00fd6eb865", size = 77796, upload-time = "2025-11-27T22:07:50.362Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/a708cdb58e76889bc65201eda12211486548ffe00ecddc5dfbdce6d4d252/pyppmd-1.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27703f041ee96912a5410fd3ce31c5cde32f9323bd67f72f100bd960ee67bf13", size = 48185, upload-time = "2025-11-27T22:07:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/f299535c18009addb866d89a7044f1086e4eecf50542e57ff5bb15840195/pyppmd-1.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e773d8353b36f7e7973a43526993fb276b98a97839cb5dc8f4e6465ad873f41a", size = 48496, upload-time = "2025-11-27T22:07:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/4a59b73ea8e305f9192ee26ceb7c3d57e17ccb9bcca0e99ef335db29fcf7/pyppmd-1.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37b1883accf840cb0b711785d353f8548853a1401d381da007c0aec362f3ffac", size = 142620, upload-time = "2025-11-27T22:07:54.411Z" }, + { url = "https://files.pythonhosted.org/packages/88/d7/fe32c2a4f8539365e6292aed25545830a5e718a510cdb4caddd6fd8d8056/pyppmd-1.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bd6d179ad39b6191ca0cbe62fb9592f33f49277b4384ad7bc5eb0e6ca27ebee", size = 144306, upload-time = "2025-11-27T22:07:55.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/e3907cf263b58f4ed4c5315bc1ac91721c85332fd0d73a89e3e2752904ef/pyppmd-1.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:806cf8d33606e44bf5ff5786c57891f57993f1eef1c763da3c58ea97de3a13c8", size = 139522, upload-time = "2025-11-27T22:07:57.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/22d5f93251e481f23bf741dde7f59cfc3e315f60b32b63f215a2d7bb8944/pyppmd-1.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1826cbce9a2c944aa08df79310a7e6d4a61fd20636b6dff64a77ea4bc43da30f", size = 142340, upload-time = "2025-11-27T22:07:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/38d36c1394d5e331db9708dae08863c87c059bd5b6e43b20234be02a0503/pyppmd-1.3.1-cp312-cp312-win32.whl", hash = "sha256:d3ff96671319318d941dd34300d641745048e8a3251b077bddf98652d6ddc513", size = 42102, upload-time = "2025-11-27T22:08:00.124Z" }, + { url = "https://files.pythonhosted.org/packages/ee/29/1398c6f67dfb66367babeed2980caee837d0a488705e3dff7ff159e017e7/pyppmd-1.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:c8c1ad39e7ebde71bf5a54cf61f489bf4790f1dd0beb70dc2e8f5ad3329d7ca7", size = 46957, upload-time = "2025-11-27T22:08:01.587Z" }, + { url = "https://files.pythonhosted.org/packages/d2/41/ee3193c16472a5a1c5f1965abb8fa87a7ad455c39688e9a0c2d87d6a7027/pyppmd-1.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:391b2bf76d7dc45b343781754d0b734dcbf539b92667986a343f5488c4bf9ca0", size = 45214, upload-time = "2025-11-27T22:08:02.721Z" }, + { url = "https://files.pythonhosted.org/packages/f0/01/7cc3854b0e1304b90d2435e946db5e1c42e103adf840a68400002fc838b4/pyppmd-1.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4b4edb3e9619fd0bc39c1a07eb03e8731db833a93b23134f36c7ef581a94b37a", size = 77800, upload-time = "2025-11-27T22:08:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/caf6305224b9432ea7373670d101392f620c1ce741d6f95458b1fb9a16a4/pyppmd-1.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8b5c813e462c91048b88e2adfbcc0c69f2c905f70097001d32066f86f675bd4", size = 48195, upload-time = "2025-11-27T22:08:05.323Z" }, + { url = "https://files.pythonhosted.org/packages/45/82/8c5d3f0f738a3cf5c209d1471efda105a16417c0aaa91a2ad39efb3d4efb/pyppmd-1.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e8d372d9fac382183e0371cf0c2d736b494b1857a1befe98d563342b1205265b", size = 48482, upload-time = "2025-11-27T22:08:06.441Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/09ab8b4f00473f210284dacf837cae8355ad900f3fd8fd98b2bba12de4fa/pyppmd-1.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b765ae21f7ed2f4ea8f32bfd9e3a4a8d738e73fc8f8dcddec9cbe2c898d60be", size = 142828, upload-time = "2025-11-27T22:08:07.611Z" }, + { url = "https://files.pythonhosted.org/packages/64/fd/673d429df719affa1445611288aab6111c0bc87e51eb4677fe2421a1dd64/pyppmd-1.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd00522ddfcc292304577386b6c217758c0c10e1fb9ce7877ad7d3b7b821a808", size = 144502, upload-time = "2025-11-27T22:08:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0f/64dad213f56eaf23210ac7d3a5dedb19ad8f186f8b8682f65e7d51d9fa4a/pyppmd-1.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3de62099ff2ca876c2d39bc547bcba6f7b878988663abd782a5bad4edac3bb44", size = 139718, upload-time = "2025-11-27T22:08:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/74/61/bd5dbe8d7374748687d48f0bc44e6b930470e09552b1bbc65ef899ab673e/pyppmd-1.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:011f845de195d60fe973a635a1f4be981b7d80f357a8acb1b2d83bdf5087c808", size = 142578, upload-time = "2025-11-27T22:08:11.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/ae/0af00a9b1cd59b6821eb182413033d5ddbe05dfcde7511e3a6b1a5b83f6d/pyppmd-1.3.1-cp313-cp313-win32.whl", hash = "sha256:7d61bd01f25289b6ae54832db4254602fb0c6d105f6e6bf0aee39b803b698b98", size = 42102, upload-time = "2025-11-27T22:08:13.297Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/311d8faca142f39b4d158c94ed7eb237a197d41dd7eb67f53442a7904e04/pyppmd-1.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:df8d84ab72381058a964ba66e5e81ed52dbd0b5ad734a5ef8353452983506098", size = 46962, upload-time = "2025-11-27T22:08:14.759Z" }, + { url = "https://files.pythonhosted.org/packages/bb/42/24b59b6bb73a3fec96c7f81521d146c2ac89ffcb89cf9e848f62e0660381/pyppmd-1.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:124a04aab6936ba011f9ad57067798c7f052fdb1848b0cc4318606eea55475e6", size = 45209, upload-time = "2025-11-27T22:08:15.933Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/ac2a011af1c7c40b6482e60d85d267ac5923fb8794b51bfddbb56b04d1ec/pyppmd-1.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ea53f71ac16e113599b8441a9d8b6dcd71cfdf15cdb33ba5151810b8e656c5ec", size = 77897, upload-time = "2025-11-27T22:08:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/20/13/737f4dac06685865d23f51b7061dbe9373c7bcc60b5b6456cd08301bc807/pyppmd-1.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:985c8703b53e5f68fe17f653e96748d60b1f855676c852a6e67cd472eb853671", size = 48268, upload-time = "2025-11-27T22:08:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d3/c50ebaee8b8a064fe9a534e98df5051e309efb705728aadff4e6893cd87f/pyppmd-1.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33daa996ad5203c665c0b55aff6329817b2cb7fa95f2c33a2e83ed0121b400cb", size = 48521, upload-time = "2025-11-27T22:08:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/80/6c/4a97c0a0ab7640aeddde4843cb6381b80ecb71fe2c6c48b9032d60586b11/pyppmd-1.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b49870c6d7194f6eb80f30335ca03596d153e02fcde2c222e4f1202ac25f7fcf", size = 142892, upload-time = "2025-11-27T22:08:20.972Z" }, + { url = "https://files.pythonhosted.org/packages/8f/22/744c32c7da3de171d9e62a1b2cb4104544ac778358565a3dbc06a2253324/pyppmd-1.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:385e92c97c42e8a6f0bfc0e4acfc6c074cb1ba3a2f650f292696dd9f19e2e603", size = 144550, upload-time = "2025-11-27T22:08:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/84af6808e0754923062122d3ee9f0532050f1612069d558bb5d53978e67a/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017a1e2903f1c3147a1046db486990d401e8a25eb52c320b1fc2fb3e7b83cbeb", size = 139850, upload-time = "2025-11-27T22:08:23.863Z" }, + { url = "https://files.pythonhosted.org/packages/20/5e/dc6166ea7929d442625301289d99313a5b358e3cb2e927a6bd913047e8ee/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f2770a4b777c0c5236b3d9294b7bf4bc15538c95d45b2079eb8ebc1298e62e37", size = 142589, upload-time = "2025-11-27T22:08:25.228Z" }, + { url = "https://files.pythonhosted.org/packages/77/92/adc6b12ddf11173a02fd631010f88ad4fe4c532363959638d5d53583a3d9/pyppmd-1.3.1-cp314-cp314-win32.whl", hash = "sha256:b9d54cd59ce97f2ba57be1da91b3d874d129faca21c9565d7afec111f942e6a1", size = 42761, upload-time = "2025-11-27T22:08:26.917Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/5003a413d9f2743298a070df6713a75dedccc470e7adeeced5b43cad7418/pyppmd-1.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0e64247618cb150d2909beb0137da3084fef1d3479b4cc73b5b47fda7611abf9", size = 47806, upload-time = "2025-11-27T22:08:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/07/b7/db927e1df7fc3132cb57960f4ea23bf174c7e86ccec22857e6a35cccdae7/pyppmd-1.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:d354d6e551d2630b0ac98f27e3ad63e86cdcac9ce2115b5dfe46e2c9d3f4e82c", size = 46122, upload-time = "2025-11-27T22:08:29.191Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5f/3150227624f374b06e331a7f67ae3383e796dd90973ee17ec3e35d30524c/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2d77ed79662c32e2551748d59763cfe3dcd10855bf3495937e3d5e5917507818", size = 78876, upload-time = "2025-11-27T22:08:30.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/11a3bba62d1d92050f4dd86db810062b506ec76d20b6e00b4ab567ff15e4/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6a1f92b94635c23d85270bb26db25cc0db544e436af86efc1cf58302d71d5af1", size = 48830, upload-time = "2025-11-27T22:08:32.087Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/e8f50cf89c689543d5a16c7be3df3c4afd43cbeb1a019b84ff9e82f13415/pyppmd-1.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:610f214f2405e27eb5a3dad7fa15f385cfc42141a01cda71995d9c1e0b09fab9", size = 48956, upload-time = "2025-11-27T22:08:33.567Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0f/944b30679a8dea3ea95a66531ee30cb6feb5d011ec7fd6832069d9130bf3/pyppmd-1.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae81a14895498d9a23429d92114c98da478b74b8e33251527d7cff3e01c09de0", size = 152872, upload-time = "2025-11-27T22:08:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/ae/87/1e48ea92994f4c72dc9b5520a6a386b21c524d3d2969c2c2a5c325929ad7/pyppmd-1.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1683e6d1ba09e377e0ae02de3a518191a3d63ccdb0b6037c74e6ddf577b5644", size = 154210, upload-time = "2025-11-27T22:08:36.368Z" }, + { url = "https://files.pythonhosted.org/packages/3f/21/650911f98c4cb360442724bbdade27cb3679b0587ea77e73512693d2918d/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e4d74fa0f3531e9dadc56e0ace41bce82d3c0babed47b3f224101dc0dbde7287", size = 147636, upload-time = "2025-11-27T22:08:37.657Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/4f8cfc1dd67b04ced8c10669fa14c4e35a42cf4a84e1101793735a82d5f0/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f8d6375b18b9c79127fee0885cfd52e2e983edb67041464309426571d38dcd4", size = 150998, upload-time = "2025-11-27T22:08:38.951Z" }, + { url = "https://files.pythonhosted.org/packages/c4/da/dc9e5e10bc56056bd8c33e533517cb327752cbaf172688bba3c23f6b2318/pyppmd-1.3.1-cp314-cp314t-win32.whl", hash = "sha256:de87f7acd575fb07a4ff42d41bcc071570fe759a36f345f1f54f574ecfccfc5b", size = 43016, upload-time = "2025-11-27T22:08:40.234Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a0/46dc15549ad2c0427a9825730e6bdf342045ad410543368afd4389a16f36/pyppmd-1.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:76e4800aa67292b4cc80058fd29b39e02a5dded721af9fe5654f356ef24307f4", size = 48636, upload-time = "2025-11-27T22:08:41.45Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7b/7e9a83848de928c26f0ab3ee105c0da63a932a0804a94807205e6313e73a/pyppmd-1.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e066cbf1d335fe20480cd8ecc10848ba78d99fe6d1e44ea00def48feaf46afdf", size = 46402, upload-time = "2025-11-27T22:08:42.804Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[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 = "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" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c3/1b817965ac12dc002d7c9cd7dfffdd7d4fbf9b45763ef2ffe7b86ee94670/soupsieve-2.10.tar.gz", hash = "sha256:49e9380d7d2905463583bafe285e818c7366a9ed7b3aee221c1ac79c905d8bc0", size = 124337, upload-time = "2026-09-24T02:36:35.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" }, +] + +[[package]] +name = "texttable" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/dc/0aff23d6036a4d3bf4f1d8c8204c5c79c4437e25e0ae94ffe4bbb55ee3c2/texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638", size = 12831, upload-time = "2023-10-03T09:48:12.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/99/4772b8e00a136f3e01236de33b0efda31ee7077203ba5967fcc76da94d65/texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917", size = 10768, upload-time = "2023-10-03T09:48:10.434Z" }, +] + +[[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 = "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 = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] diff --git a/experiments/measure-lab/.gitignore b/experiments/measure-lab/.gitignore new file mode 100644 index 000000000..60ef050d0 --- /dev/null +++ b/experiments/measure-lab/.gitignore @@ -0,0 +1,16 @@ +# Per-person signing overrides; see Config/Local.xcconfig.example. +Config/Local.xcconfig + +# Per-user Xcode state inside the committed project. +xcuserdata/ +*.xcuserstate + +# Build output. +DerivedData/ +build/ +Geometry/.build/ +Geometry/.swiftpm/ + +# Exported sessions copied off a phone go in data/. They show a real home; never commit them. +data/ +*.zip diff --git a/experiments/measure-lab/Config/Info.plist b/experiments/measure-lab/Config/Info.plist new file mode 100644 index 000000000..bffa08a58 --- /dev/null +++ b/experiments/measure-lab/Config/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Measure Lab + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSCameraUsageDescription + Measure Lab uses the camera to measure walls and the ground around them. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIFileSharingEnabled + + UILaunchScreen + + UIRequiredDeviceCapabilities + + arkit + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/experiments/measure-lab/Config/Local.xcconfig.example b/experiments/measure-lab/Config/Local.xcconfig.example new file mode 100644 index 000000000..da65d8a63 --- /dev/null +++ b/experiments/measure-lab/Config/Local.xcconfig.example @@ -0,0 +1,6 @@ +// Copy to Local.xcconfig (gitignored) to sign for a device. +// DEVELOPMENT_TEAM: the 10-character Team ID from developer.apple.com > Membership. +// BUNDLE_ID_PREFIX: a reverse-DNS prefix your team can register; the app id becomes .measurelab. + +DEVELOPMENT_TEAM = ABCDE12345 +BUNDLE_ID_PREFIX = com.example.yourname diff --git a/experiments/measure-lab/Config/Shared.xcconfig b/experiments/measure-lab/Config/Shared.xcconfig new file mode 100644 index 000000000..6430efccf --- /dev/null +++ b/experiments/measure-lab/Config/Shared.xcconfig @@ -0,0 +1,10 @@ +// Build settings shared by Debug and Release. project.yml attaches this file to the MeasureLab target. +// +// A bundle identifier can be registered by only one Apple Developer team, so each person +// signing on a device sets their own BUNDLE_ID_PREFIX and DEVELOPMENT_TEAM in Local.xcconfig +// (gitignored; copy Local.xcconfig.example). Keeping them here instead of in the project file +// stops signing changes from showing up as project.pbxproj diffs. + +BUNDLE_ID_PREFIX = dev.housescanning + +#include? "Local.xcconfig" diff --git a/experiments/measure-lab/Geometry/Package.swift b/experiments/measure-lab/Geometry/Package.swift new file mode 100644 index 000000000..ef161059e --- /dev/null +++ b/experiments/measure-lab/Geometry/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version: 6.0 +// Measurement math for MeasureLab. Vectors are the standard library's SIMD3 (no `simd` +// module, no ARKit), and trigonometry comes from Foundation, so the same code and tests run on +// macOS, iOS and Linux. +import PackageDescription + +let package = Package( + name: "MeasureGeometry", + products: [ + .library(name: "MeasureGeometry", targets: ["MeasureGeometry"]) + ], + targets: [ + .target(name: "MeasureGeometry"), + .testTarget(name: "MeasureGeometryTests", dependencies: ["MeasureGeometry"]), + ] +) diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Camera.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Camera.swift new file mode 100644 index 000000000..6f9cfe49c --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Camera.swift @@ -0,0 +1,153 @@ +import Foundation + +/// Pinhole intrinsics of one saved image, in pixels of that image. +/// +/// For ARKit these come from `ARCamera.intrinsics` and describe the unrotated (landscape) sensor +/// image. Rotating or cropping the image changes them. +public struct CameraIntrinsics: Sendable, Equatable { + public var fx: Double + public var fy: Double + public var cx: Double + public var cy: Double + + public init(fx: Double, fy: Double, cx: Double, cy: Double) { + self.fx = fx + self.fy = fy + self.cx = cx + self.cy = cy + } +} + +/// Camera-to-world rigid transform: the camera's axes and position in world coordinates. +/// +/// Camera space follows ARKit: +x to the right of the unrotated sensor image, +y up in that image, +/// and the camera looks along -z. +public struct CameraPose: Sendable, Equatable { + public var xAxis: SIMD3 + public var yAxis: SIMD3 + public var zAxis: SIMD3 + public var position: SIMD3 + + public init(xAxis: SIMD3, yAxis: SIMD3, zAxis: SIMD3, position: SIMD3) { + self.xAxis = xAxis + self.yAxis = yAxis + self.zAxis = zAxis + self.position = position + } + + /// Reads a 4×4 matrix stored column by column (16 numbers), the layout `simd_float4x4` uses. + public init(columnMajor values: [Double]) throws(GeometryInputError) { + guard values.count == 16 else { + throw .wrongMatrixSize(expected: 16, actual: values.count) + } + let bottomRow = [values[3], values[7], values[11], values[15]] + guard bottomRow == [0, 0, 0, 1] else { + throw .notAnAffineTransform(bottomRow: bottomRow) + } + xAxis = SIMD3(values[0], values[1], values[2]) + yAxis = SIMD3(values[4], values[5], values[6]) + zAxis = SIMD3(values[8], values[9], values[10]) + position = SIMD3(values[12], values[13], values[14]) + } + + /// The matrix as 16 numbers, column by column. + public var columnMajor: [Double] { + [ + xAxis.x, xAxis.y, xAxis.z, 0, + yAxis.x, yAxis.y, yAxis.z, 0, + zAxis.x, zAxis.y, zAxis.z, 0, + position.x, position.y, position.z, 1, + ] + } + + func toWorld(_ cameraVector: SIMD3) -> SIMD3 { + xAxis * cameraVector.x + yAxis * cameraVector.y + zAxis * cameraVector.z + } + + /// Inverse rotation. Valid because the axes are orthonormal. + func toCamera(_ worldVector: SIMD3) -> SIMD3 { + SIMD3(xAxis.dot(worldVector), yAxis.dot(worldVector), zAxis.dot(worldVector)) + } + + /// Angle of the rotation that takes this pose's orientation to `other`'s, in degrees. + /// + /// For rotations A and B, the relative rotation AᵀB has trace 1 + 2·cos θ, and each diagonal + /// entry of AᵀB is the dot product of matching axes. + public func rotationDegrees(to other: CameraPose) -> Double { + let trace = xAxis.dot(other.xAxis) + yAxis.dot(other.yAxis) + zAxis.dot(other.zAxis) + return acosDegrees((trace - 1) / 2) + } + + /// Straight-line distance between the two camera positions, in meters. + public func distance(to other: CameraPose) -> Double { + (other.position - position).length + } +} + +/// A half-line in world coordinates with a unit-length direction. +public struct Ray: Sendable, Equatable { + public let origin: SIMD3 + public let direction: SIMD3 + + /// Normalizes `direction`. Throws for a zero or non-finite direction. + public init(origin: SIMD3, direction: SIMD3) throws(GeometryInputError) { + let length = direction.length + guard length.isFinite, length > 0 else { + throw .degenerateDirection + } + self.origin = origin + self.direction = direction / length + } + + public func point(at t: Double) -> SIMD3 { + origin + direction * t + } + + /// Degrees below the horizon; negative when the ray points above it. + public var lookDownDegrees: Double { + 90 - acosDegrees(-direction.y) + } +} + +public enum GeometryInputError: Error, Sendable, Equatable { + case wrongMatrixSize(expected: Int, actual: Int) + case notAnAffineTransform(bottomRow: [Double]) + case degenerateDirection +} + +/// Projection between image pixels and world rays for one saved frame. +/// +/// Pixel coordinates are continuous: (0, 0) is the top-left corner of the saved image and +/// (width, height) its bottom-right corner, with v growing downward. That puts pixel centers at +/// half-integers; the half-pixel offset from the other convention is under 0.03° at iPhone focal +/// lengths. +public struct CameraFrame: Sendable, Equatable { + public var intrinsics: CameraIntrinsics + public var pose: CameraPose + + public init(intrinsics: CameraIntrinsics, pose: CameraPose) { + self.intrinsics = intrinsics + self.pose = pose + } + + /// The world ray through pixel (u, v). + /// + /// In camera space the pixel lies along ((u − cx)/fx, −(v − cy)/fy, −1): image v grows downward + /// while camera y grows upward, and the camera looks along −z. This is the pixel ray in + /// docs/00-overview.md (Conventions the code relies on). Throws only for a pose whose axes are zero or non-finite. + public func ray(throughPixel u: Double, _ v: Double) throws(GeometryInputError) -> Ray { + let k = intrinsics + let cameraDirection = SIMD3((u - k.cx) / k.fx, -(v - k.cy) / k.fy, -1) + return try Ray(origin: pose.position, direction: pose.toWorld(cameraDirection)) + } + + /// The pixel a world point projects to, or nil when the point is not in front of the camera. + /// The result can fall outside the image. + public func project(_ point: SIMD3) -> (u: Double, v: Double)? { + let p = pose.toCamera(point - pose.position) + guard p.z < 0 else { return nil } + let depth = -p.z + let k = intrinsics + return (k.cx + k.fx * p.x / depth, k.cy - k.fy * p.y / depth) + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/FrameResetGate.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/FrameResetGate.swift new file mode 100644 index 000000000..3cfa7cf83 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/FrameResetGate.swift @@ -0,0 +1,27 @@ +/// Refuses frames from the previous AR world until the reset reports a fresh tracking cycle. +/// ARFrame timestamps and ProcessInfo.systemUptime use the same clock. +public struct FrameResetGate: Sendable, Equatable { + public let resetUptime: Double + private var sawTrackingReset = false + private var ready = false + + public init(resetUptime: Double) { + self.resetUptime = resetUptime + } + + /// A callback after the cutoff must report limited or unavailable tracking before a later + /// normal callback opens the gate. A normal callback alone cannot open the new world. + public mutating func trackingChanged(isNormal: Bool, at uptime: Double) { + guard uptime > resetUptime else { return } + if !isNormal { + sawTrackingReset = true + ready = false + } else if sawTrackingReset { + ready = true + } + } + + public func accepts(frameTimestamp: Double) -> Bool { + ready && frameTimestamp > resetUptime + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/KeyframeRouter.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/KeyframeRouter.swift new file mode 100644 index 000000000..540151b89 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/KeyframeRouter.swift @@ -0,0 +1,59 @@ +/// Routes each finished keyframe write to the session that reserved it. +/// +/// Writes finish on another thread and report back later, possibly after one or more session +/// switches. A closed session stays routable until every write it reserved has reported back, +/// success or failure, however many sessions open in between. +public struct KeyframeRouter: Sendable { + public enum Route: Sendable, Equatable { + /// The session still open for new keyframes. + case current + /// A closed session still waiting for this write; append it to that session's manifest. + case closed + /// A session this router never opened, or one already drained. + case unknown + } + + private var current: Session? + private var reported: [Session: Int] = [:] + /// Reservation totals of closed sessions that still wait for writes. + private var closing: [Session: Int] = [:] + + public init() {} + + public mutating func open(_ session: Session) { + current = session + reported[session] = 0 + } + + /// Closes the current session, which made `reserved` reservations in total. Returns true when + /// some of them have not reported back yet, so the caller must keep the session's manifest. + @discardableResult + public mutating func closeCurrent(reserved: Int) -> Bool { + guard let session = current else { return false } + current = nil + if reported[session, default: 0] >= reserved { + reported[session] = nil + return false + } + closing[session] = reserved + return true + } + + /// Records one write reporting back. `drained` is true when that was the last write a closed + /// session was waiting for; the caller can then release the session. + public mutating func report(for session: Session) -> (route: Route, drained: Bool) { + if session == current { + reported[session, default: 0] += 1 + return (.current, false) + } + guard let expected = closing[session] else { return (.unknown, false) } + let count = reported[session, default: 0] + 1 + guard count >= expected else { + reported[session] = count + return (.closed, false) + } + closing[session] = nil + reported[session] = nil + return (.closed, true) + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/KeyframeSelector.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/KeyframeSelector.swift new file mode 100644 index 000000000..fb3227bf6 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/KeyframeSelector.swift @@ -0,0 +1,71 @@ +/// Decides when a moving camera has changed enough to save another keyframe: after it moves +/// `minimumTranslation` meters or turns `minimumRotation` degrees (docs/00-overview.md, +/// Conventions the code relies on: 0.5 m or 15°). +/// +/// Spacing is measured from the newest keyframe that is saved or still being written. A write +/// is a reservation until the caller commits it; a cancelled reservation leaves spacing measured +/// from the last keyframe that did save, so one failed write doesn't suppress the next ones. +public struct KeyframeSelector: Sendable, Equatable { + public struct Reservation: Hashable, Sendable { + let id: Int + } + + public var minimumTranslation: Double + public var minimumRotation: Double + /// The newest keyframe that was written successfully. + public private(set) var lastSaved: CameraPose? + private var lastSavedID = -1 + private var pending: [Int: CameraPose] = [:] + /// Never reset, so a reservation from before `reset()` can't match a new one. + private var nextID = 0 + + public init(minimumTranslation: Double = 0.5, minimumRotation: Double = 15) { + self.minimumTranslation = minimumTranslation + self.minimumRotation = minimumRotation + } + + /// The pose spacing is measured from: the newest reservation still being written if it is + /// newer than the last saved keyframe, otherwise the last saved keyframe. + var anchor: CameraPose? { + if let newest = pending.max(by: { $0.key < $1.key }), newest.key > lastSavedID { + return newest.value + } + return lastSaved + } + + /// True for the first pose and for any pose that moved or turned past a threshold. + public func wantsKeyframe(at pose: CameraPose) -> Bool { + guard let anchor else { return true } + return anchor.distance(to: pose) >= minimumTranslation + || anchor.rotationDegrees(to: pose) >= minimumRotation + } + + /// Call before writing any keyframe, including ones saved for a tap. + public mutating func reserve(at pose: CameraPose) -> Reservation { + let id = nextID + nextID += 1 + pending[id] = pose + return Reservation(id: id) + } + + /// The write succeeded. Ignored for a reservation made before `reset()`. + public mutating func commit(_ reservation: Reservation) { + guard let pose = pending.removeValue(forKey: reservation.id) else { return } + if reservation.id > lastSavedID { + lastSaved = pose + lastSavedID = reservation.id + } + } + + /// The write failed. Ignored for a reservation made before `reset()`. + public mutating func cancel(_ reservation: Reservation) { + pending.removeValue(forKey: reservation.id) + } + + /// Forgets saved and pending keyframes, for a new session. + public mutating func reset() { + lastSaved = nil + lastSavedID = -1 + pending = [:] + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Length.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Length.swift new file mode 100644 index 000000000..53e7a8e70 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Length.swift @@ -0,0 +1,100 @@ +/// Conversions between ARKit meters and the feet and inches a tape reads. +public enum Length { + /// Exact by definition (international inch, 1959). + public static let metersPerInch = 0.0254 + public static let inchesPerFoot = 12.0 + + public static func meters(feet: Double, inches: Double) -> Double { + (feet * inchesPerFoot + inches) * metersPerInch + } + + public static func inches(meters: Double) -> Double { + meters / metersPerInch + } + + /// Splits a length into whole feet and remaining inches, rounded to `inchStep` (1/8 in by + /// default). The sign goes on `negative` so both parts stay non-negative. + public static func feetAndInches(meters: Double, inchStep: Double = 0.125) -> FeetAndInches { + let totalInches = (abs(inches(meters: meters)) / inchStep).rounded() * inchStep + let feet = (totalInches / inchesPerFoot).rounded(.down) + return FeetAndInches(negative: meters < 0 && totalInches > 0, feet: Int(feet), inches: totalInches - feet * inchesPerFoot) + } +} + +public struct FeetAndInches: Sendable, Equatable { + public let negative: Bool + public let feet: Int + public let inches: Double +} + +/// A reading typed from a tape: whole or decimal feet plus inches, where inches may be a decimal +/// ("3.25"), a fraction ("1/4"), or both ("3 1/4"). +public struct TapeReading: Sendable, Equatable { + public let feet: Double + public let inches: Double + + public var meters: Double { + Length.meters(feet: feet, inches: inches) + } + + /// Zero is allowed: the return-to-reference check tapes a gap that should be 0. + public init(feet: Double, inches: Double) throws(TapeEntryError) { + guard feet.isFinite, inches.isFinite, feet >= 0, inches >= 0 else { + throw .negativeOrNotFinite + } + self.feet = feet + self.inches = inches + } + + /// One empty field counts as zero. Throws when both are empty or either does not parse. + public init(feetText: String, inchesText: String) throws(TapeEntryError) { + guard !(feetText + inchesText).allSatisfy(\.isWhitespace) else { + throw .empty + } + let feet = try Self.parseNumber(feetText, field: .feet) + let inches = try Self.parseNumber(inchesText, field: .inches) + try self.init(feet: feet, inches: inches) + } + + /// Accepts "", "3", "3.25", "1/4" and "3 1/4". Empty text is zero. + private static func parseNumber(_ text: String, field: TapeEntryError.Field) throws(TapeEntryError) -> Double { + let parts = text.split(separator: " ") + switch parts.count { + case 0: + return 0 + case 1: + if let value = decimal(parts[0]) ?? fraction(parts[0]) { return value } + case 2: + if let whole = decimal(parts[0]), let part = fraction(parts[1]) { return whole + part } + default: + break + } + throw .unreadable(field: field, text: text) + } + + private static func decimal(_ text: Substring) -> Double? { + text.contains("/") ? nil : Double(text) + } + + private static func fraction(_ text: Substring) -> Double? { + let halves = text.split(separator: "/", omittingEmptySubsequences: false) + guard + halves.count == 2, + let numerator = Double(halves[0]), + let denominator = Double(halves[1]), + denominator > 0 + else { return nil } + return numerator / denominator + } +} + +public enum TapeEntryError: Error, Sendable, Equatable { + public enum Field: String, Sendable { + case feet + case inches + } + + case unreadable(field: Field, text: String) + case negativeOrNotFinite + case empty +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Measurements.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Measurements.swift new file mode 100644 index 000000000..1b9e8f468 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Measurements.swift @@ -0,0 +1,91 @@ +/// Distances between two world points. World y is up, so "horizontal" ignores y. +public struct PointSeparation: Sendable, Equatable { + /// Straight-line 3D distance, in meters. + public let straight: Double + /// Distance in the horizontal plane, in meters. + public let horizontal: Double + /// Height of `to` minus height of `from`, in meters. + public let rise: Double + + public init(from a: SIMD3, to b: SIMD3) { + let delta = b - a + straight = delta.length + horizontal = delta.horizontal.length + rise = delta.y + } +} + +/// The quantity a tape reading is compared against. +public enum MeasuredQuantity: String, Sendable, CaseIterable { + /// 3D distance between two points. + case straight + /// Horizontal distance between two points. + case horizontal + /// Absolute height difference between two points. + case vertical + /// Absolute along-wall distance between two points. + case alongWall + /// Perpendicular horizontal distance from a point to the wall line (facing gap). + case gapToWall + /// Height of a point above the wall's ground line. Signed: negative below the line. + case heightAboveGround +} + +/// A tape reading compared with the app's value for the same quantity. +public struct TapeComparison: Sendable, Equatable { + /// App value, in meters. + public let measured: Double + /// Tape value, in meters. + public let tape: Double + + public init(measured: Double, tape: Double) { + self.measured = measured + self.tape = tape + } + + /// App minus tape, in meters. Positive means the app read long. + public var error: Double { + measured - tape + } + + public var errorInches: Double { + error / Length.metersPerInch + } +} + +/// What a measurement runs to: another point, or a wall. +public enum MeasurementTarget: Sendable { + case point(SIMD3) + case wall(Wall) +} + +/// Every quantity that applies to a pair, in meters. +/// +/// Point to point gives straight, horizontal and vertical distance, plus along-wall distance when +/// a reference wall is given; all are non-negative. Point to wall gives the facing gap and the +/// point's signed height above the wall's ground line, which is negative for a point below it +/// (see `measurementWarnings`). +public func measuredValues( + from point: SIMD3, + to target: MeasurementTarget, + referenceWall: Wall? = nil +) -> [MeasuredQuantity: Double] { + switch target { + case .point(let other): + let separation = PointSeparation(from: point, to: other) + var values: [MeasuredQuantity: Double] = [ + .straight: separation.straight, + .horizontal: separation.horizontal, + .vertical: abs(separation.rise), + ] + if let referenceWall { + values[.alongWall] = abs(referenceWall.alongDistance(from: point, to: other)) + } + return values + case .wall(let wall): + return [ + .gapToWall: wall.gap(to: point), + .heightAboveGround: wall.heightAboveGround(point), + ] + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/PortraitFillMapping.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/PortraitFillMapping.swift new file mode 100644 index 000000000..c2e2de0d2 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/PortraitFillMapping.swift @@ -0,0 +1,42 @@ +/// Maps between pixels of a landscape sensor image and points of a portrait view that shows it +/// rotated 90° clockwise, scaled to fill the view, centered and cropped. +/// +/// This is how an iPhone held upright shows the back camera's `capturedImage`: the image's left +/// edge (u = 0) is at the top of the screen and its top edge (v = 0) at the right. The frozen-frame +/// view draws the image exactly this way, so a tap there maps back to the saved image's pixel. +public struct PortraitFillMapping: Sendable, Equatable { + public let imageWidth: Double + public let imageHeight: Double + public let viewWidth: Double + public let viewHeight: Double + /// View points per image pixel. The rotated image is imageHeight wide and imageWidth tall. + public let scale: Double + + /// Nil when a dimension is zero, negative or not finite, or the scaled image would overflow, + /// for example before SwiftUI has laid out the camera view. + public init?(imageWidth: Double, imageHeight: Double, viewWidth: Double, viewHeight: Double) { + guard [imageWidth, imageHeight, viewWidth, viewHeight].allSatisfy({ $0.isFinite && $0 > 0 }) else { return nil } + let scale = max(viewWidth / imageHeight, viewHeight / imageWidth) + guard scale.isFinite, scale > 0, (imageHeight * scale).isFinite, (imageWidth * scale).isFinite else { return nil } + self.imageWidth = imageWidth + self.imageHeight = imageHeight + self.viewWidth = viewWidth + self.viewHeight = viewHeight + self.scale = scale + } + + /// Where the rotated image's top-left corner sits in the view; negative on a cropped side. + var offset: (x: Double, y: Double) { + ((viewWidth - imageHeight * scale) / 2, (viewHeight - imageWidth * scale) / 2) + } + + public func imagePixel(forViewPoint x: Double, _ y: Double) -> (u: Double, v: Double) { + let rotatedX = (x - offset.x) / scale + let rotatedY = (y - offset.y) / scale + return (rotatedY, imageHeight - rotatedX) + } + + public func viewPoint(forImagePixel u: Double, _ v: Double) -> (x: Double, y: Double) { + (offset.x + (imageHeight - v) * scale, offset.y + u * scale) + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Triangulation.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Triangulation.swift new file mode 100644 index 000000000..6b8bbb56a --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Triangulation.swift @@ -0,0 +1,76 @@ +/// A point located from two taps on the same feature in two frames taken from different places. +/// +/// Two tap rays rarely meet exactly, so the point is the midpoint of their closest approach. With +/// w = o1 − o2, a = d1·d1, b = d1·d2, c = d2·d2, d = d1·w, e = d2·w: +/// +/// t1 = (b·e − c·d) / (a·c − b²) t2 = (a·e − b·d) / (a·c − b²) +/// +/// Depth error grows as the rays approach parallel, so small ray angles are refused. Two taps on +/// different physical points can still pass every gate when their rays happen to nearly meet. +public struct Triangulation: Sendable, Equatable { + public let point: SIMD3 + /// Closest point on each ray to the other. + public let closest1: SIMD3 + public let closest2: SIMD3 + public let t1: Double + public let t2: Double + /// Distance between the closest points, in meters: how far the two rays miss each other. + public let gap: Double + /// Angle between the two ray lines in degrees, from 0 (parallel) to 90. + public let rayAngle: Double + /// Distance between the two camera positions, in meters. + public let baseline: Double + + public init(_ ray1: Ray, _ ray2: Ray, gates: TriangulationGates = TriangulationGates()) throws(TriangulationError) { + let d1 = ray1.direction + let d2 = ray2.direction + let angle = acosDegrees(abs(d1.dot(d2))) + guard angle >= gates.minimumRayAngle else { + throw .rayAngleTooSmall(angle: angle, minimum: gates.minimumRayAngle) + } + let w = ray1.origin - ray2.origin + let a = d1.dot(d1) + let b = d1.dot(d2) + let c = d2.dot(d2) + let d = d1.dot(w) + let e = d2.dot(w) + // Nonzero: the angle gate keeps the rays at least 15° from parallel. + let denominator = a * c - b * b + let t1 = (b * e - c * d) / denominator + let t2 = (a * e - b * d) / denominator + guard t1 > 0, t2 > 0 else { + throw .behindCamera(t1: t1, t2: t2) + } + let closest1 = ray1.point(at: t1) + let closest2 = ray2.point(at: t2) + let gap = (closest1 - closest2).length + guard gap <= gates.maximumGap else { + throw .raysMiss(gap: gap, maximum: gates.maximumGap) + } + point = (closest1 + closest2) / 2 + self.closest1 = closest1 + self.closest2 = closest2 + self.t1 = t1 + self.t2 = t2 + self.gap = gap + rayAngle = angle + baseline = w.length + } +} + +/// Thresholds from the research note's triangulation gates. Hypotheses, not calibrated values. +public struct TriangulationGates: Sendable, Equatable { + /// Smallest accepted angle between the rays, in degrees. + public var minimumRayAngle: Double = 15 + /// Largest accepted miss distance between the rays: 2 in, in meters. + public var maximumGap: Double = 2 * Length.metersPerInch + + public init() {} +} + +public enum TriangulationError: Error, Sendable, Equatable { + case rayAngleTooSmall(angle: Double, minimum: Double) + /// The closest approach lies behind at least one camera. + case behindCamera(t1: Double, t2: Double) + case raysMiss(gap: Double, maximum: Double) +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Vector.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Vector.swift new file mode 100644 index 000000000..cda90cb55 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Vector.swift @@ -0,0 +1,40 @@ +import Foundation + +// Vector helpers on the standard library's SIMD3. The `simd` module has equivalents, but it +// does not exist on Linux. These stay internal so they never shadow `simd` inside the app. + +extension SIMD3 where Scalar == Double { + func dot(_ other: Self) -> Double { + (self * other).sum() + } + + func cross(_ other: Self) -> Self { + Self( + y * other.z - z * other.y, + z * other.x - x * other.z, + x * other.y - y * other.x + ) + } + + var length: Double { + dot(self).squareRoot() + } + + /// The vector with its vertical (y) component removed. + var horizontal: Self { + Self(x, 0, z) + } +} + +/// World up under ARKit's `.gravity` alignment: y points away from gravity. +let worldUp = SIMD3(0, 1, 0) + +func degrees(fromRadians radians: Double) -> Double { + radians * 180 / .pi +} + +/// `acos` in degrees with the cosine clamped to [-1, 1], so a rounding error just past 1 gives +/// 0° instead of NaN. +func acosDegrees(_ cosine: Double) -> Double { + degrees(fromRadians: acos(min(1, max(-1, cosine)))) +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Wall.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Wall.swift new file mode 100644 index 000000000..de1d8a583 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Wall.swift @@ -0,0 +1,170 @@ +/// A straight wall modeled as a vertical plane through two ground-contact points. +/// +/// ARKit's vertical plane detection needs wall texture and may never find blank stucco, but the +/// wall's base usually has texture, and gravity supplies vertical. So the plane comes from two +/// taps where the wall meets the ground (docs/research t3-no-lidar-capture, "Build the wall from the +/// ground"): +/// +/// - `direction` u is the horizontal part of contact2 − contact1, normalized. The sign follows tap +/// order, so along-wall coordinates grow from the first contact toward the second. +/// - `normal` n = u × g with g = (0, 1, 0), flipped if needed so it points to the side of the wall +/// the camera was on. Positive offsets are in front of the wall, where the user stands. +/// - The ground line is the straight 3D line through both contacts. Heights are measured above it, +/// which follows a ground that slopes along the wall. +public struct Wall: Sendable, Equatable { + public let start: SIMD3 + public let end: SIMD3 + public let direction: SIMD3 + public let normal: SIMD3 + /// Horizontal distance from `start` to `end`, in meters. + public let length: Double + + public init( + contact1: SIMD3, + contact2: SIMD3, + cameraPosition: SIMD3, + gates: WallGates = WallGates() + ) throws(WallError) { + let run = (contact2 - contact1).horizontal + let separation = run.length + guard separation >= gates.minimumContactSeparation else { + throw .contactsTooClose(separation: separation, minimum: gates.minimumContactSeparation) + } + let u = run / separation + var n = u.cross(worldUp) + let cameraOffset = n.dot(cameraPosition - contact1) + guard abs(cameraOffset) >= gates.minimumCameraOffset else { + throw .cameraInWallPlane(offset: abs(cameraOffset), minimum: gates.minimumCameraOffset) + } + if cameraOffset < 0 { + n = -n + } + start = contact1 + end = contact2 + direction = u + normal = n + length = separation + } + + /// Signed horizontal distance from the plane; positive on the side the camera was on. + public func offset(of point: SIMD3) -> Double { + normal.dot(point - start) + } + + /// Perpendicular horizontal distance from a point to the wall line, as seen from above. + /// This is the facing gap for a fence or wall across from this one. + public func gap(to point: SIMD3) -> Double { + abs(offset(of: point)) + } + + /// Horizontal position along the wall, in meters from the first contact toward the second. + public func along(_ point: SIMD3) -> Double { + direction.dot(point - start) + } + + /// Signed along-wall distance from `a` to `b`. + public func alongDistance(from a: SIMD3, to b: SIMD3) -> Double { + along(b) - along(a) + } + + /// Ground height (world y) of the ground line at an along-wall position. Outside the two + /// contacts the line is extended, so check `containsAlong` before trusting it there. + public func groundHeight(atAlong s: Double) -> Double { + start.y + (end.y - start.y) * s / length + } + + /// Height of a point above the ground line directly below it. + public func heightAboveGround(_ point: SIMD3) -> Double { + point.y - groundHeight(atAlong: along(point)) + } + + /// Whether an along-wall position lies between the two contacts. + public func containsAlong(_ s: Double) -> Bool { + (0...length).contains(s) + } + + /// Checks a third ground contact against the plane. The residual is its distance from the + /// plane; the research note's gate is 2 in. + public func validate(contact: SIMD3, gates: WallGates = WallGates()) -> WallValidation { + let residual = gap(to: contact) + return WallValidation(residual: residual, tolerance: gates.validationTolerance) + } + + /// Where a ray meets the wall plane: t = n·(p1 − o) / (n·d), point = o + t·d. + /// + /// Refuses rays more than `maximumAngleFromNormal` from the normal (|n·d| < cos of that angle), + /// which also covers rays parallel to the wall, and intersections at t ≤ 0, which lie behind + /// the camera. + public func intersect(_ ray: Ray, gates: WallGates = WallGates()) throws(WallHitError) -> WallHit { + let cosine = normal.dot(ray.direction) + let angle = acosDegrees(abs(cosine)) + guard angle <= gates.maximumAngleFromNormal else { + throw .grazing(angleFromNormal: angle, maximum: gates.maximumAngleFromNormal) + } + let t = normal.dot(start - ray.origin) / cosine + guard t > 0 else { + throw .behindCamera(t: t) + } + let point = ray.point(at: t) + return WallHit( + point: point, + range: t, + angleFromNormal: angle, + along: along(point), + heightAboveGround: heightAboveGround(point), + withinContacts: containsAlong(along(point)) + ) + } +} + +/// Thresholds for building and using a wall. Defaults come from the research note's gates; each +/// is a hypothesis the outdoor run tests, not a calibrated value. +public struct WallGates: Sendable, Equatable { + /// Minimum horizontal distance between the two ground contacts, in meters. The research note + /// asks for 6.5 ft; 2 m is that rounded to the metric figure the experiment brief uses. + public var minimumContactSeparation: Double = 2.0 + /// Largest angle, in degrees, between a tap ray and the wall normal. 60° is |n·d| ≥ 0.5. + public var maximumAngleFromNormal: Double = 60 + /// Largest allowed distance of a validation contact from the plane: 2 in, in meters. + public var validationTolerance: Double = 2 * Length.metersPerInch + /// The normal's sign comes from which side of the plane the camera is on. Closer than this + /// (in meters), tracking noise could flip it, so the wall is refused instead. + public var minimumCameraOffset: Double = 0.1 + + public init() {} +} + +public enum WallError: Error, Sendable, Equatable { + /// The contacts are closer together horizontally than the minimum, including contacts stacked + /// vertically. + case contactsTooClose(separation: Double, minimum: Double) + /// The camera stood on the wall's own vertical plane, so the front side is unknown. + case cameraInWallPlane(offset: Double, minimum: Double) +} + +public enum WallHitError: Error, Sendable, Equatable { + case grazing(angleFromNormal: Double, maximum: Double) + case behindCamera(t: Double) +} + +public struct WallHit: Sendable, Equatable { + public let point: SIMD3 + /// Distance from the ray origin to the hit, in meters. + public let range: Double + /// Angle between the ray and the wall normal, in degrees. + public let angleFromNormal: Double + public let along: Double + public let heightAboveGround: Double + /// False when the hit lies beyond either contact, where the wall and its ground line are + /// extrapolated. + public let withinContacts: Bool +} + +public struct WallValidation: Sendable, Equatable { + public let residual: Double + public let tolerance: Double + + public var passes: Bool { + residual <= tolerance + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Warnings.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Warnings.swift new file mode 100644 index 000000000..62e828e86 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Warnings.swift @@ -0,0 +1,94 @@ +/// Why a point or measurement should count as an abstention rather than an accepted value. +/// +/// A result inherits the warnings of every observation it depends on, so a flaw in one ground +/// contact follows the wall it defines into every point on that wall and every measurement that +/// uses them. The observations themselves keep only their own warnings, so analysis can still +/// tell where a warning started. +public enum MeasurementWarning: String, Sendable, Codable { + /// A ground hit on ARKit's estimated surface rather than a found plane. + case estimatedPlane + /// A ground hit on a found plane's extension, past its detected edge. + case extendedPlane + /// A ground ray looking down less than the minimum angle. + case shallowLookDown + /// A wall point beyond the wall's two contacts, where the plane is extrapolated. + case outsideWallContacts + /// One of the wall's ground contacts has a warning of its own. + case wallContactWarning + /// The wall has no validation contact yet. + case wallNotValidated + /// A validation contact lay farther from the wall plane than the tolerance. + case wallValidationFailed + /// A height above ground came out negative: the point is below the wall's ground line. + case belowGround +} + +/// The qualification of a wall, from its contacts' warnings and its validation contacts. +public struct WallStatus: Sendable, Equatable { + /// Own warnings of each ground contact that defines the wall. + public var contactWarnings: [[MeasurementWarning]] + /// Whether each validation contact passed, in order. + public var validations: [Bool] + + public init(contactWarnings: [[MeasurementWarning]], validations: [Bool]) { + self.contactWarnings = contactWarnings + self.validations = validations + } + + /// A single failed validation disqualifies the wall even if another one passed. + public var warnings: [MeasurementWarning] { + var result: [MeasurementWarning] = [] + if contactWarnings.contains(where: { !$0.isEmpty }) { result.append(.wallContactWarning) } + if validations.isEmpty { + result.append(.wallNotValidated) + } else if validations.contains(false) { + result.append(.wallValidationFailed) + } + return result + } +} + +/// A point's own warnings plus, for a point located on a wall plane, that wall's status. +public struct PointEvidence: Sendable, Equatable { + public var own: [MeasurementWarning] + /// Set only when the point's position came from intersecting a wall plane. + public var wall: WallStatus? + + public init(own: [MeasurementWarning], wall: WallStatus? = nil) { + self.own = own + self.wall = wall + } + + /// Everything that should stop the point counting as accepted. + public var warnings: [MeasurementWarning] { + normalized(own + (wall?.warnings ?? [])) + } +} + +/// Warnings for one measurement value. +/// +/// It inherits both points' warnings (including the walls that located them), the target wall's +/// status for point-to-wall quantities, and the reference wall's status only when the compared +/// quantity is along-wall distance. A negative height above ground adds `belowGround`. +public func measurementWarnings( + from: PointEvidence, + to: PointEvidence?, + targetWall: WallStatus?, + referenceWall: WallStatus?, + compared: MeasuredQuantity, + value: Double +) -> [MeasurementWarning] { + var result = from.warnings + (to?.warnings ?? []) + (targetWall?.warnings ?? []) + if compared == .alongWall, let referenceWall { + result += referenceWall.warnings + } + if compared == .heightAboveGround, value < 0 { + result.append(.belowGround) + } + return normalized(result) +} + +/// Sorted by name and without repeats, so equal sets compare and encode the same way. +func normalized(_ warnings: [MeasurementWarning]) -> [MeasurementWarning] { + Set(warnings).sorted { $0.rawValue < $1.rawValue } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/CameraTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/CameraTests.swift new file mode 100644 index 000000000..4554da0f0 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/CameraTests.swift @@ -0,0 +1,109 @@ +import Testing +@testable import MeasureGeometry + +struct CameraTests { + // fx = fy = 1000 px, principal point at the center of a 1920 × 1440 image. + let intrinsics = CameraIntrinsics(fx: 1000, fy: 1000, cx: 960, cy: 720) + + @Test func `pose round-trips through a column-major matrix`() throws { + let pose = CameraPose( + xAxis: SIMD3(0, 1, 0), yAxis: SIMD3(-1, 0, 0), zAxis: SIMD3(0, 0, 1), position: SIMD3(1, 2, 3) + ) + #expect(pose.columnMajor == [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1]) + #expect(try CameraPose(columnMajor: pose.columnMajor) == pose) + } + + @Test func `pose rejects a malformed matrix`() { + #expect(throws: GeometryInputError.wrongMatrixSize(expected: 16, actual: 12)) { + try CameraPose(columnMajor: Array(repeating: 0, count: 12)) + } + var projective = identityPose.columnMajor + projective[3] = 0.5 + #expect(throws: GeometryInputError.notAnAffineTransform(bottomRow: [0.5, 0, 0, 1])) { + try CameraPose(columnMajor: projective) + } + } + + @Test func `rotation angle between poses`() { + #expect(identityPose.rotationDegrees(to: identityPose) == 0) + #expect(isClose(identityPose.rotationDegrees(to: poseTurned(90)), 90)) + #expect(isClose(poseTurned(10).rotationDegrees(to: poseTurned(25)), 15)) + #expect(isClose(poseTurned(-170).rotationDegrees(to: poseTurned(170)), 20)) + } + + @Test func `distance between camera positions`() { + #expect(poseTurned(0, at: SIMD3(1, 1, 1)).distance(to: poseTurned(40, at: SIMD3(4, 5, 1))) == 5) + } + + @Test func `ray normalizes its direction and refuses a zero one`() throws { + let ray = try Ray(origin: .zero, direction: SIMD3(0, 3, -4)) + #expect(isClose(ray.direction, SIMD3(0, 0.6, -0.8))) + #expect(isClose(ray.point(at: 10), SIMD3(0, 6, -8))) + #expect(throws: GeometryInputError.degenerateDirection) { + try Ray(origin: .zero, direction: .zero) + } + #expect(throws: GeometryInputError.degenerateDirection) { + try Ray(origin: .zero, direction: SIMD3(.nan, 0, 1)) + } + } + + @Test(arguments: [ + (SIMD3(0, -1, -1), 45.0), + (SIMD3(0, -1, 0), 90.0), + (SIMD3(1, 0, 0), 0.0), + (SIMD3(0, 1, -3.0.squareRoot()), -30.0), + ]) + func `look-down angle below the horizon`(direction: SIMD3, expected: Double) throws { + #expect(isClose(try Ray(origin: .zero, direction: direction).lookDownDegrees, expected)) + } + + @Test func `pixel rays for an unrotated camera`() throws { + let frame = CameraFrame(intrinsics: intrinsics, pose: identityPose) + // Principal point: straight ahead along −z. + #expect(isClose(try frame.ray(throughPixel: 960, 720).direction, SIMD3(0, 0, -1))) + // 1000 px right of center at fx = 1000: 45° to the right. + #expect(isClose(try frame.ray(throughPixel: 1960, 720).direction, SIMD3(1, 0, -1) / 2.0.squareRoot())) + // 1000 px below center: image v grows down, camera y grows up, so the ray points down. + #expect(isClose(try frame.ray(throughPixel: 960, 1720).direction, SIMD3(0, -1, -1) / 2.0.squareRoot())) + // Unequal focal lengths scale each axis separately: (500/1000, -(−200)/800, −1). + let stretched = CameraFrame(intrinsics: CameraIntrinsics(fx: 1000, fy: 800, cx: 960, cy: 720), pose: identityPose) + #expect(isClose(try stretched.ray(throughPixel: 1460, 520).direction, SIMD3(0.5, 0.25, -1) / 1.3125.squareRoot())) + } + + @Test func `pixel ray uses the pose rotation and position`() throws { + // Camera x points along world +y, camera y along world −x, camera z along world +z. + let pose = CameraPose( + xAxis: SIMD3(0, 1, 0), yAxis: SIMD3(-1, 0, 0), zAxis: SIMD3(0, 0, 1), position: SIMD3(1, 2, 3) + ) + let frame = CameraFrame(intrinsics: intrinsics, pose: pose) + // Camera direction (1, 0, −1) becomes world (0, 1, 0) − (0, 0, 1). + let ray = try frame.ray(throughPixel: 1960, 720) + #expect(ray.origin == SIMD3(1, 2, 3)) + #expect(isClose(ray.direction, SIMD3(0, 1, -1) / 2.0.squareRoot())) + } + + @Test func `projection inverts the pixel ray`() throws { + let pose = CameraPose( + xAxis: SIMD3(0, 1, 0), yAxis: SIMD3(-1, 0, 0), zAxis: SIMD3(0, 0, 1), position: SIMD3(1, 2, 3) + ) + let frame = CameraFrame(intrinsics: intrinsics, pose: pose) + // (1, 4, 1) − position = (0, 2, −2): camera (2, 0, −2), depth 2, so u = 960 + 1000·2/2. + let pixel = try #require(frame.project(SIMD3(1, 4, 1))) + #expect(isClose(pixel.u, 1960)) + #expect(isClose(pixel.v, 720)) + + let tilted = CameraFrame(intrinsics: intrinsics, pose: poseTurned(30, at: SIMD3(0.5, 1.4, 2))) + for (u, v) in [(100.0, 50.0), (1800.0, 1400.0), (960.0, 720.0)] { + let point = try tilted.ray(throughPixel: u, v).point(at: 7.5) + let back = try #require(tilted.project(point)) + #expect(isClose(back.u, u, within: 1e-6)) + #expect(isClose(back.v, v, within: 1e-6)) + } + } + + @Test func `projection refuses points behind the camera`() { + let frame = CameraFrame(intrinsics: intrinsics, pose: identityPose) + #expect(frame.project(SIMD3(0, 0, 1)) == nil) + #expect(frame.project(SIMD3(1, 0, 0)) == nil) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/FrameResetGateTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/FrameResetGateTests.swift new file mode 100644 index 000000000..bcc73ef82 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/FrameResetGateTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import MeasureGeometry + +struct FrameResetGateTests { + @Test func waitsForTrackingCycleAndRejectsOldFrames() { + var gate = FrameResetGate(resetUptime: 100) + #expect(!gate.accepts(frameTimestamp: 101)) + gate.trackingChanged(isNormal: true, at: 101) + #expect(!gate.accepts(frameTimestamp: 101)) + gate.trackingChanged(isNormal: false, at: 99) + #expect(!gate.accepts(frameTimestamp: 101)) + gate.trackingChanged(isNormal: false, at: 102) + #expect(!gate.accepts(frameTimestamp: 103)) + gate.trackingChanged(isNormal: true, at: 104) + #expect(!gate.accepts(frameTimestamp: 100)) + #expect(!gate.accepts(frameTimestamp: 99.9)) + #expect(gate.accepts(frameTimestamp: 104)) + } + + @Test func trackingLossClosesGateUntilNormalReturns() { + var gate = FrameResetGate(resetUptime: 100) + gate.trackingChanged(isNormal: false, at: 101) + gate.trackingChanged(isNormal: true, at: 102) + #expect(gate.accepts(frameTimestamp: 103)) + gate.trackingChanged(isNormal: false, at: 104) + #expect(!gate.accepts(frameTimestamp: 105)) + gate.trackingChanged(isNormal: true, at: 106) + #expect(gate.accepts(frameTimestamp: 107)) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/KeyframeRouterTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/KeyframeRouterTests.swift new file mode 100644 index 000000000..9f505db4a --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/KeyframeRouterTests.swift @@ -0,0 +1,57 @@ +import Testing +@testable import MeasureGeometry + +struct KeyframeRouterTests { + // A's write reports back only after two more switches; it must still reach A's manifest. + @Test func `a write survives two session switches`() { + var router = KeyframeRouter() + router.open("A") + let keepA = router.closeCurrent(reserved: 1) + router.open("B") + let keepB = router.closeCurrent(reserved: 0) + router.open("C") + #expect(keepA) + #expect(!keepB) + let late = router.report(for: "A") + #expect(late.route == .closed) + #expect(late.drained) + #expect(router.report(for: "A").route == .unknown) + } + + @Test func `writes already reported before closing need no waiting`() { + var router = KeyframeRouter() + router.open("A") + let first = router.report(for: "A") + let second = router.report(for: "A") + let keep = router.closeCurrent(reserved: 2) + let after = router.report(for: "A") + #expect(first.route == .current && !first.drained) + #expect(second.route == .current) + #expect(!keep) + #expect(after.route == .unknown) + } + + @Test func `a closed session drains only after its last write`() { + var router = KeyframeRouter() + router.open("A") + _ = router.report(for: "A") + let keep = router.closeCurrent(reserved: 3) + router.open("B") + let second = router.report(for: "A") + let other = router.report(for: "B") + let third = router.report(for: "A") + let extra = router.report(for: "A") + #expect(keep) + #expect(second.route == .closed && !second.drained) + #expect(other.route == .current) + #expect(third.route == .closed && third.drained) + #expect(extra.route == .unknown) + } + + @Test func `an unknown session is not routed`() { + var router = KeyframeRouter() + router.open("A") + let stray = router.report(for: "Z") + #expect(stray.route == .unknown) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/KeyframeSelectorTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/KeyframeSelectorTests.swift new file mode 100644 index 000000000..927bc1e15 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/KeyframeSelectorTests.swift @@ -0,0 +1,74 @@ +import Testing +@testable import MeasureGeometry + +struct KeyframeSelectorTests { + /// A selector whose last saved keyframe is the identity pose. + func savedAtOrigin() -> KeyframeSelector { + var selector = KeyframeSelector() + selector.commit(selector.reserve(at: identityPose)) + return selector + } + + @Test func `first pose is always a keyframe`() { + #expect(KeyframeSelector().wantsKeyframe(at: identityPose)) + } + + @Test(arguments: [ + (SIMD3(0.49, 0, 0), 0.0, false), + (SIMD3(0.3, 0.4, 0), 0.0, true), + (SIMD3(0, 0, 0.6), 0.0, true), + (SIMD3.zero, 14.0, false), + (SIMD3.zero, 15.5, true), + (SIMD3(0.2, 0, 0), -20.0, true), + ]) + func `spacing by distance or turn`(position: SIMD3, turn: Double, expected: Bool) { + #expect(savedAtOrigin().wantsKeyframe(at: poseTurned(turn, at: position)) == expected) + } + + @Test func `spacing restarts at the last saved pose`() { + var selector = savedAtOrigin() + selector.commit(selector.reserve(at: poseTurned(0, at: SIMD3(0.4, 0, 0)))) + // 0.8 m from the first keyframe but only 0.4 m from the tap keyframe. + #expect(!selector.wantsKeyframe(at: poseTurned(0, at: SIMD3(0.8, 0, 0)))) + selector.reset() + #expect(selector.wantsKeyframe(at: identityPose)) + } + + // If a failed write at x = 0.5 m moved the checkpoint, frames until about x = 1 m would be + // skipped. + @Test func `a cancelled write leaves spacing at the last success`() { + var selector = savedAtOrigin() + let failed = selector.reserve(at: poseTurned(0, at: SIMD3(0.5, 0, 0))) + selector.cancel(failed) + #expect(selector.lastSaved == identityPose) + #expect(selector.wantsKeyframe(at: poseTurned(0, at: SIMD3(0.51, 0, 0)))) + } + + @Test func `a write in progress holds off duplicates until it finishes`() { + var selector = savedAtOrigin() + let inFlight = selector.reserve(at: poseTurned(0, at: SIMD3(0.5, 0, 0))) + // Measured from the pending pose, so a frame 2 cm on is not another keyframe. + #expect(!selector.wantsKeyframe(at: poseTurned(0, at: SIMD3(0.52, 0, 0)))) + #expect(selector.lastSaved == identityPose) + selector.commit(inFlight) + #expect(selector.lastSaved == poseTurned(0, at: SIMD3(0.5, 0, 0))) + } + + @Test func `an older write finishing late does not move the checkpoint back`() { + var selector = KeyframeSelector() + let older = selector.reserve(at: identityPose) + let newer = selector.reserve(at: poseTurned(0, at: SIMD3(2, 0, 0))) + selector.commit(newer) + selector.commit(older) + #expect(selector.lastSaved == poseTurned(0, at: SIMD3(2, 0, 0))) + } + + @Test func `a reservation from before a reset is ignored`() { + var selector = savedAtOrigin() + let stale = selector.reserve(at: poseTurned(0, at: SIMD3(5, 0, 0))) + selector.reset() + selector.commit(stale) + #expect(selector.lastSaved == nil) + #expect(selector.wantsKeyframe(at: identityPose)) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/LengthTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/LengthTests.swift new file mode 100644 index 000000000..b9344c197 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/LengthTests.swift @@ -0,0 +1,51 @@ +import Testing +@testable import MeasureGeometry + +struct LengthTests { + @Test func `feet and inches to meters`() { + #expect(isClose(Length.meters(feet: 1, inches: 0), 0.3048)) + #expect(isClose(Length.meters(feet: 6, inches: 3.5), 1.9177)) + #expect(isClose(Length.inches(meters: 1), 39.37007874015748)) + } + + @Test func `splitting meters into feet and inches`() { + #expect(Length.feetAndInches(meters: 1.9177) == FeetAndInches(negative: false, feet: 6, inches: 3.5)) + // 35.99 in rounds to 36 in at 1/8 in steps and carries into the feet. + #expect(Length.feetAndInches(meters: 35.99 * 0.0254) == FeetAndInches(negative: false, feet: 3, inches: 0)) + #expect(Length.feetAndInches(meters: -0.0254) == FeetAndInches(negative: true, feet: 0, inches: 1)) + // Rounds to zero, so no minus sign. + #expect(Length.feetAndInches(meters: -0.0001) == FeetAndInches(negative: false, feet: 0, inches: 0)) + } + + @Test(arguments: [ + ("6", "3.5", 75.5), + ("6", "3 1/2", 75.5), + ("", "1/4", 0.25), + ("0", "30", 30.0), + ("2.5", "", 30.0), + (" 1 ", " 7/8 ", 12.875), + ("0", "0", 0.0), + ("", "0", 0.0), + ]) + func `typed tape readings`(feet: String, inches: String, totalInches: Double) throws { + let reading = try TapeReading(feetText: feet, inchesText: inches) + #expect(isClose(reading.meters, totalInches * 0.0254)) + } + + @Test(arguments: [ + ("x", "", TapeEntryError.unreadable(field: .feet, text: "x")), + ("1", "3/0", .unreadable(field: .inches, text: "3/0")), + ("1", "1/4 3", .unreadable(field: .inches, text: "1/4 3")), + ("1", "3 4", .unreadable(field: .inches, text: "3 4")), + ("1", "1/2/3", .unreadable(field: .inches, text: "1/2/3")), + ("", "", .empty), + (" ", " ", .empty), + ("-1", "", .negativeOrNotFinite), + ("inf", "", .negativeOrNotFinite), + ]) + func `unusable tape readings are refused`(feet: String, inches: String, expected: TapeEntryError) { + #expect(throws: expected) { + try TapeReading(feetText: feet, inchesText: inches) + } + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementTests.swift new file mode 100644 index 000000000..3d299865a --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import MeasureGeometry + +struct MeasurementTests { + @Test func `separation between two points`() { + // Δ = (3, 4, −4): straight √41, horizontal √(3² + (−4)²) = 5, and rise is Δy = 4. + let separation = PointSeparation(from: SIMD3(1, 2, 3), to: SIMD3(4, 6, -1)) + #expect(isClose(separation.straight, 41.0.squareRoot())) + #expect(separation.horizontal == 5) + #expect(separation.rise == 4) + #expect(PointSeparation(from: SIMD3(4, 6, -1), to: SIMD3(1, 2, 3)).rise == -4) + } + + @Test func `tape comparison reports app minus tape`() { + // Tape 6 ft 3.5 in = 75.5 in = 1.9177 m; the app read 1.95 m. + let comparison = TapeComparison(measured: 1.95, tape: 1.9177) + #expect(isClose(comparison.error, 0.0323)) + #expect(isClose(comparison.errorInches, 0.0323 / 0.0254)) + #expect(TapeComparison(measured: 1.0, tape: 1.0254).errorInches < 0) + } +} + +struct MeasuredValuesTests { + let wall: Wall + + init() throws { + wall = try Wall(contact1: SIMD3(0, 0, 0), contact2: SIMD3(4, 0, 0), cameraPosition: SIMD3(2, 1.5, 3)) + } + + @Test func `point to point without a wall`() { + let values = measuredValues(from: SIMD3(1, 2, 3), to: .point(SIMD3(4, 6, -1))) + #expect(values.count == 3) + #expect(isClose(values[.straight] ?? .nan, 41.0.squareRoot())) + #expect(values[.horizontal] == 5) + #expect(values[.vertical] == 4) + } + + @Test func `point to point along a wall`() { + // Along-wall distance is the difference in x for a wall on the x axis, sign dropped. + let values = measuredValues(from: SIMD3(3.5, 0, 2), to: .point(SIMD3(1, 1, 0.5)), referenceWall: wall) + #expect(values[.alongWall] == 2.5) + #expect(values[.vertical] == 1) + } + + @Test func `height above ground keeps its sign`() throws { + // Ground rises 0.4 m over 4 m, so at along-wall 2 it is at y = 0.2; y = −0.8 is 1 m below. + let sloped = try Wall(contact1: SIMD3(0, 0, 0), contact2: SIMD3(4, 0.4, 0), cameraPosition: SIMD3(2, 1.5, 3)) + let values = measuredValues(from: SIMD3(2, -0.8, 0), to: .wall(sloped)) + #expect(isClose(values[.heightAboveGround] ?? .nan, -1)) + } + + @Test func `point to wall gives the facing gap and height`() { + // A fence post 1.2 m out from the wall, 0.9 m up. + let values = measuredValues(from: SIMD3(2, 0.9, 1.2), to: .wall(wall)) + #expect(values.count == 2) + #expect(isClose(values[.gapToWall] ?? .nan, 1.2)) + #expect(isClose(values[.heightAboveGround] ?? .nan, 0.9)) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/PortraitFillMappingTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/PortraitFillMappingTests.swift new file mode 100644 index 000000000..70bb9f2cf --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/PortraitFillMappingTests.swift @@ -0,0 +1,60 @@ +import Testing +@testable import MeasureGeometry + +struct PortraitFillMappingTests { + @Test func `uncropped view maps corners to rotated image corners`() throws { + // A 2000 × 1000 image rotated is 1000 × 2000, exactly twice a 500 × 1000 view. + let mapping = try #require(PortraitFillMapping(imageWidth: 2000, imageHeight: 1000, viewWidth: 500, viewHeight: 1000)) + #expect(mapping.scale == 0.5) + // Top-left of the view is the image's bottom-left corner. + #expect(mapping.imagePixel(forViewPoint: 0, 0) == (u: 0, v: 1000)) + // Bottom-right of the view is the image's top-right corner. + #expect(mapping.imagePixel(forViewPoint: 500, 1000) == (u: 2000, v: 0)) + // (100, 300) is rotated (200, 600): u = 600, v = 1000 − 200. + #expect(mapping.imagePixel(forViewPoint: 100, 300) == (u: 600, v: 800)) + #expect(mapping.viewPoint(forImagePixel: 600, 800) == (x: 100, y: 300)) + } + + @Test func `cropped view keeps the center and crops the sides`() throws { + // 1920 × 1440 sensor image in a 390 × 844 pt view: height fills at 844 / 1920 pt per px, + // so the rotated image is 633 pt wide and 121.5 pt is cropped from each side. + let mapping = try #require(PortraitFillMapping(imageWidth: 1920, imageHeight: 1440, viewWidth: 390, viewHeight: 844)) + #expect(isClose(mapping.scale, 844.0 / 1920)) + #expect(isClose(mapping.offset.x, -121.5)) + #expect(isClose(mapping.offset.y, 0)) + let center = mapping.imagePixel(forViewPoint: 195, 422) + #expect(isClose(center.u, 960)) + #expect(isClose(center.v, 720)) + // The view's left edge is 121.5 pt into the rotated image: v = 1440 − 121.5 / scale. + let left = mapping.imagePixel(forViewPoint: 0, 422) + #expect(isClose(left.v, 1440 - 121.5 * 1920 / 844)) + } + + @Test(arguments: [ + (0.0, 1440.0, 390.0, 844.0), + (1920.0, 0.0, 390.0, 844.0), + (1920.0, 1440.0, 0.0, 844.0), + (1920.0, 1440.0, 390.0, 0.0), + (-1.0, 1440.0, 390.0, 844.0), + (1920.0, -1.0, 390.0, 844.0), + (1920.0, 1440.0, -1.0, 844.0), + (1920.0, 1440.0, 390.0, -1.0), + (Double.nan, 1440.0, 390.0, 844.0), + (1920.0, Double.infinity, 390.0, 844.0), + (1920.0, 1440.0, -Double.infinity, 844.0), + (1920.0, 1440.0, 390.0, Double.nan), + (Double.leastNonzeroMagnitude, 1440.0, 390.0, 844.0), + ]) + func `rejects dimensions that cannot produce a finite mapping`(imageWidth: Double, imageHeight: Double, viewWidth: Double, viewHeight: Double) { + #expect(PortraitFillMapping(imageWidth: imageWidth, imageHeight: imageHeight, viewWidth: viewWidth, viewHeight: viewHeight) == nil) + } + + @Test(arguments: [(0.0, 0.0), (1920.0, 1440.0), (123.4, 987.6)]) + func `view points and image pixels round-trip`(u: Double, v: Double) throws { + let mapping = try #require(PortraitFillMapping(imageWidth: 1920, imageHeight: 1440, viewWidth: 402, viewHeight: 874)) + let point = mapping.viewPoint(forImagePixel: u, v) + let back = mapping.imagePixel(forViewPoint: point.x, point.y) + #expect(isClose(back.u, u)) + #expect(isClose(back.v, v)) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/Support.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/Support.swift new file mode 100644 index 000000000..79ff32c18 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/Support.swift @@ -0,0 +1,27 @@ +import Foundation +@testable import MeasureGeometry + +/// Default tolerance for hand-computed answers. Expected values are exact or written to at least +/// 12 significant digits, so 1e-9 only absorbs floating-point rounding. +let tolerance = 1e-9 + +func isClose(_ a: Double, _ b: Double, within limit: Double = tolerance) -> Bool { + abs(a - b) <= limit +} + +func isClose(_ a: SIMD3, _ b: SIMD3, within limit: Double = tolerance) -> Bool { + isClose(a.x, b.x, within: limit) && isClose(a.y, b.y, within: limit) && isClose(a.z, b.z, within: limit) +} + +/// A camera turned `degrees` about world up (y), standing at `position`. At 0° it looks along −z. +func poseTurned(_ degrees: Double, at position: SIMD3 = .zero) -> CameraPose { + let r = degrees * .pi / 180 + return CameraPose( + xAxis: SIMD3(cos(r), 0, -sin(r)), + yAxis: SIMD3(0, 1, 0), + zAxis: SIMD3(sin(r), 0, cos(r)), + position: position + ) +} + +let identityPose = poseTurned(0) diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TriangulationTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TriangulationTests.swift new file mode 100644 index 000000000..b669ad2c8 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TriangulationTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +@testable import MeasureGeometry + +struct TriangulationTests { + @Test func `rays that meet give the meeting point`() throws { + // Two cameras 1 m apart both aim exactly at (0.5, 2, −3). + let target = SIMD3(0.5, 2, -3) + let o1 = SIMD3(0, 1, 0) + let o2 = SIMD3(1, 1, 0) + let result = try Triangulation( + try Ray(origin: o1, direction: target - o1), + try Ray(origin: o2, direction: target - o2) + ) + #expect(isClose(result.point, target)) + #expect(isClose(result.gap, 0)) + // Both rays have length √(0.25 + 1 + 9) = √10.25 to the target. + #expect(isClose(result.t1, 10.25.squareRoot())) + #expect(isClose(result.t2, 10.25.squareRoot())) + // d1·d2 = (−0.25 + 1 + 9) / 10.25 = 9.75 / 10.25. + #expect(isClose(result.rayAngle, acos(9.75 / 10.25) * 180 / .pi)) + #expect(result.rayAngle > 17.9 && result.rayAngle < 18.0) + #expect(result.baseline == 1) + } + + @Test func `skew rays give the midpoint of closest approach`() throws { + // Ray 1 runs down −z from the origin. Ray 2 starts at (1, 0.03, 0) heading (−1, 0, −1)/√2. + // Seen from above they cross at (0, −1); ray 2 stays 3 cm higher the whole way. + // w = (−1, −0.03, 0), a = c = 1, b = 1/√2, d = 0, e = 1/√2, so a·c − b² = 1/2, + // t1 = (b·e − c·d) / (1/2) = 1 and t2 = (a·e − b·d) / (1/2) = √2. + let result = try Triangulation( + try Ray(origin: .zero, direction: SIMD3(0, 0, -1)), + try Ray(origin: SIMD3(1, 0.03, 0), direction: SIMD3(-1, 0, -1)) + ) + #expect(isClose(result.t1, 1)) + #expect(isClose(result.t2, 2.0.squareRoot())) + #expect(isClose(result.closest1, SIMD3(0, 0, -1))) + #expect(isClose(result.closest2, SIMD3(0, 0.03, -1))) + #expect(isClose(result.point, SIMD3(0, 0.015, -1))) + #expect(isClose(result.gap, 0.03)) + #expect(isClose(result.rayAngle, 45)) + } + + @Test func `rays that miss by more than two inches are refused`() throws { + let error = #expect(throws: TriangulationError.self) { + try Triangulation( + try Ray(origin: .zero, direction: SIMD3(0, 0, -1)), + try Ray(origin: SIMD3(1, 0.06, 0), direction: SIMD3(-1, 0, -1)) + ) + } + guard case .raysMiss(let gap, let maximum)? = error else { + Issue.record("Expected raysMiss, got \(String(describing: error))") + return + } + #expect(isClose(gap, 0.06)) + #expect(maximum == 0.0508) + } + + @Test(arguments: [10.0, 14.9]) + func `ray angles under fifteen degrees are refused`(angle: Double) throws { + let r = angle * .pi / 180 + let error = #expect(throws: TriangulationError.self) { + try Triangulation( + try Ray(origin: SIMD3(-1, 0, 0), direction: SIMD3(0, 0, -1)), + try Ray(origin: .zero, direction: SIMD3(-sin(r), 0, -cos(r))) + ) + } + guard case .rayAngleTooSmall(let measured, let minimum)? = error else { + Issue.record("Expected rayAngleTooSmall, got \(String(describing: error))") + return + } + #expect(isClose(measured, angle, within: 1e-7)) + #expect(minimum == 15) + } + + @Test func `fifteen degrees and up is accepted`() throws { + // Ray 2 turns 15.1° toward ray 1 from 1 m to its right, so they cross in front of both. + let r = 15.1 * Double.pi / 180 + let result = try Triangulation( + try Ray(origin: .zero, direction: SIMD3(0, 0, -1)), + try Ray(origin: SIMD3(1, 0, 0), direction: SIMD3(-sin(r), 0, -cos(r))) + ) + #expect(isClose(result.rayAngle, 15.1, within: 1e-7)) + // They meet where ray 2 has moved 1 m left: depth 1 / tan 15.1°. + #expect(isClose(result.point, SIMD3(0, 0, -1 / tan(r)), within: 1e-9)) + } + + @Test func `parallel rays are refused before dividing by zero`() throws { + let error = #expect(throws: TriangulationError.self) { + try Triangulation( + try Ray(origin: .zero, direction: SIMD3(0, 0, -1)), + try Ray(origin: SIMD3(1, 0, 0), direction: SIMD3(0, 0, -1)) + ) + } + #expect(error == .rayAngleTooSmall(angle: 0, minimum: 15)) + } + + @Test func `a meeting point behind a camera is refused`() throws { + // The lines meet at (0, 0, 1), behind both cameras: t1 = −1, t2 = −√2. + let error = #expect(throws: TriangulationError.self) { + try Triangulation( + try Ray(origin: .zero, direction: SIMD3(0, 0, -1)), + try Ray(origin: SIMD3(1, 0, 0), direction: SIMD3(1, 0, -1)) + ) + } + guard case .behindCamera(let t1, let t2)? = error else { + Issue.record("Expected behindCamera, got \(String(describing: error))") + return + } + #expect(isClose(t1, -1)) + #expect(isClose(t2, -(2.0.squareRoot()))) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/VectorTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/VectorTests.swift new file mode 100644 index 000000000..e7b1e2405 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/VectorTests.swift @@ -0,0 +1,29 @@ +import Testing +@testable import MeasureGeometry + +struct VectorTests { + @Test func `dot product sums the component products`() { + #expect(SIMD3(1, 2, 3).dot(SIMD3(4, -5, 6)) == 12) + } + + @Test func `cross product follows the right-hand rule`() { + let x = SIMD3(1, 0, 0) + let y = SIMD3(0, 1, 0) + let z = SIMD3(0, 0, 1) + #expect(x.cross(y) == z) + #expect(y.cross(z) == x) + #expect(z.cross(x) == y) + #expect(SIMD3(1, 2, 3).cross(SIMD3(4, 5, 6)) == SIMD3(-3, 6, -3)) + } + + @Test func `length and horizontal part`() { + #expect(SIMD3(2, 3, 6).length == 7) + #expect(SIMD3(2, 3, 6).horizontal == SIMD3(2, 0, 6)) + } + + @Test func `acos in degrees clamps rounding past one`() { + #expect(acosDegrees(1.0000000000000002) == 0) + #expect(acosDegrees(-1.0000000000000002) == 180) + #expect(isClose(acosDegrees(0.5), 60)) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WallTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WallTests.swift new file mode 100644 index 000000000..d6b6da023 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WallTests.swift @@ -0,0 +1,204 @@ +import Foundation +import Testing +@testable import MeasureGeometry + +struct WallTests { + /// A 4 m wall along +x at ground height 0, seen from z = +3. + let camera = SIMD3(2, 1.5, 3) + var wall: Wall { + get throws { try Wall(contact1: SIMD3(0, 0, 0), contact2: SIMD3(4, 0, 0), cameraPosition: camera) } + } + + @Test func `direction and normal from two contacts`() throws { + let wall = try wall + #expect(wall.direction == SIMD3(1, 0, 0)) + // u × g = (1, 0, 0) × (0, 1, 0) = (0, 0, 1), which already points toward the camera. + #expect(wall.normal == SIMD3(0, 0, 1)) + #expect(wall.length == 4) + } + + @Test func `normal flips to face the camera`() throws { + let wall = try Wall(contact1: SIMD3(0, 0, 0), contact2: SIMD3(4, 0, 0), cameraPosition: SIMD3(2, 1.5, -3)) + #expect(wall.normal == SIMD3(0, 0, -1)) + #expect(wall.direction == SIMD3(1, 0, 0)) + } + + @Test func `diagonal wall`() throws { + // Horizontal run (3, 0, 4) has length 5, so u = (0.6, 0, 0.8) and + // u × g = (−0.8, 0, 0.6); the camera at (0, 1.5, 5) gives n·o = 3 > 0. + let wall = try Wall(contact1: SIMD3(0, 0, 0), contact2: SIMD3(3, 0, 4), cameraPosition: SIMD3(0, 1.5, 5)) + #expect(isClose(wall.direction, SIMD3(0.6, 0, 0.8))) + #expect(isClose(wall.normal, SIMD3(-0.8, 0, 0.6))) + #expect(isClose(wall.length, 5)) + } + + @Test func `contacts must be two meters apart horizontally`() { + #expect(throws: WallError.contactsTooClose(separation: 1.5, minimum: 2)) { + try Wall(contact1: .zero, contact2: SIMD3(1.5, 0, 0), cameraPosition: camera) + } + // The 3D distance is over 2 m, but the horizontal part is only about 1.91 m. + let error = #expect(throws: WallError.self) { + try Wall(contact1: .zero, contact2: SIMD3(1.9, 0.5, 0.2), cameraPosition: camera) + } + guard case .contactsTooClose(let separation, 2)? = error else { + Issue.record("Expected contactsTooClose, got \(String(describing: error))") + return + } + #expect(isClose(separation, (1.9 * 1.9 + 0.2 * 0.2).squareRoot())) + // Vertically stacked contacts have no horizontal run at all. + #expect(throws: WallError.contactsTooClose(separation: 0, minimum: 2)) { + try Wall(contact1: .zero, contact2: SIMD3(0, 1, 0), cameraPosition: camera) + } + #expect(throws: Never.self) { + try Wall(contact1: .zero, contact2: SIMD3(2, 0, 0), cameraPosition: camera) + } + } + + @Test func `camera standing in the wall plane is refused`() { + #expect(throws: WallError.cameraInWallPlane(offset: 0.05, minimum: 0.1)) { + try Wall(contact1: .zero, contact2: SIMD3(4, 0, 0), cameraPosition: SIMD3(6, 1.5, 0.05)) + } + } + + @Test func `along-wall position, offset and gap`() throws { + let wall = try wall + #expect(wall.along(SIMD3(1, 5, 3)) == 1) + #expect(wall.alongDistance(from: SIMD3(1, 5, 3), to: SIMD3(3.5, 0, -2)) == 2.5) + #expect(wall.alongDistance(from: SIMD3(3.5, 0, -2), to: SIMD3(1, 5, 3)) == -2.5) + #expect(wall.offset(of: SIMD3(2, 0, 1.5)) == 1.5) + #expect(wall.offset(of: SIMD3(2, 0, -0.5)) == -0.5) + #expect(wall.gap(to: SIMD3(2, 0, -0.5)) == 0.5) + // The gap ignores height and position along the wall. + #expect(wall.gap(to: SIMD3(-7, 9, 1.25)) == 1.25) + } + + @Test func `facing gap from a diagonal wall`() throws { + let wall = try Wall(contact1: SIMD3(0, 0, 0), contact2: SIMD3(3, 0, 4), cameraPosition: SIMD3(0, 1.5, 5)) + // n = (−0.8, 0, 0.6): the point (−0.8, 0.3, 0.6) + 0.5·u sits 1 m in front of the wall. + let fencePost = SIMD3(-0.8, 0.3, 0.6) + SIMD3(0.6, 0, 0.8) * 0.5 + #expect(isClose(wall.gap(to: fencePost), 1)) + #expect(isClose(wall.along(fencePost), 0.5)) + } + + @Test func `height above a sloping ground line`() throws { + // The ground rises 0.4 m over the 4 m wall. + let wall = try Wall(contact1: SIMD3(0, 0, 0), contact2: SIMD3(4, 0.4, 0), cameraPosition: camera) + #expect(isClose(wall.groundHeight(atAlong: 2), 0.2)) + #expect(isClose(wall.heightAboveGround(SIMD3(2, 1.2, 0)), 1.0)) + // Beyond the second contact the line is extended and flagged. + #expect(isClose(wall.groundHeight(atAlong: 6), 0.6)) + #expect(wall.containsAlong(4)) + #expect(!wall.containsAlong(6)) + #expect(!wall.containsAlong(-0.01)) + } + + // A wall that is translated, rotated and on sloped ground, so dropping the ground line's + // intercept or the plane's offset from the origin changes every answer below. + // Contacts (1, 0.3, 2) and (4, 0.9, 6): horizontal run (3, 0, 4), length 5, u = (0.6, 0, 0.8), + // n = u × g = (−0.8, 0, 0.6), ground rising 0.6 m over the wall. The camera stands 3 m out + // along n and 1.5 m up from the first contact. + let offsetWallStart = SIMD3(1, 0.3, 2) + let offsetCamera = SIMD3(1, 0.3, 2) + SIMD3(-0.8, 0, 0.6) * 3 + SIMD3(0, 1.5, 0) + var offsetWall: Wall { + get throws { try Wall(contact1: offsetWallStart, contact2: SIMD3(4, 0.9, 6), cameraPosition: offsetCamera) } + } + + @Test func `sloped ground line off the origin`() throws { + let wall = try offsetWall + #expect(isClose(wall.normal, SIMD3(-0.8, 0, 0.6))) + #expect(isClose(wall.length, 5)) + // Halfway along, the ground is 0.3 + 0.6 · 2.5 / 5 = 0.6. + #expect(isClose(wall.groundHeight(atAlong: 2.5), 0.6)) + #expect(isClose(wall.groundHeight(atAlong: 0), 0.3)) + #expect(isClose(wall.groundHeight(atAlong: 5), 0.9)) + // A point on the wall 2.5 m along at y = 1.9 is 1.3 m above the ground there. + let point = offsetWallStart + SIMD3(0.6, 0, 0.8) * 2.5 + SIMD3(0, 1.6, 0) + #expect(isClose(wall.along(point), 2.5)) + #expect(isClose(wall.offset(of: point), 0)) + #expect(isClose(wall.heightAboveGround(point), 1.3)) + #expect(isClose(wall.offset(of: offsetCamera), 3)) + } + + @Test func `ray meets a translated, rotated wall plane`() throws { + let wall = try offsetWall + // Aim at (2.5, 1.5, 4), which is 2.5 m along the wall: n·((2.5, 1.5, 4) − (1, 0.3, 2)) = 0. + // From the camera at (−1.4, 1.8, 3.8) the direction is (3.9, −0.3, 0.2), with n·d = −3. + let target = SIMD3(2.5, 1.5, 4) + #expect(isClose(offsetCamera, SIMD3(-1.4, 1.8, 3.8))) + let hit = try wall.intersect(try Ray(origin: offsetCamera, direction: SIMD3(3.9, -0.3, 0.2))) + #expect(isClose(hit.point, target)) + #expect(isClose(hit.range, 15.34.squareRoot())) + #expect(isClose(hit.angleFromNormal, acos(3 / 15.34.squareRoot()) * 180 / .pi)) + #expect(isClose(hit.along, 2.5)) + // Ground at 2.5 m along is 0.6, so the target is 0.9 m up. + #expect(isClose(hit.heightAboveGround, 0.9)) + #expect(hit.withinContacts) + } + + @Test func `third contact validates the plane within two inches`() throws { + let wall = try wall + let close = wall.validate(contact: SIMD3(3, 0, 0.04)) + #expect(isClose(close.residual, 0.04)) + #expect(close.tolerance == 0.0508) + #expect(close.passes) + let far = wall.validate(contact: SIMD3(3, 0, -0.06)) + #expect(isClose(far.residual, 0.06)) + #expect(!far.passes) + } + + @Test func `ray meets the wall plane`() throws { + let wall = try wall + // From the camera toward (1, 1, 0): direction (−1, −0.5, −3), length √10.25. + let ray = try Ray(origin: camera, direction: SIMD3(-1, -0.5, -3)) + let hit = try wall.intersect(ray) + #expect(isClose(hit.point, SIMD3(1, 1, 0))) + #expect(isClose(hit.range, 10.25.squareRoot())) + // cos θ = 3 / √10.25. + #expect(isClose(hit.angleFromNormal, acos(3 / 10.25.squareRoot()) * 180 / .pi)) + #expect(isClose(hit.along, 1)) + #expect(isClose(hit.heightAboveGround, 1)) + #expect(hit.withinContacts) + } + + @Test func `hit beyond the contacts is flagged`() throws { + let hit = try wall.intersect(try Ray(origin: camera, direction: SIMD3(3, 0, -3))) + #expect(isClose(hit.point, SIMD3(5, 1.5, 0))) + #expect(isClose(hit.along, 5)) + #expect(!hit.withinContacts) + } + + @Test(arguments: [59.0, 45.0, 0.0]) + func `rays within sixty degrees of the normal are accepted`(angle: Double) throws { + let r = angle * .pi / 180 + let hit = try wall.intersect(try Ray(origin: camera, direction: SIMD3(sin(r), 0, -cos(r)))) + #expect(isClose(hit.angleFromNormal, angle, within: 1e-7)) + #expect(isClose(hit.range, 3 / cos(r))) + } + + @Test func `grazing rays are refused`() throws { + // |n·d| = 0.5 / √1.25 ≈ 0.447, below cos 60° = 0.5: atan(2) ≈ 63.4° from the normal. + let grazing = try Ray(origin: camera, direction: SIMD3(1, 0, -0.5)) + try expectGrazing(try wall, grazing, angle: atan(2) * 180 / .pi) + let r = 61 * Double.pi / 180 + try expectGrazing(try wall, try Ray(origin: camera, direction: SIMD3(sin(r), 0, -cos(r))), angle: 61) + // Parallel to the wall: 90° from the normal, refused before any division by n·d = 0. + try expectGrazing(try wall, try Ray(origin: camera, direction: SIMD3(1, 0, 0)), angle: 90) + } + + private func expectGrazing(_ wall: Wall, _ ray: Ray, angle expected: Double) throws { + let error = #expect(throws: WallHitError.self) { try wall.intersect(ray) } + guard case .grazing(let angle, let maximum)? = error else { + Issue.record("Expected a grazing refusal, got \(String(describing: error))") + return + } + #expect(isClose(angle, expected, within: 1e-7)) + #expect(maximum == 60) + } + + @Test func `intersections behind the camera are refused`() throws { + let away = try Ray(origin: camera, direction: SIMD3(0, 0, 1)) + #expect(throws: WallHitError.behindCamera(t: -3)) { + try wall.intersect(away) + } + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WarningsTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WarningsTests.swift new file mode 100644 index 000000000..2434ac89a --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WarningsTests.swift @@ -0,0 +1,75 @@ +import Testing +@testable import MeasureGeometry + +struct WarningsTests { + @Test func `a wall point inherits its wall's contact warnings`() { + let wall = WallStatus(contactWarnings: [[.shallowLookDown], []], validations: [true]) + let point = PointEvidence(own: [], wall: wall) + #expect(point.warnings == [.wallContactWarning]) + } + + @Test func `a failed validation reaches later wall points`() { + let wall = WallStatus(contactWarnings: [[], []], validations: [false]) + #expect(PointEvidence(own: [], wall: wall).warnings == [.wallValidationFailed]) + // A second, passing check does not clear the failure. + let rechecked = WallStatus(contactWarnings: [[], []], validations: [false, true]) + #expect(rechecked.warnings == [.wallValidationFailed]) + } + + @Test func `a wall status lists its own qualification`() { + #expect(WallStatus(contactWarnings: [[], []], validations: [true]).warnings.isEmpty) + #expect(WallStatus(contactWarnings: [[], []], validations: []).warnings == [.wallNotValidated]) + #expect( + WallStatus(contactWarnings: [[.estimatedPlane], [.extendedPlane]], validations: []) + .warnings == [.wallContactWarning, .wallNotValidated] + ) + } + + @Test func `point warnings combine own and wall, sorted, without repeats`() { + let wall = WallStatus(contactWarnings: [[.shallowLookDown], []], validations: [false]) + let point = PointEvidence(own: [.outsideWallContacts, .outsideWallContacts], wall: wall) + #expect(point.warnings == [.outsideWallContacts, .wallContactWarning, .wallValidationFailed]) + } + + @Test func `a measurement inherits both points and the walls behind them`() { + let flaggedWall = WallStatus(contactWarnings: [[.shallowLookDown], []], validations: [true]) + let warnings = measurementWarnings( + from: PointEvidence(own: [], wall: flaggedWall), + to: PointEvidence(own: [.estimatedPlane]), + targetWall: nil, + referenceWall: nil, + compared: .horizontal, + value: 1.2 + ) + #expect(warnings == [.estimatedPlane, .wallContactWarning]) + } + + @Test func `the reference wall matters only for along-wall distance`() { + let unvalidated = WallStatus(contactWarnings: [[], []], validations: []) + let clean = PointEvidence(own: []) + #expect(measurementWarnings( + from: clean, to: clean, targetWall: nil, referenceWall: unvalidated, compared: .horizontal, value: 2 + ).isEmpty) + #expect(measurementWarnings( + from: clean, to: clean, targetWall: nil, referenceWall: unvalidated, compared: .alongWall, value: 2 + ) == [.wallNotValidated]) + } + + @Test func `a point-to-wall measurement inherits the target wall`() { + let failed = WallStatus(contactWarnings: [[], []], validations: [false]) + #expect(measurementWarnings( + from: PointEvidence(own: []), to: nil, targetWall: failed, referenceWall: nil, compared: .gapToWall, value: 1 + ) == [.wallValidationFailed]) + } + + @Test func `a negative height above ground is flagged`() { + let good = WallStatus(contactWarnings: [[], []], validations: [true]) + let clean = PointEvidence(own: []) + #expect(measurementWarnings( + from: clean, to: nil, targetWall: good, referenceWall: nil, compared: .heightAboveGround, value: -1 + ) == [.belowGround]) + #expect(measurementWarnings( + from: clean, to: nil, targetWall: good, referenceWall: nil, compared: .heightAboveGround, value: 1 + ).isEmpty) + } +} diff --git a/experiments/measure-lab/Makefile b/experiments/measure-lab/Makefile new file mode 100644 index 000000000..5c4e7b56f --- /dev/null +++ b/experiments/measure-lab/Makefile @@ -0,0 +1,27 @@ +# Commands for this experiment. CI (.github/workflows/measure-lab.yml) runs the same ones. + +XCODEGEN ?= xcodegen +# Must match XCODEGEN_VERSION in .github/workflows/measure-lab.yml; other versions write a different project. +XCODEGEN_VERSION := 2.46.0 + +.PHONY: check test build project + +check: test build + +test: + cd Geometry && swift test + +build: + xcodebuild -project MeasureLab.xcodeproj -scheme MeasureLab -configuration Debug \ + -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO build + +# Regenerates MeasureLab.xcodeproj from project.yml. Point XCODEGEN at another binary if the one on +# PATH is not $(XCODEGEN_VERSION): make project XCODEGEN=/path/to/xcodegen +project: + @found="$$($(XCODEGEN) --version 2>/dev/null)"; \ + if [ "$$found" != "Version: $(XCODEGEN_VERSION)" ]; then \ + echo "project needs XcodeGen $(XCODEGEN_VERSION), but $(XCODEGEN) reports: $${found:-nothing (not installed?)}." >&2; \ + echo "Download https://github.com/yonaskolb/XcodeGen/releases/download/$(XCODEGEN_VERSION)/xcodegen.zip, check its SHA-256 against .github/workflows/measure-lab.yml, and rerun with XCODEGEN=/path/to/xcodegen/bin/xcodegen." >&2; \ + exit 1; \ + fi + $(XCODEGEN) generate --spec project.yml diff --git a/experiments/measure-lab/MeasureLab.xcodeproj/project.pbxproj b/experiments/measure-lab/MeasureLab.xcodeproj/project.pbxproj new file mode 100644 index 000000000..62ccbfad7 --- /dev/null +++ b/experiments/measure-lab/MeasureLab.xcodeproj/project.pbxproj @@ -0,0 +1,371 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 3393B9CB3BC05791F790C61D /* MeasureGeometry in Frameworks */ = {isa = PBXBuildFile; productRef = CB1AAD3F644BB2B1470FA90E /* MeasureGeometry */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2E227FDEBD6A4C6F1E28FA55 /* MeasureLab.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = MeasureLab.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 479820C94179C67292ED85AB /* Geometry */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Geometry; path = Geometry; sourceTree = SOURCE_ROOT; }; + CACF160F0681649C9FC0373B /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; + E23191136B5FAD741D8DF52C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 8881F97BE60D6F12F7DDE669 /* MeasureLab */ = { + isa = PBXFileSystemSynchronizedRootGroup; + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = MeasureLab; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + B917ECEB746905275E5809D6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3393B9CB3BC05791F790C61D /* MeasureGeometry in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0499EE996C206E86071BD0F4 /* Packages */ = { + isa = PBXGroup; + children = ( + 479820C94179C67292ED85AB /* Geometry */, + ); + name = Packages; + sourceTree = ""; + }; + 38C0131634DE65CD5CF82570 = { + isa = PBXGroup; + children = ( + 5989C7D41663F50612EFA989 /* Config */, + 8881F97BE60D6F12F7DDE669 /* MeasureLab */, + 0499EE996C206E86071BD0F4 /* Packages */, + D729CF88AA6F7F330740CD9F /* Products */, + ); + sourceTree = ""; + }; + 5989C7D41663F50612EFA989 /* Config */ = { + isa = PBXGroup; + children = ( + E23191136B5FAD741D8DF52C /* Info.plist */, + CACF160F0681649C9FC0373B /* Shared.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; + D729CF88AA6F7F330740CD9F /* Products */ = { + isa = PBXGroup; + children = ( + 2E227FDEBD6A4C6F1E28FA55 /* MeasureLab.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 8ABD98754F6EDF1868DDBF40 /* MeasureLab */ = { + isa = PBXNativeTarget; + buildConfigurationList = FB545F1C83D68432CF54E91E /* Build configuration list for PBXNativeTarget "MeasureLab" */; + buildPhases = ( + 39E232FEFACDA9777A2BDA8C /* Sources */, + 34356284015161244386AA76 /* Resources */, + B917ECEB746905275E5809D6 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8881F97BE60D6F12F7DDE669 /* MeasureLab */, + ); + name = MeasureLab; + packageProductDependencies = ( + CB1AAD3F644BB2B1470FA90E /* MeasureGeometry */, + ); + productName = MeasureLab; + productReference = 2E227FDEBD6A4C6F1E28FA55 /* MeasureLab.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4BEDB4490DE102AD2B58C6F5 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 2600; + TargetAttributes = { + 8ABD98754F6EDF1868DDBF40 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 1DAED2EC49E2D0BBEA401AC6 /* Build configuration list for PBXProject "MeasureLab" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 38C0131634DE65CD5CF82570; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 0DA7BA37E3C893C9BF3FA8AA /* XCLocalSwiftPackageReference "Geometry" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = D729CF88AA6F7F330740CD9F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8ABD98754F6EDF1868DDBF40 /* MeasureLab */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 34356284015161244386AA76 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 39E232FEFACDA9777A2BDA8C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 34CDAC583940C159368F4EBC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 71F9C12A62375BB93BADC6F4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CACF160F0681649C9FC0373B /* Shared.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = Config/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).measurelab"; + PRODUCT_NAME = MeasureLab; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 993F07A2C0128F12C9236155 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CACF160F0681649C9FC0373B /* Shared.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = Config/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).measurelab"; + PRODUCT_NAME = MeasureLab; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + D03723A83C12365D8E66270E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1DAED2EC49E2D0BBEA401AC6 /* Build configuration list for PBXProject "MeasureLab" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 34CDAC583940C159368F4EBC /* Debug */, + D03723A83C12365D8E66270E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + FB545F1C83D68432CF54E91E /* Build configuration list for PBXNativeTarget "MeasureLab" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 71F9C12A62375BB93BADC6F4 /* Debug */, + 993F07A2C0128F12C9236155 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 0DA7BA37E3C893C9BF3FA8AA /* XCLocalSwiftPackageReference "Geometry" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Geometry; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CB1AAD3F644BB2B1470FA90E /* MeasureGeometry */ = { + isa = XCSwiftPackageProductDependency; + productName = MeasureGeometry; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 4BEDB4490DE102AD2B58C6F5 /* Project object */; +} diff --git a/experiments/measure-lab/MeasureLab.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/experiments/measure-lab/MeasureLab.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/experiments/measure-lab/MeasureLab.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/experiments/measure-lab/MeasureLab.xcodeproj/xcshareddata/xcschemes/MeasureLab.xcscheme b/experiments/measure-lab/MeasureLab.xcodeproj/xcshareddata/xcschemes/MeasureLab.xcscheme new file mode 100644 index 000000000..3a4864ad6 --- /dev/null +++ b/experiments/measure-lab/MeasureLab.xcodeproj/xcshareddata/xcschemes/MeasureLab.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/experiments/measure-lab/MeasureLab/AR/ARContainerView.swift b/experiments/measure-lab/MeasureLab/AR/ARContainerView.swift new file mode 100644 index 000000000..ee9d5119c --- /dev/null +++ b/experiments/measure-lab/MeasureLab/AR/ARContainerView.swift @@ -0,0 +1,17 @@ +import RealityKit +import SwiftUI + +/// The camera feed. `CaptureController` owns the ARView and its session. +struct ARContainerView: UIViewRepresentable { + let capture: CaptureController + + func makeUIView(context: Context) -> ARView { + capture.makeView() + } + + func updateUIView(_ view: ARView, context: Context) {} + + static func dismantleUIView(_ view: ARView, coordinator: ()) { + view.session.pause() + } +} diff --git a/experiments/measure-lab/MeasureLab/AR/ARConversions.swift b/experiments/measure-lab/MeasureLab/AR/ARConversions.swift new file mode 100644 index 000000000..f02f74fb3 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/AR/ARConversions.swift @@ -0,0 +1,44 @@ +import ARKit +import MeasureGeometry + +// ARKit hands out Float matrices; the geometry package works in Double. + +extension SIMD3 { + init(_ v: SIMD3) { + self.init(Double(v.x), Double(v.y), Double(v.z)) + } + + init(_ v: SIMD4) { + self.init(Double(v.x), Double(v.y), Double(v.z)) + } +} + +extension SIMD3 { + init(_ v: SIMD3) { + self.init(Float(v.x), Float(v.y), Float(v.z)) + } +} + +extension CameraPose { + /// `ARCamera.transform`: camera-to-world, columns are the camera axes and position. + init(_ m: simd_float4x4) { + self.init( + xAxis: SIMD3(m.columns.0), + yAxis: SIMD3(m.columns.1), + zAxis: SIMD3(m.columns.2), + position: SIMD3(m.columns.3) + ) + } +} + +extension CameraIntrinsics { + /// `ARCamera.intrinsics` is column-major: fx and fy on the diagonal, (cx, cy) in the third column. + init(_ k: simd_float3x3) { + self.init( + fx: Double(k.columns.0.x), + fy: Double(k.columns.1.y), + cx: Double(k.columns.2.x), + cy: Double(k.columns.2.y) + ) + } +} diff --git a/experiments/measure-lab/MeasureLab/AR/CaptureController.swift b/experiments/measure-lab/MeasureLab/AR/CaptureController.swift new file mode 100644 index 000000000..ff71579b5 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/AR/CaptureController.swift @@ -0,0 +1,288 @@ +import ARKit +import MeasureGeometry +import Observation +import RealityKit +import UIKit + +/// Owns the ARView and ARSession and turns screen taps into `TapInput`s for `LabSession`. +/// +/// A live tap saves the current frame as a keyframe and maps the screen point to that frame's +/// pixel with ARKit's display transform, which matches what ARView draws. A frozen tap maps +/// through `PortraitFillMapping`, which matches how the frozen image is drawn. Either way the ray +/// is rebuilt from the saved frame's pose and intrinsics, never from the live camera. +@MainActor +@Observable +final class CaptureController { + struct FrozenFrame { + let snapshot: FrameSnapshot + let image: UIImage + let mapping: PortraitFillMapping + } + + private(set) var frozen: FrozenFrame? + let session: LabSession + + @ObservationIgnored private var arView: ARView? + @ObservationIgnored private var delegate: SessionDelegate? + @ObservationIgnored private let delegateQueue = DispatchQueue(label: "MeasureLab.ARSessionDelegate") + @ObservationIgnored private var markerRoot: AnchorEntity? + @ObservationIgnored private var drawnPoints = 0 + @ObservationIgnored private var drawnWalls = 0 + + init(session: LabSession) { + self.session = session + } + + func makeView() -> ARView { + // The app configures the session itself so nothing runs that it didn't ask for: no scene + // reconstruction, and scene depth only when a LiDAR comparison run turns it on. + let view = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false) + view.renderOptions.insert(.disableMotionBlur) + let delegate = SessionDelegate(session: session, recorder: session.recorder) + view.session.delegateQueue = delegateQueue + view.session.delegate = delegate + self.delegate = delegate + arView = view + let root = AnchorEntity(world: .zero) + view.scene.addAnchor(root) + markerRoot = root + run(reset: false) + return view + } + + private func run(reset: Bool) { + let configuration = ARWorldTrackingConfiguration() + // .gravity, not .gravityAndHeading: the compass is unreliable next to a house (docs/00). + configuration.worldAlignment = .gravity + configuration.planeDetection = [.horizontal, .vertical] + if session.sceneDepthEnabled, ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) { + configuration.frameSemantics.insert(.sceneDepth) + } + arView?.session.run(configuration, options: reset ? [.resetTracking, .removeExistingAnchors] : []) + } + + /// Ends the session and opens a new one with its own world frame. + /// + /// The recorder stops before the old session closes. It waits for a new tracking cycle and + /// frames captured after the reset before saving in the new world frame. + func startNewSession(sceneDepth: Bool) { + let closing = session.recorder.stop() + frozen = nil + let destination = session.startNewSession(sceneDepth: sceneDepth, closingReserved: closing?.reserved ?? 0) + session.arSessionRestarted() + clearMarkers() + // Install the gate behind callbacks already queued from the old map. The cutoff uses the + // frame clock, not the timestamp of the last frame delivered before this reset. + let resetUptime = ProcessInfo.processInfo.systemUptime + if var destination { + destination.resetGate = FrameResetGate(resetUptime: resetUptime) + let gatedDestination = destination + delegateQueue.sync { [recorder = session.recorder] in recorder.start(gatedDestination) } + } + run(reset: true) + } + + // MARK: - Freezing + + func freeze(viewSize: CGSize) { + if let blocker = session.markBlocker { + session.refuseUnresolvedTap(reason: "trackingNotReady", message: blocker) + return + } + guard let frame = arView?.session.currentFrame else { return } + do { + let result = try session.recorder.save(frame, reason: .freeze, displayImage: true) + session.keyframeDelivered(result.delivery) + guard case .success(let saved) = result.delivery.result, let cgImage = result.image else { return } + let snapshot = saved.snapshot + guard let mapping = portraitMapping(imageWidth: snapshot.imageWidth, imageHeight: snapshot.imageHeight, viewSize: viewSize) else { + return + } + frozen = FrozenFrame( + snapshot: snapshot, + // .right rotates the landscape sensor image 90° clockwise for an upright phone, + // the same rotation PortraitFillMapping assumes. + image: UIImage(cgImage: cgImage, scale: 1, orientation: .right), + mapping: mapping + ) + } catch { + session.recorderFailed(error) + } + } + + func unfreeze() { + frozen = nil + } + + // MARK: - Taps + + /// Marks at a point of the full-screen camera layer, whose size is `viewSize`. + func mark(at point: CGPoint, viewSize: CGSize) { + if let blocker = session.markBlocker { + session.refuseUnresolvedTap(reason: "trackingNotReady", message: blocker) + return + } + let resolved: (snapshot: FrameSnapshot, pixel: (u: Double, v: Double), check: Double?) + if let frozen { + resolved = (frozen.snapshot, frozen.mapping.imagePixel(forViewPoint: point.x, point.y), nil) + } else { + guard let live = resolveLive(point, viewSize: viewSize) else { return } + resolved = live + } + // The live check above uses the latest tracking callback; this one uses the tapped frame. + guard resolved.snapshot.tracking == .normal else { + session.refuseUnresolvedTap(reason: "trackingNotReady", message: "That frame wasn't in normal tracking. Hold steady and try again.") + return + } + guard resolved.snapshot.contains(pixel: resolved.pixel.u, resolved.pixel.v) else { + session.refuseUnresolvedTap(reason: "outsideImage", message: "That spot is outside the camera image.") + return + } + let ray: Ray + do { + ray = try resolved.snapshot.camera.ray(throughPixel: resolved.pixel.u, resolved.pixel.v) + } catch { + session.refuseUnresolvedTap(reason: "invalidPose", message: "ARKit returned an unusable camera pose for that frame.") + return + } + session.handle(TapInput( + snapshot: resolved.snapshot, + pixel: resolved.pixel, + ray: ray, + frozen: frozen != nil, + displayMappingCheck: resolved.check, + ground: session.tool.needsGround ? groundHit(along: ray) : nil + )) + // A two-view pair needs a second frame, so a frozen first view is let go right away. + if session.tool == .twoView { + frozen = nil + } + syncMarkers() + } + + private func resolveLive(_ point: CGPoint, viewSize: CGSize) -> (snapshot: FrameSnapshot, pixel: (u: Double, v: Double), check: Double?)? { + guard let frame = arView?.session.currentFrame else { + session.refuseUnresolvedTap(reason: "noFrame", message: "The camera hasn't delivered a frame yet.") + return nil + } + // imageResolution is the size of capturedImage, which the recorder saves unscaled. + let resolution = frame.camera.imageResolution + guard let mapping = portraitMapping(imageWidth: Int(resolution.width), imageHeight: Int(resolution.height), viewSize: viewSize) else { + return nil + } + let delivery: KeyframeRecorder.Delivery + do { + delivery = try session.recorder.save(frame, reason: .tap).delivery + } catch { + session.recorderFailed(error) + return nil + } + session.keyframeDelivered(delivery) + guard case .success(let saved) = delivery.result else { return nil } + let snapshot = saved.snapshot + // displayTransform maps normalized image coordinates to normalized view coordinates for + // this orientation and viewport, the same mapping ARView uses to draw the camera feed. + let toImage = frame.displayTransform(for: .portrait, viewportSize: viewSize).inverted() + let normalized = CGPoint(x: point.x / viewSize.width, y: point.y / viewSize.height).applying(toImage) + let pixel = (u: Double(normalized.x) * Double(snapshot.imageWidth), v: Double(normalized.y) * Double(snapshot.imageHeight)) + let mapped = mapping.imagePixel(forViewPoint: point.x, point.y) + let check = ((mapped.u - pixel.u) * (mapped.u - pixel.u) + (mapped.v - pixel.v) * (mapped.v - pixel.v)).squareRoot() + return (snapshot, pixel, check) + } + + /// The frozen-frame mapping for this view size, or a logged refusal when the view has no usable + /// size yet. + private func portraitMapping(imageWidth: Int, imageHeight: Int, viewSize: CGSize) -> PortraitFillMapping? { + let mapping = PortraitFillMapping( + imageWidth: Double(imageWidth), + imageHeight: Double(imageHeight), + viewWidth: Double(viewSize.width), + viewHeight: Double(viewSize.height) + ) + if mapping == nil { + session.refuseUnresolvedTap(reason: "invalidViewport", message: "The camera view isn't ready yet. Try again.") + } + return mapping + } + + /// Ground raycast along the tap ray: a found plane first, then its infinite extension, then + /// ARKit's estimate. The surface kind is kept so later analysis can drop weaker hits. + private func groundHit(along ray: Ray) -> GroundHit? { + guard let arSession = arView?.session else { return nil } + let targets: [(ARRaycastQuery.Target, GroundHit.Surface)] = [ + (.existingPlaneGeometry, .detectedPlane), + (.existingPlaneInfinite, .extendedPlane), + (.estimatedPlane, .estimatedPlane), + ] + for (target, surface) in targets { + let query = ARRaycastQuery( + origin: SIMD3(ray.origin), + direction: SIMD3(ray.direction), + allowing: target, + alignment: .horizontal + ) + if let result = arSession.raycast(query).first { + return GroundHit( + point: SIMD3(result.worldTransform.columns.3), + surface: surface, + planeAnchor: result.anchor?.identifier.uuidString + ) + } + } + return nil + } + + // MARK: - Markers + + /// Adds a dot for each new point and a line along the base of each new wall. + func syncMarkers() { + guard let root = markerRoot else { return } + let points = session.manifest.points + for point in points.dropFirst(drawnPoints) { + let dot = ModelEntity( + mesh: .generateSphere(radius: 0.015), + materials: [UnlitMaterial(color: session.evidence(for: point).warnings.isEmpty ? Theme.accentUIColor : .systemOrange)] + ) + dot.position = SIMD3(point.position) + root.addChild(dot) + } + drawnPoints = points.count + let walls = session.manifest.walls + for wall in walls.dropFirst(drawnWalls) { + // Drawn between the two contacts in 3D, so a sloped ground line shows its slope. + // Measurements use the horizontal length; this is only the overlay. + let run = SIMD3(wall.end - wall.start) + let line = ModelEntity( + mesh: .generateBox(size: SIMD3(simd_length(run), 0.008, 0.008)), + materials: [UnlitMaterial(color: Theme.accentUIColor.withAlphaComponent(0.8))] + ) + line.position = SIMD3((wall.start + wall.end) / 2) + // The box's long side is x; turn x onto the contact-to-contact direction. + line.orientation = simd_quatf(from: SIMD3(1, 0, 0), to: simd_normalize(run)) + root.addChild(line) + } + drawnWalls = walls.count + } + + private func clearMarkers() { + markerRoot?.children.removeAll() + drawnPoints = 0 + drawnWalls = 0 + } + + // MARK: - Two-view guide + + /// The first two-view ray drawn into the frozen frame as a line of view points, so the second + /// tap can go on the same feature. Nil without a frozen frame or a first view. + func epipolarGuide() -> [CGPoint]? { + guard let frozen, let first = session.twoViewFirst else { return nil } + // Sample the ray from 0.3 m to 30 m with denser samples close to the camera. + let points = (0...60).compactMap { step -> CGPoint? in + let distance = 0.3 * pow(100, Double(step) / 60) + guard let pixel = frozen.snapshot.camera.project(first.ray.point(at: distance)) else { return nil } + let view = frozen.mapping.viewPoint(forImagePixel: pixel.u, pixel.v) + return CGPoint(x: view.x, y: view.y) + } + return points.count >= 2 ? points : nil + } +} diff --git a/experiments/measure-lab/MeasureLab/AR/FrameSnapshot.swift b/experiments/measure-lab/MeasureLab/AR/FrameSnapshot.swift new file mode 100644 index 000000000..84a41fcd1 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/AR/FrameSnapshot.swift @@ -0,0 +1,17 @@ +import MeasureGeometry + +/// What the app keeps of a saved keyframe to turn a tap on it into a ray later. ARFrame itself +/// is not kept: holding frames starves ARKit's camera buffer pool. +struct FrameSnapshot: Sendable { + let sessionID: String + let keyframeID: String + let camera: CameraFrame + let imageWidth: Int + let imageHeight: Int + let timestamp: Double + let tracking: TrackingState + + func contains(pixel u: Double, _ v: Double) -> Bool { + (0...Double(imageWidth)).contains(u) && (0...Double(imageHeight)).contains(v) + } +} diff --git a/experiments/measure-lab/MeasureLab/AR/KeyframeRecorder.swift b/experiments/measure-lab/MeasureLab/AR/KeyframeRecorder.swift new file mode 100644 index 000000000..0e895f6db --- /dev/null +++ b/experiments/measure-lab/MeasureLab/AR/KeyframeRecorder.swift @@ -0,0 +1,263 @@ +import ARKit +import CoreImage +import MeasureGeometry +import Synchronization + +/// Writes keyframes to the current session folder: the unrotated sensor image as JPEG plus the +/// pose, intrinsics, timestamp and tracking state that go with it. +/// +/// Two callers use it: the ARSession delegate queue offers every frame for motion keyframes, and +/// the main actor saves a frame for each tap or freeze. The folder, id counter and spacing state sit +/// in a `Mutex`; encoding happens outside the lock. `@unchecked Sendable` covers the `CIContext`, +/// which Core Image documents as safe to share between threads but older SDKs do not mark +/// `Sendable`. +final class KeyframeRecorder: @unchecked Sendable { + struct Destination: Sendable, Equatable { + let sessionID: String + let folder: URL + /// A restarted session waits for limited/unavailable then normal tracking before writing. + /// The first session has no reset gate. + var resetGate: FrameResetGate? + } + + /// A saved keyframe and the frame data a tap on it needs. + struct Saved: Sendable { + let record: KeyframeRecord + let snapshot: FrameSnapshot + } + + /// The outcome of one reserved write, tagged with the session that reserved it. Every + /// reservation produces exactly one delivery, success or failure, so `KeyframeRouter` can tell + /// when a closed session has nothing left in flight. + struct Delivery: Sendable { + let sessionID: String + let result: Result + } + + private struct State { + var destination: Destination? + var selector = KeyframeSelector() + /// Reservations made for the current destination. + var count = 0 + } + + private let state = Mutex(State()) + private let context = CIContext() + private let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + + var spacing: (meters: Double, degrees: Double) { + state.withLock { ($0.selector.minimumTranslation, $0.selector.minimumRotation) } + } + + /// Starts writing into `folder`, which must already contain a `keyframes` directory. + func start(_ destination: Destination) { + state.withLock { state in + state.destination = destination + // reset() keeps reservation ids increasing, so a write from the previous session that + // finishes now can't commit into this session's spacing. + state.selector.reset() + state.count = 0 + } + } + + /// Called on the delegate queue before the main actor receives the tracking update. + func trackingChanged(isNormal: Bool, at uptime: Double) { + state.withLock { state in + state.destination?.resetGate?.trackingChanged(isNormal: isNormal, at: uptime) + } + } + + /// Stops accepting frames until the next `start`. Returns the stopped session and how many + /// writes it reserved in total, for `KeyframeRouter.closeCurrent(reserved:)`. + @discardableResult + func stop() -> (sessionID: String, reserved: Int)? { + state.withLock { state in + defer { + state.destination = nil + state.selector.reset() + state.count = 0 + } + return state.destination.map { ($0.sessionID, state.count) } + } + } + + /// Delegate queue. Saves a keyframe when the camera moved or turned past the spacing since the + /// last saved one. Returns nil when no keyframe was due. + func offerMotionFrame(_ frame: ARFrame) -> Delivery? { + if case .notAvailable = frame.camera.trackingState { return nil } + let pose = CameraPose(frame.camera.transform) + guard let reservation = reserve(pose: pose, timestamp: frame.timestamp, onlyIfDue: true) else { return nil } + return write(frame, reason: .motion, reservation: reservation).delivery + } + + /// Saves `frame` regardless of spacing. With `displayImage`, also returns the frame as a + /// CGImage in sensor orientation for the frozen-frame view. Throws only when nothing was + /// reserved; a failed write comes back as a failed delivery. + func save(_ frame: ARFrame, reason: KeyframeRecord.Reason, displayImage: Bool = false) throws(RecorderError) -> (delivery: Delivery, image: CGImage?) { + let pose = CameraPose(frame.camera.transform) + guard let reservation = reserve(pose: pose, timestamp: frame.timestamp, onlyIfDue: false) else { + throw isRecording ? .frameFromPreviousMap : .notRecording + } + return write(frame, reason: reason, reservation: reservation, displayImage: displayImage) + } + + private struct Reservation { + let destination: Destination + let id: String + let spacing: KeyframeSelector.Reservation + } + + private var isRecording: Bool { + state.withLock { $0.destination != nil } + } + + private func reserve(pose: CameraPose, timestamp: Double, onlyIfDue: Bool) -> Reservation? { + state.withLock { state in + guard let destination = state.destination else { return nil } + if let gate = destination.resetGate, !gate.accepts(frameTimestamp: timestamp) { return nil } + if onlyIfDue, !state.selector.wantsKeyframe(at: pose) { return nil } + state.count += 1 + return Reservation( + destination: destination, + id: String(format: "k%05d", state.count), + spacing: state.selector.reserve(at: pose) + ) + } + } + + /// Writes the frame, then commits the spacing checkpoint on success or releases it on + /// failure, so a failed write doesn't hold off the next keyframes. + private func write( + _ frame: ARFrame, + reason: KeyframeRecord.Reason, + reservation: Reservation, + displayImage: Bool = false + ) -> (delivery: Delivery, image: CGImage?) { + let result: Result<(saved: Saved, image: CGImage?), RecorderError> + do { + result = .success(try encodeAndWrite(frame, reason: reason, reservation: reservation, displayImage: displayImage)) + } catch { + result = .failure(error) + } + state.withLock { state in + switch result { + case .success: state.selector.commit(reservation.spacing) + case .failure: state.selector.cancel(reservation.spacing) + } + } + let sessionID = reservation.destination.sessionID + switch result { + case .success(let written): + return (Delivery(sessionID: sessionID, result: .success(written.saved)), written.image) + case .failure(let error): + return (Delivery(sessionID: sessionID, result: .failure(error)), nil) + } + } + + private func encodeAndWrite( + _ frame: ARFrame, + reason: KeyframeRecord.Reason, + reservation: Reservation, + displayImage: Bool + ) throws(RecorderError) -> (saved: Saved, image: CGImage?) { + let buffer = frame.capturedImage + let width = CVPixelBufferGetWidth(buffer) + let height = CVPixelBufferGetHeight(buffer) + // No orientation is applied: the JPEG keeps the sensor's landscape layout, which is what + // ARCamera.intrinsics describe. + let image = CIImage(cvPixelBuffer: buffer) + let options = [kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption: 0.9] + guard let jpeg = context.jpegRepresentation(of: image, colorSpace: colorSpace, options: options) else { + throw .encodingFailed(reservation.id) + } + let imagePath = "keyframes/\(reservation.id).jpg" + let folder = reservation.destination.folder + do { + try jpeg.write(to: folder.appending(path: imagePath)) + } catch { + throw .writeFailed(imagePath, error.localizedDescription) + } + + let depth = try writeDepth(frame, id: reservation.id, folder: folder) + let intrinsics = CameraIntrinsics(frame.camera.intrinsics) + let pose = CameraPose(frame.camera.transform) + let tracking = TrackingState(frame.camera.trackingState) + let record = KeyframeRecord( + id: reservation.id, + img: imagePath, + w: width, + h: height, + intrinsics: [intrinsics.fx, intrinsics.fy, intrinsics.cx, intrinsics.cy], + pose: pose.columnMajor, + timestamp: frame.timestamp, + tracking: tracking.manifestName, + reason: reason, + depth: depth + ) + let snapshot = FrameSnapshot( + sessionID: reservation.destination.sessionID, + keyframeID: reservation.id, + camera: CameraFrame(intrinsics: intrinsics, pose: pose), + imageWidth: width, + imageHeight: height, + timestamp: frame.timestamp, + tracking: tracking + ) + let cgImage = displayImage ? context.createCGImage(image, from: image.extent) : nil + return (Saved(record: record, snapshot: snapshot), cgImage) + } + + /// Writes LiDAR depth and confidence when the session asked for scene depth. + private func writeDepth(_ frame: ARFrame, id: String, folder: URL) throws(RecorderError) -> KeyframeRecord.Depth? { + guard let sceneDepth = frame.sceneDepth else { return nil } + let depthPath = "keyframes/\(id).depth.f32" + let confidencePath = "keyframes/\(id).confidence.u8" + let depthMap = sceneDepth.depthMap + do { + try Self.rows(of: depthMap, bytesPerPixel: 4).write(to: folder.appending(path: depthPath)) + if let confidence = sceneDepth.confidenceMap { + try Self.rows(of: confidence, bytesPerPixel: 1).write(to: folder.appending(path: confidencePath)) + } + } catch { + throw .writeFailed(depthPath, error.localizedDescription) + } + return KeyframeRecord.Depth( + file: depthPath, + confidenceFile: sceneDepth.confidenceMap == nil ? nil : confidencePath, + w: CVPixelBufferGetWidth(depthMap), + h: CVPixelBufferGetHeight(depthMap) + ) + } + + /// Copies a single-plane pixel buffer row by row, dropping any row padding. + private static func rows(of buffer: CVPixelBuffer, bytesPerPixel: Int) -> Data { + CVPixelBufferLockBaseAddress(buffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) } + let width = CVPixelBufferGetWidth(buffer) + let height = CVPixelBufferGetHeight(buffer) + let stride = CVPixelBufferGetBytesPerRow(buffer) + guard let base = CVPixelBufferGetBaseAddress(buffer) else { return Data() } + var data = Data(capacity: width * height * bytesPerPixel) + for row in 0.. = [] + private var verticalPlanes: Set = [] + private var walls: [String: Wall] = [:] + /// Sends each finished keyframe write to the session that reserved it. + private var router = KeyframeRouter() + /// Closed sessions with keyframe writes still in flight, by session id. Each is dropped once + /// its last write reports back, however many sessions opened since. + private var closedSessions: [String: (manifest: SessionManifest, folder: URL)] = [:] + private var stabilityTask: Task? + private var keyframesSinceSave = 0 + private var eventCount = 0 + + enum WallStep: Equatable { + case firstContact + case secondContact(pointID: String, position: SIMD3) + /// The wall exists; further taps are optional validation contacts. + case validate(wallID: String) + } + + struct TwoViewFirst { + let tapID: String + let keyframeID: String + let ray: Ray + } + + /// Pure: nothing touches disk until `start()`, so SwiftUI can re-create the owning view freely. + init() { + manifest = Self.makeManifest(sceneDepth: false, recorder: recorder) + } + + /// Opens the first session folder. Later calls do nothing. + func start() { + guard folder == nil, storageError == nil else { return } + manifest = Self.makeManifest(sceneDepth: false, recorder: recorder) + openFolder(startRecorder: true) + } + + // MARK: - Session lifecycle + + var sceneDepthEnabled: Bool { + manifest.session.sceneDepthEnabled + } + + /// Scene depth is fixed per session, so it can change only before the first tap. + var canChangeSceneDepth: Bool { + lidarAvailable && manifest.taps.isEmpty + } + + /// Saves and closes the current session and opens an empty one in a new folder. The caller + /// stops the recorder first, passes how many writes the closing session reserved, and starts + /// the recorder on the returned destination once the AR map resets. + func startNewSession(sceneDepth: Bool, closingReserved: Int) -> KeyframeRecorder.Destination? { + save() + if router.closeCurrent(reserved: closingReserved), let folder { + closedSessions[manifest.session.id] = (manifest, folder) + } + manifest = Self.makeManifest(sceneDepth: sceneDepth && lidarAvailable, recorder: recorder) + walls = [:] + wallStep = .firstContact + twoViewFirst = nil + lastEvent = nil + keyframesSinceSave = 0 + return openFolder(startRecorder: false) + } + + private static func makeManifest(sceneDepth: Bool, recorder: KeyframeRecorder) -> SessionManifest { + let now = Date.now + let idFormatter = DateFormatter() + idFormatter.locale = Locale(identifier: "en_US_POSIX") + idFormatter.timeZone = .gmt + idFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + let spacing = recorder.spacing + let wallGates = WallGates() + let triangulationGates = TriangulationGates() + let info = SessionInfo( + // The random suffix keeps two sessions started in the same second in separate folders. + id: idFormatter.string(from: now) + "-" + String(format: "%04x", UInt16.random(in: .min ... .max)), + startedAt: now.ISO8601Format(), + startedAtUptime: ProcessInfo.processInfo.systemUptime, + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", + deviceModel: deviceModelIdentifier(), + systemVersion: UIDevice.current.systemVersion, + lidarAvailable: ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth), + meshReconstructionSupported: ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh), + sceneDepthEnabled: sceneDepth + ) + let gates = GateValues( + trackingStableSeconds: trackingStableSeconds, + minimumGroundLookDown: minimumGroundLookDown, + minimumContactSeparation: wallGates.minimumContactSeparation, + maximumAngleFromWallNormal: wallGates.maximumAngleFromNormal, + wallValidationTolerance: wallGates.validationTolerance, + minimumCameraOffsetFromWall: wallGates.minimumCameraOffset, + minimumRayAngle: triangulationGates.minimumRayAngle, + maximumRayGap: triangulationGates.maximumGap, + keyframeSpacingMeters: spacing.meters, + keyframeSpacingDegrees: spacing.degrees + ) + return SessionManifest(session: info, gates: gates) + } + + @discardableResult + private func openFolder(startRecorder: Bool) -> KeyframeRecorder.Destination? { + do { + let folder = try SessionStore.makeFolder(id: manifest.session.id) + self.folder = folder + storageError = nil + let destination = KeyframeRecorder.Destination(sessionID: manifest.session.id, folder: folder) + router.open(manifest.session.id) + if startRecorder { recorder.start(destination) } + save() + return destination + } catch { + folder = nil + storageError = "Couldn't create a folder for this session: \(error.localizedDescription)" + return nil + } + } + + /// Writes session.json and returns the folder to archive. Called right before zipping so the + /// manifest lists every keyframe saved so far. + func prepareExport() -> URL? { + save() + return folder + } + + func save() { + guard let folder else { return } + do { + try SessionStore.write(manifest, to: folder) + keyframesSinceSave = 0 + } catch { + storageError = "Couldn't save session.json: \(error.localizedDescription)" + } + } + + // MARK: - ARKit state + + func trackingChanged(to state: TrackingState, at time: Double) { + guard state != trackingState else { return } + trackingState = state + manifest.tracking.append(TrackingRecord(time: time, state: state.manifestName)) + stabilityTask?.cancel() + isTrackingStable = false + guard state == .normal else { return } + stabilityTask = Task { + try? await Task.sleep(for: .seconds(Self.trackingStableSeconds)) + guard !Task.isCancelled, trackingState == .normal else { return } + isTrackingStable = true + } + } + + func planesAdded(_ planes: [DetectedPlane]) { + for plane in planes { + switch plane.orientation { + case .horizontal: horizontalPlanes.insert(plane.id) + case .vertical: verticalPlanes.insert(plane.id) + } + } + horizontalPlaneCount = horizontalPlanes.count + verticalPlaneCount = verticalPlanes.count + } + + func planesRemoved(_ planes: [DetectedPlane]) { + for plane in planes { + switch plane.orientation { + case .horizontal: horizontalPlanes.remove(plane.id) + case .vertical: verticalPlanes.remove(plane.id) + } + } + horizontalPlaneCount = horizontalPlanes.count + verticalPlaneCount = verticalPlanes.count + } + + func interruptionChanged(isInterrupted: Bool) { + self.isInterrupted = isInterrupted + if isInterrupted { + isTrackingStable = false + save() + } + } + + func sessionFailed(_ failure: SessionFailure) { + self.failure = failure + save() + } + + /// Called when a new ARSession run resets tracking: plane counts start from zero, and taps wait + /// until the new map reports normal tracking for the full stability time. + func arSessionRestarted() { + stabilityTask?.cancel() + trackingState = .notAvailable + isTrackingStable = false + horizontalPlanes = [] + verticalPlanes = [] + horizontalPlaneCount = 0 + verticalPlaneCount = 0 + } + + /// Files one finished keyframe write under the session that reserved it. + func keyframeDelivered(_ delivery: KeyframeRecorder.Delivery) { + let (route, drained) = router.report(for: delivery.sessionID) + switch (route, delivery.result) { + case (.current, .success(let saved)): + manifest.keyframes.append(saved.record) + keyframesSinceSave += 1 + // Keep session.json close to the images on disk without re-encoding it every frame. + if keyframesSinceSave >= 20 { save() } + case (.current, .failure(let error)): + storageError = error.message + case (.closed, .success(let saved)): + if var closed = closedSessions[delivery.sessionID] { + closed.manifest.keyframes.append(saved.record) + closedSessions[delivery.sessionID] = closed + try? SessionStore.write(closed.manifest, to: closed.folder) + } + case (.closed, .failure), (.unknown, _): + break + } + if drained { + closedSessions[delivery.sessionID] = nil + } + } + + /// A save that reserved nothing, so no delivery will follow. + func recorderFailed(_ error: RecorderError) { + if error == .frameFromPreviousMap { + refuseUnresolvedTap(reason: "frameFromPreviousMap", message: error.message) + } else { + storageError = error.message + } + } + + // MARK: - Tools + + func select(_ tool: Tool) { + self.tool = tool + } + + /// Why a tap would be refused right now, or nil when it would be accepted. + var markBlocker: String? { + if isInterrupted { return "Camera paused" } + if trackingState != .normal { return trackingState.instruction } + if !isTrackingStable { return "Hold steady for a moment" } + if tool == .wallPoint, activeWall == nil { return "Make a wall first" } + return nil + } + + private var activeWall: (id: String, wall: Wall)? { + guard let id = manifest.walls.last?.id, let wall = walls[id] else { return nil } + return (id, wall) + } + + var instruction: String { + switch tool { + case .ground: + return "Aim the ring at the ground, then Mark." + case .wall: + switch wallStep { + case .firstContact: + return "Aim where the wall meets the ground at one end, then Mark." + case .secondContact: + return "Walk at least 2 m (6 ft 7 in) along the wall. Mark its base again." + case .validate(let id): + return "\(id) is set. To check it, mark a third point on its base." + } + case .wallPoint: + guard let wall = activeWall else { return "Make a wall with the Wall tool first." } + return "Aim at a point on \(wall.id), then Mark." + case .twoView: + if twoViewFirst == nil { + return "Freeze a frame and tap a feature, or aim the ring at it and Mark." + } + return "Step about 1 m sideways, then mark the same feature again." + } + } + + /// The label for the tool's secondary action, or nil when there is none. + var restartTitle: String? { + switch tool { + case .wall where wallStep != .firstContact: "New wall" + case .twoView where twoViewFirst != nil: "Start over" + default: nil + } + } + + func restartTool() { + switch tool { + case .wall: wallStep = .firstContact + case .twoView: twoViewFirst = nil + case .ground, .wallPoint: break + } + } + + private var stepName: String { + switch tool { + case .ground, .wallPoint: "single" + case .wall: + switch wallStep { + case .firstContact: "firstContact" + case .secondContact: "secondContact" + case .validate: "validationContact" + } + case .twoView: twoViewFirst == nil ? "firstView" : "secondView" + } + } + + /// Logs a tap that never reached a frame, for example while tracking is limited. + func refuseUnresolvedTap(reason: String, message: String) { + logRefusal(tap: nil, Refusal(reason: reason, message: message)) + announce(.refused, "Not measured", [message]) + save() + } + + func handle(_ input: TapInput) { + guard input.snapshot.sessionID == manifest.session.id else { return } + var tap = TapRecord( + id: "T\(manifest.taps.count + 1)", + time: input.snapshot.timestamp, + tool: tool.rawValue, + step: stepName, + keyframe: input.snapshot.keyframeID, + frozen: input.frozen, + pixel: [input.pixel.u, input.pixel.v], + rayOrigin: input.ray.origin, + rayDirection: input.ray.direction, + displayMappingCheck: input.displayMappingCheck + ) + switch tool { + case .ground: handleGround(input, tap: &tap) + case .wall: handleWall(input, tap: &tap) + case .wallPoint: handleWallPoint(input, tap: &tap) + case .twoView: handleTwoView(input, tap: &tap) + } + manifest.taps.append(tap) + if let check = input.displayMappingCheck, check > Self.displayMappingTolerance, let event = lastEvent { + // Frozen-frame taps rely on this mapping; say so on the spot rather than only in the log. + lastEvent = LabEvent(id: event.id, tone: .warning, title: event.title, lines: event.lines + [ + "Screen mapping is off by \(check.formatted(.number.precision(.fractionLength(1)))) px; frozen taps may miss", + ]) + } + save() + } + + private func handleGround(_ input: TapInput, tap: inout TapRecord) { + guard let point = makeGroundPoint(input, tap: &tap) else { return } + announce(point.flags.isEmpty ? .accepted : .warning, "\(point.id) on the ground", describe(point)) + } + + private func handleWall(_ input: TapInput, tap: inout TapRecord) { + switch wallStep { + case .firstContact: + guard let point = makeGroundPoint(input, tap: &tap) else { return } + wallStep = .secondContact(pointID: point.id, position: point.position) + announce(point.flags.isEmpty ? .accepted : .warning, "First contact \(point.id)", describe(point)) + + case .secondContact(let firstID, let firstPosition): + guard let point = makeGroundPoint(input, tap: &tap) else { return } + let wall: Wall + do { + wall = try Wall( + contact1: firstPosition, contact2: point.position, cameraPosition: input.ray.origin, gates: wallGates + ) + } catch { + refuse(&tap, "No wall from \(firstID) and \(point.id)", Self.explain(error)) + return + } + let id = "W\(manifest.walls.count + 1)" + walls[id] = wall + manifest.walls.append(WallRecord( + id: id, + contacts: [firstID, point.id], + start: wall.start, + end: wall.end, + direction: wall.direction, + normal: wall.normal, + length: wall.length, + cameraPosition: input.ray.origin, + validations: [], + warnings: [] + )) + refreshWarnings(ofWall: id) + wallStep = .validate(wallID: id) + let contactFlagged = wallStatus(id: id)?.warnings.contains(.wallContactWarning) ?? false + var lines = ["\(Format.length(wall.length)) between \(firstID) and \(point.id)"] + if contactFlagged { lines.append("A contact has a warning, so everything on \(id) is flagged") } + lines.append("Mark a third point on its base to check it") + announce(contactFlagged ? .warning : .accepted, "\(id) set", lines) + + case .validate(let wallID): + guard let wall = walls[wallID], let index = manifest.walls.firstIndex(where: { $0.id == wallID }) else { return } + guard let point = makeGroundPoint(input, tap: &tap) else { return } + let check = wall.validate(contact: point.position, gates: wallGates) + manifest.walls[index].validations.append(WallRecord.Validation( + point: point.id, residual: check.residual, tolerance: check.tolerance, passes: check.passes + )) + refreshWarnings(ofWall: wallID) + let summary = "\(point.id) is \(Format.inches(check.residual)) off \(wallID) (limit \(Format.inches(check.tolerance)))" + announce(check.passes ? .accepted : .warning, check.passes ? "\(wallID) checks out" : "\(wallID) failed its check", [summary]) + } + } + + private func handleWallPoint(_ input: TapInput, tap: inout TapRecord) { + guard let (wallID, wall) = activeWall else { + refuse(&tap, "Not measured", Refusal(reason: "noWall", message: "There is no wall yet. Make one with the Wall tool.")) + return + } + let hit: WallHit + do { + hit = try wall.intersect(input.ray, gates: wallGates) + } catch { + refuse(&tap, "Not on \(wallID)", Self.explain(error)) + return + } + let point = addPoint( + kind: .wall, + position: hit.point, + taps: [tap.id], + onWall: PointRecord.OnWall(wall: wallID, range: hit.range, angleFromNormal: hit.angleFromNormal), + flags: hit.withinContacts ? [] : [.outsideWallContacts], + wallWarnings: wallStatus(id: wallID)?.warnings ?? [] + ) + tap.point = point.id + announce(evidence(for: point).warnings.isEmpty ? .accepted : .warning, "\(point.id) on \(wallID)", describe(point)) + } + + private func handleTwoView(_ input: TapInput, tap: inout TapRecord) { + guard let first = twoViewFirst else { + twoViewFirst = TwoViewFirst(tapID: tap.id, keyframeID: input.snapshot.keyframeID, ray: input.ray) + announce(.accepted, "First view saved", ["Step about 1 m sideways and mark the same feature."]) + return + } + guard first.keyframeID != input.snapshot.keyframeID else { + refuse(&tap, "Need a second view", Refusal( + reason: "sameFrame", + message: "Both taps are on the same frame. Step sideways, freeze a new frame, then tap the feature." + )) + return + } + let result: Triangulation + do { + result = try Triangulation(first.ray, input.ray, gates: triangulationGates) + } catch { + // A small angle can be fixed from the first view; a miss means the taps disagree. + if case .rayAngleTooSmall = error {} else { twoViewFirst = nil } + refuse(&tap, "Not triangulated", Self.explain(error)) + return + } + twoViewFirst = nil + let point = addPoint( + kind: .twoView, + position: result.point, + taps: [first.tapID, tap.id], + twoView: PointRecord.TwoView( + firstTap: first.tapID, + secondTap: tap.id, + rayAngle: result.rayAngle, + gap: result.gap, + baseline: result.baseline, + t1: result.t1, + t2: result.t2 + ), + flags: [] + ) + tap.point = point.id + announce(.accepted, "\(point.id) from two views", describe(point)) + } + + /// Adds a ground point from the tap's raycast, or logs why there is none. + private func makeGroundPoint(_ input: TapInput, tap: inout TapRecord) -> PointRecord? { + guard let hit = input.ground else { + refuse(&tap, "No ground there", Refusal( + reason: "noGround", + message: "ARKit found no ground along that ray. Aim at ground it has mapped, or move closer." + )) + return nil + } + let lookDown = input.ray.lookDownDegrees + var flags: [MeasurementWarning] = [] + switch hit.surface { + case .detectedPlane: break + case .extendedPlane: flags.append(.extendedPlane) + case .estimatedPlane: flags.append(.estimatedPlane) + } + if lookDown < Self.minimumGroundLookDown { flags.append(.shallowLookDown) } + let point = addPoint( + kind: .ground, + position: hit.point, + taps: [tap.id], + ground: PointRecord.Ground(surface: hit.surface.rawValue, planeAnchor: hit.planeAnchor, lookDown: lookDown), + flags: flags + ) + tap.point = point.id + return point + } + + private func addPoint( + kind: PointRecord.Kind, + position: SIMD3, + taps: [String], + ground: PointRecord.Ground? = nil, + onWall: PointRecord.OnWall? = nil, + twoView: PointRecord.TwoView? = nil, + flags: [MeasurementWarning], + wallWarnings: [MeasurementWarning] = [] + ) -> PointRecord { + let coordinates = activeWall.map { active in + PointRecord.WallCoordinates( + wall: active.id, + along: active.wall.along(position), + heightAboveGround: active.wall.heightAboveGround(position), + offset: active.wall.offset(of: position), + withinContacts: active.wall.containsAlong(active.wall.along(position)) + ) + } + let point = PointRecord( + id: "P\(manifest.points.count + 1)", + kind: kind, + position: position, + taps: taps, + ground: ground, + onWall: onWall, + twoView: twoView, + wallCoordinates: coordinates, + flags: flags, + wallWarnings: wallWarnings + ) + manifest.points.append(point) + return point + } + + // MARK: - Measurements + + enum MeasureTarget: Hashable { + case point(String) + case wall(String) + + var id: String { + switch self { + case .point(let id), .wall(let id): id + } + } + } + + func point(id: String) -> PointRecord? { + manifest.points.first { $0.id == id } + } + + func values(from pointID: String, to target: MeasureTarget, referenceWall: String?) -> [MeasuredQuantity: Double] { + guard let from = point(id: pointID) else { return [:] } + switch target { + case .point(let id): + guard let to = point(id: id) else { return [:] } + return measuredValues(from: from.position, to: .point(to.position), referenceWall: referenceWall.flatMap { walls[$0] }) + case .wall(let id): + guard let wall = walls[id] else { return [:] } + return measuredValues(from: from.position, to: .wall(wall)) + } + } + + func addMeasurement( + from pointID: String, + to target: MeasureTarget, + referenceWall: String?, + compared quantity: MeasuredQuantity, + tape: TapeReading? + ) { + let values = values(from: pointID, to: target, referenceWall: referenceWall) + guard let measured = values[quantity] else { return } + let warnings = warnings(from: pointID, to: target, referenceWall: referenceWall, compared: quantity, value: measured) + let comparison = tape.map { TapeComparison(measured: measured, tape: $0.meters) } + let id = "M\(manifest.measurements.count + 1)" + manifest.measurements.append(MeasurementRecord( + id: id, + time: ProcessInfo.processInfo.systemUptime, + from: pointID, + to: target.id, + referenceWall: values[.alongWall] == nil ? nil : referenceWall, + values: Dictionary(uniqueKeysWithValues: values.map { ($0.key.rawValue, $0.value) }), + compared: quantity.rawValue, + tape: tape.map { MeasurementRecord.Tape(feet: $0.feet, inches: $0.inches, meters: $0.meters) }, + errorMeters: comparison?.error, + errorInches: comparison?.errorInches, + warnings: warnings, + accepted: warnings.isEmpty + )) + save() + var lines = ["\(quantity.title): \(Format.length(measured))"] + if let comparison { + lines.append("Tape \(Format.length(comparison.tape)) · error \(Format.signedInches(comparison.error))") + } + lines += warnings.map(\.message) + announce(warnings.isEmpty ? .accepted : .warning, warnings.isEmpty ? "\(id) saved" : "\(id) saved as an abstention", lines) + } + + // MARK: - Warnings + + /// The wall's qualification from its contacts' own warnings and its validation checks. + func wallStatus(id: String) -> WallStatus? { + guard let record = manifest.walls.first(where: { $0.id == id }) else { return nil } + return WallStatus( + contactWarnings: record.contacts.map { point(id: $0)?.flags ?? [] }, + validations: record.validations.map(\.passes) + ) + } + + private func refreshWarnings(ofWall id: String) { + guard let index = manifest.walls.firstIndex(where: { $0.id == id }), let status = wallStatus(id: id) else { return } + manifest.walls[index].warnings = status.warnings + } + + /// A point's own warnings plus, for a point on a wall, that wall's current status. + func evidence(for point: PointRecord) -> PointEvidence { + PointEvidence(own: point.flags, wall: point.onWall.flatMap { wallStatus(id: $0.wall) }) + } + + /// Everything that makes this measurement an abstention, from the current state of the points + /// and walls it depends on. + func warnings( + from pointID: String, + to target: MeasureTarget, + referenceWall: String?, + compared quantity: MeasuredQuantity, + value: Double + ) -> [MeasurementWarning] { + guard let from = point(id: pointID) else { return [] } + let to: PointEvidence? + let targetWall: WallStatus? + switch target { + case .point(let id): + to = point(id: id).map(evidence(for:)) + targetWall = nil + case .wall(let id): + to = nil + targetWall = wallStatus(id: id) + } + return measurementWarnings( + from: evidence(for: from), + to: to, + targetWall: targetWall, + referenceWall: referenceWall.flatMap(wallStatus(id:)), + compared: quantity, + value: value + ) + } + + // MARK: - Log helpers + + /// Why a tap was refused: a stable code for analysis, the text shown, and the numbers behind it. + private struct Refusal { + let reason: String + let message: String + var values: [String: Double] = [:] + } + + /// Logs the refusal against the tap and shows it. + private func refuse(_ tap: inout TapRecord, _ title: String, _ refusal: Refusal) { + tap.refusal = logRefusal(tap: tap.id, refusal) + announce(.refused, title, [refusal.message]) + } + + @discardableResult + private func logRefusal(tap: String?, _ refusal: Refusal) -> String { + let id = "R\(manifest.refusals.count + 1)" + manifest.refusals.append(RefusalRecord( + id: id, + time: ProcessInfo.processInfo.systemUptime, + tool: tool.rawValue, + tap: tap, + reason: refusal.reason, + message: refusal.message, + values: refusal.values + )) + return id + } + + private func announce(_ tone: LabEvent.Tone, _ title: String, _ lines: [String]) { + eventCount += 1 + lastEvent = LabEvent(id: eventCount, tone: tone, title: title, lines: lines) + } + + private func describe(_ point: PointRecord) -> [String] { + var lines: [String] = [] + if let ground = point.ground { + lines.append("\(Format.degrees(ground.lookDown)) look-down · \(GroundHit.Surface(rawValue: ground.surface)?.label ?? ground.surface)") + } + if let onWall = point.onWall { + lines.append("\(Format.length(onWall.range)) away · \(Format.degrees(onWall.angleFromNormal)) from straight on") + } + if let twoView = point.twoView { + lines.append("Rays \(Format.degrees(twoView.rayAngle)) apart, missing by \(Format.inches(twoView.gap)) · \(Format.length(twoView.baseline)) step") + } + if let coordinates = point.wallCoordinates { + lines.append("\(Format.length(coordinates.along)) along \(coordinates.wall) · \(Format.length(coordinates.heightAboveGround)) up · \(Format.length(coordinates.offset)) out") + } + lines += evidence(for: point).warnings.map(\.message) + return lines + } + + private static func explain(_ error: WallError) -> Refusal { + switch error { + case .contactsTooClose(let separation, let minimum): + Refusal( + reason: "contactsTooClose", + message: "The contacts are \(Format.length(separation)) apart; at least \(Format.length(minimum)) is needed. Mark the second one farther along.", + values: ["separation": separation, "minimum": minimum] + ) + case .cameraInWallPlane(let offset, let minimum): + Refusal( + reason: "cameraInWallPlane", + message: "You're standing in line with the wall, so its front side is unclear. Step out in front of it and mark the second contact again.", + values: ["offset": offset, "minimum": minimum] + ) + } + } + + private static func explain(_ error: WallHitError) -> Refusal { + switch error { + case .grazing(let angle, let maximum): + Refusal( + reason: "grazingRay", + message: "That ray meets the wall \(Format.degrees(angle)) from straight on; the limit is \(Format.degrees(maximum)). Stand more in front of the point.", + values: ["angleFromNormal": angle, "maximum": maximum] + ) + case .behindCamera(let t): + Refusal( + reason: "wallBehindCamera", + message: "The wall plane is behind the camera along that ray. Face the wall and try again.", + values: ["t": t] + ) + } + } + + private static func explain(_ error: TriangulationError) -> Refusal { + switch error { + case .rayAngleTooSmall(let angle, let minimum): + Refusal( + reason: "rayAngleTooSmall", + message: "The two views are \(Format.degrees(angle)) apart; at least \(Format.degrees(minimum)) is needed. Step farther sideways and mark it again.", + values: ["rayAngle": angle, "minimum": minimum] + ) + case .behindCamera(let t1, let t2): + Refusal( + reason: "raysMeetBehindCamera", + message: "The rays meet behind the camera, so the taps aren't on the same feature. Start over.", + values: ["t1": t1, "t2": t2] + ) + case .raysMiss(let gap, let maximum): + Refusal( + reason: "raysMiss", + message: "The rays pass \(Format.inches(gap)) apart (limit \(Format.inches(maximum))), so the taps may be on different features. Start over.", + values: ["gap": gap, "maximum": maximum] + ) + } + } +} + +struct LabEvent: Identifiable, Equatable { + enum Tone: Equatable { + case accepted + /// Accepted, but a flag or failed check applies. + case warning + case refused + } + + let id: Int + let tone: Tone + let title: String + let lines: [String] +} + +extension MeasurementWarning { + var message: String { + switch self { + case .estimatedPlane: "Estimated surface, not a found plane" + case .extendedPlane: "Past the edge of the found plane" + case .shallowLookDown: "Looking down less than 30°; tap from closer" + case .outsideWallContacts: "Beyond the wall's two contacts" + case .wallContactWarning: "One of the wall's contacts has a warning" + case .wallNotValidated: "The wall has no check contact yet" + case .wallValidationFailed: "The wall failed its check" + case .belowGround: "Below the wall's ground line" + } + } +} + +extension GroundHit.Surface { + var label: String { + switch self { + case .detectedPlane: "found plane" + case .extendedPlane: "extended plane" + case .estimatedPlane: "estimated surface" + } + } +} + +extension MeasuredQuantity { + var title: String { + switch self { + case .straight: "Straight line" + case .horizontal: "Horizontal" + case .vertical: "Height difference" + case .alongWall: "Along the wall" + case .gapToWall: "Gap to the wall" + case .heightAboveGround: "Height above ground" + } + } +} + +/// The hardware model, for example "iPhone15,4", so results can be grouped by phone. +private func deviceModelIdentifier() -> String { + var info = utsname() + uname(&info) + return withUnsafeBytes(of: &info.machine) { bytes in + String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) + } +} diff --git a/experiments/measure-lab/MeasureLab/Session/SessionManifest.swift b/experiments/measure-lab/MeasureLab/Session/SessionManifest.swift new file mode 100644 index 000000000..dc81718ee --- /dev/null +++ b/experiments/measure-lab/MeasureLab/Session/SessionManifest.swift @@ -0,0 +1,247 @@ +import Foundation +import MeasureGeometry + +/// The contents of session.json. The README's "Session format" section documents every field; +/// change both together and bump `formatVersion` when a field changes meaning. +/// +/// Lengths are ARKit meters in the gravity-aligned world frame (y up). Vectors are [x, y, z]. +struct SessionManifest: Codable, Sendable { + var format = "measure-lab-session" + var formatVersion = 2 + var units = Units() + var conventions = Conventions() + var session: SessionInfo + var gates: GateValues + var keyframes: [KeyframeRecord] = [] + var taps: [TapRecord] = [] + var points: [PointRecord] = [] + var walls: [WallRecord] = [] + var measurements: [MeasurementRecord] = [] + var refusals: [RefusalRecord] = [] + var tracking: [TrackingRecord] = [] +} + +struct Units: Codable, Sendable { + var length = "meters" + var angle = "degrees" + var time = "seconds of device uptime, the clock of ARFrame.timestamp" + var image = "pixels of the saved JPEG" +} + +struct Conventions: Codable, Sendable { + var world = "ARKit world frame with worldAlignment .gravity: right-handed, y up (away from gravity), origin and heading fixed where the session started" + var camera = "ARKit camera frame: +x right and +y up in the unrotated sensor image, the camera looks along -z" + var pose = "camera-to-world 4x4 matrix, 16 numbers column by column (simd_float4x4 layout)" + var intrinsics = "[fx, fy, cx, cy] in pixels of the saved, unrotated landscape JPEG" + var pixel = "[u, v] continuous image coordinates: (0, 0) is the top-left corner of the JPEG, v grows down" + var ray = "origin is the camera position; direction is a unit vector through the tapped pixel" +} + +struct SessionInfo: Codable, Sendable { + let id: String + /// Wall-clock start, ISO 8601 UTC. + let startedAt: String + /// Device uptime at `startedAt`, to line wall-clock time up with the other timestamps. + let startedAtUptime: Double + let appVersion: String + let deviceModel: String + let systemVersion: String + /// `ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth)`: the phone has LiDAR. + let lidarAvailable: Bool + /// `supportsSceneReconstruction(.mesh)`. The outdoor protocol expects false on the test phone. + let meshReconstructionSupported: Bool + /// Whether this session asked ARKit for LiDAR scene depth maps. + let sceneDepthEnabled: Bool +} + +/// The acceptance thresholds this session ran with, so a replay can apply the same ones. +struct GateValues: Codable, Sendable { + let trackingStableSeconds: Double + let minimumGroundLookDown: Double + let minimumContactSeparation: Double + let maximumAngleFromWallNormal: Double + let wallValidationTolerance: Double + let minimumCameraOffsetFromWall: Double + let minimumRayAngle: Double + let maximumRayGap: Double + let keyframeSpacingMeters: Double + let keyframeSpacingDegrees: Double +} + +struct KeyframeRecord: Codable, Sendable, Identifiable { + enum Reason: String, Codable, Sendable { + /// The camera moved or turned past the spacing threshold. + case motion + /// Saved for a tap on the live view. + case tap + /// Saved when the frame was frozen for tapping. + case freeze + } + + struct Depth: Codable, Sendable { + /// Float32 little-endian meters, row-major, `w` × `h`. + let file: String + /// UInt8 ARConfidenceLevel (0 low, 1 medium, 2 high), same layout, or nil if ARKit gave none. + let confidenceFile: String? + let w: Int + let h: Int + } + + let id: String + /// Path of the JPEG relative to the session folder. + let img: String + let w: Int + let h: Int + /// [fx, fy, cx, cy] for this JPEG. + let intrinsics: [Double] + /// Camera-to-world, column-major. + let pose: [Double] + let timestamp: Double + let tracking: String + let reason: Reason + let depth: Depth? +} + +struct TapRecord: Codable, Sendable { + let id: String + let time: Double + let tool: String + /// Tool step, for example "firstContact" or "secondView". + let step: String + let keyframe: String + /// True when the tap was on a frozen frame rather than the live view. + let frozen: Bool + let pixel: [Double] + let rayOrigin: SIMD3 + let rayDirection: SIMD3 + /// Live taps only: pixel distance between ARKit's display transform and the app's own + /// portrait mapping for the same screen point. Near zero means frozen-frame taps map correctly. + let displayMappingCheck: Double? + /// The point this tap produced, or nil when it was refused or only started a two-view pair. + var point: String? + var refusal: String? +} + +struct PointRecord: Codable, Sendable, Identifiable { + enum Kind: String, Codable, Sendable { + case ground + case wall + case twoView + } + + struct Ground: Codable, Sendable { + /// "detectedPlane" (inside a found plane), "extendedPlane" (a found plane extended past its + /// edge) or "estimatedPlane" (ARKit's guess without a found plane). + let surface: String + let planeAnchor: String? + let lookDown: Double + } + + struct OnWall: Codable, Sendable { + let wall: String + let range: Double + let angleFromNormal: Double + } + + struct TwoView: Codable, Sendable { + let firstTap: String + let secondTap: String + let rayAngle: Double + let gap: Double + let baseline: Double + let t1: Double + let t2: Double + } + + /// Position relative to the most recent wall when the point was made. + struct WallCoordinates: Codable, Sendable { + let wall: String + let along: Double + let heightAboveGround: Double + let offset: Double + let withinContacts: Bool + } + + let id: String + let kind: Kind + let position: SIMD3 + let taps: [String] + let ground: Ground? + let onWall: OnWall? + let twoView: TwoView? + let wallCoordinates: WallCoordinates? + /// Warnings about this point's own observation: "estimatedPlane", "extendedPlane", + /// "shallowLookDown", "outsideWallContacts". + let flags: [MeasurementWarning] + /// For a point on a wall: that wall's warnings when the point was made. The wall's final + /// state is in `walls[].warnings`. + let wallWarnings: [MeasurementWarning] +} + +struct WallRecord: Codable, Sendable, Identifiable { + struct Validation: Codable, Sendable { + let point: String + let residual: Double + let tolerance: Double + let passes: Bool + } + + let id: String + let contacts: [String] + let start: SIMD3 + let end: SIMD3 + let direction: SIMD3 + let normal: SIMD3 + let length: Double + let cameraPosition: SIMD3 + var validations: [Validation] + /// "wallContactWarning", "wallNotValidated", "wallValidationFailed"; updated after each check. + var warnings: [MeasurementWarning] +} + +struct MeasurementRecord: Codable, Sendable, Identifiable { + struct Tape: Codable, Sendable { + let feet: Double + let inches: Double + let meters: Double + } + + let id: String + let time: Double + let from: String + /// A point id or a wall id. + let to: String + /// The wall used for along-wall distance, if any. + let referenceWall: String? + /// Every quantity that applies, keyed by name ("straight", "horizontal", "vertical", + /// "alongWall", "gapToWall", "heightAboveGround"). Height above ground is signed. + let values: [String: Double] + /// The quantity compared with the tape. + let compared: String + let tape: Tape? + /// App minus tape, meters and inches. + let errorMeters: Double? + let errorInches: Double? + /// Every warning inherited from the points and walls this value depends on, plus + /// "belowGround" for a negative height. + let warnings: [MeasurementWarning] + /// True only with no warnings. Scoring counts any other measurement as an abstention. + let accepted: Bool +} + +struct RefusalRecord: Codable, Sendable { + let id: String + let time: Double + let tool: String + let tap: String? + /// Stable machine-readable reason, for example "grazingRay" or "rayAngleTooSmall". + let reason: String + let message: String + /// Numbers behind the refusal, for example the measured angle and its limit. + let values: [String: Double] +} + +struct TrackingRecord: Codable, Sendable { + let time: Double + let state: String +} diff --git a/experiments/measure-lab/MeasureLab/Session/SessionStore.swift b/experiments/measure-lab/MeasureLab/Session/SessionStore.swift new file mode 100644 index 000000000..edf263061 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/Session/SessionStore.swift @@ -0,0 +1,64 @@ +import CoreTransferable +import Foundation +import UniformTypeIdentifiers + +/// Session folders live in Documents/Sessions//, which the Files app can also see +/// (UIFileSharingEnabled), so a session can be recovered even if sharing fails. +enum SessionStore { + private static var root: URL { + URL.documentsDirectory.appending(path: "Sessions", directoryHint: .isDirectory) + } + + /// Creates `//keyframes/` and returns the session folder. + static func makeFolder(id: String) throws -> URL { + let folder = root.appending(path: id, directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: folder.appending(path: "keyframes", directoryHint: .isDirectory), + withIntermediateDirectories: true + ) + return folder + } + + static func write(_ manifest: SessionManifest, to folder: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(manifest) + try data.write(to: folder.appending(path: "session.json"), options: .atomic) + } + + /// Zips a session folder with the system's own archiver: NSFileCoordinator's `.forUploading` + /// read hands back a temporary zip of any directory. + static func zip(folder: URL) throws -> URL { + let destination = URL.temporaryDirectory.appending(path: "MeasureLab-\(folder.lastPathComponent).zip") + try? FileManager.default.removeItem(at: destination) + var coordinationError: NSError? + var copyError: (any Error)? + NSFileCoordinator().coordinate(readingItemAt: folder, options: .forUploading, error: &coordinationError) { zipURL in + do { + try FileManager.default.copyItem(at: zipURL, to: destination) + } catch { + copyError = error + } + } + if let error = coordinationError ?? copyError { + throw error + } + return destination + } +} + +/// A session folder shared as one zip. The zip is made when the share target asks for it, after +/// session.json is flushed. A keyframe still being written at that moment can appear in the zip +/// without a `keyframes` entry; replay code should use only listed keyframes. +struct SessionArchive: Transferable { + let session: LabSession + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(exportedContentType: .zip) { archive in + guard let folder = await archive.session.prepareExport() else { + throw CocoaError(.fileNoSuchFile) + } + return SentTransferredFile(try SessionStore.zip(folder: folder)) + } + } +} diff --git a/experiments/measure-lab/MeasureLab/Session/TapInput.swift b/experiments/measure-lab/MeasureLab/Session/TapInput.swift new file mode 100644 index 000000000..b592e3590 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/Session/TapInput.swift @@ -0,0 +1,28 @@ +import MeasureGeometry + +/// One accepted tap, resolved to a saved frame, a pixel of its JPEG and the world ray through it. +struct TapInput: Sendable { + let snapshot: FrameSnapshot + let pixel: (u: Double, v: Double) + let ray: Ray + let frozen: Bool + let displayMappingCheck: Double? + /// The ARKit ground raycast, when the tool needs one. Nil means ARKit found no horizontal + /// surface along the ray, which never counts as open space. + let ground: GroundHit? +} + +struct GroundHit: Sendable { + enum Surface: String, Sendable { + /// Inside the extent of a horizontal plane ARKit has found. + case detectedPlane + /// On a found plane's infinite extension, past its detected edge. + case extendedPlane + /// ARKit's estimate without a found plane. + case estimatedPlane + } + + let point: SIMD3 + let surface: Surface + let planeAnchor: String? +} diff --git a/experiments/measure-lab/MeasureLab/Session/Tool.swift b/experiments/measure-lab/MeasureLab/Session/Tool.swift new file mode 100644 index 000000000..964e527ad --- /dev/null +++ b/experiments/measure-lab/MeasureLab/Session/Tool.swift @@ -0,0 +1,36 @@ +/// The measuring tools. Each tap goes to the selected one. +enum Tool: String, CaseIterable, Identifiable, Sendable { + /// Raycast to a horizontal plane ARKit has found. + case ground + /// Two ground contacts at the base of a wall make a vertical plane; a third checks it. + case wall + /// A ray from the tapped frame meets the latest wall's plane. + case wallPoint + /// The same feature tapped in two frames from different places, triangulated. + case twoView + + var id: Self { self } + + var title: String { + switch self { + case .ground: "Ground" + case .wall: "Wall" + case .wallPoint: "On wall" + case .twoView: "Two-view" + } + } + + var systemImage: String { + switch self { + case .ground: "arrow.down.to.line" + case .wall: "ruler" + case .wallPoint: "scope" + case .twoView: "eyes" + } + } + + /// Whether a tap for this tool needs an ARKit raycast to the ground. + var needsGround: Bool { + self == .ground || self == .wall + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/ControlPanel.swift b/experiments/measure-lab/MeasureLab/UI/ControlPanel.swift new file mode 100644 index 000000000..efa4a5686 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/ControlPanel.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// Tool picker, the current step, and the Freeze · Mark · Measure row. +struct ControlPanel: View { + let session: LabSession + let isFrozen: Bool + let onMark: () -> Void + let onToggleFreeze: () -> Void + let onMeasure: () -> Void + + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 6) { + ForEach(Tool.allCases) { tool in + ToolButton(tool: tool, isSelected: session.tool == tool) { + session.select(tool) + } + } + } + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(stepText) + .font(.subheadline) + .foregroundStyle(session.markBlocker == nil ? .primary : Theme.warning) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + if let title = session.restartTitle { + Button(title, action: session.restartTool) + .font(.subheadline.bold()) + .foregroundStyle(Theme.accent) + .frame(minHeight: 44) + } + } + + HStack(spacing: 10) { + Button(action: onToggleFreeze) { + Label(isFrozen ? "Live" : "Freeze", systemImage: isFrozen ? "video" : "pause") + .frame(maxWidth: .infinity, minHeight: Theme.controlHeight) + } + .buttonStyle(PressableButtonStyle()) + .background(.thinMaterial, in: .capsule) + + Button(action: onMark) { + Label("Mark", systemImage: "plus.viewfinder") + .font(.headline) + .foregroundStyle(.black) + .frame(maxWidth: .infinity, minHeight: Theme.controlHeight) + .background(Theme.accent.opacity(session.markBlocker == nil ? 1 : 0.4), in: .capsule) + } + .buttonStyle(PressableButtonStyle()) + .layoutPriority(1) + + Button(action: onMeasure) { + Label("Measure", systemImage: "ruler") + .frame(maxWidth: .infinity, minHeight: Theme.controlHeight) + } + .buttonStyle(PressableButtonStyle()) + .background(.thinMaterial, in: .capsule) + .disabled(session.manifest.points.isEmpty) + } + .font(.subheadline.bold()) + .labelStyle(.titleAndIcon) + } + .padding(12) + .background(.regularMaterial, in: Theme.panelShape) + } + + private var stepText: String { + if let blocker = session.markBlocker { return blocker } + if isFrozen { return "Frozen frame. Tap the spot to measure. \(session.instruction)" } + return session.instruction + } +} + +private struct ToolButton: View { + let tool: Tool + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 3) { + Image(systemName: tool.systemImage) + .font(.body) + Text(tool.title) + .font(.caption.bold()) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .foregroundStyle(isSelected ? .black : .primary) + .frame(maxWidth: .infinity, minHeight: Theme.controlHeight) + .background(isSelected ? AnyShapeStyle(Theme.accent) : AnyShapeStyle(.thinMaterial), in: .rect(cornerRadius: 12)) + } + .buttonStyle(PressableButtonStyle()) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/FailureView.swift b/experiments/measure-lab/MeasureLab/UI/FailureView.swift new file mode 100644 index 000000000..d29c99ea3 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/FailureView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct FailureView: View { + let failure: SessionFailure + @Environment(\.openURL) private var openURL + + var body: some View { + switch failure { + case .cameraAccessDenied: + ContentUnavailableView { + Label("Camera access is off", systemImage: "camera") + } description: { + Text("Measure Lab needs the camera to measure. Turn on Camera for Measure Lab in Settings.") + } actions: { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + } + case .other(let message): + ContentUnavailableView( + "The camera session stopped", + systemImage: "exclamationmark.triangle", + description: Text("\(message) Everything so far is saved. Close and reopen Measure Lab to continue in a new session.") + ) + } + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/Format.swift b/experiments/measure-lab/MeasureLab/UI/Format.swift new file mode 100644 index 000000000..fbd203803 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/Format.swift @@ -0,0 +1,53 @@ +import Foundation +import MeasureGeometry + +/// Text for lengths and angles. Tapes read feet, inches and eighths, so lengths show both units. +enum Format { + /// "1.918 m · 6 ft 3 1/2 in" + static func length(_ meters: Double) -> String { + "\(meters.formatted(.number.precision(.fractionLength(3)))) m · \(feetAndInches(meters))" + } + + /// "6 ft 3 1/2 in", to the nearest 1/8 in. + static func feetAndInches(_ meters: Double) -> String { + let split = Length.feetAndInches(meters: meters) + let sign = split.negative ? "−" : "" + let inches = eighths(split.inches) + return split.feet == 0 ? "\(sign)\(inches) in" : "\(sign)\(split.feet) ft \(inches) in" + } + + /// "0.8 in" + static func inches(_ meters: Double) -> String { + "\(Length.inches(meters: meters).formatted(.number.precision(.fractionLength(1)))) in" + } + + /// "+1.3 in" or "−0.4 in" + static func signedInches(_ meters: Double) -> String { + let value = Length.inches(meters: meters) + let rounded = (value * 10).rounded() / 10 + let magnitude = abs(rounded).formatted(.number.precision(.fractionLength(1))) + return rounded == 0 ? "0.0 in" : "\(rounded > 0 ? "+" : "−")\(magnitude) in" + } + + /// "38°" + static func degrees(_ value: Double) -> String { + "\(value.formatted(.number.precision(.fractionLength(0))))°" + } + + /// 3.5 → "3 1/2", 0.125 → "1/8", 7 → "7". Expects a multiple of 1/8. + static func eighths(_ inches: Double) -> String { + var whole = Int(inches.rounded(.down)) + var numerator = Int(((inches - Double(whole)) * 8).rounded()) + if numerator == 8 { + whole += 1 + numerator = 0 + } + guard numerator > 0 else { return "\(whole)" } + var denominator = 8 + while numerator.isMultiple(of: 2) { + numerator /= 2 + denominator /= 2 + } + return whole == 0 ? "\(numerator)/\(denominator)" : "\(whole) \(numerator)/\(denominator)" + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/FrozenFrameView.swift b/experiments/measure-lab/MeasureLab/UI/FrozenFrameView.swift new file mode 100644 index 000000000..91c36d596 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/FrozenFrameView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// A held frame drawn exactly as `PortraitFillMapping` assumes: rotated upright, scaled to fill +/// and centered. For the second view of a two-view pair it also draws the first tap's ray; the +/// same feature lies somewhere along that line. +struct FrozenFrameView: View { + let frozen: CaptureController.FrozenFrame + let size: CGSize + let guide: [CGPoint]? + + var body: some View { + Image(uiImage: frozen.image) + .resizable() + .scaledToFill() + .frame(width: size.width, height: size.height) + .clipped() + .overlay { + if let guide { + Path { path in path.addLines(guide) } + .stroke(Theme.accent, style: StrokeStyle(lineWidth: 2, dash: [6, 5])) + .shadow(color: .black.opacity(0.6), radius: 1) + } + } + .accessibilityLabel("Frozen camera frame \(frozen.snapshot.keyframeID)") + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/MeasureScreen.swift b/experiments/measure-lab/MeasureLab/UI/MeasureScreen.swift new file mode 100644 index 000000000..46573e88a --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/MeasureScreen.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// Full-screen camera with the status on top and the tools at the bottom. +/// +/// Tapping the camera marks that spot; Mark marks under the center ring, which keeps a finger off +/// the target. Freeze holds one frame still so a spot can be tapped precisely. +struct MeasureScreen: View { + let session: LabSession + @State private var capture: CaptureController + @State private var layerSize: CGSize = .zero + @State private var frozenTap: CGPoint? + @State private var isShowingMeasure = false + @State private var isShowingSession = false + @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init(session: LabSession) { + self.session = session + _capture = State(initialValue: CaptureController(session: session)) + } + + var body: some View { + ZStack { + cameraLayer + .ignoresSafeArea() + VStack(spacing: 10) { + StatusPanel(session: session) { + isShowingSession = true + } + Spacer(minLength: 0) + if let event = session.lastEvent { + ResultCard(event: event) + .id(event.id) + .transition(reduceMotion ? .opacity : .opacity.combined(with: .offset(y: 8))) + } + ControlPanel( + session: session, + isFrozen: capture.frozen != nil, + onMark: markAtRing, + onToggleFreeze: toggleFreeze, + onMeasure: { isShowingMeasure = true } + ) + } + .padding(.horizontal, 12) + .padding(.bottom, 4) + .animation(.easeOut(duration: 0.2), value: session.lastEvent?.id) + } + .sensoryFeedback(trigger: session.lastEvent) { _, event in + switch event?.tone { + case .accepted: .success + case .warning: .warning + case .refused: .error + case nil: nil + } + } + .sheet(isPresented: $isShowingMeasure) { + MeasureSheet(session: session) + } + .sheet(isPresented: $isShowingSession) { + SessionSheet(session: session) { sceneDepth in + frozenTap = nil + capture.startNewSession(sceneDepth: sceneDepth) + } + } + .onAppear { + session.start() + } + .onChange(of: scenePhase) { _, phase in + if phase != .active { session.save() } + } + .onChange(of: capture.frozen == nil) { + frozenTap = nil + } + } + + private var cameraLayer: some View { + ZStack { + ARContainerView(capture: capture) + if let frozen = capture.frozen { + FrozenFrameView(frozen: frozen, size: layerSize, guide: capture.epipolarGuide()) + } + Color.clear + .contentShape(.rect) + .onTapGesture(coordinateSpace: .local) { location in + if capture.frozen != nil { frozenTap = location } + capture.mark(at: location, viewSize: layerSize) + } + .accessibilityHidden(true) + Reticle() + .allowsHitTesting(false) + if let frozenTap { + TapMarker() + .position(frozenTap) + .allowsHitTesting(false) + } + } + .onGeometryChange(for: CGSize.self) { $0.size } action: { size in + layerSize = size + } + } + + private func markAtRing() { + let center = CGPoint(x: layerSize.width / 2, y: layerSize.height / 2) + if capture.frozen != nil { frozenTap = center } + capture.mark(at: center, viewSize: layerSize) + } + + private func toggleFreeze() { + if capture.frozen == nil { + capture.freeze(viewSize: layerSize) + } else { + capture.unfreeze() + } + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/MeasureSheet.swift b/experiments/measure-lab/MeasureLab/UI/MeasureSheet.swift new file mode 100644 index 000000000..14209b025 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/MeasureSheet.swift @@ -0,0 +1,190 @@ +import MeasureGeometry +import SwiftUI + +/// Picks two points, or a point and a wall, shows every distance that applies, and compares the +/// chosen one with a tape reading. +struct MeasureSheet: View { + let session: LabSession + @State private var fromID: String + @State private var target: LabSession.MeasureTarget? + @State private var referenceWall: String? + @State private var compared: MeasuredQuantity? + @State private var feetText = "" + @State private var inchesText = "" + @Environment(\.dismiss) private var dismiss + + init(session: LabSession) { + self.session = session + let points = session.manifest.points + // Default to the two newest points, the usual pair right after marking them. + _fromID = State(initialValue: points.dropLast().last?.id ?? points.last?.id ?? "") + let defaultTarget: LabSession.MeasureTarget? = if points.count >= 2, let last = points.last { + .point(last.id) + } else if let wall = session.manifest.walls.last { + .wall(wall.id) + } else { + nil + } + _target = State(initialValue: defaultTarget) + _referenceWall = State(initialValue: session.manifest.walls.last?.id) + } + + var body: some View { + NavigationStack { + Form { + Section("From") { + Picker("Point", selection: $fromID) { + ForEach(session.manifest.points) { point in + Text(label(for: point)).tag(point.id) + } + } + .onChange(of: fromID) { _, newID in + if case .point(let id) = target, id == newID { target = nil } + } + } + Section("To") { + Picker("Point or wall", selection: $target) { + Text("Choose").tag(LabSession.MeasureTarget?.none) + ForEach(session.manifest.points.filter { $0.id != fromID }) { point in + Text(label(for: point)).tag(LabSession.MeasureTarget?.some(.point(point.id))) + } + ForEach(session.manifest.walls) { wall in + Text("\(wall.id) · wall line").tag(LabSession.MeasureTarget?.some(.wall(wall.id))) + } + } + if case .point = target, !session.manifest.walls.isEmpty { + Picker("Along-wall reference", selection: $referenceWall) { + ForEach(session.manifest.walls) { wall in + Text(wall.id).tag(String?.some(wall.id)) + } + } + } + } + if !values.isEmpty { + Section { + ForEach(orderedQuantities, id: \.self) { quantity in + Button { + compared = quantity + } label: { + LabeledContent { + Text(Format.length(values[quantity] ?? 0)) + .monospacedDigit() + } label: { + Label(quantity.title, systemImage: selectedQuantity == quantity ? "largecircle.fill.circle" : "circle") + } + } + .tint(.primary) + .accessibilityAddTraits(selectedQuantity == quantity ? .isSelected : []) + } + } header: { + Text("Compare with the tape") + } footer: { + if !selectedWarnings.isEmpty { + Text("Saved as an abstention: \(selectedWarnings.map(\.message).joined(separator: "; ")).") + .foregroundStyle(Theme.warning) + } + } + Section { + HStack { + TextField("Feet", text: $feetText) + .keyboardType(.numbersAndPunctuation) + Text("ft").foregroundStyle(.secondary) + TextField("Inches", text: $inchesText) + .keyboardType(.numbersAndPunctuation) + Text("in").foregroundStyle(.secondary) + } + .monospacedDigit() + } header: { + Text("Tape reading") + } footer: { + Text(tapeFooter) + .foregroundStyle(tapeError == nil ? Color.secondary : Theme.refused) + } + } + } + .navigationTitle("Measure") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save", action: save) + .disabled(!canSave) + } + } + } + } + + private var values: [MeasuredQuantity: Double] { + guard let target else { return [:] } + if case .point(let id) = target, id == fromID { return [:] } + return session.values(from: fromID, to: target, referenceWall: referenceWall) + } + + private var orderedQuantities: [MeasuredQuantity] { + MeasuredQuantity.allCases.filter { values[$0] != nil } + } + + /// The chosen quantity, or the first one that applies. + private var selectedQuantity: MeasuredQuantity? { + if let compared, values[compared] != nil { return compared } + return orderedQuantities.first + } + + private var selectedWarnings: [MeasurementWarning] { + guard let target, let quantity = selectedQuantity, let value = values[quantity] else { return [] } + return session.warnings(from: fromID, to: target, referenceWall: referenceWall, compared: quantity, value: value) + } + + private var tapeIsEmpty: Bool { + feetText.trimmingCharacters(in: .whitespaces).isEmpty && inchesText.trimmingCharacters(in: .whitespaces).isEmpty + } + + private var tape: Result? { + if tapeIsEmpty { return nil } + return Result { () throws(TapeEntryError) in try TapeReading(feetText: feetText, inchesText: inchesText) } + } + + private var tapeError: TapeEntryError? { + if case .failure(let error) = tape { return error } + return nil + } + + private var tapeFooter: String { + switch tape { + case nil: + return "Optional. Inches take decimals or fractions, like 3.25 or 3 1/4." + case .failure(.unreadable(let field, let text)): + return "Can't read \"\(text)\" as \(field.rawValue). Use a number like 3.25 or 3 1/4." + case .failure(.empty): + return "Enter feet, inches or both." + case .failure(.negativeOrNotFinite): + return "Enter a positive length." + case .success(let reading): + guard let quantity = selectedQuantity, let measured = values[quantity] else { return "" } + let comparison = TapeComparison(measured: measured, tape: reading.meters) + return "Tape \(Format.length(reading.meters)). App minus tape: \(Format.signedInches(comparison.error))." + } + } + + private var canSave: Bool { + selectedQuantity != nil && tapeError == nil + } + + private func save() { + guard let target, let quantity = selectedQuantity else { return } + let reading = if case .success(let reading) = tape { reading } else { TapeReading?.none } + session.addMeasurement(from: fromID, to: target, referenceWall: referenceWall, compared: quantity, tape: reading) + dismiss() + } + + private func label(for point: PointRecord) -> String { + let kind = switch point.kind { + case .ground: "ground" + case .wall: "on wall" + case .twoView: "two-view" + } + return session.evidence(for: point).warnings.isEmpty ? "\(point.id) · \(kind)" : "\(point.id) · \(kind) · flagged" + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/ResultCard.swift b/experiments/measure-lab/MeasureLab/UI/ResultCard.swift new file mode 100644 index 000000000..86b7935e5 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/ResultCard.swift @@ -0,0 +1,32 @@ +import SwiftUI + +/// What the last tap or measurement did: accepted, accepted with a warning, or refused and why. +struct ResultCard: View { + let event: LabEvent + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: event.tone.systemImage) + .foregroundStyle(event.tone.color) + .font(.headline) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(event.title) + .font(.headline) + ForEach(event.lines, id: \.self) { line in + Text(line) + .font(.subheadline) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + .padding(14) + .background(.regularMaterial, in: Theme.panelShape) + .overlay { + Theme.panelShape.strokeBorder(event.tone.color.opacity(0.6), lineWidth: 1) + } + .accessibilityElement(children: .combine) + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/Reticle.swift b/experiments/measure-lab/MeasureLab/UI/Reticle.swift new file mode 100644 index 000000000..b8dca7263 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/Reticle.swift @@ -0,0 +1,32 @@ +import SwiftUI + +/// The aiming ring at the center of the camera view. Mark measures the pixel under its center. +struct Reticle: View { + var body: some View { + ZStack { + Circle() + .stroke(.black.opacity(0.5), lineWidth: 4) + Circle() + .stroke(Theme.accent, lineWidth: 2) + Rectangle() + .fill(Theme.accent) + .frame(width: 2, height: 2) + } + .frame(width: 28, height: 28) + .accessibilityHidden(true) + } +} + +/// Where the last tap on a frozen frame landed. +struct TapMarker: View { + var body: some View { + ZStack { + Circle() + .stroke(.black.opacity(0.6), lineWidth: 3) + Circle() + .stroke(Theme.accent, lineWidth: 1.5) + } + .frame(width: 18, height: 18) + .accessibilityHidden(true) + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/RootView.swift b/experiments/measure-lab/MeasureLab/UI/RootView.swift new file mode 100644 index 000000000..c46c81a5b --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/RootView.swift @@ -0,0 +1,21 @@ +import ARKit +import SwiftUI + +/// The measuring screen, or a plain explanation when AR can't run. +struct RootView: View { + @State private var session = LabSession() + + var body: some View { + if !ARWorldTrackingConfiguration.isSupported { + ContentUnavailableView( + "AR isn't available", + systemImage: "arkit", + description: Text("Measure Lab needs an iPhone that supports ARKit world tracking. The Simulator can't run it.") + ) + } else if let failure = session.failure { + FailureView(failure: failure) + } else { + MeasureScreen(session: session) + } + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/SessionSheet.swift b/experiments/measure-lab/MeasureLab/UI/SessionSheet.swift new file mode 100644 index 000000000..d226a71f4 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/SessionSheet.swift @@ -0,0 +1,121 @@ +import MeasureGeometry +import SwiftUI + +/// What the session holds, the LiDAR comparison switch, sharing, and starting over. +struct SessionSheet: View { + let session: LabSession + /// Starts a new session; the argument is whether it records LiDAR scene depth. + let onNewSession: (Bool) -> Void + @State private var isConfirmingNewSession = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + LabeledContent("Session", value: session.manifest.session.id) + LabeledContent("Phone", value: session.manifest.session.deviceModel) + LabeledContent("LiDAR", value: session.lidarAvailable ? "Yes" : "No") + LabeledContent("Mesh reconstruction", value: session.meshReconstructionSupported ? "Supported" : "Not supported") + LabeledContent("Keyframes", value: "\(session.manifest.keyframes.count)") + LabeledContent("Refused taps", value: "\(session.manifest.refusals.count)") + } footer: { + Text("The no-LiDAR question needs a phone without LiDAR. On a LiDAR phone ARKit still uses the sensor for planes even with depth off.") + } + + if session.lidarAvailable { + Section { + Toggle("Record LiDAR depth", isOn: Binding( + get: { session.sceneDepthEnabled }, + set: { enabled in onNewSession(enabled) } + )) + .disabled(!session.canChangeSceneDepth) + } footer: { + Text("For comparison runs only. Set it before the first tap; changing it starts a new session.") + } + } + + Section("Measurements") { + if session.manifest.measurements.isEmpty { + Text("None yet").foregroundStyle(.secondary) + } + ForEach(session.manifest.measurements) { measurement in + MeasurementRow(measurement: measurement) + } + } + + Section("Walls") { + if session.manifest.walls.isEmpty { + Text("None yet").foregroundStyle(.secondary) + } + ForEach(session.manifest.walls) { wall in + VStack(alignment: .leading, spacing: 2) { + Text("\(wall.id) · \(wall.contacts.joined(separator: " → "))").font(.headline) + Text(Format.length(wall.length)).monospacedDigit() + ForEach(wall.validations, id: \.point) { check in + Text("\(check.point): \(Format.inches(check.residual)) off · \(check.passes ? "passes" : "fails")") + .foregroundStyle(check.passes ? Color.secondary : Theme.warning) + } + } + .font(.subheadline) + } + } + + Section { + if session.folder != nil { + ShareLink( + item: SessionArchive(session: session), + preview: SharePreview("Measure Lab session \(session.manifest.session.id)") + ) { + Label("Share session (.zip)", systemImage: "square.and.arrow.up") + } + } + Button("New session", systemImage: "plus.circle") { + isConfirmingNewSession = true + } + } footer: { + Text("Sessions are also in the Files app under On My iPhone › Measure Lab › Sessions.") + } + } + .navigationTitle("Session") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .confirmationDialog("Start a new session?", isPresented: $isConfirmingNewSession, titleVisibility: .visible) { + Button("New session") { + onNewSession(session.sceneDepthEnabled) + dismiss() + } + } message: { + Text("This one stays saved. The new one starts a fresh AR map, so points from this session can't be measured against it.") + } + } + } +} + +private struct MeasurementRow: View { + let measurement: MeasurementRecord + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("\(measurement.id) · \(measurement.from) → \(measurement.to) · \(title)") + .font(.headline) + if let value = measurement.values[measurement.compared] { + Text(Format.length(value)).monospacedDigit() + } + if let tape = measurement.tape, let error = measurement.errorMeters { + Text("Tape \(Format.length(tape.meters)) · error \(Format.signedInches(error))") + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + .font(.subheadline) + } + + private var title: String { + MeasuredQuantity(rawValue: measurement.compared)?.title.lowercased() ?? measurement.compared + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/StatusPanel.swift b/experiments/measure-lab/MeasureLab/UI/StatusPanel.swift new file mode 100644 index 000000000..851ddb793 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/StatusPanel.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// Tracking state, what ARKit has found, and the way into the session sheet. +struct StatusPanel: View { + let session: LabSession + let onOpenSession: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Label { + Text(trackingText) + .font(.headline) + } icon: { + Circle() + .fill(trackingColor) + .frame(width: 10, height: 10) + } + Text(countsText) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + if let error = session.storageError { + Text(error) + .font(.footnote) + .foregroundStyle(Theme.refused) + } + } + .accessibilityElement(children: .combine) + Spacer(minLength: 0) + Button("Session", systemImage: "list.bullet.rectangle", action: onOpenSession) + .labelStyle(.iconOnly) + .font(.title3) + .frame(width: Theme.controlHeight, height: Theme.controlHeight) + .contentShape(.rect) + } + .padding(.leading, 14) + .padding(.vertical, 6) + .background(.regularMaterial, in: Theme.panelShape) + } + + private var trackingText: String { + if session.isInterrupted { return "Camera paused" } + if session.trackingState == .normal, !session.isTrackingStable { return "Tracking · hold steady" } + return session.trackingState.instruction + } + + private var trackingColor: Color { + switch session.trackingState { + case .normal: session.isTrackingStable ? .green : Theme.accent + case .limited: Theme.warning + case .notAvailable: Theme.refused + } + } + + private var countsText: String { + let depth = if !session.lidarAvailable { + "no LiDAR" + } else if session.sceneDepthEnabled { + "LiDAR depth on" + } else { + "LiDAR depth off" + } + let manifest = session.manifest + return "\(session.horizontalPlaneCount) ground · \(session.verticalPlaneCount) wall planes · \(manifest.keyframes.count) frames · \(depth)" + } +} diff --git a/experiments/measure-lab/MeasureLab/UI/Theme.swift b/experiments/measure-lab/MeasureLab/UI/Theme.swift new file mode 100644 index 000000000..d0e7273d6 --- /dev/null +++ b/experiments/measure-lab/MeasureLab/UI/Theme.swift @@ -0,0 +1,45 @@ +import SwiftUI +import UIKit + +/// Shared look. One accent, tape-measure yellow, marks what the app measured; everything else +/// stays neutral so it reads over any camera image in daylight. +enum Theme { + static let accent = Color(red: 1, green: 0.8, blue: 0) + /// The same yellow for RealityKit materials, which take UIColor. + static let accentUIColor = UIColor(red: 1, green: 0.8, blue: 0, alpha: 1) + static let warning = Color.orange + static let refused = Color(red: 1, green: 0.45, blue: 0.4) + static let panelShape = RoundedRectangle(cornerRadius: 16) + /// Minimum height for anything tappable; above Apple's 44 pt so it works with a glove or in a hurry. + static let controlHeight: CGFloat = 52 +} + +extension LabEvent.Tone { + var color: Color { + switch self { + case .accepted: Theme.accent + case .warning: Theme.warning + case .refused: Theme.refused + } + } + + var systemImage: String { + switch self { + case .accepted: "checkmark.circle.fill" + case .warning: "exclamationmark.triangle.fill" + case .refused: "xmark.octagon.fill" + } + } +} + +/// Press feedback for the app's custom buttons: a slight shrink, dropped under Reduce Motion. +struct PressableButtonStyle: ButtonStyle { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed && !reduceMotion ? 0.97 : 1) + .opacity(configuration.isPressed ? 0.85 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} diff --git a/experiments/measure-lab/README.md b/experiments/measure-lab/README.md new file mode 100644 index 000000000..27cfe84a3 --- /dev/null +++ b/experiments/measure-lab/README.md @@ -0,0 +1,152 @@ +# Measure lab + +## Question + +Can an ordinary iPhone without LiDAR measure what the battery placement checks need, outdoors, on the first try? The checks need a straight wall's line, points on that wall (meter, door and window edges), a facing gap to a fence or wall, and an overhead height. The method comes from the no-LiDAR capture research note (`docs/research/t3-no-lidar-capture.md`). + +Every session is also recorded in the format below, so other pipelines can replay the same frames and taps. + +## Using the app + +The app tracks with ARKit and never uses LiDAR scene reconstruction. On a LiDAR phone, the Session sheet can record depth maps for comparison runs; the switch is off by default and locked after the first tap. + +Taps count only after tracking has been normal for 1 s. Tap the camera image to mark a spot, or press **Mark** to mark under the center ring. **Freeze** holds one frame still for precise tapping. + +| Tool | A tap | Refused when | +| --- | --- | --- | +| Ground | Finds the ground under the tap. Hits past the edge of a found plane, on ARKit's estimated surface, or looking down less than 30° are flagged. | ARKit finds no ground there | +| Wall | The first two taps mark where the wall meets the ground, and the wall is the vertical plane through them. Later taps on the wall's base check that plane. | The two contacts are under 2 m apart; the camera stands in line with the wall | +| On wall | Finds where the tap meets the newest wall. Reports the distance along the wall from the first contact and the height above the ground line. Points beyond the contacts are flagged. A point also carries its wall's warnings: a flagged contact, no check yet, or a failed check. | The ray is more than 60° from straight on, or the wall is behind the camera | +| Two-view | Tap a feature, step sideways, and tap it again in a new frame. On a frozen second view, a dashed line shows where the feature must lie. | The views are under 15° apart, the rays miss by more than 2 in, or both taps are on one frame | +| Measure | Point to point: straight, horizontal, height difference, and distance along a chosen wall. Point to wall: facing gap and height above ground. Type the tape reading in feet and inches (`6`, `3 1/4`) to record app minus tape. | | + +The limits are the research note's gates. Each is a hypothesis this run tests, not a calibrated value, and every session records the ones it used. + +## Pass criteria + +Set before the run, from the research note. The method passes when all of these hold: + +- Wall, opening and rigid-ground distances are within 4 in of the tape. Facing gap and overhead height are within 6 in. +- The 30 ft span is within 8 in. The return-to-reference gap is within 4 in. +- Every accepted measurement's interval contains the taped value, using the bounds above as the interval (app value ± bound). A measurement with `accepted: false` is an abstention; record its error anyway, to show whether its warnings were needed. +- The hidden-contact, low-parallax, mismatched-tap and wrong-plane cases abstain: a refusal, a flag, or a failed wall check. +- On both sides of a threshold, the decision rule never gives a false PASS. Use the public 3 ft fence clearance from Base's help page as `T`: PASS when `distance − bound > T`, FAIL when `distance + bound < T`, UNSURE otherwise. Equality is UNSURE, matching the Lane C decision rule. +- The uncoached operator finishes one capture in 8 minutes, with at most one corrective prompt per measurement. + +On a failure, narrow the method's claim or send that quantity to review. Never widen the bounds to pass. One house can admit a method to the demo; it cannot prove it across homes. + +## One-hour outdoor protocol + +Bring an iPhone without LiDAR running this app, a 50 ft tape, a spirit level, chalk or painter's tape, and two people. One of them, the uncoached operator, has not seen the tape values and gets no coaching. Stay on the ground: no ladders, and keep clear of gas fittings. + +1. **Minutes 0 to 5: check the phone and the pixel mapping.** Open the Session sheet and confirm LiDAR shows No and Mesh reconstruction shows Not supported. With Ground, mark a sharp paving joint, walk 2 m away, and check the yellow dot still sits on the joint. Freeze a frame, tap the same joint, and check the tap ring sits on it. +2. **Minutes 5 to 15: tape the ground truth.** Chalk two marks at the base of one straight wall, 30 ft apart. Tape and write down, out of the uncoached operator's sight: + - the 30 ft span between the marks; + - one door or window: the width and the sill height above the ground at the wall; + - the electric meter's bottom edge height above the ground; + - the facing gap from the wall to a rigid fence or wall across from it, perpendicular to the wall, with chalk at both ends; + - the lowest overhead near the wall (an eave corner or porch beam), height above the ground below it; + - a reference X chalked on paving 2 m from the wall. +3. **Minutes 15 to 40: three captures.** Two by the coached operator, one by the uncoached operator. Start each with Session › New session. In each: + 1. Ground: mark the reference X. + 2. Wall: mark the two chalk marks as contacts, then one more point on the wall's base as a validation contact. + 3. On wall: mark both sides of the opening, its sill, and the meter's bottom edge. + 4. Ground: mark the chalk at the fence base. + 5. Two-view: mark the overhead corner, step about 1 m sideways, and mark it again. + 6. Walk to the far end of the wall and back, then mark the reference X again with Ground. + 7. Measure each taped quantity and type the tape value. The 30 ft span is contact to contact along the wall. The opening width is along the wall. The sill and meter are height above ground against the wall. The facing gap is the fence point against the wall. The overhead is the two-view point's height above ground against the wall. The return gap is the straight distance between the two reference X points, with the tape value 0 and error read directly. + 8. Note the capture time, from New session to the last measurement. +4. **Minutes 40 to 50: try to break it.** In a fourth session, try wall points on blank stucco, a capture in direct sun, a wall contact hidden by a shrub, a two-view pair with a 20 cm step, and a two-view pair where the second tap is on a different feature. Build one wall from a planter's base instead of the wall's, then validate it against the real wall base. Each of these should end in a refusal, a flag, or a failed check. +5. **Minutes 50 to 60: export and score.** Share each session's zip to a laptop and put it in `experiments/measure-lab/data/` (git ignores it). Fill in the results table from `measurements` and `refusals` in each session.json: maximum and median absolute error per method, abstentions, capture time, and the decision check on both sides of the 3 ft threshold. + +## Build and run + +Requires Xcode 26 or newer and an iPhone on iOS 26. XcodeGen 2.46.0 is needed only to change `project.yml`. + +From this folder: + +```sh +make test # geometry package: swift test (runs on macOS and Linux) +make build # app, unsigned: xcodebuild ... -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO build +make project # regenerate MeasureLab.xcodeproj after editing project.yml, then commit it +``` + +To run on a phone: + +1. `cp Config/Local.xcconfig.example Config/Local.xcconfig`. +2. Set `DEVELOPMENT_TEAM` to your Team ID and `BUNDLE_ID_PREFIX` to a prefix your team can register. The app id becomes `.measurelab`. +3. Open `MeasureLab.xcodeproj`, pick the iPhone, and run. + +Git ignores `Local.xcconfig`. Leave the team field in Xcode's Signing & Capabilities tab empty; setting it there writes your team into `project.pbxproj`, and CI's drift check fails. + +## Session format + +Each session is a folder, `Documents/Sessions//` on the phone, also visible in the Files app under On My iPhone › Measure Lab. **Share session** in the Session sheet sends it as one zip. + +``` +/ + session.json + keyframes/ + k00001.jpg unrotated landscape sensor image + k00001.depth.f32 only with LiDAR depth on: Float32 meters, row-major + k00001.confidence.u8 only with LiDAR depth on: UInt8 0 low, 1 medium, 2 high +``` + +Units and frames, also written into `units` and `conventions` in every session.json: + +- Lengths are meters, angles degrees. Times are seconds of device uptime, the clock of `ARFrame.timestamp`. `session.startedAt` (ISO 8601) and `session.startedAtUptime` line that clock up with wall time. +- World: ARKit's frame with `.gravity` alignment. Right-handed, y up, origin and heading wherever the session started. Each session has its own world; points from two sessions can't be compared. +- Camera: +x right and +y up in the unrotated sensor image, looking along −z. +- `pose`: camera-to-world 4×4, 16 numbers column by column (the `simd_float4x4` layout). +- `intrinsics`: `[fx, fy, cx, cy]` in pixels of that keyframe's JPEG. +- Pixels: `[u, v]`, with (0, 0) the top-left corner of the JPEG and v growing down. The ray through a pixel has camera-space direction `((u − cx)/fx, −(v − cy)/fy, −1)`, the same as `pixel_ray` in docs/02. +- Vectors are `[x, y, z]` arrays. + +Top-level fields of session.json (`format` is `"measure-lab-session"`, `formatVersion` is 2): + +| Field | Contents | +| --- | --- | +| `session` | `id`, `startedAt`, `startedAtUptime`, `appVersion`, `deviceModel` (for example `iPhone15,4`), `systemVersion`, `lidarAvailable`, `meshReconstructionSupported`, `sceneDepthEnabled` | +| `gates` | Every threshold the session ran with, so a replay can apply the same ones | +| `keyframes` | `id`, `img` (path), `w`, `h`, `intrinsics`, `pose`, `timestamp`, `tracking` (`normal`, `limited.excessiveMotion`, ...), `reason` (`motion`, `tap` or `freeze`), `depth` (`file`, `confidenceFile`, `w`, `h`) or null. The field names match the `keyframes` entries in docs/01's `scene.json`. | +| `taps` | `id`, `time`, `tool`, `step` (`firstContact`, `secondView`, ...), `keyframe`, `frozen`, `pixel`, `rayOrigin`, `rayDirection`, `displayMappingCheck`, and the `point` and/or `refusal` it produced | +| `points` | `id`, `kind` (`ground`, `wall`, `twoView`), `position`, `taps`, then per kind `ground` (`surface`, `planeAnchor`, `lookDown`), `onWall` (`wall`, `range`, `angleFromNormal`) or `twoView` (`firstTap`, `secondTap`, `rayAngle`, `gap`, `baseline`, `t1`, `t2`); `wallCoordinates` against the newest wall at the time (`along`, `heightAboveGround`, `offset`, `withinContacts`); `flags`, the point's own warnings (`estimatedPlane`, `extendedPlane`, `shallowLookDown`, `outsideWallContacts`); `wallWarnings`, for a point on a wall, that wall's warnings when the point was made | +| `walls` | `id`, `contacts` (point ids), `start`, `end`, `direction`, `normal`, `length`, `cameraPosition`, `validations` (`point`, `residual`, `tolerance`, `passes`), `warnings` as of the last check (`wallContactWarning`, `wallNotValidated`, `wallValidationFailed`) | +| `measurements` | `id`, `time`, `from`, `to` (point or wall id), `referenceWall`, `values` (every quantity that applies, keyed `straight`, `horizontal`, `vertical`, `alongWall`, `gapToWall`, `heightAboveGround`), `compared`, `tape` (`feet`, `inches`, `meters`) or null, `errorMeters` and `errorInches` (app minus tape), `warnings` (inherited as above, plus `belowGround`), and `accepted`, true only with no warnings. `heightAboveGround` is signed; the other values are non-negative. | +| `refusals` | `id`, `time`, `tool`, `tap` (null when no frame was resolved), `reason` (for example `grazingRay`, `rayAngleTooSmall`, `noGround`, `trackingNotReady`), `message` as shown, `values` behind it | +| `tracking` | `time`, `state`, one entry per tracking change | + +Keyframes are saved when the camera moves 0.5 m or turns 15° since the last saved one, and at every live tap and freeze. They are listed in arrival order; sort by `id` or `timestamp` if order matters. A JPEG that was still being written when the zip was made can be in the folder without an entry; use only listed keyframes. `displayMappingCheck` on live taps is the pixel distance between ARKit's display transform and the app's own portrait mapping for the same screen point. It should stay under a pixel or two; a larger value means frozen-frame taps land on the wrong pixel. + +To replay a tap, build the ray through `pixel` from its keyframe's `pose` and `intrinsics`; it should match `rayOrigin` and `rayDirection`. `CameraFrame.ray(throughPixel:)` in `Geometry/` does this. + +## Results + +Not run yet. + +| Quantity | Method | Tape | Captures (error, in) | Max | Median | Abstentions | Within bound | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 30 ft span | Wall contacts | | | | | | | +| Opening width | On wall | | | | | | | +| Sill height | On wall | | | | | | | +| Meter bottom height | On wall | | | | | | | +| Facing gap | Ground to wall | | | | | | | +| Overhead height | Two-view | | | | | | | +| Return to reference | Ground | | | | | | | + +Break-it cases (expected result: refusal, flag or failed check): + +| Case | Result | +| --- | --- | +| Blank stucco wall point | | +| Direct sun | | +| Shrub-hidden contact | | +| Two-view with a 20 cm step | | +| Two-view on different features | | +| Wall from a planter's base | | + +Capture time, uncoached operator: + +Decision check at 3 ft, both sides: + +What this changes: diff --git a/experiments/measure-lab/Tools/make-app-icon.swift b/experiments/measure-lab/Tools/make-app-icon.swift new file mode 100644 index 000000000..739541bf9 --- /dev/null +++ b/experiments/measure-lab/Tools/make-app-icon.swift @@ -0,0 +1,63 @@ +// Draws MeasureLab's placeholder app icon: a black tape-measure blade with tick marks on the +// app's tape yellow. App Store Connect rejects an upload without an icon or with transparency, +// so the PNG is opaque. +// +// Run from experiments/measure-lab: +// swift Tools/make-app-icon.swift MeasureLab/Assets.xcassets/AppIcon.appiconset/AppIcon.png +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +let size = 1024 +guard CommandLine.arguments.count == 2 else { + FileHandle.standardError.write(Data("usage: swift make-app-icon.swift \n".utf8)) + exit(64) +} +let output = URL(fileURLWithPath: CommandLine.arguments[1]) + +guard let context = CGContext( + data: nil, + width: size, + height: size, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + // No alpha channel: the App Store rejects icons with transparency. + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue +) else { fatalError("Couldn't create a bitmap context") } + +let yellow = CGColor(srgbRed: 1, green: 0.8, blue: 0, alpha: 1) +let ink = CGColor(srgbRed: 0.08, green: 0.08, blue: 0.08, alpha: 1) + +context.setFillColor(yellow) +context.fill(CGRect(x: 0, y: 0, width: size, height: size)) + +// The blade runs corner to corner at 45°, drawn in a rotated frame centered on the icon. +context.translateBy(x: CGFloat(size) / 2, y: CGFloat(size) / 2) +context.rotate(by: .pi / 4) +let bladeLength: CGFloat = 1180 +let bladeWidth: CGFloat = 300 +let blade = CGRect(x: -bladeLength / 2, y: -bladeWidth / 2, width: bladeLength, height: bladeWidth) +context.setFillColor(ink) +context.fill(blade) + +// Tick marks along the top edge: a tall one every fourth tick, like inches and quarter inches. +context.setFillColor(yellow) +let spacing: CGFloat = 44 +let tickWidth: CGFloat = 12 +var index = 0 +var x = blade.minX + spacing / 2 +while x < blade.maxX { + let height: CGFloat = index % 4 == 0 ? 150 : 80 + context.fill(CGRect(x: x - tickWidth / 2, y: blade.maxY - height, width: tickWidth, height: height)) + index += 1 + x += spacing +} + +guard + let image = context.makeImage(), + let destination = CGImageDestinationCreateWithURL(output as CFURL, UTType.png.identifier as CFString, 1, nil) +else { fatalError("Couldn't prepare the PNG") } +CGImageDestinationAddImage(destination, image, nil) +guard CGImageDestinationFinalize(destination) else { fatalError("Couldn't write \(output.path)") } diff --git a/experiments/measure-lab/project.yml b/experiments/measure-lab/project.yml new file mode 100644 index 000000000..ba057b55b --- /dev/null +++ b/experiments/measure-lab/project.yml @@ -0,0 +1,62 @@ +# Source of truth for MeasureLab.xcodeproj. After editing, run `make project` in this folder and +# commit the regenerated project; CI fails if the committed project differs. +name: MeasureLab + +options: + # CI installs this exact version; older releases write a different project file. + minimumXcodeGenVersion: 2.46.0 + # Written into the project and scheme as LastUpgradeCheck, so Xcode 26 does not offer + # a settings upgrade that would edit the generated file. + xcodeVersion: "26.0" + deploymentTarget: + iOS: "26.0" + developmentLanguage: en + +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + SWIFT_APPROACHABLE_CONCURRENCY: YES + +packages: + MeasureGeometry: + path: Geometry + +targets: + MeasureLab: + type: application + platform: iOS + sources: + # Synchronized folder: Xcode picks up files added to MeasureLab/ without project-file edits. + - path: MeasureLab + type: syncedFolder + - path: Config/Info.plist + buildPhase: none + dependencies: + - package: MeasureGeometry + configFiles: + Debug: Config/Shared.xcconfig + Release: Config/Shared.xcconfig + settings: + base: + PRODUCT_NAME: MeasureLab + PRODUCT_BUNDLE_IDENTIFIER: $(BUNDLE_ID_PREFIX).measurelab + INFOPLIST_FILE: Config/Info.plist + MARKETING_VERSION: 0.1.0 + CURRENT_PROJECT_VERSION: 1 + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: NO + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: Apple Development + +schemes: + MeasureLab: + build: + targets: + MeasureLab: all + run: + config: Debug + archive: + config: Release diff --git a/experiments/meter-closeup/.gitignore b/experiments/meter-closeup/.gitignore new file mode 100644 index 000000000..2d9f16e2d --- /dev/null +++ b/experiments/meter-closeup/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/experiments/meter-closeup/Makefile b/experiments/meter-closeup/Makefile new file mode 100644 index 000000000..ab7601a87 --- /dev/null +++ b/experiments/meter-closeup/Makefile @@ -0,0 +1,46 @@ +# Each results table has one target. Images and raw recognizer output go to $(METER_DATA), +# default ~/house-scanning-data/meter; nothing from there is committed. + +OCR := meterocr/.build/release/meterocr + +.PHONY: check leakcheck images q1 q2 q45 sweep review + +check: + uv run ruff check . + uv run ruff format --check . + uv run pytest -q + +# Needs the HMAC key (file or METER_HMAC_KEY), not the plaintext labels. +leakcheck: + uv run python -m meter_eval.leakcheck + +$(OCR): meterocr/Package.swift meterocr/Sources/meterocr/main.swift + swift build -c release --package-path meterocr + +# Downloads the 83 photos in manifest.csv (about 200 MB, one request per second). +images: + uv run python -m meter_eval.commons fetch + +# results/clean.md and results/clean_per_image.csv (about 1 minute). +q1: $(OCR) + uv run python -m meter_eval.clean + +# The degraded reads behind results/sweep.md, in two processes of up to 2 GB each +# (about 25 minutes). Needs results/clean_per_image.csv from q1. +sweep: $(OCR) + uv run python -m meter_eval.sweep --shard 0 --shards 2 & \ + uv run python -m meter_eval.sweep --shard 1 --shards 2 & \ + wait + +# results/sweep.md and results/sweep_rows.csv from the sweep rows. +q2: + uv run python -m meter_eval.analyze + +# results/locate.md and results/locate_per_image.csv: barcodes and number finding (Q4, Q5), +# about a minute. `--split dev` reproduces results/locate_dev.md, the half the rules came from. +q45: $(OCR) + uv run python -m meter_eval.locate + +# The label review page, outside git: ~/house-scanning-data/meter-closeup/review.html. +review: + uv run python -m meter_eval.review build diff --git a/experiments/meter-closeup/README.md b/experiments/meter-closeup/README.md new file mode 100644 index 000000000..35e2e1968 --- /dev/null +++ b/experiments/meter-closeup/README.md @@ -0,0 +1,159 @@ +# Meter close-up readability + +The app photographs the electric meter so Base can read its meter number and class label (CL200, CL320). A homeowner must not be asked for photos again, so the phone has to decide before they leave whether a close-up is readable. This experiment measures that on real meter photos. + +## Questions and pass criteria, set before the run + +1. How often does Apple's on-device text recognition read the full meter number and the class label correctly? If it gets the number right on at least 90% of usable close-ups, the phone can gate capture on its own read. Below 70%, the phone should gate on photo quality and a server should do the reading. +2. Which cheap photo checks predict a failed read, and at what thresholds? A check is useful if, above its threshold, at least 95% of the photos that read correctly undegraded still read after controlled degradation. +3. Does a second pass (crop the detected label and read again) raise the read rate? + +Questions 4 and 5 were added after the first run found that the phone reads the number but cannot tell which recognized line it is. Their criteria were committed before those measurements ran. + +4. Does Vision's barcode detection find, decode and confirm the meter number? Measured on every usable photo: photos with a barcode found, with one decoded, and with a decode that contains the labelled number. A barcode is worth reading first if a decode contains the number on at least a third of the photos where a barcode is found. +5. Which rule picks the meter number out of the recognized lines? Each rule and the best combination are reported with precision (the pick is the number, among photos where the rule makes a pick) and recall (over photos whose number Vision read). The app can fill in the number without asking if the combination's precision is at least 95%. A tap-to-confirm screen showing the top three candidates avoids a retake if the number is among them on at least 95% of photos whose number Vision read. To limit fitting rules to the photos they are scored on, rules were written using only the odd-numbered photos (m01, m03, …) and committed before the even-numbered ones were scored; both halves are reported. + +## Data and labels + +83 photos from Wikimedia Commons (the `Electricity meters (kWh)` category and meter-brand searches), all under CC0, public domain, CC BY or CC BY-SA. `manifest.csv` records each photo's URL, license and author. The photos are not committed; `make images` downloads them. Three are excluded: two show several meters at once, and one label shows a customer's name and address. Of the 80 usable photos, 22 carry a US-style class label (CL200, 200 CL, CL20) and 57 an IEC current rating such as `10(60)A`. Most meters are Taiwanese, European or Canadian; US residential meters are a minority. + +**The labels were made by AI readers, not people, so every number below is only as good as those labels.** Reader 1 (Claude Opus, the author of this experiment) read every photo, zooming into the original. Reader 2 (a separate Claude Sonnet agent) read every photo without seeing reader 1's labels. The label is always reader 1's number, so reader 1 chose which printed identifier counts as the meter number: utility plates over maker serials. `labels.py` records two agreement rules: + +- **Loose**, which the headline results use (73 of the 78 photos showing a number): reader 1's number equals any number reader 2 listed, its main number or one of its other numbers, or one contains the other with at least 6 characters in the shorter; and neither reader doubted a character. Reader 2 doubting only which printed number is the ID does not count as doubt (`labels.py` lists those photos). +- **Strict** (62 photos): both readers' main numbers are identical and both were sure. + +`manifest.csv` stores each meter number as an HMAC-SHA256 digest, because `CONTRIBUTING.md` forbids committing meter numbers. An unkeyed hash of a short number can be reversed by trying every number of that length; without the key, which lives in `~/house-scanning-data/meter-closeup/hmac.key` and never in git, the digests reveal nothing. Scoring a read needs the key: get it from the team, or create a new one with `uv run python -m meter_eval.labels --new-key` and rebuild the manifest from the plaintext labels. `make leakcheck`, which also runs in CI with the key from a repository secret, fails if any identifier either reader transcribed appears in a tracked file. + +## Method + +`meterocr/` is a Swift command-line tool that calls Vision's `VNRecognizeTextRequest` (revision 3, `en-US`, `minimumTextHeight` 0) on macOS, the same API the iPhone app would use, and optionally `VNDetectBarcodesRequest` (revision 4, all symbologies). The primary configuration is `accurate` without language correction. A number counts as read when the whole labelled number appears exactly in one recognized line, or in lines sharing a row. This is an upper bound on what the app gets: the app must still pick which recognized string is the meter number. + +For question 2, the 71 photos whose agreed number read correctly undegraded were degraded with five controlled transforms. These are synthetic changes applied to the real photos, and every level is scaled to the height of the number's line so it means the same thing at any resolution: Gaussian blur (focus), horizontal motion blur (hand shake), downscaling (label too small), a white glare patch over the number, and cropping the frame edge into the number. Each check was measured three ways. On the whole photo, which needs no locator. On the top candidate of the number-finding ranking below, which is what the phone can compute. And on the true number's box from the undegraded read, which the phone does not have, so those rows only show what a perfect locator would allow; for the framing transform that box is clipped to the frame, so its gap is 0 by construction. For each photo and transform, the break is the first level from which it never reads again. A check's retake threshold is the value at or below which 95% of photos have broken. Every check meets the pre-set criterion by construction of its threshold, so the deciding tests are the AUC over all degraded reads and how many of the 75 real, undegraded photos that read correctly each threshold would wrongly send back. + +For question 3, every failed degraded read was retried on a crop around the top candidate, and on that crop upscaled 2x. + +For questions 4 and 5, candidates for the meter number are the digit-bearing tokens of every recognized line: runs of digit groups merge, so `12 345 678` is one token, and a line of two to four groups is also offered whole. A candidate is right when it equals the labelled number after dropping separators and any letters before the first digit, so `NO. 12345678` and `ABC 123456` count, but a barcode line that merely contains the number does not. A text token is barcode-confirmed when it equals a part of a decoded payload. When it sits inside a longer payload part, it takes the payload's full string, which repairs reads that dropped a digit. The ranking scores each candidate as 8 × barcode-confirmed + 3 × after a `No.`/`Nr.`/`#:` label + 2 × alone on its line + 1 × 6–14 characters with 5 or more digits − 6 × on a specification line (voltages, ratings, `Kh`, `CL200`, `FORM`…) − 4 × rotated text − 6 × all zeros, with ties broken toward taller print. + +## Results + +### 1. On-device reading works on these photos + +| What was read | Photos | `accurate` | `accurate` + language correction | `fast` | +|---|---|---|---|---| +| Meter number, exact | loose agreement (73) | 71/73 = 97% (91%–99%) | 71/73 = 97% (91%–99%) | 55/73 = 75% (64%–84%) | +| Meter number, exact | strict agreement (62) | 60/62 = 97% (89%–99%) | 60/62 = 97% (89%–99%) | 47/62 = 76% (64%–85%) | +| Meter number, exact | all labelled (78) | 75/78 = 96% (89%–99%) | 74/78 = 95% (88%–98%) | 57/78 = 73% (62%–82%) | +| US class label | all labelled (22) | 20/22 = 91% (72%–97%) | 20/22 = 91% (72%–97%) | 14/22 = 64% (43%–80%) | +| Current rating, e.g. `10(60)A` | all labelled (57) | 57/57 = 100% (94%–100%) | 57/57 = 100% (94%–100%) | 39/57 = 68% (56%–79%) | + +Ranges are 95% Wilson intervals. Verdict: the `accurate` level reads the full number on 97% of these close-ups under either agreement rule, above the 90% bar. The `fast` level misses a quarter of numbers and should not be used for this; language correction adds nothing. The two agreed numbers it missed are a thin-print number split by dots, which Vision read only in part, and a utility plate on a dark background that Vision never detected. The two class labels it missed are an oblique shot with `CL` and `200` on separate plates, and a `CL` cut off by the frame edge. + +Reading is not the hard part; finding the number is (question 5). On only 21 of 75 photos was the tallest recognized line with 4 or more digits the meter number; nameplates carry registers, serials, model codes and seal numbers. + +### 2. Retake checks: only focus and size survive on the phone + +| Problem | Check the phone can compute | Retake when | Evidence | +|---|---|---|---| +| Out of focus | Laplacian variance of the whole photo, shrunk to at most 1024 px | 6.63 or less | AUC 0.96; rejects 0 of 75 good photos. Reading holds to a blur σ of 0.04 × line height (94%) and fails at 0.13 (4%) | +| Number too small | line height of the ranking's top candidate | 33.9 px or less | AUC 0.86; rejects 2 of 75 good photos. On the true number's line the cut would be 12 px (96% read at 15 px, 77% at 12 px); the top candidate likely needs more because once the number is too small, a larger line takes its place | +| Glare | none found | – | on the true box the saturated share works (AUC 0.95), but on the top candidate it has an AUC of 0.55 and rejects all 75 good photos; the whole-photo saturated share rejects 47 | +| Number cut off | none found | – | the top candidate's gap to the frame edge has an AUC of 0.61 and rejects 37 of 75 good photos at its 95% cut | +| Hand shake | none found | – | reading survives streaks up to 0.2 × line height (93%), but every sharpness score either misses streaks (AUC 0.69–0.85) or rejects 21–37 of 75 good photos | + +Verdict: only the focus check meets the criterion without a locator, and the size check with the phone's top candidate. Glare and framing checks work only on the true number's box: once glare washes out the number or the edge cuts it, the number usually stops being the top candidate (on degraded photos the top candidate was the number on 1,366 of 3,309 reads). "Rejects 0 of 75 good photos" is weak evidence: no real photo is near a threshold. The smallest label is 22 px, and the lowest whole-photo sharpness is 58.7 against a cut of 6.63. Even above every break, 0.7–2.6% of degraded reads failed anyway, which no photo check can prevent. + +`results/sweep.md` has the full read-rate curves, every check's 95% and 80% thresholds, and the real-photo rejection counts. + +### 3. A second pass rarely helps + +Re-reading a crop around the top candidate recovered 3–4% of failed reads for blur, motion, glare and framing. For labels made too small it recovered 6% as cut and 11% when upscaled 2x (13% with either). Verdict: a retake beats a second pass; upscaling a small label is the only case worth adding. Combining two frames was not tested. + +### 4. Barcodes confirm the number when there is one + +| Photos | Barcode found | Decoded | Decode contains the meter number | +|---|---|---|---| +| All usable: 80 | 25 | 25 | 20 of 24 with a labelled number | +| US-style (CL class label): 22 | 9 | 9 | 8 of 8 with a labelled number | + +Every barcode Vision found also decoded, and 20 of the 24 decodes on photos with a labelled number contain it, well above the one-third bar. The four misses encode a different identifier: a Hydro-Québec barcode carrying a second utility number, and QR codes on two Taiwanese smart meters and a Japanese meter carrying the maker serial or a product record. + +### 5. Finding the number: no rule can fill it in unasked, and tap-to-confirm falls short + +| Rule | Held-out photos: precision | Held-out: recall | All 71 read photos: precision | All: recall | +|---|---|---|---|---| +| Tallest digit line (phase 1 guess) | 30% | 30% | 28% | 28% | +| A barcode confirms a text line | 6/8 = 75% | 18% | 17/20 = 85% | 24% | +| Line nearest a decoded barcode | 6/9 = 67% | 18% | 13/20 = 65% | 18% | +| After a `No.`/`Nr.`/`#:` label | 5/10 = 50% | 15% | 11/20 = 55% | 15% | +| Alone on its line, 6–14 characters, not a specification line | 23/33 = 70% | 70% | 45/70 = 64% | 63% | +| Barcode, else label word | 11/17 = 65% | 33% | 27/36 = 75% | 38% | +| Ranking, number is first | – | 21/33 = 64% | – | 48/71 = 68% | +| Ranking, number in the top three | – | 28/33 = 85% (69%–93%) | – | 64/71 = 90% (81%–95%) | +| Number is any candidate at all | – | 29/33 = 88% | – | 66/71 = 93% | + +Held-out photos are the 33 even-numbered photos whose number Vision read; the rules were committed before they were scored. Verdict: both criteria fail. No rule reaches 95% precision, so the app cannot fill in the number unasked. The top three hold the number on 85% of held-out photos, short of 95%, so a tap-to-confirm screen alone would still send about one homeowner in seven back for a retake or a typed entry. Under the strict labels the top three hold it on 56 of 60 read photos (93%, 84%–97%), still short. + +Ten photos went wrong: on seven the top three missed the number, and on three the barcode rule picked the wrong identifier. They split evenly between two causes: + +- **Several real identifiers on one plate.** The rule picked another identifier printed on the meter: a maker serial next to a utility plate, a second barcode, or a `SERIAL#` line. Reader 1 chose which identifier is the label, so these misses depend on that choice. Base should say which identifier it uses for each utility, or accept any printed identifier. +- **Vision split the number or merged a neighbour into it.** A prefix or digits landed in a separate observation (a number printed like `S12 A345 678` read as `12A345` and `678`), or a neighbouring character joined it, so the exact number never appears as a candidate. + +Exploratory, not held out: on US-style meters, chosen as a subgroup after the held-out scoring, the barcode rule was right on all its picks, 5 of 5 on the odd-numbered photos the rules were written on and 3 of 3 held out (44%–100%). Three picks cannot support filling in the number without asking. + +## For the app: what to port + +Recommended flow, from the results above: read the close-up, then show the three best candidates, with a barcode-confirmed candidate ranked first, and let the homeowner tap the number or "none of these". The tap confirms the number, so a confirmed number needs no readability check. Before showing candidates, block a photo that fails the focus check. After "none of these", ask for a retake, and use the size check to say "move closer". There is no measured signal for glare, framing or hand shake, so those prompts can only be general advice. + +Every check runs on the full-resolution photo after applying its EXIF orientation. "Luma" is the 8-bit image L = 0.299 R + 0.587 G + 0.114 B. Boxes are Vision's normalized `boundingBox`, converted to a top-left origin. `src/meter_eval/retake.py` implements the same definitions, and `tests/test_retake.py` holds hand-computed cases and a pinned value for the resize. + +| Check | Input | Formula | Retake when | +|---|---|---|---| +| Out of focus | whole photo, luma | shrink so the long side is 1024 px, bilinear with antialiasing as PIL does, leaving smaller photos as they are; convolve with the 4-neighbour Laplacian [[0,1,0],[1,−4,1],[0,1,0]] over interior pixels; population variance | 6.63 or less | +| Number too small | top candidate's box | box height × photo height in pixels | 33.9 px or less | +| No number | ranking | no candidate at all | always | + +Finding the number: read with `VNRecognizeTextRequest` revision 3, `.accurate`, `en-US`, language correction off, `minimumTextHeight` 0, and run `VNDetectBarcodesRequest` revision 4 on the same image. Build candidates and rank them by the score in the method section. `src/meter_eval/locate.py` is the reference for the tokens, features and score. + +## Field test before trusting the thresholds + +Commons photos are processed JPEGs from many cameras, not frames from the app's camera pipeline. Sharpening, noise reduction and tone mapping change sharpness values, so the focus threshold may move in the app; the size threshold depends on geometry and the ranking. Take these close-ups of one real meter with the app's own capture path (or the iPhone camera if the app is not ready), then run `uv run python -m meter_eval.fieldtest PHOTO_DIR --number ""`. As in the app, its checks use the ranking's top candidate; `--number` only scores the outcome. It prints each photo's read result, rank and check values, and counts retakes asked for photos that read and photos accepted that did not. + +| Threshold | Photos to take | It holds if | +|---|---|---| +| Out of focus | three with focus locked on a distant background, one from 5 cm (inside the minimum focus distance), three sharp | blurred photos score 6.63 or less and fail to read; sharp photos score far above | +| Number too small | square to the meter from 15, 30, 50, 80 and 120 cm | photos whose top candidate is 34 px or taller read, and the number is in the top three | +| Glare (no check) | the flashlight of a second phone held beside the camera at 0°, 20° and 45°, or direct sun, with the reflection on the number | records how often glare alone breaks reading and whether the number drops out of the top three | +| Cut off (no check) | the frame edge cutting a quarter digit, half a digit and one whole digit | records whether a cut number ever appears as a confirmable candidate | +| Hand shake (no check) | three in shade while sweeping the phone sideways during capture | records how often shake alone breaks reading; if it does, gate capture on gyroscope motion instead of an image score | + +## Checking the labels by hand + +Running `make review` builds `~/house-scanning-data/meter-closeup/review.html` outside git, because it shows the plaintext meter numbers. Open the file in a browser. Each whole photo appears first, beside the number the AI readers settled on, with a zoom on the line Vision read below it, so the reviewer sees every identifier on the plate. Press K to keep the number, or F to type a correction and Enter to save it. Progress stays in the browser, so the page can be closed and reopened. "Download answers" saves a CSV. `uv run python -m meter_eval.review ingest ` stores it for `python -m meter_eval.labels`. A kept number then counts as agreed under both rules; a corrected one replaces the label but is not counted as agreed until someone keeps it. Rerun the tables after that. + +## Rerun + +Needs macOS with Xcode 26 (Swift 6), uv, and the HMAC key. From this folder: + +```sh +make images # download the 83 photos to ~/house-scanning-data/meter (set METER_DATA to change) +make q1 # results/clean.md and results/clean_per_image.csv, about a minute +make sweep # the degraded reads, two processes of up to 2 GB, about 25 minutes +make q2 # results/sweep.md and results/sweep_rows.csv +make q45 # results/locate.md and results/locate_per_image.csv, about a minute +make review # the label review page, outside git +make check # lint and unit tests +make leakcheck +``` + +Every number in this README comes from one of those results files. `manifest.csv` and `identifier_digests.txt` are the committed output of `python -m meter_eval.labels`, which merges the readers' plaintext labels from the data directory. The sweep tags each row with the digest of the label it was scored against: after a label changes, `make sweep` sweeps that photo again and `make q2` ignores the stale rows. + +## Limits + +- The labels come from AI readers (see above), and reader 1 chose which identifier counts as the meter number, which decides five of the number-finding misses. +- Commons photos are mostly deliberate, well-lit close-ups; homeowners' photos will be worse, so 97% is likely optimistic. US residential meters are a minority of the set, and class labels were read on only 22 photos. +- Degradations are synthetic transforms of real photos: one glare model, horizontal motion only, and no noise, low light, compression or perspective. Class labels were not swept; the thresholds are for meter numbers. +- The class-label matching rule was loosened after the first run, to ignore separators and read `×` as `x`; every first-run miss was one of those. The meter-number rule was not changed. +- Sharpness values are in grey levels squared on 0–255 images and depend on the camera's processing. Recheck the thresholds on iPhone captures before shipping them. +- macOS Vision stands in for iOS Vision: same API and revision, not verified on a phone. +- The number-finding rules were written on half of the photos after the author had read every photo while labelling, so the held-out half limits but does not remove fitting to this set. Its 33 photos give wide intervals. +- The branch history was rewritten on 2026-09-26 to remove real meter numbers that had been used as examples, and to replace unkeyed digests. Commits before that rewrite may still be reachable on GitHub by their old IDs. diff --git a/experiments/meter-closeup/identifier_digests.txt b/experiments/meter-closeup/identifier_digests.txt new file mode 100644 index 000000000..0222abc43 --- /dev/null +++ b/experiments/meter-closeup/identifier_digests.txt @@ -0,0 +1,178 @@ +018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf +02b0324e2155abfc5e6289f62638477273cbf3ff440a9a31eae4d0ec471d064d +070d1d3f27bfb96388c3c2d192d8714544a70cdeac777a2e93270a5b256c5aff +0723124b0e852c05bdee553e7ba54e647ea374262690347e8359755cb9f41f04 +09ecaf29ca7ce1019a6d9340ed5e6f270365ad48f7ef8c931522a48ea0cffa29 +0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd +0b9f1964189c9b91e74a4adfc68a59ec89b4edee20f5b2637116f5fabecb4a5d +0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03 +0c7251d10037e21889a28815d151510a8d19c63e8de96c0485544be40ef197c5 +0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3 +0f42986d9426c184e5118c4b5df571157729b004615deb08e80a156be5479948 +0f4cb902593cfe6baa0a9aab9f24846e0e1e264775d7759b77a4a1ed302d82ca +15377c265cae31a9f80fb2bc5c92e18086dbe0ce14ac4c63787b2569084301e2 +19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912 +1d0b5db7b4e2060faffbfdc2b4adf71252fca677d49adef210c8617523d4bddf +1d9fe7e2cbf10bfa01cdebcbb55384723224487d17aa1e6117d644bb5c34b01c +1e6bf32c55c44532c60e04793a6c475cc89b5314e5501a7159f462d92639231c +1ef0eb8c3919af0dc428645b1f1bc48b856933f0d484288d48de8ae8a809e215 +2001c38e8d378db374ffa5e7e3f4de14fc92f945258ff6412157dcbbd68c864f +2069324b79d39a88392c23a01fcb2670b96154a32e15b2ee258e3012275bdef8 +23873cdb90f35fdc3f88ac969db9376e0c91b8ded21b8caca879f1c8edffb1ef +2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed +24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7 +254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4 +26e85c3d99b3933ecf2909aa42d2d780537b2485b0d105a69d6e6bfda990bbdc +286b85d026bf9211363b55888910eedb152da13ceca2e9742c9d65944d11d3de +2b07ced25f8f21e08b7dcb69fcea7def2ca8e96314f816ce96df54aa1e75a745 +2b32026e6fd3cc16db35ffa12afc397b8d13ec406f0523600cd98459a7a52881 +2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee +2e075a54541ed7a44d0166e4d9f6e66bcef57cf0d9b2d7daca538435c3274909 +2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3 +2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567 +30249d117d7ee6dd7fdba2f49c89a2c3fcb5565444f10b69c344823e7e69ae61 +30cde859b71e2204ebedb0008d7c888f0463b887ec98687f006e79e35ee77c2d +31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93 +324cbc1d2159eed2c384b5fe34c360c7f9df2abb52de1095fd538c8ba1bc85fc +32aa450e0d2ab8f44d4c5e2cc8973e642dfbd46935a866ec4fde4495c20556cd +330e90f1eb099999cec057d92e358a51879555872843fa289e869df4ce3d2fa0 +33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9 +36b8ee35c381babdf544d2f0b708f092c3d55a2db954325072ca1acbf050f7e6 +37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9 +37fe80fdef9618ec56b3a32c537d35c7ead51884fc523120b7c30b11078a6cdf +384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a +39751ff5b071c0632e09c47110f85c4d684862e81098a43f671f2d2bca4888b4 +39c7ed6d8a112e84b88172103937ecd7365197a994aa80dd1074835612166b12 +3b474dacd53ae562238df2fa496cbf771277473a6d475d0a0a1d1f4722f38279 +3c525408c98b185903e3971f4fdb1fe67ed2af0225ebfecdcd1ab52194b06de6 +3c6db36924fac335b1902cdd239f61b924145b2173c4072dbfb958ebd19cdb7c +3f4f84a2880bc62faa1775027cb3f8cfd35f1de6d97c3df1484706b25d9bf2a7 +4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5 +4cfd6f0bcc56a91153bbd133f1da353ee0359aebb7f7866c1e1093012c7357ba +4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d +50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e +5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3 +51ab77257c80c44360410180240559151fe901343093b48c428a6bee81d66989 +53202cf0873ba7097410df67eee2046113295fbf24012c3655f0786475764338 +55034d864bec1f2f62b49121db7e244706f9b23c22ff2026b3a98d32e600aa4c +60517358b1e7e635d920bc7eaf3f142f35d5256a01c69172e3d24be663fdddc9 +6259845b4fb9c642690735d9a8548b66e76e0bdc8d708c13114d7a3a2a859bf0 +625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059 +642b593fdff0b187153be10b169a13881279631712fe7246bd742337cd1eca97 +64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5 +66e600ec6248ca04513a98eb44a28e04ce4a6fcf67c734b26819dd19e7967ca0 +68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2 +69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b +6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a +6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841 +6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166 +6d6bd0afe171c50ac7532272f69aa000362efe2ebf506baf9f403efa429b3900 +6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669 +6fa1504bf26d55123d53d42f2013fb0162a9f6690da5d8de47d6ae2a6ccc39b7 +6ff10162993a701ea95ebb0d07166aac475f044a16def6680f1a8091d0507a75 +70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e +74ae24af0f0f7da382b5c3bea76b6da50e681882e6bf965e9ae3d01fc80360cb +75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7 +775275c31112b198acc7b4208257c20e431c8ce183a2b16a7c4d6f0cf85092d7 +783059e5de030787c8479a25eba0f1adf5436abf9c2df7e27f3c4aa1538ee595 +7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964 +79b3c2a281b01defc899b20d5490c5da1ea2d7ac0bc9e3e13ea7b6f8c30c3f46 +7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18 +7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e +7c1a883da44da3e4ca4171705ffdc84dcb271dc5c8d0268b7e8cf6fce570e5a0 +7ceb988d6be7b345277da2dd0aca16b0c5e1c970804c62ed6b08033bd9e2db69 +7d548efa0adb0ee7e4d16dab50ee77bb73650229340570e747c71fb8e11554b7 +7df25b825e3620cdde8156d2699b2d7be681afbef6f68996a258677aa2669f15 +7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459 +7e4184bfc1ee346f570fa59db67e39004ecb236aa21d8b977ad7fccc2385e9ab +7eef84464509036aaa7ad29762ec69db99c1513fdafda5d3f153038613f9464c +7f368d7285e39d25b63d8d7774516de3d6c21c1d463ae36a7024f80ef767748c +80062c22d4686bf8ad892f3cecb20ba0e90bf2f75e82b37bff8b4fe8043f908a +80cc226c0eeb8981d37df808c38d80373efa14ec3d250aefdff1cfe3769cbacd +81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5 +8254d36ef50e0f3bc6c56db52aa1057a6155ba485f3cb696f6c04e0effacf24d +828d0091de8aa165036e50cad9dd65fe8e15471d6eeeaa236657ee445c71aaea +85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366 +8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3 +89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a +89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f +8c91b29ac0798f00455df9d855e1bcdaa93338bc6f7f5171937db2e8bb1dfe0a +8d037ac00e8f13058cc6fcddb23172372e6fd974b18b7bfea0a233724cdde0a3 +929229e37b49aef3110a1b075079be93bf8b3131cbbc870dc540af1a5f1ef890 +947f438d9139860f40737d8b30e3e17a6df8573443ae2745f71534ff6f7e7b08 +96eaee03b735376eaff23a4ed6efcf3a7b4c9d7bd6db3567db4f809c02107293 +9a482f6aa7beae65452cc513ff4040cb772415b02cc4dcd97d154a48cc741cf6 +9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53 +9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8 +9d1c333ae82634e6bab39373ca4da8997918d8e97c9e2c72bcfd6e2d3c5800da +9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7 +9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459 +9e75e7582d39104b7bb6c00043e6af360900d3ec295624b24186b6c843817a63 +9f05bbb396c03db3c78aa1fcac47502b8e7c442a7ed8bdb530f0bec490879ce8 +a05f1a24e53de46473f7cbb4f66330c79d2230650fb4df7db05687aee32345c5 +a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c +aacbbb4e81a0784b0d0cf903d78ddebd2241441e9914c3e0a9432858a65e3f82 +ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb +b0630c1e8bb8043b5092020e0e5a41308d04baf41b06b2a7815dbd75ba43d031 +b126ac601261ae01c8114ce0651b159aabb725bc53037885e6195dff6149aaa5 +b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1 +b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760 +b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2 +b9f5a0565a90da5a42fbf1df2df8fdd54fe3ef63abde6c6d8d90814faf6d44e6 +ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685 +ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5 +bc1dc9c8ddf36d8cbb3bbbd18a683faeffe9925eb6711d6b920e772e745ff544 +bcece5f217130246442efce703b44e07ffaf4426cc9063b26cb0b97bd18581e7 +c2f2a03b1a69fe00ab687ca8990e68756eccfae216182ac900c7c2f4799e4883 +c525a7288d73a93e4893fe942c83199e7e1b3f75a8dec9fbdaeaa80e09368da7 +c678f24956a95b379b168af9749325393ef5fdcf92c453d392a83a849ff80759 +ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2 +ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b +cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae +cc0452235c8de2979d4892e516b17793859b3ee74c1a8e5da92f58924f87f201 +cd9e3615f93099bb110820255d45cd033d9b95fa67bdcaa6cef3659ad1320857 +cdc2b1c6ff401c4a7df6abc21a0ea74fd26f9f582e7ffddb772d602452acbd1f +d0e72adece02bd942378eea9324c04a2cee78de61c9f3d2f238e81b1ed851f0a +d14373bf7b37f5ae055dd4dc5199bf4796d9471b1785d8c12424e1d3d3fc3fc5 +d170646a579d4b3d108665756ee375e7ac587a270ea450090e33858a0b36101a +d1ecaa477659557752cccb27bec3d488a106d18cdc03930e4b0db139258742c1 +d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e +d52eea1caa1153ee6e88a21684ae0b83f89df83e424591757561b8a699fb7538 +d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5 +d65a247a1d1cabafab5652c9177cddcfa3e0724aa2a5dd2295efb22374b46cc5 +d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91 +d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576 +d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb +daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712 +db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920 +db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2 +dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0 +dc33b6f9880a527c9488013d48be1610c779bf6cae1679de3a14200fd5f0ae75 +dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071 +dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89 +dd2f8e65a88b25d61f78654f130abe51c6bbe52da091489427138909164ddeb0 +dd88460c43b7acbbd980fc96cbc8c8f3596d24310fc9b5a5255ed9d581df50e4 +de9a401671f2fca4440f80b3439073f958d9c2aaf98cd8eea2325e08aacb2d79 +e0df28253ddd72aa47e745baa0270e44cdf1793570a097ac6a7f1226955244d4 +e2607e398c1f0d0177eb8a0679701a17f448cb4e5a39f164b5d4d32fcfc950f8 +e4910d3234dad58a995d9b54708de7b1bcd79dcd0378bb5d0a7a00265597bfab +e52b5f06780ee1876f205995145ba42ea53f4e73d8b0c0e18b81643c3019b508 +e7cb7331ee7148fd9ebdae4fe8bc5840e5edc635b50ea55622f9fba4254cec82 +e894f2e689ed388846dc032d9601b711160995eed809dbc036550734db7b020f +e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44 +ea8fb0dbd56e2f8f3dccf31136f9ad3cdf799435d8acef744a0626efad5460ce +eac78440f4e5756d48b3918a37994aa36c18c2cbc774e931b8272a19d42531f0 +ec77c26960f1a931c3c89bc974547ae8becb9808e744c28bc13137d7a469c0cd +ec9e23baded7d5dee470695e72f1412d280fe20e39e1f991a929fb4369461ed1 +ef2fa3c2491e873fddfbb777df9f3c729b023ddd0c298d4bd3ceafcbb33b58e8 +eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed +f0374ee344bd4d9848ee39551927a72846947e7d74e353505b8a302b3ada21fd +f1634cb7224a60ad7521ba3f78c0d859ac0227a9dab0f6f3720e3df4af5d4453 +f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2 +f4686545d0f1832cf9b7519be9559eb6eafb0dd5394b04eda5e6c5ce8202f594 +f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac +f64bfaa814caf2b06d04287461d6f65b78c59356c53082c196f04d44bd6aeb46 +f6844a87811b7f9d2770478c91b57f5e1e22c72a47f5808981dc2ffb5ed8ce9c +f71438221197c7de241bfba4d1a7fcff6dc6ff903961e1581225ae6aa468d8c5 +f736cd24a90786494be31508a0ae4e5cb364843ab63cd91a31d38626e2ae8103 +fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3 diff --git a/experiments/meter-closeup/manifest.csv b/experiments/meter-closeup/manifest.csv new file mode 100644 index 000000000..b049656bd --- /dev/null +++ b/experiments/meter-closeup/manifest.csv @@ -0,0 +1,84 @@ +id,title,page_url,image_url,license,license_url,author,usable,number_hmac,number_hmac_lenient,number_len,number_core_hmac,number_core_len,number_agreed,number_agreed_strict,human_check,class_label,class_kind,class_agreed,notes +m01,File:1979 Tatung D4S 2S watthour meter.jpg,https://commons.wikimedia.org/wiki/File:1979_Tatung_D4S_2S_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/e/ed/1979_Tatung_D4S_2S_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,7,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,7,yes,yes,,30(200)A,current_rating,yes,Tatung D4S; faded number print +m02,File:1991-08 Tatung TT-12 I-20T watthour meter.jpg,https://commons.wikimedia.org/wiki/File:1991-08_Tatung_TT-12_I-20T_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/2/20/1991-08_Tatung_TT-12_I-20T_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,8,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,8,yes,yes,,20(60)A,current_rating,yes,Tatung TT-12 +m03,File:1993 Tatung D4S 2S watthour meter.jpg,https://commons.wikimedia.org/wiki/File:1993_Tatung_D4S_2S_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/f/fd/1993_Tatung_D4S_2S_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,8,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,8,yes,yes,,30(200)A,current_rating,yes,Tatung D4S +m04,File:1993 Tatung TT-9 D-68T watthour meter.jpg,https://commons.wikimedia.org/wiki/File:1993_Tatung_TT-9_D-68T_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/f/f8/1993_Tatung_TT-9_D-68T_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,8,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,8,yes,yes,,30(75)A,current_rating,yes,Tatung TT-9 +m05,"File:0067Poblacion Baliuag, Bulacan 07.jpg","https://commons.wikimedia.org/wiki/File:0067Poblacion_Baliuag,_Bulacan_07.jpg",https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/0067Poblacion_Baliuag%2C_Bulacan_07.jpg/3840px-0067Poblacion_Baliuag%2C_Bulacan_07.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Judgefloro,yes,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6279a44f15c0aedea0fadbf0eeb7eaa247d98fb19a419b603d883aa9b33e7db3,12,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,12,yes,yes,,CL200,ansi_class,yes,Meralco L+G Focus RXR; utility number above barcode +m06,File:1727Cuisine of Bulacan Baliuag 02.jpg,https://commons.wikimedia.org/wiki/File:1727Cuisine_of_Bulacan_Baliuag_02.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/1727Cuisine_of_Bulacan_Baliuag_02.jpg/3840px-1727Cuisine_of_Bulacan_Baliuag_02.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Judgefloro,yes,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6279a44f15c0aedea0fadbf0eeb7eaa247d98fb19a419b603d883aa9b33e7db3,12,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,12,yes,yes,,CL200,ansi_class,yes,same meter as m05; plate partly covered by a plate of food +m07,File:2001 Tatung D5A-2 watthour meter.jpg,https://commons.wikimedia.org/wiki/File:2001_Tatung_D5A-2_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/2/28/2001_Tatung_D5A-2_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,8,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,8,yes,yes,,30(200)A,current_rating,yes,Tatung D5A-2 +m08,File:1994 Tatung D5A-2V kilovarhours meter.jpg,https://commons.wikimedia.org/wiki/File:1994_Tatung_D5A-2V_kilovarhours_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/b/b7/1994_Tatung_D5A-2V_kilovarhours_meter.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Solomon203,yes,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,8,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,8,yes,yes,,30(200)A,current_rating,yes,Tatung D5A-2V varhour +m09,File:1995 Chunghsin I-70-S watthour meter.jpg,https://commons.wikimedia.org/wiki/File:1995_Chunghsin_I-70-S_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/2/2f/1995_Chunghsin_I-70-S_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,8,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,8,yes,yes,,30(200)A,current_rating,yes,Chunghsin I-70-S +m10,File:2002 Chunghsin V-X3A watthour meter.jpg,https://commons.wikimedia.org/wiki/File:2002_Chunghsin_V-X3A_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/3/3c/2002_Chunghsin_V-X3A_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,8,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,8,yes,yes,,15(100)A,current_rating,yes,Taiwan V-X3A; number on dark plate +m11,File:2003 Chunghsin kV2c electronic watthour meter.jpg,https://commons.wikimedia.org/wiki/File:2003_Chunghsin_kV2c_electronic_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/1/18/2003_Chunghsin_kV2c_electronic_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,8,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,8,yes,yes,,CL 200,ansi_class,yes,GE kV2c; LCD also shows the number; dirty face +m12,File:2009 Chunghsin kV2c electronic watthour meter.jpg,https://commons.wikimedia.org/wiki/File:2009_Chunghsin_kV2c_electronic_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/3/3a/2009_Chunghsin_kV2c_electronic_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,d0e72adece02bd942378eea9324c04a2cee78de61c9f3d2f238e81b1ed851f0a,d0e72adece02bd942378eea9324c04a2cee78de61c9f3d2f238e81b1ed851f0a,8,d0e72adece02bd942378eea9324c04a2cee78de61c9f3d2f238e81b1ed851f0a,8,no,no,,2.5A(10A),current_rating,no,GE kV2c FITZALL; glare right of number +m13,File:2002 Tatung D4S 2S watthour meter.jpg,https://commons.wikimedia.org/wiki/File:2002_Tatung_D4S_2S_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/c/cf/2002_Tatung_D4S_2S_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,8,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,8,yes,yes,,30(200)A,current_rating,yes,Tatung D4S; number on dark plate +m14,File:2007 Tatung I-20T watthour meter.jpg,https://commons.wikimedia.org/wiki/File:2007_Tatung_I-20T_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/8/8c/2007_Tatung_I-20T_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,8,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,8,yes,yes,,20(60)A,current_rating,yes,Tatung I-20T; oblique view +m15,File:2010 Chunghsin kV2c electronic watthour meter.jpg,https://commons.wikimedia.org/wiki/File:2010_Chunghsin_kV2c_electronic_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/b/b7/2010_Chunghsin_kV2c_electronic_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,8,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,8,yes,yes,,CL200,ansi_class,yes,GE kV2c +m16,File:2007 Okt HH 355 k.JPG,https://commons.wikimedia.org/wiki/File:2007_Okt_HH_355_k.JPG,https://upload.wikimedia.org/wikipedia/commons/c/c0/2007_Okt_HH_355_k.JPG,Public domain,,Benutzer:Harry20. Original uploader was Harry20 at de.wikipedia,yes,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,8,2069324b79d39a88392c23a01fcb2670b96154a32e15b2ee258e3012275bdef8,7,yes,yes,,2-200 AMP,current_rating,yes,Elster AB1 (Canada); barcode text confirms both numbers +m17,File:2010 Tatung RXS4e electronic watthour meter.jpg,https://commons.wikimedia.org/wiki/File:2010_Tatung_RXS4e_electronic_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/2/2c/2010_Tatung_RXS4e_electronic_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,8,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,8,yes,yes,,30(100)A,current_rating,yes,Tatung electronic FM16A +m18,File:2010 Tatung E-21 watthour meter.jpg,https://commons.wikimedia.org/wiki/File:2010_Tatung_E-21_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/f/ff/2010_Tatung_E-21_watthour_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Solomon203,yes,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,8,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,8,yes,yes,,10(50)A,current_rating,yes,Tatung E-21 +m19,File:5379Baliuag Bulacan Landmarks during the 2020 coronavirus pandemic 35.jpg,https://commons.wikimedia.org/wiki/File:5379Baliuag_Bulacan_Landmarks_during_the_2020_coronavirus_pandemic_35.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/2/20/5379Baliuag_Bulacan_Landmarks_during_the_2020_coronavirus_pandemic_35.jpg/3840px-5379Baliuag_Bulacan_Landmarks_during_the_2020_coronavirus_pandemic_35.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Judgefloro,yes,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6279a44f15c0aedea0fadbf0eeb7eaa247d98fb19a419b603d883aa9b33e7db3,12,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,12,yes,yes,,CL200,ansi_class,yes,same meter as m05 +m20,"File:9865Poblacion Bustos, Bulacan 41.jpg","https://commons.wikimedia.org/wiki/File:9865Poblacion_Bustos,_Bulacan_41.jpg",https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/9865Poblacion_Bustos%2C_Bulacan_41.jpg/3840px-9865Poblacion_Bustos%2C_Bulacan_41.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Judgefloro,no: several meters in one frame; not a single-meter close-up,,,,,,,,,,,,several meters in one frame; not a single-meter close-up +m21,"File:9865Poblacion Bustos, Bulacan 42.jpg","https://commons.wikimedia.org/wiki/File:9865Poblacion_Bustos,_Bulacan_42.jpg",https://upload.wikimedia.org/wikipedia/commons/thumb/5/58/9865Poblacion_Bustos%2C_Bulacan_42.jpg/3840px-9865Poblacion_Bustos%2C_Bulacan_42.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Judgefloro,yes,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,12,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,12,yes,yes,,CL200,ansi_class,yes,Meralco Seochang SCS1321; neighbour meters at frame edge +m22,File:Aclara I-210+ single phase smart electricity meter.jpg,https://commons.wikimedia.org/wiki/File:Aclara_I-210%2B_single_phase_smart_electricity_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Aclara_I-210%2B_single_phase_smart_electricity_meter.jpg/3840px-Aclara_I-210%2B_single_phase_smart_electricity_meter.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,277volts,yes,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,8,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,8,yes,yes,,CL 200,ansi_class,yes,Aclara I-210+ (US co-op) +m23,File:Aclara kV2c FITZALL 3-phase smart electricity meter.jpg,https://commons.wikimedia.org/wiki/File:Aclara_kV2c_FITZALL_3-phase_smart_electricity_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/b/b7/Aclara_kV2c_FITZALL_3-phase_smart_electricity_meter.jpg/3840px-Aclara_kV2c_FITZALL_3-phase_smart_electricity_meter.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,277volts,yes,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,8,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,8,yes,yes,,CL 200,ansi_class,yes,GE I-210 style (US municipal); sunlit +m24,File:CFE electric power meters 2016.jpg,https://commons.wikimedia.org/wiki/File:CFE_electric_power_meters_2016.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/CFE_electric_power_meters_2016.jpg/3840px-CFE_electric_power_meters_2016.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Cvmontuy,no: three meters in one frame,,,,,,,,,,,,three meters in one frame +m25,File:Close up shot of an US electrical meter 000 0027.JPG,https://commons.wikimedia.org/wiki/File:Close_up_shot_of_an_US_electrical_meter_000_0027.JPG,https://upload.wikimedia.org/wikipedia/commons/d/d7/Close_up_shot_of_an_US_electrical_meter_000_0027.JPG,Public domain,,,yes,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,4,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,4,yes,no,,CL200,ansi_class,yes,Sangamo J4S (US); 6975 is the utility plate; # the maker serial +m26,File:Compteur HQ.JPG,https://commons.wikimedia.org/wiki/File:Compteur_HQ.JPG,https://upload.wikimedia.org/wikipedia/commons/5/55/Compteur_HQ.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Bouchecl,yes,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,11,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,11,yes,yes,,2-200 AMP,current_rating,yes,Sangamo K2S Hydro-Quebec +m27,File:Chunghsin I-70-S watthour meter 20190518.jpg,https://commons.wikimedia.org/wiki/File:Chunghsin_I-70-S_watthour_meter_20190518.jpg,https://upload.wikimedia.org/wikipedia/commons/7/75/Chunghsin_I-70-S_watthour_meter_20190518.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Solomon203,yes,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,8,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,8,yes,yes,,30(200)A,current_rating,yes,Chunghsin I-70-S +m28,File:DWP meter.jpg,https://commons.wikimedia.org/wiki/File:DWP_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/DWP_meter.jpg/3840px-DWP_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Downtowngal,yes,3c6db36924fac335b1902cdd239f61b924145b2173c4072dbfb958ebd19cdb7c,3c6db36924fac335b1902cdd239f61b924145b2173c4072dbfb958ebd19cdb7c,10,4cfd6f0bcc56a91153bbd133f1da353ee0359aebb7f7866c1e1093012c7357ba,8,no,no,,CL200,ansi_class,yes,Itron (Los Angeles DWP); meter is a small part of frame +m29,File:Electric Meter.jpg,https://commons.wikimedia.org/wiki/File:Electric_Meter.jpg,https://upload.wikimedia.org/wikipedia/commons/b/bc/Electric_Meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Angelsharum,yes,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,9,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,9,yes,yes,,CL200,ansi_class,yes,L+G Focus RXR-SD (US co-op); low resolution; barcode text too small +m30,File:Electric meter.jpg,https://commons.wikimedia.org/wiki/File:Electric_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/0/04/Electric_meter.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,NellieBly,yes,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,9,d170646a579d4b3d108665756ee375e7ac587a270ea450090e33858a0b36101a,7,yes,no,,2-200 AMP,current_rating,yes,CGE I-70S (Canada) +m31,File:Electrical meter.jpg,https://commons.wikimedia.org/wiki/File:Electrical_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/d/d4/Electrical_meter.jpg,Public domain,,,yes,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,6,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,6,yes,yes,,CL 200,ansi_class,yes,GE I-60-S PG&E plate +m32,File:Electricity meter.JPG,https://commons.wikimedia.org/wiki/File:Electricity_meter.JPG,https://upload.wikimedia.org/wikipedia/commons/thumb/2/24/Electricity_meter.JPG/3840px-Electricity_meter.JPG,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Editor abcdef,yes,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,8,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,8,yes,no,,200 CL,ansi_class,yes,Elster AB1 (US); camera date stamp in frame +m33,File:Electricity Meter Closeup.jpg,https://commons.wikimedia.org/wiki/File:Electricity_Meter_Closeup.jpg,https://upload.wikimedia.org/wikipedia/commons/a/a6/Electricity_Meter_Closeup.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,LordShadowWing,yes,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,5,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,5,yes,no,,200 CL,ansi_class,yes,GE (US); # on utility brass plate; # maker serial +m34,File:Elster Type R15 electricity meter.jpeg,https://commons.wikimedia.org/wiki/File:Elster_Type_R15_electricity_meter.jpeg,https://upload.wikimedia.org/wikipedia/commons/7/7a/Elster_Type_R15_electricity_meter.jpeg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Zuzu,yes,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,5,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,5,yes,no,,CL200,ansi_class,yes,Elster R1S Borough of Lansdale; which number the utility uses is unclear +m35,File:Elster A3RAL electricity meter.jpg,https://commons.wikimedia.org/wiki/File:Elster_A3RAL_electricity_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Elster_A3RAL_electricity_meter.jpg/3840px-Elster_A3RAL_electricity_meter.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,277volts,yes,,,,,,,,,CL20,ansi_class,yes,Elster A3RAL; number covered by a sticker +m36,File:GE Watthour Meter.JPG,https://commons.wikimedia.org/wiki/File:GE_Watthour_Meter.JPG,https://upload.wikimedia.org/wikipedia/commons/8/85/GE_Watthour_Meter.JPG,CC BY 2.0,https://creativecommons.org/licenses/by/2.0,The original uploader was Empoor at Dutch Wikipedia.,yes,,,,,,,,,CL 200,ansi_class,yes,GE I-70 (Baltimore); oblique; number off frame; CL and 200 on separate plates +m37,File:Hydro quebec meter.JPG,https://commons.wikimedia.org/wiki/File:Hydro_quebec_meter.JPG,https://upload.wikimedia.org/wikipedia/commons/b/b9/Hydro_quebec_meter.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Kristoferb (talk),yes,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,11,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,11,yes,yes,,0.1-10 AMP,current_rating,yes,Sangamo K2A Hydro-Quebec +m38,File:House Meter - Flickr - Brian in Cleveland.jpg,https://commons.wikimedia.org/wiki/File:House_Meter_-_Flickr_-_Brian_in_Cleveland.jpg,https://upload.wikimedia.org/wikipedia/commons/6/60/House_Meter_-_Flickr_-_Brian_in_Cleveland.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0,"Brian Cassidy from Cleveland, OH, USA",yes,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,5,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,5,yes,no,,,,,Westinghouse DS (US); no class printed; # on utility plate +m39,File:Itron OpenWay Electricity Meter with Two-Way Communications.JPG,https://commons.wikimedia.org/wiki/File:Itron_OpenWay_Electricity_Meter_with_Two-Way_Communications.JPG,https://upload.wikimedia.org/wikipedia/commons/1/13/Itron_OpenWay_Electricity_Meter_with_Two-Way_Communications.JPG,CC BY 3.0,https://creativecommons.org/licenses/by/3.0,Dwight Burdette,yes,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,9,51ab77257c80c44360410180240559151fe901343093b48c428a6bee81d66989,7,yes,yes,,CL200,ansi_class,yes,Itron OpenWay DTE (US) +m40,File:Landis+Gyr FocusAXR-SD Hydro-Quebec.JPG,https://commons.wikimedia.org/wiki/File:Landis%2BGyr_FocusAXR-SD_Hydro-Quebec.JPG,https://upload.wikimedia.org/wikipedia/commons/d/d1/Landis%2BGyr_FocusAXR-SD_Hydro-Quebec.JPG,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Bouchecl,yes,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,11,de9a401671f2fca4440f80b3439073f958d9c2aaf98cd8eea2325e08aacb2d79,10,yes,yes,,2-200A,current_rating,yes,L+G Focus AXR-SD Hydro-Quebec +m41,File:Kwh meter (252835649).jpg,https://commons.wikimedia.org/wiki/File:Kwh_meter_(252835649).jpg,https://upload.wikimedia.org/wikipedia/commons/e/eb/Kwh_meter_%28252835649%29.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0,"Robbie Sproule from Montreal, Canada",yes,23873cdb90f35fdc3f88ac969db9376e0c91b8ded21b8caca879f1c8edffb1ef,23873cdb90f35fdc3f88ac969db9376e0c91b8ded21b8caca879f1c8edffb1ef,8,23873cdb90f35fdc3f88ac969db9376e0c91b8ded21b8caca879f1c8edffb1ef,8,no,no,,2-100 AMP,current_rating,no,Sangamo CJ3S Hydro-Quebec; stamped utility plate; letters HS and spacing uncertain +m42,File:PG&E Meter on Angel Island.jpg,https://commons.wikimedia.org/wiki/File:PG%26E_Meter_on_Angel_Island.jpg,https://upload.wikimedia.org/wikipedia/commons/5/54/PG%26E_Meter_on_Angel_Island.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0,Кevin,yes,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,6,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,6,yes,yes,,CL 200,ansi_class,yes,GE/Mexico MX; small in frame; sunlit +m43,File:P 20200715 153903 vHDR On.jpg,https://commons.wikimedia.org/wiki/File:P_20200715_153903_vHDR_On.jpg,https://upload.wikimedia.org/wikipedia/commons/d/d7/P_20200715_153903_vHDR_On.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Jidanni,yes,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,8,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,8,yes,yes,,30(200)A,current_rating,yes,Chunghsin I-70-S; reflections on glass +m44,File:PowerStreamMeter.jpg,https://commons.wikimedia.org/wiki/File:PowerStreamMeter.jpg,https://upload.wikimedia.org/wikipedia/commons/6/6b/PowerStreamMeter.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Raysonho @ Open Grid Scheduler / Grid Engine,yes,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,10,09ecaf29ca7ce1019a6d9340ed5e6f270365ad48f7ef8c931522a48ea0cffa29,6,yes,yes,,1-200A,current_rating,no,Sensus iCon PowerStream (Canada) +m45,File:PowerStreamMeter4.jpg,https://commons.wikimedia.org/wiki/File:PowerStreamMeter4.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/PowerStreamMeter4.jpg/3840px-PowerStreamMeter4.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Raysonho @ Open Grid Scheduler / Grid Engine,yes,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,10,6ff10162993a701ea95ebb0d07166aac475f044a16def6680f1a8091d0507a75,6,yes,yes,,1-200A,current_rating,no,Sensus iCon PowerStream (Canada) +m46,File:Smart Meter (10945291434).jpg,https://commons.wikimedia.org/wiki/File:Smart_Meter_(10945291434).jpg,https://upload.wikimedia.org/wikipedia/commons/9/9a/Smart_Meter_%2810945291434%29.jpg,Public domain,,National Institute of Standards and Technology,yes,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,8,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,8,yes,yes,,CL 200,ansi_class,yes,Aclara I-210+cn (US) +m47,File:Tatung E-31 watthour meter 20160702.jpg,https://commons.wikimedia.org/wiki/File:Tatung_E-31_watthour_meter_20160702.jpg,https://upload.wikimedia.org/wikipedia/commons/d/df/Tatung_E-31_watthour_meter_20160702.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Solomon203,yes,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,8,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,8,yes,yes,,10(60)A,current_rating,yes,Tatung E-31 +m48,File:Tatung M-21T 20190216.jpg,https://commons.wikimedia.org/wiki/File:Tatung_M-21T_20190216.jpg,https://upload.wikimedia.org/wikipedia/commons/a/aa/Tatung_M-21T_20190216.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Solomon203,yes,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,8,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,8,yes,yes,,10(30)A,current_rating,yes,Tatung M-21T +m49,File:Tatung E-31 watthour meter 20190324.jpg,https://commons.wikimedia.org/wiki/File:Tatung_E-31_watthour_meter_20190324.jpg,https://upload.wikimedia.org/wikipedia/commons/0/01/Tatung_E-31_watthour_meter_20190324.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Solomon203,yes,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,8,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,8,yes,yes,,10(60)A,current_rating,yes,Tatung E-31 +m50,File:Tatung TT-001 20200409.jpg,https://commons.wikimedia.org/wiki/File:Tatung_TT-001_20200409.jpg,https://upload.wikimedia.org/wikipedia/commons/f/fb/Tatung_TT-001_20200409.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Solomon203,yes,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,8,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,8,yes,yes,,30(120)A,current_rating,yes,Tatung TT-001 smart; utility number on lower sticker +m51,File:Tatung TT-001 20210314.jpg,https://commons.wikimedia.org/wiki/File:Tatung_TT-001_20210314.jpg,https://upload.wikimedia.org/wikipedia/commons/2/24/Tatung_TT-001_20210314.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Solomon203,yes,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,10,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,10,yes,yes,,30(120)A,current_rating,yes,Tatung TT-001 smart +m52,File:Tatung TT-001 20210605.jpg,https://commons.wikimedia.org/wiki/File:Tatung_TT-001_20210605.jpg,https://upload.wikimedia.org/wikipedia/commons/7/7f/Tatung_TT-001_20210605.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Solomon203,yes,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,10,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,10,yes,yes,,30(120)A,current_rating,yes,Tatung TT-001 smart +m53,File:Tatung D4S 2S watthour meter at Jiufen Old Street 20091229.jpg,https://commons.wikimedia.org/wiki/File:Tatung_D4S_2S_watthour_meter_at_Jiufen_Old_Street_20091229.jpg,https://upload.wikimedia.org/wikipedia/commons/9/92/Tatung_D4S_2S_watthour_meter_at_Jiufen_Old_Street_20091229.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0,takuma mori,yes,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,8,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,8,yes,yes,,30(200)A,current_rating,yes,Tatung D4S; second meter at edge; handwritten number on box +m54,File:Tatung RXS4e 20230527.jpg,https://commons.wikimedia.org/wiki/File:Tatung_RXS4e_20230527.jpg,https://upload.wikimedia.org/wikipedia/commons/4/45/Tatung_RXS4e_20230527.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Solomon203,yes,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,8,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,8,yes,yes,,30(100)A,current_rating,yes,Tatung electronic FM16A +m55,File:Tatung D4S 2S at Treasure Hill 20141025.jpg,https://commons.wikimedia.org/wiki/File:Tatung_D4S_2S_at_Treasure_Hill_20141025.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f7/Tatung_D4S_2S_at_Treasure_Hill_20141025.jpg/3840px-Tatung_D4S_2S_at_Treasure_Hill_20141025.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0,加蛋�加價,yes,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,8,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,8,yes,yes,,30(200)A,current_rating,yes,Tatung D4S; faded; glare +m56,File:Wattmeter USA.JPG,https://commons.wikimedia.org/wiki/File:Wattmeter_USA.JPG,https://upload.wikimedia.org/wikipedia/commons/e/ed/Wattmeter_USA.JPG,Public domain,,user:Stilfehler,yes,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,8,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,8,yes,yes,,CL200,ansi_class,yes,GE I-70-S (US); last 0 of CL200 in glare +m57,File:Yipwm 1b (7691592010).jpg,https://commons.wikimedia.org/wiki/File:Yipwm_1b_(7691592010).jpg,https://upload.wikimedia.org/wikipedia/commons/6/69/Yipwm_1b_%287691592010%29.jpg,CC BY-SA 2.0,https://creativecommons.org/licenses/by-sa/2.0,"Greg Goebel from Loveland CO, USA",yes,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,5,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,5,yes,no,,200 CL,ansi_class,yes,Westinghouse D5S City of Loveland (US); low resolution +m58,File:中興 kV2c 20150905.jpg,https://commons.wikimedia.org/wiki/File:%E4%B8%AD%E8%88%88_kV2c_20150905.jpg,https://upload.wikimedia.org/wikipedia/commons/9/92/%E4%B8%AD%E8%88%88_kV2c_20150905.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Honmingjun,yes,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,8,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,8,yes,yes,,2.5A(10A),current_rating,no,Taiwan FM10A; strong glare on LCD and lower plate +m59,File:Argentinian electricity meter.jpg,https://commons.wikimedia.org/wiki/File:Argentinian_electricity_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/8/8a/Argentinian_electricity_meter.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Yorenuncio,yes,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,6,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,6,yes,yes,,5(40)A,current_rating,yes,Argentine E80L2/1; faint print +m60,File:Contador-kilovatio-hora.jpg,https://commons.wikimedia.org/wiki/File:Contador-kilovatio-hora.jpg,https://upload.wikimedia.org/wikipedia/commons/9/9d/Contador-kilovatio-hora.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Carlos P.,yes,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,8,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,8,yes,yes,,"0,5-10(60)A",current_rating,yes,L+G Iberdrola (Spain); letters in barcode text doubtful +m61,File:Drehstromzähler l&g hochkant.jpg,https://commons.wikimedia.org/wiki/File:Drehstromz%C3%A4hler_l%26g_hochkant.jpg,https://upload.wikimedia.org/wikipedia/commons/1/1d/Drehstromz%C3%A4hler_l%26g_hochkant.jpg,CC BY-SA 3.0,http://creativecommons.org/licenses/by-sa/3.0/,Harry20 at German Wikipedia,yes,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,8,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,8,yes,yes,,10(60) A,current_rating,yes,Landis & Gyr MM2600J (Germany); utility plate under glare +m62,File:Drehstromzaehler-Obernjesa.jpg,https://commons.wikimedia.org/wiki/File:Drehstromzaehler-Obernjesa.jpg,https://upload.wikimedia.org/wikipedia/commons/1/13/Drehstromzaehler-Obernjesa.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Werner Harbig,yes,c525a7288d73a93e4893fe942c83199e7e1b3f75a8dec9fbdaeaa80e09368da7,c525a7288d73a93e4893fe942c83199e7e1b3f75a8dec9fbdaeaa80e09368da7,7,c525a7288d73a93e4893fe942c83199e7e1b3f75a8dec9fbdaeaa80e09368da7,7,yes,yes,,10(40),current_rating,no,DZG DV6N4 (Germany); rating split into boxes 10(40) and A +m63,File:Drehstromzähler elektronisch.JPG,https://commons.wikimedia.org/wiki/File:Drehstromz%C3%A4hler_elektronisch.JPG,https://upload.wikimedia.org/wikipedia/commons/a/ae/Drehstromz%C3%A4hler_elektronisch.JPG,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Bicycle Tourer,yes,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,14,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,14,yes,no,,"0,25-5(60) A",current_rating,yes,EMH ED300S (Germany) +m64,File:Drehstromzähler Siemens.JPG,https://commons.wikimedia.org/wiki/File:Drehstromz%C3%A4hler_Siemens.JPG,https://upload.wikimedia.org/wikipedia/commons/9/90/Drehstromz%C3%A4hler_Siemens.JPG,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Bicycle Tourer,yes,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,6,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,6,yes,yes,,10(60)A,current_rating,yes,Siemens 7CA44 HEAG (Germany); utility E.-Nr.; maker Nr. faint +m65,File:Dornbirn-electric sub-distributor photovoltaic 2-meter-02ASD.jpg,https://commons.wikimedia.org/wiki/File:Dornbirn-electric_sub-distributor_photovoltaic_2-meter-02ASD.jpg,https://upload.wikimedia.org/wikipedia/commons/5/56/Dornbirn-electric_sub-distributor_photovoltaic_2-meter-02ASD.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Asurnipal,yes,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,6,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,6,yes,yes,,"0,25-5(60) A",current_rating,yes,Iskra MT174 Vorarlberg Netz (Austria) +m66,File:E350 slimme elektriciteitsmeter.jpg,https://commons.wikimedia.org/wiki/File:E350_slimme_elektriciteitsmeter.jpg,https://upload.wikimedia.org/wikipedia/commons/c/c7/E350_slimme_elektriciteitsmeter.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Wimton,yes,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,17,8c91b29ac0798f00455df9d855e1bcdaa93338bc6f7f5171937db2e8bb1dfe0a,16,yes,no,,"0,25-5(80) A",current_rating,no,L+G E350 (Netherlands); rating print small +m67,File:Electricity meter type L7C2.jpg,https://commons.wikimedia.org/wiki/File:Electricity_meter_type_L7C2.jpg,https://upload.wikimedia.org/wikipedia/commons/8/83/Electricity_meter_type_L7C2.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Echtner,yes,f4686545d0f1832cf9b7519be9559eb6eafb0dd5394b04eda5e6c5ce8202f594,f4686545d0f1832cf9b7519be9559eb6eafb0dd5394b04eda5e6c5ce8202f594,6,f4686545d0f1832cf9b7519be9559eb6eafb0dd5394b04eda5e6c5ce8202f594,6,no,no,,10-30,current_rating,no,French L7C2; low resolution; second digit 0 vs 8 doubtful +m68,File:Landis & Gyr 2 rate electromechanical meter. Type YL11d.JPG,https://commons.wikimedia.org/wiki/File:Landis_%26_Gyr_2_rate_electromechanical_meter._Type_YL11d.JPG,https://upload.wikimedia.org/wikipedia/commons/d/d2/Landis_%26_Gyr_2_rate_electromechanical_meter._Type_YL11d.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,RODALCO,yes,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,8,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,8,yes,yes,,3x25,current_rating,no,Landis & Gyr YL11d (UK) +m69,File:Landis+Gyr Zähler.jpg,https://commons.wikimedia.org/wiki/File:Landis%2BGyr_Z%C3%A4hler.jpg,https://upload.wikimedia.org/wikipedia/commons/4/49/Landis%2BGyr_Z%C3%A4hler.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Paebi,yes,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,9,e894f2e689ed388846dc032d9601b711160995eed809dbc036550734db7b020f,6,yes,yes,,10(40),current_rating,no,Landis & Gyr HK4d EWZ (Switzerland); maker No. faint +m70,File:Landis&Gyr Zähler 1992.jpg,https://commons.wikimedia.org/wiki/File:Landis%26Gyr_Z%C3%A4hler_1992.jpg,https://upload.wikimedia.org/wikipedia/commons/3/3c/Landis%26Gyr_Z%C3%A4hler_1992.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Paebi,yes,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,9,55034d864bec1f2f62b49121db7e244706f9b23c22ff2026b3a98d32e600aa4c,6,yes,yes,,10(40) A,current_rating,yes,Landis & Gyr MK4d EWZ (Switzerland) +m71,File:Landis&Gyr Zähler 1966.jpg,https://commons.wikimedia.org/wiki/File:Landis%26Gyr_Z%C3%A4hler_1966.jpg,https://upload.wikimedia.org/wikipedia/commons/9/9b/Landis%26Gyr_Z%C3%A4hler_1966.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Paebi,yes,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,6,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,6,yes,no,,10(40),current_rating,no,EWZ plate (Switzerland); prefix letters cut by crop +m72,File:Meteran Listrik PLN Analog.jpg,https://commons.wikimedia.org/wiki/File:Meteran_Listrik_PLN_Analog.jpg,https://upload.wikimedia.org/wikipedia/commons/c/c9/Meteran_Listrik_PLN_Analog.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,SATELITBM,no: label shows a customer's name and address; excluded for privacy,,,,,,,,,,,,label shows a customer's name and address; excluded for privacy +m73,File:Logarex smart household electricity meter build year 2023.jpg,https://commons.wikimedia.org/wiki/File:Logarex_smart_household_electricity_meter_build_year_2023.jpg,https://upload.wikimedia.org/wikipedia/commons/1/1d/Logarex_smart_household_electricity_meter_build_year_2023.jpg,CC BY 4.0,https://creativecommons.org/licenses/by/4.0,RobbieIanMorrison,yes,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,d55612c12a0aaddf7f92055b7f5ab6025189bffe72a6b10fdc0509a1c07127fb,14,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,14,yes,no,,"0,25-5(60)A",current_rating,yes,Logarex (Germany) +m74,File:Static watthour meter.jpg,https://commons.wikimedia.org/wiki/File:Static_watthour_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/2/24/Static_watthour_meter.jpg,Public domain,,Neon at Malayalam Wikipedia,yes,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,6,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,6,yes,yes,,5-20 A,current_rating,yes,Maxwell RS-14 (India) +m75,File:Static Energy Meter GBT17215-2002.jpg,https://commons.wikimedia.org/wiki/File:Static_Energy_Meter_GBT17215-2002.jpg,https://upload.wikimedia.org/wikipedia/commons/f/f4/Static_Energy_Meter_GBT17215-2002.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Sokoteshab,yes,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,10,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,10,yes,yes,,10(40)A,current_rating,yes,Oxford DDS8111; dot-matrix number +m76,File:Smart card prepaid electric meter.jpg,https://commons.wikimedia.org/wiki/File:Smart_card_prepaid_electric_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/e/e6/Smart_card_prepaid_electric_meter.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,MD MIM YT,yes,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,6,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,6,yes,yes,,10(60)A,current_rating,yes,FGF DEM-03 (Bangladesh) +m77,File:Siemens household electricity meter electromechanical induction type.jpg,https://commons.wikimedia.org/wiki/File:Siemens_household_electricity_meter_electromechanical_induction_type.jpg,https://upload.wikimedia.org/wikipedia/commons/8/87/Siemens_household_electricity_meter_electromechanical_induction_type.jpg,CC BY 4.0,https://creativecommons.org/licenses/by/4.0,RobbieIanMorrison,yes,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,7,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,7,yes,yes,,10(60) A,current_rating,yes,Siemens 7AA50 BEWAG (Germany) +m78,File:Stromzaehler-01 hg.jpg,https://commons.wikimedia.org/wiki/File:Stromzaehler-01_hg.jpg,https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/Stromzaehler-01_hg.jpg/3840px-Stromzaehler-01_hg.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Hannes Grobe,yes,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,8,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,8,yes,yes,,10 (30) A,current_rating,yes,AEG J14H (Germany) +m79,File:The South Eastern Electricity Board electricity meter.jpg,https://commons.wikimedia.org/wiki/File:The_South_Eastern_Electricity_Board_electricity_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/c/c2/The_South_Eastern_Electricity_Board_electricity_meter.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Andy Li,yes,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,b249ebea15d96a6ae9419c1a235f6ba15458dc9edbc57dfc775b6692b125d5a2,8,9f05bbb396c03db3c78aa1fcac47502b8e7c442a7ed8bdb530f0bec490879ce8,7,yes,yes,,20-80 A,current_rating,yes,SEEBOARD (UK); CL 147dh is a type code +m80,File:Toshiba Toko Meter Systems SmaMeII-TypeH smart meter.jpg,https://commons.wikimedia.org/wiki/File:Toshiba_Toko_Meter_Systems_SmaMeII-TypeH_smart_meter.jpg,https://upload.wikimedia.org/wikipedia/commons/6/6d/Toshiba_Toko_Meter_Systems_SmaMeII-TypeH_smart_meter.jpg,CC BY 4.0,https://creativecommons.org/licenses/by/4.0,Darklanlan,yes,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,10,6fa1504bf26d55123d53d42f2013fb0162a9f6690da5d8de47d6ae2a6ccc39b7,9,yes,yes,,60A,current_rating,yes,Toshiba S43S-TA (Japan); small image +m81,File:Vorarlberger Kraftwerke-Energy meter (electro)-05ASD.jpg,https://commons.wikimedia.org/wiki/File:Vorarlberger_Kraftwerke-Energy_meter_(electro)-05ASD.jpg,https://upload.wikimedia.org/wikipedia/commons/4/47/Vorarlberger_Kraftwerke-Energy_meter_%28electro%29-05ASD.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Asurnipal,yes,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,9,ea8fb0dbd56e2f8f3dccf31136f9ad3cdf799435d8acef744a0626efad5460ce,6,yes,yes,,10 (60) A,current_rating,yes,Iskra E72FD-02 VKW (Austria) +m82,File:Vorarlberger Kraftwerke-Energy meter (electro)-07ASD.jpg,https://commons.wikimedia.org/wiki/File:Vorarlberger_Kraftwerke-Energy_meter_(electro)-07ASD.jpg,https://upload.wikimedia.org/wikipedia/commons/e/e3/Vorarlberger_Kraftwerke-Energy_meter_%28electro%29-07ASD.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Asurnipal,yes,79b3c2a281b01defc899b20d5490c5da1ea2d7ac0bc9e3e13ea7b6f8c30c3f46,79b3c2a281b01defc899b20d5490c5da1ea2d7ac0bc9e3e13ea7b6f8c30c3f46,9,929229e37b49aef3110a1b075079be93bf8b3131cbbc870dc540af1a5f1ef890,6,yes,yes,,10(40) A,current_rating,yes,Landis & Gyr CL146df3 VKW (Austria); CL146df3 is a type code +m83,File:Vorarlberger Kraftwerke-Energy meter (electro)-09ASD crop.jpg,https://commons.wikimedia.org/wiki/File:Vorarlberger_Kraftwerke-Energy_meter_(electro)-09ASD_crop.jpg,https://upload.wikimedia.org/wikipedia/commons/b/b7/Vorarlberger_Kraftwerke-Energy_meter_%28electro%29-09ASD_crop.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Asurnipal,yes,39c7ed6d8a112e84b88172103937ecd7365197a994aa80dd1074835612166b12,39c7ed6d8a112e84b88172103937ecd7365197a994aa80dd1074835612166b12,9,6d6bd0afe171c50ac7532272f69aa000362efe2ebf506baf9f403efa429b3900,6,no,no,,10(60)A,current_rating,yes,Siemens 7CJ50 VKW (Austria); utility plate very faint; all six digits doubtful diff --git a/experiments/meter-closeup/meterocr/Package.swift b/experiments/meter-closeup/meterocr/Package.swift new file mode 100644 index 000000000..c4445b63c --- /dev/null +++ b/experiments/meter-closeup/meterocr/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "meterocr", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget(name: "meterocr") + ] +) diff --git a/experiments/meter-closeup/meterocr/Sources/meterocr/main.swift b/experiments/meter-closeup/meterocr/Sources/meterocr/main.swift new file mode 100644 index 000000000..06c56c635 --- /dev/null +++ b/experiments/meter-closeup/meterocr/Sources/meterocr/main.swift @@ -0,0 +1,172 @@ +// Reads text from images with Apple Vision (VNRecognizeTextRequest), the engine the iPhone app +// would call. One JSON request per stdin line, one JSON result per stdout line, so a Python +// driver can stream thousands of degraded images through a single process. +// +// Request: {"path": "...", "level": "accurate"|"fast", "language_correction": false, +// "crop": [x, y, w, h], // optional, normalized, top-left origin +// "barcodes": false} // true also runs VNDetectBarcodesRequest (revision 4) +// Result: {"path", "width", "height", "elapsed_ms", "lines": [{"text", "confidence", +// "box": [x, y, w, h], "candidates": [...]}], // box normalized, top-left origin +// "barcodes": [{"payload", "symbology", "confidence", "box"}]} // when requested +// +// Usage: meterocr < requests.jsonl or meterocr [--fast] [--lc] image... + +import CoreGraphics +import Foundation +import ImageIO +import Vision + +struct Request: Decodable { + let path: String + var level: String? = "accurate" + var language_correction: Bool? = false + var crop: [Double]? = nil + var barcodes: Bool? = false +} + +struct Line: Encodable { + let text: String + let confidence: Float + let box: [Double] + let candidates: [String] +} + +struct Barcode: Encodable { + let payload: String? + let symbology: String + let confidence: Float + let box: [Double] +} + +struct Result: Encodable { + let path: String + let level: String + let language_correction: Bool + let crop: [Double]? + let width: Int + let height: Int + let elapsed_ms: Double + let lines: [Line] + let barcodes: [Barcode]? + let error: String? +} + +enum ReadError: Error, CustomStringConvertible { + case unreadableImage(String) + case badCrop([Double]) + + var description: String { + switch self { + case .unreadableImage(let path): "cannot decode image at \(path)" + case .badCrop(let crop): "crop must be 4 normalized values inside [0, 1], got \(crop)" + } + } +} + +func loadImage(_ path: String) throws -> CGImage { + let url = URL(fileURLWithPath: path) as CFURL + guard let source = CGImageSourceCreateWithURL(url, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { throw ReadError.unreadableImage(path) } + return image +} + +func read(_ request: Request) -> Result { + let level = request.level ?? "accurate" + let correction = request.language_correction ?? false + let started = Date() + var width = 0 + var height = 0 + do { + var image = try loadImage(request.path) + if let crop = request.crop { + guard crop.count == 4, crop.allSatisfy({ (0...1).contains($0) }), + crop[0] + crop[2] <= 1.0001, crop[1] + crop[3] <= 1.0001 + else { throw ReadError.badCrop(crop) } + let rect = CGRect( + x: crop[0] * Double(image.width), y: crop[1] * Double(image.height), + width: crop[2] * Double(image.width), height: crop[3] * Double(image.height) + ).integral + guard let cropped = image.cropping(to: rect) else { throw ReadError.badCrop(crop) } + image = cropped + } + width = image.width + height = image.height + + let vision = VNRecognizeTextRequest() + vision.revision = VNRecognizeTextRequestRevision3 + vision.recognitionLevel = level == "fast" ? .fast : .accurate + vision.usesLanguageCorrection = correction + vision.recognitionLanguages = ["en-US"] + // Default is already 0.0 (full resolution, per the SDK header); set it so a future + // default change cannot silently drop small labels. + vision.minimumTextHeight = 0 + let barcodeRequest = VNDetectBarcodesRequest() + barcodeRequest.revision = VNDetectBarcodesRequestRevision4 + let wantsBarcodes = request.barcodes ?? false + try VNImageRequestHandler(cgImage: image, orientation: .up) + .perform(wantsBarcodes ? [vision, barcodeRequest] : [vision]) + let barcodes = wantsBarcodes + ? (barcodeRequest.results ?? []).map { observation in + let b = observation.boundingBox + return Barcode( + payload: observation.payloadStringValue, + symbology: observation.symbology.rawValue, + confidence: observation.confidence, + box: [b.minX, 1 - b.maxY, b.width, b.height]) + } + : nil + + let lines = (vision.results ?? []).compactMap { observation -> Line? in + let top = observation.topCandidates(3) + guard let best = top.first else { return nil } + let b = observation.boundingBox // normalized, bottom-left origin + return Line( + text: best.string, + confidence: best.confidence, + box: [b.minX, 1 - b.maxY, b.width, b.height], + candidates: top.map(\.string) + ) + } + return Result( + path: request.path, level: level, language_correction: correction, crop: request.crop, + width: width, height: height, elapsed_ms: Date().timeIntervalSince(started) * 1000, + lines: lines, barcodes: barcodes, error: nil) + } catch { + return Result( + path: request.path, level: level, language_correction: correction, crop: request.crop, + width: width, height: height, elapsed_ms: Date().timeIntervalSince(started) * 1000, + lines: [], barcodes: nil, error: String(describing: error)) + } +} + +let encoder = JSONEncoder() +encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + +func emit(_ result: Result) { + let data = try! encoder.encode(result) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) +} + +var arguments = Array(CommandLine.arguments.dropFirst()) +if arguments.isEmpty { + let decoder = JSONDecoder() + while let line = readLine() { + guard !line.isEmpty else { continue } + do { + let request = try decoder.decode(Request.self, from: Data(line.utf8)) + autoreleasepool { emit(read(request)) } + } catch { + FileHandle.standardError.write(Data("bad request line: \(line)\n".utf8)) + exit(2) + } + } +} else { + let fast = arguments.contains("--fast") + let correction = arguments.contains("--lc") + arguments.removeAll { $0.hasPrefix("--") } + for path in arguments { + emit(read(Request(path: path, level: fast ? "fast" : "accurate", language_correction: correction))) + } +} diff --git a/experiments/meter-closeup/pyproject.toml b/experiments/meter-closeup/pyproject.toml new file mode 100644 index 000000000..f36c2ecd8 --- /dev/null +++ b/experiments/meter-closeup/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "meter-closeup" +version = "0.1.0" +description = "How often on-device text recognition reads meter numbers and class labels, and which photo checks predict a failed read" +requires-python = ">=3.12" +dependencies = [ + "numpy>=2.1", + "pillow>=11.0", + "scipy>=1.14", +] + +[dependency-groups] +dev = ["pytest>=8.3", "ruff>=0.8"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/meter_eval"] + +[tool.uv] +required-version = ">=0.11.15,<0.12" + +[tool.ruff] +line-length = 100 + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/experiments/meter-closeup/results/clean.md b/experiments/meter-closeup/results/clean.md new file mode 100644 index 000000000..469357e4f --- /dev/null +++ b/experiments/meter-closeup/results/clean.md @@ -0,0 +1,10 @@ +| What was read | Photos | `accurate` | `accurate_lc` | `fast` | +|---|---|---|---|---| +| Meter number, exact | both readers agree (73) | 71/73 = 97% (91%–99%) | 71/73 = 97% (91%–99%) | 55/73 = 75% (64%–84%) | +| Meter number, O→0 and I/L→1 allowed | both readers agree (73) | 71/73 = 97% (91%–99%) | 71/73 = 97% (91%–99%) | 57/73 = 78% (67%–86%) | +| Meter number, exact | readers' main numbers identical (62) | 60/62 = 97% (89%–99%) | 60/62 = 97% (89%–99%) | 47/62 = 76% (64%–85%) | +| Meter number, exact | all labelled (78) | 75/78 = 96% (89%–99%) | 74/78 = 95% (88%–98%) | 57/78 = 73% (62%–82%) | +| US class label (CL200, 200 CL, CL20) | all labelled (22) | 20/22 = 91% (72%–97%) | 20/22 = 91% (72%–97%) | 14/22 = 64% (43%–80%) | +| Current rating (e.g. 10(60)A) | all labelled (57) | 57/57 = 100% (94%–100%) | 57/57 = 100% (94%–100%) | 39/57 = 68% (56%–79%) | + +On 21 of 75 photos whose number was read, the tallest line with 4 or more digits was the meter number. diff --git a/experiments/meter-closeup/results/clean_per_image.csv b/experiments/meter-closeup/results/clean_per_image.csv new file mode 100644 index 000000000..a02d35b7a --- /dev/null +++ b/experiments/meter-closeup/results/clean_per_image.csv @@ -0,0 +1,81 @@ +id,class_accurate,class_accurate_lc,class_fast,class_kind,contrast,edge_margin,global_lap_var,global_saturated,height,label_contrast,label_lap_var,label_lap_var_32,label_saturated,label_text_height_px,lap_var,lap_var_32,number_accurate,number_accurate_lc,number_agreed,number_agreed_strict,number_box,number_fast,number_lenient_accurate,number_lenient_accurate_lc,number_lenient_fast,saturated,tallest_is_number,text_height_px,width +m01,1,1,1,current_rating,0.0279,6.8571,103.1906,0.0001,1536,0.0279,22.8126,44.2042,0.0,62.5152,22.8126,44.2042,1,1,yes,yes,"[0.4157, 0.68023, 0.15552, 0.0407]",0,1,1,0,0.0,0,62.5116,2048 +m02,1,1,0,current_rating,0.1683,10.9482,237.1029,0.0154,1200,0.1683,557.8671,1151.9859,0.0,49.128,557.8671,1151.9859,1,1,yes,yes,"[0.43438, 0.51084, 0.19951, 0.04094]",1,1,1,1,0.0,0,49.1278,1600 +m03,1,1,1,current_rating,0.1188,5.95,299.7425,0.0003,1536,0.1188,400.3871,1218.7012,0.0,59.9962,400.3871,1218.7012,1,1,yes,yes,"[0.41715, 0.72852, 0.1875, 0.03906]",1,1,1,1,0.0,0,60.0,2048 +m04,1,1,0,current_rating,0.1154,9.1463,286.2157,0.0071,1920,0.1154,195.4383,488.8027,0.0,53.3952,195.4383,488.8027,1,1,yes,yes,"[0.33915, 0.34302, 0.26938, 0.02781]",0,1,1,0,0.0,1,53.3953,1440 +m05,1,1,1,ansi_class,0.2008,7.8193,215.2044,0.0004,2880,0.2008,3913.6822,3722.1975,0.0102,147.6,3913.6822,3722.1975,1,1,yes,yes,"[0.4296, 0.548, 0.13521, 0.05125]",1,1,1,1,0.0102,0,147.6048,3840 +m06,1,1,1,ansi_class,0.1843,22.2,124.0765,0.0,2880,0.1843,3686.541,3204.1361,0.0003,55.8144,3686.541,3204.1361,1,1,yes,yes,"[0.40262, 0.43023, 0.1032, 0.01938]",1,1,1,1,0.0003,0,55.814,3840 +m07,1,1,0,current_rating,0.1624,7.7166,221.4679,0.0022,1536,0.1954,429.1108,589.6823,0.0,41.6717,855.7554,1366.0441,1,1,yes,yes,"[0.42006, 0.67248, 0.14244, 0.02713]",1,1,1,1,0.0,0,47.6407,2048 +m08,1,1,1,current_rating,0.0777,9.25,156.6411,0.0009,1536,0.0777,211.9883,363.9311,0.0,47.6314,211.9883,363.9311,1,1,yes,yes,"[0.37355, 0.68217, 0.17587, 0.03101]",1,1,1,1,0.0,0,47.6279,2048 +m09,1,1,1,current_rating,0.2185,5.9158,410.6551,0.0,1536,0.2185,437.8597,1213.5256,0.0,57.2928,437.8597,1213.5256,1,1,yes,yes,"[0.40695, 0.74201, 0.16576, 0.0373]",1,1,1,1,0.0,0,57.3003,2048 +m10,1,1,1,current_rating,0.2267,11.2052,202.921,0.0136,1920,0.2267,616.4947,1451.499,0.003,53.5488,616.4947,1451.499,1,1,yes,yes,"[0.41662, 0.58998, 0.15706, 0.02789]",1,1,1,1,0.003,1,53.541,1440 +m11,1,1,0,ansi_class,0.1055,4.75,149.6562,0.0041,2048,0.0713,72.932,145.4951,0.0,49.6845,63.9587,182.2495,1,1,yes,yes,"[0.4204, 0.35415, 0.1437, 0.02426]",1,1,1,1,0.0,0,102.4,1536 +m12,1,1,1,current_rating,0.1738,14.3077,310.2364,0.0359,1536,0.1738,1152.1712,1153.2619,0.0504,38.6918,1152.1712,1153.2619,1,1,no,no,"[0.47529, 0.36047, 0.11483, 0.02519]",1,1,1,1,0.0504,0,38.6977,2048 +m13,1,1,1,current_rating,0.2044,7.2184,182.4655,0.0001,1536,0.2044,228.1067,638.2558,0.0,60.1651,228.1067,638.2558,1,1,yes,yes,"[0.43892, 0.67809, 0.15267, 0.03917]",1,1,1,1,0.0,1,60.1644,2048 +m14,1,1,0,current_rating,0.2734,3.7762,228.1518,0.0046,1536,0.2187,637.732,3723.0144,0.0271,160.5274,994.7418,2486.7059,1,1,yes,yes,"[0.49857, 0.56943, 0.12392, 0.10451]",0,1,1,0,0.0007,1,92.9437,2048 +m15,1,1,1,ansi_class,0.1631,16.0,256.6838,0.0001,1536,0.1631,836.206,902.8001,0.0,38.6918,836.206,902.8001,1,1,yes,yes,"[0.44913, 0.4031, 0.11192, 0.02519]",0,1,1,0,0.0,0,38.6977,2048 +m16,1,1,0,current_rating,0.1679,8.7895,113.0552,0.0079,1920,0.1371,141.8334,371.1699,0.0,61.8624,125.1008,497.1518,1,1,yes,yes,"[0.43565, 0.70892, 0.17962, 0.03222]",1,1,1,1,0.0,0,70.6977,2560 +m17,1,1,1,current_rating,0.1631,15.6667,82.3792,0.0598,1920,0.1631,160.0453,250.3322,0.0,50.2272,160.0453,250.3322,1,1,yes,yes,"[0.40988, 0.44622, 0.17297, 0.02616]",1,1,1,1,0.0,0,50.2326,1920 +m18,1,1,0,current_rating,0.1989,9.5419,562.2818,0.0016,1800,0.1989,1141.6192,1325.0341,0.0,63.828,1141.6192,1325.0341,1,1,yes,yes,"[0.42146, 0.62616, 0.19342, 0.03546]",0,1,1,0,0.0,0,63.8312,1800 +m19,1,1,1,ansi_class,0.225,8.9506,289.084,0.0017,2880,0.225,2934.6606,4404.3514,0.0186,124.3296,2934.6606,4404.3514,1,1,yes,yes,"[0.40712, 0.57046, 0.15391, 0.04317]",1,1,1,1,0.0186,0,124.32,3840 +m21,1,1,1,ansi_class,0.1836,15.4545,251.286,0.0005,2880,0.1836,1780.3545,2135.2141,0.0001,61.4016,1780.3545,2135.2141,1,1,yes,yes,"[0.5, 0.64922, 0.14244, 0.02132]",1,1,1,1,0.0001,0,61.3953,3840 +m22,1,1,1,ansi_class,0.2206,15.1758,118.2906,0.0068,2884,0.2206,114.5526,584.0294,0.0115,78.2718,114.5526,584.0294,1,1,yes,yes,"[0.50436, 0.56093, 0.11483, 0.02714]",1,1,1,1,0.0115,0,78.2824,3840 +m23,1,1,0,ansi_class,0.1199,3.6276,194.6655,0.0089,2884,0.1142,28.7739,105.5562,0.0,75.6762,54.463,439.7465,1,1,yes,yes,"[0.44139, 0.36115, 0.08536, 0.02624]",1,1,1,1,0.0,0,139.4713,3840 +m25,1,1,1,ansi_class,0.1497,2.8077,286.454,0.0001,1932,0.1497,632.3701,1327.6412,0.0,166.152,632.3701,1327.6412,1,1,yes,no,"[0.35676, 0.67253, 0.17312, 0.086]",1,1,1,1,0.0,1,166.1545,2580 +m26,1,1,1,current_rating,0.1127,6.9656,158.0149,0.0057,2398,0.1127,50.0639,444.1321,0.0,100.2604,50.0639,444.1321,1,1,yes,yes,"[0.31674, 0.63333, 0.33032, 0.04181]",1,1,1,1,0.0,1,100.2667,2205 +m27,1,1,1,current_rating,0.22,6.4087,631.1447,0.0001,2160,0.22,849.2782,2276.5349,0.0,76.3344,849.2782,2276.5349,1,1,yes,yes,"[0.42148, 0.73814, 0.15122, 0.03534]",1,1,1,1,0.0,1,76.3445,2880 +m28,1,1,0,ansi_class,0.1246,5.4332,262.402,0.0676,3079,0.1085,99.8804,117.6006,0.0,72.5105,153.2637,269.9702,1,1,no,no,"[0.42297, 0.52717, 0.07849, 0.02355]",0,1,1,0,0.0,0,122.9144,3840 +m29,1,1,1,ansi_class,0.172,4.5002,268.3764,0.0001,881,0.1617,3357.5567,1557.8617,0.0,30.694,2625.9434,1984.5028,1,1,yes,yes,"[0.28915, 0.6862, 0.18382, 0.03484]",0,1,1,0,0.0,0,46.7943,1000 +m30,1,1,1,current_rating,0.21,12.82,207.3558,0.0031,3264,0.21,513.5297,1291.5924,0.0,76.247,513.5297,1291.5924,1,1,yes,no,"[0.39922, 0.606, 0.20155, 0.02336]",1,1,1,1,0.0,0,76.2326,2448 +m31,1,1,1,ansi_class,0.2709,7.3636,234.9624,0.0056,1439,0.2709,760.6468,2938.0512,0.2099,77.0872,760.6468,2938.0512,1,1,yes,yes,"[0.37355, 0.55195, 0.20058, 0.05357]",1,1,1,1,0.2099,1,77.0893,1610 +m32,1,1,1,ansi_class,0.1522,1.8636,192.9792,0.0193,2880,0.1035,196.3832,381.5871,0.0,83.7216,2534.4529,600.7521,1,1,yes,no,"[0.36337, 0.75581, 0.1875, 0.02907]",1,1,1,1,0.0,0,122.7907,3840 +m33,1,1,1,ansi_class,0.1624,1.8699,326.034,0.0003,1840,0.0904,160.1648,402.5174,0.0,235.8696,288.6125,678.1862,1,1,yes,no,"[0.31673, 0.77998, 0.28225, 0.12819]",1,1,1,1,0.0001,0,258.7262,3264 +m34,1,1,1,ansi_class,0.1617,7.8295,189.4977,0.0,3072,0.1903,746.8445,759.5292,0.0,35.7274,607.1894,1237.4981,1,1,yes,no,"[0.24419, 0.4782, 0.04457, 0.01163]",1,1,1,1,0.0,0,47.6737,2304 +m35,1,1,0,ansi_class,0.147,4.6026,125.7513,0.0076,2884,,,,,,79.7369,355.3581,,,,,,,,,,0.0002,,63.7729,3840 +m36,0,0,0,ansi_class,0.1828,0.2923,73.8901,0.0,2000,,,,,,39.7939,2145.9553,,,,,,,,,,0.0,,486.8736,3008 +m37,1,1,0,current_rating,0.2395,9.5186,786.504,0.0132,2852,0.2395,1110.5286,2804.6348,0.0046,87.043,1106.5909,2878.1721,1,1,yes,yes,"[0.34072, 0.57267, 0.2879, 0.03052]",1,1,1,1,0.0046,0,87.0523,2432 +m38,,,,,0.2813,3.32,763.4349,0.0482,1056,0.251,3232.3501,2872.8585,0.0,77.1091,3007.6239,2442.5892,1,1,yes,no,"[0.36538, 0.72675, 0.24463, 0.07302]",0,1,1,0,0.0,1,50.0,1056 +m39,1,1,0,ansi_class,0.2364,12.1362,148.646,0.005,3145,0.2004,102.5742,1287.0675,0.0,102.9988,139.5044,1745.5176,1,1,yes,yes,"[0.30959, 0.58925, 0.28788, 0.03275]",1,1,1,1,0.0,0,97.9542,3316 +m40,1,1,0,current_rating,0.2141,12.037,190.7039,0.0,1820,0.218,733.9421,626.1387,0.0,37.037,987.2677,348.7976,1,1,yes,yes,"[0.40262, 0.70494, 0.17442, 0.02035]",1,1,1,1,0.0,1,27.0,1820 +m41,1,1,0,current_rating,0.1005,4.8952,125.2406,0.0,1063,0.07,154.0115,318.7651,0.0,67.2666,189.8077,245.2166,1,0,no,no,"[0.29167, 0.83333, 0.34931, 0.06328]",0,1,0,0,0.0,1,53.3831,1064 +m42,0,0,1,ansi_class,0.177,8.05,269.0556,0.0145,976,0.177,384.7099,318.8083,0.001,39.9965,384.7099,318.8083,1,1,yes,yes,"[0.5082, 0.56967, 0.16189, 0.04098]",0,1,1,0,0.001,1,40.0,976 +m43,1,1,1,current_rating,0.1836,6.3003,560.1025,0.0295,3264,0.1667,323.3865,1135.4457,0.0,99.4214,469.1124,1614.8075,1,1,yes,yes,"[0.20155, 0.57674, 0.45724, 0.03046]",0,1,1,0,0.0,0,99.4326,1836 +m44,1,1,1,current_rating,0.1748,26.696,385.9376,0.0018,4928,0.1795,144.9326,2312.4544,0.0,167.9462,349.5333,900.5206,1,1,yes,yes,"[0.39827, 0.54374, 0.33271, 0.03408]",1,1,1,1,0.0,0,50.1395,3264 +m45,1,1,1,current_rating,0.2781,5.524,995.3953,0.0056,2543,0.2781,3199.9619,3511.9379,0.0195,112.1972,3199.9619,3511.9379,1,1,yes,yes,"[0.37449, 0.71218, 0.16973, 0.04412]",1,1,1,1,0.0195,0,112.1881,3840 +m46,1,1,1,ansi_class,0.2076,10.8968,154.1993,0.0,2592,0.2076,130.8069,730.0385,0.0,83.3328,130.8069,730.0385,1,1,yes,yes,"[0.54797, 0.60068, 0.15116, 0.03215]",1,1,1,1,0.0,0,83.3299,3018 +m47,1,1,1,current_rating,0.1087,8.5635,245.9082,0.0,2200,0.1087,116.3392,232.3516,0.0,64.152,116.3392,232.3516,1,1,yes,yes,"[0.43961, 0.48148, 0.22742, 0.02916]",1,1,1,1,0.0,1,64.155,1650 +m48,1,1,0,current_rating,0.1862,4.2168,367.2184,0.0081,1800,0.1416,307.8695,1121.1024,0.0,74.844,228.9177,1024.1136,1,1,yes,yes,"[0.42726, 0.73212, 0.16291, 0.04158]",1,1,1,1,0.0,0,125.6259,2400 +m49,1,1,1,current_rating,0.0929,2.1521,760.5981,0.0003,2200,0.1168,211.817,392.114,0.0,60.764,205.5966,362.3245,1,1,yes,yes,"[0.44574, 0.57122, 0.22287, 0.02762]",1,1,1,1,0.0,0,75.6619,1650 +m50,1,1,1,current_rating,0.2051,7.0573,515.1266,0.0065,1800,0.2051,579.5252,2085.8076,0.0,76.428,579.5252,2085.8076,1,1,yes,yes,"[0.22473, 0.61458, 0.22201, 0.04246]",1,1,1,1,0.0,0,76.4231,2400 +m51,1,1,1,current_rating,0.2043,9.5714,435.9481,0.0118,2000,0.2043,814.2792,1132.6426,0.0033,40.7,814.2792,1132.6426,1,1,yes,yes,"[0.48643, 0.4375, 0.25388, 0.02035]",1,1,1,1,0.0033,0,40.6977,1500 +m52,1,1,1,current_rating,0.2126,7.2353,439.7117,0.1202,2400,0.2126,561.6514,1470.3243,0.052,59.304,561.6514,1470.3243,1,1,yes,yes,"[0.43605, 0.55087, 0.32558, 0.02471]",1,1,1,1,0.052,0,59.3023,1800 +m53,1,1,1,current_rating,0.1476,7.4504,354.857,0.0068,2692,0.1476,339.5602,1619.6752,0.0008,105.7418,339.5602,1619.6752,1,1,yes,yes,"[0.41168, 0.66803, 0.1216, 0.03928]",1,1,1,1,0.0008,0,105.7531,3590 +m54,1,1,1,current_rating,0.2055,13.4,414.8675,0.0406,1260,0.2055,802.3696,659.5251,0.0635,36.6282,802.3696,659.5251,1,1,yes,yes,"[0.44186, 0.38953, 0.13227, 0.02907]",1,1,1,1,0.0635,0,36.6279,1680 +m55,1,1,1,current_rating,0.0739,1.2153,95.6539,0.0,2552,0.0739,90.5726,243.4395,0.0,144.0094,90.5726,243.4395,1,1,yes,yes,"[0.61667, 0.875, 0.1625, 0.05643]",0,1,1,0,0.0,0,144.0,3840 +m56,1,1,0,ansi_class,0.1203,6.8649,59.9466,0.0018,1944,0.1203,45.3992,137.8426,0.0,64.2103,45.3992,137.8426,1,1,yes,yes,"[0.38953, 0.74023, 0.21512, 0.03303]",1,1,1,1,0.0,1,64.2093,2592 +m57,1,1,0,ansi_class,0.1556,10.2885,75.2136,0.0001,1356,0.1209,279.7141,432.9524,0.0,42.5242,273.4106,596.331,1,1,yes,no,"[0.46279, 0.5411, 0.1224, 0.03136]",1,1,1,1,0.0,0,47.805,1292 +m58,1,1,0,current_rating,0.1642,10.5333,615.6008,0.0065,1840,0.163,539.4862,2133.6712,0.0,71.1344,544.1762,2145.3319,1,1,yes,yes,"[0.4811, 0.40722, 0.11483, 0.03866]",1,1,1,1,0.0,1,71.134,3264 +m59,1,1,1,current_rating,0.1892,3.0736,58.7014,0.0013,1712,0.0837,9.6577,187.1992,0.0,99.724,16.3406,1347.9076,1,1,yes,yes,"[0.15116, 0.92621, 0.20203, 0.05825]",1,1,1,1,0.0,0,201.3951,2288 +m60,1,1,0,current_rating,0.1303,10.0081,191.2347,0.0003,1233,0.1303,932.3136,170.3534,0.0,21.9844,932.3136,170.3534,1,1,yes,yes,"[0.20221, 0.3987, 0.15257, 0.01783]",1,1,1,1,0.0,0,21.9822,1088 +m61,1,1,1,current_rating,0.1483,4.0,84.5045,0.0077,1600,0.099,145.9077,138.2746,0.0,40.592,202.6494,652.372,1,1,yes,yes,"[0.11417, 0.42733, 0.85885, 0.02537]",1,1,1,1,0.0,0,66.6667,1200 +m62,1,1,1,current_rating,0.1199,5.4444,132.6872,0.0001,1536,,,,,,120.8435,137.9798,0,0,yes,yes,,1,0,0,1,0.0,,53.5814,2048 +m63,1,1,0,current_rating,0.3115,3.661,562.1212,0.0001,3375,0.3115,278.5452,2627.9631,0.0,114.2775,278.5452,2627.9631,1,1,yes,no,"[0.24488, 0.8422, 0.32362, 0.03386]",0,1,1,1,0.0,0,114.2649,2995 +m64,1,1,1,current_rating,0.1475,7.8606,356.359,0.0166,3454,0.0727,53.2992,509.4227,0.0,113.2567,81.4618,381.2756,1,1,yes,yes,"[0.44305, 0.6871, 0.17411, 0.03279]",1,1,1,1,0.0,1,85.4913,2999 +m65,1,1,0,current_rating,0.1327,14.9239,346.5615,0.002,4032,0.2049,92.9323,676.6001,0.0,87.6154,460.6701,644.6243,1,1,yes,yes,"[0.41833, 0.54159, 0.15944, 0.02173]",0,1,1,0,0.0,0,41.2326,3024 +m66,1,1,0,current_rating,0.123,6.7705,105.6538,0.0,4160,0.123,68.0765,591.2082,0.0,101.5456,68.0765,591.2082,1,1,yes,no,"[0.22031, 0.24692, 0.25513, 0.02441]",1,1,1,1,0.0,0,101.526,3120 +m67,1,1,1,current_rating,0.1711,7.7359,353.9356,0.0034,960,0.1711,2225.4767,1853.416,0.0,38.4864,2225.4767,1853.416,1,1,no,no,"[0.32339, 0.64974, 0.10792, 0.04009]",1,1,1,1,0.0,0,38.4899,1280 +m68,1,1,1,current_rating,0.114,5.1,125.4153,0.0004,1600,0.114,156.3484,163.4184,0.0,46.512,156.3484,163.4184,1,1,yes,yes,"[0.19767, 0.46221, 0.35659, 0.02907]",1,1,1,1,0.0,1,46.5116,1200 +m69,1,1,1,current_rating,0.1411,9.3927,338.7386,0.0158,2741,0.1497,125.4711,816.8404,0.0,74.6648,122.0605,832.7568,1,1,yes,yes,"[0.24379, 0.36865, 0.35457, 0.02724]",1,1,1,1,0.0,1,74.667,1766 +m70,1,1,1,current_rating,0.096,19.9189,225.2125,0.0017,2816,0.0909,94.926,456.6479,0.0,81.8611,173.8457,219.2205,1,1,yes,yes,"[0.28488, 0.35756, 0.2522, 0.02907]",0,1,1,0,0.0,0,41.3023,2112 +m71,1,1,0,current_rating,0.242,6.9289,147.9668,0.0007,2112,0.242,206.677,1160.3222,0.0,112.1261,206.677,1160.3222,1,1,yes,no,"[0.47668, 0.57908, 0.16148, 0.05309]",1,1,1,1,0.0,1,112.1201,2816 +m73,1,1,1,current_rating,0.2373,24.1589,732.8247,0.0005,3500,0.2663,605.2059,1863.5435,0.0,73.255,1347.1069,2272.3619,1,1,yes,no,"[0.4782, 0.33483, 0.20349, 0.02093]",0,1,1,1,0.0,0,54.95,3600 +m74,1,1,1,current_rating,0.0831,6.0114,242.1299,0.0027,1600,0.0831,656.536,498.7252,0.0,54.016,656.536,498.7252,1,1,yes,yes,"[0.53629, 0.47241, 0.19309, 0.03376]",1,1,1,1,0.0,0,54.0213,1200 +m75,1,1,1,current_rating,0.1088,6.3506,267.9679,0.0003,3150,0.1088,248.8148,713.1227,0.0,91.5705,248.8148,713.1227,1,1,yes,yes,"[0.27851, 0.59738, 0.44298, 0.02907]",0,1,1,0,0.0,1,91.5698,2088 +m76,1,1,0,current_rating,0.1934,5.6326,861.0101,0.0083,4624,0.1936,864.1116,1766.2201,0.0003,157.3547,862.3978,1747.5456,1,1,yes,yes,"[0.50476, 0.30057, 0.23999, 0.03403]",1,1,1,1,0.0003,0,157.3362,3472 +m77,1,1,1,current_rating,0.1371,23.7196,247.0402,0.0017,3800,0.1043,835.5318,1006.4249,0.0,117.458,1327.9825,1083.3013,1,1,yes,yes,"[0.49381, 0.50417, 0.12587, 0.03091]",1,1,1,1,0.0,0,60.7558,3600 +m78,1,1,1,current_rating,0.2046,11.9805,401.6008,0.0826,4554,0.2046,342.4563,1566.0021,0.3479,99.4594,342.4563,1566.0021,1,1,yes,yes,"[0.51034, 0.40698, 0.17931, 0.02184]",1,1,1,1,0.3479,0,99.4717,3840 +m79,1,1,1,current_rating,0.226,13.7485,236.167,0.0002,4032,0.226,689.2416,3100.7372,0.0,91.9296,689.2416,3100.7372,1,1,yes,yes,"[0.43008, 0.66375, 0.1224, 0.0228]",1,1,1,1,0.0,1,91.9264,3024 +m80,1,1,1,current_rating,0.2679,3.9486,678.0941,0.0069,1082,0.2781,5457.1217,4829.7049,0.0057,47.9651,5392.2525,4742.2377,1,1,yes,yes,"[0.23153, 0.18024, 0.33571, 0.04433]",0,1,1,0,0.0062,0,47.964,818 +m81,1,1,1,current_rating,0.0946,20.9136,307.5007,0.0066,4624,0.2638,241.25,1554.5725,0.0,147.3669,104.1087,351.5098,1,1,yes,yes,"[0.5155, 0.46667, 0.24865, 0.03187]",0,1,1,0,0.0,0,70.9694,3468 +m82,1,1,1,current_rating,0.154,12.8606,264.8675,0.005,4624,,,,,,127.826,855.498,0,0,yes,yes,,0,0,0,0,0.0,,90.3886,3468 +m83,1,1,0,current_rating,0.1151,6.7089,34.7012,0.0015,1509,,,,,,57.6747,155.0028,0,0,no,no,,0,0,0,0,0.0,,52.6395,1485 diff --git a/experiments/meter-closeup/results/locate.md b/experiments/meter-closeup/results/locate.md new file mode 100644 index 000000000..4d7b9680c --- /dev/null +++ b/experiments/meter-closeup/results/locate.md @@ -0,0 +1,92 @@ +### Barcodes + +| Photos | Barcode found | Decoded | Decode contains the meter number | +|---|---|---|---| +| All usable: 80 | 25 | 25 | 20 of 24 with a labelled number | +| US-style (CL class label): 22 | 9 | 9 | 8 of 8 with a labelled number | + +### Finding the number + +**odd-numbered photos, used to write the rules: 38 with an agreed number, 38 read** + +| Rule | Picks | Precision | Recall over read photos | +|---|---|---|---| +| tallest digit line (phase 1) | 38 | 10/38 = 26% (15%–42%) | 10/38 = 26% (15%–42%) | +| barcode confirms a text line | 12 | 11/12 = 92% (65%–99%) | 11/38 = 29% (17%–45%) | +| line nearest a decoded barcode | 11 | 7/11 = 64% (35%–85%) | 7/38 = 18% (9%–33%) | +| after a No./Nr./#: keyword | 10 | 6/10 = 60% (31%–83%) | 6/38 = 16% (7%–30%) | +| alone on its line, 6-14 characters, not a spec line | 37 | 22/37 = 59% (43%–74%) | 22/38 = 58% (42%–72%) | +| combined: barcode, else keyword | 19 | 16/19 = 84% (62%–94%) | 16/38 = 42% (28%–58%) | +| ranking, top 1 | 38 | – | 27/38 = 71% (55%–83%) | +| ranking, top 3 | 38 | – | 36/38 = 95% (83%–99%) | +| number is any candidate (ceiling) | 38 | – | 37/38 = 97% (87%–100%) | + +**even-numbered photos, held out: 35 with an agreed number, 33 read** + +| Rule | Picks | Precision | Recall over read photos | +|---|---|---|---| +| tallest digit line (phase 1) | 33 | 10/33 = 30% (17%–47%) | 10/33 = 30% (17%–47%) | +| barcode confirms a text line | 8 | 6/8 = 75% (41%–93%) | 6/33 = 18% (9%–34%) | +| line nearest a decoded barcode | 9 | 6/9 = 67% (35%–88%) | 6/33 = 18% (9%–34%) | +| after a No./Nr./#: keyword | 10 | 5/10 = 50% (24%–76%) | 5/33 = 15% (7%–31%) | +| alone on its line, 6-14 characters, not a spec line | 33 | 23/33 = 70% (53%–83%) | 23/33 = 70% (53%–83%) | +| combined: barcode, else keyword | 17 | 11/17 = 65% (41%–83%) | 11/33 = 33% (20%–50%) | +| ranking, top 1 | 33 | – | 21/33 = 64% (47%–78%) | +| ranking, top 3 | 33 | – | 28/33 = 85% (69%–93%) | +| number is any candidate (ceiling) | 33 | – | 29/33 = 88% (73%–95%) | + +**all photos: 73 with an agreed number, 71 read** + +| Rule | Picks | Precision | Recall over read photos | +|---|---|---|---| +| tallest digit line (phase 1) | 71 | 20/71 = 28% (19%–40%) | 20/71 = 28% (19%–40%) | +| barcode confirms a text line | 20 | 17/20 = 85% (64%–95%) | 17/71 = 24% (16%–35%) | +| line nearest a decoded barcode | 20 | 13/20 = 65% (43%–82%) | 13/71 = 18% (11%–29%) | +| after a No./Nr./#: keyword | 20 | 11/20 = 55% (34%–74%) | 11/71 = 15% (9%–26%) | +| alone on its line, 6-14 characters, not a spec line | 70 | 45/70 = 64% (53%–74%) | 45/71 = 63% (52%–74%) | +| combined: barcode, else keyword | 36 | 27/36 = 75% (59%–86%) | 27/71 = 38% (28%–50%) | +| ranking, top 1 | 71 | – | 48/71 = 68% (56%–77%) | +| ranking, top 3 | 71 | – | 64/71 = 90% (81%–95%) | +| number is any candidate (ceiling) | 71 | – | 66/71 = 93% (85%–97%) | + +**all photos, strict labels (readers' main numbers identical): 62 with an agreed number, 60 read** + +| Rule | Picks | Precision | Recall over read photos | +|---|---|---|---| +| tallest digit line (phase 1) | 60 | 17/60 = 28% (19%–41%) | 17/60 = 28% (19%–41%) | +| barcode confirms a text line | 17 | 14/17 = 82% (59%–94%) | 14/60 = 23% (14%–35%) | +| line nearest a decoded barcode | 17 | 10/17 = 59% (36%–78%) | 10/60 = 17% (9%–28%) | +| after a No./Nr./#: keyword | 17 | 10/17 = 59% (36%–78%) | 10/60 = 17% (9%–28%) | +| alone on its line, 6-14 characters, not a spec line | 60 | 42/60 = 70% (57%–80%) | 42/60 = 70% (57%–80%) | +| combined: barcode, else keyword | 32 | 24/32 = 75% (58%–87%) | 24/60 = 40% (29%–53%) | +| ranking, top 1 | 60 | – | 42/60 = 70% (57%–80%) | +| ranking, top 3 | 60 | – | 56/60 = 93% (84%–97%) | +| number is any candidate (ceiling) | 60 | – | 56/60 = 93% (84%–97%) | + +**exploratory: US-style meters (CL class label), odd-numbered: 12 with an agreed number, 12 read** + +| Rule | Picks | Precision | Recall over read photos | +|---|---|---|---| +| tallest digit line (phase 1) | 12 | 3/12 = 25% (9%–53%) | 3/12 = 25% (9%–53%) | +| barcode confirms a text line | 5 | 5/5 = 100% (57%–100%) | 5/12 = 42% (19%–68%) | +| line nearest a decoded barcode | 5 | 4/5 = 80% (38%–96%) | 4/12 = 33% (14%–61%) | +| after a No./Nr./#: keyword | 0 | – | 0/12 = 0% (0%–24%) | +| alone on its line, 6-14 characters, not a spec line | 11 | 6/11 = 55% (28%–79%) | 6/12 = 50% (25%–75%) | +| combined: barcode, else keyword | 5 | 5/5 = 100% (57%–100%) | 5/12 = 42% (19%–68%) | +| ranking, top 1 | 12 | – | 8/12 = 67% (39%–86%) | +| ranking, top 3 | 12 | – | 11/12 = 92% (65%–99%) | +| number is any candidate (ceiling) | 12 | – | 12/12 = 100% (76%–100%) | + +**exploratory: US-style meters (CL class label), held out: 7 with an agreed number, 7 read** + +| Rule | Picks | Precision | Recall over read photos | +|---|---|---|---| +| tallest digit line (phase 1) | 7 | 2/7 = 29% (8%–64%) | 2/7 = 29% (8%–64%) | +| barcode confirms a text line | 3 | 3/3 = 100% (44%–100%) | 3/7 = 43% (16%–75%) | +| line nearest a decoded barcode | 3 | 3/3 = 100% (44%–100%) | 3/7 = 43% (16%–75%) | +| after a No./Nr./#: keyword | 1 | 0/1 = 0% (0%–79%) | 0/7 = 0% (0%–35%) | +| alone on its line, 6-14 characters, not a spec line | 7 | 5/7 = 71% (36%–92%) | 5/7 = 71% (36%–92%) | +| combined: barcode, else keyword | 4 | 3/4 = 75% (30%–95%) | 3/7 = 43% (16%–75%) | +| ranking, top 1 | 7 | – | 5/7 = 71% (36%–92%) | +| ranking, top 3 | 7 | – | 5/7 = 71% (36%–92%) | +| number is any candidate (ceiling) | 7 | – | 6/7 = 86% (49%–97%) | diff --git a/experiments/meter-closeup/results/locate_dev.md b/experiments/meter-closeup/results/locate_dev.md new file mode 100644 index 000000000..63ce65419 --- /dev/null +++ b/experiments/meter-closeup/results/locate_dev.md @@ -0,0 +1,21 @@ +### Barcodes + +| Photos | Barcode found | Decoded | Decode contains the meter number | +|---|---|---|---| +| 42 | 13 | 13 | 12 of 12 with a labelled number | + +### Finding the number + +**all photos: 38 with an agreed number, 38 read** + +| Rule | Picks | Precision | Recall over read photos | +|---|---|---|---| +| tallest digit line (phase 1) | 38 | 10/38 = 26% (15%–42%) | 10/38 = 26% (15%–42%) | +| barcode confirms a text line | 12 | 11/12 = 92% (65%–99%) | 11/38 = 29% (17%–45%) | +| line nearest a decoded barcode | 11 | 7/11 = 64% (35%–85%) | 7/38 = 18% (9%–33%) | +| after a No./Nr./#: keyword | 10 | 6/10 = 60% (31%–83%) | 6/38 = 16% (7%–30%) | +| alone on its line, 6-14 characters, not a spec line | 37 | 22/37 = 59% (43%–74%) | 22/38 = 58% (42%–72%) | +| combined: barcode, else keyword | 19 | 16/19 = 84% (62%–94%) | 16/38 = 42% (28%–58%) | +| ranking, top 1 | 38 | – | 27/38 = 71% (55%–83%) | +| ranking, top 3 | 38 | – | 36/38 = 95% (83%–99%) | +| number is any candidate (ceiling) | 38 | – | 37/38 = 97% (87%–100%) | diff --git a/experiments/meter-closeup/results/locate_per_image.csv b/experiments/meter-closeup/results/locate_per_image.csv new file mode 100644 index 000000000..d39c19ca8 --- /dev/null +++ b/experiments/meter-closeup/results/locate_per_image.csv @@ -0,0 +1,81 @@ +id,split,after a No./Nr./#: keyword,"alone on its line, 6-14 characters, not a spec line",barcode confirms a text line,barcode_decoded,barcode_found,barcode_has_number,candidates,"combined: barcode, else keyword",line nearest a decoded barcode,rank,read,strict,tallest digit line (phase 1),us_style +m01,dev,,1,,0,0,0,3,,,1,1,1,0,0 +m02,test,,1,,0,0,0,6,,,1,1,1,0,0 +m03,dev,1,1,,0,0,0,4,1,,1,1,1,0,0 +m04,test,1,1,,0,0,0,3,1,,1,1,1,1,0 +m05,dev,,1,1,1,1,1,6,1,1,1,1,1,0,1 +m06,test,,1,1,1,1,1,5,1,1,1,1,1,0,1 +m07,dev,,0,,0,0,0,5,,,2,1,1,0,0 +m08,test,1,1,,0,0,0,6,1,,1,1,1,0,0 +m09,dev,,1,,0,0,0,4,,,1,1,1,0,0 +m10,test,,1,,0,0,0,2,,,1,1,1,1,0 +m11,dev,,0,,0,0,0,8,,,2,1,1,0,1 +m12,test,,,,1,1,1,,,,,,0,,0 +m13,dev,,1,,0,0,0,3,,,1,1,1,1,0 +m14,test,0,1,,0,0,0,5,0,,2,1,1,1,0 +m15,dev,,0,1,1,1,1,10,1,0,1,1,1,0,1 +m16,test,,0,,0,0,0,9,,,,1,1,0,0 +m17,dev,,1,1,1,1,1,7,1,0,1,1,1,0,0 +m18,test,,1,,0,0,0,6,,,1,1,1,0,0 +m19,dev,,1,1,1,1,1,5,1,1,1,1,1,0,1 +m21,dev,,1,1,1,1,1,5,1,1,1,1,1,0,1 +m22,test,,1,1,1,1,1,6,1,1,1,1,1,0,1 +m23,dev,,0,,0,0,0,8,,,2,1,1,0,1 +m25,dev,,,,0,0,0,3,,,1,1,0,1,1 +m26,test,,1,,0,0,0,6,,,1,1,1,0,0 +m27,dev,,1,,0,0,0,6,,,1,1,1,1,0 +m28,test,,,,0,0,0,,,,,,0,,1 +m29,dev,,1,,0,0,0,6,,,1,1,1,1,1 +m30,test,,1,,0,0,0,3,,,1,1,0,0,0 +m31,dev,,1,,0,0,0,3,,,1,1,1,1,1 +m32,test,,0,,0,0,0,8,,,,1,0,0,1 +m33,dev,,0,,0,0,0,2,,,2,1,0,0,1 +m34,test,0,0,,0,0,0,6,0,,5,1,0,0,1 +m35,dev,,,,1,1,,,,,,,0,,1 +m36,test,,,,0,0,,,,,,,0,,1 +m37,dev,,1,,0,0,0,10,,,1,1,1,0,0 +m38,test,,0,,0,0,0,2,,,2,1,0,1,0 +m39,dev,,1,1,1,1,1,8,1,1,1,1,1,0,1 +m40,test,,1,0,1,1,0,11,0,0,2,1,1,1,0 +m41,dev,,,,0,0,0,,,,,,0,,0 +m42,test,,1,,0,0,0,4,,,1,1,1,1,1 +m43,dev,,0,1,1,1,1,7,1,,1,1,1,0,0 +m44,test,,0,0,1,1,1,8,0,1,2,1,1,0,0 +m45,dev,,1,1,1,1,1,8,1,0,1,1,1,0,0 +m46,test,,1,1,1,1,1,6,1,1,1,1,1,0,1 +m47,dev,,1,,0,0,0,7,,,1,1,1,1,0 +m48,test,,0,,0,0,0,5,,,2,1,1,0,0 +m49,dev,,0,,0,0,0,9,,,2,1,1,0,0 +m50,test,,0,,1,1,0,5,,,,1,1,0,0 +m51,dev,1,0,,0,0,0,12,1,,1,1,1,0,0 +m52,test,,1,,1,1,0,7,,,1,1,1,0,0 +m53,dev,1,0,,0,0,0,6,1,,1,1,1,0,0 +m54,test,,1,,0,0,0,5,,,1,1,1,0,0 +m55,dev,1,1,,0,0,0,4,1,,1,1,1,0,0 +m56,test,,1,,0,0,0,2,,,1,1,1,1,1 +m57,dev,,0,,0,0,0,5,,,5,1,0,0,1 +m58,test,,1,1,1,1,1,6,1,1,1,1,1,1,0 +m59,dev,,0,,0,0,0,6,,,2,1,1,0,0 +m60,test,1,1,,0,0,0,19,1,,1,1,1,0,0 +m61,dev,,0,,0,0,0,11,,,,1,1,0,0 +m62,test,,,,0,0,0,6,,,,0,1,0,0 +m63,dev,0,0,1,1,1,1,12,1,1,1,1,0,0,0 +m64,test,0,1,,0,0,0,7,0,,2,1,1,1,0 +m65,dev,0,0,0,1,1,1,14,0,0,2,1,1,0,0 +m66,test,,0,1,1,1,1,13,1,1,1,1,0,0,0 +m67,dev,,,,0,0,0,,,,,,0,,0 +m68,test,1,1,,0,0,0,4,1,,1,1,1,1,0 +m69,dev,,1,,0,0,0,7,,,1,1,1,1,0 +m70,test,0,1,,0,0,0,8,,,2,1,1,0,0 +m71,dev,,1,,0,0,0,4,,,1,1,0,1,0 +m73,dev,1,1,1,1,1,1,18,1,1,1,1,0,0,0 +m74,test,,1,,0,0,0,12,,,1,1,1,0,0 +m75,dev,1,1,,0,0,0,5,1,,1,1,1,1,0 +m76,test,,0,1,1,1,1,9,1,0,1,1,1,0,0 +m77,dev,0,1,,0,0,0,7,0,,2,1,1,0,0 +m78,test,1,1,,0,0,0,5,1,,1,1,1,0,0 +m79,dev,,1,1,1,1,1,7,1,1,1,1,1,1,0 +m80,test,0,0,,1,1,0,9,0,0,,1,1,0,0 +m81,dev,0,0,,0,0,0,10,0,,3,1,1,0,0 +m82,test,,0,,0,0,0,6,,,,0,1,0,0 +m83,dev,,,,0,0,0,,,,,,0,,0 diff --git a/experiments/meter-closeup/results/locate_per_image_dev.csv b/experiments/meter-closeup/results/locate_per_image_dev.csv new file mode 100644 index 000000000..6d87b4e70 --- /dev/null +++ b/experiments/meter-closeup/results/locate_per_image_dev.csv @@ -0,0 +1,43 @@ +id,split,after a No./Nr./#: keyword,"alone on its line, 6-14 characters, not a spec line",barcode confirms a text line,barcode_decoded,barcode_found,barcode_has_number,candidates,"combined: barcode, else keyword",line nearest a decoded barcode,rank,read,tallest digit line (phase 1) +m01,dev,,1,,0,0,0,3,,,1,1,0 +m03,dev,1,1,,0,0,0,4,1,,1,1,0 +m05,dev,,1,1,1,1,1,6,1,1,1,1,0 +m07,dev,,0,,0,0,0,5,,,2,1,0 +m09,dev,,1,,0,0,0,4,,,1,1,0 +m11,dev,,0,,0,0,0,8,,,2,1,0 +m13,dev,,1,,0,0,0,3,,,1,1,1 +m15,dev,,0,1,1,1,1,10,1,0,1,1,0 +m17,dev,,1,1,1,1,1,7,1,0,1,1,0 +m19,dev,,1,1,1,1,1,5,1,1,1,1,0 +m21,dev,,1,1,1,1,1,5,1,1,1,1,0 +m23,dev,,0,,0,0,0,8,,,2,1,0 +m25,dev,,,,0,0,0,3,,,1,1,1 +m27,dev,,1,,0,0,0,6,,,1,1,1 +m29,dev,,1,,0,0,0,6,,,1,1,1 +m31,dev,,1,,0,0,0,3,,,1,1,1 +m33,dev,,0,,0,0,0,2,,,2,1,0 +m35,dev,,,,1,1,,,,,,, +m37,dev,,1,,0,0,0,10,,,1,1,0 +m39,dev,,1,1,1,1,1,8,1,1,1,1,0 +m41,dev,,,,0,0,0,,,,,, +m43,dev,,0,1,1,1,1,7,1,,1,1,0 +m45,dev,,1,1,1,1,1,8,1,0,1,1,0 +m47,dev,,1,,0,0,0,7,,,1,1,1 +m49,dev,,0,,0,0,0,9,,,2,1,0 +m51,dev,1,0,,0,0,0,12,1,,1,1,0 +m53,dev,1,0,,0,0,0,6,1,,1,1,0 +m55,dev,1,1,,0,0,0,4,1,,1,1,0 +m57,dev,,0,,0,0,0,5,,,5,1,0 +m59,dev,,0,,0,0,0,6,,,2,1,0 +m61,dev,,0,,0,0,0,11,,,,1,0 +m63,dev,0,0,1,1,1,1,12,1,1,1,1,0 +m65,dev,0,0,0,1,1,1,14,0,0,2,1,0 +m67,dev,,,,0,0,0,,,,,, +m69,dev,,1,,0,0,0,7,,,1,1,1 +m71,dev,,1,,0,0,0,4,,,1,1,1 +m73,dev,1,1,1,1,1,1,18,1,1,1,1,0 +m75,dev,1,1,,0,0,0,5,1,,1,1,1 +m77,dev,0,1,,0,0,0,7,0,,2,1,0 +m79,dev,,1,1,1,1,1,7,1,1,1,1,1 +m81,dev,0,0,,0,0,0,10,0,,3,1,0 +m83,dev,,,,0,0,0,,,,,, diff --git a/experiments/meter-closeup/results/sweep.md b/experiments/meter-closeup/results/sweep.md new file mode 100644 index 000000000..19dc99af6 --- /dev/null +++ b/experiments/meter-closeup/results/sweep.md @@ -0,0 +1,91 @@ +Swept photos: 71; degraded reads: 3309. + +### Read rate by degradation level + +**Gaussian blur, σ as a fraction of the number's line height** + +| Level | 0.02 | 0.04 | 0.06 | 0.08 | 0.1 | 0.13 | 0.16 | 0.2 | 0.25 | 0.3 | +|---|---|---|---|---|---|---|---|---|---|---| +| Read correctly (photos) | 99% (71) | 94% (71) | 85% (71) | 68% (71) | 38% (71) | 4% (71) | 0% (71) | 0% (71) | 0% (71) | 0% (71) | + +**Horizontal motion blur, streak length as a fraction of line height** + +| Level | 0.05 | 0.1 | 0.15 | 0.2 | 0.3 | 0.4 | 0.5 | 0.7 | 1 | +|---|---|---|---|---|---|---|---|---|---| +| Read correctly (photos) | 99% (71) | 96% (71) | 94% (71) | 93% (71) | 69% (71) | 39% (71) | 10% (71) | 0% (71) | 0% (71) | + +**Downscaled so the number's line is this many pixels tall** + +| Level | 48 | 40 | 32 | 26 | 22 | 18 | 15 | 12 | 10 | 8 | 6 | +|---|---|---|---|---|---|---|---|---|---|---|---| +| Read correctly (photos) | 96% (57) | 95% (64) | 93% (69) | 94% (70) | 93% (70) | 94% (71) | 96% (71) | 77% (71) | 61% (71) | 30% (71) | 6% (71) | + +**White glare patch on the number, peak opacity** + +| Level | 0.3 | 0.5 | 0.7 | 0.85 | 1 | 1.5 | 2.5 | 4 | +|---|---|---|---|---|---|---|---|---| +| Read correctly (photos) | 99% (71) | 97% (71) | 97% (71) | 97% (71) | 23% (71) | 4% (71) | 4% (71) | 4% (71) | + +**Right frame edge this many line heights past the number (negative cuts it)** + +| Level | 2 | 1 | 0.5 | 0.25 | 0.1 | 0 | -0.5 | -1 | -2 | +|---|---|---|---|---|---|---|---|---|---| +| Read correctly (photos) | 100% (70) | 96% (70) | 99% (71) | 97% (71) | 99% (71) | 100% (71) | 17% (71) | 14% (71) | 13% (70) | + +### Retake thresholds from each photo's break point + +| Degradation | Check | Retake when (95% of photos) | Retake when (80%) | Median break | AUC | +|---|---|---|---|---|---| +| blur | degradation level (σ / line height) | ≥ 0.06 | ≥ 0.08 | 0.1 | – | +| blur | label sharpness, resized to a 32 px line | ≤ 39.1 | ≤ 4.92 | 1.99 | 0.96 | +| blur | top-candidate sharpness, 32 px line | ≤ 59.6 | ≤ 10.8 | 1.68 | 0.96 | +| blur | whole-photo sharpness at up to 1024 px | ≤ 6.63 | ≤ 2.89 | 1.31 | 0.96 | +| motion | degradation level (streak / line height) | ≥ 0.3 | ≥ 0.3 | 0.4 | – | +| motion | label sharpness, resized to a 32 px line | ≤ 701 | ≤ 320 | 132 | 0.69 | +| motion | top-candidate sharpness, 32 px line | ≤ 503 | ≤ 246 | 101 | 0.85 | +| motion | whole-photo sharpness at up to 1024 px | ≤ 190 | ≤ 107 | 52.4 | 0.75 | +| scale | degradation level (line height px) | ≤ 12 | ≤ 10 | 8 | – | +| scale | label line height in pixels | ≤ 12 | ≤ 10 | 8 | 0.88 | +| scale | top-candidate line height in pixels | ≤ 33.9 | ≤ 16 | 6 | 0.86 | +| glare | degradation level (peak opacity) | ≥ 1 | ≥ 1 | 1 | – | +| glare | label share of pixels ≥ 250 | ≥ 0.0729 | ≥ 0.133 | 0.242 | 0.95 | +| glare | top-candidate share of pixels ≥ 250 | ≥ 0 | ≥ 0 | 0 | 0.55 | +| glare | label RMS contrast | ≤ 0.156 | ≤ 0.14 | 0.104 | 0.83 | +| glare | whole-photo share of pixels ≥ 250 | ≥ 0.0009 | ≥ 0.0025 | 0.00915 | 0.85 | +| edge | degradation level (line heights) | ≤ -0.5 | ≤ -0.5 | -0.5 | – | +| edge | label gap to the frame edge (clipped box) | ≤ 0 | ≤ 0 | 0 | 0.90 | +| edge | top-candidate gap to the frame edge, in line heights | ≤ 7.73 | ≤ 0.814 | 0.195 | 0.61 | + +- blur: 71 photos, 0 never broke; 2 of 277 reads above the break failed anyway (0.7%). +- motion: 71 photos, 0 never broke; 6 of 361 reads above the break failed anyway (1.7%). +- scale: 71 photos, 4 never broke; 15 of 584 reads above the break failed anyway (2.6%). +- glare: 71 photos, 3 never broke; 3 of 305 reads above the break failed anyway (1.0%). +- edge: 71 photos, 9 never broke; 9 of 457 reads above the break failed anyway (2.0%). + +### Real photos that read correctly but a threshold would reject + +| Check | Rejected at the 95% threshold | Rejected at the 80% threshold | +|---|---|---| +| label sharpness, resized to a 32 px line (blur) | 0/75 | 0/75 | +| top-candidate sharpness, 32 px line (blur) | 1/75 | 0/75 | +| whole-photo sharpness at up to 1024 px (blur) | 0/75 | 0/75 | +| label sharpness, resized to a 32 px line (motion) | 32/75 | 14/75 | +| top-candidate sharpness, 32 px line (motion) | 22/75 | 9/75 | +| whole-photo sharpness at up to 1024 px (motion) | 21/75 | 8/75 | +| label line height in pixels (scale) | 0/75 | 0/75 | +| top-candidate line height in pixels (scale) | 2/75 | 0/75 | +| label share of pixels ≥ 250 (glare) | 2/75 | 2/75 | +| top-candidate share of pixels ≥ 250 (glare) | 75/75 | 75/75 | +| label RMS contrast (glare) | 31/75 | 27/75 | +| whole-photo share of pixels ≥ 250 (glare) | 47/75 | 37/75 | +| top-candidate gap to the frame edge, in line heights (edge) | 37/75 | 0/75 | + +### Second pass on failed reads + +| Degradation | Failed reads | Crop re-read recovers | Crop 2x re-read recovers | Either | +|---|---|---|---|---| +| blur | 435 | 10 (2%) | 10 (2%) | 13 (3%) | +| motion | 284 | 6 (2%) | 7 (2%) | 9 (3%) | +| scale | 187 | 11 (6%) | 21 (11%) | 24 (13%) | +| glare | 266 | 6 (2%) | 6 (2%) | 7 (3%) | +| edge | 188 | 7 (4%) | 7 (4%) | 8 (4%) | diff --git a/experiments/meter-closeup/results/sweep_rows.csv b/experiments/meter-closeup/results/sweep_rows.csv new file mode 100644 index 000000000..96eae27a0 --- /dev/null +++ b/experiments/meter-closeup/results/sweep_rows.csv @@ -0,0 +1,3310 @@ +family,id,level,contrast,crop,crop_x2,edge_margin,global_lap_var,global_saturated,guess_is_number,height,label_contrast,label_edge_margin,label_hmac,label_lap_var,label_lap_var_32,label_saturated,label_text_height_px,lap_var,lap_var_32,ok,saturated,text_height_px,width +blur,m01,0.02,0.077,,,5.3189,34.5787,0.0001,1,1536,0.0242,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,3.2112,12.094,0.0,62.5152,8.3439,104.8262,1,0.0,77.5019,2048 +blur,m01,0.04,0.0204,,,6.6407,7.7381,0.0,1,1536,0.0205,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,1.6859,2.5746,0.0,62.5152,1.6747,2.6362,1,0.0,63.9899,2048 +blur,m01,0.06,0.0172,0,0,7.15,2.5182,0.0,0,1536,0.0178,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,1.3499,0.7849,0.0,62.5152,1.347,0.6938,0,0.0,59.5349,2048 +blur,m01,0.08,0.0155,0,0,9.8,1.1308,0.0,0,1536,0.0159,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,1.301,0.3734,0.0,62.5152,1.5985,0.3406,0,0.0,44.6512,2048 +blur,m01,0.1,0.0586,0,0,10.1053,0.6363,0.0,0,1536,0.0143,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,1.2181,0.2356,0.0,62.5152,1.4942,0.7577,0,0.0,56.5581,2048 +blur,m01,0.13,,0,0,,0.3604,0.0,,1536,0.0126,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,1.025,0.2096,0.0,62.5152,,,0,,,2048 +blur,m01,0.16,,0,0,,0.2573,0.0,,1536,0.0113,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,0.8107,0.2286,0.0,62.5152,,,0,,,2048 +blur,m01,0.2,,0,0,,0.2025,0.0,,1536,0.0099,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,0.6204,0.2516,0.0,62.5152,,,0,,,2048 +blur,m01,0.25,,0,0,,0.1775,0.0,,1536,0.0087,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,0.4286,0.1986,0.0,62.5152,,,0,,,2048 +blur,m01,0.3,,0,0,,0.1655,0.0,,1536,0.008,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,0.3496,0.1601,0.0,62.5152,,,0,,,2048 +edge,m01,2.0,0.0831,,,1.7131,72.5735,0.0,1,1536,0.0279,2.0018,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,22.8126,44.2042,0.0,62.5152,42.6914,209.3331,1,0.0,74.1628,1295 +edge,m01,1.0,0.0802,,,0.7931,67.9072,0.0,1,1536,0.0279,0.994,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,22.8126,44.2042,0.0,62.5152,42.3103,211.7033,1,0.0,75.7486,1232 +edge,m01,0.5,0.082,,,0.1175,66.2516,0.0,0,1536,0.0279,0.4981,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,22.8126,44.2042,0.0,62.5152,41.5622,210.1374,1,0.0,74.9435,1201 +edge,m01,0.25,0.0807,,,0.1805,64.4172,0.0,1,1536,0.0279,0.2422,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,22.8126,44.2042,0.0,62.5152,42.5935,213.0942,1,0.0,74.1628,1185 +edge,m01,0.1,0.0813,,,0.0882,64.0518,0.0,1,1536,0.0274,0.0982,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,22.0622,43.2443,0.0,62.5152,42.3542,219.7688,1,0.0,74.7683,1176 +edge,m01,0.0,0.0794,,,0.0303,63.697,0.0,1,1536,0.0273,0.0023,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,21.989,43.2349,0.0,62.5152,42.8841,213.0802,1,0.0,73.6744,1170 +edge,m01,-0.5,0.0867,0,0,0.2053,63.3875,0.0,0,1536,0.0263,0.0,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,20.0496,38.301,0.0,62.5152,42.8626,232.1171,0,0.0,75.22,1139 +edge,m01,-1.0,0.0915,0,0,0.0232,60.7156,0.0,0,1536,0.0263,0.0,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,20.1787,39.4923,0.0,62.5152,48.7562,223.8989,0,0.0,68.1071,1107 +edge,m01,-2.0,0.097,0,0,0.0601,59.1552,0.0,0,1536,0.0264,0.0,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,21.1022,42.1292,0.0,62.5152,51.8448,286.1229,0,0.0,74.1628,1045 +glare,m01,0.3,0.0269,,,7.0319,100.559,0.0001,1,1536,0.0267,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,15.9558,28.1607,0.0,62.5152,16.4707,27.6251,1,0.0,60.8638,2048 +glare,m01,0.5,0.0308,,,7.0223,98.9973,0.0001,1,1536,0.0305,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,11.8429,19.71,0.0,62.5152,12.737,21.7966,1,0.0,60.941,2048 +glare,m01,0.7,0.037,,,7.2,97.5423,0.0001,1,1536,0.0366,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,8.4085,13.1572,0.0,62.5152,9.1559,14.0233,1,0.0,59.5349,2048 +glare,m01,0.85,0.0927,0,0,8.3043,96.5206,0.0001,0,1536,0.0423,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,6.3889,9.3853,0.0,62.5152,81.4248,372.8487,0,0.0,68.4651,2048 +glare,m01,1.0,0.0912,0,0,8.3043,95.5903,0.003,0,1536,0.0483,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,4.7571,6.7173,0.2537,62.5152,74.7127,340.9768,0,0.0,68.4651,2048 +glare,m01,1.5,0.0909,0,0,8.3043,92.9069,0.0131,0,1536,0.0395,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,1.937,2.4026,0.635,62.5152,55.8628,251.5087,0,0.0411,68.4651,2048 +glare,m01,2.5,0.0968,0,0,9.1429,89.0532,0.025,0,1536,0.013,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,0.3129,0.1784,0.905,62.5152,46.3842,172.512,0,0.1314,62.5116,2048 +glare,m01,4.0,,0,0,,85.2018,0.0367,,1536,0.0,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,0.0,0.0,1.0,62.5152,,,0,,,2048 +motion,m01,0.05,0.0268,,,7.0383,77.7214,0.0001,1,1536,0.0266,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,10.8321,31.0997,0.0,62.5152,11.1104,30.3405,1,0.0,60.8121,2048 +motion,m01,0.1,0.0816,,,5.56,45.352,0.0,1,1536,0.0246,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,6.2686,16.699,0.0,62.5152,9.8061,73.7006,1,0.0,74.4186,2048 +motion,m01,0.15,0.0752,,,5.4527,32.1513,0.0,0,1536,0.023,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,4.739,11.4311,0.0,62.5152,6.0611,33.9418,1,0.0,75.7599,2048 +motion,m01,0.2,0.0217,,,6.9458,24.6735,0.0,1,1536,0.0212,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,3.772,8.2871,0.0,62.5152,3.7684,7.8558,1,0.0,61.5676,2048 +motion,m01,0.3,0.0192,0,0,7.5789,19.4956,0.0,0,1536,0.0192,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,3.0337,6.1565,0.0,62.5152,3.2229,5.1847,0,0.0,56.5581,2048 +motion,m01,0.4,0.018,0,0,8.5294,16.7203,0.0,0,1536,0.0177,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,2.6037,4.9539,0.0,62.5152,2.9777,3.8494,0,0.0,50.6047,2048 +motion,m01,0.5,0.0663,0,0,12.0,14.9341,0.0,0,1536,0.0165,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,2.3317,4.1811,0.0,62.5152,10.1429,24.683,0,0.0,47.6279,2048 +motion,m01,0.7,,0,0,,12.4173,0.0,,1536,0.015,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,1.8551,3.2789,0.0,62.5152,,,0,,,2048 +motion,m01,1.0,,0,0,,10.2098,0.0,,1536,0.015,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,1.6509,2.8069,0.0,62.5152,,,0,,,2048 +scale,m01,48,0.0274,,,6.6903,115.4188,0.0001,1,1179,0.0279,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,40.3539,43.6583,0.0,47.9853,38.7901,43.2023,1,0.0,49.3183,1572 +scale,m01,40,0.0922,1,0,4.372,113.9006,0.0001,0,983,0.0278,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,57.7602,42.84,0.0,40.0081,117.6256,275.8429,0,0.0,60.0565,1310 +scale,m01,32,0.0905,,,3.8333,111.7388,0.0001,0,786,0.0277,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,85.3645,85.3645,0.0,31.9902,367.1793,355.4273,1,0.0,52.4,1048 +scale,m01,26,0.0929,,,3.8605,277.7435,0.0001,0,639,0.0273,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,111.2763,28.9023,0.0,26.0073,503.5601,337.7252,1,0.0,42.9328,852 +scale,m01,22,0.0916,,,5.0354,346.6974,0.0001,0,541,0.0265,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,129.2497,20.4505,0.0,22.0187,585.9438,203.4252,1,0.0,28.4907,721 +scale,m01,18,0.0304,,,5.7988,428.0447,0.0001,1,442,0.0264,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,164.1356,14.2409,0.0,17.9894,237.6148,32.4457,1,0.0,20.9564,590 +scale,m01,15,0.0964,,,4.6936,502.7006,0.0001,1,369,0.0253,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,181.8537,8.4475,0.0,15.0183,771.1947,124.3494,1,0.0,21.1225,491 +scale,m01,12,0.0723,,,5.8571,597.7127,0.0001,1,295,0.0245,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,211.4986,4.68,0.0,12.0065,895.3725,40.2878,1,0.0,13.9527,393 +scale,m01,10,0.0512,0,1,5.5,677.5461,0.0001,0,246,0.0236,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,231.1833,2.8997,0.0,10.0122,599.0224,14.5236,0,0.0,12.0,328 +scale,m01,8,0.0816,0,0,7.2,784.8255,0.0001,0,197,0.0221,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,252.5592,1.1655,0.0,8.0179,3540.6297,44.9363,0,0.0,9.9495,262 +scale,m01,6,,0,0,,954.7494,0.0,,147,0.0197,6.8568,d812ecbb0d03d8e7c4f63d989b77152a9b74545079658868dfcd72a5b9c28576,208.991,0.4368,0.0,5.9829,,,0,,,197 +blur,m02,0.02,0.1353,,,2.626,100.8902,0.013,0,1200,0.1477,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,156.9499,491.9056,0.0,49.128,233.4818,509.9937,1,0.0342,43.7209,1600 +blur,m02,0.04,0.1174,,,10.7876,22.7687,0.0107,1,1200,0.118,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,25.6724,103.7484,0.0,49.128,25.3546,110.8704,1,0.0,49.8394,1600 +blur,m02,0.06,0.0958,,,10.6812,6.8789,0.0088,1,1200,0.0957,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,6.9976,25.1077,0.0,49.128,6.8968,27.1784,1,0.0,50.2079,1600 +blur,m02,0.08,0.0805,,,10.6729,2.7916,0.0073,1,1200,0.0798,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,3.2821,7.8654,0.0,49.128,3.212,8.3286,1,0.0,50.2475,1600 +blur,m02,0.1,0.054,0,0,16.7143,1.4412,0.006,0,1200,0.0684,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,2.2812,2.9629,0.0,49.128,2.1195,0.7862,0,0.0,32.5581,1600 +blur,m02,0.13,,0,0,,0.7317,0.0037,,1200,0.056,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,1.8175,0.96,0.0,49.128,,,0,,,1600 +blur,m02,0.16,,0,0,,0.4827,0.0019,,1200,0.0471,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,1.7394,0.5041,0.0,49.128,,,0,,,1600 +blur,m02,0.2,,0,0,,0.3487,0.0001,,1200,0.0385,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,1.7111,0.318,0.0,49.128,,,0,,,1600 +blur,m02,0.25,,0,0,,0.2812,0.0,,1200,0.0322,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,1.6186,0.234,0.0,49.128,,,0,,,1600 +blur,m02,0.3,,0,0,,0.2547,0.0,,1200,0.0294,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,1.3902,0.2336,0.0,49.128,,,0,,,1600 +edge,m02,2.0,0.1674,,,1.9322,143.1471,0.0175,1,1200,0.1683,1.9902,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,557.8671,1151.9859,0.0,49.128,559.4701,1254.006,1,0.0,50.5131,1112 +edge,m02,1.0,0.1672,,,0.9409,139.0598,0.0168,1,1200,0.1683,0.9928,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,557.8671,1151.9859,0.0,49.128,558.0386,1252.4439,1,0.0,50.6231,1063 +edge,m02,0.5,0.1666,,,0.4611,137.3149,0.0165,1,1200,0.1683,0.5043,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,557.8671,1151.9859,0.0,49.128,562.0696,1360.2963,1,0.0,52.0,1039 +edge,m02,0.25,0.1678,,,0.2344,136.1135,0.0164,1,1200,0.1683,0.2601,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,557.8671,1151.9859,0.0,49.128,557.6356,1230.2225,1,0.0,49.9712,1027 +edge,m02,0.1,0.1673,,,0.1153,135.1483,0.0163,1,1200,0.169,0.0972,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,563.0345,1163.8865,0.0,49.128,567.7943,1376.8739,1,0.0,52.0,1019 +edge,m02,0.0,0.1685,,,0.0303,134.8203,0.0163,1,1200,0.1695,0.0,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,566.0111,1169.2867,0.0,49.128,568.2238,1281.1487,1,0.0,50.7825,1014 +edge,m02,-0.5,0.1655,0,0,0.3159,130.8715,0.0162,0,1200,0.167,0.0,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,550.5111,1143.247,0.0,49.128,541.1429,1240.3825,0,0.0,50.5094,990 +edge,m02,-1.0,0.1653,0,0,0.036,126.7834,0.0161,0,1200,0.167,0.0,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,556.8796,1146.667,0.0,49.128,570.4855,1444.2984,0,0.0,52.9707,965 +edge,m02,-2.0,0.1639,0,0,0.468,123.4671,0.0155,0,1200,0.1666,0.0,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,540.6649,1118.0799,0.0,49.128,523.9014,1207.9635,0,0.0,51.0088,916 +glare,m02,0.3,0.1365,,,10.9981,219.8799,0.0154,1,1200,0.1364,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,351.6054,719.6157,0.0,49.128,351.8134,718.366,1,0.0,48.9129,1600 +glare,m02,0.5,0.163,,,2.5466,210.3237,0.0154,0,1200,0.1189,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,242.3384,493.3545,0.0,49.128,981.2329,1561.035,1,0.0979,44.8219,1600 +glare,m02,0.7,0.163,,,2.5466,202.3038,0.0154,0,1200,0.1065,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,157.5923,317.7023,0.0,49.128,981.2329,1561.035,1,0.0979,44.8219,1600 +glare,m02,0.85,0.163,,,2.5466,197.3299,0.0154,0,1200,0.1018,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,109.6686,219.1556,0.0,49.128,981.2329,1561.035,1,0.0979,44.8219,1600 +glare,m02,1.0,0.163,0,0,2.5466,193.2845,0.0187,0,1200,0.1017,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,75.6233,149.164,0.2259,49.128,981.2329,1561.035,0,0.0979,44.8219,1600 +glare,m02,1.5,0.163,0,0,2.5466,184.4895,0.0354,0,1200,0.0714,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,24.1875,45.8068,0.6203,49.128,981.2329,1561.035,0,0.0979,44.8219,1600 +glare,m02,2.5,0.1521,0,0,3.0408,174.6777,0.0558,0,1200,0.0159,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,1.1257,1.6407,0.9049,49.128,974.7794,1487.6421,0,0.1456,47.6847,1600 +glare,m02,4.0,0.163,0,0,2.5466,165.4238,0.075,0,1200,0.0,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,0.0,0.0,1.0,49.128,981.2329,1561.035,0,0.0979,44.8219,1600 +motion,m02,0.05,0.1649,,,10.342,213.363,0.0152,1,1200,0.1664,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,492.8342,1097.7337,0.0,49.128,505.901,1303.1538,1,0.0,51.9084,1600 +motion,m02,0.1,0.1566,,,8.8462,146.5171,0.014,1,1200,0.1565,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,406.4827,913.1936,0.0,49.128,342.5361,1201.7449,1,0.0,60.4651,1600 +motion,m02,0.15,0.1974,,,3.5649,125.0604,0.0136,0,1200,0.1487,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,377.4794,830.7609,0.0,49.128,237.4797,2719.8177,1,0.0,131.0915,1600 +motion,m02,0.2,0.1365,,,9.2,108.9202,0.0131,1,1200,0.138,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,353.2178,763.4411,0.0,49.128,282.3054,911.9067,1,0.0,58.1395,1600 +motion,m02,0.3,0.1249,,,8.3451,95.0211,0.0125,1,1200,0.1241,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,331.7779,711.7188,0.0,49.128,227.8478,844.3862,1,0.0,61.7754,1600 +motion,m02,0.4,0.1154,,,8.1742,86.4769,0.0122,1,1200,0.1142,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,317.0194,679.7497,0.0,49.128,206.9227,684.8821,1,0.0,63.0593,1600 +motion,m02,0.5,0.1088,0,0,8.6196,79.9973,0.0119,0,1200,0.1073,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,306.2598,658.7425,0.0,49.128,199.9681,621.5711,0,0.0,59.8019,1600 +motion,m02,0.7,,0,0,,70.6259,0.0114,,1200,0.0979,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,288.1521,624.7312,0.0,49.128,,,0,,,1600 +motion,m02,1.0,,0,0,,58.6038,0.0108,,1200,0.0917,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,261.644,578.1008,0.0,49.128,,,0,,,1600 +scale,m02,48,0.1636,,,2.5538,239.7253,0.0153,0,1172,0.1683,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,595.1709,1181.5809,0.0,47.9817,1031.0407,1579.0079,1,0.0913,43.6482,1563 +scale,m02,40,0.1669,,,9.5959,238.5341,0.0153,1,977,0.1683,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,1016.838,1184.8379,0.0,39.9984,1054.7433,1609.2088,1,0.0,45.3186,1303 +scale,m02,32,0.1659,,,10.2941,233.7761,0.0153,1,782,0.1681,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,1821.5835,1821.5835,0.0,32.0151,1883.0305,1272.5676,1,0.0,34.0,1042 +scale,m02,26,0.1655,,,10.1429,574.7418,0.0156,1,635,0.1668,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,2952.3888,927.0127,0.0,25.9969,2950.8124,1123.1771,1,0.0,27.956,847 +scale,m02,22,0.1684,,,9.2308,774.8919,0.0156,1,537,0.165,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,3988.9248,771.3488,0.0,21.9848,3826.0585,1157.5777,1,0.0,25.9517,716 +scale,m02,18,0.1598,,,8.7125,1036.2637,0.0158,1,440,0.1614,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,5335.2702,568.4749,0.0,18.0136,5604.7673,1055.739,1,0.0,22.2525,586 +scale,m02,15,0.1387,,,6.1111,1270.5575,0.0157,0,366,0.1561,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,6879.5224,403.8308,0.0,14.984,5422.5423,540.4736,1,0.0,18.0,489 +scale,m02,12,0.1464,,,9.1734,1556.084,0.0152,1,293,0.1487,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,8353.8767,213.7948,0.0,11.9954,7850.4262,352.8603,1,0.0,14.1143,391 +scale,m02,10,0.1358,,,7.3854,1744.799,0.0145,1,244,0.1394,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,9215.0063,122.5914,0.0,9.9894,7191.8191,330.2166,1,0.0,14.584,326 +scale,m02,8,,0,0,,1927.7715,0.0129,,195,0.1293,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,10117.9641,49.7509,0.0,7.9833,,,0,,,261 +scale,m02,6,,0,0,,2127.508,0.0111,,147,0.1116,10.9482,2b529b822606e1cf1fcccaf68186d6ac7ad9a585a2802b919a0923e60d1cbeee,10083.4147,22.6294,0.0,6.0182,,,0,,,195 +blur,m03,0.02,0.0969,,,5.877,103.8524,0.0,1,1536,0.0975,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,60.4369,370.3137,0.0,59.9962,59.6357,377.7128,1,0.0,60.683,2048 +blur,m03,0.04,0.0825,,,4.977,17.4632,0.0,1,1536,0.0761,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,6.3958,46.1143,0.0,59.9962,5.6763,67.9178,1,0.0,70.6544,2048 +blur,m03,0.06,0.0521,,,5.9124,4.5754,0.0,1,1536,0.0646,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,2.1277,8.3156,0.0,59.9962,2.0436,7.6359,1,0.0,60.3483,2048 +blur,m03,0.08,0.0445,,,5.9048,1.7725,0.0,1,1536,0.0575,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,1.5563,2.4754,0.0,59.9962,1.5253,2.2013,1,0.0,60.4203,2048 +blur,m03,0.1,0.0377,0,0,6.3158,0.919,0.0,0,1536,0.0527,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,1.473,0.9956,0.0,59.9962,1.5249,0.755,0,0.0,56.5581,2048 +blur,m03,0.13,,0,0,,0.4701,0.0,,1536,0.0474,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,1.4398,0.438,0.0,59.9962,,,0,,,2048 +blur,m03,0.16,,0,0,,0.3104,0.0,,1536,0.0437,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,1.4936,0.2773,0.0,59.9962,,,0,,,2048 +blur,m03,0.2,,0,0,,0.226,0.0,,1536,0.04,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,1.4157,0.198,0.0,59.9962,,,0,,,2048 +blur,m03,0.25,,0,0,,0.1846,0.0,,1536,0.0368,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,1.1616,0.1784,0.0,59.9962,,,0,,,2048 +blur,m03,0.3,,0,0,,0.1668,0.0,,1536,0.0346,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,1.0039,0.1789,0.0,59.9962,,,0,,,2048 +edge,m03,2.0,0.1264,,,2.0969,254.9227,0.0005,1,1536,0.1188,1.9947,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,400.3871,1218.7012,0.0,59.9962,415.4776,1209.2453,1,0.0,58.4884,1358 +edge,m03,1.0,0.1187,,,1.0296,247.7389,0.0005,1,1536,0.1188,0.9947,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,400.3871,1218.7012,0.0,59.9962,397.9129,1221.0027,1,0.0,60.4755,1298 +edge,m03,0.5,0.1199,,,0.5172,242.5907,0.0005,1,1536,0.1188,0.4946,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,400.3871,1218.7012,0.0,59.9962,398.3549,1227.9808,1,0.0,60.1316,1268 +edge,m03,0.25,0.1205,,,0.305,238.7736,0.0005,1,1536,0.1188,0.2446,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,400.3871,1218.7012,0.0,59.9962,412.6067,1201.5159,1,0.0,58.4884,1253 +edge,m03,0.1,0.1173,,,0.1045,238.0827,0.0005,1,1536,0.1201,0.0946,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,409.2813,1247.1634,0.0,59.9962,395.4131,1245.8128,1,0.0,61.5986,1244 +edge,m03,0.0,0.1205,,,0.0334,237.6939,0.0005,1,1536,0.121,0.0,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,415.3468,1264.405,0.0,59.9962,410.7848,1269.3964,1,0.0,60.7295,1238 +edge,m03,-0.5,0.1189,0,0,0.1909,231.8867,0.0005,0,1536,0.1203,0.0,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,408.0653,1234.075,0.0,59.9962,416.586,1200.1057,0,0.0,58.4884,1208 +edge,m03,-1.0,0.1231,0,0,0.3844,227.4827,0.0005,0,1536,0.1226,0.0,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,415.6265,1244.3098,0.0,59.9962,432.5255,1234.7444,0,0.0,58.0465,1178 +edge,m03,-2.0,0.1274,0,0,0.1787,220.6493,0.0005,0,1536,0.1279,0.0,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,435.0666,1312.034,0.0,59.9962,430.3114,1314.9324,0,0.0,60.7418,1118 +glare,m03,0.3,0.0973,,,5.9059,281.2057,0.0003,1,1536,0.0973,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,247.4597,756.2685,0.0,59.9962,247.4597,751.4731,1,0.0,60.4095,2048 +glare,m03,0.5,0.0851,,,5.8968,270.5291,0.0003,1,1536,0.0852,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,168.4263,517.6668,0.0,59.9962,168.0223,521.6322,1,0.0,60.4949,2048 +glare,m03,0.7,0.0751,,,5.8526,261.301,0.0003,1,1536,0.0758,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,107.8597,333.9652,0.0002,59.9962,105.2734,324.8667,1,0.0002,60.916,2048 +glare,m03,0.85,0.0706,,,5.8248,255.3012,0.0005,1,1536,0.0713,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,74.5688,232.062,0.0092,59.9962,72.7932,225.5819,1,0.0089,61.1837,2048 +glare,m03,1.0,0.0617,0,0,7.625,250.0693,0.0052,0,1536,0.0692,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,51.6417,160.9716,0.305,59.9962,58.2029,111.6343,0,0.2418,47.6279,2048 +glare,m03,1.5,0.1039,0,0,0.1395,236.9481,0.0189,0,1536,0.0496,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,17.1319,52.0621,0.6652,59.9962,176.4351,1035.6132,0,0.0,128.0,2048 +glare,m03,2.5,0.1039,0,0,0.1395,221.1274,0.0371,0,1536,0.0142,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,0.7826,1.433,0.9313,59.9962,176.4351,1035.6132,0,0.0,128.0,2048 +glare,m03,4.0,0.1039,0,0,0.1395,207.576,0.0537,0,1536,0.0,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,0.0,0.0,1.0,59.9962,176.4351,1035.6132,0,0.0,128.0,2048 +motion,m03,0.05,0.1097,,,5.95,224.1877,0.0003,1,1536,0.1097,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,189.7627,801.6978,0.0,59.9962,189.7627,801.6978,1,0.0,60.0,2048 +motion,m03,0.1,0.0937,,,5.1142,128.108,0.0002,1,1536,0.0948,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,78.5062,332.6232,0.0,59.9962,67.3706,380.2663,1,0.0,68.9287,2048 +motion,m03,0.15,0.0875,,,4.8725,90.7986,0.0002,1,1536,0.0834,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,51.7723,196.1825,0.0,59.9962,43.5826,218.707,1,0.0,71.7515,2048 +motion,m03,0.2,0.0826,,,5.95,73.9017,0.0002,1,1536,0.0777,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,40.1942,147.5374,0.0,59.9962,40.0967,146.9968,1,0.0,60.0,2048 +motion,m03,0.3,0.0734,,,6.3684,57.7321,0.0001,1,1536,0.0693,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,29.1773,103.4811,0.0,59.9962,30.7636,96.7982,1,0.0,56.5581,2048 +motion,m03,0.4,,0,0,,49.5611,0.0001,,1536,0.0646,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,23.3042,81.3608,0.0,59.9962,,,0,,,2048 +motion,m03,0.5,0.0579,0,0,7.1765,44.4966,0.0001,0,1536,0.0617,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,19.3875,66.4556,0.0,59.9962,23.381,59.0447,0,0.0,50.6047,2048 +motion,m03,0.7,,0,0,,37.9477,0.0001,,1536,0.0597,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,14.5894,49.2636,0.0,59.9962,,,0,,,2048 +motion,m03,1.0,,0,0,,32.3802,0.0001,,1536,0.0591,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,10.5777,35.6781,0.0,59.9962,,,0,,,2048 +scale,m03,48,0.117,,,5.6553,336.4025,0.0003,1,1229,0.1181,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,751.4741,1212.3005,0.0,48.0047,710.5602,1267.9691,1,0.0,50.6899,1639 +scale,m03,40,0.1202,,,6.3158,331.4492,0.0003,1,1024,0.1192,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,1257.4575,1208.331,0.0,39.9974,1325.1227,1160.9467,1,0.0,38.0,1365 +scale,m03,32,0.144,,,5.5882,320.3034,0.0004,1,819,0.1164,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,2165.2207,2165.2207,0.0,31.9901,2190.2388,1316.6559,1,0.0,33.9585,1092 +scale,m03,26,0.1188,,,5.9231,769.979,0.0002,1,666,0.1155,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,3198.0137,931.6585,0.0,26.014,3217.0805,870.7057,1,0.0,26.0,888 +scale,m03,22,0.127,,,5.9091,939.5022,0.0003,1,563,0.1119,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,3947.7901,668.597,0.0,21.9908,4017.3339,646.8101,1,0.0,21.961,751 +scale,m03,18,0.1163,,,5.3,1099.9462,0.0001,1,461,0.1062,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,4524.3261,401.379,0.0,18.0067,4085.5062,481.2983,1,0.0,19.9567,614 +scale,m03,15,0.1166,,,6.4286,1195.1529,0.0001,1,384,0.1002,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,4581.0645,214.2217,0.0,14.999,5131.4857,199.9218,1,0.0,14.0,512 +scale,m03,12,0.0898,,,6.0,1282.4195,0.0001,1,307,0.0945,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,4562.8753,103.3824,0.0,11.9914,4436.0502,98.4726,1,0.0,11.961,410 +scale,m03,10,0.0956,,,6.0,1277.3637,0.0002,1,256,0.0845,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,3933.4432,48.1714,0.0,9.9994,3756.137,49.2308,1,0.0,10.0,341 +scale,m03,8,0.1407,,,3.2857,1273.133,0.0002,0,205,0.078,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,3535.6924,15.9796,0.0,8.0073,4988.5394,169.2579,1,0.0,13.932,273 +scale,m03,6,,0,0,,1371.3059,0.0003,,154,0.0696,5.9503,9da1e58889b4a059a3be2a14278ee387eb916b94ea80058bac80b21fd95bc0a7,2967.6199,6.5895,0.0,6.0152,,,0,,,205 +blur,m04,0.02,0.1049,,,9.1463,82.6571,0.0068,1,1920,0.1049,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,43.5825,190.8432,0.0,53.3952,43.5825,190.8432,1,0.0,53.3953,1440 +blur,m04,0.04,0.0917,,,9.1463,13.6336,0.006,1,1920,0.0917,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,8.1885,39.9023,0.0,53.3952,8.1885,39.9023,1,0.0,53.3953,1440 +blur,m04,0.06,0.0841,,,9.1463,4.252,0.0054,1,1920,0.0841,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,3.1149,10.1893,0.0,53.3952,3.1149,10.1893,1,0.0,53.3953,1440 +blur,m04,0.08,0.0453,,,10.7743,1.8054,0.0046,1,1920,0.0801,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,2.0555,3.2478,0.0,53.3952,1.9018,2.0589,1,0.0,52.3051,1440 +blur,m04,0.1,,0,0,,0.9738,0.0035,,1920,0.078,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,1.737,1.3875,0.0,53.3952,,,0,,,1440 +blur,m04,0.13,,0,0,,0.5418,0.0021,,1920,0.0763,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,1.6421,0.7312,0.0,53.3952,,,0,,,1440 +blur,m04,0.16,,0,0,,0.3801,0.0007,,1920,0.0755,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,1.563,0.5245,0.0,53.3952,,,0,,,1440 +blur,m04,0.2,,0,0,,0.2876,0.0,,1920,0.0753,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,1.4141,0.4042,0.0,53.3952,,,0,,,1440 +blur,m04,0.25,,0,0,,0.2359,0.0,,1920,0.0761,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,1.2149,0.3411,0.0,53.3952,,,0,,,1440 +blur,m04,0.3,,0,0,,0.2113,0.0,,1920,0.0774,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,1.173,0.3121,0.0,53.3952,,,0,,,1440 +edge,m04,2.0,0.1156,,,1.9818,268.7307,0.007,1,1920,0.1154,1.9986,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,195.4383,488.8027,0.0,53.3952,195.9857,486.3518,1,0.0,53.3953,983 +edge,m04,1.0,0.1154,,,0.9938,272.6048,0.0069,1,1920,0.1154,1.006,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,195.4383,488.8027,0.0,53.3952,195.4383,488.8027,1,0.0,53.3953,930 +edge,m04,0.5,0.1154,,,0.522,274.1304,0.0065,1,1920,0.1154,0.5004,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,195.4383,488.8027,0.0,53.3952,195.6617,486.1968,1,0.0,53.3953,903 +edge,m04,0.25,0.1155,,,0.2613,273.8956,0.0065,1,1920,0.1154,0.2569,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,195.4383,488.8027,0.0,53.3952,195.8027,484.1402,1,0.0,53.3953,890 +edge,m04,0.1,0.1172,,,0.1108,273.8796,0.0065,1,1920,0.1159,0.1071,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,198.3455,490.4242,0.0,53.3952,206.2559,430.2963,1,0.0,50.3953,882 +edge,m04,0.0,0.1162,,,0.0588,273.6136,0.0064,1,1920,0.1163,0.0,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,200.6734,496.016,0.0,53.3952,194.9802,349.5017,1,0.0,47.4419,876 +edge,m04,-0.5,0.1169,0,0,0.2088,271.3754,0.0063,0,1920,0.1171,0.0,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,194.4086,483.3906,0.0,53.3952,193.8954,479.8053,0,0.0,53.3953,850 +edge,m04,-1.0,0.1275,0,0,0.3838,267.6853,0.0062,0,1920,0.1176,0.0,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,189.6287,465.4291,0.0,53.3952,183.4458,530.7563,0,0.0,57.0814,823 +edge,m04,-2.0,0.1204,0,0,0.0506,264.054,0.0059,0,1920,0.1205,0.0,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,180.7369,445.6595,0.0,53.3952,180.1284,445.5303,0,0.0,53.717,769 +glare,m04,0.3,0.0965,,,10.3529,275.6346,0.0072,1,1920,0.0966,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,120.7103,299.3148,0.0,53.3952,116.551,208.4556,1,0.0,47.4419,1440 +glare,m04,0.5,0.0858,,,9.1463,269.722,0.0072,1,1920,0.0858,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,81.6972,201.1654,0.0,53.3952,81.6972,201.1654,1,0.0,53.3953,1440 +glare,m04,0.7,0.0771,,,9.1463,264.7097,0.0072,1,1920,0.0771,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,51.59,126.0885,0.0001,53.3952,51.59,126.0885,1,0.0001,53.3953,1440 +glare,m04,0.85,0.0717,,,10.3529,261.5422,0.0075,1,1920,0.0724,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,35.0798,84.4855,0.0306,53.3952,33.3145,58.7204,1,0.035,47.4419,1440 +glare,m04,1.0,0.046,0,0,12.625,258.9091,0.0117,0,1920,0.0696,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,23.5219,55.7709,0.3186,53.3952,32.5765,50.0782,0,0.2101,44.6512,1440 +glare,m04,1.5,0.0592,0,0,17.3,252.867,0.028,0,1920,0.0454,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,6.6693,15.2314,0.6563,53.3952,39.1357,16.1162,0,0.055,27.907,1440 +glare,m04,2.5,0.0782,0,0,11.6481,246.5921,0.0497,0,1920,0.008,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,0.2157,0.2121,0.95,53.3952,63.0036,59.5898,0,0.1446,36.8903,1440 +glare,m04,4.0,0.1682,0,0,4.9,241.8243,0.0687,0,1920,0.0,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,0.0,0.0,1.0,53.3952,33.2654,157.7274,0,0.145,80.0,1440 +motion,m04,0.05,0.1118,,,9.1463,185.5507,0.007,1,1920,0.1118,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,118.5957,371.1608,0.0,53.3952,118.5957,371.1608,1,0.0,53.3953,1440 +motion,m04,0.1,0.1071,,,9.1463,114.5199,0.0068,1,1920,0.1071,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,84.4595,269.1273,0.0,53.3952,84.4595,269.1273,1,0.0,53.3953,1440 +motion,m04,0.15,0.1006,,,9.1463,78.3239,0.0066,1,1920,0.1007,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,65.83,201.1373,0.0,53.3952,65.6973,200.2389,1,0.0,53.3953,1440 +motion,m04,0.2,0.0961,,,9.6908,63.3648,0.0063,1,1920,0.0957,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,57.2673,171.1605,0.0,53.3952,58.0304,142.289,1,0.0,50.3953,1440 +motion,m04,0.3,0.0834,,,10.4661,51.1,0.0057,0,1920,0.09,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,49.6709,146.2653,0.0,53.3952,53.4502,128.5921,1,0.0,50.3953,1440 +motion,m04,0.4,0.0882,,,9.9677,44.6471,0.0054,1,1920,0.0865,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,45.5705,134.2404,0.0,53.3952,47.5739,114.6817,1,0.0,50.3953,1440 +motion,m04,0.5,,0,0,,39.7751,0.0052,,1920,0.0832,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,42.1913,124.7472,0.0,53.3952,,,0,,,1440 +motion,m04,0.7,,0,0,,34.8129,0.0051,,1920,0.079,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,38.7748,115.4085,0.0,53.3952,,,0,,,1440 +motion,m04,1.0,,0,0,,30.4725,0.005,,1920,0.0747,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,35.7368,107.7705,0.0,53.3952,,,0,,,1440 +scale,m04,48,0.1168,,,9.7614,291.2891,0.0071,1,1726,0.1156,9.1429,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,252.4307,465.692,0.0,48.0001,259.4816,431.0982,1,0.0,45.2151,1294 +scale,m04,40,0.117,,,9.7212,289.4387,0.0071,1,1438,0.1154,9.1507,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,420.5897,477.8907,0.0,39.9908,431.9164,429.2075,1,0.0,38.0,1079 +scale,m04,32,0.1169,,,9.8638,272.4104,0.0071,1,1151,0.1157,9.1438,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,778.1756,778.1756,0.0,32.0093,786.8235,398.7867,1,0.0,29.974,863 +scale,m04,26,0.1165,,,9.9131,632.2977,0.007,1,935,0.1146,9.1432,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,1265.5829,376.7969,0.0,26.0023,1263.8774,294.8035,1,0.0,23.9744,701 +scale,m04,22,0.1143,,,9.1779,681.5948,0.007,1,791,0.1144,9.1426,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,1702.7524,291.404,0.0,21.9977,1694.8908,288.9702,1,0.0,21.9722,593 +scale,m04,18,0.1113,,,9.1064,688.6928,0.007,1,647,0.112,9.1417,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,2205.1379,214.0498,0.0,17.9931,2164.1768,210.5907,1,0.0,17.9722,485 +scale,m04,15,0.1114,,,8.6197,692.8966,0.0069,1,539,0.1118,9.1634,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,2825.7522,148.1782,0.0,14.9896,2857.4125,140.2111,1,0.0,15.9704,405 +scale,m04,12,0.1052,,,9.1879,733.2342,0.0069,1,431,0.1059,9.1677,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,2934.3254,72.8268,0.0,11.9861,2845.2946,71.4739,1,0.0,11.9722,324 +scale,m04,10,0.128,,,7.5,790.6546,0.0068,1,360,0.102,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,3341.3024,48.161,0.0,10.0116,3863.6988,110.1615,1,0.0,12.0,270 +scale,m04,8,0.0955,0,1,12.0,876.6119,0.0063,0,288,0.0978,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,3467.7733,17.5926,0.0,8.0093,4270.1895,10.3492,0,0.0,6.0,216 +scale,m04,6,,0,0,,1003.5041,0.0061,,216,0.1,9.1464,db777b3071763bcdd9dea11d85f0c026d9cc549d9e46bf62bcdeb1f951f06aa2,2738.0464,6.9687,0.0,6.007,,,0,,,162 +blur,m05,0.02,0.1094,,,7.7706,27.103,0.0,1,2880,0.1094,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,5.8544,637.0681,0.0,147.6,5.835,643.8859,1,0.0,148.3778,3840 +blur,m05,0.04,0.0822,,,7.8505,3.3557,0.0,1,2880,0.082,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.602,55.8594,0.0,147.6,1.5945,55.0593,1,0.0,147.0893,3840 +blur,m05,0.06,0.0604,0,0,12.5926,0.9354,0.0,0,2880,0.0692,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.4777,9.16,0.0,147.6,1.6031,1.6466,0,0.0,95.0686,3840 +blur,m05,0.08,,0,0,,0.4302,0.0,,2880,0.0613,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.4127,2.5858,0.0,147.6,,,0,,,3840 +blur,m05,0.1,,0,0,,0.2688,0.0,,2880,0.0555,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.3552,1.0275,0.0,147.6,,,0,,,3840 +blur,m05,0.13,,0,0,,0.1835,0.0,,2880,0.0492,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.2289,0.3154,0.0,147.6,,,0,,,3840 +blur,m05,0.16,,0,0,,0.155,0.0,,2880,0.0448,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.006,0.147,0.0,147.6,,,0,,,3840 +blur,m05,0.2,,0,0,,0.138,0.0,,2880,0.0403,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,0.801,0.1029,0.0,147.6,,,0,,,3840 +blur,m05,0.25,,0,0,,0.1292,0.0,,2880,0.0361,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,0.637,0.1179,0.0,147.6,,,0,,,3840 +blur,m05,0.3,,0,0,,0.1256,0.0,,2880,0.0329,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,0.5191,0.1516,0.0,147.6,,,0,,,3840 +edge,m05,2.0,0.2034,,,2.2053,283.7506,0.0005,1,2880,0.2008,1.9995,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3913.6822,3722.1975,0.0102,147.6,4004.7344,3783.6673,1,0.0109,135.2093,2464 +edge,m05,1.0,0.2036,,,1.1216,292.7502,0.0005,1,2880,0.2008,0.9968,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3913.6822,3722.1975,0.0102,147.6,4013.5779,3759.9826,1,0.011,135.2093,2316 +edge,m05,0.5,0.203,,,0.5404,296.6596,0.0005,1,2880,0.2008,0.5022,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3913.6822,3722.1975,0.0102,147.6,3984.1266,3689.5846,1,0.0108,137.1513,2243 +edge,m05,0.25,0.2032,,,0.2882,297.3586,0.0005,1,2880,0.2008,0.2516,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3913.6822,3722.1975,0.0102,147.6,3993.3029,3809.8381,1,0.0108,137.0853,2206 +edge,m05,0.1,0.2055,,,0.1324,299.573,0.0005,1,2880,0.2037,0.1025,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,4062.4203,3861.9403,0.0106,147.6,4116.8921,3860.3254,1,0.0112,135.2093,2184 +edge,m05,0.0,0.2069,,,0.0174,299.3665,0.0005,1,2880,0.2056,0.0009,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,4170.8996,3974.2505,0.0109,147.6,4202.6898,4003.1675,1,0.0113,139.6501,2169 +edge,m05,-0.5,0.1584,0,0,1.3272,297.8181,0.0005,0,2880,0.2115,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,4428.7714,4221.0152,0.0105,147.6,380.4644,1017.6157,0,0.0,423.04,2095 +edge,m05,-1.0,0.1584,0,0,1.2602,284.6755,0.0004,0,2880,0.2156,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,4641.2249,4396.6415,0.0106,147.6,381.5979,1016.6254,0,0.0,422.9548,2021 +edge,m05,-2.0,0.1575,0,0,0.9216,242.8162,0.0002,0,2880,0.2176,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,4348.206,4516.5049,0.0069,147.6,381.2382,1034.2546,0,0.0,421.3418,1874 +glare,m05,0.3,0.1571,,,7.877,185.733,0.0005,1,2880,0.1569,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2402.3206,2292.7547,0.0121,147.6,2403.6445,2278.6903,1,0.0122,146.5397,3840 +glare,m05,0.5,0.1313,,,7.8627,169.7114,0.0005,1,2880,0.1313,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1618.2238,1550.8639,0.0142,147.6,1616.7886,1544.8278,1,0.0142,146.7899,3840 +glare,m05,0.7,0.1131,,,1.7766,156.5726,0.0006,0,2880,0.1111,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1012.2634,976.6565,0.0183,147.6,362.6355,891.3683,1,0.0,354.6301,3840 +glare,m05,0.85,0.1084,,,1.8193,148.5704,0.0007,0,2880,0.1014,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,675.4788,655.2598,0.0264,147.6,357.3981,857.4466,1,0.0,349.2592,3840 +glare,m05,1.0,0.0785,0,0,14.6127,142.2007,0.002,0,2880,0.098,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,438.6239,427.4379,0.1358,147.6,524.5012,768.9623,0,0.1047,73.786,3840 +glare,m05,1.5,0.1104,0,0,28.5,130.0413,0.0095,0,2880,0.0757,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,132.2149,130.6052,0.5689,147.6,1413.2877,760.1256,0,0.0013,33.4884,3840 +glare,m05,2.5,0.1365,0,0,2.1995,120.8752,0.0189,0,2880,0.0319,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,16.3351,16.6191,0.839,147.6,278.9233,515.3051,0,0.0,255.6056,3840 +glare,m05,4.0,0.1586,0,0,1.3271,114.6996,0.0277,0,2880,0.0015,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,0.037,0.0268,0.9982,147.6,318.1972,919.9706,0,0.0,423.1724,3840 +motion,m05,0.05,0.1382,,,7.8849,101.85,0.0,1,2880,0.138,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,351.2843,1765.5401,0.0,147.6,350.0402,1758.28,1,0.0,146.4023,3840 +motion,m05,0.1,0.1125,,,7.8108,48.5653,0.0,1,2880,0.1127,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,146.4348,693.5365,0.0,147.6,145.2702,690.3958,1,0.0,147.7817,3840 +motion,m05,0.15,0.1017,,,7.8804,34.2754,0.0,1,2880,0.1019,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,89.7787,469.0843,0.0,147.6,88.4522,462.1417,1,0.0,146.5674,3840 +motion,m05,0.2,0.0936,,,7.0418,25.912,0.0,1,2880,0.0948,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,61.8667,354.2692,0.0,147.6,62.39,409.3395,1,0.0,162.313,3840 +motion,m05,0.3,0.0786,0,0,15.4286,17.8835,0.0,0,2880,0.0882,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,41.8069,242.2126,0.0,147.6,46.6594,97.5618,0,0.0,78.1395,3840 +motion,m05,0.4,0.0836,0,0,14.4057,13.1059,0.0,0,2880,0.0837,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,30.7965,169.6968,0.0,147.6,57.7019,96.0032,0,0.0,96.3131,3840 +motion,m05,0.5,,0,0,,10.1917,0.0,,2880,0.0795,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,24.8768,123.8003,0.0,147.6,,,0,,,3840 +motion,m05,0.7,,0,0,,7.0365,0.0,,2880,0.073,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,18.277,73.1095,0.0,147.6,,,0,,,3840 +motion,m05,1.0,,0,0,,4.7728,0.0,,2880,0.0652,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,13.2798,45.5992,0.0,147.6,,,0,,,3840 +scale,m05,48,0.1404,,,2.0961,203.28,0.0001,0,937,0.1654,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6959.0378,3543.6849,0.0,48.0212,623.0639,607.1832,1,0.0,86.8485,1249 +scale,m05,40,0.1594,,,9.0823,193.1517,0.0001,1,780,0.158,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6944.3974,3505.4359,0.0,39.975,7077.0614,3352.9895,1,0.0,34.5428,1041 +scale,m05,32,0.1459,,,7.0366,439.8669,0.0001,1,624,0.1486,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,7023.2728,7023.2728,0.0,31.98,6923.1829,3009.6466,1,0.0,35.1329,833 +scale,m05,26,0.1376,,,7.5838,465.7153,0.0,1,507,0.1384,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6715.8181,1756.482,0.0,25.9837,6636.564,1889.571,1,0.0,26.6674,676 +scale,m05,22,0.1283,,,6.9705,489.0244,0.0,1,429,0.1309,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6738.4925,1082.8819,0.0,21.9862,6537.5894,1406.6103,1,0.0,24.3832,572 +scale,m05,18,0.1216,1,1,9.625,525.761,0.0,0,351,0.1216,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6490.8604,568.5921,0.0,17.9887,6204.5012,337.2288,0,0.0,15.9545,468 +scale,m05,15,,0,0,,551.2115,0.0,,293,0.1135,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6034.5927,296.8633,0.0,15.0162,,,0,,,390 +scale,m05,12,,0,0,,578.162,0.0,,234,0.1017,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,4694.4372,109.0246,0.0,11.9925,,,0,,,312 +scale,m05,10,,0,0,,618.1829,0.0,,195,0.0943,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,4255.1771,55.3078,0.0,9.9937,,,0,,,260 +scale,m05,8,,0,0,,669.9056,0.0,,156,0.0848,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2971.4885,15.7263,0.0,7.995,,,0,,,208 +scale,m05,6,,0,0,,784.511,0.0,,117,0.0771,7.8195,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2593.4002,7.1145,0.0,5.9962,,,0,,,156 +blur,m06,0.02,0.1403,,,22.2,83.6196,0.0,1,2880,0.1403,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,184.4155,665.0824,0.0,55.8144,184.4155,665.0824,1,0.0,55.814,3840 +blur,m06,0.04,0.1107,,,22.2,32.1707,0.0,1,2880,0.1113,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,13.3236,84.4419,0.0,55.8144,13.1544,82.9942,1,0.0,55.814,3840 +blur,m06,0.06,0.093,,,22.2,13.22,0.0,1,2880,0.0934,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3.9867,19.9535,0.0,55.8144,3.9444,19.6138,1,0.0,55.814,3840 +blur,m06,0.08,0.0812,,,22.2,6.2347,0.0,1,2880,0.0814,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2.2289,5.7364,0.0,55.8144,2.207,5.6474,1,0.0,55.814,3840 +blur,m06,0.1,0.07,0,0,18.3157,3.391,0.0,0,2880,0.0732,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.8494,2.0931,0.0,55.8144,1.7722,3.666,0,0.0,67.3465,3840 +blur,m06,0.13,,0,0,,1.6146,0.0,,2880,0.0646,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.7026,0.6979,0.0,55.8144,,,0,,,3840 +blur,m06,0.16,,0,0,,0.9198,0.0,,2880,0.0586,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.6242,0.359,0.0,55.8144,,,0,,,3840 +blur,m06,0.2,,0,0,,0.5225,0.0,,2880,0.0529,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.519,0.2392,0.0,55.8144,,,0,,,3840 +blur,m06,0.25,,0,0,,0.3234,0.0,,2880,0.0479,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.3735,0.1991,0.0,55.8144,,,0,,,3840 +blur,m06,0.3,,0,0,,0.2406,0.0,,2880,0.0447,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.2514,0.2008,0.0,55.8144,,,0,,,3840 +edge,m06,2.0,0.1842,,,1.7378,170.2312,0.0,1,2880,0.1843,2.0004,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3686.541,3204.1361,0.0003,55.8144,3713.8549,3349.6614,1,0.0003,61.8682,2054 +edge,m06,1.0,0.1841,,,0.8864,172.2914,0.0,1,2880,0.1843,0.9971,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3686.541,3204.1361,0.0003,55.8144,3701.8254,3312.859,1,0.0002,60.0177,1998 +edge,m06,0.5,0.1842,,,0.4062,173.2098,0.0,1,2880,0.1843,0.4954,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3686.541,3204.1361,0.0003,55.8144,3712.7192,3390.1867,1,0.0002,58.9804,1970 +edge,m06,0.25,0.1848,,,0.1903,173.5094,0.0,1,2880,0.1843,0.2446,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3686.541,3204.1361,0.0003,55.8144,3732.7744,3306.3807,1,0.0002,59.8952,1956 +edge,m06,0.1,0.1865,,,0.0432,176.0998,0.0,1,2880,0.1859,0.1012,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3755.8158,3330.813,0.0003,55.8144,3798.5317,3278.2822,1,0.0002,64.3385,1948 +edge,m06,0.0,0.1878,,,0.0487,173.9407,0.0,1,2880,0.1871,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3803.4557,3306.4333,0.0003,55.8144,3866.218,3545.9615,1,0.0002,59.7604,1942 +edge,m06,-0.5,0.1876,0,0,0.052,175.1566,0.0,0,2880,0.1868,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3713.3642,3273.3918,0.0003,55.8144,3769.1241,3403.7657,0,0.0003,57.4755,1914 +edge,m06,-1.0,0.1847,0,0,0.0769,176.119,0.0,0,2880,0.1862,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3632.6836,3334.5509,0.0003,55.8144,3544.4595,3207.3479,0,0.0003,54.4186,1887 +edge,m06,-2.0,0.1855,0,0,0.1664,168.6507,0.0,0,2880,0.1843,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3369.8545,3129.5268,0.0004,55.8144,3416.2463,3017.802,0,0.0003,50.2326,1831 +glare,m06,0.3,0.1453,,,22.2,110.2112,0.0,1,2880,0.1453,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2268.2114,1946.6222,0.0003,55.8144,2268.2114,1946.6222,1,0.0003,55.814,3840 +glare,m06,0.5,0.1246,,,22.2,102.7313,0.0,1,2880,0.1253,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1529.841,1296.3399,0.0004,55.8144,1509.559,1294.097,1,0.0004,55.814,3840 +glare,m06,0.7,0.1129,,,22.2,96.6769,0.0,1,2880,0.1132,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,958.1994,796.7773,0.0008,55.8144,945.7581,791.8472,1,0.0007,55.814,3840 +glare,m06,0.85,0.1111,,,22.2,93.0444,0.0,1,2880,0.1109,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,638.6003,520.3135,0.0015,55.8144,630.5236,513.7566,1,0.0014,55.814,3840 +glare,m06,1.0,0.057,0,0,39.8333,90.2207,0.0006,0,2880,0.1149,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,412.8868,329.0448,0.1707,55.8144,284.947,153.617,0,0.0046,33.4884,3840 +glare,m06,1.5,0.1647,0,0,24.9347,85.2085,0.0051,0,2880,0.081,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,99.7133,75.5742,0.6208,55.8144,494.8967,336.3948,0,0.0355,45.4439,3840 +glare,m06,2.5,0.1091,0,0,4.7342,81.7084,0.0109,0,2880,0.0187,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.9778,0.9447,0.9045,55.8144,223.0218,254.2379,0,0.0,130.6251,3840 +glare,m06,4.0,0.1117,0,0,4.6817,79.429,0.016,0,2880,0.0,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,0.0,0.0,1.0,55.8144,221.4241,264.0608,0,0.0,131.9498,3840 +motion,m06,0.05,0.1567,,,22.2,108.3584,0.0,1,2880,0.1567,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,821.2884,1486.8398,0.0,55.8144,821.2884,1486.8398,1,0.0,55.814,3840 +motion,m06,0.1,0.1332,,,22.2,76.0113,0.0,1,2880,0.1339,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,378.5388,561.8302,0.0,55.8144,373.3658,554.8771,1,0.0,55.814,3840 +motion,m06,0.15,0.1244,,,22.2,60.0889,0.0,1,2880,0.125,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,302.2932,435.2155,0.0,55.8144,298.1504,429.7219,1,0.0,55.814,3840 +motion,m06,0.2,0.1144,,,22.2,45.9141,0.0,1,2880,0.115,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,229.5717,327.4119,0.0,55.8144,226.4233,321.9132,1,0.0,55.814,3840 +motion,m06,0.3,0.0989,0,0,24.5556,32.1137,0.0,0,2880,0.102,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,164.71,228.1997,0.0,55.8144,210.519,259.1129,0,0.0,50.2326,3840 +motion,m06,0.4,0.0957,0,0,4.8261,26.492,0.0,0,2880,0.0961,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,136.9277,190.7449,0.0,55.8144,53.5295,112.7874,0,0.0,128.3721,3840 +motion,m06,0.5,,0,0,,22.224,0.0,,2880,0.0917,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,114.8455,162.2737,0.0,55.8144,,,0,,,3840 +motion,m06,0.7,,0,0,,17.6825,0.0,,2880,0.088,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,90.0729,132.2141,0.0,55.8144,,,0,,,3840 +motion,m06,1.0,,0,0,,13.8428,0.0,,2880,0.0838,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,66.9675,103.0169,0.0,55.8144,,,0,,,3840 +scale,m06,48,0.179,,,22.2,125.2072,0.0,1,2477,0.1829,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,4467.2437,3212.474,0.0003,48.0043,4166.2882,3011.9914,1,0.0003,47.911,3302 +scale,m06,40,0.1782,,,22.2,125.6617,0.0,1,2064,0.1793,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,5461.5535,3076.857,0.0001,40.0003,5391.8791,3038.4756,1,0.0001,40.0,2752 +scale,m06,32,0.1726,,,22.2,126.527,0.0,1,1651,0.1726,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6044.9656,6044.9656,0.0,31.9964,6044.9656,6044.9656,1,0.0,31.9961,2202 +scale,m06,26,0.1668,,,22.2,126.6421,0.0,1,1342,0.1668,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6441.7429,1838.9784,0.0,26.008,6441.7429,1838.9784,1,0.0,26.0078,1789 +scale,m06,22,0.1591,,,22.2,124.3452,0.0,1,1135,0.1603,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6090.5477,1092.6478,0.0,21.9963,5983.2292,1092.2992,1,0.0,21.9961,1514 +scale,m06,18,0.1514,,,17.7562,117.8595,0.0,1,929,0.1515,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,5652.3907,555.2362,0.0,18.004,6120.354,1170.4101,1,0.0,22.3868,1238 +scale,m06,15,0.1438,,,20.75,105.1302,0.0,1,774,0.145,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,5862.8496,335.4406,0.0,15.0001,5692.0404,300.4096,1,0.0,16.0,1032 +scale,m06,12,0.1336,,,19.0,266.6371,0.0,1,619,0.1365,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6570.4868,170.0916,0.0,11.9962,5946.6643,264.9396,1,0.0,13.9774,826 +scale,m06,10,0.1267,,,17.6579,296.424,0.0,1,516,0.1294,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,7646.5019,107.5952,0.0,10.0001,6651.6653,190.0549,1,0.0,12.4669,688 +scale,m06,8,0.1245,0,1,30.0,342.2872,0.0,0,413,0.1196,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,8044.3645,38.5036,0.0,8.0039,9272.464,23.6037,0,0.0,5.9855,550 +scale,m06,6,,0,0,,411.2733,0.0,,310,0.1046,22.1997,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,6507.1141,14.8451,0.0,6.0078,,,0,,,413 +blur,m07,0.02,0.1993,,,8.8506,131.4135,0.0017,1,1536,0.1849,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,159.1406,294.2259,0.0,41.6717,120.9379,421.4139,1,0.0,51.0698,2048 +blur,m07,0.04,0.1858,,,9.5625,41.3175,0.001,1,1536,0.1672,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,33.9238,75.3803,0.0,41.6717,27.5744,98.4488,1,0.0,47.6279,2048 +blur,m07,0.06,0.1701,,,10.958,13.6071,0.0005,1,1536,0.1517,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,10.4869,21.9625,0.0,41.6717,9.4665,20.816,1,0.0,42.0698,2048 +blur,m07,0.08,0.1661,0,0,9.5324,5.4612,0.0002,0,1536,0.1391,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,4.8795,7.8184,0.0,41.6717,4.1959,10.8341,0,0.0,48.0465,2048 +blur,m07,0.1,0.1469,0,0,10.3779,2.6746,0.0001,0,1536,0.1289,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,3.0345,3.3382,0.0,41.6717,2.7313,5.4869,0,0.0,49.2571,2048 +blur,m07,0.13,,0,0,,1.2018,0.0001,,1536,0.117,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,2.3092,1.4009,0.0,41.6717,,,0,,,2048 +blur,m07,0.16,,0,0,,0.6871,0.0,,1536,0.1077,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,2.0271,0.856,0.0,41.6717,,,0,,,2048 +blur,m07,0.2,,0,0,,0.4034,0.0,,1536,0.0977,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,1.8234,0.5756,0.0,41.6717,,,0,,,2048 +blur,m07,0.25,,0,0,,0.2637,0.0,,1536,0.088,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,1.6718,0.4334,0.0,41.6717,,,0,,,2048 +blur,m07,0.3,,0,0,,0.2041,0.0,,1536,0.08,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,1.5083,0.3837,0.0,41.6717,,,0,,,2048 +edge,m07,2.0,0.1961,,,1.9471,176.1743,0.0036,1,1536,0.1954,1.9918,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,429.1108,589.6823,0.0,41.6717,439.7737,753.7317,1,0.0,44.6512,1235 +edge,m07,1.0,0.1982,,,0.9596,165.4966,0.0037,1,1536,0.1954,1.0079,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,429.1108,589.6823,0.0,41.6717,424.3387,790.658,1,0.0,46.3706,1194 +edge,m07,0.5,0.2003,,,0.4991,163.8589,0.0038,1,1536,0.1954,0.5039,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,429.1108,589.6823,0.0,41.6717,408.3177,848.5694,1,0.0,48.54,1173 +edge,m07,0.25,0.1942,,,0.3497,164.3803,0.0038,1,1536,0.1954,0.24,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,429.1108,589.6823,0.0,41.6717,446.1343,766.0875,1,0.0,44.6512,1162 +edge,m07,0.1,0.1972,,,0.1391,163.4485,0.0039,1,1536,0.1949,0.096,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,433.4797,596.5563,0.0,41.6717,429.8397,816.2218,1,0.0,46.8506,1156 +edge,m07,0.0,0.1951,,,0.097,160.4806,0.0039,1,1536,0.1946,0.0,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,437.0469,602.6166,0.0,41.6717,445.5175,782.0882,1,0.0,45.4055,1152 +edge,m07,-0.5,0.1945,0,0,0.3497,155.8077,0.004,0,1536,0.195,0.0,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,426.35,585.2046,0.0,41.6717,446.0228,767.0565,0,0.0,44.6512,1131 +edge,m07,-1.0,0.1939,0,0,0.05,152.7626,0.004,0,1536,0.1959,0.0,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,438.7442,603.4395,0.0,41.6717,466.2035,803.3897,0,0.0,44.6512,1110 +edge,m07,-2.0,0.1945,0,0,0.0443,152.8074,0.0042,0,1536,0.1967,0.0,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,429.9707,587.3744,0.0,41.6717,450.4719,817.1845,0,0.0,46.2165,1069 +glare,m07,0.3,0.1621,,,9.4654,209.9292,0.0022,1,1536,0.1561,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,265.1452,361.1336,0.0,41.6717,222.4506,427.2091,1,0.0,48.0698,2048 +glare,m07,0.5,0.1363,,,9.4654,203.4774,0.0022,1,1536,0.1342,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,178.514,242.6098,0.0,41.6717,158.7018,305.156,1,0.0,48.0698,2048 +glare,m07,0.7,0.1164,,,9.4654,198.0407,0.0022,1,1536,0.1181,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,112.2421,151.4466,0.0002,41.6717,109.7436,210.253,1,0.0001,48.0698,2048 +glare,m07,0.85,0.1072,,,9.4654,194.639,0.0024,1,1536,0.1113,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,75.507,100.989,0.0219,41.6717,82.2083,156.9029,1,0.0213,48.0698,2048 +glare,m07,1.0,0.1627,0,0,7.7628,191.7992,0.0047,0,1536,0.1101,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,49.8257,65.8717,0.2238,41.6717,857.4213,1353.9985,0,0.0,47.3741,2048 +glare,m07,1.5,0.1627,0,0,7.7628,185.5196,0.0136,0,1536,0.075,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,13.1644,16.7326,0.6354,41.6717,857.4213,1353.9985,0,0.0,47.3741,2048 +glare,m07,2.5,0.1627,0,0,7.7628,179.3325,0.0244,0,1536,0.0171,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,0.4764,0.427,0.9425,41.6717,857.4213,1353.9985,0,0.0,47.3741,2048 +glare,m07,4.0,0.1627,0,0,7.7628,174.567,0.0344,0,1536,0.0,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,0.0,0.0,1.0,41.6717,857.4213,1353.9985,0,0.0,47.3741,2048 +motion,m07,0.05,0.2051,,,9.4654,198.7446,0.0021,1,1536,0.1937,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,330.9185,510.3163,0.0,41.6717,262.8847,583.497,1,0.0,48.0698,2048 +motion,m07,0.1,0.2011,,,9.4654,140.7461,0.0017,1,1536,0.1887,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,222.1195,353.3308,0.0,41.6717,175.653,410.1988,1,0.0,48.0698,2048 +motion,m07,0.15,0.1971,,,9.4654,102.5648,0.0013,1,1536,0.1839,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,174.4575,269.3223,0.0,41.6717,137.067,307.3323,1,0.0,48.0698,2048 +motion,m07,0.2,0.1938,,,9.5625,83.2505,0.0009,1,1536,0.1801,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,155.4576,234.8387,0.0,41.6717,123.0435,266.8486,1,0.0,47.6279,2048 +motion,m07,0.3,0.1853,,,10.958,61.9251,0.0004,1,1536,0.1736,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,135.5614,201.5194,0.0,41.6717,114.4672,196.9892,1,0.0,42.0698,2048 +motion,m07,0.4,0.1521,1,0,11.9231,53.7452,0.0003,0,1536,0.1702,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,127.7181,189.1976,0.0,41.6717,143.0138,195.3023,0,0.0,38.6977,2048 +motion,m07,0.5,,0,0,,48.3653,0.0002,,1536,0.1672,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,122.3644,181.0746,0.0,41.6717,,,0,,,2048 +motion,m07,0.7,,0,0,,41.7452,0.0001,,1536,0.1621,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,114.4371,169.243,0.0,41.6717,,,0,,,2048 +motion,m07,1.0,,0,0,,35.6065,0.0,,1536,0.158,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,108.225,160.2872,0.0,41.6717,,,0,,,2048 +scale,m07,40,0.1608,,,7.9202,246.7695,0.0022,0,1474,0.1951,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,476.6443,610.7765,0.0,39.9896,881.2177,1288.553,1,0.0,44.6121,1966 +scale,m07,32,0.1983,,,10.3333,247.2973,0.0022,1,1180,0.1966,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,885.3877,885.3877,0.0,32.0134,898.1315,702.8772,1,0.0,34.3023,1573 +scale,m07,26,0.2069,,,8.8125,243.5285,0.0022,1,958,0.1964,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,1586.6919,519.9724,0.0,25.9905,1206.944,1206.944,1,0.0,32.0,1278 +scale,m07,22,0.1927,,,10.1667,238.2209,0.0022,1,811,0.1952,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,2461.5629,456.0813,0.0,22.0024,2487.9163,595.9121,1,0.0,23.9704,1081 +scale,m07,18,0.2036,,,7.4615,563.4979,0.0021,1,663,0.1927,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,3863.6397,372.5401,0.0,17.9872,2502.4338,718.0871,1,0.0,25.9608,885 +scale,m07,15,0.2043,,,9.1111,696.654,0.002,1,553,0.1932,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,5043.026,273.0622,0.0,15.0029,3793.2167,363.4445,1,0.0,17.9675,737 +scale,m07,12,0.1333,,,8.8333,847.267,0.0019,0,442,0.1868,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,6785.8932,166.2761,0.0,11.9915,6311.519,176.7088,1,0.0,12.0,590 +scale,m07,10,0.195,,,11.2,964.4121,0.0017,0,369,0.1869,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,6778.3228,94.1364,0.0,10.011,6908.3748,107.9473,1,0.0,9.973,491 +scale,m07,8,,0,0,,1069.0158,0.0016,,295,0.1733,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,8356.9087,49.7843,0.0,8.0034,,,0,,,393 +scale,m07,6,,0,0,,1179.7239,0.0013,,221,0.1644,11.0722,384604c4612d0d681ef8e151d30073e506e11c5ef2f21155c494e2bd1683084a,5849.2975,20.1284,0.0,5.9957,,,0,,,295 +blur,m08,0.02,0.0674,,,9.25,79.6711,0.0004,1,1536,0.0674,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,48.683,127.9428,0.0,47.6314,48.683,127.9428,1,0.0,47.6279,2048 +blur,m08,0.04,0.0554,,,9.25,21.7331,0.0001,1,1536,0.0555,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,6.6076,19.6426,0.0,47.6314,6.5604,19.4969,1,0.0,47.6279,2048 +blur,m08,0.06,0.0468,,,8.7059,7.2175,0.0,1,1536,0.0479,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,2.4774,4.2514,0.0,47.6314,2.4516,5.1652,1,0.0,50.6046,2048 +blur,m08,0.08,0.0426,,,9.25,3.0867,0.0,1,1536,0.0426,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1.8203,1.4293,0.0,47.6314,1.8094,1.4197,1,0.0,47.6279,2048 +blur,m08,0.1,,0,0,,1.649,0.0,,1536,0.0389,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1.6276,0.6959,0.0,47.6314,,,0,,,2048 +blur,m08,0.13,,0,0,,0.8065,0.0,,1536,0.0344,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1.4913,0.3883,0.0,47.6314,,,0,,,2048 +blur,m08,0.16,,0,0,,0.4743,0.0,,1536,0.031,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1.5537,0.3011,0.0,47.6314,,,0,,,2048 +blur,m08,0.2,,0,0,,0.2824,0.0,,1536,0.0277,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1.3879,0.2447,0.0,47.6314,,,0,,,2048 +blur,m08,0.25,,0,0,,0.1945,0.0,,1536,0.0247,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1.2027,0.2445,0.0,47.6314,,,0,,,2048 +blur,m08,0.3,,0,0,,0.1622,0.0,,1536,0.0225,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,0.9806,0.2538,0.0,47.6314,,,0,,,2048 +edge,m08,2.0,0.0773,,,2.0405,139.7263,0.0015,1,1536,0.0777,1.99,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,211.9883,363.9311,0.0,47.6314,208.726,384.7227,1,0.0,49.186,1220 +edge,m08,1.0,0.0741,,,0.9409,130.0902,0.0016,1,1536,0.0777,1.0033,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,211.9883,363.9311,0.0,47.6314,189.1422,410.2861,1,0.0,53.9821,1173 +edge,m08,0.5,0.0741,,,0.4865,126.7823,0.0016,1,1536,0.0777,0.4994,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,211.9883,363.9311,0.0,47.6314,189.1422,405.1521,1,0.0,53.9456,1149 +edge,m08,0.25,0.0741,,,0.2801,125.5093,0.0016,1,1536,0.0777,0.2475,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,211.9883,363.9311,0.0,47.6314,189.1407,404.7772,1,0.0,53.9451,1137 +edge,m08,0.1,0.0747,,,0.1551,126.8252,0.0016,1,1536,0.0784,0.1005,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,215.9186,370.813,0.0,47.6314,192.6234,413.9227,1,0.0,53.9493,1130 +edge,m08,0.0,0.075,,,0.1135,123.4589,0.0016,1,1536,0.0789,0.0,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,218.7631,373.999,0.0,47.6314,194.1002,415.3497,1,0.0,53.913,1125 +edge,m08,-0.5,0.0686,0,0,0.2364,118.5714,0.0017,0,1536,0.0719,0.0,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,199.9728,335.1227,0.0,47.6314,177.3677,378.5667,0,0.0,54.0327,1101 +edge,m08,-1.0,0.0715,0,0,0.0454,114.078,0.0017,0,1536,0.0722,0.0,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,200.7721,340.1991,0.0,47.6314,195.5235,341.7377,0,0.0,49.1163,1078 +edge,m08,-2.0,0.0669,0,0,0.2178,111.3833,0.0018,0,1536,0.0686,0.0,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,186.28,313.3275,0.0001,47.6314,175.4048,331.7269,0,0.0,50.5606,1030 +glare,m08,0.3,0.0627,,,9.25,152.8196,0.0009,1,1536,0.0627,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,129.3946,220.7933,0.0001,47.6314,129.3946,220.7933,1,0.0001,47.6279,2048 +glare,m08,0.5,0.054,,,9.25,150.6905,0.0009,1,1536,0.0539,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,86.3119,146.8014,0.0006,47.6314,86.9832,147.6719,1,0.0006,47.6279,2048 +glare,m08,0.7,0.0466,,,9.25,148.8891,0.001,1,1536,0.0466,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,53.501,90.6645,0.0054,47.6314,53.501,90.6645,1,0.0054,47.6279,2048 +glare,m08,0.85,0.0428,,,9.25,147.7397,0.0046,1,1536,0.0428,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,35.6251,60.0774,0.283,47.6314,35.8944,60.3635,1,0.2852,47.6279,2048 +glare,m08,1.0,0.1622,0,0,11.0909,146.7916,0.0093,0,1536,0.0406,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,23.5373,39.4462,0.4751,47.6314,1685.8269,1100.3322,0,0.002,33.0,2048 +glare,m08,1.5,0.1622,0,0,11.0909,144.5309,0.0214,0,1536,0.0271,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,6.7738,11.192,0.7298,47.6314,1685.8269,1100.3322,0,0.002,33.0,2048 +glare,m08,2.5,0.1622,0,0,11.0909,141.677,0.036,0,1536,0.0049,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,0.1342,0.0568,0.9842,47.6314,1685.8269,1100.3322,0,0.002,33.0,2048 +glare,m08,4.0,0.1622,0,0,11.0909,138.9308,0.0498,0,1536,0.0,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,0.0,0.0,1.0,47.6314,1685.8269,1100.3322,0,0.002,33.0,2048 +motion,m08,0.05,0.075,,,9.25,139.6965,0.0009,1,1536,0.075,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,145.3418,290.8396,0.0,47.6314,145.3418,290.8396,1,0.0,47.6279,2048 +motion,m08,0.1,0.0653,,,9.25,81.534,0.0006,1,1536,0.0655,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,51.4204,111.4972,0.0,47.6314,51.0258,110.6541,1,0.0,47.6279,2048 +motion,m08,0.15,0.0605,,,9.25,61.7614,0.0005,1,1536,0.0607,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,37.1094,75.3527,0.0,47.6314,36.8254,74.778,1,0.0,47.6279,2048 +motion,m08,0.2,0.0554,,,9.25,47.3891,0.0003,1,1536,0.0555,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,27.3717,53.8691,0.0,47.6314,27.1619,53.4521,1,0.0,47.6279,2048 +motion,m08,0.3,0.0515,,,9.9333,37.9284,0.0002,1,1536,0.0509,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,21.1199,40.5256,0.0,47.6314,21.8899,35.5897,1,0.0,44.6512,2048 +motion,m08,0.4,0.0305,,,11.7619,31.8397,0.0001,0,1536,0.0468,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,16.7066,31.2763,0.0,47.6314,8.7637,11.0823,1,0.0,42.0,2048 +motion,m08,0.5,,0,0,,28.2647,0.0001,,1536,0.0439,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,13.7853,25.6153,0.0,47.6314,,,0,,,2048 +motion,m08,0.7,,0,0,,24.3959,0.0,,1536,0.0407,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,10.8839,19.8,0.0,47.6314,,,0,,,2048 +motion,m08,1.0,,0,0,,20.8895,0.0,,1536,0.0386,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,8.7864,15.9321,0.0,47.6314,,,0,,,2048 +scale,m08,40,0.0774,,,9.25,175.7931,0.0009,1,1290,0.0777,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,343.35,362.0149,0.0,40.0029,340.1734,356.287,1,0.0,40.0,1720 +scale,m08,32,0.0774,,,9.25,173.9916,0.0009,1,1032,0.0774,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,623.0875,623.0875,0.0,32.0023,623.0875,623.0875,1,0.0,32.0,1376 +scale,m08,26,0.0704,,,7.3355,167.2451,0.0009,1,838,0.0768,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,985.8793,279.2574,0.0,25.9864,797.5331,797.5331,1,0.0,31.9487,1118 +scale,m08,22,0.075,,,9.1818,363.5795,0.0008,1,709,0.0759,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1305.9216,208.4007,0.0,21.9861,1296.9196,213.4501,1,0.0,21.969,946 +scale,m08,18,0.0727,,,9.2222,471.4413,0.0008,1,580,0.0737,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1599.005,135.9873,0.0,17.9858,1563.4465,132.233,1,0.0,18.0,774 +scale,m08,15,0.0648,,,7.5556,576.3338,0.0008,1,484,0.0707,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1730.0743,80.8929,0.0,15.0088,1384.2229,123.8579,1,0.0,18.0,645 +scale,m08,12,0.0669,,,9.3333,712.8766,0.0006,1,387,0.0669,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1815.6089,40.1122,0.0,12.0009,1807.7235,40.4719,1,0.0,11.9691,516 +scale,m08,10,0.0596,,,7.6667,820.8521,0.0005,1,322,0.0639,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1844.3586,23.7332,0.0,9.9852,1561.1005,36.2276,1,0.0,12.0,430 +scale,m08,8,0.0418,0,0,14.0,944.2954,0.0004,0,258,0.0594,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1812.5583,8.1085,0.0,8.0006,988.1957,2.3835,0,0.0,6.0,344 +scale,m08,6,,0,0,,1122.7156,0.0002,,193,0.0537,9.2493,8725b03a64d9bdd96dc7e600a4bfc2d0d40a99baf3eb3c4fa4da1c748ad330a3,1736.7912,3.7111,0.0,5.9849,,,0,,,258 +blur,m09,0.02,0.2005,,,5.9222,169.386,0.0,1,1536,0.2005,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,80.8824,510.9001,0.0,57.2928,80.8824,510.9001,1,0.0,57.2437,2048 +blur,m09,0.04,0.1707,,,5.8576,36.6937,0.0,1,1536,0.1715,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,18.566,146.5524,0.0,57.2928,18.3788,149.513,1,0.0,57.8247,2048 +blur,m09,0.06,0.1456,,,5.9193,10.079,0.0,1,1536,0.1456,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,6.4931,45.0929,0.0,57.2928,6.4931,45.0929,1,0.0,57.2691,2048 +blur,m09,0.08,0.1288,,,5.7,3.6577,0.0,1,1536,0.1261,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,3.2382,15.3193,0.0,57.2928,3.1512,16.9469,1,0.0,59.5349,2048 +blur,m09,0.1,0.1191,0,1,5.4286,1.6828,0.0,0,1536,0.112,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,2.1972,5.5,0.0,57.2928,2.1439,7.0726,0,0.0,62.5116,2048 +blur,m09,0.13,0.0863,0,0,6.3889,0.7145,0.0,0,1536,0.0976,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,1.8102,1.4746,0.0,57.2928,1.8552,1.4809,0,0.0,53.5814,2048 +blur,m09,0.16,,0,0,,0.4063,0.0,,1536,0.0873,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,1.7467,0.6449,0.0,57.2928,,,0,,,2048 +blur,m09,0.2,,0,0,,0.2586,0.0,,1536,0.0769,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,1.6147,0.3442,0.0,57.2928,,,0,,,2048 +blur,m09,0.25,,0,0,,0.1919,0.0,,1536,0.0674,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,1.5517,0.2404,0.0,57.2928,,,0,,,2048 +blur,m09,0.3,,0,0,,0.1622,0.0,,1536,0.0607,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,1.5254,0.1934,0.0,57.2928,,,0,,,2048 +edge,m09,2.0,0.2245,,,2.1647,322.368,0.0,1,1536,0.2185,1.9913,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,437.8597,1213.5256,0.0,57.2928,467.5992,1133.0509,1,0.0,53.5814,1287 +edge,m09,1.0,0.2243,,,1.0832,319.8412,0.0,1,1536,0.2185,0.9965,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,437.8597,1213.5256,0.0,57.2928,466.3277,1134.3069,1,0.0,53.5814,1230 +edge,m09,0.5,0.2245,,,0.5827,320.7564,0.0,1,1536,0.2185,0.5077,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,437.8597,1213.5256,0.0,57.2928,467.5992,1133.0509,1,0.0,53.5814,1202 +edge,m09,0.25,0.224,,,0.2498,319.5622,0.0,1,1536,0.2185,0.2459,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,437.8597,1213.5256,0.0,57.2928,466.305,1132.6429,1,0.0,53.5814,1187 +edge,m09,0.1,0.2257,,,0.1248,314.9055,0.0,1,1536,0.2203,0.1063,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,447.5009,1239.6415,0.0,57.2928,475.4076,1151.4394,1,0.0,53.5814,1179 +edge,m09,0.0,0.2269,,,0.0416,313.5831,0.0,1,1536,0.2217,0.0016,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,454.8035,1269.8165,0.0,57.2928,481.8792,1172.7787,1,0.0,53.5814,1173 +edge,m09,-0.5,0.2276,0,0,0.0384,313.7648,0.0,0,1536,0.2226,0.0,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,455.9604,1242.918,0.0,57.2928,456.0302,1299.0611,0,0.0,58.0465,1144 +edge,m09,-1.0,0.2288,0,0,0.0347,300.6252,0.0,0,1536,0.2234,0.0,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,461.8311,1260.52,0.0,57.2928,471.8966,1284.0854,0,0.0,56.4797,1116 +edge,m09,-2.0,0.2263,0,0,0.0417,273.6501,0.0,0,1536,0.2208,0.0,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,451.8667,1208.5576,0.0,57.2928,479.1802,1119.1943,0,0.0,53.5814,1058 +glare,m09,0.3,0.1726,,,6.3889,391.9546,0.0,1,1536,0.1697,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,268.4572,738.9196,0.0,57.2928,281.5927,686.7464,1,0.0,53.5814,2048 +glare,m09,0.5,0.1407,,,5.9102,381.5412,0.0,1,1536,0.1407,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,179.92,493.7636,0.0,57.2928,179.92,497.0101,1,0.0,57.3506,2048 +glare,m09,0.7,0.1161,,,5.8151,372.6835,0.0,1,1536,0.1167,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,111.7054,304.7665,0.0,57.2928,110.9102,308.0486,1,0.0,58.2143,2048 +glare,m09,0.85,0.1047,,,6.0,367.1069,0.0,1,1536,0.1043,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,73.9042,200.3807,0.0007,57.2928,74.2456,194.8349,1,0.0007,56.5581,2048 +glare,m09,1.0,0.1014,0,0,6.3889,362.4317,0.002,0,1536,0.0987,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,47.7598,127.8178,0.1912,57.2928,50.8007,120.2228,0,0.1992,53.5814,2048 +glare,m09,1.5,0.2608,0,0,8.16,351.9488,0.0137,0,1536,0.0635,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,11.8021,30.5342,0.6174,57.2928,887.006,4280.0154,0,0.0,74.4186,2048 +glare,m09,2.5,0.2608,0,0,8.16,339.3184,0.028,0,1536,0.0126,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,0.2729,0.1459,0.9086,57.2928,887.006,4280.0154,0,0.0,74.4186,2048 +glare,m09,4.0,0.2608,0,0,8.16,328.1965,0.0411,0,1536,0.0,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,0.0,0.0,1.0,57.2928,887.006,4280.0154,0,0.0,74.4186,2048 +motion,m09,0.05,0.2158,,,6.3889,315.4276,0.0,1,1536,0.2133,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,229.1862,925.8059,0.0,57.2928,236.671,837.3155,1,0.0,53.5814,2048 +motion,m09,0.1,0.2024,,,5.7598,187.2331,0.0,1,1536,0.2024,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,140.3725,608.3761,0.0,57.2928,141.6875,642.2946,1,0.0,58.7285,2048 +motion,m09,0.15,0.1895,,,5.8662,133.2374,0.0,1,1536,0.1904,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,109.0653,459.9723,0.0,57.2928,108.6298,466.1223,1,0.0,57.7467,2048 +motion,m09,0.2,0.1828,,,5.9298,114.5389,0.0,1,1536,0.1828,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,95.7522,394.9767,0.0,57.2928,95.7522,394.9767,1,0.0,57.1754,2048 +motion,m09,0.3,0.1652,,,5.6941,85.6927,0.0,1,1536,0.1656,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,73.4162,293.2413,0.0,57.2928,74.1276,314.8082,1,0.0,59.3509,2048 +motion,m09,0.4,0.1532,,,6.3889,71.8655,0.0,1,1536,0.1545,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,60.5314,237.7983,0.0,57.2928,55.4908,199.4844,1,0.0,53.5814,2048 +motion,m09,0.5,0.1446,0,0,6.3333,63.9227,0.0,0,1536,0.147,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,51.9217,200.1003,0.0,57.2928,43.0485,150.9795,0,0.0,53.5814,2048 +motion,m09,0.7,0.1305,0,0,13.0667,55.551,0.0,0,1536,0.1374,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,43.089,163.2842,0.0,57.2928,46.3899,94.5052,0,0.0,44.6512,2048 +motion,m09,1.0,,0,0,,48.2744,0.0,,1536,0.1303,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,37.8175,143.9848,0.0,57.2928,,,0,,,2048 +scale,m09,48,0.221,,,5.8674,453.5315,0.0,1,1287,0.2176,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,678.9454,1172.0351,0.0,48.0051,686.1347,1283.6519,1,0.0,48.6736,1716 +scale,m09,40,0.2222,,,6.3889,454.0323,0.0,1,1072,0.219,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,1117.9379,1190.6342,0.0,39.9856,1189.4739,1124.2471,1,0.0,37.3953,1430 +scale,m09,32,0.2199,,,5.9375,445.7166,0.0,1,858,0.216,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,1942.4956,1942.4956,0.0,32.0034,1956.8137,1956.8137,1,0.0,32.0,1144 +scale,m09,26,0.2115,,,4.5516,962.8039,0.0,1,697,0.2148,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,3083.9505,921.0766,0.0,25.9981,2786.0114,1584.8591,1,0.0,32.9606,929 +scale,m09,22,0.2166,,,5.4167,1260.741,0.0,1,590,0.2171,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,4363.5172,759.8296,0.0,22.007,3971.5606,940.1837,1,0.0,24.0,786 +scale,m09,18,0.2168,,,5.3,1610.8058,0.0,1,483,0.2129,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,6505.6272,623.7021,0.0,18.0159,6025.5826,823.8122,1,0.0,19.9587,643 +scale,m09,15,0.2095,,,5.5,1921.853,0.0,1,402,0.2109,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,9470.2152,469.8482,0.0,14.9946,8652.5495,419.2247,1,0.0,16.0,536 +scale,m09,12,0.2016,,,4.4986,2241.5179,0.0001,1,322,0.2052,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,13747.2947,326.9166,0.0,12.0106,10463.0913,573.2864,1,0.0,15.4044,429 +scale,m09,10,0.1933,,,4.423,2418.0161,0.0001,1,268,0.2035,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,17661.9795,233.7866,0.0,9.9964,12577.7414,398.2845,1,0.0,13.0002,357 +scale,m09,8,0.1877,0,0,13.6667,2505.5328,0.0,0,214,0.1851,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,20355.2059,93.5057,0.0,7.9822,20246.1952,48.478,0,0.0,6.0,286 +scale,m09,6,0.1662,0,0,3.4,2474.781,0.0001,0,161,0.163,5.9166,9e048be6adaf09f15ee06a7d8d16ab3faee21d7630767cbbaa2b5e8d524a0459,21185.756,46.3693,0.0,6.0053,11814.8922,145.4797,0,0.0,9.9383,214 +blur,m10,0.02,0.2087,,,11.7641,84.7154,0.0127,1,1920,0.2092,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,123.4228,614.3843,0.0,53.5488,129.9146,562.5343,1,0.0,51.2305,1440 +blur,m10,0.04,0.1842,,,10.4246,18.9371,0.0117,1,1920,0.1838,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,23.4316,144.8703,0.0,53.5488,21.2801,166.0324,1,0.0,57.5085,1440 +blur,m10,0.06,0.1684,1,0,9.5748,5.794,0.0113,0,1920,0.1649,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,6.5117,36.1511,0.0,53.5488,5.4248,50.142,0,0.0,62.2972,1440 +blur,m10,0.08,,0,0,,2.3787,0.011,,1920,0.1519,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,2.8885,10.426,0.0,53.5488,,,0,,,1440 +blur,m10,0.1,,0,0,,1.2093,0.0106,,1920,0.1421,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,2.0612,3.8283,0.0,53.5488,,,0,,,1440 +blur,m10,0.13,,0,0,,0.6133,0.0102,,1920,0.1306,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,1.7255,1.3098,0.0,53.5488,,,0,,,1440 +blur,m10,0.16,,0,0,,0.4014,0.0097,,1920,0.1215,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,1.7003,0.6591,0.0,53.5488,,,0,,,1440 +blur,m10,0.2,,0,0,,0.2889,0.0089,,1920,0.1114,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,1.5815,0.4171,0.0,53.5488,,,0,,,1440 +blur,m10,0.25,,0,0,,0.2352,0.0079,,1920,0.1003,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,1.6481,0.3071,0.0,53.5488,,,0,,,1440 +blur,m10,0.3,,0,0,,0.214,0.0068,,1920,0.0904,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,1.5794,0.2655,0.0,53.5488,,,0,,,1440 +edge,m10,2.0,0.2289,,,2.1994,253.3348,0.0209,1,1920,0.2267,1.9963,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,616.4947,1451.499,0.003,53.5488,676.7991,1387.0601,1,0.0033,50.6316,933 +edge,m10,1.0,0.2295,,,1.1473,257.9428,0.0221,1,1920,0.2267,1.0066,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,616.4947,1451.499,0.003,53.5488,668.5671,1376.7668,1,0.0033,50.908,880 +edge,m10,0.5,0.2285,,,0.6104,259.5384,0.0228,1,1920,0.2267,0.5024,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,616.4947,1451.499,0.003,53.5488,676.8059,1382.0477,1,0.0033,50.2326,853 +edge,m10,0.25,0.2287,,,0.3288,261.5453,0.0232,1,1920,0.2267,0.2409,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,616.4947,1451.499,0.003,53.5488,674.0392,1394.5824,1,0.0033,50.7029,839 +edge,m10,0.1,0.2291,,,0.163,262.8351,0.0234,1,1920,0.2282,0.0915,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,636.6113,1497.6635,0.0031,53.5488,687.9197,1417.3562,1,0.0034,50.8726,831 +edge,m10,0.0,0.2305,,,0.1093,261.9229,0.0236,1,1920,0.2292,0.0,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,646.8902,1520.1926,0.0032,53.5488,690.3852,1415.4638,1,0.0034,50.6407,826 +edge,m10,-0.5,0.2337,0,0,0.0516,260.4785,0.0243,0,1920,0.2328,0.0,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,658.6006,1548.2679,0.0031,53.5488,696.8442,1477.5969,0,0.0033,51.2977,799 +edge,m10,-1.0,0.2357,0,0,0.1538,253.271,0.0252,0,1920,0.2341,0.0,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,675.5411,1595.7529,0.0036,53.5488,690.8891,1527.6009,0,0.0037,52.2724,773 +edge,m10,-2.0,0.2389,0,0,0.0522,231.0639,0.027,0,1920,0.2374,0.0,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,692.5006,1524.5821,0.0028,53.5488,750.6127,1479.1398,0,0.003,50.8382,719 +glare,m10,0.3,0.1733,,,11.7079,195.2548,0.0136,1,1920,0.173,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,376.775,888.5855,0.0038,53.5488,396.7404,845.8111,1,0.004,51.4743,1440 +glare,m10,0.5,0.1435,,,11.8033,191.0365,0.0136,1,1920,0.1426,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,252.3935,598.2768,0.0044,53.5488,269.2701,563.408,1,0.0046,51.062,1440 +glare,m10,0.7,0.1209,,,11.4622,187.6265,0.0136,1,1920,0.1206,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,156.2564,373.8954,0.0051,53.5488,159.6516,364.1134,1,0.0053,52.5673,1440 +glare,m10,0.85,0.1142,,,11.766,185.543,0.0136,1,1920,0.1126,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,103.2343,249.1502,0.007,53.5488,108.6254,235.4098,1,0.0074,51.2222,1440 +glare,m10,1.0,0.1758,0,0,9.292,183.8633,0.0142,0,1920,0.1139,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,66.8785,162.6476,0.0835,53.5488,370.9328,489.425,0,0.0,39.6357,1440 +glare,m10,1.5,0.1749,0,0,8.6806,180.3473,0.0203,0,1920,0.081,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,16.4476,41.0424,0.5739,53.5488,365.5908,584.1364,0,0.0,42.4264,1440 +glare,m10,2.5,0.1741,0,0,9.3356,176.8,0.0276,0,1920,0.0219,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,0.4178,0.4486,0.8568,53.5488,364.2159,474.5099,0,0.0,39.4533,1440 +glare,m10,4.0,0.1724,0,0,9.2423,173.3869,0.0343,0,1920,0.0,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,0.0,0.0,1.0,53.5488,359.4135,483.4322,0,0.0,39.8455,1440 +motion,m10,0.05,0.2211,,,11.621,145.2617,0.0131,1,1920,0.2201,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,293.2281,1031.5558,0.0011,53.5488,304.7421,960.7231,1,0.0011,51.6178,1440 +motion,m10,0.1,0.212,,,11.5571,94.653,0.0126,1,1920,0.2117,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,186.4695,693.0309,0.0004,53.5488,191.4932,638.3499,1,0.0004,51.9007,1440 +motion,m10,0.15,0.2005,,,11.7906,61.2226,0.0121,1,1920,0.2006,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,129.422,465.0031,0.0001,53.5488,136.4135,416.2154,1,0.0001,50.8819,1440 +motion,m10,0.2,0.1926,,,11.8889,48.2708,0.0117,1,1920,0.1923,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,108.6251,383.4249,0.0,53.5488,114.9264,342.834,1,0.0,50.2326,1440 +motion,m10,0.3,0.1741,0,0,13.5,37.9231,0.0113,0,1920,0.1843,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,91.2149,319.7357,0.0,53.5488,117.0922,243.0668,0,0.0,44.6512,1440 +motion,m10,0.4,,0,0,,32.537,0.0109,,1920,0.1794,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,82.2305,289.469,0.0,53.5488,,,0,,,1440 +motion,m10,0.5,,0,0,,28.6189,0.0106,,1920,0.1758,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,75.202,264.9433,0.0,53.5488,,,0,,,1440 +motion,m10,0.7,,0,0,,24.5988,0.0096,,1920,0.1719,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,69.0272,245.0067,0.0,53.5488,,,0,,,1440 +motion,m10,1.0,,0,0,,20.5418,0.0084,,1920,0.1672,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,62.2494,221.9534,0.0,53.5488,,,0,,,1440 +scale,m10,48,0.2276,,,12.0023,206.836,0.0136,1,1721,0.2269,11.2056,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,799.9371,1439.2191,0.0029,47.9987,855.796,1353.5052,1,0.0031,45.0262,1291 +scale,m10,40,0.2262,,,11.0364,206.3752,0.0136,1,1434,0.227,11.2087,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,1317.4507,1431.9727,0.0035,39.9943,1296.7383,1486.3793,1,0.0035,40.6125,1076 +scale,m10,32,0.225,,,11.0243,201.3154,0.0135,1,1147,0.2264,11.2132,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,2306.4571,2306.4571,0.0028,31.9898,2227.7524,1358.4002,1,0.0027,32.6106,861 +scale,m10,26,0.2236,,,11.1344,425.7816,0.0136,1,932,0.2258,11.2035,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,3725.7703,1154.7942,0.0023,25.9935,3628.0207,1139.1391,1,0.0022,26.363,699 +scale,m10,22,0.2214,,,10.0497,562.0948,0.0137,1,789,0.2253,11.2082,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,5380.2772,982.3172,0.0023,22.0052,4674.8274,1155.9012,1,0.002,24.472,592 +scale,m10,18,0.2166,,,9.9069,740.5538,0.0137,1,645,0.2232,11.2093,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,7909.686,699.8262,0.0004,17.9891,6433.3115,854.8093,1,0.0004,20.1853,484 +scale,m10,15,0.2175,,,10.474,896.1427,0.0136,1,538,0.2202,11.1896,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,10733.8335,545.4425,0.0,15.0048,9759.2717,456.1224,1,0.0,16.0,403 +scale,m10,12,0.2107,,,13.558,1063.0886,0.0136,1,430,0.2123,11.2208,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,12890.65,317.9589,0.0,11.9927,15588.3783,221.5675,1,0.0,10.0,323 +scale,m10,10,0.2031,0,1,11.3894,1184.8926,0.0136,0,359,0.205,11.1931,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,14616.5372,197.8951,0.0,10.0125,14603.0315,192.4671,0,0.0,9.9722,269 +scale,m10,8,,0,0,,1358.5619,0.0133,,287,0.1963,11.1905,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,17013.0994,79.793,0.0,8.0044,,,0,,,215 +scale,m10,6,,0,0,,1502.965,0.0123,,215,0.1758,11.1861,d21151c02e76da5e75c70233f3e0aa54ca5a16923059707558dc7e33349b596e,9965.9105,26.1279,0.0,5.9964,,,0,,,161 +blur,m11,0.02,0.1103,,,4.641,78.0847,0.0033,0,2048,0.0659,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,21.7436,68.55,0.0,49.6845,15.85,191.3073,1,0.0,116.093,1536 +blur,m11,0.04,0.0992,,,4.9444,21.4406,0.0021,0,2048,0.057,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,4.8509,16.0371,0.0,49.6845,3.353,76.7346,1,0.0,107.1628,1536 +blur,m11,0.06,0.1027,,,4.6286,7.6244,0.0014,0,2048,0.0505,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,2.3267,4.1347,0.0,49.6845,2.2513,40.358,1,0.0,111.2326,1536 +blur,m11,0.08,0.1108,,,4.1613,3.2925,0.001,0,2048,0.046,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1.7117,1.2631,0.0,49.6845,1.8525,24.5423,1,0.0,122.1588,1536 +blur,m11,0.1,0.1025,,,4.359,1.6972,0.0008,0,2048,0.0427,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1.5354,0.5969,0.0,49.6845,1.6909,9.9661,1,0.0,116.0931,1536 +blur,m11,0.13,0.1005,0,0,4.0256,0.8045,0.0006,0,2048,0.0385,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1.4512,0.3529,0.0,49.6845,1.5636,4.096,0,0.0,116.093,1536 +blur,m11,0.16,0.0842,0,0,4.5801,0.4755,0.0004,0,2048,0.0347,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1.349,0.2726,0.0,49.6845,1.5433,1.8649,0,0.0,115.6147,1536 +blur,m11,0.2,,0,0,,0.2959,0.0002,,2048,0.0301,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1.1783,0.2591,0.0,49.6845,,,0,,,1536 +blur,m11,0.25,,0,0,,0.2087,0.0,,2048,0.0248,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1.2391,0.2254,0.0,49.6845,,,0,,,1536 +blur,m11,0.3,,0,0,,0.1744,0.0,,2048,0.0204,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1.1929,0.1923,0.0,49.6845,,,0,,,1536 +edge,m11,2.0,0.0718,,,2.1218,182.6222,0.0065,1,2048,0.0713,2.0035,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,72.932,145.4951,0.0,49.6845,74.5929,133.9632,1,0.0,47.6279,966 +edge,m11,1.0,0.0712,,,1.0245,180.13,0.0068,1,2048,0.0713,0.9971,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,72.932,145.4951,0.0,49.6845,72.3666,141.825,1,0.0,49.1886,916 +edge,m11,0.5,0.0714,,,0.5332,190.1766,0.007,1,2048,0.0713,0.494,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,72.932,145.4951,0.0,49.6845,73.2036,144.7203,1,0.0,49.7145,891 +edge,m11,0.25,0.0714,,,0.3005,189.7236,0.0071,1,2048,0.0713,0.2524,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,72.932,145.4951,0.0,49.6845,73.2036,141.9652,1,0.0,48.9375,879 +edge,m11,0.1,0.0726,,,0.1223,189.7504,0.0072,1,2048,0.072,0.0914,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,75.1732,149.5893,0.0,49.6845,77.196,141.0145,1,0.0,48.1462,871 +edge,m11,0.0,0.0723,,,0.0581,179.5242,0.0072,1,2048,0.0725,0.0,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,76.6935,151.6601,0.0,49.6845,75.7585,146.638,1,0.0,48.8865,866 +edge,m11,-0.5,0.0722,0,0,0.1165,181.3405,0.0074,0,2048,0.0724,0.0,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,76.9768,153.4379,0.0,49.6845,75.9309,148.7835,0,0.0,49.3214,842 +edge,m11,-1.0,0.075,0,0,0.1871,190.4746,0.0077,0,2048,0.0744,0.0,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,78.1504,160.2088,0.0,49.6845,80.2659,149.9119,0,0.0,47.6279,817 +edge,m11,-2.0,0.0794,0,0,0.0713,191.9642,0.0082,0,2048,0.075,0.0,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,70.6647,149.8789,0.0,49.6845,83.8827,114.4031,0,0.0,41.6744,767 +glare,m11,0.3,0.1024,,,4.9189,148.7568,0.0041,0,2048,0.0549,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,45.1137,87.3226,0.0,49.6845,61.2621,191.0472,1,0.0,110.1395,1536 +glare,m11,0.5,0.0968,,,4.6004,148.2601,0.0041,0,2048,0.0453,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,30.3733,57.2394,0.0,49.6845,56.3997,157.8196,1,0.0,105.6458,1536 +glare,m11,0.7,0.0377,,,13.0454,147.8505,0.0041,1,2048,0.0377,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,19.1296,34.6858,0.0,49.6845,19.1296,34.228,1,0.0,49.5006,1536 +glare,m11,0.85,0.0346,,,13.3372,147.6005,0.005,1,2048,0.0342,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,12.9963,22.4012,0.145,49.6845,13.3854,21.5196,1,0.1463,48.4261,1536 +glare,m11,1.0,0.1022,0,0,4.8929,147.3933,0.0071,0,2048,0.0332,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,8.5517,14.1228,0.372,49.6845,50.3569,143.6106,0,0.0,111.2146,1536 +glare,m11,1.5,0.0784,0,0,14.4313,146.9401,0.0118,0,2048,0.0232,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,2.5052,3.6475,0.6998,49.6845,147.9256,84.1614,0,0.0,30.318,1536 +glare,m11,2.5,0.079,0,0,14.7,146.426,0.018,0,2048,0.0059,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,0.2389,0.0698,0.9748,49.6845,151.9697,82.5938,0,0.0,29.7674,1536 +glare,m11,4.0,0.0775,0,0,14.532,145.9361,0.0237,0,2048,0.0,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,0.0,0.0,1.0,49.6845,142.1351,80.1209,0,0.0,30.1092,1536 +motion,m11,0.05,0.1115,,,4.8421,133.9428,0.004,0,2048,0.0701,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,52.0091,125.1518,0.0,49.6845,47.8636,240.886,1,0.0,113.1163,1536 +motion,m11,0.1,0.1078,,,5.0556,77.9389,0.0033,0,2048,0.0643,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,23.9166,63.9291,0.0,49.6845,22.058,156.6115,1,0.0,107.1628,1536 +motion,m11,0.15,0.109,,,4.8333,57.8117,0.0028,0,2048,0.0602,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,16.705,41.8141,0.0,49.6845,17.4465,124.7169,1,0.0,107.1628,1536 +motion,m11,0.2,0.1113,,,4.6798,42.5203,0.0024,0,2048,0.0553,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,11.7959,26.4369,0.0,49.6845,17.9424,147.5493,1,0.0,114.452,1536 +motion,m11,0.3,0.0933,,,4.9167,30.9494,0.0018,0,2048,0.0509,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,8.4044,17.2183,0.0,49.6845,10.9427,82.9839,1,0.0,107.1628,1536 +motion,m11,0.4,0.0893,0,0,5.5731,25.1288,0.0014,0,2048,0.0484,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,6.7454,13.4543,0.0,49.6845,8.325,66.0528,0,0.0,105.1486,1536 +motion,m11,0.5,0.1478,0,0,0.316,21.4742,0.0012,0,2048,0.0464,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,5.5769,10.511,0.0,49.6845,11.1516,614.844,0,0.0156,240.7332,1536 +motion,m11,0.7,0.1519,0,0,0.2535,17.0675,0.0008,0,2048,0.045,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,4.8079,8.8069,0.0,49.6845,6.9173,412.1726,0,0.0,231.4331,1536 +motion,m11,1.0,0.1561,0,0,0.251,13.5168,0.0003,0,2048,0.0436,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,3.8567,6.9307,0.0,49.6845,5.1071,297.5317,0,0.0,231.7845,1536 +scale,m11,48,0.1061,,,4.7492,166.3996,0.0041,0,1979,0.0712,12.9945,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,77.8903,141.6602,0.0,48.0105,65.7407,183.55,1,0.0,98.95,1484 +scale,m11,40,0.072,,,13.3899,167.3575,0.0041,1,1649,0.0712,12.9993,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,128.3078,145.8704,0.0,40.0047,133.2808,142.337,1,0.0,38.8471,1237 +scale,m11,32,0.1148,,,4.6142,164.9274,0.004,0,1319,0.0712,12.9934,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,231.4121,231.4121,0.0,31.9989,161.7427,240.2688,1,0.0,71.4458,989 +scale,m11,26,0.116,,,4.5405,158.5244,0.0039,0,1072,0.0709,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,380.4628,115.2801,0.0,26.0067,222.1572,241.0166,1,0.0,58.0844,804 +scale,m11,22,0.0709,,,12.9233,364.8995,0.0039,1,907,0.0707,12.9919,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,557.4506,95.9281,0.0,22.0038,562.2571,99.0081,1,0.0,21.9758,680 +scale,m11,18,0.1265,,,3.8584,471.5327,0.0036,0,742,0.0701,12.985,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,836.9636,76.3515,0.0,18.0009,342.8838,281.6179,1,0.0,44.5586,556 +scale,m11,15,0.1167,,,4.4375,578.7103,0.0034,0,618,0.0686,13.0107,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1104.7754,54.938,0.0,14.9927,369.0568,369.0568,1,0.0,32.0,464 +scale,m11,12,0.1172,,,4.0687,732.1526,0.0033,0,495,0.0672,12.988,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1512.5216,36.6134,0.0,12.0087,477.5461,166.8595,1,0.0,27.9435,371 +scale,m11,10,0.1086,,,4.9975,874.2508,0.0031,0,412,0.0643,12.9967,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1697.0056,22.172,0.0,9.9951,629.3294,95.7899,1,0.0,20.7261,309 +scale,m11,8,0.0958,0,1,5.4778,1049.0718,0.0028,0,330,0.0598,12.9704,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1622.4698,7.3444,0.0,8.0058,961.1697,48.4245,0,0.0006,16.0,247 +scale,m11,6,,0,0,,1243.3697,0.0022,,247,0.0546,12.9792,9aefb88ea9a181be7d5b744c1c521ad9c0c69212f27a230944f6c96097959a53,1206.0642,2.6936,0.0,5.9922,,,0,,,185 +blur,m13,0.02,0.1989,,,7.0863,68.9417,0.0,1,1536,0.1952,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,38.1512,254.4121,0.0,60.1651,37.3982,269.1414,1,0.0,61.2122,2048 +blur,m13,0.04,0.1718,,,7.5175,12.4498,0.0,1,1536,0.1815,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,7.4575,55.1949,0.0,60.1651,7.6837,50.2111,1,0.0,57.7341,2048 +blur,m13,0.06,0.1815,,,6.714,3.5333,0.0,1,1536,0.1687,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,3.3812,17.3853,0.0,60.1651,3.2438,20.973,1,0.0,64.3711,2048 +blur,m13,0.08,0.0773,0,0,8.8571,1.4769,0.0,0,1536,0.1567,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,2.3054,6.8609,0.0,60.1651,1.8114,4.8853,0,0.0,62.5116,2048 +blur,m13,0.1,0.0714,0,0,9.3,0.8009,0.0,0,1536,0.1459,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,1.8934,3.1303,0.0,60.1651,1.6315,1.493,0,0.0,59.5349,2048 +blur,m13,0.13,,0,0,,0.4254,0.0,,1536,0.1326,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,1.6659,1.223,0.0,60.1651,,,0,,,2048 +blur,m13,0.16,,0,0,,0.2826,0.0,,1536,0.122,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,1.6031,0.6158,0.0,60.1651,,,0,,,2048 +blur,m13,0.2,,0,0,,0.2043,0.0,,1536,0.1105,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,1.4066,0.3478,0.0,60.1651,,,0,,,2048 +blur,m13,0.25,,0,0,,0.1678,0.0,,1536,0.0983,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,1.1817,0.2723,0.0,60.1651,,,0,,,2048 +blur,m13,0.3,,0,0,,0.1551,0.0,,1536,0.0872,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,1.1702,0.2145,0.0,60.1651,,,0,,,2048 +edge,m13,2.0,0.2261,,,1.8952,133.4949,0.0001,1,1536,0.2044,2.0016,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,228.1067,638.2558,0.0,60.1651,210.989,644.2374,1,0.0,64.2,1332 +edge,m13,1.0,0.1936,,,1.1195,134.1707,0.0001,1,1536,0.2044,1.0043,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,228.1067,638.2558,0.0,60.1651,248.0723,613.9187,1,0.0,55.814,1272 +edge,m13,0.5,0.1845,,,0.6242,134.2595,0.0001,1,1536,0.2044,0.5057,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,228.1067,638.2558,0.0,60.1651,254.5146,582.3012,1,0.0,53.5814,1242 +edge,m13,0.25,0.1841,,,0.2858,132.9453,0.0001,1,1536,0.2044,0.2564,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,228.1067,638.2558,0.0,60.1651,257.0385,595.0393,1,0.0,54.3851,1227 +edge,m13,0.1,0.1847,,,0.1249,132.5365,0.0001,1,1536,0.2048,0.1068,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,231.8341,646.8608,0.0,60.1651,255.6816,586.0734,1,0.0,53.5814,1218 +edge,m13,0.0,0.1852,,,0.0417,132.3714,0.0001,1,1536,0.2052,0.007,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,234.4076,657.5761,0.0,60.1651,259.7433,592.9673,1,0.0,53.5814,1212 +edge,m13,-0.5,0.1863,0,0,0.1621,132.454,0.0001,0,1536,0.2066,0.0,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,232.9391,640.4414,0.0,60.1651,257.5016,591.8198,0,0.0,54.4557,1181 +edge,m13,-1.0,0.186,0,0,0.2082,130.1948,0.0001,0,1536,0.2062,0.0,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,231.3314,625.62,0.0,60.1651,256.1012,571.9697,0,0.0,53.5814,1151 +edge,m13,-2.0,0.1281,0,0,3.7163,124.7315,0.0001,0,1536,0.2051,0.0,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,228.1094,620.8266,0.0,60.1651,146.6698,588.8894,0,0.0001,71.4419,1091 +glare,m13,0.3,0.1657,,,7.185,171.4197,0.0001,1,1536,0.1644,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,144.2092,398.1298,0.0,60.1651,141.9839,411.9011,1,0.0,60.4258,2048 +glare,m13,0.5,0.1417,,,7.472,165.1293,0.0001,1,1536,0.1451,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,99.4082,272.5558,0.0,60.1651,102.8493,270.7781,1,0.0,58.064,2048 +glare,m13,0.7,0.1334,,,7.3761,159.6881,0.0001,1,1536,0.1346,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,64.5087,174.827,0.0,60.1651,65.9454,171.0921,1,0.0,58.7708,2048 +glare,m13,0.85,0.1337,,,7.0303,156.1598,0.0001,1,1536,0.1339,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,44.7381,119.551,0.0,60.1651,44.9706,131.8796,1,0.0,61.6674,2048 +glare,m13,1.0,0.1175,0,0,10.3889,153.0955,0.0015,0,1536,0.1397,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,30.6152,79.8055,0.1341,60.1651,154.812,378.2975,0,0.0,53.5814,2048 +glare,m13,1.5,0.1143,0,0,10.3889,145.7631,0.0118,0,1536,0.107,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,9.3601,21.856,0.592,60.1651,110.5795,269.7984,0,0.0839,53.5814,2048 +glare,m13,2.5,,0,0,,137.4997,0.0241,,1536,0.0336,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,0.8133,1.1781,0.8764,60.1651,,,0,,,2048 +glare,m13,4.0,,0,0,,130.2512,0.0354,,1536,0.0,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,0.0,0.0,1.0,60.1651,,,0,,,2048 +motion,m13,0.05,0.2043,,,7.107,139.8755,0.0001,1,1536,0.2024,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,135.4619,519.9254,0.0,60.1651,133.9913,536.565,1,0.0,61.045,2048 +motion,m13,0.1,0.2052,,,7.0037,82.6864,0.0,1,1536,0.1988,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,96.9279,367.0843,0.0,60.1651,93.2102,408.8714,1,0.0,61.886,2048 +motion,m13,0.15,0.2019,,,6.974,58.9711,0.0,1,1536,0.1959,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,84.2167,306.2196,0.0,60.1651,80.8831,345.2256,1,0.0,62.1321,2048 +motion,m13,0.2,0.1849,,,7.5092,48.2015,0.0,1,1536,0.1938,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,78.4817,282.4101,0.0,60.1651,81.3588,273.5642,1,0.0,57.7942,2048 +motion,m13,0.3,0.1425,0,0,8.6471,37.538,0.0,0,1536,0.1909,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,72.5943,258.8913,0.0,60.1651,76.2487,172.7641,0,0.0,50.6046,2048 +motion,m13,0.4,0.0919,0,0,10.3889,32.0031,0.0,0,1536,0.1892,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,69.3176,247.5367,0.0,60.1651,22.6371,67.2913,0,0.0,53.5814,2048 +motion,m13,0.5,0.0885,0,0,10.3889,28.3949,0.0,0,1536,0.1879,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,67.0422,239.5856,0.0,60.1651,20.0276,58.4802,0,0.0,53.5814,2048 +motion,m13,0.7,,0,0,,23.8754,0.0,,1536,0.1866,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,64.2119,230.9039,0.0,60.1651,,,0,,,2048 +motion,m13,1.0,,0,0,,20.0134,0.0,,1536,0.1855,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,60.917,222.2827,0.0,60.1651,,,0,,,2048 +scale,m13,48,0.2071,,,7.0485,203.1833,0.0001,1,1225,0.2048,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,402.3216,643.0475,0.0,47.9833,399.3052,679.2695,1,0.0,49.0629,1634 +scale,m13,40,0.1957,,,7.5436,202.0013,0.0001,1,1021,0.2051,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,625.9128,625.2877,0.0,39.9926,658.6098,627.911,1,0.0,38.3781,1362 +scale,m13,32,0.2112,,,6.9378,197.1737,0.0001,1,817,0.2047,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,1039.9879,1039.9879,0.0,32.0019,991.8935,609.1541,1,0.0,33.3026,1089 +scale,m13,26,0.2085,,,6.9091,467.973,0.0001,1,664,0.204,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,1622.3526,501.8373,0.0,26.0089,1596.3106,608.6929,1,0.0,27.1286,885 +scale,m13,22,0.2188,,,6.4987,589.9368,0.0,1,562,0.2031,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,2243.7745,431.3757,0.0,22.0135,2003.3423,507.6029,1,0.0,24.2903,749 +scale,m13,18,0.1054,,,6.3846,719.8133,0.0,0,460,0.2028,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,3006.6846,290.8339,0.0,18.0182,2768.1186,702.9002,1,0.0,26.0,613 +scale,m13,15,0.2101,,,5.8889,810.1013,0.0,1,383,0.197,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,3769.1143,214.1086,0.0,15.0021,3441.667,341.6585,1,0.0,17.9531,511 +scale,m13,12,0.2068,,,6.3436,890.8512,0.0,1,306,0.1993,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,4673.1836,119.1182,0.0,11.986,4215.2949,170.1732,1,0.0,13.4433,408 +scale,m13,10,0.2186,,,7.2,924.9835,0.0001,1,255,0.1975,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,5310.3631,75.7645,0.0,9.9884,4938.4823,76.8765,1,0.0,9.9609,340 +scale,m13,8,,0,0,,982.4608,0.0001,,204,0.1943,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,5408.2841,35.1783,0.0,7.9907,,,0,,,272 +scale,m13,6,,0,0,,1128.5736,0.0,,153,0.187,7.2183,dbd64ada7fefaa0ea96e528134010b942099858a474e56a6954d3efb55581ce0,4051.9085,21.348,0.0,5.993,,,0,,,204 +blur,m14,0.02,0.1761,0,0,3.1579,6.199,0.003,0,1536,0.175,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,4.652,743.0797,0.0,160.5274,4.7106,728.1556,0,0.0,158.3628,2048 +blur,m14,0.04,,0,0,,0.7872,0.0022,,1536,0.1444,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,1.984,113.7084,0.0,160.5274,,,0,,,2048 +blur,m14,0.06,,0,0,,0.3072,0.0011,,1536,0.1201,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,1.7577,30.6505,0.0,160.5274,,,0,,,2048 +blur,m14,0.08,,0,0,,0.1982,0.0001,,1536,0.0993,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,1.708,11.837,0.0,160.5274,,,0,,,2048 +blur,m14,0.1,,0,0,,0.1625,0.0,,1536,0.0818,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,1.6397,5.1825,0.0,160.5274,,,0,,,2048 +blur,m14,0.13,,0,0,,0.1459,0.0,,1536,0.0626,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,1.5587,1.5218,0.0,160.5274,,,0,,,2048 +blur,m14,0.16,,0,0,,0.1414,0.0,,1536,0.051,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,1.5747,0.4455,0.0,160.5274,,,0,,,2048 +blur,m14,0.2,,0,0,,0.1419,0.0,,1536,0.0428,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,1.1327,0.1455,0.0,160.5274,,,0,,,2048 +blur,m14,0.25,,0,0,,0.1474,0.0,,1536,0.0374,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,0.8466,0.0861,0.0,160.5274,,,0,,,2048 +blur,m14,0.3,,0,0,,0.1517,0.0,,1536,0.0335,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,0.6893,0.0911,0.0,160.5274,,,0,,,2048 +edge,m14,2.0,0.2205,,,2.0767,217.4028,0.0059,1,1536,0.2187,2.0005,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,637.732,3723.0144,0.0271,160.5274,642.0623,3658.9997,1,0.0277,156.3843,1596 +edge,m14,1.0,0.2145,1,1,1.0333,200.1892,0.0063,0,1536,0.2187,0.9976,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,637.732,3723.0144,0.0271,160.5274,652.1438,3612.3233,0,0.0242,170.24,1435 +edge,m14,0.5,0.219,,,0.5041,202.9055,0.0065,1,1536,0.2187,0.4992,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,637.732,3723.0144,0.0271,160.5274,653.6244,3636.4472,1,0.0274,162.65,1355 +edge,m14,0.25,0.1726,,,-0.0212,204.7702,0.0067,0,1536,0.2187,0.2501,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,637.732,3723.0144,0.0271,160.5274,403.4519,2208.7884,1,0.0116,244.9361,1315 +edge,m14,0.1,0.2236,,,0.1062,202.5682,0.0068,1,1536,0.2229,0.1005,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,674.5369,3969.7402,0.0281,160.5274,676.7897,3768.0594,1,0.0287,159.4046,1291 +edge,m14,0.0,0.2214,,,0.0816,195.3782,0.0069,1,1536,0.2252,0.0009,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,695.1767,4045.8276,0.0296,160.5274,683.7963,3825.1026,1,0.0274,164.2338,1275 +edge,m14,-0.5,0.2442,0,0,-0.0866,177.052,0.007,0,1536,0.2377,0.0,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,828.5976,4614.0866,0.0314,160.5274,718.4293,4033.7491,0,0.0354,133.12,1195 +edge,m14,-1.0,0.2188,0,0,2.8464,147.3396,0.007,0,1536,0.2486,0.0,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,922.3301,4472.0968,0.0303,160.5274,1731.8709,1939.2155,0,0.0017,41.0424,1114 +glare,m14,0.3,0.2734,,,3.7762,214.1114,0.0046,0,1536,0.1851,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,467.6542,2562.8119,0.0281,160.5274,994.7418,2486.7059,1,0.0007,92.9437,2048 +glare,m14,0.5,0.2734,,,3.7762,206.1708,0.0046,0,1536,0.1666,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,373.4526,1934.6449,0.029,160.5274,994.7418,2486.7059,1,0.0007,92.9437,2048 +glare,m14,0.7,0.2734,,,3.7762,199.298,0.0047,0,1536,0.1526,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,294.9637,1422.9685,0.0301,160.5274,994.7418,2486.7059,1,0.0007,92.9437,2048 +glare,m14,0.85,0.2734,,,3.7762,194.9093,0.0047,0,1536,0.1459,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,246.4299,1115.3444,0.0327,160.5274,994.7418,2486.7059,1,0.0007,92.9437,2048 +glare,m14,1.0,0.2734,0,0,3.7762,191.1242,0.0059,0,1536,0.1428,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,206.4401,872.3337,0.0784,160.5274,994.7418,2486.7059,0,0.0007,92.9437,2048 +glare,m14,1.5,0.2734,0,0,3.7762,182.0258,0.012,0,1536,0.1271,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,122.7802,412.2789,0.3179,160.5274,994.7418,2486.7059,0,0.0007,92.9437,2048 +glare,m14,2.5,0.2734,0,0,3.7762,171.0039,0.0197,0,1536,0.0871,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,52.8232,127.085,0.6124,160.5274,994.7418,2486.7059,0,0.0007,92.9437,2048 +glare,m14,4.0,0.2734,0,0,3.7762,161.0694,0.027,0,1536,0.0466,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,17.2839,28.8202,0.8064,160.5274,994.7418,2486.7059,0,0.0007,92.9437,2048 +motion,m14,0.05,0.2014,,,3.1829,69.0562,0.004,1,1536,0.2007,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,87.9692,2541.294,0.0189,160.5274,87.6786,2472.3894,1,0.0191,157.5522,2048 +motion,m14,0.1,0.2135,0,0,5.3668,26.5773,0.0035,0,1536,0.1852,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,29.2524,1373.4196,0.0086,160.5274,33.3071,502.857,0,0.0123,94.5337,2048 +motion,m14,0.15,0.1566,0,0,2.2508,15.1168,0.0032,0,1536,0.1731,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,17.4709,769.9403,0.0002,160.5274,15.9319,812.8432,0,0.001,197.0605,2048 +motion,m14,0.2,,0,0,,10.2011,0.003,,1536,0.1627,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,12.412,483.4079,0.0,160.5274,,,0,,,2048 +motion,m14,0.3,,0,0,,5.8825,0.0028,,1536,0.1442,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,8.044,250.8255,0.0,160.5274,,,0,,,2048 +motion,m14,0.4,,0,0,,4.1086,0.0025,,1536,0.1279,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,6.0989,150.3178,0.0,160.5274,,,0,,,2048 +motion,m14,0.5,,0,0,,3.1339,0.0022,,1536,0.1135,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,5.1823,109.1728,0.0,160.5274,,,0,,,2048 +motion,m14,0.7,,0,0,,2.193,0.0014,,1536,0.088,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,3.9783,67.5033,0.0,160.5274,,,0,,,2048 +motion,m14,1.0,,0,0,,1.5501,0.0005,,1536,0.0612,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,3.1069,41.9073,0.0,160.5274,,,0,,,2048 +scale,m14,48,0.2113,0,1,3.0681,800.6989,0.0044,0,459,0.212,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,5863.3258,3545.7224,0.0218,47.9701,5994.9733,3530.8801,0,0.0206,47.9318,612 +scale,m14,40,0.1679,0,1,1.5706,900.7178,0.0043,0,383,0.2091,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,6727.9737,3424.3844,0.0195,40.0273,4505.8894,2495.1065,0,0.0093,65.0302,510 +scale,m14,32,0.173,0,1,1.4082,1007.8918,0.0041,0,306,0.2038,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,7381.829,7381.829,0.0159,31.9801,4126.7087,2201.8228,0,0.0068,55.7507,408 +scale,m14,26,,0,0,,1125.4708,0.0042,,249,0.1995,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,7939.2209,1995.6024,0.018,26.023,,,0,,,332 +scale,m14,22,,0,0,,1225.5378,0.0042,,211,0.1953,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,8347.2639,1287.0902,0.0168,22.0516,,,0,,,281 +scale,m14,18,,0,0,,1367.0933,0.0037,,172,0.1899,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,8951.8418,751.3298,0.0088,17.9757,,,0,,,230 +scale,m14,15,,0,0,,1526.645,0.0034,,144,0.1825,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,9372.864,455.1192,0.0,15.0494,,,0,,,191 +scale,m14,12,,0,0,,1771.2769,0.0035,,115,0.1742,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,10504.2575,230.0478,0.0,12.0187,,,0,,,153 +scale,m14,10,,0,0,,1988.4993,0.0032,,96,0.1711,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,11548.1353,141.0644,0.0,10.033,,,0,,,128 +scale,m14,8,,0,0,,2376.0029,0.0029,,77,0.1614,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,13253.1065,61.8165,0.0,8.0473,,,0,,,102 +scale,m14,6,,0,0,,2953.0679,0.0036,,57,0.1484,3.1199,625b27db46755dda5d84a6c4f6a9542cd80c50f7bdcb782e9fa855ee5f6a8059,18807.6049,40.5149,0.0,5.9571,,,0,,,77 +blur,m15,0.02,0.1464,,,16.0,167.3398,0.0,1,1536,0.147,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,304.9804,438.108,0.0,38.6918,301.356,440.1253,1,0.0,38.6977,2048 +blur,m15,0.04,0.119,,,16.0,55.8234,0.0,1,1536,0.1195,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,57.9392,101.7537,0.0,38.6918,57.255,102.366,1,0.0,38.6977,2048 +blur,m15,0.06,0.1016,,,6.5278,18.281,0.0,0,1536,0.0968,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,15.1918,26.6727,0.0,38.6918,3.5557,112.5484,1,0.0,107.1628,2048 +blur,m15,0.08,0.0773,,,14.4675,7.1738,0.0,1,1536,0.0798,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,5.7665,8.82,0.0,38.6918,5.4395,11.792,1,0.0,42.7592,2048 +blur,m15,0.1,0.0671,,,16.0,3.3664,0.0,1,1536,0.067,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,3.195,3.4937,0.0,38.6918,3.1634,3.4576,1,0.0,38.6977,2048 +blur,m15,0.13,0.1288,0,0,0.1959,1.4085,0.0,0,1536,0.0531,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,2.0519,1.2186,0.0,38.6918,1.5787,267.4015,0,0.0,242.0435,2048 +blur,m15,0.16,0.0828,0,0,6.5,0.7435,0.0,0,1536,0.043,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,1.7908,0.664,0.0,38.6918,1.5697,10.2224,0,0.0,107.1628,2048 +blur,m15,0.2,0.0768,0,0,6.3514,0.4227,0.0,0,1536,0.0336,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,1.7129,0.4759,0.0,38.6918,1.5195,5.9876,0,0.0,110.1395,2048 +blur,m15,0.25,,0,0,,0.2729,0.0,,1536,0.0276,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,1.5167,0.3961,0.0,38.6918,,,0,,,2048 +blur,m15,0.3,,0,0,,0.2109,0.0,,1536,0.0282,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,1.4023,0.3476,0.0,38.6918,,,0,,,2048 +edge,m15,2.0,0.1712,,,2.1881,201.0072,0.0001,1,1536,0.1631,1.9893,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,836.206,902.8001,0.0,38.6918,944.6551,829.901,1,0.0,35.7209,1226 +edge,m15,1.0,0.1712,,,1.1253,193.2337,0.0001,1,1536,0.1631,1.0072,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,836.206,902.8001,0.0,38.6918,944.6551,829.901,1,0.0,35.7209,1188 +edge,m15,0.5,0.1712,,,0.5627,194.6984,0.0,1,1536,0.1631,0.4903,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,836.206,902.8001,0.0,38.6918,944.6551,829.901,1,0.0,35.7209,1168 +edge,m15,0.25,0.1652,,,0.2936,193.9471,0.0,1,1536,0.1631,0.2577,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,836.206,902.8001,0.0,38.6918,862.8091,888.0354,1,0.0,37.9535,1159 +edge,m15,0.1,0.1663,,,0.1175,193.8189,0.0,1,1536,0.1647,0.1026,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,856.9534,927.6512,0.0,38.6918,876.9029,912.2216,1,0.0,37.9535,1153 +edge,m15,0.0,0.1674,,,0.0588,191.0371,0.0,1,1536,0.1658,0.0,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,870.8968,930.309,0.0,38.6918,890.986,921.2497,1,0.0,37.9535,1149 +edge,m15,-0.5,0.1735,0,0,0.0625,190.1604,0.0,0,1536,0.1658,0.0,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,853.4636,923.6525,0.0,38.6918,956.9582,841.3329,0,0.0,35.7209,1130 +edge,m15,-1.0,0.1719,0,0,0.0639,185.2004,0.0,0,1536,0.1614,0.0,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,817.4576,890.9742,0.0,38.6918,959.2522,732.3641,0,0.0,34.0973,1110 +edge,m15,-2.0,0.1358,0,0,11.0872,176.7876,0.0,0,1536,0.162,0.0,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,770.7341,840.8412,0.0,38.6918,1084.0494,382.5012,0,0.0,26.7907,1072 +glare,m15,0.3,0.1271,,,16.0,242.8437,0.0001,1,1536,0.1271,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,508.8276,549.6163,0.0,38.6918,508.8276,549.6163,1,0.0,38.6977,2048 +glare,m15,0.5,0.1091,,,17.4167,235.2639,0.0001,1,1536,0.1048,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,338.1249,366.1206,0.0001,38.6918,373.0853,332.2391,1,0.0001,35.7209,2048 +glare,m15,0.7,0.0885,,,17.4167,228.9586,0.0001,1,1536,0.085,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,208.4878,225.8037,0.0003,38.6918,229.9253,204.6895,1,0.0003,35.7209,2048 +glare,m15,0.85,0.0761,,,17.4167,225.081,0.0003,1,1536,0.0731,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,136.2327,147.9276,0.0442,38.6918,150.187,133.8819,1,0.0487,35.7209,2048 +glare,m15,1.0,0.0712,,,19.0909,221.9209,0.0018,1,1536,0.0653,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,86.5523,94.2247,0.326,38.6918,107.2193,74.0146,1,0.3359,32.7442,2048 +glare,m15,1.5,0.1591,0,0,11.8786,215.52,0.0066,0,1536,0.0394,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,20.1116,21.6022,0.669,38.6918,915.2072,957.0254,0,0.0,39.093,2048 +glare,m15,2.5,0.1591,0,0,11.8786,210.6424,0.0133,0,1536,0.007,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,0.2575,0.0815,0.9481,38.6918,915.2072,957.0254,0,0.0,39.093,2048 +glare,m15,4.0,0.1591,0,0,11.8786,207.7343,0.0195,0,1536,0.0,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,0.0,0.0,1.0,38.6918,915.2072,957.0254,0,0.0,39.093,2048 +motion,m15,0.05,0.1573,,,16.0,228.4587,0.0,1,1536,0.1581,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,607.4548,749.1497,0.0,38.6918,600.1924,747.8561,1,0.0,38.6977,2048 +motion,m15,0.1,0.1429,,,16.0,153.7718,0.0,1,1536,0.1435,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,350.1502,446.3512,0.0,38.6918,345.9715,443.2841,1,0.0,38.6977,2048 +motion,m15,0.15,0.1053,,,6.5,106.5509,0.0,0,1536,0.1281,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,224.9813,276.8845,0.0,38.6918,27.4162,258.2456,1,0.0,107.1628,2048 +motion,m15,0.2,0.1149,,,16.0,84.2625,0.0,1,1536,0.1153,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,173.216,206.5777,0.0,38.6918,171.1583,204.1559,1,0.0,38.6977,2048 +motion,m15,0.3,0.0973,,,16.0,65.935,0.0,1,1536,0.0975,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,123.111,144.186,0.0,38.6918,121.6596,142.3469,1,0.0,38.6977,2048 +motion,m15,0.4,0.0908,,,17.4167,58.9238,0.0,1,1536,0.0894,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,102.9223,119.2285,0.0,38.6918,110.6829,106.2447,1,0.0,35.7209,2048 +motion,m15,0.5,0.0692,0,0,12.6667,52.442,0.0,0,1536,0.0833,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,85.1102,97.1621,0.0,38.6918,40.9269,50.2609,0,0.0,39.0,2048 +motion,m15,0.7,0.0767,0,0,17.0909,44.9858,0.0,0,1536,0.0777,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,66.4417,74.9043,0.0,38.6918,56.9909,43.8683,0,0.0,32.7442,2048 +motion,m15,1.0,0.0681,0,0,9.6502,38.4006,0.0,0,1536,0.0743,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,57.0386,63.6282,0.0,38.6918,25.4521,57.3386,0,0.0,50.9586,2048 +scale,m15,32,0.1647,,,16.0,286.0515,0.0001,1,1270,0.1647,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,1422.7762,1422.7762,0.0,31.9913,1422.7762,1422.7762,1,0.0,31.9961,1694 +scale,m15,26,0.1642,,,16.0,284.4052,0.0001,1,1032,0.1659,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,2589.4794,809.3332,0.0,25.9961,2520.3254,784.2835,1,0.0,26.0,1376 +scale,m15,22,0.1707,,,17.7,277.9332,0.0001,1,873,0.1632,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,3809.2958,666.0847,0.0,21.9909,4276.3116,561.5587,1,0.0,19.9771,1164 +scale,m15,18,0.16,,,16.1111,581.1017,0.0001,1,715,0.1625,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,5970.8558,557.5259,0.0,18.0109,5772.6949,525.6895,1,0.0,17.9749,953 +scale,m15,15,0.1634,,,17.1429,731.377,0.0001,1,595,0.1577,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,7717.3752,361.7123,0.0,14.9881,8528.5015,347.5813,1,0.0,13.9765,794 +scale,m15,12,0.1504,,,16.0,906.7439,0.0001,1,476,0.1519,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,9818.7444,232.1582,0.0,11.9904,9563.8787,224.2511,1,0.0,12.0,635 +scale,m15,10,0.153,,,5.7893,1001.2406,0.0001,0,397,0.1448,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,11111.214,148.0959,0.0,10.0004,1026.5845,502.7919,1,0.0,30.6717,529 +scale,m15,8,0.1594,0,0,0.009,1083.326,0.0002,0,318,0.1346,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,12549.0198,58.4889,0.0033,8.0104,560.7179,984.1421,0,0.0,178.9734,423 +scale,m15,6,0.1561,0,0,0.0136,1113.8574,0.0002,0,238,0.1127,16.0024,6a1899cb2dafd0673a2701203d811a4b7d2ea7b43d54bd62d2fe957cf52c0a1a,11325.2196,26.4893,0.0,5.9952,768.3472,964.5716,0,0.0,134.4752,318 +blur,m16,0.02,0.1579,,,8.7895,62.1616,0.0072,1,1920,0.1271,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,20.1824,169.5633,0.0,61.8624,20.7087,286.9354,1,0.0,70.6977,2560 +blur,m16,0.04,0.1406,,,8.3,22.5171,0.0065,0,1920,0.1124,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,6.4327,50.9587,0.0,61.8624,7.6572,132.5854,1,0.0,74.4186,2560 +blur,m16,0.06,0.1239,,,8.3,7.8741,0.0059,0,1920,0.1,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,2.8391,13.3425,0.0,61.8624,3.4746,42.4957,1,0.0,74.4186,2560 +blur,m16,0.08,0.1201,0,0,7.7349,3.255,0.0055,0,1920,0.0913,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,2.0025,4.1,0.0,61.8624,2.2394,18.9908,0,0.0,79.7515,2560 +blur,m16,0.1,0.1035,0,0,8.8421,1.5723,0.0049,0,1920,0.085,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,1.7543,1.5963,0.0,61.8624,1.88,5.6232,0,0.0,70.6977,2560 +blur,m16,0.13,,0,0,,0.6794,0.0042,,1920,0.0782,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,1.6963,0.581,0.0,61.8624,,,0,,,2560 +blur,m16,0.16,,0,0,,0.3854,0.0034,,1920,0.0733,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,1.6197,0.3101,0.0,61.8624,,,0,,,2560 +blur,m16,0.2,,0,0,,0.2327,0.0023,,1920,0.0684,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,1.5237,0.2044,0.0,61.8624,,,0,,,2560 +blur,m16,0.25,,0,0,,0.1627,0.0011,,1920,0.0639,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,1.5562,0.16,0.0,61.8624,,,0,,,2560 +blur,m16,0.3,,0,0,,0.1348,0.0006,,1920,0.0598,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,1.6093,0.1454,0.0,61.8624,,,0,,,2560 +edge,m16,2.0,0.1679,,,1.2218,67.9704,0.0014,1,1920,0.1371,2.003,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,141.8334,371.1699,0.0,61.8624,127.9179,587.5608,1,0.0,75.3488,1699 +edge,m16,1.0,0.1681,,,0.4071,66.1839,0.0009,1,1920,0.1371,1.0008,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,141.8334,371.1699,0.0,61.8624,128.0563,595.4705,1,0.0,75.3488,1637 +edge,m16,0.5,0.172,,,0.0352,65.2025,0.0007,1,1920,0.1371,0.4996,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,141.8334,371.1699,0.0,61.8624,134.2887,647.4168,1,0.0,75.9204,1606 +edge,m16,0.25,0.1736,,,0.037,64.1737,0.0006,0,1920,0.1371,0.2572,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,141.8334,371.1699,0.0,61.8624,135.4785,644.546,1,0.0,75.3488,1591 +edge,m16,0.1,0.1725,,,0.037,63.2305,0.0006,0,1920,0.1367,0.0955,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,143.641,375.4752,0.0,61.8624,134.733,635.0661,1,0.0,75.3488,1581 +edge,m16,0.0,0.1729,,,0.037,62.7075,0.0006,0,1920,0.1366,0.0,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,144.5707,378.1632,0.0,61.8624,132.8886,633.4792,1,0.0,75.3488,1575 +edge,m16,-0.5,0.0591,,,15.6073,60.6161,0.0005,0,1920,0.1364,0.0,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,147.4977,389.5547,0.0,61.8624,47.0517,22.5969,1,0.0,30.3375,1544 +edge,m16,-1.0,0.1367,,,0.028,58.3674,0.0005,0,1920,0.1367,0.0,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,148.1838,392.9869,0.0,61.8624,153.9642,337.5756,1,0.0,56.4821,1513 +edge,m16,-2.0,0.1378,,,0.0554,52.1978,0.0005,0,1920,0.1383,0.0,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,142.3458,371.7898,0.0,61.8624,154.4506,258.0551,1,0.0,50.2326,1451 +glare,m16,0.3,0.152,,,8.2209,107.0689,0.0079,1,1920,0.1276,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,87.0859,221.5455,0.0,61.8624,96.8394,444.3258,1,0.0,75.3073,2560 +glare,m16,0.5,0.1477,,,8.1938,103.784,0.0079,1,1920,0.133,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,58.444,144.9738,0.0,61.8624,80.1777,365.1708,1,0.0,75.542,2560 +glare,m16,0.7,0.1508,,,8.4148,101.0156,0.0079,1,1920,0.1467,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,36.3772,86.6328,0.0,61.8624,67.3802,284.6626,1,0.0,73.6686,2560 +glare,m16,0.85,0.1572,,,8.5265,99.3076,0.0079,1,1920,0.1613,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,24.1946,55.0098,0.0,61.8624,57.3991,232.3987,1,0.0,72.7577,2560 +glare,m16,1.0,0.167,,,7.9625,97.9165,0.0089,1,1920,0.1785,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,15.8848,33.8526,0.1185,61.8624,46.9877,225.0252,1,0.0,77.8261,2560 +glare,m16,1.5,0.1943,0,0,8.2474,94.8124,0.0223,0,1920,0.1383,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,4.2659,7.7585,0.604,61.8624,29.5386,125.633,0,0.0704,75.2916,2560 +glare,m16,2.5,0.0566,0,0,25.7778,91.5348,0.0393,0,1920,0.0368,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,0.3002,0.2837,0.8943,61.8624,41.3256,25.2763,0,0.0,33.4884,2560 +glare,m16,4.0,0.1138,0,0,4.226,88.9129,0.0549,0,1920,0.0,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,0.0,0.0,1.0,61.8624,53.0305,1014.9734,0,0.0,184.1067,2560 +motion,m16,0.05,0.1643,,,8.7895,101.6429,0.0077,1,1920,0.1347,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,73.0561,302.8719,0.0,61.8624,64.3676,424.361,1,0.0,70.6977,2560 +motion,m16,0.1,0.1603,,,8.3689,79.32,0.0074,1,1920,0.1295,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,45.2434,213.3596,0.0,61.8624,41.7495,344.5201,1,0.0,74.0499,2560 +motion,m16,0.15,0.1505,,,8.2078,62.4477,0.0069,0,1920,0.124,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,34.0447,162.4191,0.0,61.8624,30.9135,249.1222,1,0.0,75.2068,2560 +motion,m16,0.2,0.1431,,,8.7895,51.4813,0.0066,0,1920,0.1192,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,27.6078,131.3672,0.0,61.8624,24.8589,148.9675,1,0.0,70.6977,2560 +motion,m16,0.3,0.1357,0,0,8.3,38.6866,0.006,0,1920,0.1117,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,19.9759,96.7003,0.0,61.8624,19.0172,136.5401,0,0.0,74.4186,2560 +motion,m16,0.4,0.0948,0,0,7.4107,32.9744,0.0055,0,1920,0.1078,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,16.6189,82.2586,0.0,61.8624,15.6935,76.3113,0,0.0,61.6183,2560 +motion,m16,0.5,,0,0,,29.0323,0.005,,1920,0.1051,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,14.6037,74.4235,0.0,61.8624,,,0,,,2560 +motion,m16,0.7,,0,0,,23.7937,0.0039,,1920,0.1012,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,12.1855,64.6896,0.0,61.8624,,,0,,,2560 +motion,m16,1.0,,0,0,,18.8362,0.0022,,1920,0.0965,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,9.94,53.9436,0.0,61.8624,,,0,,,2560 +scale,m16,48,0.1683,,,8.3,115.4652,0.0079,0,1490,0.1371,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,236.6425,365.3037,0.0,48.0078,211.3462,586.7459,1,0.0,57.7519,1986 +scale,m16,40,0.1685,,,8.3498,116.439,0.0079,0,1241,0.137,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,355.3987,370.8351,0.0,39.985,312.9168,566.5407,1,0.0,47.9659,1655 +scale,m16,32,0.1746,,,8.0,115.4942,0.0078,1,993,0.1369,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,575.0507,575.0507,0.0,31.9945,528.5434,640.9435,1,0.0,39.9598,1324 +scale,m16,26,0.1648,,,8.5026,113.5803,0.0078,1,807,0.1367,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,922.2237,300.8274,0.0,26.0015,859.014,495.9084,1,0.0,30.5088,1076 +scale,m16,22,0.1647,,,8.4615,242.0292,0.0078,1,683,0.1364,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,1391.967,260.435,0.0,22.0063,1342.6932,439.4359,1,0.0,25.962,910 +scale,m16,18,0.1133,,,9.3333,373.825,0.0077,0,559,0.1358,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,2241.7608,223.3791,0.0,18.011,1397.437,305.4575,1,0.0,23.9571,745 +scale,m16,15,0.1085,,,9.4,534.833,0.0077,0,466,0.1345,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,3381.541,184.1845,0.0,15.0145,1964.3648,236.5907,1,0.0,20.0,621 +scale,m16,12,0.1629,,,7.375,767.292,0.0075,0,372,0.1311,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,4663.3381,120.1869,0.0,11.9858,5261.4725,272.2589,1,0.0,16.0,497 +scale,m16,10,0.1605,0,0,8.3333,956.0635,0.0072,0,310,0.1263,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,5191.9859,73.98,0.0,9.9882,7398.1814,201.4673,0,0.0,12.0,414 +scale,m16,8,0.1148,0,0,7.5,1138.8553,0.0068,0,248,0.1191,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,5152.4093,26.6513,0.0,7.9906,5444.6876,26.977,0,0.0,8.0,331 +scale,m16,6,0.1601,0,0,7.5,1336.4611,0.0064,0,186,0.1082,8.0341,75ca3524fab48dfe4ec89e6b732d3d1d614059c628d66749908e93c7c83ab7e7,4365.5262,11.5782,0.0,5.9929,3697.4708,20.8945,0,0.0,8.0,248 +blur,m17,0.02,0.1451,,,16.5882,40.7872,0.0594,1,1920,0.1563,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,29.273,108.2291,0.0,50.2272,29.0421,87.5923,1,0.0,47.4419,1920 +blur,m17,0.04,0.0946,,,4.9096,13.5473,0.0588,0,1920,0.1441,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,9.5713,40.5528,0.0,50.2272,2.921,79.1331,1,0.0,125.3531,1920 +blur,m17,0.06,0.0872,,,4.8862,5.1768,0.0582,0,1920,0.1305,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,4.7905,16.7367,0.0,50.2272,2.1259,42.0839,1,0.0,124.3379,1920 +blur,m17,0.08,0.0995,,,4.7742,2.4504,0.0577,0,1920,0.1178,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,3.0123,7.5063,0.0,50.2272,1.8404,33.528,1,0.0,128.8154,1920 +blur,m17,0.1,0.0806,0,0,4.9796,1.3891,0.0571,0,1920,0.1066,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,2.3256,3.5903,0.0,50.2272,1.5929,14.8425,0,0.0,123.6214,1920 +blur,m17,0.13,0.1615,0,0,1.6198,0.7326,0.0561,0,1920,0.0934,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,1.9117,1.3991,0.0,50.2272,1.4689,124.7307,0,0.0,255.6804,1920 +blur,m17,0.16,,0,0,,0.4625,0.055,,1920,0.0834,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,1.7778,0.7133,0.0,50.2272,,,0,,,1920 +blur,m17,0.2,,0,0,,0.3107,0.0535,,1920,0.0735,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,1.6821,0.4089,0.0,50.2272,,,0,,,1920 +blur,m17,0.25,,0,0,,0.2337,0.0518,,1920,0.0646,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,1.6711,0.2725,0.0,50.2272,,,0,,,1920 +blur,m17,0.3,,0,0,,0.2,0.0501,,1920,0.0578,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,1.46,0.2418,0.0,50.2272,,,0,,,1920 +edge,m17,2.0,0.1838,,,-0.1126,90.7426,0.0735,0,1920,0.1631,2.0094,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,160.0453,250.3322,0.0,50.2272,44.9152,1285.3678,1,0.0,272.7483,1220 +edge,m17,1.0,0.1634,,,0.9442,90.7883,0.0762,1,1920,0.1631,0.994,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,160.0453,250.3322,0.0,50.2272,159.7135,251.117,1,0.0,50.2326,1169 +edge,m17,0.5,0.163,,,0.4999,90.9694,0.0776,1,1920,0.1631,0.4963,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,160.0453,250.3322,0.0,50.2272,160.1824,250.8924,1,0.0,50.2326,1144 +edge,m17,0.25,0.1631,,,0.222,91.2246,0.0784,1,1920,0.1631,0.2574,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,160.0453,250.3322,0.0,50.2272,159.8037,251.5777,1,0.0,50.2326,1132 +edge,m17,0.1,0.1623,,,0.111,91.5856,0.0789,1,1920,0.1623,0.0981,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,161.4914,252.7778,0.0,50.2272,161.4914,252.7778,1,0.0,50.2326,1124 +edge,m17,0.0,0.1617,,,0.0554,91.5624,0.0792,1,1920,0.1617,0.0,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,161.1933,252.843,0.0,50.2272,160.8943,254.8894,1,0.0,50.2326,1119 +edge,m17,-0.5,0.1618,0,0,0.2659,91.7871,0.0807,0,1920,0.1605,0.0,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,157.8573,245.8575,0.0,50.2272,161.9277,271.7649,0,0.0,51.9285,1094 +edge,m17,-1.0,0.1607,0,0,0.0554,91.5833,0.0825,0,1920,0.1608,0.0,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,162.217,253.39,0.0,50.2272,161.8708,255.1809,0,0.0,50.2326,1069 +edge,m17,-2.0,0.1528,0,0,0.0587,89.1847,0.0864,0,1920,0.1611,0.0,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,162.9168,254.1286,0.0,50.2272,160.943,216.5196,0,0.0,47.4419,1019 +glare,m17,0.3,0.1318,,,16.5882,78.6046,0.0598,1,1920,0.1392,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,102.2133,155.0688,0.0,50.2272,102.1143,135.2213,1,0.0,47.4419,1920 +glare,m17,0.5,0.1322,,,14.6451,76.5159,0.0598,1,1920,0.131,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,70.9446,105.6322,0.0,50.2272,73.6189,129.3528,1,0.0,53.7071,1920 +glare,m17,0.7,0.1304,,,15.2641,74.7646,0.0598,1,1920,0.1303,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,46.7608,67.4671,0.0,50.2272,46.7218,69.8646,1,0.0,51.5346,1920 +glare,m17,0.85,0.1342,,,16.5882,73.6732,0.0599,1,1920,0.1349,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,32.8825,46.1025,0.0,50.2272,32.5758,40.3931,1,0.0,47.4419,1920 +glare,m17,1.0,0.1254,0,0,5.0,72.7834,0.0612,0,1920,0.1433,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,23.1101,31.2945,0.1478,50.2272,17.1346,67.4689,0,0.0,122.7907,1920 +glare,m17,1.5,0.1076,0,0,10.7623,70.8405,0.0705,0,1920,0.111,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,7.4602,9.4803,0.6106,50.2272,247.6613,145.0934,0,0.0,31.1163,1920 +glare,m17,2.5,0.1076,0,0,10.7623,68.7738,0.0823,0,1920,0.0324,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,0.4798,0.3769,0.8895,50.2272,247.6613,145.0934,0,0.0,31.1163,1920 +glare,m17,4.0,0.107,0,0,10.6726,67.0014,0.0932,0,1920,0.0,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,0.0,0.0,1.0,50.2272,244.5433,143.3387,0,0.0,31.1163,1920 +motion,m17,0.05,0.1613,,,15.6667,65.4972,0.0597,1,1920,0.1613,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,86.2047,191.3064,0.0,50.2272,86.2047,191.3064,1,0.0,50.2326,1920 +motion,m17,0.1,0.1586,,,15.6667,48.9083,0.0594,1,1920,0.1586,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,57.9672,145.5261,0.0,50.2272,57.9672,145.5261,1,0.0,50.2326,1920 +motion,m17,0.15,0.1425,,,16.5882,34.5943,0.0592,1,1920,0.1538,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,41.6056,112.5723,0.0,50.2272,40.6066,90.5962,1,0.0,47.4419,1920 +motion,m17,0.2,0.139,,,16.5882,29.5444,0.059,1,1920,0.1506,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,35.9057,99.7079,0.0,50.2272,34.9493,79.4707,1,0.0,47.4419,1920 +motion,m17,0.3,0.074,,,5.1459,23.286,0.0585,0,1920,0.1437,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,28.0156,81.1758,0.0,50.2272,5.351,39.4281,1,0.0,119.5496,1920 +motion,m17,0.4,0.104,0,0,4.8701,20.1248,0.0583,0,1920,0.1386,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,24.0088,71.6,0.0,50.2272,8.1224,126.5256,0,0.0,127.3708,1920 +motion,m17,0.5,0.0718,0,0,5.26,18.2502,0.0578,0,1920,0.1354,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,21.5596,65.8374,0.0,50.2272,3.6452,28.2718,0,0.0,118.0552,1920 +motion,m17,0.7,0.2001,0,0,1.1825,16.0252,0.0568,0,1920,0.1315,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,18.6691,58.733,0.0,50.2272,3.7605,341.833,0,0.0,308.3279,1920 +motion,m17,1.0,0.2026,0,0,1.0614,14.1472,0.0555,0,1920,0.1294,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,16.6294,54.0896,0.0,50.2272,3.2158,242.0727,0,0.0,336.8384,1920 +scale,m17,48,0.1506,,,16.6471,83.6029,0.0598,1,1835,0.1626,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,164.7589,237.8962,0.0,48.0036,169.2503,224.7913,1,0.0,45.3416,1835 +scale,m17,40,0.1617,,,15.7222,83.8619,0.0598,1,1529,0.1614,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,256.2977,246.1102,0.0,39.9986,256.2963,245.6176,1,0.0,40.0029,1529 +scale,m17,32,0.1519,,,18.0,82.4682,0.0598,1,1223,0.163,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,407.5745,407.5745,0.0,31.9937,420.7356,156.2685,1,0.0,27.9771,1223 +scale,m17,26,0.1666,,,15.6923,152.1774,0.0598,1,994,0.1618,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,627.6265,184.6392,0.0,26.003,610.9588,178.9908,1,0.0,26.0,994 +scale,m17,22,0.1664,,,14.4167,208.3879,0.0597,1,841,0.1629,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,857.0599,153.2269,0.0,22.0006,948.604,217.9892,1,0.0,23.9715,841 +scale,m17,18,0.165,,,15.6667,297.7473,0.0597,1,688,0.1584,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,1309.3262,136.7018,0.0,17.9981,1272.6393,125.9897,1,0.0,18.0,688 +scale,m17,15,0.1757,,,12.838,405.9897,0.0596,1,573,0.1576,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,1940.4165,110.8926,0.0,14.9897,1799.7587,200.9624,1,0.0,18.1938,573 +scale,m17,12,0.0814,0,0,5.1191,571.4935,0.0596,0,459,0.1615,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,3127.8924,82.4765,0.0,12.0074,271.9499,111.6528,0,0.0,28.8286,459 +scale,m17,10,0.0827,,,5.0,721.6704,0.0594,0,382,0.1584,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,4478.8171,64.8544,0.0,9.9931,354.8487,71.6877,1,0.0,24.0,382 +scale,m17,8,0.2117,0,0,1.1804,933.3548,0.0593,0,306,0.1623,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,6517.1252,34.5431,0.0,8.005,1593.0887,1265.3966,0,0.0006,49.7561,306 +scale,m17,6,0.2181,0,0,1.5057,1288.5142,0.0591,0,229,0.1535,15.6682,b38f85dd3e25e36fbf1528f22a593d163787340cdff26895cbc8948a7998e760,6955.5113,19.3095,0.0,5.9906,2827.6489,1294.2868,0,0.0004,34.5377,229 +blur,m18,0.02,0.1588,,,15.138,106.0891,0.0008,0,1800,0.1788,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,53.2107,428.4452,0.0,63.828,76.9433,131.5924,1,0.0,38.3287,1800 +blur,m18,0.04,0.1456,0,0,12.6126,14.181,0.0007,0,1800,0.157,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,8.0875,77.3649,0.0,63.828,9.8092,30.8696,0,0.0,45.831,1800 +blur,m18,0.06,0.1005,0,0,9.1355,2.935,0.0006,0,1800,0.1403,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,3.0768,19.9227,0.0,63.828,2.1735,17.1254,0,0.0,74.2623,1800 +blur,m18,0.08,0.11,0,0,7.863,1.045,0.0005,0,1800,0.128,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,2.0823,7.2019,0.0,63.828,1.6465,9.1637,0,0.0,85.8189,1800 +blur,m18,0.1,,0,0,,0.5902,0.0004,,1800,0.1189,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1.8289,3.1024,0.0,63.828,,,0,,,1800 +blur,m18,0.13,,0,0,,0.3566,0.0002,,1800,0.1094,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1.7196,1.2142,0.0,63.828,,,0,,,1800 +blur,m18,0.16,,0,0,,0.2684,0.0001,,1800,0.1029,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1.6494,0.6277,0.0,63.828,,,0,,,1800 +blur,m18,0.2,,0,0,,0.2172,0.0,,1800,0.097,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1.4951,0.3464,0.0,63.828,,,0,,,1800 +blur,m18,0.25,,0,0,,0.1908,0.0,,1800,0.0921,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1.3656,0.2225,0.0,63.828,,,0,,,1800 +blur,m18,0.3,,0,0,,0.1792,0.0,,1800,0.0876,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1.465,0.1704,0.0,63.828,,,0,,,1800 +edge,m18,2.0,0.1989,,,2.0126,580.7627,0.0019,1,1800,0.1989,1.9931,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1141.6192,1325.0341,0.0,63.828,1141.1597,1340.6223,1,0.0,63.6151,1234 +edge,m18,1.0,0.1989,,,1.023,572.7504,0.0018,1,1800,0.1989,1.0061,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1141.6192,1325.0341,0.0,63.828,1141.1597,1340.6223,1,0.0,63.7874,1171 +edge,m18,0.5,0.1988,,,0.5249,564.2062,0.0014,1,1800,0.1989,0.5047,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1141.6192,1325.0341,0.0,63.828,1140.6775,1351.935,1,0.0,64.4214,1139 +edge,m18,0.25,0.1988,,,0.2824,558.6371,0.0008,1,1800,0.1989,0.2541,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1141.6192,1325.0341,0.0,63.828,1140.6775,1351.935,1,0.0,64.2654,1123 +edge,m18,0.1,0.1991,,,0.1201,556.9778,0.0005,1,1800,0.1992,0.0974,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1163.4384,1287.6227,0.0,63.828,1158.3084,1369.5929,1,0.0,64.203,1113 +edge,m18,0.0,0.1995,,,0.0388,555.5142,0.0005,1,1800,0.1994,0.0034,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1176.3376,1302.9783,0.0,63.828,1178.8314,1370.0046,1,0.0,64.158,1107 +edge,m18,-0.5,0.1943,0,0,0.2493,542.0891,0.0004,0,1800,0.1984,0.0,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1132.6868,1246.0161,0.0,63.828,1148.9634,1350.8727,0,0.0,62.7907,1075 +edge,m18,-1.0,0.1951,0,0,0.0416,537.4355,0.0004,0,1800,0.1987,0.0,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1115.3313,1224.2678,0.0,63.828,1143.328,1350.7268,0,0.0,62.7907,1043 +edge,m18,-2.0,0.1889,0,0,5.3795,514.237,0.0003,0,1800,0.1881,0.0,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1111.137,1205.836,0.0,63.828,1496.5882,1045.2913,0,0.0,38.2415,979 +glare,m18,0.3,0.1758,,,9.4688,549.0275,0.0016,1,1800,0.1758,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,726.1843,837.969,0.0,63.828,726.1843,855.6795,1,0.0,64.2994,1800 +glare,m18,0.5,0.1709,,,9.4906,541.3803,0.0016,1,1800,0.1709,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,506.455,581.3276,0.0,63.828,506.455,555.3068,1,0.0,64.1593,1800 +glare,m18,0.7,0.1747,,,9.7083,534.743,0.0016,1,1800,0.1755,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,334.1073,380.1075,0.0,63.828,340.2129,403.9345,1,0.0,62.7907,1800 +glare,m18,0.85,0.1846,,,9.7083,530.3292,0.0016,1,1800,0.1847,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,235.4667,264.9336,0.0,63.828,239.499,284.7164,1,0.0,62.7907,1800 +glare,m18,1.0,0.1867,0,0,15.5411,526.4338,0.0025,0,1800,0.1982,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,163.0153,181.0259,0.0835,63.828,1526.4946,1048.6372,0,0.0,37.3331,1800 +glare,m18,1.5,0.194,0,0,12.3161,516.4462,0.0145,0,1800,0.1571,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,47.0848,50.2974,0.581,63.828,1221.7495,1135.9149,0,0.0,47.0457,1800 +glare,m18,2.5,0.194,0,0,12.3161,504.024,0.0294,0,1800,0.0511,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1.7026,1.428,0.8697,63.828,1221.7495,1135.9149,0,0.0,47.0457,1800 +glare,m18,4.0,0.1952,0,0,12.4762,492.3716,0.0429,0,1800,0.0,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,0.0,0.0,1.0,63.828,1293.0305,1161.9561,0,0.0,46.4419,1800 +motion,m18,0.05,0.2012,,,9.1607,362.0382,0.001,1,1800,0.1932,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,484.4815,1009.7637,0.0,63.828,465.0668,1095.9218,1,0.0,66.3504,1800 +motion,m18,0.1,0.1682,,,14.5394,205.9685,0.0009,0,1800,0.1856,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,352.6736,650.8591,0.0,63.828,450.0783,408.9974,1,0.0,39.8903,1800 +motion,m18,0.15,0.1489,,,2.6791,154.0546,0.0008,0,1800,0.178,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,302.7536,498.3814,0.0,63.828,364.4612,900.4451,1,0.0,72.9738,1800 +motion,m18,0.2,0.2039,,,8.9867,135.1187,0.0008,0,1800,0.1741,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,285.6047,457.5249,0.0,63.828,238.9036,423.437,1,0.0,64.4704,1800 +motion,m18,0.3,0.1793,,,9.1579,120.7406,0.0007,1,1800,0.1685,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,266.0008,416.0746,0.0,63.828,255.1549,466.9311,1,0.0,66.3694,1800 +motion,m18,0.4,0.1272,0,0,15.5556,106.1115,0.0006,0,1800,0.1643,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,252.2245,392.3475,0.0,63.828,153.2686,157.9445,0,0.0,47.093,1800 +motion,m18,0.5,,0,0,,96.6108,0.0005,,1800,0.1614,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,243.4363,378.6186,0.0,63.828,,,0,,,1800 +motion,m18,0.7,,0,0,,79.4176,0.0003,,1800,0.1567,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,229.5685,361.0698,0.0,63.828,,,0,,,1800 +motion,m18,1.0,,0,0,,61.282,0.0001,,1800,0.1529,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,215.1915,349.2802,0.0,63.828,,,0,,,1800 +scale,m18,48,0.1977,,,9.6321,556.4101,0.0015,1,1354,0.1971,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1628.3374,1361.5449,0.0,48.0128,1630.1069,1370.7002,1,0.0,47.6705,1354 +scale,m18,40,0.1879,,,9.886,527.626,0.0014,1,1128,0.1964,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,1965.1027,1326.3119,0.0,39.9989,1955.439,1240.1634,1,0.0,38.6095,1128 +scale,m18,32,0.225,,,7.46,1213.328,0.0013,1,902,0.195,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,2371.7818,2371.7818,0.0,31.9849,1844.1174,1432.619,1,0.0,40.1327,902 +scale,m18,26,0.211,,,8.5795,1415.8226,0.0011,1,733,0.1944,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,3229.4353,893.9859,0.0,25.9922,2892.2421,995.94,1,0.0,28.8168,733 +scale,m18,22,0.2134,0,0,10.0943,1525.109,0.001,0,620,0.1919,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,4118.5546,721.227,0.0,21.9852,4212.8783,601.8122,0,0.0,20.1691,620 +scale,m18,18,0.1991,0,0,11.3083,1608.5811,0.0009,0,508,0.1881,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,5306.4713,520.1017,0.0,18.0137,6494.528,326.5675,0,0.0,14.4988,508 +scale,m18,15,0.1696,,,2.6184,1776.2604,0.001,0,423,0.1911,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,5915.1086,303.7062,0.0,14.9996,8032.1483,622.9121,1,0.0,16.9346,423 +scale,m18,12,0.2068,,,8.0,1795.0812,0.0009,1,338,0.1777,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,6148.9024,162.8751,0.0,11.9855,5554.3009,230.6865,1,0.0,14.0,338 +scale,m18,10,0.2166,,,6.9164,1466.0242,0.0008,1,282,0.1718,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,7064.3113,123.1967,0.0,9.9997,5674.757,212.2982,1,0.0,13.4837,282 +scale,m18,8,0.2124,,,7.6,1182.6829,0.0009,1,226,0.1686,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,7303.669,42.4686,0.0,8.014,6173.1599,99.0056,1,0.0,10.0,226 +scale,m18,6,,0,0,,1231.016,0.0008,,169,0.1522,9.5426,db551bf85302140aa27bef89491c035b8e729fd3c76c1bf0c16758fb4e96d920,7163.9779,22.8661,0.0,5.9927,,,0,,,169 +blur,m19,0.02,0.1515,,,8.9051,63.8762,0.0007,1,2880,0.1516,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,12.7408,933.4131,0.0035,124.3296,12.7076,949.1709,1,0.0035,124.9463,3840 +blur,m19,0.04,0.117,,,8.7425,10.1316,0.0003,1,2880,0.1172,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2.3467,110.711,0.0001,124.3296,2.3277,117.8979,1,0.0001,127.2173,3840 +blur,m19,0.06,0.0984,,,8.5927,2.7585,0.0001,1,2880,0.0992,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.6717,18.7601,0.0,124.3296,1.668,21.6338,1,0.0,129.4884,3840 +blur,m19,0.08,,0,0,,1.1479,0.0,,2880,0.0881,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.5861,5.0142,0.0,124.3296,,,0,,,3840 +blur,m19,0.1,,0,0,,0.6117,0.0,,2880,0.0799,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.5751,1.8117,0.0,124.3296,,,0,,,3840 +blur,m19,0.13,,0,0,,0.3196,0.0,,2880,0.0706,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.5088,0.608,0.0,124.3296,,,0,,,3840 +blur,m19,0.16,,0,0,,0.211,0.0,,2880,0.0636,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.4288,0.2573,0.0,124.3296,,,0,,,3840 +blur,m19,0.2,,0,0,,0.1525,0.0,,2880,0.0565,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.297,0.1236,0.0,124.3296,,,0,,,3840 +blur,m19,0.25,,0,0,,0.1225,0.0,,2880,0.0497,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1.1117,0.0924,0.0,124.3296,,,0,,,3840 +blur,m19,0.3,,0,0,,0.1081,0.0,,2880,0.044,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,0.9346,0.0954,0.0,124.3296,,,0,,,3840 +edge,m19,2.0,0.2246,,,1.9636,356.463,0.0026,1,2880,0.225,1.9999,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2934.6606,4404.3514,0.0186,124.3296,2921.7227,4489.959,1,0.0181,129.337,2403 +edge,m19,1.0,0.2253,,,1.0185,367.2451,0.0026,1,2880,0.225,1.0025,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2934.6606,4404.3514,0.0186,124.3296,2949.7316,4526.1523,1,0.0184,126.0961,2279 +edge,m19,0.5,0.2255,,,0.5244,371.4831,0.0026,1,2880,0.225,0.5039,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2934.6606,4404.3514,0.0186,124.3296,2952.6153,4518.9955,1,0.0184,126.0618,2217 +edge,m19,0.25,0.2255,,,0.2588,373.2527,0.0026,1,2880,0.225,0.2465,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2934.6606,4404.3514,0.0186,124.3296,2953.0067,4465.389,1,0.0185,124.8673,2185 +edge,m19,0.1,0.2273,,,0.1262,374.7218,0.0026,1,2880,0.2271,0.1017,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3016.772,4511.4679,0.0192,124.3296,3021.0632,4610.0114,1,0.0189,126.0618,2167 +edge,m19,0.0,0.2289,,,0.0236,376.7969,0.0026,1,2880,0.2288,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3078.9504,4622.8875,0.0196,124.3296,3082.9258,4726.5639,1,0.0193,126.0961,2154 +edge,m19,-0.5,0.2471,0,0,0.8138,375.2772,0.0026,0,2880,0.2303,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3071.3121,4629.8982,0.0202,124.3296,4360.165,7281.4629,0,0.0239,73.3984,2092 +edge,m19,-1.0,0.2317,0,0,0.1087,366.184,0.0025,0,2880,0.2286,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,3009.1657,4505.9449,0.0201,124.3296,3078.9031,4540.2412,0,0.0217,112.5913,2030 +edge,m19,-2.0,0.2035,0,0,0.0832,324.6075,0.0022,0,2880,0.2191,0.0,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,2602.4145,3911.7687,0.0038,124.3296,2659.7646,2945.0017,0,0.0066,50.2558,1906 +glare,m19,0.3,0.172,,,8.3716,249.5503,0.0017,1,2880,0.1739,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1785.801,2670.0779,0.0217,124.3296,1743.8073,2640.8059,1,0.0208,132.5507,3840 +glare,m19,0.5,0.141,,,8.3716,228.1438,0.0018,1,2880,0.1427,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,1189.7074,1772.8643,0.0258,124.3296,1161.1356,1750.3321,1,0.0247,132.5507,3840 +glare,m19,0.7,0.1149,,,8.6726,210.6635,0.002,1,2880,0.1158,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,730.5427,1081.4453,0.0342,124.3296,720.2492,1062.4407,1,0.0335,128.1434,3840 +glare,m19,0.85,0.1001,,,8.7879,200.1099,0.0022,1,2880,0.1006,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,475.5316,697.2097,0.0504,124.3296,470.9871,698.2775,1,0.0498,126.6336,3840 +glare,m19,1.0,0.0926,0,0,9.175,191.7481,0.0045,0,2880,0.092,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,298.033,429.3319,0.2401,124.3296,302.5012,429.0653,0,0.2456,121.3373,3840 +glare,m19,1.5,0.106,0,0,26.1667,176.4287,0.0142,0,2880,0.0602,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,72.2556,94.0519,0.631,124.3296,814.3169,476.4044,0,0.0,33.4884,3840 +glare,m19,2.5,0.0777,0,0,26.1667,166.2809,0.0259,0,2880,0.0178,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,4.2385,4.3278,0.8923,124.3296,408.5483,228.0789,0,0.0008,33.4884,3840 +glare,m19,4.0,0.2097,0,0,1.0682,159.7467,0.0376,0,2880,0.0,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,0.0,0.0,1.0,124.3296,189.8565,992.0928,0,0.0001,491.1628,3840 +motion,m19,0.05,0.1769,,,8.9506,176.6795,0.0012,1,2880,0.1769,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,323.2856,2116.7937,0.0062,124.3296,323.2856,2116.7937,1,0.0062,124.32,3840 +motion,m19,0.1,0.1502,,,8.8935,97.4967,0.001,1,2880,0.1508,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,173.617,786.9317,0.0039,124.3296,170.7434,788.7538,1,0.0038,125.0757,3840 +motion,m19,0.15,0.1344,,,8.8452,68.6966,0.0008,1,2880,0.135,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,111.0999,503.2506,0.0008,124.3296,109.3159,509.9637,1,0.0008,125.7931,3840 +motion,m19,0.2,0.1252,,,9.2297,55.905,0.0008,1,2880,0.1262,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,81.4209,373.3363,0.0,124.3296,81.2989,361.7886,1,0.0,120.8713,3840 +motion,m19,0.3,0.1171,0,0,9.5986,42.375,0.0007,0,2880,0.116,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,51.1574,263.3309,0.0,124.3296,54.5479,254.2292,0,0.0,116.5743,3840 +motion,m19,0.4,0.109,0,0,9.2249,34.1018,0.0006,0,2880,0.1103,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,36.4748,202.0813,0.0,124.3296,36.2889,186.953,0,0.0,120.0967,3840 +motion,m19,0.5,0.1064,0,0,9.5611,28.9463,0.0005,0,2880,0.1068,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,29.1375,164.2709,0.0,124.3296,29.527,148.0775,0,0.0,116.048,3840 +motion,m19,0.7,,0,0,,21.955,0.0005,,2880,0.1001,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,21.0324,112.5203,0.0,124.3296,,,0,,,3840 +motion,m19,1.0,,0,0,,16.0177,0.0004,,2880,0.0925,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,15.0262,70.8497,0.0,124.3296,,,0,,,3840 +scale,m19,48,0.2041,,,8.413,289.5697,0.0013,1,1112,0.2061,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,7214.8307,4118.6837,0.0094,48.005,7081.5521,4387.5355,1,0.0089,50.948,1483 +scale,m19,40,0.1984,,,8.4659,274.3343,0.0012,1,927,0.2,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,7370.6952,4061.3986,0.0096,40.0186,7300.9477,4232.9662,1,0.009,42.1262,1235 +scale,m19,32,0.1906,,,8.3932,558.5395,0.0012,1,741,0.1922,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,7929.0223,7929.0223,0.0076,31.989,7859.6146,3962.9853,1,0.0072,33.8319,988 +scale,m19,26,0.1809,,,8.0636,636.2107,0.0011,1,602,0.1838,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,8477.5519,2447.6564,0.0069,25.9883,8209.3753,2984.9283,1,0.0063,28.5591,803 +scale,m19,22,0.1762,,,8.8852,705.8479,0.001,1,510,0.1762,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,8725.726,1572.6078,0.0063,22.0167,8646.8985,1587.0335,1,0.0065,22.1294,679 +scale,m19,18,0.1626,,,7.4523,795.7728,0.0009,1,417,0.1671,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,9413.5931,882.2604,0.0067,18.0019,8584.7026,1362.8235,1,0.0059,21.3152,556 +scale,m19,15,0.1612,,,10.8418,888.8819,0.0008,1,347,0.1579,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,9794.8818,506.5597,0.005,14.98,10872.3017,315.9363,1,0.0065,12.4331,463 +scale,m19,12,,0,0,,984.3771,0.0007,,278,0.146,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,9324.8627,221.0548,0.0044,12.0013,,,0,,,371 +scale,m19,10,,0,0,,1054.0699,0.0006,,232,0.1353,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,8467.3334,114.5255,0.0025,10.0154,,,0,,,309 +scale,m19,8,,0,0,,1153.8599,0.0005,,185,0.1255,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,7591.1489,36.2949,0.004,7.9864,,,0,,,247 +scale,m19,6,,0,0,,1332.6514,0.0005,,139,0.1113,8.95,cbd887e49a0a9b341f8d053fc5cc1417c94d6264cc3f7584a76dbb75be1e43ae,5718.8566,14.6901,0.0036,6.0006,,,0,,,185 +blur,m21,0.02,0.1555,,,14.1667,152.6903,0.0002,1,2880,0.1518,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,84.1541,507.4418,0.0,61.4016,98.3158,707.5713,1,0.0,66.9767,3840 +blur,m21,0.04,0.1253,,,14.1667,56.3789,0.0001,1,2880,0.123,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,11.2372,102.6587,0.0,61.4016,11.8881,145.6477,1,0.0,66.9767,3840 +blur,m21,0.06,0.1044,,,13.0,20.5388,0.0,1,2880,0.101,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,3.8794,26.3024,0.0,61.4016,3.7444,45.5521,1,0.0,72.5581,3840 +blur,m21,0.08,0.0904,,,13.0,8.6488,0.0,1,2880,0.0855,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,2.2631,8.1046,0.0,61.4016,2.1569,13.7084,1,0.0,72.5581,3840 +blur,m21,0.1,0.0835,1,1,12.0714,4.2394,0.0,0,2880,0.0746,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1.8408,2.9658,0.0,61.4016,1.7108,6.3133,0,0.0,78.1395,3840 +blur,m21,0.13,0.0667,0,0,14.25,1.8555,0.0,0,2880,0.0643,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1.7047,0.8455,0.0,61.4016,1.7273,1.1248,0,0.0,66.9767,3840 +blur,m21,0.16,0.0629,0,0,18.3636,0.9854,0.0,0,2880,0.0583,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1.7113,0.3616,0.0,61.4016,1.5465,0.3333,0,0.0,61.3953,3840 +blur,m21,0.2,,0,0,,0.5298,0.0,,2880,0.0539,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1.6436,0.2004,0.0,61.4016,,,0,,,3840 +blur,m21,0.25,,0,0,,0.3114,0.0,,2880,0.0511,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1.5365,0.1647,0.0,61.4016,,,0,,,3840 +blur,m21,0.3,,0,0,,0.2175,0.0,,2880,0.0496,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1.4939,0.1455,0.0,61.4016,,,0,,,3840 +edge,m21,2.0,0.1886,,,1.6463,275.1652,0.0004,1,2880,0.1836,2.0037,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1780.3545,2135.2141,0.0001,61.4016,2017.7997,2575.0912,1,0.0004,71.1628,2590 +edge,m21,1.0,0.1881,,,0.7646,274.3251,0.0004,1,2880,0.1836,0.994,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1780.3545,2135.2141,0.0001,61.4016,2004.5217,2562.7132,1,0.0004,71.1628,2528 +edge,m21,0.5,0.1886,,,0.4116,275.9897,0.0004,1,2880,0.1836,0.5054,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1780.3545,2135.2141,0.0001,61.4016,2017.7021,2581.7967,1,0.0004,71.1628,2498 +edge,m21,0.25,0.1891,,,0.1176,274.6357,0.0004,1,2880,0.1836,0.2448,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1780.3545,2135.2141,0.0001,61.4016,2034.4792,2600.4365,1,0.0004,71.1628,2482 +edge,m21,0.1,0.1364,,,13.828,274.8499,0.0004,0,2880,0.1843,0.0982,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1800.0971,2164.5618,0.0001,61.4016,2393.4779,642.0926,1,0.0,25.1163,2473 +edge,m21,0.0,0.1375,,,13.8178,273.3361,0.0004,0,2880,0.1847,0.0005,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1810.2947,2158.9995,0.0001,61.4016,2475.3027,649.3158,1,0.0,25.1163,2467 +edge,m21,-0.5,0.1363,0,0,12.4986,268.3069,0.0004,0,2880,0.1845,0.0,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1819.5503,2177.7263,0.0001,61.4016,2410.0793,641.974,0,0.0,25.1163,2436 +edge,m21,-1.0,0.136,0,0,11.3287,265.0143,0.0004,0,2880,0.1846,0.0,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1829.6994,2183.6701,0.0001,61.4016,2394.8035,641.2643,0,0.0,25.1163,2406 +edge,m21,-2.0,0.1363,0,0,8.8326,257.6021,0.0004,0,2880,0.1848,0.0,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1856.2633,2195.8699,0.0001,61.4016,2410.0793,641.974,0,0.0,25.1163,2344 +glare,m21,0.3,0.135,,,17.0,221.9297,0.0005,1,2880,0.1421,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1075.2986,1289.5774,0.0001,61.4016,933.272,984.1661,1,0.0001,55.814,3840 +glare,m21,0.5,0.1116,,,17.0,206.0979,0.0005,1,2880,0.1178,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,712.383,854.5781,0.0002,61.4016,615.3189,647.7506,1,0.0002,55.814,3840 +glare,m21,0.7,0.0932,,,17.0,193.3003,0.0005,1,2880,0.0985,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,435.0421,523.0021,0.0006,61.4016,373.0745,392.0021,1,0.0004,55.814,3840 +glare,m21,0.85,0.0679,,,30.0,185.6536,0.0006,0,2880,0.0893,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,284.0724,341.1393,0.0048,61.4016,501.7528,183.1108,1,0.0,27.907,3840 +glare,m21,1.0,0.0594,0,0,30.0,179.6869,0.0023,0,2880,0.0862,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,181.7387,217.9186,0.2476,61.4016,336.6149,122.56,0,0.0003,27.907,3840 +glare,m21,1.5,0.1379,0,0,18.2727,169.1416,0.0108,0,2880,0.0563,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,48.1488,56.6765,0.6636,61.4016,789.8781,828.3394,0,0.0,61.3953,3840 +glare,m21,2.5,0.1364,0,0,18.2727,162.092,0.0215,0,2880,0.0106,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,1.346,1.4086,0.9387,61.4016,762.9199,800.2831,0,0.0,61.3953,3840 +glare,m21,4.0,0.1351,0,0,18.2727,157.2664,0.0313,0,2880,0.0,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,0.0,0.0,1.0,61.4016,725.6704,761.0719,0,0.0,61.3953,3840 +motion,m21,0.05,0.1719,,,14.1667,214.5083,0.0004,1,2880,0.1678,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,493.5872,1200.0216,0.0,61.4016,517.393,1454.5788,1,0.0,66.9767,3840 +motion,m21,0.1,0.1511,,,14.1667,139.4151,0.0002,1,2880,0.1495,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,225.7703,548.4542,0.0,61.4016,219.7955,612.8237,1,0.0,66.9767,3840 +motion,m21,0.15,0.1359,,,14.1667,90.1542,0.0001,1,2880,0.1352,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,155.5635,366.4368,0.0,61.4016,148.3755,380.8296,1,0.0,66.9767,3840 +motion,m21,0.2,0.1246,,,14.1667,66.3036,0.0,1,2880,0.1241,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,123.8045,279.0882,0.0,61.4016,117.2005,277.8851,1,0.0,66.9767,3840 +motion,m21,0.3,0.1108,,,13.0,46.9974,0.0,1,2880,0.1096,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,93.1826,208.349,0.0,61.4016,81.1465,221.1815,1,0.0,72.5581,3840 +motion,m21,0.4,0.1004,,,14.1667,36.4177,0.0,1,2880,0.0988,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,75.2343,167.6592,0.0,61.4016,69.3429,158.1517,1,0.0,66.9767,3840 +motion,m21,0.5,0.0948,0,0,14.1274,31.4472,0.0,0,2880,0.0928,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,65.6495,145.921,0.0,61.4016,60.36,137.442,0,0.0,67.1628,3840 +motion,m21,0.7,0.0894,0,0,14.1667,25.287,0.0,0,2880,0.0858,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,53.5765,120.2121,0.0,61.4016,54.6711,128.941,0,0.0,66.9767,3840 +motion,m21,1.0,,0,0,,20.4085,0.0,,2880,0.0811,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,44.4029,101.9979,0.0,61.4016,,,0,,,3840 +scale,m21,48,0.1751,,,15.3636,253.5419,0.0005,1,2251,0.1826,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,2589.6988,2133.0046,0.0001,47.9913,2404.6642,1825.8116,1,0.0003,47.9864,3002 +scale,m21,40,0.1803,,,15.4545,254.7873,0.0005,1,1876,0.1803,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,3268.3623,1978.742,0.0002,39.9963,3268.3623,1978.742,1,0.0002,39.9922,2502 +scale,m21,32,0.1713,,,15.3636,256.5195,0.0004,1,1501,0.1781,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,4038.0943,4038.0943,0.0003,32.0013,3544.2273,3544.2273,1,0.0001,31.9981,2001 +scale,m21,26,0.1664,,,15.3636,254.3178,0.0004,1,1220,0.1716,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,3974.4889,1160.8744,0.0,26.0104,3632.7765,1053.4243,1,0.0,26.0078,1626 +scale,m21,22,0.1653,,,15.3636,247.3507,0.0004,1,1032,0.1712,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,4565.1965,841.9815,0.0,22.0022,4012.916,715.6098,1,0.0,22.0,1376 +scale,m21,18,0.1649,,,13.8,227.5198,0.0003,1,844,0.1668,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,5297.2714,530.2105,0.0,17.9941,5242.7135,738.6828,1,0.0,20.0,1126 +scale,m21,15,0.1569,,,14.375,488.7796,0.0003,1,704,0.1618,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,6721.1591,390.5911,0.0,15.0093,6053.029,316.0993,1,0.0,16.0,938 +scale,m21,12,0.1528,,,13.1429,561.3766,0.0003,1,563,0.1522,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,8101.9031,219.5997,0.0,12.0032,8024.7678,342.8441,1,0.0,13.9752,750 +scale,m21,10,0.1475,,,12.8333,615.0299,0.0002,1,469,0.1457,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,9778.3843,135.9193,0.0,9.9991,9111.832,232.7428,1,0.0,11.9745,625 +scale,m21,8,0.1362,1,1,4.6154,672.2699,0.0002,0,375,0.1359,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,10445.8868,51.9245,0.0,7.995,6210.5218,1684.1823,0,0.0,25.9309,500 +scale,m21,6,0.0912,0,0,22.6667,754.7072,0.0001,0,281,0.1139,15.4531,b2dd88399ca289658cbef701614e0a6cce89ab6800cfbff6fc0cd1e3de55d6c1,9665.7547,23.2241,0.0,5.9909,5705.6397,12.3751,0,0.0,5.9787,375 +blur,m22,0.02,0.2093,,,14.8682,66.2757,0.0057,1,2884,0.21,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,14.1377,274.929,0.0065,78.2718,14.0808,289.3271,1,0.0072,79.8554,3840 +blur,m22,0.04,0.1881,,,14.4853,21.74,0.0049,1,2884,0.1898,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,5.1237,98.1416,0.0054,78.2718,5.0448,113.0668,1,0.0061,81.8957,3840 +blur,m22,0.06,0.1693,,,14.1333,7.9973,0.0042,1,2884,0.17,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,2.7823,35.655,0.0051,78.2718,2.7126,43.3575,1,0.0107,83.675,3840 +blur,m22,0.08,0.1515,,,12.5483,3.4238,0.0035,1,2884,0.1535,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,1.9578,12.5941,0.0045,78.2718,1.8205,21.3607,1,0.0156,93.6256,3840 +blur,m22,0.1,0.1376,,,11.9332,1.7136,0.0028,1,2884,0.1401,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,1.7021,4.9197,0.0039,78.2718,1.5444,9.7043,1,0.015,98.4819,3840 +blur,m22,0.13,0.1228,0,0,13.1829,0.7812,0.0022,0,2884,0.1234,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,1.6699,1.7608,0.0031,78.2718,1.6115,2.7475,0,0.0005,89.2824,3840 +blur,m22,0.16,0.1106,0,0,5.5018,0.4482,0.0018,0,2884,0.1088,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,1.6411,0.8391,0.0018,78.2718,1.3037,5.9221,0,0.0,175.2011,3840 +blur,m22,0.2,0.097,0,0,5.5388,0.2685,0.0013,0,2884,0.0921,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,1.577,0.4003,0.0004,78.2718,1.2873,2.1807,0,0.0,175.0516,3840 +blur,m22,0.25,,0,0,,0.1813,0.0007,,2884,0.075,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,1.5796,0.2095,0.0,78.2718,,,0,,,3840 +blur,m22,0.3,,0,0,,0.1457,0.0002,,2884,0.0615,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,1.5308,0.1472,0.0,78.2718,,,0,,,3840 +edge,m22,2.0,0.2236,,,2.0942,105.327,0.0069,1,2884,0.2206,1.997,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,114.5526,584.0294,0.0115,78.2718,118.2168,567.7752,1,0.0109,76.0,2534 +edge,m22,1.0,0.2212,,,0.9962,106.722,0.0068,1,2884,0.2206,1.0005,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,114.5526,584.0294,0.0115,78.2718,116.3243,627.501,1,0.0127,79.5917,2456 +edge,m22,0.5,0.2236,,,0.5512,107.6044,0.0064,1,2884,0.2206,0.5022,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,114.5526,584.0294,0.0115,78.2718,118.2168,567.7752,1,0.0109,76.0,2417 +edge,m22,0.25,0.2236,,,0.2757,108.0488,0.0061,1,2884,0.2206,0.2467,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,114.5526,584.0294,0.0115,78.2718,118.1527,569.0407,1,0.0114,76.0,2397 +edge,m22,0.1,0.2239,,,0.108,108.3652,0.0059,1,2884,0.2216,0.1062,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,116.6718,593.8429,0.0066,78.2718,119.6567,597.8676,1,0.0072,76.7021,2386 +edge,m22,0.0,0.2248,,,0.1102,108.71,0.0057,1,2884,0.2224,0.004,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,118.3342,605.9947,0.0044,78.2718,121.3378,586.906,1,0.0046,76.0,2378 +edge,m22,-0.5,0.2073,,,6.5421,108.9763,0.0051,1,2884,0.2226,0.0,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,118.3007,598.5804,0.0021,78.2718,209.873,449.9091,1,0.0,52.5054,2339 +edge,m22,-1.0,0.2068,,,5.9863,108.8777,0.0043,1,2884,0.221,0.0,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,117.8089,596.3022,0.0005,78.2718,207.424,426.0099,1,0.0,51.0531,2299 +edge,m22,-2.0,0.2069,,,4.4419,106.2538,0.0032,1,2884,0.2222,0.0,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,116.8237,590.7792,0.0005,78.2718,207.4799,423.0336,1,0.0,50.9302,2221 +glare,m22,0.3,0.1737,,,15.1758,110.9873,0.007,1,2884,0.1737,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,73.7686,360.8898,0.0182,78.2718,73.7686,360.8898,1,0.0182,78.2824,3840 +glare,m22,0.5,0.1442,,,15.1758,106.9046,0.0074,1,2884,0.1442,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,51.0896,244.6717,0.0361,78.2718,51.0896,244.6717,1,0.0361,78.2824,3840 +glare,m22,0.7,0.117,,,15.0561,103.4647,0.0085,1,2884,0.117,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,33.5533,154.6738,0.1278,78.2718,33.5533,155.5899,1,0.1278,78.891,3840 +glare,m22,0.85,0.099,,,14.9331,101.3275,0.01,1,2884,0.0994,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,23.4719,104.1712,0.2865,78.2718,23.3437,106.6393,1,0.2875,79.5196,3840 +glare,m22,1.0,0.2009,,,16.314,99.5805,0.0118,1,2884,0.086,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,16.1709,68.1822,0.4616,78.2718,182.8117,364.5846,1,0.0,50.9262,3840 +glare,m22,1.5,0.1994,,,16.314,95.7717,0.017,1,2884,0.0466,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,4.6262,16.0899,0.7399,78.2718,180.1043,358.6329,1,0.0,50.9262,3840 +glare,m22,2.5,0.1971,,,15.865,91.6747,0.0228,1,2884,0.004,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,0.3354,0.1274,0.9951,78.2718,178.4048,389.2468,1,0.0,52.3234,3840 +glare,m22,4.0,0.1925,,,16.314,87.7093,0.0274,1,2884,0.0,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,0.0,0.0,1.0,78.2718,167.3308,333.033,1,0.0,50.9262,3840 +motion,m22,0.05,0.2164,,,14.9284,97.4859,0.0063,1,2884,0.217,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,48.5408,451.0798,0.0093,78.2718,48.1448,462.4899,1,0.0098,79.544,3840 +motion,m22,0.1,0.209,,,15.0358,64.1939,0.0057,1,2884,0.2091,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,29.6805,295.1013,0.0076,78.2718,29.6323,297.1646,1,0.0081,78.9937,3840 +motion,m22,0.15,0.1995,,,15.1758,45.8347,0.0052,1,2884,0.1995,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,22.581,217.7265,0.0064,78.2718,22.581,217.7265,1,0.0064,78.2824,3840 +motion,m22,0.2,0.1883,,,14.6542,36.6952,0.0048,1,2884,0.19,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,18.1269,164.5729,0.0053,78.2718,17.5852,175.1979,1,0.0092,80.9829,3840 +motion,m22,0.3,0.1783,,,16.274,28.9079,0.0042,1,2884,0.1764,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,13.873,117.0385,0.0054,78.2718,14.2439,97.7274,1,0.008,73.0,3840 +motion,m22,0.4,0.1651,,,16.274,24.6603,0.0036,1,2884,0.1654,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,11.3719,93.7383,0.0049,78.2718,11.5334,77.0259,1,0.007,73.0,3840 +motion,m22,0.5,0.148,0,0,5.2336,22.2008,0.0032,0,2884,0.1565,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,9.7594,78.5051,0.0044,78.2718,7.217,258.302,0,0.0,182.5757,3840 +motion,m22,0.7,,0,0,,19.1239,0.0025,,2884,0.1442,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,7.8214,61.0957,0.0031,78.2718,,,0,,,3840 +motion,m22,1.0,,0,0,,16.7043,0.0017,,2884,0.1358,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,6.4032,50.2248,0.0018,78.2718,,,0,,,3840 +scale,m22,48,0.219,,,14.7802,119.8625,0.0066,1,1769,0.2204,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,344.9799,569.3496,0.0105,48.0107,340.6099,618.5705,1,0.012,49.2643,2355 +scale,m22,40,0.2194,,,14.9548,120.3196,0.0065,1,1474,0.2209,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,539.0177,589.1739,0.0101,40.0044,529.5308,602.0544,1,0.0099,40.5851,1962 +scale,m22,32,0.22,,,14.8625,120.4499,0.0063,1,1179,0.2201,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,907.0295,907.0295,0.0085,31.9981,914.6527,575.0178,1,0.0089,32.6577,1570 +scale,m22,26,0.2195,,,14.8043,116.5286,0.0061,1,958,0.2203,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,1482.7266,468.5623,0.0085,26.0001,1481.4357,517.4365,1,0.0128,26.5939,1276 +scale,m22,22,0.2136,,,13.9167,113.0758,0.006,1,811,0.2192,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,2050.1167,372.6303,0.0074,22.0105,1991.6607,482.4275,1,0.0087,23.9704,1079 +scale,m22,18,0.2225,,,15.1111,260.558,0.0059,1,663,0.2187,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,3137.8511,308.8411,0.0057,17.9938,3716.6416,361.6474,1,0.0107,17.9729,883 +scale,m22,15,0.2177,,,14.125,327.0455,0.0059,1,553,0.2173,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,4868.3346,257.3646,0.0052,15.0084,5504.9589,273.5125,1,0.0111,15.9711,736 +scale,m22,12,0.2171,,,15.1667,427.3703,0.0058,1,442,0.2171,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,8501.3382,226.7247,0.0135,11.9959,8501.3382,226.7247,1,0.0135,12.0,589 +scale,m22,10,0.214,,,15.2,515.8119,0.0058,1,368,0.2133,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,12189.2463,179.7693,0.0172,9.9875,12394.553,180.8319,1,0.0142,10.0,491 +scale,m22,8,0.1974,,,12.0,601.6633,0.0055,1,295,0.2027,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,16009.444,76.7291,0.0306,8.0063,13670.3873,179.7811,1,0.0269,9.9662,392 +scale,m22,6,,0,0,,673.0373,0.0059,,221,0.1851,15.178,d707fbf31f1ba58c743969b869a44cd937b7344a96c1b6c17bf7734d84428a91,15060.9791,33.0168,0.039,5.9979,,,0,,,294 +blur,m23,0.02,0.1158,,,3.7434,114.0553,0.0064,0,2884,0.11,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,4.9042,57.6925,0.0,75.6762,5.9511,266.784,1,0.0,135.736,3840 +blur,m23,0.04,0.1111,,,3.5727,38.9676,0.0047,0,2884,0.1009,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,2.6933,21.6199,0.0,75.6762,2.6629,95.6863,1,0.0,141.1451,3840 +blur,m23,0.06,0.0923,,,14.3077,14.7403,0.004,1,2884,0.0914,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1.9031,8.2488,0.0,75.6762,1.9245,7.3906,1,0.0,72.5184,3840 +blur,m23,0.08,,0,0,,6.6286,0.0034,,2884,0.0826,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1.6288,3.5087,0.0,75.6762,,,0,,,3840 +blur,m23,0.1,,0,0,,3.4678,0.003,,2884,0.0751,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1.5336,1.7031,0.0,75.6762,,,0,,,3840 +blur,m23,0.13,,0,0,,1.5585,0.0024,,2884,0.0655,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1.4553,0.699,0.0,75.6762,,,0,,,3840 +blur,m23,0.16,,0,0,,0.835,0.002,,2884,0.0577,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1.4622,0.3612,0.0,75.6762,,,0,,,3840 +blur,m23,0.2,,0,0,,0.447,0.0015,,2884,0.0496,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1.4109,0.2179,0.0,75.6762,,,0,,,3840 +blur,m23,0.25,,0,0,,0.2608,0.0009,,2884,0.0421,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1.4869,0.1502,0.0,75.6762,,,0,,,3840 +blur,m23,0.3,,0,0,,0.1856,0.0001,,2884,0.0381,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1.3251,0.1389,0.0,75.6762,,,0,,,3840 +edge,m23,2.0,0.1149,,,0.3121,175.9076,0.0145,0,2884,0.1142,1.999,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,28.7739,105.5562,0.0,75.6762,56.2644,429.0831,1,0.0,127.4326,2174 +edge,m23,1.0,0.0928,,,-0.0254,179.743,0.015,0,2884,0.1142,0.9948,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,28.7739,105.5562,0.0,75.6762,56.6935,422.2938,1,0.0,116.5337,2098 +edge,m23,0.5,0.1146,,,0.544,181.276,0.0153,1,2884,0.1142,0.5058,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,28.7739,105.5562,0.0,75.6762,29.4354,100.3046,1,0.0,72.8565,2061 +edge,m23,0.25,0.1212,,,0.2679,182.0743,0.0154,1,2884,0.1142,0.2548,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,28.7739,105.5562,0.0,75.6762,30.1142,68.0476,1,0.0,59.492,2042 +edge,m23,0.1,0.1216,,,0.1908,182.315,0.0155,1,2884,0.1146,0.0962,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,28.8929,107.9347,0.0,75.6762,30.1468,69.868,1,0.0,59.6629,2030 +edge,m23,0.0,0.1231,,,0.1196,182.7954,0.0156,1,2884,0.1148,0.0037,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,28.9942,109.3954,0.0,75.6762,30.2931,67.7692,1,0.0,58.2555,2023 +edge,m23,-0.5,0.1219,0,1,0.0514,183.2665,0.0159,0,2884,0.1135,0.0,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,29.0654,107.9342,0.0,75.6762,30.3199,66.558,0,0.0,58.2209,1985 +edge,m23,-1.0,0.1229,1,1,0.0469,184.3889,0.0162,0,2884,0.1142,0.0,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,29.4074,109.8234,0.0,75.6762,30.991,66.9484,0,0.0,58.2209,1947 +edge,m23,-2.0,0.1176,0,0,0.0664,185.4255,0.0168,0,2884,0.1123,0.0,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,28.7115,104.2709,0.0,75.6762,29.6583,74.7478,0,0.0,63.0349,1871 +glare,m23,0.3,0.1199,,,3.6276,193.6834,0.0089,0,2884,0.0878,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,20.1557,64.1498,0.0,75.6762,54.463,439.7465,1,0.0,139.4713,3840 +glare,m23,0.5,0.1199,,,3.6276,193.1514,0.0089,0,2884,0.0722,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,14.6431,42.9189,0.0,75.6762,54.463,439.7465,1,0.0,139.4713,3840 +glare,m23,0.7,0.1199,,,3.6276,192.6935,0.0089,0,2884,0.0597,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,10.3147,26.6485,0.0,75.6762,54.463,439.7465,1,0.0,139.4713,3840 +glare,m23,0.85,0.1199,,,3.6276,192.4091,0.0089,0,2884,0.0536,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,7.7065,17.685,0.0049,75.6762,54.463,439.7465,1,0.0,139.4713,3840 +glare,m23,1.0,0.1199,0,0,3.6276,192.1807,0.0099,0,2884,0.0516,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,5.7783,11.68,0.2612,75.6762,54.463,439.7465,0,0.0,139.4713,3840 +glare,m23,1.5,0.1199,0,0,3.6276,191.6567,0.0125,0,2884,0.035,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,2.2396,3.3178,0.6247,75.6762,54.463,439.7465,0,0.0,139.4713,3840 +glare,m23,2.5,0.1199,0,0,3.6276,191.076,0.0165,0,2884,0.0111,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,0.3919,0.1906,0.9245,75.6762,54.463,439.7465,0,0.0,139.4713,3840 +glare,m23,4.0,0.1199,0,0,3.6276,190.6318,0.0201,0,2884,0.0,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,0.0,0.0,1.0,75.6762,54.463,439.7465,0,0.0,139.4713,3840 +motion,m23,0.05,0.118,,,3.6276,162.0648,0.0078,0,2884,0.1124,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,12.287,80.5155,0.0,75.6762,20.2751,373.2384,1,0.0,139.4713,3840 +motion,m23,0.1,0.1152,,,3.6276,105.9171,0.0063,0,2884,0.1083,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,7.6261,50.0946,0.0,75.6762,11.3312,242.3594,1,0.0,139.4713,3840 +motion,m23,0.15,0.1139,,,3.5727,78.2445,0.0055,0,2884,0.1046,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,6.0124,35.8589,0.0,75.6762,8.7682,171.6464,1,0.0,141.1451,3840 +motion,m23,0.2,0.1114,,,3.6132,57.3533,0.0049,0,2884,0.0995,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,4.8575,24.91,0.0,75.6762,7.047,119.0433,1,0.0,140.4796,3840 +motion,m23,0.3,0.1078,0,0,3.7609,38.0542,0.004,0,2884,0.0909,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,3.7038,16.1543,0.0,75.6762,5.3249,69.1563,0,0.0,135.5657,3840 +motion,m23,0.4,,0,0,,29.894,0.0034,,2884,0.0853,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,3.2182,12.9414,0.0,75.6762,,,0,,,3840 +motion,m23,0.5,,0,0,,24.3529,0.0029,,2884,0.0811,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,2.8722,10.8195,0.0,75.6762,,,0,,,3840 +motion,m23,0.7,,0,0,,18.4785,0.0021,,2884,0.0769,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,2.5253,8.7719,0.0,75.6762,,,0,,,3840 +motion,m23,1.0,,0,0,,13.8459,0.0012,,2884,0.0716,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,2.1261,6.8525,0.0,75.6762,,,0,,,3840 +scale,m23,48,0.1195,,,3.6278,196.7863,0.0088,0,1829,0.1143,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,70.9887,106.7673,0.0,47.993,121.0907,441.675,1,0.0,88.4473,2436 +scale,m23,40,0.1194,,,3.6273,198.2859,0.0087,0,1524,0.1139,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,102.5197,107.3833,0.0,39.9898,171.811,444.2619,1,0.0,73.7062,2030 +scale,m23,32,0.1193,,,3.6278,198.5571,0.0086,0,1220,0.1144,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,162.7223,162.7223,0.0,32.0128,269.8532,446.5468,1,0.0,58.9971,1624 +scale,m23,26,0.1196,,,3.6276,194.9471,0.0083,0,991,0.1139,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,270.9651,88.9979,0.0,26.0038,413.9028,444.3348,1,0.0,47.7518,1319 +scale,m23,22,0.1188,,,3.4904,189.3792,0.008,0,838,0.1139,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,419.6658,77.8143,0.0,21.9891,565.5803,452.6857,1,0.0,41.6,1116 +scale,m23,18,0.1212,,,3.1784,427.0542,0.0075,0,686,0.1135,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,727.4,70.7848,0.0,18.0006,719.9694,432.1205,1,0.0,37.0,913 +scale,m23,15,0.1233,,,3.2566,545.1918,0.0071,0,572,0.1136,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1179.8265,61.163,0.0,15.0093,917.6248,378.8397,1,0.0,30.4,761 +scale,m23,12,,0,0,,707.1744,0.0066,,457,0.1125,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,1923.5803,49.3138,0.0,11.9917,,,0,,,609 +scale,m23,10,,0,0,,845.4521,0.0061,,381,0.1106,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,2676.1358,38.3832,0.0,9.9974,,,0,,,507 +scale,m23,8,,0,0,,1029.7825,0.0056,,305,0.108,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,3735.5238,18.303,0.0,8.0032,,,0,,,406 +scale,m23,6,,0,0,,1300.4276,0.0052,,229,0.1016,13.7633,33b0ec22b59e52d2fbb8c594f43d8bc712b5d6826ab8c5e2c4064c8d102240a9,4588.5243,11.5845,0.0,6.009,,,0,,,304 +blur,m25,0.02,0.1214,,,2.7788,12.5576,0.0,1,1932,0.1217,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,2.6592,347.819,0.0,166.152,2.6575,356.409,1,0.0,167.6179,2580 +blur,m25,0.04,0.1013,,,3.0402,1.2492,0.0,1,1932,0.0995,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,1.5938,63.7122,0.0,166.152,1.63,53.5401,1,0.0,156.3036,2580 +blur,m25,0.06,0.0786,,,2.2346,0.4091,0.0,1,1932,0.0824,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,1.4666,16.8637,0.0,166.152,1.4643,27.6836,1,0.0,196.8601,2580 +blur,m25,0.08,0.0675,,,2.2994,0.2378,0.0,1,1932,0.0696,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,1.4252,5.4885,0.0,166.152,1.4136,8.3509,1,0.0,190.4837,2580 +blur,m25,0.1,,0,0,,0.1777,0.0,,1932,0.0601,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,1.4298,1.9917,0.0,166.152,,,0,,,2580 +blur,m25,0.13,,0,0,,0.1446,0.0,,1932,0.0503,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,1.394,0.6025,0.0,166.152,,,0,,,2580 +blur,m25,0.16,,0,0,,0.1337,0.0,,1932,0.0436,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,1.1673,0.2632,0.0,166.152,,,0,,,2580 +blur,m25,0.2,,0,0,,0.128,0.0,,1932,0.0377,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,0.8685,0.1436,0.0,166.152,,,0,,,2580 +blur,m25,0.25,,0,0,,0.1284,0.0,,1932,0.0335,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,0.6346,0.1233,0.0,166.152,,,0,,,2580 +blur,m25,0.3,,0,0,,0.1331,0.0,,1932,0.0315,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,0.5298,0.1292,0.0,166.152,,,0,,,2580 +edge,m25,2.0,0.1479,,,1.905,281.7551,0.0001,1,1932,0.1497,1.9976,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,632.3701,1327.6412,0.0,166.152,614.8199,1348.5179,1,0.0,172.2749,1699 +edge,m25,1.0,0.1643,,,1.4476,284.492,0.0002,1,1932,0.1497,0.9985,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,632.3701,1327.6412,0.0,166.152,800.1828,1221.6524,1,0.0,125.3875,1533 +edge,m25,0.5,0.1653,,,0.7936,284.7766,0.0002,1,1932,0.1497,0.499,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,632.3701,1327.6412,0.0,166.152,814.2209,1229.339,1,0.0,123.2457,1450 +edge,m25,0.25,0.1655,,,0.4523,284.9177,0.0002,1,1932,0.1497,0.2522,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,632.3701,1327.6412,0.0,166.152,815.7606,1238.8456,1,0.0,122.6735,1409 +edge,m25,0.1,0.1646,,,0.2367,284.44,0.0002,1,1932,0.1521,0.1018,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,657.819,1356.6135,0.0,166.152,802.0427,1259.6788,1,0.0,125.8957,1384 +edge,m25,0.0,0.166,,,0.1018,283.5888,0.0002,1,1932,0.1534,0.0,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,675.0333,1397.6379,0.0,166.152,826.7969,1298.2849,1,0.0,126.4445,1367 +edge,m25,-0.5,0.1063,0,0,0.2296,269.0416,0.0002,0,1932,0.1515,0.0,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,615.7199,1344.8025,0.0,166.152,379.5826,416.1409,0,0.0,60.3236,1284 +edge,m25,-1.0,0.135,0,0,1.2622,253.2131,0.0002,0,1932,0.1509,0.0,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,607.4746,1347.1628,0.0,166.152,699.8728,685.1085,0,0.0,53.3546,1201 +edge,m25,-2.0,0.1519,0,0,0.0255,212.7858,0.0002,0,1932,0.141,0.0,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,465.4552,1068.7381,0.0,166.152,879.9268,1815.4308,0,0.0,110.0,1035 +glare,m25,0.3,0.1182,,,2.8234,270.1712,0.0001,1,1932,0.1181,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,404.5667,848.7513,0.0,166.152,405.0412,833.5781,1,0.0,165.3686,2580 +glare,m25,0.5,0.1087,,,2.823,260.9394,0.0001,1,1932,0.1086,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,283.2583,595.0605,0.0,166.152,283.4236,582.6175,1,0.0,165.3888,2580 +glare,m25,0.7,0.1117,,,2.8224,252.895,0.0001,1,1932,0.1117,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,187.1241,394.3335,0.0,166.152,187.0772,384.2786,1,0.0,165.4184,2580 +glare,m25,0.85,0.122,,,2.9969,247.6869,0.0001,1,1932,0.1219,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,131.9168,278.5443,0.0,166.152,133.5985,256.0701,1,0.0,157.1651,2580 +glare,m25,1.0,0.1367,,,3.1629,243.1728,0.0012,1,1932,0.1372,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,90.7711,192.2035,0.0425,166.152,91.0985,169.0588,1,0.048,150.4932,2580 +glare,m25,1.5,0.1202,0,0,10.75,232.2065,0.0137,0,1932,0.1305,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,27.4933,60.6803,0.4775,166.152,861.234,393.198,0,0.0,30.0117,2580 +glare,m25,2.5,0.1146,0,0,10.75,219.631,0.0295,0,1932,0.069,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,2.9453,6.3462,0.7693,166.152,602.0528,275.4341,0,0.0,30.0117,2580 +glare,m25,4.0,0.1566,0,0,6.966,209.1606,0.044,0,1932,0.0154,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,0.2451,0.4658,0.9595,166.152,722.869,1601.5193,0,0.0,120.0466,2580 +motion,m25,0.05,0.1343,,,2.7664,91.7234,0.0,1,1932,0.1354,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,59.7307,820.0682,0.0,166.152,58.6946,824.6797,1,0.0,168.2571,2580 +motion,m25,0.1,0.1113,,,6.028,44.2223,0.0,0,1932,0.1212,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,28.17,380.1748,0.0,166.152,54.8304,494.2243,1,0.0,123.7981,2580 +motion,m25,0.15,0.0828,,,4.8182,32.4389,0.0,0,1932,0.1107,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,19.5071,252.67,0.0,166.152,26.2394,105.6565,1,0.0,82.532,2580 +motion,m25,0.2,0.078,,,4.6087,26.5707,0.0,0,1932,0.1016,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,15.1805,189.3525,0.0,166.152,21.2618,85.6019,1,0.0,86.2836,2580 +motion,m25,0.3,0.0911,,,3.3421,20.4691,0.0,1,1932,0.0866,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,10.4562,122.0202,0.0,166.152,11.6933,98.65,1,0.0,142.5553,2580 +motion,m25,0.4,0.0843,0,0,3.6129,17.5265,0.0,0,1932,0.0783,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,8.2831,93.6228,0.0,166.152,10.5925,78.4625,0,0.0,133.0784,2580 +motion,m25,0.5,,0,0,,15.6145,0.0,,1932,0.0727,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,6.8018,76.1227,0.0,166.152,,,0,,,2580 +motion,m25,0.7,,0,0,,13.389,0.0,,1932,0.0654,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,5.1829,57.2632,0.0,166.152,,,0,,,2580 +motion,m25,1.0,,0,0,,11.4284,0.0,,1932,0.0599,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,4.1173,43.2448,0.0,166.152,,,0,,,2580 +scale,m25,48,0.1316,,,3.959,760.602,0.0001,0,558,0.1436,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,1888.4485,1267.2229,0.0,47.988,3487.9422,1143.0779,1,0.0,28.2575,745 +scale,m25,40,0.1441,,,3.0,918.3302,0.0001,1,465,0.1429,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,2154.8503,1267.4239,0.0,39.99,2147.7379,1205.6863,1,0.0,37.9185,621 +scale,m25,32,0.1219,,,4.2222,1110.6962,0.0001,0,372,0.1405,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,2501.8597,2501.8597,0.0,31.992,3839.1067,346.2565,1,0.0,18.0,497 +scale,m25,26,0.119,,,4.4286,1246.0601,0.0001,0,302,0.1387,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,3116.1913,884.2944,0.0,25.972,4243.8775,179.1049,1,0.0,14.0,404 +scale,m25,22,0.1183,,,3.5787,1320.9612,0.0,0,256,0.1365,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,3580.6491,595.4139,0.0,22.016,4445.5606,198.738,1,0.0,14.4655,342 +scale,m25,18,0.108,,,2.6594,1379.9161,0.0,0,209,0.1327,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,4278.4329,385.3277,0.0,17.974,3985.6971,183.9701,1,0.0,14.8052,280 +scale,m25,15,0.1265,,,2.625,1392.6882,0.0,1,174,0.1285,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,5156.6711,248.7543,0.0,14.964,4892.1955,220.3971,1,0.0,16.0,233 +scale,m25,12,,0,0,,1389.6791,0.0,,140,0.1232,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,6121.5368,135.3628,0.0,12.04,,,0,,,186 +scale,m25,10,0.103,,,1.375,1450.166,0.0,1,116,0.1206,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,7568.9498,91.6027,0.0,9.976,4023.1327,172.2055,1,0.0,16.0,155 +scale,m25,8,,0,0,,1577.2156,0.0,,93,0.1105,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,7938.3007,36.5271,0.0,7.998,,,0,,,124 +scale,m25,6,0.0945,0,0,1.6489,1907.8703,0.0,0,70,0.0948,2.8078,94b4e2ccc3ee6ea3ce2bb5d5f35425a448cbcec74688d984fb9efaba5e52f3b7,6557.4503,14.7521,0.0,6.02,2215.9867,47.1319,0,0.0,12.0,93 +blur,m26,0.02,0.0997,,,6.9656,32.8158,0.0054,1,2398,0.0997,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,5.3552,202.5445,0.0,100.2604,5.3552,202.5445,1,0.0,100.2667,2205 +blur,m26,0.04,0.0791,,,6.6898,5.4049,0.0052,1,2398,0.0802,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,2.0197,50.1471,0.0,100.2604,1.9774,46.987,1,0.0,99.3294,2205 +blur,m26,0.06,0.0631,,,6.7203,1.461,0.005,1,2398,0.0654,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,1.4297,11.4362,0.0,100.2604,1.3714,11.2011,1,0.0,102.0119,2205 +blur,m26,0.08,0.0519,0,0,7.2199,0.6102,0.0049,0,2398,0.0555,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,1.3568,3.0263,0.0,100.2604,1.2938,1.9378,0,0.0,89.6993,2205 +blur,m26,0.1,,0,0,,0.3481,0.0047,,2398,0.0482,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,1.3603,1.14,0.0,100.2604,,,0,,,2205 +blur,m26,0.13,,0,0,,0.2248,0.0044,,2398,0.04,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,1.3316,0.4273,0.0,100.2604,,,0,,,2205 +blur,m26,0.16,,0,0,,0.182,0.0041,,2398,0.0342,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,1.3512,0.2104,0.0,100.2604,,,0,,,2205 +blur,m26,0.2,,0,0,,0.1625,0.0037,,2398,0.029,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,0.9897,0.1275,0.0,100.2604,,,0,,,2205 +blur,m26,0.25,,0,0,,0.1559,0.0031,,2398,0.0252,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,0.6256,0.1471,0.0,100.2604,,,0,,,2205 +blur,m26,0.3,,0,0,,0.154,0.0025,,2398,0.0229,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,0.4837,0.1885,0.0,100.2604,,,0,,,2205 +edge,m26,2.0,0.1127,,,1.991,182.2049,0.0077,1,2398,0.1127,1.9971,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,50.0639,444.1321,0.0,100.2604,50.1204,446.4792,1,0.0,100.2667,1627 +edge,m26,1.0,0.1127,,,0.9954,185.9784,0.0082,1,2398,0.1127,0.9997,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,50.0639,444.1321,0.0,100.2604,50.1204,446.4792,1,0.0,100.2667,1527 +edge,m26,0.5,0.1127,,,0.4977,186.2065,0.0085,1,2398,0.1127,0.501,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,50.0639,444.1321,0.0,100.2604,50.1204,446.4792,1,0.0,100.2667,1477 +edge,m26,0.25,0.1129,,,0.2976,187.0688,0.0086,1,2398,0.1127,0.2517,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,50.0639,444.1321,0.0,100.2604,50.2037,445.2013,1,0.0,100.2667,1452 +edge,m26,0.1,0.1137,,,0.0995,186.55,0.0087,1,2398,0.1137,0.1021,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,50.7861,451.9436,0.0,100.2604,50.7861,451.9436,1,0.0,100.2667,1437 +edge,m26,0.0,0.1144,,,0.0995,186.5064,0.0088,1,2398,0.1144,0.0023,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,51.2737,457.6403,0.0,100.2604,51.2737,457.6403,1,0.0,100.2667,1427 +edge,m26,-0.5,0.1144,0,0,0.199,185.2363,0.0091,0,2398,0.1144,0.0,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,52.4575,462.38,0.0,100.2604,52.4575,462.38,0,0.0,100.2667,1377 +edge,m26,-1.0,0.1144,0,0,0.3843,183.173,0.0094,0,2398,0.1152,0.0,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,53.4594,466.6547,0.0,100.2604,50.7917,363.446,0,0.0,90.6221,1327 +edge,m26,-2.0,0.1198,0,0,0.0434,178.3225,0.0102,0,2398,0.116,0.0,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,54.5949,483.1612,0.0,100.2604,53.5211,286.5854,0,0.0,80.2849,1226 +glare,m26,0.3,0.0939,,,6.9656,147.2153,0.0057,1,2398,0.0939,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,32.4778,273.8094,0.0,100.2604,32.4778,273.8094,1,0.0,100.2667,2205 +glare,m26,0.5,0.0891,,,6.9656,141.1097,0.0057,1,2398,0.0891,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,22.7506,186.0902,0.0,100.2604,22.7506,186.0902,1,0.0,100.2667,2205 +glare,m26,0.7,0.0916,,,6.9656,135.8191,0.0057,1,2398,0.0916,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,15.0829,118.0346,0.0,100.2604,15.0829,118.0346,1,0.0,100.2667,2205 +glare,m26,0.85,0.0981,,,6.9656,132.3759,0.0057,1,2398,0.0981,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,10.7692,80.3199,0.0,100.2604,10.7692,80.3199,1,0.0,100.2667,2205 +glare,m26,1.0,0.0466,0,0,11.3966,129.4218,0.0096,0,2398,0.1077,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,7.497,53.8407,0.1668,100.2604,7.8907,12.2534,0,0.0,52.2965,2205 +glare,m26,1.5,0.1093,0,0,5.5457,122.188,0.0415,0,2398,0.0823,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,2.5263,15.1821,0.6188,100.2604,30.1273,126.591,0,0.0,77.2484,2205 +glare,m26,2.5,0.1315,0,0,7.0301,112.9179,0.0806,0,2398,0.0209,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,0.2521,0.4796,0.9036,100.2604,23.783,58.1492,0,0.0399,60.8854,2205 +glare,m26,4.0,0.1088,0,0,8.049,104.1694,0.1163,0,2398,0.0,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,0.0,0.0,1.0,100.2604,21.9431,39.6341,0,0.0068,53.2176,2205 +motion,m26,0.05,0.1084,,,6.9656,97.6281,0.0056,1,2398,0.1084,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,18.9677,349.9033,0.0,100.2604,18.9677,349.9033,1,0.0,100.2667,2205 +motion,m26,0.1,0.0996,,,6.9656,56.662,0.0055,1,2398,0.0996,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,12.1893,227.3435,0.0,100.2604,12.1893,227.3435,1,0.0,100.2667,2205 +motion,m26,0.15,0.0905,,,6.9656,41.1642,0.0054,1,2398,0.0905,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,9.3173,157.8882,0.0,100.2604,9.3173,157.8882,1,0.0,100.2667,2205 +motion,m26,0.2,0.0835,,,7.7376,33.3416,0.0053,1,2398,0.0829,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,7.8205,123.7716,0.0,100.2604,5.7918,65.8191,1,0.0,87.3372,2205 +motion,m26,0.3,0.0724,,,6.8404,25.5303,0.0052,1,2398,0.0728,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,6.3125,95.0662,0.0,100.2604,6.2855,104.7797,1,0.0,101.9089,2205 +motion,m26,0.4,0.0665,0,0,8.4897,21.4127,0.0051,0,2398,0.0659,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,5.5241,81.0697,0.0,100.2604,3.9434,33.0794,0,0.0,83.2235,2205 +motion,m26,0.5,0.0574,0,0,7.0829,18.7533,0.0051,0,2398,0.0611,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,5.0182,72.3832,0.0,100.2604,3.4187,12.3087,0,0.0,60.0,2205 +motion,m26,0.7,,0,0,,15.35,0.0048,,2398,0.057,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,4.3821,64.2595,0.0,100.2604,,,0,,,2205 +motion,m26,1.0,,0,0,,12.4148,0.0043,,2398,0.0558,6.966,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,3.9805,59.6554,0.0,100.2604,,,0,,,2205 +scale,m26,48,0.1177,,,8.55,153.6615,0.0057,1,1148,0.1126,6.9686,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,259.3637,451.7717,0.0,47.9979,253.9188,301.3951,1,0.0,40.0,1056 +scale,m26,40,0.1159,,,8.4206,311.3152,0.0057,1,957,0.1127,6.9662,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,408.9961,452.2882,0.0,40.0122,397.2882,310.166,1,0.0,33.9645,880 +scale,m26,32,0.1154,,,7.8674,448.298,0.0056,1,765,0.1127,6.9716,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,674.1199,674.1199,0.0,31.9846,679.4831,285.0266,1,0.0,27.9634,704 +scale,m26,26,0.1176,,,8.3636,611.1046,0.0056,1,622,0.1124,6.9667,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,1189.932,370.2989,0.0,26.0058,1213.8367,228.351,1,0.0,22.0,572 +scale,m26,22,0.1142,,,7.7,762.1983,0.0056,1,526,0.1108,6.9708,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,1693.8109,313.1954,0.0,21.9921,1790.5059,242.9164,1,0.0,20.0,484 +scale,m26,18,0.114,,,8.0186,959.4429,0.0055,1,431,0.1105,6.9605,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,2723.3488,258.1045,0.0,18.0201,2855.5459,165.6788,1,0.0,15.963,396 +scale,m26,15,0.1105,,,7.5925,1133.3822,0.0054,1,359,0.1083,6.9637,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,3870.4135,196.2615,0.0,15.0098,3864.0717,156.8652,1,0.0,13.9611,330 +scale,m26,12,0.0977,,,4.1573,1314.3646,0.0054,1,287,0.1024,6.9686,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,5024.0572,118.2232,0.0,11.9995,4014.2192,154.8985,1,0.0,13.9514,264 +scale,m26,10,0.0924,,,5.8577,1415.9669,0.0053,1,239,0.0963,6.9734,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,5671.1468,71.6507,0.0,9.9926,4985.9075,114.2819,1,0.0,11.95,220 +scale,m26,8,0.0768,0,1,3.0157,1497.6527,0.0053,0,191,0.0862,6.9807,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,4986.4412,22.1901,0.0,7.9857,2485.4173,93.9837,0,0.0,13.9271,176 +scale,m26,6,,0,0,,1550.4311,0.0052,,144,0.0736,6.9444,fe2876b292dc5e08ca1fb5e4f852edfb45eda72a80820aab572bb4308a393ca3,3375.3491,8.2646,0.0,6.0206,,,0,,,132 +blur,m27,0.02,0.19,,,6.3939,205.0189,0.0,1,2160,0.1893,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,42.0961,613.7818,0.0,76.3344,42.2141,619.2693,1,0.0,76.5089,2880 +blur,m27,0.04,0.1549,,,6.4201,38.1817,0.0,1,2160,0.1549,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,6.9092,139.8259,0.0,76.3344,6.9092,138.6603,1,0.0,76.2186,2880 +blur,m27,0.06,0.1566,,,5.8,10.2416,0.0,1,2160,0.1281,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,2.7401,37.6293,0.0,76.3344,2.7935,54.3498,1,0.0,83.7209,2880 +blur,m27,0.08,0.1461,,,5.6546,3.6632,0.0,1,2160,0.1079,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,1.9361,13.1125,0.0,76.3344,1.953,21.5732,1,0.0,85.6981,2880 +blur,m27,0.1,0.1468,,,5.317,1.6819,0.0,1,2160,0.0923,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,1.7336,5.5108,0.0,76.3344,1.7152,11.7365,1,0.0,91.0316,2880 +blur,m27,0.13,0.1124,0,0,5.85,0.73,0.0,0,2160,0.0756,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,1.6359,1.9344,0.0,76.3344,1.6082,2.8246,0,0.0,83.7209,2880 +blur,m27,0.16,,0,0,,0.4151,0.0,,2160,0.0655,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,1.5887,0.8243,0.0,76.3344,,,0,,,2880 +blur,m27,0.2,,0,0,,0.2643,0.0,,2160,0.0598,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,1.5332,0.3236,0.0,76.3344,,,0,,,2880 +blur,m27,0.25,,0,0,,0.1966,0.0,,2160,0.0592,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,1.125,0.1901,0.0,76.3344,,,0,,,2880 +blur,m27,0.3,,0,0,,0.1685,0.0,,2160,0.0604,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,1.0094,0.1615,0.0,76.3344,,,0,,,2880 +edge,m27,2.0,0.2101,,,2.0766,532.335,0.0001,1,2160,0.22,1.9994,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,849.2782,2276.5349,0.0,76.3344,831.1604,2047.0546,1,0.0,74.0049,1802 +edge,m27,1.0,0.2048,,,1.0609,507.9536,0.0001,1,2160,0.22,1.0038,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,849.2782,2276.5349,0.0,76.3344,798.8519,2050.423,1,0.0,73.8276,1726 +edge,m27,0.5,0.1963,,,0.5649,493.2673,0.0001,1,2160,0.22,0.506,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,849.2782,2276.5349,0.0,76.3344,756.774,1863.5863,1,0.0,72.2093,1688 +edge,m27,0.25,0.2047,,,0.2969,485.5939,0.0001,1,2160,0.22,0.244,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,849.2782,2276.5349,0.0,76.3344,794.1872,2010.0256,1,0.0,73.5387,1668 +edge,m27,0.1,0.1977,,,0.0454,482.3919,0.0001,1,2160,0.2188,0.0999,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,840.2799,2269.8502,0.0,76.3344,729.2144,1819.5301,1,0.0,69.0698,1657 +edge,m27,0.0,0.1987,,,0.044,479.1618,0.0001,1,2160,0.2197,0.0,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,851.8211,2325.9002,0.0,76.3344,747.9054,1832.9335,1,0.0,69.8221,1649 +edge,m27,-0.5,0.2072,0,0,0.0356,461.758,0.0001,0,2160,0.2177,0.0,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,826.3342,2240.4818,0.0,76.3344,799.3735,2077.4118,0,0.0,74.7084,1611 +edge,m27,-1.0,0.196,0,0,0.0434,448.3275,0.0,0,2160,0.2175,0.0,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,821.0428,2230.6293,0.0,76.3344,752.5375,1802.0375,0,0.0,72.2093,1573 +edge,m27,-2.0,0.1882,0,0,0.1738,412.4893,0.0,0,2160,0.2146,0.0,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,778.6306,2093.5898,0.0,76.3344,695.4541,1652.0367,0,0.0,72.2093,1497 +glare,m27,0.3,0.1851,,,6.2852,596.0161,0.0001,1,2160,0.182,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,533.6155,1411.7823,0.0,76.3344,524.9034,1432.4696,1,0.0,77.7337,2880 +glare,m27,0.5,0.1704,,,6.4221,576.5198,0.0001,1,2160,0.1704,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,369.0919,963.6068,0.0,76.3344,369.0919,958.4805,1,0.0,76.1963,2880 +glare,m27,0.7,0.1727,,,6.2468,560.1726,0.0001,1,2160,0.1725,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,240.8574,617.2121,0.0,76.3344,237.3206,643.9863,1,0.0,78.1772,2880 +glare,m27,0.85,0.1831,,,6.5,549.9972,0.0001,1,2160,0.183,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,168.4165,423.5825,0.0,76.3344,164.5588,406.7887,1,0.0,75.3488,2880 +glare,m27,1.0,0.2034,,,6.784,541.5422,0.0008,1,2160,0.1998,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,117.0405,287.1151,0.082,76.3344,101.5167,228.085,1,0.0874,72.698,2880 +glare,m27,1.5,0.1913,0,0,6.709,523.5591,0.0106,0,2160,0.1557,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,39.8104,90.1972,0.5856,76.3344,2261.8383,4197.7428,0,0.0033,63.4151,2880 +glare,m27,2.5,0.1913,0,0,6.709,505.3464,0.0226,0,2160,0.0434,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,3.5567,6.4904,0.8734,76.3344,2261.8383,4197.7428,0,0.0033,63.4151,2880 +glare,m27,4.0,0.1913,0,0,6.709,489.944,0.0337,0,2160,0.0,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,0.0,0.0,1.0,76.3344,2261.8383,4197.7428,0,0.0033,63.4151,2880 +motion,m27,0.05,0.2044,,,6.5,446.5101,0.0,1,2160,0.2099,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,361.5162,1541.298,0.0,76.3344,336.729,1417.3424,1,0.0,75.3488,2880 +motion,m27,0.1,0.197,,,6.3908,259.9398,0.0,1,2160,0.1965,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,268.0441,1033.3307,0.0,76.3344,267.4992,1032.3298,1,0.0,76.5433,2880 +motion,m27,0.15,0.1931,,,6.303,206.1832,0.0,1,2160,0.1869,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,241.8724,869.4152,0.0,76.3344,238.6132,823.118,1,0.0,77.5308,2880 +motion,m27,0.2,0.1817,,,6.3697,169.6481,0.0,1,2160,0.176,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,224.3732,757.2161,0.0,76.3344,219.9877,744.456,1,0.0,76.7778,2880 +motion,m27,0.3,0.1709,,,6.2509,134.9139,0.0,1,2160,0.1607,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,206.1721,658.1923,0.0,76.3344,200.2773,595.7143,1,0.0,78.1293,2880 +motion,m27,0.4,0.1537,,,6.4009,117.5408,0.0,1,2160,0.151,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,195.8997,607.801,0.0,76.3344,192.588,597.4888,1,0.0,76.4308,2880 +motion,m27,0.5,0.1246,0,0,6.8824,107.4194,0.0,0,2160,0.1455,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,189.5651,579.2999,0.0,76.3344,109.1126,210.8837,0,0.0,71.1628,2880 +motion,m27,0.7,,0,0,,93.2136,0.0,,2160,0.1394,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,179.843,540.0708,0.0,76.3344,,,0,,,2880 +motion,m27,1.0,,0,0,,78.9904,0.0,,2160,0.1393,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,170.8647,514.3419,0.0,76.3344,,,0,,,2880 +scale,m27,48,0.2216,,,6.2806,640.2325,0.0,1,1358,0.2194,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,2521.0852,2093.4364,0.0,47.9917,2491.8527,2173.8373,1,0.0,48.9046,1811 +scale,m27,40,0.2148,,,6.3974,633.6747,0.0,1,1132,0.2148,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,3324.4129,2097.1416,0.0,40.0049,3324.4129,2097.1416,1,0.0,40.0759,1509 +scale,m27,32,0.2155,,,6.4375,597.3942,0.0,1,905,0.2142,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,4278.4999,4278.4999,0.0,31.9827,4301.1883,4301.1883,1,0.0,31.9647,1207 +scale,m27,26,0.2375,,,5.5299,1256.5348,0.0,1,736,0.2082,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,4888.0327,1236.0404,0.0,26.0102,4557.8636,1903.8312,1,0.0,29.8513,981 +scale,m27,22,0.2498,,,5.2404,1420.3647,0.0,1,623,0.2113,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,5216.4694,864.9951,0.0,22.0168,4697.5499,1586.4038,1,0.0,26.6107,830 +scale,m27,18,0.2333,,,5.6076,1589.2762,0.0,1,509,0.203,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,7227.1947,743.0861,0.0,17.9881,6980.6561,987.4216,1,0.0,20.2626,679 +scale,m27,15,0.2244,,,6.0,1682.429,0.0,1,424,0.2056,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,9536.1447,514.1578,0.0,14.9842,9219.7394,453.3714,1,0.0,16.0,566 +scale,m27,12,0.2292,,,4.625,1820.626,0.0,1,340,0.1851,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,13083.3367,323.6742,0.0,12.0156,10421.9045,477.4619,1,0.0,16.0,453 +scale,m27,10,0.2264,,,4.4286,1932.3208,0.0,1,283,0.181,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,13809.7428,193.9456,0.0,10.0012,10870.0896,413.3594,1,0.0,13.9507,377 +scale,m27,8,0.2129,,,5.0,1975.6725,0.0,1,226,0.1678,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,16134.5914,79.9635,0.0,7.9868,13342.2677,188.0184,1,0.0,10.0,302 +scale,m27,6,,0,0,,2003.9765,0.0,,170,0.129,6.4097,6ad96d38311f97aa69a4ee6407f916b95b4ba364511c1e3c5809f0602943e841,14815.3474,35.1436,0.0,6.0078,,,0,,,226 +blur,m29,0.02,0.1496,,,3.9338,105.4373,0.0,1,881,0.143,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,1026.9712,532.547,0.0,30.694,868.7572,1249.6604,1,0.0,52.9587,1000 +blur,m29,0.04,0.1269,,,3.91,14.0188,0.0,1,881,0.1198,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,79.3836,57.1412,0.0,30.694,87.5411,397.1097,1,0.0,53.2905,1000 +blur,m29,0.06,0.111,,,3.8846,5.671,0.0,1,881,0.1047,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,23.3265,17.1898,0.0,30.694,27.9987,153.8522,1,0.0,53.5144,1000 +blur,m29,0.08,0.0978,,,4.0234,3.0173,0.0,1,881,0.0924,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,8.7044,5.9436,0.0,30.694,11.0328,52.6712,1,0.0,51.6048,1000 +blur,m29,0.1,0.0912,1,1,5.0476,2.1921,0.0,0,881,0.0833,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,4.3541,2.5903,0.0,30.694,5.8807,11.8813,0,0.0,41.9524,1000 +blur,m29,0.13,,0,0,,1.7164,0.0,,881,0.0732,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,2.49,1.0778,0.0,30.694,,,0,,,1000 +blur,m29,0.16,,0,0,,1.5341,0.0,,881,0.0659,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,2.0002,0.6733,0.0,30.694,,,0,,,1000 +blur,m29,0.2,,0,0,,1.4188,0.0,,881,0.0583,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,1.8115,0.5112,0.0,30.694,,,0,,,1000 +blur,m29,0.25,,0,0,,1.3749,0.0,,881,0.0511,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,1.6596,0.4565,0.0,30.694,,,0,,,1000 +blur,m29,0.3,,0,0,,1.3462,0.0,,881,0.0456,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,1.5631,0.4147,0.0,30.694,,,0,,,1000 +edge,m29,2.0,0.172,,,1.2803,323.5347,0.0,1,881,0.1617,1.9883,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3357.5567,1557.8617,0.0,30.694,2625.9434,1984.5028,1,0.0,46.7943,534 +edge,m29,1.0,0.172,,,0.6392,315.3744,0.0,1,881,0.1617,1.0109,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3357.5567,1557.8617,0.0,30.694,2625.9434,1984.5028,1,0.0,46.7943,504 +edge,m29,0.5,0.1703,,,0.2852,317.7821,0.0,1,881,0.1617,0.4897,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3357.5567,1557.8617,0.0,30.694,2698.1929,2086.396,1,0.0,48.5983,488 +edge,m29,0.25,0.1716,,,0.157,315.1909,0.0,1,881,0.1617,0.2616,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3357.5567,1557.8617,0.0,30.694,2702.5439,2072.6434,1,0.0,47.4511,481 +edge,m29,0.1,0.1733,,,0.0408,314.929,0.0,1,881,0.1624,0.0987,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3441.1811,1559.5789,0.0,30.694,2698.0242,2074.7361,1,0.0,46.7943,476 +edge,m29,0.0,0.1743,,,-0.0,312.3609,0.0,1,881,0.1626,0.001,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3465.3481,1541.5443,0.0,30.694,2696.3197,2034.7055,1,0.0,45.8332,473 +edge,m29,-0.5,0.176,,,-0.0111,299.8011,0.0,0,881,0.1619,0.0,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3409.4112,1576.3206,0.0,30.694,2630.9857,2006.5044,1,0.0,44.2612,458 +edge,m29,-1.0,0.1677,,,-0.0055,275.3387,0.0,0,881,0.1609,0.0,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3233.131,1443.092,0.0,30.694,2595.5363,2158.3198,1,0.0,54.4874,442 +edge,m29,-2.0,0.1788,,,-0.0049,234.1146,0.0,0,881,0.1561,0.0,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,2831.7492,1303.7704,0.0,30.694,2072.8041,1583.959,1,0.0,40.2389,412 +glare,m29,0.3,0.1372,,,4.5002,234.8695,0.0001,1,881,0.1245,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,2031.5978,940.1867,0.0,30.694,1712.9207,1268.593,1,0.0,46.7943,1000 +glare,m29,0.5,0.1206,,,4.4778,216.5565,0.0001,1,881,0.107,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,1347.9864,622.527,0.0,30.694,1227.1195,890.3014,1,0.0,46.9858,1000 +glare,m29,0.7,0.1116,,,4.4113,201.4622,0.0001,1,881,0.0991,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,823.1067,378.6026,0.0,30.694,854.4673,603.4489,1,0.0,47.6093,1000 +glare,m29,0.85,0.1109,,,4.2163,192.2615,0.0001,1,881,0.1011,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,536.9758,245.8588,0.0,30.694,622.9955,438.8088,1,0.0,49.3895,1000 +glare,m29,1.0,0.1458,0,0,8.8985,184.9063,0.0015,0,881,0.1092,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,337.8063,153.619,0.1326,30.694,2471.8747,322.1555,0,0.0,19.685,1000 +glare,m29,1.5,0.1458,0,0,8.6828,170.3626,0.0134,0,881,0.082,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,79.3392,34.5625,0.5962,30.694,2471.8747,346.488,0,0.0,20.1235,1000 +glare,m29,2.5,0.1458,0,0,8.8985,159.6527,0.0287,0,881,0.0227,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,1.7609,0.5666,0.8872,30.694,2471.8747,322.1555,0,0.0,19.685,1000 +glare,m29,4.0,0.1458,0,0,8.8985,153.2938,0.0427,0,881,0.0,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,0.0,0.0,1.0,30.694,2471.8747,322.1555,0,0.0,19.685,1000 +motion,m29,0.05,0.1588,,,4.2146,127.3983,0.0,1,881,0.1487,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,1206.0292,633.5609,0.0,30.694,1039.3587,1404.7604,1,0.0,49.4093,1000 +motion,m29,0.1,0.1459,,,3.7698,70.326,0.0,1,881,0.14,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,501.933,290.26,0.0,30.694,453.7988,1099.3936,1,0.0,55.2632,1000 +motion,m29,0.15,0.1335,,,3.9412,40.3631,0.0,0,881,0.1297,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,308.3615,177.923,0.0,30.694,41.1622,147.3002,1,0.0,70.6462,1000 +motion,m29,0.2,0.1305,,,3.9123,33.6045,0.0,1,881,0.1253,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,238.5971,140.9865,0.0,30.694,212.7773,513.4111,1,0.0,53.2584,1000 +motion,m29,0.3,0.1198,,,3.6715,23.1767,0.0,1,881,0.1159,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,171.9318,103.9764,0.0,30.694,146.5415,384.5572,1,0.0,55.6612,1000 +motion,m29,0.4,0.1138,1,1,4.0984,18.0126,0.0,0,881,0.1097,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,134.1962,82.5817,0.0,30.694,117.4558,276.0079,0,0.0,51.2016,1000 +motion,m29,0.5,0.1112,0,0,4.2917,14.4888,0.0,0,881,0.1054,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,108.7678,68.4342,0.0,30.694,93.2863,190.179,0,0.0,47.9456,1000 +motion,m29,0.7,0.1094,0,0,5.8872,10.2675,0.0,0,881,0.0997,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,76.951,49.7587,0.0,30.694,54.5145,59.6945,0,0.0,36.0369,1000 +motion,m29,1.0,,0,0,,6.7769,0.0,,881,0.0934,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,46.6514,30.8057,0.0,30.694,,,0,,,1000 +scale,m29,26,0.1662,,,4.0861,325.8456,0.0001,1,746,0.1578,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3584.7703,1054.4501,0.0,25.9906,2973.6512,2016.3417,1,0.0,43.1385,847 +scale,m29,22,0.1695,,,3.5423,420.0221,0.0001,0,631,0.1547,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,3965.9601,701.0975,0.0,21.984,194.1681,223.6686,1,0.0,47.9241,717 +scale,m29,18,0.1651,,,3.064,532.0674,0.0,0,517,0.1505,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,4068.2905,379.5312,0.0,18.0123,274.5824,289.0942,1,0.0,47.9864,586 +scale,m29,15,0.1328,,,4.0588,642.5599,0.0001,0,431,0.1489,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,4256.1071,227.4588,0.0,15.016,263.8324,150.9974,1,0.0,33.9213,489 +scale,m29,12,0.1491,,,3.6178,799.6247,0.0001,1,344,0.1423,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,4566.6521,127.8602,0.0,11.985,5272.888,951.2043,1,0.0,22.2623,391 +scale,m29,10,0.1476,,,4.038,945.5053,0.0001,1,287,0.1366,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,4731.227,80.8692,0.0,9.9991,6866.0544,493.1496,1,0.0,16.7842,326 +scale,m29,8,0.1382,,,3.8571,1104.3083,0.0001,1,230,0.1303,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,5113.9368,34.3018,0.0,8.0132,7630.2845,310.6411,1,0.0,14.0,261 +scale,m29,6,,0,0,,1266.7466,0.0001,,172,0.1211,8.0069,daf3175411ec75597f1a41a6325f6e09786e7282998ea9d856a650c504e57712,5751.4718,19.3966,0.0,5.9925,,,0,,,195 +blur,m30,0.02,0.19,,,12.82,108.811,0.0024,1,3264,0.19,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,27.8797,501.2491,0.0,76.247,27.8797,501.2491,1,0.0,76.2326,2448 +blur,m30,0.04,0.1516,1,1,5.053,35.83,0.0017,0,3264,0.1602,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,6.9512,139.3918,0.0,76.247,3.1182,402.293,0,0.0,153.3257,2448 +blur,m30,0.06,0.1349,1,1,12.0069,12.4777,0.0014,1,3264,0.1336,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,3.0013,40.1187,0.0,76.247,2.8963,47.1416,0,0.0,81.0,2448 +blur,m30,0.08,0.1252,0,1,5.4915,5.0498,0.0011,0,3264,0.1134,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,2.0136,12.6094,0.0,76.247,1.4313,48.7724,0,0.0,141.0626,2448 +blur,m30,0.1,0.0991,0,0,5.9615,2.4108,0.001,0,3264,0.0984,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,1.7482,4.4901,0.0,76.247,1.4022,10.2466,0,0.0,123.3488,2448 +blur,m30,0.13,0.0735,0,0,9.6287,1.0381,0.0008,0,3264,0.0821,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,1.6503,1.3333,0.0,76.247,1.4421,0.5073,0,0.0,71.4201,2448 +blur,m30,0.16,,0,0,,0.5623,0.0006,,3264,0.0707,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,1.6398,0.5448,0.0,76.247,,,0,,,2448 +blur,m30,0.2,,0,0,,0.32,0.0004,,3264,0.0603,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,1.5372,0.2518,0.0,76.247,,,0,,,2448 +blur,m30,0.25,,0,0,,0.2087,0.0001,,3264,0.0521,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,1.5911,0.1389,0.0,76.247,,,0,,,2448 +blur,m30,0.3,,0,0,,0.1643,0.0,,3264,0.047,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,1.503,0.1095,0.0,76.247,,,0,,,2448 +edge,m30,2.0,0.2121,,,1.9153,238.338,0.0011,1,3264,0.21,1.9977,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,513.5297,1291.5924,0.0,76.247,512.2426,1419.1089,1,0.0,80.9296,1623 +edge,m30,1.0,0.2101,,,1.055,231.9886,0.0002,1,3264,0.21,1.0009,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,513.5297,1291.5924,0.0,76.247,515.0286,1287.186,1,0.0,76.2326,1547 +edge,m30,0.5,0.2101,,,0.5585,227.8641,0.0002,1,3264,0.21,0.5025,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,513.5297,1291.5924,0.0,76.247,515.4374,1302.9317,1,0.0,76.2326,1509 +edge,m30,0.25,0.21,,,0.249,225.7809,0.0002,1,3264,0.21,0.2533,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,513.5297,1291.5924,0.0,76.247,513.5297,1291.5924,1,0.0,76.2326,1490 +edge,m30,0.1,0.2131,,,0.1016,223.7239,0.0002,1,3264,0.211,0.0959,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,521.9678,1313.2825,0.0,76.247,518.5194,1453.631,1,0.0,81.4538,1478 +edge,m30,0.0,0.2102,,,0.062,223.601,0.0001,1,3264,0.2105,0.0041,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,526.919,1326.0227,0.0,76.247,524.9006,1316.93,1,0.0,76.2326,1471 +edge,m30,-0.5,0.213,0,0,0.0662,216.7252,0.0001,0,3264,0.2095,0.0,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,529.1456,1292.6019,0.0,76.247,536.023,1193.8539,0,0.0,71.4884,1433 +edge,m30,-1.0,0.2128,0,0,0.0587,209.7083,0.0001,0,3264,0.2103,0.0,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,528.2516,1262.8866,0.0,76.247,536.9522,1243.9798,0,0.0,75.1534,1394 +edge,m30,-2.0,0.1704,0,0,0.0357,194.2159,0.0,0,3264,0.206,0.0,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,516.5604,1182.7063,0.0,76.247,151.6817,629.6259,0,0.0,125.1456,1318 +glare,m30,0.3,0.1667,1,1,12.82,192.4742,0.0031,1,3264,0.1667,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,320.4458,799.7467,0.0,76.247,320.4458,799.7467,0,0.0,76.2326,2448 +glare,m30,0.5,0.1411,1,1,12.82,184.2603,0.0031,1,3264,0.1411,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,218.944,544.0107,0.0,76.247,218.944,544.0107,0,0.0,76.2326,2448 +glare,m30,0.7,0.1198,1,0,12.82,177.4323,0.0031,1,3264,0.1198,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,140.2371,345.2699,0.0,76.247,140.2371,345.2699,0,0.0,76.2326,2448 +glare,m30,0.85,0.1085,,,12.82,173.2162,0.0031,1,3264,0.1085,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,96.2719,234.0546,0.0,76.247,96.2719,234.0546,1,0.0,76.2326,2448 +glare,m30,1.0,0.154,0,0,6.2545,169.7661,0.0048,0,3264,0.1025,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,64.9042,155.5316,0.2206,76.247,62.1413,277.5822,0,0.0227,123.919,2448 +glare,m30,1.5,0.1486,0,0,10.7071,162.4655,0.0141,0,3264,0.0677,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,18.2931,41.5171,0.6234,76.247,144.6577,289.1117,0,0.0,69.5233,2448 +glare,m30,2.5,0.1558,0,0,10.7763,155.0918,0.026,0,3264,0.015,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,0.7623,0.908,0.9138,76.247,128.0881,252.5161,0,0.0,69.0827,2448 +glare,m30,4.0,0.1754,0,0,10.8621,149.413,0.0369,0,3264,0.0,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,0.0,0.0,1.0,76.247,103.7579,205.6283,0,0.0009,68.5442,2448 +motion,m30,0.05,0.2027,,,12.82,175.5553,0.003,1,3264,0.2027,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,141.1279,934.2197,0.0,76.247,141.1279,934.2197,1,0.0,76.2326,2448 +motion,m30,0.1,0.1901,0,0,12.82,129.9909,0.0028,1,3264,0.1901,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,77.9447,618.2221,0.0,76.247,77.9447,618.2221,0,0.0,76.2326,2448 +motion,m30,0.15,0.1553,0,1,5.6371,107.522,0.0026,0,3264,0.1799,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,59.2269,482.3117,0.0,76.247,20.0115,551.3523,0,0.0,137.4883,2448 +motion,m30,0.2,0.1503,0,1,5.6326,87.1447,0.0024,0,3264,0.1677,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,45.783,376.8019,0.0,76.247,15.725,435.6135,0,0.0,137.5954,2448 +motion,m30,0.3,0.1549,,,10.9886,64.5379,0.002,1,3264,0.1502,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,32.9275,276.9485,0.0,76.247,29.3064,350.1761,1,0.0,88.0544,2448 +motion,m30,0.4,0.1424,0,0,4.8852,53.6313,0.0016,0,3264,0.1406,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,27.1681,231.7214,0.0,76.247,10.4395,331.6366,0,0.0,155.7499,2448 +motion,m30,0.5,0.1252,0,0,6.1923,45.5949,0.0014,0,3264,0.1333,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,22.9816,200.104,0.0,76.247,7.5069,147.6074,0,0.0,123.3488,2448 +motion,m30,0.7,0.1205,0,0,5.9761,36.7188,0.0011,0,3264,0.1258,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,18.5287,167.8886,0.0,76.247,5.9735,124.5377,0,0.0,126.8945,2448 +motion,m30,1.0,,0,0,,29.5917,0.0009,,3264,0.1201,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,15.1362,145.3993,0.0,76.247,,,0,,,2448 +scale,m30,48,0.2135,,,12.9591,209.6964,0.003,1,2055,0.2101,12.8153,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,1135.5665,1289.9079,0.0,48.0048,1162.6477,1311.1571,1,0.0,47.4404,1541 +scale,m30,40,0.2118,1,0,13.1372,211.0262,0.003,1,1712,0.2104,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,1560.3659,1274.636,0.0,39.9923,1580.2544,1252.8854,0,0.0,38.9923,1284 +scale,m30,32,0.2095,,,11.9726,207.8261,0.003,1,1370,0.209,12.8112,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,2175.4185,2175.4185,0.0,32.0032,2108.5103,1367.9033,1,0.0,34.1698,1027 +scale,m30,26,0.2094,,,11.8536,198.9918,0.0029,1,1113,0.2074,12.8213,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,3059.084,932.4532,0.0,25.9997,2993.5914,1176.9724,1,0.0,27.9749,835 +scale,m30,22,0.2,,,12.7273,406.563,0.0028,1,942,0.2079,12.8084,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,4237.7309,786.1693,0.0,22.0051,3997.1219,743.6533,1,0.0,22.0,706 +scale,m30,18,0.1641,,,5.8235,563.7709,0.0027,0,771,0.2031,12.8119,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,6219.8743,615.2568,0.0,18.0106,1273.0616,725.386,1,0.0,31.2352,578 +scale,m30,15,0.1584,,,5.8462,758.236,0.0026,0,642,0.2028,12.8307,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,8739.8424,468.0703,0.0,14.9971,1895.7716,579.9836,1,0.0,26.0,482 +scale,m30,12,0.1631,0,0,5.4359,1059.313,0.0025,0,514,0.2003,12.8008,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,12598.8023,323.0262,0.0,12.007,2999.0492,556.1081,0,0.0,22.3783,385 +scale,m30,10,0.2071,1,0,12.9596,1318.9375,0.0024,1,428,0.1875,12.8174,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,14418.7452,209.4338,0.0,9.9981,16664.0071,241.0645,0,0.0,10.0,321 +scale,m30,8,0.1305,0,0,3.86,1586.622,0.0022,0,342,0.1778,12.8424,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,16336.6972,86.7187,0.0015,7.9891,4141.6575,181.3288,0,0.0,16.0,257 +scale,m30,6,0.1537,0,0,4.9936,1789.0132,0.0018,0,257,0.1566,12.834,85f19306a5983b6cd45e0ca3637a85c24e910271f79af2715cc7b66e202b3366,13909.4266,36.9626,0.0,6.0035,5814.6413,151.9438,0,0.0,11.9535,193 +blur,m31,0.02,0.2415,,,7.4582,39.5237,0.0028,1,1439,0.2394,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,57.3944,874.792,0.137,77.0872,58.4587,853.3317,1,0.1398,76.0262,1610 +blur,m31,0.04,0.1988,,,7.1756,5.3704,0.0013,1,1439,0.1991,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,9.2008,203.1743,0.0649,77.0872,9.1405,218.4807,1,0.0637,78.9774,1610 +blur,m31,0.06,0.1626,,,6.9143,1.758,0.0002,1,1439,0.1625,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,4.1235,69.4425,0.0135,77.0872,3.9801,81.9497,1,0.0125,81.7614,1610 +blur,m31,0.08,0.1391,,,6.3223,0.8428,0.0001,1,1439,0.1324,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,2.6229,24.2809,0.003,77.0872,2.5118,36.3917,1,0.0025,88.6852,1610 +blur,m31,0.1,0.1197,,,6.2431,0.5197,0.0,1,1439,0.1097,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,2.1202,8.6866,0.0,77.0872,2.0577,13.9222,1,0.0,89.7264,1610 +blur,m31,0.13,,0,0,,0.3439,0.0,,1439,0.0858,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,1.8478,2.4724,0.0,77.0872,,,0,,,1610 +blur,m31,0.16,,0,0,,0.2767,0.0,,1439,0.0695,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,1.7695,0.9711,0.0,77.0872,,,0,,,1610 +blur,m31,0.2,,0,0,,0.2397,0.0,,1439,0.0562,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,1.7441,0.3505,0.0,77.0872,,,0,,,1610 +blur,m31,0.25,,0,0,,0.2193,0.0,,1439,0.0479,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,1.4755,0.2038,0.0,77.0872,,,0,,,1610 +blur,m31,0.3,,0,0,,0.2144,0.0,,1439,0.0432,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,1.209,0.1816,0.0,77.0872,,,0,,,1610 +edge,m31,2.0,0.2679,,,1.9469,214.7636,0.008,1,1439,0.2709,2.0062,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,760.6468,2938.0512,0.2099,77.0872,778.3879,3233.2896,1,0.2067,79.4796,1079 +edge,m31,1.0,0.269,,,0.9728,212.7281,0.0086,1,1439,0.2709,0.9943,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,760.6468,2938.0512,0.2099,77.0872,756.27,3174.4441,1,0.2063,79.4796,1001 +edge,m31,0.5,0.2706,,,0.5129,215.9743,0.0088,1,1439,0.2709,0.5014,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,760.6468,2938.0512,0.2099,77.0872,757.6475,3028.1128,1,0.2087,77.3881,963 +edge,m31,0.25,0.2703,,,0.2434,217.0884,0.0089,1,1439,0.2709,0.2549,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,760.6468,2938.0512,0.2099,77.0872,762.3396,3168.2904,1,0.2087,77.3881,944 +edge,m31,0.1,0.2698,,,0.1281,217.8743,0.009,1,1439,0.2729,0.0992,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,779.566,3019.2299,0.2159,77.0872,757.7848,3214.2736,1,0.2069,80.5451,932 +edge,m31,0.0,0.2706,,,0.0247,218.4164,0.009,1,1439,0.2741,0.0,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,791.9031,3058.7993,0.2201,77.0872,778.145,3388.611,1,0.2126,80.3238,924 +edge,m31,-0.5,0.2334,0,0,0.0452,214.0253,0.0087,0,1439,0.2714,0.0,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,772.7667,2941.0216,0.225,77.0872,929.0742,1620.4985,0,0.0254,46.2469,886 +edge,m31,-1.0,0.2719,0,0,0.3969,211.8779,0.0082,0,1439,0.2731,0.0,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,784.0878,2958.403,0.2234,77.0872,772.1156,2997.9404,0,0.2223,78.5278,847 +edge,m31,-2.0,,0,0,,202.5189,0.0067,,1439,0.2705,0.0,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,797.6416,2874.2533,0.2109,77.0872,,,0,,,770 +glare,m31,0.3,0.216,,,7.3636,214.5442,0.0065,1,1439,0.216,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,496.8009,1865.55,0.2584,77.0872,496.8009,1865.55,1,0.2584,77.0893,1610 +glare,m31,0.5,0.1813,,,7.3636,203.1697,0.0075,1,1439,0.1813,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,356.131,1300.443,0.3031,77.0872,356.131,1300.443,1,0.3031,77.0893,1610 +glare,m31,0.7,0.1515,,,7.5938,193.5447,0.0088,1,1439,0.1496,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,244.4148,856.3525,0.3668,77.0872,244.6626,811.2849,1,0.3818,74.7532,1610 +glare,m31,0.85,0.1302,,,7.5938,187.4658,0.0095,1,1439,0.1288,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,179.2561,601.1104,0.4054,77.0872,177.9226,560.9418,1,0.4215,74.7532,1610 +glare,m31,1.0,0.1133,,,7.5938,182.3959,0.0108,1,1439,0.1124,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,130.5006,413.4962,0.4724,77.0872,128.2505,378.8179,1,0.4909,74.7532,1610 +glare,m31,1.5,0.1597,0,0,10.2381,171.2112,0.0229,0,1439,0.0704,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,50.1034,128.383,0.7706,77.0872,126.5023,254.5419,0,0.1847,49.0568,1610 +glare,m31,2.5,,0,0,,160.1252,0.0395,,1439,0.0225,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,7.4102,10.5202,0.9522,77.0872,,,0,,,1610 +glare,m31,4.0,,0,0,,151.8793,0.0535,,1439,0.0001,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,0.0006,0.0,1.0,77.0872,,,0,,,1610 +motion,m31,0.05,0.261,,,7.3636,123.6631,0.0047,1,1439,0.261,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,403.5047,2207.9809,0.1908,77.0872,403.5047,2207.9809,1,0.1908,77.0893,1610 +motion,m31,0.1,0.2453,,,7.2576,63.9425,0.004,1,1439,0.2463,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,304.5852,1537.5138,0.1682,77.0872,302.3203,1530.0398,1,0.1668,78.1434,1610 +motion,m31,0.15,0.2252,,,6.7852,47.7614,0.0034,1,1439,0.2317,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,273.8877,1332.4797,0.1485,77.0872,257.8093,1472.3996,1,0.1343,83.2105,1610 +motion,m31,0.2,0.2159,,,6.9143,41.3828,0.003,1,1439,0.2208,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,258.7537,1231.1387,0.134,77.0872,246.1873,1277.4211,1,0.1238,81.7614,1610 +motion,m31,0.3,0.194,,,6.8952,32.6227,0.0025,1,1439,0.1965,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,236.3077,1084.2092,0.114,77.0872,226.7625,1208.4204,1,0.105,81.8144,1610 +motion,m31,0.4,0.182,,,7.5938,27.8764,0.0022,1,1439,0.1805,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,223.049,1012.6938,0.1004,77.0872,227.6342,933.239,1,0.1023,74.7532,1610 +motion,m31,0.5,,0,0,,24.8136,0.002,,1439,0.1693,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,214.1091,966.9509,0.0922,77.0872,,,0,,,1610 +motion,m31,0.7,,0,0,,21.2313,0.0018,,1439,0.1566,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,202.4108,912.8353,0.0859,77.0872,,,0,,,1610 +motion,m31,1.0,,0,0,,18.0832,0.0017,,1439,0.1516,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,190.5346,872.2156,0.0826,77.0872,,,0,,,1610 +scale,m31,48,0.2647,,,6.6096,409.4395,0.0055,1,896,0.2711,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,2642.6258,2838.7471,0.2089,47.9987,2855.8813,3559.5913,1,0.1862,51.2005,1003 +scale,m31,40,0.2674,,,7.2171,603.9485,0.0056,1,747,0.2689,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,3699.0494,2607.769,0.212,40.0168,3729.0475,3077.8914,1,0.2057,40.6346,835 +scale,m31,32,0.2652,,,6.3882,898.0834,0.0054,1,597,0.2689,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,4928.8961,4928.8961,0.2112,31.9813,5273.5384,3191.375,1,0.1794,36.2334,668 +scale,m31,26,0.2586,,,6.484,1184.3131,0.0054,1,485,0.2625,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,6166.4995,1673.7109,0.2083,25.9814,6356.7746,2591.9294,1,0.1892,29.1496,543 +scale,m31,22,0.2506,,,5.6322,1400.2151,0.0053,1,411,0.2587,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,6645.9509,1125.6452,0.2056,22.0173,8123.8753,2753.3391,1,0.1561,27.2567,459 +scale,m31,18,0.2441,,,5.8596,1602.3968,0.0047,1,336,0.2565,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,8179.9302,795.2618,0.1962,17.9995,9929.8873,1877.2392,1,0.1492,22.1714,376 +scale,m31,15,0.2446,,,5.0838,1732.1509,0.0044,1,280,0.2501,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,11148.7176,633.2389,0.2123,14.9996,10796.8053,1426.1473,1,0.1285,20.0,313 +scale,m31,12,0.2288,,,4.8889,1959.4056,0.0041,1,224,0.2454,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,16092.9013,424.4648,0.1984,11.9997,12167.0366,937.6486,1,0.108,16.6889,251 +scale,m31,10,0.2254,,,4.7085,2252.4508,0.0039,1,187,0.2406,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,22977.241,330.7824,0.1754,10.0176,15582.7768,795.4752,1,0.0951,15.0869,209 +scale,m31,8,0.2103,,,5.8,2729.7378,0.0039,1,149,0.2107,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,30006.7789,144.2495,0.1798,7.9819,21597.3722,294.1363,1,0.1302,9.9333,167 +scale,m31,6,,0,0,,3368.9787,0.0029,,112,0.1768,7.3638,2f8db0c25a4786f152ad7bc4ab03596e96a4612c07e841d513a76e5c33f16567,27879.419,56.265,0.123,5.9998,,,0,,,125 +blur,m32,0.02,0.1339,,,1.7826,94.1863,0.0183,0,2880,0.0922,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,6.8098,119.5265,0.0,83.7216,8.001,279.104,1,0.0,128.3721,3840 +blur,m32,0.04,0.1281,,,1.7579,28.0983,0.0166,0,2880,0.0779,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,2.4407,31.834,0.0,83.7216,2.1092,80.5885,1,0.0,130.0919,3840 +blur,m32,0.06,0.1254,,,1.7391,10.1378,0.0151,0,2880,0.066,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,1.7284,9.1239,0.0,83.7216,1.7088,22.911,1,0.0,128.3721,3840 +blur,m32,0.08,0.1238,,,1.625,4.518,0.0137,0,2880,0.0569,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,1.5823,3.0075,0.0,83.7216,1.5703,8.9809,1,0.0,133.9535,3840 +blur,m32,0.1,0.1219,,,1.6244,2.4007,0.0125,0,2880,0.0502,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,1.5454,1.1868,0.0,83.7216,1.549,3.8567,1,0.0,134.0,3840 +blur,m32,0.13,0.0541,,,3.9019,1.1532,0.0108,0,2880,0.0426,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,1.5074,0.4348,0.0,83.7216,1.3251,2.0064,1,0.0,124.2584,3840 +blur,m32,0.16,0.0456,0,0,3.9508,0.6577,0.0094,0,2880,0.0373,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,1.3182,0.238,0.0,83.7216,1.3424,0.7863,0,0.0,121.8144,3840 +blur,m32,0.2,,0,0,,0.376,0.008,,2880,0.0325,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,1.1463,0.1595,0.0,83.7216,,,0,,,3840 +blur,m32,0.25,,0,0,,0.2321,0.0065,,2880,0.0292,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,0.9607,0.1344,0.0,83.7216,,,0,,,3840 +blur,m32,0.3,,0,0,,0.1714,0.005,,2880,0.0278,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,0.8105,0.1355,0.0,83.7216,,,0,,,3840 +edge,m32,2.0,0.0996,,,0.5007,134.4301,0.0215,0,2880,0.1035,2.0026,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,196.3832,381.5871,0.0,83.7216,85.3342,218.481,1,0.0,116.3775,2283 +edge,m32,1.0,0.1,,,0.0322,130.8263,0.0223,0,2880,0.1035,0.9993,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,196.3832,381.5871,0.0,83.7216,86.3959,224.8926,1,0.0,117.682,2199 +edge,m32,0.5,0.0981,,,0.0353,128.965,0.0227,0,2880,0.1035,0.4976,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,196.3832,381.5871,0.0,83.7216,83.3338,204.0223,1,0.0,114.4921,2157 +edge,m32,0.25,0.0984,,,0.032,127.1907,0.0229,0,2880,0.1035,0.2468,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,196.3832,381.5871,0.0,83.7216,82.1647,214.6304,1,0.0,117.4832,2136 +edge,m32,0.1,0.0981,,,0.1843,127.3876,0.0231,0,2880,0.104,0.1034,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,198.6995,387.544,0.0,83.7216,81.2591,202.2417,1,0.0,113.4186,2124 +edge,m32,0.0,0.0977,,,0.0737,127.0476,0.0232,0,2880,0.1044,0.0,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,200.3654,391.5013,0.0,83.7216,80.6527,198.4545,1,0.0,113.4186,2115 +edge,m32,-0.5,0.0964,0,0,0.0399,125.0351,0.0236,0,2880,0.104,0.0,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,197.7967,386.1121,0.0,83.7216,78.9325,172.6579,0,0.0,104.6512,2073 +edge,m32,-1.0,0.0976,0,0,0.4884,121.3018,0.0241,0,2880,0.1035,0.0,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,194.77,382.8551,0.0,83.7216,78.0186,190.3225,0,0.0,110.7947,2032 +edge,m32,-2.0,0.0973,0,0,0.0369,116.2906,0.0252,0,2880,0.1034,0.0,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,191.7017,375.0415,0.0,83.7216,76.3714,193.1709,0,0.0,113.4186,1948 +glare,m32,0.3,0.1516,,,1.7826,182.8638,0.0193,0,2880,0.1005,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,121.1437,230.2037,0.0,83.7216,2422.5036,583.8364,1,0.0,128.3721,3840 +glare,m32,0.5,0.1516,,,1.7826,177.2303,0.0193,0,2880,0.1177,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,81.5466,152.3741,0.0,83.7216,2422.5036,583.8364,1,0.0,128.3721,3840 +glare,m32,0.7,0.1516,,,1.7826,172.4543,0.0193,0,2880,0.1447,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,51.2933,93.0524,0.0,83.7216,2422.5036,583.8364,1,0.0,128.3721,3840 +glare,m32,0.85,0.1516,,,1.7826,169.3779,0.0193,0,2880,0.1686,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,34.6228,60.7053,0.0,83.7216,2422.5036,583.8364,1,0.0,128.3721,3840 +glare,m32,1.0,0.1516,,,1.7826,166.8055,0.0202,0,2880,0.1944,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,22.9736,38.8182,0.1055,83.7216,2422.5036,583.8364,1,0.0,128.3721,3840 +glare,m32,1.5,0.1516,0,0,1.7826,160.8447,0.0348,0,2880,0.1496,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,6.519,9.9701,0.607,83.7216,2422.5036,583.8364,0,0.0,128.3721,3840 +glare,m32,2.5,0.1516,0,0,1.7826,153.5171,0.0533,0,2880,0.0363,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,0.281,0.1369,0.903,83.7216,2422.5036,583.8364,0,0.0,128.3721,3840 +glare,m32,4.0,0.1516,0,0,1.7826,146.9052,0.0703,0,2880,0.0,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,0.0,0.0,1.0,83.7216,2422.5036,583.8364,0,0.0,128.3721,3840 +motion,m32,0.05,0.1416,,,1.7589,154.3446,0.0188,0,2880,0.0993,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,55.5658,258.7673,0.0,83.7216,611.8857,465.0876,1,0.0,130.0175,3840 +motion,m32,0.1,0.1372,,,1.7826,96.6029,0.018,0,2880,0.0935,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,28.8718,151.7152,0.0,83.7216,441.4919,292.9795,1,0.0,128.3721,3840 +motion,m32,0.15,0.1335,,,1.8291,62.1073,0.0169,0,2880,0.0865,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,19.0745,97.9023,0.0,83.7216,373.6466,173.6592,1,0.0,125.0037,3840 +motion,m32,0.2,0.1322,,,1.7768,49.9115,0.0159,0,2880,0.0819,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,15.3411,76.9425,0.0,83.7216,335.3239,132.7345,1,0.0,125.5251,3840 +motion,m32,0.3,0.1282,,,1.8182,38.956,0.0143,0,2880,0.0752,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,11.4688,56.583,0.0,83.7216,294.8864,105.3317,1,0.0,122.7907,3840 +motion,m32,0.4,0.1286,,,1.52,33.4965,0.013,0,2880,0.071,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,9.3134,45.7649,0.0,83.7216,224.8467,88.3222,1,0.0,139.5349,3840 +motion,m32,0.5,0.0741,0,0,3.8064,29.7992,0.0116,0,2880,0.068,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,7.8587,38.6349,0.0,83.7216,4.6288,65.8917,0,0.0,125.6091,3840 +motion,m32,0.7,0.1218,0,0,1.7208,25.8255,0.0095,0,2880,0.0651,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,6.1845,31.9211,0.0,83.7216,195.479,50.7923,0,0.0,123.2558,3840 +motion,m32,1.0,,0,0,,22.547,0.0073,,2880,0.0632,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,5.0383,27.897,0.0,83.7216,,,0,,,3840 +scale,m32,48,0.1446,,,1.7831,195.6905,0.0192,0,1651,0.1027,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,512.6944,377.3421,0.0,47.9946,1913.9548,602.6053,1,0.0,73.5911,2202 +scale,m32,40,0.1421,,,1.7641,195.959,0.0192,0,1376,0.1025,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,618.2637,370.7947,0.0,40.0003,1604.3787,580.6017,1,0.0,61.9573,1835 +scale,m32,32,0.1418,,,1.7425,193.6341,0.0192,0,1101,0.1019,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,737.3426,737.3426,0.0,32.0061,1416.3015,569.3688,1,0.0,48.9807,1468 +scale,m32,26,0.1396,,,1.7485,183.1748,0.0191,0,894,0.1014,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,851.1037,238.6638,0.0,25.9886,1183.8405,556.9115,1,0.0,40.0,1193 +scale,m32,22,0.1394,,,1.646,357.2839,0.019,0,757,0.099,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,994.2672,171.8753,0.0,22.006,1039.3737,513.9104,1,0.0,35.1397,1009 +scale,m32,18,0.1367,,,1.6026,438.9062,0.019,0,619,0.1007,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,1538.0276,148.0351,0.0,17.9943,1036.6011,435.4554,1,0.0,29.9516,826 +scale,m32,15,0.1358,,,1.6667,540.1234,0.0188,0,516,0.0986,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,2100.036,106.4484,0.0,15.0001,1304.7375,263.6208,1,0.0,24.0,688 +scale,m32,12,0.0954,,,3.7778,685.0617,0.0184,1,413,0.0959,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,3015.0816,75.7095,0.0,12.0059,1213.2776,119.5281,1,0.0,17.9565,550 +scale,m32,10,0.0977,,,4.1429,801.6506,0.0182,0,344,0.0945,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,3564.1226,49.4952,0.0,10.0001,1953.0922,85.2851,1,0.0,14.0,459 +scale,m32,8,0.1367,,,1.4299,938.0073,0.0174,0,275,0.0839,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,4150.4376,19.6092,0.0,7.9942,1727.8438,67.2317,1,0.0,13.9493,367 +scale,m32,6,0.0866,,,3.4,1203.0858,0.0169,1,206,0.0768,7.4001,b701b994331e7090d85bae72f41111ac750b75651ff524dd64b0aec9d199d8b2,4092.0169,9.3885,0.0,5.9884,3542.5696,46.8999,1,0.0,10.0,275 +blur,m33,0.02,0.1351,,,3.419,9.1856,0.0,0,1840,0.0711,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,1.2379,99.5067,0.0,235.8696,1.2612,73.3354,1,0.0,166.6869,3264 +blur,m33,0.04,0.1386,,,1.9651,1.19,0.0,0,1840,0.059,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,1.0173,18.9536,0.0,235.8696,1.0188,30.5486,1,0.0,252.4661,3264 +blur,m33,0.06,0.112,,,2.1765,0.3932,0.0,0,1840,0.0501,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,1.0719,5.1809,0.0,235.8696,1.1037,5.0932,1,0.0,230.0001,3264 +blur,m33,0.08,0.042,1,1,0.8087,0.2197,0.0,0,1840,0.0438,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,1.073,1.7354,0.0,235.8696,1.1146,1.3991,0,0.0,217.4237,3264 +blur,m33,0.1,,0,0,,0.1654,0.0,,1840,0.0394,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,0.9753,0.6765,0.0,235.8696,,,0,,,3264 +blur,m33,0.13,,0,0,,0.1418,0.0,,1840,0.0348,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,0.767,0.2535,0.0,235.8696,,,0,,,3264 +blur,m33,0.16,,0,0,,0.1364,0.0,,1840,0.0318,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,0.6132,0.1573,0.0,235.8696,,,0,,,3264 +blur,m33,0.2,,0,0,,0.1357,0.0,,1840,0.029,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,0.4688,0.1398,0.0,235.8696,,,0,,,3264 +blur,m33,0.25,,0,0,,0.1388,0.0,,1840,0.027,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,0.358,0.1619,0.0,235.8696,,,0,,,3264 +blur,m33,0.3,,0,0,,0.1468,0.0,,1840,0.0257,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,0.2947,0.1595,0.0,235.8696,,,0,,,3264 +edge,m33,2.0,0.1645,,,1.4019,292.1035,0.0003,0,1840,0.0904,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,160.1648,402.5174,0.0,235.8696,299.1156,665.8039,1,0.0001,243.4088,2427 +edge,m33,1.0,0.1667,,,0.4527,266.2593,0.0001,0,1840,0.0904,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,160.1648,402.5174,0.0,235.8696,307.2561,677.2524,1,0.0001,238.4947,2191 +edge,m33,0.5,0.1643,,,0.0104,257.9776,0.0001,0,1840,0.0904,0.5,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,160.1648,402.5174,0.0,235.8696,249.0945,577.7395,1,0.0,216.0232,2073 +edge,m33,0.25,0.1562,,,0.2632,238.3642,0.0001,0,1840,0.0904,0.2498,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,160.1648,402.5174,0.0,235.8696,266.781,520.3932,1,0.0,177.3505,2014 +edge,m33,0.1,0.1513,,,0.1031,232.894,0.0001,0,1840,0.0905,0.1015,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,160.6044,418.0072,0.0,235.8696,244.6129,519.3973,1,0.0,190.4854,1979 +edge,m33,0.0,0.1542,,,0.0161,230.4309,0.0001,0,1840,0.0905,0.0,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,160.6899,421.3961,0.0,235.8696,268.6449,526.9208,1,0.0,176.4127,1955 +edge,m33,-0.5,0.1494,0,0,0.122,206.346,0.0001,0,1840,0.0871,0.0,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,138.3383,387.4224,0.0,235.8696,252.4215,497.9157,0,0.0,171.0299,1837 +edge,m33,-1.0,0.0894,0,0,0.0321,199.3155,0.0002,0,1840,0.0874,0.0,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,138.9785,401.3735,0.0,235.8696,147.6775,404.8816,0,0.0,237.6667,1719 +edge,m33,-2.0,0.1313,0,0,0.0163,175.8633,0.0002,0,1840,0.084,0.0,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,128.8014,363.2363,0.0,235.8696,244.8682,543.562,0,0.0,163.4651,1483 +glare,m33,0.3,0.1571,,,1.8692,301.8706,0.0003,0,1840,0.0806,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,104.8321,254.8554,0.0,235.8696,228.5375,559.1296,1,0.0001,258.7923,3264 +glare,m33,0.5,0.1577,,,1.8699,288.1461,0.0003,0,1840,0.0865,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,74.9667,177.0868,0.0,235.8696,193.7451,489.0961,1,0.0001,258.7262,3264 +glare,m33,0.7,0.1635,,,1.8214,276.2117,0.0003,0,1840,0.1009,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,51.3297,116.3639,0.0,235.8696,164.1176,441.4132,1,0.0001,265.567,3264 +glare,m33,0.85,0.1672,,,1.9563,268.5342,0.0003,0,1840,0.1152,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,37.736,81.7595,0.0,235.8696,147.2259,407.4659,1,0.0002,252.0,3264 +glare,m33,1.0,0.1728,0,0,2.0154,261.882,0.0044,0,1840,0.1314,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,27.4687,56.4492,0.0667,235.8696,130.8092,348.7029,0,0.0005,247.0619,3264 +glare,m33,1.5,0.1591,0,0,3.4266,246.2848,0.0488,0,1840,0.1148,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,10.2906,18.5097,0.5516,235.8696,94.3257,237.2164,0,0.0585,166.0515,3264 +glare,m33,2.5,,0,0,,228.4057,0.092,,1840,0.0466,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,1.3786,1.5691,0.8346,235.8696,,,0,,,3264 +glare,m33,4.0,,0,0,,213.461,0.1275,,1840,0.0008,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,0.0046,0.0086,0.9996,235.8696,,,0,,,3264 +motion,m33,0.05,0.1624,,,3.0672,132.3023,0.0001,0,1840,0.0839,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,43.0766,287.7372,0.0,235.8696,45.335,409.3465,1,0.0,184.1206,3264 +motion,m33,0.1,0.1545,,,2.9286,103.9075,0.0,0,1840,0.0785,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,35.9282,203.676,0.0,235.8696,29.5705,298.862,1,0.0,189.4118,3264 +motion,m33,0.15,0.1611,,,2.1848,94.0455,0.0,0,1840,0.0739,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,32.6981,175.0212,0.0,235.8696,93.332,400.0653,1,0.0,234.3282,3264 +motion,m33,0.2,0.1443,,,2.9722,87.4386,0.0,0,1840,0.0696,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,29.8782,160.4519,0.0,235.8696,21.6254,232.3132,1,0.0,185.9241,3264 +motion,m33,0.3,0.0713,1,1,0.6519,78.5928,0.0,0,1840,0.0636,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,25.4731,148.2571,0.0,235.8696,53.182,201.1261,0,0.0,247.0062,3264 +motion,m33,0.4,0.0465,,,0.8444,72.1083,0.0,1,1840,0.06,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,21.8716,142.1326,0.0,235.8696,5.5455,21.2235,1,0.0,213.4021,3264 +motion,m33,0.5,,0,0,,66.3361,0.0,,1840,0.0574,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,18.4957,137.6332,0.0,235.8696,,,0,,,3264 +motion,m33,0.7,,0,0,,56.8934,0.0,,1840,0.0537,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,13.0376,130.9411,0.0,235.8696,,,0,,,3264 +motion,m33,1.0,,0,0,,45.9167,0.0,,1840,0.0512,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,8.1527,123.1752,0.0,235.8696,,,0,,,3264 +scale,m33,48,0.1503,,,1.5152,683.0412,0.0001,0,374,0.0838,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,612.9675,413.3384,0.0,47.9431,946.6002,662.7639,1,0.0,60.0785,664 +scale,m33,40,0.0846,,,0.5873,752.9415,0.0001,1,312,0.0837,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,668.8037,347.787,0.0,39.9953,715.3499,441.1114,1,0.0,43.2262,554 +scale,m33,32,0.1579,,,1.9712,918.8749,0.0001,0,250,0.0814,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,740.2596,740.2596,0.0,32.0475,1287.7818,623.8306,1,0.0,34.3968,443 +scale,m33,26,0.1467,,,1.5093,1131.9395,0.0001,0,203,0.0797,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,831.6795,219.1586,0.0,26.0226,1313.307,640.7067,1,0.0,32.6867,360 +scale,m33,22,0.1451,,,1.7492,1315.3089,0.0001,0,172,0.078,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,952.4281,162.192,0.0,22.0487,1471.3058,376.4676,1,0.0,24.8983,304 +scale,m33,18,0.1533,,,2.1111,1526.4031,0.0001,0,140,0.0759,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,1159.944,112.6991,0.0,17.9466,1774.3459,165.1759,1,0.0,18.0,249 +scale,m33,15,0.0737,,,0.8571,1726.9581,0.0001,1,117,0.0744,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,1215.2602,63.9316,0.0,14.9982,1266.815,50.695,1,0.0,13.8814,208 +scale,m33,12,0.0701,,,0.6667,1924.9332,0.0001,1,94,0.0728,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,1544.1593,39.4528,0.0,12.0499,1676.7792,41.5333,1,0.0,12.0,166 +scale,m33,10,0.0735,1,1,1.6667,2063.5781,0.0,0,78,0.0696,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,1802.0981,26.2529,0.0,9.9988,2238.3833,6.6546,0,0.0,6.0,138 +scale,m33,8,,0,0,,2281.9387,0.0001,,62,0.0654,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,2037.2195,13.6782,0.0,7.9478,,,0,,,111 +scale,m33,6,,0,0,,2608.9941,0.0003,,47,0.0569,0.7164,7896b80fec49da6fa430ab1eb6427aaf41f5877692a2cbc43ae8ec1f940fb964,2000.3648,5.13,0.0,6.0249,,,0,,,83 +blur,m34,0.02,0.1503,,,7.8394,148.849,0.0,0,3072,0.1757,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,374.0796,438.6229,0.0,35.7274,284.924,721.8259,1,0.0,47.6526,2304 +blur,m34,0.04,0.1297,,,8.1067,76.7352,0.0,0,3072,0.1483,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,94.6035,126.3843,0.0,35.7274,59.1616,178.8978,1,0.0,46.0937,2304 +blur,m34,0.06,0.1202,,,10.6232,35.5393,0.0,0,3072,0.1257,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,25.3382,34.2195,0.0,35.7274,16.3555,20.609,1,0.0,35.6225,2304 +blur,m34,0.08,0.1108,,,7.5785,18.6909,0.0,0,3072,0.1096,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,8.3481,10.2748,0.0,35.7274,5.038,20.7101,1,0.0,51.566,2304 +blur,m34,0.1,,0,0,,10.4536,0.0,,3072,0.0973,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,4.0926,4.0603,0.0,35.7274,,,0,,,2304 +blur,m34,0.13,,0,0,,5.0631,0.0,,3072,0.0835,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,2.3774,1.4681,0.0,35.7274,,,0,,,2304 +blur,m34,0.16,,0,0,,2.7796,0.0,,3072,0.0735,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,1.9112,0.7405,0.0,35.7274,,,0,,,2304 +blur,m34,0.2,,0,0,,1.4629,0.0,,3072,0.0636,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,1.7712,0.4896,0.0,35.7274,,,0,,,2304 +blur,m34,0.25,,0,0,,0.7981,0.0,,3072,0.0539,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,1.6315,0.4179,0.0,35.7274,,,0,,,2304 +blur,m34,0.3,,0,0,,0.5127,0.0,,3072,0.046,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,1.6374,0.3905,0.0,35.7274,,,0,,,2304 +edge,m34,2.0,0.156,,,4.541,312.212,0.0,0,3072,0.1903,2.0068,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,746.8445,759.5292,0.0,35.7274,641.4853,943.3733,1,0.0,42.2374,737 +edge,m34,1.0,0.1634,,,1.9787,308.2453,0.0,0,3072,0.1903,0.9991,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,746.8445,759.5292,0.0,35.7274,633.3629,1209.3971,1,0.0,46.3578,701 +edge,m34,0.5,0.1657,,,1.5693,308.0767,0.0,0,3072,0.1903,0.4953,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,746.8445,759.5292,0.0,35.7274,654.3352,1249.0921,1,0.0,46.1794,683 +edge,m34,0.25,0.1667,,,1.2516,308.5652,0.0,0,3072,0.1903,0.2434,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,746.8445,759.5292,0.0,35.7274,687.4218,1652.9563,1,0.0,51.9147,674 +edge,m34,0.1,0.1639,,,1.4172,328.7744,0.0,0,3072,0.1928,0.1035,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,778.6806,812.7015,0.0,35.7274,636.8988,990.312,1,0.0,42.4019,669 +edge,m34,0.0,0.1672,,,1.2002,308.0159,0.0,0,3072,0.1941,0.0,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,795.6808,808.1199,0.0,35.7274,676.5712,1337.825,1,0.0,47.1025,665 +edge,m34,-0.5,0.1539,0,0,2.6005,302.9873,0.0,0,3072,0.1908,0.0,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,767.6637,783.8587,0.0,35.7274,618.7357,741.9275,0,0.0,38.9555,647 +edge,m34,-1.0,0.1654,0,0,0.4706,316.0351,0.0,0,3072,0.1865,0.0,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,739.8112,757.2917,0.0,35.7274,652.9889,1237.5531,0,0.0,46.1304,630 +edge,m34,-2.0,0.1631,0,0,1.6065,303.1295,0.0,0,3072,0.1735,0.0,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,589.7867,585.8558,0.0,35.7274,730.8057,529.3317,0,0.0,32.3529,594 +glare,m34,0.3,0.1489,,,7.8295,185.8965,0.0,0,3072,0.1489,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,463.5786,467.9555,0.0,35.7274,533.9311,1070.5614,1,0.0,47.6737,2304 +glare,m34,0.5,0.1432,,,7.8295,183.8923,0.0,0,3072,0.1236,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,314.3383,316.1848,0.0,35.7274,494.2117,980.6606,1,0.0,47.6737,2304 +glare,m34,0.7,0.14,,,7.8295,182.2182,0.0,0,3072,0.1016,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,200.0487,199.2385,0.0,35.7274,462.3473,908.0917,1,0.0,47.6737,2304 +glare,m34,0.85,0.1393,,,7.8295,181.1767,0.0,0,3072,0.0887,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,135.0368,133.6996,0.0017,35.7274,443.5794,865.1121,1,0.0006,47.6737,2304 +glare,m34,1.0,0.1405,0,1,7.7704,180.3199,0.0001,0,3072,0.0809,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,89.4733,87.6501,0.1403,35.7274,431.0853,860.4705,0,0.0278,48.0737,2304 +glare,m34,1.5,0.144,0,0,6.7643,178.4286,0.0006,0,3072,0.0557,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,23.7694,22.8928,0.5483,35.7274,431.8789,1093.1831,0,0.0909,55.1186,2304 +glare,m34,2.5,0.1488,0,0,7.434,176.5198,0.0012,0,3072,0.0224,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,1.9314,1.5486,0.8369,35.7274,473.2152,998.848,0,0.083,50.1718,2304 +glare,m34,4.0,0.1596,0,0,12.2585,174.9886,0.0018,0,3072,0.0028,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,0.0919,0.033,0.994,35.7274,680.589,405.2048,0,0.0016,30.5463,2304 +motion,m34,0.05,0.1569,,,7.8394,174.9274,0.0,0,3072,0.1853,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,569.793,627.0049,0.0,35.7274,425.2093,984.1636,1,0.0,47.6526,2304 +motion,m34,0.1,0.144,,,7.6181,134.1912,0.0,0,3072,0.1705,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,306.4266,358.8686,0.0,35.7274,179.2763,532.2401,1,0.0,49.0256,2304 +motion,m34,0.15,0.1364,,,7.7607,115.0422,0.0,0,3072,0.1627,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,231.0488,271.8663,0.0,35.7274,123.2554,333.9173,1,0.0,48.1284,2304 +motion,m34,0.2,0.1311,,,6.769,87.2017,0.0,0,3072,0.1495,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,153.9767,177.9372,0.0,35.7274,86.1768,301.547,1,0.0,54.4654,2304 +motion,m34,0.3,0.1213,,,9.4335,61.1085,0.0,0,3072,0.1336,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,101.6537,116.1989,0.0,35.7274,54.9055,83.7814,1,0.0,40.2326,2304 +motion,m34,0.4,0.1196,,,7.2545,51.0989,0.0,0,3072,0.1259,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,84.8768,97.2079,0.0,35.7274,47.5379,151.2542,1,0.0,52.633,2304 +motion,m34,0.5,0.1093,0,0,13.625,42.3532,0.0,0,3072,0.1189,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,68.4588,78.5537,0.0,35.7274,26.4287,28.8813,0,0.0,35.7209,2304 +motion,m34,0.7,,0,0,,33.6743,0.0,,3072,0.1134,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,56.4638,66.2376,0.0,35.7274,,,0,,,2304 +motion,m34,1.0,,0,0,,25.5926,0.0,,3072,0.1051,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,41.3288,49.1745,0.0,35.7274,,,0,,,2304 +scale,m34,32,0.1663,,,7.6114,174.1316,0.0,0,2752,0.1905,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,1047.7607,1047.7607,0.0,32.0058,911.0694,1447.3211,1,0.0,43.9391,2064 +scale,m34,26,0.1661,,,7.5965,175.4734,0.0,0,2236,0.1902,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,2033.7354,685.7316,0.0,26.0047,1693.929,1391.6268,1,0.0,35.7005,1677 +scale,m34,22,0.1647,,,7.6065,176.7027,0.0,0,1892,0.1907,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,3440.1045,631.3277,0.0,22.004,2608.7409,1308.8624,1,0.0,30.1942,1419 +scale,m34,18,0.164,,,7.5571,175.5219,0.0,0,1548,0.1893,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,5798.1389,536.9784,0.0,18.0032,4144.5512,1059.8569,1,0.0,24.8642,1161 +scale,m34,15,0.1734,,,10.9798,168.468,0.0,0,1290,0.1871,15.7393,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,8659.0324,424.64,0.0,15.0027,7070.0699,714.1625,1,0.0,18.4918,967 +scale,m34,12,0.1511,,,7.712,156.2077,0.0,0,1032,0.1815,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,12386.6891,295.8765,0.0,12.0022,6914.8201,469.2761,1,0.0,16.276,774 +scale,m34,10,0.1519,,,10.3839,387.3655,0.0,0,860,0.1741,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,15138.7052,189.8714,0.0,10.0018,9089.896,118.9893,1,0.0,10.0,645 +scale,m34,8,0.1384,,,8.8,447.1264,0.0,0,688,0.1609,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,15938.4187,66.7587,0.0,8.0014,6873.0642,88.2191,1,0.0,10.0,516 +scale,m34,6,,0,0,,552.9008,0.0,,516,0.1438,15.7474,0c15d866c50ec35202bfd5f6389ba80783c240524802bb23ee82412119174c03,13683.9751,28.0273,0.0,6.0011,,,0,,,387 +blur,m37,0.02,0.2063,,,9.5186,212.3052,0.0103,1,2852,0.2059,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,32.5731,820.2959,0.0005,87.043,32.5301,824.8516,1,0.0,87.0523,2432 +blur,m37,0.04,0.1689,,,9.4711,36.4887,0.0088,1,2852,0.169,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,5.5162,178.9865,0.0002,87.043,5.5312,179.2081,1,0.0,87.0523,2432 +blur,m37,0.06,0.1386,,,9.0405,9.6672,0.0077,1,2852,0.1391,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,2.3623,46.5264,0.0,87.043,2.3262,54.563,1,0.0,91.1977,2432 +blur,m37,0.08,0.1148,,,8.2174,3.5099,0.0067,1,2852,0.1162,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1.7533,14.8576,0.0,87.043,1.6782,23.3111,1,0.0,100.261,2432 +blur,m37,0.1,0.0976,,,8.6475,1.6354,0.0057,1,2852,0.0983,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1.634,5.63,0.0,87.043,1.5833,7.4398,1,0.0,95.343,2432 +blur,m37,0.13,,0,0,,0.7309,0.0041,,2852,0.0777,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1.5659,1.8121,0.0,87.043,,,0,,,2432 +blur,m37,0.16,,0,0,,0.4259,0.0028,,2852,0.0625,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1.5264,0.721,0.0,87.043,,,0,,,2432 +blur,m37,0.2,,0,0,,0.2643,0.0017,,2852,0.0492,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1.4348,0.2628,0.0,87.043,,,0,,,2432 +blur,m37,0.25,,0,0,,0.1844,0.0007,,2852,0.0405,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1.1617,0.1441,0.0,87.043,,,0,,,2432 +blur,m37,0.3,,0,0,,0.1534,0.0,,2852,0.0365,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,0.8313,0.152,0.0,87.043,,,0,,,2432 +edge,m37,2.0,0.2397,,,2.0467,879.6909,0.0102,1,2852,0.2395,2.0013,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1110.5286,2804.6348,0.0046,87.043,1111.378,2869.6802,1,0.0041,87.0523,1703 +edge,m37,1.0,0.2395,,,1.0948,860.3795,0.0089,1,2852,0.2395,1.0018,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1110.5286,2804.6348,0.0046,87.043,1112.9598,2903.1558,1,0.004,87.0523,1616 +edge,m37,0.5,0.2401,,,0.5718,856.4977,0.0082,1,2852,0.2395,0.4963,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1110.5286,2804.6348,0.0046,87.043,1116.5216,2911.7251,1,0.004,87.0523,1572 +edge,m37,0.25,0.2399,,,0.3801,848.8481,0.0076,1,2852,0.2395,0.255,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1110.5286,2804.6348,0.0046,87.043,1117.6867,2884.4642,1,0.004,87.0523,1551 +edge,m37,0.1,0.2404,,,0.1905,847.905,0.0073,1,2852,0.2404,0.1057,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1127.1237,2853.9919,0.0039,87.043,1123.0923,2873.6424,1,0.004,87.0523,1538 +edge,m37,0.0,0.2412,,,0.0476,846.6804,0.0072,1,2852,0.2412,0.0023,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1139.369,2940.8409,0.0039,87.043,1135.2733,2961.5339,1,0.004,87.0523,1529 +edge,m37,-0.5,0.2406,0,0,0.3493,831.5956,0.0065,0,2852,0.2402,0.0,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1148.5544,2944.0451,0.0033,87.043,1161.4228,2855.5504,0,0.0032,82.907,1485 +edge,m37,-1.0,0.2382,0,0,0.7365,819.0756,0.0059,0,2852,0.2422,0.0,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1183.8031,3032.6394,0.003,87.043,1091.4386,2673.3407,0,0.0014,78.7616,1442 +edge,m37,-2.0,0.2378,0,0,0.4735,772.881,0.0047,0,2852,0.2392,0.0,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,1205.0755,3002.2727,0.0019,87.043,1102.4044,2666.9655,0,0.0009,78.7616,1355 +glare,m37,0.3,0.1873,,,9.5186,720.553,0.0133,1,2852,0.1872,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,670.9809,1717.9394,0.0062,87.043,669.3615,1758.6449,1,0.0062,87.0523,2432 +glare,m37,0.5,0.1548,,,9.5186,684.1315,0.0133,1,2852,0.1544,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,444.2672,1155.3602,0.0087,87.043,445.6591,1167.4701,1,0.0081,87.0523,2432 +glare,m37,0.7,0.1254,,,9.5186,653.7556,0.0135,1,2852,0.1249,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,271.8635,722.2258,0.0162,87.043,272.9715,728.8666,1,0.0156,87.0523,2432 +glare,m37,0.85,0.107,,,9.5186,634.906,0.0141,1,2852,0.1066,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,177.9171,482.1756,0.0405,87.043,178.7929,485.9899,1,0.0402,87.0523,2432 +glare,m37,1.0,0.0939,0,0,9.5186,619.4789,0.0208,0,2852,0.0936,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,113.9424,314.8057,0.3323,87.043,114.5568,317.7809,0,0.3334,87.0523,2432 +glare,m37,1.5,0.2613,0,0,5.0606,586.4855,0.043,0,2852,0.0536,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,29.5144,78.7974,0.6858,87.043,1125.1147,6170.56,0,0.0058,136.7965,2432 +glare,m37,2.5,0.2611,0,0,5.0606,552.2094,0.0698,0,2852,0.008,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,0.5577,0.6845,0.9711,87.043,1122.2378,6152.7353,0,0.0058,136.7965,2432 +glare,m37,4.0,0.2609,0,0,5.0606,523.3214,0.0936,0,2852,0.0,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,0.0,0.0,1.0,87.043,1118.2523,6131.8202,0,0.0058,136.7965,2432 +motion,m37,0.05,0.2291,,,9.5186,566.7482,0.0119,1,2852,0.2291,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,463.5226,2034.9097,0.0028,87.043,460.9216,2055.598,1,0.0028,87.0523,2432 +motion,m37,0.1,0.2101,,,9.4711,293.4724,0.0106,1,2852,0.2105,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,287.684,1188.4246,0.0018,87.043,285.9804,1196.6787,1,0.0013,87.0523,2432 +motion,m37,0.15,0.1958,,,9.4711,215.6229,0.01,1,2852,0.1963,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,239.9721,919.8486,0.0013,87.043,238.0917,920.6713,1,0.0013,87.0523,2432 +motion,m37,0.2,0.1834,,,9.4235,175.5693,0.0096,1,2852,0.1843,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,213.3251,783.8599,0.0009,87.043,211.0791,781.8512,1,0.0009,87.0523,2432 +motion,m37,0.3,0.1634,,,9.4235,130.9613,0.0089,1,2852,0.1641,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,180.1616,639.6969,0.0004,87.043,178.2041,638.4802,1,0.0004,87.0523,2432 +motion,m37,0.4,0.1421,,,8.3186,109.0278,0.0082,0,2852,0.1511,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,161.4772,566.5509,0.0001,87.043,122.8297,390.4668,1,0.0001,83.1744,2432 +motion,m37,0.5,0.1275,0,0,9.2769,95.6679,0.0077,0,2852,0.141,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,147.967,516.9062,0.0001,87.043,55.8428,216.7041,0,0.0,75.0291,2432 +motion,m37,0.7,0.1275,0,0,7.3438,80.2964,0.0065,0,2852,0.1295,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,130.26,463.4415,0.0001,87.043,104.3245,486.888,0,0.0,95.343,2432 +motion,m37,1.0,,0,0,,66.3003,0.0049,,2852,0.1253,9.5198,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,112.6108,426.8854,0.0,87.043,,,0,,,2432 +scale,m37,48,0.2372,,,9.992,797.3737,0.0129,1,1573,0.2377,9.5173,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,3361.3321,2831.8463,0.0047,48.008,3343.84,2711.2182,1,0.0056,45.7267,1341 +scale,m37,40,0.2362,,,9.5072,775.6386,0.0127,1,1311,0.2361,9.5203,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,4329.9598,2709.6296,0.0027,40.0117,4353.517,2743.8822,1,0.0022,39.9695,1118 +scale,m37,32,0.2335,,,9.5,730.0349,0.0122,1,1048,0.2339,9.5233,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,5442.5947,5442.5947,0.0015,31.985,5424.2322,5424.2322,1,0.0015,32.0,894 +scale,m37,26,0.2277,,,8.7857,1838.2756,0.0118,1,852,0.2307,9.5128,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,6207.1436,1687.9651,0.0012,26.003,6558.2149,2141.3145,1,0.0013,28.0,726 +scale,m37,22,0.2269,,,9.4523,2107.3488,0.0117,1,721,0.228,9.5225,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,7316.3931,1268.1884,0.0018,22.0049,7375.7104,1284.7833,1,0.0011,21.9695,615 +scale,m37,18,0.2162,,,8.4831,2432.4258,0.0114,1,590,0.2243,9.5176,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,9980.0546,945.8063,0.0017,18.0068,9181.6267,1194.0345,1,0.0009,20.0,503 +scale,m37,15,0.1868,,,4.4618,2723.9879,0.0111,0,491,0.2195,9.5268,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,13140.0429,655.8049,0.001,14.9853,9564.4463,2620.7029,1,0.0069,26.3762,419 +scale,m37,12,0.2024,,,8.1393,3051.4809,0.0108,1,393,0.2109,9.5162,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,16977.1856,402.8826,0.0027,11.9944,15375.0787,601.398,1,0.0036,13.9645,335 +scale,m37,10,0.1908,,,7.8054,3251.2273,0.0105,1,328,0.1996,9.4961,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,19607.3985,258.8806,0.0039,10.0106,16103.0453,375.0672,1,0.0128,12.0,279 +scale,m37,8,0.1575,,,4.5714,3428.662,0.0102,0,262,0.1853,9.5446,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,21519.7156,99.7577,0.0072,7.9962,11638.5161,441.9796,1,0.007,14.0,224 +scale,m37,6,0.1706,0,1,1.9096,3573.6726,0.0097,0,197,0.1601,9.5204,7e20da436d9dd9b2fe7a4d8e49d70bb8a7233455d65ecd83ecc5291001c97459,20849.8742,46.0168,0.0,6.0124,7401.1965,806.9388,0,0.0024,19.899,168 +blur,m38,0.02,0.2404,,,3.0803,24.8935,0.0407,0,1056,0.218,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,50.3985,777.742,0.0,77.1091,78.9456,440.6022,1,0.0,53.0679,1056 +blur,m38,0.04,0.2016,,,2.9829,2.4639,0.035,0,1056,0.1883,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,7.7275,146.5973,0.0,77.1091,10.6193,66.4029,1,0.0,54.5521,1056 +blur,m38,0.06,0.1666,1,1,2.6923,0.9182,0.0309,0,1056,0.1662,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3.3482,39.1454,0.0,77.1091,3.346,40.9572,0,0.0,78.0,1056 +blur,m38,0.08,0.149,1,1,2.5072,0.6035,0.0271,0,1056,0.1501,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,2.2868,12.4931,0.0,77.1091,2.2905,16.3186,0,0.0,82.5601,1056 +blur,m38,0.1,0.1353,1,0,2.7541,0.5028,0.0239,0,1056,0.138,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,1.9275,4.6921,0.0,77.1091,1.9528,5.0999,0,0.0,76.345,1056 +blur,m38,0.13,,0,0,,0.4473,0.0197,,1056,0.1243,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,1.7978,1.4892,0.0,77.1091,,,0,,,1056 +blur,m38,0.16,,0,0,,0.4271,0.0154,,1056,0.1137,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,1.7082,0.6622,0.0,77.1091,,,0,,,1056 +blur,m38,0.2,,0,0,,0.411,0.0108,,1056,0.1028,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,1.6709,0.3114,0.0,77.1091,,,0,,,1056 +blur,m38,0.25,,0,0,,0.3996,0.0076,,1056,0.0921,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,1.5006,0.2097,0.0,77.1091,,,0,,,1056 +blur,m38,0.3,,0,0,,0.3913,0.0044,,1056,0.0827,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,1.4714,0.1602,0.0,77.1091,,,0,,,1056 +edge,m38,2.0,0.2813,,,2.16,867.8591,0.0481,0,1056,0.251,1.995,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3232.3501,2872.8585,0.0,77.1091,3007.6239,2442.5892,1,0.0,50.0,798 +edge,m38,1.0,0.2818,0,0,0.5992,846.8301,0.0432,0,1056,0.251,0.9964,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3232.3501,2872.8585,0.0,77.1091,3038.4658,2444.6065,0,0.0,50.0,721 +edge,m38,0.5,0.2837,0,0,0.0416,841.9204,0.0371,0,1056,0.251,0.5036,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3232.3501,2872.8585,0.0,77.1091,3086.8604,2343.4521,0,0.0,48.0,683 +edge,m38,0.25,0.2832,0,0,0.1569,849.2922,0.0348,0,1056,0.251,0.2442,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3232.3501,2872.8585,0.0,77.1091,3040.9435,2461.5258,0,0.0,50.5781,663 +edge,m38,0.1,0.2855,,,0.0383,829.6892,0.0339,0,1056,0.2497,0.1015,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3325.0386,2944.3635,0.0,77.1091,3101.0035,2520.6654,1,0.0,50.6827,652 +edge,m38,0.0,0.2859,,,0.0369,828.4388,0.0332,0,1056,0.2488,0.0,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3374.5821,2959.1329,0.0,77.1091,3079.8874,2501.0913,1,0.0,51.2084,644 +edge,m38,-0.5,0.2402,0,0,0.0263,820.5834,0.0296,0,1056,0.2414,0.0,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3244.7249,2837.3352,0.0,77.1091,3254.3633,2829.0714,0,0.0,76.0,606 +edge,m38,-1.0,0.2314,0,0,0.0182,812.9355,0.0271,0,1056,0.2347,0.0,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3253.2248,2834.0811,0.0,77.1091,3225.2233,2740.5077,0,0.0,75.2296,567 +edge,m38,-2.0,,0,0,,700.4446,0.0241,,1056,0.2225,0.0,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3037.6098,2669.6408,0.0,77.1091,,,0,,,490 +glare,m38,0.3,0.2351,,,3.32,716.6214,0.0482,0,1056,0.198,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,2053.2982,1822.9581,0.0,77.1091,2180.9671,1783.0063,1,0.0,50.0,1056 +glare,m38,0.5,0.2092,0,0,3.32,690.018,0.0482,0,1056,0.1691,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,1431.8345,1272.9465,0.0,77.1091,1721.0835,1415.3654,0,0.0,50.0,1056 +glare,m38,0.7,0.1875,0,0,3.32,666.9119,0.0482,0,1056,0.1492,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,946.4445,841.1917,0.0,77.1091,1345.4194,1100.6609,0,0.0,50.0,1056 +glare,m38,0.85,0.1788,0,0,3.4583,651.9799,0.0482,0,1056,0.1424,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,670.1628,595.8719,0.0,77.1091,1127.1171,862.0907,0,0.0,48.0,1056 +glare,m38,1.0,0.1716,0,0,3.32,639.0243,0.0498,0,1056,0.1437,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,468.6638,416.9473,0.0495,77.1091,904.7345,744.7302,0,0.0023,50.0,1056 +glare,m38,1.5,,0,0,,608.0218,0.0709,,1056,0.1122,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,161.0922,141.6468,0.5358,77.1091,,,0,,,1056 +glare,m38,2.5,,0,0,,572.418,0.0944,,1056,0.0432,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,15.8432,11.836,0.8201,77.1091,,,0,,,1056 +glare,m38,4.0,,0,0,,541.0298,0.1156,,1056,0.0023,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,0.0383,0.0479,0.996,77.1091,,,0,,,1056 +motion,m38,0.05,0.2636,,,3.32,335.743,0.0453,0,1056,0.2354,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,769.0542,1769.0829,0.0,77.1091,787.6859,1140.2839,1,0.0,50.0,1056 +motion,m38,0.1,0.2442,,,2.6429,241.3004,0.0416,0,1056,0.219,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,422.5493,927.2473,0.0,77.1091,365.5074,725.1264,1,0.0,61.6,1056 +motion,m38,0.15,0.2232,,,3.4583,209.9353,0.0393,0,1056,0.2058,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,310.6168,668.8679,0.0,77.1091,336.0276,373.5468,1,0.0,48.0,1056 +motion,m38,0.2,0.1972,,,2.7632,196.3464,0.0377,1,1056,0.1977,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,262.9734,567.8837,0.0,77.1091,266.6528,563.6148,1,0.0,76.0,1056 +motion,m38,0.3,0.1806,,,2.8235,173.8954,0.0348,1,1056,0.1812,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,193.8389,419.577,0.0,77.1091,197.7292,392.6063,1,0.0,74.8,1056 +motion,m38,0.4,,0,0,,159.7604,0.0322,,1056,0.1714,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,157.6116,349.87,0.0,77.1091,,,0,,,1056 +motion,m38,0.5,,0,0,,149.2172,0.0302,,1056,0.165,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,135.5071,306.8421,0.0,77.1091,,,0,,,1056 +motion,m38,0.7,,0,0,,134.6268,0.0264,,1056,0.1575,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,110.1795,260.2968,0.0,77.1091,,,0,,,1056 +motion,m38,1.0,,0,0,,118.6456,0.0217,,1056,0.1514,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,88.896,223.9924,0.0,77.1091,,,0,,,1056 +scale,m38,48,0.2782,0,0,3.25,2120.4753,0.0481,0,657,0.2456,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,3821.1337,2717.6707,0.0,47.9741,4496.5697,4496.5697,0,0.0,31.9514,657 +scale,m38,40,0.2744,,,3.0714,2524.3122,0.048,0,548,0.2437,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,4369.2698,2682.0049,0.0,40.015,5287.5389,1895.0081,1,0.0,28.0,548 +scale,m38,32,0.2721,0,0,3.0909,3022.8246,0.0472,0,438,0.2409,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,5184.8126,5184.8126,0.0,31.9828,7495.3242,1332.9297,0,0.0,22.0,438 +scale,m38,26,0.2696,0,0,3.0,3339.4177,0.0468,0,356,0.2391,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,6395.5362,1762.8238,0.0,25.9951,10306.5922,945.7414,0,0.0,18.0,356 +scale,m38,22,0.2669,,,2.875,3580.7847,0.0461,0,301,0.2351,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,7443.956,1225.3105,0.0,21.979,13080.9273,729.725,1,0.0,15.947,301 +scale,m38,18,0.2647,,,2.6807,3745.9977,0.0454,0,247,0.2322,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,9199.7404,846.8842,0.0,18.0359,15391.2956,629.1713,1,0.0,14.0907,247 +scale,m38,15,0.2522,,,3.2,3763.2235,0.0446,0,205,0.2279,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,10285.5767,533.5897,0.0,14.9691,19845.8134,272.4614,1,0.0,9.9515,205 +scale,m38,12,,0,0,,3851.4789,0.0428,,164,0.2194,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,12590.7229,308.1059,0.0,11.9753,,,0,,,164 +scale,m38,10,0.2454,0,0,1.375,4097.3442,0.0402,0,137,0.2135,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,14942.1155,202.7705,0.0,10.0037,14630.8755,785.0763,0,0.0,15.8841,137 +scale,m38,8,0.2761,0,0,0.7778,4359.4736,0.0396,0,110,0.198,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,14845.7752,74.7943,0.0,8.0322,8616.4823,735.9277,0,0.0,18.0,110 +scale,m38,6,0.1972,0,0,1.4,4976.573,0.0375,0,82,0.1851,2.7421,dd2ebd9ab40ce6fa9f73aa01e0eb0fe3560586b11926466ebf5e011579dfec89,14576.3598,36.8381,0.0,5.9876,10253.8329,139.1327,0,0.0,10.0,82 +blur,m39,0.02,0.2023,,,9.5278,52.58,0.0032,1,3145,0.1803,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,10.7143,518.4293,0.0,102.9988,14.2253,822.4945,1,0.0,107.1899,3316 +blur,m39,0.04,0.157,,,10.2929,11.8496,0.0018,1,3145,0.1518,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,3.1752,132.6175,0.0,102.9988,4.2453,311.5463,1,0.0,114.907,3316 +blur,m39,0.06,0.1279,,,11.0475,3.6058,0.001,1,3145,0.13,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,1.9142,34.4221,0.0,102.9988,2.2679,65.4159,1,0.0,107.3639,3316 +blur,m39,0.08,0.1088,,,10.1234,1.4165,0.0006,1,3145,0.116,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,1.62,9.1971,0.0,102.9988,1.736,20.5312,1,0.0,116.7407,3316 +blur,m39,0.1,0.0904,0,0,10.6957,0.6964,0.0003,1,3145,0.1071,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,1.558,2.9235,0.0,102.9988,1.7139,5.5984,0,0.0,110.7733,3316 +blur,m39,0.13,,0,0,,0.3339,0.0,,3145,0.0982,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,1.5473,0.775,0.0,102.9988,,,0,,,3316 +blur,m39,0.16,0.0803,0,0,7.5453,0.2124,0.0,0,3145,0.092,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,1.4704,0.3336,0.0,102.9988,1.3872,1.1953,0,0.0,164.1653,3316 +blur,m39,0.2,,0,0,,0.1534,0.0,,3145,0.0853,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,1.3394,0.1959,0.0,102.9988,,,0,,,3316 +blur,m39,0.25,,0,0,,0.1285,0.0,,3145,0.0776,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,1.302,0.1638,0.0,102.9988,,,0,,,3316 +blur,m39,0.3,,0,0,,0.1204,0.0,,3145,0.0703,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,1.2874,0.1551,0.0,102.9988,,,0,,,3316 +edge,m39,2.0,0.2426,,,7.4949,176.5209,0.0029,1,3145,0.2004,1.998,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,102.5742,1287.0675,0.0,102.9988,87.034,720.2024,1,0.0,77.9752,2187 +edge,m39,1.0,0.2304,,,4.8012,175.7322,0.003,1,3145,0.2004,0.998,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,102.5742,1287.0675,0.0,102.9988,133.9795,1541.6023,1,0.0,98.8511,2084 +edge,m39,0.5,0.2301,,,4.1222,176.8061,0.0031,1,3145,0.2004,0.5028,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,102.5742,1287.0675,0.0,102.9988,147.5728,1768.3791,1,0.0,103.0184,2033 +edge,m39,0.25,0.2289,,,3.7771,177.819,0.0031,1,3145,0.2004,0.2504,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,102.5742,1287.0675,0.0,102.9988,152.1572,1867.3495,1,0.0,105.2014,2007 +edge,m39,0.1,0.2308,,,3.8333,178.6453,0.0032,1,3145,0.2019,0.1048,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,103.8981,1313.6931,0.0,102.9988,134.1204,1580.808,1,0.0,99.9259,1992 +edge,m39,0.0,0.2302,,,3.7231,178.6922,0.0032,1,3145,0.203,0.0,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,104.8999,1319.6928,0.0,102.9988,137.072,1630.241,1,0.0,100.329,1981 +edge,m39,-0.5,0.2301,,,3.0472,181.1548,0.0033,1,3145,0.2083,0.0,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,109.4263,1388.3905,0.0,102.9988,143.2307,1959.5519,1,0.0,104.8736,1930 +edge,m39,-1.0,0.2296,,,2.6217,182.7603,0.0034,1,3145,0.213,0.0,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,112.1217,1428.2322,0.0,102.9988,136.9071,1811.1678,1,0.0,102.7236,1878 +edge,m39,-2.0,0.2321,,,1.6029,185.7657,0.0034,1,3145,0.2209,0.0,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,119.7021,1538.8227,0.0,102.9988,139.7226,1874.3801,1,0.0,102.326,1775 +glare,m39,0.3,0.1698,,,12.1616,122.4299,0.005,1,3145,0.1556,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,63.7566,757.6781,0.0,102.9988,75.3009,908.084,1,0.0,97.758,3316 +glare,m39,0.5,0.1258,,,12.1428,108.4252,0.0051,1,3145,0.1294,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,42.8867,489.7704,0.0,102.9988,42.7587,492.3496,1,0.0,97.9035,3316 +glare,m39,0.7,0.0826,,,12.015,96.9544,0.0055,1,3145,0.1088,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,27.0402,290.0638,0.0,102.9988,19.7079,217.9464,1,0.0,98.9034,3316 +glare,m39,0.85,0.0526,,,11.9922,90.0718,0.0063,1,3145,0.0991,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,18.4029,184.9953,0.0239,102.9988,8.7941,81.2022,1,0.0614,99.0834,3316 +glare,m39,1.0,0.1005,0,0,11.7775,84.7472,0.0133,0,3145,0.0962,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,12.4581,118.2464,0.2866,102.9988,10.4387,81.9078,0,0.3953,87.1668,3316 +glare,m39,1.5,0.0723,0,0,10.0973,74.2554,0.0397,0,3145,0.0653,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,4.02,36.4034,0.6644,102.9988,15.0211,149.1921,0,0.1116,88.6234,3316 +glare,m39,2.5,0.0521,0,0,13.4286,63.6562,0.0729,0,3145,0.0117,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,0.2199,0.6004,0.9351,102.9988,8.0011,41.5643,0,0.3002,67.4273,3316 +glare,m39,4.0,,0,0,,55.935,0.1057,,3145,0.0,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,0.0,0.0,1.0,102.9988,,,0,,,3316 +motion,m39,0.05,0.2192,,,11.2404,102.2386,0.0043,1,3145,0.1915,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,28.3142,865.0973,0.0,102.9988,38.1807,1406.1709,1,0.0,105.6338,3316 +motion,m39,0.1,0.1984,,,11.4544,56.0415,0.0035,1,3145,0.1763,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,13.1398,432.0804,0.0,102.9988,17.7851,708.314,1,0.0,103.7422,3316 +motion,m39,0.15,0.1756,,,11.1818,38.547,0.0028,1,3145,0.1621,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,8.8156,258.075,0.0,102.9988,11.2588,432.4482,1,0.0,105.9571,3316 +motion,m39,0.2,0.1562,,,11.5132,29.0814,0.0022,1,3145,0.1491,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,6.7357,173.0154,0.0,102.9988,8.4582,263.8855,1,0.0,103.2349,3316 +motion,m39,0.3,0.1377,0,0,11.5057,22.0729,0.0016,0,3145,0.1362,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,5.1446,122.2977,0.0,102.9988,6.0306,169.4208,0,0.0,103.0984,3316 +motion,m39,0.4,0.1114,0,0,10.0766,18.5642,0.0012,0,3145,0.1281,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,4.3373,97.9402,0.0,102.9988,4.3853,66.8304,0,0.0,88.787,3316 +motion,m39,0.5,0.0677,0,0,11.6875,16.4477,0.001,0,3145,0.1229,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,3.8573,84.2633,0.0,102.9988,4.0013,42.6392,0,0.0,77.0598,3316 +motion,m39,0.7,,0,0,,13.8733,0.0006,,3145,0.1162,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,3.4641,72.9689,0.0,102.9988,,,0,,,3316 +motion,m39,1.0,,0,0,,11.837,0.0004,,3145,0.1069,9.9671,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,3.0468,61.8521,0.0,102.9988,,,0,,,3316 +scale,m39,48,0.2267,,,10.6591,150.8458,0.0049,1,1466,0.2005,9.9625,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,841.6692,1301.111,0.0,48.0115,1221.0429,2266.7202,1,0.0,51.9054,1545 +scale,m39,40,0.2302,,,10.8199,148.7647,0.0049,1,1221,0.1998,9.9719,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,1256.7426,1278.8271,0.0,39.9877,1945.2194,2267.0585,1,0.0,42.3687,1288 +scale,m39,32,0.22,,,9.3821,147.9595,0.0048,1,977,0.1998,9.9659,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,2088.5509,2088.5509,0.0,31.9968,3307.5983,2833.59,1,0.0,38.8089,1030 +scale,m39,26,0.2203,,,10.376,359.4337,0.0047,1,794,0.198,9.9651,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,3050.8394,983.1985,0.0,26.0035,4404.7443,1993.6965,1,0.0,28.871,837 +scale,m39,22,0.2163,,,9.4718,462.4262,0.0046,1,672,0.1966,9.9595,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,4217.7765,791.8858,0.0,22.008,6369.7161,2136.774,1,0.0,26.5751,708 +scale,m39,18,0.2192,,,9.9698,606.2512,0.0044,1,550,0.1952,9.9688,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,6177.1237,625.537,0.0,18.0125,9679.0914,1466.3268,1,0.0,20.6308,580 +scale,m39,15,0.2106,,,9.4444,760.3327,0.004,1,458,0.1912,9.9691,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,8456.9269,474.0106,0.0,14.9995,12937.5998,1250.6339,1,0.0,18.0,483 +scale,m39,12,0.1819,,,7.5556,964.2466,0.0036,1,366,0.1849,9.9697,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,11851.6741,296.4103,0.0009,11.9865,13623.2346,1249.49,1,0.0007,18.0,386 +scale,m39,10,0.1397,0,0,7.1344,1125.0262,0.003,0,305,0.1768,9.98,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,14046.0115,193.201,0.0007,9.9887,4062.1748,439.4382,0,0.0,17.9412,322 +scale,m39,8,,0,0,,1251.6106,0.0027,,244,0.1646,9.9955,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,15725.6042,72.9526,0.0011,7.991,,,0,,,258 +scale,m39,6,,0,0,,1369.9798,0.0024,,183,0.1398,9.9697,70e5e150cb232f695c9ad747f8043d06c86c07ffb9070a3e2e7b53d14a6a2e6e,11254.1589,23.415,0.0,5.9933,,,0,,,193 +blur,m40,0.02,0.1993,,,12.037,126.1404,0.0,0,1820,0.2065,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,258.4493,304.3076,0.0,37.037,366.4646,150.699,1,0.0,27.0,1820 +blur,m40,0.04,0.1785,,,11.0,45.2083,0.0,1,1820,0.1836,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,66.9339,104.0686,0.0,37.037,63.3717,202.8766,1,0.0,44.9709,1820 +blur,m40,0.06,0.1567,,,11.0,16.1425,0.0,1,1820,0.1597,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,27.8814,43.2673,0.0,37.037,24.5625,80.2082,1,0.0,44.9709,1820 +blur,m40,0.08,0.139,,,10.3333,6.633,0.0,1,1820,0.139,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,11.6236,16.8265,0.0,37.037,9.69,36.3958,1,0.0,47.6163,1820 +blur,m40,0.1,0.1244,,,11.0,3.1238,0.0,1,1820,0.1227,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,5.4488,6.5651,0.0,37.037,4.7734,11.6273,1,0.0,44.9709,1820 +blur,m40,0.13,0.1105,,,10.3889,1.3316,0.0,1,1820,0.1049,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,2.803,2.1212,0.0,37.037,2.5372,4.0484,1,0.0,47.6163,1820 +blur,m40,0.16,0.0988,0,0,11.0588,0.7254,0.0,0,1820,0.0918,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,2.0831,1.025,0.0,37.037,1.9902,1.5236,0,0.0,44.9709,1820 +blur,m40,0.2,,0,0,,0.4204,0.0,,1820,0.0791,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,1.7771,0.5719,0.0,37.037,,,0,,,1820 +blur,m40,0.25,,0,0,,0.2828,0.0,,1820,0.0678,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,1.6413,0.4259,0.0,37.037,,,0,,,1820 +blur,m40,0.3,,0,0,,0.2281,0.0,,1820,0.0596,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,1.5298,0.3775,0.0,37.037,,,0,,,1820 +edge,m40,2.0,0.213,,,1.8121,214.9844,0.0,1,1820,0.218,1.9923,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,733.9421,626.1387,0.0,37.037,705.3901,830.8475,1,0.0,42.3256,1124 +edge,m40,1.0,0.2152,,,0.9974,213.3302,0.0,1,1820,0.218,0.9933,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,733.9421,626.1387,0.0,37.037,741.4511,773.925,1,0.0,39.6802,1087 +edge,m40,0.5,0.2183,,,0.6414,212.4084,0.0,1,1820,0.218,0.5073,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,733.9421,626.1387,0.0,37.037,737.837,629.9024,1,0.0,37.0349,1069 +edge,m40,0.25,0.2128,,,0.2496,211.3623,0.0,1,1820,0.218,0.2373,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,733.9421,626.1387,0.0,37.037,702.277,825.8394,1,0.0,42.3256,1059 +edge,m40,0.1,0.2135,,,0.1872,211.9307,0.0,1,1820,0.2187,0.1023,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,744.3089,635.1322,0.0,37.037,711.2568,837.2586,1,0.0,42.3256,1054 +edge,m40,0.0,0.2141,,,0.0625,210.8788,0.0,1,1820,0.2193,0.0,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,753.2436,641.8716,0.0,37.037,720.2787,848.9778,1,0.0,42.3256,1050 +edge,m40,-0.5,0.2157,,,0.0667,209.0801,0.0,0,1820,0.2198,0.0,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,755.7564,641.9769,0.0,37.037,714.9074,715.3816,1,0.0,39.6802,1032 +edge,m40,-1.0,0.2128,,,0.0623,205.6449,0.0,0,1820,0.2203,0.0,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,760.909,644.8794,0.0,37.037,686.5836,802.3372,1,0.0,42.3256,1013 +edge,m40,-2.0,0.213,,,0.1217,195.4269,0.0,0,1820,0.2192,0.0,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,736.4531,627.1894,0.0,37.037,693.6365,857.4329,1,0.0,42.9916,976 +glare,m40,0.3,0.2014,,,12.037,173.2732,0.0,0,1820,0.1708,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,451.5934,384.4785,0.0,37.037,866.4392,305.7315,1,0.0,27.0,1820 +glare,m40,0.5,0.1932,,,12.037,163.4813,0.0,0,1820,0.1442,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,303.6349,258.1252,0.0,37.037,792.0536,279.118,1,0.0,27.0,1820 +glare,m40,0.7,0.1853,,,12.037,155.2575,0.0,0,1820,0.1245,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,189.2773,160.7715,0.0,37.037,718.5516,253.3994,1,0.0,27.0,1820 +glare,m40,0.85,0.1795,,,12.037,150.0914,0.0,0,1820,0.1163,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,126.0175,106.8042,0.0,37.037,667.196,235.3061,1,0.0,27.0,1820 +glare,m40,1.0,0.1739,,,12.037,145.7515,0.0014,0,1820,0.1153,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,81.853,69.4089,0.214,37.037,617.7215,217.7787,1,0.0,27.0,1820 +glare,m40,1.5,0.1567,0,0,12.037,136.2732,0.0108,0,1820,0.0746,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,19.1066,16.0103,0.6309,37.037,466.6744,164.2965,0,0.0,27.0,1820 +glare,m40,2.5,0.1319,0,0,12.037,126.825,0.0229,0,1820,0.0118,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,0.2298,0.0935,0.9233,37.037,237.5566,83.4225,0,0.0,27.0,1820 +glare,m40,4.0,0.1637,0,0,13.0909,119.7178,0.034,0,1820,0.0,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,0.0,0.0,1.0,37.037,617.2764,299.1067,0,0.0,29.0988,1820 +motion,m40,0.05,0.2119,,,12.037,174.7406,0.0,0,1820,0.2158,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,604.6854,566.9864,0.0,37.037,843.1718,308.4567,1,0.0,27.0,1820 +motion,m40,0.1,0.205,,,12.037,135.1429,0.0,0,1820,0.2085,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,471.5747,460.1724,0.0,37.037,672.9847,251.0095,1,0.0,27.0,1820 +motion,m40,0.15,0.1926,,,11.6875,105.2535,0.0,1,1820,0.1989,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,383.5392,375.0479,0.0,37.037,349.7247,495.5714,1,0.0,42.3256,1820 +motion,m40,0.2,0.1897,,,12.5333,95.3449,0.0,1,1820,0.1938,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,349.6744,340.5251,0.0,37.037,336.6918,414.4589,1,0.0,39.6802,1820 +motion,m40,0.3,0.172,,,11.75,74.1488,0.0,1,1820,0.1756,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,257.6285,246.5491,0.0,37.037,239.7445,324.9617,1,0.0,42.3256,1820 +motion,m40,0.4,0.1595,1,0,10.3889,63.7952,0.0,0,1820,0.1633,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,207.633,197.5973,0.0,37.037,170.7107,293.6992,0,0.0,47.6163,1820 +motion,m40,0.5,0.1524,0,0,11.0,57.9013,0.0,0,1820,0.1549,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,176.7793,168.259,0.0,37.037,156.9379,242.5198,0,0.0,44.9709,1820 +motion,m40,0.7,0.1234,0,0,7.9412,51.4388,0.0,0,1820,0.1456,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,146.1257,138.7301,0.0,37.037,112.1522,174.645,0,0.0,44.9709,1820 +motion,m40,1.0,0.1438,0,0,11.1176,45.9986,0.0,0,1820,0.1402,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,129.8653,124.226,0.0,37.037,131.4714,197.5743,0,0.0,44.9709,1820 +scale,m40,32,0.2168,,,13.4762,192.3327,0.0,0,1572,0.2174,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,993.4869,993.4869,0.0,31.9902,1429.5297,237.6397,1,0.0,21.0,1572 +scale,m40,26,0.2151,,,12.6667,190.1648,0.0,0,1278,0.217,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,1602.385,519.6414,0.0,26.0073,2361.5187,244.3675,1,0.0,18.0,1278 +scale,m40,22,0.2091,,,9.5,186.5995,0.0,0,1081,0.2168,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,2251.7069,437.0147,0.0,21.9983,2844.4829,386.3345,1,0.0,19.9815,1081 +scale,m40,18,0.2137,,,13.3333,417.9162,0.0,0,885,0.2159,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,3242.37,315.1694,0.0,18.0098,5232.8775,152.335,1,0.0,11.9865,885 +scale,m40,15,0.2076,,,11.0,586.1953,0.0,0,737,0.2159,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,5061.1361,275.579,0.0,14.9979,7331.1999,202.3626,1,0.0,11.9837,737 +scale,m40,12,0.2134,,,13.5,816.9559,0.0,1,590,0.2134,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,9253.3837,229.6015,0.0,12.0065,9253.3837,229.6015,1,0.0,12.0,590 +scale,m40,10,0.2039,,,11.1667,989.876,0.0,1,491,0.2097,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,14148.4993,198.4419,0.0,9.9918,11786.5804,297.4022,1,0.0,11.9756,491 +scale,m40,8,0.1889,,,8.8333,1184.851,0.0,1,393,0.2026,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,21013.6632,98.642,0.0,7.9976,14104.4181,335.1711,1,0.0,11.9695,393 +scale,m40,6,0.1664,0,1,6.5,1339.1603,0.0,0,295,0.1856,13.4993,81155b0cbe82a779f5f7274021885d7c36c11c8600f7db6f05dd6e46f23c43f5,22224.4186,47.126,0.0,6.0033,12475.5394,289.0172,0,0.0,11.9595,295 +blur,m42,0.02,0.1754,,,7.3443,81.661,0.0097,1,976,0.1717,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,93.8265,137.4091,0.0,39.9965,92.3757,165.0509,1,0.0,43.5438,976 +blur,m42,0.04,0.1665,,,7.619,14.6946,0.0068,1,976,0.1635,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,18.9681,36.2753,0.0,39.9965,18.1813,40.435,1,0.0,42.0,976 +blur,m42,0.06,0.1549,,,7.6667,4.9632,0.005,1,976,0.1567,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,6.7387,11.1695,0.0,39.9965,6.5289,12.8097,1,0.0,42.0,976 +blur,m42,0.08,0.1505,,,7.3182,2.8433,0.0038,1,976,0.1517,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,3.4582,4.1348,0.0,39.9965,3.3318,5.2507,1,0.0,44.0,976 +blur,m42,0.1,0.0678,0,0,8.5714,2.2022,0.0028,0,976,0.1477,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,2.4052,1.8909,0.0,39.9965,2.2271,1.6834,0,0.0,42.0,976 +blur,m42,0.13,,0,0,,1.8173,0.0018,,976,0.143,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,1.9546,0.8969,0.0,39.9965,,,0,,,976 +blur,m42,0.16,,0,0,,1.6989,0.001,,976,0.139,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,1.7587,0.5809,0.0,39.9965,,,0,,,976 +blur,m42,0.2,,0,0,,1.6317,0.0005,,976,0.135,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,1.6499,0.4367,0.0,39.9965,,,0,,,976 +blur,m42,0.25,,0,0,,1.6042,0.0,,976,0.1314,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,1.5912,0.3561,0.0,39.9965,,,0,,,976 +blur,m42,0.3,,0,0,,1.5832,0.0,,976,0.1289,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,1.5335,0.333,0.0,39.9965,,,0,,,976 +edge,m42,2.0,0.1764,,,1.943,305.0679,0.0189,1,976,0.177,2.0,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,384.7099,318.8083,0.001,39.9965,387.8587,338.8773,1,0.001,41.1022,734 +edge,m42,1.0,0.1782,,,0.9585,306.9125,0.0192,1,976,0.177,0.9999,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,384.7099,318.8083,0.001,39.9965,383.9926,341.8391,1,0.001,41.5323,694 +edge,m42,0.5,0.1782,,,0.4763,308.4509,0.0195,1,976,0.177,0.4998,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,384.7099,318.8083,0.001,39.9965,383.9926,341.8391,1,0.001,41.5807,674 +edge,m42,0.25,0.1764,,,0.2423,308.9813,0.0197,1,976,0.177,0.2498,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,384.7099,318.8083,0.001,39.9965,387.8587,332.6514,1,0.001,40.8405,664 +edge,m42,0.1,0.1624,,,0.095,309.7299,0.0197,1,976,0.1629,0.0998,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,394.3904,323.3933,0.0011,39.9965,397.7518,343.9427,1,0.001,40.9128,658 +edge,m42,0.0,0.1534,,,0.05,309.9692,0.0197,1,976,0.1534,0.0,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,400.4156,331.0038,0.001,39.9965,400.4156,331.0038,1,0.001,40.0,654 +edge,m42,-0.5,0.2141,1,1,2.8,305.9487,0.0195,0,976,0.1242,0.0,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,366.4832,294.1214,0.0001,39.9965,1319.7382,176.9705,0,0.0067,20.0,634 +edge,m42,-1.0,0.2141,1,1,1.8,301.6629,0.0193,0,976,0.0976,0.0,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,340.7061,269.8171,0.0001,39.9965,1319.7382,176.9705,0,0.0067,20.0,614 +edge,m42,-2.0,0.2156,,,0.0599,298.18,0.0189,0,976,0.0929,0.0,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,295.6684,236.8832,0.0,39.9965,1217.4181,270.5696,1,0.0053,23.6005,574 +glare,m42,0.3,0.1421,,,7.3992,264.0208,0.0146,1,976,0.1386,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,244.4273,202.6102,0.0011,39.9965,238.2962,228.8264,1,0.0011,43.2263,976 +glare,m42,0.5,0.1235,,,7.4271,261.1049,0.0146,1,976,0.1203,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,169.9534,141.1949,0.0015,39.9965,166.5727,157.0256,1,0.0015,43.067,976 +glare,m42,0.7,0.1143,,,7.383,258.6147,0.0146,1,976,0.1115,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,111.4282,92.8347,0.0022,39.9965,110.1118,106.3186,1,0.0021,43.3197,976 +glare,m42,0.85,0.1151,,,7.5477,256.9624,0.0147,1,976,0.1126,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,78.434,65.1795,0.0029,39.9965,77.9929,72.2228,1,0.0027,42.3905,976 +glare,m42,1.0,0.191,1,1,16.4,255.5158,0.0155,0,976,0.1204,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,54.3844,45.0549,0.0744,39.9965,741.5696,100.3862,0,0.0185,20.0,976 +glare,m42,1.5,0.1922,1,1,16.4,252.0006,0.0247,0,976,0.097,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,16.8201,13.3971,0.5776,39.9965,574.8383,78.1726,0,0.087,20.0,976 +glare,m42,2.5,0.1763,0,0,16.4,247.9307,0.0359,0,976,0.037,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,1.1854,0.7139,0.8824,39.9965,692.8909,93.3157,0,0.013,20.0,976 +glare,m42,4.0,0.1015,0,0,14.1111,244.0071,0.0458,0,976,0.0007,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,0.0127,0.0093,0.9996,39.9965,857.4516,81.5237,0,0.0218,18.0,976 +motion,m42,0.05,0.1795,,,7.7233,178.1341,0.0137,1,976,0.1739,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,218.6562,238.4856,0.0002,39.9965,215.4335,250.4546,1,0.0002,41.4107,976 +motion,m42,0.1,0.1736,,,7.3707,97.9294,0.0122,1,976,0.1698,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,103.6305,129.1255,0.0,39.9965,97.6546,148.1797,1,0.0,43.3905,976 +motion,m42,0.15,0.1705,,,7.619,69.744,0.0113,1,976,0.1659,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,70.6323,86.5789,0.0,39.9965,68.0417,93.2879,1,0.0,42.0,976 +motion,m42,0.2,0.125,,,6.4553,56.4922,0.0107,0,976,0.1626,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,52.0226,62.4479,0.0,39.9965,182.0649,322.5386,1,0.0003,42.7011,976 +motion,m42,0.3,0.1144,0,0,9.5556,43.3589,0.0098,0,976,0.1578,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,36.6762,43.7668,0.0,39.9965,37.7071,33.101,0,0.0,36.0,976 +motion,m42,0.4,,0,0,,36.6638,0.0091,,976,0.1544,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,29.5694,35.5466,0.0,39.9965,,,0,,,976 +motion,m42,0.5,,0,0,,32.5096,0.0085,,976,0.1517,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,25.0775,30.1775,0.0,39.9965,,,0,,,976 +motion,m42,0.7,,0,0,,27.3847,0.0075,,976,0.1476,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,20.1245,24.1583,0.0,39.9965,,,0,,,976 +motion,m42,1.0,,0,0,,22.9849,0.0063,,976,0.1433,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,15.7018,18.8367,0.0,39.9965,,,0,,,976 +scale,m42,32,0.174,,,4.8571,429.4357,0.0139,0,781,0.1762,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,532.1073,532.1073,0.0007,32.0054,1592.8711,1903.5679,1,0.0,41.9463,781 +scale,m42,26,0.179,,,7.7637,711.8062,0.0136,1,634,0.1766,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,800.2131,244.3586,0.0009,25.9813,722.512,246.1307,1,0.0006,26.7791,634 +scale,m42,22,0.18,,,7.1724,1018.1302,0.0132,1,537,0.1734,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,1023.1928,187.2823,0.0012,22.0063,959.8312,248.195,1,0.001,24.4832,537 +scale,m42,18,0.174,,,7.0339,1445.4228,0.0125,1,439,0.1794,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,1586.3417,151.4403,0.0,17.9902,1474.3762,220.6206,1,0.0,20.6951,439 +scale,m42,15,0.1778,,,8.4636,1823.4559,0.0117,1,366,0.1754,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,2234.8282,112.5877,0.0007,14.9987,2135.3567,93.41,1,0.0007,14.1759,366 +scale,m42,12,0.179,,,6.8571,2232.1616,0.0103,1,293,0.1715,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,3320.076,81.1544,0.001,12.0071,2766.9103,110.2744,1,0.0008,13.9524,293 +scale,m42,10,,0,0,,2467.547,0.0087,,244,0.1697,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,4101.2592,54.6038,0.0015,9.9991,,,0,,,244 +scale,m42,8,,0,0,,2636.1246,0.0079,,195,0.1713,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,4683.3218,22.3388,0.0023,7.9911,,,0,,,195 +scale,m42,6,,0,0,,2819.6301,0.0074,,146,0.1614,8.0505,64a409aebbb09ce0b9837c9fb8b514d1473c967df889cf971f515f59eb919ac5,4751.7805,10.553,0.0,5.9831,,,0,,,146 +blur,m43,0.02,0.1588,,,6.1286,161.3162,0.0222,1,3264,0.1483,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,9.2471,327.359,0.0,99.4214,12.1424,499.2832,1,0.0,102.1799,1836 +blur,m43,0.04,0.1969,0,0,0.197,28.8953,0.0179,0,3264,0.1313,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,2.444,60.0442,0.0,99.4214,2.3756,1574.3467,0,0.0466,450.2603,1836 +blur,m43,0.06,0.1224,0,0,5.9966,7.8017,0.0145,0,3264,0.1194,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1.732,14.4934,0.0,99.4214,1.8908,23.9572,0,0.0,104.5382,1836 +blur,m43,0.08,0.141,0,0,0.3049,2.996,0.0111,0,3264,0.111,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1.5638,4.4652,0.0,99.4214,1.8558,6.7393,0,0.0,120.7515,1836 +blur,m43,0.1,,0,0,,1.4272,0.0084,,3264,0.1049,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1.5114,1.7775,0.0,99.4214,,,0,,,1836 +blur,m43,0.13,,0,0,,0.6216,0.006,,3264,0.098,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1.5093,0.6242,0.0,99.4214,,,0,,,1836 +blur,m43,0.16,,0,0,,0.3569,0.0039,,3264,0.0929,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1.4884,0.2988,0.0,99.4214,,,0,,,1836 +blur,m43,0.2,,0,0,,0.2278,0.0021,,3264,0.0878,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1.282,0.1862,0.0,99.4214,,,0,,,1836 +blur,m43,0.25,,0,0,,0.1664,0.0014,,3264,0.083,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1.329,0.1359,0.0,99.4214,,,0,,,1836 +blur,m43,0.3,,0,0,,0.1428,0.0009,,3264,0.0793,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1.3684,0.1094,0.0,99.4214,,,0,,,1836 +edge,m43,2.0,0.1809,,,1.7936,543.5458,0.0266,1,3264,0.1667,1.9962,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,323.3865,1135.4457,0.0,99.4214,421.0123,1716.963,1,0.0,110.7788,1408 +edge,m43,1.0,0.1836,1,0,1.0039,514.0778,0.0267,0,3264,0.1667,1.0004,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,323.3865,1135.4457,0.0,99.4214,469.1124,1614.8075,0,0.0,99.4333,1309 +edge,m43,0.5,0.1828,,,0.5174,504.8376,0.0271,0,3264,0.1667,0.4975,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,323.3865,1135.4457,0.0,99.4214,455.0183,1590.7646,1,0.0,100.8018,1259 +edge,m43,0.25,0.1836,1,1,0.2446,498.7457,0.0272,0,3264,0.1667,0.246,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,323.3865,1135.4457,0.0,99.4214,469.1124,1614.8075,0,0.0,99.4333,1234 +edge,m43,0.1,0.1839,1,1,0.0987,496.006,0.0272,0,3264,0.167,0.0952,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,328.7091,1155.7577,0.0,99.4214,470.0389,1624.6377,0,0.0,100.7759,1219 +edge,m43,0.0,0.1857,,,0.0059,492.0817,0.0272,0,3264,0.1672,0.0046,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,329.5638,1156.8362,0.0,99.4214,491.8663,1676.7033,1,0.0,99.4329,1210 +edge,m43,-0.5,0.1846,0,0,0.0561,468.4803,0.0271,0,3264,0.1648,0.0,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,314.6199,1098.4061,0.0,99.4214,499.6967,1577.331,0,0.0,93.3906,1160 +edge,m43,-1.0,0.1836,0,0,0.0045,444.8418,0.0274,0,3264,0.1626,0.0,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,292.6067,1017.7328,0.0,99.4214,453.9579,1436.4589,0,0.0,93.0392,1110 +edge,m43,-2.0,0.1827,0,0,-0.0108,415.1961,0.0278,0,3264,0.1594,0.0,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,260.3342,911.99,0.0,99.4214,422.6057,1332.7709,0,0.0,92.1601,1011 +glare,m43,0.3,0.1439,,,5.2002,513.9884,0.0295,1,3264,0.1486,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,205.7639,721.3885,0.0,99.4214,246.92,1103.5788,1,0.0,120.2352,1836 +glare,m43,0.5,0.1343,,,5.8736,487.4755,0.0295,1,3264,0.1437,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,142.9369,502.8009,0.0,99.4214,185.2794,705.5224,1,0.0,106.6159,1836 +glare,m43,0.7,0.1302,,,5.917,464.6433,0.0295,1,3264,0.1454,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,93.4374,329.3814,0.0,99.4214,120.2712,453.8611,1,0.0,105.8498,1836 +glare,m43,0.85,0.1338,,,5.8572,449.7928,0.0295,1,3264,0.1506,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,65.0118,229.2744,0.0,99.4214,82.634,316.2167,1,0.0,106.9176,1836 +glare,m43,1.0,0.1391,0,0,5.8581,437.0054,0.0338,1,3264,0.1593,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,43.8461,154.614,0.1683,99.4214,64.0527,243.9082,0,0.081,106.7682,1836 +glare,m43,1.5,0.2153,0,0,0.4356,406.5641,0.0698,0,3264,0.1185,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,11.4636,39.5357,0.6148,99.4214,183.2485,2267.4743,0,0.0993,437.6756,1836 +glare,m43,2.5,0.2161,0,0,0.4475,370.4451,0.1161,0,3264,0.0279,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,0.2504,0.3932,0.9053,99.4214,181.9607,2285.2441,0,0.1007,428.1554,1836 +glare,m43,4.0,0.2106,0,0,0.5431,338.2365,0.1581,0,3264,0.0,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,0.0,0.0,1.0,99.4214,170.935,2144.3636,0,0.0881,373.1352,1836 +motion,m43,0.05,0.1743,,,6.1705,410.702,0.0282,1,3264,0.1609,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,149.94,823.7526,0.0,99.4214,188.3084,1155.1701,1,0.0,101.4945,1836 +motion,m43,0.1,0.2057,0,0,0.4609,263.9445,0.0263,0,3264,0.1538,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,107.9877,543.9092,0.0,99.4214,49.5186,2098.3716,0,0.0778,389.035,1836 +motion,m43,0.15,0.1557,1,1,6.1423,201.5226,0.0247,0,3264,0.148,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,84.4615,438.0191,0.0,99.4214,96.961,582.266,0,0.0,101.9611,1836 +motion,m43,0.2,0.2018,0,0,0.207,165.7435,0.0232,0,3264,0.1436,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,67.1945,380.9914,0.0,99.4214,24.7059,1800.5273,0,0.0685,451.2203,1836 +motion,m43,0.3,0.1424,0,0,6.5004,121.8969,0.0209,0,3264,0.1372,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,43.906,309.5072,0.0,99.4214,49.694,371.9941,0,0.0,97.1392,1836 +motion,m43,0.4,0.1285,0,0,0.7059,93.857,0.0192,0,3264,0.1324,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,29.6963,256.0989,0.0,99.4214,85.4938,141.6292,0,0.0,80.6512,1836 +motion,m43,0.5,0.1071,0,0,0.7482,74.5615,0.0178,0,3264,0.1286,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,21.3464,210.6266,0.0,99.4214,11.2642,324.6063,0,0.0,290.1087,1836 +motion,m43,0.7,,0,0,,51.1194,0.0158,,3264,0.123,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,13.5892,144.2433,0.0,99.4214,,,0,,,1836 +motion,m43,1.0,,0,0,,34.4046,0.0136,,3264,0.116,3.722,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,9.0537,92.1266,0.0,99.4214,,,0,,,1836 +scale,m43,48,0.1794,,,5.6866,566.9922,0.0285,1,1576,0.1653,3.7199,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1315.2354,1107.2632,0.0,48.005,1708.1595,1732.3829,1,0.0,53.4688,886 +scale,m43,40,0.177,,,5.487,553.0904,0.028,1,1313,0.1646,3.7242,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,1674.0516,1115.9061,0.0,39.994,2114.3981,1739.2558,1,0.0,45.8067,739 +scale,m43,32,0.1714,,,5.215,522.4587,0.0271,1,1051,0.1631,3.7208,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,2052.195,2052.195,0.0,32.0135,2405.7238,1618.834,1,0.0,38.2974,591 +scale,m43,26,0.1709,1,1,4.8781,1275.6083,0.0263,0,854,0.1615,3.7191,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,2557.6632,729.1031,0.0,26.0128,3189.7865,1626.6191,0,0.0,33.5692,480 +scale,m43,22,0.153,0,0,4.4775,1501.2836,0.0257,0,722,0.1601,3.7208,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,3135.4555,536.5498,0.0,21.9921,2472.7071,798.2314,0,0.0,27.8782,406 +scale,m43,18,0.1592,,,4.6684,1770.5851,0.0245,1,591,0.1576,3.7171,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,3953.3167,362.3628,0.0,18.0019,4193.0663,874.8117,1,0.0,24.1068,332 +scale,m43,15,0.1486,,,3.7086,2019.5179,0.0233,0,492,0.1544,3.7254,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,4705.3939,244.857,0.0,14.9863,2271.7044,425.0603,1,0.0,22.7577,277 +scale,m43,12,0.1424,,,2.2015,2261.9581,0.0217,0,394,0.1486,3.7283,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,5233.7634,134.8946,0.0,12.0012,4454.7949,887.6199,1,0.0,23.654,222 +scale,m43,10,0.2035,0,0,0.3868,2411.841,0.0199,0,328,0.1441,3.7321,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,5431.4363,77.6904,0.0,9.9909,3592.1443,2368.5452,0,0.0533,43.2,185 +scale,m43,8,0.199,0,0,0.4066,2614.9439,0.0185,0,263,0.1372,3.7236,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,5293.3443,28.3261,0.0,8.011,3746.8336,2162.6254,0,0.0442,37.2226,148 +scale,m43,6,,0,0,,2940.7142,0.017,,197,0.1272,3.7283,4fb0cbac94492eff17ebbcc2656aab4233a296e83426e04feb946930b387444d,4505.2889,12.8599,0.0,6.0006,,,0,,,111 +blur,m44,0.02,0.0807,,,5.8627,75.7483,0.0007,0,4928,0.1456,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,3.3388,663.6997,0.0,167.9462,1.7322,95.9377,1,0.0,197.6935,3264 +blur,m44,0.04,0.0923,,,5.5516,10.6934,0.0002,0,4928,0.1172,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,1.3018,93.6361,0.0,167.9462,1.2606,35.3918,1,0.0,207.4168,3264 +blur,m44,0.06,0.0827,,,5.5118,2.6157,0.0,0,4928,0.1007,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,1.1746,16.987,0.0,167.9462,1.1606,11.2082,1,0.0,209.0485,3264 +blur,m44,0.08,0.0888,,,5.5135,0.9558,0.0,0,4928,0.0897,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,1.1929,4.7581,0.0,167.9462,1.1875,4.1762,1,0.0,207.7211,3264 +blur,m44,0.1,0.0722,0,0,5.7104,0.4642,0.0,0,4928,0.081,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,1.2804,1.9239,0.0,167.9462,1.1005,1.3742,0,0.0,200.5581,3264 +blur,m44,0.13,,0,0,,0.2232,0.0,,4928,0.0703,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,1.1774,0.7179,0.0,167.9462,,,0,,,3264 +blur,m44,0.16,,0,0,,0.1433,0.0,,4928,0.0614,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,1.2183,0.3461,0.0,167.9462,,,0,,,3264 +blur,m44,0.2,,0,0,,0.105,0.0,,4928,0.052,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,1.1076,0.1627,0.0,167.9462,,,0,,,3264 +blur,m44,0.25,,0,0,,0.0889,0.0,,4928,0.0436,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,0.797,0.0927,0.0,167.9462,,,0,,,3264 +blur,m44,0.3,,0,0,,0.0865,0.0,,4928,0.0383,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,0.5732,0.1008,0.0,167.9462,,,0,,,3264 +edge,m44,2.0,0.1886,,,11.2781,446.3954,0.0022,0,4928,0.1795,2.0011,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,144.9326,2312.4544,0.0,167.9462,305.5119,1397.6604,1,0.0,73.0249,2722 +edge,m44,1.0,0.1889,,,9.0105,469.5777,0.0023,0,4928,0.1795,1.0008,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,144.9326,2312.4544,0.0,167.9462,306.9143,1401.724,1,0.0,73.0249,2554 +edge,m44,0.5,0.2016,,,3.6869,484.1922,0.0024,1,4928,0.1795,0.5006,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,144.9326,2312.4544,0.0,167.9462,136.4399,1311.9156,1,0.0,119.8991,2470 +edge,m44,0.25,0.1888,,,7.2871,479.2577,0.0025,0,4928,0.1795,0.2506,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,144.9326,2312.4544,0.0,167.9462,306.4256,1371.5434,1,0.0,72.7124,2428 +edge,m44,0.1,0.2012,,,3.1884,488.9035,0.0025,1,4928,0.181,0.1017,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,147.7987,2364.713,0.0,167.9462,137.6689,1325.1397,1,0.0,120.4191,2403 +edge,m44,0.0,0.2099,,,3.4011,487.7694,0.0025,1,4928,0.1821,0.0005,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,149.83,2395.0006,0.0,167.9462,142.9402,1245.5682,1,0.0,107.4419,2386 +edge,m44,-0.5,0.2012,,,2.3577,498.686,0.0026,1,4928,0.1845,0.0,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,156.1878,2434.2081,0.0,167.9462,137.6653,1327.052,1,0.0,120.4142,2302 +edge,m44,-1.0,0.2015,,,1.6525,505.3609,0.0025,1,4928,0.1867,0.0,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,163.5396,2455.3374,0.0,167.9462,138.2455,1313.0752,1,0.0,119.8991,2218 +edge,m44,-2.0,0.2017,,,0.2794,501.8481,0.0026,1,4928,0.1921,0.0,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,181.257,2488.7273,0.0,167.9462,138.5822,1328.0511,1,0.0,119.9038,2050 +glare,m44,0.3,0.1419,,,26.696,317.118,0.0018,0,4928,0.1411,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,90.0577,1405.6295,0.0,167.9462,222.165,567.107,1,0.0,50.1395,3264 +glare,m44,0.5,0.1637,,,18.9961,279.4048,0.0018,0,4928,0.1219,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,61.2876,940.0423,0.0,167.9462,234.3605,1030.9668,1,0.0,72.3425,3264 +glare,m44,0.7,0.15,,,17.2846,248.0804,0.0018,0,4928,0.111,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,38.8822,580.3152,0.0,167.9462,195.622,1050.3503,1,0.0,79.5052,3264 +glare,m44,0.85,0.128,,,5.8986,228.7626,0.0018,0,4928,0.1096,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,26.3035,381.6919,0.0,167.9462,82.3308,294.5839,1,0.0,205.3333,3264 +glare,m44,1.0,0.1585,0,0,4.8766,213.0042,0.0048,0,4928,0.1142,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,17.3185,242.8969,0.1613,167.9462,69.8907,352.7516,0,0.0,235.1184,3264 +glare,m44,1.5,0.1177,0,0,17.4426,180.7354,0.0277,0,4928,0.083,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,4.6394,53.1482,0.6109,167.9462,111.5274,576.1098,0,0.0,78.7907,3264 +glare,m44,2.5,0.0859,0,0,19.0528,150.7325,0.0563,0,4928,0.0216,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,0.2624,0.3792,0.8943,167.9462,42.5105,175.7382,0,0.0,72.1287,3264 +glare,m44,4.0,0.1702,0,0,12.3466,127.9125,0.0825,0,4928,0.0,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,0.0,0.0,1.0,167.9462,234.304,621.2702,0,0.0,63.6564,3264 +motion,m44,0.05,0.1614,,,18.7005,217.0996,0.0015,0,4928,0.1667,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,42.286,1636.9212,0.0,167.9462,76.3575,531.2297,1,0.0,73.0984,3264 +motion,m44,0.1,0.1284,,,5.3477,107.2137,0.0011,0,4928,0.1502,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,28.2266,940.1949,0.0,167.9462,14.6204,294.2081,1,0.0,214.749,3264 +motion,m44,0.15,0.1175,,,5.592,77.8155,0.0009,0,4928,0.1397,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,23.035,711.9254,0.0,167.9462,12.7309,258.4298,1,0.0,205.5778,3264 +motion,m44,0.2,0.1138,,,5.5986,63.3101,0.0006,0,4928,0.1318,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,19.3529,584.2208,0.0,167.9462,10.8848,242.7124,1,0.0,205.3333,3264 +motion,m44,0.3,0.1314,0,0,10.5309,50.0366,0.0003,0,4928,0.1234,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,15.3458,470.5588,0.0,167.9462,10.9017,148.2488,0,0.0,116.085,3264 +motion,m44,0.4,0.1189,0,0,5.1215,42.526,0.0001,0,4928,0.1182,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,12.589,409.3389,0.0,167.9462,11.7303,396.8304,0,0.0,171.907,3264 +motion,m44,0.5,0.1275,0,0,13.4523,37.5421,0.0,0,4928,0.1149,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,10.7191,370.8565,0.0,167.9462,10.4094,103.2141,0,0.0,93.1163,3264 +motion,m44,0.7,,0,0,,30.7837,0.0,,4928,0.1098,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,7.7422,314.7678,0.0,167.9462,,,0,,,3264 +motion,m44,1.0,,0,0,,24.2093,0.0,,4928,0.1047,5.2283,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,5.3851,256.937,0.0,167.9462,,,0,,,3264 +scale,m44,48,0.1793,,,18.8832,377.7776,0.0016,0,1408,0.1774,5.2308,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,2430.6795,2307.5138,0.0,47.9846,4835.0776,754.0833,1,0.0,20.7564,933 +scale,m44,40,0.172,,,22.6851,363.2316,0.0015,0,1174,0.1749,5.2244,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,3061.1622,2241.6236,0.0,40.0099,10301.5129,451.32,1,0.0,14.0,777 +scale,m44,32,0.1287,,,5.2344,765.5851,0.0014,0,939,0.1736,5.2289,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,4179.977,4179.977,0.0,32.0011,411.6828,371.5723,1,0.0,41.9877,622 +scale,m44,26,0.1707,,,5.2273,874.6004,0.0013,0,763,0.1689,5.2246,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,5451.5679,1529.1205,0.0,26.003,5560.8055,1558.3718,1,0.0,25.966,505 +scale,m44,22,0.124,,,5.2124,958.5716,0.0013,0,646,0.1665,5.2299,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,6666.5552,1168.2403,0.0,22.0157,706.5058,265.1114,1,0.0,29.1364,428 +scale,m44,18,0.164,,,5.875,1067.8948,0.0011,0,528,0.1612,5.2326,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,7846.0193,711.3378,0.0,17.9942,8497.3597,397.251,1,0.0,16.0,350 +scale,m44,15,0.1541,,,5.5714,1163.3959,0.001,0,440,0.1526,5.2386,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,8615.8005,437.7804,0.0,14.9952,9093.0065,358.3036,1,0.0,14.0,292 +scale,m44,12,0.1078,0,0,9.6254,1260.6844,0.0008,0,352,0.142,5.2251,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,8998.6109,208.6528,0.0,11.9962,6961.9055,13.3811,0,0.0,6.0,233 +scale,m44,10,0.1177,0,0,10.0341,1306.6545,0.0007,0,293,0.1348,5.2266,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,7934.9389,101.6539,0.0,9.9854,2080.5693,7.7384,0,0.0,5.9796,194 +scale,m44,8,,0,0,,1350.4093,0.0005,,235,0.1244,5.2065,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,6540.2922,32.2475,0.0,8.0088,,,0,,,155 +scale,m44,6,,0,0,,1422.8767,0.0004,,176,0.1111,5.2476,5175333a0d1195f566a388b7c5c27a2322642121ff49eedc9d87dbed281f70d3,6218.7883,14.5213,0.0,5.9981,,,0,,,117 +blur,m45,0.02,0.1706,,,12.3909,241.6041,0.0005,0,2543,0.2299,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,14.4899,928.3457,0.0,112.1972,41.3192,376.2578,1,0.0,61.3177,3840 +blur,m45,0.04,0.1979,,,6.5005,36.3871,0.0001,1,2543,0.1918,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3.1353,225.705,0.0,112.1972,3.2969,145.6768,1,0.0,96.5001,3840 +blur,m45,0.06,0.1474,,,4.7984,9.5002,0.0,0,2543,0.1626,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1.7006,56.189,0.0,112.1972,1.4818,63.1266,1,0.0,128.9493,3840 +blur,m45,0.08,0.1318,,,4.4,3.4876,0.0,0,2543,0.1429,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1.4766,14.7664,0.0,112.1972,1.3052,21.7872,1,0.0,139.4189,3840 +blur,m45,0.1,0.1213,0,0,4.7984,1.5856,0.0,0,2543,0.1288,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1.4838,4.9229,0.0,112.1972,1.2994,5.9393,0,0.0,128.9493,3840 +blur,m45,0.13,0.0834,0,0,4.7879,0.6543,0.0,0,2543,0.1121,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1.4545,1.5779,0.0,112.1972,1.2883,3.6641,0,0.0,184.0329,3840 +blur,m45,0.16,,0,0,,0.3502,0.0,,2543,0.0979,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1.4305,0.7265,0.0,112.1972,,,0,,,3840 +blur,m45,0.2,,0,0,,0.2022,0.0,,2543,0.0816,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1.259,0.3603,0.0,112.1972,,,0,,,3840 +blur,m45,0.25,,0,0,,0.1345,0.0,,2543,0.0655,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1.3065,0.1822,0.0,112.1972,,,0,,,3840 +blur,m45,0.3,,0,0,,0.1053,0.0,,2543,0.0541,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1.295,0.0955,0.0,112.1972,,,0,,,3840 +edge,m45,2.0,0.2769,,,1.9904,1335.2777,0.0088,1,2543,0.2781,1.9982,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3199.9619,3511.9379,0.0195,112.1972,3135.0562,3506.7642,1,0.0189,112.0563,2314 +edge,m45,1.0,0.2719,,,0.7876,1338.1057,0.0089,1,2543,0.2781,1.0,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3199.9619,3511.9379,0.0195,112.1972,2943.218,3298.581,1,0.0174,110.2257,2202 +edge,m45,0.5,0.2562,,,0.3921,1318.4904,0.0087,1,2543,0.2781,0.5009,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3199.9619,3511.9379,0.0195,112.1972,3238.7142,3982.4957,1,0.0196,142.3868,2146 +edge,m45,0.25,0.2677,,,0.2457,1314.8301,0.0086,1,2543,0.2781,0.2513,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3199.9619,3511.9379,0.0195,112.1972,2847.4873,3191.5628,1,0.0168,111.317,2118 +edge,m45,0.1,0.2692,,,0.1106,1309.866,0.0085,1,2543,0.2808,0.0998,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3277.8382,3600.5767,0.02,112.1972,2927.4739,3230.1031,1,0.0173,112.5747,2101 +edge,m45,0.0,0.272,,,0.0112,1308.7626,0.0085,1,2543,0.2826,0.0017,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3330.2829,3657.1629,0.0203,112.1972,2957.9009,3318.069,1,0.0174,111.0866,2090 +edge,m45,-0.5,0.2844,0,0,0.5297,1263.6605,0.0079,0,2543,0.2825,0.0,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3334.0568,3663.3777,0.0208,112.1972,6422.1005,6137.1993,0,0.038,62.8358,2034 +edge,m45,-1.0,0.3065,0,0,1.2042,1204.1992,0.0074,0,2543,0.2819,0.0,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3312.9141,3610.7712,0.0209,112.1972,7940.741,9707.6096,0,0.0595,69.2272,1978 +edge,m45,-2.0,0.3067,0,0,0.0545,1089.7682,0.0065,0,2543,0.2844,0.0,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,3361.5669,3568.1686,0.0221,112.1972,8329.0618,8423.4647,0,0.0566,56.183,1865 +glare,m45,0.3,0.2175,,,5.485,864.6411,0.0058,1,2543,0.2175,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1957.681,2146.2266,0.0217,112.1972,1976.8078,2146.8724,1,0.0221,112.9265,3840 +glare,m45,0.5,0.1791,,,5.5973,791.4239,0.0059,1,2543,0.1794,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,1312.1279,1439.4323,0.0241,112.1972,1295.4382,1413.0635,1,0.0234,110.9694,3840 +glare,m45,0.7,0.1425,,,5.8031,729.0688,0.0062,1,2543,0.145,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,815.0097,894.4586,0.0281,112.1972,759.1399,839.5135,1,0.0245,107.5769,3840 +glare,m45,0.85,0.1169,,,13.8909,689.3945,0.0064,0,2543,0.1234,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,538.3211,590.9814,0.0363,112.1972,1085.524,1047.6035,1,0.0732,54.794,3840 +glare,m45,1.0,0.2859,0,0,22.244,655.8758,0.0102,0,2543,0.1079,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,343.8538,378.6753,0.2914,112.1972,6413.2726,5632.953,0,0.0401,54.1561,3840 +glare,m45,1.5,0.2841,0,0,22.1225,581.919,0.0221,0,2543,0.0618,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,77.7265,86.6026,0.6492,112.1972,6331.0982,5668.9437,0,0.0417,54.4792,3840 +glare,m45,2.5,0.2719,0,0,23.8738,513.354,0.0386,0,2543,0.0095,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,0.5096,0.294,0.9237,112.1972,5768.9133,4950.9796,0,0.0428,50.565,3840 +glare,m45,4.0,0.2543,0,0,22.6213,462.4797,0.0541,0,2543,0.0,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,0.0,0.0,1.0,112.1972,5012.7661,4451.1939,0,0.0435,53.3041,3840 +motion,m45,0.05,0.2119,,,14.2294,523.6934,0.0013,0,2543,0.2507,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,525.7604,1963.0649,0.0012,112.1972,1099.0044,1941.1364,1,0.002,53.5173,3840 +motion,m45,0.1,0.232,,,5.8199,265.8259,0.0009,1,2543,0.2327,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,341.1724,1184.8418,0.0004,112.1972,324.083,1110.532,1,0.0004,107.2969,3840 +motion,m45,0.15,0.2044,,,5.345,183.9193,0.0007,0,2543,0.2129,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,253.6371,778.093,0.0001,112.1972,373.7799,1030.1702,1,0.0002,115.2659,3840 +motion,m45,0.2,0.2027,,,6.6133,149.011,0.0005,0,2543,0.1998,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,212.082,607.7026,0.0,112.1972,360.3128,817.2189,1,0.0001,95.2257,3840 +motion,m45,0.3,0.1767,,,5.2518,112.0061,0.0003,1,2543,0.1805,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,153.1761,428.9639,0.0,112.1972,158.9044,440.127,1,0.0,117.662,3840 +motion,m45,0.4,0.117,0,0,4.5833,95.2769,0.0001,0,2543,0.1711,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,121.6249,353.1631,0.0,112.1972,142.6234,245.7696,0,0.0,66.921,3840 +motion,m45,0.5,0.1135,0,0,4.2308,84.2417,0.0,0,2543,0.165,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,99.0703,306.4675,0.0,112.1972,136.2595,254.2974,0,0.0,72.4978,3840 +motion,m45,0.7,0.1583,0,0,9.8727,68.9227,0.0,0,2543,0.158,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,68.4109,251.5219,0.0,112.1972,93.2957,536.0428,0,0.0,102.1512,3840 +motion,m45,1.0,,0,0,,53.8234,0.0,,2543,0.1509,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,45.7322,197.8767,0.0,112.1972,,,0,,,3840 +scale,m45,48,0.2905,,,7.3746,995.4835,0.0027,1,1088,0.2675,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,6519.4195,3283.3305,0.0017,48.0026,7304.7158,3573.3861,1,0.0009,37.0016,1643 +scale,m45,40,0.2509,,,11.3707,965.0118,0.0022,0,907,0.2637,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,6423.744,3361.9836,0.0001,40.0168,19273.1987,4235.4654,1,0.0154,23.6224,1369 +scale,m45,32,0.2386,,,11.8292,862.0961,0.0012,0,725,0.2581,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,6564.0698,6564.0698,0.0001,31.987,22727.2866,2277.8297,1,0.0012,18.2081,1095 +scale,m45,26,0.221,,,11.7623,1949.6306,0.0011,0,589,0.2552,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,7102.9081,1940.3462,0.0003,25.9867,21292.7866,1124.8082,1,0.0016,14.8812,890 +scale,m45,22,0.2304,,,5.0833,2028.0115,0.001,0,499,0.2527,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,8223.7248,1434.2187,0.0,22.0159,8106.3314,1820.7957,1,0.0001,23.952,753 +scale,m45,18,0.216,,,5.1,2104.7224,0.001,0,408,0.2486,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,11244.1252,1069.1049,0.0003,18.001,10571.1997,1361.1805,1,0.0002,20.0,616 +scale,m45,15,0.231,,,6.0,2207.2381,0.0009,0,340,0.2431,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,15601.9076,784.02,0.0,15.0008,15813.4764,647.6203,1,0.0,14.0,513 +scale,m45,12,0.1921,,,4.125,2364.2318,0.0007,0,272,0.2366,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,21940.4541,532.0822,0.0007,12.0006,13076.8578,598.9804,1,0.0003,16.0,411 +scale,m45,10,0.1892,,,4.6667,2520.5193,0.0007,0,227,0.2239,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,25160.3805,336.325,0.0032,10.0152,15320.4404,350.1096,1,0.0017,11.9474,342 +scale,m45,8,0.1772,,,4.4,2741.6575,0.0005,0,181,0.2053,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,24569.519,111.3872,0.0,7.9857,13402.1163,175.8477,1,0.0,9.9451,274 +scale,m45,6,0.2137,0,0,3.1429,2976.6526,0.0002,0,136,0.1799,5.5236,0b33b4d62db422e2f87f8626e8617745c83a72412cdba4987c5bef2c10920bcd,20238.9711,40.6617,0.0,6.0003,2794.6507,146.4737,0,0.0,14.0,205 +blur,m46,0.02,0.1965,,,11.0701,64.2637,0.0,1,2592,0.1955,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,12.5143,293.4964,0.0,83.3328,12.6746,284.5517,1,0.0,81.9404,3018 +blur,m46,0.04,0.1494,,,11.3158,15.7489,0.0,0,2592,0.1753,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,3.6944,91.1775,0.0,83.3328,5.0688,130.0102,1,0.0,83.3299,3018 +blur,m46,0.06,0.1523,,,10.0025,5.128,0.0,1,2592,0.1558,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,2.1541,32.1888,0.0,83.3328,2.0753,40.8392,1,0.0,90.7516,3018 +blur,m46,0.08,0.1375,,,10.3903,2.1142,0.0,1,2592,0.1391,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,1.7073,11.6004,0.0,83.3328,1.6757,13.1802,1,0.0,87.3062,3018 +blur,m46,0.1,0.1076,,,9.3612,1.0742,0.0,1,2592,0.1252,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,1.604,4.7624,0.0,83.3328,1.5725,6.6573,1,0.0,97.0,3018 +blur,m46,0.13,0.1067,0,0,8.9644,0.5132,0.0,0,2592,0.1076,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,1.639,1.7545,0.0,83.3328,1.5608,3.0761,0,0.0,101.0,3018 +blur,m46,0.16,0.0833,0,0,4.5877,0.3012,0.0,0,2592,0.0927,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,1.6501,0.7952,0.0,83.3328,1.16,2.071,0,0.0,175.4244,3018 +blur,m46,0.2,0.0867,0,0,4.3467,0.1893,0.0,0,2592,0.0764,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,1.5913,0.3414,0.0,83.3328,1.0525,1.0772,0,0.0,184.6015,3018 +blur,m46,0.25,,0,0,,0.1411,0.0,,2592,0.0603,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,1.4375,0.1901,0.0,83.3328,,,0,,,3018 +blur,m46,0.3,,0,0,,0.124,0.0,,2592,0.0479,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,1.4816,0.1175,0.0,83.3328,,,0,,,3018 +edge,m46,2.0,0.2118,,,2.1363,150.7923,0.0,1,2592,0.2076,2.0043,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,130.8069,730.0385,0.0,83.3328,137.305,697.8464,1,0.0,79.2791,2277 +edge,m46,1.0,0.2118,,,1.0913,153.1056,0.0,1,2592,0.2076,0.9963,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,130.8069,730.0385,0.0,83.3328,138.1748,704.1873,1,0.0,79.2791,2193 +edge,m46,0.5,0.2116,,,0.5705,154.0684,0.0,1,2592,0.2076,0.5043,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,130.8069,730.0385,0.0,83.3328,137.9109,694.7054,1,0.0,79.2791,2152 +edge,m46,0.25,0.2123,,,0.3324,155.0871,0.0,1,2592,0.2076,0.2523,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,130.8069,730.0385,0.0,83.3328,136.8574,693.9708,1,0.0,79.2791,2131 +edge,m46,0.1,0.2129,,,0.0951,155.6493,0.0,1,2592,0.2094,0.0963,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,134.1092,749.0759,0.0,83.3328,140.3387,712.3397,1,0.0,79.2791,2118 +edge,m46,0.0,0.2145,,,0.0475,155.7913,0.0,1,2592,0.2106,0.0003,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,136.2372,763.3325,0.0,83.3328,139.2033,710.4235,1,0.0,79.2791,2110 +edge,m46,-0.5,0.2045,,,7.6643,155.612,0.0,1,2592,0.2086,0.0,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,134.0013,751.1621,0.0,83.3328,456.4483,1138.5251,1,0.0,54.5316,2068 +edge,m46,-1.0,0.2086,,,6.5323,155.5874,0.0,1,2592,0.2088,0.0,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,133.581,765.3024,0.0,83.3328,487.6617,1455.3724,1,0.0,58.1206,2027 +edge,m46,-2.0,0.2081,,,5.0073,154.0431,0.0,1,2592,0.2134,0.0,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,145.4422,818.7688,0.0,83.3328,482.2488,1466.2759,1,0.0,58.6131,1943 +glare,m46,0.3,0.1647,,,10.5965,146.877,0.0,1,2592,0.1666,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,85.3916,467.3083,0.0,83.3328,83.0063,484.1028,1,0.0,85.6716,3018 +glare,m46,0.5,0.1466,,,11.3701,142.7556,0.0,1,2592,0.1443,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,61.2327,330.4833,0.0,83.3328,63.9609,317.8336,1,0.0,79.8115,3018 +glare,m46,0.7,0.1277,,,10.7886,139.2555,0.0,1,2592,0.1283,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,42.1115,223.2066,0.0,83.3328,41.5796,227.0065,1,0.0,84.1591,3018 +glare,m46,0.85,0.1219,,,10.8968,137.0406,0.0,1,2592,0.1219,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,31.1467,161.9261,0.0,83.3328,31.1467,161.9261,1,0.0,83.3299,3018 +glare,m46,1.0,0.1249,,,12.4744,135.172,0.0014,1,2592,0.1214,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,23.0173,117.4701,0.1713,83.3328,22.4486,87.2083,1,0.189,72.7585,3018 +glare,m46,1.5,0.2073,,,8.6569,130.7383,0.0095,1,2592,0.0876,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,8.9124,43.5731,0.5977,83.3328,474.4743,1426.9929,1,0.0,58.1936,3018 +glare,m46,2.5,0.206,,,8.6687,125.876,0.0201,1,2592,0.0263,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,0.9456,3.8276,0.889,83.3328,466.5052,1380.5413,1,0.0,58.1189,3018 +glare,m46,4.0,0.2053,,,8.6687,121.6428,0.0297,1,2592,0.0,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,0.0,0.0,1.0,83.3328,463.094,1370.1416,1,0.0,58.1189,3018 +motion,m46,0.05,0.2034,,,10.8968,113.1541,0.0,1,2592,0.2034,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,51.0868,544.7174,0.0,83.3328,51.0868,544.7174,1,0.0,83.3299,3018 +motion,m46,0.1,0.1952,,,10.8968,64.6181,0.0,1,2592,0.1952,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,31.1768,326.9027,0.0,83.3328,31.1768,326.9027,1,0.0,83.3299,3018 +motion,m46,0.15,0.1829,,,10.4001,45.1388,0.0,1,2592,0.186,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,23.9247,235.5563,0.0,83.3328,23.6662,261.4214,1,0.0,87.2766,3018 +motion,m46,0.2,0.1737,,,10.7953,34.2059,0.0,1,2592,0.1744,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,18.8675,171.9601,0.0,83.3328,19.3836,185.724,1,0.0,84.0095,3018 +motion,m46,0.3,0.1377,,,11.5294,26.6775,0.0,0,2592,0.1586,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,14.5818,121.6964,0.0,83.3328,31.1307,164.4264,1,0.0,74.5584,3018 +motion,m46,0.4,0.1389,0,0,8.1429,22.9432,0.0,0,2592,0.1467,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,12.1727,97.5753,0.0,83.3328,25.8744,106.6095,0,0.0,61.401,3018 +motion,m46,0.5,0.1379,,,8.6055,20.4159,0.0,1,2592,0.1367,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,10.4763,80.9149,0.0,83.3328,14.9348,202.5877,1,0.0,105.159,3018 +motion,m46,0.7,0.1286,0,0,12.0,17.5757,0.0,0,2592,0.1267,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,8.576,64.8563,0.0,83.3328,19.7447,150.2168,0,0.0,78.9442,3018 +motion,m46,1.0,,0,0,,14.9805,0.0,,2592,0.1211,10.8964,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,6.9477,53.6375,0.0,83.3328,,,0,,,3018 +scale,m46,48,0.2066,,,10.7216,157.1616,0.0,1,1493,0.2075,10.894,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,528.6942,735.6306,0.0,47.9999,521.3302,768.7617,1,0.0,48.765,1738 +scale,m46,40,0.2063,,,10.7248,155.6926,0.0,1,1244,0.2073,10.9005,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,782.6862,722.4023,0.0,39.9946,769.516,750.0553,1,0.0,40.6446,1449 +scale,m46,32,0.206,,,10.7644,153.1526,0.0,1,995,0.2071,10.9008,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,1221.0593,1221.0593,0.0,31.9892,1157.794,869.0987,1,0.0,32.2981,1159 +scale,m46,26,0.1978,,,8.2887,304.6785,0.0,1,809,0.2066,10.8968,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,1787.7793,563.7456,0.0,26.0093,5588.8245,635.5518,1,0.0,18.7509,942 +scale,m46,22,0.2038,,,10.6286,396.8585,0.0,1,684,0.2056,10.9044,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,2456.5556,449.5162,0.0,21.9906,2412.5372,464.7648,1,0.0,22.5477,797 +scale,m46,18,0.1986,,,9.8,527.9996,0.0,1,560,0.2053,10.8958,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,3481.2206,347.0325,0.0,18.004,3271.2778,443.201,1,0.0,20.0,652 +scale,m46,15,0.1763,,,9.0,660.5736,0.0,1,467,0.2057,10.8813,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,5311.6887,276.979,0.0,15.014,11073.1673,150.5839,1,0.0,9.9786,543 +scale,m46,12,0.1996,,,10.8375,845.6958,0.0,1,373,0.2015,10.9139,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,8192.2263,202.6519,0.0,11.9919,8306.8787,209.3478,1,0.0,11.9679,435 +scale,m46,10,0.0947,,,4.4545,997.5,0.0,0,311,0.197,10.893,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,11240.6136,147.9761,0.0,9.9986,595.7377,115.3269,1,0.0,21.9295,362 +scale,m46,8,0.1031,0,0,4.3333,1176.3027,0.0,0,249,0.1899,10.8992,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,14433.8176,67.4283,0.0,8.0053,815.7415,85.6745,0,0.0,17.928,290 +scale,m46,6,0.1484,,,8.2561,1390.1518,0.0,0,187,0.1752,10.8597,ca131f74aea55db69036c9e164d07060cf90521ca8a3f4471d3a5701dfac153b,15868.3418,35.2522,0.0,6.012,9975.2596,57.046,1,0.0,7.9574,217 +blur,m47,0.02,0.1023,,,8.7895,82.6119,0.0,1,2200,0.1013,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,11.2098,90.5938,0.0,64.152,11.4552,90.4024,1,0.0,62.5276,1650 +blur,m47,0.04,0.0894,,,8.3917,16.037,0.0,1,2200,0.0898,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,3.8076,28.4901,0.0,64.152,3.7612,29.9893,1,0.0,65.1119,1650 +blur,m47,0.06,0.0793,,,8.1429,4.1543,0.0,1,2200,0.0788,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,2.2548,9.4527,0.0,64.152,2.1527,10.2943,1,0.0,67.1512,1650 +blur,m47,0.08,0.0781,,,7.7933,1.6866,0.0,0,2200,0.0699,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,1.7911,3.3336,0.0,64.152,1.5981,4.603,1,0.0,74.6647,1650 +blur,m47,0.1,0.0631,,,7.6443,0.9079,0.0,1,2200,0.0626,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,1.6523,1.356,0.0,64.152,1.5125,1.7715,1,0.0,71.5175,1650 +blur,m47,0.13,,0,0,,0.4883,0.0,,2200,0.0538,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,1.6375,0.5363,0.0,64.152,,,0,,,1650 +blur,m47,0.16,,0,0,,0.3248,0.0,,2200,0.0464,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,1.5248,0.2994,0.0,64.152,,,0,,,1650 +blur,m47,0.2,,0,0,,0.2284,0.0,,2200,0.0387,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,1.6343,0.157,0.0,64.152,,,0,,,1650 +blur,m47,0.25,,0,0,,0.1789,0.0,,2200,0.0321,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,1.262,0.1182,0.0,64.152,,,0,,,1650 +blur,m47,0.3,,0,0,,0.1602,0.0,,2200,0.0275,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,1.0389,0.1118,0.0,64.152,,,0,,,1650 +edge,m47,2.0,0.1082,,,1.9714,239.7371,0.0,1,2200,0.1087,2.0015,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,116.3392,232.3516,0.0,64.152,115.3787,257.1789,1,0.0,64.4918,1229 +edge,m47,1.0,0.1082,,,0.973,241.8687,0.0,1,2200,0.1087,1.0039,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,116.3392,232.3516,0.0,64.152,114.7783,257.6712,1,0.0,65.0379,1165 +edge,m47,0.5,0.1039,,,0.449,241.2509,0.0,1,2200,0.1087,0.5051,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,116.3392,232.3516,0.0,64.152,106.2191,265.8936,1,0.0,70.4419,1133 +edge,m47,0.25,0.1078,,,0.2344,240.4307,0.0,1,2200,0.1087,0.2557,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,116.3392,232.3516,0.0,64.152,113.7744,260.6914,1,0.0,65.5935,1117 +edge,m47,0.1,0.1082,,,0.0882,239.2719,0.0,1,2200,0.1092,0.0998,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,118.9088,237.896,0.0,64.152,117.0239,268.0852,1,0.0,65.236,1107 +edge,m47,0.0,0.1086,,,0.037,238.5786,0.0,1,2200,0.1096,0.0062,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,120.0766,240.1368,0.0,64.152,117.6588,268.7923,1,0.0,65.2877,1101 +edge,m47,-0.5,0.1093,0,0,0.2449,235.354,0.0,0,2200,0.1088,0.0,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,118.173,235.5695,0.0,64.152,119.6781,260.8522,0,0.0,63.5456,1069 +edge,m47,-1.0,0.1085,0,0,0.0457,232.6219,0.0,0,2200,0.1099,0.0,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,119.3971,249.1095,0.0,64.152,116.2099,263.6914,0,0.0,65.813,1036 +edge,m47,-2.0,0.1596,0,0,3.583,226.9375,0.0,0,2200,0.1077,0.0,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,115.7679,245.7516,0.0,64.152,356.3894,353.2161,0,0.0,38.3721,972 +glare,m47,0.3,0.0877,,,8.6695,240.7171,0.0,1,2200,0.0871,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,73.2666,142.4696,0.0,64.152,74.2243,157.2991,1,0.0,63.3847,1650 +glare,m47,0.5,0.0821,,,8.7895,237.6592,0.0,1,2200,0.0816,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,50.0661,96.0398,0.0,64.152,50.8989,104.5407,1,0.0,62.5276,1650 +glare,m47,0.7,0.0851,,,8.6262,234.9564,0.0,1,2200,0.0853,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,32.3063,60.4464,0.0,64.152,32.3618,64.8803,1,0.0,63.6999,1650 +glare,m47,0.85,0.0932,,,8.5923,233.1537,0.0,1,2200,0.0932,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,22.4261,40.8976,0.0,64.152,22.2751,40.4615,1,0.0,63.9488,1650 +glare,m47,1.0,0.1596,0,0,14.25,231.5686,0.0014,0,2200,0.1045,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,15.4554,27.33,0.1239,64.152,356.3894,353.2161,0,0.0,38.3721,1650 +glare,m47,1.5,0.1056,0,0,9.0,227.403,0.0135,0,2200,0.0809,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,4.7068,7.6836,0.5955,64.152,82.9558,85.9712,0,0.0,41.5698,1650 +glare,m47,2.5,0.1596,0,0,14.25,221.9475,0.0288,0,2200,0.0219,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,0.2666,0.1291,0.8837,64.152,356.3894,353.2161,0,0.0,38.3721,1650 +glare,m47,4.0,0.1595,0,0,14.25,216.4456,0.0429,0,2200,0.0,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,0.0,0.0,1.0,64.152,356.2059,353.0253,0,0.0,38.3721,1650 +motion,m47,0.05,0.1067,,,8.6147,199.6602,0.0,1,2200,0.1067,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,55.8707,183.2413,0.0,64.152,55.9918,187.9495,1,0.0,63.7841,1650 +motion,m47,0.1,0.1022,,,8.5351,136.4235,0.0,1,2200,0.1023,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,31.8529,119.1333,0.0,64.152,31.6574,119.9444,1,0.0,64.0172,1650 +motion,m47,0.15,0.0976,,,8.7375,101.2981,0.0,1,2200,0.0953,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,21.1759,78.1576,0.0,64.152,20.9658,76.9376,1,0.0,62.5413,1650 +motion,m47,0.2,0.091,,,8.4445,90.4974,0.0,1,2200,0.0902,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,17.0093,61.4759,0.0,64.152,16.2576,63.7839,1,0.0,64.7448,1650 +motion,m47,0.3,0.0846,,,9.0,80.8027,0.0,1,2200,0.0814,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,12.6033,43.8956,0.0,64.152,12.6281,42.1305,1,0.0,60.7558,1650 +motion,m47,0.4,0.0522,0,0,13.0952,72.8685,0.0,0,2200,0.0738,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,9.9134,33.7812,0.0,64.152,27.8958,27.3084,0,0.0,35.407,1650 +motion,m47,0.5,0.0522,0,0,15.8182,68.851,0.0,0,2200,0.0687,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,8.439,28.5589,0.0,64.152,28.0363,27.8125,0,0.0,35.1744,1650 +motion,m47,0.7,,0,0,,62.0713,0.0,,2200,0.0613,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,6.6254,21.906,0.0,64.152,,,0,,,1650 +motion,m47,1.0,,0,0,,55.1043,0.0,,2200,0.0593,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,5.5176,19.0106,0.0,64.152,,,0,,,1650 +scale,m47,48,0.1079,,,8.42,250.2346,0.0,1,1646,0.1086,8.5675,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,215.8422,252.125,0.0,47.9974,213.6017,263.9306,1,0.0,48.7872,1235 +scale,m47,40,0.1101,,,8.8485,245.8526,0.0,1,1372,0.1085,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,307.4836,250.5777,0.0,40.0075,316.0871,243.4706,1,0.0,38.5835,1029 +scale,m47,32,0.1745,,,5.4172,236.5796,0.0,0,1097,0.1081,8.5666,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,451.5715,451.5715,0.0,31.9885,568.5002,438.8465,1,0.0,41.3123,823 +scale,m47,26,0.104,,,7.739,538.1286,0.0,1,892,0.1078,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,624.1138,181.7593,0.0,26.0107,564.876,214.2649,1,0.0,28.3806,669 +scale,m47,22,0.106,,,8.156,656.2676,0.0,1,754,0.1075,8.5716,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,817.4221,145.1,0.0,21.9866,782.7808,158.5981,1,0.0,23.0399,566 +scale,m47,18,0.1004,,,7.3463,821.8745,0.0,1,617,0.1071,8.5687,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,1212.875,112.7991,0.0,17.9917,1023.1932,159.5523,1,0.0,20.906,463 +scale,m47,15,0.1034,,,8.0,974.3029,0.0,1,514,0.1074,8.5752,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,1859.3708,93.5825,0.0,14.9882,1642.232,77.9034,1,0.0,16.0,386 +scale,m47,12,0.1046,,,8.4726,1005.3183,0.0,1,412,0.1047,8.564,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,2800.4505,66.1486,0.0,12.0139,2763.6575,65.6516,1,0.0,12.0,309 +scale,m47,10,0.0916,,,6.6113,940.4667,0.0,1,343,0.1026,8.5557,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,3709.1474,46.8672,0.0,10.0019,2634.9258,74.552,1,0.0,12.6465,257 +scale,m47,8,0.0984,0,0,9.6667,991.7943,0.0,0,274,0.0974,8.5849,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,4635.5512,20.8611,0.0,7.9898,1600.916,4.0714,0,0.0,6.0,206 +scale,m47,6,0.0452,0,0,7.6667,1155.8071,0.0,0,206,0.0884,8.5363,2438dd00c2ec94adeee4d2d95c8a9a29d4fcca76e520fd8b07ea77280b75c4ed,4326.6943,9.51,0.0,6.007,1210.3515,2.5092,0,0.0,6.0,154 +blur,m48,0.02,0.1167,,,5.5714,107.766,0.0078,1,1800,0.1207,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,27.0271,383.5325,0.0,74.844,26.7869,358.5531,1,0.0,73.2558,2400 +blur,m48,0.04,0.1462,,,6.0413,18.5112,0.0075,0,1800,0.0983,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,4.3796,62.3261,0.0,74.844,3.3044,73.7837,1,0.0,90.542,2400 +blur,m48,0.06,0.2178,,,3.0845,4.9771,0.0071,1,1800,0.0846,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,2.0906,14.0019,0.0,74.844,1.7227,84.9804,1,0.0,122.1355,2400 +blur,m48,0.08,0.2115,,,2.9664,1.9715,0.0067,1,1800,0.0762,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,1.7498,4.5914,0.0,74.844,1.3206,36.3462,1,0.0,126.798,2400 +blur,m48,0.1,0.1134,,,2.0968,1.0012,0.0063,0,1800,0.0707,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,1.6512,1.8838,0.0,74.844,1.3433,5.2598,1,0.0,108.1395,2400 +blur,m48,0.13,0.1077,0,0,1.9478,0.5069,0.0058,0,1800,0.0658,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,1.5901,0.7867,0.0,74.844,1.2698,2.8098,0,0.0,117.412,2400 +blur,m48,0.16,,0,0,,0.3309,0.0052,,1800,0.0632,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,1.5626,0.4523,0.0,74.844,,,0,,,2400 +blur,m48,0.2,,0,0,,0.2367,0.0044,,1800,0.0624,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,1.2378,0.2934,0.0,74.844,,,0,,,2400 +blur,m48,0.25,,0,0,,0.1931,0.0032,,1800,0.0631,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,0.9703,0.2304,0.0,74.844,,,0,,,2400 +blur,m48,0.3,,0,0,,0.1755,0.0021,,1800,0.0642,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,0.9876,0.2077,0.0,74.844,,,0,,,2400 +edge,m48,2.0,0.1855,,,0.5823,248.0232,0.0124,0,1800,0.1416,1.9987,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,307.8695,1121.1024,0.0,74.844,234.411,1057.2427,1,0.0,127.4484,1566 +edge,m48,1.0,0.1875,,,0.2219,245.0146,0.013,0,1800,0.1416,0.9966,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,307.8695,1121.1024,0.0,74.844,228.4159,1047.3685,1,0.0,130.392,1491 +edge,m48,0.5,0.1849,,,0.0513,240.2724,0.0133,0,1800,0.1416,0.5023,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,307.8695,1121.1024,0.0,74.844,220.1792,1031.3463,1,0.0,128.6897,1454 +edge,m48,0.25,0.128,,,0.2589,239.2342,0.0135,1,1800,0.1416,0.2484,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,307.8695,1121.1024,0.0,74.844,291.9659,930.1857,1,0.0,68.9919,1435 +edge,m48,0.1,0.1293,,,0.109,237.7567,0.0136,1,1800,0.1425,0.1014,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,312.5121,1140.8495,0.0,74.844,296.9958,951.4893,1,0.0,68.9919,1424 +edge,m48,0.0,0.1306,,,0.0292,237.5643,0.0136,1,1800,0.1431,0.0,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,316.4993,1166.0691,0.0,74.844,302.8075,980.7713,1,0.0,68.9919,1416 +edge,m48,-0.5,0.1395,0,0,0.1849,232.5481,0.0138,0,1800,0.1411,0.0,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,316.1515,1153.985,0.0,74.844,324.6158,1057.2788,0,0.0,70.6395,1379 +edge,m48,-1.0,0.1418,0,0,0.2883,228.55,0.014,0,1800,0.14,0.0,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,318.9854,1153.3223,0.0,74.844,332.0472,1118.7332,0,0.0,72.027,1342 +edge,m48,-2.0,0.144,0,0,0.1373,223.0899,0.0145,0,1800,0.139,0.0,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,332.9965,1228.2005,0.0,74.844,203.7947,786.3246,0,0.0,109.3548,1267 +glare,m48,0.3,0.1862,,,4.2168,351.2436,0.0081,0,1800,0.1269,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,191.7446,690.4818,0.0,74.844,228.9177,1024.1136,1,0.0,125.6259,2400 +glare,m48,0.5,0.1862,,,4.2168,342.1034,0.0081,0,1800,0.132,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,130.4202,466.7039,0.0,74.844,228.9177,1024.1136,1,0.0,125.6259,2400 +glare,m48,0.7,0.1862,,,4.2168,334.0706,0.0081,0,1800,0.1481,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,83.1865,293.7478,0.0,74.844,228.9177,1024.1136,1,0.0,125.6259,2400 +glare,m48,0.85,0.1862,,,4.2168,328.7729,0.0081,0,1800,0.1655,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,56.8716,197.9347,0.0,74.844,228.9177,1024.1136,1,0.0,125.6259,2400 +glare,m48,1.0,0.1862,,,4.2168,324.1463,0.009,0,1800,0.1861,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,38.3734,130.5291,0.0772,74.844,228.9177,1024.1136,1,0.0,125.6259,2400 +glare,m48,1.5,0.1862,0,0,4.2168,312.4438,0.0204,0,1800,0.153,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,11.1592,35.0369,0.5771,74.844,228.9177,1024.1136,0,0.0,125.6259,2400 +glare,m48,2.5,0.1862,0,0,4.221,298.3771,0.0343,0,1800,0.0515,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,0.5921,0.6676,0.8661,74.844,228.9177,1024.1136,0,0.0,125.5161,2400 +glare,m48,4.0,0.1862,0,0,4.221,286.3901,0.0473,0,1800,0.0,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,0.0,0.0,1.0,74.844,228.9177,1024.1136,0,0.0,125.5161,2400 +motion,m48,0.05,0.1337,,,5.4646,265.9752,0.0081,1,1800,0.1337,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,138.2564,766.5531,0.0,74.844,138.2564,762.0911,1,0.0,74.5679,2400 +motion,m48,0.1,0.1195,,,5.5714,193.7098,0.008,1,1800,0.1243,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,97.392,469.5359,0.0,74.844,90.0152,423.1496,1,0.0,73.2558,2400 +motion,m48,0.15,0.1511,,,5.6224,158.5094,0.008,0,1800,0.1148,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,78.5357,322.8497,0.0,74.844,62.3225,299.4932,1,0.0,96.0596,2400 +motion,m48,0.2,0.1597,,,4.9562,142.7158,0.008,0,1800,0.1083,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,69.3491,267.5931,0.0,74.844,50.2205,255.1729,1,0.0,108.3488,2400 +motion,m48,0.3,0.1576,,,4.3588,126.6458,0.008,0,1800,0.101,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,60.4339,220.78,0.0,74.844,77.0442,426.4231,1,0.0,121.6933,2400 +motion,m48,0.4,0.1033,,,5.2345,114.7396,0.0079,1,1800,0.0971,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,54.6199,195.9863,0.0,74.844,58.3989,223.6557,1,0.0,77.8643,2400 +motion,m48,0.5,0.091,0,0,5.5357,106.7701,0.0078,0,1800,0.0948,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,51.0872,181.8105,0.0,74.844,23.5632,88.1289,0,0.0,97.6744,2400 +motion,m48,0.7,0.1381,0,0,5.0,93.5814,0.0077,0,1800,0.0917,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,45.4142,164.8311,0.0,74.844,55.2636,297.38,0,0.0,80.2326,2400 +motion,m48,1.0,,0,0,,79.1713,0.0074,,1800,0.0904,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,39.5482,154.5562,0.0,74.844,,,0,,,2400 +scale,m48,48,0.1434,,,5.3819,373.1702,0.0081,1,1154,0.1442,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,859.538,1130.737,0.0,47.9833,845.1572,1148.1551,1,0.0,48.4783,1539 +scale,m48,40,0.1588,,,5.4,363.2751,0.0081,1,962,0.1397,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,1183.9064,1096.9551,0.0,40.0,1302.634,1193.337,1,0.0,40.0,1283 +scale,m48,32,0.1444,,,5.4375,329.1939,0.0081,1,770,0.1444,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,1911.2988,1911.2988,0.0,32.0166,1916.0949,1916.0949,1,0.0,32.0,1026 +scale,m48,26,0.1694,,,4.7641,841.2664,0.008,1,625,0.139,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,2834.0264,804.5104,0.0,25.9875,2671.4854,1114.5744,1,0.0,29.2081,834 +scale,m48,22,0.1728,,,4.4355,983.0522,0.0081,1,529,0.1415,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,3770.6228,636.4468,0.0,21.9958,3348.7393,999.0881,1,0.0,26.4925,705 +scale,m48,18,0.1761,,,2.0691,1139.7864,0.0081,0,433,0.1451,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,4910.6903,469.718,0.0,18.0041,10099.7002,1689.4556,1,0.0,21.7251,577 +scale,m48,15,0.1646,,,1.9869,1249.6697,0.0079,0,361,0.1267,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,5260.2386,260.7061,0.0,15.0104,9873.1933,1045.5901,1,0.0,18.534,481 +scale,m48,12,0.186,,,4.3,1361.2759,0.0079,0,289,0.1347,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,5894.4567,148.8708,0.0,12.0166,2751.8077,380.8586,1,0.0,19.931,385 +scale,m48,10,0.1971,,,3.8134,1459.4539,0.0077,0,241,0.1121,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,5601.9771,78.6145,0.0,10.0208,3136.6374,348.5319,1,0.0,18.701,321 +scale,m48,8,0.1716,0,0,4.8333,1568.2096,0.0075,0,192,0.1278,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,6260.219,36.2742,0.0,7.9834,4099.5984,111.3545,0,0.0,12.0,257 +scale,m48,6,0.1756,0,0,3.5,1719.7488,0.0076,0,144,0.0893,5.4425,d5738e5af35a34ba27ff944ea30f3d241265923ba08eeb156aa5095ba50be2c5,3805.2314,10.2561,0.0,5.9875,4375.2633,117.2334,0,0.0,12.0,192 +blur,m49,0.02,0.0887,,,2.1282,246.7928,0.0,0,2200,0.1078,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,21.4799,137.6003,0.0,60.764,16.7978,126.5449,1,0.0,76.3506,1650 +blur,m49,0.04,0.0811,,,2.1743,39.5409,0.0,0,2200,0.0949,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,4.9691,33.7658,0.0,60.764,2.9555,20.0195,1,0.0,74.9133,1650 +blur,m49,0.06,0.081,,,2.0856,8.9794,0.0,0,2200,0.0843,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,2.6003,10.094,0.0,60.764,2.0445,6.4427,1,0.0,77.126,1650 +blur,m49,0.08,0.077,,,7.3478,3.2548,0.0,1,2200,0.0758,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,1.9311,3.494,0.0,60.764,1.7731,5.5942,1,0.0,73.5465,1650 +blur,m49,0.1,0.0718,,,7.2372,1.705,0.0,1,2200,0.069,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,1.7413,1.4294,0.0,60.764,1.5379,2.206,1,0.0,74.6567,1650 +blur,m49,0.13,0.0658,0,0,6.8355,0.8519,0.0,0,2200,0.0607,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,1.6724,0.5801,0.0,60.764,1.4161,0.9519,0,0.0,79.0277,1650 +blur,m49,0.16,,0,0,,0.525,0.0,,2200,0.0538,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,1.6137,0.3385,0.0,60.764,,,0,,,1650 +blur,m49,0.2,,0,0,,0.3313,0.0,,2200,0.0465,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,1.5263,0.2322,0.0,60.764,,,0,,,1650 +blur,m49,0.25,,0,0,,0.2333,0.0,,2200,0.0398,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,1.2919,0.1979,0.0,60.764,,,0,,,1650 +blur,m49,0.3,,0,0,,0.1929,0.0,,2200,0.0344,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,1.2669,0.1543,0.0,60.764,,,0,,,1650 +edge,m49,2.0,0.0914,,,2.1806,756.6627,0.0002,0,2200,0.1168,2.0044,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,211.817,392.114,0.0,60.764,205.5761,359.4797,1,0.0,74.889,1225 +edge,m49,1.0,0.0835,,,2.3821,758.7297,0.0002,0,2200,0.1168,1.0005,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,211.817,392.114,0.0,60.764,207.4223,343.1143,1,0.0,69.8423,1164 +edge,m49,0.5,0.0914,,,2.2236,757.3344,0.0002,0,2200,0.1168,0.5068,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,211.817,392.114,0.0,60.764,204.4626,349.7254,1,0.0,73.5855,1134 +edge,m49,0.25,0.089,,,2.2356,756.6797,0.0002,0,2200,0.1168,0.2435,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,211.817,392.114,0.0,60.764,206.0715,353.3259,1,0.0,73.3125,1118 +edge,m49,0.1,0.0903,,,2.2826,754.546,0.0002,0,2200,0.1175,0.0953,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,216.3226,404.2918,0.0,60.764,203.9527,343.9547,1,0.0,72.0664,1109 +edge,m49,0.0,0.0919,,,2.1846,752.6942,0.0002,0,2200,0.1181,0.0,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,218.932,406.7802,0.0,60.764,204.6929,353.9548,1,0.0,74.7532,1103 +edge,m49,-0.5,0.0914,0,0,2.184,743.3652,0.0002,0,2200,0.1154,0.0,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,211.3615,394.2146,0.0,60.764,205.7577,361.6873,0,0.0,74.7958,1073 +edge,m49,-1.0,0.0933,0,0,2.1431,744.0452,0.0002,0,2200,0.1165,0.0,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,214.4282,404.2593,0.0,60.764,206.3596,359.0058,0,0.0,75.9571,1042 +edge,m49,-2.0,0.091,0,0,2.1934,753.0792,0.0002,0,2200,0.1165,0.0,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,209.0463,398.5584,0.0,60.764,205.272,353.0093,0,0.0,73.8805,982 +glare,m49,0.3,0.091,,,2.197,748.7102,0.0003,0,2200,0.0961,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,133.8871,243.161,0.0,60.764,206.4879,366.5319,1,0.0,74.5058,1650 +glare,m49,0.5,0.091,,,2.197,741.7352,0.0003,0,2200,0.0936,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,91.9068,165.6147,0.0,60.764,206.4879,366.5319,1,0.0,74.5058,1650 +glare,m49,0.7,0.091,,,2.197,735.457,0.0003,0,2200,0.1012,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,59.7261,105.6338,0.0,60.764,206.4879,366.5319,1,0.0,74.5058,1650 +glare,m49,0.85,0.091,,,2.197,731.2068,0.0003,0,2200,0.1123,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,41.3427,71.8646,0.0,60.764,206.4879,366.5319,1,0.0,74.5058,1650 +glare,m49,1.0,0.091,0,0,2.197,727.3493,0.0014,0,2200,0.1269,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,28.387,48.0403,0.1081,60.764,206.4879,366.5319,0,0.0,74.5058,1650 +glare,m49,1.5,0.091,0,0,2.197,717.0458,0.0132,0,2200,0.0982,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,8.1518,13.03,0.5947,60.764,206.4879,366.5319,0,0.0,74.5058,1650 +glare,m49,2.5,0.091,0,0,2.197,704.1112,0.0279,0,2200,0.0258,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,0.2686,0.1496,0.8844,60.764,206.4879,366.5319,0,0.0,74.5058,1650 +glare,m49,4.0,0.091,0,0,2.197,691.3768,0.0414,0,2200,0.0,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,0.0,0.0,1.0,60.764,206.4879,366.5319,0,0.0,74.5058,1650 +motion,m49,0.05,0.0886,,,2.204,582.6403,0.0001,0,2200,0.1136,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,93.939,277.5763,0.0,60.764,84.6568,260.0187,1,0.0,74.2729,1650 +motion,m49,0.1,0.0883,,,2.1665,372.6529,0.0,0,2200,0.1078,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,53.251,164.1487,0.0,60.764,44.5608,144.2848,1,0.0,75.3403,1650 +motion,m49,0.15,0.1027,,,2.8754,300.0572,0.0,0,2200,0.1017,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,38.7801,111.9101,0.0,60.764,110.4387,271.0649,1,0.0,77.4856,1650 +motion,m49,0.2,0.0785,,,2.304,263.1788,0.0,0,2200,0.0962,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,31.3379,85.5118,0.0,60.764,25.0715,72.8217,1,0.0,71.5999,1650 +motion,m49,0.3,0.0828,,,8.9474,242.9125,0.0,1,2200,0.0874,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,23.7121,61.224,0.0,60.764,19.2041,51.6741,1,0.0,60.7558,1650 +motion,m49,0.4,0.0779,,,8.8947,225.5958,0.0,1,2200,0.0812,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,19.3871,48.6483,0.0,60.764,15.6459,40.9448,1,0.0,60.7558,1650 +motion,m49,0.5,0.0779,0,0,8.9474,216.6129,0.0,0,2200,0.0764,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,16.4941,40.6638,0.0,60.764,15.4437,37.7758,0,0.0,60.7558,1650 +motion,m49,0.7,,0,0,,200.969,0.0,,2200,0.0696,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,12.6506,30.8305,0.0,60.764,,,0,,,1650 +motion,m49,1.0,,0,0,,185.6939,0.0,,2200,0.0675,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,10.9209,27.5556,0.0,60.764,,,0,,,1650 +scale,m49,48,0.0931,,,2.1846,775.9327,0.0003,0,1738,0.117,8.9952,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,363.834,398.4627,0.0,48.0036,319.0147,359.351,1,0.0,59.0558,1303 +scale,m49,40,0.0955,,,2.1296,770.2523,0.0003,0,1448,0.1167,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,528.1174,385.4049,0.0,39.9938,441.9579,365.4782,1,0.0,50.2202,1086 +scale,m49,32,0.092,,,2.1613,735.1004,0.0002,0,1159,0.1162,8.9961,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,736.5248,736.5248,0.0,32.0116,593.6696,335.4478,1,0.0,39.8626,869 +scale,m49,26,0.1066,,,7.937,1591.9328,0.0002,1,941,0.1154,9.0018,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,938.0856,262.0476,0.0,25.9904,1104.9539,385.3575,1,0.0,27.9703,706 +scale,m49,22,0.1101,,,7.5353,1781.6813,0.0001,1,797,0.115,8.9874,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,1226.101,209.5228,0.0,22.0131,999.8629,281.3795,1,0.0,25.9674,597 +scale,m49,18,0.0874,,,2.1332,1876.7837,0.0,0,652,0.1143,8.9986,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,1759.3011,160.0581,0.0,18.0082,810.8465,141.3471,1,0.0,22.6462,489 +scale,m49,15,0.1076,,,7.4399,2112.119,0.0,1,543,0.1127,8.9931,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,2419.0823,119.4322,0.0,14.9977,1936.99,171.6254,1,0.0,17.9669,407 +scale,m49,12,0.1103,,,8.8333,2188.9478,0.0,1,434,0.1107,9.0125,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,3483.206,76.1876,0.0,11.9871,3438.7758,75.6049,1,0.0,12.0,326 +scale,m49,10,0.1028,,,7.3333,1830.6963,0.0,1,362,0.108,9.0152,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,4305.013,50.4721,0.0,9.9984,3246.7626,71.0868,1,0.0,12.0,272 +scale,m49,8,0.1074,,,11.945,1572.9617,0.0,1,290,0.1034,8.978,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,5141.0376,22.2905,0.0,8.0098,6795.5141,15.1244,1,0.0,6.0,217 +scale,m49,6,,0,0,,1921.1772,0.0001,,217,0.0944,9.0125,0ce543e1da7be0af898cf83f38f05c7a06fab6b3a1884618f25b419f8657e1b3,4964.9174,10.3375,0.0,5.9935,,,0,,,163 +blur,m50,0.02,0.1787,,,6.9906,127.8893,0.0026,1,1800,0.1788,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,41.2724,606.0424,0.0,76.428,41.2042,612.5353,1,0.0,77.141,2400 +blur,m50,0.04,0.1486,,,6.7002,19.5537,0.0013,1,1800,0.1486,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,6.758,127.8722,0.0,76.428,6.8328,154.0479,1,0.0,80.474,2400 +blur,m50,0.06,0.1255,,,6.6361,5.0981,0.0006,1,1800,0.1259,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,2.5874,32.0721,0.0,76.428,2.5817,39.9353,1,0.0,81.2536,2400 +blur,m50,0.08,0.1123,,,7.9514,1.9742,0.0002,1,1800,0.1105,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,1.7104,8.7819,0.0,76.428,1.7211,10.3875,1,0.0,77.4366,2400 +blur,m50,0.1,0.1033,,,7.8355,0.9885,0.0,1,1800,0.0993,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,1.5284,3.1566,0.0,76.428,1.5186,3.935,1,0.0,78.6771,2400 +blur,m50,0.13,0.0885,0,0,8.0,0.4821,0.0,0,1800,0.0859,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,1.4809,1.1008,0.0,76.428,1.5249,1.2901,0,0.0,76.7442,2400 +blur,m50,0.16,,0,0,,0.3043,0.0,,1800,0.0747,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,1.4215,0.5134,0.0,76.428,,,0,,,2400 +blur,m50,0.2,,0,0,,0.2148,0.0,,1800,0.0624,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,1.4226,0.2615,0.0,76.428,,,0,,,2400 +blur,m50,0.25,,0,0,,0.1753,0.0,,1800,0.0503,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,1.3838,0.167,0.0,76.428,,,0,,,2400 +blur,m50,0.3,,0,0,,0.1568,0.0,,1800,0.0417,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,1.4358,0.111,0.0,76.428,,,0,,,2400 +edge,m50,2.0,0.2095,,,2.0915,302.4402,0.0048,1,1800,0.2051,1.9996,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,579.5252,2085.8076,0.0,76.428,613.968,2205.5261,1,0.0,75.0,1225 +edge,m50,1.0,0.2094,,,1.0943,270.3952,0.0038,1,1800,0.2051,1.0052,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,579.5252,2085.8076,0.0,76.428,614.1216,2189.8827,1,0.0,75.0,1149 +edge,m50,0.5,0.2028,,,0.5538,255.4066,0.0033,0,1800,0.2051,0.4949,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,579.5252,2085.8076,0.0,76.428,593.3595,2034.2426,1,0.0,73.4734,1110 +edge,m50,0.25,0.2029,,,0.304,251.5298,0.0031,0,1800,0.2051,0.2463,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,579.5252,2085.8076,0.0,76.428,594.39,2027.1431,1,0.0,73.3891,1091 +edge,m50,0.1,0.2046,,,0.1596,250.4807,0.003,1,1800,0.2064,0.1024,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,589.7176,2129.2161,0.0,76.428,603.5099,2060.934,1,0.0,73.4707,1080 +edge,m50,0.0,0.2041,,,0.0528,249.7014,0.003,0,1800,0.2073,0.0,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,597.6978,2158.0123,0.0,76.428,606.7816,2073.4757,1,0.0,73.3972,1072 +edge,m50,-0.5,0.1988,0,0,0.0533,242.069,0.0028,0,1800,0.2042,0.0,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,592.3015,2143.9566,0.0,76.428,601.9472,2001.7506,0,0.0,72.1327,1034 +edge,m50,-1.0,0.1956,0,0,0.0143,229.4668,0.0026,0,1800,0.2023,0.0,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,588.0454,2139.1958,0.0,76.428,596.9389,1976.544,0,0.0,71.042,996 +edge,m50,-2.0,0.2052,0,0,0.0214,210.0656,0.0024,0,1800,0.2042,0.0,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,623.8298,2278.759,0.0,76.428,702.6305,2003.1127,0,0.0,62.9742,919 +glare,m50,0.3,0.1648,,,7.0299,489.6592,0.0065,1,1800,0.1648,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,368.8254,1327.0982,0.0,76.428,368.8254,1328.5988,1,0.0,76.7122,2400 +glare,m50,0.5,0.1426,,,6.9909,475.4222,0.0066,0,1800,0.1427,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,257.3062,927.9595,0.0,76.428,256.8954,937.3523,1,0.0,77.1378,2400 +glare,m50,0.7,0.1268,,,6.9912,463.4609,0.0066,0,1800,0.1268,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,170.1741,614.8315,0.0,76.428,170.1741,618.5403,1,0.0,77.1346,2400 +glare,m50,0.85,0.1121,,,7.7143,455.9674,0.0066,1,1800,0.1205,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,120.3724,435.8305,0.0,76.428,122.0162,393.2005,1,0.0,70.0,2400 +glare,m50,1.0,0.1598,0,0,4.6667,449.77,0.0099,0,1800,0.12,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,84.113,304.9159,0.1952,76.428,753.2829,1343.042,0,0.0043,52.3256,2400 +glare,m50,1.5,0.1161,0,0,3.4623,435.6372,0.0314,0,1800,0.0855,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,24.9092,89.0015,0.623,76.428,157.5187,237.2778,0,0.0,137.782,2400 +glare,m50,2.5,0.1532,0,0,3.4286,420.815,0.0574,0,1800,0.0231,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,1.0067,2.5046,0.9062,76.428,608.4372,1606.6037,0,0.005,70.0,2400 +glare,m50,4.0,0.1532,0,0,3.4286,408.6696,0.0814,0,1800,0.0,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,0.0,0.0,1.0,76.428,608.4372,1606.6037,0,0.005,70.0,2400 +motion,m50,0.05,0.1959,,,7.0196,352.8448,0.0058,1,1800,0.196,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,224.5051,1430.0813,0.0,76.428,224.1407,1434.1792,1,0.0,76.8242,2400 +motion,m50,0.1,0.1824,,,6.8924,233.5688,0.0052,1,1800,0.1818,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,143.093,915.9104,0.0,76.428,145.2939,962.7133,1,0.0,78.2232,2400 +motion,m50,0.15,0.1707,,,7.1155,204.6871,0.0049,1,1800,0.1721,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,121.7979,764.7956,0.0,76.428,118.0273,738.6282,1,0.0,75.8011,2400 +motion,m50,0.2,0.1596,,,7.1152,182.8332,0.0045,1,1800,0.1612,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,103.4973,630.9476,0.0,76.428,99.6914,606.9343,1,0.0,75.8039,2400 +motion,m50,0.3,0.1457,,,7.0731,158.6838,0.0039,1,1800,0.1477,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,83.0523,500.0852,0.0,76.428,78.7731,475.3856,1,0.0,76.2481,2400 +motion,m50,0.4,0.1343,,,7.6514,143.5952,0.0035,1,1800,0.1398,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,70.9338,431.7284,0.0,76.428,63.5036,358.9877,1,0.0,70.9718,2400 +motion,m50,0.5,0.1299,0,0,8.7357,133.2959,0.0032,0,1800,0.1345,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,63.1559,387.2799,0.0,76.428,73.1662,368.9073,0,0.0,63.093,2400 +motion,m50,0.7,,0,0,,116.0969,0.0027,,1800,0.1281,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,51.404,328.4908,0.0,76.428,,,0,,,2400 +motion,m50,1.0,,0,0,,96.0632,0.0022,,1800,0.1215,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,38.6081,261.9233,0.0,76.428,,,0,,,2400 +scale,m50,48,0.2039,,,7.108,522.5652,0.006,0,1130,0.2029,7.0586,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,1738.0464,2048.9872,0.0,47.9798,1761.7943,2117.0299,1,0.0,47.6466,1507 +scale,m50,40,0.2029,,,7.7143,501.8533,0.0058,1,942,0.2046,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,2604.4818,2039.2823,0.0,39.9973,2741.5662,1926.294,1,0.0,36.6333,1256 +scale,m50,32,0.2018,,,6.2187,983.2775,0.0056,1,754,0.2008,7.0547,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,3831.0686,3831.0686,0.0,32.0148,3670.5605,2253.8568,1,0.0,36.19,1005 +scale,m50,26,0.2037,,,6.8591,1189.8878,0.0053,1,612,0.2014,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,4924.015,1347.9543,0.0,25.9855,5021.5751,1490.0532,1,0.0,26.7518,816 +scale,m50,22,0.2069,,,6.9899,1304.7657,0.0052,0,518,0.1972,7.0604,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,5449.1975,917.0729,0.0,21.9943,6473.1981,1131.4296,1,0.0,22.0,691 +scale,m50,18,0.1494,,,2.2592,1395.1022,0.0044,0,424,0.1936,7.0528,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,6675.7285,618.1756,0.0,18.003,460.1097,292.1988,1,0.0,45.6652,565 +scale,m50,15,0.132,,,2.9557,1465.8409,0.0046,0,353,0.1907,7.062,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,8774.0855,434.2218,0.0,14.9884,471.5306,164.9361,1,0.0,30.2986,471 +scale,m50,12,0.1771,,,7.0062,1566.9054,0.0044,0,283,0.1821,7.0508,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,12371.3442,292.9168,0.0,12.0162,11808.8895,275.654,1,0.0,11.9577,377 +scale,m50,10,0.1342,,,2.7273,1685.6502,0.0031,0,236,0.1771,7.042,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,14617.9646,195.9958,0.0,10.0206,542.1248,103.0882,1,0.0,22.0,314 +scale,m50,8,0.173,,,9.2963,1834.0624,0.0029,0,188,0.1616,7.0664,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,13835.3412,74.0582,0.0,7.9825,20925.2452,46.2817,1,0.0,6.0,251 +scale,m50,6,,0,0,,2031.7761,0.003,,141,0.138,7.057,dc8747eea98d5392b07797b9ad2fc05c8c4e4fc1d25431ae63d02b5d80dcf071,10039.4967,21.6792,0.0,5.9869,,,0,,,188 +blur,m51,0.02,0.076,,,1.1739,245.9907,0.0102,0,2000,0.1863,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,314.9986,571.6446,0.0,40.7,22.436,205.7453,1,0.0,133.7209,1500 +blur,m51,0.04,0.075,,,1.25,70.6447,0.0092,0,2000,0.1553,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,72.0681,157.7283,0.0,40.7,4.5761,141.1628,1,0.0,133.3333,1500 +blur,m51,0.06,0.0716,,,1.1875,21.7691,0.0087,0,2000,0.1296,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,19.0613,42.0216,0.0,40.7,2.6276,81.6587,1,0.0,133.3333,1500 +blur,m51,0.08,0.0687,,,1.1875,9.0534,0.0081,0,2000,0.1128,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,6.3241,11.8701,0.0,40.7,2.0825,48.2205,1,0.0,133.3333,1500 +blur,m51,0.1,0.0659,,,1.1875,4.4171,0.0076,0,2000,0.1014,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,3.0327,3.8429,0.0,40.7,1.8184,28.2387,1,0.0,133.3333,1500 +blur,m51,0.13,0.0896,0,0,8.4375,1.8766,0.007,0,2000,0.0889,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,2.0485,1.3106,0.0,40.7,2.0748,2.0524,0,0.0,46.5116,1500 +blur,m51,0.16,0.0542,0,0,1.3571,0.9689,0.0065,0,2000,0.0786,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,1.7724,0.7346,0.0,40.7,1.5376,3.9404,0,0.0,116.6668,1500 +blur,m51,0.2,0.0637,0,0,0.9474,0.5161,0.0058,0,2000,0.0664,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,1.6401,0.4957,0.0,40.7,1.4303,6.1828,0,0.0,158.3333,1500 +blur,m51,0.25,0.0518,0,0,2.4667,0.3227,0.0049,0,2000,0.0532,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,1.6118,0.3873,0.0,40.7,1.4065,1.2238,0,0.0,125.0001,1500 +blur,m51,0.3,,0,0,,0.2517,0.0042,,2000,0.0426,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,1.7215,0.3374,0.0,40.7,,,0,,,1500 +edge,m51,2.0,0.2051,,,2.0717,497.4223,0.0142,1,2000,0.2043,2.0033,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,814.2792,1132.6426,0.0033,40.7,822.4119,1159.7142,1,0.0033,40.6977,1192 +edge,m51,1.0,0.0778,,,0.5319,486.9266,0.0145,0,2000,0.2043,0.9959,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,814.2792,1132.6426,0.0033,40.7,64.0385,232.0241,1,0.0,130.8139,1151 +edge,m51,0.5,0.0773,,,0.3854,482.0984,0.0146,0,2000,0.2043,0.5045,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,814.2792,1132.6426,0.0033,40.7,63.6453,233.1438,1,0.0,127.907,1131 +edge,m51,0.25,0.0774,,,0.2952,481.0073,0.0147,0,2000,0.2043,0.2588,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,814.2792,1132.6426,0.0033,40.7,63.6524,228.3904,1,0.0,127.907,1121 +edge,m51,0.1,0.0774,,,0.2497,479.2684,0.0148,0,2000,0.2055,0.1114,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,826.6435,1152.1915,0.0034,40.7,63.6524,228.3904,1,0.0,127.907,1115 +edge,m51,0.0,0.0776,,,0.1999,479.3345,0.0148,0,2000,0.2065,0.0,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,836.896,1169.5128,0.0034,40.7,64.1202,232.5433,1,0.0,130.8139,1110 +edge,m51,-0.5,0.0766,0,0,0.0454,470.6711,0.0149,0,2000,0.2038,0.0,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,833.0603,1160.3007,0.0033,40.7,64.2964,236.8937,0,0.0,127.907,1090 +edge,m51,-1.0,0.0712,0,0,1.6474,458.503,0.0151,0,2000,0.2002,0.0,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,820.1764,1134.2158,0.0027,40.7,55.3299,181.0231,0,0.0,98.8372,1070 +edge,m51,-2.0,0.0758,0,0,1.0414,429.4318,0.0155,0,2000,0.2004,0.0,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,852.982,1181.9278,0.0024,40.7,63.3207,229.1147,0,0.0,130.8139,1029 +glare,m51,0.3,0.078,,,1.1739,400.3959,0.0119,0,2000,0.1579,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,522.4567,713.8707,0.0051,40.7,64.7602,239.4805,1,0.0,133.7209,1500 +glare,m51,0.5,0.078,,,1.1739,380.7364,0.0121,0,2000,0.1288,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,367.5009,494.105,0.008,40.7,64.7602,239.4805,1,0.0,133.7209,1500 +glare,m51,0.7,0.078,,,1.1739,364.2295,0.0124,0,2000,0.1027,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,245.848,323.2795,0.0208,40.7,64.7602,239.4805,1,0.0,133.7209,1500 +glare,m51,0.85,0.078,,,1.1739,353.9497,0.0137,0,2000,0.0866,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,176.2208,227.004,0.1223,40.7,64.7602,239.4805,1,0.0,133.7209,1500 +glare,m51,1.0,0.078,0,0,1.1739,345.4532,0.019,0,2000,0.0761,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,125.4568,158.4062,0.3697,40.7,64.7602,239.4805,0,0.0,133.7209,1500 +glare,m51,1.5,0.078,0,0,1.1739,326.7306,0.0333,0,2000,0.0459,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,39.3193,48.0397,0.7189,40.7,64.7602,239.4805,0,0.0,133.7209,1500 +glare,m51,2.5,0.078,0,0,1.1739,308.0596,0.0515,0,2000,0.005,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,0.3338,0.2637,0.9837,40.7,64.7602,239.4805,0,0.0,133.7209,1500 +glare,m51,4.0,0.078,0,0,1.1739,294.1354,0.0679,0,2000,0.0,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,0.0,0.0,1.0,40.7,64.7602,239.4805,0,0.0,133.7209,1500 +motion,m51,0.05,0.0773,,,1.2,391.8466,0.0116,0,2000,0.2002,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,673.2923,1003.3547,0.0018,40.7,48.5597,231.7348,1,0.0,130.8139,1500 +motion,m51,0.1,0.0767,,,1.1739,287.975,0.0106,0,2000,0.1873,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,478.1473,732.6347,0.0004,40.7,33.727,214.5193,1,0.0,133.7209,1500 +motion,m51,0.15,0.0757,,,1.3023,223.2777,0.0101,0,2000,0.1727,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,370.8263,551.1012,0.0,40.7,27.1789,180.4485,1,0.0,125.0,1500 +motion,m51,0.2,0.075,,,1.1957,190.4601,0.0097,0,2000,0.1597,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,309.9941,445.3214,0.0,40.7,23.4879,172.3703,1,0.0,133.7209,1500 +motion,m51,0.3,0.0731,,,1.25,157.565,0.0091,0,2000,0.1422,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,244.4781,337.9327,0.0,40.7,19.0667,133.43,1,0.0,127.907,1500 +motion,m51,0.4,0.0719,,,1.25,140.6843,0.0087,0,2000,0.1331,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,208.8731,285.1001,0.0,40.7,16.6652,117.8623,1,0.0,127.907,1500 +motion,m51,0.5,0.0707,0,0,1.2,129.8524,0.0083,0,2000,0.1261,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,183.651,247.628,0.0,40.7,14.886,110.1129,0,0.0,130.8139,1500 +motion,m51,0.7,0.0686,0,0,1.4,116.1982,0.0075,0,2000,0.118,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,151.6626,203.3232,0.0,40.7,14.2911,108.0021,0,0.0,125.0,1500 +motion,m51,1.0,,0,0,,103.5461,0.0064,,2000,0.1103,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,117.8604,157.9052,0.0,40.7,,,0,,,1500 +scale,m51,40,0.0777,,,1.1996,440.2545,0.0118,0,1966,0.2048,9.5676,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,849.0364,1146.3373,0.0032,40.0081,63.822,228.3021,1,0.0,128.5901,1474 +scale,m51,32,0.0777,,,1.1977,440.7252,0.0118,0,1572,0.2047,9.5709,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,1674.9892,1674.9892,0.0034,31.9902,105.0868,228.6956,1,0.0,102.8198,1179 +scale,m51,26,0.0811,,,1.1759,429.0696,0.0118,0,1278,0.2042,9.5659,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,2952.0569,962.2091,0.004,26.0073,163.6612,267.8222,1,0.0003,90.525,958 +scale,m51,22,0.078,,,1.1618,415.3519,0.0117,0,1081,0.2036,9.5738,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,4465.1387,826.2283,0.0042,21.9983,220.3799,247.3896,1,0.0,73.9316,811 +scale,m51,18,0.0765,,,1.2495,963.8485,0.0115,0,885,0.2022,9.5601,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,7120.3554,682.3149,0.0069,18.0098,273.0251,229.288,1,0.0,55.9368,663 +scale,m51,15,0.0792,,,1.0736,1172.4888,0.0113,0,737,0.2013,9.5752,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,10724.3035,527.9666,0.0077,14.9979,406.7791,294.86,1,0.0002,53.9268,553 +scale,m51,12,0.0736,,,1.2105,1377.4526,0.0111,0,590,0.1929,9.5601,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,14936.9702,351.1119,0.0108,12.0065,364.4016,210.5112,1,0.0,38.0,442 +scale,m51,10,0.079,,,1.0548,1518.0955,0.0108,0,491,0.1839,9.5904,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,18074.8527,237.2656,0.0101,9.9918,458.9349,277.0792,1,0.0007,35.9268,369 +scale,m51,8,0.0775,,,1.084,1691.7723,0.0104,0,393,0.1689,9.579,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,19397.1487,87.8798,0.0127,7.9976,601.7459,225.9459,1,0.0006,29.3151,295 +scale,m51,6,0.0756,0,0,0.9989,1948.9691,0.0094,0,295,0.1448,9.5601,f36d2a4dc80ec56cdb48e75746c6c4f29abf0fe6c3c0903c96fb3437362962b2,13903.1585,29.806,0.0,6.0033,766.968,135.9537,0,0.0,21.9257,221 +blur,m52,0.02,0.195,,,7.2353,181.7638,0.1065,1,2400,0.1939,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,80.1971,513.8651,0.0253,59.304,82.5622,530.4053,1,0.0251,59.3023,1800 +blur,m52,0.04,0.1638,,,6.8333,41.7641,0.0931,1,2400,0.1675,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,14.6088,125.8752,0.018,59.304,14.1687,150.7118,1,0.0192,62.7907,1800 +blur,m52,0.06,0.1412,,,6.7553,13.3163,0.0828,1,2400,0.1449,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,5.5928,39.6159,0.0146,59.304,5.3889,46.8655,1,0.0135,63.0,1800 +blur,m52,0.08,0.1838,,,2.491,5.5702,0.0739,0,2400,0.1281,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,2.9995,12.5211,0.0103,59.304,1.7644,127.4834,1,0.0277,222.1725,1800 +blur,m52,0.1,0.1141,,,6.3684,2.819,0.0667,1,2400,0.1161,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,2.2126,4.2745,0.006,59.304,2.2391,6.8477,1,0.0,66.2791,1800 +blur,m52,0.13,0.1025,0,0,6.05,1.3056,0.0585,0,2400,0.1024,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,1.83,1.3642,0.0004,59.304,1.7378,2.2168,0,0.0,69.7674,1800 +blur,m52,0.16,0.0917,0,0,6.05,0.7346,0.0508,0,2400,0.0913,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,1.7186,0.6739,0.0,59.304,1.6182,0.9974,0,0.0,69.7674,1800 +blur,m52,0.2,0.0887,0,0,2.8,0.4244,0.0418,0,2400,0.0786,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,1.5859,0.4008,0.0,59.304,1.4266,5.1167,0,0.0,200.0,1800 +blur,m52,0.25,,0,0,,0.2716,0.0332,,2400,0.0652,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,1.4748,0.2718,0.0,59.304,,,0,,,1800 +blur,m52,0.3,,0,0,,0.2035,0.026,,2400,0.0547,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,1.5028,0.2019,0.0,59.304,,,0,,,1800 +edge,m52,2.0,0.213,,,2.0006,465.096,0.14,1,2400,0.2126,2.0077,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,561.6514,1470.3243,0.052,59.304,564.9344,1474.984,1,0.0498,59.3023,1490 +edge,m52,1.0,0.2464,,,1.4998,453.4063,0.1421,1,2400,0.2126,0.996,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,561.6514,1470.3243,0.052,59.304,699.1634,882.4261,1,0.0237,41.8605,1430 +edge,m52,0.5,0.2132,,,0.5289,445.1388,0.1432,1,2400,0.2126,0.507,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,561.6514,1470.3243,0.052,59.304,565.5082,1487.9821,1,0.0501,59.3023,1401 +edge,m52,0.25,0.147,,,0.9317,438.5968,0.1439,0,2400,0.2126,0.254,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,561.6514,1470.3243,0.052,59.304,462.0996,893.5822,1,0.3627,52.3256,1386 +edge,m52,0.1,0.2108,,,0.0471,433.3504,0.1444,1,2400,0.214,0.1023,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,569.0174,1491.1849,0.0528,59.304,546.4378,1540.0248,1,0.0508,62.144,1377 +edge,m52,0.0,0.2151,,,0.0587,429.2288,0.1446,1,2400,0.2149,0.0011,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,574.3211,1504.5426,0.0533,59.304,575.9876,1506.1624,1,0.0515,59.3023,1371 +edge,m52,-0.5,0.2149,0,0,0.0587,412.3407,0.1455,0,2400,0.2147,0.0,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,578.6536,1518.398,0.056,59.304,580.4236,1519.6043,0,0.0542,59.3023,1341 +edge,m52,-1.0,0.2115,0,0,0.0588,394.0154,0.1472,0,2400,0.211,0.0,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,575.504,1515.1795,0.0588,59.304,579.3435,1522.0971,0,0.0564,59.3023,1312 +edge,m52,-2.0,0.2239,0,0,0.125,371.8893,0.1455,0,2400,0.21,0.0,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,585.3531,1553.1984,0.0648,59.304,529.3752,1185.5868,0,0.025,55.814,1252 +glare,m52,0.3,0.1648,,,7.2353,406.2899,0.1222,1,2400,0.1645,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,355.981,930.6602,0.0813,59.304,357.9698,932.8555,1,0.0791,59.3023,1800 +glare,m52,0.5,0.1343,,,7.2353,387.4622,0.1263,1,2400,0.134,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,247.2758,648.038,0.1318,59.304,248.6107,649.0317,1,0.1297,59.3023,1800 +glare,m52,0.7,0.1062,,,7.2353,371.3239,0.1383,1,2400,0.106,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,162.416,426.3647,0.3207,59.304,163.2311,426.5584,1,0.3194,59.3023,1800 +glare,m52,0.85,0.088,,,7.2353,361.0114,0.151,1,2400,0.088,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,114.0137,300.4966,0.4961,59.304,114.0137,300.4966,1,0.4961,59.3023,1800 +glare,m52,1.0,0.0905,0,0,7.8076,352.1892,0.1627,0,2400,0.075,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,78.6793,208.5178,0.6076,59.304,70.9912,147.2271,0,0.3736,54.9198,1800 +glare,m52,1.5,0.147,0,0,7.3333,331.0638,0.1885,0,2400,0.0423,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,22.0629,59.0817,0.8263,59.304,457.0536,879.3791,0,0.3636,52.3256,1800 +glare,m52,2.5,0.147,0,0,7.3333,307.3677,0.2151,0,2400,0.0037,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,0.2692,0.3518,0.9921,59.304,457.0536,879.3791,0,0.3636,52.3256,1800 +glare,m52,4.0,0.147,0,0,7.3333,288.9168,0.2359,0,2400,0.0,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,0.0,0.0,1.0,59.304,457.0536,879.3791,0,0.3636,52.3256,1800 +motion,m52,0.05,0.2066,1,1,7.2353,346.8331,0.1147,0,2400,0.2066,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,291.3566,1097.7484,0.0322,59.304,291.3566,1097.7484,0,0.0322,59.3023,1800 +motion,m52,0.1,0.1949,,,7.2353,222.3327,0.1108,1,2400,0.1948,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,178.9837,713.8318,0.026,59.304,178.2,710.7717,1,0.0258,59.3023,1800 +motion,m52,0.15,0.1801,,,7.0251,173.0444,0.1035,1,2400,0.1825,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,144.1604,565.2513,0.0213,59.304,137.9523,572.5949,1,0.0213,61.0073,1800 +motion,m52,0.2,0.1699,,,7.1765,150.4615,0.1004,0,2400,0.1711,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,122.4883,470.0726,0.0199,59.304,120.7718,462.9133,1,0.0192,59.3023,1800 +motion,m52,0.3,0.1532,,,7.1765,127.6536,0.0931,1,2400,0.1545,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,97.6306,367.5181,0.0165,59.304,96.1651,360.7678,1,0.0163,59.3023,1800 +motion,m52,0.4,0.1469,0,0,7.625,115.6155,0.0875,0,2400,0.145,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,82.9865,312.6302,0.0142,59.304,88.8013,287.3676,0,0.0146,55.814,1800 +motion,m52,0.5,0.1393,0,0,13.8571,107.7043,0.0826,0,2400,0.1377,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,72.931,274.4498,0.0119,59.304,109.8403,274.2553,0,0.0065,48.8372,1800 +motion,m52,0.7,,0,0,,97.4149,0.0739,,2400,0.1284,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,60.0231,226.4826,0.0082,59.304,,,0,,,1800 +motion,m52,1.0,,0,0,,88.4628,0.0647,,2400,0.1217,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,47.9349,182.864,0.0032,59.304,,,0,,,1800 +scale,m52,48,0.2126,,,7.2341,447.4408,0.1194,1,1943,0.2126,7.2338,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,968.1793,1459.9416,0.0505,48.0115,968.1793,1459.9416,1,0.0505,48.0102,1457 +scale,m52,40,0.2123,,,7.2338,443.7497,0.1189,1,1619,0.2123,7.2335,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,1543.1802,1439.1456,0.0517,40.0055,1543.1802,1439.1456,1,0.0517,40.0044,1214 +scale,m52,32,0.2118,,,7.1856,431.7387,0.1182,1,1295,0.2115,7.2332,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,2521.5056,2521.5056,0.0493,31.9994,2535.8924,2535.8924,1,0.0473,31.9753,971 +scale,m52,26,0.145,,,7.7273,418.0952,0.1172,0,1052,0.2107,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,3624.5399,1095.4856,0.0499,25.9949,2934.3553,506.0582,1,0.326,22.0,789 +scale,m52,22,0.2103,,,7.2727,947.614,0.1162,1,890,0.2088,7.2404,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,4489.6272,790.4813,0.0522,21.9919,4506.7248,799.606,1,0.0503,22.0,668 +scale,m52,18,0.1982,,,6.5,1119.2259,0.1151,1,728,0.2066,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,5899.8433,561.3898,0.0486,17.9889,5317.3737,701.9767,1,0.0456,20.0,546 +scale,m52,15,0.1968,,,6.6214,1274.7102,0.1131,0,607,0.2055,7.2311,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,7894.5166,398.6148,0.046,14.999,7028.4616,411.457,1,0.0453,15.9737,455 +scale,m52,12,0.1972,,,7.0,1440.5275,0.1102,1,486,0.1995,7.2251,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,11514.2923,277.6848,0.0502,12.0091,11211.2456,277.3763,1,0.0486,12.0,364 +scale,m52,10,0.1503,,,6.516,1643.3243,0.1075,0,405,0.193,7.241,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,14859.9311,199.1623,0.0545,10.0076,3591.9908,95.4219,1,0.0907,11.9704,304 +scale,m52,8,0.1679,,,5.7762,1880.4342,0.1036,1,324,0.1814,7.235,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,17562.7614,82.4738,0.0602,8.006,13385.1757,178.2826,1,0.0659,10.0,243 +scale,m52,6,0.142,0,1,4.2173,2290.6808,0.0959,0,243,0.1568,7.2251,89a9b181641c44d6a155ff5b52f56de2efe47abd4a2949de1a4e5e08da61c87a,13188.9988,28.5704,0.052,6.0045,8383.9312,111.0566,0,0.05,9.959,182 +blur,m53,0.02,0.1232,,,7.4162,117.6761,0.0057,1,2692,0.1232,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,9.064,333.897,0.0,105.7418,9.064,333.897,1,0.0,106.1827,3590 +blur,m53,0.04,0.0383,,,1.2295,23.0875,0.0045,0,2692,0.109,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,1.5787,34.5229,0.0,105.7418,1.0294,2.2954,1,0.0,140.273,3590 +blur,m53,0.06,0.0416,,,8.7665,6.9934,0.0033,1,2692,0.1006,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,1.2777,7.5048,0.0,105.7418,1.1772,2.1784,1,0.0,92.0741,3590 +blur,m53,0.08,,0,0,,2.8871,0.0025,,2692,0.0939,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,1.2948,2.7071,0.0,105.7418,,,0,,,3590 +blur,m53,0.1,,0,0,,1.4433,0.0018,,2692,0.0883,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,1.3011,1.3214,0.0,105.7418,,,0,,,3590 +blur,m53,0.13,,0,0,,0.6627,0.0009,,2692,0.0819,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,1.2495,0.5838,0.0,105.7418,,,0,,,3590 +blur,m53,0.16,,0,0,,0.3933,0.0002,,2692,0.0775,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,1.1973,0.32,0.0,105.7418,,,0,,,3590 +blur,m53,0.2,,0,0,,0.2554,0.0,,2692,0.0742,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,1.0739,0.2033,0.0,105.7418,,,0,,,3590 +blur,m53,0.25,,0,0,,0.1915,0.0,,2692,0.0727,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,0.9842,0.1675,0.0,105.7418,,,0,,,3590 +blur,m53,0.3,,0,0,,0.1653,0.0,,2692,0.0733,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,0.9019,0.1388,0.0,105.7418,,,0,,,3590 +edge,m53,2.0,0.0971,,,2.3584,304.1188,0.0078,1,2692,0.1476,2.0004,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,339.5602,1619.6752,0.0008,105.7418,331.1942,1278.7309,1,0.0012,89.4545,2126 +edge,m53,1.0,0.0992,,,1.2485,281.4985,0.0073,1,2692,0.1476,0.9979,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,339.5602,1619.6752,0.0008,105.7418,344.8318,1321.2858,1,0.0013,84.8257,2020 +edge,m53,0.5,0.0972,,,0.5679,265.0063,0.0072,1,2692,0.1476,0.4967,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,339.5602,1619.6752,0.0008,105.7418,338.0412,1326.05,1,0.0012,89.4275,1967 +edge,m53,0.25,0.1,,,0.2904,259.7991,0.007,1,2692,0.1476,0.2508,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,339.5602,1619.6752,0.0008,105.7418,355.3983,1315.1419,1,0.0013,84.154,1941 +edge,m53,0.1,0.1011,,,0.0972,255.8121,0.0069,1,2692,0.1484,0.0995,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,323.0633,1654.4471,0.0008,105.7418,328.5031,1293.4699,1,0.0013,84.8532,1925 +edge,m53,0.0,0.1023,,,0.0066,254.2878,0.0068,1,2692,0.1501,0.0,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,330.5132,1668.1527,0.0009,105.7418,364.3656,1347.6179,1,0.0014,81.3998,1914 +edge,m53,-0.5,0.1057,0,0,0.3993,243.6217,0.0064,0,2692,0.1565,0.0,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,320.799,1787.2971,0.0007,105.7418,324.4523,1346.4002,0,0.0013,79.0384,1862 +edge,m53,-1.0,0.0436,0,0,1.1223,234.0539,0.006,0,2692,0.1633,0.0,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,276.4529,1857.8242,0.0008,105.7418,13.4629,27.6786,0,0.0,153.2082,1809 +edge,m53,-2.0,0.0435,0,0,1.1335,208.9976,0.005,0,2692,0.1809,0.0,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,199.3372,1955.4932,0.0005,105.7418,13.4002,27.3787,0,0.0,152.0432,1703 +glare,m53,0.3,0.1282,,,7.4479,339.1175,0.0068,1,2692,0.1282,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,218.2232,1038.3349,0.0014,105.7418,218.2232,1038.3349,1,0.0014,105.7838,3590 +glare,m53,0.5,0.1219,,,6.5802,329.9727,0.0068,1,2692,0.1168,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,153.6422,732.7404,0.0021,105.7418,157.7301,783.6138,1,0.0018,119.3467,3590 +glare,m53,0.7,0.1071,,,7.4761,321.9804,0.0068,1,2692,0.107,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,103.2929,493.2677,0.0043,105.7418,102.7817,487.4771,1,0.0043,105.3848,3590 +glare,m53,0.85,0.1012,,,7.4761,316.7567,0.0072,1,2692,0.101,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,74.5939,357.6118,0.0485,105.7418,74.3,352.7037,1,0.0488,105.3848,3590 +glare,m53,1.0,0.039,0,0,1.3196,312.153,0.0095,0,2692,0.0964,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,53.5551,259.1568,0.3127,105.7418,11.6348,17.181,0,0.0,134.7117,3590 +glare,m53,1.5,0.039,0,0,1.3196,300.5689,0.015,0,2692,0.0753,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,20.5456,97.3182,0.6439,105.7418,11.6348,17.181,0,0.0,134.7117,3590 +glare,m53,2.5,0.039,0,0,1.3196,286.7852,0.0226,0,2692,0.0335,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,3.0593,9.9645,0.8999,105.7418,11.6348,17.181,0,0.0,134.7117,3590 +glare,m53,4.0,0.039,0,0,1.3196,274.7545,0.0294,0,2692,0.0001,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,0.0007,0.0,1.0,105.7418,11.6348,17.181,0,0.0,134.7117,3590 +motion,m53,0.05,0.136,,,7.4162,268.6534,0.0065,1,2692,0.136,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,113.0632,965.6693,0.0003,105.7418,113.0632,965.6693,1,0.0003,106.1827,3590 +motion,m53,0.1,0.0671,,,8.9623,156.668,0.006,1,2692,0.1221,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,62.2442,357.7167,0.0001,105.7418,57.6592,294.9508,1,0.0001,89.7029,3590 +motion,m53,0.15,0.1068,,,7.272,119.3778,0.0055,1,2692,0.1163,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,46.9142,252.6537,0.0,105.7418,40.5519,232.7665,1,0.0,109.2592,3590 +motion,m53,0.2,0.0561,,,8.6424,99.5156,0.0051,1,2692,0.1101,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,37.2122,192.4871,0.0,105.7418,31.6883,145.6949,1,0.0,93.4041,3590 +motion,m53,0.3,0.1171,0,0,13.0275,75.1465,0.0043,0,2692,0.1023,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,25.5856,131.7584,0.0,105.7418,15.1301,137.771,0,0.0,73.7739,3590 +motion,m53,0.4,,0,0,,62.0291,0.0038,,2692,0.096,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,19.9785,102.2718,0.0,105.7418,,,0,,,3590 +motion,m53,0.5,,0,0,,52.0835,0.0032,,2692,0.0907,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,16.0005,82.3072,0.0,105.7418,,,0,,,3590 +motion,m53,0.7,,0,0,,39.9173,0.0027,,2692,0.0869,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,11.7026,58.6212,0.0,105.7418,,,0,,,3590 +motion,m53,1.0,,0,0,,29.8844,0.0023,,2692,0.0842,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,8.4014,39.8582,0.0,105.7418,,,0,,,3590 +scale,m53,48,0.1451,,,7.4161,360.7523,0.0069,1,1222,0.1453,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,2005.3735,1529.526,0.0016,48.0002,2020.2611,1641.5046,1,0.0015,48.2005,1630 +scale,m53,40,0.039,,,1.273,355.6301,0.007,0,1018,0.144,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,2609.7042,1533.3695,0.0014,39.987,26.5941,18.6822,1,0.0,52.0506,1358 +scale,m53,32,0.1724,,,5.8723,338.7244,0.0071,1,815,0.1413,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,3193.0483,3193.0483,0.0006,32.0132,3285.426,1795.2869,1,0.0004,39.951,1086 +scale,m53,26,0.1669,,,6.1424,801.735,0.0071,1,662,0.1376,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,3560.0003,943.012,0.0004,26.0034,4115.053,1533.1661,1,0.0003,31.3438,883 +scale,m53,22,0.0419,,,1.0252,982.2441,0.0072,0,560,0.1346,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,3671.3862,580.3238,0.0,21.9968,48.3384,48.3384,1,0.0,31.9024,747 +scale,m53,18,0.1342,,,6.87,1210.5986,0.0073,1,458,0.1289,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,3217.287,284.0295,0.0,17.9902,3156.679,351.9664,1,0.0,19.6276,611 +scale,m53,15,0.1365,,,9.7111,1401.7198,0.0071,0,382,0.1214,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,2872.8015,143.0051,0.0006,15.005,3835.264,88.2987,1,0.0009,11.7931,509 +scale,m53,12,,0,0,,1616.4442,0.007,,305,0.1141,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,2886.8473,70.3263,0.0,11.9804,,,0,,,407 +scale,m53,10,0.1953,0,0,5.708,1792.2799,0.0071,0,255,0.1263,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,2876.6226,41.1556,0.0,10.0164,6465.6227,180.6643,0,0.0,13.1461,340 +scale,m53,8,,0,0,,2121.7575,0.0069,,204,0.1126,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,2009.992,12.5133,0.0,8.0131,,,0,,,272 +scale,m53,6,,0,0,,2551.4523,0.0069,,153,0.1233,7.4514,f50093081eda4b6aac894bf38aea58f64a4102c78d45cd48a3c50a3ced0103ac,2276.2134,7.625,0.0,6.0098,,,0,,,204 +blur,m54,0.02,0.1934,,,13.4,208.0536,0.0325,1,1260,0.1934,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,278.5667,314.4091,0.0378,36.6282,278.5667,314.4091,1,0.0378,36.6279,1680 +blur,m54,0.04,0.1846,,,12.5625,51.5119,0.026,1,1260,0.1709,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,62.2902,90.1639,0.0146,36.6282,60.5603,112.4237,1,0.0245,39.0698,1680 +blur,m54,0.06,0.165,,,12.5625,16.2569,0.0217,1,1260,0.1487,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,21.8505,31.5558,0.0042,36.6282,19.8969,37.7268,1,0.0141,39.0698,1680 +blur,m54,0.08,0.1487,,,12.5625,6.8152,0.0188,1,1260,0.1295,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,9.4031,12.506,0.0,36.6282,8.7188,15.2097,1,0.0066,39.0698,1680 +blur,m54,0.1,0.1347,,,12.5625,3.4597,0.0164,1,1260,0.1136,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,5.007,5.5334,0.0,36.6282,4.8139,7.0057,1,0.0019,39.0698,1680 +blur,m54,0.13,,0,0,,1.6657,0.0137,,1260,0.096,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,2.7802,2.0586,0.0,36.6282,,,0,,,1680 +blur,m54,0.16,,0,0,,0.9936,0.0119,,1260,0.0842,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,2.1149,1.0235,0.0,36.6282,,,0,,,1680 +blur,m54,0.2,,0,0,,0.6207,0.0098,,1260,0.0749,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,1.8056,0.59,0.0,36.6282,,,0,,,1680 +blur,m54,0.25,,0,0,,0.4267,0.0076,,1260,0.0696,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,1.7197,0.4289,0.0,36.6282,,,0,,,1680 +blur,m54,0.3,,0,0,,0.3398,0.0059,,1260,0.0674,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,1.413,0.3495,0.0,36.6282,,,0,,,1680 +edge,m54,2.0,0.2191,,,1.915,277.0461,0.0512,1,1260,0.2055,2.0056,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,802.3696,659.5251,0.0635,36.6282,779.4294,780.4559,1,0.0775,39.6155,1038 +edge,m54,1.0,0.2051,,,1.0545,272.112,0.0509,1,1260,0.2055,0.9955,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,802.3696,659.5251,0.0635,36.6282,781.9695,615.6898,1,0.0627,36.0,1001 +edge,m54,0.5,0.2159,,,0.5171,272.3017,0.0515,1,1260,0.2055,0.504,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,802.3696,659.5251,0.0635,36.6282,760.9199,710.5854,1,0.0724,38.5476,983 +edge,m54,0.25,0.2156,,,0.259,270.5171,0.0518,1,1260,0.2055,0.2583,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,802.3696,659.5251,0.0635,36.6282,755.4728,705.2873,1,0.0721,38.4583,974 +edge,m54,0.1,0.2159,,,0.1026,270.5646,0.0519,1,1260,0.2061,0.0945,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,812.4519,672.6338,0.0636,36.6282,764.7425,714.6696,1,0.0725,38.5437,968 +edge,m54,0.0,0.2162,,,0.0499,270.8568,0.0521,1,1260,0.2064,0.0126,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,817.2542,669.7174,0.0639,36.6282,770.7358,732.9256,1,0.0731,38.7648,965 +edge,m54,-0.5,0.2131,0,0,0.2632,269.4019,0.0527,0,1260,0.2064,0.0,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,802.6951,660.1486,0.0644,36.6282,770.2943,717.3082,0,0.0714,38.0,946 +edge,m54,-1.0,0.2168,0,0,0.0526,265.8122,0.0531,0,1260,0.2069,0.0,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,806.9527,669.8855,0.0655,36.6282,747.0823,677.5188,0,0.0764,38.0,928 +edge,m54,-2.0,0.2067,0,0,0.2775,259.2494,0.0539,0,1260,0.2065,0.0,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,772.4258,632.2233,0.0689,36.6282,761.1082,601.776,0,0.0695,36.0,891 +glare,m54,0.3,0.1667,,,13.4,402.0022,0.0414,1,1260,0.1667,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,498.2536,407.8499,0.0741,36.6282,498.2536,407.8499,1,0.0741,36.6279,1680 +glare,m54,0.5,0.1451,,,13.4,394.7975,0.043,1,1260,0.1451,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,341.0424,277.8247,0.087,36.6282,341.0424,277.8247,1,0.087,36.6279,1680 +glare,m54,0.7,0.1327,,,12.5625,388.6849,0.0445,1,1260,0.1292,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,217.9522,176.8676,0.1088,36.6282,216.0482,203.925,1,0.1489,39.0698,1680 +glare,m54,0.85,0.1249,,,12.0827,384.838,0.0455,1,1260,0.1222,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,149.7351,120.6751,0.1441,36.6282,156.6279,161.9935,1,0.1981,40.5596,1680 +glare,m54,1.0,0.1178,0,0,13.4,381.5965,0.047,0,1260,0.1204,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,101.6938,81.1585,0.2729,36.6282,92.7832,74.4376,0,0.3196,36.6279,1680 +glare,m54,1.5,0.1596,0,0,1.5662,374.2454,0.0517,0,1260,0.0879,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,30.977,23.7869,0.6625,36.6282,403.7728,1430.4519,0,0.0001,170.78,1680 +glare,m54,2.5,0.1652,0,0,34.0258,366.0297,0.0569,0,1260,0.0261,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,1.4705,0.9309,0.8976,36.6282,2088.3858,124.3628,0,0.4391,15.2768,1680 +glare,m54,4.0,0.2319,0,0,21.5556,358.9243,0.0636,0,1260,0.0,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,0.0,0.0,1.0,36.6282,2587.4877,463.6813,0,0.4178,21.9767,1680 +motion,m54,0.05,0.202,,,13.4,334.3885,0.039,1,1260,0.2022,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,547.3675,516.1431,0.0595,36.6282,545.1669,514.6549,1,0.0594,36.6279,1680 +motion,m54,0.1,0.2051,,,12.3857,193.7393,0.0351,1,1260,0.1935,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,329.2762,332.4364,0.0556,36.6282,320.8022,396.4205,1,0.0644,39.6057,1680 +motion,m54,0.15,0.201,,,12.5625,157.2041,0.0337,1,1260,0.189,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,289.71,290.712,0.0541,36.6282,280.0192,341.6588,1,0.0615,39.0698,1680 +motion,m54,0.2,0.1929,,,12.5625,119.4335,0.0319,1,1260,0.1802,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,237.4607,234.1651,0.053,36.6282,226.9898,271.7784,1,0.0592,39.0698,1680 +motion,m54,0.3,0.1614,,,14.4286,88.7662,0.0299,1,1260,0.1665,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,194.5475,186.9718,0.0525,36.6282,186.9978,155.9284,1,0.0483,34.186,1680 +motion,m54,0.4,0.1521,0,0,14.4286,74.4408,0.0284,0,1260,0.1579,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,174.8565,166.8591,0.0521,36.6282,168.0946,139.4534,0,0.0494,34.186,1680 +motion,m54,0.5,0.0705,0,0,16.2222,68.0275,0.0273,0,1260,0.1538,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,166.4707,158.1336,0.0527,36.6282,45.4478,9.7671,0,0.0345,21.9767,1680 +motion,m54,0.7,0.1529,0,0,1.4728,57.1263,0.025,0,1260,0.1471,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,151.9134,143.5124,0.0516,36.6282,25.7674,436.9826,0,0.0,177.2703,1680 +motion,m54,1.0,0.1336,0,0,1.5417,48.6631,0.0226,0,1260,0.1447,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,141.964,134.6926,0.0527,36.6282,18.0105,295.6547,0,0.0,168.0,1680 +scale,m54,32,0.2012,,,13.4,413.8917,0.0397,1,1101,0.2046,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,1076.5439,1076.5439,0.0591,32.0061,1160.9419,1160.9419,1,0.0553,31.9439,1468 +scale,m54,26,0.2197,,,11.7806,395.2331,0.0392,1,894,0.205,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,1713.0329,540.6119,0.0544,25.9886,1797.2425,820.6941,1,0.0757,29.4773,1193 +scale,m54,22,0.2138,,,12.25,792.5689,0.0387,1,757,0.2007,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,2469.9258,457.8564,0.0504,22.006,2579.5507,613.2066,1,0.068,23.9683,1009 +scale,m54,18,0.2208,,,12.1,954.042,0.0372,1,619,0.2042,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,3471.0019,358.1761,0.0579,17.9943,2933.0024,408.6011,1,0.07,19.9677,826 +scale,m54,15,0.19,,,14.4286,1083.904,0.0358,1,516,0.2027,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,4968.3208,283.3477,0.0606,15.0001,5009.5162,238.5515,1,0.0304,14.0,688 +scale,m54,12,0.1377,,,2.94,1235.8323,0.0339,0,413,0.1976,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,7558.2757,203.8892,0.0696,12.0059,1159.7382,622.7161,1,0.0,32.4795,550 +scale,m54,10,0.1993,,,13.4,1343.4833,0.032,1,344,0.1821,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,10334.6243,171.989,0.0051,10.0001,9527.4203,126.0364,1,0.0712,10.0,459 +scale,m54,8,0.1536,0,0,1.6471,1486.9656,0.0296,0,275,0.179,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,13360.1522,75.5779,0.0047,7.9942,3396.1837,1319.6593,0,0.0,33.8768,367 +scale,m54,6,,0,0,,1669.0141,0.0255,,206,0.1791,13.3997,68c6fe975d5ff847d7da02127f92b3140eee56b961630dd9e79641a34f9082d2,14516.8003,40.5062,0.0285,5.9884,,,0,,,275 +blur,m55,0.02,0.0662,,,1.1,22.2692,0.0,1,2552,0.0655,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,1.9539,60.4013,0.0,144.0094,1.9237,71.6915,1,0.0,159.5,3840 +blur,m55,0.04,0.0556,,,1.0374,3.7619,0.0,1,2552,0.0593,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,1.378,8.2759,0.0,144.0094,1.3294,11.6229,1,0.0,165.9993,3840 +blur,m55,0.06,0.0496,,,1.2153,1.132,0.0,1,2552,0.0548,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,1.2647,2.1785,0.0,144.0094,1.25,2.108,1,0.0,144.0,3840 +blur,m55,0.08,,0,0,,0.5123,0.0,,2552,0.0512,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,1.1434,0.8537,0.0,144.0094,,,0,,,3840 +blur,m55,0.1,,0,0,,0.2984,0.0,,2552,0.0479,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,1.0194,0.4377,0.0,144.0094,,,0,,,3840 +blur,m55,0.13,,0,0,,0.1831,0.0,,2552,0.0439,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,0.8815,0.2248,0.0,144.0094,,,0,,,3840 +blur,m55,0.16,,0,0,,0.1383,0.0,,2552,0.0406,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,0.7895,0.1521,0.0,144.0094,,,0,,,3840 +blur,m55,0.2,,0,0,,0.1142,0.0,,2552,0.037,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,0.7156,0.1326,0.0,144.0094,,,0,,,3840 +blur,m55,0.25,,0,0,,0.1021,0.0,,2552,0.0337,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,0.6075,0.1456,0.0,144.0094,,,0,,,3840 +blur,m55,0.3,,0,0,,0.0974,0.0,,2552,0.031,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,0.512,0.1424,0.0,144.0094,,,0,,,3840 +edge,m55,2.0,0.0698,,,1.536,86.7526,0.0,1,2552,0.0739,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,90.5726,243.4395,0.0,144.0094,95.6088,221.1894,1,0.0,120.433,3280 +edge,m55,1.0,0.0713,,,1.2342,84.496,0.0,1,2552,0.0739,0.9998,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,90.5726,243.4395,0.0,144.0094,93.7963,223.77,1,0.0,125.3792,3136 +edge,m55,0.5,0.0536,,,0.7335,82.5603,0.0,1,2552,0.0739,0.4999,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,90.5726,243.4395,0.0,144.0094,83.7552,143.9581,1,0.0,114.8393,3064 +edge,m55,0.25,0.0668,,,0.3572,81.656,0.0,1,2552,0.0739,0.2499,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,90.5726,243.4395,0.0,144.0094,94.0807,219.3377,1,0.0,123.2,3028 +edge,m55,0.1,0.0664,,,0.2434,81.3539,0.0,1,2552,0.0741,0.0971,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,91.1832,249.0329,0.0,144.0094,94.1161,228.045,1,0.0,124.6804,3006 +edge,m55,0.0,0.0706,,,0.175,80.7951,0.0,1,2552,0.0748,0.0,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,91.9586,254.9514,0.0,144.0094,95.5302,227.703,1,0.0,123.3118,2992 +edge,m55,-0.5,0.0704,0,0,0.0302,78.4183,0.0,0,2552,0.0778,0.0,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,95.9487,279.8683,0.0,144.0094,99.9155,251.8239,0,0.0,123.3289,2920 +edge,m55,-1.0,0.0723,0,0,0.0303,75.0746,0.0,0,2552,0.0816,0.0,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,100.9399,307.9438,0.0,144.0094,105.5964,276.9871,0,0.0,123.1034,2848 +edge,m55,-2.0,0.1063,0,0,0.0307,65.2451,0.0,0,2552,0.0915,0.0,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,114.5458,369.0738,0.0,144.0094,215.5575,619.8498,0,0.0,112.6645,2704 +glare,m55,0.3,0.084,,,1.0937,92.5217,0.0,1,2552,0.0831,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,61.1025,161.2961,0.0,144.0094,59.1401,167.9855,1,0.0,160.0,3840 +glare,m55,0.5,0.0935,,,1.0023,90.7026,0.0,1,2552,0.0987,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,44.643,117.1137,0.0,144.0094,40.5027,116.0983,1,0.0,169.8765,3840 +glare,m55,0.7,0.1131,,,1.2359,89.118,0.0,1,2552,0.1187,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,31.3858,81.5863,0.0,144.0094,29.299,75.2136,1,0.0,147.0131,3840 +glare,m55,0.85,0.0967,,,1.758,88.0604,0.0,1,2552,0.1357,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,23.6142,60.873,0.0,144.0094,13.7954,17.36,1,0.0,110.0543,3840 +glare,m55,1.0,0.153,0,0,5.1444,87.1096,0.0011,0,2552,0.1533,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,17.636,44.9831,0.0729,144.0094,262.6682,1793.1558,0,0.0,189.8643,3840 +glare,m55,1.5,0.153,0,0,5.1444,84.6496,0.0136,0,2552,0.133,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,7.1259,16.8214,0.5625,144.0094,262.6682,1793.1558,0,0.0,189.8643,3840 +glare,m55,2.5,0.153,0,0,5.1444,81.5738,0.0278,0,2552,0.0532,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,1.0243,1.9762,0.8482,144.0094,262.6682,1793.1558,0,0.0,189.8643,3840 +glare,m55,4.0,0.153,0,0,5.1444,79.049,0.0388,0,2552,0.0,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,0.0,0.0,1.0,144.0094,262.6682,1793.1558,0,0.0,189.8643,3840 +motion,m55,0.05,0.0692,,,1.2153,57.7622,0.0,1,2552,0.0692,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,12.9739,142.4013,0.0,144.0094,12.9739,142.4013,1,0.0,144.0,3840 +motion,m55,0.1,0.0429,,,1.8051,31.1604,0.0,1,2552,0.0648,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,7.3187,57.42,0.0,144.0094,7.859,35.1114,1,0.0,107.8045,3840 +motion,m55,0.15,0.0623,,,1.0637,21.8651,0.0,1,2552,0.0611,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,5.3104,35.5031,0.0,144.0094,5.1466,40.2678,1,0.0,163.2019,3840 +motion,m55,0.2,0.0379,,,1.6271,18.317,0.0,1,2552,0.0583,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,4.3815,25.2841,0.0,144.0094,4.5975,19.0165,1,0.0,118.1363,3840 +motion,m55,0.3,0.0606,0,0,5.2672,14.5008,0.0,0,2552,0.054,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,3.444,17.3267,0.0,144.0094,7.1292,32.6295,0,0.0,95.4179,3840 +motion,m55,0.4,,0,0,,12.2231,0.0,,2552,0.0503,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,2.8469,12.8239,0.0,144.0094,,,0,,,3840 +motion,m55,0.5,0.0406,0,0,9.728,10.7448,0.0,0,2552,0.0478,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,2.525,11.0611,0.0,144.0094,6.9746,25.1241,0,0.0,77.8533,3840 +motion,m55,0.7,,0,0,,8.5485,0.0,,2552,0.0442,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,2.214,8.7681,0.0,144.0094,,,0,,,3840 +motion,m55,1.0,,0,0,,6.3677,0.0,,2552,0.0424,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,1.9087,6.9796,0.0,144.0094,,,0,,,3840 +scale,m55,48,0.0689,,,1.3792,94.451,0.0,1,851,0.0729,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,243.4607,244.9144,0.0,48.0219,256.1618,229.0385,1,0.0,43.0537,1280 +scale,m55,40,0.052,,,1.6402,91.9276,0.0,1,709,0.0726,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,330.0354,237.2536,0.0,40.0089,280.357,143.1075,1,0.0,32.6609,1067 +scale,m55,32,0.0626,,,1.5762,223.8566,0.0,1,567,0.0719,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,459.905,459.905,0.0,31.9958,502.7808,157.1752,1,0.0,26.7345,853 +scale,m55,26,0.0625,,,1.5217,299.8591,0.0,1,461,0.0707,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,585.5876,155.2682,0.0,26.0142,619.0876,107.4007,1,0.0,22.2104,693 +scale,m55,22,0.0675,,,1.2929,368.8961,0.0,1,390,0.0705,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,652.9412,108.9317,0.0,22.0077,643.7213,93.6849,1,0.0,21.1952,587 +scale,m55,18,0.0689,,,1.375,451.9533,0.0,1,319,0.0673,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,659.5603,57.8441,0.0,18.0012,744.0828,38.688,1,0.0,15.95,480 +scale,m55,15,0.0633,,,1.125,520.3955,0.0,1,266,0.0664,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,633.0413,32.2846,0.0,15.0104,592.2005,27.0747,1,0.0,16.0,400 +scale,m55,12,0.067,,,0.75,586.0654,0.0,0,213,0.067,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,747.1311,19.2829,0.0,12.0196,735.2199,36.8686,1,0.0,15.9252,320 +scale,m55,10,0.0671,0,0,3.4,653.6912,0.0,0,177,0.065,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,752.9952,10.704,0.0,9.9881,2148.1428,25.9427,0,0.0,9.9438,267 +scale,m55,8,,0,0,,752.6482,0.0,,142,0.064,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,851.7786,4.5423,0.0,8.0131,,,0,,,213 +scale,m55,6,,0,0,,888.8028,0.0,,106,0.0613,1.2151,7bfeb36638b5b39997b6ca7d1eef6fa21bcfd0c3f4637b97251d62ae899b4b2e,960.8606,2.613,0.0,5.9816,,,0,,,160 +blur,m56,0.02,0.1153,,,6.8649,33.3892,0.0013,1,1944,0.1153,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,8.3467,72.8728,0.0,64.2103,8.3046,72.4149,1,0.0,64.2093,2592 +blur,m56,0.04,0.1126,,,5.6505,12.0606,0.0009,1,1944,0.1062,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,3.5447,25.6107,0.0,64.2103,3.2976,43.4746,1,0.0,76.6016,2592 +blur,m56,0.06,0.1048,,,5.6449,4.4378,0.0005,1,1944,0.0974,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,2.1391,8.8622,0.0,64.2103,2.0078,15.3364,1,0.0,76.6714,2592 +blur,m56,0.08,0.098,,,5.6974,1.9101,0.0003,1,1944,0.0903,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,1.7179,3.469,0.0,64.2103,1.6456,5.9991,1,0.0,76.0,2592 +blur,m56,0.1,0.1166,,,4.037,0.9761,0.0002,0,1944,0.0848,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,1.6101,1.5339,0.0,64.2103,1.5747,9.3775,1,0.0,101.7209,2592 +blur,m56,0.13,0.0707,0,0,6.0526,0.4694,0.0,0,1944,0.0785,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,1.6013,0.6054,0.0,64.2103,1.6427,1.0321,0,0.0,71.5814,2592 +blur,m56,0.16,,0,0,,0.2918,0.0,,1944,0.0739,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,1.6139,0.3323,0.0,64.2103,,,0,,,2592 +blur,m56,0.2,,0,0,,0.2022,0.0,,1944,0.0697,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,1.5306,0.2085,0.0,64.2103,,,0,,,2592 +blur,m56,0.25,,0,0,,0.1588,0.0,,1944,0.0669,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,1.424,0.1617,0.0,64.2103,,,0,,,2592 +blur,m56,0.3,,0,0,,0.1398,0.0,,1944,0.0658,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,1.301,0.1301,0.0,64.2103,,,0,,,2592 +edge,m56,2.0,0.1294,,,1.8756,42.872,0.0027,1,1944,0.1203,2.0051,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,45.3992,137.8426,0.0,64.2103,48.4649,204.1124,1,0.0,73.2,1696 +edge,m56,1.0,0.1203,,,1.0386,42.665,0.0028,1,1944,0.1203,0.9928,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,45.3992,137.8426,0.0,64.2103,45.0043,148.3263,1,0.0,65.2093,1631 +edge,m56,0.5,0.1203,,,0.5622,42.1411,0.0029,1,1944,0.1203,0.4944,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,45.3992,137.8426,0.0,64.2103,45.0267,148.1194,1,0.0,65.2093,1599 +edge,m56,0.25,0.1203,,,0.3029,41.8118,0.0029,1,1944,0.1203,0.2452,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,45.3992,137.8426,0.0,64.2103,45.1803,149.2137,1,0.0,65.2093,1583 +edge,m56,0.1,0.1205,,,0.1733,41.6435,0.0029,1,1944,0.1206,0.1051,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,45.9591,140.4392,0.0,64.2103,45.4924,150.445,1,0.0,65.2093,1574 +edge,m56,0.0,0.1208,,,0.0866,41.5857,0.0029,1,1944,0.121,0.0,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,46.3993,141.3944,0.0,64.2103,45.8591,151.7306,1,0.0,65.2093,1567 +edge,m56,-0.5,0.1214,,,0.3462,41.0062,0.003,1,1944,0.1216,0.0,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,48.1433,147.5949,0.0,64.2103,47.7556,159.1109,1,0.0,65.2093,1535 +edge,m56,-1.0,0.1234,,,0.0453,40.5038,0.003,1,1944,0.1229,0.0,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,49.0847,151.1963,0.0,64.2103,49.7627,149.9543,1,0.0,62.2093,1503 +edge,m56,-2.0,0.1149,0,0,0.0433,38.9025,0.0028,0,1944,0.115,0.0,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,47.4788,144.8065,0.0,64.2103,46.9098,154.6903,0,0.0,65.2093,1439 +glare,m56,0.3,0.0945,,,6.8649,56.1756,0.0018,0,1944,0.0946,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,28.7906,81.8137,0.0,64.2103,28.672,81.3284,1,0.0,64.2093,2592 +glare,m56,0.5,0.0813,,,6.8649,54.0269,0.0018,0,1944,0.0814,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,19.4008,52.8855,0.0,64.2103,19.3366,52.5839,1,0.0,64.2093,2592 +glare,m56,0.7,0.0732,,,6.8649,52.1925,0.0018,0,1944,0.0734,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,12.3165,31.1062,0.0,64.2103,12.2899,30.9422,1,0.0,64.2093,2592 +glare,m56,0.85,0.0817,,,6.5116,51.014,0.0018,0,1944,0.0719,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,8.358,19.2736,0.0,64.2103,6.7812,16.9457,1,0.0,67.4329,2592 +glare,m56,1.0,,0,0,,50.015,0.0056,,1944,0.0747,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,5.4823,11.3945,0.2452,64.2103,,,0,,,2592 +glare,m56,1.5,,0,0,,47.5249,0.0247,,1944,0.0519,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,1.391,1.6737,0.6485,64.2103,,,0,,,2592 +glare,m56,2.5,0.1556,0,0,6.4545,44.5362,0.049,0,1944,0.0113,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,0.1431,0.0209,0.9367,64.2103,54.5668,663.5284,0,0.0,118.8,2592 +glare,m56,4.0,0.0927,0,0,9.3774,42.0238,0.0712,0,1944,0.0,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,0.0,0.0,1.0,64.2103,25.1764,95.4801,0,0.054,79.2718,2592 +motion,m56,0.05,0.119,,,6.8649,51.4142,0.0016,1,1944,0.119,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,23.7171,115.9239,0.0,64.2103,23.7171,115.9239,1,0.0,64.2093,2592 +motion,m56,0.1,0.1158,,,6.8649,35.8663,0.0014,1,1944,0.1158,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,14.8686,82.3042,0.0,64.2103,14.8686,82.3042,1,0.0,64.2093,2592 +motion,m56,0.15,0.1166,,,6.0139,23.1934,0.0012,1,1944,0.1105,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,10.2576,55.1285,0.0,64.2103,9.7115,76.2324,1,0.0,72.0,2592 +motion,m56,0.2,0.1091,,,6.4265,18.255,0.001,1,1944,0.1066,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,8.4849,43.9267,0.0,64.2103,8.2137,52.864,1,0.0,68.0,2592 +motion,m56,0.3,0.1026,,,6.4265,13.3526,0.0009,1,1944,0.0996,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,6.6614,32.5079,0.0,64.2103,6.4473,39.2778,1,0.0,68.0,2592 +motion,m56,0.4,0.0966,,,6.4265,10.6181,0.0008,1,1944,0.0932,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,5.4459,25.2104,0.0,64.2103,5.2697,30.5348,1,0.0,68.0,2592 +motion,m56,0.5,0.0924,,,6.4265,9.2492,0.0008,0,1944,0.089,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,4.822,21.4882,0.0,64.2103,4.6849,26.2169,1,0.0,68.0,2592 +motion,m56,0.7,0.08,0,0,5.5219,7.5327,0.0007,0,1944,0.0827,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,3.9043,16.6228,0.0,64.2103,3.5858,31.6998,0,0.0,79.1395,2592 +motion,m56,1.0,0.0836,0,0,4.8696,6.2473,0.0005,0,1944,0.0791,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,3.335,13.5104,0.0,64.2103,3.6764,41.6961,0,0.0,86.6512,2592 +scale,m56,48,0.1219,,,6.3927,60.8385,0.0017,1,1453,0.1206,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,80.9676,141.4997,0.0,47.9926,79.8832,166.9037,1,0.0,51.0966,1938 +scale,m56,40,0.12,,,6.8385,61.2944,0.0017,1,1211,0.12,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,119.7259,140.5486,0.0,39.9993,119.157,140.8853,1,0.0,40.1529,1615 +scale,m56,32,0.1219,,,6.6972,60.383,0.0017,0,969,0.12,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,207.3395,207.3395,0.0,32.0061,208.8168,147.2115,1,0.0,32.7566,1292 +scale,m56,26,0.1351,,,4.8412,59.1388,0.0016,1,787,0.1201,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,362.5661,120.167,0.0,25.9946,349.7761,285.1363,1,0.0,35.2219,1050 +scale,m56,22,0.1234,,,6.8182,133.7438,0.0016,1,666,0.1196,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,570.1496,105.1311,0.0,21.998,596.5886,114.5706,1,0.0,22.0,888 +scale,m56,18,0.1195,,,13.1429,206.9683,0.0015,0,545,0.12,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,982.8305,99.6907,0.0,18.0013,1377.3014,65.4369,1,0.0,13.9744,727 +scale,m56,15,0.132,,,5.5556,298.1225,0.0015,1,454,0.1192,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,1493.0147,77.7961,0.0,14.9956,1432.6131,135.6913,1,0.0,18.0,606 +scale,m56,12,0.119,,,6.8333,433.5009,0.0014,1,363,0.119,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,2436.7323,59.5763,0.0,11.9899,2436.3481,59.081,1,0.0,11.967,484 +scale,m56,10,0.1186,,,5.6667,543.4623,0.0013,1,303,0.1166,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,3145.6378,42.0933,0.0,10.0081,2513.7686,60.9834,1,0.0,11.9605,404 +scale,m56,8,0.1336,,,4.3333,671.9856,0.0011,1,242,0.1124,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,3995.7108,19.0288,0.0,7.9933,2894.9037,70.0762,1,0.0,12.0,323 +scale,m56,6,0.1231,0,0,6.6667,792.2156,0.0008,0,182,0.106,6.8647,018701e5b75f5c6888ea5e08bc51926abce9df70b4ca84eb187bfb386680dbaf,4827.1126,10.7062,0.0,6.0115,4758.4666,12.1341,0,0.0,6.0,242 +blur,m57,0.02,0.1511,,,9.7296,36.4654,0.0,0,1356,0.1089,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,108.6083,226.1338,0.0,42.5242,106.9128,374.6114,1,0.0,50.528,1292 +blur,m57,0.04,0.1309,,,10.3028,8.6973,0.0,0,1356,0.088,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,23.9047,57.2339,0.0,42.5242,23.8049,86.2187,1,0.0,47.7394,1292 +blur,m57,0.06,0.1287,,,9.4586,2.8738,0.0,0,1356,0.0707,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,6.9215,15.0295,0.0,42.5242,6.6485,29.9403,1,0.0,51.9499,1292 +blur,m57,0.08,0.1209,,,9.4913,1.4089,0.0,0,1356,0.0576,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,3.2509,5.0542,0.0,42.5242,3.2147,10.1978,1,0.0,51.573,1292 +blur,m57,0.1,0.098,,,9.9357,0.8739,0.0,0,1356,0.0483,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,2.1856,1.9005,0.0,42.5242,2.3208,3.6636,1,0.0,49.3042,1292 +blur,m57,0.13,,0,0,,0.5401,0.0,,1356,0.0391,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,1.7862,0.65,0.0,42.5242,,,0,,,1292 +blur,m57,0.16,,0,0,,0.3914,0.0,,1356,0.033,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,1.7528,0.4026,0.0,42.5242,,,0,,,1292 +blur,m57,0.2,,0,0,,0.3031,0.0,,1356,0.0274,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,1.6943,0.3008,0.0,42.5242,,,0,,,1292 +blur,m57,0.25,,0,0,,0.2579,0.0,,1356,0.0231,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,1.5017,0.2714,0.0,42.5242,,,0,,,1292 +blur,m57,0.3,,0,0,,0.2359,0.0,,1356,0.0207,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,1.3221,0.2583,0.0,42.5242,,,0,,,1292 +edge,m57,2.0,0.1557,,,1.848,99.2918,0.0001,0,1356,0.1209,1.9973,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,279.7141,432.9524,0.0,42.5242,273.4058,630.4726,1,0.0,48.5247,841 +edge,m57,1.0,0.1557,,,0.9822,99.2009,0.0001,0,1356,0.1209,1.0097,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,279.7141,432.9524,0.0,42.5242,273.4058,630.4726,1,0.0,48.5832,799 +edge,m57,0.5,0.1579,,,0.5286,98.157,0.0001,0,1356,0.1209,0.4923,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,279.7141,432.9524,0.0,42.5242,272.8529,617.6639,1,0.0,48.6814,777 +edge,m57,0.25,0.1579,,,0.3218,97.9664,0.0001,0,1356,0.1209,0.2571,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,279.7141,432.9524,0.0,42.5242,272.8529,636.1807,1,0.0,48.8874,767 +edge,m57,0.1,0.1556,,,0.1598,97.4915,0.0001,0,1356,0.1227,0.0925,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,290.8686,455.0423,0.0,42.5242,274.9952,637.2777,1,0.0,48.6383,760 +edge,m57,0.0,0.1556,,,0.078,97.1969,0.0001,0,1356,0.1238,0.0,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,297.5153,465.4304,0.0,42.5242,278.5288,641.7577,1,0.0,48.5201,756 +edge,m57,-0.5,0.1116,0,0,3.204,94.6321,0.0001,0,1356,0.1231,0.0,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,280.0346,426.4083,0.0,42.5242,301.5525,503.0518,0,0.0,48.0,735 +edge,m57,-1.0,0.1207,0,0,1.4515,91.9071,0.0001,0,1356,0.1231,0.0,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,279.0351,427.2872,0.0,42.5242,323.2997,567.8254,0,0.0,49.4989,714 +edge,m57,-2.0,0.1116,0,0,1.8722,84.6575,0.0001,0,1356,0.1114,0.0,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,207.1645,319.7387,0.0,42.5242,301.5525,503.0518,0,0.0,48.0,671 +glare,m57,0.3,0.1534,,,10.369,72.2173,0.0001,0,1356,0.0959,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,174.6115,266.8486,0.0,42.5242,220.5844,463.3804,1,0.0,47.4372,1292 +glare,m57,0.5,0.1581,,,10.2738,70.5151,0.0001,0,1356,0.0873,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,118.7684,180.0096,0.0,42.5242,188.5892,410.1649,1,0.0,47.8732,1292 +glare,m57,0.7,0.1672,,,10.284,69.0564,0.0001,0,1356,0.0873,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,75.1072,112.5565,0.0,42.5242,162.2619,352.9347,1,0.0,47.826,1292 +glare,m57,0.85,0.1817,,,9.6595,68.1221,0.0001,0,1356,0.0934,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,50.9207,75.2013,0.0,42.5242,139.1461,348.5437,1,0.0,50.8919,1292 +glare,m57,1.0,0.1903,0,0,9.8504,67.3158,0.0006,0,1356,0.1036,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,33.7666,48.5244,0.0796,42.5242,127.0292,310.9417,0,0.0057,49.9137,1292 +glare,m57,1.5,0.2199,0,0,9.9519,65.478,0.0051,0,1356,0.0886,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,9.32,12.2478,0.5537,42.5242,95.8601,223.6297,0,0.1527,49.4249,1292 +glare,m57,2.5,0.1137,0,0,10.5238,63.3865,0.0107,0,1356,0.0365,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,0.6583,0.3177,0.8333,42.5242,352.916,435.0303,0,0.0,42.0,1292 +glare,m57,4.0,0.1388,0,0,9.7917,61.5982,0.0158,0,1356,0.0015,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,0.0267,0.0202,0.9985,42.5242,430.1132,680.3869,0,0.0,48.0,1292 +motion,m57,0.05,0.1129,,,9.7917,62.6241,0.0001,0,1356,0.1184,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,213.7789,376.9971,0.0,42.5242,222.6984,427.1988,1,0.0,48.0,1292 +motion,m57,0.1,0.1034,,,9.2083,37.5312,0.0,0,1356,0.1107,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,128.8565,250.4272,0.0,42.5242,107.7134,241.549,1,0.0,48.0,1292 +motion,m57,0.15,0.0986,,,9.2083,24.5204,0.0,0,1356,0.1022,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,85.5602,168.2835,0.0,42.5242,61.5932,140.0889,1,0.0,48.0,1292 +motion,m57,0.2,0.1391,,,9.9859,17.3077,0.0,0,1356,0.0911,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,59.2333,114.3356,0.0,42.5242,71.0379,209.2608,1,0.0,49.2419,1292 +motion,m57,0.3,0.1271,,,10.4907,13.3602,0.0,0,1356,0.0799,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,43.8836,83.6718,0.0,42.5242,58.073,144.9617,1,0.0,46.5132,1292 +motion,m57,0.4,0.1129,,,10.4392,11.3143,0.0,0,1356,0.0722,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,35.5836,67.6137,0.0,42.5242,48.515,118.9996,1,0.0,46.7407,1292 +motion,m57,0.5,0.1143,,,10.3323,10.0091,0.0,0,1356,0.0666,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,30.2822,57.3065,0.0,42.5242,42.9582,113.7899,1,0.0,47.2056,1292 +motion,m57,0.7,,0,0,,8.3022,0.0,,1356,0.0582,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,22.9746,42.8944,0.0,42.5242,,,0,,,1292 +motion,m57,1.0,,0,0,,7.096,0.0,,1356,0.0537,12.6031,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,18.9228,35.0396,0.0,42.5242,,,0,,,1292 +scale,m57,40,0.1572,,,10.1334,76.5321,0.0001,0,1276,0.1204,12.595,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,324.9552,442.2305,0.0,40.0154,319.5598,617.1775,1,0.0,45.7363,1215 +scale,m57,32,0.1611,,,9.6847,123.725,0.0001,0,1020,0.1211,12.6049,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,642.4261,642.4261,0.0,31.9872,624.8614,716.4125,1,0.0,38.3889,972 +scale,m57,26,0.1568,,,10.3947,211.0861,0.0001,0,829,0.1207,12.6051,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,1190.7682,373.7119,0.0,25.9974,1183.7774,568.5892,1,0.0,29.0441,790 +scale,m57,22,0.1328,,,9.2308,306.8582,0.0001,0,702,0.1205,12.5867,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,1916.5831,338.3986,0.0,22.0147,2452.1158,623.9936,1,0.0,26.0,668 +scale,m57,18,0.1682,,,9.0899,441.517,0.0001,0,574,0.1192,12.6052,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,3088.7669,290.5292,0.0,18.0006,2685.974,524.3531,1,0.0,22.8317,547 +scale,m57,15,0.1695,,,8.3601,557.6729,0.0,0,478,0.1148,12.6186,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,4312.6681,211.1824,0.0,14.9901,3472.8163,456.6444,1,0.0,20.5668,456 +scale,m57,12,0.1488,1,1,9.748,676.1075,0.0,0,383,0.111,12.6057,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,5558.0958,124.7845,0.0,12.0109,4851.4478,199.3212,0,0.0,14.1166,365 +scale,m57,10,0.157,1,1,8.1684,766.7684,0.0,0,319,0.1059,12.6054,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,6475.7806,79.1277,0.0,10.0038,5192.4538,192.7674,0,0.0,13.9563,304 +scale,m57,8,0.1372,0,0,9.1982,899.0434,0.0,0,255,0.0974,12.6049,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,7180.4667,32.5416,0.0,7.9968,5903.9989,75.9042,0,0.0,9.9609,243 +scale,m57,6,,0,0,,1164.2326,0.0,,191,0.0861,12.6041,a64d6c4a6f2e9da8afc6d437f7495b96b7a65518e92a79e9631c04d08b67104c,7441.5927,15.9795,0.0,5.9898,,,0,,,182 +blur,m58,0.02,0.1343,,,10.1694,239.7169,0.0044,1,1840,0.1333,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,51.1359,557.8217,0.0,71.1344,51.4653,609.8104,1,0.0,73.5602,3264 +blur,m58,0.04,0.1052,,,10.5333,48.3869,0.0039,1,1840,0.1042,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,6.793,90.0701,0.0,71.1344,6.8571,92.1395,1,0.0,71.134,3264 +blur,m58,0.06,0.0734,,,5.3104,12.4509,0.0036,0,1840,0.0847,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,2.6856,19.3789,0.0,71.1344,1.9839,14.0821,1,0.0,89.3758,3264 +blur,m58,0.08,0.0679,,,11.4803,4.587,0.0034,1,1840,0.0721,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,1.8731,5.4312,0.0,71.1344,1.856,3.9484,1,0.0,65.6297,3264 +blur,m58,0.1,0.0602,0,0,11.3438,2.151,0.0033,0,1840,0.0635,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,1.6843,1.847,0.0,71.1344,1.6422,1.4148,0,0.0,66.3634,3264 +blur,m58,0.13,,0,0,,0.9113,0.003,,1840,0.0552,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,1.5503,0.5629,0.0,71.1344,,,0,,,3264 +blur,m58,0.16,,0,0,,0.4864,0.0027,,1840,0.0495,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,1.4856,0.2796,0.0,71.1344,,,0,,,3264 +blur,m58,0.2,,0,0,,0.2741,0.0022,,1840,0.0445,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,1.335,0.1867,0.0,71.1344,,,0,,,3264 +blur,m58,0.25,,0,0,,0.1775,0.0016,,1840,0.0408,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,1.1697,0.1484,0.0,71.1344,,,0,,,3264 +blur,m58,0.3,,0,0,,0.1399,0.0012,,1840,0.0387,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,1.1302,0.1353,0.0,71.1344,,,0,,,3264 +edge,m58,2.0,0.1597,,,1.9423,554.4399,0.0101,1,1840,0.163,1.9946,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,539.4862,2133.6712,0.0,71.1344,515.7513,2132.8913,1,0.0,73.9019,2087 +edge,m58,1.0,0.1589,,,1.2336,524.6893,0.0105,1,1840,0.163,0.9965,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,539.4862,2133.6712,0.0,71.1344,550.4677,1641.6603,1,0.0,58.6282,2016 +edge,m58,0.5,0.1601,,,0.4859,511.8983,0.0107,1,1840,0.163,0.5045,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,539.4862,2133.6712,0.0,71.1344,518.8589,2126.672,1,0.0,73.4737,1981 +edge,m58,0.25,0.161,,,0.289,504.6949,0.0108,1,1840,0.163,0.2514,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,539.4862,2133.6712,0.0,71.1344,524.3291,2106.8916,1,0.0,72.5566,1963 +edge,m58,0.1,0.1604,,,0.1368,499.4003,0.0108,1,1840,0.1649,0.0968,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,553.1206,2193.6475,0.0,71.1344,529.7956,2094.654,1,0.0,71.0653,1952 +edge,m58,0.0,0.1633,,,0.0105,496.8193,0.0109,1,1840,0.1661,0.0,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,562.3756,2227.7363,0.0,71.1344,540.7112,2219.3754,1,0.0,73.4298,1945 +edge,m58,-0.5,0.162,0,0,0.0221,478.4008,0.0111,0,1840,0.1689,0.0,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,570.4958,2280.5345,0.0001,71.1344,562.8735,1662.1467,0,0.0,58.7121,1910 +edge,m58,-1.0,0.1624,0,0,0.0236,457.344,0.0113,0,1840,0.1696,0.0,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,587.3841,2347.2699,0.0001,71.1344,588.5961,1588.2146,0,0.0,55.6986,1874 +edge,m58,-2.0,0.1397,0,0,7.3483,430.9736,0.0117,0,1840,0.1723,0.0,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,615.8409,2424.4123,0.0001,71.1344,365.0875,928.9561,0,0.0003,50.5926,1803 +glare,m58,0.3,0.1296,,,10.2638,591.4223,0.0065,1,1840,0.1289,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,335.8725,1319.2393,0.0,71.1344,334.9636,1363.1693,1,0.0,72.9156,3264 +glare,m58,0.5,0.1104,,,10.5333,578.0273,0.0065,1,1840,0.1095,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,228.4122,894.6711,0.0001,71.1344,230.5788,901.081,1,0.0001,71.134,3264 +glare,m58,0.7,0.0967,,,8.9391,566.7497,0.0065,1,1840,0.0949,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,145.2807,564.9009,0.0001,71.1344,144.7066,675.0496,1,0.0001,83.0424,3264 +glare,m58,0.85,0.0901,,,8.9405,559.7193,0.0065,1,1840,0.0884,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,98.674,380.048,0.0007,71.1344,98.6599,454.2192,1,0.0006,83.0293,3264 +glare,m58,1.0,0.1337,0,0,11.4286,553.9026,0.008,0,1840,0.0868,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,65.4741,247.785,0.2022,71.1344,315.6796,1281.2101,0,0.0,66.3918,3264 +glare,m58,1.5,0.1337,0,0,11.4286,541.5843,0.015,0,1840,0.0604,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,17.8351,62.1983,0.611,71.1344,313.2922,1271.619,0,0.0,66.3918,3264 +glare,m58,2.5,0.1337,0,0,11.4286,530.7945,0.0242,0,1840,0.0156,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,0.4603,0.3819,0.8958,71.1344,308.8824,1252.3568,0,0.0,66.3918,3264 +glare,m58,4.0,0.1341,0,0,11.4286,523.0729,0.0328,0,1840,0.0,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,0.0,0.0,1.0,71.1344,302.0642,1225.1415,0,0.0,66.3918,3264 +motion,m58,0.05,0.1496,,,10.5333,423.6321,0.0052,1,1840,0.1485,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,234.2616,1276.9584,0.0,71.1344,234.5898,1274.6199,1,0.0,71.134,3264 +motion,m58,0.1,0.1344,,,10.5333,245.97,0.0047,1,1840,0.1335,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,144.7495,739.8942,0.0,71.1344,144.0611,730.6464,1,0.0,71.134,3264 +motion,m58,0.15,0.1188,,,9.9087,151.7558,0.0044,1,1840,0.1174,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,99.9865,462.2298,0.0,71.1344,94.972,471.3433,1,0.0,75.4031,3264 +motion,m58,0.2,0.1088,,,10.1458,124.5493,0.0042,1,1840,0.1087,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,82.9139,367.235,0.0,71.1344,79.2937,373.4866,1,0.0,73.7237,3264 +motion,m58,0.3,0.0962,,,10.5333,95.748,0.0039,1,1840,0.0958,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,61.3689,262.9036,0.0,71.1344,60.291,254.3666,1,0.0,71.134,3264 +motion,m58,0.4,0.0852,,,12.4997,80.321,0.0036,1,1840,0.0889,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,49.4491,209.6235,0.0,71.1344,53.6646,177.9285,1,0.0,60.3411,3264 +motion,m58,0.5,0.0489,0,0,8.9167,69.8883,0.0033,0,1840,0.0844,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,40.7736,173.7081,0.0,71.1344,5.9772,13.857,0,0.0,56.9072,3264 +motion,m58,0.7,,0,0,,57.8353,0.0027,,1840,0.0791,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,30.1246,133.8696,0.0,71.1344,,,0,,,3264 +motion,m58,1.0,,0,0,,46.2262,0.002,,1840,0.0742,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,20.0651,96.2579,0.0,71.1344,,,0,,,3264 +scale,m58,48,0.1627,,,10.5333,624.5488,0.0063,1,1242,0.1627,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,1742.3105,2134.2283,0.0,48.0157,1742.3105,2134.2283,1,0.0,48.0155,2202 +scale,m58,40,0.1631,,,10.4218,625.954,0.006,1,1035,0.1642,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,2739.3991,2170.0948,0.0001,40.0131,2695.4375,2173.3653,1,0.0001,40.4216,1835 +scale,m58,32,0.1612,,,10.5333,613.4485,0.0057,1,828,0.1612,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,4040.9199,4040.9199,0.0,32.0105,4040.9199,4040.9199,1,0.0,32.0103,1468 +scale,m58,26,0.1487,,,12.2824,572.4023,0.0053,1,673,0.1549,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,4923.2222,1366.0545,0.0,26.0182,4762.5349,855.8609,1,0.0,22.6036,1193 +scale,m58,22,0.1461,,,9.5135,1259.1245,0.005,1,569,0.1543,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,5717.8874,935.5031,0.0,21.9975,5006.5552,1118.2963,1,0.0,24.424,1009 +scale,m58,18,0.1366,,,10.5602,1288.369,0.0048,1,466,0.15,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,6849.6038,601.8029,0.0,18.0156,5881.1049,517.7792,1,0.0,18.1202,826 +scale,m58,15,0.139,,,10.8282,1280.2798,0.0046,1,388,0.1436,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,7729.2944,385.1537,0.0,15.0001,7523.007,340.2844,1,0.0,14.6689,688 +scale,m58,12,0.135,,,10.5,1230.5047,0.0044,1,310,0.1362,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,8504.9676,205.922,0.0,11.9846,8262.4463,199.6485,1,0.0,12.0,551 +scale,m58,10,0.1347,1,1,18.0,1176.2515,0.0042,0,259,0.1247,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,8665.7126,116.3767,0.0,10.0129,14450.5381,32.8362,0,0.0,5.9769,459 +scale,m58,8,,0,0,,1153.1162,0.0039,,207,0.1153,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,8261.0855,39.7382,0.0,8.0026,,,0,,,367 +scale,m58,6,,0,0,,1209.3561,0.0037,,155,0.0895,10.5334,7ad33f54547efdf23938f5211acfc3200d88f3924ec36ba96769233eb09a9b18,5956.6378,13.3319,0.0,5.9923,,,0,,,275 +blur,m59,0.02,0.1725,,,3.5182,27.3567,0.0012,0,1712,0.0762,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,2.9659,100.056,0.0,99.724,4.4026,810.3179,1,0.0,177.8691,2288 +blur,m59,0.04,0.1716,,,3.5926,6.6775,0.0011,0,1712,0.0636,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.6361,25.9861,0.0,99.724,2.2414,305.9772,1,0.0,179.5107,2288 +blur,m59,0.06,0.1542,,,3.5952,1.817,0.001,0,1712,0.0539,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.2876,6.4516,0.0,99.724,1.7105,115.5969,1,0.0,176.5108,2288 +blur,m59,0.08,0.0461,,,0.331,0.68,0.0008,1,1712,0.0475,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.2484,1.7809,0.0,99.724,1.2196,1.6319,1,0.0,98.0071,2288 +blur,m59,0.1,0.1531,0,0,3.2203,0.3719,0.0007,0,1712,0.043,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.2741,0.6308,0.0,99.724,1.3463,25.0968,0,0.0,196.132,2288 +blur,m59,0.13,0.1259,0,0,3.384,0.2339,0.0005,0,1712,0.0379,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.3664,0.2494,0.0,99.724,1.3744,8.3646,0,0.0,184.8846,2288 +blur,m59,0.16,,0,0,,0.189,0.0002,,1712,0.0336,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.3361,0.1518,0.0,99.724,,,0,,,2288 +blur,m59,0.2,,0,0,,0.1675,0.0,,1712,0.029,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.0428,0.1135,0.0,99.724,,,0,,,2288 +blur,m59,0.25,,0,0,,0.1626,0.0,,1712,0.0247,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,0.7215,0.1249,0.0,99.724,,,0,,,2288 +blur,m59,0.3,,0,0,,0.1642,0.0,,1712,0.0213,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,0.5604,0.1675,0.0,99.724,,,0,,,2288 +edge,m59,2.0,0.0864,,,0.1202,20.8008,0.0028,0,1712,0.0837,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,9.6577,187.1992,0.0,99.724,9.4824,269.7633,1,0.0,115.0216,1008 +edge,m59,1.0,0.0865,,,0.1276,19.8206,0.0031,0,1712,0.0837,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,9.6577,187.1992,0.0,99.724,9.5292,263.3394,1,0.0,113.6632,908 +edge,m59,0.5,0.0886,,,0.2094,19.4246,0.0033,1,1712,0.0837,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,9.6577,187.1992,0.0,99.724,10.0664,195.5188,1,0.0,100.2667,858 +edge,m59,0.25,0.0881,,,0.1924,19.2681,0.0034,1,1712,0.0837,0.2497,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,9.6577,187.1992,0.0,99.724,9.9273,211.582,1,0.0,103.0221,833 +edge,m59,0.1,0.0881,,,0.1924,19.2856,0.0035,1,1712,0.0847,0.0993,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,9.8815,193.1621,0.0,99.724,9.9101,211.4978,1,0.0,103.0185,818 +edge,m59,0.0,0.0847,,,0.2604,19.3049,0.0035,1,1712,0.0855,0.0,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,10.0175,196.4216,0.0,99.724,9.7782,194.7324,1,0.0,99.8095,808 +edge,m59,-0.5,0.0862,,,0.0243,18.9766,0.0037,1,1712,0.0865,0.0,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,10.1688,198.7247,0.0,99.724,10.1365,190.7199,1,0.0,97.9481,758 +edge,m59,-1.0,0.0832,0,0,0.1081,18.3455,0.004,0,1712,0.0844,0.0,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,9.8396,193.9786,0.0,99.724,9.5568,221.1048,0,0.0,104.9961,708 +edge,m59,-2.0,0.0782,0,0,0.025,18.2583,0.0047,0,1712,0.0781,0.0,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,9.0436,174.1087,0.0,99.724,9.0411,172.3605,0,0.0,99.5349,609 +glare,m59,0.3,0.1883,,,3.1252,57.7497,0.0013,0,1712,0.0662,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,7.1588,114.6178,0.0,99.724,16.5396,1344.6562,1,0.0,198.0709,2288 +glare,m59,0.5,0.1892,,,3.0736,57.2033,0.0013,0,1712,0.0559,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,5.3031,77.0122,0.0,99.724,16.3406,1347.9076,1,0.0,201.3951,2288 +glare,m59,0.7,0.1892,,,3.0736,56.7474,0.0013,0,1712,0.0476,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,3.8823,48.1853,0.0001,99.724,16.3406,1347.9076,1,0.0,201.3951,2288 +glare,m59,0.85,0.1892,,,3.0736,56.4558,0.0034,0,1712,0.0437,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,2.995,32.0469,0.1085,99.724,16.3406,1347.9076,1,0.0,201.3951,2288 +glare,m59,1.0,0.1892,0,0,3.0736,56.213,0.0096,0,1712,0.042,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,2.2722,20.9416,0.3608,99.724,16.3406,1347.9076,0,0.0,201.3951,2288 +glare,m59,1.5,0.1892,0,0,3.0736,55.6576,0.0206,0,1712,0.0292,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,0.9021,5.413,0.6693,99.724,16.3406,1347.9076,0,0.0,201.3951,2288 +glare,m59,2.5,0.1896,0,0,3.0279,55.0552,0.0339,0,1712,0.0084,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,0.1711,0.0727,0.9334,99.724,16.5143,1403.7253,0,0.0,204.4758,2288 +glare,m59,4.0,0.1913,0,0,2.9282,54.5005,0.045,0,1712,0.0,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,0.0,0.0,1.0,99.724,16.3973,1427.1422,0,0.0,211.3898,2288 +motion,m59,0.05,0.1855,,,3.2375,45.8346,0.0012,0,1712,0.0814,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,6.002,155.4238,0.0,99.724,10.9775,1201.5591,1,0.0,193.0369,2288 +motion,m59,0.1,0.1674,,,3.6503,28.3182,0.0012,0,1712,0.076,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,4.4094,99.5191,0.0,99.724,9.6228,874.773,1,0.0,171.8096,2288 +motion,m59,0.15,0.1743,,,3.4294,19.6697,0.0012,0,1712,0.0702,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,3.5011,64.3296,0.0,99.724,7.8169,755.9582,1,0.0,183.1223,2288 +motion,m59,0.2,0.183,,,3.1083,15.9003,0.0012,0,1712,0.0654,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,2.9701,47.536,0.0,99.724,6.2813,664.8958,1,0.0,204.0643,2288 +motion,m59,0.3,0.0605,,,0.2987,12.6182,0.0011,1,1712,0.0583,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,2.4182,32.2023,0.0,99.724,2.2691,28.0443,1,0.0,99.8414,2288 +motion,m59,0.4,0.0912,0,0,6.1111,10.8832,0.001,0,1712,0.0535,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,2.0729,24.8649,0.0,99.724,2.8731,28.6056,0,0.0,85.6,2288 +motion,m59,0.5,,0,0,,9.7892,0.0009,,1712,0.0497,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.8877,20.119,0.0,99.724,,,0,,,2288 +motion,m59,0.7,0.0848,0,0,6.1111,8.4477,0.0007,0,1712,0.0445,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.5369,14.5029,0.0,99.724,2.5701,23.6594,0,0.0,85.6001,2288 +motion,m59,1.0,,0,0,,7.1722,0.0005,,1712,0.0416,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1.4545,11.4229,0.0,99.724,,,0,,,2288 +scale,m59,48,0.191,,,2.2685,59.2832,0.0012,0,824,0.0835,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,68.0886,187.706,0.0,47.998,127.5444,2089.2996,1,0.0,124.6172,1101 +scale,m59,40,0.1965,,,3.0523,109.8222,0.0012,0,687,0.0836,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,124.4838,189.4458,0.0,40.0177,183.0511,1366.2131,1,0.0,84.8929,918 +scale,m59,32,0.0858,,,0.1985,227.7877,0.0013,0,549,0.0835,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,264.9267,264.9267,0.0,31.9793,252.3226,248.9843,1,0.0,35.7284,734 +scale,m59,26,0.0888,,,0.2019,425.8852,0.0012,0,446,0.0842,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,525.2103,172.1824,0.0,25.9795,538.145,203.7261,1,0.0,27.0676,597 +scale,m59,22,0.084,,,0.1667,663.0383,0.0012,1,378,0.084,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,856.4039,159.1585,0.0,22.0185,850.3288,205.0063,1,0.0,24.0,505 +scale,m59,18,0.0836,,,0.1879,1031.7564,0.0013,1,309,0.0835,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1427.6407,130.0548,0.0,17.9992,1422.9841,150.3984,1,0.0,18.838,413 +scale,m59,15,0.0848,,,0.243,1398.3461,0.0012,1,258,0.0815,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,1977.3924,96.111,0.0,15.0285,1842.0605,101.5717,1,0.0,16.1518,344 +scale,m59,12,0.081,,,0.1242,1822.961,0.0012,1,206,0.0792,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,2691.7876,59.6842,0.0,11.9995,2278.471,99.8251,1,0.0,14.4186,275 +scale,m59,10,0.1877,,,3.2,2000.4838,0.0012,0,172,0.0756,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,3138.1291,38.1543,0.0,10.019,4512.1826,561.5692,1,0.0,20.0,229 +scale,m59,8,0.0821,0,0,2.3333,1992.9613,0.0012,0,137,0.0687,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,2911.9626,12.5381,0.0,7.9803,4408.3901,9.1984,0,0.0,5.9565,184 +scale,m59,6,0.1694,0,0,3.0291,1844.5083,0.0013,0,103,0.06,0.2668,69924fae4d49932fead36542cd3db3122b6297fe215f60bd803685761007179b,2157.8473,4.5192,0.0,5.9998,4852.5009,111.7487,0,0.0,11.8846,138 +blur,m60,0.02,0.1244,,,10.0081,141.941,0.0002,1,1233,0.1244,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,600.0557,114.8488,0.0,21.9844,600.0557,114.8488,1,0.0,21.9822,1088 +blur,m60,0.04,0.1109,,,10.0081,59.7562,0.0001,1,1233,0.1109,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,208.031,43.4341,0.0,21.9844,208.031,43.4341,1,0.0,21.9822,1088 +blur,m60,0.06,0.097,,,9.9171,21.2802,0.0001,1,1233,0.0973,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,72.4036,15.3054,0.0,21.9844,71.6268,15.1226,1,0.0,21.9822,1088 +blur,m60,0.08,0.0876,,,10.5085,9.6119,0.0,1,1233,0.0873,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,26.8291,5.6048,0.0,21.9844,29.0027,4.3435,1,0.0,19.9838,1088 +blur,m60,0.1,0.076,,,4.3503,4.5221,0.0,0,1233,0.0799,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,9.6227,1.9324,0.0,21.9844,6.2992,9.256,1,0.0,38.6054,1088 +blur,m60,0.13,0.0688,1,0,3.9122,1.9613,0.0,0,1233,0.0718,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,3.674,0.6471,0.0,21.9844,2.6674,3.7357,0,0.0,41.8766,1088 +blur,m60,0.16,0.0768,0,0,5.693,1.0598,0.0,0,1233,0.0649,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,2.4564,0.3811,0.0,21.9844,2.5696,0.9885,0,0.0,28.7623,1088 +blur,m60,0.2,,0,0,,0.6271,0.0,,1233,0.0562,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,1.9408,0.2857,0.0,21.9844,,,0,,,1088 +blur,m60,0.25,,0,0,,0.4414,0.0,,1233,0.0462,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,1.7471,0.238,0.0,21.9844,,,0,,,1088 +blur,m60,0.3,,0,0,,0.3637,0.0,,1233,0.0375,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,1.6214,0.2156,0.0,21.9844,,,0,,,1088 +edge,m60,2.0,0.1303,,,2.0016,212.9232,0.0001,1,1233,0.1303,2.0014,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,932.3136,170.3534,0.0,21.9844,932.3136,170.3534,1,0.0,21.9822,430 +edge,m60,1.0,0.1303,,,1.0008,211.92,0.0001,1,1233,0.1303,1.0007,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,932.3136,170.3534,0.0,21.9844,932.3136,170.3534,1,0.0,21.9822,408 +edge,m60,0.5,0.1344,,,0.4991,210.6993,0.0001,1,1233,0.1303,0.5003,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,932.3136,170.3534,0.0,21.9844,1008.8969,134.3083,1,0.0,19.9838,397 +edge,m60,0.25,0.1347,,,0.1997,210.7071,0.0001,1,1233,0.1303,0.2274,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,932.3136,170.3534,0.0,21.9844,1014.5833,135.4838,1,0.0,19.9838,391 +edge,m60,0.1,0.1312,,,0.091,211.7734,0.0001,1,1233,0.1312,0.0909,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,948.4844,172.8134,0.0,21.9844,948.4844,172.8134,1,0.0,21.9822,388 +edge,m60,0.0,0.1318,,,0.091,209.299,0.0001,1,1233,0.1318,0.0,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,958.7275,174.6045,0.0,21.9844,958.7275,174.6045,1,0.0,21.9822,386 +edge,m60,-0.5,0.1351,,,0.0998,208.4416,0.0001,0,1233,0.1308,0.0,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,947.0812,171.4134,0.0,21.9844,1029.8518,136.6504,1,0.0,19.9838,375 +edge,m60,-1.0,0.1303,,,0.091,205.6428,0.0001,0,1233,0.1303,0.0,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,930.0502,168.9388,0.0,21.9844,930.0502,168.9388,1,0.0,21.9822,364 +edge,m60,-2.0,0.1279,0,0,0.0876,196.5707,0.0001,0,1233,0.1283,0.0,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,919.784,166.5261,0.0,21.9844,914.023,175.4907,0,0.0,22.4039,342 +glare,m60,0.3,0.1062,,,11.0089,185.5798,0.0003,1,1233,0.1026,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,569.4909,103.8272,0.0,21.9844,622.2364,82.4285,1,0.0,19.9838,1088 +glare,m60,0.5,0.0902,,,11.0089,182.3006,0.0003,1,1233,0.0872,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,382.202,69.4897,0.0,21.9844,417.5198,55.2027,1,0.0,19.9838,1088 +glare,m60,0.7,0.0784,,,11.0089,179.3967,0.0003,1,1233,0.0759,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,238.6796,43.276,0.0,21.9844,260.9165,34.4361,1,0.0,19.9838,1088 +glare,m60,0.85,0.0737,,,11.0089,177.4895,0.0003,1,1233,0.0715,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,159.328,28.7246,0.0,21.9844,173.9725,22.8745,1,0.0,19.9838,1088 +glare,m60,1.0,0.0914,,,9.5633,175.7923,0.002,0,1233,0.0714,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,104.2685,18.7259,0.2701,21.9844,984.523,92.078,1,0.0,17.9854,1088 +glare,m60,1.5,0.0869,,,9.5633,171.4448,0.0084,0,1233,0.0477,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,27.1823,4.8051,0.656,21.9844,882.7484,82.5406,1,0.0,17.9854,1088 +glare,m60,2.5,0.0802,,,9.5633,166.093,0.0163,0,1233,0.0084,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,0.306,0.048,0.9423,21.9844,701.0603,65.3083,1,0.0,17.9854,1088 +glare,m60,4.0,0.1366,,,14.0923,161.578,0.0237,0,1233,0.0,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,0.0,0.0,1.0,21.9844,481.4539,131.7843,1,0.1132,24.9714,1088 +motion,m60,0.05,0.1303,,,10.0081,191.2347,0.0003,1,1233,0.1303,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,932.3136,170.3534,0.0,21.9844,932.3136,170.3534,1,0.0,21.9822,1088 +motion,m60,0.1,0.1298,,,11.0089,152.081,0.0003,1,1233,0.1256,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,595.0102,116.3192,0.0,21.9844,654.9893,92.1542,1,0.0,19.9838,1088 +motion,m60,0.15,0.1228,,,10.6086,119.9437,0.0002,1,1233,0.1196,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,451.3477,90.664,0.0,21.9844,499.9794,71.8126,1,0.0,19.9838,1088 +motion,m60,0.2,0.1162,,,10.6086,100.2917,0.0001,1,1233,0.1133,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,350.8658,71.5915,0.0,21.9844,387.8504,56.3966,1,0.0,19.9838,1088 +motion,m60,0.3,0.1035,,,11.7873,75.5867,0.0,1,1233,0.0999,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,189.3252,39.7791,0.0,21.9844,231.2406,25.6197,1,0.0,17.9854,1088 +motion,m60,0.4,0.0839,,,3.9477,68.2241,0.0,1,1233,0.096,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,154.9606,33.0022,0.0,21.9844,99.657,134.3348,1,0.0,43.0376,1088 +motion,m60,0.5,0.0989,,,13.6361,63.4721,0.0,0,1233,0.0929,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,132.7351,28.435,0.0,21.9844,127.5177,127.5177,1,0.0,31.9741,1088 +motion,m60,0.7,0.0965,0,0,12.4101,57.2276,0.0,0,1233,0.0884,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,110.4538,23.8537,0.0,21.9844,183.0214,28.624,0,0.0,19.9838,1088 +motion,m60,1.0,0.0838,0,0,12.7184,51.0959,0.0,0,1233,0.0844,10.0073,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,99.6612,21.6717,0.0,21.9844,90.2438,80.3175,0,0.0,34.7476,1088 +scale,m60,18,0.1293,,,9.8778,330.3788,0.0003,1,1010,0.1299,10.0048,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,1295.4223,127.3209,0.0,18.0083,1276.8121,122.8246,1,0.0,18.0,891 +scale,m60,15,0.133,,,10.727,472.8388,0.0002,1,841,0.1304,10.006,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,2117.1641,112.5951,0.0,14.995,2246.6213,96.5208,1,0.0,13.9834,742 +scale,m60,12,0.1278,,,10.0149,666.4483,0.0002,1,673,0.1283,10.0097,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,3660.8922,94.1146,0.0,11.9996,3622.8202,91.7622,1,0.0,11.9822,594 +scale,m60,10,0.1246,,,9.7977,809.4752,0.0002,1,561,0.1264,10.0068,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,5415.2987,75.6077,0.0,10.0026,5111.853,72.7534,1,0.0,9.9822,495 +scale,m60,8,0.174,,,2.2747,964.5632,0.0002,0,449,0.1203,10.0023,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,7163.4635,33.978,0.0,8.0057,2161.2487,799.8687,1,0.0039,26.9696,396 +scale,m60,6,0.1575,,,2.6409,1100.8117,0.0001,0,337,0.1095,9.9949,6d61e891775b419671dc7202861feaaf50fe33b68c08d52b12090edde80e4166,7859.8289,16.5619,0.0,6.0087,3484.9773,388.5842,1,0.0022,18.0943,297 +blur,m61,0.02,0.1421,,,4.0,48.0139,0.0077,0,1600,0.0945,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,45.9419,69.0477,0.0,40.592,70.1604,434.8713,1,0.0,66.6667,1200 +blur,m61,0.04,0.1289,,,3.6364,15.3391,0.0076,0,1600,0.0877,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,10.803,19.3682,0.0,40.592,16.8814,239.0017,1,0.0,73.3333,1200 +blur,m61,0.06,0.1169,,,3.3333,5.1566,0.0075,0,1600,0.0826,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,4.0682,5.5686,0.0,40.592,7.2357,145.3833,1,0.0,80.0,1200 +blur,m61,0.08,0.0911,0,0,7.3714,2.212,0.0074,0,1600,0.0793,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,2.2463,2.1107,0.0,40.592,3.5252,5.2245,0,0.0,43.4305,1200 +blur,m61,0.1,0.0736,0,0,7.6667,1.1557,0.0073,0,1600,0.077,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1.7321,1.1064,0.0,40.592,2.5768,2.2158,0,0.0,41.8605,1200 +blur,m61,0.13,,0,0,,0.5721,0.0071,,1600,0.0744,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1.3846,0.6006,0.0,40.592,,,0,,,1200 +blur,m61,0.16,,0,0,,0.3736,0.0069,,1600,0.0724,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1.2855,0.4297,0.0,40.592,,,0,,,1200 +blur,m61,0.2,,0,0,,0.2639,0.0066,,1600,0.0701,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1.2131,0.3403,0.0,40.592,,,0,,,1200 +blur,m61,0.25,,0,0,,0.216,0.0062,,1600,0.0678,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1.2469,0.3006,0.0,40.592,,,0,,,1200 +blur,m61,0.3,,0,0,,0.1948,0.0057,,1600,0.0656,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1.2477,0.2919,0.0,40.592,,,0,,,1200 +edge,m61,0.5,0.1483,,,4.0045,85.2508,0.0078,0,1600,0.099,0.502,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,145.9077,138.2746,0.0,40.592,202.6494,652.372,1,0.0,66.6667,1188 +edge,m61,0.25,0.1313,,,7.9177,85.9197,0.0078,0,1600,0.099,0.2556,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,145.9077,138.2746,0.0,40.592,331.9501,327.232,1,0.0,40.8883,1178 +edge,m61,0.1,0.147,,,3.6322,86.274,0.0079,0,1600,0.0988,0.1078,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,146.5394,139.6501,0.0,40.592,191.4028,757.4489,1,0.0,73.3333,1172 +edge,m61,0.0,0.1315,,,7.9821,86.3755,0.0079,0,1600,0.0987,0.0093,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,146.9657,139.4975,0.0,40.592,334.3973,329.8657,1,0.0,40.5778,1168 +edge,m61,-0.5,0.1273,,,8.4519,87.3958,0.0081,0,1600,0.0959,0.0,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,147.1336,139.4824,0.0,40.592,330.8854,279.7535,1,0.0,38.4483,1147 +edge,m61,-1.0,0.1483,,,4.0012,87.6884,0.0082,0,1600,0.0932,0.0,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,146.3314,137.775,0.0,40.592,202.6494,652.372,1,0.0,66.6667,1127 +edge,m61,-2.0,0.1473,,,3.6341,88.3211,0.0085,0,1600,0.09,0.0,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,145.2542,135.2278,0.0,40.592,193.453,768.5108,1,0.0,73.3333,1086 +glare,m61,0.3,0.115,,,8.5749,58.873,0.008,0,1600,0.1045,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,94.6536,90.3523,0.0,40.592,260.4736,219.7635,1,0.0,37.799,1200 +glare,m61,0.5,0.1079,,,4.0,44.8996,0.0082,0,1600,0.1119,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,66.7881,64.5073,0.0,40.592,93.2922,297.5459,1,0.0,66.6667,1200 +glare,m61,0.7,0.1001,,,4.0,33.4981,0.0087,0,1600,0.1217,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,44.5036,43.7023,0.0,40.592,61.5948,195.5904,1,0.0002,66.6667,1200 +glare,m61,0.85,0.0933,,,8.6088,26.5994,0.0091,0,1600,0.1302,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,31.5446,31.3776,0.0,40.592,173.1987,136.0457,1,0.0,37.6585,1200 +glare,m61,1.0,0.1018,,,4.0,21.1565,0.0248,0,1600,0.1397,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,21.621,21.8499,0.2044,40.592,27.6198,86.5136,1,0.0004,66.6667,1200 +glare,m61,1.5,0.0718,0,0,8.0806,10.7983,0.2008,0,1600,0.0981,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,5.9268,5.9718,0.6465,40.592,97.0296,88.8856,0,0.0,40.1122,1200 +glare,m61,2.5,0.0456,0,0,8.2276,3.7351,0.4047,0,1600,0.0134,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,0.1366,0.0588,0.9461,40.592,19.0394,15.8895,0,0.0,39.4364,1200 +glare,m61,4.0,,0,0,,1.1939,0.5913,,1600,0.0,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,0.0,0.0,1.0,40.592,,,0,,,1200 +motion,m61,0.05,0.1469,,,4.0,73.9139,0.0077,0,1600,0.0978,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,99.0596,111.5494,0.0,40.592,161.8924,612.0952,1,0.0,66.6667,1200 +motion,m61,0.1,0.143,,,4.0,52.7931,0.0077,0,1600,0.0946,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,59.5593,67.1046,0.0,40.592,121.7649,505.3952,1,0.0,66.6667,1200 +motion,m61,0.15,0.1378,,,3.6364,40.7454,0.0077,0,1600,0.0914,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,44.778,45.6158,0.0,40.592,102.1132,513.7596,1,0.0,73.3333,1200 +motion,m61,0.2,0.1331,,,3.6364,34.22,0.0077,0,1600,0.0887,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,37.5235,34.8019,0.0,40.592,93.3402,447.0748,1,0.0,73.3333,1200 +motion,m61,0.3,0.1245,,,3.6364,28.3468,0.0076,0,1600,0.0851,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,30.3124,25.302,0.0,40.592,83.2541,374.6657,1,0.0,73.3333,1200 +motion,m61,0.4,0.1177,0,0,3.2849,25.5289,0.0075,0,1600,0.0828,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,26.8089,20.9482,0.0,40.592,80.5343,389.0345,0,0.0,81.1483,1200 +motion,m61,0.5,0.1084,0,0,4.0,23.7443,0.0075,0,1600,0.0814,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,24.5358,18.1873,0.0,40.592,74.0202,253.0818,0,0.0,66.6667,1200 +motion,m61,0.7,,0,0,,21.6325,0.0073,,1600,0.0802,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,22.428,16.2065,0.0,40.592,,,0,,,1200 +motion,m61,1.0,,0,0,,19.5693,0.007,,1600,0.0789,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,20.3787,14.3529,0.0,40.592,,,0,,,1200 +scale,m61,40,0.145,,,3.3312,85.5817,0.0077,0,1577,0.099,0.7971,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,157.3527,155.7931,0.0,40.0085,180.9698,846.461,1,0.0,78.85,1182 +scale,m61,32,0.1449,,,3.3342,84.6161,0.0077,0,1261,0.0989,0.7978,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,261.2192,261.2192,0.0,31.9916,322.332,823.3005,1,0.0,63.05,946 +scale,m61,26,0.1342,,,7.8796,73.1063,0.0077,0,1025,0.1001,0.7979,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,423.751,123.8318,0.0,26.0042,812.7153,246.9024,1,0.0,26.3479,769 +scale,m61,22,0.1191,,,7.9091,191.924,0.0077,0,867,0.098,0.7973,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,525.5809,93.8424,0.0,21.9958,1010.0118,181.1103,1,0.0,21.9747,650 +scale,m61,18,0.1348,,,8.0,282.0182,0.0077,0,709,0.0978,0.798,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,752.5589,70.1978,0.0,17.9873,1580.3363,157.0008,1,0.0,17.9746,532 +scale,m61,15,0.144,,,7.149,391.6242,0.0077,0,591,0.098,0.7971,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1093.3288,54.5485,0.0,14.9937,2089.1778,152.0354,1,0.0,16.445,443 +scale,m61,12,0.149,0,0,7.2532,548.287,0.0077,0,473,0.0955,0.7982,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1546.1948,37.1954,0.0,12.0,2958.409,104.4046,0,0.0,13.1281,355 +scale,m61,10,0.1554,0,0,6.6667,678.1052,0.0077,0,394,0.0935,0.7989,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1935.2518,25.9218,0.0,9.9958,3327.0917,83.757,0,0.0,12.0,296 +scale,m61,8,0.1388,0,0,8.0,789.0104,0.0076,0,315,0.0924,0.7968,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,2069.5753,11.4372,0.0,7.9916,4358.18,28.3456,0,0.0,7.9747,236 +scale,m61,6,,0,0,,843.114,0.0077,,236,0.088,0.7976,ada829bd5984e91d19733de1f6d55d083f228592acfc6f2e7826461457a67fdb,1723.0457,3.9405,0.0,5.9873,,,0,,,177 +blur,m63,0.02,0.2803,,,3.6731,210.49,0.0,1,3375,0.2803,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,13.9955,1127.0903,0.0,114.2775,13.9955,1120.5105,1,0.0,113.9326,2995 +blur,m63,0.04,0.2285,,,3.6309,53.2866,0.0,1,3375,0.2288,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,4.2327,344.5462,0.0,114.2775,4.2337,350.3546,1,0.0,114.9886,2995 +blur,m63,0.06,0.1641,,,-0.044,15.0541,0.0,0,3375,0.1819,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,2.3073,101.888,0.0,114.2775,1.4556,29.9415,1,0.0,109.0502,2995 +blur,m63,0.08,0.0895,,,0.6976,4.8475,0.0,0,3375,0.1471,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,1.7826,29.6811,0.0,114.2775,1.0209,12.8358,1,0.0,315.4634,2995 +blur,m63,0.1,0.0866,0,0,0.9132,1.9012,0.0,0,3375,0.1227,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,1.6613,9.57,0.0,114.2775,1.0089,8.5713,0,0.0,320.2763,2995 +blur,m63,0.13,0.0884,0,0,1.3103,0.6822,0.0,0,3375,0.0977,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,1.6041,2.4403,0.0,114.2775,1.0134,4.4164,0,0.0,312.9818,2995 +blur,m63,0.16,0.0651,0,0,1.3191,0.3474,0.0,0,3375,0.0802,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,1.5707,0.9111,0.0,114.2775,0.9494,2.8723,0,0.0,312.8148,2995 +blur,m63,0.2,0.0592,0,0,1.381,0.2007,0.0,0,3375,0.0641,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,1.4378,0.3463,0.0,114.2775,0.8451,1.4522,0,0.0,295.3125,2995 +blur,m63,0.25,,0,0,,0.1377,0.0,,3375,0.0525,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,1.3258,0.1316,0.0,114.2775,,,0,,,2995 +blur,m63,0.3,,0,0,,0.1114,0.0,,3375,0.0467,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,1.1606,0.0921,0.0,114.2775,,,0,,,2995 +edge,m63,2.0,0.0711,,,0.3041,629.5466,0.0,0,3375,0.3115,1.9981,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,278.5452,2627.9631,0.0,114.2775,19.2632,79.1732,1,0.0,302.82,1931 +edge,m63,1.0,0.0722,,,0.1064,644.2681,0.0,0,3375,0.3115,1.0006,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,278.5452,2627.9631,0.0,114.2775,19.4169,79.6742,1,0.0,309.672,1817 +edge,m63,0.5,0.078,,,0.0475,649.1739,0.0,0,3375,0.3115,0.5018,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,278.5452,2627.9631,0.0,114.2775,19.3927,78.727,1,0.0,298.5076,1760 +edge,m63,0.25,0.3116,,,0.2844,654.2484,0.0,1,3375,0.3115,0.248,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,278.5452,2627.9631,0.0,114.2775,278.8017,2624.0633,1,0.0,114.1206,1731 +edge,m63,0.1,0.3122,,,0.155,655.3883,0.0,1,3375,0.3122,0.0993,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,280.3943,2651.3645,0.0,114.2775,280.3451,2657.9826,1,0.0,114.2488,1714 +edge,m63,0.0,0.3126,,,0.0694,656.6563,0.0,1,3375,0.3126,0.003,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,281.7331,2674.2972,0.0,114.2775,281.6821,2676.6828,1,0.0,114.1442,1703 +edge,m63,-0.5,0.103,0,0,0.1742,666.9515,0.0,0,3375,0.3123,0.0,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,274.8713,2649.4963,0.0,114.2775,19.2188,77.5916,0,0.0,307.3432,1646 +edge,m63,-1.0,0.3103,0,0,0.0302,671.0988,0.0,0,3375,0.312,0.0,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,272.2009,2611.9907,0.0,114.2775,264.9188,2210.6129,0,0.0,107.7916,1588 +edge,m63,-2.0,0.3113,0,0,0.4203,672.4705,0.0,0,3375,0.3118,0.0,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,269.571,2593.097,0.0,114.2775,261.8647,1580.9805,0,0.0,93.2049,1474 +glare,m63,0.3,0.2446,,,3.6568,509.3277,0.0001,1,3375,0.2446,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,173.5382,1609.6176,0.0,114.2775,173.5382,1609.6176,1,0.0,114.3702,2995 +glare,m63,0.5,0.2038,,,3.6568,480.2778,0.0001,1,3375,0.2038,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,117.9727,1081.9032,0.0,114.2775,117.9727,1081.9032,1,0.0,114.3702,2995 +glare,m63,0.7,0.1686,,,3.6568,456.2496,0.0001,1,3375,0.1686,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,75.2547,676.8611,0.0,114.2775,75.2547,676.8611,1,0.0,114.3702,2995 +glare,m63,0.85,0.1484,,,3.6568,441.4782,0.0001,1,3375,0.1484,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,51.5385,454.4699,0.0003,114.2775,51.5385,454.4699,1,0.0003,114.3702,2995 +glare,m63,1.0,0.2361,0,0,0.0526,429.4474,0.006,0,3375,0.1362,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,34.8694,300.0054,0.2269,114.2775,140.8553,796.5385,0,0.0,93.2049,2995 +glare,m63,1.5,0.2361,0,0,0.0526,403.7874,0.0373,0,3375,0.0858,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,10.3772,83.1941,0.648,114.2775,140.8553,796.5385,0,0.0,93.2049,2995 +glare,m63,2.5,0.2361,0,0,0.0526,378.1053,0.0741,0,3375,0.0169,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,0.4006,2.0977,0.9377,114.2775,140.8553,796.5385,0,0.0,93.2049,2995 +glare,m63,4.0,0.2361,0,0,0.0526,359.5658,0.1024,0,3375,0.0,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,0.0,0.0,1.0,114.2775,140.8553,796.5385,0,0.0,93.2049,2995 +motion,m63,0.05,0.3007,,,3.661,399.82,0.0,1,3375,0.3007,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,71.8472,1952.1097,0.0,114.2775,71.8472,1952.1097,1,0.0,114.2649,2995 +motion,m63,0.1,0.285,,,3.6649,267.8513,0.0,1,3375,0.285,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,48.6834,1384.6412,0.0,114.2775,48.6834,1379.9565,1,0.0,114.1409,2995 +motion,m63,0.15,0.2013,0,0,0.0526,199.0957,0.0,0,3375,0.266,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,38.8185,1064.7679,0.0,114.2775,10.3705,177.7168,0,0.0,93.2049,2995 +motion,m63,0.2,0.1934,0,0,0.0356,166.7847,0.0,0,3375,0.2509,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,33.4962,892.7273,0.0,114.2775,8.3695,93.8864,0,0.0,82.5549,2995 +motion,m63,0.3,0.0624,0,0,1.253,141.5159,0.0,0,3375,0.2355,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,28.5562,779.8961,0.0,114.2775,1.9945,28.1668,0,0.0,326.0073,2995 +motion,m63,0.4,0.0666,0,0,1.2028,128.2127,0.0,0,3375,0.2271,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,25.3235,726.6537,0.0,114.2775,1.848,22.1879,0,0.0,328.4269,2995 +motion,m63,0.5,0.0888,0,0,1.1491,120.5376,0.0,0,3375,0.2214,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,23.0707,687.7659,0.0,114.2775,1.7677,17.0685,0,0.0,294.3314,2995 +motion,m63,0.7,,0,0,,110.5158,0.0,,3375,0.215,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,19.5025,643.4599,0.0,114.2775,,,0,,,2995 +motion,m63,1.0,,0,0,,100.294,0.0,,3375,0.2093,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,15.5824,586.5918,0.0,114.2775,,,0,,,2995 +scale,m63,48,0.3116,,,3.6647,569.818,0.0001,1,1418,0.3115,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,1715.4089,2642.8647,0.0,48.0135,1719.1687,2594.6626,1,0.0,47.9588,1258 +scale,m63,40,0.3128,,,3.8022,558.9311,0.0001,1,1181,0.31,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,2583.1069,2644.7712,0.0,39.9887,2582.0957,2442.2926,1,0.0,38.9155,1048 +scale,m63,32,0.3099,,,4.2857,1067.9996,0.0001,1,945,0.3098,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,4229.0097,4229.0097,0.0,31.9977,3938.3406,1555.7616,1,0.0,27.9704,839 +scale,m63,26,0.3069,,,3.5781,1618.2931,0.0002,1,768,0.3096,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,6305.5563,2004.7236,0.0,26.0045,6336.2799,2128.7695,1,0.0,26.3959,681 +scale,m63,22,0.3091,,,4.1,2197.8137,0.0002,1,650,0.307,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,8549.6804,1660.7744,0.0,22.009,8267.7185,1203.6294,1,0.0,20.0,577 +scale,m63,18,0.3076,,,4.8571,3136.8855,0.0002,1,532,0.3025,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,12758.7481,1336.7323,0.0,18.0135,12779.964,603.5345,1,0.0,14.0,472 +scale,m63,15,0.3007,,,3.5105,4258.4646,0.0002,1,443,0.301,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,19099.4327,1146.682,0.0,15.0,19019.06,1250.6489,1,0.0,15.5614,393 +scale,m63,12,0.2914,0,0,3.6667,5734.427,0.0003,0,354,0.292,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,28199.5293,803.5749,0.0005,11.9864,27828.5203,809.96,0,0.0005,12.0,314 +scale,m63,10,0.2388,0,0,1.2041,6624.1669,0.0003,0,295,0.2777,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,34342.6399,553.9614,0.0,9.9887,26377.4692,347.4466,0,0.0059,9.9662,262 +scale,m63,8,0.0912,0,0,1.2727,6917.1277,0.0003,0,236,0.2476,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,34352.9058,205.594,0.0023,7.991,214.5747,37.8244,0,0.0,22.0,210 +scale,m63,6,0.0637,0,0,1.375,6048.7828,0.0003,0,177,0.2036,3.6604,6f7e3edd2b87dc7655cf669741089ba55d883956e37813ba17ff31fe0ca47669,30083.6589,80.008,0.0,5.9932,379.8501,22.4389,0,0.0,15.9101,157 +blur,m64,0.02,0.1426,,,7.2022,123.3001,0.0141,0,3454,0.0585,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,3.1915,126.6392,0.0,113.2567,5.029,135.5807,1,0.0,93.1565,2999 +blur,m64,0.04,0.1254,,,7.1943,25.504,0.0123,0,3454,0.0473,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1.1873,15.3019,0.0,113.2567,1.8258,24.0865,1,0.0,93.2713,2999 +blur,m64,0.06,0.0402,,,8.0417,6.6959,0.0108,1,3454,0.0414,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1.1077,2.9546,0.0,113.2567,1.0831,3.5284,1,0.0,120.4884,2999 +blur,m64,0.08,0.0391,,,7.8074,2.3741,0.0098,1,3454,0.0382,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1.2075,0.8446,0.0,113.2567,1.1492,1.0649,1,0.0,123.8855,2999 +blur,m64,0.1,0.039,,,7.8364,1.0953,0.0089,1,3454,0.0363,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1.2382,0.3292,0.0,113.2567,1.1746,0.4067,1,0.0,123.1541,2999 +blur,m64,0.13,,0,0,,0.4879,0.0079,,3454,0.0346,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,0.9918,0.1555,0.0,113.2567,,,0,,,2999 +blur,m64,0.16,,0,0,,0.293,0.0068,,3454,0.0335,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,0.82,0.1316,0.0,113.2567,,,0,,,2999 +blur,m64,0.2,,0,0,,0.1913,0.0061,,3454,0.0324,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,0.6994,0.1273,0.0,113.2567,,,0,,,2999 +blur,m64,0.25,,0,0,,0.14,0.0052,,3454,0.0314,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,0.6236,0.1406,0.0,113.2567,,,0,,,2999 +blur,m64,0.3,,0,0,,0.1206,0.0041,,3454,0.0304,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,0.58,0.1325,0.0,113.2567,,,0,,,2999 +edge,m64,2.0,0.1474,,,7.8636,465.394,0.0239,0,3454,0.0727,1.9967,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,53.2992,509.4227,0.0,113.2567,81.3537,380.9312,1,0.0,85.4913,2077 +edge,m64,1.0,0.147,,,7.8731,472.6303,0.0253,0,3454,0.0727,0.9989,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,53.2992,509.4227,0.0,113.2567,81.3536,381.3004,1,0.0,85.4913,1964 +edge,m64,0.5,0.1474,,,7.2789,476.5182,0.026,0,3454,0.0727,0.4957,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,53.2992,509.4227,0.0,113.2567,81.3537,380.9312,1,0.0,85.4913,1907 +edge,m64,0.25,0.148,,,6.9746,477.9865,0.0264,0,3454,0.0727,0.2484,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,53.2992,509.4227,0.0,113.2567,81.4531,381.2906,1,0.0,85.4913,1879 +edge,m64,0.1,0.1474,,,6.7512,479.3519,0.0267,0,3454,0.0736,0.0983,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,54.7509,525.9443,0.0,113.2567,81.3537,380.9312,1,0.0,85.4913,1862 +edge,m64,0.0,0.1474,,,6.6304,479.7728,0.0268,0,3454,0.0742,0.0012,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,55.7003,534.187,0.0,113.2567,81.3537,380.9312,1,0.0,85.4913,1851 +edge,m64,-0.5,0.148,0,0,5.9789,485.2573,0.0277,0,3454,0.0743,0.0,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,55.7142,524.1223,0.0,113.2567,81.4531,381.2906,0,0.0,85.4913,1794 +edge,m64,-1.0,0.147,0,0,5.288,493.9937,0.0286,0,3454,0.0742,0.0,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,55.8378,529.5915,0.0,113.2567,81.3536,381.3004,0,0.0,85.4913,1738 +edge,m64,-2.0,0.1479,0,0,3.9868,505.4219,0.0306,0,3454,0.0727,0.0,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,50.0856,502.1971,0.0,113.2567,81.3446,381.3274,0,0.0,85.4913,1624 +glare,m64,0.3,0.1475,,,7.8606,352.8492,0.0166,0,3454,0.0675,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,33.8388,311.8077,0.0,113.2567,81.4618,381.2756,1,0.0,85.4913,2999 +glare,m64,0.5,0.1475,,,7.8606,350.8078,0.0166,0,3454,0.0777,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,23.3386,208.4572,0.0,113.2567,81.4625,381.2744,1,0.0,85.4913,2999 +glare,m64,0.7,0.1476,,,7.8606,349.0045,0.0166,0,3454,0.0953,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,15.1126,128.7794,0.0,113.2567,81.4634,381.2318,1,0.0,85.4913,2999 +glare,m64,0.85,0.1476,,,7.8606,347.8166,0.0166,0,3454,0.1115,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,10.4548,84.508,0.0,113.2567,81.447,381.1039,1,0.0,85.4913,2999 +glare,m64,1.0,0.1476,,,7.8606,346.7629,0.0173,0,3454,0.1291,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,7.1514,53.1558,0.08,113.2567,81.4381,381.0711,1,0.0,85.4913,2999 +glare,m64,1.5,0.1476,0,0,7.8606,344.0235,0.0257,0,3454,0.1093,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,2.1035,11.8572,0.5698,113.2567,81.4342,381.0692,0,0.0,85.4913,2999 +glare,m64,2.5,0.1475,0,0,7.8606,339.8954,0.0361,0,3454,0.0392,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,0.2339,0.2365,0.8548,113.2567,81.4156,380.6669,0,0.0,85.4913,2999 +glare,m64,4.0,0.1475,0,0,7.8606,335.5194,0.0456,0,3454,0.0,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,0.0,0.0,1.0,113.2567,81.3833,380.4741,0,0.0,85.4913,2999 +motion,m64,0.05,0.1438,,,7.8606,280.2573,0.0164,0,3454,0.0672,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,19.6216,330.8462,0.0,113.2567,34.0565,259.1405,1,0.0,85.4913,2999 +motion,m64,0.1,0.1412,,,7.802,215.5418,0.0159,0,3454,0.0611,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,12.8432,190.017,0.0,113.2567,24.2147,182.6433,1,0.0,85.4913,2999 +motion,m64,0.15,0.1435,,,7.3297,181.6356,0.0156,0,3454,0.0561,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,9.956,133.9021,0.0,113.2567,21.8918,179.5611,1,0.0,91.0,2999 +motion,m64,0.2,0.1348,,,8.2369,165.524,0.0154,0,3454,0.0528,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,8.4199,109.318,0.0,113.2567,13.7751,97.0303,1,0.0,81.4426,2999 +motion,m64,0.3,0.1817,,,4.6404,151.6002,0.0152,0,3454,0.0487,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,6.7546,85.2229,0.0,113.2567,9.9069,405.3777,1,0.0,156.2638,2999 +motion,m64,0.4,0.0412,,,8.4469,144.5696,0.0149,1,3454,0.0461,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,5.7284,70.5926,0.0,113.2567,5.3379,71.1637,1,0.0,115.0315,2999 +motion,m64,0.5,0.0377,,,9.2381,138.8201,0.0146,1,3454,0.044,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,4.8945,59.3029,0.0,113.2567,5.2353,59.4487,1,0.0,105.4273,2999 +motion,m64,0.7,,0,0,,131.29,0.0141,,3454,0.0418,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,3.914,46.8297,0.0,113.2567,,,0,,,2999 +motion,m64,1.0,,0,0,,121.7487,0.0133,,3454,0.04,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,3.1401,37.4585,0.0,113.2567,,,0,,,2999 +scale,m64,48,0.1565,,,7.2112,358.6063,0.0163,0,1464,0.0725,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,436.245,513.1882,0.0,48.0046,629.4609,506.4242,1,0.0,39.4372,1271 +scale,m64,40,0.1531,,,7.4305,350.8547,0.0162,0,1220,0.0715,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,659.7735,503.8978,0.0,40.0038,902.4032,902.4032,1,0.0,32.0,1059 +scale,m64,32,0.1453,,,7.9073,640.5968,0.016,0,976,0.0719,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1002.4132,1002.4132,0.0,32.003,1132.1232,251.4963,1,0.0,24.0,847 +scale,m64,26,0.1516,,,7.6096,933.8654,0.0159,0,793,0.0683,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1303.0713,337.2297,0.0,26.0025,1449.3402,203.0502,1,0.0,19.9748,688 +scale,m64,22,0.135,,,9.2836,1230.3864,0.0158,0,671,0.0694,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1542.3924,245.5961,0.0,22.0021,1522.1988,67.6934,1,0.0,13.9792,583 +scale,m64,18,0.0718,,,5.1239,1624.8313,0.0153,1,549,0.0656,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1611.2865,129.5619,0.0,18.0017,819.1207,259.0207,1,0.0,29.4631,477 +scale,m64,15,0.0709,,,5.2014,1971.5401,0.0152,1,457,0.0621,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1588.812,71.9687,0.0,14.985,862.2633,165.1236,1,0.0,24.1519,397 +scale,m64,12,0.0731,,,4.3348,2351.5801,0.0146,1,366,0.0536,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1612.743,33.428,0.0,12.0011,705.3551,112.9614,1,0.0,22.7129,318 +scale,m64,10,0.0648,,,7.0,2561.1278,0.0139,1,305,0.0527,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1547.2301,17.7376,0.0,10.0009,1226.7484,24.7417,1,0.0,11.9608,265 +scale,m64,8,0.2033,0,0,4.1667,2574.1626,0.014,0,244,0.0542,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1378.2277,6.0657,0.0,8.0008,13207.2272,327.9657,0,0.0,12.0,212 +scale,m64,6,0.2356,0,0,3.0,2494.0915,0.0125,0,183,0.0396,8.5425,2e967e936529efd449fe5d16fb3854dfa2422e182e8c56877f453380f1d3a6e3,1125.0225,2.2902,0.0,6.0006,10828.303,264.7142,0,0.0,11.9348,159 +blur,m65,0.02,0.112,,,12.9422,163.1414,0.0008,0,4032,0.1941,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,10.092,277.4646,0.0,87.6154,26.1476,91.3995,1,0.0,47.093,3024 +blur,m65,0.04,0.1735,,,13.4375,40.0692,0.0001,1,4032,0.1771,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,2.8784,74.4442,0.0,87.6154,2.7583,88.3821,1,0.0,93.7674,3024 +blur,m65,0.06,0.1552,,,12.5302,12.0888,0.0,1,4032,0.1608,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1.7537,26.6911,0.0,87.6154,1.5827,38.6875,1,0.0,100.5492,3024 +blur,m65,0.08,0.1401,,,12.6471,4.9038,0.0,1,4032,0.1468,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1.4841,10.6588,0.0,87.6154,1.3542,15.6445,1,0.0,99.6279,3024 +blur,m65,0.1,0.1265,,,11.9444,2.3929,0.0,1,4032,0.1356,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1.4161,4.3601,0.0,87.6154,1.2705,7.7032,1,0.0,105.4884,3024 +blur,m65,0.13,,0,0,,1.0561,0.0,,4032,0.1225,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1.5178,1.3011,0.0,87.6154,,,0,,,3024 +blur,m65,0.16,,0,0,,0.5871,0.0,,4032,0.1117,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1.4891,0.5306,0.0,87.6154,,,0,,,3024 +blur,m65,0.2,,0,0,,0.3377,0.0,,4032,0.099,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1.5257,0.2654,0.0,87.6154,,,0,,,3024 +blur,m65,0.25,,0,0,,0.2119,0.0,,4032,0.0852,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1.1882,0.199,0.0,87.6154,,,0,,,3024 +blur,m65,0.3,,0,0,,0.157,0.0,,4032,0.0735,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1.1148,0.1409,0.0,87.6154,,,0,,,3024 +edge,m65,2.0,0.1465,,,13.0651,336.2726,0.0001,0,4032,0.2049,1.9954,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,92.9323,676.6001,0.0,87.6154,438.1669,858.3163,1,0.0,47.093,1922 +edge,m65,1.0,0.1469,,,13.0298,336.6252,0.0001,0,4032,0.2049,1.0024,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,92.9323,676.6001,0.0,87.6154,439.3073,857.6101,1,0.0,47.093,1835 +edge,m65,0.5,0.1466,,,13.0499,338.4054,0.0001,0,4032,0.2049,0.5002,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,92.9323,676.6001,0.0,87.6154,437.9888,855.9715,1,0.0,47.093,1791 +edge,m65,0.25,0.1318,,,14.9166,337.7284,0.0001,0,4032,0.2049,0.2491,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,92.9323,676.6001,0.0,87.6154,451.5261,631.1669,1,0.0,41.2326,1769 +edge,m65,0.1,0.1466,,,13.0508,339.8056,0.0001,0,4032,0.2043,0.1007,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,93.4003,692.2431,0.0,87.6154,437.9888,855.9715,1,0.0,47.093,1756 +edge,m65,0.0,0.1469,,,13.0273,341.9099,0.0001,0,4032,0.2036,0.0,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,94.9717,698.7403,0.0,87.6154,439.3073,857.6101,1,0.0,47.093,1747 +edge,m65,-0.5,0.1467,,,13.0483,342.8718,0.0001,0,4032,0.202,0.0,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,100.1547,732.3504,0.0,87.6154,439.4096,860.5961,1,0.0,47.093,1703 +edge,m65,-1.0,0.1469,0,0,13.0323,340.1887,0.0001,0,4032,0.1993,0.0,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,100.0613,734.2534,0.0,87.6154,439.3073,857.6101,0,0.0,47.093,1660 +edge,m65,-2.0,0.1465,0,0,13.0783,332.3083,0.0001,0,4032,0.1943,0.0,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,100.751,713.2325,0.0,87.6154,438.1669,858.3163,0,0.0,47.093,1572 +glare,m65,0.3,0.1327,,,14.9239,340.618,0.002,0,4032,0.1561,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,57.3153,405.8824,0.0,87.6154,460.6701,644.6243,1,0.0,41.2326,3024 +glare,m65,0.5,0.1327,,,14.9239,337.3001,0.002,0,4032,0.1288,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,38.4561,267.0019,0.0,87.6154,460.6701,644.6243,1,0.0,41.2326,3024 +glare,m65,0.7,0.1327,,,14.9239,334.5102,0.002,0,4032,0.1093,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,24.0886,160.7952,0.0,87.6154,460.6701,644.6243,1,0.0,41.2326,3024 +glare,m65,0.85,0.1327,,,14.9239,332.7623,0.002,0,4032,0.1031,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,16.2026,103.3264,0.0,87.6154,460.6701,644.6243,1,0.0,41.2326,3024 +glare,m65,1.0,0.1327,,,14.9239,331.3097,0.0028,0,4032,0.1053,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,10.7406,64.2883,0.1378,87.6154,460.6701,644.6243,1,0.0,41.2326,3024 +glare,m65,1.5,0.1327,0,0,14.9239,327.9268,0.0089,0,4032,0.0723,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,3.0199,14.0611,0.5973,87.6154,460.6701,644.6243,0,0.0,41.2326,3024 +glare,m65,2.5,0.1327,0,0,14.9239,323.4834,0.0164,0,4032,0.02,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,0.3288,0.4791,0.8933,87.6154,460.5055,644.4361,0,0.0,41.2326,3024 +glare,m65,4.0,0.1326,0,0,14.9239,318.7949,0.0234,0,4032,0.0,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,0.0,0.0,1.0,87.6154,459.7362,643.0942,0,0.0,41.2326,3024 +motion,m65,0.05,0.1193,,,14.9239,292.4346,0.0017,0,4032,0.202,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,38.6611,529.4033,0.0,87.6154,191.8662,323.0151,1,0.0,41.2326,3024 +motion,m65,0.1,0.1935,,,14.55,182.6857,0.0012,1,4032,0.1948,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,20.8592,309.3116,0.0,87.6154,21.7832,319.1809,1,0.0,86.9515,3024 +motion,m65,0.15,0.1827,,,13.545,140.1463,0.001,1,4032,0.1886,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,15.9301,231.101,0.0,87.6154,15.3147,264.606,1,0.0,92.9641,3024 +motion,m65,0.2,0.1722,,,12.6471,117.6287,0.0008,1,4032,0.1811,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,12.5078,179.2402,0.0,87.6154,11.2774,228.1432,1,0.0,99.6279,3024 +motion,m65,0.3,0.163,,,13.0667,101.7316,0.0006,1,4032,0.17,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,9.6409,136.9463,0.0,87.6154,8.9254,168.5524,1,0.0,95.9635,3024 +motion,m65,0.4,0.0649,,,8.6,91.1551,0.0005,0,4032,0.1604,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,7.6795,105.1676,0.0,87.6154,18.081,71.3557,1,0.0,58.6046,3024 +motion,m65,0.5,0.0674,0,0,21.375,83.9708,0.0003,0,4032,0.1542,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,6.4748,86.2274,0.0,87.6154,11.8701,26.6889,0,0.0,46.8837,3024 +motion,m65,0.7,,0,0,,74.2818,0.0002,,4032,0.1458,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,5.069,64.8097,0.0,87.6154,,,0,,,3024 +motion,m65,1.0,,0,0,,64.0296,0.0,,4032,0.1389,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,4.0863,51.0131,0.0,87.6154,,,0,,,3024 +scale,m65,48,0.1315,,,14.6666,351.4669,0.002,0,2209,0.2047,14.4406,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,403.9395,664.8864,0.0,48.0016,2461.7804,503.2107,1,0.0,22.9896,1657 +scale,m65,40,0.1286,,,15.0027,353.1918,0.002,0,1841,0.2047,14.4411,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,637.9528,677.6077,0.0,40.0049,3641.3243,373.4359,1,0.0,18.7311,1381 +scale,m65,32,0.1442,,,12.9824,350.5157,0.002,0,1473,0.2045,14.4286,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1098.9719,1098.9719,0.0,32.0083,4932.0089,373.4939,1,0.0,17.1395,1104 +scale,m65,26,0.1194,,,15.1624,337.0827,0.002,0,1197,0.2042,14.4264,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,1756.3843,531.2436,0.0,26.0108,6613.9769,147.1613,1,0.0,11.99,897 +scale,m65,22,0.1413,,,12.8164,665.3916,0.002,0,1012,0.2034,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,2478.8076,431.9361,0.0,21.9908,7131.3561,168.727,1,0.0,12.0,759 +scale,m65,18,0.1926,,,12.8793,807.1608,0.002,1,828,0.2023,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,3556.1933,330.2555,0.0,17.9924,3259.2131,408.3074,1,0.0,20.0,621 +scale,m65,15,0.2047,,,14.7143,911.8392,0.0017,1,690,0.2025,14.4524,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,5030.2612,242.8786,0.0,14.9937,5278.2942,214.6581,1,0.0,14.0,518 +scale,m65,12,0.19,,,12.4286,967.1136,0.0009,1,552,0.1984,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,6806.8158,158.6563,0.0,11.995,5989.0664,236.9177,1,0.0,14.0,414 +scale,m65,10,0.1833,,,11.5406,983.2458,0.0004,1,460,0.1957,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,8998.7663,122.4109,0.0,9.9958,7170.1272,198.8339,1,0.0,12.4375,345 +scale,m65,8,0.1737,,,4.25,1063.7641,0.0003,0,368,0.1906,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,12701.4549,60.053,0.0,7.9966,836.1673,52.2622,1,0.0,16.0,276 +scale,m65,6,0.1624,,,9.2885,1179.8015,0.0003,0,276,0.1787,14.4384,4b097b40cda613076647ebeb78710504cfb784942971b31cc0385ea0339d5ee5,16709.9068,35.9379,0.0,5.9975,12087.3834,29.7526,1,0.0,6.0,207 +blur,m66,0.02,0.108,,,6.8309,57.8177,0.0,1,4160,0.1081,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,6.3837,258.2099,0.0,101.5456,6.3867,251.9866,1,0.0,100.6703,3120 +blur,m66,0.04,0.0798,,,2.2252,19.0423,0.0,0,4160,0.0872,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,2.2776,55.5393,0.0,101.5456,1.3519,24.694,1,0.0,122.029,3120 +blur,m66,0.06,0.0736,,,6.5716,6.9051,0.0,1,4160,0.0724,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,1.6101,12.4267,0.0,101.5456,1.5911,13.8524,1,0.0,104.5939,3120 +blur,m66,0.08,0.0525,0,0,8.7692,3.1176,0.0,0,4160,0.0634,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,1.4271,3.3377,0.0,101.5456,1.5617,1.3094,0,0.0,78.6046,3120 +blur,m66,0.1,,0,0,,1.6704,0.0,,4160,0.0579,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,1.4493,1.1259,0.0,101.5456,,,0,,,3120 +blur,m66,0.13,,0,0,,0.819,0.0,,4160,0.0531,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,1.3081,0.3151,0.0,101.5456,,,0,,,3120 +blur,m66,0.16,,0,0,,0.487,0.0,,4160,0.0503,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,1.1585,0.1624,0.0,101.5456,,,0,,,3120 +blur,m66,0.2,,0,0,,0.2998,0.0,,4160,0.0479,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,1.0134,0.1042,0.0,101.5456,,,0,,,3120 +blur,m66,0.25,,0,0,,0.2001,0.0,,4160,0.0461,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,0.904,0.0742,0.0,101.5456,,,0,,,3120 +blur,m66,0.3,,0,0,,0.1568,0.0,,4160,0.045,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,0.8111,0.0578,0.0,101.5456,,,0,,,3120 +edge,m66,2.0,0.123,,,2.0001,114.9917,0.0,1,4160,0.123,1.9954,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,68.0765,591.2082,0.0,101.5456,67.6965,590.8264,1,0.0,101.8073,1686 +edge,m66,1.0,0.1228,,,1.0598,117.556,0.0,1,4160,0.123,1.0008,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,68.0765,591.2082,0.0,101.5456,67.0245,576.0314,1,0.0,100.8442,1585 +edge,m66,0.5,0.1224,,,0.5867,118.5144,0.0,1,4160,0.123,0.4986,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,68.0765,591.2082,0.0,101.5456,66.463,559.4926,1,0.0,99.7935,1534 +edge,m66,0.25,0.1227,,,0.3391,119.2887,0.0,1,4160,0.123,0.2524,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,68.0765,591.2082,0.0,101.5456,67.0919,579.3223,1,0.0,100.8492,1509 +edge,m66,0.1,0.1225,,,0.1057,119.101,0.0,1,4160,0.1231,0.1047,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,68.5775,600.8515,0.0,101.5456,66.582,556.8298,1,0.0,99.5027,1494 +edge,m66,0.0,0.1228,,,0.1005,119.1079,0.0,1,4160,0.1233,0.0,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,69.0179,604.0924,0.0,101.5456,67.1368,565.1053,1,0.0,99.7985,1483 +edge,m66,-0.5,0.1227,0,0,0.0406,119.7867,0.0,0,4160,0.1236,0.0,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,68.7434,617.4748,0.0,101.5456,66.5397,548.8615,0,0.0,98.3477,1433 +edge,m66,-1.0,0.1215,0,0,0.0399,120.7844,0.0,0,4160,0.1233,0.0,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,68.579,629.8637,0.0,101.5456,64.9721,514.9374,0,0.0,95.1749,1382 +edge,m66,-2.0,0.1195,0,0,0.0359,121.4709,0.0,0,4160,0.123,0.0,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,66.642,616.8738,0.0,101.5456,60.7184,424.6623,0,0.0,91.3019,1280 +glare,m66,0.3,0.0971,,,6.7705,98.2082,0.0,1,4160,0.0971,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,43.9206,359.5488,0.0,101.5456,43.9206,359.5488,1,0.0,101.526,3120 +glare,m66,0.5,0.0836,,,6.4334,94.2377,0.0,1,4160,0.0828,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,30.247,239.3619,0.0,101.5456,30.6709,278.0847,1,0.0,106.7685,3120 +glare,m66,0.7,0.0736,,,6.59,91.0483,0.0,1,4160,0.073,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,19.8648,147.9468,0.0005,101.5456,20.0385,159.1015,1,0.0005,104.291,3120 +glare,m66,0.85,0.0701,,,6.4989,89.1592,0.0001,1,4160,0.0693,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,13.9919,97.132,0.0107,101.5456,14.1485,107.9676,1,0.0103,105.7096,3120 +glare,m66,1.0,0.0616,0,0,13.3077,87.7283,0.0034,1,4160,0.0697,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,9.6411,62.4751,0.2568,101.5456,8.455,31.7624,0,0.1325,78.6046,3120 +glare,m66,1.5,0.097,0,0,2.3,85.1652,0.0192,0,4160,0.0481,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,3.0214,16.181,0.6488,101.5456,26.744,139.7538,0,0.0,120.9302,3120 +glare,m66,2.5,0.0976,0,0,2.2752,83.1094,0.038,0,4160,0.0109,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,0.233,0.4366,0.9396,101.5456,26.6732,141.1398,0,0.0,122.0121,3120 +glare,m66,4.0,0.0966,0,0,2.273,81.6461,0.0556,0,4160,0.0,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,0.0,0.0,1.0,101.5456,26.5743,139.8533,0,0.0,122.109,3120 +motion,m66,0.05,0.1186,,,6.7586,88.7744,0.0,1,4160,0.1186,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,24.5022,474.8293,0.0,101.5456,24.5022,476.9781,1,0.0,101.7037,3120 +motion,m66,0.1,0.1105,,,6.6773,62.9741,0.0,1,4160,0.1103,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,16.8595,317.4234,0.0,101.5456,16.7156,325.2802,1,0.0,102.9423,3120 +motion,m66,0.15,0.0824,,,2.25,49.7091,0.0,0,4160,0.1035,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,14.2207,249.3227,0.0,101.5456,2.9762,41.596,1,0.0,120.9302,3120 +motion,m66,0.2,0.0633,,,14.5159,44.0419,0.0,0,4160,0.0989,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,13.0411,226.3538,0.0,101.5456,3.1438,11.5639,1,0.0,67.0635,3120 +motion,m66,0.3,0.0572,,,13.3662,38.6767,0.0,0,4160,0.0938,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,11.6251,202.8455,0.0,101.5456,2.4962,10.2576,1,0.0,73.254,3120 +motion,m66,0.4,0.0758,0,0,9.5,35.9694,0.0,0,4160,0.0909,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,10.6664,190.8724,0.0,101.5456,3.9315,26.9879,0,0.0,72.5581,3120 +motion,m66,0.5,,0,0,,34.44,0.0,,4160,0.0896,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,10.0874,184.6688,0.0,101.5456,,,0,,,3120 +motion,m66,0.7,,0,0,,32.3426,0.0,,4160,0.0879,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,9.034,174.6525,0.0,101.5456,,,0,,,3120 +motion,m66,1.0,,0,0,,30.1084,0.0,,4160,0.0861,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,7.8369,160.6164,0.0,101.5456,,,0,,,3120 +scale,m66,48,0.1227,,,6.8439,107.2992,0.0,1,1966,0.123,6.7713,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,327.9004,593.7692,0.0,47.9901,327.0632,582.2975,1,0.0,47.393,1475 +scale,m66,40,0.1232,,,6.6739,107.9264,0.0,1,1639,0.123,6.7677,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,528.9749,606.7104,0.0,40.008,531.3878,630.8774,1,0.0,40.5603,1229 +scale,m66,32,0.1227,,,6.7011,107.0171,0.0,1,1311,0.1228,6.7673,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,927.85,927.85,0.0,32.0015,924.9544,653.4925,1,0.0,32.4101,983 +scale,m66,26,0.1217,,,6.738,102.999,0.0,1,1065,0.1221,6.7712,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,1492.4992,478.6104,0.0,25.9967,1521.1034,495.8228,1,0.0,26.0261,799 +scale,m66,22,0.1211,,,6.4265,222.6615,0.0,1,901,0.121,6.7716,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,2136.9935,404.4951,0.0,21.9934,2134.9417,471.5741,1,0.0,22.9719,676 +scale,m66,18,0.0966,,,2.3718,321.7181,0.0,0,737,0.1185,6.7721,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,2963.5949,308.8315,0.0,17.9902,488.5403,78.5703,1,0.0,20.8648,553 +scale,m66,15,0.071,,,14.6,422.8972,0.0,0,615,0.1154,6.7654,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,3758.1904,211.1935,0.0,15.0122,1747.9175,23.4926,1,0.0,9.9838,461 +scale,m66,12,0.1085,,,7.9784,569.0188,0.0,1,492,0.1085,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,4347.3185,116.464,0.0,12.0097,4743.8546,68.3644,1,0.0,10.0,369 +scale,m66,10,,0,0,,696.9543,0.0,,410,0.1015,6.758,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,4761.8008,68.3876,0.0,10.0081,,,0,,,307 +scale,m66,8,,0,0,,860.4309,0.0,,328,0.0921,6.769,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,4416.6401,22.9669,0.0,8.0065,,,0,,,246 +scale,m66,6,,0,0,,1086.1047,0.0,,246,0.079,6.7507,37306bb0ef4f8fd140632591f0c4cc8705e6bd746aea2ef3ced9c3b08f7c6ed9,3509.8897,8.7271,0.0,6.0049,,,0,,,184 +blur,m68,0.02,0.109,,,5.1,53.4777,0.0003,1,1600,0.109,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,29.1012,77.4705,0.0,46.512,28.9922,77.0834,1,0.0,46.5116,1200 +blur,m68,0.04,0.1004,,,5.1,13.23,0.0001,1,1600,0.1003,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,7.9623,23.9931,0.0,46.512,7.9333,23.8532,1,0.0,46.5116,1200 +blur,m68,0.06,0.0925,,,5.1,4.4924,0.0,1,1600,0.0925,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,3.5814,7.7376,0.0,46.512,3.5737,7.6787,1,0.0,46.5116,1200 +blur,m68,0.08,0.0849,,,4.9048,1.9506,0.0,1,1600,0.0859,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,2.3053,2.9007,0.0,46.512,2.2912,3.4689,1,0.0,48.8372,1200 +blur,m68,0.1,0.079,,,5.0,1.069,0.0,1,1600,0.0803,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,1.907,1.3833,0.0,46.512,1.901,1.7133,1,0.0,48.8372,1200 +blur,m68,0.13,,0,0,,0.5902,0.0,,1600,0.0728,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,1.7715,0.713,0.0,46.512,,,0,,,1200 +blur,m68,0.16,,0,0,,0.4148,0.0,,1600,0.0665,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,1.7255,0.4928,0.0,46.512,,,0,,,1200 +blur,m68,0.2,,0,0,,0.3088,0.0,,1600,0.06,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,1.64,0.3463,0.0,46.512,,,0,,,1200 +blur,m68,0.25,,0,0,,0.2502,0.0,,1600,0.0549,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,1.6438,0.268,0.0,46.512,,,0,,,1200 +blur,m68,0.3,,0,0,,0.2222,0.0,,1600,0.0522,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,1.5593,0.2469,0.0,46.512,,,0,,,1200 +edge,m68,2.0,0.1159,,,2.0917,135.4951,0.0006,1,1600,0.114,1.9971,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,156.3484,163.4184,0.0,46.512,164.3559,157.6279,1,0.0,44.4651,758 +edge,m68,1.0,0.1159,,,1.0466,135.9901,0.0007,1,1600,0.114,1.0081,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,156.3484,163.4184,0.0,46.512,164.3581,157.0075,1,0.0,44.4651,712 +edge,m68,0.5,0.1159,,,0.5227,136.5718,0.0007,1,1600,0.114,0.4921,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,156.3484,163.4184,0.0,46.512,164.3559,157.6279,1,0.0,44.4651,688 +edge,m68,0.25,0.1159,,,0.3129,135.2104,0.0007,1,1600,0.114,0.2556,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,156.3484,163.4184,0.0,46.512,165.0698,157.7369,1,0.0,44.4651,677 +edge,m68,0.1,0.116,,,0.0523,133.1919,0.0007,1,1600,0.1142,0.1051,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,158.6955,166.5835,0.0,46.512,166.8478,159.318,1,0.0,44.4651,670 +edge,m68,0.0,0.1163,,,0.0521,131.9641,0.0007,1,1600,0.1144,0.0,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,160.4267,167.1199,0.0,46.512,168.3079,161.8046,1,0.0,44.4651,665 +edge,m68,-0.5,0.1153,0,0,0.0523,131.0668,0.0007,0,1600,0.1135,0.0,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,155.0632,159.8463,0.0,46.512,162.7063,153.8136,0,0.0,44.4651,642 +edge,m68,-1.0,0.1172,0,0,0.0521,131.1665,0.0008,0,1600,0.1153,0.0,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,161.672,167.1936,0.0,46.512,169.6994,161.0091,0,0.0,44.4651,619 +edge,m68,-2.0,0.1168,0,0,0.0523,118.6563,0.0008,0,1600,0.115,0.0,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,151.365,156.7617,0.0,46.512,158.9612,151.8064,0,0.0,44.4651,572 +glare,m68,0.3,0.0886,,,5.387,114.4717,0.0004,1,1600,0.0868,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,99.7024,102.2013,0.0,46.512,104.7402,98.7083,1,0.0,44.4651,1200 +glare,m68,0.5,0.0745,,,5.1,108.182,0.0004,1,1600,0.0745,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,69.5886,70.2113,0.0,46.512,69.5886,70.2113,1,0.0,46.5116,1200 +glare,m68,0.7,0.0684,,,4.8571,102.8299,0.0004,1,1600,0.0699,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,45.7238,45.3804,0.0,46.512,43.3658,47.5321,1,0.0,48.8372,1200 +glare,m68,0.85,0.0725,,,5.1,99.416,0.0004,1,1600,0.0725,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,32.1538,31.4909,0.0,46.512,32.1538,31.4909,1,0.0,46.5116,1200 +glare,m68,1.0,0.0747,0,0,10.2349,96.5234,0.0047,0,1600,0.0799,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,22.2265,21.6578,0.2145,46.512,25.6807,29.4418,0,0.0414,49.0451,1200 +glare,m68,1.5,0.1196,0,0,8.5,89.6558,0.0343,0,1600,0.0568,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,6.583,6.3324,0.6405,46.512,411.639,210.0684,0,0.0,32.5581,1200 +glare,m68,2.5,0.1171,0,0,8.5,81.9743,0.0734,0,1600,0.0101,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,0.1949,0.0638,0.9298,46.512,395.519,201.8143,0,0.0,32.5581,1200 +glare,m68,4.0,0.1136,0,0,8.5,75.4876,0.1072,0,1600,0.0,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,0.0,0.0,1.0,46.512,371.8499,189.7315,0,0.0,32.5581,1200 +motion,m68,0.05,0.113,,,5.1,110.4495,0.0004,1,1600,0.113,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,107.6234,141.4345,0.0,46.512,107.1646,140.8139,1,0.0,46.5116,1200 +motion,m68,0.1,0.1085,,,5.1,75.3474,0.0004,1,1600,0.1085,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,58.7245,84.9515,0.0,46.512,58.4758,84.5284,1,0.0,46.5116,1200 +motion,m68,0.15,0.1051,,,5.05,65.9268,0.0003,1,1600,0.105,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,45.3329,63.2442,0.0,46.512,44.8614,62.4474,1,0.0,46.5116,1200 +motion,m68,0.2,0.1018,,,5.05,60.8339,0.0003,1,1600,0.1017,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,37.5389,50.158,0.0,46.512,37.1534,49.5645,1,0.0,46.5116,1200 +motion,m68,0.3,0.095,,,5.15,54.816,0.0003,1,1600,0.095,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,27.798,35.347,0.0,46.512,27.6859,35.1661,1,0.0,46.5116,1200 +motion,m68,0.4,0.0899,,,5.05,51.7425,0.0003,1,1600,0.0897,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,22.6278,28.7952,0.0,46.512,22.3131,28.3557,1,0.0,46.5116,1200 +motion,m68,0.5,0.0852,,,4.8571,50.0349,0.0003,1,1600,0.086,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,19.8872,25.1453,0.0,46.512,18.6095,26.4985,1,0.0,48.8372,1200 +motion,m68,0.7,0.0788,0,0,5.1,47.0052,0.0003,0,1600,0.0783,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,15.2826,19.0595,0.0,46.512,15.0846,18.7801,0,0.0,46.5116,1200 +motion,m68,1.0,,0,0,,44.0451,0.0003,,1600,0.0726,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,11.6162,14.7022,0.0,46.512,,,0,,,1200 +scale,m68,40,0.1141,,,5.1,124.22,0.0004,1,1376,0.1141,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,172.4259,159.556,0.0,40.0003,172.4259,159.556,1,0.0,40.0,1032 +scale,m68,32,0.1142,,,5.1297,121.2119,0.0004,1,1101,0.1139,5.1014,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,260.2641,260.2641,0.0,32.0061,259.5324,259.5324,1,0.0,31.971,826 +scale,m68,26,0.1111,,,4.7073,254.7881,0.0004,1,894,0.1137,5.1036,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,401.5878,126.5434,0.0,25.9886,365.4392,145.7811,1,0.0,28.0,671 +scale,m68,22,0.1114,,,4.6728,321.598,0.0004,1,757,0.1139,5.1021,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,593.101,108.3994,0.0,22.006,555.3616,132.3258,1,0.0,23.9683,568 +scale,m68,18,0.1106,,,4.5073,436.4184,0.0004,1,619,0.1132,5.0971,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,976.6328,92.1528,0.0,17.9943,889.941,120.3799,1,0.0,19.9677,464 +scale,m68,15,0.1053,,,6.4832,553.6739,0.0003,1,516,0.1138,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,1546.5923,78.5466,0.0,15.0001,1425.8028,67.0409,1,0.0,16.0,387 +scale,m68,12,0.1018,,,5.8713,727.2471,0.0003,1,413,0.1116,5.104,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,2303.1626,51.8628,0.0,12.0059,1950.4376,74.5971,1,0.0,13.9662,310 +scale,m68,10,0.1038,,,4.1667,875.5832,0.0002,1,344,0.1092,5.0998,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,2921.3777,36.6075,0.0,10.0001,2306.4593,51.1946,1,0.0,12.0,258 +scale,m68,8,0.0988,0,0,4.6167,1002.3936,0.0002,0,275,0.1063,5.0937,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,3626.9213,15.9739,0.0,7.9942,3920.781,52.4406,0,0.0,9.9638,206 +scale,m68,6,0.1048,0,0,4.3056,1128.8324,0.0003,0,206,0.1011,5.1163,254bbea508fa319de8a4850aee2180547cc989ace30983ff0d1095a58805b4d4,3896.1576,8.3822,0.0,5.9884,6319.6415,15.2186,0,0.0,6.0,155 +blur,m69,0.02,0.1264,,,9.5387,153.9881,0.0136,1,2741,0.1349,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,20.9871,346.9792,0.0,74.6648,21.6041,333.6278,1,0.0,73.5356,1766 +blur,m69,0.04,0.1074,,,10.2039,35.5912,0.0122,1,2741,0.1144,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,4.4921,72.2293,0.0,74.6648,4.7383,55.0328,1,0.0,68.3633,1766 +blur,m69,0.06,0.0889,,,8.6872,9.6772,0.0112,1,2741,0.1,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,1.8418,16.8154,0.0,74.6648,1.7709,16.6682,1,0.0,78.439,1766 +blur,m69,0.08,0.0789,,,8.5721,3.8328,0.0104,1,2741,0.0906,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,1.4084,4.9368,0.0,74.6648,1.4207,5.1935,1,0.0,79.9883,1766 +blur,m69,0.1,0.0737,0,0,8.5143,1.8883,0.0097,0,2741,0.0839,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,1.3213,1.8095,0.0,74.6648,1.3781,2.0687,0,0.0,80.5178,1766 +blur,m69,0.13,,0,0,,0.8697,0.0089,,2741,0.0767,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,1.4008,0.6219,0.0,74.6648,,,0,,,1766 +blur,m69,0.16,,0,0,,0.512,0.0083,,2741,0.0715,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,1.3961,0.3177,0.0,74.6648,,,0,,,1766 +blur,m69,0.2,,0,0,,0.3204,0.0076,,2741,0.0663,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,1.4571,0.1837,0.0,74.6648,,,0,,,1766 +blur,m69,0.25,,0,0,,0.217,0.0067,,2741,0.0618,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,1.3421,0.1292,0.0,74.6648,,,0,,,1766 +blur,m69,0.3,,0,0,,0.1707,0.0058,,2741,0.0584,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,1.163,0.1334,0.0,74.6648,,,0,,,1766 +edge,m69,2.0,0.1451,,,2.1622,367.8513,0.0221,1,2741,0.1497,1.9996,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,125.4711,816.8404,0.0,74.6648,131.0104,753.7165,1,0.0,69.8578,1206 +edge,m69,1.0,0.1468,,,1.1124,371.5353,0.0235,1,2741,0.1497,0.9951,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,125.4711,816.8404,0.0,74.6648,135.119,727.4786,1,0.0,68.0217,1131 +edge,m69,0.5,0.1474,,,0.6461,374.4533,0.0242,1,2741,0.1497,0.4995,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,125.4711,816.8404,0.0,74.6648,135.1473,725.8436,1,0.0,67.7282,1094 +edge,m69,0.25,0.1447,,,0.2811,374.4583,0.0246,1,2741,0.1497,0.245,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,125.4711,816.8404,0.0,74.6648,129.4301,747.5321,1,0.0,70.0514,1075 +edge,m69,0.1,0.1481,,,0.1765,375.4691,0.0249,1,2741,0.1509,0.0977,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,127.4969,830.7964,0.0,74.6648,136.4838,732.8748,1,0.0,67.7282,1064 +edge,m69,0.0,0.1477,,,0.0542,374.4366,0.025,1,2741,0.1516,0.004,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,128.8334,839.8666,0.0,74.6648,134.3797,768.8742,1,0.0,69.788,1057 +edge,m69,-0.5,0.0382,0,0,12.8485,372.8395,0.0259,0,2741,0.1479,0.0,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,125.4848,813.3678,0.0,74.6648,40.9641,1.1963,0,0.5538,12.3705,1019 +edge,m69,-1.0,0.0299,0,0,6.1986,372.8502,0.0268,0,2741,0.1503,0.0,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,129.7503,840.4187,0.0,74.6648,22.5678,5.5908,0,0.7071,24.3516,982 +edge,m69,-2.0,0.0324,0,0,8.1579,365.8419,0.0288,0,2741,0.1463,0.0,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,124.1754,796.4843,0.0,74.6648,27.9857,3.1538,0,0.6892,18.9931,907 +glare,m69,0.3,0.116,,,9.6403,307.5316,0.0158,1,2741,0.1222,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,80.9514,515.8299,0.0,74.6648,76.2207,472.5294,1,0.0,72.7686,1766 +glare,m69,0.5,0.102,,,9.7838,289.7903,0.0158,1,2741,0.1064,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,56.9482,358.4083,0.0,74.6648,51.2439,301.4943,1,0.0,71.7122,1766 +glare,m69,0.7,0.0945,,,10.8941,274.3414,0.0158,1,2741,0.0936,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,38.1285,235.0247,0.0,74.6648,35.3799,145.8627,1,0.0,64.0377,1766 +glare,m69,0.85,0.088,,,10.256,264.3002,0.0159,1,2741,0.0866,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,27.1478,164.7177,0.0,74.6648,22.0339,108.8162,1,0.0,68.0217,1766 +glare,m69,1.0,0.0641,0,0,13.4368,255.6152,0.0239,0,2741,0.0826,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,18.8928,113.4237,0.3076,74.6648,221.6863,90.19,0,0.0,27.8881,1766 +glare,m69,1.5,0.1258,0,0,11.7572,234.6004,0.0478,0,2741,0.0537,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,5.4456,31.4362,0.6629,74.6648,297.0596,250.8235,0,0.0,31.8721,1766 +glare,m69,2.5,0.1227,0,0,11.7572,211.0027,0.0776,0,2741,0.0095,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,0.1857,0.2573,0.9393,74.6648,277.4954,234.1979,0,0.0,31.8721,1766 +glare,m69,4.0,0.119,0,0,11.7145,192.1223,0.107,0,2741,0.0,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,0.0,0.0,1.0,74.6648,258.9856,258.9856,0,0.0,31.9883,1766 +motion,m69,0.05,0.138,,,9.9845,279.3079,0.0155,1,2741,0.1438,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,57.2206,606.5799,0.0,74.6648,57.8791,535.3868,1,0.0,70.2469,1766 +motion,m69,0.1,0.1291,,,10.3004,214.4694,0.015,1,2741,0.1352,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,37.7192,398.3053,0.0,74.6648,35.3664,304.5201,1,0.0,67.7282,1766 +motion,m69,0.15,0.1169,,,10.3004,167.0139,0.0147,1,2741,0.1246,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,27.2017,264.2161,0.0,74.6648,23.2034,185.064,1,0.0,67.7282,1766 +motion,m69,0.2,0.1075,,,10.1359,148.1305,0.0145,1,2741,0.1165,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,22.673,212.9541,0.0,74.6648,17.6018,138.0987,1,0.0,68.428,1766 +motion,m69,0.3,0.1382,0,0,8.9166,134.2016,0.0142,0,2741,0.1069,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,18.3064,168.4085,0.0,74.6648,39.7593,46.852,0,0.0,36.2135,1766 +motion,m69,0.4,0.0869,0,0,9.617,125.1232,0.0137,0,2741,0.0994,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,15.2031,138.4061,0.0,74.6648,9.4681,79.6904,0,0.0,71.7123,1766 +motion,m69,0.5,,0,0,,120.4786,0.0134,,2741,0.0946,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,13.2504,118.6453,0.0,74.6648,,,0,,,1766 +motion,m69,0.7,,0,0,,113.1867,0.0129,,2741,0.0881,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,10.5209,92.4792,0.0,74.6648,,,0,,,1766 +motion,m69,1.0,0.0725,0,0,10.1938,105.3165,0.0123,0,2741,0.0834,5.7662,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,8.0969,70.4039,0.0,74.6648,5.0324,27.5922,0,0.0,63.7442,1766 +scale,m69,48,0.1446,,,9.9776,344.7155,0.0154,1,1762,0.1497,5.765,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,413.9279,799.3572,0.0,47.9969,424.2052,765.3791,1,0.0,45.0773,1135 +scale,m69,40,0.1436,,,9.607,342.8499,0.0152,1,1468,0.1497,5.7673,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,682.9854,804.2761,0.0,39.9883,699.828,811.2905,1,0.0,39.0267,946 +scale,m69,32,0.1441,,,9.9953,337.3661,0.0149,1,1175,0.1496,5.7659,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,1253.496,1253.496,0.0,32.007,1356.9749,714.8033,1,0.0,29.9745,757 +scale,m69,26,0.1265,,,12.9789,653.0882,0.0147,0,954,0.1492,5.7695,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,2140.0773,678.2845,0.0,25.987,1985.1185,47.1361,1,0.0,10.0,615 +scale,m69,22,0.1435,,,9.6271,912.9346,0.0144,1,808,0.148,5.7597,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,3096.4703,541.0235,0.0,22.0099,3301.876,457.8352,1,0.0,20.356,520 +scale,m69,18,0.1384,,,8.9319,1295.345,0.0142,1,661,0.1461,5.7679,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,4453.8616,410.133,0.0,18.0056,4309.6548,451.7908,1,0.0,18.5782,426 +scale,m69,15,0.1332,,,9.5221,1638.0105,0.0141,1,551,0.1416,5.7661,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,5500.3521,274.6073,0.0,15.0092,5521.3678,257.8084,1,0.0,14.6548,355 +scale,m69,12,0.1098,,,5.1356,1852.5211,0.0138,1,441,0.1358,5.7635,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,6484.1282,150.5929,0.0,12.0128,3467.4649,254.7209,1,0.0,17.1152,284 +scale,m69,10,0.1137,,,3.4948,1785.3687,0.0138,1,367,0.1303,5.7795,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,7224.9816,91.2762,0.0,9.9971,4055.2595,213.4916,1,0.0,15.9565,237 +scale,m69,8,,0,0,,1760.2875,0.013,,294,0.1225,5.7534,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,7624.4325,33.9984,0.0,8.0086,,,0,,,189 +scale,m69,6,,0,0,,1922.6522,0.0129,,220,0.1102,5.7766,89f9b84a57901ec1c930b17c50fc382b819f3d7ac92b01cd2cfada23da61362f,6537.0333,13.7212,0.0,5.9928,,,0,,,142 +blur,m70,0.02,0.0776,,,19.5416,84.3706,0.0009,0,2816,0.0783,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,8.9256,181.2099,0.0,81.8611,13.0188,28.5893,1,0.0,42.0204,2112 +blur,m70,0.04,0.0698,,,15.9199,17.3722,0.0004,0,2816,0.0639,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1.9288,29.1431,0.0,81.8611,2.3583,6.2243,1,0.0,51.2253,2112 +blur,m70,0.06,0.0463,0,0,13.3353,5.3966,0.0002,1,2816,0.0565,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1.1473,4.757,0.0,81.8611,1.2967,2.243,0,0.0,62.0,2112 +blur,m70,0.08,0.0395,0,0,13.2,2.3664,0.0001,0,2816,0.0523,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1.0723,1.2869,0.0,81.8611,1.2116,0.67,0,0.0,61.3954,2112 +blur,m70,0.1,,0,0,,1.2687,0.0,,2816,0.0494,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1.0765,0.501,0.0,81.8611,,,0,,,2112 +blur,m70,0.13,,0,0,,0.6476,0.0,,2816,0.0459,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1.0784,0.2385,0.0,81.8611,,,0,,,2112 +blur,m70,0.16,,0,0,,0.4059,0.0,,2816,0.0431,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1.0332,0.1698,0.0,81.8611,,,0,,,2112 +blur,m70,0.2,,0,0,,0.2627,0.0,,2816,0.0396,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1.043,0.1372,0.0,81.8611,,,0,,,2112 +blur,m70,0.25,,0,0,,0.1892,0.0,,2816,0.0355,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1.0011,0.1367,0.0,81.8611,,,0,,,2112 +blur,m70,0.3,,0,0,,0.1614,0.0,,2816,0.0318,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,0.92,0.1642,0.0,81.8611,,,0,,,2112 +edge,m70,2.0,0.0953,,,0.5269,197.3866,0.0002,0,2816,0.0909,1.9996,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,94.926,456.6479,0.0,81.8611,163.5716,271.2813,1,0.0,46.3872,1298 +edge,m70,1.0,0.1682,,,0.6086,197.7142,0.0002,0,2816,0.0909,0.9979,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,94.926,456.6479,0.0,81.8611,200.0255,650.9437,1,0.0005,74.0,1216 +edge,m70,0.5,0.0844,,,0.7641,198.5669,0.0002,1,2816,0.0909,0.497,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,94.926,456.6479,0.0,81.8611,83.6301,202.5956,1,0.0,58.1057,1175 +edge,m70,0.25,0.1489,,,1.1943,197.7719,0.0002,0,2816,0.0909,0.2527,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,94.926,456.6479,0.0,81.8611,196.1558,584.7196,1,0.0,75.0346,1155 +edge,m70,0.1,0.1821,,,0.1995,197.5828,0.0002,0,2816,0.0916,0.0939,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,97.0602,467.0583,0.0,81.8611,203.0667,737.1981,1,0.0005,80.574,1142 +edge,m70,0.0,0.1352,,,0.9855,196.4985,0.0002,0,2816,0.0921,0.0,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,98.4136,473.5968,0.0,81.8611,180.8469,441.7435,1,0.0,66.3675,1134 +edge,m70,-0.5,0.1373,0,0,0.4106,191.8518,0.0002,0,2816,0.0933,0.0,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,98.9557,481.814,0.0,81.8611,183.8312,471.4721,0,0.0,68.6727,1093 +edge,m70,-1.0,0.1369,0,0,0.0576,185.414,0.0002,0,2816,0.0934,0.0,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,100.4553,482.2035,0.0,81.8611,188.0783,479.7846,0,0.0,67.2819,1052 +edge,m70,-2.0,0.1087,0,0,6.3331,176.2533,0.0002,0,2816,0.0981,0.0,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,113.4614,550.5725,0.0,81.8611,200.1199,162.4137,0,0.0,36.4138,971 +glare,m70,0.3,0.0878,,,19.9189,218.5015,0.0017,0,2816,0.0748,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,62.8593,294.4045,0.0,81.8611,154.7419,194.9539,1,0.0,41.3023,2112 +glare,m70,0.5,0.0825,,,19.9189,214.5278,0.0017,0,2816,0.0665,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,45.2324,207.9019,0.0,81.8611,142.417,179.6408,1,0.0,41.3023,2112 +glare,m70,0.7,0.0776,,,19.9189,210.9702,0.0017,0,2816,0.061,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,31.0155,139.4103,0.0,81.8611,130.8653,165.2239,1,0.0,41.3023,2112 +glare,m70,0.85,0.0741,,,19.9189,208.5767,0.0017,0,2816,0.0598,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,22.676,99.2448,0.0,81.8611,122.6938,155.0262,1,0.0,41.3023,2112 +glare,m70,1.0,0.0708,0,0,19.9189,206.3957,0.0061,0,2816,0.0603,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,16.1511,69.1323,0.2928,81.8611,115.1117,145.5316,0,0.0,41.3023,2112 +glare,m70,1.5,0.0621,0,0,19.9189,200.5027,0.0203,0,2816,0.0414,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,4.6945,18.2488,0.6439,81.8611,91.6571,116.5423,0,0.0,41.3023,2112 +glare,m70,2.5,0.058,0,0,19.9189,192.5431,0.0383,0,2816,0.009,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,0.1755,0.1381,0.9251,81.8611,58.1075,74.5252,0,0.0733,41.3023,2112 +glare,m70,4.0,0.1364,0,0,7.1354,185.6223,0.0555,0,2816,0.0,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,0.0,0.0,1.0,81.8611,154.253,411.2257,0,0.0,70.4922,2112 +motion,m70,0.05,0.089,,,20.5949,175.1973,0.0013,0,2816,0.0871,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,51.1476,359.876,0.0,81.8611,77.4227,111.5218,1,0.0,39.9131,2112 +motion,m70,0.1,0.0814,,,22.0549,111.0788,0.0009,0,2816,0.0798,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,37.391,222.743,0.0,81.8611,45.9373,51.8502,1,0.0,37.3023,2112 +motion,m70,0.15,0.0747,,,22.0549,84.2777,0.0006,0,2816,0.0739,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,31.3654,161.2106,0.0,81.8611,32.6695,36.5387,1,0.0,37.3023,2112 +motion,m70,0.2,0.0554,,,12.869,73.6857,0.0005,1,2816,0.0701,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,28.0358,139.2801,0.0,81.8611,18.1775,53.298,1,0.0,63.2785,2112 +motion,m70,0.3,0.048,0,0,13.7637,62.7442,0.0003,1,2816,0.065,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,23.1247,114.4313,0.0,81.8611,14.7465,35.606,0,0.0,58.2802,2112 +motion,m70,0.4,,0,0,,57.555,0.0002,,2816,0.0622,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,20.087,99.7028,0.0,81.8611,,,0,,,2112 +motion,m70,0.5,,0,0,,54.0038,0.0002,,2816,0.0603,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,17.6598,89.1304,0.0,81.8611,,,0,,,2112 +motion,m70,0.7,,0,0,,48.8781,0.0001,,2816,0.0572,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,14.4099,74.1478,0.0,81.8611,,,0,,,2112 +motion,m70,1.0,,0,0,,43.2618,0.0001,,2816,0.0532,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,10.8304,58.8274,0.0,81.8611,,,0,,,2112 +scale,m70,48,0.0962,,,20.9347,229.9675,0.0016,0,1651,0.0908,7.3484,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,285.855,448.4255,0.0,47.9946,708.0104,149.0047,1,0.0,23.0299,1238 +scale,m70,40,0.0943,,,20.0928,226.9719,0.0016,0,1376,0.0907,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,424.0136,451.4565,0.0,40.0003,1032.347,136.8601,1,0.0,19.9767,1032 +scale,m70,32,0.0994,0,0,23.0,223.9247,0.0015,0,1101,0.0902,7.3521,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,719.7126,719.7126,0.0,32.0061,1907.2143,78.1445,0,0.0,13.9873,826 +scale,m70,26,0.1661,,,4.4717,509.264,0.0014,0,894,0.0899,7.3553,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1199.5153,370.256,0.0,25.9886,1934.8352,375.7263,1,0.0013,22.3264,671 +scale,m70,22,0.1705,0,0,4.2,673.0156,0.0014,0,757,0.0888,7.3531,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1710.7128,303.0429,0.0,22.006,2609.0775,359.3507,0,0.0006,19.9736,568 +scale,m70,18,0.0919,,,27.3775,879.0602,0.0012,0,619,0.0865,7.3459,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,2370.0029,225.2527,0.0,17.9943,3821.9568,8.8117,1,0.0,5.9903,464 +scale,m70,15,0.1763,,,5.9954,1011.2769,0.0012,0,516,0.0834,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,2789.649,136.0769,0.0,15.0001,4437.1835,243.9398,1,0.0,15.2416,387 +scale,m70,12,0.2026,0,0,3.0,1065.4128,0.0011,0,413,0.0761,7.3558,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,2577.4504,54.7045,0.0,12.0059,4798.9377,206.3091,0,0.0,13.9662,310 +scale,m70,10,0.0774,0,0,9.0,1131.9988,0.0008,0,344,0.0715,7.3498,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,2397.2109,26.9756,0.0,10.0001,3006.0664,13.0467,0,0.0,8.0,258 +scale,m70,8,0.2224,0,0,3.8333,1328.4058,0.0006,0,275,0.0669,7.3409,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,2159.7331,8.7413,0.0,7.9942,4293.1116,114.9056,0,0.0,11.9565,206 +scale,m70,6,,0,0,,1639.9986,0.0009,,206,0.0609,7.3736,19571c1106a56afdb8cd90c5b0600404f55da37341d5ded8ac67c7f81cf6b912,1614.2641,3.1467,0.0,5.9884,,,0,,,155 +blur,m71,0.02,0.225,,,6.969,37.1795,0.0,1,2112,0.2249,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,7.1283,448.0794,0.0,112.1261,7.139,448.1234,1,0.0,111.5185,2816 +blur,m71,0.04,0.1961,,,6.4521,8.0629,0.0,1,2112,0.1972,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,2.9825,156.6677,0.0,112.1261,2.9471,192.1703,1,0.0,119.186,2816 +blur,m71,0.06,0.169,,,6.3855,2.6514,0.0,1,2112,0.1693,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,2.0354,55.6636,0.0,112.1261,1.9849,69.6831,1,0.0,120.3747,2816 +blur,m71,0.08,0.1514,,,6.1787,1.1764,0.0,1,2112,0.1464,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,1.7368,19.2043,0.0,112.1261,1.6621,26.6991,1,0.0,124.408,2816 +blur,m71,0.1,0.1326,,,6.2379,0.6601,0.0,1,2112,0.128,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,1.6455,7.772,0.0,112.1261,1.5644,10.751,1,0.0,123.2791,2816 +blur,m71,0.13,,0,0,,0.3647,0.0,,2112,0.1057,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,1.5844,2.8238,0.0,112.1261,,,0,,,2816 +blur,m71,0.16,,0,0,,0.243,0.0,,2112,0.0888,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,1.566,1.1637,0.0,112.1261,,,0,,,2816 +blur,m71,0.2,,0,0,,0.1755,0.0,,2112,0.0734,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,1.4108,0.4356,0.0,112.1261,,,0,,,2816 +blur,m71,0.25,,0,0,,0.1444,0.0,,2112,0.0618,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,1.3738,0.1788,0.0,112.1261,,,0,,,2816 +blur,m71,0.3,,0,0,,0.1311,0.0,,2112,0.0555,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,1.2599,0.1078,0.0,112.1261,,,0,,,2816 +edge,m71,2.0,0.2478,,,2.1848,117.1045,0.0008,1,2112,0.242,1.9972,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,206.677,1160.3222,0.0,112.1261,215.3472,1115.1029,1,0.0,105.2309,2021 +edge,m71,1.0,0.2488,,,1.1071,117.4307,0.0007,1,2112,0.242,0.9984,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,206.677,1160.3222,0.0,112.1261,218.5877,1140.8006,1,0.0,105.2495,1909 +edge,m71,0.5,0.2503,,,0.6173,116.2467,0.0006,1,2112,0.242,0.4989,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,206.677,1160.3222,0.0,112.1261,222.4403,1129.5097,1,0.0,104.3721,1853 +edge,m71,0.25,0.2519,,,0.3331,115.5671,0.0006,1,2112,0.242,0.2492,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,206.677,1160.3222,0.0,112.1261,226.2643,1099.3866,1,0.0,101.3023,1825 +edge,m71,0.1,0.2529,,,0.1818,115.5208,0.0006,1,2112,0.2445,0.0976,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,212.945,1205.9075,0.0,112.1261,229.1047,1114.0368,1,0.0,101.3023,1808 +edge,m71,0.0,0.2542,,,0.059,115.4663,0.0006,1,2112,0.2462,0.0,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,217.2273,1226.9835,0.0,112.1261,233.0759,1150.4481,1,0.0,102.2122,1797 +edge,m71,-0.5,0.2488,0,0,0.1758,112.933,0.0005,0,2112,0.2417,0.0,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,216.5548,1198.4787,0.0,112.1261,229.2022,1139.6202,0,0.0,103.1965,1741 +edge,m71,-1.0,0.2475,0,0,0.2583,110.6835,0.0004,0,2112,0.241,0.0,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,212.3456,1192.9944,0.0,112.1261,223.3902,1141.372,0,0.0,104.7969,1685 +edge,m71,-2.0,,0,0,,103.962,0.0003,,2112,0.2502,0.0,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,218.7853,1334.0402,0.0,112.1261,,,0,,,1573 +glare,m71,0.3,0.2062,,,7.0762,138.7949,0.0007,1,2112,0.2044,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,130.9603,726.8238,0.0,112.1261,132.1733,722.6837,1,0.0,109.6707,2816 +glare,m71,0.5,0.1903,,,7.1849,133.5987,0.0008,1,2112,0.1887,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,90.3866,499.0422,0.0,112.1261,91.888,484.1541,1,0.0,108.1189,2816 +glare,m71,0.7,0.183,,,7.3077,129.2177,0.0008,1,2112,0.1827,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,58.4346,320.2077,0.0,112.1261,59.8796,309.6456,1,0.0,106.4186,2816 +glare,m71,0.85,0.1846,,,7.3077,126.4323,0.0008,1,2112,0.1854,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,40.3566,218.3925,0.0001,112.1261,41.1533,210.1842,1,0.0001,106.4186,2816 +glare,m71,1.0,0.1934,,,7.1977,124.0741,0.0027,1,2112,0.1938,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,26.9937,143.7011,0.1041,112.1261,27.1159,139.3356,1,0.1084,107.9397,2816 +glare,m71,1.5,0.0647,0,0,15.4244,118.7149,0.0136,0,2112,0.1623,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,7.1917,34.7893,0.5511,112.1261,45.491,108.1408,0,0.0,60.4787,2816 +glare,m71,2.5,0.0449,0,0,19.6059,112.9563,0.0273,0,2112,0.067,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,0.5302,0.4944,0.8347,112.1261,29.7662,40.5869,0,0.0,47.8926,2816 +glare,m71,4.0,0.0399,0,0,18.9605,107.9238,0.0404,0,2112,0.0005,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,0.0028,0.0027,0.9998,112.1261,21.6,31.3088,0,0.0119,49.4186,2816 +motion,m71,0.05,0.2371,,,7.1075,101.347,0.0007,1,2112,0.2346,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,45.77,755.0078,0.0,112.1261,46.1195,733.5769,1,0.0,109.2195,2816 +motion,m71,0.1,0.2266,,,7.1012,80.2549,0.0006,1,2112,0.2243,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,30.2111,478.6212,0.0,112.1261,30.4132,461.0072,1,0.0,109.3093,2816 +motion,m71,0.15,0.2099,,,6.8708,71.8801,0.0005,1,2112,0.2102,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,23.4195,340.3288,0.0,112.1261,26.968,343.863,1,0.0,112.4487,2816 +motion,m71,0.2,0.1989,,,7.0,67.8758,0.0005,1,2112,0.1986,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,19.9695,257.8817,0.0,112.1261,21.2347,248.93,1,0.0,110.5116,2816 +motion,m71,0.3,0.1781,,,6.7698,62.3996,0.0005,1,2112,0.1782,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,15.1102,164.5259,0.0,112.1261,20.8234,180.816,1,0.0,114.0104,2816 +motion,m71,0.4,0.1749,,,6.3538,58.9803,0.0004,1,2112,0.1674,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,12.7369,132.2009,0.0,112.1261,25.7343,226.2267,1,0.0,120.9318,2816 +motion,m71,0.5,,0,0,,56.1123,0.0004,,2112,0.1586,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,10.7412,108.3907,0.0,112.1261,,,0,,,2816 +motion,m71,0.7,,0,0,,51.3969,0.0003,,2112,0.1482,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,8.4614,88.0201,0.0,112.1261,,,0,,,2816 +motion,m71,1.0,,0,0,,45.511,0.0002,,2112,0.1392,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,6.8512,74.2401,0.0,112.1261,,,0,,,2816 +scale,m71,48,0.2411,,,6.9167,145.7896,0.0006,1,904,0.2411,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,985.4864,1143.6047,0.0,47.9934,974.6924,1152.8717,1,0.0,48.0,1206 +scale,m71,40,0.2397,,,6.8393,256.2205,0.0005,1,753,0.2411,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,1337.0795,1148.6038,0.0,39.9768,1319.077,1158.2052,1,0.0,40.2772,1005 +scale,m71,32,0.2386,,,6.2972,365.7966,0.0005,1,603,0.2404,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,1982.2894,1982.2894,0.0,32.0133,1934.4321,1363.2182,1,0.0,34.8094,804 +scale,m71,26,0.2408,,,6.2338,500.4628,0.0004,1,490,0.24,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,2834.1709,870.8091,0.0,26.0141,2723.4632,1074.1708,1,0.0,28.5129,653 +scale,m71,22,0.2732,,,5.4933,636.7769,0.0004,1,414,0.2391,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,3732.0013,678.033,0.0,21.9793,3781.7919,1303.3154,1,0.0,27.1971,553 +scale,m71,18,0.2619,,,5.9158,835.3323,0.0004,1,339,0.2385,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,5433.9898,521.2149,0.0,17.9975,5179.1431,752.9448,1,0.0,20.5137,452 +scale,m71,15,0.2711,,,5.2382,1054.1212,0.0003,1,283,0.2362,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,8368.4447,444.7165,0.0,15.0245,7625.1939,958.1121,1,0.0,19.276,377 +scale,m71,12,0.2382,,,7.0,1370.9285,0.0001,1,226,0.2355,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,14444.1169,344.3636,0.0,11.9983,14559.5012,379.5838,1,0.0,12.0,301 +scale,m71,10,,0,0,,1654.3884,0.0,,188,0.2283,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,20437.4829,275.2494,0.0,9.9809,,,0,,,251 +scale,m71,8,,0,0,,2002.9812,0.0001,,151,0.2188,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,27591.5272,128.7596,0.0,8.0166,,,0,,,201 +scale,m71,6,,0,0,,2507.0051,0.0006,,113,0.1973,6.9284,ba719fb29cf9c7a432725d9b21083087ab11d6c4942c7415c7bd8ec2484015f5,23144.02,49.007,0.0,5.9992,,,0,,,151 +blur,m73,0.02,0.1946,,,24.4174,370.4398,0.0003,1,3500,0.2452,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,43.5533,612.7324,0.0,73.255,100.129,597.6628,1,0.0,54.38,3600 +blur,m73,0.04,0.1489,,,25.4,99.7014,0.0001,1,3500,0.2148,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,9.1141,173.8288,0.0,73.255,12.4016,67.991,1,0.0,52.3169,3600 +blur,m73,0.06,0.1214,,,22.9179,28.253,0.0,0,3500,0.1855,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,3.987,60.867,0.0,73.255,2.939,13.1886,1,0.0,57.8619,3600 +blur,m73,0.08,0.1058,,,2.9003,10.2731,0.0,0,3500,0.1621,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,2.4206,20.7673,0.0,73.255,1.4368,44.2228,1,0.0,176.1701,3600 +blur,m73,0.1,0.1383,,,13.6255,4.6434,0.0,1,3500,0.144,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,1.9524,7.7436,0.0,73.255,1.9021,12.2131,1,0.0,84.1016,3600 +blur,m73,0.13,0.1697,0,0,2.2268,1.9277,0.0,0,3500,0.1236,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,1.7886,2.4807,0.0,73.255,1.1072,25.4832,0,0.0,227.3559,3600 +blur,m73,0.16,,0,0,,1.0039,0.0,,3500,0.1085,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,1.7132,0.9911,0.0,73.255,,,0,,,3600 +blur,m73,0.2,,0,0,,0.515,0.0,,3500,0.0944,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,1.6043,0.3866,0.0,73.255,,,0,,,3600 +blur,m73,0.25,,0,0,,0.2809,0.0,,3500,0.0828,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,1.5208,0.2018,0.0,73.255,,,0,,,3600 +blur,m73,0.3,,0,0,,0.1861,0.0,,3500,0.0757,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,1.4817,0.1477,0.0,73.255,,,0,,,3600 +edge,m73,2.0,0.2391,,,11.4626,890.6621,0.0006,1,3500,0.2663,2.0055,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,605.2059,1863.5435,0.0,73.255,1338.8318,2005.635,1,0.0,51.4059,2601 +edge,m73,1.0,0.2394,,,9.9668,894.7378,0.0006,1,3500,0.2663,0.9954,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,605.2059,1863.5435,0.0,73.255,1342.9688,1997.2762,1,0.0,51.5189,2527 +edge,m73,0.5,0.2391,,,9.3263,896.2381,0.0006,1,3500,0.2663,0.5039,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,605.2059,1863.5435,0.0,73.255,1338.8322,1973.0507,1,0.0,51.2384,2491 +edge,m73,0.25,0.2377,,,8.9343,902.4584,0.0006,1,3500,0.2663,0.2446,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,605.2059,1863.5435,0.0,73.255,1318.6173,1932.1912,1,0.0,51.2384,2472 +edge,m73,0.1,0.2394,,,8.7031,900.9645,0.0006,1,3500,0.2669,0.0944,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,613.9195,1893.3952,0.0,73.255,1342.9688,1997.2762,1,0.0,51.408,2461 +edge,m73,0.0,0.2376,,,8.6268,900.2544,0.0006,1,3500,0.2673,0.0,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,619.5261,1910.6639,0.0,73.255,1318.6586,1954.5246,1,0.0,51.2384,2454 +edge,m73,-0.5,0.2377,0,0,7.7245,893.5887,0.0006,1,3500,0.2678,0.0,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,623.1402,1901.5921,0.0,73.255,1334.2272,2119.6789,0,0.0,52.5693,2417 +edge,m73,-1.0,0.2381,0,0,7.0498,883.5088,0.0006,1,3500,0.268,0.0,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,616.8683,1886.3433,0.0,73.255,1318.3976,1954.2622,0,0.0,51.2384,2381 +edge,m73,-2.0,0.2391,0,0,5.7546,862.9862,0.0006,1,3500,0.2695,0.0,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,610.005,1839.2569,0.0,73.255,1338.8322,1973.0507,0,0.0,51.2384,2308 +glare,m73,0.3,0.1953,,,24.5253,641.733,0.0005,1,3500,0.2069,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,366.337,1119.8009,0.0,73.255,916.3777,1518.5234,1,0.0,54.1455,3600 +glare,m73,0.5,0.1691,,,24.3766,590.9926,0.0005,1,3500,0.1706,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,242.1876,736.5745,0.0,73.255,676.0296,1127.631,1,0.0,54.4691,3600 +glare,m73,0.7,0.146,,,24.1123,548.3041,0.0005,1,3500,0.1394,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,148.1629,445.7598,0.0,73.255,484.7815,815.7331,1,0.0,55.054,3600 +glare,m73,0.85,0.1309,,,24.1123,521.6202,0.0005,1,3500,0.1217,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,96.8749,288.2569,0.0,73.255,361.9225,607.3925,1,0.0,55.054,3600 +glare,m73,1.0,0.1199,0,0,23.837,499.4667,0.0029,1,3500,0.1118,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,61.7849,182.093,0.2436,73.255,266.5087,449.031,0,0.0,55.6768,3600 +glare,m73,1.5,0.2374,0,0,20.7778,451.7512,0.0172,0,3500,0.0671,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,14.8041,43.0469,0.6433,73.255,780.1953,1010.6576,0,0.0,47.0852,3600 +glare,m73,2.5,0.231,0,0,20.7778,405.9399,0.0334,0,3500,0.0088,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,0.1807,0.1492,0.9474,73.255,724.8661,939.4794,0,0.0,47.0852,3600 +glare,m73,4.0,0.2312,0,0,23.5,370.9007,0.0493,0,3500,0.0,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,0.0,0.0,1.0,73.255,722.3472,759.7465,0,0.0,41.8535,3600 +motion,m73,0.05,0.2199,,,24.777,566.5826,0.0004,1,3500,0.2573,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,200.1144,1162.341,0.0,73.255,423.0571,1352.8401,1,0.0,53.6064,3600 +motion,m73,0.1,0.2001,,,25.4,379.7671,0.0002,1,3500,0.246,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,122.1977,690.7714,0.0,73.255,254.4825,708.5054,1,0.0,52.3169,3600 +motion,m73,0.15,0.1758,,,23.8332,251.2451,0.0002,1,3500,0.2303,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,88.6262,466.8929,0.0,73.255,167.6091,491.0009,1,0.0,55.6856,3600 +motion,m73,0.2,0.1686,,,28.1829,198.1204,0.0001,0,3500,0.2152,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,69.3822,338.6404,0.0,73.255,132.6323,253.9947,1,0.0,47.151,3600 +motion,m73,0.3,0.1949,,,15.6455,158.1774,0.0,1,3500,0.1954,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,51.8667,234.2185,0.0,73.255,51.5463,232.7801,1,0.0,73.2436,3600 +motion,m73,0.4,0.1819,0,1,15.7169,139.202,0.0,0,3500,0.183,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,42.2506,185.691,0.0,73.255,41.9908,184.5121,0,0.0,73.2436,3600 +motion,m73,0.5,0.1674,0,0,11.4593,126.6444,0.0,0,3500,0.1726,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,35.2581,150.5639,0.0,73.255,37.4749,385.9182,0,0.0,100.0,3600 +motion,m73,0.7,0.1584,0,0,11.407,113.7711,0.0,0,3500,0.163,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,29.0312,120.6478,0.0,73.255,31.0289,318.104,0,0.0,100.0,3600 +motion,m73,1.0,,0,0,,102.6926,0.0,,3500,0.158,15.6428,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,24.3382,103.2927,0.0,73.255,,,0,,,3600 +scale,m73,48,0.2323,,,24.3966,728.6246,0.0005,1,2293,0.2651,15.6461,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,1454.9831,1786.5818,0.0,47.9925,2740.9274,2167.5601,1,0.0,35.6563,2359 +scale,m73,40,0.2317,,,24.9729,731.4321,0.0005,1,1911,0.2645,15.646,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,2172.7162,1810.7421,0.0,39.9972,4166.3353,1827.872,1,0.0,29.0536,1966 +scale,m73,32,0.229,,,24.3811,725.7838,0.0005,1,1529,0.263,15.646,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,3300.6359,3300.6359,0.0,32.002,6891.6906,1566.7855,1,0.0,23.791,1573 +scale,m73,26,0.2292,,,26.2222,699.9354,0.0005,1,1242,0.2598,15.6491,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,4535.8711,1285.595,0.0,25.9951,11236.21,1047.5046,1,0.0,18.0,1278 +scale,m73,22,0.2398,,,33.3333,670.7131,0.0005,1,1051,0.2576,15.6424,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,5726.6907,965.2308,0.0,21.9974,18823.5286,451.303,1,0.0,11.9886,1081 +scale,m73,18,0.2181,,,27.1667,1549.5478,0.0006,1,860,0.2543,15.6504,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,7262.4131,668.4463,0.0,17.9998,20135.1485,454.8873,1,0.0,12.0,885 +scale,m73,15,0.2045,,,27.2,1789.6959,0.0005,1,717,0.2509,15.6325,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,9664.0925,492.9933,0.0,15.0068,22711.9357,285.0898,1,0.0,9.9861,737 +scale,m73,12,0.2475,,,15.5271,1994.8487,0.0006,1,573,0.2479,15.6595,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,15028.251,372.3031,0.0,11.9929,14789.8062,373.1215,1,0.0,11.9791,590 +scale,m73,10,0.1697,,,2.4144,2050.0821,0.0007,0,478,0.2449,15.6219,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,21496.9378,300.6152,0.0,10.0045,863.0222,309.828,1,0.0,27.7935,491 +scale,m73,8,0.1223,,,2.5983,2070.4672,0.0007,0,382,0.2335,15.6462,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,27901.0744,133.373,0.0,7.9953,1176.141,196.2842,1,0.0,21.3017,393 +scale,m73,6,0.1082,0,0,3.0,2014.6507,0.0004,0,287,0.2114,15.6322,ba5190752fde5d7badbc5df6df05091c87b35eb77c21e8c04a5066225782a685,25779.6504,58.814,0.0,6.0069,2040.8538,112.6312,0,0.0,13.9514,295 +blur,m74,0.02,0.0674,,,6.4277,51.6144,0.0011,1,1600,0.0687,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,37.1846,142.0103,0.0,54.016,33.3303,105.482,1,0.0,50.6236,1200 +blur,m74,0.04,0.0543,,,6.9563,7.73,0.0007,1,1600,0.0562,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,4.973,20.1134,0.0,54.016,4.906,11.6914,1,0.0,46.7056,1200 +blur,m74,0.06,0.048,,,6.3636,2.2933,0.0005,1,1600,0.0488,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,2.2779,4.4784,0.0,54.016,2.2674,3.6388,1,0.0,51.1628,1200 +blur,m74,0.08,0.0459,,,5.52,1.0363,0.0003,1,1600,0.044,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1.777,1.4321,0.0,54.016,1.7049,1.5872,1,0.0,58.1395,1200 +blur,m74,0.1,0.0418,,,5.7917,0.6245,0.0001,1,1600,0.0405,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1.6666,0.6038,0.0,54.016,1.6156,0.5943,1,0.0,55.814,1200 +blur,m74,0.13,,0,0,,0.4085,0.0,,1600,0.0369,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1.6185,0.3113,0.0,54.016,,,0,,,1200 +blur,m74,0.16,,0,0,,0.3246,0.0,,1600,0.0339,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1.5429,0.2345,0.0,54.016,,,0,,,1200 +blur,m74,0.2,,0,0,,0.2768,0.0,,1600,0.0306,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1.2525,0.2206,0.0,54.016,,,0,,,1200 +blur,m74,0.25,,0,0,,0.2508,0.0,,1600,0.0273,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1.0226,0.2111,0.0,54.016,,,0,,,1200 +blur,m74,0.3,,0,0,,0.239,0.0,,1600,0.0244,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,0.9267,0.2086,0.0,54.016,,,0,,,1200 +edge,m74,2.0,0.0805,,,2.4177,266.5124,0.0029,1,1600,0.0831,1.9947,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,656.536,498.7252,0.0,54.016,562.9385,345.6677,1,0.0,44.8145,983 +edge,m74,1.0,0.0812,,,1.3069,273.8487,0.0031,1,1600,0.0831,0.995,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,656.536,498.7252,0.0,54.016,579.4795,333.1665,1,0.0,42.2966,929 +edge,m74,0.5,0.0806,,,0.6526,277.6075,0.0032,1,1600,0.0831,0.4951,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,656.536,498.7252,0.0,54.016,568.5019,347.2158,1,0.0,44.9537,902 +edge,m74,0.25,0.0808,,,0.3484,279.6664,0.0032,1,1600,0.0831,0.2544,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,656.536,498.7252,0.0,54.016,572.9954,341.8321,1,0.0,44.2619,889 +edge,m74,0.1,0.0813,,,0.1977,280.4069,0.0033,1,1600,0.0842,0.1063,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,676.1196,509.7829,0.0,54.016,573.705,333.0838,1,0.0,43.1928,881 +edge,m74,0.0,0.0818,,,0.0312,280.8876,0.0033,1,1600,0.0851,0.0,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,691.6084,525.2865,0.0,54.016,585.8232,369.3272,1,0.0,45.2534,875 +edge,m74,-0.5,0.1104,0,0,8.93,279.835,0.0034,0,1600,0.0853,0.0,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,672.3135,510.9596,0.0,54.016,184.4381,72.3764,0,0.0,30.5934,848 +edge,m74,-1.0,0.1088,0,0,11.1078,279.3289,0.0034,0,1600,0.0871,0.0,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,690.0421,518.6054,0.0,54.016,213.5319,47.142,0,0.0,24.5981,821 +edge,m74,-2.0,0.1076,0,0,11.8422,278.1452,0.0037,0,1600,0.0874,0.0,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,689.7454,512.026,0.0,54.016,224.5905,40.8869,0,0.0,23.1328,767 +glare,m74,0.3,0.0653,,,7.4502,237.1732,0.0027,1,1600,0.0678,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,415.0083,312.8387,0.0,54.016,355.6026,209.787,1,0.0,43.6234,1200 +glare,m74,0.5,0.0622,,,7.477,234.3539,0.0027,1,1600,0.0649,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,289.1566,215.9996,0.0,54.016,241.4244,137.3928,1,0.0,43.4687,1200 +glare,m74,0.7,0.0661,,,7.4956,231.9128,0.0027,1,1600,0.069,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,189.383,140.5975,0.0,54.016,154.607,88.4227,1,0.0,43.3482,1200 +glare,m74,0.85,0.0735,,,6.9949,230.3251,0.0027,1,1600,0.0761,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,133.1038,97.9078,0.0,54.016,107.2756,66.4467,1,0.0,46.435,1200 +glare,m74,1.0,0.0827,1,1,8.0848,228.9826,0.0039,0,1600,0.0857,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,92.2478,67.306,0.1171,54.016,74.1583,38.6395,0,0.158,40.2047,1200 +glare,m74,1.5,0.1109,0,0,9.2859,225.6663,0.0127,0,1600,0.0717,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,29.0885,20.5001,0.5785,54.016,193.2509,70.9847,0,0.0,29.4616,1200 +glare,m74,2.5,0.1112,0,0,10.9232,221.857,0.0238,0,1600,0.0262,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1.5467,0.7908,0.8585,54.016,598.4694,248.4274,0,0.0096,33.0,1200 +glare,m74,4.0,0.1109,0,0,9.2859,218.5779,0.0339,0,1600,0.0,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,0.0,0.0,1.0,54.016,193.2509,70.9847,0,0.0,29.4616,1200 +motion,m74,0.05,0.0748,,,7.1673,165.7338,0.002,1,1600,0.0769,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,233.3625,332.3194,0.0,54.016,214.3641,227.6668,1,0.0,45.3324,1200 +motion,m74,0.1,0.0695,,,7.1284,122.7147,0.0017,1,1600,0.0713,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,145.5551,212.8505,0.0,54.016,137.7355,151.7842,1,0.0,45.5636,1200 +motion,m74,0.15,0.0637,,,6.9242,96.634,0.0014,1,1600,0.0651,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,100.5792,140.1097,0.0,54.016,97.7895,107.6982,1,0.0,46.8818,1200 +motion,m74,0.2,0.06,,,6.0492,84.3186,0.0013,1,1600,0.0608,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,79.4204,109.2251,0.0,54.016,74.164,100.6532,1,0.0,53.2633,1200 +motion,m74,0.3,0.0556,,,5.8054,72.841,0.001,1,1600,0.0561,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,60.273,82.2845,0.0,54.016,55.4095,72.9005,1,0.0,55.0505,1200 +motion,m74,0.4,0.0487,,,7.1746,64.4289,0.0008,1,1600,0.0525,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,47.4954,65.3352,0.0,54.016,42.6602,41.8147,1,0.0,44.6658,1200 +motion,m74,0.5,0.0477,,,6.3972,59.2803,0.0006,1,1600,0.0503,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,40.1042,55.4766,0.0,54.016,36.0412,41.8302,1,0.0,49.7897,1200 +motion,m74,0.7,,0,0,,50.5531,0.0003,,1600,0.0472,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,29.8965,42.1716,0.0,54.016,,,0,,,1200 +motion,m74,1.0,,0,0,,41.4895,0.0001,,1600,0.0449,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,21.4751,31.6823,0.0,54.016,,,0,,,1200 +scale,m74,48,0.0816,,,6.5068,238.4924,0.0022,1,1422,0.0825,6.0092,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,568.1313,502.313,0.0,48.0067,535.0803,435.2713,1,0.0,44.5984,1066 +scale,m74,40,0.079,,,5.994,225.9513,0.0021,1,1185,0.082,6.0137,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,677.1595,493.7927,0.0,40.0056,565.9537,412.7596,1,0.0,39.8262,889 +scale,m74,32,0.078,,,5.9916,484.4476,0.0019,1,948,0.0808,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,926.9925,926.9925,0.0,32.0045,750.6127,750.6127,1,0.0,32.0,711 +scale,m74,26,0.0777,,,5.3653,548.8119,0.0016,1,770,0.0793,6.0172,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1210.6774,329.5233,0.0,25.9952,1105.9131,428.078,1,0.0,29.2618,578 +scale,m74,22,0.0756,,,5.9878,603.3041,0.0015,1,652,0.0783,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1535.6191,253.8737,0.0,22.0115,1292.4189,215.1747,1,0.0,22.0,489 +scale,m74,18,0.0735,,,5.4101,661.6131,0.0014,1,533,0.0751,6.0158,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1654.064,148.0767,0.0,17.9941,1472.3434,183.272,1,0.0,19.9625,400 +scale,m74,15,0.0697,,,5.6082,716.2554,0.0012,1,444,0.0716,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1720.7925,84.5356,0.0,14.9894,1480.6538,65.3727,1,0.0,16.0,333 +scale,m74,12,0.0651,,,7.1933,780.0269,0.0012,1,355,0.0676,6.0289,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1805.5611,40.6507,0.0,11.9848,1794.9249,22.0514,1,0.0,9.9719,267 +scale,m74,10,0.0615,1,1,2.6167,833.1507,0.001,0,296,0.0648,6.012,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1742.684,21.9516,0.0,9.993,1441.7497,228.7003,0,0.0,22.7424,222 +scale,m74,8,,0,0,,890.1313,0.0009,,237,0.0595,6.0205,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1739.2406,8.181,0.0,8.0011,,,0,,,178 +scale,m74,6,,0,0,,976.003,0.0006,,178,0.0521,5.9895,e901312ec37a31bd8b8bcd722e00c1d4d3e6c1be1444785c426d04eac0cada44,1375.5062,3.2484,0.0,6.0093,,,0,,,133 +blur,m75,0.02,0.0936,,,6.0482,103.0807,0.0001,1,3150,0.093,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,8.7978,240.5358,0.0,91.5705,8.7048,271.0168,1,0.0,96.1483,2088 +blur,m75,0.04,0.0764,,,5.7577,20.8848,0.0,1,3150,0.077,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,1.8644,35.5111,0.0,91.5705,1.8486,46.785,1,0.0,101.0,2088 +blur,m75,0.06,0.0687,,,5.4694,5.5204,0.0,1,3150,0.0676,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,1.2377,7.3627,0.0,91.5705,1.2466,12.6559,1,0.0,105.4855,2088 +blur,m75,0.08,0.0622,,,5.4694,1.9667,0.0,1,3150,0.0618,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,1.2352,2.1148,0.0,91.5705,1.2282,3.6886,1,0.0,105.4855,2088 +blur,m75,0.1,0.0563,,,5.9894,0.9177,0.0,1,3150,0.0577,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,1.2642,0.8275,0.0,91.5705,1.2361,0.9543,1,0.0,96.3285,2088 +blur,m75,0.13,0.0584,0,0,5.546,0.4324,0.0,0,3150,0.0535,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,1.3344,0.3074,0.0,91.5705,1.1988,0.457,0,0.0,100.7267,2088 +blur,m75,0.16,,0,0,,0.2675,0.0,,3150,0.0505,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,1.2128,0.1798,0.0,91.5705,,,0,,,2088 +blur,m75,0.2,,0,0,,0.1814,0.0,,3150,0.0474,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,1.0587,0.14,0.0,91.5705,,,0,,,2088 +blur,m75,0.25,,0,0,,0.1401,0.0,,3150,0.0446,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,0.9438,0.1403,0.0,91.5705,,,0,,,2088 +blur,m75,0.3,,0,0,,0.1244,0.0,,3150,0.0429,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,0.8127,0.1636,0.0,91.5705,,,0,,,2088 +edge,m75,2.0,0.1094,,,1.9054,312.7147,0.0004,1,3150,0.1088,2.0042,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,248.8148,713.1227,0.0,91.5705,243.3792,775.6628,1,0.0,96.1483,1690 +edge,m75,1.0,0.1088,,,1.0001,326.7851,0.0004,1,3150,0.1088,0.9995,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,248.8148,713.1227,0.0,91.5705,248.8148,713.1227,1,0.0,91.5698,1598 +edge,m75,0.5,0.1094,,,0.4762,331.4892,0.0004,1,3150,0.1088,0.4972,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,248.8148,713.1227,0.0,91.5705,243.3802,776.1401,1,0.0,96.1483,1552 +edge,m75,0.25,0.1087,,,0.3,336.7169,0.0004,1,3150,0.1088,0.246,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,248.8148,713.1227,0.0,91.5705,249.8236,717.0059,1,0.0,91.5698,1529 +edge,m75,0.1,0.1086,,,0.1501,337.9556,0.0004,1,3150,0.1086,0.1041,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,251.9087,722.4017,0.0,91.5705,251.9087,722.4017,1,0.0,91.5698,1516 +edge,m75,0.0,0.1089,,,0.0476,338.6789,0.0004,1,3150,0.1085,0.0,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,254.389,730.0597,0.0,91.5705,248.8353,794.0467,1,0.0,96.1483,1506 +edge,m75,-0.5,0.1092,0,0,0.0499,346.0175,0.0004,0,3150,0.1094,0.0,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,258.1917,744.0594,0.0,91.5705,257.645,738.3991,0,0.0,91.5698,1461 +edge,m75,-1.0,0.1104,0,0,0.0498,350.7232,0.0004,0,3150,0.1106,0.0,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,261.8369,758.5525,0.0,91.5705,261.2523,752.6222,0,0.0,91.5698,1415 +edge,m75,-2.0,0.1131,0,0,0.15,354.7975,0.0005,0,3150,0.1132,0.0,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,266.6744,776.7296,0.0,91.5705,266.3484,780.4275,0,0.0,91.5698,1323 +glare,m75,0.3,0.0912,,,6.3506,233.9716,0.0003,1,3150,0.0912,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,151.8408,438.2792,0.0,91.5705,151.8408,438.2792,1,0.0,91.5698,2088 +glare,m75,0.5,0.0819,,,6.3506,215.0752,0.0003,1,3150,0.0819,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,101.0056,294.4896,0.0,91.5705,101.0056,294.4896,1,0.0,91.5698,2088 +glare,m75,0.7,0.0758,,,6.3506,199.15,0.0003,1,3150,0.0758,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,62.6198,184.2727,0.0,91.5705,62.6198,184.2727,1,0.0,91.5698,2088 +glare,m75,0.85,0.0898,,,6.3506,189.1859,0.0003,0,3150,0.0737,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,41.5373,123.2147,0.0002,91.5705,47.275,157.5746,1,0.0002,91.5698,2088 +glare,m75,1.0,0.1393,0,0,5.1065,180.9147,0.009,0,3150,0.0742,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,26.9749,81.1003,0.2809,91.5705,499.6399,1178.6794,0,0.0,89.2599,2088 +glare,m75,1.5,0.1393,0,0,5.1065,162.7474,0.0508,0,3150,0.0502,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,7.5324,21.6045,0.6635,91.5705,499.6399,1178.6794,0,0.0,89.2599,2088 +glare,m75,2.5,0.1393,0,0,5.1065,145.8024,0.103,0,3150,0.0083,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,0.1749,0.1725,0.9538,91.5705,499.6399,1178.6794,0,0.0,89.2599,2088 +glare,m75,4.0,0.1393,0,0,5.1065,135.207,0.1505,0,3150,0.0,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,0.0,0.0,1.0,91.5705,499.6399,1178.6794,0,0.0,89.2599,2088 +motion,m75,0.05,0.1009,,,6.3506,194.0235,0.0002,1,3150,0.1009,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,48.1031,454.9811,0.0,91.5705,48.1031,454.9811,1,0.0,91.5698,2088 +motion,m75,0.1,0.0916,,,6.3506,129.7831,0.0001,1,3150,0.0918,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,25.8165,235.2729,0.0,91.5705,25.9135,236.1212,1,0.0,91.5698,2088 +motion,m75,0.15,0.0847,,,6.0482,96.6154,0.0,1,3150,0.0837,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,17.5759,151.6339,0.0,91.5705,18.0325,172.5978,1,0.0,96.1483,2088 +motion,m75,0.2,0.0801,,,6.0424,85.2352,0.0,1,3150,0.0791,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,14.3392,124.2253,0.0,91.5705,14.7329,141.2384,1,0.0,96.2413,2088 +motion,m75,0.3,0.0729,,,5.9472,72.0526,0.0,1,3150,0.0724,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,10.483,91.5818,0.0,91.5705,10.7042,103.444,1,0.0,96.2413,2088 +motion,m75,0.4,0.0677,,,6.5083,64.1961,0.0,1,3150,0.0677,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,8.2427,72.3433,0.0,91.5705,8.3765,68.0361,1,0.0,87.2413,2088 +motion,m75,0.5,0.0621,0,0,5.8943,59.2967,0.0,0,3150,0.065,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,7.0661,61.3074,0.0,91.5705,6.7414,66.7757,0,0.0,96.3285,2088 +motion,m75,0.7,0.0728,0,0,6.6118,52.6357,0.0,0,3150,0.0621,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,5.7781,51.4537,0.0,91.5705,7.5525,57.6628,0,0.0,82.4128,2088 +motion,m75,1.0,,0,0,,45.1389,0.0,,3150,0.0593,6.3506,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,4.7179,44.7395,0.0,91.5705,,,0,,,2088 +scale,m75,48,0.1085,,,6.0384,272.0717,0.0003,1,1651,0.108,6.3542,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,547.2832,715.6872,0.0,47.9946,542.0276,773.511,1,0.0,50.3939,1095 +scale,m75,40,0.1076,,,6.0476,271.1021,0.0003,1,1376,0.1078,6.35,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,775.57,712.4037,0.0,40.0003,755.2474,775.0001,1,0.0,42.0,912 +scale,m75,32,0.1079,,,5.9466,256.2937,0.0002,1,1101,0.1073,6.3523,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,1236.4211,1236.4211,0.0,32.0061,1199.9561,781.6001,1,0.0,33.9691,730 +scale,m75,26,0.1076,,,5.8473,581.7866,0.0002,1,894,0.1065,6.355,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,1891.1859,550.6232,0.0,25.9886,1833.6876,639.2544,1,0.0,28.0,593 +scale,m75,22,0.1104,,,5.7576,763.4585,0.0002,1,757,0.1047,6.3534,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,2469.4192,406.2676,0.0,22.006,2455.2032,538.8241,1,0.0,23.9683,502 +scale,m75,18,0.1122,,,5.0991,989.9672,0.0001,1,619,0.1013,6.3458,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,2967.9636,271.464,0.0,17.9943,3073.2614,501.7828,1,0.0,21.9645,410 +scale,m75,15,0.0977,,,5.875,1160.1532,0.0001,1,516,0.097,6.35,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,3222.89,155.3304,0.0,15.0001,3094.828,140.3947,1,0.0,16.0,342 +scale,m75,12,0.0925,,,5.4417,1303.2842,0.0001,1,413,0.0922,6.3562,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,3359.5518,74.4473,0.0,12.0059,2912.5313,114.4232,1,0.0,13.9662,274 +scale,m75,10,0.0953,,,7.75,1386.1941,0.0001,1,344,0.088,6.35,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,3550.1322,43.1737,0.0,10.0001,4575.8277,20.8757,1,0.0,8.0,228 +scale,m75,8,0.0807,,,6.0218,1406.3205,0.0,1,275,0.0821,6.3407,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,3298.063,14.6489,0.0,7.9942,3192.1666,15.9547,1,0.0,7.971,182 +scale,m75,6,0.081,0,0,5.9681,1267.9895,0.0,0,206,0.0749,6.3716,d8c3b7e9cd3b235e3da7e106b95dec6ec3a8a43d09316f5d904f3f27151babeb,2965.3198,6.5533,0.0,5.9884,3885.4057,26.5212,0,0.0,8.2897,137 +blur,m76,0.02,0.1634,,,5.6824,154.1675,0.0036,1,4624,0.1644,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,3.5216,464.7825,0.0,157.3547,3.4893,444.6187,1,0.0,154.8214,3472 +blur,m76,0.04,0.0833,,,2.3725,21.0298,0.0022,0,4624,0.1427,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,1.7589,85.77,0.0,157.3547,1.5316,337.114,1,0.0,313.5303,3472 +blur,m76,0.06,0.0692,,,2.2941,5.517,0.0016,0,4624,0.1283,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,1.5239,18.4326,0.0,157.3547,1.4641,140.9527,1,0.0,327.5333,3472 +blur,m76,0.08,0.0585,,,2.2941,2.1474,0.0013,0,4624,0.1187,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,1.4841,5.2977,0.0,157.3547,1.4222,55.2188,1,0.0,327.5333,3472 +blur,m76,0.1,0.0621,0,0,2.0652,1.033,0.0009,0,4624,0.1116,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,1.4685,2.0608,0.0,157.3547,1.3471,26.7807,0,0.0,365.5011,3472 +blur,m76,0.13,0.0489,0,0,2.2576,0.457,0.0005,0,4624,0.1038,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,1.4591,0.6537,0.0,157.3547,1.2166,5.3137,0,0.0,331.8711,3472 +blur,m76,0.16,0.0546,0,0,2.0,0.2718,0.0002,0,4624,0.0978,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,1.3217,0.2849,0.0,157.3547,1.046,2.0408,0,0.0,366.0666,3472 +blur,m76,0.2,,0,0,,0.185,0.0,,4624,0.0913,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,1.1959,0.1476,0.0,157.3547,,,0,,,3472 +blur,m76,0.25,,0,0,,0.1484,0.0,,4624,0.0848,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,1.0524,0.0957,0.0,157.3547,,,0,,,3472 +blur,m76,0.3,,0,0,,0.1339,0.0,,4624,0.0799,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,0.9402,0.0806,0.0,157.3547,,,0,,,3472 +edge,m76,2.0,0.1947,,,2.0368,994.9705,0.0099,1,4624,0.1936,1.9969,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,864.1116,1766.2201,0.0003,157.3547,831.1402,1708.8254,1,0.0,151.3241,2900 +edge,m76,1.0,0.1909,,,1.087,1019.7028,0.0105,1,4624,0.1936,0.9992,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,864.1116,1766.2201,0.0003,157.3547,773.1084,1493.398,1,0.0,142.1289,2743 +edge,m76,0.5,0.1938,,,0.4236,1040.7484,0.0107,1,4624,0.1936,0.4971,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,864.1116,1766.2201,0.0003,157.3547,801.5208,1716.1091,1,0.0,157.3087,2664 +edge,m76,0.25,0.1953,,,0.2206,1050.0305,0.0108,1,4624,0.1936,0.2493,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,864.1116,1766.2201,0.0003,157.3547,838.4213,1720.6416,1,0.0,150.3288,2625 +edge,m76,0.1,0.1958,,,0.1337,1055.809,0.0109,1,4624,0.1955,0.1031,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,880.5072,1775.0643,0.0003,157.3547,801.7277,1582.2893,1,0.0,149.0651,2602 +edge,m76,0.0,0.1295,,,1.9307,1058.0291,0.0109,0,4624,0.1969,0.0014,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,890.8129,1798.5951,0.0003,157.3547,461.6099,1417.4219,1,0.0,295.7209,2586 +edge,m76,-0.5,0.1295,0,0,1.6592,1073.8535,0.0111,0,4624,0.1968,0.0,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,903.2361,1859.8506,0.0003,157.3547,461.463,1418.0373,0,0.0,295.7209,2507 +edge,m76,-1.0,0.1294,0,0,1.3835,1084.0367,0.0114,0,4624,0.1948,0.0,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,914.9586,1906.727,0.0003,157.3547,462.1555,1428.761,0,0.0,295.7209,2428 +edge,m76,-2.0,0.1305,0,0,0.8835,1100.3542,0.012,0,4624,0.1842,0.0,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,908.8639,1844.7537,0.0004,157.3547,468.3513,1494.212,0,0.0,289.0,2271 +glare,m76,0.3,0.1577,,,5.6765,819.8771,0.0083,1,4624,0.158,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,551.1725,1103.0101,0.0003,157.3547,547.4171,1086.2943,1,0.0002,156.1393,3472 +glare,m76,0.5,0.1428,,,5.6151,796.6639,0.0083,1,4624,0.1431,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,385.7676,758.1501,0.0003,157.3547,387.147,757.4918,1,0.0003,157.8191,3472 +glare,m76,0.7,0.1378,,,5.5539,776.6143,0.0084,1,4624,0.1376,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,255.7174,489.4545,0.0003,157.3547,258.1977,499.0527,1,0.0003,159.531,3472 +glare,m76,0.85,0.1421,,,5.374,763.6936,0.0084,1,4624,0.1408,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,180.8228,337.7697,0.0003,157.3547,196.9866,357.636,1,0.0003,164.7853,3472 +glare,m76,1.0,0.1524,0,0,2.3476,752.5715,0.0098,0,4624,0.1494,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,125.8946,228.2803,0.1018,157.3547,396.3776,1271.9086,0,0.0,318.6291,3472 +glare,m76,1.5,0.1545,0,0,2.3971,726.1895,0.0234,0,4624,0.1137,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,39.6123,67.6668,0.5843,157.3547,416.7882,1268.7001,0,0.0,312.0196,3472 +glare,m76,2.5,0.1895,0,0,2.5682,694.967,0.04,0,4624,0.034,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,2.9697,4.4405,0.8719,157.3547,415.5523,1190.5091,0,0.0095,295.7209,3472 +glare,m76,4.0,0.1088,0,0,25.58,667.5975,0.0557,0,4624,0.0,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,0.0,0.0,1.0,157.3547,189.6193,73.1423,0,0.0,33.6046,3472 +motion,m76,0.05,0.1767,,,5.7257,489.494,0.0059,1,4624,0.1774,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,97.1235,996.1427,0.0,157.3547,94.2717,957.6624,1,0.0,154.8214,3472 +motion,m76,0.1,0.1086,,,2.3617,290.3899,0.0053,0,4624,0.1629,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,53.6875,448.0386,0.0,157.3547,60.9489,1008.4417,1,0.0,315.8837,3472 +motion,m76,0.15,0.1079,,,2.5455,238.7086,0.005,0,4624,0.1522,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,39.2125,269.8351,0.0,157.3547,57.7739,753.2099,1,0.0,295.7209,3472 +motion,m76,0.2,0.1047,,,2.6905,216.1323,0.0048,0,4624,0.1457,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,32.2338,204.2946,0.0,157.3547,56.5257,656.4893,1,0.0,282.2791,3472 +motion,m76,0.3,0.0951,0,0,2.6279,190.3834,0.0045,0,4624,0.1367,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,23.8462,144.7235,0.0,157.3547,49.4494,565.0701,0,0.0,289.0,3472 +motion,m76,0.4,0.0818,0,0,2.2941,176.5135,0.0042,0,4624,0.131,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,19.5113,116.3553,0.0,157.3547,41.1371,496.7909,0,0.0,327.5333,3472 +motion,m76,0.5,,0,0,,167.5721,0.004,,4624,0.1275,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,16.8521,99.3747,0.0,157.3547,,,0,,,3472 +motion,m76,0.7,,0,0,,155.7268,0.0036,,4624,0.1231,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,13.578,83.8202,0.0,157.3547,,,0,,,3472 +motion,m76,1.0,,0,0,,143.8913,0.0031,,4624,0.1175,5.632,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,10.862,69.3971,0.0,157.3547,,,0,,,3472 +scale,m76,48,0.1874,,,5.4842,835.474,0.0064,1,1411,0.1865,5.6295,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,2504.7826,1671.0535,0.0002,48.0163,2564.1457,1752.7851,1,0.0002,49.2672,1059 +scale,m76,40,0.1832,,,5.8931,802.7196,0.006,1,1175,0.1847,5.6367,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,2879.939,1592.2762,0.0001,39.9853,2852.6917,1485.9927,1,0.0,37.9677,883 +scale,m76,32,0.1793,,,6.3571,1703.8425,0.0054,1,940,0.1805,5.6335,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,3157.045,3157.045,0.0001,31.9882,2908.4536,1013.2826,1,0.0,28.0,706 +scale,m76,26,0.1794,,,6.0,1832.3695,0.0048,1,764,0.1783,5.6354,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,3528.9451,969.6596,0.0,25.9989,3672.4491,838.1454,1,0.0,24.0,574 +scale,m76,22,0.165,,,4.4076,1895.6394,0.0049,1,646,0.175,5.6314,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,4148.6773,720.3038,0.0,21.9834,6063.8438,1187.8873,1,0.0,24.0,485 +scale,m76,18,0.1113,,,2.2632,1992.1349,0.0046,0,529,0.1712,5.6291,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,5227.2614,492.9468,0.0,18.0019,2315.3636,1361.0271,1,0.0,37.9283,397 +scale,m76,15,0.1168,,,2.8204,2101.1258,0.0043,0,441,0.1702,5.6298,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,6665.394,340.4182,0.0,15.0072,2758.5286,749.4349,1,0.0,25.8423,331 +scale,m76,12,0.1648,,,5.4948,2265.195,0.0039,1,353,0.1639,5.6309,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,7392.9547,177.1906,0.0,12.0126,7092.3992,172.5367,1,0.0,11.9661,265 +scale,m76,10,0.0973,,,1.6429,2375.3036,0.0026,0,294,0.1566,5.6383,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,7687.6815,105.9596,0.0,10.0048,2214.5141,747.5368,1,0.0,28.0,221 +scale,m76,8,0.0907,0,0,1.8,2614.3309,0.0024,0,235,0.1445,5.6495,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,6880.7291,34.4232,0.0,7.997,2880.3111,360.9153,0,0.0,19.9153,177 +scale,m76,6,0.0795,0,0,2.0,3013.3582,0.0023,0,176,0.1422,5.6256,ca0d8b173225c28556cbc295baa8d4fffe7cb3a2a4585e5a6a4d53d1070432b2,6968.9206,16.9498,0.0,5.9893,3281.6266,132.7397,0,0.0,14.0,132 +blur,m77,0.02,0.1074,,,23.7196,67.6082,0.0003,0,3800,0.0807,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,4.4668,189.9736,0.0,117.458,5.4944,36.9331,1,0.0,60.7558,3600 +blur,m77,0.04,0.0672,,,11.8831,11.3589,0.0001,1,3800,0.0703,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1.314,18.4914,0.0,117.458,1.3159,17.2598,1,0.0,115.1856,3600 +blur,m77,0.06,0.0514,,,12.7277,3.147,0.0,1,3800,0.0653,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1.164,3.3056,0.0,117.458,1.2064,2.5094,1,0.0,107.5198,3600 +blur,m77,0.08,0.0449,,,12.5526,1.2766,0.0,1,3800,0.0622,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1.2057,0.9394,0.0,117.458,1.2466,0.7075,1,0.0,109.0503,3600 +blur,m77,0.1,0.0957,0,0,5.2028,0.6654,0.0,0,3800,0.0596,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1.104,0.3999,0.0,117.458,1.3659,3.2399,0,0.0,143.0,3600 +blur,m77,0.13,,0,0,,0.3453,0.0,,3800,0.0563,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,0.8856,0.211,0.0,117.458,,,0,,,3600 +blur,m77,0.16,,0,0,,0.2268,0.0,,3800,0.0533,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,0.7944,0.1655,0.0,117.458,,,0,,,3600 +blur,m77,0.2,,0,0,,0.1591,0.0,,3800,0.0494,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,0.7487,0.1457,0.0,117.458,,,0,,,3600 +blur,m77,0.25,,0,0,,0.1251,0.0,,3800,0.0445,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,0.6763,0.1476,0.0,117.458,,,0,,,3600 +blur,m77,0.3,,0,0,,0.1106,0.0,,3800,0.04,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,0.583,0.155,0.0,117.458,,,0,,,3600 +edge,m77,2.0,0.1377,,,8.9894,288.7041,0.0025,0,3800,0.1043,2.002,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,835.5318,1006.4249,0.0,117.458,1328.7421,1088.5929,1,0.0,60.7558,2466 +edge,m77,1.0,0.1367,,,7.0018,290.0772,0.0026,0,3800,0.1043,0.9974,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,835.5318,1006.4249,0.0,117.458,1327.875,1101.0487,1,0.0,60.7558,2348 +edge,m77,0.5,0.1377,,,6.0852,287.5733,0.0027,0,3800,0.1043,0.5036,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,835.5318,1006.4249,0.0,117.458,1328.7421,1088.5929,1,0.0,60.7558,2290 +edge,m77,0.25,0.1367,,,5.5479,286.4601,0.0027,0,3800,0.1043,0.2482,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,835.5318,1006.4249,0.0,117.458,1327.875,1101.0487,1,0.0,60.7558,2260 +edge,m77,0.1,0.1387,,,5.3518,285.5211,0.0027,0,3800,0.1056,0.1035,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,845.9104,1038.2465,0.0,117.458,1328.7152,1082.0475,1,0.0,60.7558,2243 +edge,m77,0.0,0.1371,,,5.09,286.0135,0.0027,0,3800,0.1065,0.0013,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,853.591,1059.6009,0.0,117.458,1327.9825,1083.3013,1,0.0,60.7558,2231 +edge,m77,-0.5,0.1367,0,0,4.0935,283.6984,0.0028,0,3800,0.1052,0.0,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,846.9257,1027.6768,0.0,117.458,1327.875,1101.0487,0,0.0,60.7558,2172 +edge,m77,-1.0,0.1377,0,0,3.1782,282.2552,0.0028,0,3800,0.1068,0.0,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,854.9416,1045.3393,0.0,117.458,1328.7421,1088.5929,0,0.0,60.7558,2113 +edge,m77,-2.0,0.1405,0,0,1.2706,281.3246,0.003,0,3800,0.1111,0.0,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,885.0828,1185.1417,0.0,117.458,1318.5331,1086.8371,0,0.0,60.7558,1996 +glare,m77,0.3,0.1363,,,23.7196,241.9716,0.0017,0,3800,0.0912,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,535.24,632.697,0.0,117.458,1317.0806,1073.9242,1,0.0,60.7558,3600 +glare,m77,0.5,0.1359,,,23.7196,239.1352,0.0017,0,3800,0.0973,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,376.0828,437.1836,0.0,117.458,1309.7787,1068.3997,1,0.0,60.7558,3600 +glare,m77,0.7,0.1354,,,23.7196,236.7384,0.0017,0,3800,0.1133,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,251.3955,284.1376,0.0,117.458,1302.5452,1061.9224,1,0.0,60.7558,3600 +glare,m77,0.85,0.1351,,,23.7196,235.2167,0.0017,0,3800,0.1298,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,180.207,197.4317,0.0,117.458,1296.5865,1057.1243,1,0.0,60.7558,3600 +glare,m77,1.0,0.1348,0,0,23.7196,233.9422,0.0021,0,3800,0.1488,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,127.9936,134.7822,0.0578,117.458,1291.3473,1052.0203,0,0.0,60.7558,3600 +glare,m77,1.5,0.1361,0,0,23.6287,231.0358,0.0068,0,3800,0.1304,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,45.8709,38.5997,0.5454,117.458,1263.5648,1036.9596,0,0.0,60.7558,3600 +glare,m77,2.5,0.135,0,0,23.6287,227.8256,0.0127,0,3800,0.0529,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,4.8082,0.8223,0.8303,117.458,1229.4542,1008.6713,0,0.0,60.7558,3600 +glare,m77,4.0,0.135,0,0,23.6287,224.5747,0.0182,0,3800,0.001,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,0.0081,0.0147,0.9992,117.458,1180.0134,967.7578,0,0.0,60.7558,3600 +motion,m77,0.05,0.1221,,,23.7196,164.0417,0.0009,0,3800,0.0917,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,124.424,578.1488,0.0,117.458,246.568,353.4982,1,0.0,60.7558,3600 +motion,m77,0.1,0.1168,,,23.6287,99.8263,0.0005,0,3800,0.0831,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,69.7821,279.9894,0.0,117.458,151.1019,221.491,1,0.0,60.7558,3600 +motion,m77,0.15,0.1128,,,22.2339,80.5806,0.0003,0,3800,0.0788,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,52.1874,208.7571,0.0,117.458,126.6975,220.8889,1,0.0,65.79,3600 +motion,m77,0.2,0.0733,,,11.9936,73.2513,0.0003,1,3800,0.0765,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,44.3937,178.9045,0.0,117.458,44.7426,158.8925,1,0.0,113.6799,3600 +motion,m77,0.3,0.0498,,,13.5659,65.2294,0.0002,1,3800,0.0732,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,33.5033,140.0637,0.0,117.458,36.1534,106.4042,1,0.0,99.2734,3600 +motion,m77,0.4,0.1024,0,0,5.5429,61.4989,0.0002,0,3800,0.0715,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,27.2811,118.2309,0.0,117.458,13.3115,100.7362,0,0.0,133.9013,3600 +motion,m77,0.5,0.0665,0,0,16.3281,58.8777,0.0001,0,3800,0.0705,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,23.2775,104.5296,0.0,117.458,21.4187,24.9086,0,0.0,49.7093,3600 +motion,m77,0.7,,0,0,,55.0428,0.0001,,3800,0.0694,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,18.6151,88.2477,0.0,117.458,,,0,,,3600 +motion,m77,1.0,,0,0,,50.4005,0.0001,,3800,0.0683,11.6565,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,14.6704,75.7963,0.0,117.458,,,0,,,3600 +scale,m77,48,0.1298,,,23.5309,244.1199,0.001,0,1553,0.0978,11.6544,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1223.2772,898.0584,0.0,48.0032,2398.9823,552.0629,1,0.0,25.0246,1471 +scale,m77,40,0.1333,,,22.2727,233.1975,0.0008,0,1294,0.0962,11.6575,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1508.7371,895.7664,0.0,39.9975,2426.5364,391.3071,1,0.0,22.0,1226 +scale,m77,32,0.0918,,,12.3993,215.4787,0.0006,1,1035,0.0958,11.6622,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1974.2889,1974.2889,0.0,31.9918,2086.3401,749.7058,1,0.0,29.971,981 +scale,m77,26,0.0928,,,10.4524,525.807,0.0005,1,841,0.0941,11.6604,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,2256.7734,556.0598,0.0,25.9953,1948.4632,674.5396,1,0.0,28.8456,797 +scale,m77,22,0.0786,,,14.2222,613.5511,0.0003,1,712,0.0888,11.6474,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,2364.4137,352.4127,0.0,22.0079,2898.3023,244.3985,1,0.0,18.0,674 +scale,m77,18,0.0809,,,12.7449,706.2288,0.0003,1,582,0.0861,11.6699,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,2161.9068,173.044,0.0,17.9896,2363.1918,147.6226,1,0.0,16.3175,552 +scale,m77,15,0.0825,,,10.6119,782.7404,0.0003,1,485,0.0822,11.6699,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,2078.4304,91.1916,0.0,14.9914,1740.4228,114.3275,1,0.0,16.3916,460 +scale,m77,12,0.0692,,,13.6633,822.5181,0.0002,1,388,0.0812,11.6699,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1851.3587,39.8142,0.0,11.9931,1885.0236,25.0758,1,0.0,10.0994,368 +scale,m77,10,0.1486,0,0,4.3456,844.7907,0.0002,0,324,0.0767,11.6205,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1628.662,19.3728,0.0,10.0148,3143.3644,142.4979,0,0.0,14.2396,306 +scale,m77,8,0.1524,0,0,3.4286,919.5177,0.0002,0,259,0.0758,11.639,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,1348.203,5.837,0.0,8.0057,2924.2766,118.0385,0,0.0,13.9462,245 +scale,m77,6,0.084,0,0,6.3333,953.4944,0.0,0,194,0.0639,11.6699,9bf6813936b2a9adbad7fb0fcd2c6a035e79a1a4966ecefb7e5c85e78b3420f8,962.344,2.4454,0.0,5.9965,4060.0326,8.6603,0,0.0,6.0,184 +blur,m78,0.02,0.1779,,,11.2164,217.3185,0.0799,1,4554,0.1826,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,12.4954,574.5533,0.3216,99.4594,11.9258,653.2817,1,0.338,106.2484,3840 +blur,m78,0.04,0.1508,,,11.2164,69.5025,0.0768,1,4554,0.1545,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,2.9522,130.3608,0.2976,99.4594,2.8454,154.3876,1,0.3114,106.2484,3840 +blur,m78,0.06,0.1296,0,0,11.2164,23.8134,0.074,0,4554,0.1325,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1.5868,33.6106,0.2637,99.4594,1.5481,40.4122,0,0.2754,106.2484,3840 +blur,m78,0.08,0.1162,0,1,11.9805,9.7347,0.0713,0,4554,0.1165,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1.4007,10.0792,0.2156,99.4594,1.3889,9.9019,0,0.2136,99.4717,3840 +blur,m78,0.1,0.1007,,,10.5439,4.7232,0.0687,1,4554,0.1044,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1.417,3.5902,0.1598,99.4594,1.3616,5.2506,1,0.1783,113.0252,3840 +blur,m78,0.13,0.0852,,,11.3151,2.0529,0.0649,1,4554,0.0899,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1.4916,1.193,0.0839,99.4594,1.4959,1.4631,1,0.1052,105.907,3840 +blur,m78,0.16,,0,0,,1.0948,0.0614,,4554,0.0777,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1.5161,0.5433,0.0335,99.4594,,,0,,,3840 +blur,m78,0.2,,0,0,,0.5805,0.0569,,4554,0.064,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1.4837,0.2425,0.0125,99.4594,,,0,,,3840 +blur,m78,0.25,,0,0,,0.3248,0.0516,,4554,0.0502,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1.4027,0.1268,0.0038,99.4594,,,0,,,3840 +blur,m78,0.3,,0,0,,0.2159,0.0466,,4554,0.0395,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1.3377,0.0912,0.0014,99.4594,,,0,,,3840 +edge,m78,2.0,0.2046,,,2.0586,514.301,0.1113,1,4554,0.2046,1.9982,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,342.4563,1566.0021,0.3479,99.4594,342.9478,1574.2026,1,0.3416,99.4717,2847 +edge,m78,1.0,0.2046,,,1.0625,518.5616,0.1006,1,4554,0.2046,1.0029,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,342.4563,1566.0021,0.3479,99.4594,342.9478,1574.2026,1,0.3416,99.4717,2748 +edge,m78,0.5,0.2047,,,0.5318,525.208,0.0931,1,4554,0.2046,0.5001,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,342.4563,1566.0021,0.3479,99.4594,342.9281,1575.2158,1,0.345,99.4717,2698 +edge,m78,0.25,0.2047,,,0.2661,527.3702,0.0896,1,4554,0.2046,0.2488,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,342.4563,1566.0021,0.3479,99.4594,342.9288,1570.234,1,0.3461,99.4717,2673 +edge,m78,0.1,0.2059,,,0.1329,529.9792,0.0875,1,4554,0.2062,0.098,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,349.396,1599.0762,0.3381,99.4594,348.0142,1590.8201,1,0.3369,99.4717,2658 +edge,m78,0.0,0.2021,,,0.0623,530.4889,0.0861,1,4554,0.2073,0.0,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,354.128,1620.9303,0.3319,99.4594,336.2765,1679.3038,1,0.3467,106.2484,2648 +edge,m78,-0.5,0.2029,0,0,0.1995,527.8452,0.0795,0,4554,0.2031,0.0,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,352.3865,1614.1964,0.3222,99.4594,351.3753,1612.301,0,0.3214,99.4717,2599 +edge,m78,-1.0,0.1974,0,0,0.0546,523.9172,0.0719,0,4554,0.2044,0.0,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,355.9923,1636.5337,0.3078,99.4594,335.0747,1782.854,0,0.3242,111.6543,2549 +edge,m78,-2.0,0.1944,0,0,0.0603,503.018,0.0582,0,4554,0.2001,0.0,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,334.0442,1534.6356,0.2912,99.4594,317.9438,1631.5757,0,0.3024,107.2952,2449 +glare,m78,0.3,0.1618,,,11.9805,374.4452,0.0859,1,4554,0.1618,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,210.996,964.0314,0.4965,99.4594,210.996,964.0314,1,0.4965,99.4717,3840 +glare,m78,0.5,0.1344,,,11.9805,359.2133,0.0881,1,4554,0.1344,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,142.2925,651.1834,0.5872,99.4594,142.2925,651.1834,1,0.5872,99.4717,3840 +glare,m78,0.7,0.1088,,,11.9805,346.1456,0.0897,1,4554,0.1088,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,89.452,410.0403,0.6635,99.4594,89.452,410.0403,1,0.6635,99.4717,3840 +glare,m78,0.85,0.0918,,,11.9805,337.7654,0.0908,1,4554,0.0918,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,60.06,275.3444,0.7258,99.4594,60.06,275.3444,1,0.7258,99.4717,3840 +glare,m78,1.0,0.0799,,,12.6363,330.6328,0.0918,1,4554,0.0779,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,39.3718,181.0898,0.7946,99.4594,41.5347,170.251,1,0.7846,94.7528,3840 +glare,m78,1.5,0.2081,0,0,12.8601,314.2703,0.0949,0,4554,0.0442,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,10.7485,48.6958,0.9244,99.4594,297.432,1446.7639,0,0.781,92.6686,3840 +glare,m78,2.5,0.2044,0,0,12.8601,297.8524,0.0992,0,4554,0.002,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,0.1864,0.1785,0.9977,99.4594,287.3636,1396.9802,0,0.7824,92.6686,3840 +glare,m78,4.0,0.1988,0,0,12.8601,286.1028,0.1042,0,4554,0.0,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,0.0,0.0,1.0,99.4594,272.7778,1324.6855,0,0.7873,92.6686,3840 +motion,m78,0.05,0.1945,,,11.9805,339.2536,0.0817,1,4554,0.1945,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,78.1074,1038.7928,0.3324,99.4594,78.1074,1038.7928,1,0.3324,99.4717,3840 +motion,m78,0.1,0.1832,,,12.8346,238.5542,0.0813,1,4554,0.1783,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,41.8096,540.4215,0.3287,99.4594,44.8323,484.7675,1,0.3065,92.8525,3840 +motion,m78,0.15,0.167,,,12.8346,180.2641,0.0799,1,4554,0.1629,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,28.9944,330.6973,0.3167,99.4594,31.0706,298.0297,1,0.2936,92.8525,3840 +motion,m78,0.2,0.1535,,,12.7633,151.2557,0.0792,1,4554,0.1504,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,22.9255,243.0311,0.3124,99.4594,24.3409,218.513,1,0.2946,92.8525,3840 +motion,m78,0.3,0.135,,,12.7633,124.6639,0.0775,1,4554,0.1331,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,16.5532,162.6253,0.3013,99.4594,17.4247,145.4903,1,0.2784,92.8525,3840 +motion,m78,0.4,0.1213,,,11.914,111.4015,0.076,1,4554,0.1222,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,13.1993,126.822,0.2886,99.4594,12.8904,123.3761,1,0.2901,99.4717,3840 +motion,m78,0.5,0.0996,0,0,12.6741,103.0156,0.0747,0,4554,0.1135,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,11.0297,102.4309,0.2812,99.4594,18.4932,147.0025,0,0.3545,92.9838,3840 +motion,m78,0.7,0.1027,0,0,11.8951,92.8245,0.0722,0,4554,0.1032,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,8.3591,75.2028,0.2778,99.4594,8.4454,74.1697,0,0.2804,99.6293,3840 +motion,m78,1.0,,0,0,,83.9495,0.0686,,4554,0.0973,11.9822,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,6.6406,60.6713,0.274,99.4594,,,0,,,3840 +scale,m78,48,0.1983,,,11.2167,403.9581,0.0827,1,2198,0.2036,11.9797,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1427.9787,1565.5457,0.3498,48.0043,1349.5728,1645.6848,1,0.3611,51.4651,1853 +scale,m78,40,0.1977,,,11.247,408.8552,0.0827,1,1832,0.203,11.9763,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,1889.8283,1522.5432,0.3488,40.0109,1781.9153,1586.4351,1,0.363,42.6046,1544 +scale,m78,32,0.1972,,,11.1584,404.456,0.083,1,1465,0.2018,11.9792,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,2632.3153,2632.3153,0.3566,31.9956,2489.5048,1616.6986,1,0.3718,34.3486,1235 +scale,m78,26,0.2017,,,12.0769,384.4492,0.083,1,1190,0.2008,11.9891,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,3763.9763,1122.6808,0.3638,25.9896,3815.4039,1150.0663,1,0.3529,26.0,1004 +scale,m78,22,0.1997,,,11.9978,703.5304,0.0833,1,1007,0.1996,11.9806,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,5171.2051,888.6597,0.3739,21.9929,5178.081,888.9137,1,0.3744,21.9782,849 +scale,m78,18,0.194,,,10.6846,973.3,0.0833,1,824,0.1969,11.9855,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,7457.3412,690.6973,0.3748,17.9962,6826.54,885.0744,1,0.3993,20.0,695 +scale,m78,15,0.1884,,,11.247,1244.5018,0.083,1,687,0.1948,11.9763,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,9801.1243,477.4399,0.3898,15.0041,8893.5881,418.5088,1,0.4089,15.9767,579 +scale,m78,12,0.1836,,,11.8293,1590.7696,0.0832,1,549,0.1858,11.9842,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,12022.5639,287.7952,0.397,11.9902,11624.3123,277.936,1,0.4118,11.9782,463 +scale,m78,10,0.1738,,,11.8,1867.5401,0.083,1,458,0.1774,11.9763,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,13404.0154,180.8117,0.408,10.0027,12704.908,162.5762,1,0.3966,10.0,386 +scale,m78,8,0.1467,,,9.3697,2155.7613,0.0815,1,366,0.164,11.9971,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,13241.3244,62.0668,0.4223,7.9934,9325.0312,126.5273,1,0.4533,10.0,309 +scale,m78,6,0.1437,0,1,12.0436,2481.8485,0.0792,0,275,0.1452,11.9882,eff391b2d40cafa17d3a931ee9c25d82369ac28a5e73ca57be230c015e3e86ed,11574.8338,25.5248,0.3926,6.006,11324.5653,24.7808,0,0.3623,5.9783,232 +blur,m79,0.02,0.1913,,,14.2153,95.7111,0.0,1,4032,0.19,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,31.1755,811.9614,0.0,91.9296,30.5867,744.9447,1,0.0,89.0106,3024 +blur,m79,0.04,0.1535,,,13.217,21.2041,0.0,1,4032,0.1552,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,4.3914,155.3566,0.0,91.9296,4.3492,172.8138,1,0.0,95.2745,3024 +blur,m79,0.06,0.1295,,,14.2095,6.431,0.0,1,4032,0.129,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,2.1356,38.2643,0.0,91.9296,2.1626,35.0465,1,0.0,89.0455,3024 +blur,m79,0.08,0.11,,,12.97,2.6145,0.0,1,4032,0.1104,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,1.6945,10.8646,0.0,91.9296,1.6692,12.6731,1,0.0,97.0219,3024 +blur,m79,0.1,0.0943,0,0,18.1667,1.3238,0.0,0,4032,0.0964,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,1.5925,4.1317,0.0,91.9296,1.7503,1.5402,0,0.0,70.3256,3024 +blur,m79,0.13,,0,0,,0.6194,0.0,,4032,0.0806,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,1.5059,1.265,0.0,91.9296,,,0,,,3024 +blur,m79,0.16,,0,0,,0.3661,0.0,,4032,0.0692,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,1.4745,0.5165,0.0,91.9296,,,0,,,3024 +blur,m79,0.2,,0,0,,0.2362,0.0,,4032,0.058,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,1.3653,0.2458,0.0,91.9296,,,0,,,3024 +blur,m79,0.25,,0,0,,0.174,0.0,,4032,0.0482,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,1.2936,0.1412,0.0,91.9296,,,0,,,3024 +blur,m79,0.3,,0,0,,0.1464,0.0,,4032,0.0414,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,1.1676,0.1163,0.0,91.9296,,,0,,,3024 +edge,m79,2.0,0.2294,,,2.1302,322.0475,0.0001,1,4032,0.226,2.0048,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,689.2416,3100.7372,0.0,91.9296,701.4403,3055.8368,1,0.0,87.907,1855 +edge,m79,1.0,0.2297,,,1.0576,307.3787,0.0001,1,4032,0.226,1.004,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,689.2416,3100.7372,0.0,91.9296,712.5919,3061.1792,1,0.0,88.5371,1763 +edge,m79,0.5,0.2291,,,0.5176,308.995,0.0001,1,4032,0.226,0.5037,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,689.2416,3100.7372,0.0,91.9296,715.3219,3178.5909,1,0.0,90.0631,1717 +edge,m79,0.25,0.2292,,,0.2667,307.4743,0.0001,1,4032,0.226,0.2535,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,689.2416,3100.7372,0.0,91.9296,699.845,3010.5236,1,0.0,87.907,1694 +edge,m79,0.1,0.2299,,,0.1313,291.9164,0.0001,1,4032,0.2274,0.1012,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,700.6174,3176.611,0.0,91.9296,709.0928,3078.4561,1,0.0,88.5451,1680 +edge,m79,0.0,0.2314,,,0.1306,294.774,0.0001,1,4032,0.2284,0.0033,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,705.8083,3162.0743,0.0,91.9296,720.2233,3120.1763,1,0.0,88.7085,1671 +edge,m79,-0.5,0.2306,0,0,0.0645,269.8355,0.0001,0,4032,0.2282,0.0,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,692.7857,3090.6553,0.0,91.9296,699.424,3000.888,0,0.0,88.7733,1625 +edge,m79,-1.0,0.0965,0,0,5.6846,269.5379,0.0001,0,4032,0.2268,0.0,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,670.0304,2994.413,0.0,91.9296,156.7996,520.2968,0,0.0,82.3023,1579 +edge,m79,-2.0,0.0967,0,0,4.5524,229.4676,0.0001,0,4032,0.2178,0.0,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,586.3911,2563.6832,0.0,91.9296,157.0268,495.1848,0,0.0,82.3023,1487 +glare,m79,0.3,0.1771,,,14.4,218.0418,0.0002,1,4032,0.1767,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,432.2217,1926.4697,0.0,91.9296,421.9199,1803.5524,1,0.0,87.907,3024 +glare,m79,0.5,0.1476,,,14.0455,207.9101,0.0002,1,4032,0.1474,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,296.1425,1312.5974,0.0,91.9296,295.6339,1275.7156,1,0.0,90.0495,3024 +glare,m79,0.7,0.1233,,,14.1504,199.4125,0.0002,1,4032,0.1238,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,190.6279,835.0215,0.0,91.9296,186.6368,797.0306,1,0.0,89.4048,3024 +glare,m79,0.85,0.1109,,,14.2169,194.0827,0.0002,1,4032,0.1117,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,130.8817,566.272,0.0,91.9296,127.3699,538.0449,1,0.0,89.0004,3024 +glare,m79,1.0,0.105,0,0,14.4,189.6174,0.0009,0,4032,0.1065,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,87.9546,373.9227,0.1472,91.9296,84.1204,347.4447,0,0.1548,87.907,3024 +glare,m79,1.5,0.0969,0,0,11.393,180.1306,0.0042,0,4032,0.0765,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,26.4318,107.4365,0.5764,91.9296,157.2219,520.9971,0,0.0,82.3023,3024 +glare,m79,2.5,0.0969,0,0,11.393,171.3293,0.0086,0,4032,0.0279,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,2.5358,7.2291,0.862,91.9296,157.2219,520.9971,0,0.0,82.3023,3024 +glare,m79,4.0,0.0969,0,0,11.393,165.0179,0.0125,0,4032,0.0003,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,0.0025,0.0009,1.0,91.9296,157.2219,520.9971,0,0.0,82.3023,3024 +motion,m79,0.05,0.2055,,,14.2374,160.685,0.0,1,4032,0.2037,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,133.4449,1439.6169,0.0,91.9296,135.4765,1367.0323,1,0.0,88.8767,3024 +motion,m79,0.1,0.186,,,14.1506,95.96,0.0,1,4032,0.1845,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,73.9561,644.8707,0.0,91.9296,75.4675,625.893,1,0.0,89.4036,3024 +motion,m79,0.15,0.1674,,,13.937,69.6094,0.0,1,4032,0.1668,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,53.9219,411.2872,0.0,91.9296,54.5965,411.3319,1,0.0,90.7262,3024 +motion,m79,0.2,0.1584,,,14.8135,60.4534,0.0,1,4032,0.1558,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,45.8185,310.0277,0.0,91.9296,48.5348,280.8791,1,0.0,85.4313,3024 +motion,m79,0.3,0.1423,,,15.4165,50.1647,0.0,1,4032,0.1398,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,35.7776,228.9191,0.0,91.9296,39.0841,206.8377,1,0.0,82.2929,3024 +motion,m79,0.4,0.0734,0,0,22.1988,45.8379,0.0,0,4032,0.1305,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,30.6256,191.5993,0.0,91.9296,29.4789,48.6865,0,0.0,47.2558,3024 +motion,m79,0.5,,0,0,,42.9463,0.0,,4032,0.1256,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,27.1751,173.4642,0.0,91.9296,,,0,,,3024 +motion,m79,0.7,,0,0,,38.8403,0.0,,4032,0.1204,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,22.3799,154.9727,0.0,91.9296,,,0,,,3024 +motion,m79,1.0,,0,0,,33.8967,0.0,,4032,0.1129,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,17.2459,130.3461,0.0,91.9296,,,0,,,3024 +scale,m79,48,0.2256,,,14.1607,238.7241,0.0001,1,2105,0.2251,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,2937.4338,3137.3201,0.0,47.994,2889.6254,3022.6667,1,0.0,46.6429,1579 +scale,m79,40,0.2236,,,13.6794,239.5325,0.0001,1,1754,0.2236,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,4157.9372,3050.4208,0.0,39.9912,4157.9372,3028.4086,1,0.0,40.1848,1316 +scale,m79,32,0.2222,,,14.2431,232.8319,0.0001,1,1404,0.2209,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,5694.3028,5694.3028,0.0,32.0112,5558.6879,2506.4159,1,0.0,30.9361,1053 +scale,m79,26,0.2186,,,14.313,215.8504,0.0001,1,1140,0.2148,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,6182.803,1950.065,0.0,25.992,6572.8452,1775.242,1,0.0,24.9781,855 +scale,m79,22,0.2208,,,14.6917,439.1481,0.0001,1,965,0.2115,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,6814.2738,1269.4468,0.0,22.002,8069.6903,1238.6318,1,0.0,20.5163,724 +scale,m79,18,0.2126,,,15.0343,465.4564,0.0001,1,789,0.205,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,7850.7308,804.1368,0.0,17.9892,8618.6491,682.1809,1,0.0,16.4588,592 +scale,m79,15,0.2039,,,14.8571,495.8818,0.0,1,658,0.2,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,10209.025,556.1225,0.0,15.0024,10358.3679,452.6098,1,0.0,14.0,493 +scale,m79,12,0.2022,,,16.6,542.7455,0.0,1,526,0.1932,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,14198.894,358.0806,0.0,11.9928,16682.5216,243.8058,1,0.0,10.0,395 +scale,m79,10,,0,0,,584.9192,0.0001,,439,0.1836,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,16105.7573,223.8635,0.0,10.0092,,,0,,,329 +scale,m79,8,,0,0,,632.8567,0.0,,351,0.1677,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,15791.9558,76.544,0.0,8.0028,,,0,,,263 +scale,m79,6,,0,0,,711.7914,0.0,,263,0.1468,13.7478,31070c8bf30a04a0a31b650f2509cc3282c69e61bfa6aba78c1c69418ef63d93,14286.7613,35.4328,0.0,5.9964,,,0,,,197 +blur,m80,0.02,0.2189,,,4.3006,90.7655,0.0,0,1082,0.2422,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,534.3955,1125.4055,0.0,47.9651,512.2353,912.9369,1,0.0,44.1279,818 +blur,m80,0.04,0.1814,,,1.3939,7.3557,0.0,0,1082,0.2202,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,29.7216,106.7584,0.0,47.9651,22.1726,227.3923,1,0.0,66.0,818 +blur,m80,0.06,0.1576,0,0,1.4194,1.9854,0.0,0,1082,0.2092,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,6.3562,21.1895,0.0,47.9651,7.1298,59.5752,0,0.0,62.0,818 +blur,m80,0.08,0.1477,0,0,1.3902,0.9675,0.0,0,1082,0.2011,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,3.0725,6.7645,0.0,47.9651,3.8769,25.0043,0,0.0,62.9557,818 +blur,m80,0.1,0.1263,0,0,1.4814,0.6282,0.0,0,1082,0.1941,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,2.2314,2.8993,0.0,47.9651,2.6615,8.5835,0,0.0,60.5634,818 +blur,m80,0.13,,0,0,,0.4477,0.0,,1082,0.1846,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1.8417,1.2428,0.0,47.9651,,,0,,,818 +blur,m80,0.16,,0,0,,0.3829,0.0,,1082,0.1758,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1.7128,0.7271,0.0,47.9651,,,0,,,818 +blur,m80,0.2,,0,0,,0.3452,0.0,,1082,0.1651,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1.6591,0.486,0.0,47.9651,,,0,,,818 +blur,m80,0.25,,0,0,,0.322,0.0,,1082,0.1528,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1.5638,0.3554,0.0,47.9651,,,0,,,818 +blur,m80,0.3,,0,0,,0.3089,0.0,,1082,0.1413,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1.5173,0.3017,0.0,47.9651,,,0,,,818 +edge,m80,2.0,0.279,,,2.1259,915.1213,0.0097,1,1082,0.2781,2.0014,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,5457.1217,4829.7049,0.0057,47.9651,5282.5462,4749.6655,1,0.0051,46.0227,560 +edge,m80,1.0,0.2675,,,2.5626,876.3013,0.0086,0,1082,0.2781,1.0007,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,5457.1217,4829.7049,0.0057,47.9651,5364.3838,4694.3272,1,0.0063,48.151,512 +edge,m80,0.5,0.2535,,,2.3981,811.8706,0.0076,0,1082,0.2781,0.5003,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,5457.1217,4829.7049,0.0057,47.9651,4934.71,4139.4307,1,0.0058,42.5108,488 +edge,m80,0.25,0.254,,,2.2052,780.1672,0.0072,0,1082,0.2781,0.2501,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,5457.1217,4829.7049,0.0057,47.9651,5002.2314,4197.2433,1,0.0058,42.5972,476 +edge,m80,0.1,0.2568,,,1.8569,766.1722,0.007,0,1082,0.2698,0.1042,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,5522.3465,4836.3478,0.0054,47.9651,5112.4534,4538.0246,1,0.006,44.9758,469 +edge,m80,0.0,0.2537,,,1.8823,751.6007,0.0068,0,1082,0.2629,0.0,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,5491.9366,4836.4073,0.0055,47.9651,4965.5314,4168.1156,1,0.0058,42.4732,464 +edge,m80,-0.5,0.254,0,0,1.3598,707.2621,0.0062,0,1082,0.2646,0.0,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,5449.3821,4782.0003,0.0056,47.9651,5002.2314,4197.2433,0,0.0058,42.6045,440 +edge,m80,-1.0,0.2542,0,0,0.8459,673.2494,0.0056,0,1082,0.2665,0.0,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,5360.9651,4709.9208,0.006,47.9651,5027.2044,4222.1387,0,0.0059,42.492,416 +edge,m80,-2.0,0.2569,0,0,0.0869,616.5615,0.0046,0,1082,0.2705,0.0,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,5707.2362,4995.0838,0.0067,47.9651,5324.737,4703.1295,0,0.0064,43.6963,368 +glare,m80,0.3,0.2226,,,4.3182,602.8875,0.007,1,1082,0.2289,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,3510.1756,3067.1401,0.0077,47.9651,3263.9862,2873.5381,1,0.0068,44.0,818 +glare,m80,0.5,0.1948,,,4.2499,560.2206,0.0072,1,1082,0.1993,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,2482.5266,2144.0987,0.0107,47.9651,2335.6579,2084.32,1,0.0099,44.6941,818 +glare,m80,0.7,0.1736,,,3.9456,523.2677,0.0075,1,1082,0.1738,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1672.1406,1421.5685,0.0222,47.9651,1614.2619,1432.0197,1,0.0209,46.5833,818 +glare,m80,0.85,0.1583,,,4.3333,499.4937,0.0089,1,1082,0.1586,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1205.5781,1011.2228,0.0731,47.9651,1061.939,896.0883,1,0.0729,42.0,818 +glare,m80,1.0,0.13,0,0,1.5704,478.9474,0.0151,0,1082,0.1477,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,862.9552,714.1624,0.2838,47.9651,561.6118,544.3479,0,0.0001,57.4762,818 +glare,m80,1.5,0.1134,0,0,3.5,428.687,0.0379,0,1082,0.106,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,289.8181,231.1132,0.6509,47.9651,2872.8317,636.1154,0,0.0104,24.0,818 +glare,m80,2.5,0.102,0,0,3.5,365.8671,0.074,0,1082,0.035,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,14.1522,10.4562,0.9153,47.9651,2312.3657,509.4197,0,0.0144,24.0,818 +glare,m80,4.0,0.0945,0,0,5.3333,311.2826,0.104,0,1082,0.0,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,0.0,0.0,1.0,47.9651,2313.3126,59.4585,0,0.1361,12.0,818 +motion,m80,0.05,0.2447,,,4.4757,472.4053,0.0039,0,1082,0.269,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,3442.7767,3756.153,0.0017,47.9651,3115.0181,3124.909,1,0.0017,42.4407,818 +motion,m80,0.1,0.2273,,,4.3224,255.1472,0.002,0,1082,0.2503,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1834.7901,1901.7332,0.0004,47.9651,1593.0589,1629.6021,1,0.0006,43.4588,818 +motion,m80,0.15,0.1892,,,1.5738,224.7749,0.0016,0,1082,0.2438,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1634.1065,1634.6995,0.0002,47.9651,718.7744,860.5385,1,0.0,57.866,818 +motion,m80,0.2,0.2124,0,0,4.5238,201.2881,0.0014,0,1082,0.2373,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1494.8474,1470.2,0.0,47.9651,1240.7812,1182.7532,0,0.0,42.0,818 +motion,m80,0.3,0.1758,0,0,1.6188,183.8355,0.0013,0,1082,0.2312,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1391.4527,1352.8466,0.0,47.9651,587.3123,508.6133,0,0.0,56.6365,818 +motion,m80,0.4,0.1273,0,0,1.92,170.3661,0.0012,0,1082,0.2266,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1311.6382,1272.419,0.0,47.9651,163.752,219.4048,0,0.0,50.0,818 +motion,m80,0.5,,0,0,,160.6782,0.001,,1082,0.2236,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1267.7645,1238.9873,0.0,47.9651,,,0,,,818 +motion,m80,0.7,,0,0,,146.2994,0.0009,,1082,0.2203,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1187.9742,1178.6732,0.0,47.9651,,,0,,,818 +motion,m80,1.0,,0,0,,131.8714,0.0007,,1082,0.218,3.9485,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,1096.0151,1119.5524,0.0,47.9651,,,0,,,818 +scale,m80,40,0.2539,,,3.898,1491.7929,0.0062,0,902,0.279,3.949,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,7111.9683,4786.5728,0.0052,39.9857,6896.0175,4467.614,1,0.0051,38.9161,682 +scale,m80,32,0.2452,0,0,4.1202,1899.8191,0.0066,0,722,0.2707,3.9497,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,9540.4352,9540.4352,0.0059,32.0063,9551.0352,3870.2322,0,0.0053,30.5649,546 +scale,m80,26,0.1919,0,0,1.647,2049.8864,0.0059,0,587,0.2628,3.9416,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,11210.4745,2837.9012,0.0035,26.0217,2264.6771,998.4271,0,0.0,30.2237,443 +scale,m80,22,0.2618,0,0,3.8987,2013.3529,0.006,0,496,0.2611,3.9487,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,11692.3401,1946.8708,0.0024,21.9877,11190.02,1829.8438,0,0.0028,22.0,375 +scale,m80,18,0.259,0,0,3.8763,1887.474,0.0052,0,406,0.2571,3.9493,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,11231.9459,1021.216,0.001,17.998,10523.5309,962.8039,0,0.0013,18.0,307 +scale,m80,15,0.261,0,0,3.625,1755.1113,0.005,0,338,0.2481,3.9558,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,9440.3471,482.9574,0.0005,14.9835,9080.0077,403.0968,0,0.0007,16.0,256 +scale,m80,12,0.1831,0,0,1.5714,1678.5515,0.005,0,271,0.2386,3.9509,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,7780.3864,182.8192,0.0,12.0134,5167.8764,248.4814,0,0.0,13.9485,205 +scale,m80,10,,0,0,,1765.1236,0.0045,,226,0.2262,3.9518,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,7895.1964,115.708,0.0,10.0186,,,0,,,171 +scale,m80,8,,0,0,,1936.5178,0.006,,180,0.2135,3.9462,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,7126.1799,39.0764,0.0,7.9794,,,0,,,136 +scale,m80,6,,0,0,,2284.548,0.0048,,135,0.2268,3.9462,50f377f18eef24ccdd63dcd011369002301298abaab834fefa1339580c3a857e,4767.2916,14.913,0.0,5.9846,,,0,,,102 +blur,m81,0.02,0.071,,,18.4167,104.5313,0.0048,0,4624,0.2459,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,3.839,457.4706,0.0,147.3669,2.048,29.826,1,0.0,80.6512,3468 +blur,m81,0.04,0.0876,,,6.6332,26.2962,0.0032,1,4624,0.2286,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,1.6917,89.5645,0.0,147.3669,1.5004,20.6326,1,0.0,123.2401,3468 +blur,m81,0.06,0.0879,,,6.1089,8.9658,0.0015,1,4624,0.2148,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,1.4677,24.7885,0.0,147.3669,1.3801,7.6917,1,0.0,134.067,3468 +blur,m81,0.08,0.0606,0,0,7.5625,3.6337,0.0006,0,4624,0.2038,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,1.4432,8.6962,0.0,147.3669,1.3833,0.6982,0,0.0,107.5349,3468 +blur,m81,0.1,,0,0,,1.6472,0.0,,4624,0.1944,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,1.4116,3.805,0.0,147.3669,,,0,,,3468 +blur,m81,0.13,,0,0,,0.6224,0.0,,4624,0.1823,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,1.3199,1.5485,0.0,147.3669,,,0,,,3468 +blur,m81,0.16,,0,0,,0.3027,0.0,,4624,0.1718,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,1.2283,0.7925,0.0,147.3669,,,0,,,3468 +blur,m81,0.2,,0,0,,0.1748,0.0,,4624,0.16,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,1.1807,0.3686,0.0,147.3669,,,0,,,3468 +blur,m81,0.25,,0,0,,0.1473,0.0,,4624,0.1481,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,0.9984,0.1748,0.0,147.3669,,,0,,,3468 +blur,m81,0.3,,0,0,,0.1453,0.0,,4624,0.1382,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,0.9996,0.1035,0.0,147.3669,,,0,,,3468 +edge,m81,2.0,0.0908,,,12.672,337.2498,0.0078,0,4624,0.2638,2.0013,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,241.25,1554.5725,0.0,147.3669,94.1658,392.74,1,0.0,80.6512,2945 +edge,m81,1.0,0.0909,,,10.8947,342.2115,0.0082,0,4624,0.2638,0.997,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,241.25,1554.5725,0.0,147.3669,94.3856,389.5737,1,0.0,80.6512,2797 +edge,m81,0.5,0.0894,,,9.5431,344.5034,0.0085,0,4624,0.2638,0.5017,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,241.25,1554.5725,0.0,147.3669,90.5948,404.7733,1,0.0,84.3325,2724 +edge,m81,0.25,0.0893,,,9.0026,344.6992,0.0086,0,4624,0.2638,0.2506,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,241.25,1554.5725,0.0,147.3669,90.402,404.2344,1,0.0,85.0199,2687 +edge,m81,0.1,0.0908,,,9.2389,344.4946,0.0087,0,4624,0.2647,0.1013,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,243.1759,1574.465,0.0,147.3669,94.1803,393.3057,1,0.0,80.6512,2665 +edge,m81,0.0,0.0939,,,9.9725,344.7329,0.0087,0,4624,0.2653,0.0,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,244.3871,1584.9717,0.0,147.3669,102.0804,360.4484,1,0.0,73.1667,2650 +edge,m81,-0.5,0.0893,0,0,7.7322,340.6037,0.0088,0,4624,0.2694,0.0,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,243.638,1568.2178,0.0,147.3669,90.3988,405.0269,0,0.0,84.3325,2576 +edge,m81,-1.0,0.0908,0,0,7.2387,335.4996,0.009,0,4624,0.2733,0.0,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,243.919,1538.847,0.0,147.3669,94.3703,393.3853,0,0.0,80.6512,2503 +edge,m81,-2.0,0.0908,0,0,5.4074,321.6817,0.0091,0,4624,0.2708,0.0,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,257.7734,1554.6596,0.0,147.3669,94.3703,393.3853,0,0.0,80.6512,2355 +glare,m81,0.3,0.0853,,,18.4167,286.8209,0.0067,0,4624,0.2157,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,152.2963,967.5844,0.0,147.3669,81.984,337.8821,1,0.0,80.6512,3468 +glare,m81,0.5,0.0832,,,18.4167,275.1432,0.0067,0,4624,0.1882,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,105.2978,661.7475,0.0,147.3669,74.2656,304.3715,1,0.0,80.6512,3468 +glare,m81,0.7,0.0824,,,18.4167,265.0955,0.0067,0,4624,0.1663,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,68.4386,423.143,0.0,147.3669,66.924,273.3191,1,0.0,80.6512,3468 +glare,m81,0.85,0.0827,,,18.4167,258.6104,0.0068,0,4624,0.155,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,47.458,288.4179,0.0021,147.3669,61.8555,251.4906,1,0.0,80.6512,3468 +glare,m81,1.0,0.0832,0,0,17.531,253.0593,0.0103,0,4624,0.1493,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,32.3031,191.6144,0.1884,147.3669,55.2162,241.8371,0,0.0,84.7047,3468 +glare,m81,1.5,0.0921,0,0,17.5295,239.9725,0.0242,0,4624,0.1084,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,9.6339,51.9304,0.6115,147.3669,42.4349,181.6593,0,0.0,84.712,3468 +glare,m81,2.5,0.1061,0,0,18.4167,225.8622,0.0418,0,4624,0.0329,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,0.608,1.9146,0.8915,147.3669,29.9566,113.3365,0,0.0757,80.6512,3468 +glare,m81,4.0,0.0973,0,0,18.4167,215.0984,0.0575,0,4624,0.0,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,0.0,0.0,1.0,147.3669,24.4799,87.6109,0,0.0683,80.6512,3468 +motion,m81,0.05,0.0824,,,18.4167,248.329,0.0066,0,4624,0.2594,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,115.9705,1230.6641,0.0,147.3669,21.1777,172.2277,1,0.0,80.6512,3468 +motion,m81,0.1,0.075,,,20.177,185.2555,0.0065,0,4624,0.2532,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,90.8016,932.3755,0.0,147.3669,13.0158,78.239,1,0.0,73.2436,3468 +motion,m81,0.15,0.0715,,,21.6833,162.4731,0.0064,0,4624,0.2482,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,74.5604,838.0192,0.0,147.3669,10.6781,55.7441,1,0.0,68.7633,3468 +motion,m81,0.2,0.1348,,,6.1964,149.7156,0.0063,1,4624,0.2436,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,61.0138,779.2995,0.0,147.3669,60.4364,602.349,1,0.0,131.6028,3468 +motion,m81,0.3,0.0796,0,0,7.3171,133.3995,0.0061,1,4624,0.2362,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,40.6753,700.8942,0.0,147.3669,10.5166,106.1274,0,0.0,110.9013,3468 +motion,m81,0.4,0.0604,0,0,23.0,122.0986,0.0059,0,4624,0.2305,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,28.1083,641.7262,0.0,147.3669,6.4136,34.659,0,0.0,67.2093,3468 +motion,m81,0.5,,0,0,,112.6844,0.0057,,4624,0.2256,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,19.3946,585.005,0.0,147.3669,,,0,,,3468 +motion,m81,0.7,,0,0,,97.0746,0.0053,,4624,0.2171,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,10.6449,475.6263,0.0,147.3669,,,0,,,3468 +motion,m81,1.0,,0,0,,77.6137,0.0047,,4624,0.207,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,6.2968,328.0461,0.0,147.3669,,,0,,,3468 +scale,m81,48,0.0925,,,20.9228,308.7311,0.0063,0,1506,0.2623,5.5527,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,1819.5091,1522.6377,0.0,47.9962,1090.2398,222.9356,1,0.0,23.1142,1130 +scale,m81,40,0.0843,,,15.9591,300.0219,0.0062,0,1255,0.2617,5.5488,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,2142.981,1507.5205,0.0,39.9969,1125.2347,282.4556,1,0.0,25.1555,941 +scale,m81,32,0.0816,0,0,15.5521,530.9807,0.006,0,1004,0.261,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,2757.5118,2757.5118,0.0,31.9975,1407.4973,190.0799,0,0.0,20.5452,753 +scale,m81,26,0.0848,,,18.3043,714.4758,0.0058,0,816,0.2577,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,3491.516,1036.0409,0.0,26.0059,1961.0818,81.536,1,0.0,14.1985,612 +scale,m81,22,0.2684,,,5.75,913.8496,0.0056,0,690,0.2571,5.5556,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,4104.5222,730.9369,0.0,21.9903,3903.2287,964.5552,1,0.0,24.0,518 +scale,m81,18,0.2034,,,7.7778,1216.117,0.0054,0,565,0.2534,5.5536,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,4712.211,486.5723,0.0,18.0066,4326.1784,407.2765,1,0.0,17.9682,424 +scale,m81,15,0.2607,,,6.7452,1567.2259,0.0049,0,471,0.2524,5.5464,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,5607.3511,334.7985,0.0,15.0108,9160.3012,492.5917,1,0.0,15.9661,353 +scale,m81,12,,0,0,,2109.5981,0.0044,,377,0.2484,5.5356,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,6780.4416,193.0744,0.0,12.015,,,0,,,282 +scale,m81,10,,0,0,,2656.6876,0.0041,,314,0.2422,5.5385,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,8060.8334,126.5594,0.0,10.0072,,,0,,,235 +scale,m81,8,,0,0,,3356.3739,0.004,,251,0.2355,5.5429,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,9585.798,51.9302,0.0,7.9994,,,0,,,188 +scale,m81,6,,0,0,,3975.0021,0.0031,,188,0.2261,5.5503,24be340afbeed9abb72cfdec437809eeef1b9e4d1ed821a5b3b1a190e62bd9f7,10261.4959,26.0403,0.0,5.9916,,,0,,,141 diff --git a/experiments/meter-closeup/src/meter_eval/__init__.py b/experiments/meter-closeup/src/meter_eval/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/experiments/meter-closeup/src/meter_eval/analyze.py b/experiments/meter-closeup/src/meter_eval/analyze.py new file mode 100644 index 000000000..64d5558af --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/analyze.py @@ -0,0 +1,271 @@ +"""Turn the sweep rows into read-rate curves, retake thresholds and second-pass gains. + +Writes results/sweep_rows.csv (merged rows, no recognized text) and results/sweep.md. + +Thresholds: walk each degradation of a photo from mild to severe and find its break, the +first level from which it never reads again. The retake threshold is the check value at or +below which 95% (or 80%) of photos have broken, so a photo scoring above it reads for 95% +of photos. +Isolated failures before the break (a read fails, then succeeds at a harsher level) are +recognizer noise that no photo check can prevent; they are counted separately. AUC is over +every degraded read: the chance a random successful read scores better than a failed one. +""" + +import csv +import json +from collections import defaultdict + +import numpy as np + +from meter_eval.degrade import LEVELS +from meter_eval.paths import MANIFEST, RESULTS_DIR +from meter_eval.stats import auc +from meter_eval.sweep import SWEEP_DIR + +FAMILY_NAMES = { + "blur": "Gaussian blur, σ as a fraction of the number's line height", + "motion": "Horizontal motion blur, streak length as a fraction of line height", + "scale": "Downscaled so the number's line is this many pixels tall", + "glare": "White glare patch on the number, peak opacity", + "edge": "Right frame edge this many line heights past the number (negative cuts it)", +} + +UNITS = { + "blur": "σ / line height", + "motion": "streak / line height", + "scale": "line height px", + "glare": "peak opacity", + "edge": "line heights", +} + +# (column, higher is better, description). "label" columns are measured on the number's true +# box from the undegraded read, which the phone does not have; they bound what a perfect +# locator would allow. For the edge family that box is clipped to the frame, so its gap is 0 +# whenever the edge cuts the number, by construction. "top candidate" columns use the +# number-finding ranking's first pick on the degraded read, which the phone can compute. +# "whole photo" columns need no locator. +CHECKS = { + "blur": [ + ("label_lap_var_32", True, "label sharpness, resized to a 32 px line"), + ("lap_var_32", True, "top-candidate sharpness, 32 px line"), + ("global_lap_var", True, "whole-photo sharpness at up to 1024 px"), + ], + "motion": [ + ("label_lap_var_32", True, "label sharpness, resized to a 32 px line"), + ("lap_var_32", True, "top-candidate sharpness, 32 px line"), + ("global_lap_var", True, "whole-photo sharpness at up to 1024 px"), + ], + "scale": [ + ("label_text_height_px", True, "label line height in pixels"), + ("text_height_px", True, "top-candidate line height in pixels"), + ], + "glare": [ + ("label_saturated", False, "label share of pixels ≥ 250"), + ("saturated", False, "top-candidate share of pixels ≥ 250"), + ("label_contrast", True, "label RMS contrast"), + ("global_saturated", False, "whole-photo share of pixels ≥ 250"), + ], + "edge": [ + ("label_edge_margin", True, "label gap to the frame edge (clipped box)"), + ("edge_margin", True, "top-candidate gap to the frame edge, in line heights"), + ], +} + + +def load_rows() -> list[dict]: + """Sweep rows scored against each photo's current label; stale rows are ignored.""" + with MANIFEST.open() as handle: + current = {r["id"]: r["number_hmac"] for r in csv.DictReader(handle)} + rows = [] + for path in sorted(SWEEP_DIR.glob("rows-*.jsonl")): + rows += [json.loads(line) for line in path.open()] + rows = [r for r in rows if current.get(r["id"]) == r.get("label_hmac")] + keys = [(r["id"], r["family"], r["level"]) for r in rows] + if len(keys) != len(set(keys)): + raise SystemExit("duplicate sweep rows; delete DATA_DIR/sweep and run `make sweep`") + return rows + + +def write_rows(rows: list[dict]) -> None: + fields = sorted( + {k for r in rows for k in r}, key=lambda k: (k not in ("id", "family", "level"), k) + ) + with (RESULTS_DIR / "sweep_rows.csv").open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields, restval="") + writer.writeheader() + writer.writerows(sorted(rows, key=lambda r: (r["id"], r["family"]))) + + +def curve_table(rows: list[dict]) -> str: + out = [] + for family, levels in LEVELS.items(): + by_level = defaultdict(list) + for r in rows: + if r["family"] == family: + by_level[r["level"]].append(r["ok"]) + cells = [ + (f"{level:g}", f"{sum(v) / len(v):.0%} ({len(v)})") + for level in levels + if (v := by_level.get(level)) + ] + out.append(f"**{FAMILY_NAMES[family]}**\n") + out.append("| Level | " + " | ".join(c[0] for c in cells) + " |") + out.append("|---|" + "---|" * len(cells)) + out.append("| Read correctly (photos) | " + " | ".join(c[1] for c in cells) + " |\n") + return "\n".join(out) + + +def break_values(rows: list[dict], family: str, column: str, higher: bool): + """Per photo, the check's value at its break (see module docstring). + + Photos that read at the harshest level have no break and contribute no value. Also + returns the number of isolated failures before a break, and the reads they came from. + """ + order = {level: i for i, level in enumerate(LEVELS[family])} + worst = float("-inf") if higher else float("inf") + by_photo = defaultdict(list) + for r in rows: + if r["family"] == family: + by_photo[r["id"]].append(r) + breaks, never, isolated, before = [], 0, 0, 0 + for photo_rows in by_photo.values(): + photo_rows.sort(key=lambda r: order[r["level"]]) + succeeded = [i for i, r in enumerate(photo_rows) if r["ok"]] + start = succeeded[-1] + 1 if succeeded else 0 + isolated += sum(1 for r in photo_rows[:start] if not r["ok"]) + before += start + if start == len(photo_rows): + never += 1 + continue + breaks.append(photo_rows[start].get(column, worst)) + return breaks, never, isolated, before + + +def thresholds(rows: list[dict]) -> list[dict]: + """Every check's 95% and 80% retake thresholds, median break and AUC, per degradation.""" + found = [] + for family, checks in CHECKS.items(): + # The level itself: larger is harsher except for scale and edge. + level_check = ("level", family in ("scale", "edge"), f"degradation level ({UNITS[family]})") + for column, higher, description in [level_check, *checks]: + worst = float("-inf") if higher else float("inf") + breaks, never, _, _ = break_values(rows, family, column, higher) + # Photos that never broke count as breaking below every observed value. + padded = breaks + [worst] * never + cuts = { + share: float( + np.percentile(padded, share if higher else 100 - share, method="nearest") + ) + for share in (95, 80) + } + subset = [r for r in rows if r["family"] == family] + # No detected digit line means the phone found no number: the worst score. + values = [r.get(column, worst) for r in subset] + score = auc([v if higher else -v for v in values], [bool(r["ok"]) for r in subset]) + found.append( + { + "family": family, + "column": column, + "higher": higher, + "description": description, + "cuts": cuts, + "median": float(np.median(breaks)), + "auc": None if column == "level" else score, + } + ) + return found + + +def fmt_cut(value: float, higher: bool) -> str: + # A photo fails at its break value and reads only strictly beyond it, so the threshold + # value itself calls for a retake. + return f"{'≤' if higher else '≥'} {value:.3g}" if np.isfinite(value) else "never" + + +def break_table(rows: list[dict], found: list[dict]) -> str: + out = [ + "| Degradation | Check | Retake when (95% of photos) | Retake when (80%) | Median break | AUC |", + "|---|---|---|---|---|---|", + ] + for t in found: + out.append( + f"| {t['family']} | {t['description']} | {fmt_cut(t['cuts'][95], t['higher'])} | " + f"{fmt_cut(t['cuts'][80], t['higher'])} | {t['median']:.3g} | " + f"{'–' if t['auc'] is None else f'{t["auc"]:.2f}'} |" + ) + notes = [] + for family in CHECKS: + _, never, isolated, before = break_values(rows, family, "level", True) + photos = len({r["id"] for r in rows if r["family"] == family}) + notes.append( + f"- {family}: {photos} photos, {never} never broke; {isolated} of {before} reads " + f"above the break failed anyway ({isolated / before:.1%})." + ) + return "\n".join(out) + "\n\n" + "\n".join(notes) + "\n" + + +def false_retake_table(found: list[dict]) -> str: + """How many real, undegraded photos that read correctly each threshold would reject.""" + with (RESULTS_DIR / "clean_per_image.csv").open() as handle: + good = [r for r in csv.DictReader(handle) if r["number_accurate"] == "1"] + out = [ + "| Check | Rejected at the 95% threshold | Rejected at the 80% threshold |", + "|---|---|---|", + ] + for t in found: + if t["column"] not in good[0] or t["column"] == "level": + continue + values = [float(r[t["column"]]) for r in good if r[t["column"]] != ""] + cells = [] + for share in (95, 80): + cut, higher = t["cuts"][share], t["higher"] + rejected = sum(1 for v in values if (v <= cut if higher else v >= cut)) + cells.append(f"{rejected}/{len(values)}") + out.append(f"| {t['description']} ({t['family']}) | {cells[0]} | {cells[1]} |") + return "\n".join(out) + "\n" + + +def second_pass_table(rows: list[dict]) -> str: + out = [ + "| Degradation | Failed reads | Crop re-read recovers | Crop 2x re-read recovers | Either |", + "|---|---|---|---|---|", + ] + for family in LEVELS: + failed = [r for r in rows if r["family"] == family and not r["ok"]] + if not failed: + continue + crop = sum(r.get("crop", 0) for r in failed) + crop2 = sum(r.get("crop_x2", 0) for r in failed) + either = sum(max(r.get("crop", 0), r.get("crop_x2", 0)) for r in failed) + n = len(failed) + out.append( + f"| {family} | {n} | {crop} ({crop / n:.0%}) | {crop2} ({crop2 / n:.0%}) | " + f"{either} ({either / n:.0%}) |" + ) + return "\n".join(out) + "\n" + + +def main() -> None: + rows = load_rows() + write_rows(rows) + images = len({r["id"] for r in rows}) + found = thresholds(rows) + text = "\n".join( + [ + f"Swept photos: {images}; degraded reads: {len(rows)}.\n", + "### Read rate by degradation level\n", + curve_table(rows), + "### Retake thresholds from each photo's break point\n", + break_table(rows, found), + "### Real photos that read correctly but a threshold would reject\n", + false_retake_table(found), + "### Second pass on failed reads\n", + second_pass_table(rows), + ] + ) + (RESULTS_DIR / "sweep.md").write_text(text) + print(text) + + +if __name__ == "__main__": + main() diff --git a/experiments/meter-closeup/src/meter_eval/clean.py b/experiments/meter-closeup/src/meter_eval/clean.py new file mode 100644 index 000000000..a3708e8e3 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/clean.py @@ -0,0 +1,126 @@ +"""Q1: read every usable photo as downloaded, with three Vision configurations. + +Writes results/clean_per_image.csv (booleans and check values, no recognized text) and +results/clean.md. Raw recognizer output, which contains meter numbers, stays in the data dir. +""" + +import csv +import json + +from PIL import Image + +from meter_eval.locate import top_candidate +from meter_eval.match import class_read, number_boxes +from meter_eval.ocr import CONFIGS, PRIMARY, Reader +from meter_eval.paths import DATA_DIR, MANIFEST, RESULTS_DIR +from meter_eval.quality import device_checks, gray, region_checks, tallest_digit_line, union_box +from meter_eval.stats import share + + +def usable_rows() -> list[dict]: + with MANIFEST.open() as handle: + return [row for row in csv.DictReader(handle) if row["usable"] == "yes"] + + +def evaluate(row: dict, results: dict[str, dict]) -> dict: + out = { + "id": row["id"], + "number_agreed": row["number_agreed"], + "number_agreed_strict": row["number_agreed_strict"], + "class_kind": row["class_kind"], + } + has_number = bool(row["number_hmac"]) + for config, result in results.items(): + lines = result["lines"] + if has_number: + length = int(row["number_len"]) + strict = number_boxes(lines, row["number_hmac"], length, lenient=False) + lenient = number_boxes(lines, row["number_hmac_lenient"], length, lenient=True) + out[f"number_{config}"] = int(strict is not None) + out[f"number_lenient_{config}"] = int(lenient is not None) + if config == PRIMARY and strict is not None: + out["number_box"] = json.dumps([round(v, 5) for v in union_box(strict)]) + # Is the tallest line with 4+ digits, the phone's guess at the label, the number? + out["tallest_is_number"] = int(tallest_digit_line(lines)["box"] in strict) + if row["class_label"]: + out[f"class_{config}"] = int(class_read(lines, row["class_label"])) + return out + + +def run() -> list[dict]: + raw_path = DATA_DIR / "ocr" / "clean.jsonl" + raw_path.parent.mkdir(parents=True, exist_ok=True) + rows = [] + with Reader() as reader, raw_path.open("w") as raw: + for row in usable_rows(): + path = DATA_DIR / "images" / f"{row['id']}.jpg" + # Barcodes feed the number-finding ranking that picks the phone-side box. + results = { + config: reader.read(path, config, barcodes=config == PRIMARY) for config in CONFIGS + } + for result in results.values(): + raw.write(json.dumps(result) + "\n") + out = evaluate(row, results) + with Image.open(path) as image: + g = gray(image) + guess = top_candidate(results[PRIMARY]) + checks = device_checks(g, guess and guess["box"]) + out |= {k: round(v, 4) for k, v in checks.items()} + if "number_box" in out: + located = region_checks(g, json.loads(out["number_box"])) + out |= {f"label_{k}": round(v, 4) for k, v in located.items()} + out["width"], out["height"] = g.shape[1], g.shape[0] + rows.append(out) + print(row["id"], out.get(f"number_{PRIMARY}", "-"), flush=True) + return rows + + +def rate(rows: list[dict], key: str) -> str: + values = [int(r[key]) for r in rows if r.get(key, "") != ""] + return share(sum(values), len(values)) + + +def table(rows: list[dict]) -> str: + agreed = [r for r in rows if r["number_agreed"] == "yes"] + strict = [r for r in rows if r["number_agreed_strict"] == "yes"] + with_number = [r for r in rows if r["number_agreed"]] + ansi = [r for r in rows if r["class_kind"] == "ansi_class"] + rating = [r for r in rows if r["class_kind"] == "current_rating"] + lines = [ + "| What was read | Photos | " + " | ".join(f"`{c}`" for c in CONFIGS) + " |", + "|---|---|" + "---|" * len(CONFIGS), + ] + groups = [ + ("Meter number, exact", "number_{}", agreed, "both readers agree"), + ("Meter number, O→0 and I/L→1 allowed", "number_lenient_{}", agreed, "both readers agree"), + ("Meter number, exact", "number_{}", strict, "readers' main numbers identical"), + ("Meter number, exact", "number_{}", with_number, "all labelled"), + ("US class label (CL200, 200 CL, CL20)", "class_{}", ansi, "all labelled"), + ("Current rating (e.g. 10(60)A)", "class_{}", rating, "all labelled"), + ] + for name, key, subset, which in groups: + cells = [rate(subset, key.format(c)) for c in CONFIGS] + lines.append(f"| {name} | {which} ({len(subset)}) | " + " | ".join(cells) + " |") + return "\n".join(lines) + "\n" + + +def main() -> None: + rows = run() + RESULTS_DIR.mkdir(exist_ok=True) + fields = sorted({k for r in rows for k in r}, key=lambda k: (k != "id", k)) + with (RESULTS_DIR / "clean_per_image.csv").open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields, restval="") + writer.writeheader() + writer.writerows(rows) + located = [r for r in rows if "tallest_is_number" in r] + hits = sum(r["tallest_is_number"] for r in located) + text = table(rows) + ( + f"\nOn {hits} of {len(located)} photos whose number was read, the tallest line with 4 or" + " more digits was the meter number.\n" + ) + (RESULTS_DIR / "clean.md").write_text(text) + print(text) + + +if __name__ == "__main__": + main() diff --git a/experiments/meter-closeup/src/meter_eval/commons.py b/experiments/meter-closeup/src/meter_eval/commons.py new file mode 100644 index 000000000..08879840e --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/commons.py @@ -0,0 +1,206 @@ +"""Find and download openly licensed meter photos from Wikimedia Commons. + +`find` walks the meter category tree plus a few brand searches and writes every +reusable-license image to candidates.csv in the data directory, for screening by eye. +`fetch` downloads the images listed in the committed manifest.csv. +""" + +import argparse +import csv +import io +import json +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from PIL import Image, ImageOps + +from meter_eval.paths import DATA_DIR, MANIFEST + +API = "https://commons.wikimedia.org/w/api.php" +USER_AGENT = "house-scanning-meter-eval/0.1 (https://github.com/SamGu-NRX/house-scanning)" +ROOT_CATEGORY = "Category:Electricity meters (kWh)" +SEARCHES = [ + "electric meter Itron", + "electric meter Landis", + "electric meter Sensus", + "electric meter Elster", + "electric meter Aclara", + "electric meter General Electric", + "smart meter house", + "electric meter closeup", + "watthour meter", +] +# Stored images are capped at this width, a standard Commons thumbnail step. iPhone close-ups +# are 4032 px wide; the shared disk cannot hold originals of 20+ MB each. +MAX_WIDTH = 3840 + +REUSABLE = re.compile(r"^(CC0|Public domain|PD|CC BY(-SA)? [0-9.]+)", re.IGNORECASE) + + +def api_get(**params) -> dict: + params = {**params, "format": "json", "formatversion": "2"} + # POST: 50 long file titles overflow the URL length limit of a GET. + request = urllib.request.Request( + API, data=urllib.parse.urlencode(params).encode(), headers={"User-Agent": USER_AGENT} + ) + for attempt in range(4): + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + except OSError: + if attempt == 3: + raise + time.sleep(2 + 3 * attempt) + raise AssertionError("unreachable") + + +def walk_category(root: str, max_depth: int) -> set[str]: + files: set[str] = set() + seen: set[str] = set() + frontier = [(root, 0)] + while frontier: + category, depth = frontier.pop() + if category in seen: + continue + seen.add(category) + cont: dict = {} + while True: + data = api_get( + action="query", + list="categorymembers", + cmtitle=category, + cmlimit="500", + cmtype="file|subcat", + **cont, + ) + for member in data["query"]["categorymembers"]: + if member["ns"] == 6: + files.add(member["title"]) + elif member["ns"] == 14 and depth < max_depth: + frontier.append((member["title"], depth + 1)) + if "continue" not in data: + break + cont = data["continue"] + print(f"{len(seen)} categories, {len(files)} files under {root}", file=sys.stderr) + return files + + +def search_files(query: str, limit: int = 200) -> set[str]: + data = api_get(action="query", list="search", srsearch=query, srnamespace="6", srlimit=limit) + return {hit["title"] for hit in data["query"]["search"]} + + +def image_info(titles: list[str]) -> list[dict]: + rows = [] + for start in range(0, len(titles), 50): + batch = titles[start : start + 50] + data = api_get( + action="query", + titles="|".join(batch), + prop="imageinfo", + iiprop="url|size|mime|extmetadata", + iiurlwidth=str(MAX_WIDTH), + ) + for page in data["query"]["pages"]: + info = (page.get("imageinfo") or [None])[0] + if not info or info.get("mime") not in ("image/jpeg", "image/png"): + continue + meta = info.get("extmetadata", {}) + + def field(name: str, meta=meta) -> str: + value = meta.get(name, {}).get("value", "") + return re.sub(r"<[^>]+>", "", str(value)).strip() + + # Originals up to MAX_WIDTH; wider ones as a MAX_WIDTH thumbnail. + wide = info["width"] > MAX_WIDTH and "thumburl" in info + rows.append( + { + "title": page["title"], + "page_url": info["descriptionurl"], + "image_url": (info["thumburl"] if wide else info["url"]).split("?")[0], + "width": info["width"], + "height": info["height"], + "license": field("LicenseShortName"), + "license_url": field("LicenseUrl"), + "author": field("Artist"), + "description": field("ImageDescription")[:200], + } + ) + return rows + + +def find(args: argparse.Namespace) -> None: + titles = walk_category(ROOT_CATEGORY, args.depth) + for query in SEARCHES: + titles |= search_files(query) + rows = [ + row + for row in image_info(sorted(titles)) + if REUSABLE.match(row["license"]) and min(row["width"], row["height"]) >= args.min_side + ] + out = DATA_DIR / "candidates.csv" + with out.open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + print(f"{len(rows)} reusable candidates of {len(titles)} files -> {out}") + + +def download(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + for attempt in range(4): + try: + with urllib.request.urlopen(request, timeout=120) as response: + return response.read() + except urllib.error.HTTPError as error: + if error.code != 429 or attempt == 3: + raise + time.sleep(30 * (attempt + 1)) + raise AssertionError("unreachable") + + +def save_upright(data: bytes, dest: Path) -> None: + """Apply EXIF rotation and save plain RGB, so Vision and the checks see the same pixels.""" + with Image.open(io.BytesIO(data)) as image: + ImageOps.exif_transpose(image).convert("RGB").save(dest, quality=95) + + +def fetch(args: argparse.Namespace) -> None: + source = Path(args.source) if args.source else MANIFEST + with source.open() as handle: + rows = list(csv.DictReader(handle)) + images = DATA_DIR / "images" + images.mkdir(parents=True, exist_ok=True) + for row in rows: + dest = images / f"{row['id']}.jpg" + if dest.exists(): + continue + save_upright(download(row["image_url"]), dest) + print(f"fetched {row['id']}", flush=True) + time.sleep(1.0) + print(f"{len(rows)} images in {images}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(required=True) + p_find = sub.add_parser("find", help="list reusable candidate images") + p_find.add_argument("--depth", type=int, default=3) + p_find.add_argument("--min-side", type=int, default=800) + p_find.set_defaults(func=find) + p_fetch = sub.add_parser("fetch", help="download the images in manifest.csv") + p_fetch.add_argument( + "--source", help="CSV with id and image_url columns (default manifest.csv)" + ) + p_fetch.set_defaults(func=fetch) + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/experiments/meter-closeup/src/meter_eval/degrade.py b/experiments/meter-closeup/src/meter_eval/degrade.py new file mode 100644 index 000000000..89e241f84 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/degrade.py @@ -0,0 +1,99 @@ +"""Controlled degradations of a real photo, scaled to the height of its meter-number line. + +Levels are fractions of the number's line height h in pixels, so one level means the same +thing on a 3840 px close-up and a 1024 px snapshot. The label box is normalized [x, y, w, h] +with a top-left origin. +""" + +import numpy as np +from PIL import Image, ImageFilter +from scipy.ndimage import uniform_filter1d + +# Each family: the levels swept, in the unit its function takes. +LEVELS = { + # Gaussian blur, sigma as a fraction of h (out-of-focus). + "blur": [0.02, 0.04, 0.06, 0.08, 0.10, 0.13, 0.16, 0.20, 0.25, 0.30], + # Horizontal motion blur, streak length as a fraction of h (hand shake). + "motion": [0.05, 0.10, 0.15, 0.20, 0.30, 0.40, 0.50, 0.70, 1.0], + # Downscale the whole photo so the number's line is this many pixels tall. + "scale": [48, 40, 32, 26, 22, 18, 15, 12, 10, 8, 6], + # Specular glare: a white Gaussian patch centred on the number, blended in with this peak + # opacity (values above 1 widen the fully white core). + "glare": [0.3, 0.5, 0.7, 0.85, 1.0, 1.5, 2.5, 4.0], + # Framing: crop so the number's right edge sits this many line heights inside the frame + # (negative values cut into the number). + "edge": [2.0, 1.0, 0.5, 0.25, 0.1, 0.0, -0.5, -1.0, -2.0], +} + + +def gaussian_blur(image: Image.Image, box: list[float], fraction: float) -> Image.Image: + sigma = fraction * box[3] * image.height + return image.filter(ImageFilter.GaussianBlur(radius=sigma)) + + +def motion_blur(image: Image.Image, box: list[float], fraction: float) -> Image.Image: + length = max(1, round(fraction * box[3] * image.height)) + pixels = np.asarray(image, dtype=np.float32) + streaked = uniform_filter1d(pixels, size=length, axis=1, mode="nearest") + return Image.fromarray(np.clip(streaked + 0.5, 0, 255).astype(np.uint8)) + + +def downscale(image: Image.Image, box: list[float], line_px: float) -> Image.Image | None: + """None when the photo's number is already smaller than the target.""" + scale = line_px / (box[3] * image.height) + if scale >= 1: + return None + size = (max(1, round(image.width * scale)), max(1, round(image.height * scale))) + return image.resize(size, Image.LANCZOS) + + +def glare(image: Image.Image, box: list[float], strength: float) -> Image.Image: + """Blend toward white with opacity min(1, strength * gaussian), centred on the box. + + The patch's sigma is 35% of the box width, so at strength 1 the middle of the number + is fully white and its ends are partly washed out. + """ + pixels = np.asarray(image, dtype=np.float32) + height, width = pixels.shape[:2] + cx = (box[0] + box[2] / 2) * width + cy = (box[1] + box[3] / 2) * height + sigma = 0.35 * box[2] * width + ys, xs = np.ogrid[:height, :width] + weight = np.exp(-(((xs - cx) ** 2 + (ys - cy) ** 2) / (2 * sigma**2))) + alpha = np.minimum(1.0, strength * weight)[..., None] + washed = pixels * (1 - alpha) + 255.0 * alpha + return Image.fromarray(np.clip(washed + 0.5, 0, 255).astype(np.uint8)) + + +def edge_crop( + image: Image.Image, box: list[float], margin: float +) -> tuple[Image.Image, list[float]] | None: + """Crop the right side so the box's right edge sits `margin` line heights from the frame. + + Returns the crop and the box in the crop's normalized coordinates, or None when the photo + has less room to the right than the requested margin. + """ + line_px = box[3] * image.height + right = (box[0] + box[2]) * image.width + margin * line_px + if right > image.width or right <= box[0] * image.width: + return None + cropped = image.crop((0, 0, round(right), image.height)) + new_box = [box[0] * image.width / cropped.width, box[1], 0.0, box[3]] + new_box[2] = min(1.0, (box[0] + box[2]) * image.width / cropped.width) - new_box[0] + return cropped, new_box + + +def apply(family: str, image: Image.Image, box: list[float], level: float): + """Degraded image and the number's box in its coordinates, or None if not applicable.""" + if family == "blur": + return gaussian_blur(image, box, level), box + if family == "motion": + return motion_blur(image, box, level), box + if family == "scale": + scaled = downscale(image, box, level) + return None if scaled is None else (scaled, box) + if family == "glare": + return glare(image, box, level), box + if family == "edge": + return edge_crop(image, box, level) + raise ValueError(f"unknown degradation family {family!r}; expected one of {list(LEVELS)}") diff --git a/experiments/meter-closeup/src/meter_eval/fieldtest.py b/experiments/meter-closeup/src/meter_eval/fieldtest.py new file mode 100644 index 000000000..3ae22da08 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/fieldtest.py @@ -0,0 +1,88 @@ +"""Score a folder of real close-ups against the retake checks and the number finder. + + uv run python -m meter_eval.fieldtest PHOTO_DIR --number "12 345 678" + +For each photo: whether Vision read the number, where the number-finding ranking put it, the +checks the app would run, and whether they would have asked for a retake. As in the app, the +checks use the ranking's top candidate, not the true number; --number only scores the +outcome. The summary counts the two costly outcomes: a retake asked for a photo that read (a +wasted retake) and a photo accepted that did not read (a re-request later). The meter number +is taken from the command line and is never written to disk. +""" + +import argparse +import subprocess +from pathlib import Path + +from PIL import Image, ImageOps + +from meter_eval import retake +from meter_eval.locate import candidates, ranked, top_candidate +from meter_eval.match import core, digest, normalize, number_boxes +from meter_eval.ocr import Reader +from meter_eval.quality import gray + +SUFFIXES = {".jpg", ".jpeg", ".png", ".heic"} + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawTextHelpFormatter + ) + parser.add_argument("folder", type=Path) + parser.add_argument("--number", required=True, help="the meter number as printed") + args = parser.parse_args() + + target, length = digest(normalize(args.number)), len(normalize(args.number)) + target_core = digest(core(args.number)) + photos = sorted(p for p in args.folder.iterdir() if p.suffix.lower() in SUFFIXES) + if not photos: + raise SystemExit(f"no .jpg, .png or .heic photos in {args.folder}") + + upright = args.folder / ".upright.jpg" + print( + "| Photo | Read | Rank | Top candidate is the number | Top line px | Sharpness | " + "Retake because |" + ) + print("|---|---|---|---|---|---|---|") + wasted = missed = 0 + with Reader() as reader: + for photo in photos: + source = photo + if photo.suffix.lower() == ".heic": + # PIL cannot decode HEIC; macOS sips can, and keeps the orientation tag. + source = args.folder / ".converted.jpg" + subprocess.run( + ["sips", "-s", "format", "jpeg", str(photo), "--out", str(source)], + check=True, + capture_output=True, + ) + with Image.open(source) as image: + ImageOps.exif_transpose(image).convert("RGB").save(upright, quality=95) + result = reader.read(upright, barcodes=True) + read = number_boxes(result["lines"], target, length, lenient=False) is not None + order = ranked(candidates(result)) + rank = next((i + 1 for i, c in enumerate(order) if digest(c) == target_core), None) + with Image.open(upright) as image: + g = gray(image) + guess = top_candidate(result) + box = guess and guess["box"] + why = retake.reasons(g, box) + wasted += read and bool(why) + missed += not read and not why + height_px = f"{retake.line_height_px(box, g.shape[0]):.0f}" if box else "–" + print( + f"| {photo.name} | {'yes' if read else 'no'} | {rank or '–'} | " + f"{'yes' if rank == 1 else 'no'} | {height_px} | " + f"{retake.whole_photo_sharpness(g):.1f} | {', '.join(why) or '–'} |" + ) + upright.unlink(missing_ok=True) + (args.folder / ".converted.jpg").unlink(missing_ok=True) + print( + f"\n{len(photos)} photos; {wasted} retakes asked for photos that read; " + f"{missed} photos accepted that did not read." + ) + + +if __name__ == "__main__": + main() diff --git a/experiments/meter-closeup/src/meter_eval/labels.py b/experiments/meter-closeup/src/meter_eval/labels.py new file mode 100644 index 000000000..041a9553c --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/labels.py @@ -0,0 +1,204 @@ +"""Merge the two readers' labels into the committed manifest.csv. + +Both readers are AI models that read each photo by eye (reader 1 is the experiment author, +reader 2 an independent pass that never saw reader 1's labels). The label is always reader +1's number, so reader 1 chose which printed identifier counts as the meter number. Two +agreement rules are recorded: + +- number_agreed (the loose rule the headline results use): reader 1's number, normalized, + equals any number reader 2 listed (its main number or one of its other numbers), or one + contains the other and the shorter has at least 6 characters (a barcode line such as + ACG012345678 printing the plate number 12 345 678). Neither reader may have doubted a + character. +- number_agreed_strict: both readers' main numbers are identical after normalization and + both readers were sure. + +A person's answers from the review page (`review.py`), when present: "keep" makes a number +agreed under both rules; "fix" replaces the number with the person's reading but does not +by itself make it agreed. + +Plaintext labels stay in the data directory. The manifest keeps keyed digests (match.py), +and identifier_digests.txt keeps the digest of every identifier either reader transcribed, +so `leakcheck` can find one in the repository without the plaintext. +""" + +import argparse +import csv +import re +import secrets + +from meter_eval.match import core, digest, lenient_digest, normalize, normalize_class +from meter_eval.paths import DATA_DIR, EXPERIMENT_DIR, KEY_PATH, MANIFEST + +READER1 = DATA_DIR / "labels_reader1.csv" +READER2 = [DATA_DIR / "labels_reader2a.csv", DATA_DIR / "labels_reader2b.csv"] +HUMAN = DATA_DIR / "labels_human.csv" +SOURCES = DATA_DIR / "shortlist.csv" +IDENTIFIER_DIGESTS = EXPERIMENT_DIR / "identifier_digests.txt" +# Reader 2 marked these "unsure" for a reason other than the characters of reader 1's number, +# per its notes: which of two printed numbers is the meter's ID (m25, m30, m33, m38), or a +# second number cut off by the frame (m63). It transcribed reader 1's number identically. +DOUBT_NOT_ABOUT_CHARACTERS = {"m25", "m30", "m33", "m38", "m63"} + +FIELDS = [ + "id", + "title", + "page_url", + "image_url", + "license", + "license_url", + "author", + "usable", + "number_hmac", + "number_hmac_lenient", + "number_len", + "number_core_hmac", + "number_core_len", + "number_agreed", + "number_agreed_strict", + "human_check", + "class_label", + "class_kind", + "class_agreed", + "notes", +] + + +def read_csv(path) -> dict[str, dict]: + with path.open() as handle: + return {row["id"]: row for row in csv.DictReader(handle)} + + +def numbers_of(row: dict) -> set[str]: + values = [row["meter_number"], *row.get("other_numbers", "").split(";")] + # Reader 2 annotated some numbers in parentheses, e.g. "12345678 (FlexNet)". + values = [re.sub(r"\(.*?\)", "", v) for v in values] + return {normalize(v) for v in values if v.strip() and v not in ("NONE", "EXCLUDE")} + + +def same_number(a: str, b: str) -> bool: + short, long = sorted((a, b), key=len) + return a == b or (len(short) >= 6 and short in long) + + +def class_kind(label: str) -> str: + if not label or label == "NONE": + return "" + return "ansi_class" if "CL" in label.upper() else "current_rating" + + +def scrub(note: str) -> str: + """Drop digit runs from free-text notes so no meter number reaches the repository.""" + return re.sub(r"\d[\d ]{3,}\d", "#", note) + + +def build() -> list[dict]: + first, sources = read_csv(READER1), read_csv(SOURCES) + second = {k: v for path in READER2 for k, v in read_csv(path).items()} + human = read_csv(HUMAN) if HUMAN.exists() else {} + rows = [] + for image_id, r1 in first.items(): + r2 = second[image_id] + source = sources[image_id] + number = r1["meter_number"] + usable = "no: " + scrub(r1["notes"]) if number == "EXCLUDE" else "yes" + row = {key: source[key] for key in ("title", "page_url", "image_url", "license")} + row |= { + "id": image_id, + "license_url": source["license_url"], + "author": " ".join(source["author"].split())[:120], + "usable": usable, + "notes": scrub(r1["notes"]), + } + verdict = human.get(image_id, {}).get("verdict", "") + if verdict == "fix": + number = human[image_id]["number"] + if number not in ("EXCLUDE", "NONE"): + r2_sure = r2["number_sure"] == "sure" or image_id in DOUBT_NOT_ABOUT_CHARACTERS + agreed = ( + any(same_number(normalize(number), other) for other in numbers_of(r2)) + and r1["number_sure"] == "sure" + and r2_sure + ) + strict = ( + normalize(number) == normalize(r2["meter_number"]) + and r1["number_sure"] == "sure" + and r2["number_sure"] == "sure" + ) + kept = verdict == "keep" + row |= { + "number_hmac": digest(normalize(number)), + "number_hmac_lenient": lenient_digest(number), + "number_len": len(normalize(number)), + "number_core_hmac": digest(core(number)), + "number_core_len": len(core(number)), + "number_agreed": "yes" if (agreed and verdict != "fix") or kept else "no", + "number_agreed_strict": "yes" if (strict and verdict != "fix") or kept else "no", + "human_check": verdict, + } + label = r1["class_label"] + if usable == "yes" and label and label != "NONE": + agreed = ( + normalize_class(label) == normalize_class(r2["class_label"]) + and r1["class_sure"] == "sure" + and r2["class_sure"] == "sure" + ) + row |= { + "class_label": label, + "class_kind": class_kind(label), + "class_agreed": "yes" if agreed else "no", + } + rows.append(row) + return rows + + +def known_numbers() -> set[str]: + """Every identifier either reader transcribed, normalized, with and without its prefix.""" + first = read_csv(READER1) + second = {k: v for path in READER2 for k, v in read_csv(path).items()} + found = set() + for row in [*first.values(), *second.values()]: + for value in numbers_of(row): + for form in (value, core(value)): + if len(form) >= 5 and sum(ch.isdigit() for ch in form) >= 5: + found.add(form) + return found + + +def new_key() -> None: + if KEY_PATH.exists(): + raise SystemExit( + f"{KEY_PATH} exists. Every digest in manifest.csv depends on it; delete it by hand " + "only if you mean to re-key, then rebuild the manifest." + ) + KEY_PATH.parent.mkdir(parents=True, exist_ok=True) + KEY_PATH.write_text(secrets.token_hex(32) + "\n") + KEY_PATH.chmod(0o600) + print(f"wrote a new key to {KEY_PATH}; now run `python -m meter_eval.labels`") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--new-key", action="store_true", help="create the HMAC key instead") + if parser.parse_args().new_key: + new_key() + return + rows = build() + IDENTIFIER_DIGESTS.write_text( + "".join(f"{d}\n" for d in sorted({digest(n) for n in known_numbers()})) + ) + with MANIFEST.open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=FIELDS, restval="") + writer.writeheader() + writer.writerows(rows) + usable = [r for r in rows if r["usable"] == "yes"] + agreed = [r for r in usable if r.get("number_agreed") == "yes"] + strict = [r for r in usable if r.get("number_agreed_strict") == "yes"] + print( + f"{len(rows)} images, {len(usable)} usable, {len(agreed)} with an agreed meter number " + f"({len(strict)} under the strict rule)" + ) + + +if __name__ == "__main__": + main() diff --git a/experiments/meter-closeup/src/meter_eval/leakcheck.py b/experiments/meter-closeup/src/meter_eval/leakcheck.py new file mode 100644 index 000000000..6268c01ac --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/leakcheck.py @@ -0,0 +1,62 @@ +"""Fail if a transcribed meter identifier appears in any tracked file of this experiment. + +identifier_digests.txt holds the keyed digest (match.digest) of every identifier either +reader transcribed, with and without a letter prefix. Every run of 5 to 24 letters and digits +with at least 5 digits in a tracked file is digested and looked up, so the check needs the +key but no plaintext, and runs in CI with the key from the METER_HMAC_KEY secret. +""" + +import csv +import re +import subprocess + +from meter_eval.match import digest, normalize +from meter_eval.paths import EXPERIMENT_DIR + +DIGESTS = EXPERIMENT_DIR / "identifier_digests.txt" +SHORTEST, LONGEST = 5, 24 + + +def pieces(name: str, text: str) -> list[str]: + text = re.sub(r"\b[0-9a-f]{64}\b", "", text) # the digests themselves + if name.endswith(".csv"): + # Cell by cell; decimal measurements such as 62.5152 are not identifiers. + cells = [c for row in csv.reader(text.splitlines()) for c in row] + return [c for c in cells if not re.fullmatch(r"-?\d+\.\d+", c)] + return re.findall(r"[A-Za-z0-9][A-Za-z0-9 .\-]*", text) + + +def windows(token: str): + for length in range(SHORTEST, min(LONGEST, len(token)) + 1): + for start in range(len(token) - length + 1): + part = token[start : start + length] + if sum(ch.isdigit() for ch in part) >= SHORTEST: + yield part + + +def main() -> None: + known = set(DIGESTS.read_text().split()) + tracked = subprocess.run( + ["git", "ls-files", "--cached", "--others", "--exclude-standard", "."], + cwd=EXPERIMENT_DIR, + capture_output=True, + text=True, + check=True, + ).stdout.split() + leaks = [] + for name in tracked: + if name.endswith(".lock") or name == DIGESTS.name: + continue + text = (EXPERIMENT_DIR / name).read_text(errors="ignore") + tokens = {normalize(p) for p in pieces(name, text)} + seen = {part for token in tokens for part in windows(token)} + leaks += [(name, len(part)) for part in seen if digest(part) in known] + for name, length in sorted(leaks): + print(f"{name}: a transcribed identifier ({length} characters)") + if leaks: + raise SystemExit(f"{len(leaks)} meter identifiers in tracked files; use synthetic ones") + print(f"no transcribed identifier in {len(tracked)} files") + + +if __name__ == "__main__": + main() diff --git a/experiments/meter-closeup/src/meter_eval/locate.py b/experiments/meter-closeup/src/meter_eval/locate.py new file mode 100644 index 000000000..160e51325 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/locate.py @@ -0,0 +1,326 @@ +"""Q4 and Q5: find the meter number among everything Vision reads on a close-up. + +Candidates are the digit-bearing tokens of each recognized line plus decoded barcode +payloads. A candidate is right when its core (see `match.core`) equals the labelled number's +core, so "NO. 12345678" and "ABC 123456" count, while a longer string that merely contains +the number, such as a barcode line "*XYZ1234567890…*", does not. + +The rules and the ranking were written from the odd-numbered photos only (`--split dev`) +and committed before the even-numbered photos were scored. + +Writes results/locate.md and results/locate_per_image.csv; raw Vision output stays in the +data directory because it contains meter numbers. +""" + +import argparse +import csv +import json +import re + +from meter_eval.match import core, digest, number_read +from meter_eval.ocr import Reader +from meter_eval.paths import DATA_DIR, MANIFEST, RESULTS_DIR +from meter_eval.stats import share + +# A label word in front of the number: No., Nr., №, #, S/N, Serial. +# "#:" is how Vision renders the Taiwanese label 電號: in front of the number. +KEYWORD = re.compile(r"^\s*(?:N[O0R]\.?|N°|№|#\s*:|S/?N|SERIAL\s*#?)\s*[.:#]?\s*", re.IGNORECASE) +# Lines that describe the meter rather than identify it: ratings, voltages, constants, forms. +SPEC = re.compile( + r"\d\s*\(\s*\d+\s*\)|\d\s*V\b|HZ|\bKH\b|KH\s*\d|\bKT\b|\bTA\s*=?\s*\d|\bCL\s*\d|\d\s*CL\b|" + r"\bFM\s*\d|FORM|TYPE|IMP|KWH|REV|U/|°C|AMP|VOLT|\bPHASE|\bWIRE|\d+/\d+|\bCA\s*\d", + re.IGNORECASE, +) +DIGIT_GROUP = re.compile(r"^[\d.,\-]+$") + + +def digits(text: str) -> int: + return sum(ch.isdigit() for ch in text) + + +def tokens(text: str) -> list[str]: + """Digit-bearing tokens of one line, with a leading label word removed. + + Runs of purely numeric groups merge ("12 345 678" is one token); other groups stand + alone ("123ABC456789"). The whole remaining line is also a token when it has at most four + groups, so a number printed as "1 ABC00 1234 5678" survives. + """ + rest = KEYWORD.sub("", text).strip() + groups = rest.split() + found, run = [], [] + for group in groups: + if DIGIT_GROUP.match(group): + run.append(group) + continue + if run: + found.append(" ".join(run)) + run = [] + found.append(group) + if run: + found.append(" ".join(run)) + if 1 < len(groups) <= 4: + found.append(rest) + unique = list(dict.fromkeys(found)) + return [t for t in unique if digits(t) >= 4] + + +def payload_tokens(barcodes: list[dict]) -> set[str]: + """Cores of the digit-bearing parts of every decoded payload.""" + parts = set() + for barcode in barcodes: + for part in re.split(r"[\s;*,{}]+", barcode.get("payload") or ""): + if digits(part) >= 4: + parts.add(core(part)) + return parts + + +def candidates(result: dict) -> list[dict]: + """Every candidate with the features the rules use. + + A text token that equals a barcode payload part is confirmed. One that sits inside a + longer payload part (at least 6 characters) is a partial read of it, as when Vision drops + a digit or a prefix printed apart; the candidate then takes the payload's full string. + """ + payloads = payload_tokens(result.get("barcodes") or []) + found = [] + for line in result["lines"]: + text = line["text"] + _, _, w, h = line["box"] + for token in tokens(text): + token_core = core(token) + confirmed = token_core in payloads + if not confirmed and len(token_core) >= 6: + longer = [p for p in payloads if token_core in p] + if longer: + token_core, confirmed = min(longer, key=len), True + found.append( + { + "core": token_core, + "box": line["box"], + "barcode_confirmed": confirmed, + "keyword": bool(KEYWORD.match(text)) + and not re.match(r"\s*CAT", text, re.IGNORECASE), + "alone": core(KEYWORD.sub("", text)) == token_core, + "spec": bool(SPEC.search(text)), + "vertical": h > w, + "zeros": set(token_core) <= {"0"}, + "length_ok": 6 <= len(token_core) <= 14 and digits(token_core) >= 5, + "height": h, + } + ) + return found + + +def near_barcode(candidate: dict, barcodes: list[dict]) -> bool: + """The candidate's line sits within 1.5 line heights above or below a barcode it overlaps.""" + x, y, w, h = candidate["box"] + for b in barcodes: + bx, by, bw, bh = b["box"] + overlaps = min(x + w, bx + bw) - max(x, bx) > 0.3 * min(w, bw) + gap = max(by - (y + h), y - (by + bh)) + if overlaps and gap <= 1.5 * h: + return True + return False + + +def score(c: dict) -> float: + """Hand-set ranking score, chosen on the odd-numbered photos. + + Breaking ties toward taller print beat breaking them toward lower lines or not at all + (top-1 27 vs 26 vs 23 of 38 there). + """ + return ( + 8 * c["barcode_confirmed"] + + 3 * c["keyword"] + + 2 * c["alone"] + + 1 * c["length_ok"] + - 6 * c["spec"] + - 4 * c["vertical"] + - 6 * c["zeros"] + + c["height"] # tie-break toward larger print + ) + + +def top_candidate(result: dict) -> dict | None: + """The phone's best guess at the number's line: the highest-scoring candidate.""" + return best(candidates(result)) + + +def ranked(found: list[dict]) -> list[str]: + """Candidate cores, best first, each core once.""" + order = sorted(found, key=score, reverse=True) + return list(dict.fromkeys(c["core"] for c in order)) + + +def tallest(pool: list[dict]) -> dict | None: + return max(pool, key=lambda c: c["height"], default=None) + + +def best(pool: list[dict]) -> dict | None: + return max(pool, key=score, default=None) + + +def rules(found: list[dict], barcodes: list[dict]) -> dict[str, dict | None]: + """Each rule's pick, or None when the rule does not fire.""" + decoded = [b for b in barcodes if b.get("payload")] + plausible = [c for c in found if not c["spec"] and not c["vertical"] and not c["zeros"]] + confirmed = best([c for c in found if c["barcode_confirmed"]]) + return { + "tallest digit line (phase 1)": tallest(found), + "barcode confirms a text line": confirmed, + "line nearest a decoded barcode": tallest( + [c for c in plausible if near_barcode(c, decoded)] + ), + "after a No./Nr./#: keyword": tallest([c for c in plausible if c["keyword"]]), + "alone on its line, 6-14 characters, not a spec line": tallest( + [c for c in plausible if c["alone"] and c["length_ok"]] + ), + "combined: barcode, else keyword": confirmed + or tallest([c for c in plausible if c["keyword"] and c["length_ok"]]), + } + + +def scan(rows: list[dict]) -> dict[str, dict]: + raw_path = DATA_DIR / "ocr" / "scan.jsonl" + results = {} + with Reader() as reader, raw_path.open("w") as raw: + for row in rows: + result = reader.read(DATA_DIR / "images" / f"{row['id']}.jpg", barcodes=True) + raw.write(json.dumps(result) + "\n") + results[row["id"]] = result + return results + + +def barcode_stats(row: dict, result: dict) -> dict: + barcodes = result.get("barcodes") or [] + payloads = [b["payload"] for b in barcodes if b.get("payload")] + target = row["number_core_hmac"] + length = int(row["number_core_len"] or 0) + has_number = any( + digest(core(p)[i : i + length]) == target + for p in payloads + for i in range(len(core(p)) - length + 1) + ) + return { + "barcode_found": int(bool(barcodes)), + "barcode_decoded": int(bool(payloads)), + "barcode_has_number": int(has_number) if row["number_hmac"] else "", + } + + +def evaluate(row: dict, result: dict) -> dict: + out = { + "id": row["id"], + "split": "dev" if int(row["id"][1:]) % 2 else "test", + "us_style": int(row["class_kind"] == "ansi_class"), + "strict": int(row["number_agreed_strict"] == "yes"), + } + out |= barcode_stats(row, result) + if row["number_agreed"] != "yes": + return out + out["read"] = int( + number_read(result["lines"], row["number_hmac"], int(row["number_len"]), lenient=False) + ) + found = candidates(result) + target = row["number_core_hmac"] + order = ranked(found) + hits = [i for i, c in enumerate(order) if digest(c) == target] + out["rank"] = hits[0] + 1 if hits else "" + out["candidates"] = len(order) + for name, pick in rules(found, result.get("barcodes") or []).items(): + out[name] = "" if pick is None else int(digest(pick["core"]) == target) + return out + + +def barcode_table(rows: list[dict]) -> str: + out = [ + "| Photos | Barcode found | Decoded | Decode contains the meter number |", + "|---|---|---|---|", + ] + for title, subset in ( + ("All usable", rows), + ("US-style (CL class label)", [r for r in rows if r["us_style"]]), + ): + found = [r for r in subset if r["barcode_found"]] + decoded = [r for r in found if r["barcode_decoded"]] + with_number = [r for r in found if r["barcode_has_number"] != ""] + contains = [r for r in with_number if r["barcode_has_number"]] + out.append( + f"| {title}: {len(subset)} | {len(found)} | {len(decoded)} | " + f"{len(contains)} of {len(with_number)} with a labelled number |" + ) + return "\n".join(out) + "\n" + + +def rule_table(rows: list[dict], names: list[str]) -> str: + out = [] + groups = { + "odd-numbered photos, used to write the rules": lambda r: r["split"] == "dev", + "even-numbered photos, held out": lambda r: r["split"] == "test", + "all photos": lambda r: True, + "all photos, strict labels (readers' main numbers identical)": lambda r: r["strict"], + # Exploratory: chosen after the held-out scoring, as a description of Base's market. + "exploratory: US-style meters (CL class label), odd-numbered": lambda r: ( + r["us_style"] and r["split"] == "dev" + ), + "exploratory: US-style meters (CL class label), held out": lambda r: ( + r["us_style"] and r["split"] == "test" + ), + } + if {r["split"] for r in rows} == {"dev"}: + groups = {"odd-numbered photos, used to write the rules": groups["all photos"]} + for title, keep in groups.items(): + subset = [r for r in rows if "read" in r and keep(r)] + read = [r for r in subset if r["read"]] + out.append(f"**{title}: {len(subset)} with an agreed number, {len(read)} read**\n") + out.append("| Rule | Picks | Precision | Recall over read photos |") + out.append("|---|---|---|---|") + for name in names: + fired = [r for r in read if r[name] != ""] + right = sum(r[name] for r in fired) + out.append( + f"| {name} | {len(fired)} | {share(right, len(fired))} | " + f"{share(right, len(read))} |" + ) + for k in (1, 3): + hit = sum(1 for r in read if r["rank"] != "" and r["rank"] <= k) + out.append(f"| ranking, top {k} | {len(read)} | – | {share(hit, len(read))} |") + present = sum(1 for r in read if r["rank"] != "") + out.append( + f"| number is any candidate (ceiling) | {len(read)} | – | " + f"{share(present, len(read))} |\n" + ) + return "\n".join(out) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--split", choices=["dev", "all"], default="all") + args = parser.parse_args() + with MANIFEST.open() as handle: + manifest = [r for r in csv.DictReader(handle) if r["usable"] == "yes"] + if args.split == "dev": + manifest = [r for r in manifest if int(r["id"][1:]) % 2] + results = scan(manifest) + rows = [evaluate(row, results[row["id"]]) for row in manifest] + names = list(rules([], [])) # rule names, in table order + text = "\n".join( + [ + "### Barcodes\n", + barcode_table(rows), + "### Finding the number\n", + rule_table(rows, names), + ] + ) + suffix = "" if args.split == "all" else "_dev" + fields = sorted({k for r in rows for k in r}, key=lambda k: (k not in ("id", "split"), k)) + with (RESULTS_DIR / f"locate_per_image{suffix}.csv").open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields, restval="") + writer.writeheader() + writer.writerows(rows) + (RESULTS_DIR / f"locate{suffix}.md").write_text(text) + print(text) + + +if __name__ == "__main__": + main() diff --git a/experiments/meter-closeup/src/meter_eval/match.py b/experiments/meter-closeup/src/meter_eval/match.py new file mode 100644 index 000000000..13aaf85f2 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/match.py @@ -0,0 +1,116 @@ +"""Decide whether recognizer output contains a labelled string. + +Meter numbers are compared through an HMAC-SHA256 digest of their normalized form, because +CONTRIBUTING.md forbids committing meter numbers. An unkeyed hash of a short number can be +reversed by trying every number of that length, so the digest is keyed: without the key in +KEY_PATH (or the METER_HMAC_KEY environment variable, as in CI) the committed digests do not +reveal the numbers, and scoring a read needs the key. +""" + +import functools +import hashlib +import hmac +import os +import re +from collections.abc import Sequence + +from meter_eval.paths import KEY_PATH + +# Lenient mode maps letters Vision commonly returns for digits. Applied to both the label and +# the output, so it can only add matches. +DIGIT_FIXUPS = str.maketrans({"O": "0", "I": "1", "L": "1"}) + + +def normalize(text: str) -> str: + """Uppercase and keep only A-Z and 0-9.""" + return re.sub(r"[^A-Z0-9]", "", text.upper()) + + +def normalize_class(text: str) -> str: + """Uppercase, keep A-Z, 0-9 and parentheses, and read × as X. + + Separators are dropped because meters print them inconsistently and Vision returns them + inconsistently ("0.1-10" read as "0.1,-10", "20(60)A" as "20(60)/A"); parentheses stay + so 10(60)A and 1060A remain different. This rule was loosened after inspecting the first + run's misses, which were all separator or × differences. + """ + return re.sub(r"[^A-Z0-9()]", "", text.upper().replace("×", "X")) + + +def core(text: str) -> str: + """The normalized text without a leading run of letters before a digit. + + "NO. 12345678", "ABC 123456" and "XYZW123456" become 12345678, 123456 and 123456, so a + utility prefix or a label word printed apart from the number does not decide a match. + """ + return re.sub(r"^[A-Z]+(?=\d)", "", normalize(text)) + + +@functools.cache +def key() -> bytes: + if os.environ.get("METER_HMAC_KEY"): + return bytes.fromhex(os.environ["METER_HMAC_KEY"]) + if not KEY_PATH.exists(): + raise FileNotFoundError( + f"No HMAC key: METER_HMAC_KEY is unset (in CI, the repository secret of that name) " + f"and {KEY_PATH} does not exist. Ask the team for the key, or start over with " + "`python -m meter_eval.labels --new-key` and rebuild the manifest from the " + "plaintext labels." + ) + return bytes.fromhex(KEY_PATH.read_text().strip()) + + +def digest(normalized: str) -> str: + return hmac.new(key(), normalized.encode(), hashlib.sha256).hexdigest() + + +def rows_of_text(lines: Sequence[dict]) -> list[tuple[str, list[list[float]]]]: + """Each line's (text, [box]), plus lines sharing a row joined left to right. + + Vision sometimes splits one printed number into two observations ("123 45" and "678"). + Two boxes share a row when their vertical overlap is at least half the shorter box. + """ + texts = [(line["text"], [line["box"]]) for line in lines] + rows: list[list[dict]] = [] + for line in sorted(lines, key=lambda item: item["box"][1]): + _, y, _, h = line["box"] + for row in rows: + _, ry, _, rh = row[0]["box"] + overlap = min(y + h, ry + rh) - max(y, ry) + if overlap >= 0.5 * min(h, rh): + row.append(line) + break + else: + rows.append([line]) + for row in rows: + if len(row) > 1: + ordered = sorted(row, key=lambda item: item["box"][0]) + texts.append((" ".join(item["text"] for item in ordered), [i["box"] for i in ordered])) + return texts + + +def number_boxes( + lines: Sequence[dict], target_digest: str, length: int, lenient: bool +) -> list[list[float]] | None: + """Boxes of the first line or joined row containing the labelled number, else None.""" + for text, boxes in rows_of_text(lines): + candidate = normalize(text) + if lenient: + candidate = candidate.translate(DIGIT_FIXUPS) + for start in range(len(candidate) - length + 1): + if digest(candidate[start : start + length]) == target_digest: + return boxes + return None + + +def number_read(lines: Sequence[dict], target_digest: str, length: int, lenient: bool) -> bool: + return number_boxes(lines, target_digest, length, lenient) is not None + + +def class_read(lines: Sequence[dict], label: str) -> bool: + target = normalize_class(label) + return any(target in normalize_class(text) for text, _ in rows_of_text(lines)) + + +def lenient_digest(number: str) -> str: + return digest(normalize(number).translate(DIGIT_FIXUPS)) diff --git a/experiments/meter-closeup/src/meter_eval/ocr.py b/experiments/meter-closeup/src/meter_eval/ocr.py new file mode 100644 index 000000000..5fd8b4067 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/ocr.py @@ -0,0 +1,63 @@ +"""Stream read requests through one long-lived meterocr process.""" + +import json +import subprocess +from pathlib import Path +from typing import Self + +from meter_eval.paths import EXPERIMENT_DIR + +BINARY = EXPERIMENT_DIR / "meterocr" / ".build" / "release" / "meterocr" + +# The three configurations Q1 compares. The app would use one; the others show what the +# choice costs. +CONFIGS = { + "accurate": {"level": "accurate", "language_correction": False}, + "accurate_lc": {"level": "accurate", "language_correction": True}, + "fast": {"level": "fast", "language_correction": False}, +} +PRIMARY = "accurate" + + +class Reader: + def __init__(self) -> None: + if not BINARY.exists(): + raise FileNotFoundError( + f"{BINARY} is missing; build it with `swift build -c release --package-path meterocr`" + ) + self.process = subprocess.Popen( + [str(BINARY)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1 + ) + + def read( + self, + path: Path, + config: str = PRIMARY, + crop: list[float] | None = None, + barcodes: bool = False, + ) -> dict: + request = {"path": str(path), **CONFIGS[config], "barcodes": barcodes} + if crop is not None: + request["crop"] = crop + assert self.process.stdin and self.process.stdout + self.process.stdin.write(json.dumps(request) + "\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + if not line: + raise RuntimeError(f"meterocr exited with {self.process.wait()} while reading {path}") + result = json.loads(line) + if result.get("error"): + raise RuntimeError(f"meterocr failed on {path}: {result['error']}") + result["config"] = config + return result + + def close(self) -> None: + if self.process.stdin: + self.process.stdin.close() + self.process.wait(timeout=30) + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc) -> None: + self.close() diff --git a/experiments/meter-closeup/src/meter_eval/paths.py b/experiments/meter-closeup/src/meter_eval/paths.py new file mode 100644 index 000000000..d626fde60 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/paths.py @@ -0,0 +1,12 @@ +import os +from pathlib import Path + +EXPERIMENT_DIR = Path(__file__).resolve().parents[2] +MANIFEST = EXPERIMENT_DIR / "manifest.csv" +RESULTS_DIR = EXPERIMENT_DIR / "results" +# Images and raw recognizer output stay outside the repository. +DATA_DIR = Path(os.environ.get("METER_DATA", Path.home() / "house-scanning-data" / "meter")) +# The label review page shows plaintext meter numbers, so it lives outside the repository too. +REVIEW_DIR = DATA_DIR.parent / "meter-closeup" +# The key for the meter-number digests in manifest.csv; never committed. +KEY_PATH = REVIEW_DIR / "hmac.key" diff --git a/experiments/meter-closeup/src/meter_eval/quality.py b/experiments/meter-closeup/src/meter_eval/quality.py new file mode 100644 index 000000000..17fe6bea7 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/quality.py @@ -0,0 +1,125 @@ +"""Cheap photo checks a phone could run on a close-up before accepting it. + +Images are grayscale float arrays of 0-255 values; boxes are normalized [x, y, w, h] with a +top-left origin, as meterocr returns them. +""" + +import numpy as np +from PIL import Image + +SATURATED = 250 + + +def gray(image: Image.Image) -> np.ndarray: + return np.asarray(image.convert("L"), dtype=np.float64) + + +def laplacian_variance(g: np.ndarray) -> float: + """Variance of the 4-neighbour Laplacian over interior pixels (OpenCV's usual blur score).""" + if g.shape[0] < 3 or g.shape[1] < 3: + return 0.0 + lap = g[:-2, 1:-1] + g[2:, 1:-1] + g[1:-1, :-2] + g[1:-1, 2:] - 4 * g[1:-1, 1:-1] + return float(lap.var()) + + +def saturated_fraction(g: np.ndarray, level: int = SATURATED) -> float: + return float((g >= level).mean()) if g.size else 0.0 + + +def rms_contrast(g: np.ndarray) -> float: + return float(g.std() / 255.0) if g.size else 0.0 + + +def crop_box(g: np.ndarray, box: list[float], pad: float = 0.0) -> np.ndarray: + """Crop a normalized top-left-origin [x, y, w, h] box, padded by `pad` box heights.""" + height, width = g.shape + x, y, w, h = box + margin = pad * h + x0 = round(max(0.0, x - margin * height / width) * width) + x1 = round(min(1.0, x + w + margin * height / width) * width) + y0 = round(max(0.0, y - margin) * height) + y1 = round(min(1.0, y + h + margin) * height) + return g[y0:y1, x0:x1] + + +def union_box(boxes: list[list[float]]) -> list[float]: + x0 = min(b[0] for b in boxes) + y0 = min(b[1] for b in boxes) + x1 = max(b[0] + b[2] for b in boxes) + y1 = max(b[1] + b[3] for b in boxes) + return [x0, y0, x1 - x0, y1 - y0] + + +def edge_gap(box: list[float], width: int, height: int) -> float: + """Distance from the box to the nearest frame edge, in line heights.""" + line = box[3] * height + gaps = [ + box[0] * width, + box[1] * height, + (1 - box[0] - box[2]) * width, + (1 - box[1] - box[3]) * height, + ] + return min(gaps) / line + + +def region_checks(g: np.ndarray, box: list[float]) -> dict[str, float]: + """Checks on a text box: its height in pixels, and sharpness, glare and contrast inside it. + + lap_var_32 measures sharpness after resizing the box so its line is 32 px tall, which + makes one threshold usable for labels photographed at any size. + """ + region = crop_box(g, box, pad=0.25) + return { + "text_height_px": box[3] * g.shape[0], + "lap_var": laplacian_variance(region), + "lap_var_32": laplacian_variance(resample_to_height(g, box, 32)), + "saturated": saturated_fraction(region), + "contrast": rms_contrast(region), + } + + +def resample_to_height(g: np.ndarray, box: list[float], text_height: int) -> np.ndarray: + """The padded box region, resized so the text line is `text_height` pixels tall.""" + region = crop_box(g, box, pad=0.25) + line_px = box[3] * g.shape[0] + if region.size == 0 or line_px <= 0: + return region + scale = text_height / line_px + size = (max(1, round(region.shape[1] * scale)), max(1, round(region.shape[0] * scale))) + resized = Image.fromarray(region.astype(np.float32), mode="F").resize(size, Image.BILINEAR) + return np.asarray(resized, dtype=np.float64) + + +def digit_lines(lines: list[dict], min_digits: int = 4) -> list[dict]: + return [line for line in lines if sum(ch.isdigit() for ch in line["text"]) >= min_digits] + + +def tallest_digit_line(lines: list[dict]) -> dict | None: + """The phone's guess at the meter number: the tallest line with at least 4 digits.""" + return max(digit_lines(lines), key=lambda line: line["box"][3], default=None) + + +def device_checks(g: np.ndarray, box: list[float] | None) -> dict[str, float]: + """Checks a phone can compute without knowing the answer. + + `box` is the phone's guess at the number's line, the number-finding ranking's top + candidate (locate.top_candidate). With no candidate the region checks are absent, which + the analysis scores as the worst value: the app should treat that photo as failed. + """ + checks = { + "global_lap_var": laplacian_variance(downscale_long_side(g, 1024)), + "global_saturated": saturated_fraction(g), + } + if box is None: + return checks + height, width = g.shape + return checks | region_checks(g, box) | {"edge_margin": edge_gap(box, width, height)} + + +def downscale_long_side(g: np.ndarray, long_side: int) -> np.ndarray: + """Shrink so the long side is `long_side` pixels (bilinear, antialiased); never enlarge.""" + scale = long_side / max(g.shape) + if scale >= 1: + return g + size = (round(g.shape[1] * scale), round(g.shape[0] * scale)) + return np.asarray(Image.fromarray(g.astype(np.float32), mode="F").resize(size, Image.BILINEAR)) diff --git a/experiments/meter-closeup/src/meter_eval/retake.py b/experiments/meter-closeup/src/meter_eval/retake.py new file mode 100644 index 000000000..d2cc29038 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/retake.py @@ -0,0 +1,49 @@ +"""The retake checks the app should port: the ones that survive without knowing the answer. + +The phone cannot know where the true number is, so every check here uses either the whole +photo or the number-finding ranking's top candidate (locate.top_candidate). Checks measured +on the true number's box (results/sweep.md, "label" rows) are not ported: on the top candidate +the glare check has an AUC of 0.55 and the framing check 0.61, because a washed-out or cut +number usually stops being the top candidate. + +Every check runs on the full-resolution photo after EXIF rotation, converted to 8-bit luma +(L = 0.299 R + 0.587 G + 0.114 B, as PIL's "L" mode). `box` is normalized [x, y, w, h] with +a top-left origin, as meterocr returns Vision's boxes. Each threshold is the value at or below +which 95% of the 71 swept photos had stopped reading; see results/sweep.md, "Retake +thresholds from each photo's break point". +""" + +import numpy as np + +from meter_eval.quality import downscale_long_side, laplacian_variance + +# results/sweep.md, blur, "whole-photo sharpness at up to 1024 px", 95% column. AUC 0.96; +# rejects 0 of 75 good photos, whose lowest score is 58.7 (weak evidence: none is near 6.63). +MIN_SHARPNESS = 6.63 +# results/sweep.md, scale, "top-candidate line height in pixels", 95% column. AUC 0.86; +# rejects 2 of 75 good photos. On the true number's box the cut would be 12 px; the top +# candidate likely needs more because once the number is too small, a larger line takes +# its place. +MIN_TOP_LINE_PX = 33.9 + + +def line_height_px(box: list[float], image_height: int) -> float: + return box[3] * image_height + + +def whole_photo_sharpness(g: np.ndarray) -> float: + """Laplacian variance of the luma image shrunk to a 1024 px long side (bilinear, + antialiased). Photos already that small are used as they are.""" + return laplacian_variance(downscale_long_side(g, 1024)) + + +def reasons(g: np.ndarray, top_box: list[float] | None) -> list[str]: + """Why the app should ask for a retake; empty when the photo passes every check.""" + found = [] + if whole_photo_sharpness(g) <= MIN_SHARPNESS: + found.append("out of focus") + if top_box is None: + found.append("no number found") + elif line_height_px(top_box, g.shape[0]) <= MIN_TOP_LINE_PX: + found.append("number too small") + return found diff --git a/experiments/meter-closeup/src/meter_eval/review.py b/experiments/meter-closeup/src/meter_eval/review.py new file mode 100644 index 000000000..d86c2f5a3 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/review.py @@ -0,0 +1,339 @@ +"""A local page for a person to confirm the AI-made meter-number labels. + +`build` writes REVIEW_DIR/review.html with each whole photo, the number the AI readers +settled on, and below it a zoom on the line Vision read. The whole photo comes first so the +reviewer sees every identifier on the plate, not only the one Vision found. The reviewer +keeps the number (K) or types a correction (F, then Enter) and downloads the answers as CSV. +`ingest` copies that CSV to DATA_DIR/labels_human.csv, where `labels.py` picks it up. +Everything stays outside git, because the page shows plaintext meter numbers. +""" + +import argparse +import csv +import json +import shutil + +from PIL import Image + +from meter_eval.paths import DATA_DIR, MANIFEST, RESULTS_DIR, REVIEW_DIR + +HUMAN_LABELS = DATA_DIR / "labels_human.csv" + + +def crop_number(image: Image.Image, box: list[float]) -> Image.Image: + """The number's line with some context around it.""" + x, y, w, h = box + pad_x, pad_y = 0.3 * w, 1.5 * h + view = image.crop( + ( + round(max(0.0, x - pad_x) * image.width), + round(max(0.0, y - pad_y) * image.height), + round(min(1.0, x + w + pad_x) * image.width), + round(min(1.0, y + h + pad_y) * image.height), + ) + ) + view.thumbnail((720, 240)) + return view + + +def build() -> None: + with MANIFEST.open() as handle: + manifest = [r for r in csv.DictReader(handle) if r["usable"] == "yes"] + with (DATA_DIR / "labels_reader1.csv").open() as handle: + first = {r["id"]: r for r in csv.DictReader(handle)} + second = {} + for name in ("labels_reader2a.csv", "labels_reader2b.csv"): + with (DATA_DIR / name).open() as handle: + second |= {r["id"]: r for r in csv.DictReader(handle)} + with (RESULTS_DIR / "clean_per_image.csv").open() as handle: + boxes = {r["id"]: r["number_box"] for r in csv.DictReader(handle)} + + crops = REVIEW_DIR / "crops" + crops.mkdir(parents=True, exist_ok=True) + items = [] + for row in manifest: + image_id = row["id"] + source = DATA_DIR / "images" / f"{image_id}.jpg" + box = json.loads(boxes[image_id]) if boxes.get(image_id) else None + with Image.open(source) as image: + whole = image.copy() + whole.thumbnail((1400, 1050)) + whole.save(crops / f"{image_id}-whole.jpg", quality=85) + if box is not None: + crop_number(image, box).save(crops / f"{image_id}.jpg", quality=85) + number = first[image_id]["meter_number"] + other = second[image_id]["meter_number"] + items.append( + { + "id": image_id, + "zoom": box is not None, + "number": "" if number == "NONE" else number, + "agreed": row["number_agreed"] == "yes", + "other": "" if other in ("NONE", number) else other, + "note": first[image_id]["notes"], + "photo": source.as_uri(), + "page": row["page_url"], + } + ) + # Inside + + + +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(required=True) + sub.add_parser("build", help="write review.html and the crops").set_defaults( + func=lambda a: build() + ) + p_ingest = sub.add_parser("ingest", help="store the downloaded answers") + p_ingest.add_argument("csv") + p_ingest.set_defaults(func=lambda a: ingest(a.csv)) + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/experiments/meter-closeup/src/meter_eval/stats.py b/experiments/meter-closeup/src/meter_eval/stats.py new file mode 100644 index 000000000..3c8a1d6a4 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/stats.py @@ -0,0 +1,31 @@ +import math +from collections.abc import Sequence + + +def wilson(successes: int, n: int, z: float = 1.96) -> tuple[float, float]: + """95% Wilson score interval for a proportion.""" + if n == 0: + return (0.0, 1.0) + p = successes / n + denom = 1 + z * z / n + centre = (p + z * z / (2 * n)) / denom + half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom + return (max(0.0, centre - half), min(1.0, centre + half)) + + +def share(hits: int, n: int) -> str: + """ "hits/n = rate (95% Wilson interval)", or a dash when there is nothing to count.""" + if n == 0: + return "–" + low, high = wilson(hits, n) + return f"{hits}/{n} = {hits / n:.0%} ({low:.0%}–{high:.0%})" + + +def auc(scores: Sequence[float], labels: Sequence[bool]) -> float | None: + """Probability that a random positive scores above a random negative (ties count half).""" + pos = [s for s, y in zip(scores, labels, strict=True) if y] + neg = [s for s, y in zip(scores, labels, strict=True) if not y] + if not pos or not neg: + return None + wins = sum((p > n) + 0.5 * (p == n) for p in pos for n in neg) + return wins / (len(pos) * len(neg)) diff --git a/experiments/meter-closeup/src/meter_eval/sweep.py b/experiments/meter-closeup/src/meter_eval/sweep.py new file mode 100644 index 000000000..c2c0509a2 --- /dev/null +++ b/experiments/meter-closeup/src/meter_eval/sweep.py @@ -0,0 +1,148 @@ +"""Q2 and Q3: degrade each photo whose number read correctly, and find where reading breaks. + +Only photos with an agreed number that the primary configuration read undegraded are swept, +so every failure here is caused by the controlled degradation. The number's box comes from +that clean read, and the "label_" checks use it; the phone does not know that box, so every +unprefixed check uses the number-finding ranking's top candidate on the degraded read. When a +degraded read fails, two second passes run (Q3): Vision on a crop around that top candidate, +and on that crop upscaled 2x. + +Rows go to DATA_DIR/sweep/rows-.jsonl, each tagged with the digest of the label it was +scored against. A photo is swept again when its label changes; `python -m meter_eval.analyze` +merges the rows and ignores stale ones. Raw recognizer output, which contains meter numbers, +is not kept. +""" + +import argparse +import csv +import json +import os +import time + +from PIL import Image + +from meter_eval.degrade import LEVELS, apply +from meter_eval.locate import top_candidate +from meter_eval.match import digest as match_digest +from meter_eval.match import number_read +from meter_eval.ocr import Reader +from meter_eval.paths import DATA_DIR, MANIFEST, RESULTS_DIR +from meter_eval.quality import device_checks, edge_gap, gray, region_checks + +SWEEP_DIR = DATA_DIR / "sweep" + + +def crop_around(box: list[float], width: int, height: int) -> list[float]: + """The box padded by one line height on every side, clipped to the frame.""" + pad_x = box[3] * height / width + x0, y0 = max(0.0, box[0] - pad_x), max(0.0, box[1] - box[3]) + x1, y1 = min(1.0, box[0] + box[2] + pad_x), min(1.0, box[1] + 2 * box[3]) + return [x0, y0, x1 - x0, y1 - y0] + + +def second_pass( + reader: Reader, path, image: Image.Image, guess: dict | None, digest: str, length: int +) -> dict: + if guess is None: + return {"crop": 0, "crop_x2": 0} + crop = crop_around(guess["box"], image.width, image.height) + cropped = reader.read(path, crop=crop) + x0, y0 = crop[0] * image.width, crop[1] * image.height + region = image.crop( + ( + round(x0), + round(y0), + round(x0 + crop[2] * image.width), + round(y0 + crop[3] * image.height), + ) + ) + upscaled_path = SWEEP_DIR / f"upscaled-{os.getpid()}.jpg" + region.resize((region.width * 2, region.height * 2), Image.LANCZOS).save( + upscaled_path, quality=95 + ) + upscaled = reader.read(upscaled_path) + return { + "crop": int(number_read(cropped["lines"], digest, length, lenient=False)), + "crop_x2": int(number_read(upscaled["lines"], digest, length, lenient=False)), + } + + +def sweep_image(reader: Reader, row: dict, box: list[float]) -> list[dict]: + records = [] + digest, length = row["number_hmac"], int(row["number_len"]) + work_path = SWEEP_DIR / f"work-{os.getpid()}.jpg" + with Image.open(DATA_DIR / "images" / f"{row['id']}.jpg") as original: + original.load() + for family, levels in LEVELS.items(): + for level in levels: + applied = apply(family, original, box, level) + if applied is None: + continue + image, label_box = applied + image.save(work_path, quality=95) + result = reader.read(work_path, barcodes=True) + ok = number_read(result["lines"], digest, length, lenient=False) + g = gray(image) + guess = top_candidate(result) + record = { + "id": row["id"], + "label_hmac": digest, + "family": family, + "level": level, + "ok": int(ok), + "width": image.width, + "height": image.height, + "label_edge_margin": edge_gap(label_box, image.width, image.height), + } + record |= {f"label_{k}": v for k, v in region_checks(g, label_box).items()} + record |= device_checks(g, guess and guess["box"]) + if guess is not None: + record["guess_is_number"] = int( + match_digest(guess["core"]) == row["number_core_hmac"] + ) + if not ok: + record |= second_pass(reader, work_path, image, guess, digest, length) + records.append( + {k: round(v, 4) if isinstance(v, float) else v for k, v in record.items()} + ) + return records + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--shard", type=int, default=0) + parser.add_argument("--shards", type=int, default=1) + args = parser.parse_args() + + with MANIFEST.open() as handle: + manifest = {row["id"]: row for row in csv.DictReader(handle)} + with (RESULTS_DIR / "clean_per_image.csv").open() as handle: + clean = [r for r in csv.DictReader(handle) if r["number_accurate"] == "1"] + targets = [r for r in clean if manifest[r["id"]]["number_agreed"] == "yes"] + mine = targets[args.shard :: args.shards] + + SWEEP_DIR.mkdir(parents=True, exist_ok=True) + out_path = SWEEP_DIR / f"rows-{args.shard}.jsonl" + current = {r["id"]: manifest[r["id"]]["number_hmac"] for r in targets} + kept = [] + if out_path.exists(): + rows = [json.loads(line) for line in out_path.open()] + kept = [r for r in rows if current.get(r["id"]) == r.get("label_hmac")] + # Rows scored against an older label are dropped, so those photos are swept again. + out_path.write_text("".join(json.dumps(r) + "\n" for r in kept)) + done = {r["id"] for r in kept} + with Reader() as reader, out_path.open("a") as out: + for clean_row in mine: + if clean_row["id"] in done: + continue + started = time.time() + box = json.loads(clean_row["number_box"]) + # Written per image, so a rerun after an interruption never keeps half an image. + for record in sweep_image(reader, manifest[clean_row["id"]], box): + out.write(json.dumps(record) + "\n") + out.flush() + print(f"{clean_row['id']} swept in {time.time() - started:.0f}s", flush=True) + + +if __name__ == "__main__": + main() diff --git a/experiments/meter-closeup/tests/conftest.py b/experiments/meter-closeup/tests/conftest.py new file mode 100644 index 000000000..f021cc1df --- /dev/null +++ b/experiments/meter-closeup/tests/conftest.py @@ -0,0 +1,12 @@ +import pytest + +from meter_eval import match + + +@pytest.fixture(autouse=True) +def test_key(monkeypatch): + """Tests digest with a fixed key, never the real one.""" + monkeypatch.setenv("METER_HMAC_KEY", "00" * 32) + match.key.cache_clear() + yield + match.key.cache_clear() diff --git a/experiments/meter-closeup/tests/test_degrade.py b/experiments/meter-closeup/tests/test_degrade.py new file mode 100644 index 000000000..05aea4355 --- /dev/null +++ b/experiments/meter-closeup/tests/test_degrade.py @@ -0,0 +1,57 @@ +import numpy as np +import pytest +from PIL import Image + +from meter_eval.degrade import downscale, edge_crop, glare +from meter_eval.quality import edge_gap +from meter_eval.sweep import crop_around + +BOX = [0.25, 0.4, 0.5, 0.1] # on a 200 x 100 image: x 50-150, y 40-50, line height 10 px + + +def grey_image(width=200, height=100, value=100): + return Image.fromarray(np.full((height, width, 3), value, dtype=np.uint8)) + + +def test_downscale_hits_the_target_line_height(): + scaled = downscale(grey_image(), BOX, 5) + assert scaled.size == (100, 50) # 10 px line -> 5 px is a factor of 0.5 + + +def test_downscale_refuses_to_enlarge(): + assert downscale(grey_image(), BOX, 12) is None + + +def test_glare_whitens_the_box_centre_and_spares_far_pixels(): + out = np.asarray(glare(grey_image(), BOX, 1.0)) + assert out[45, 100, 0] == 255 # centre: opacity 1 + # Corner (0, 0) is 100 px from the centre along x and 45 along y; sigma is 35 px. + weight = np.exp(-(100**2 + 45**2) / (2 * 35**2)) + assert out[0, 0, 0] == round(100 * (1 - weight) + 255 * weight) + + +def test_edge_crop_places_the_right_edge_at_the_margin(): + cropped, box = edge_crop(grey_image(), BOX, 1.0) + assert cropped.size == (160, 100) # right edge 150 + 1 line (10 px) + assert box[0] * 160 == pytest.approx(50) + assert (box[0] + box[2]) * 160 == pytest.approx(150) + + +def test_edge_crop_with_negative_margin_cuts_the_box(): + cropped, box = edge_crop(grey_image(), BOX, -2.0) + assert cropped.size == (130, 100) + assert box[0] + box[2] == pytest.approx(1.0) + + +def test_edge_crop_needs_room_in_the_photo(): + assert edge_crop(grey_image(), BOX, 6.0) is None # 150 + 60 > 200 + + +def test_edge_gap_in_line_heights(): + # Gaps: left 50, top 40, right 50, bottom 50 px; nearest is 40 px = 4 line heights. + assert edge_gap(BOX, 200, 100) == pytest.approx(4.0) + + +def test_crop_around_pads_one_line_height(): + x, y, w, h = crop_around(BOX, 200, 100) + assert (x * 200, y * 100, w * 200, h * 100) == pytest.approx((40, 30, 120, 30)) diff --git a/experiments/meter-closeup/tests/test_leakcheck.py b/experiments/meter-closeup/tests/test_leakcheck.py new file mode 100644 index 000000000..1437896ca --- /dev/null +++ b/experiments/meter-closeup/tests/test_leakcheck.py @@ -0,0 +1,17 @@ +from meter_eval.leakcheck import pieces, windows + + +def test_windows_only_yield_runs_with_five_digits(): + parts = set(windows("AB12345")) + assert parts == {"B12345", "AB12345", "12345"} + + +def test_csv_cells_skip_decimal_measurements_but_keep_integers(): + text = "id,value,count\nm01,62.5152,1234567\n" + assert "62.5152" not in pieces("x.csv", text) + assert "1234567" in pieces("x.csv", text) + + +def test_digests_in_text_are_ignored(): + text = "number_hmac " + "a" * 64 + assert all("aaaa" not in p for p in pieces("x.md", text)) diff --git a/experiments/meter-closeup/tests/test_locate.py b/experiments/meter-closeup/tests/test_locate.py new file mode 100644 index 000000000..722ecebf7 --- /dev/null +++ b/experiments/meter-closeup/tests/test_locate.py @@ -0,0 +1,82 @@ +from meter_eval.locate import candidates, near_barcode, ranked, rules, tokens +from meter_eval.match import core + + +def line(text, box=(0.4, 0.5, 0.2, 0.03)): + return {"text": text, "box": list(box), "confidence": 1.0} + + +def test_core_drops_letters_before_the_first_digit(): + assert core("NO. 12345678") == "12345678" + assert core("ABC 123456") == "123456" + assert core("1 ABC00 1234 5678") == "1ABC0012345678" + assert core("#:00-01-2345-67") == "0001234567" + + +def test_tokens_merge_digit_groups_and_keep_mixed_groups(): + assert tokens("12 345 678") == ["12 345 678"] + assert tokens("NO. 12345678") == ["12345678"] + # A line of two to four groups is also offered whole. + assert tokens("Type: SCS1321") == ["SCS1321", "Type: SCS1321"] + # "1" and "ABC00" have fewer than 4 digits; the digit run and the whole line remain. + assert tokens("1 ABC00 1234 5678") == ["1234 5678", "1 ABC00 1234 5678"] + + +def test_tokens_skip_short_numbers(): + assert tokens("CL 200") == [] + + +def test_barcode_equal_to_a_text_token_confirms_it(): + result = { + "lines": [line("123ABC456789")], + "barcodes": [{"payload": "123ABC456789", "box": [0.4, 0.6, 0.2, 0.03]}], + } + [only] = candidates(result) + assert only["barcode_confirmed"] and only["core"] == "123ABC456789" + + +def test_partial_text_read_takes_the_barcodes_full_string(): + # Vision dropped the leading 1; the QR payload has the whole number. + result = { + "lines": [line("2345678")], + "barcodes": [{"payload": "X0YZ01234567;12345678", "box": [0.8, 0.3, 0.1, 0.05]}], + } + [only] = candidates(result) + assert only["core"] == "12345678" and only["barcode_confirmed"] + + +def test_short_token_inside_a_payload_is_not_confirmed(): + result = { + "lines": [line("12345")], + "barcodes": [{"payload": "9912345000", "box": [0.8, 0.3, 0.1, 0.05]}], + } + [only] = candidates(result) + assert not only["barcode_confirmed"] and only["core"] == "12345" + + +def test_spec_line_ranks_below_a_bare_number(): + result = { + "lines": [ + line("220V 30(200)A 60Hz", (0.3, 0.5, 0.3, 0.05)), + line("12345678", (0.4, 0.7, 0.2, 0.03)), + ], + "barcodes": [], + } + assert ranked(candidates(result))[0] == "12345678" + + +def test_near_barcode_needs_overlap_and_a_small_gap(): + barcode = [{"payload": "x", "box": [0.40, 0.60, 0.20, 0.03]}] + above = {"box": [0.42, 0.55, 0.15, 0.03]} # gap 0.02 <= 1.5 * 0.03 + far = {"box": [0.42, 0.40, 0.15, 0.03]} # gap 0.17 + beside = {"box": [0.70, 0.60, 0.10, 0.03]} # no horizontal overlap + assert near_barcode(above, barcode) + assert not near_barcode(far, barcode) + assert not near_barcode(beside, barcode) + + +def test_rules_do_not_fire_without_their_cue(): + picks = rules(candidates({"lines": [line("12345678")], "barcodes": []}), []) + assert picks["barcode confirms a text line"] is None + assert picks["after a No./Nr./#: keyword"] is None + assert picks["tallest digit line (phase 1)"]["core"] == "12345678" diff --git a/experiments/meter-closeup/tests/test_metrics.py b/experiments/meter-closeup/tests/test_metrics.py new file mode 100644 index 000000000..f4963856e --- /dev/null +++ b/experiments/meter-closeup/tests/test_metrics.py @@ -0,0 +1,138 @@ +import numpy as np +import pytest + +from meter_eval.match import ( + class_read, + digest, + lenient_digest, + normalize, + number_boxes, + number_read, + rows_of_text, +) +from meter_eval.quality import ( + crop_box, + laplacian_variance, + rms_contrast, + saturated_fraction, +) +from meter_eval.stats import auc, wilson + + +def line(text, x, y, w=0.1, h=0.05): + return {"text": text, "box": [x, y, w, h]} + + +# --- quality ----------------------------------------------------------------------------- + + +def test_laplacian_variance_of_flat_image_is_zero(): + assert laplacian_variance(np.full((5, 5), 128.0)) == 0.0 + + +def test_laplacian_variance_of_single_bright_pixel(): + # 5x5 zeros with one 1 in the centre; interior is the 3x3 block around it. + # Laplacian there: centre -4, its four neighbours 1, four corners 0. + # mean = 0, variance = (16 + 4) / 9. + g = np.zeros((5, 5)) + g[2, 2] = 1.0 + assert laplacian_variance(g) == pytest.approx(20 / 9) + + +def test_laplacian_variance_of_linear_ramp_is_zero(): + g = np.tile(np.arange(6, dtype=float) * 10, (6, 1)) + assert laplacian_variance(g) == 0.0 + + +def test_saturated_fraction_counts_pixels_at_or_above_level(): + g = np.array([[249.0, 250.0], [255.0, 0.0]]) + assert saturated_fraction(g) == 0.5 + + +def test_rms_contrast_of_half_black_half_white(): + g = np.array([[0.0, 255.0], [0.0, 255.0]]) + assert rms_contrast(g) == pytest.approx(0.5) + + +def test_crop_box_pads_by_box_height(): + g = np.arange(100 * 100, dtype=float).reshape(100, 100) + region = crop_box(g, [0.4, 0.4, 0.2, 0.1], pad=0.5) + # 0.5 box heights = 0.05 of image height = 5 px on every side (square image). + assert region.shape == (20, 30) + assert region[0, 0] == g[35, 35] + + +# --- matching ---------------------------------------------------------------------------- + + +def test_normalize_drops_everything_but_letters_and_digits(): + assert normalize("No. 12-345 678") == "NO12345678" + + +def test_number_found_inside_a_longer_line(): + lines = [line("ND. 1234567", 0.3, 0.6)] + assert number_read(lines, digest("1234567"), 7, lenient=False) + + +def test_number_with_one_wrong_digit_is_not_read(): + lines = [line("ND. 1234563", 0.3, 0.6)] + assert not number_read(lines, digest("1234567"), 7, lenient=False) + + +def test_number_split_across_two_boxes_on_one_row_is_joined(): + lines = [line("5678", 0.5, 0.52), line("123", 0.3, 0.5)] + joined = dict(rows_of_text(lines)) + assert joined["123 5678"] == [[0.3, 0.5, 0.1, 0.05], [0.5, 0.52, 0.1, 0.05]] + assert number_boxes(lines, digest("1235678"), 7, lenient=False) == joined["123 5678"] + + +def test_boxes_on_different_rows_are_not_joined(): + lines = [line("123", 0.3, 0.1), line("5678", 0.5, 0.5)] + assert not number_read(lines, digest("1235678"), 7, lenient=False) + + +def test_lenient_mode_accepts_letter_o_for_zero(): + lines = [line("4O71I2", 0.3, 0.5)] + assert not number_read(lines, digest("407112"), 6, lenient=False) + assert number_read(lines, lenient_digest("407112"), 6, lenient=True) + + +def test_class_label_ignores_spacing_and_case(): + assert class_read([line("cl 200 240V", 0.1, 0.1)], "CL200") + assert not class_read([line("CL20O", 0.1, 0.1)], "CL200") + + +def test_iec_rating_keeps_parentheses(): + assert class_read([line("10 (60) A", 0.1, 0.1)], "10(60)A") + assert not class_read([line("1060A", 0.1, 0.1)], "10(60)A") + + +def test_rating_ignores_separators_and_reads_times_sign_as_x(): + assert class_read([line("20(60)/A", 0.1, 0.1)], "20(60)A") + assert class_read([line("0.1,-10 AMP", 0.1, 0.1)], "0.1-10 AMP") + assert class_read([line("3×25", 0.1, 0.1)], "3x25") + + +# --- stats ------------------------------------------------------------------------------- + + +def test_wilson_interval_for_8_of_10(): + low, high = wilson(8, 10) + assert low == pytest.approx(0.4902, abs=1e-4) + assert high == pytest.approx(0.9433, abs=1e-4) + + +def test_auc_perfect_and_tied(): + assert auc([3, 2, 1, 0], [True, True, False, False]) == 1.0 + assert auc([1, 1], [True, False]) == 0.5 + + +def test_digest_is_hmac_sha256_with_the_key(): + # RFC 4231-style check: HMAC-SHA256 of "1234567" under 32 zero bytes, computed with + # Python's hmac module independently of meter_eval. + import hashlib + import hmac + + expected = hmac.new(bytes(32), b"1234567", hashlib.sha256).hexdigest() + assert digest("1234567") == expected + assert digest("1234567") != hashlib.sha256(b"1234567").hexdigest() diff --git a/experiments/meter-closeup/tests/test_retake.py b/experiments/meter-closeup/tests/test_retake.py new file mode 100644 index 000000000..6c8200855 --- /dev/null +++ b/experiments/meter-closeup/tests/test_retake.py @@ -0,0 +1,50 @@ +import numpy as np +import pytest + +from meter_eval import retake + +# 200 x 100 image; box 40 px tall (0.4 of the height). +BOX = [0.25, 0.3, 0.5, 0.4] + + +def sharp(): + # Alternating 0/200 pixels: every interior Laplacian is +-800, far above any threshold. + return (np.indices((100, 200)).sum(axis=0) % 2) * 200.0 + + +def test_sharp_photo_with_a_tall_top_candidate_passes(): + assert retake.reasons(sharp(), BOX) == [] + + +def test_flat_photo_is_out_of_focus(): + assert "out of focus" in retake.reasons(np.full((100, 200), 128.0), BOX) + + +def test_no_candidate_is_its_own_reason(): + assert retake.reasons(sharp(), None) == ["no number found"] + + +def test_top_candidate_below_the_threshold_is_too_small(): + # 33 px is under the 33.9 px threshold; 35 px is over it. + assert "number too small" in retake.reasons(sharp(), [0.25, 0.3, 0.5, 0.33]) + assert "number too small" not in retake.reasons(sharp(), [0.25, 0.3, 0.5, 0.35]) + + +def sine_rows(period: int, width: int, height: int): + x = np.arange(width) + return np.tile(128 + 100 * np.sin(2 * np.pi * x / period), (height, 1)) + + +def test_whole_photo_sharpness_leaves_small_photos_at_their_size(): + # Below 1024 px nothing is resized. For a sine of amplitude A and period P the Laplacian + # variance is (2(cos(2pi/P) - 1))^2 * A^2 / 2, about 115.9 at P = 16. + assert retake.whole_photo_sharpness(sine_rows(16, 1024, 768)) == pytest.approx(115.9, rel=0.01) + + +def test_whole_photo_sharpness_after_halving_is_pinned(): + # A 2048 px photo is halved, turning 32 px stripes into 16 px ones. The antialiasing + # filter softens them slightly, so the value lands a little under 115.9. This pins the + # implementation (PIL bilinear on a float image); a port should land within about 2%. + assert retake.whole_photo_sharpness(sine_rows(32, 2048, 1536)) == pytest.approx( + 112.68, rel=1e-3 + ) diff --git a/experiments/meter-closeup/uv.lock b/experiments/meter-closeup/uv.lock new file mode 100644 index 000000000..30ef95171 --- /dev/null +++ b/experiments/meter-closeup/uv.lock @@ -0,0 +1,333 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "meter-closeup" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "scipy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.1" }, + { name = "pillow", specifier = ">=11.0" }, + { name = "scipy", specifier = ">=1.14" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "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" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] diff --git a/experiments/panel-label/.gitignore b/experiments/panel-label/.gitignore new file mode 100644 index 000000000..2d9f16e2d --- /dev/null +++ b/experiments/panel-label/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/experiments/panel-label/README.md b/experiments/panel-label/README.md new file mode 100644 index 000000000..63dee5a1c --- /dev/null +++ b/experiments/panel-label/README.md @@ -0,0 +1,63 @@ +# Panel label readability + +Installers check the main electrical panel's manufacturer, model and main breaker amperage. Some panel lines are known hazards: Federal Pacific Stab-Lok and Zinsco are both subjects of public safety notices. The main breaker's amperage matters for any battery install. If the phone can read a close-up of the panel label, a homeowner can answer with one photo instead of a form. This experiment set out to measure that on real, openly licensed panel photos, reusing the meter close-up tooling. + +## Questions and pass criteria, set before the run + +1. How often does Vision's `accurate` mode read the manufacturer, the model or series, and the main breaker's amperage? Each field is scored on the photos where both AI readers agree it is legible, under the strict rule (both readers transcribed the same text and were sure). One photo can replace the form if the manufacturer and the main amperage are each read on at least 90% of those photos; below 70%, keep the form. +2. Can a simple rule map the recognized text to a manufacturer from a small public list, including the known-hazard lines? Precision (the named manufacturer is right, among photos where the rule names one) and recall (over photos with an agreed manufacturer) are reported on held-out photos. The rule may name a manufacturer without asking the homeowner if its held-out precision is at least 95%. To limit fitting, the rules are written using only odd-numbered photos and committed before the even-numbered ones are scored. +3. Do the meter close-up's blur and too-small checks transfer to panel labels? They transfer if, on the same controlled degradations of the panel photos, the panel's own 95% break-point threshold for each check is no stricter than the meter's (whole-photo sharpness 6.63; top-candidate line height 33.9 px). Otherwise the panel needs its own thresholds, which are reported. + +If fewer than about 40 usable openly licensed photos exist, the experiment stops there and reports what was searched. + +## Result: stopped, too few photos + +There are not enough openly licensed photos of residential panel labels to answer these questions. None of the three was measured. + +| Photos examined at full resolution, duplicates removed | 67 | +|---|---| +| Any of manufacturer, model or amperage legible | 19 | +| Manufacturer and a model or amperage legible | 9 | +| All three legible | 6 | + +`uv run python -m panel_eval.screen` regenerates these counts from `screened.csv`, which lists every examined photo with its URL, license, author and the per-field call. The calls were made by one AI reader (Claude Opus) looking at each photo at its stored resolution, so treat the counts as approximate. Even so, the lenient count is under half the 40-photo bar. The 9 photos also overstate the pool: two show the same panel, and one is an Indian moulded-case breaker, not a US residential panel. That leaves 7 distinct US panels. + +The known-hazard lines are almost absent: one Federal Pacific Stab-Lok panel, found twice, and no Zinsco panel. Precision and recall for naming a hazard line cannot be estimated from one example. + +As an anecdote and not a measured rate: on the 8 legible US photos, Vision read the manufacturer or line name on 6 (Federal Pacific and Stab-Lok, Frank Adam, Square D QO twice, GE, Square D). It read the amperage text on 4 ("100", "100A", "Mains 100 A Max", "125"). It read nothing useful on the Siemens main breaker, and garbled the Square D Multi-Breaker plate. + +## What was searched + +On 2026-09-26, filtered to CC0, public domain, CC BY and CC BY-SA: + +- **Wikimedia Commons.** The file search for each of 20 queries in `src/panel_eval/sources.py` (for example "breaker panel", "load center", "Federal Pacific", "Stab-Lok", "Zinsco", plus maker names with "breaker panel"), and the `Square D` and `Square D circuit breakers` categories. There are no Commons categories for US residential panels, load centers or breaker boxes; the related categories (`Distribution boards`, `Circuit breakers`) hold mostly European DIN-rail boards and industrial gear. +- **Openverse** (openly licensed images indexed from Flickr, Commons and others): the same 20 queries at up to 100 results each, then every accessible page of 11 core queries, 1,808 results in total. The anonymous API stops at 240 results per query, so the long tails of "electrical panel", "fuse box", "load center", "circuit breaker" and "service panel" were not reachable. + +The first pass returned 1,476 unique results and the deeper Openverse pass 1,356 more, about 2,800 in all. Screening titles and about 850 thumbnails left 78 photos to download at full resolution; removing 11 duplicates left 67. Most results are aircraft and ship panels, European consumer units, industrial switchgear, substations, car fuse boxes and diagrams. Most US residential panel photos are wide shots of wiring in which no label text is legible; Flickr copies in Openverse are mostly 1024 px. + +## What would unblock it: photos to take on field day + +The questions can run on the team's own photos, kept in `~/house-scanning-data/panel/images` and never committed, like the meter photos. The pre-set bar needs about 40 panels, each with the photos below. Ask the homeowner's permission first. + +| Photo | Framing | Answers | +|---|---|---| +| Door label | the label inside the panel door, filling the frame, straight on | manufacturer, model or catalogue number, rated amperage | +| Main breaker | the main breaker handle and its rating, from about 20 cm | main amperage | +| Deadfront | the whole cover with the door open, including any brand badge or series name | manufacturer and line (Stab-Lok, Zinsco and other hazard lines show their name here) | +| Context | the panel and its surroundings from about 1 m | lets a reader check which panel the close-ups belong to | + +- Take each close-up the way a homeowner would, handheld with the phone's own camera, not with a tripod or macro lens. Question 3 then degrades these photos under control, as the meter sweep did, so no deliberately bad shots are needed. +- Include several homes built between about 1950 and 1985. Federal Pacific Stab-Lok and Zinsco panels were installed then, and at least a few are needed to score naming them. +- Frame out, or cover before shooting, anything that identifies the home: inspection stickers with addresses, handwritten names in the circuit directory, and the meter's serial number. + +Sources this search did not use are a second option. Unsplash and Pexels photos are free to use but not CC-licensed, and whether they count as "openly licensed" is the team's call. US government works are public domain, for example CPSC recall notices, but those show individual breakers rather than panel labels. Home-inspection sites have many panel photos but are not openly licensed. + +## Rerun + +```sh +uv run python -m panel_eval.sources find # candidates.csv in ~/house-scanning-data/panel +uv run python -m panel_eval.screen # the counts above, from screened.csv +uv run pytest -q +``` + +`meterocr/` is a copy of the meter experiment's Vision reader, kept so this folder stands alone once the team has photos. diff --git a/experiments/panel-label/meterocr/Package.swift b/experiments/panel-label/meterocr/Package.swift new file mode 100644 index 000000000..c4445b63c --- /dev/null +++ b/experiments/panel-label/meterocr/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "meterocr", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget(name: "meterocr") + ] +) diff --git a/experiments/panel-label/meterocr/Sources/meterocr/main.swift b/experiments/panel-label/meterocr/Sources/meterocr/main.swift new file mode 100644 index 000000000..06c56c635 --- /dev/null +++ b/experiments/panel-label/meterocr/Sources/meterocr/main.swift @@ -0,0 +1,172 @@ +// Reads text from images with Apple Vision (VNRecognizeTextRequest), the engine the iPhone app +// would call. One JSON request per stdin line, one JSON result per stdout line, so a Python +// driver can stream thousands of degraded images through a single process. +// +// Request: {"path": "...", "level": "accurate"|"fast", "language_correction": false, +// "crop": [x, y, w, h], // optional, normalized, top-left origin +// "barcodes": false} // true also runs VNDetectBarcodesRequest (revision 4) +// Result: {"path", "width", "height", "elapsed_ms", "lines": [{"text", "confidence", +// "box": [x, y, w, h], "candidates": [...]}], // box normalized, top-left origin +// "barcodes": [{"payload", "symbology", "confidence", "box"}]} // when requested +// +// Usage: meterocr < requests.jsonl or meterocr [--fast] [--lc] image... + +import CoreGraphics +import Foundation +import ImageIO +import Vision + +struct Request: Decodable { + let path: String + var level: String? = "accurate" + var language_correction: Bool? = false + var crop: [Double]? = nil + var barcodes: Bool? = false +} + +struct Line: Encodable { + let text: String + let confidence: Float + let box: [Double] + let candidates: [String] +} + +struct Barcode: Encodable { + let payload: String? + let symbology: String + let confidence: Float + let box: [Double] +} + +struct Result: Encodable { + let path: String + let level: String + let language_correction: Bool + let crop: [Double]? + let width: Int + let height: Int + let elapsed_ms: Double + let lines: [Line] + let barcodes: [Barcode]? + let error: String? +} + +enum ReadError: Error, CustomStringConvertible { + case unreadableImage(String) + case badCrop([Double]) + + var description: String { + switch self { + case .unreadableImage(let path): "cannot decode image at \(path)" + case .badCrop(let crop): "crop must be 4 normalized values inside [0, 1], got \(crop)" + } + } +} + +func loadImage(_ path: String) throws -> CGImage { + let url = URL(fileURLWithPath: path) as CFURL + guard let source = CGImageSourceCreateWithURL(url, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { throw ReadError.unreadableImage(path) } + return image +} + +func read(_ request: Request) -> Result { + let level = request.level ?? "accurate" + let correction = request.language_correction ?? false + let started = Date() + var width = 0 + var height = 0 + do { + var image = try loadImage(request.path) + if let crop = request.crop { + guard crop.count == 4, crop.allSatisfy({ (0...1).contains($0) }), + crop[0] + crop[2] <= 1.0001, crop[1] + crop[3] <= 1.0001 + else { throw ReadError.badCrop(crop) } + let rect = CGRect( + x: crop[0] * Double(image.width), y: crop[1] * Double(image.height), + width: crop[2] * Double(image.width), height: crop[3] * Double(image.height) + ).integral + guard let cropped = image.cropping(to: rect) else { throw ReadError.badCrop(crop) } + image = cropped + } + width = image.width + height = image.height + + let vision = VNRecognizeTextRequest() + vision.revision = VNRecognizeTextRequestRevision3 + vision.recognitionLevel = level == "fast" ? .fast : .accurate + vision.usesLanguageCorrection = correction + vision.recognitionLanguages = ["en-US"] + // Default is already 0.0 (full resolution, per the SDK header); set it so a future + // default change cannot silently drop small labels. + vision.minimumTextHeight = 0 + let barcodeRequest = VNDetectBarcodesRequest() + barcodeRequest.revision = VNDetectBarcodesRequestRevision4 + let wantsBarcodes = request.barcodes ?? false + try VNImageRequestHandler(cgImage: image, orientation: .up) + .perform(wantsBarcodes ? [vision, barcodeRequest] : [vision]) + let barcodes = wantsBarcodes + ? (barcodeRequest.results ?? []).map { observation in + let b = observation.boundingBox + return Barcode( + payload: observation.payloadStringValue, + symbology: observation.symbology.rawValue, + confidence: observation.confidence, + box: [b.minX, 1 - b.maxY, b.width, b.height]) + } + : nil + + let lines = (vision.results ?? []).compactMap { observation -> Line? in + let top = observation.topCandidates(3) + guard let best = top.first else { return nil } + let b = observation.boundingBox // normalized, bottom-left origin + return Line( + text: best.string, + confidence: best.confidence, + box: [b.minX, 1 - b.maxY, b.width, b.height], + candidates: top.map(\.string) + ) + } + return Result( + path: request.path, level: level, language_correction: correction, crop: request.crop, + width: width, height: height, elapsed_ms: Date().timeIntervalSince(started) * 1000, + lines: lines, barcodes: barcodes, error: nil) + } catch { + return Result( + path: request.path, level: level, language_correction: correction, crop: request.crop, + width: width, height: height, elapsed_ms: Date().timeIntervalSince(started) * 1000, + lines: [], barcodes: nil, error: String(describing: error)) + } +} + +let encoder = JSONEncoder() +encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + +func emit(_ result: Result) { + let data = try! encoder.encode(result) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) +} + +var arguments = Array(CommandLine.arguments.dropFirst()) +if arguments.isEmpty { + let decoder = JSONDecoder() + while let line = readLine() { + guard !line.isEmpty else { continue } + do { + let request = try decoder.decode(Request.self, from: Data(line.utf8)) + autoreleasepool { emit(read(request)) } + } catch { + FileHandle.standardError.write(Data("bad request line: \(line)\n".utf8)) + exit(2) + } + } +} else { + let fast = arguments.contains("--fast") + let correction = arguments.contains("--lc") + arguments.removeAll { $0.hasPrefix("--") } + for path in arguments { + emit(read(Request(path: path, level: fast ? "fast" : "accurate", language_correction: correction))) + } +} diff --git a/experiments/panel-label/pyproject.toml b/experiments/panel-label/pyproject.toml new file mode 100644 index 000000000..23cc3c063 --- /dev/null +++ b/experiments/panel-label/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "panel-label" +version = "0.1.0" +description = "Whether on-device text recognition reads residential panel labels: manufacturer, model and main breaker amperage" +requires-python = ">=3.12" +dependencies = [ + "numpy>=2.1", + "pillow>=11.0", + "scipy>=1.14", +] + +[dependency-groups] +dev = ["pytest>=8.3", "ruff>=0.8"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/panel_eval"] + +[tool.uv] +required-version = ">=0.11.15,<0.12" + +[tool.ruff] +line-length = 100 + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/experiments/panel-label/screened.csv b/experiments/panel-label/screened.csv new file mode 100644 index 000000000..843a1b336 --- /dev/null +++ b/experiments/panel-label/screened.csv @@ -0,0 +1,79 @@ +id,source,title,page_url,image_url,license,license_url,author,duplicate_of,manufacturer,model,amperage,note +p01,wikimedia,File:Air duct and fuse box in a construction site.jpg,https://commons.wikimedia.org/wiki/File:Air_duct_and_fuse_box_in_a_construction_site.jpg,https://upload.wikimedia.org/wikipedia/commons/1/1c/Air_duct_and_fuse_box_in_a_construction_site.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0,"Paul Cutler from Chaska, USA",,0,0,0, +p02,wikimedia,File:Eaton circuit breaker panel open.JPG,https://commons.wikimedia.org/wiki/File:Eaton_circuit_breaker_panel_open.JPG,https://upload.wikimedia.org/wikipedia/commons/6/62/Eaton_circuit_breaker_panel_open.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,BrokenSphere,,1,0,0,"Eaton door label, fine print not legible" +p03,wikimedia,File:Fuse box in old apartment building.JPG,https://commons.wikimedia.org/wiki/File:Fuse_box_in_old_apartment_building.JPG,https://upload.wikimedia.org/wikipedia/commons/f/f1/Fuse_box_in_old_apartment_building.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Dmitry G,,0,0,0, +p04,wikimedia,File:Eaton circuit breaker panel closed.JPG,https://commons.wikimedia.org/wiki/File:Eaton_circuit_breaker_panel_closed.JPG,https://upload.wikimedia.org/wikipedia/commons/e/ec/Eaton_circuit_breaker_panel_closed.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,BrokenSphere,,1,0,0,"Eaton logo on closed door, small" +p05,wikimedia,File:Nice USSR fuse box.JPG,https://commons.wikimedia.org/wiki/File:Nice_USSR_fuse_box.JPG,https://upload.wikimedia.org/wikipedia/commons/c/c9/Nice_USSR_fuse_box.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Dmitry G,,0,0,0, +p06,wikimedia,File:Murray-fuse-box.jpg,https://commons.wikimedia.org/wiki/File:Murray-fuse-box.jpg,https://upload.wikimedia.org/wikipedia/commons/b/b0/Murray-fuse-box.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0,"Original photo: p.Gordon on Flickr Modified by Gazebo on June 1, 2014 with cropping, lossless optimi",,0,0,0, +p07,wikimedia,File:Stab-Lok circuit breaker panel interior .jpg,https://commons.wikimedia.org/wiki/File:Stab-Lok_circuit_breaker_panel_interior_.jpg,https://upload.wikimedia.org/wikipedia/commons/1/19/Stab-Lok_circuit_breaker_panel_interior_.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Repeater-reclaim,,1,1,1,Federal Pacific Stab-Lok load center; main handle 100 +p08,wikimedia,File:EMU100 Breaker Panel EP101 DSC 0886.jpg,https://commons.wikimedia.org/wiki/File:EMU100_Breaker_Panel_EP101_DSC_0886.jpg,https://upload.wikimedia.org/wikipedia/commons/f/f1/EMU100_Breaker_Panel_EP101_DSC_0886.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Lexcie,,0,0,0, +p09,wikimedia,"File:Жаркент, дунган�ка� мечеть, щитки.jpg","https://commons.wikimedia.org/wiki/File:%D0%96%D0%B0%D1%80%D0%BA%D0%B5%D0%BD%D1%82,_%D0%B4%D1%83%D0%BD%D0%B3%D0%B0%D0%BD%D1%81%D0%BA%D0%B0%D1%8F_%D0%BC%D0%B5%D1%87%D0%B5%D1%82%D1%8C,_%D1%89%D0%B8%D1%82%D0%BA%D0%B8.jpg",https://thumb.wikimedia.org/wikipedia/commons/thumb/5/59/%D0%96%D0%B0%D1%80%D0%BA%D0%B5%D0%BD%D1%82%2C_%D0%B4%D1%83%D0%BD%D0%B3%D0%B0%D0%BD%D1%81%D0%BA%D0%B0%D1%8F_%D0%BC%D0%B5%D1%87%D0%B5%D1%82%D1%8C%2C_%D1%89%D0%B8%D1%82%D0%BA%D0%B8.jpg/3840px-%D0%96%D0%B0%D1%80%D0%BA%D0%B5%D0%BD%D1%82%2C_%D0%B4%D1%83%D0%BD%D0%B3%D0%B0%D0%BD%D1%81%D0%BA%D0%B0%D1%8F_%D0%BC%D0%B5%D1%87%D0%B5%D1%82%D1%8C%2C_%D1%89%D0%B8%D1%82%D0%BA%D0%B8.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Nikolai Bulykin,,0,0,0, +p10,wikimedia,EMU100 Breaker Panel EP101 DSC 0886,https://commons.wikimedia.org/w/index.php?curid=115094472,https://upload.wikimedia.org/wikipedia/commons/f/f1/EMU100_Breaker_Panel_EP101_DSC_0886.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0/,Lexcie,p08,0,0,0, +p11,wikimedia,Stab-Lok circuit breaker panel interior,https://commons.wikimedia.org/w/index.php?curid=108022261,https://upload.wikimedia.org/wikipedia/commons/1/19/Stab-Lok_circuit_breaker_panel_interior_.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0/,Repeater-reclaim,p07,0,0,0, +p12,wikimedia,Schneider Electric 1-Phase 100-Amp Residential Breaker Panel,https://commons.wikimedia.org/w/index.php?curid=25772432,https://upload.wikimedia.org/wikipedia/commons/1/1d/Schneider_Electric_1-Phase_100-Amp_Residential_Breaker_Panel.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0/,Kevin Kiefuik,,1,0,0,"Schneider Electric label on side, fine print not legible" +p13,wikimedia,Eaton circuit breaker panel open,https://commons.wikimedia.org/w/index.php?curid=3898593,https://upload.wikimedia.org/wikipedia/commons/6/62/Eaton_circuit_breaker_panel_open.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0/,BrokenSphere,p02,0,0,0, +p14,wikimedia,Eaton circuit breaker panel closed,https://commons.wikimedia.org/w/index.php?curid=3898597,https://upload.wikimedia.org/wikipedia/commons/e/ec/Eaton_circuit_breaker_panel_closed.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0/,BrokenSphere,p04,0,0,0, +p15,wikimedia,Electrical panel and subpanel with cover removed from subpanel,https://commons.wikimedia.org/w/index.php?curid=28566920,https://upload.wikimedia.org/wikipedia/commons/e/ef/Electrical_panel_and_subpanel_with_cover_removed_from_subpanel.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0/,Gvolk,,0,0,0, +p16,wikimedia,Electrical panels in 1946 house,https://commons.wikimedia.org/w/index.php?curid=119120895,https://upload.wikimedia.org/wikipedia/commons/a/a3/Electrical_panels_in_1946_house.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0/,Circacies,,1,0,0,"Frank Adam badge on deadfront, small" +p17,wikimedia,File:OpenClosedPanelboardEEUU(GE)CloseupTop.jpg,https://commons.wikimedia.org/wiki/File:OpenClosedPanelboardEEUU(GE)CloseupTop.jpg,https://upload.wikimedia.org/wikipedia/commons/e/ef/OpenClosedPanelboardEEUU%28GE%29CloseupTop.jpg,Public domain,,Where at English Wikipedia,,0,0,1,main handle 100; no maker text +p18,wikimedia,File:Wall Circuit Breaker and Wiring.jpg,https://commons.wikimedia.org/wiki/File:Wall_Circuit_Breaker_and_Wiring.jpg,https://upload.wikimedia.org/wikipedia/commons/5/55/Wall_Circuit_Breaker_and_Wiring.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Paul The Writer,,0,0,0, +p19,wikimedia,US wiring basement-panel,https://commons.wikimedia.org/w/index.php?curid=25730113,https://upload.wikimedia.org/wikipedia/commons/5/5d/US_wiring_basement-panel.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,davef3138,,0,0,0, +p20,wikimedia,Electrical panel opened,https://commons.wikimedia.org/w/index.php?curid=112407360,https://upload.wikimedia.org/wikipedia/commons/6/65/Electrical_panel_opened.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0/,FuriousYogi,,1,0,0,"Eaton door label, fine print not legible" +p21,wikimedia,File:Seattle - Moore Theatre - electricals 02.jpg,https://commons.wikimedia.org/wiki/File:Seattle_-_Moore_Theatre_-_electricals_02.jpg,https://upload.wikimedia.org/wikipedia/commons/8/81/Seattle_-_Moore_Theatre_-_electricals_02.jpg,CC BY-SA 3.0,http://creativecommons.org/licenses/by-sa/3.0/,Joe Mabel.,,0,0,0, +p22,wikimedia,File:EEUU(GE)BreakerpanelInnards.jpg,https://commons.wikimedia.org/wiki/File:EEUU(GE)BreakerpanelInnards.jpg,https://upload.wikimedia.org/wikipedia/commons/e/ea/EEUU%28GE%29BreakerpanelInnards.jpg,Public domain,,The original uploader was Where at English Wikipedia.,,0,0,0, +p23,wikimedia,File:US wiring basement-panel.jpg,https://commons.wikimedia.org/wiki/File:US_wiring_basement-panel.jpg,https://upload.wikimedia.org/wikipedia/commons/5/5d/US_wiring_basement-panel.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0,davef3138,p19,0,0,0, +p24,wikimedia,File:Electrical panel and subpanel with cover removed from subpanel.jpg,https://commons.wikimedia.org/wiki/File:Electrical_panel_and_subpanel_with_cover_removed_from_subpanel.jpg,https://upload.wikimedia.org/wikipedia/commons/e/ef/Electrical_panel_and_subpanel_with_cover_removed_from_subpanel.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Gvolk,p15,0,0,0, +p25,wikimedia,File:Electrical panel opened.jpg,https://commons.wikimedia.org/wiki/File:Electrical_panel_opened.jpg,https://upload.wikimedia.org/wikipedia/commons/6/65/Electrical_panel_opened.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,FuriousYogi,p20,0,0,0, +p26,wikimedia,File:Exposed Wall Wiring and Breaker Box.jpg,https://commons.wikimedia.org/wiki/File:Exposed_Wall_Wiring_and_Breaker_Box.jpg,https://thumb.wikimedia.org/wikipedia/commons/thumb/1/18/Exposed_Wall_Wiring_and_Breaker_Box.jpg/3840px-Exposed_Wall_Wiring_and_Breaker_Box.jpg,CC0,http://creativecommons.org/publicdomain/zero/1.0/deed.en,Paul The Writer,,0,0,0, +p27,wikimedia,File:Fuse box grounded to hot wire.JPG,https://commons.wikimedia.org/wiki/File:Fuse_box_grounded_to_hot_wire.JPG,https://upload.wikimedia.org/wikipedia/commons/e/e5/Fuse_box_grounded_to_hot_wire.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Dmitry G,,0,0,0, +p28,flickr,HipstaPrint Circuit Breaker Box,https://www.flickr.com/photos/10710442@N08/18787764101,https://live.staticflickr.com/364/18787764101_47939ef1ef_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Steve Snodgrass,,0,0,0, +p29,wikimedia,File:Distribution board in Slovakia 01.jpg,https://commons.wikimedia.org/wiki/File:Distribution_board_in_Slovakia_01.jpg,https://thumb.wikimedia.org/wikipedia/commons/thumb/0/0a/Distribution_board_in_Slovakia_01.jpg/3840px-Distribution_board_in_Slovakia_01.jpg,CC BY 4.0,https://creativecommons.org/licenses/by/4.0,Oto Zapletal,,0,0,0, +p30,wikimedia,File:Old Soviet distribution board.JPG,https://commons.wikimedia.org/wiki/File:Old_Soviet_distribution_board.JPG,https://upload.wikimedia.org/wikipedia/commons/9/9d/Old_Soviet_distribution_board.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,Pinot,,0,0,0, +p31,flickr,Abandoned Dairy Factory (8),https://www.flickr.com/photos/30036111@N04/8170430415,https://live.staticflickr.com/8197/8170430415_e03ca117e4_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,janbommes,,0,0,0, +p32,wikimedia,File:Electrical panels in 1946 house.jpg,https://commons.wikimedia.org/wiki/File:Electrical_panels_in_1946_house.jpg,https://upload.wikimedia.org/wikipedia/commons/a/a3/Electrical_panels_in_1946_house.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Circacies,p16,0,0,0, +p33,wikimedia,File:Square D main circuit breaker from 1946.jpg,https://commons.wikimedia.org/wiki/File:Square_D_main_circuit_breaker_from_1946.jpg,https://upload.wikimedia.org/wikipedia/commons/f/f6/Square_D_main_circuit_breaker_from_1946.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0,Circacies,,1,1,0,Square D Multi-Breaker main; handle rating too small to read +p34,wikimedia,File:Square D 20-Amp Double-Pole Breaker (1).JPG,https://commons.wikimedia.org/wiki/File:Square_D_20-Amp_Double-Pole_Breaker_(1).JPG,https://upload.wikimedia.org/wikipedia/commons/6/65/Square_D_20-Amp_Double-Pole_Breaker_%281%29.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,CyberXRef,,0,0,0, +p35,wikimedia,File:Square D 20-Amp Single-Pole Breaker (1).JPG,https://commons.wikimedia.org/wiki/File:Square_D_20-Amp_Single-Pole_Breaker_(1).JPG,https://upload.wikimedia.org/wikipedia/commons/c/cf/Square_D_20-Amp_Single-Pole_Breaker_%281%29.JPG,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0,CyberXRef,,0,0,0, +p36,flickr,Main house breaker and old meter,https://www.flickr.com/photos/35034363486@N01/1462085618,https://live.staticflickr.com/1247/1462085618_5918e51aab_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Mike Weston,,0,0,0, +p37,wikimedia,Frank Adam electrical panel label from 1946,https://commons.wikimedia.org/w/index.php?curid=119120896,https://upload.wikimedia.org/wikipedia/commons/f/f1/Frank_Adam_electrical_panel_label_from_1946.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0/,Circacies,,1,1,1,"Frank Adam load center nameplate, 100 A, catalogue number" +p38,wordpress,"Close-up of a 200-amp breaker in the ON position, with red, black, blue, yellow, and brown wires con",https://wordpress.org/photos/photo/515687aa4e/,https://pd.w.org/2025/07/515687aa4ebe1f8c3.25750924-2048x1152.jpg,CC CC0 1.0,https://creativecommons.org/publicdomain/zero/1.0/,Moses Cursor Ssebunya,,1,0,1,"Indian 200 A moulded-case main breaker, not a US residential panel" +p39,flickr,Electrical panel finished,https://www.flickr.com/photos/34197011@N00/202290783,https://live.staticflickr.com/70/202290783_65a8eead40_b.jpg,CC BY-SA 2.0,https://creativecommons.org/licenses/by-sa/2.0/,scottbb,,0,0,0, +p40,flickr,Electrical closeup,https://www.flickr.com/photos/34197011@N00/202290560,https://live.staticflickr.com/71/202290560_fb40def5b7_b.jpg,CC BY-SA 2.0,https://creativecommons.org/licenses/by-sa/2.0/,scottbb,,1,0,1,"Siemens main breaker, 150 A" +p41,flickr,Megger testing the main and branch breakers in electrical panels in a power substation in Queens. 09,https://www.flickr.com/photos/59595815@N03/48689467532,https://live.staticflickr.com/65535/48689467532_8dcb145f84_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,MTA C&D - EAST SIDE ACCESS,,0,0,0, +p42,flickr,Electrical hazard in old panel,https://www.flickr.com/photos/26670433@N07/27652554691,https://live.staticflickr.com/7446/27652554691_91a0a15969_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,davef3138,,0,0,0, +p43,flickr,200A main panel,https://www.flickr.com/photos/26670433@N07/36167061425,https://live.staticflickr.com/4315/36167061425_09b0334565_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,davef3138,,0,0,0, +p44,flickr,Mains switch/breaker panel...,https://www.flickr.com/photos/91198056@N00/8295829115,https://live.staticflickr.com/8076/8295829115_a0ac42fbf9.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Podknox,,0,0,0, +p45,wikimedia,Megger testing the main and branch breakers in electrical panels in a power substation in Queens. 09,https://commons.wikimedia.org/w/index.php?curid=86620496,https://upload.wikimedia.org/wikipedia/commons/6/62/Megger_testing_the_main_and_branch_breakers_in_electrical_panels_in_a_power_substation_in_Queens._09-04-2019_%2848689467532%29.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,MTA Capital Construction Mega Projects,p41,0,0,0, +p46,flickr,Preparation for injection testing after an electrical panel upgrade. 06-25-19,https://www.flickr.com/photos/59595815@N03/48146653086,https://live.staticflickr.com/65535/48146653086_50528e0cf2_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,MTA C&D - EAST SIDE ACCESS,,0,0,0, +p47,flickr,Electrical panel upgrade,https://www.flickr.com/photos/26670433@N07/27161367425,https://live.staticflickr.com/7342/27161367425_0202dfd83c_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,davef3138,,0,0,0, +p48,flickr,1 electrical panel upgrade before - Copy,https://www.flickr.com/photos/62485517@N08/13018082643,https://live.staticflickr.com/7366/13018082643_b2bbe0a94d.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,fallsavenuevintagefashion,,0,0,0, +p49,flickr,2 electrical panel upgrade after - Copy,https://www.flickr.com/photos/62485517@N08/13017916415,https://live.staticflickr.com/7410/13017916415_b4b638122e.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,fallsavenuevintagefashion,,0,0,0, +p50,flickr,Electrical Panel upgrade,https://www.flickr.com/photos/193473686@N05/52584053369,https://live.staticflickr.com/65535/52584053369_2f99fc876f_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Phyxter Home Services,,0,0,0, +p51,flickr,Performing injection testing on the electrical breakers after a panel upgrade in a power substation.,https://www.flickr.com/photos/59595815@N03/48105050226,https://live.staticflickr.com/65535/48105050226_1fafc2c9fe_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,MTA C&D - EAST SIDE ACCESS,,0,0,0, +p52,flickr,Electric service upgrade,https://www.flickr.com/photos/26670433@N07/27652552351,https://live.staticflickr.com/7687/27652552351_07da08535b_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,davef3138,,0,0,0, +p53,flickr,Electric service upgrade,https://www.flickr.com/photos/26670433@N07/27652555241,https://live.staticflickr.com/7249/27652555241_a39d5d9ea3_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,davef3138,,0,0,0, +p54,flickr,Upstairs MR breaker panel,https://www.flickr.com/photos/21881956@N05/3726676241,https://live.staticflickr.com/2555/3726676241_fc8c1fb85a_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,vaxomatic,,0,0,0, +p55,flickr,2012-09-05_040504_ac-breaker-panel,https://www.flickr.com/photos/62485517@N08/8453699897,https://live.staticflickr.com/8088/8453699897_9d6756767b_m.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,fallsavenuevintagefashion,,0,0,0, +p56,flickr,20051008main-panel-lower,https://www.flickr.com/photos/26670433@N07/3573729611,https://live.staticflickr.com/3621/3573729611_aa19d11b16_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,davef3138,,1,0,0,Square D logos on breakers only +p57,flickr,Old panel,https://www.flickr.com/photos/26670433@N07/27573940545,https://live.staticflickr.com/7453/27573940545_3463f959d5_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,davef3138,,0,0,0, +p58,wikimedia,File:Breakerpanel-level4-inside-view135dl.jpg,https://commons.wikimedia.org/w/index.php?curid=47145303,https://upload.wikimedia.org/wikipedia/commons/c/cf/Breakerpanel-level4-inside-view135dl.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0/,The original uploader was Glogger at English Wikipedia.,,0,0,0, +p59,flickr,Snow-filled Electrical Panel,https://www.flickr.com/photos/57202181@N05/5905086734,https://live.staticflickr.com/5277/5905086734_016c63fcf9_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Alan R. Light,,0,0,0, +p60,flickr,"Day Fifteen, Electrical Panel",https://www.flickr.com/photos/37663331@N00/2587104216,https://live.staticflickr.com/3029/2587104216_81b497106a_b.jpg,CC BY-SA 2.0,https://creativecommons.org/licenses/by-sa/2.0/,Numinosity by Gary J Wood,,0,0,0, +p61,flickr,Old Electrical Panel,https://www.flickr.com/photos/18658635@N00/15391420009,https://live.staticflickr.com/3956/15391420009_e0926596df_b.jpg,CC BY-SA 2.0,https://creativecommons.org/licenses/by-sa/2.0/,Patty OK,,0,0,0, +p62,flickr,Old electric panel in my backyard,https://www.flickr.com/photos/65588072@N00/126123291,https://live.staticflickr.com/53/126123291_4418fea272_b.jpg,CC BY-SA 2.0,https://creativecommons.org/licenses/by-sa/2.0/,thomascrenshaw,,0,0,0, +p63,flickr,Electric Panel in Shed,https://www.flickr.com/photos/79155290@N06/48990697453,https://live.staticflickr.com/65535/48990697453_ec4549e3a2_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,bobcapra,,1,1,1,"Square D QO load center label, mains 100 A" +p64,flickr,Electric Panel in Shed,https://www.flickr.com/photos/79155290@N06/48990697533,https://live.staticflickr.com/65535/48990697533_09bc11eca2_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,bobcapra,,1,1,1,"same panel as p63, wider view" +p65,wikimedia,"Antarctica electrical panel, wired",https://commons.wikimedia.org/w/index.php?curid=25730249,https://upload.wikimedia.org/wikipedia/commons/d/da/Antarctica_electrical_panel%2C_wired.jpg,CC BY-SA 2.0,https://creativecommons.org/licenses/by-sa/2.0/,Eli Duke,,0,0,0, +p66,wikimedia,Instructions on an electric panel in an apartment,https://commons.wikimedia.org/w/index.php?curid=71311202,https://upload.wikimedia.org/wikipedia/commons/4/4c/Instructions_on_an_electric_panel_in_an_apartment.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0/,Dquai,,1,1,1,"GE load center wiring-diagram label, 125 A" +p67,flickr,Painting near breaker box #1,https://www.flickr.com/photos/68552389@N00/642732090,https://live.staticflickr.com/1298/642732090_2822a28e7c.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,cbb4104,,0,0,0, +p68,flickr,breaker box,https://www.flickr.com/photos/14381622@N00/10386983003,https://live.staticflickr.com/3773/10386983003_286abfac51.jpg,CC BY-SA 2.0,https://creativecommons.org/licenses/by-sa/2.0/,JSF539,,0,0,0, +p69,flickr,Super Breakers,https://www.flickr.com/photos/24192350@N03/4383213682,https://live.staticflickr.com/4040/4383213682_9b5c59d599_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,StickerGiant,,0,0,0, +p70,wikimedia,Seattle - Moore Theatre - electricals 02,https://commons.wikimedia.org/w/index.php?curid=3247946,https://upload.wikimedia.org/wikipedia/commons/8/81/Seattle_-_Moore_Theatre_-_electricals_02.jpg,CC BY-SA 3.0,https://creativecommons.org/licenses/by-sa/3.0/,Joe Mabel.,p21,0,0,0, +p71,flickr,Fuse box,https://www.flickr.com/photos/64401168@N00/4723843707,https://live.staticflickr.com/1201/4723843707_03e8fe7cde_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Average Jane,,1,1,1,"Square D switch and fuse cabinet nameplate, 60 A" +p72,flickr,Fuse box,https://www.flickr.com/photos/69024677@N00/2355953595,https://live.staticflickr.com/2101/2355953595_6541da1784_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Giles Douglas,,0,0,0, +p73,flickr,Overkill fuse box,https://www.flickr.com/photos/69024677@N00/2408454318,https://live.staticflickr.com/3272/2408454318_04fafa9520_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Giles Douglas,,0,0,0, +p74,flickr,Fuse box w/o permit and missing labels,https://www.flickr.com/photos/14529688@N08/6164685732,https://live.staticflickr.com/6168/6164685732_6ee8277d7f_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Ken_Mayer,,1,0,0,GE logo on door label +p75,flickr,Circuit breakers,https://www.flickr.com/photos/44124483743@N01/3957069731,https://live.staticflickr.com/2491/3957069731_055b58eda8_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,brownpau,,1,0,0,"Square D door label, fine print not legible at 1024 px" +p76,flickr,Circuit Breaker,https://www.flickr.com/photos/49062413@N00/3354702763,https://live.staticflickr.com/3550/3354702763_a5a814c903_b.jpg,CC BY 2.0,https://creativecommons.org/licenses/by/2.0/,Helga's Lobster Stew,,1,0,0,"Westinghouse 15 A breaker plate, not a main" +p77,wikimedia,Square D main circuit breaker from 1946,https://commons.wikimedia.org/w/index.php?curid=119120894,https://upload.wikimedia.org/wikipedia/commons/f/f6/Square_D_main_circuit_breaker_from_1946.jpg,CC BY-SA 4.0,https://creativecommons.org/licenses/by-sa/4.0/,Circacies,p33,0,0,0, +p78,flickr,Old service panel,https://www.flickr.com/photos/87836984@N00/385715312,https://live.staticflickr.com/148/385715312_c0f0659ad4_b.jpg,CC BY-SA 2.0,https://creativecommons.org/licenses/by-sa/2.0/,claytron,,0,0,0, diff --git a/experiments/panel-label/src/panel_eval/__init__.py b/experiments/panel-label/src/panel_eval/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/experiments/panel-label/src/panel_eval/paths.py b/experiments/panel-label/src/panel_eval/paths.py new file mode 100644 index 000000000..d65d67445 --- /dev/null +++ b/experiments/panel-label/src/panel_eval/paths.py @@ -0,0 +1,11 @@ +import os +from pathlib import Path + +EXPERIMENT_DIR = Path(__file__).resolve().parents[2] +MANIFEST = EXPERIMENT_DIR / "manifest.csv" +RESULTS_DIR = EXPERIMENT_DIR / "results" +# Photos and raw recognizer output stay outside the repository. +DATA_DIR = Path(os.environ.get("PANEL_DATA", Path.home() / "house-scanning-data" / "panel")) +# The label review page and the HMAC key live here, outside the repository too. +REVIEW_DIR = DATA_DIR.parent / "panel-label" +KEY_PATH = REVIEW_DIR / "hmac.key" diff --git a/experiments/panel-label/src/panel_eval/screen.py b/experiments/panel-label/src/panel_eval/screen.py new file mode 100644 index 000000000..91c030440 --- /dev/null +++ b/experiments/panel-label/src/panel_eval/screen.py @@ -0,0 +1,48 @@ +"""Tally screened.csv: how many openly licensed panel photos have a legible label field. + +screened.csv lists every photo examined at full resolution after screening thumbnails, with a +per-field call made by eye: manufacturer, model or series, and amperage. Duplicates (the same +file found twice, or one photo reposted) are counted once. +""" + +import csv +from collections.abc import Iterable + +from panel_eval.paths import EXPERIMENT_DIR + +SCREENED = EXPERIMENT_DIR / "screened.csv" +# The pre-set bar: "fewer than about 40 usable photos" stops the experiment. +REQUIRED = 40 + + +def tally(rows: Iterable[dict]) -> dict[str, int]: + unique = [r for r in rows if not r["duplicate_of"]] + + def has(r: dict, field: str) -> bool: + return r[field] == "1" + + return { + "examined": len(unique), + "any field legible": sum( + 1 for r in unique if has(r, "manufacturer") or has(r, "model") or has(r, "amperage") + ), + "manufacturer and (model or amperage)": sum( + 1 for r in unique if has(r, "manufacturer") and (has(r, "model") or has(r, "amperage")) + ), + "all three": sum( + 1 for r in unique if has(r, "manufacturer") and has(r, "model") and has(r, "amperage") + ), + } + + +def main() -> None: + with SCREENED.open() as handle: + counts = tally(csv.DictReader(handle)) + for name, count in counts.items(): + print(f"{name}: {count}") + verdict = "meets" if counts["any field legible"] >= REQUIRED else "is short of" + print(f"Even the most lenient count {verdict} the {REQUIRED}-photo bar.") + + +if __name__ == "__main__": + main() diff --git a/experiments/panel-label/src/panel_eval/sources.py b/experiments/panel-label/src/panel_eval/sources.py new file mode 100644 index 000000000..4eff75a4e --- /dev/null +++ b/experiments/panel-label/src/panel_eval/sources.py @@ -0,0 +1,240 @@ +"""Find and download openly licensed photos of residential electrical panels. + +`find` runs a fixed list of searches on Wikimedia Commons and on Openverse (an index of +openly licensed images from Flickr, Commons and other sources) and writes every result under +CC0, public domain, CC BY or CC BY-SA to candidates.csv in the data directory, for screening +by eye. `fetch` downloads the photos listed in the committed manifest.csv. +""" + +import argparse +import csv +import io +import json +import re +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from PIL import Image, ImageOps + +from panel_eval.paths import DATA_DIR, MANIFEST + +USER_AGENT = "house-scanning-panel-eval/0.1 (https://github.com/SamGu-NRX/house-scanning)" +COMMONS_API = "https://commons.wikimedia.org/w/api.php" +OPENVERSE_API = "https://api.openverse.org/v1/images/" +SEARCHES = [ + "breaker panel", + "electrical panel", + "circuit breaker panel", + "circuit breaker box", + "breaker box", + "load center", + "service panel", + "electrical service panel", + "main breaker", + "panelboard", + "Square D panel", + "Federal Pacific", + "Stab-Lok", + "Zinsco", + "Cutler-Hammer panel", + "Siemens breaker panel", + "General Electric breaker panel", + "Murray breaker panel", + "Challenger breaker panel", + "Westinghouse breaker panel", +] +# Stored photos are capped at this width, a standard Commons thumbnail step. +MAX_WIDTH = 3840 +REUSABLE_COMMONS = re.compile(r"^(CC0|Public domain|PD|CC BY(-SA)? [0-9.]+)", re.IGNORECASE) +REUSABLE_OPENVERSE = {"by", "by-sa", "cc0", "pdm"} + + +def get_json(url: str, params: dict, post: bool = False) -> dict: + encoded = urllib.parse.urlencode(params) + request = ( + urllib.request.Request(url, data=encoded.encode(), headers={"User-Agent": USER_AGENT}) + if post + else urllib.request.Request(f"{url}?{encoded}", headers={"User-Agent": USER_AGENT}) + ) + for attempt in range(4): + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if error.code != 429 or attempt == 3: + raise + time.sleep(30 * (attempt + 1)) + raise AssertionError("unreachable") + + +def commons(query: str) -> list[dict]: + hits = get_json( + COMMONS_API, + { + "action": "query", + "list": "search", + "srsearch": query, + "srnamespace": "6", + "srlimit": "100", + "format": "json", + "formatversion": "2", + }, + )["query"]["search"] + titles = [hit["title"] for hit in hits] + rows = [] + for start in range(0, len(titles), 50): + data = get_json( + COMMONS_API, + { + "action": "query", + "titles": "|".join(titles[start : start + 50]), + "prop": "imageinfo", + "iiprop": "url|size|mime|extmetadata", + "iiurlwidth": str(MAX_WIDTH), + "format": "json", + "formatversion": "2", + }, + post=True, + ) + for page in data["query"]["pages"]: + info = (page.get("imageinfo") or [None])[0] + if not info or info.get("mime") not in ("image/jpeg", "image/png"): + continue + meta = info.get("extmetadata", {}) + + def field(name: str, meta=meta) -> str: + value = str(meta.get(name, {}).get("value", "")) + return " ".join(re.sub(r"<[^>]+>", "", value).split()) + + if not REUSABLE_COMMONS.match(field("LicenseShortName")): + continue + wide = info["width"] > MAX_WIDTH and "thumburl" in info + rows.append( + { + "source": "wikimedia", + "title": page["title"], + "page_url": info["descriptionurl"], + "image_url": (info["thumburl"] if wide else info["url"]).split("?")[0], + "width": info["width"], + "height": info["height"], + "license": field("LicenseShortName"), + "license_url": field("LicenseUrl"), + "author": field("Artist")[:120], + } + ) + return rows + + +def openverse(query: str, pages: int = 5) -> list[dict]: + rows = [] + for page in range(1, pages + 1): + data = get_json( + OPENVERSE_API, + { + "q": query, + "license": ",".join(sorted(REUSABLE_OPENVERSE)), + "page_size": "20", + "page": str(page), + }, + ) + for item in data["results"]: + if item["license"] not in REUSABLE_OPENVERSE: + continue + license_name = f"CC {item['license'].upper()} {item.get('license_version') or ''}" + rows.append( + { + "source": item["source"], + "title": item["title"] or "", + "page_url": item["foreign_landing_url"], + "image_url": item["url"], + "width": item.get("width") or "", + "height": item.get("height") or "", + "license": license_name.strip(), + "license_url": item.get("license_url") or "", + "author": (item.get("creator") or "")[:120], + } + ) + if page >= data.get("page_count", 0): + break + time.sleep(2) + return rows + + +def find(args: argparse.Namespace) -> None: + found: dict[str, dict] = {} + for query in SEARCHES: + for row in commons(query) + openverse(query): + found.setdefault(row["page_url"], row) + print(f"{query}: {len(found)} unique so far", flush=True) + time.sleep(2) + rows = [ + r + for r in found.values() + if not r["width"] or min(int(r["width"]), int(r["height"])) >= args.min_side + ] + out = DATA_DIR / "candidates.csv" + with out.open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + print(f"{len(rows)} candidates -> {out}") + + +def download(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + for attempt in range(4): + try: + with urllib.request.urlopen(request, timeout=120) as response: + return response.read() + except urllib.error.HTTPError as error: + if error.code != 429 or attempt == 3: + raise + time.sleep(30 * (attempt + 1)) + raise AssertionError("unreachable") + + +def save_upright(data: bytes, dest: Path) -> None: + """Apply EXIF rotation, cap the width, save plain RGB.""" + with Image.open(io.BytesIO(data)) as image: + upright = ImageOps.exif_transpose(image).convert("RGB") + if upright.width > MAX_WIDTH: + upright.thumbnail((MAX_WIDTH, MAX_WIDTH * 4)) + upright.save(dest, quality=95) + + +def fetch(args: argparse.Namespace) -> None: + source = Path(args.source) if args.source else MANIFEST + with source.open() as handle: + rows = list(csv.DictReader(handle)) + images = DATA_DIR / "images" + images.mkdir(parents=True, exist_ok=True) + for row in rows: + dest = images / f"{row['id']}.jpg" + if dest.exists(): + continue + save_upright(download(row["image_url"]), dest) + print(f"fetched {row['id']}", flush=True) + time.sleep(1.0) + print(f"{len(rows)} images in {images}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(required=True) + p_find = sub.add_parser("find", help="list reusable candidate photos") + p_find.add_argument("--min-side", type=int, default=800) + p_find.set_defaults(func=find) + p_fetch = sub.add_parser("fetch", help="download the photos in manifest.csv") + p_fetch.add_argument( + "--source", help="CSV with id and image_url columns (default manifest.csv)" + ) + p_fetch.set_defaults(func=fetch) + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/experiments/panel-label/tests/test_panel.py b/experiments/panel-label/tests/test_panel.py new file mode 100644 index 000000000..e9e2ef3e5 --- /dev/null +++ b/experiments/panel-label/tests/test_panel.py @@ -0,0 +1,52 @@ +import io + +from PIL import Image + +from panel_eval.screen import tally +from panel_eval.sources import MAX_WIDTH, REUSABLE_COMMONS, save_upright + + +def row(dup="", m="0", s="0", a="0"): + return {"duplicate_of": dup, "manufacturer": m, "model": s, "amperage": a} + + +def test_tally_counts_each_photo_once_and_nests_the_criteria(): + rows = [ + row(m="1", s="1", a="1"), + row(dup="p01", m="1", s="1", a="1"), # a second copy of the first photo + row(m="1", a="1"), + row(a="1"), + row(), + ] + assert tally(rows) == { + "examined": 4, + "any field legible": 3, + "manufacturer and (model or amperage)": 2, + "all three": 1, + } + + +def test_commons_license_filter_accepts_only_reusable_licenses(): + for ok in ("CC0", "Public domain", "PD-USGov", "CC BY 2.0", "CC BY-SA 4.0"): + assert REUSABLE_COMMONS.match(ok), ok + for bad in ("GFDL", "CC BY-NC 2.0", "CC BY-ND 4.0", "Copyrighted", ""): + assert not REUSABLE_COMMONS.match(bad), bad + + +def test_save_upright_applies_exif_rotation_and_caps_width(tmp_path): + image = Image.new("RGB", (MAX_WIDTH * 2, 100), "white") + exif = image.getexif() + exif[0x0112] = 6 # stored rotated: display needs a 90 degree turn + buffer = io.BytesIO() + image.save(buffer, format="JPEG", exif=exif) + dest = tmp_path / "out.jpg" + save_upright(buffer.getvalue(), dest) + with Image.open(dest) as saved: + # Rotated to 100 x 7680, which is already under the width cap. + assert saved.size == (100, MAX_WIDTH * 2) + + wide = io.BytesIO() + Image.new("RGB", (MAX_WIDTH * 2, 100), "white").save(wide, format="JPEG") + save_upright(wide.getvalue(), dest) + with Image.open(dest) as saved: + assert saved.size == (MAX_WIDTH, 50) diff --git a/experiments/panel-label/uv.lock b/experiments/panel-label/uv.lock new file mode 100644 index 000000000..8b2da4ea6 --- /dev/null +++ b/experiments/panel-label/uv.lock @@ -0,0 +1,333 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "panel-label" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "scipy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.1" }, + { name = "pillow", specifier = ">=11.0" }, + { name = "scipy", specifier = ">=1.14" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[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" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] diff --git a/experiments/research-handoff/README.md b/experiments/research-handoff/README.md new file mode 100644 index 000000000..9b0cc2b61 --- /dev/null +++ b/experiments/research-handoff/README.md @@ -0,0 +1,13 @@ +# Reusable prototypes from the live-survey research + +These isolated experiments accompany the [research handoff](../../docs/06-research-handoff.md). They do not change the iOS app, server, web client or team-owned branches. + +| Package | Purpose | State | +| --- | --- | --- | +| [capture-admission](capture-admission/README.md) | Credit progress only after the actual returned evidence is durably admitted under valid task/coordinate revisions | Executed synthetic prototype; see package test results | +| [equipment-association](equipment-association/README.md) | Keep observed labels, equipment candidates and reviewed associations distinct | Executed synthetic prototype; see package test results | +| [native-proposals](native-proposals/README.md) | Narrow stale-task fix and separate ground-coverage replay helper | Reset change tested in an isolated harness; ground helper prepared only; neither integrated | + +Python packages use the standard library and temporary synthetic data. Their READMEs give exact commands and boundaries. Native patches target a pinned public source revision and are not automatically applied or built. + +Downloaded weights/media, customer information, private criteria, build caches and local run receipts are intentionally excluded. Aggregate historical outcomes and failures remain visible in the [evidence report](../../docs/research/2026-09-26-experiment-evidence.md). diff --git a/experiments/research-handoff/capture-admission/.gitignore b/experiments/research-handoff/capture-admission/.gitignore new file mode 100644 index 000000000..4ebc28107 --- /dev/null +++ b/experiments/research-handoff/capture-admission/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +test-receipt.json diff --git a/experiments/research-handoff/capture-admission/README.md b/experiments/research-handoff/capture-admission/README.md new file mode 100644 index 000000000..7e51e6aac --- /dev/null +++ b/experiments/research-handoff/capture-admission/README.md @@ -0,0 +1,15 @@ +# Capture admission prototype + +A requested photo must not become completed capture progress until the returned bytes, returned-frame metadata and task assessment commit together. This standalone SQLite prototype separates capture requests, durable evidence, current requirement support and server acknowledgment. + +The published version includes the reviewed fixes: persistent epoch history, current-epoch spatial admission, strict runtime types, transactional requirement updates, consistent read snapshots and refusal to publish uncommitted progress. Earlier tests missed epoch reuse/future-epoch activation and a two-writer revision race; this export contains the corrected implementation, not that historical version. + +Run from the repository root with Python 3.10+ on macOS or Linux; only the standard library is used: + +```sh +python3 -B -m unittest discover -s experiments/research-handoff/capture-admission -p 'test_*.py' -v +``` + +Export validation on Python 3.13.7: **26 tests passed** (19 producer controls plus 7 existing independent regression controls). The tests use temporary databases, scheduled concurrent connections and one owned subprocess killed before commit. The regression module retains the existing test bodies with the original review-output/inventory wrapper removed. The implementation and producer test bodies are unchanged. Running `test_admission.py` directly also writes an ignored local receipt. + +`usable` and task support are supplied assessments; this code does not establish photo quality, OCR, geometry or installation eligibility. Progress is distinct from upload completion and independent-view count. Matching a local hash/length does not prove remote durable storage. SQLite process-crash rollback at one seam is not a phone power-loss guarantee. The byte-BLOB store, orphan recovery and schema are experiment choices, with no production migration or native camera adapter. No app/server integration, customer media or business thresholds are included. diff --git a/experiments/research-handoff/capture-admission/admission.py b/experiments/research-handoff/capture-admission/admission.py new file mode 100644 index 000000000..8ed9e022c --- /dev/null +++ b/experiments/research-handoff/capture-admission/admission.py @@ -0,0 +1,223 @@ +"""Synthetic capture-admission reference; upstream assessment is NOT verified here.""" +from __future__ import annotations + +import hashlib +import json +import math +import sqlite3 +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + + +class Conflict(ValueError): + pass + + +def valid_id(value): + return isinstance(value, str) and bool(value.strip()) + + +@dataclass(frozen=True) +class ReturnedStill: + frame_id: str + timestamp: float + epoch: str + tracking: str + usable: bool + # Pairs are (requirement ID, exact assessed revision), not inferred from request. + supports: tuple[tuple[str, int], ...] + + def encoded(self) -> str: + if not valid_id(self.frame_id) or not valid_id(self.epoch): + raise ValueError("Returned-frame identity/epoch must be nonempty strings") + if type(self.timestamp) not in (int, float) or not math.isfinite(self.timestamp) or self.timestamp < 0: + raise ValueError("Returned-frame identity/epoch/finite timestamp required") + if not isinstance(self.tracking, str) or self.tracking not in {"normal", "limited", "unavailable"}: + raise ValueError("Unknown tracking state") + if type(self.usable) is not bool: + raise ValueError("Assessment must explicitly state usable") + if not isinstance(self.supports, tuple) or any( + not isinstance(pair, tuple) or len(pair) != 2 or not valid_id(pair[0]) + or type(pair[1]) is not int or pair[1] < 1 for pair in self.supports + ): + raise ValueError("Invalid assessed requirement revision") + d = asdict(self) + d["timestamp"] = float(self.timestamp) + d["supports"] = sorted(set(self.supports)) + return json.dumps(d, sort_keys=True, separators=(",", ":"), allow_nan=False) + + +class Ledger: + def __init__(self, path: Path): + self.db = sqlite3.connect(path, isolation_level=None) + self.db.execute("PRAGMA foreign_keys=ON") + self.db.execute("PRAGMA journal_mode=WAL") + self.db.execute("PRAGMA synchronous=FULL") + self.db.executescript(""" + CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS epochs(id TEXT PRIMARY KEY); + INSERT OR IGNORE INTO epochs SELECT value FROM settings WHERE key='epoch'; + CREATE TABLE IF NOT EXISTS requirements( + id TEXT PRIMARY KEY, revision INTEGER NOT NULL, kind TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS requests( + id TEXT PRIMARY KEY, target TEXT NOT NULL, revision INTEGER NOT NULL, + preview_frame TEXT NOT NULL, epoch TEXT NOT NULL, status TEXT NOT NULL, + still_metadata TEXT, asset_hash TEXT); + CREATE UNIQUE INDEX IF NOT EXISTS one_pending_capture + ON requests(status) WHERE status='pending'; + CREATE TABLE IF NOT EXISTS assets( + hash TEXT PRIMARY KEY, payload BLOB NOT NULL, server_accepted INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS support( + capture_id TEXT NOT NULL REFERENCES requests(id), + requirement_id TEXT NOT NULL, revision INTEGER NOT NULL, + PRIMARY KEY(capture_id, requirement_id, revision)); + """) + + def close(self): + self.db.close() + + def _transaction(self, operation): + self.db.execute("BEGIN IMMEDIATE") + try: + result = operation() + self.db.execute("COMMIT") + return result + except BaseException: + self.db.execute("ROLLBACK") + raise + + def _read_view(self, operation): + if self.db.in_transaction: + raise Conflict("Progress is unavailable inside an uncommitted write") + self.db.execute("BEGIN") + try: + result = operation() + self.db.execute("COMMIT") + return result + except BaseException: + self.db.execute("ROLLBACK") + raise + + def set_epoch(self, epoch: str): + """Start a fresh coordinate epoch. Re-registration into old maps is not modeled.""" + if not valid_id(epoch): + raise ValueError("Epoch is required") + def op(): + current = self.db.execute("SELECT value FROM settings WHERE key='epoch'").fetchone() + if current and current[0] == epoch: + return + if self.db.execute("SELECT 1 FROM epochs WHERE id=?", (epoch,)).fetchone(): + raise Conflict("Epoch ID cannot be reused without explicit registration") + self.db.execute("INSERT INTO epochs VALUES(?)", (epoch,)) + self.db.execute("INSERT OR REPLACE INTO settings VALUES('epoch',?)", (epoch,)) + self._transaction(op) + + def require(self, key: str, revision: int, kind: str): + if not valid_id(key) or type(revision) is not int or revision < 1 or not isinstance(kind, str) or kind not in {"detail", "spatial"}: + raise ValueError("Invalid requirement") + def op(): + old = self.db.execute("SELECT revision,kind FROM requirements WHERE id=?", (key,)).fetchone() + if old and (revision < old[0] or (revision == old[0] and kind != old[1])): + raise Conflict("A changed requirement needs a newer revision") + self.db.execute("INSERT OR REPLACE INTO requirements VALUES(?,?,?)", (key, revision, kind)) + self._transaction(op) + + def request(self, capture_id: str, target: str, preview_frame: str): + if not all(valid_id(x) for x in (capture_id, target, preview_frame)): + raise ValueError("Capture and preview IDs required") + def op(): + task = self.db.execute("SELECT revision FROM requirements WHERE id=?", (target,)).fetchone() + epoch = self.db.execute("SELECT value FROM settings WHERE key='epoch'").fetchone() + if not task or not epoch: + raise ValueError("Current requirement and epoch required") + identity = (target, task[0], preview_frame, epoch[0]) + old = self.db.execute("SELECT target,revision,preview_frame,epoch FROM requests WHERE id=?", (capture_id,)).fetchone() + if old: + if old != identity: + raise Conflict("Capture ID was reused for different intent") + return "duplicate_request" + self.db.execute("INSERT INTO requests(id,target,revision,preview_frame,epoch,status) VALUES(?,?,?,?,?,'pending')", (capture_id, *identity)) + return "requested" + return self._transaction(op) + + def receive(self, capture_id: str, payload: bytes, still: ReturnedStill, + before_commit: Callable[[], None] | None = None): + """Fault hook is an experiment seam. Payload is opaque; no quality/OCR is inferred.""" + if not valid_id(capture_id) or not isinstance(still, ReturnedStill): + raise ValueError("Capture ID and returned-still record required") + if not isinstance(payload, bytes) or not payload: + raise ValueError("Nonempty returned bytes required") + metadata = still.encoded() + digest = hashlib.sha256(payload).hexdigest() + def op(): + prior = self.db.execute("SELECT status,still_metadata,asset_hash FROM requests WHERE id=?", (capture_id,)).fetchone() + if not prior: + raise Conflict("Unknown capture callback") + status, old_metadata, old_hash = prior + if status in {"admitted", "rejected"}: + if (old_metadata, old_hash) != (metadata, digest): + raise Conflict("Conflicting callback for completed capture") + return "duplicate_callback" + if status != "pending": + raise Conflict("Native capture was interrupted; callback requires explicit recovery") + new_status = "admitted" if still.usable else "rejected" + # Retain source bytes for either decision in this tiny audit prototype. + self.db.execute("INSERT OR IGNORE INTO assets(hash,payload) VALUES(?,?)", (digest, payload)) + self.db.execute("UPDATE requests SET status=?,still_metadata=?,asset_hash=? WHERE id=?", (new_status, metadata, digest, capture_id)) + if still.usable: + for key, revision in set(still.supports): + # An unrecognized assessment must not retroactively satisfy a + # requirement introduced later under the same identifier. + self.db.execute("""INSERT INTO support + SELECT ?,id,revision FROM requirements WHERE id=? AND revision=? + AND (kind='detail' OR (kind='spatial' AND ?='normal' + AND ?=(SELECT value FROM settings WHERE key='epoch')))""", + (capture_id, key, revision, still.tracking, still.epoch)) + if before_commit: + before_commit() + return new_status + return self._transaction(op) + + def recover_orphaned_requests(self): + """Called only after the app establishes no corresponding native operations survive.""" + return self.db.execute("UPDATE requests SET status='interrupted' WHERE status='pending'").rowcount + + def acknowledge(self, digest: str, byte_count: int): + if not valid_id(digest): + raise Conflict("Asset hash must be a string") + def op(): + asset = self.db.execute("SELECT length(payload) FROM assets WHERE hash=?", (digest,)).fetchone() + if type(byte_count) is not int or not asset or byte_count != asset[0]: + raise Conflict("Server acceptance does not identify a committed asset") + self.db.execute("UPDATE assets SET server_accepted=1 WHERE hash=?", (digest,)) + self._transaction(op) + + def progress(self): + return self._read_view(self._progress) + + def _progress(self): + epoch = self.db.execute("SELECT value FROM settings WHERE key='epoch'").fetchone() + result = {} + for key, revision, kind in self.db.execute("SELECT id,revision,kind FROM requirements ORDER BY id").fetchall(): + candidates = self.db.execute("""SELECT r.asset_hash,r.still_metadata FROM support s + JOIN requests r ON r.id=s.capture_id JOIN assets a ON a.hash=r.asset_hash + WHERE s.requirement_id=? AND s.revision=? AND r.status='admitted'""", (key, revision)).fetchall() + assets = set() + for digest, encoded in candidates: + frame = json.loads(encoded) + if kind == "spatial" and (not epoch or frame["epoch"] != epoch[0] or frame["tracking"] != "normal"): + continue + assets.add(digest) + result[key] = {"revision": revision, "kind": kind, "captured": bool(assets), + "unique_assets": len(assets), "spatial_epoch": epoch[0] if epoch and kind == "spatial" else None} + return result + + def snapshot(self): + def read(): + progress = self._progress() + epoch = self.db.execute("SELECT value FROM settings WHERE key='epoch'").fetchone() + return {"progress": progress, "epoch": epoch[0] if epoch else None, + "requests": self.db.execute("SELECT id,status,preview_frame,still_metadata,asset_hash FROM requests ORDER BY id").fetchall(), + "assets": self.db.execute("SELECT hash,length(payload),server_accepted FROM assets ORDER BY hash").fetchall()} + return self._read_view(read) diff --git a/experiments/research-handoff/capture-admission/test_admission.py b/experiments/research-handoff/capture-admission/test_admission.py new file mode 100644 index 000000000..43527e4d6 --- /dev/null +++ b/experiments/research-handoff/capture-admission/test_admission.py @@ -0,0 +1,307 @@ +"""Fault controls for the protocol, not perception or installation tests.""" +from dataclasses import replace +import hashlib +import json +from pathlib import Path +import signal +import sqlite3 +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +from admission import Conflict, Ledger, ReturnedStill + + +def still(**changes): + base = ReturnedStill("actual-frame-107", 10.7, "epoch-a", "normal", True, + (("detail-a", 1), ("wall-a", 1))) + return replace(base, **changes) + + +class AdmissionControls(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "survey.sqlite" + self.db = Ledger(self.path) + self.db.set_epoch("epoch-a") + self.db.require("detail-a", 1, "detail") + self.db.require("detail-b", 1, "detail") + self.db.require("wall-a", 1, "spatial") + self.db.request("capture-1", "detail-a", "preview-frame-100") + + def tearDown(self): + self.db.close() + self.tmp.cleanup() + + def reopen(self): + self.db.close() + self.db = Ledger(self.path) + + def test_good_preview_bad_returned_still(self): + # The request itself represents an eligible preview, never an admission. + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + self.assertEqual(self.db.receive("capture-1", b"blurry source", still(usable=False)), "rejected") + self.assertFalse(any(v["captured"] for v in self.db.progress().values())) + self.assertEqual(len(self.db.snapshot()["assets"]), 1) + + def test_actual_still_metadata_and_local_progress_before_upload(self): + self.db.receive("capture-1", b"actual source", still()) + record = self.db.snapshot()["requests"][0] + self.assertEqual(record[2], "preview-frame-100") + self.assertEqual(json.loads(record[3])["frame_id"], "actual-frame-107") + self.assertEqual(json.loads(record[3])["timestamp"], 10.7) + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + self.assertEqual(self.db.snapshot()["assets"][0][2], 0) + + def test_save_exception_rolls_back_and_retry_admits_once(self): + def fail(): + raise OSError("injected storage failure before commit") + with self.assertRaises(OSError): + self.db.receive("capture-1", b"actual source", still(), fail) + self.reopen() + self.assertEqual(self.db.snapshot()["assets"], []) + self.assertEqual(self.db.snapshot()["requests"][0][1], "pending") + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + self.assertEqual(self.db.receive("capture-1", b"actual source", still()), "admitted") + self.assertEqual(self.db.progress()["detail-a"]["unique_assets"], 1) + + def test_owned_process_killed_before_commit(self): + self.db.close() + marker = Path(self.tmp.name) / "transaction-entered" + proc = subprocess.Popen([sys.executable, __file__, "--crash-worker", str(self.path), str(marker)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 10 + while not marker.exists() and proc.poll() is None and time.monotonic() < deadline: + time.sleep(.02) + self.assertTrue(marker.exists(), "Owned child never reached precommit seam") + proc.kill() + proc.communicate(timeout=3) + self.assertEqual(proc.returncode, -signal.SIGKILL) + finally: + if proc.poll() is None: + proc.kill() + proc.communicate(timeout=3) + self.db = Ledger(self.path) + self.assertEqual(self.db.snapshot()["assets"], []) + self.assertEqual(self.db.snapshot()["requests"][0][1], "pending") + self.assertFalse(any(v["captured"] for v in self.db.progress().values())) + + def test_callback_idempotency_and_conflict_after_restart(self): + self.db.receive("capture-1", b"actual source", still()) + self.reopen() + before = self.db.snapshot() + self.assertEqual(self.db.receive("capture-1", b"actual source", still()), "duplicate_callback") + for payload, frame in [(b"different source", still()), (b"actual source", still(timestamp=10.8))]: + with self.assertRaises(Conflict): + self.db.receive("capture-1", payload, frame) + self.assertEqual(self.db.snapshot(), before) + + def test_other_current_requirement_does_not_inherit_requested_target(self): + # Actual assessment supports B even though preview/request was for A. + self.db.receive("capture-1", b"actual B detail", still(supports=(("detail-b", 1),))) + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + self.assertTrue(self.db.progress()["detail-b"]["captured"]) + + def test_reset_preserves_details_and_invalidates_old_spatial_progress(self): + self.db.receive("capture-1", b"actual source", still()) + self.assertTrue(self.db.progress()["wall-a"]["captured"]) + original_assets = self.db.snapshot()["assets"] + self.db.set_epoch("epoch-b") + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.assertEqual(self.db.snapshot()["assets"], original_assets) + + def test_old_epoch_callback_and_limited_tracking_are_not_spatial_evidence(self): + self.db.set_epoch("epoch-b") + self.db.receive("capture-1", b"old epoch still", still()) + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.db.request("capture-2", "wall-a", "preview-b") + self.db.receive("capture-2", b"limited still", still(epoch="epoch-b", tracking="limited")) + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + + def test_requirement_revision_and_unknown_target_do_not_reuse_support(self): + self.db.receive("capture-1", b"actual source", still(supports=(("detail-a", 1), ("future-detail", 1)))) + self.db.require("detail-a", 2, "detail") + self.db.require("future-detail", 1, "detail") + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + self.assertFalse(self.db.progress()["future-detail"]["captured"]) + with self.assertRaises(Conflict): + self.db.require("detail-a", 1, "detail") + + def test_empty_assessment_does_not_complete_request(self): + self.db.receive("capture-1", b"unhelpful but sharp", still(supports=())) + self.assertFalse(any(v["captured"] for v in self.db.progress().values())) + + def test_server_acceptance_matches_exact_committed_hash_and_length(self): + data = b"actual source" + self.db.receive("capture-1", data, still()) + digest = hashlib.sha256(data).hexdigest() + for key, size in [("0" * 64, len(data)), (digest, len(data) + 1), (digest, True)]: + with self.assertRaises(Conflict): + self.db.acknowledge(key, size) + self.assertEqual(self.db.snapshot()["assets"][0][2], 0) + self.db.acknowledge(digest, len(data)) + self.db.acknowledge(digest, len(data)) + self.assertEqual(self.db.snapshot()["assets"][0][2], 1) + + def test_identical_asset_under_two_requests_has_one_unique_asset(self): + self.db.receive("capture-1", b"same bytes", still()) + self.db.request("capture-2", "detail-a", "preview-frame-200") + self.db.receive("capture-2", b"same bytes", still(frame_id="actual-frame-207", timestamp=20.7)) + self.assertEqual(len(self.db.snapshot()["assets"]), 1) + self.assertEqual(self.db.progress()["detail-a"]["unique_assets"], 1) + self.assertEqual(len(self.db.snapshot()["requests"]), 2) + + def test_single_inflight_capture_and_explicit_orphan_recovery(self): + with self.assertRaises(sqlite3.IntegrityError): + self.db.request("capture-2", "detail-b", "preview-frame-101") + self.assertEqual(self.db.recover_orphaned_requests(), 1) + self.db.request("capture-2", "detail-b", "preview-frame-200") + with self.assertRaises(Conflict): + self.db.receive("capture-1", b"late callback", still()) + self.assertEqual(self.db.snapshot()["assets"], []) + + def test_prior_epoch_cannot_resurrect_spatial_support(self): + self.db.receive("capture-1", b"actual source", still()) + self.db.set_epoch("epoch-b") + self.reopen() + with self.assertRaises(Conflict): + self.db.set_epoch("epoch-a") + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.db.set_epoch("epoch-b") # Current-epoch assignment is idempotent. + + def test_unknown_future_epoch_does_not_gain_spatial_support_later(self): + self.db.receive("capture-1", b"unregistered future-epoch source", still(epoch="future-epoch")) + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.db.set_epoch("future-epoch") + self.assertFalse(self.db.progress()["wall-a"]["captured"]) + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + + def test_malformed_metadata_cannot_use_sqlite_affinity_to_match_task(self): + self.db.require("1", 1, "detail") + bad = [still(frame_id=["frame"]), still(timestamp=True), still(timestamp=float("nan")), + still(epoch=1), still(supports=((True, 1),)), still(supports=(("1", True),))] + before = self.db.snapshot() + for frame in bad: + with self.assertRaises(ValueError): + self.db.receive("capture-1", b"actual source", frame) + self.assertEqual(self.db.snapshot(), before) + for invalid in (True, 1, ["task"], " "): + with self.assertRaises(ValueError): + self.db.require(invalid, 1, "detail") + self.assertFalse(self.db.progress()["1"]["captured"]) + + def test_two_writers_cannot_regress_requirement_revision(self): + self.db.require("detail-a", 2, "detail") + self.db.receive("capture-1", b"revision2 evidence", still(supports=(("detail-a", 2),))) + ready, go, done = threading.Event(), threading.Event(), threading.Event() + failures = [] + def writer_b(): + other = None + try: + other = Ledger(self.path) + ready.set() + if not go.wait(3): + raise RuntimeError("Writer B was never released") + other.require("detail-a", 3, "detail") + except BaseException as error: + failures.append(repr(error)) + finally: + if other: + other.close() + done.set() + worker = threading.Thread(target=writer_b) + worker.start() + self.assertTrue(ready.wait(3)) + def before_insert(sql): + if sql.startswith("INSERT OR REPLACE INTO requirements"): + # A has already read rev2. Old separate autocommits allowed B to + # install rev3 here, then A overwrote it with rev2. BEGIN IMMEDIATE + # now holds the write reservation through the validated write. + go.set() + done.wait(.3) + self.db.db.set_trace_callback(before_insert) + try: + self.db.require("detail-a", 2, "detail") + finally: + self.db.db.set_trace_callback(None) + go.set() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(failures, []) + self.assertEqual(self.db.progress()["detail-a"]["revision"], 3) + self.assertFalse(self.db.progress()["detail-a"]["captured"]) + + def test_progress_cannot_publish_uncommitted_admission(self): + def before_commit(): + with self.assertRaises(Conflict): + self.db.progress() + with self.assertRaises(Conflict): + self.db.snapshot() + self.db.receive("capture-1", b"actual source", still(), before_commit) + self.assertTrue(self.db.progress()["detail-a"]["captured"]) + + def test_snapshot_is_one_read_view_during_concurrent_epoch_and_revision_changes(self): + self.db.receive("capture-1", b"actual source", still()) + ready, go, done = threading.Event(), threading.Event(), threading.Event() + errors = [] + def change_state(): + other = None + try: + other = Ledger(self.path) + ready.set() + if not go.wait(3): + raise RuntimeError("Writer was not released") + other.set_epoch("epoch-b") + other.require("detail-a", 2, "detail") + except BaseException as e: + errors.append(repr(e)) + finally: + if other: + other.close() + done.set() + worker = threading.Thread(target=change_state) + worker.start() + self.assertTrue(ready.wait(3)) + def change_after_epoch_read(sql): + if sql.startswith("SELECT id,revision,kind FROM requirements"): + go.set() + done.wait(2) + self.db.db.set_trace_callback(change_after_epoch_read) + try: + old_view = self.db.snapshot() + finally: + self.db.db.set_trace_callback(None) + go.set() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(old_view["epoch"], "epoch-a") + self.assertEqual(old_view["progress"]["detail-a"]["revision"], 1) + self.assertTrue(old_view["progress"]["wall-a"]["captured"]) + current = self.db.snapshot() + self.assertEqual(current["epoch"], "epoch-b") + self.assertEqual(current["progress"]["detail-a"]["revision"], 2) + self.assertFalse(current["progress"]["wall-a"]["captured"]) + + +if __name__ == "__main__": + if len(sys.argv) == 4 and sys.argv[1] == "--crash-worker": + ledger = Ledger(Path(sys.argv[2])) + def freeze_before_commit(): + Path(sys.argv[3]).write_text("inside uncommitted admission transaction") + signal.pause() + ledger.receive("capture-1", b"actual source", still(), freeze_before_commit) + raise SystemExit("The owned worker should be killed before commit") + suite = unittest.defaultTestLoader.loadTestsFromTestCase(AdmissionControls) + result = unittest.TextTestRunner(verbosity=2).run(suite) + receipt = {"tests_run": result.testsRun, "failures": len(result.failures), "errors": len(result.errors), + "passed": result.wasSuccessful(), "scope": "synthetic admission protocol and SQLite process-crash rollback, no camera/perception/physical accuracy", + "python": sys.version, "sqlite": sqlite3.sqlite_version} + Path(__file__).with_name("test-receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/experiments/research-handoff/capture-admission/test_regressions.py b/experiments/research-handoff/capture-admission/test_regressions.py new file mode 100644 index 000000000..ac4053a19 --- /dev/null +++ b/experiments/research-handoff/capture-admission/test_regressions.py @@ -0,0 +1,274 @@ +"""Existing independent synthetic regression controls, packaged without review tooling.""" +from dataclasses import replace +import hashlib +import sqlite3 +import tempfile +import threading +import time +import unittest +from pathlib import Path + +from admission import Conflict, Ledger, ReturnedStill + +TEMP = None +DETAILS = {} + + +def frame(**changes): + return replace(ReturnedStill("actual-1", 1.2, "epoch-a", "normal", True, + (("detail", 1), ("wall", 2))), **changes) + + +class FixControls(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(dir=TEMP) + self.path = Path(self.tmp.name) / "ledger.sqlite" + self.db = Ledger(self.path) + self.db.set_epoch("epoch-a") + self.db.require("detail", 1, "detail") + self.db.require("wall", 2, "spatial") + self.db.request("capture-1", "wall", "preview-1") + + def tearDown(self): + self.db.close() + self.tmp.cleanup() + + def test_epoch_aba_reopen_and_late_old_epoch(self): + self.db.receive("capture-1", b"first", frame()) + self.assertTrue(self.db.progress()["wall"]["captured"]) + self.db.set_epoch("epoch-b") + self.db.close() + self.db = Ledger(self.path) + before = self.db.snapshot() + with self.assertRaises(Conflict): + self.db.set_epoch("epoch-a") + self.db.set_epoch("epoch-b") + self.assertEqual(before, self.db.snapshot()) + self.db.request("capture-2", "wall", "preview-2") + self.db.receive("capture-2", b"late old epoch", frame(frame_id="actual-2")) + after = self.db.snapshot() + self.assertTrue(after["progress"]["detail"]["captured"]) + self.assertFalse(after["progress"]["wall"]["captured"]) + self.assertEqual(after["progress"]["wall"]["spatial_epoch"], "epoch-b") + self.assertEqual(self.db.db.execute("SELECT requirement_id FROM support WHERE capture_id='capture-2'").fetchall(), [("detail",)]) + DETAILS["epoch_aba"] = {"after_reopen_and_old_epoch_attempt": before, + "late_old_epoch_callback": after} + + def test_unknown_future_epoch_and_retry_stay_ineligible(self): + future = frame(epoch="future-epoch") + self.db.receive("capture-1", b"future", future) + first = self.db.snapshot() + self.db.set_epoch("future-epoch") + self.db.close() + self.db = Ledger(self.path) + self.assertEqual(self.db.receive("capture-1", b"future", future), "duplicate_callback") + after = self.db.snapshot() + self.assertTrue(after["progress"]["detail"]["captured"]) + self.assertFalse(after["progress"]["wall"]["captured"]) + self.assertEqual(self.db.db.execute("SELECT requirement_id FROM support").fetchall(), [("detail",)]) + DETAILS["future_epoch"] = {"before_epoch_introduction": first, + "after_introduction_reopen_identical_retry": after} + + def test_actual_two_connection_revision_race(self): + self.db.receive("capture-1", b"revision 2", frame()) + ready, go, attempted, done = [threading.Event() for _ in range(4)] + failures, events, seam_checks = [], [], [] + def record(name): + events.append({"event": name, "monotonic": time.monotonic()}) + def writer_b(): + db = None + try: + db = Ledger(self.path) + def trace(sql): + if sql == "BEGIN IMMEDIATE": + record("B BEGIN IMMEDIATE attempted") + attempted.set() + db.db.set_trace_callback(trace) + ready.set() + if not go.wait(3): + raise RuntimeError("B not released") + db.require("wall", 3, "spatial") + record("B revision 3 committed") + except BaseException as e: + failures.append(repr(e)) + finally: + if db: + db.close() + done.set() + worker = threading.Thread(target=writer_b) + worker.start() + self.assertTrue(ready.wait(3)) + def at_a_write(sql): + if sql.startswith("INSERT OR REPLACE INTO requirements"): + record("A validated revision 2; write pending") + go.set() + saw_attempt = attempted.wait(2) + committed_while_a_held_write = done.wait(.15) + seam_checks.append({"b_attempted_begin": saw_attempt, + "b_completed_while_a_transaction_open": committed_while_a_held_write, + "a_in_transaction": self.db.db.in_transaction}) + self.db.db.set_trace_callback(at_a_write) + try: + self.db.require("wall", 2, "spatial") + record("A revision 2 committed") + finally: + self.db.db.set_trace_callback(None) + go.set() + worker.join(4) + self.assertFalse(worker.is_alive()) + self.assertEqual(failures, []) + self.assertEqual(seam_checks, [{"b_attempted_begin": True, + "b_completed_while_a_transaction_open": False, + "a_in_transaction": True}]) + after = self.db.progress() + self.assertEqual(after["wall"]["revision"], 3) + self.assertFalse(after["wall"]["captured"]) + with self.assertRaises(Conflict): + self.db.require("wall", 2, "spatial") + DETAILS["two_connection_revision_race"] = {"events": events, "seam_checks": seam_checks, "after": after} + + def test_runtime_types_reject_affinity_alias_and_malformed_shapes(self): + self.db.require("1", 1, "detail") + bad_changes = [ + {"frame_id": ["bad"]}, {"frame_id": True}, {"frame_id": " "}, + {"timestamp": True}, {"timestamp": False}, {"timestamp": "1.0"}, + {"timestamp": float("nan")}, {"timestamp": float("inf")}, {"timestamp": -1}, + {"epoch": 1}, {"epoch": {}}, {"epoch": "\t"}, + {"tracking": ["normal"]}, {"tracking": True}, {"tracking": "unknown"}, + {"usable": 1}, {"usable": "true"}, + {"supports": ((True, 1),)}, {"supports": (("1", True),)}, + {"supports": (("1", 1.0),)}, {"supports": (("1", 0),)}, + {"supports": ((" ", 1),)}, {"supports": ((["1"], 1),)}, + {"supports": [("1", 1)]}, {"supports": (["1", 1],)}, + {"supports": (("1",),)}, {"supports": (("1", 1, "extra"),)}, + {"supports": None}, + ] + before = self.db.snapshot() + cases = [] + for changes in bad_changes: + with self.subTest(changes=repr(changes)): + with self.assertRaises(ValueError): + self.db.receive("capture-1", b"still", frame(**changes)) + self.assertEqual(self.db.snapshot(), before) + cases.append(repr(changes)) + bad_api = [ + lambda: self.db.require(True, 1, "detail"), + lambda: self.db.require("1", True, "detail"), + lambda: self.db.require("x", 1, ["detail"]), + lambda: self.db.set_epoch(True), + lambda: self.db.request(True, "wall", "preview"), + lambda: self.db.request("new", True, "preview"), + lambda: self.db.request("new", "wall", {}), + lambda: self.db.receive(True, b"still", frame()), + lambda: self.db.acknowledge(True, 5), + ] + for i, call in enumerate(bad_api): + with self.subTest(api_case=i): + with self.assertRaises(ValueError): + call() + self.assertEqual(self.db.snapshot(), before) + self.assertFalse(self.db.progress()["1"]["captured"]) + DETAILS["runtime_types"] = {"returned_still_rejections": cases, "public_api_rejections": len(bad_api), "no_snapshot_mutations": True} + + def test_read_view_does_not_mix_concurrent_admission_and_ack(self): + ready, go, done = [threading.Event() for _ in range(3)] + errors, seam_checks = [], [] + payload = b"concurrent still" + def writer(): + db = None + try: + db = Ledger(self.path) + ready.set() + if not go.wait(3): + raise RuntimeError("writer not released") + db.receive("capture-1", payload, frame()) + db.acknowledge(hashlib.sha256(payload).hexdigest(), len(payload)) + except BaseException as e: + errors.append(repr(e)) + finally: + if db: + db.close() + done.set() + thread = threading.Thread(target=writer) + thread.start() + self.assertTrue(ready.wait(3)) + def after_first_epoch_read(sql): + if sql.startswith("SELECT id,revision,kind FROM requirements"): + go.set() + seam_checks.append({"writer_completed_during_reader_transaction": done.wait(3), + "reader_in_transaction": self.db.db.in_transaction}) + self.db.db.set_trace_callback(after_first_epoch_read) + try: + old_view = self.db.snapshot() + finally: + self.db.db.set_trace_callback(None) + go.set() + thread.join(4) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(seam_checks, [{"writer_completed_during_reader_transaction": True, + "reader_in_transaction": True}]) + self.assertFalse(any(v["captured"] for v in old_view["progress"].values())) + self.assertEqual(old_view["requests"][0][1], "pending") + self.assertIsNone(old_view["requests"][0][3]) + self.assertEqual(old_view["assets"], []) + new_view = self.db.snapshot() + self.assertTrue(all(v["captured"] for v in new_view["progress"].values())) + self.assertEqual(new_view["requests"][0][1], "admitted") + self.assertEqual(new_view["assets"][0][2], 1) + DETAILS["read_view_admission_and_ack"] = {"seam_checks": seam_checks, "old_view": old_view, "new_view": new_view} + + def test_precommit_other_connection_view_and_rollback(self): + other = Ledger(self.path) + before = self.db.snapshot() + observed = {} + def before_commit(): + self.assertTrue(self.db.db.in_transaction) + for method in (self.db.progress, self.db.snapshot): + with self.assertRaises(Conflict): + method() + self.assertTrue(self.db.db.in_transaction) + observed["other_connection_during_write"] = other.snapshot() + self.assertEqual(observed["other_connection_during_write"], before) + raise OSError("independent rollback after failed publication attempts") + try: + with self.assertRaises(OSError): + self.db.receive("capture-1", b"rollback", frame(), before_commit) + self.assertFalse(self.db.db.in_transaction) + self.assertEqual(self.db.snapshot(), before) + self.assertEqual(other.snapshot(), before) + self.assertEqual(self.db.receive("capture-1", b"rollback", frame()), "admitted") + observed["other_connection_after_commit"] = other.snapshot() + self.assertTrue(observed["other_connection_after_commit"]["progress"]["wall"]["captured"]) + finally: + other.close() + DETAILS["precommit_publication_and_rollback"] = observed + + def test_failed_read_rolls_back_read_transaction(self): + self.db.receive("capture-1", b"saved", frame()) + before = self.db.snapshot() + denials = [] + def authorizer(action, table, column, database, source): + if action == sqlite3.SQLITE_READ and table == "assets": + denials.append({"table": table, "column": column, + "inside_transaction": self.db.db.in_transaction}) + return sqlite3.SQLITE_DENY + return sqlite3.SQLITE_OK + self.db.db.set_authorizer(authorizer) + try: + with self.assertRaises(sqlite3.DatabaseError) as caught: + self.db.snapshot() + error = str(caught.exception) + finally: + self.db.db.set_authorizer(None) + self.assertTrue(denials) + self.assertTrue(all(x["inside_transaction"] for x in denials)) + self.assertFalse(self.db.db.in_transaction) + self.assertEqual(self.db.snapshot(), before) + self.db.require("detail", 2, "detail") + self.assertFalse(self.db.progress()["detail"]["captured"]) + DETAILS["failed_read_cleanup"] = {"denials": denials, "error": error, "transaction_cleared": True, "subsequent_read_write_succeeded": True} + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/experiments/research-handoff/equipment-association/.gitignore b/experiments/research-handoff/equipment-association/.gitignore new file mode 100644 index 000000000..c18dd8d83 --- /dev/null +++ b/experiments/research-handoff/equipment-association/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/experiments/research-handoff/equipment-association/README.md b/experiments/research-handoff/equipment-association/README.md new file mode 100644 index 000000000..1d91654e5 --- /dev/null +++ b/experiments/research-handoff/equipment-association/README.md @@ -0,0 +1,15 @@ +# Equipment evidence association prototype + +A continuing tracker UUID does not establish that a label belongs to the intended enclosure. This standalone, in-memory event reducer preserves source observations, requires explicit contextual nomination and per-label review records, and makes superseded attachments historical after revisions, contradictions or resets. + +The published implementation is **fix v2**. It permits at most one ticket per candidate/label/candidate-revision, across all ticket states. An earlier version allowed a sibling ticket's late decision to overrule newer same-pair intent. The corrected version refuses that duplicate atomically; exact event replay remains idempotent. The same unresolved ticket can receive a fresh-revision review, and a new nomination/reset context permits a new ticket while preserving history. + +Run from the repository root with Python 3.10+; only the standard library is used: + +```sh +python3 -B experiments/research-handoff/equipment-association/test_association.py +``` + +Export validation on Python 3.13.7: **11 tests passed**, including four sibling-ticket regressions. The source and synthetic fixture are unchanged from reviewed v2; only the test module's stale preparation-time docstring was updated. The fixture's `cases` list describes the seven original scenarios; the test module includes the four later regressions. + +All photo references, hashes, labels and reviewer actions are invented. No image is loaded. Explicit review fields are software assertions, not verification of a person's inspection or physical equipment identity. Tracker confidence and label text cannot automatically attach evidence. The proposed sidecar is not integrated with the app, scene consumer or capture-admission prototype. Persistence, UI authorization, production concurrency and migration of historical duplicate tickets remain outside this experiment. diff --git a/experiments/research-handoff/equipment-association/association.py b/experiments/research-handoff/equipment-association/association.py new file mode 100644 index 000000000..58eb220a0 --- /dev/null +++ b/experiments/research-handoff/equipment-association/association.py @@ -0,0 +1,235 @@ +"""Prepared evidence-association reducer; software checks do not prove physical identity.""" +from __future__ import annotations +import copy +import hashlib +import json +import math +import re +import threading + + +class Conflict(ValueError): + pass + + +def require(ok, message): + if not ok: + raise Conflict(message) + + +def text(value): + return isinstance(value, str) and bool(value.strip()) + + +def digest(value): + return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) is not None + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) + + +def scope_key(scope): + require(isinstance(scope, dict) and set(scope) == {"session_id", "epoch_id", "sequence_id"}, "scope fields") + require(all(text(v) for v in scope.values()), "scope identifiers") + return canonical(scope) + + +def photo(region): + require(isinstance(region, dict) and set(region) == { + "frame_id", "asset_ref", "asset_sha256", "width", "height", "xywh", "scope" + }, "photo region fields") + require(text(region["frame_id"]) and text(region["asset_ref"]) and digest(region["asset_sha256"]), "photo reference/hash") + require(all(type(region[k]) is int and region[k] > 0 for k in ("width", "height")), "photo dimensions") + box = region["xywh"] + require(isinstance(box, list) and len(box) == 4 and all( + type(v) in (int, float) and math.isfinite(v) for v in box), "finite photo region") + x, y, w, h = box + require(x >= 0 and y >= 0 and w > 0 and h > 0 and x+w <= region["width"] and y+h <= region["height"], "photo region bounds") + scope_key(region["scope"]) + return region + + +class Associations: + """One process-local actor; persist events/state atomically in a real app adapter.""" + def __init__(self, scope): + key = scope_key(scope) + self._lock = threading.RLock() + self._state = {"revision": 0, "scope": copy.deepcopy(scope), "seen_scopes": [key], + "candidates": {}, "labels": {}, "tracks": {}, "nominations": {}, + "tickets": {}, "reviews": {}, "attachments": {}, "events": {}} + + def apply(self, event): + """Apply one event atomically; duplicate exact IDs return the original result.""" + frozen = json.loads(canonical(event)) + require(isinstance(frozen, dict) and set(frozen) == {"id", "type", "data"}, "event fields") + require(text(frozen["id"]) and text(frozen["type"]) and isinstance(frozen["data"], dict), "event identity/data") + encoded = canonical(frozen) + with self._lock: + old = self._state["events"].get(frozen["id"]) + if old: + require(old["event"] == frozen, "event ID reused with different content") + return copy.deepcopy(old["result"]) + state = copy.deepcopy(self._state) + result = self._reduce(state, frozen) + state["revision"] += 1 + result["snapshot_revision"] = state["revision"] + state["events"][frozen["id"]] = {"event": frozen, "sha256": hashlib.sha256(encoded.encode()).hexdigest(), "result": result} + self._state = state + return copy.deepcopy(result) + + @staticmethod + def _reduce(s, e): + kind, d, eid = e["type"], e["data"], e["id"] + def fields(*names): + require(set(d) == set(names), "unexpected/missing event fields") + def known_photo(value): + p = photo(value) + require(scope_key(p["scope"]) in s["seen_scopes"], "unregistered photo scope") + return p + def current_photo(value): + p = known_photo(value) + require(p["scope"] == s["scope"], "context photo is not in current scope") + return p + def candidate(cid, revision=None): + require(cid in s["candidates"], "unknown equipment candidate") + c = s["candidates"][cid] + if revision is not None: + require(type(revision) is int and revision == c["revision"], "stale candidate revision") + return c + def invalidate(cid, reason): + for ticket in s["tickets"].values(): + if ticket["candidate_id"] == cid and ticket["status"] in {"needs_context", "needs_review", "unresolved"}: + ticket.update(status="stale", reason=reason, revision=ticket["revision"]+1) + + if kind == "create_candidate": + fields("candidate_id", "description") + require(text(d["candidate_id"]) and text(d["description"]), "candidate description") + require(d["candidate_id"] not in s["candidates"], "candidate ID already exists") + s["candidates"][d["candidate_id"]] = {"id": d["candidate_id"], "description": d["description"], "revision": 0, "status": "needs_context", "nomination_id": None, "contradictions": []} + return {"status": "needs_context", "reason": "context_photo_required"} + if kind in {"record_label", "record_track"}: + if kind == "record_label": + fields("observation_id", "text", "producer", "raw_result_ref", "raw_result_sha256", "photo") + require(isinstance(d["text"], str) and text(d["producer"]) and text(d["raw_result_ref"]) and digest(d["raw_result_sha256"]), "label source fields") + table = s["labels"] + else: + fields("observation_id", "tracker_uuid", "confidence", "photo") + require(text(d["tracker_uuid"]) and type(d["confidence"]) in (int,float) and math.isfinite(d["confidence"]) and 0 <= d["confidence"] <= 1, "tracker diagnostics") + table = s["tracks"] + require(text(d["observation_id"]) and d["observation_id"] not in table, "observation ID reused") + known_photo(d["photo"]) + table[d["observation_id"]] = copy.deepcopy(d) + return {"status": "source_saved", "reason": "no_equipment_attachment_inferred"} + if kind == "nominate_context": + fields("candidate_id", "expected_candidate_revision", "actor", "reason", "photo", "alternatives", "considered_contradictions") + c = candidate(d["candidate_id"], d["expected_candidate_revision"]) + require(text(d["actor"]) and text(d["reason"]), "explicit context reviewer required") + current_photo(d["photo"]) + require(isinstance(d["alternatives"], list) and len(set(d["alternatives"])) == len(d["alternatives"]) and all(x in s["candidates"] and x != c["id"] for x in d["alternatives"]), "inspectable alternatives") + require(isinstance(d["considered_contradictions"], list) and sorted(d["considered_contradictions"]) == sorted(c["contradictions"]), "explicitly consider outstanding contradictions") + invalidate(c["id"], "context_changed") + c.update(revision=c["revision"]+1, status="context_nominated", nomination_id=eid, contradictions=[]) + s["nominations"][eid] = {**copy.deepcopy(d), "candidate_revision": c["revision"], "scope": copy.deepcopy(s["scope"])} + return {"status": "context_nominated", "candidate_revision": c["revision"], "nomination_id": eid, "reason": "label_specific_review_still_required"} + if kind == "request_attachment": + fields("ticket_id", "candidate_id", "label_id", "expected_candidate_revision") + c = candidate(d["candidate_id"], d["expected_candidate_revision"]) + require(text(d["ticket_id"]) and d["ticket_id"] not in s["tickets"] and d["label_id"] in s["labels"], "new ticket and saved label required") + # One authority per label/candidate/context; sibling tickets must not + # re-admit an older decision after this pair was resolved elsewhere. + require(not any(t["candidate_id"] == c["id"] and t["label_id"] == d["label_id"] + and t["candidate_revision"] == c["revision"] + for t in s["tickets"].values()), + "ticket already exists for candidate/label/context revision") + status = "needs_review" if c["status"] == "context_nominated" else "needs_context" + ticket = {"id": d["ticket_id"], "candidate_id": c["id"], "label_id": d["label_id"], "candidate_revision": c["revision"], "nomination_id": c["nomination_id"], "scope": copy.deepcopy(s["scope"]), "revision": 1, "status": status, "reason": "review_label_and_context" if status == "needs_review" else "context_photo_required"} + s["tickets"][ticket["id"]] = ticket + return copy.deepcopy(ticket) + if kind == "review_attachment": + fields("ticket_id", "expected_ticket_revision", "expected_candidate_revision", "decision", "actor", "reason", "photo") + require(d["ticket_id"] in s["tickets"] and d["decision"] in {"attach", "unresolved", "reject"} and text(d["actor"]) and text(d["reason"]), "explicit label review required") + known_photo(d["photo"]) + ticket = s["tickets"][d["ticket_id"]];c = candidate(ticket["candidate_id"]) + fresh = (type(d["expected_ticket_revision"]) is int and type(d["expected_candidate_revision"]) is int + and d["expected_ticket_revision"] == ticket["revision"] and d["expected_candidate_revision"] == c["revision"] + and ticket["candidate_revision"] == c["revision"] and ticket["scope"] == s["scope"] + and d["photo"]["scope"] == s["scope"] and ticket["nomination_id"] == c["nomination_id"] + and c["status"] == "context_nominated" and ticket["status"] in {"needs_review", "unresolved"}) + outcome = {"status": "stale_review", "reason": "context_scope_or_review_revision_changed"} + conflicts = set() + if fresh and d["decision"] == "attach": + for link in s["attachments"].values(): + other = candidate(link["candidate_id"]) + if (link["label_id"] == ticket["label_id"] and other["id"] != c["id"] + and other["status"] == "context_nominated" and other["revision"] == link["candidate_revision"] + and link["scope"] == s["scope"]): + conflicts.add(other["id"]) + if conflicts: + # A later incompatible explicit claim does not silently replace the earlier one. + # Both require context review; the original observation and decisions survive. + for cid in conflicts | {c["id"]}: + disputed = candidate(cid) + disputed.update(revision=disputed["revision"]+1, status="contradicted") + disputed["contradictions"].append(eid) + invalidate(cid, "conflicting_current_assignment") + outcome = {"status": "unresolved", "reason": "conflicting_current_assignment"} + s["reviews"][eid] = {**copy.deepcopy(d), "outcome": copy.deepcopy(outcome)} + return outcome + if fresh: + status = {"attach": "attached", "unresolved": "unresolved", "reject": "rejected"}[d["decision"]] + ticket.update(status=status, reason={"attach": "explicit_contextual_review", "unresolved": "association_unresolved", "reject": "review_rejected"}[d["decision"]], revision=ticket["revision"]+1) + outcome = {"status": status, "reason": ticket["reason"], "ticket_revision": ticket["revision"]} + if status == "attached": + s["attachments"][eid] = {"id": eid, "review_id": eid, "ticket_id": ticket["id"], "candidate_id": c["id"], "label_id": ticket["label_id"], "candidate_revision": c["revision"], "nomination_id": c["nomination_id"], "scope": copy.deepcopy(s["scope"])} + s["reviews"][eid] = {**copy.deepcopy(d), "outcome": copy.deepcopy(outcome)} + return outcome + if kind == "contradict": + fields("candidate_id", "expected_candidate_revision", "actor", "reason", "photo") + c = candidate(d["candidate_id"], d["expected_candidate_revision"]) + require(text(d["actor"]) and text(d["reason"]), "contradiction source required") + known_photo(d["photo"]) + c.update(revision=c["revision"]+1, status="contradicted") + c["contradictions"].append(eid);invalidate(c["id"], "association_contradicted") + return {"status": "contradicted", "reason": "context_review_required", "candidate_revision": c["revision"]} + if kind == "reset_scope": + fields("scope", "reason") + key = scope_key(d["scope"]);require(text(d["reason"]) and key not in s["seen_scopes"], "fresh reset scope and reason required") + new = d["scope"];old = s["scope"];seen = [json.loads(x) for x in s["seen_scopes"]] + if new["session_id"] != old["session_id"]: + require(not any(x["session_id"] == new["session_id"] for x in seen), "session ID cannot be resurrected") + elif new["epoch_id"] != old["epoch_id"]: + require(not any(x["session_id"] == new["session_id"] and x["epoch_id"] == new["epoch_id"] for x in seen), "coordinate epoch cannot be resurrected") + s["seen_scopes"].append(key);s["scope"] = copy.deepcopy(d["scope"]) + for c in s["candidates"].values(): + c.update(revision=c["revision"]+1, status="needs_context", nomination_id=None) + invalidate(c["id"], "scope_changed") + return {"status": "needs_context", "reason": "fresh_context_after_reset_required"} + raise Conflict("unsupported event; tracker continuity never attaches labels") + + def snapshot(self): + with self._lock: + s = copy.deepcopy(self._state) + for attachment in s["attachments"].values(): + c = s["candidates"][attachment["candidate_id"]] + current = c["status"] == "context_nominated" and c["revision"] == attachment["candidate_revision"] and s["scope"] == attachment["scope"] + attachment.update(current=current, status="reviewed_current" if current else "historical_only") + for ticket in s["tickets"].values(): + c = s["candidates"][ticket["candidate_id"]] + if ticket["status"] == "attached" and (ticket["scope"] != s["scope"] or ticket["candidate_revision"] != c["revision"]): + ticket.update(status="historical_only", reason="current_context_requires_review") + return s + + def sidecar(self, scene_sha256, expected_snapshot_revision): + """Proposed C1 adjunct only. No C1 object/admission/physical confidence emitted.""" + require(digest(scene_sha256), "exact C1 scene byte hash required") + with self._lock: + require(type(expected_snapshot_revision) is int and expected_snapshot_revision == self._state["revision"], "stale export snapshot") + s = self.snapshot() + return {"schema": "equipment-association-sidecar/1", "scene_sha256": scene_sha256, + "snapshot_revision": s["revision"], "scope": s["scope"], + "coordinate_contract": "Original encoded photo top-left pixel-cell xywh; caller must preserve camera/image transform provenance", + "candidates": s["candidates"], "label_observations": s["labels"], "track_observations": s["tracks"], + "nominations": s["nominations"], "review_tickets": s["tickets"], "reviews": s["reviews"], + "associations": s["attachments"], "events": s["events"], + "not_established": ["physical equipment identity", "photo/exposure provenance", "OCR correctness", "measurement uncertainty", "C1 ingestion compatibility", "capture-admission completion"]} diff --git a/experiments/research-handoff/equipment-association/fixtures.json b/experiments/research-handoff/equipment-association/fixtures.json new file mode 100644 index 000000000..b45226d0d --- /dev/null +++ b/experiments/research-handoff/equipment-association/fixtures.json @@ -0,0 +1,21 @@ +{ + "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}, + "reset_scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-2"}, + "notice": "Synthetic symbolic asset references/hashes only; no genuine photo, OCR or physical identity evidence. Candidate A/B names describe test intentions, not an inference made by the module.", + "photos": { + "context_a": {"frame_id": "frame-context-a", "asset_ref": "synthetic/context-a.jpg", "asset_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "width": 640, "height": 480, "xywh": [100, 80, 180, 250], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}}, + "context_b": {"frame_id": "frame-context-b", "asset_ref": "synthetic/context-b.jpg", "asset_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "width": 640, "height": 480, "xywh": [330, 80, 180, 250], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}}, + "label_a": {"frame_id": "frame-label-a", "asset_ref": "synthetic/label-a.jpg", "asset_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "width": 640, "height": 480, "xywh": [120, 130, 100, 40], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}}, + "label_b": {"frame_id": "frame-label-b", "asset_ref": "synthetic/label-b.jpg", "asset_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "width": 640, "height": 480, "xywh": [350, 130, 100, 40], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-1"}}, + "new_context_a": {"frame_id": "frame-new-context-a", "asset_ref": "synthetic/new-context-a.jpg", "asset_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "width": 640, "height": 480, "xywh": [110, 90, 180, 250], "scope": {"session_id": "session-1", "epoch_id": "epoch-1", "sequence_id": "sequence-2"}} + }, + "cases": [ + {"id": "same_uuid_wrong_target", "expected": "Two high-confidence samples with the same UUID cannot attach a label; explicit unresolved review retains it without a link."}, + {"id": "explicit_review_and_immutable_source", "expected": "Inspectably reviewed per-label attachment is current; caller mutation cannot change saved source data."}, + {"id": "reset_and_late_label", "expected": "Old pending review is stale; late old-scope label remains saved; fresh context/new review is required."}, + {"id": "contradiction_preserves_history", "expected": "Current link becomes historical_only, pending ticket becomes stale, originals remain; contradiction acknowledgement is explicit. A competing explicit current assignment of the same label also makes both candidates disputed without silently replacing history."}, + {"id": "candidate_revision_race", "expected": "Late result for superseded nomination is recorded stale without current attachment."}, + {"id": "review_ticket_revision_race", "expected": "An old attach decision cannot overwrite a newer unresolved/current decision."}, + {"id": "idempotency_and_export_revision", "expected": "Exact event replay is idempotent, changed replay conflicts, old snapshot export and reused scope fail."} + ] +} diff --git a/experiments/research-handoff/equipment-association/test_association.py b/experiments/research-handoff/equipment-association/test_association.py new file mode 100644 index 000000000..61127f8a6 --- /dev/null +++ b/experiments/research-handoff/equipment-association/test_association.py @@ -0,0 +1,182 @@ +"""Eleven synthetic v2 transition cases; no real media or model inputs.""" +import copy +import importlib.util +import json +from pathlib import Path +import sys +import unittest + +ROOT=Path(__file__).resolve().parent +spec=importlib.util.spec_from_file_location('equipment_association_prepared',ROOT/'association.py') +module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) +Associations,Conflict=module.Associations,module.Conflict +F=json.loads((ROOT/'fixtures.json').read_text()) + +class TransitionCases(unittest.TestCase): + def setUp(self): + self.a=Associations(F['scope']);self.serial=0 + for cid in ('A','B'):self.send('create_candidate',candidate_id=cid,description='Synthetic enclosure '+cid) + def send(self,kind,**data): + self.serial+=1 + return self.a.apply({'id':f'event-{self.serial}','type':kind,'data':data}) + def photo(self,name):return copy.deepcopy(F['photos'][name]) + def revision(self):return self.a.snapshot()['candidates']['A']['revision'] + def label(self,oid='label-a',which='label_a'): + return self.send('record_label',observation_id=oid,text='Original label '+oid,producer='synthetic-extractor',raw_result_ref='synthetic/'+oid+'.json',raw_result_sha256='f'*64,photo=self.photo(which)) + def nominate(self,which='context_a',considered=()): + return self.send('nominate_context',candidate_id='A',expected_candidate_revision=self.revision(),actor='reviewer-1',reason='Inspect this contextual photo; no identity probability asserted',photo=self.photo(which),alternatives=['B'],considered_contradictions=list(considered)) + def ticket(self,tid='ticket-a',label='label-a'): + return self.send('request_attachment',ticket_id=tid,candidate_id='A',label_id=label,expected_candidate_revision=self.revision()) + def review(self,ticket,decision='attach',which='context_a'): + return self.send('review_attachment',ticket_id=ticket['id'],expected_ticket_revision=ticket['revision'],expected_candidate_revision=ticket['candidate_revision'],decision=decision,actor='reviewer-1',reason='Explicit inspection of original label and contextual enclosure evidence',photo=self.photo(which)) + def current(self):return [x for x in self.a.snapshot()['attachments'].values() if x['current']] + + def test_same_uuid_wrong_target(self): + self.nominate();self.label('label-b','label_b') + for i,which in enumerate(('context_a','context_b')): + self.send('record_track',observation_id=f'track-{i}',tracker_uuid='unchanged-uuid',confidence=.95,photo=self.photo(which)) + ticket=self.ticket(label='label-b') + self.assertEqual(ticket['status'],'needs_review');self.assertEqual(self.current(),[]) + with self.assertRaises(Conflict):self.send('attach_from_tracker',ticket_id=ticket['id'],tracker_uuid='unchanged-uuid',confidence=.95) + self.assertEqual(self.review(ticket,'unresolved','context_b')['status'],'unresolved') + self.assertEqual(self.current(),[]);self.assertIn('label-b',self.a.snapshot()['labels']) + + def test_explicit_review_and_immutable_source(self): + event={'id':'original-label','type':'record_label','data':{'observation_id':'label-a','text':'SERIAL ORIGINAL','producer':'synthetic','raw_result_ref':'synthetic/original.json','raw_result_sha256':'f'*64,'photo':self.photo('label_a')}} + self.a.apply(event);event['data']['text']='MUTATED';event['data']['photo']['xywh'][0]=999 + self.nominate();self.assertEqual(self.review(self.ticket())['status'],'attached') + view=self.a.snapshot();self.assertEqual(view['labels']['label-a']['text'],'SERIAL ORIGINAL') + self.assertEqual(len(self.current()),1) + view['labels']['label-a']['text']='VIEW MUTATION' + self.assertEqual(self.a.snapshot()['labels']['label-a']['text'],'SERIAL ORIGINAL') + sidecar=self.a.sidecar('0'*64,self.a.snapshot()['revision']) + self.assertEqual(sidecar['schema'],'equipment-association-sidecar/1') + self.assertIn('physical equipment identity',sidecar['not_established']);self.assertNotIn('supports',sidecar) + + def test_reset_and_late_label(self): + self.label();self.nominate();old=self.ticket() + self.send('reset_scope',scope=copy.deepcopy(F['reset_scope']),reason='Tracking reset') + self.label('late-label','label_b') + self.assertEqual(self.review(old)['status'],'stale_review') + self.assertIn('late-label',self.a.snapshot()['labels']);self.assertEqual(self.current(),[]) + self.nominate('new_context_a') + new=self.ticket('new-ticket','late-label') + self.assertEqual(self.review(new,which='new_context_a')['status'],'attached') + self.assertEqual(len(self.current()),1) + + def test_contradiction_preserves_history(self): + self.label();self.label('label-b','label_b');self.nominate();self.review(self.ticket()) + self.ticket('pending-b','label-b') + original=copy.deepcopy(self.a.snapshot()['labels']) + self.send('contradict',candidate_id='A',expected_candidate_revision=self.revision(),actor='reviewer-2',reason='Context suggests a different enclosure',photo=self.photo('context_b')) + snapshot=self.a.snapshot();cid=snapshot['candidates']['A']['contradictions'][0] + self.assertEqual(self.current(),[]);self.assertEqual(snapshot['labels'],original) + self.assertEqual(next(iter(snapshot['attachments'].values()))['status'],'historical_only') + self.assertEqual(snapshot['tickets']['pending-b']['status'],'stale') + with self.assertRaises(Conflict):self.nominate() + self.nominate(considered=[cid]) + self.assertEqual(self.current(),[],'New nomination does not automatically restore historical links') + self.review(self.ticket('reattach-a')) + self.send('nominate_context',candidate_id='B',expected_candidate_revision=0,actor='reviewer-2',reason='Competing explicit context claim',photo=self.photo('context_b'),alternatives=['A'],considered_contradictions=[]) + competing=self.send('request_attachment',ticket_id='competing-b',candidate_id='B',label_id='label-a',expected_candidate_revision=1) + result=self.review(competing,which='context_b') + self.assertEqual(result['reason'],'conflicting_current_assignment') + self.assertEqual(self.current(),[]) + self.assertTrue(all(c['status']=='contradicted' for c in self.a.snapshot()['candidates'].values())) + self.assertEqual(self.a.snapshot()['labels'],original) + + def test_candidate_revision_race(self): + self.label();self.nominate();old=self.ticket() + self.nominate('context_b') + self.assertEqual(self.review(old)['status'],'stale_review') + self.assertEqual(self.current(),[]) + self.assertEqual(self.a.snapshot()['candidates']['A']['status'],'context_nominated') + + def test_review_ticket_revision_race(self): + self.label();self.nominate();old=self.ticket() + self.assertEqual(self.review(old,'unresolved')['status'],'unresolved') + self.assertEqual(self.review(old,'attach')['status'],'stale_review');self.assertEqual(self.current(),[]) + current=self.a.snapshot()['tickets']['ticket-a'] + self.assertEqual(self.review(current,'attach')['status'],'attached') + self.assertEqual(self.review(old,'unresolved')['status'],'stale_review') + self.assertEqual(self.a.snapshot()['tickets']['ticket-a']['status'],'attached') + self.assertEqual(len(self.current()),1) + + def test_idempotency_and_export_revision(self): + event={'id':'sample','type':'record_track','data':{'observation_id':'sample','tracker_uuid':'uuid','confidence':.99,'photo':self.photo('context_a')}} + old_revision=self.a.snapshot()['revision'];first=self.a.apply(event) + self.assertEqual(self.a.apply(copy.deepcopy(event)),first) + event['data']['confidence']=.8 + with self.assertRaises(Conflict):self.a.apply(event) + with self.assertRaises(Conflict):self.a.sidecar('0'*64,old_revision) + self.send('reset_scope',scope=copy.deepcopy(F['reset_scope']),reason='Reset') + with self.assertRaises(Conflict):self.send('reset_scope',scope=copy.deepcopy(F['scope']),reason='Old sequence must not return') + unknown=self.photo('label_a');unknown['scope']['epoch_id']='future' + with self.assertRaises(Conflict):self.send('record_label',observation_id='future',text='saved?',producer='synthetic',raw_result_ref='synthetic/future.json',raw_result_sha256='f'*64,photo=unknown) + self.assertNotIn('future',self.a.snapshot()['labels']) + + def assert_sibling_refused_atomically(self, ticket_id): + before = self.a.snapshot() + with self.assertRaises(Conflict): + self.ticket(ticket_id) + self.assertEqual(self.a.snapshot(), before) + + def test_sibling_ticket_rejection_order(self): + self.label(); self.nominate(); first = self.ticket() + # A queued second form cannot create independent authority before a decision. + self.assert_sibling_refused_atomically('sibling-before-reject') + self.assertEqual(self.review(first, 'reject')['status'], 'rejected') + # Nor can a later request bypass this revision's rejection. + self.assert_sibling_refused_atomically('sibling-after-reject') + self.assertEqual(self.current(), []) + self.nominate('context_b') + fresh = self.ticket('fresh-after-rejected-nomination') + self.assertEqual(self.review(fresh, which='context_b')['status'], 'attached') + self.assertEqual(len(self.current()), 1) + self.assertEqual(self.a.snapshot()['tickets']['ticket-a']['status'], 'rejected') + + def test_sibling_ticket_unresolved_order(self): + self.label(); self.nominate(); first = self.ticket() + self.assert_sibling_refused_atomically('sibling-before-unresolved') + self.assertEqual(self.review(first, 'unresolved')['status'], 'unresolved') + self.assert_sibling_refused_atomically('sibling-after-unresolved') + self.assertEqual(self.current(), []) + current = self.a.snapshot()['tickets']['ticket-a'] + self.assertEqual(self.review(current)['status'], 'attached') + self.assertEqual(len(self.current()), 1) + + def test_attached_ticket_replay_and_new_nomination(self): + self.label(); self.nominate() + event = {'id': 'exact-ticket-request', 'type': 'request_attachment', + 'data': {'ticket_id': 'ticket-a', 'candidate_id': 'A', + 'label_id': 'label-a', 'expected_candidate_revision': self.revision()}} + original_result = self.a.apply(event) + self.assertEqual(self.review(original_result)['status'], 'attached') + before = self.a.snapshot() + self.assertEqual(self.a.apply(copy.deepcopy(event)), original_result) + self.assertEqual(self.a.snapshot(), before, 'Exact replay does not reapply or advance state') + self.assert_sibling_refused_atomically('sibling-after-attached') + self.nominate('context_b') + self.assertEqual(self.current(), []) + fresh = self.ticket('fresh-after-attached-nomination') + self.assertEqual(self.review(fresh, which='context_b')['status'], 'attached') + self.assertEqual(len(self.current()), 1) + links = list(self.a.snapshot()['attachments'].values()) + self.assertEqual(sum(link['status'] == 'historical_only' for link in links), 1) + + def test_same_pair_after_scope_reset(self): + self.label(); self.nominate(); self.review(self.ticket()) + source = copy.deepcopy(self.a.snapshot()['labels']['label-a']) + self.send('reset_scope', scope=copy.deepcopy(F['reset_scope']), reason='New capture scope') + provisional = self.ticket('reset-needs-context') + self.assertEqual(provisional['status'], 'needs_context') + self.nominate('new_context_a') + self.assertEqual(self.a.snapshot()['tickets']['reset-needs-context']['status'], 'stale') + fresh = self.ticket('reset-fresh-context') + self.assertEqual(self.review(fresh, which='new_context_a')['status'], 'attached') + self.assertEqual(len(self.current()), 1) + self.assertEqual(self.a.snapshot()['labels']['label-a'], source) + links = list(self.a.snapshot()['attachments'].values()) + self.assertEqual(sum(link['status'] == 'historical_only' for link in links), 1) + +if __name__=='__main__':unittest.main(verbosity=2) diff --git a/experiments/research-handoff/native-proposals/.gitattributes b/experiments/research-handoff/native-proposals/.gitattributes new file mode 100644 index 000000000..9dff32d1e --- /dev/null +++ b/experiments/research-handoff/native-proposals/.gitattributes @@ -0,0 +1,2 @@ +# Unified patches represent unchanged blank context lines with a single space. +*.patch whitespace=-blank-at-eol diff --git a/experiments/research-handoff/native-proposals/README.md b/experiments/research-handoff/native-proposals/README.md new file mode 100644 index 000000000..9f1589daa --- /dev/null +++ b/experiments/research-handoff/native-proposals/README.md @@ -0,0 +1,27 @@ +# Native patch proposals + +Both patches target exact commit [`a39d0a503412d688dd151f628cdeebc00f8221b6`](https://github.com/SamGu-NRX/house-scanning/commit/a39d0a503412d688dd151f628cdeebc00f8221b6). They are review artifacts, not changes applied to this branch's app. Patch labels are repository-relative; hashes are recorded in [patch-identities.json](patch-identities.json). + +| Patch | Target | Status | +| --- | --- | --- | +| [scan-engine-task-guards.patch](scan-engine-task-guards.patch) | `ios/HouseScan/Runtime/ScanEngine.swift` | Narrow native host cases executed; full iOS integration untested | +| [ground-replay-helper-unrun.patch](ground-replay-helper-unrun.patch) | `ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift` | Prepared only; **UNRUN and unintegrated** | + +The ScanEngine patch refuses obsolete queued keyframe/close-up work before storage starts, and rechecks generation/phase after the thumbnail await before updating current UI/counts. It moves the existing per-generation pending-save decrement into a Task-exit `defer`, so success, failure and early returns drain the captured generation's entry. It preserves this baseline's store epoch and dictionary-based pending counts. + +One Swift 6 host build and one run exercised nine paired cases: **18 observations matched**. Four baseline defect cases reproduced obsolete storage calls or stale post-thumbnail updates; all nine patched cases met their assertions, including five existing-behavior/drain controls. The executable used eight source-bound current/patched method bodies and four explicit completion-signaling overlays, with controlled storage, UI and framework dependencies. This is actual-method host evidence, not a full app/store/JPEG/OCR test. It does not fix producer-frame world provenance, already-running fixed-name still writes, durable capture inventory, export/response correlation or same-generation retake identity. No old scalar-counter patch should be transplanted over the current dictionary design. + +The ground helper proposes synchronous recomputation from a caller-qualified camera ledger in original acceptance order, including observations whose original delta was zero. It retains latent skip flags and marked-end coordinates, discards photographic row state, replays existing geometry methods, and advances revision from the prior map. Unchanged samples and meter-y-only updates preserve current evidence/revision. Changed horizontal geometry, nonfinite heights or a nonzero residual cell shift are refused without mutation. + +**The ground helper is not a complete geometry fix.** Ordinary anchor refinement can leave a residual shift and prevent its use. Carrying that residual into freshly projected evidence, or dropping it while retaining rounded skip cells, would mix coordinate histories. Caller ledger admission, world/session provenance, refusal handling, coherent UI publication and export snapshot integration remain unresolved. This helper does not implement them. Nine source-prepared controls cover fresh-map equivalence, useful zero-delta observations, order dependence, latent skips/bounds and revision collisions; none was compiled or run. It makes no physical-accuracy, occlusion or phone-performance claim. + +To check applicability later, use a separate checkout already at the exact baseline and inspect the output of `git rev-parse HEAD` first. These commands only check whether the patches apply; they do not apply them: + +```sh +git rev-parse HEAD +# Expected: a39d0a503412d688dd151f628cdeebc00f8221b6 +git apply --check /path/to/native-proposals/scan-engine-task-guards.patch +git apply --check /path/to/native-proposals/ground-replay-helper-unrun.patch +``` + +Applicability against another commit requires a fresh source review. No apply check, application, app build or test rerun was performed while packaging this handoff. The export check establishes byte identity and relative labels only. diff --git a/experiments/research-handoff/native-proposals/ground-replay-helper-unrun.patch b/experiments/research-handoff/native-proposals/ground-replay-helper-unrun.patch new file mode 100644 index 000000000..208fdbbd0 --- /dev/null +++ b/experiments/research-handoff/native-proposals/ground-replay-helper-unrun.patch @@ -0,0 +1,59 @@ +--- a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift ++++ b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift +@@ -310,6 +310,56 @@ + revision += 1 + } + ++ /// A narrow ground-replay boundary; does not admit/qualify observations or repair anchor shifts. ++ public enum GroundReplayError: Error { ++ case invalidHeight ++ case horizontalFrameChanged ++ case unresolvedCellShift ++ } ++ ++ /// Re-evaluates all caller-qualified accepted cameras in their original acceptance order. ++ /// The caller owns source-ID deduplication, successful storage, tracking and generation checks. ++ /// Include zero-delta observations. Never pass speculative planner cameras or all stored frames. ++ /// Returns true only when photographic rows were rebuilt; false preserves unchanged samples. ++ /// ++ /// Only groundY and meter.y may differ. Nonzero pendingShift refuses a ground change without ++ /// mutation: existing rounded skip cells have no exact annotation coordinates to reproject. ++ /// A successful rebuild starts a fresh zero-residual grid, retains latent skip flags and ends, ++ /// and publishes revision old+1, independent of how many observe calls changed the fresh map. ++ /// The caller must still publish the map/geometry together, and handle refusal as unresolved. ++ @discardableResult ++ public mutating func replayGround(_ frame: WallFrame, acceptedCameras: [CameraFrame]) throws -> Bool { ++ guard frame.groundY.isFinite, frame.meter.y.isFinite else { ++ throw GroundReplayError.invalidHeight ++ } ++ guard frame.meter.x == wall.meter.x, frame.meter.z == wall.meter.z, ++ frame.outward == wall.outward else { ++ throw GroundReplayError.horizontalFrameChanged ++ } ++ guard frame.groundY != wall.groundY else { ++ wall = frame ++ return false ++ } ++ guard pendingShift == 0 else { throw GroundReplayError.unresolvedCellShift } ++ ++ var rebuilt = CoverageMap(wall: frame, config: config) ++ rebuilt.leftEnd = leftEnd ++ rebuilt.rightEnd = rightEnd ++ for (band, bandCells) in cells { ++ for (index, cell) in bandCells where cell.skipped { ++ var retained = Cell(rowCount: config.rowsPerBand) ++ retained.skipped = true ++ rebuilt.cells[band, default: [:]][index] = retained ++ } ++ } ++ for camera in acceptedCameras { ++ rebuilt.observe(camera, trackingNormal: true) ++ } ++ rebuilt.revision = revision + 1 ++ self = rebuilt ++ return true ++ } ++ + // MARK: Reading + + /// s extent of seen or covered cells, or nil when nothing has been seen. diff --git a/experiments/research-handoff/native-proposals/patch-identities.json b/experiments/research-handoff/native-proposals/patch-identities.json new file mode 100644 index 000000000..8dec3d965 --- /dev/null +++ b/experiments/research-handoff/native-proposals/patch-identities.json @@ -0,0 +1,23 @@ +{ + "scope": "Public-safe patch export only; no production edit or execution.", + "patches": [ + { + "patch": "scan-engine-task-guards.patch", + "baselineCommit": "a39d0a503412d688dd151f628cdeebc00f8221b6", + "target": "ios/HouseScan/Runtime/ScanEngine.swift", + "bytes": 1647, + "sha256": "94eb349baf51a0f72f938b4e3a25e6b547352fd46b1b2bf224270845bf17044d", + "hunks": 3, + "exportCheck": "Byte-identical to reviewed proposal; existing a/ and b/ labels are already relative. No application or compile check performed in handoff." + }, + { + "patch": "ground-replay-helper-unrun.patch", + "baselineCommit": "a39d0a503412d688dd151f628cdeebc00f8221b6", + "target": "ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift", + "bytes": 2778, + "sha256": "cfb471608f05f0bc724a52ee518469c5e0e2f050d797b10787c5dbbad049690c", + "hunks": 1, + "exportCheck": "Byte-identical to reviewed proposal; existing a/ and b/ labels are already relative. No application or compile check performed in handoff." + } + ] +} diff --git a/experiments/research-handoff/native-proposals/scan-engine-task-guards.patch b/experiments/research-handoff/native-proposals/scan-engine-task-guards.patch new file mode 100644 index 000000000..5166fb9f4 --- /dev/null +++ b/experiments/research-handoff/native-proposals/scan-engine-task-guards.patch @@ -0,0 +1,33 @@ +--- a/ios/HouseScan/Runtime/ScanEngine.swift ++++ b/ios/HouseScan/Runtime/ScanEngine.swift +@@ -280,6 +280,7 @@ + let scan = generation + let store = store + Task { ++ guard scan == generation, state.phase == .meterCloseUp else { return } + let saved = await store.saveStill(frame.jpeg, name: "meter_close.jpg") + guard scan == generation, state.phase == .meterCloseUp else { return } + if !saved { +@@ -289,6 +290,7 @@ + return + } + let thumbnail = await store.thumbnail(ofStill: "meter_close.jpg") ++ guard scan == generation, state.phase == .meterCloseUp else { return } + state.closeUp = .captured(thumbnail) + state.captureCount += 1 + state.lastCapture = CaptureEvent(id: state.captureCount, kind: .closeUp, thumbnail: thumbnail) +@@ -368,9 +370,12 @@ + let store = store + pendingSaves[scan, default: 0] += 1 + Task { ++ defer { ++ let left = (pendingSaves[scan] ?? 1) - 1 ++ pendingSaves[scan] = left > 0 ? left : nil ++ } ++ guard scan == generation else { return } + let saved = await store.saveKeyframe(frame.jpeg, index: index, camera: frame.camera) +- let left = (pendingSaves[scan] ?? 1) - 1 +- pendingSaves[scan] = left > 0 ? left : nil + guard scan == generation, saved.stored else { return } + // Coverage only moves on kept frames with normal tracking (checklist R3). + coverage?.observe(frame.camera, trackingNormal: frame.tracking == .normal) diff --git a/experiments/scoring/.python-version b/experiments/scoring/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/experiments/scoring/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/experiments/scoring/README.md b/experiments/scoring/README.md new file mode 100644 index 000000000..b64eca601 --- /dev/null +++ b/experiments/scoring/README.md @@ -0,0 +1,185 @@ +# Scoring capture pipelines against a tape survey + +Given an independent tape survey of a house and each capture pipeline's measurements of the same named distances and candidate spots: how large are the errors, how close are they to the thresholds that decide each check, and which outputs are missing or unsafe? + +`score` answers that for any number of houses and pipeline runs. It does not run pipelines; each one writes a results file. The fieldwork protocol (what to survey, gates, freezing the rules) is the research note `docs/research/t3-scoring-protocol.md`. + +## Commands + +```sh +cd experiments/scoring +uv sync --locked +uv run score --rules fixtures/rules.json \ + --truth fixtures/truth/synthetic-01.json \ + --results fixtures/results/ar-taps.json fixtures/results/photo-depth.json fixtures/results/mesh-scaled.json +``` + +`score` prints a markdown summary and writes `measurements.csv`, `checks.csv` and `runs.csv` to `--out` (default `data/`, which git ignores). `--truth` and `--results` take several files. Any input problem stops the run with exit code 2 and a message naming the file, the field and the fix, and no CSV is written. + +CI runs `uv run ruff check .`, `uv run ruff format --check .` and `uv run pytest -q`. + +Everything in `fixtures/` is synthetic. Keep real surveys and results beside the capture in `captures/` or `data/`, never in git. + +## Input formats + +All three files are JSON starting with `"format": 1` and `"unit": "ft"`. Numbers are read as exact decimals, so ties with a threshold resolve the same way every time: in floats 3.1 − 3.0 is 0.10000000000000009, which would pass a 3.1 ± 0.1 ft clearance against a 3 ft rule that is exactly borderline. Unknown fields, duplicate keys, `NaN`, negative lengths and numbers written as strings are rejected. Fields that may be null must still be present. + +### Rules file + +Named thresholds using `rules.yaml` parameter names, with public or clearly synthetic values only. Base's unpublished values never go in a tracked file. + +```json +{ + "format": 1, "unit": "ft", "name": "Synthetic scoring fixture", + "thresholds": { + "gas_clearance_ft": {"value_ft": 3, "pass_when": "at_least", "source": "Public: Base help page"}, + "max_route_ft": {"value_ft": 20, "pass_when": "at_most", "source": "Public: Base help page"} + } +} +``` + +A threshold name must end in `_ft`. `pass_when` is `at_least` for a clearance or `at_most` for a limit such as route length. Each results file carries the sha256 of the exact rules file it ran under (`shasum -a 256 rules.json`), so a run made under different rules cannot be scored by mistake. + +### Survey file (one per house) + +```json +{ + "format": 1, "unit": "ft", "house": "synthetic-01", + "captures": ["synthetic-01-walk-1"], + "scale_reference": "scale", + "candidates": [{"id": "c1", "marker": "Blue painter's tape outline", "location": "South wall, 7 ft 5 in from the SW corner"}], + "measurements": [ + {"id": "c1-gas", "candidate": "c1", "from": "footprint edge", "to": "gas regulator", + "status": "measured", "value_ft": 4.500, "plus_minus_ft": 0.010, + "method": "tape", "measured_by": ["surveyor-a", "surveyor-b"]} + ], + "checks": [ + {"candidate": "c1", "check": "gas", "measurement": "c1-gas", "threshold": "gas_clearance_ft"}, + {"candidate": "c1", "check": "route", "measurement": "c1-route", + "threshold": "max_route_ft", "review_threshold": "review_route_ft"} + ] +} +``` + +| Field | Meaning | +|---|---| +| `captures` | Recording ids this survey applies to, ideally each recording's sha256. Each id belongs to one house. | +| `scale_reference` | The one measurement some pipelines may use to set scale. It must be measured, cannot decide a check, and is never scored. | +| `candidates[]` | Spot `id`, its physical `marker`, and its `location` as tape offsets from permanent corners. | +| `measurements[]` | `candidate` (null for house-level distances such as wall length), `from` and `to` in words, `status` (`measured`, `absent` or `not_measured`; only `measured` has `value_ft` and `plus_minus_ft`), `method`, `measured_by`. | +| `checks[]` | The spot, check name, deciding measurement and `threshold`. Every spot needs the same check names with the same thresholds, so the denominator cannot shrink. An absent feature may only decide an `at_least` check. | +| `checks[].review_threshold` | Optional. Makes a band: values that clear `review_threshold` pass, values past `threshold` fail, values between go to review. Both thresholds must point the same way, with the review line on the passing side. | + +### Results file (one per pipeline run) + +```json +{ + "format": 1, "unit": "ft", + "pipeline": "ar-taps", "capture": "synthetic-01-walk-1", + "rules_sha256": "<64 hex digits>", "scale_source": "ar_poses", + "measurements": [ + {"id": "c1-gas", "value_ft": 4.7, "plus_minus_ft": 0.3}, + {"id": "c2-facing", "value_ft": null, "missing": "failed"} + ], + "outcomes": [{"candidate": "c1", "check": "gas", "outcome": "pass"}], + "timing": {"capture_s": 420, "processing_s": 12.5} +} +``` + +| Field | Meaning | +|---|---| +| `pipeline` | Row name. Each variant is its own pipeline id. | +| `capture` | Must appear in exactly one survey's `captures`. | +| `scale_source` | `native_metric`, `scale_reference` or `ar_poses`. | +| `measurements[]` | One entry per survey measurement (the scale reference is optional). `value_ft` with `plus_minus_ft` (a number, or null if the run reports none), or `value_ft` null with `missing`: `unsupported`, `failed` or `absent`. | +| `outcomes` | One `pass`, `unsure` or `fail` per survey check, exactly as the pipeline decided; null for a run that makes no decisions. | +| `timing` | `capture_s` and `processing_s` in seconds, or null. Runs on the same capture must report the same `capture_s`. | + +## Import a Measure Lab session + +`score import-measure-lab` turns a session zip shared from Measure Lab (session format 2, documented in `experiments/measure-lab/README.md` "Session format") into a results file, using a map file the team writes after the walk. + +```sh +uv run score import-measure-lab fixtures/measure-lab/synthetic-session-01.zip \ + --map fixtures/measure-lab/map.json --rules fixtures/rules.json \ + --truth fixtures/truth/synthetic-01.json --out data/measure-lab.json +``` + +An excerpt of a map; `fixtures/measure-lab/map.json` is complete. Every survey measurement id needs a key, except the scale reference. + +```json +{ + "format": 1, "unit": "ft", + "pipeline": "measure-lab", + "session": "synthetic-session-01", + "notes": "plus_minus_ft_by_key is docs/02's ±0.3 ft for AR taps, the plan's untested estimate.", + "plus_minus_ft_by_key": {"straight": 0.3, "alongWall": 0.3, "gapToWall": 0.3}, + "measurements": { + "wall-length": {"session_measurement": "m2", "key": "alongWall"}, + "c1-gas": {"session_measurement": "m3", "key": "straight", "plus_minus_ft": 0.25}, + "c2-facing": {"refusal": "r1"}, + "c1-pool": "absent", + "c2-pool": "unsupported" + } +} +``` + +| Map entry | Becomes | +|---|---| +| `{"session_measurement": id, "key": k}` | `values[k]` in feet. `k` must be the measurement's `compared` quantity, the one `accepted` vouches for. `accepted: false` becomes null, missing `failed`. | +| `{"refusal": id}` | Null, missing `failed`. | +| `"absent"` / `"unsupported"` | Null, missing `absent` / `unsupported`. | + +Every value needs a stated uncertainty, its entry's `plus_minus_ft` or its key's in `plus_minus_ft_by_key`; there is no default. The ±0.3 ft in the example is docs/02's estimate for AR taps, not a measured error bar. `session` must match the zip's session id. + +The importer converts meters exactly (1 ft = 0.3048 m) and rounds half up to a millionth of a foot. The capture id is the zip's sha256, which must already be in the survey's `captures`. `scale_source` is `ar_poses`. `capture_s` runs from the session's start to its last measurement; `processing_s` is null, because the rig shows each value as it is tapped. A map or session problem stops the import with a specific message, and the output is loaded through the scorer before it is written. + +`--decide` writes a row named `+rule` whose outcomes apply the survey's strict rule (below) to the run's own values and uncertainties. With no value it says unsure, except that an absent feature passes an `at_least` check. This emulates the lane C rule until a real solver exists. + +The fixture zip is built reproducibly from `fixtures/measure-lab/synthetic-session-01/session.json`. After editing it, run `uv run python tests/helpers.py rebuild-session-zip` and put the printed sha256 in the survey's `captures`. + +## What the numbers mean + +The summary has one section per house: distances, checks, timing, and every unsafe pass, missed review and decision made without its measurement, by name. Nothing is averaged across houses, and missing outputs stay in the denominator. + +**Distances.** Error is run minus survey, in inches. The summary gives the median and maximum absolute error over distances both sides measured, and how often the survey value lies within the run's reported ±. Every survey measurement except the scale reference lands in one bucket: scored, unsupported, failed, wrongly absent (the run says it is not there; the survey measured it), phantom (the run measured a feature the survey found absent), absent in both, or not surveyed. + +**The scale reference is excluded from every error statistic.** A run given it can match it exactly, so its error says nothing about accuracy. + +**Survey outcome.** With s the survey value's margin on the passing side and u its uncertainty, a check passes when s > u, fails when s < −u, and is borderline otherwise. Both comparisons are strict, following `docs/02-implementation-plan.md` Lane C ("PASS if the margin is larger than the error"), so a value exactly on a line or exactly u from it never passes or fails. A band works the same way with two lines. For the route: + +| Survey | Outcome | +|---|---| +| L + u < `review_route_ft` | pass | +| L − u > `max_route_ft` | fail | +| anything else | review | + +An absent feature passes. An unmeasured one is unknown and left out of the decision counts. + +**Error relative to the threshold.** |error| / max(m, u), where m is the survey value's distance to the nearest line. At 1 or above the error could flip the check, since a run exactly on a line neither passes nor fails. With m and u both zero the CSV reports `at_threshold`, and any nonzero error could flip. + +**Decisions.** A correct run passes a passing check, fails a failing one, and says unsure on a borderline or review one. Wrong answers are counted in three categories that mean the same for every check: + +- **Unsafe pass:** a pass where the survey fails. This count matters most. +- **Missed review:** a pass where the survey is borderline or review. The protocol note counts a borderline pass as unsafe; here it stays visible in its own column, so "unsafe" always means the survey shows the spot breaks a rule. +- **Over-caution:** an unsure or fail where the survey passes. False rejections, the fails among them, are also listed. + +**Decided without its measurement:** a pass or fail whose deciding value is missing as `failed` or `unsupported`, or marked `absent` for an `at_most` limit. It is scored as usual and also counted, flagged and listed on its own. + +An unsure is justified when the survey is borderline or review, or the run had no value; otherwise it is avoidable. + +**Timing.** As reported. Rows sharing a recording share its capture time. + +## What one to three houses can and cannot show + +They can show which distances each pipeline returns, any unsafe pass at these spots, paired errors on identical input, and whether errors fit the plan's hypothesized error bars (±0.3 ft taps, ±0.5 ft mesh, ±1.5 ft photo detection). + +They cannot show an accuracy rate for homes in general (distances from one house are not independent samples), validated error bars (tuning and scoring on the same house proves nothing), safety (zero unsafe passes at nine spots is a sample result), homeowner effort, or installation eligibility (electrical checks are out of scope). + +An honest pitch sentence: "On [N] houses, AR taps measured [k] of [n] distances, median error [x] in, [u] unsafe passes." + +## Not scored + +- Spot placement error: how far a pipeline's spot sits from the taped mark. +- Latency beyond one processing time per run: cold and warm starts, crashes, timeouts. +- `rules.yaml` does not exist yet. Threshold names follow the protocol note, and `facing_gap_ft`, `pool_clearance_ft` and `review_route_ft` in the fixture are synthetic placeholders. The format uses feet to match `rules.yaml` and `scene.json`; the protocol records surveys in meters, so convert once when typing a survey up. diff --git a/experiments/scoring/fixtures/measure-lab/map.json b/experiments/scoring/fixtures/measure-lab/map.json new file mode 100644 index 000000000..c5ffe169f --- /dev/null +++ b/experiments/scoring/fixtures/measure-lab/map.json @@ -0,0 +1,50 @@ +{ + "format": 1, + "unit": "ft", + "pipeline": "measure-lab", + "session": "synthetic-session-01", + "notes": "Synthetic example. plus_minus_ft_by_key is docs/02-implementation-plan.md's +-0.3 ft for AR taps, the plan's untested estimate, not a measured error bar.", + "plus_minus_ft_by_key": { + "straight": 0.3, + "horizontal": 0.3, + "vertical": 0.3, + "alongWall": 0.3, + "gapToWall": 0.3, + "heightAboveGround": 0.3 + }, + "measurements": { + "scale": { + "session_measurement": "m1", + "key": "straight" + }, + "wall-length": { + "session_measurement": "m2", + "key": "alongWall" + }, + "c1-gas": { + "session_measurement": "m3", + "key": "straight" + }, + "c1-facing": { + "session_measurement": "m4", + "key": "gapToWall" + }, + "c1-route": { + "session_measurement": "m5", + "key": "alongWall" + }, + "c1-pool": "absent", + "c2-gas": { + "session_measurement": "m6", + "key": "straight" + }, + "c2-facing": { + "refusal": "r1" + }, + "c2-route": { + "session_measurement": "m7", + "key": "alongWall" + }, + "c2-pool": "unsupported" + } +} diff --git a/experiments/scoring/fixtures/measure-lab/synthetic-session-01.zip b/experiments/scoring/fixtures/measure-lab/synthetic-session-01.zip new file mode 100644 index 000000000..f75e62f66 Binary files /dev/null and b/experiments/scoring/fixtures/measure-lab/synthetic-session-01.zip differ diff --git a/experiments/scoring/fixtures/measure-lab/synthetic-session-01/session.json b/experiments/scoring/fixtures/measure-lab/synthetic-session-01/session.json new file mode 100644 index 000000000..d36bc319a --- /dev/null +++ b/experiments/scoring/fixtures/measure-lab/synthetic-session-01/session.json @@ -0,0 +1,485 @@ +{ + "format": "measure-lab-session", + "formatVersion": 2, + "units": { + "length": "meters", + "angle": "degrees", + "time": "seconds of device uptime, the clock of ARFrame.timestamp", + "image": "pixels of the saved JPEG" + }, + "conventions": { + "world": "ARKit world frame with worldAlignment .gravity: right-handed, y up (away from gravity), origin and heading fixed where the session started", + "camera": "ARKit camera frame: +x right and +y up in the unrotated sensor image, the camera looks along -z", + "pose": "camera-to-world 4x4 matrix, 16 numbers column by column (simd_float4x4 layout)", + "intrinsics": "[fx, fy, cx, cy] in pixels of the saved, unrotated landscape JPEG", + "pixel": "[u, v] continuous image coordinates: (0, 0) is the top-left corner of the JPEG, v grows down", + "ray": "origin is the camera position; direction is a unit vector through the tapped pixel" + }, + "session": { + "id": "synthetic-session-01", + "startedAt": "2026-09-26T15:00:00Z", + "startedAtUptime": 1000.25, + "appVersion": "0.0.0-synthetic", + "deviceModel": "synthetic", + "systemVersion": "0.0", + "lidarAvailable": false, + "meshReconstructionSupported": false, + "sceneDepthEnabled": false + }, + "gates": { + "trackingStableSeconds": 2.0, + "minimumGroundLookDown": 20.0, + "minimumContactSeparation": 1.0, + "maximumAngleFromWallNormal": 60.0, + "wallValidationTolerance": 0.05, + "minimumCameraOffsetFromWall": 1.0, + "minimumRayAngle": 5.0, + "maximumRayGap": 0.05, + "keyframeSpacingMeters": 0.5, + "keyframeSpacingDegrees": 15.0 + }, + "keyframes": [], + "taps": [], + "points": [ + { + "id": "p1", + "kind": "ground", + "position": [ + 0.0, + 0.0, + 0.0 + ], + "taps": [], + "ground": { + "surface": "detectedPlane", + "planeAnchor": "A1", + "lookDown": 42.0 + }, + "onWall": null, + "twoView": null, + "wallCoordinates": { + "wall": "w1", + "along": 0.0, + "heightAboveGround": 0.0, + "offset": -0.0, + "withinContacts": true + }, + "flags": [], + "wallWarnings": [] + }, + { + "id": "p2", + "kind": "ground", + "position": [ + 9.87552, + 0.0, + 0.0 + ], + "taps": [], + "ground": { + "surface": "detectedPlane", + "planeAnchor": "A1", + "lookDown": 42.0 + }, + "onWall": null, + "twoView": null, + "wallCoordinates": { + "wall": "w1", + "along": 9.87552, + "heightAboveGround": 0.0, + "offset": -0.0, + "withinContacts": true + }, + "flags": [], + "wallWarnings": [] + }, + { + "id": "p3", + "kind": "wall", + "position": [ + 0.5, + 0.0, + 0.0 + ], + "taps": [], + "ground": null, + "onWall": { + "wall": "w1", + "range": 2.5, + "angleFromNormal": 12.0 + }, + "twoView": null, + "wallCoordinates": { + "wall": "w1", + "along": 0.5, + "heightAboveGround": 0.0, + "offset": 0.0, + "withinContacts": true + }, + "flags": [], + "wallWarnings": [] + }, + { + "id": "p4", + "kind": "wall", + "position": [ + 2.02, + 0.0, + 0.0 + ], + "taps": [], + "ground": null, + "onWall": { + "wall": "w1", + "range": 2.5, + "angleFromNormal": 12.0 + }, + "twoView": null, + "wallCoordinates": { + "wall": "w1", + "along": 2.02, + "heightAboveGround": 0.0, + "offset": 0.0, + "withinContacts": true + }, + "flags": [], + "wallWarnings": [] + }, + { + "id": "p5", + "kind": "ground", + "position": [ + 2.26, + 0.0, + 1.4 + ], + "taps": [], + "ground": { + "surface": "detectedPlane", + "planeAnchor": "A1", + "lookDown": 42.0 + }, + "onWall": null, + "twoView": null, + "wallCoordinates": { + "wall": "w1", + "along": 2.26, + "heightAboveGround": 0.0, + "offset": -1.4, + "withinContacts": true + }, + "flags": [], + "wallWarnings": [] + }, + { + "id": "p6", + "kind": "ground", + "position": [ + 2.26, + 0.0, + 1.49352 + ], + "taps": [], + "ground": { + "surface": "detectedPlane", + "planeAnchor": "A1", + "lookDown": 42.0 + }, + "onWall": null, + "twoView": null, + "wallCoordinates": { + "wall": "w1", + "along": 2.26, + "heightAboveGround": 0.0, + "offset": -1.49352, + "withinContacts": true + }, + "flags": [], + "wallWarnings": [] + }, + { + "id": "p7", + "kind": "wall", + "position": [ + 3.71856, + 0.3, + 0.0 + ], + "taps": [], + "ground": null, + "onWall": { + "wall": "w1", + "range": 2.5, + "angleFromNormal": 12.0 + }, + "twoView": null, + "wallCoordinates": { + "wall": "w1", + "along": 3.71856, + "heightAboveGround": 0.3, + "offset": 0.0, + "withinContacts": true + }, + "flags": [], + "wallWarnings": [] + }, + { + "id": "p8", + "kind": "ground", + "position": [ + 6.4, + 0.0, + 0.95 + ], + "taps": [], + "ground": { + "surface": "detectedPlane", + "planeAnchor": "A1", + "lookDown": 42.0 + }, + "onWall": null, + "twoView": null, + "wallCoordinates": { + "wall": "w1", + "along": 6.4, + "heightAboveGround": 0.0, + "offset": -0.95, + "withinContacts": true + }, + "flags": [], + "wallWarnings": [] + }, + { + "id": "p9", + "kind": "wall", + "position": [ + 6.00456, + 0.3, + 0.0 + ], + "taps": [], + "ground": null, + "onWall": { + "wall": "w1", + "range": 2.5, + "angleFromNormal": 12.0 + }, + "twoView": null, + "wallCoordinates": { + "wall": "w1", + "along": 6.00456, + "heightAboveGround": 0.3, + "offset": 0.0, + "withinContacts": true + }, + "flags": [], + "wallWarnings": [] + } + ], + "walls": [ + { + "id": "w1", + "contacts": [ + "p1", + "p2" + ], + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 9.87552, + 0.0, + 0.0 + ], + "direction": [ + 1.0, + 0.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "length": 9.87552, + "cameraPosition": [ + 4.9, + 1.4, + 3.0 + ], + "validations": [ + { + "point": "p3", + "residual": 0.01, + "tolerance": 0.05, + "passes": true + } + ], + "warnings": [] + } + ], + "measurements": [ + { + "id": "m1", + "time": 1010.5, + "from": "p3", + "to": "p4", + "referenceWall": "w1", + "values": { + "straight": 1.524, + "horizontal": 1.524, + "vertical": 0.0, + "alongWall": 1.524 + }, + "compared": "straight", + "tape": null, + "errorMeters": null, + "errorInches": null, + "warnings": [], + "accepted": true + }, + { + "id": "m2", + "time": 1060.0, + "from": "p1", + "to": "p2", + "referenceWall": "w1", + "values": { + "straight": 9.87552, + "horizontal": 9.87552, + "vertical": 0.0, + "alongWall": 9.87552 + }, + "compared": "alongWall", + "tape": null, + "errorMeters": null, + "errorInches": null, + "warnings": [], + "accepted": true + }, + { + "id": "m3", + "time": 1120.0, + "from": "p5", + "to": "w1", + "referenceWall": "w1", + "values": { + "straight": 1.4, + "horizontal": 1.4, + "vertical": 0.0, + "gapToWall": 1.4, + "heightAboveGround": 0.0 + }, + "compared": "straight", + "tape": null, + "errorMeters": null, + "errorInches": null, + "warnings": [], + "accepted": true + }, + { + "id": "m4", + "time": 1180.0, + "from": "p6", + "to": "w1", + "referenceWall": "w1", + "values": { + "straight": 1.49352, + "horizontal": 1.49352, + "vertical": 0.0, + "gapToWall": 1.49352, + "heightAboveGround": 0.0 + }, + "compared": "gapToWall", + "tape": null, + "errorMeters": null, + "errorInches": null, + "warnings": [], + "accepted": true + }, + { + "id": "m5", + "time": 1240.0, + "from": "p1", + "to": "p7", + "referenceWall": "w1", + "values": { + "straight": 3.73065, + "horizontal": 3.71856, + "vertical": 0.3, + "alongWall": 3.71856, + "heightAboveGround": 0.3 + }, + "compared": "alongWall", + "tape": null, + "errorMeters": null, + "errorInches": null, + "warnings": [], + "accepted": true + }, + { + "id": "m6", + "time": 1350.0, + "from": "p8", + "to": "w1", + "referenceWall": "w1", + "values": { + "straight": 0.95, + "horizontal": 0.95, + "vertical": 0.04, + "gapToWall": 0.95, + "heightAboveGround": -0.04 + }, + "compared": "straight", + "tape": null, + "errorMeters": null, + "errorInches": null, + "warnings": [ + "belowGround" + ], + "accepted": false + }, + { + "id": "m7", + "time": 1412.75, + "from": "p1", + "to": "p9", + "referenceWall": "w1", + "values": { + "straight": 6.01205, + "horizontal": 6.00456, + "vertical": 0.3, + "alongWall": 6.00456, + "heightAboveGround": 0.3 + }, + "compared": "alongWall", + "tape": null, + "errorMeters": null, + "errorInches": null, + "warnings": [], + "accepted": true + } + ], + "refusals": [ + { + "id": "r1", + "time": 1300.0, + "tool": "gapToWall", + "tap": null, + "reason": "grazingRay", + "message": "The ray meets the ground too shallowly. Step back and tap again.", + "values": { + "lookDown": 8.0, + "minimumGroundLookDown": 20.0 + } + } + ], + "tracking": [ + { + "time": 1000.25, + "state": "normal" + } + ] +} diff --git a/experiments/scoring/fixtures/results/ar-taps.json b/experiments/scoring/fixtures/results/ar-taps.json new file mode 100644 index 000000000..ad087cfb7 --- /dev/null +++ b/experiments/scoring/fixtures/results/ar-taps.json @@ -0,0 +1,31 @@ +{ + "format": 1, + "unit": "ft", + "pipeline": "ar-taps", + "capture": "synthetic-01-walk-1", + "rules_sha256": "05b466b3436155e190513a21b6333304ad9da4e93f1b5022b1813a433bdb325e", + "scale_source": "ar_poses", + "measurements": [ + {"id": "scale", "value_ft": 6.000, "plus_minus_ft": 0.3}, + {"id": "wall-length", "value_ft": 32.300, "plus_minus_ft": 0.3}, + {"id": "c1-gas", "value_ft": 4.700, "plus_minus_ft": 0.3}, + {"id": "c1-facing", "value_ft": 4.600, "plus_minus_ft": 0.3}, + {"id": "c1-route", "value_ft": 12.250, "plus_minus_ft": 0.3}, + {"id": "c1-pool", "value_ft": null, "missing": "absent"}, + {"id": "c2-gas", "value_ft": 3.350, "plus_minus_ft": 0.3}, + {"id": "c2-facing", "value_ft": null, "missing": "failed"}, + {"id": "c2-route", "value_ft": 19.500, "plus_minus_ft": 0.3}, + {"id": "c2-pool", "value_ft": null, "missing": "failed"} + ], + "outcomes": [ + {"candidate": "c1", "check": "gas", "outcome": "pass"}, + {"candidate": "c1", "check": "facing_gap", "outcome": "pass"}, + {"candidate": "c1", "check": "route", "outcome": "pass"}, + {"candidate": "c1", "check": "pool", "outcome": "pass"}, + {"candidate": "c2", "check": "gas", "outcome": "pass"}, + {"candidate": "c2", "check": "facing_gap", "outcome": "pass"}, + {"candidate": "c2", "check": "route", "outcome": "pass"}, + {"candidate": "c2", "check": "pool", "outcome": "fail"} + ], + "timing": {"capture_s": 420, "processing_s": 12.5} +} diff --git a/experiments/scoring/fixtures/results/mesh-scaled.json b/experiments/scoring/fixtures/results/mesh-scaled.json new file mode 100644 index 000000000..6d97f29b5 --- /dev/null +++ b/experiments/scoring/fixtures/results/mesh-scaled.json @@ -0,0 +1,22 @@ +{ + "format": 1, + "unit": "ft", + "pipeline": "mesh-scaled", + "capture": "synthetic-01-walk-1", + "rules_sha256": "05b466b3436155e190513a21b6333304ad9da4e93f1b5022b1813a433bdb325e", + "scale_source": "scale_reference", + "measurements": [ + {"id": "scale", "value_ft": 5.000, "plus_minus_ft": null}, + {"id": "wall-length", "value_ft": 32.600, "plus_minus_ft": null}, + {"id": "c1-gas", "value_ft": null, "missing": "absent"}, + {"id": "c1-facing", "value_ft": 5.100, "plus_minus_ft": 0.5}, + {"id": "c1-route", "value_ft": 11.500, "plus_minus_ft": 0.5}, + {"id": "c1-pool", "value_ft": null, "missing": "absent"}, + {"id": "c2-gas", "value_ft": 3.000, "plus_minus_ft": 0.5}, + {"id": "c2-facing", "value_ft": 2.600, "plus_minus_ft": 0.5}, + {"id": "c2-route", "value_ft": null, "missing": "failed"}, + {"id": "c2-pool", "value_ft": null, "missing": "absent"} + ], + "outcomes": null, + "timing": {"capture_s": 420, "processing_s": null} +} diff --git a/experiments/scoring/fixtures/results/photo-depth.json b/experiments/scoring/fixtures/results/photo-depth.json new file mode 100644 index 000000000..3d0175d51 --- /dev/null +++ b/experiments/scoring/fixtures/results/photo-depth.json @@ -0,0 +1,30 @@ +{ + "format": 1, + "unit": "ft", + "pipeline": "photo-depth", + "capture": "synthetic-01-walk-1", + "rules_sha256": "05b466b3436155e190513a21b6333304ad9da4e93f1b5022b1813a433bdb325e", + "scale_source": "native_metric", + "measurements": [ + {"id": "wall-length", "value_ft": 30.500, "plus_minus_ft": 1.5}, + {"id": "c1-gas", "value_ft": 5.500, "plus_minus_ft": 1.5}, + {"id": "c1-facing", "value_ft": 3.800, "plus_minus_ft": 1.5}, + {"id": "c1-route", "value_ft": null, "missing": "unsupported"}, + {"id": "c1-pool", "value_ft": 12.000, "plus_minus_ft": 1.5}, + {"id": "c2-gas", "value_ft": 2.400, "plus_minus_ft": 1.5}, + {"id": "c2-facing", "value_ft": 2.900, "plus_minus_ft": 1.5}, + {"id": "c2-route", "value_ft": 21.000, "plus_minus_ft": 1.5}, + {"id": "c2-pool", "value_ft": null, "missing": "unsupported"} + ], + "outcomes": [ + {"candidate": "c1", "check": "gas", "outcome": "pass"}, + {"candidate": "c1", "check": "facing_gap", "outcome": "fail"}, + {"candidate": "c1", "check": "route", "outcome": "unsure"}, + {"candidate": "c1", "check": "pool", "outcome": "pass"}, + {"candidate": "c2", "check": "gas", "outcome": "fail"}, + {"candidate": "c2", "check": "facing_gap", "outcome": "unsure"}, + {"candidate": "c2", "check": "route", "outcome": "fail"}, + {"candidate": "c2", "check": "pool", "outcome": "unsure"} + ], + "timing": {"capture_s": 420, "processing_s": 95.0} +} diff --git a/experiments/scoring/fixtures/rules.json b/experiments/scoring/fixtures/rules.json new file mode 100644 index 000000000..823f2e343 --- /dev/null +++ b/experiments/scoring/fixtures/rules.json @@ -0,0 +1,32 @@ +{ + "format": 1, + "unit": "ft", + "name": "Synthetic scoring fixture. Not a real placement policy.", + "thresholds": { + "gas_clearance_ft": { + "value_ft": 3, + "pass_when": "at_least", + "source": "Public: Base help page, 3 ft from a gas meter or regulator" + }, + "facing_gap_ft": { + "value_ft": 3, + "pass_when": "at_least", + "source": "Synthetic placeholder; the public passage width is TBD in docs/01-feature-map.md" + }, + "review_route_ft": { + "value_ft": 15, + "pass_when": "at_most", + "source": "Synthetic placeholder; the confident reach before review, per docs/02-implementation-plan.md Lane C" + }, + "max_route_ft": { + "value_ft": 20, + "pass_when": "at_most", + "source": "Public: Base help page, within 20 ft of the meter" + }, + "pool_clearance_ft": { + "value_ft": 5, + "pass_when": "at_least", + "source": "Synthetic placeholder; no public value, TBD in docs/01-feature-map.md" + } + } +} diff --git a/experiments/scoring/fixtures/truth/synthetic-01.json b/experiments/scoring/fixtures/truth/synthetic-01.json new file mode 100644 index 000000000..328419406 --- /dev/null +++ b/experiments/scoring/fixtures/truth/synthetic-01.json @@ -0,0 +1,137 @@ +{ + "format": 1, + "unit": "ft", + "house": "synthetic-01", + "captures": ["synthetic-01-walk-1", "7ded14d64c8b2810d9b26150b439bc9c4dc52a753f6189ee22d481fc14ce8e9d"], + "scale_reference": "scale", + "candidates": [ + { + "id": "c1", + "marker": "Blue painter's tape outlining the 31 in x 22 in footprint", + "location": "South wall, footprint's west edge 7 ft 5 in east of the southwest corner" + }, + { + "id": "c2", + "marker": "Yellow chalk outline of the 31 in x 22 in footprint", + "location": "South wall, footprint's west edge 21 ft 0 in east of the southwest corner" + } + ], + "measurements": [ + { + "id": "scale", + "candidate": null, + "from": "scale mark A on the south wall", + "to": "scale mark B on the south wall", + "status": "measured", + "value_ft": 5.000, + "plus_minus_ft": 0.005, + "method": "tape", + "measured_by": ["surveyor-a", "surveyor-b"] + }, + { + "id": "wall-length", + "candidate": null, + "from": "southwest exterior corner", + "to": "southeast exterior corner", + "status": "measured", + "value_ft": 32.500, + "plus_minus_ft": 0.010, + "method": "laser to a target card", + "measured_by": ["surveyor-a", "surveyor-b"] + }, + { + "id": "c1-gas", + "candidate": "c1", + "from": "footprint edge", + "to": "gas regulator", + "status": "measured", + "value_ft": 4.500, + "plus_minus_ft": 0.010, + "method": "tape", + "measured_by": ["surveyor-a", "surveyor-b"] + }, + { + "id": "c1-facing", + "candidate": "c1", + "from": "wall", + "to": "facing fence, minimum of three points across the footprint", + "status": "measured", + "value_ft": 5.000, + "plus_minus_ft": 0.020, + "method": "laser at three points", + "measured_by": ["surveyor-a", "surveyor-b"] + }, + { + "id": "c1-route", + "candidate": "c1", + "from": "meter connection point", + "to": "battery connection point, along the supported route", + "status": "measured", + "value_ft": 12.000, + "plus_minus_ft": 0.050, + "method": "tape along each segment", + "measured_by": ["surveyor-a", "surveyor-b"] + }, + { + "id": "c1-pool", + "candidate": "c1", + "from": "footprint edge", + "to": "pool edge", + "status": "absent", + "method": "walked the lot; no pool", + "measured_by": ["surveyor-a"] + }, + { + "id": "c2-gas", + "candidate": "c2", + "from": "footprint edge", + "to": "gas meter", + "status": "measured", + "value_ft": 3.020, + "plus_minus_ft": 0.030, + "method": "tape", + "measured_by": ["surveyor-a", "surveyor-b"] + }, + { + "id": "c2-facing", + "candidate": "c2", + "from": "wall", + "to": "facing hedge, minimum of three points across the footprint", + "status": "measured", + "value_ft": 2.500, + "plus_minus_ft": 0.020, + "method": "laser at three points", + "measured_by": ["surveyor-a", "surveyor-b"] + }, + { + "id": "c2-route", + "candidate": "c2", + "from": "meter connection point", + "to": "battery connection point, along the supported route", + "status": "measured", + "value_ft": 19.900, + "plus_minus_ft": 0.050, + "method": "tape along each segment", + "measured_by": ["surveyor-a", "surveyor-b"] + }, + { + "id": "c2-pool", + "candidate": "c2", + "from": "footprint edge", + "to": "pool edge", + "status": "not_measured", + "method": "neighbour's yard behind a locked gate", + "measured_by": ["surveyor-a"] + } + ], + "checks": [ + {"candidate": "c1", "check": "gas", "measurement": "c1-gas", "threshold": "gas_clearance_ft"}, + {"candidate": "c1", "check": "facing_gap", "measurement": "c1-facing", "threshold": "facing_gap_ft"}, + {"candidate": "c1", "check": "route", "measurement": "c1-route", "threshold": "max_route_ft", "review_threshold": "review_route_ft"}, + {"candidate": "c1", "check": "pool", "measurement": "c1-pool", "threshold": "pool_clearance_ft"}, + {"candidate": "c2", "check": "gas", "measurement": "c2-gas", "threshold": "gas_clearance_ft"}, + {"candidate": "c2", "check": "facing_gap", "measurement": "c2-facing", "threshold": "facing_gap_ft"}, + {"candidate": "c2", "check": "route", "measurement": "c2-route", "threshold": "max_route_ft", "review_threshold": "review_route_ft"}, + {"candidate": "c2", "check": "pool", "measurement": "c2-pool", "threshold": "pool_clearance_ft"} + ] +} diff --git a/experiments/scoring/pyproject.toml b/experiments/scoring/pyproject.toml new file mode 100644 index 000000000..7c68b1586 --- /dev/null +++ b/experiments/scoring/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "house-scanning-scoring" +version = "0.1.0" +description = "Scores capture pipelines against an independent tape survey of the same house" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] + +[project.scripts] +score = "scoring.cli:main" + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "ruff>=0.16.9", +] + +[build-system] +requires = ["uv_build>=0.11.15,<0.12.0"] +build-backend = "uv_build" + +[tool.uv] +# setup-uv reads this in CI, so local and CI runs use the same uv minor version as uv.lock. +required-version = ">=0.11.15,<0.12" + +[tool.uv.build-backend] +module-name = "scoring" + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["--strict-markers", "--strict-config"] diff --git a/experiments/scoring/src/scoring/__init__.py b/experiments/scoring/src/scoring/__init__.py new file mode 100644 index 000000000..8188b352f --- /dev/null +++ b/experiments/scoring/src/scoring/__init__.py @@ -0,0 +1 @@ +"""Score capture pipelines against an independent tape survey of the same house.""" diff --git a/experiments/scoring/src/scoring/cli.py b/experiments/scoring/src/scoring/cli.py new file mode 100644 index 000000000..8ccc3d185 --- /dev/null +++ b/experiments/scoring/src/scoring/cli.py @@ -0,0 +1,57 @@ +"""The score command: load the inputs, print a markdown summary, write the CSV tables.""" + +import argparse +import sys +from pathlib import Path + +from scoring import measure_lab +from scoring.inputs import InputError, load_study +from scoring.metrics import score_run +from scoring.report import markdown, write_csvs + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + if argv[:1] == ["import-measure-lab"]: + return measure_lab.main(argv[1:]) + parser = argparse.ArgumentParser( + prog="score", + description="Score pipeline runs against a tape survey. Prints a markdown summary and " + "writes measurements.csv, checks.csv and runs.csv.", + epilog="To turn a Measure Lab session into a results file, run " + "`score import-measure-lab --help`.", + ) + parser.add_argument("--rules", type=Path, required=True, help="rules file (JSON)") + parser.add_argument( + "--truth", type=Path, nargs="+", required=True, help="one survey file per house" + ) + parser.add_argument( + "--results", type=Path, nargs="+", required=True, help="one file per pipeline run" + ) + parser.add_argument( + "--out", + type=Path, + default=Path("data"), + help="directory for the CSV tables (default: data, which git ignores)", + ) + args = parser.parse_args(argv) + + try: + study = load_study(args.rules, args.truth, args.results) + except InputError as error: + print(f"score: {error}", file=sys.stderr) + return 2 + + runs = [ + score_run(house.truth, results, study.rules.thresholds) + for house in study.houses + for results in house.runs + ] + sys.stdout.write(markdown(study, runs)) + for path in write_csvs(runs, args.out): + print(f"score: wrote {path}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/experiments/scoring/src/scoring/inputs.py b/experiments/scoring/src/scoring/inputs.py new file mode 100644 index 000000000..6886787a0 --- /dev/null +++ b/experiments/scoring/src/scoring/inputs.py @@ -0,0 +1,712 @@ +"""Load and validate the three input files: the rules, a house's tape survey, and a pipeline run. + +Any problem raises InputError naming the file, the field and what was wrong. A typo in a hand-typed +survey has to stop the run, because a silently skipped row changes the score without anyone seeing. + +Numbers are parsed as Decimal, not float. Checks compare survey values against thresholds, and an +exact tie has to land on the same side every time: in floats 3.1 - 3.0 is 0.10000000000000009, which +would pass a 3.1 +- 0.1 ft clearance against a 3 ft rule that is exactly borderline. +""" + +import hashlib +import json +import re +from collections.abc import Container, Iterable +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +from typing import Any, Literal + +FORMAT = 1 +UNIT = "ft" + +PassWhen = Literal["at_least", "at_most"] +SurveyStatus = Literal["measured", "absent", "not_measured"] +MissingReason = Literal["unsupported", "failed", "absent"] +Outcome = Literal["pass", "unsure", "fail"] +ScaleSource = Literal["native_metric", "scale_reference", "ar_poses"] + +PASS_WHEN: tuple[PassWhen, ...] = ("at_least", "at_most") +SURVEY_STATUSES: tuple[SurveyStatus, ...] = ("measured", "absent", "not_measured") +MISSING_REASONS: tuple[MissingReason, ...] = ("unsupported", "failed", "absent") +OUTCOMES: tuple[Outcome, ...] = ("pass", "unsure", "fail") +SCALE_SOURCES: tuple[ScaleSource, ...] = ("native_metric", "scale_reference", "ar_poses") + +# Threshold names follow rules.yaml, where every length parameter ends in _ft. +THRESHOLD_NAME = re.compile(r"[a-z][a-z0-9_]*_ft") +SHA256 = re.compile(r"[0-9a-f]{64}") + + +class InputError(ValueError): + """An input file is malformed or inconsistent with the other inputs.""" + + +@dataclass(frozen=True) +class Threshold: + name: str + value_ft: Decimal + pass_when: PassWhen + source: str + + +@dataclass(frozen=True) +class Rules: + path: Path + sha256: str + name: str + thresholds: dict[str, Threshold] + + +@dataclass(frozen=True) +class Candidate: + id: str + marker: str + location: str + + +@dataclass(frozen=True) +class SurveyMeasurement: + id: str + candidate: str | None + start: str + end: str + status: SurveyStatus + value_ft: Decimal | None + plus_minus_ft: Decimal | None + method: str + measured_by: tuple[str, ...] + + +@dataclass(frozen=True) +class Check: + candidate: str + check: str + measurement: str + # The fail line. With review_threshold set, values between the two lines are for review. + threshold: str + review_threshold: str | None = None + + +@dataclass(frozen=True) +class Truth: + path: Path + house: str + captures: tuple[str, ...] + scale_reference: str + candidates: dict[str, Candidate] + measurements: dict[str, SurveyMeasurement] + checks: tuple[Check, ...] + + +@dataclass(frozen=True) +class PipelineMeasurement: + id: str + value_ft: Decimal | None + plus_minus_ft: Decimal | None + missing: MissingReason | None + + +@dataclass(frozen=True) +class Results: + path: Path + pipeline: str + capture: str + rules_sha256: str + scale_source: ScaleSource + measurements: dict[str, PipelineMeasurement] + # None means the run makes no pass/unsure/fail decisions (a distances-only row). + outcomes: dict[tuple[str, str], Outcome] | None + capture_seconds: Decimal | None + processing_seconds: Decimal | None + + +@dataclass(frozen=True) +class House: + truth: Truth + runs: tuple[Results, ...] + + +@dataclass(frozen=True) +class Study: + rules: Rules + houses: tuple[House, ...] + + +def _describe(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return f"the boolean {str(value).lower()}" + if isinstance(value, str): + return f"the string {value!r}" + if isinstance(value, list): + return "a list" + if isinstance(value, dict): + return "an object" + return f"{value!r}" + + +class Fields: + """One JSON object being validated. `path` locates it inside `file` for error messages.""" + + def __init__(self, data: Any, file: Path, path: str, allowed: Iterable[str]): + self.file = file + self.path = path + if not isinstance(data, dict): + raise self.error(f"expected an object, got {_describe(data)}") + allowed = set(allowed) + unknown = sorted(set(data) - allowed) + if unknown: + raise self.error( + f"unknown field {', '.join(map(repr, unknown))}; " + f"allowed fields are {', '.join(sorted(allowed))}" + ) + self.data: dict[str, Any] = data + + def error(self, message: str, key: str | None = None) -> InputError: + where = ".".join(part for part in (self.path, key) if part) + prefix = f"{self.file}: {where}" if where else str(self.file) + return InputError(f"{prefix}: {message}") + + def has(self, key: str) -> bool: + return key in self.data + + def raw(self, key: str) -> Any: + if key not in self.data: + raise self.error(f"missing required field {key!r}") + return self.data[key] + + def text(self, key: str) -> str: + value = self.raw(key) + if not isinstance(value, str) or not value.strip(): + raise self.error(f"expected a non-empty string, got {_describe(value)}", key) + return value + + def optional_text(self, key: str) -> str | None: + return None if self.raw(key) is None else self.text(key) + + def length(self, key: str) -> Decimal: + """A non-negative finite number: a distance, a tolerance or a duration.""" + value = self.raw(key) + if isinstance(value, bool) or not isinstance(value, int | Decimal): + raise self.error(f"expected a number, got {_describe(value)}", key) + if value < 0: + raise self.error(f"must not be negative, got {value}", key) + # Keep derived inches and error-to-margin ratios within Decimal's 28-digit + # reporting precision, including a 1e-12 ft distance to a threshold. + number = Decimal(value) + if number > Decimal("1000000000") or number.as_tuple().exponent < -12: + raise self.error("must be at most 1000000000 with at most 12 decimal places", key) + return number + + def optional_length(self, key: str) -> Decimal | None: + """Like length, but an explicit null is allowed. The key itself is still required.""" + return None if self.raw(key) is None else self.length(key) + + def choice[T: str](self, key: str, choices: tuple[T, ...]) -> T: + value = self.raw(key) + if value not in choices: + raise self.error(f"expected one of {', '.join(choices)}, got {_describe(value)}", key) + return value + + def items(self, key: str) -> list[Any]: + value = self.raw(key) + if not isinstance(value, list) or not value: + raise self.error(f"expected a non-empty list, got {_describe(value)}", key) + return value + + def child(self, value: Any, path: str, allowed: Iterable[str]) -> "Fields": + return Fields(value, self.file, path, allowed) + + def text_list(self, key: str) -> tuple[str, ...]: + values = self.items(key) + for index, value in enumerate(values): + if not isinstance(value, str) or not value.strip(): + raise self.error( + f"expected a non-empty string, got {_describe(value)}", f"{key}[{index}]" + ) + if len(set(values)) != len(values): + raise self.error("lists the same value twice", key) + return tuple(values) + + def header(self) -> None: + """Every file starts with the format version and the length unit.""" + version = self.raw("format") + if type(version) is not int or version != FORMAT: + raise self.error( + f"this scorer reads format {FORMAT}, got {_describe(version)}", "format" + ) + if self.raw("unit") != UNIT: + raise self.error( + f'every length must be in feet ("{UNIT}"), got {_describe(self.raw("unit"))}', + "unit", + ) + + +def read_json(path: Path) -> tuple[Any, bytes]: + try: + raw = path.read_bytes() + except OSError as error: + raise InputError(f"{path}: cannot read ({error.strerror})") from None + return parse_json(raw, str(path)), raw + + +def parse_json(raw: bytes, path: str) -> Any: + """Parse JSON with every float as an exact Decimal, rejecting duplicate keys and NaN. + + `path` names the source in error messages, which may be a member inside a zip. + """ + + def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + seen: dict[str, Any] = {} + for key, value in pairs: + if key in seen: + raise InputError( + f"{path}: key {key!r} appears twice in one object; JSON would keep only the " + "last one, so the file is ambiguous" + ) + seen[key] = value + return seen + + def reject_constant(name: str) -> Any: + raise InputError(f"{path}: {name} is not a measurement; write null for a missing value") + + try: + return json.loads( + raw, + parse_float=Decimal, + parse_constant=reject_constant, + object_pairs_hook=reject_duplicates, + ) + except json.JSONDecodeError as error: + raise InputError( + f"{path}: not valid JSON ({error.msg} at line {error.lineno}, column {error.colno})" + ) from None + except UnicodeDecodeError: + raise InputError(f"{path}: not UTF-8 text") from None + + +def _unique_id(fields: Fields, seen: Container[str], kind: str) -> str: + value = fields.text("id") + if value in seen: + raise fields.error(f"{kind} {value!r} is listed twice", "id") + return value + + +def load_rules(path: Path) -> Rules: + data, raw = read_json(path) + top = Fields(data, path, "", {"format", "unit", "name", "thresholds"}) + top.header() + name = top.text("name") + table = top.raw("thresholds") + if not isinstance(table, dict) or not table: + raise top.error("expected an object with at least one threshold", "thresholds") + thresholds: dict[str, Threshold] = {} + for key, entry in table.items(): + fields = top.child(entry, f"thresholds.{key}", {"value_ft", "pass_when", "source"}) + if not THRESHOLD_NAME.fullmatch(key): + raise fields.error( + "a threshold is named by its rules.yaml parameter: lower_snake_case, ending in " + "_ft because every value is in feet" + ) + thresholds[key] = Threshold( + name=key, + value_ft=fields.length("value_ft"), + pass_when=fields.choice("pass_when", PASS_WHEN), + source=fields.text("source"), + ) + return Rules(path, hashlib.sha256(raw).hexdigest(), name, thresholds) + + +SURVEY_MEASUREMENT_FIELDS = ( + "id", + "candidate", + "from", + "to", + "status", + "value_ft", + "plus_minus_ft", + "method", + "measured_by", +) + + +def _load_survey_measurement(fields: Fields, seen: Container[str]) -> SurveyMeasurement: + measurement_id = _unique_id(fields, seen, "measurement") + fields.path += f" ({measurement_id})" + status = fields.choice("status", SURVEY_STATUSES) + value = plus_minus = None + if status == "measured": + value = fields.length("value_ft") + plus_minus = fields.length("plus_minus_ft") + else: + for key in ("value_ft", "plus_minus_ft"): + if fields.has(key): + raise fields.error( + f'status {status!r} has no value; remove {key} or set status to "measured"', + key, + ) + return SurveyMeasurement( + id=measurement_id, + candidate=fields.optional_text("candidate"), + start=fields.text("from"), + end=fields.text("to"), + status=status, + value_ft=value, + plus_minus_ft=plus_minus, + method=fields.text("method"), + measured_by=fields.text_list("measured_by"), + ) + + +def load_truth(path: Path, rules: Rules) -> Truth: + data, _ = read_json(path) + top = Fields( + data, + path, + "", + { + "format", + "unit", + "house", + "captures", + "scale_reference", + "candidates", + "measurements", + "checks", + }, + ) + top.header() + + candidates: dict[str, Candidate] = {} + for index, entry in enumerate(top.items("candidates")): + fields = top.child(entry, f"candidates[{index}]", {"id", "marker", "location"}) + candidate_id = _unique_id(fields, candidates, "candidate") + candidates[candidate_id] = Candidate( + candidate_id, fields.text("marker"), fields.text("location") + ) + + measurements: dict[str, SurveyMeasurement] = {} + for index, entry in enumerate(top.items("measurements")): + fields = top.child(entry, f"measurements[{index}]", SURVEY_MEASUREMENT_FIELDS) + measurement = _load_survey_measurement(fields, measurements) + if measurement.candidate is not None and measurement.candidate not in candidates: + raise fields.error( + f"no candidate {measurement.candidate!r}; candidates are {', '.join(candidates)}", + "candidate", + ) + measurements[measurement.id] = measurement + + scale_reference = top.text("scale_reference") + reference = measurements.get(scale_reference) + if reference is None: + raise top.error(f"{scale_reference!r} is not a measurement id", "scale_reference") + if reference.status != "measured": + raise top.error( + f"{scale_reference!r} must be measured, but its status is {reference.status!r}", + "scale_reference", + ) + + checks: list[Check] = [] + for index, entry in enumerate(top.items("checks")): + fields = top.child( + entry, + f"checks[{index}]", + {"candidate", "check", "measurement", "threshold", "review_threshold"}, + ) + check = Check( + candidate=fields.text("candidate"), + check=fields.text("check"), + measurement=fields.text("measurement"), + threshold=fields.text("threshold"), + review_threshold=( + fields.text("review_threshold") if fields.has("review_threshold") else None + ), + ) + if any((c.candidate, c.check) == (check.candidate, check.check) for c in checks): + raise fields.error(f"check {check.check!r} at {check.candidate!r} is listed twice") + _validate_check(fields, check, candidates, measurements, scale_reference, rules) + checks.append(check) + _require_same_checks_at_every_spot(top, candidates, checks) + + return Truth( + path=path, + house=top.text("house"), + captures=top.text_list("captures"), + scale_reference=scale_reference, + candidates=candidates, + measurements=measurements, + checks=tuple(checks), + ) + + +def _require_same_checks_at_every_spot( + top: Fields, candidates: dict[str, Candidate], checks: list[Check] +) -> None: + """Every spot needs the same check names, each with the same thresholds. + + The protocol applies every distance to every spot, so the denominator is fixed. A survey that + left a hard check out at one spot would otherwise score with a smaller denominator unnoticed. + """ + names: dict[str, set[str]] = {candidate: set() for candidate in candidates} + policies: dict[str, tuple[str, str | None]] = {} + for check in checks: + names[check.candidate].add(check.check) + policy = (check.threshold, check.review_threshold) + previous = policies.setdefault(check.check, policy) + if policy != previous: + raise top.error( + f"check {check.check!r} at candidate {check.candidate!r} uses threshold " + f"{check.threshold!r} and review_threshold {check.review_threshold!r}, " + f"but other spots use {previous[0]!r} and {previous[1]!r}; " + "every spot needs the same threshold mapping", + "checks", + ) + every = set().union(*names.values()) + for candidate, found in names.items(): + if found != every: + raise top.error( + f"candidate {candidate!r} has no {', '.join(sorted(every - found))} check; " + "every spot needs the same checks. Survey the distance, or record it as " + "absent or not_measured" + ) + + +def _validate_check( + fields: Fields, + check: Check, + candidates: dict[str, Candidate], + measurements: dict[str, SurveyMeasurement], + scale_reference: str, + rules: Rules, +) -> None: + if check.candidate not in candidates: + raise fields.error(f"no candidate {check.candidate!r}", "candidate") + measurement = measurements.get(check.measurement) + if measurement is None: + raise fields.error(f"no measurement {check.measurement!r}", "measurement") + if check.measurement == scale_reference: + raise fields.error( + f"{check.measurement!r} is the scale reference, which is never scored, so it " + "cannot decide a check", + "measurement", + ) + if measurement.candidate != check.candidate: + raise fields.error( + f"the check is at candidate {check.candidate!r} but measurement " + f"{check.measurement!r} belongs to {measurement.candidate!r}" + ) + threshold = rules.thresholds.get(check.threshold) + if threshold is None: + raise fields.error(f"{check.threshold!r} is not in {rules.path}", "threshold") + if measurement.status == "absent" and threshold.pass_when == "at_most": + raise fields.error( + f"measurement {check.measurement!r} is absent, but {check.threshold!r} passes " + "at_most a distance; only a clearance (at_least) passes when the feature does not " + "exist" + ) + if check.review_threshold is not None: + _validate_review_band(fields, check.review_threshold, threshold, rules) + + +def _validate_review_band(fields: Fields, review_name: str, fail: Threshold, rules: Rules) -> None: + """The review line must sit on the passing side of the fail line, in the same direction.""" + review = rules.thresholds.get(review_name) + if review is None: + raise fields.error(f"{review_name!r} is not in {rules.path}", "review_threshold") + if review.name == fail.name: + raise fields.error("must differ from threshold", "review_threshold") + if review.pass_when != fail.pass_when: + raise fields.error( + f"{review.name!r} passes {review.pass_when} but {fail.name!r} passes " + f"{fail.pass_when}; a review band needs both in the same direction", + "review_threshold", + ) + inside = ( + review.value_ft <= fail.value_ft + if fail.pass_when == "at_most" + else review.value_ft >= fail.value_ft + ) + if not inside: + raise fields.error( + f"{review.name!r} ({review.value_ft} ft) must be on the passing side of " + f"{fail.name!r} ({fail.value_ft} ft, {fail.pass_when})", + "review_threshold", + ) + + +def _load_pipeline_measurement(fields: Fields, seen: Container[str]) -> PipelineMeasurement: + measurement_id = _unique_id(fields, seen, "measurement") + fields.path += f" ({measurement_id})" + value = fields.optional_length("value_ft") + if value is None: + if fields.has("plus_minus_ft"): + raise fields.error("a null value has no uncertainty; remove it", "plus_minus_ft") + return PipelineMeasurement( + measurement_id, None, None, fields.choice("missing", MISSING_REASONS) + ) + if fields.has("missing"): + raise fields.error("only a null value_ft gives a reason it is missing", "missing") + return PipelineMeasurement(measurement_id, value, fields.optional_length("plus_minus_ft"), None) + + +def load_results(path: Path) -> Results: + data, _ = read_json(path) + top = Fields( + data, + path, + "", + { + "format", + "unit", + "pipeline", + "capture", + "rules_sha256", + "scale_source", + "measurements", + "outcomes", + "timing", + }, + ) + top.header() + rules_sha256 = top.text("rules_sha256") + if not SHA256.fullmatch(rules_sha256): + raise top.error( + "expected the 64 lowercase hex digits of the rules file's sha256", "rules_sha256" + ) + + measurements: dict[str, PipelineMeasurement] = {} + for index, entry in enumerate(top.items("measurements")): + fields = top.child( + entry, f"measurements[{index}]", {"id", "value_ft", "plus_minus_ft", "missing"} + ) + measurement = _load_pipeline_measurement(fields, measurements) + measurements[measurement.id] = measurement + + outcomes: dict[tuple[str, str], Outcome] | None = None + if top.raw("outcomes") is not None: + outcomes = {} + for index, entry in enumerate(top.items("outcomes")): + fields = top.child(entry, f"outcomes[{index}]", {"candidate", "check", "outcome"}) + key = (fields.text("candidate"), fields.text("check")) + if key in outcomes: + raise fields.error(f"check {key[1]!r} at {key[0]!r} is listed twice") + outcomes[key] = fields.choice("outcome", OUTCOMES) + + timing = top.child(top.raw("timing"), "timing", {"capture_s", "processing_s"}) + return Results( + path=path, + pipeline=top.text("pipeline"), + capture=top.text("capture"), + rules_sha256=rules_sha256, + scale_source=top.choice("scale_source", SCALE_SOURCES), + measurements=measurements, + outcomes=outcomes, + capture_seconds=timing.optional_length("capture_s"), + processing_seconds=timing.optional_length("processing_s"), + ) + + +def _checks_list(keys: Iterable[tuple[str, str]]) -> str: + return ", ".join(f"{check} at {candidate}" for candidate, check in sorted(keys)) + + +def _require_run_matches_survey(results: Results, truth: Truth, rules: Rules) -> None: + """Reject a run that does not answer exactly the survey's questions under the same rules.""" + path = results.path + if results.rules_sha256 != rules.sha256: + raise InputError( + f"{path}: rules_sha256: the run used rules hashing to {results.rules_sha256}, but " + f"{rules.path} hashes to {rules.sha256}; every run must use the same frozen rules" + ) + + expected = set(truth.measurements) + reported = set(results.measurements) + unknown = sorted(reported - expected) + if unknown: + raise InputError( + f"{path}: measurements {', '.join(map(repr, unknown))} are not in {truth.path}" + ) + # The scale reference is optional: only runs that declare it as their scale source get it. + missing = sorted(expected - reported - {truth.scale_reference}) + if missing: + raise InputError( + f"{path}: no entry for measurements {', '.join(map(repr, missing))}; report every " + 'survey measurement, with value_ft null and a "missing" reason when there is no value' + ) + + if results.outcomes is None: + return + expected_checks = {(check.candidate, check.check) for check in truth.checks} + extra = set(results.outcomes) - expected_checks + if extra: + raise InputError(f"{path}: outcomes for checks not in the survey: {_checks_list(extra)}") + absent = expected_checks - set(results.outcomes) + if absent: + raise InputError( + f'{path}: no outcome for {_checks_list(absent)}; report "unsure" when the run ' + "cannot decide, or set outcomes to null for a run that makes no decisions" + ) + + +def _require_same_capture_time(results: Results, earlier: list[Results]) -> None: + """Every row scored on one recording shares its walk time; none can claim a shorter one.""" + if results.capture_seconds is None: + return + for other in earlier: + if other.capture == results.capture and other.capture_seconds not in ( + None, + results.capture_seconds, + ): + raise InputError( + f"{results.path}: timing.capture_s: {results.capture_seconds} s differs from " + f"{other.capture_seconds} s in {other.path} for the same capture " + f"{results.capture!r}; runs on one recording share its capture time" + ) + + +def load_study(rules_path: Path, truth_paths: list[Path], results_paths: list[Path]) -> Study: + """Load every file and pair each run with the survey of the house it captured.""" + rules = load_rules(rules_path) + truths: list[Truth] = [] + by_capture: dict[str, Truth] = {} + for path in truth_paths: + truth = load_truth(path, rules) + for other in truths: + if other.house == truth.house: + raise InputError( + f"{path}: house {truth.house!r} is already surveyed in {other.path}" + ) + for capture in truth.captures: + if capture in by_capture: + raise InputError( + f"{path}: capture {capture!r} already belongs to {by_capture[capture].path}" + ) + by_capture[capture] = truth + truths.append(truth) + + runs: dict[str, list[Results]] = {truth.house: [] for truth in truths} + seen_runs: dict[tuple[str, str], Path] = {} + for path in results_paths: + results = load_results(path) + truth = by_capture.get(results.capture) + if truth is None: + raise InputError( + f"{path}: capture: no survey lists capture {results.capture!r}; add it to that " + "house's captures" + ) + key = (results.capture, results.pipeline) + if key in seen_runs: + raise InputError( + f"{path}: pipeline {results.pipeline!r} on capture {results.capture!r} is " + f"already scored from {seen_runs[key]}" + ) + seen_runs[key] = path + _require_run_matches_survey(results, truth, rules) + _require_same_capture_time(results, runs[truth.house]) + runs[truth.house].append(results) + + return Study( + rules=rules, + houses=tuple(House(truth, tuple(runs[truth.house])) for truth in truths), + ) diff --git a/experiments/scoring/src/scoring/measure_lab.py b/experiments/scoring/src/scoring/measure_lab.py new file mode 100644 index 000000000..9ec7f4511 --- /dev/null +++ b/experiments/scoring/src/scoring/measure_lab.py @@ -0,0 +1,498 @@ +"""Turn a Measure Lab session zip into a results file the scorer reads. + +The rig's measurements carry app-generated ids and meter values. A map file, written by hand after +the walk, says which session measurement and `values` key answers each survey measurement, and with +what uncertainty. The session format is Measure Lab's formatVersion 2, documented in +experiments/measure-lab/README.md ("Session format"). Only the fields used here are validated. +""" + +import argparse +import hashlib +import json +import sys +import tempfile +import zipfile +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal, localcontext +from pathlib import Path +from typing import Any + +from scoring.inputs import ( + FORMAT, + UNIT, + Fields, + InputError, + MissingReason, + Outcome, + PipelineMeasurement, + Rules, + Threshold, + Truth, + load_rules, + load_study, + load_truth, + parse_json, + read_json, +) +from scoring.metrics import decide + +SESSION_FORMAT = "measure-lab-session" +SESSION_FORMAT_VERSION = 2 +VALUE_KEYS = ("straight", "horizontal", "vertical", "alongWall", "gapToWall", "heightAboveGround") + +# Exact by definition (international yard and pound agreement, 1959). +METERS_PER_FOOT = Decimal("0.3048") +# Division by 0.3048 rarely terminates (1 m is 3.28083989501312... ft), so feet are rounded to a +# millionth of a foot. ARKit is good to centimeters at best, so the rounding is invisible, and it +# keeps every value inside the scorer's 12-decimal-place limit. +FEET_PLACES = Decimal("0.000001") +# Capture time comes from the device-uptime clock, a Double of seconds; milliseconds are enough. +SECONDS_PLACES = Decimal("0.001") +# ARKit world tracking supplies metric scale; the rig uses no scale reference or depth model. +SCALE_SOURCE = "ar_poses" +# Marks a row whose outcomes the importer computed with the survey's rule. +RULE_SUFFIX = "+rule" + +MAP_ENTRY_FIELDS = {"session_measurement", "key", "plus_minus_ft", "refusal"} + + +def meters_to_feet(meters: Decimal) -> Decimal: + """Exact division by 0.3048, then half-up rounding to FEET_PLACES.""" + with localcontext() as context: + context.prec = 50 + return (meters / METERS_PER_FOOT).quantize(FEET_PLACES, rounding=ROUND_HALF_UP) + + +@dataclass(frozen=True) +class SessionMeasurement: + id: str + time: Decimal + values: dict[str, Decimal] + compared: str + accepted: bool + + +@dataclass(frozen=True) +class Session: + zip_path: Path + zip_sha256: str + member: str + id: str + started_at_uptime: Decimal + measurements: dict[str, SessionMeasurement] + refusals: set[str] + + +def _number(value: Any, where: str) -> Decimal: + if isinstance(value, bool) or not isinstance(value, int | Decimal): + raise InputError(f"{where}: expected a number, got {value!r}") + return Decimal(value) + + +def _text(value: Any, where: str) -> str: + if not isinstance(value, str) or not value: + raise InputError(f"{where}: expected a non-empty string, got {value!r}") + return value + + +def _list(data: dict[str, Any], key: str, where: str) -> list[Any]: + value = data.get(key) + if not isinstance(value, list): + raise InputError(f"{where}: expected a list in {key!r}, got {value!r}") + return value + + +def _session_member(archive: zipfile.ZipFile, path: Path) -> str: + """The one session.json, at the zip's root or inside its single session folder.""" + found = [ + name + for name in archive.namelist() + if not name.startswith("__MACOSX/") + and name.split("/")[-1] == "session.json" + and name.count("/") <= 1 + ] + if len(found) != 1: + where = "none" if not found else ", ".join(found) + raise InputError( + f"{path}: expected exactly one session.json at the top of the zip or in its session " + f"folder, found {where}; share the session from Measure Lab's Session sheet" + ) + return found[0] + + +def load_session(path: Path) -> Session: + try: + raw_zip = path.read_bytes() + except OSError as error: + raise InputError(f"{path}: cannot read ({error.strerror})") from None + try: + with zipfile.ZipFile(path) as archive: + member = _session_member(archive, path) + raw = archive.read(member) + except zipfile.BadZipFile: + raise InputError(f"{path}: not a zip file") from None + where = f"{path}!{member}" + data = parse_json(raw, where) + if not isinstance(data, dict): + raise InputError(f"{where}: expected a JSON object") + + if data.get("format") != SESSION_FORMAT: + raise InputError(f"{where}: format is {data.get('format')!r}, not {SESSION_FORMAT!r}") + version = data.get("formatVersion") + if type(version) is not int or version != SESSION_FORMAT_VERSION: + raise InputError( + f"{where}: formatVersion is {version!r}; this importer reads Measure Lab session " + f"format {SESSION_FORMAT_VERSION} only, whose value fields may differ in other versions" + ) + units = data.get("units") + if not isinstance(units, dict) or units.get("length") != "meters": + raise InputError(f"{where}: units.length must be 'meters'") + + info = data.get("session") + if not isinstance(info, dict): + raise InputError(f"{where}: session: expected an object") + session_id = _text(info.get("id"), f"{where}: session.id") + started = _number(info.get("startedAtUptime"), f"{where}: session.startedAtUptime") + + measurements: dict[str, SessionMeasurement] = {} + for index, entry in enumerate(_list(data, "measurements", where)): + at = f"{where}: measurements[{index}]" + if not isinstance(entry, dict): + raise InputError(f"{at}: expected an object") + measurement_id = _text(entry.get("id"), f"{at}.id") + if measurement_id in measurements: + raise InputError(f"{at}.id: measurement {measurement_id!r} is listed twice") + values = entry.get("values") + if not isinstance(values, dict): + raise InputError(f"{at} ({measurement_id}).values: expected an object") + accepted = entry.get("accepted") + if not isinstance(accepted, bool): + raise InputError(f"{at} ({measurement_id}).accepted: expected true or false") + measurements[measurement_id] = SessionMeasurement( + id=measurement_id, + time=_number(entry.get("time"), f"{at} ({measurement_id}).time"), + values={ + key: _number(value, f"{at} ({measurement_id}).values.{key}") + for key, value in values.items() + }, + compared=_text(entry.get("compared"), f"{at} ({measurement_id}).compared"), + accepted=accepted, + ) + + refusals: set[str] = set() + for index, entry in enumerate(_list(data, "refusals", where)): + if not isinstance(entry, dict): + raise InputError(f"{where}: refusals[{index}]: expected an object") + refusals.add(_text(entry.get("id"), f"{where}: refusals[{index}].id")) + + return Session( + zip_path=path, + zip_sha256=hashlib.sha256(raw_zip).hexdigest(), + member=member, + id=session_id, + started_at_uptime=started, + measurements=measurements, + refusals=refusals, + ) + + +@dataclass(frozen=True) +class FromSession: + measurement: str + key: str + plus_minus_ft: Decimal + + +@dataclass(frozen=True) +class FromRefusal: + refusal: str + + +@dataclass(frozen=True) +class Map: + path: Path + pipeline: str + session: str + entries: dict[str, FromSession | FromRefusal | MissingReason] + + +def load_map(path: Path) -> Map: + data, _ = read_json(path) + top = Fields( + data, + path, + "", + {"format", "unit", "pipeline", "session", "notes", "plus_minus_ft_by_key", "measurements"}, + ) + top.header() + if top.has("notes"): + top.text("notes") + by_key: dict[str, Decimal] = {} + if top.has("plus_minus_ft_by_key"): + table = top.raw("plus_minus_ft_by_key") + keys = top.child(table, "plus_minus_ft_by_key", VALUE_KEYS) + by_key = {key: keys.length(key) for key in keys.data} + + table = top.raw("measurements") + if not isinstance(table, dict) or not table: + raise top.error("expected an object keyed by survey measurement id", "measurements") + entries: dict[str, FromSession | FromRefusal | MissingReason] = {} + for survey_id, entry in table.items(): + where = f"measurements.{survey_id}" + if entry in ("absent", "unsupported"): + entries[survey_id] = entry + continue + if isinstance(entry, str): + raise top.error(f'expected "absent", "unsupported" or an object, got {entry!r}', where) + fields = top.child(entry, where, MAP_ENTRY_FIELDS) + if fields.has("refusal"): + extra = sorted(set(fields.data) - {"refusal"}) + if extra: + raise fields.error(f"a refusal entry takes no {', '.join(extra)}") + entries[survey_id] = FromRefusal(fields.text("refusal")) + continue + key = fields.choice("key", VALUE_KEYS) + if fields.has("plus_minus_ft"): + plus_minus = fields.length("plus_minus_ft") + elif key in by_key: + plus_minus = by_key[key] + else: + raise fields.error( + f"no uncertainty for values key {key!r}; state plus_minus_ft here or in " + "plus_minus_ft_by_key" + ) + entries[survey_id] = FromSession(fields.text("session_measurement"), key, plus_minus) + + return Map(path, top.text("pipeline"), top.text("session"), entries) + + +def _check_map(mapping: Map, session: Session, truth: Truth) -> None: + where = str(mapping.path) + if mapping.session != session.id: + raise InputError( + f"{where}: session is {mapping.session!r}, but {session.zip_path} holds session " + f"{session.id!r}; this map was written for another walk" + ) + unknown = sorted(set(mapping.entries) - set(truth.measurements)) + if unknown: + raise InputError( + f"{where}: measurements {', '.join(map(repr, unknown))} are not survey measurement " + f"ids in {truth.path}" + ) + # The scale reference may be left out, as in a results file: the rig does not use it. + left_out = sorted(set(truth.measurements) - set(mapping.entries) - {truth.scale_reference}) + if left_out: + raise InputError( + f"{where}: no entry for survey measurements {', '.join(map(repr, left_out))}; map " + 'each to a session measurement, a refusal, "absent" or "unsupported"' + ) + for survey_id, entry in mapping.entries.items(): + at = f"{where}: measurements.{survey_id}" + if isinstance(entry, FromRefusal) and entry.refusal not in session.refusals: + raise InputError(f"{at}.refusal: {session.member} has no refusal {entry.refusal!r}") + if not isinstance(entry, FromSession): + continue + measurement = session.measurements.get(entry.measurement) + if measurement is None: + raise InputError( + f"{at}.session_measurement: {session.member} has no measurement " + f"{entry.measurement!r}" + ) + if entry.key not in measurement.values: + raise InputError( + f"{at}.key: measurement {entry.measurement!r} has no {entry.key!r} value; it has " + f"{', '.join(measurement.values) or 'none'}" + ) + if entry.key != measurement.compared: + raise InputError( + f"{at}.key: {entry.key!r} was not the validated quantity for measurement " + f"{entry.measurement!r}; its compared field is {measurement.compared!r}" + ) + if measurement.accepted and measurement.values[entry.key] < 0: + raise InputError( + f"{at}: measurement {entry.measurement!r} is accepted but its {entry.key} is " + f"{measurement.values[entry.key]} m; format 2 only allows a negative " + "heightAboveGround, and marks it belowGround and not accepted" + ) + + +def _imported_measurement( + survey_id: str, entry: FromSession | FromRefusal | MissingReason, session: Session +) -> PipelineMeasurement: + if isinstance(entry, str): + return PipelineMeasurement(survey_id, None, None, entry) + if isinstance(entry, FromRefusal): + return PipelineMeasurement(survey_id, None, None, "failed") + measurement = session.measurements[entry.measurement] + if not measurement.accepted: + # The rig's own abstention: a value with warnings that its scoring counts as no answer. + return PipelineMeasurement(survey_id, None, None, "failed") + value_ft = meters_to_feet(measurement.values[entry.key]) + return PipelineMeasurement(survey_id, value_ft, entry.plus_minus_ft, None) + + +def _measurement_json(measurement: PipelineMeasurement) -> dict[str, Any]: + if measurement.value_ft is None: + return {"id": measurement.id, "value_ft": None, "missing": measurement.missing} + return { + "id": measurement.id, + "value_ft": measurement.value_ft, + "plus_minus_ft": measurement.plus_minus_ft, + } + + +def _rule_outcome( + measurement: PipelineMeasurement, threshold: Threshold, review: Threshold | None +) -> Outcome: + """The survey's strict rule applied to the run's own value. Without a value it never decides, + except that a feature the operator marked absent clears an at_least check, as in the survey.""" + if measurement.value_ft is None: + absent_clearance = measurement.missing == "absent" and threshold.pass_when == "at_least" + return "pass" if absent_clearance else "unsure" + assert measurement.plus_minus_ft is not None # the map always states an uncertainty + result = decide(measurement.value_ft, measurement.plus_minus_ft, threshold, review) + return "unsure" if result == "borderline" or result == "review" else result + + +def _outcomes( + truth: Truth, rules: Rules, measurements: dict[str, PipelineMeasurement] +) -> list[dict[str, str]]: + outcomes = [] + for check in truth.checks: + threshold = rules.thresholds[check.threshold] + review = ( + None if check.review_threshold is None else rules.thresholds[check.review_threshold] + ) + outcome = _rule_outcome(measurements[check.measurement], threshold, review) + outcomes.append({"candidate": check.candidate, "check": check.check, "outcome": outcome}) + return outcomes + + +def build_results( + session: Session, mapping: Map, rules: Rules, truth: Truth, *, decide_outcomes: bool +) -> dict[str, Any]: + _check_map(mapping, session, truth) + if session.zip_sha256 not in truth.captures: + raise InputError( + f"{truth.path}: captures does not list {session.zip_sha256}, the sha256 of " + f"{session.zip_path}; add it so this run is scored against this survey" + ) + measurements = { + survey_id: _imported_measurement(survey_id, mapping.entries[survey_id], session) + for survey_id in truth.measurements + if survey_id in mapping.entries + } + times = [measurement.time for measurement in session.measurements.values()] + capture_s = None + if times: + capture_s = (max(times) - session.started_at_uptime).quantize( + SECONDS_PLACES, rounding=ROUND_HALF_UP + ) + if capture_s < 0: + raise InputError(f"{session.zip_path}: a measurement predates startedAtUptime") + return { + "format": FORMAT, + "unit": UNIT, + "pipeline": mapping.pipeline + (RULE_SUFFIX if decide_outcomes else ""), + "capture": session.zip_sha256, + "rules_sha256": rules.sha256, + "scale_source": SCALE_SOURCE, + "measurements": [_measurement_json(m) for m in measurements.values()], + "outcomes": _outcomes(truth, rules, measurements) if decide_outcomes else None, + # The rig shows each value as it is tapped and records no processing time, so + # processing_s is null; 0 would claim a measurement nobody took. + "timing": {"capture_s": capture_s, "processing_s": None}, + } + + +def dumps(value: Any, indent: str = "") -> str: + """JSON text with every Decimal written exactly, never through a float.""" + inner = indent + " " + if isinstance(value, dict): + if not value: + return "{}" + items = [f"{inner}{_string(key)}: {dumps(item, inner)}" for key, item in value.items()] + return "{\n" + ",\n".join(items) + f"\n{indent}}}" + if isinstance(value, list): + if not value: + return "[]" + return "[\n" + ",\n".join(inner + dumps(item, inner) for item in value) + f"\n{indent}]" + if isinstance(value, Decimal): + return format(value, "f") + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, str): + return _string(value) + raise TypeError(f"cannot write {type(value).__name__} as JSON") + + +def _string(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def import_session( + session_path: Path, + map_path: Path, + rules_path: Path, + truth_path: Path, + out_path: Path, + *, + decide_outcomes: bool, +) -> dict[str, Any]: + rules = load_rules(rules_path) + truth = load_truth(truth_path, rules) + session = load_session(session_path) + results = build_results( + session, load_map(map_path), rules, truth, decide_outcomes=decide_outcomes + ) + text = dumps(results) + "\n" + # Load the output through the scorer first, so a file that would not score is never written. + with tempfile.TemporaryDirectory() as scratch: + candidate = Path(scratch) / "results.json" + candidate.write_text(text, encoding="utf-8") + load_study(rules_path, [truth_path], [candidate]) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(text, encoding="utf-8") + return results + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser( + prog="score import-measure-lab", + description="Convert a Measure Lab session zip (format 2) into a results file, using a " + "map file that ties session measurements to survey measurement ids.", + ) + parser.add_argument("session", type=Path, help="session zip shared from Measure Lab") + parser.add_argument("--map", type=Path, required=True, help="map file (JSON)") + parser.add_argument("--rules", type=Path, required=True, help="rules file (JSON)") + parser.add_argument("--truth", type=Path, required=True, help="survey file for this house") + parser.add_argument("--out", type=Path, required=True, help="results file to write") + parser.add_argument( + "--decide", + action="store_true", + help=f"compute pass/unsure/fail with the survey's strict rule and add {RULE_SUFFIX} " + "to the pipeline id; without it the row makes no decisions", + ) + args = parser.parse_args(argv) + try: + results = import_session( + args.session, + args.map, + args.rules, + args.truth, + args.out, + decide_outcomes=args.decide, + ) + except InputError as error: + print(f"score import-measure-lab: {error}", file=sys.stderr) + return 2 + print( + f"score import-measure-lab: wrote {args.out} (pipeline {results['pipeline']}, " + f"capture {results['capture']})", + file=sys.stderr, + ) + return 0 diff --git a/experiments/scoring/src/scoring/metrics.py b/experiments/scoring/src/scoring/metrics.py new file mode 100644 index 000000000..d426aeec6 --- /dev/null +++ b/experiments/scoring/src/scoring/metrics.py @@ -0,0 +1,382 @@ +"""Compare one pipeline run with the tape survey of the same house. + +Pure functions over the loaded inputs. Every figure is exact Decimal arithmetic on the numbers as +typed, so a tie with a threshold or with a reported uncertainty resolves the same way on every +machine. Errors are reported in inches; survey values, uncertainties and thresholds stay in feet. +""" + +from dataclasses import dataclass +from decimal import Decimal +from statistics import median +from typing import Literal + +from scoring.inputs import ( + Check, + Outcome, + PipelineMeasurement, + Results, + SurveyMeasurement, + Threshold, + Truth, +) + +INCHES_PER_FOOT = Decimal(12) + +MeasurementStatus = Literal[ + "scored", # both the survey and the run have a value + "scale_reference", # the house's declared scale distance: never scored + "not_surveyed", # the survey could not reach it, so nothing can be said about the run + "missing_unsupported", # the run has no way to produce this distance + "missing_failed", # the run tried and produced nothing + "false_absent", # the run says the feature does not exist; the survey measured it + "phantom", # the run gives a distance to a feature the survey found absent + "absent_agreed", # both say the feature does not exist +] + +# pass and fail are outside the survey's uncertainty. borderline: the survey itself is unsure, +# because its ± reaches a check's single threshold. review: the survey value, or its ±, lies in a +# check's review band. unknown: the survey could not measure the deciding distance. +TruthOutcome = Literal["pass", "fail", "borderline", "review", "unknown"] +Abstention = Literal["justified", "avoidable"] +# The error ratio when the survey sits exactly on a threshold with no uncertainty: no denominator. +AtThreshold = Literal["at_threshold"] +AT_THRESHOLD: AtThreshold = "at_threshold" + + +@dataclass(frozen=True) +class MeasurementScore: + survey: SurveyMeasurement + reported: PipelineMeasurement | None + status: MeasurementStatus + # Run minus survey, so a positive error means the run overestimated. None unless scored. + error_ft: Decimal | None + # |run - survey| <= the run's reported uncertainty. None when not scored or none reported. + truth_within_reported: bool | None + + @property + def signed_error_in(self) -> Decimal | None: + return None if self.error_ft is None else self.error_ft * INCHES_PER_FOOT + + @property + def abs_error_in(self) -> Decimal | None: + return None if self.error_ft is None else abs(self.error_ft) * INCHES_PER_FOOT + + +def score_measurement( + survey: SurveyMeasurement, reported: PipelineMeasurement | None, *, scale_reference: bool +) -> MeasurementScore: + status = _measurement_status(survey, reported, scale_reference=scale_reference) + if status != "scored": + return MeasurementScore(survey, reported, status, None, None) + assert reported is not None and reported.value_ft is not None + assert survey.value_ft is not None + error_ft = reported.value_ft - survey.value_ft + within = None if reported.plus_minus_ft is None else abs(error_ft) <= reported.plus_minus_ft + return MeasurementScore(survey, reported, status, error_ft, within) + + +def _measurement_status( + survey: SurveyMeasurement, reported: PipelineMeasurement | None, *, scale_reference: bool +) -> MeasurementStatus: + if scale_reference: + return "scale_reference" + if survey.status == "not_measured": + return "not_surveyed" + if reported is None: + raise ValueError(f"run has no entry for survey measurement {survey.id!r}") + if reported.missing == "unsupported": + return "missing_unsupported" + if reported.missing == "failed": + return "missing_failed" + run_says_absent = reported.missing == "absent" + if survey.status == "absent": + return "absent_agreed" if run_says_absent else "phantom" + return "false_absent" if run_says_absent else "scored" + + +def truth_outcome( + survey: SurveyMeasurement, threshold: Threshold, review: Threshold | None = None +) -> TruthOutcome: + """The outcome the tape survey supports under the rules. + + Margins are signed so that positive is the passing side, and u is the survey uncertainty. + The check passes when the margin to the pass line is larger than u and fails when the margin + to the fail line is below -u. The fail line is `threshold`. The pass line is `review` when + the check has a review band and `threshold` otherwise. Anything else is borderline for a + single threshold, or review for a band, as docs/02-implementation-plan.md "Lane C" sends a + route past the confident reach to UNSURE. + + Both comparisons are strict, following Lane C: "A check answers PASS if the margin is larger + than the error." A value exactly on a line, or exactly u from it, is borderline or review, + never pass or fail, including when u = 0. The solver uses the same rule, so a correct + pipeline agrees with this outcome. A missing feature clears every at_least rule. + """ + if survey.status == "not_measured": + return "unknown" + if survey.status == "absent": + if threshold.pass_when != "at_least": + raise ValueError(f"absent measurement {survey.id!r} cannot decide an at_most rule") + return "pass" + assert survey.value_ft is not None and survey.plus_minus_ft is not None + return decide(survey.value_ft, survey.plus_minus_ft, threshold, review) + + +def decide( + value_ft: Decimal, plus_minus_ft: Decimal, threshold: Threshold, review: Threshold | None +) -> Literal["pass", "fail", "borderline", "review"]: + """The strict Lane C rule for one value and its uncertainty; truth_outcome explains it. + + The survey outcome and the Measure Lab importer's --decide row both call this, so a run + that emulates the rule and the survey it is scored against can never disagree about ties. + """ + pass_margin = margin_ft(value_ft, review or threshold) + if pass_margin > plus_minus_ft: + return "pass" + if margin_ft(value_ft, threshold) < -plus_minus_ft: + return "fail" + return "review" if review else "borderline" + + +def margin_ft(value_ft: Decimal, threshold: Threshold) -> Decimal: + """Value minus threshold, signed so that positive is the passing side.""" + if threshold.pass_when == "at_least": + return value_ft - threshold.value_ft + return threshold.value_ft - value_ft + + +def passing_margin_ft(survey: SurveyMeasurement, threshold: Threshold) -> Decimal | None: + """The survey value's margin_ft, or None when the survey has no value.""" + return None if survey.value_ft is None else margin_ft(survey.value_ft, threshold) + + +def expected_outcome(truth: TruthOutcome) -> Outcome | None: + """What a correct run reports: unsure for a borderline or review survey outcome.""" + return { + "pass": "pass", + "fail": "fail", + "borderline": "unsure", + "review": "unsure", + "unknown": None, + }[truth] + + +def error_to_margin( + abs_error_ft: Decimal, margins_ft: tuple[Decimal, ...], survey_plus_minus_ft: Decimal +) -> Decimal | AtThreshold: + """error / max(m, survey uncertainty). At 1 or above, the run's error could flip the check. + + m is the survey value's distance to the nearest threshold in `margins_ft`: the one threshold, + or either edge of a review band, since crossing either edge changes the outcome. + """ + scale = max(min(abs(margin) for margin in margins_ft), survey_plus_minus_ft) + if scale == 0: + return AT_THRESHOLD + return abs_error_ft / scale + + +def could_flip(ratio: Decimal | AtThreshold, abs_error_ft: Decimal) -> bool: + """An error equal to the margin can put the run exactly on a line, where the strict rule + neither passes nor fails it, so a ratio of exactly 1 can flip the check.""" + if isinstance(ratio, str): + return abs_error_ft > 0 + return ratio >= 1 + + +@dataclass(frozen=True) +class CheckScore: + check: Check + threshold: Threshold + review: Threshold | None + survey: SurveyMeasurement + measurement: MeasurementScore + truth: TruthOutcome + # Signed survey margins to the fail threshold and to the review threshold; positive passes. + margin_ft: Decimal | None + review_margin_ft: Decimal | None + error_to_margin: Decimal | AtThreshold | None + could_flip: bool | None + reported: Outcome | None + expected: Outcome | None + # The following are None when the run makes no decisions or the survey outcome is unknown. + agrees: bool | None + # The three wrong-answer categories mean the same for every check, banded or not. + # A pass where the survey fails. + unsafe_pass: bool | None + # A pass where the survey is unsure (borderline) or in a review band. + missed_review: bool | None + # An unsure or fail where the survey passes. + over_caution: bool | None + false_rejection: bool | None + # A pass or fail without a deciding distance. Counted whatever the survey outcome. + decided_without_measurement: bool | None + abstention: Abstention | None + + +def _decided_without_measurement( + reported: Outcome, measurement: MeasurementScore, threshold: Threshold +) -> bool: + """Absence supports only a pass on an at_least clearance.""" + missing = measurement.reported.missing if measurement.reported else None + return reported in ("pass", "fail") and ( + missing in ("failed", "unsupported") + or (missing == "absent" and (threshold.pass_when == "at_most" or reported == "fail")) + ) + + +def score_check( + check: Check, + threshold: Threshold, + measurement: MeasurementScore, + reported: Outcome | None, + review: Threshold | None = None, +) -> CheckScore: + survey = measurement.survey + truth = truth_outcome(survey, threshold, review) + margin = passing_margin_ft(survey, threshold) + review_margin = None if review is None else passing_margin_ft(survey, review) + + ratio: Decimal | AtThreshold | None = None + flip: bool | None = None + if measurement.error_ft is not None: + assert margin is not None and survey.plus_minus_ft is not None + abs_error_ft = abs(measurement.error_ft) + margins = (margin,) if review_margin is None else (margin, review_margin) + ratio = error_to_margin(abs_error_ft, margins, survey.plus_minus_ft) + flip = could_flip(ratio, abs_error_ft) + + expected = expected_outcome(truth) + agrees = unsafe = missed = over_caution = rejection = without_measurement = None + abstention: Abstention | None = None + if reported is not None: + without_measurement = _decided_without_measurement(reported, measurement, threshold) + if reported is not None and truth != "unknown": + agrees = reported == expected + unsafe = reported == "pass" and truth == "fail" + missed = reported == "pass" and truth in ("borderline", "review") + over_caution = reported in ("unsure", "fail") and truth == "pass" + rejection = reported == "fail" and truth == "pass" + if reported == "unsure": + evidence_missing = measurement.status in ("missing_unsupported", "missing_failed") + justified = truth in ("borderline", "review") or evidence_missing + abstention = "justified" if justified else "avoidable" + + return CheckScore( + check=check, + threshold=threshold, + review=review, + survey=survey, + measurement=measurement, + truth=truth, + margin_ft=margin, + review_margin_ft=review_margin, + error_to_margin=ratio, + could_flip=flip, + reported=reported, + expected=expected, + agrees=agrees, + unsafe_pass=unsafe, + missed_review=missed, + over_caution=over_caution, + false_rejection=rejection, + decided_without_measurement=without_measurement, + abstention=abstention, + ) + + +@dataclass(frozen=True) +class RunScore: + truth: Truth + results: Results + measurements: tuple[MeasurementScore, ...] + checks: tuple[CheckScore, ...] + + def count(self, status: MeasurementStatus) -> int: + return sum(score.status == status for score in self.measurements) + + @property + def denominator(self) -> int: + """Every survey measurement except the scale reference, whether or not the run answered.""" + return sum(score.status != "scale_reference" for score in self.measurements) + + @property + def scored_errors_in(self) -> list[Decimal]: + return [s.abs_error_in for s in self.measurements if s.abs_error_in is not None] + + @property + def median_abs_error_in(self) -> Decimal | None: + errors = self.scored_errors_in + return median(errors) if errors else None + + @property + def max_abs_error_in(self) -> Decimal | None: + errors = self.scored_errors_in + return max(errors) if errors else None + + @property + def within_reported(self) -> tuple[int, int]: + """(scored distances inside the run's ±, scored distances where the run gave a ±).""" + flags = [s.truth_within_reported for s in self.measurements] + return sum(flag is True for flag in flags), sum(flag is not None for flag in flags) + + @property + def makes_decisions(self) -> bool: + return self.results.outcomes is not None + + @property + def judged(self) -> int: + """Checks the survey can settle: its outcome is not unknown.""" + return sum(score.truth != "unknown" for score in self.checks) + + @property + def agreements(self) -> int: + return sum(score.agrees is True for score in self.checks) + + @property + def unsafe_passes(self) -> int: + return sum(score.unsafe_pass is True for score in self.checks) + + @property + def missed_reviews(self) -> int: + return sum(score.missed_review is True for score in self.checks) + + @property + def over_cautious(self) -> int: + return sum(score.over_caution is True for score in self.checks) + + @property + def decided_without_measurement(self) -> int: + return sum(score.decided_without_measurement is True for score in self.checks) + + @property + def false_rejections(self) -> int: + return sum(score.false_rejection is True for score in self.checks) + + @property + def could_flip(self) -> int: + return sum(score.could_flip is True for score in self.checks) + + def abstentions(self, kind: Abstention) -> int: + return sum(score.abstention == kind for score in self.checks) + + +def score_run(truth: Truth, results: Results, thresholds: dict[str, Threshold]) -> RunScore: + measurements = tuple( + score_measurement( + survey, + results.measurements.get(survey.id), + scale_reference=survey.id == truth.scale_reference, + ) + for survey in truth.measurements.values() + ) + by_id = {score.survey.id: score for score in measurements} + checks = tuple( + score_check( + check, + thresholds[check.threshold], + by_id[check.measurement], + None if results.outcomes is None else results.outcomes[(check.candidate, check.check)], + None if check.review_threshold is None else thresholds[check.review_threshold], + ) + for check in truth.checks + ) + return RunScore(truth, results, measurements, checks) diff --git a/experiments/scoring/src/scoring/report.py b/experiments/scoring/src/scoring/report.py new file mode 100644 index 000000000..8c27ed0b9 --- /dev/null +++ b/experiments/scoring/src/scoring/report.py @@ -0,0 +1,458 @@ +"""Turn run scores into a markdown summary and three CSV tables. + +CSV cells hold the figure, or stay empty when it does not apply (a missing distance has no error). +Booleans are written true or false. Decimals are rounded half up for display only; every +comparison was made on the exact values. +""" + +import csv +from collections.abc import Iterable +from decimal import ROUND_HALF_UP, Decimal +from pathlib import Path + +from scoring.inputs import Study +from scoring.metrics import AtThreshold, CheckScore, MeasurementScore, RunScore + +MEASUREMENT_COLUMNS = ( + "house", + "capture", + "pipeline", + "measurement", + "candidate", + "from", + "to", + "survey_status", + "survey_ft", + "survey_plus_minus_ft", + "run_ft", + "run_plus_minus_ft", + "run_missing", + "status", + "signed_error_in", + "abs_error_in", + "truth_within_reported", +) + +CHECK_COLUMNS = ( + "house", + "capture", + "pipeline", + "candidate", + "check", + "measurement", + "threshold", + "threshold_ft", + "review_threshold", + "review_threshold_ft", + "pass_when", + "survey_ft", + "survey_plus_minus_ft", + "margin_ft", + "review_margin_ft", + "truth_outcome", + "run_ft", + "abs_error_in", + "error_to_margin", + "could_flip", + "run_outcome", + "expected_outcome", + "agrees", + "unsafe_pass", + "missed_review", + "over_caution", + "false_rejection", + "decided_without_measurement", + "abstention", +) + +RUN_COLUMNS = ( + "house", + "capture", + "pipeline", + "scale_source", + "capture_s", + "processing_s", + "measurements", + "scored", + "median_abs_error_in", + "max_abs_error_in", + "within_reported", + "with_reported_uncertainty", + "missing_unsupported", + "missing_failed", + "false_absent", + "phantom", + "absent_agreed", + "not_surveyed", + "checks", + "judged", + "agrees", + "unsafe_passes", + "missed_reviews", + "over_cautious", + "false_rejections", + "decided_without_measurement", + "abstentions_justified", + "abstentions_avoidable", + "could_flip", +) + + +def fixed(value: Decimal | None, places: int) -> str: + """Round half up to `places` decimals. Never prints -0.00 or scientific notation.""" + if value is None: + return "" + rounded = value.quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP) + if rounded == 0: + rounded = abs(rounded) + return format(rounded, "f") + + +def feet(value: Decimal | None) -> str: + return fixed(value, 3) + + +def inches(value: Decimal | None) -> str: + return fixed(value, 2) + + +def ratio(value: Decimal | AtThreshold | None) -> str: + if value is None: + return "" + return value if isinstance(value, str) else fixed(value, 2) + + +def flag(value: bool | None) -> str: + return "" if value is None else str(value).lower() + + +def seconds(value: Decimal | None) -> str: + return fixed(value, 1) + + +def measurement_row(run: RunScore, score: MeasurementScore) -> dict[str, str]: + survey, reported = score.survey, score.reported + return { + "house": run.truth.house, + "capture": run.results.capture, + "pipeline": run.results.pipeline, + "measurement": survey.id, + "candidate": survey.candidate or "", + "from": survey.start, + "to": survey.end, + "survey_status": survey.status, + "survey_ft": feet(survey.value_ft), + "survey_plus_minus_ft": feet(survey.plus_minus_ft), + "run_ft": feet(reported.value_ft) if reported else "", + "run_plus_minus_ft": feet(reported.plus_minus_ft) if reported else "", + "run_missing": (reported.missing or "") if reported else "", + "status": score.status, + "signed_error_in": inches(score.signed_error_in), + "abs_error_in": inches(score.abs_error_in), + "truth_within_reported": flag(score.truth_within_reported), + } + + +def check_row(run: RunScore, score: CheckScore) -> dict[str, str]: + reported = score.measurement.reported + return { + "house": run.truth.house, + "capture": run.results.capture, + "pipeline": run.results.pipeline, + "candidate": score.check.candidate, + "check": score.check.check, + "measurement": score.check.measurement, + "threshold": score.threshold.name, + "threshold_ft": feet(score.threshold.value_ft), + "review_threshold": score.review.name if score.review else "", + "review_threshold_ft": feet(score.review.value_ft) if score.review else "", + "pass_when": score.threshold.pass_when, + "survey_ft": feet(score.survey.value_ft), + "survey_plus_minus_ft": feet(score.survey.plus_minus_ft), + "margin_ft": feet(score.margin_ft), + "review_margin_ft": feet(score.review_margin_ft), + "truth_outcome": score.truth, + "run_ft": feet(reported.value_ft) if reported else "", + "abs_error_in": inches(score.measurement.abs_error_in), + "error_to_margin": ratio(score.error_to_margin), + "could_flip": flag(score.could_flip), + "run_outcome": score.reported or "", + "expected_outcome": score.expected or "", + "agrees": flag(score.agrees), + "unsafe_pass": flag(score.unsafe_pass), + "missed_review": flag(score.missed_review), + "over_caution": flag(score.over_caution), + "false_rejection": flag(score.false_rejection), + "decided_without_measurement": flag(score.decided_without_measurement), + "abstention": score.abstention or "", + } + + +def run_row(run: RunScore) -> dict[str, str]: + within, with_uncertainty = run.within_reported + decides = run.makes_decisions + + def decision(count: int) -> str: + return str(count) if decides else "" + + return { + "house": run.truth.house, + "capture": run.results.capture, + "pipeline": run.results.pipeline, + "scale_source": run.results.scale_source, + "capture_s": seconds(run.results.capture_seconds), + "processing_s": seconds(run.results.processing_seconds), + "measurements": str(run.denominator), + "scored": str(run.count("scored")), + "median_abs_error_in": inches(run.median_abs_error_in), + "max_abs_error_in": inches(run.max_abs_error_in), + "within_reported": str(within), + "with_reported_uncertainty": str(with_uncertainty), + "missing_unsupported": str(run.count("missing_unsupported")), + "missing_failed": str(run.count("missing_failed")), + "false_absent": str(run.count("false_absent")), + "phantom": str(run.count("phantom")), + "absent_agreed": str(run.count("absent_agreed")), + "not_surveyed": str(run.count("not_surveyed")), + "checks": str(len(run.checks)), + "judged": str(run.judged), + "agrees": decision(run.agreements), + "unsafe_passes": decision(run.unsafe_passes), + "missed_reviews": decision(run.missed_reviews), + "over_cautious": decision(run.over_cautious), + "false_rejections": decision(run.false_rejections), + "decided_without_measurement": decision(run.decided_without_measurement), + "abstentions_justified": decision(run.abstentions("justified")), + "abstentions_avoidable": decision(run.abstentions("avoidable")), + "could_flip": str(run.could_flip), + } + + +def _write(path: Path, columns: tuple[str, ...], rows: Iterable[dict[str, str]]) -> None: + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=columns, lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + + +def write_csvs(runs: list[RunScore], out_dir: Path) -> list[Path]: + out_dir.mkdir(parents=True, exist_ok=True) + paths = [out_dir / "measurements.csv", out_dir / "checks.csv", out_dir / "runs.csv"] + measurement_rows = (measurement_row(r, s) for r in runs for s in r.measurements) + _write(paths[0], MEASUREMENT_COLUMNS, measurement_rows) + _write(paths[1], CHECK_COLUMNS, (check_row(r, s) for r in runs for s in r.checks)) + _write(paths[2], RUN_COLUMNS, (run_row(r) for r in runs)) + return paths + + +def _table(header: list[str], rows: list[list[str]]) -> list[str]: + lines = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)] + lines += ["| " + " | ".join(cell or "n/a" for cell in row) + " |" for row in rows] + return lines + + +def _count(number: int, noun: str) -> str: + return f"{number} {noun}" if number == 1 else f"{number} {noun}s" + + +def markdown(study: Study, runs: list[RunScore]) -> str: + rules = study.rules + houses = study.houses + spots = sum(len(house.truth.candidates) for house in houses) + lines = [ + "# Capture pipeline scores", + "", + f"Rules: `{rules.path.name}` ({rules.name}), sha256 `{rules.sha256}`.", + f"{_count(len(houses), 'house')}, {_count(spots, 'candidate spot')}, " + f"{_count(len(runs), 'pipeline run')}.", + "", + "Each house's declared scale reference is left out of every error figure: a run that was " + "given it can match it exactly, so its error says nothing about accuracy.", + ] + for house in houses: + truth = house.truth + house_runs = [run for run in runs if run.truth is truth] + reference = truth.measurements[truth.scale_reference] + lines += [ + "", + f"## House {truth.house}", + "", + f"Captures: {', '.join(truth.captures)}. Candidates: {', '.join(truth.candidates)}. " + f"Scale reference `{reference.id}` ({feet(reference.value_ft)} ft), excluded.", + ] + if not house_runs: + lines += ["", "No pipeline runs for this house."] + continue + lines += _distances(house_runs) + _checks(house_runs) + _timing(house_runs) + lines += _named_lists(house_runs) + lines += [ + "", + "These are paired results at the listed spots, a case series. They are not an accuracy " + "rate for other homes, validated error bars, or evidence of safety.", + ] + return "\n".join(lines) + "\n" + + +def _distances(runs: list[RunScore]) -> list[str]: + denominator = runs[0].denominator + rows = [] + for run in runs: + within, with_uncertainty = run.within_reported + rows.append( + [ + f"`{run.results.pipeline}`", + run.results.scale_source, + f"{run.count('scored')}/{denominator}", + inches(run.median_abs_error_in), + inches(run.max_abs_error_in), + f"{within}/{with_uncertainty}" if with_uncertainty else "", + str(run.count("missing_unsupported")), + str(run.count("missing_failed")), + str(run.count("false_absent")), + str(run.count("phantom")), + str(run.count("absent_agreed")), + str(run.count("not_surveyed")), + ] + ) + header = [ + "Pipeline", + "Scale source", + "Scored", + "Median error (in)", + "Max error (in)", + "Survey inside run's ±", + "Unsupported", + "Failed", + "Wrongly absent", + "Phantom", + "Absent, agreed", + "Not surveyed", + ] + return [ + "", + "### Distances", + "", + f"{denominator} distances besides the scale reference; each row's counts add up to it. " + "Errors are absolute, in inches, over the distances both the survey and the run " + "measured. Wrongly absent: the run says the feature is not there, but the survey " + "measured it. Phantom: the run measured a feature the survey found absent.", + "", + *_table(header, rows), + ] + + +def _checks(runs: list[RunScore]) -> list[str]: + total = len(runs[0].checks) + judged = runs[0].judged + rows = [] + for run in runs: + if run.makes_decisions: + decided = [ + f"{run.agreements}/{judged}", + str(run.unsafe_passes), + str(run.missed_reviews), + str(run.over_cautious), + str(run.false_rejections), + str(run.decided_without_measurement), + str(run.abstentions("justified")), + str(run.abstentions("avoidable")), + ] + else: + decided = ["no decisions", "", "", "", "", "", "", ""] + rows.append([f"`{run.results.pipeline}`", *decided, str(run.could_flip)]) + header = [ + "Pipeline", + "Agree", + "Unsafe passes", + "Missed reviews", + "Over-cautious", + "False rejections", + "Decided without its measurement", + "Unsure, justified", + "Unsure, avoidable", + "Error could flip", + ] + return [ + "", + "### Checks", + "", + f"{total} checks, {judged} with a survey outcome. The survey passes a check when it " + "clears the pass line by more than its ± and fails it when it misses the fail line by " + "more than its ±. Anything between, including a value exactly on a line, is borderline " + "(one threshold) or review (a band such as review_route_ft to max_route_ft), where the " + "right answer is unsure. Unsafe pass: a pass where the survey fails. Missed review: a " + "pass where the survey is borderline or review. Over-cautious: unsure or fail where the " + "survey passes; false rejections are the fails among them. Decided without its " + "measurement: a pass or fail with the run's measurement missing as failed or " + "unsupported; a claimed absence also cannot support an at_most decision or an at_least " + "fail. It is also scored as usual. An unsure is justified when the survey is borderline " + "or review, or the run's measurement failed or is unsupported. The error could flip a " + "check when it is at least as large as both the survey's " + "distance to the nearest threshold and its ±.", + "", + *_table(header, rows), + ] + + +def _timing(runs: list[RunScore]) -> list[str]: + rows = [ + [ + f"`{run.results.pipeline}`", + seconds(run.results.capture_seconds) or "not recorded", + seconds(run.results.processing_seconds) or "not recorded", + ] + for run in runs + ] + return ["", "### Timing", "", *_table(["Pipeline", "Capture (s)", "Processing (s)"], rows)] + + +def _survey_text(score: CheckScore) -> str: + """The survey outcome and the lines it was judged against, for the named lists.""" + survey, threshold, review = score.survey, score.threshold, score.review + limits = f"`{threshold.name}` {threshold.pass_when} {feet(threshold.value_ft)} ft" + if review is not None: + limits = f"`{review.name}` {feet(review.value_ft)} ft and {limits}" + if survey.value_ft is None: + return f"the survey is {score.truth} ({survey.status.replace('_', ' ')}) against {limits}" + return ( + f"the survey is {score.truth} at {feet(survey.value_ft)} ± " + f"{feet(survey.plus_minus_ft)} ft against {limits}" + ) + + +def _wrong_pass(run: RunScore, score: CheckScore) -> str: + reported = score.measurement.reported + has_value = reported is not None and reported.value_ft is not None + run_value = f"{feet(reported.value_ft)} ft" if has_value else "no value" + return ( + f"- `{run.results.pipeline}` passed `{score.check.check}` at " + f"`{score.check.candidate}`; {_survey_text(score)} (run measured {run_value})." + ) + + +def _decision_without_measurement(run: RunScore, score: CheckScore) -> str: + reported = score.measurement.reported + assert reported is not None and reported.missing is not None + return ( + f"- `{run.results.pipeline}` reported {score.reported} for `{score.check.check}` at " + f"`{score.check.candidate}` with measurement `{score.check.measurement}` missing " + f"({reported.missing}); {_survey_text(score)}." + ) + + +def _named_lists(runs: list[RunScore]) -> list[str]: + """Every unsafe pass, missed review and decision made without its measurement, by name.""" + lines = [] + for title, empty, picked, line in ( + ("Unsafe passes", "No unsafe passes.", lambda s: s.unsafe_pass, _wrong_pass), + ("Missed reviews", "No missed reviews.", lambda s: s.missed_review, _wrong_pass), + ( + "Decided without its measurement", + "No such decisions.", + lambda s: s.decided_without_measurement, + _decision_without_measurement, + ), + ): + found = [line(run, score) for run in runs for score in run.checks if picked(score)] + lines += ["", f"### {title}", "", *found] if found else ["", empty] + return lines diff --git a/experiments/scoring/tests/helpers.py b/experiments/scoring/tests/helpers.py new file mode 100644 index 000000000..6cb9af128 --- /dev/null +++ b/experiments/scoring/tests/helpers.py @@ -0,0 +1,194 @@ +"""Builders for small synthetic inputs. Numbers are strings so each one is an exact Decimal.""" + +import copy +import hashlib +import json +import sys +import zipfile +from decimal import Decimal +from pathlib import Path +from typing import Any + +from scoring.inputs import PipelineMeasurement, SurveyMeasurement, Threshold + +FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" + + +def survey( + value: str | None = "4", + plus_minus: str | None = "0", + *, + status: str = "measured", + id: str = "m", + candidate: str | None = "c1", +) -> SurveyMeasurement: + return SurveyMeasurement( + id=id, + candidate=candidate, + start="footprint edge", + end="gas regulator", + status=status, # type: ignore[arg-type] + value_ft=None if value is None else Decimal(value), + plus_minus_ft=None if plus_minus is None else Decimal(plus_minus), + method="tape", + measured_by=("surveyor-a",), + ) + + +def reported( + value: str | None, plus_minus: str | None = None, *, missing: str | None = None, id: str = "m" +) -> PipelineMeasurement: + return PipelineMeasurement( + id=id, + value_ft=None if value is None else Decimal(value), + plus_minus_ft=None if plus_minus is None else Decimal(plus_minus), + missing=missing, # type: ignore[arg-type] + ) + + +def threshold( + value: str = "3", pass_when: str = "at_least", name: str = "gas_clearance_ft" +) -> Threshold: + return Threshold(name, Decimal(value), pass_when, "synthetic") # type: ignore[arg-type] + + +RULES: dict[str, Any] = { + "format": 1, + "unit": "ft", + "name": "synthetic test rules", + "thresholds": { + "gas_clearance_ft": {"value_ft": 3, "pass_when": "at_least", "source": "synthetic"}, + "max_route_ft": {"value_ft": 20, "pass_when": "at_most", "source": "synthetic"}, + "review_route_ft": {"value_ft": 15, "pass_when": "at_most", "source": "synthetic"}, + }, +} + +TRUTH: dict[str, Any] = { + "format": 1, + "unit": "ft", + "house": "h1", + "captures": ["cap-1"], + "scale_reference": "scale", + "candidates": [{"id": "c1", "marker": "blue tape", "location": "south wall"}], + "measurements": [ + { + "id": "scale", + "candidate": None, + "from": "mark A", + "to": "mark B", + "status": "measured", + "value_ft": 5.0, + "plus_minus_ft": 0.01, + "method": "tape", + "measured_by": ["a", "b"], + }, + { + "id": "c1-gas", + "candidate": "c1", + "from": "footprint edge", + "to": "gas regulator", + "status": "measured", + "value_ft": 4.5, + "plus_minus_ft": 0.01, + "method": "tape", + "measured_by": ["a", "b"], + }, + { + "id": "c1-route", + "candidate": "c1", + "from": "meter", + "to": "battery", + "status": "measured", + "value_ft": 12, + "plus_minus_ft": 0.05, + "method": "tape", + "measured_by": ["a"], + }, + ], + "checks": [ + { + "candidate": "c1", + "check": "gas", + "measurement": "c1-gas", + "threshold": "gas_clearance_ft", + }, + { + "candidate": "c1", + "check": "route", + "measurement": "c1-route", + "threshold": "max_route_ft", + }, + ], +} + +RESULTS: dict[str, Any] = { + "format": 1, + "unit": "ft", + "pipeline": "p1", + "capture": "cap-1", + "rules_sha256": "", # filled in by Files.write_all + "scale_source": "ar_poses", + "measurements": [ + {"id": "c1-gas", "value_ft": 4.7, "plus_minus_ft": 0.3}, + {"id": "c1-route", "value_ft": None, "missing": "failed"}, + ], + "outcomes": [ + {"candidate": "c1", "check": "gas", "outcome": "pass"}, + {"candidate": "c1", "check": "route", "outcome": "unsure"}, + ], + "timing": {"capture_s": 300, "processing_s": 10}, +} + + +class Files: + """Writes editable copies of RULES, TRUTH and RESULTS into a temporary directory.""" + + def __init__(self, root: Path): + self.root = root + self.rules = copy.deepcopy(RULES) + self.truth = copy.deepcopy(TRUTH) + self.results = copy.deepcopy(RESULTS) + + def write(self, name: str, data: Any) -> Path: + path = self.root / name + path.write_text(json.dumps(data, indent=2)) + return path + + def write_all(self) -> tuple[Path, Path, Path]: + rules = self.write("rules.json", self.rules) + if not self.results["rules_sha256"]: + self.results["rules_sha256"] = hashlib.sha256(rules.read_bytes()).hexdigest() + return rules, self.write("truth.json", self.truth), self.write("results.json", self.results) + + +MEASURE_LAB = FIXTURES / "measure-lab" +SESSION_ID = "synthetic-session-01" +SESSION_ZIP = MEASURE_LAB / f"{SESSION_ID}.zip" + + +def session_zip(session: dict[str, Any] | bytes, dest: Path, folder: str = SESSION_ID) -> Path: + """Zip a session the way Measure Lab shares one: the session folder at the top, holding + session.json and keyframes/. Stored, with fixed timestamps, so the bytes and therefore the + capture id (the zip's sha256) are the same on every machine.""" + raw = session if isinstance(session, bytes) else (json.dumps(session, indent=2) + "\n").encode() + with zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as archive: + for name, data in ( + (f"{folder}/", b""), + (f"{folder}/keyframes/", b""), + (f"{folder}/session.json", raw), + ): + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = (0o40755 if name.endswith("/") else 0o100644) << 16 + archive.writestr(info, data) + return dest + + +def committed_session() -> dict[str, Any]: + return json.loads((MEASURE_LAB / SESSION_ID / "session.json").read_text()) + + +if __name__ == "__main__" and sys.argv[1:] == ["rebuild-session-zip"]: + # uv run python tests/helpers.py rebuild-session-zip + raw = (MEASURE_LAB / SESSION_ID / "session.json").read_bytes() + print(hashlib.sha256(session_zip(raw, SESSION_ZIP).read_bytes()).hexdigest()) diff --git a/experiments/scoring/tests/test_fixtures.py b/experiments/scoring/tests/test_fixtures.py new file mode 100644 index 000000000..e203e99d8 --- /dev/null +++ b/experiments/scoring/tests/test_fixtures.py @@ -0,0 +1,293 @@ +"""The committed synthetic fixtures, scored through the CLI and checked against hand-worked answers. + +House synthetic-01 has two spots. Survey outcomes under fixtures/rules.json: +Passing is strict. Route checks pass below review_route_ft (15), fail beyond max_route_ft (20), +and are review from one line to the other, both included. +c1: gas pass (4.5 vs 3), facing_gap pass (5.0 vs 3), route pass (12.05 < 15), pool pass (absent). +c2: gas borderline (3.02 +- 0.03 vs 3), facing_gap fail (2.5 vs 3), route review (19.9 +- 0.05 +is past 15 but not past 20), pool unknown (not measured). +""" + +import csv +from pathlib import Path + +import pytest +from helpers import FIXTURES + +from scoring.cli import main + +RUNS = ["ar-taps", "photo-depth", "mesh-scaled"] + + +def run_cli(tmp_path: Path, *extra: str) -> int: + return main( + [ + "--rules", + str(FIXTURES / "rules.json"), + "--truth", + str(FIXTURES / "truth" / "synthetic-01.json"), + "--results", + *(str(FIXTURES / "results" / f"{name}.json") for name in RUNS), + "--out", + str(tmp_path), + *extra, + ] + ) + + +@pytest.fixture +def scored(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + assert run_cli(tmp_path) == 0 + output = capsys.readouterr() + + def table(name: str) -> list[dict[str, str]]: + with (tmp_path / name).open(newline="") as handle: + return list(csv.DictReader(handle)) + + return output, table + + +def by_key(rows: list[dict[str, str]], *keys: str) -> dict[tuple[str, ...], dict[str, str]]: + return {tuple(row[key] for key in keys): row for row in rows} + + +# Abs errors in inches, per run: see each results fixture. +# ar-taps: wall 2.4, c1-gas 2.4, c1-facing 4.8, c1-route 3.0, c2-gas 3.96, c2-route 4.8 +# ar-taps passes c2 facing_gap and fails c2 pool with both measurements missing as failed: +# decisions made without their measurement. It passes c1 pool on its own claim that there is no +# pool (missing as absent), which does not count. +# photo-depth: wall 24, c1-gas 12, c1-facing 14.4, c2-gas 7.44, c2-facing 4.8, c2-route 13.2 +# mesh-scaled: wall 1.2, c1-facing 1.2, c1-route 6, c2-gas 0.24, c2-facing 1.2 +EXPECTED_RUNS = { + "ar-taps": { + "scored": "6", + "median_abs_error_in": "3.48", # (3.0 + 3.96) / 2 + "max_abs_error_in": "4.80", # the 12 in scale-reference error is excluded + "within_reported": "3", + "with_reported_uncertainty": "6", + "missing_unsupported": "0", + "missing_failed": "1", + "false_absent": "0", + "phantom": "0", + "absent_agreed": "1", + "not_surveyed": "1", + "judged": "7", + "agrees": "4", + "unsafe_passes": "1", # c2 facing_gap, where the survey fails + "missed_reviews": "2", # c2 gas (borderline) and c2 route (review) + "over_cautious": "0", + "false_rejections": "0", + "decided_without_measurement": "2", # c2 facing_gap pass, c2 pool fail + "abstentions_justified": "0", + "abstentions_avoidable": "0", + "could_flip": "2", + "capture_s": "420.0", + "processing_s": "12.5", + }, + "photo-depth": { + "scored": "6", + "median_abs_error_in": "12.60", # (12 + 13.2) / 2 + "max_abs_error_in": "24.00", + "within_reported": "5", + "with_reported_uncertainty": "6", + "missing_unsupported": "1", + "missing_failed": "0", + "false_absent": "0", + "phantom": "1", + "absent_agreed": "0", + "not_surveyed": "1", + "judged": "7", + "agrees": "2", + "unsafe_passes": "0", + "missed_reviews": "0", + "over_cautious": "2", # c1 facing_gap fail, c1 route unsure + "false_rejections": "1", # c1 facing_gap + "decided_without_measurement": "0", # its unsupported distances got unsure + "abstentions_justified": "1", + "abstentions_avoidable": "1", + "could_flip": "2", + "capture_s": "420.0", + "processing_s": "95.0", + }, + "mesh-scaled": { + "scored": "5", + "median_abs_error_in": "1.20", + "max_abs_error_in": "6.00", + "within_reported": "4", + "with_reported_uncertainty": "4", # wall-length has no reported +- + "missing_unsupported": "0", + "missing_failed": "1", + "false_absent": "1", + "phantom": "0", + "absent_agreed": "1", + "not_surveyed": "1", + "judged": "7", + # No decisions: the decision counts are empty, not zero. + "agrees": "", + "unsafe_passes": "", + "missed_reviews": "", + "over_cautious": "", + "false_rejections": "", + "decided_without_measurement": "", + "abstentions_justified": "", + "abstentions_avoidable": "", + "could_flip": "0", + "capture_s": "420.0", + "processing_s": "", + }, +} + + +def test_run_summaries(scored): + _, table = scored + runs = by_key(table("runs.csv"), "pipeline") + assert list(runs) == [(name,) for name in RUNS] + for name, expected in EXPECTED_RUNS.items(): + row = runs[(name,)] + assert row["measurements"] == "9" + assert row["checks"] == "8" + assert {key: row[key] for key in expected} == expected, name + + +def test_measurement_rows(scored): + _, table = scored + rows = by_key(table("measurements.csv"), "pipeline", "measurement") + assert len(rows) == 30 # 3 runs x 10 survey measurements, scale reference included + + scale = rows[("ar-taps", "scale")] + assert (scale["status"], scale["run_ft"], scale["signed_error_in"]) == ( + "scale_reference", + "6.000", + "", + ) + assert rows[("photo-depth", "scale")]["run_ft"] == "" # left out of that run + + facing = rows[("ar-taps", "c1-facing")] + assert (facing["signed_error_in"], facing["abs_error_in"]) == ("-4.80", "4.80") + assert facing["truth_within_reported"] == "false" + + edge = rows[("mesh-scaled", "c1-route")] # 0.5 ft error, 0.5 ft reported + assert (edge["signed_error_in"], edge["truth_within_reported"]) == ("-6.00", "true") + + assert rows[("mesh-scaled", "wall-length")]["truth_within_reported"] == "" + assert rows[("mesh-scaled", "c1-gas")]["status"] == "false_absent" + assert rows[("photo-depth", "c1-pool")]["status"] == "phantom" + assert rows[("ar-taps", "c2-facing")]["status"] == "missing_failed" + assert rows[("ar-taps", "c2-pool")]["status"] == "not_surveyed" + + +def test_check_rows(scored): + _, table = scored + rows = by_key(table("checks.csv"), "pipeline", "candidate", "check") + assert len(rows) == 24 + + borderline = rows[("ar-taps", "c2", "gas")] + assert borderline["truth_outcome"] == "borderline" + assert borderline["margin_ft"] == "0.020" + assert borderline["error_to_margin"] == "11.00" # 0.33 / max(0.02, 0.03) + assert (borderline["could_flip"], borderline["agrees"]) == ("true", "false") + assert (borderline["unsafe_pass"], borderline["missed_review"]) == ("false", "true") + + unsafe = rows[("ar-taps", "c2", "facing_gap")] # passed with no value; the survey fails + assert (unsafe["truth_outcome"], unsafe["run_outcome"]) == ("fail", "pass") + assert (unsafe["unsafe_pass"], unsafe["missed_review"]) == ("true", "false") + assert unsafe["decided_without_measurement"] == "true" + + unknown_blind = rows[("ar-taps", "c2", "pool")] # failed with no value; survey unknown + assert (unknown_blind["truth_outcome"], unknown_blind["agrees"]) == ("unknown", "") + assert unknown_blind["decided_without_measurement"] == "true" + + absent_claim = rows[("ar-taps", "c1", "pool")] # passed on its claim that there is no pool + assert absent_claim["decided_without_measurement"] == "false" + assert rows[("photo-depth", "c1", "route")]["decided_without_measurement"] == "false" + assert rows[("mesh-scaled", "c2", "route")]["decided_without_measurement"] == "" + + route = rows[("ar-taps", "c2", "route")] + assert route["truth_outcome"] == "review" + assert (route["margin_ft"], route["review_margin_ft"]) == ("0.100", "-4.900") + assert route["error_to_margin"] == "4.00" # 0.4 / max(min(0.1, 4.9), 0.05) + assert (route["missed_review"], route["unsafe_pass"], route["agrees"]) == ( + "true", + "false", + "false", + ) + + in_band = rows[("photo-depth", "c2", "route")] # fail where the survey is review + assert (in_band["false_rejection"], in_band["over_caution"], in_band["agrees"]) == ( + "false", + "false", + "false", + ) + assert in_band["error_to_margin"] == "11.00" # 1.1 / 0.1 + + rejection = rows[("photo-depth", "c1", "facing_gap")] + assert (rejection["false_rejection"], rejection["over_caution"]) == ("true", "true") + + unsupported = rows[("photo-depth", "c1", "route")] + assert (unsupported["abstention"], unsupported["over_caution"]) == ("justified", "true") + assert rows[("photo-depth", "c2", "facing_gap")]["abstention"] == "avoidable" + assert rows[("mesh-scaled", "c1", "route")]["error_to_margin"] == "0.17" # 0.5 / 3 + + unknown = rows[("photo-depth", "c2", "pool")] + assert (unknown["truth_outcome"], unknown["run_outcome"], unknown["agrees"]) == ( + "unknown", + "unsure", + "", + ) + assert rows[("mesh-scaled", "c2", "gas")]["error_to_margin"] == "0.67" # 0.02 / 0.03 + + +def test_markdown_summary(scored): + (stdout, stderr), _ = scored + assert stdout.startswith("# Capture pipeline scores\n") + assert "left out of every error figure" in stdout + assert "Scale reference `scale` (5.000 ft), excluded." in stdout + assert "| `ar-taps` | ar_poses | 6/9 | 3.48 | 4.80 | 3/6 | 0 | 1 | 0 | 0 | 1 | 1 |" in stdout + assert ( + "| `mesh-scaled` | no decisions | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0 |" in stdout + ) + assert "| `ar-taps` | 4/7 | 1 | 2 | 0 | 0 | 2 | 0 | 0 | 2 |" in stdout + assert "| `mesh-scaled` | 420.0 | not recorded |" in stdout + unsafe, rest = stdout.split("### Unsafe passes")[1].split("### Missed reviews") + missed, blind = rest.split("### Decided without its measurement") + blind = blind.split("\n\nThese are paired results")[0] + assert blind.strip().splitlines() == [ + "- `ar-taps` reported pass for `facing_gap` at `c2` with measurement `c2-facing` missing " + "(failed); the survey is fail at 2.500 ± 0.020 ft against `facing_gap_ft` at_least " + "3.000 ft.", + "- `ar-taps` reported fail for `pool` at `c2` with measurement `c2-pool` missing " + "(failed); the survey is unknown (not measured) against `pool_clearance_ft` at_least " + "5.000 ft.", + ] + assert unsafe.strip() == ( + "- `ar-taps` passed `facing_gap` at `c2`; the survey is fail at 2.500 ± 0.020 ft " + "against `facing_gap_ft` at_least 3.000 ft (run measured no value)." + ) + assert ( + "- `ar-taps` passed `gas` at `c2`; the survey is borderline at 3.020 ± 0.030 ft against " + "`gas_clearance_ft` at_least 3.000 ft (run measured 3.350 ft)." + ) in missed + assert ( + "- `ar-taps` passed `route` at `c2`; the survey is review at 19.900 ± 0.050 ft against " + "`review_route_ft` 15.000 ft and `max_route_ft` at_most 20.000 ft (run measured 19.500 ft)." + ) in missed + assert "case series" in stdout + assert "wrote" in stderr and "runs.csv" in stderr + + +def test_bad_input_exits_2_with_the_reason(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + code = main( + [ + "--rules", + str(FIXTURES / "rules.json"), + "--truth", + str(FIXTURES / "truth" / "synthetic-01.json"), + "--results", + str(tmp_path / "missing.json"), + "--out", + str(tmp_path), + ] + ) + assert code == 2 + assert "cannot read" in capsys.readouterr().err + assert not (tmp_path / "runs.csv").exists() diff --git a/experiments/scoring/tests/test_inputs.py b/experiments/scoring/tests/test_inputs.py new file mode 100644 index 000000000..6b713b2cd --- /dev/null +++ b/experiments/scoring/tests/test_inputs.py @@ -0,0 +1,448 @@ +"""Every input problem stops the run with a message naming the file, the field and the fix.""" + +from collections.abc import Callable +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from helpers import Files + +from scoring.inputs import InputError, load_study +from scoring.metrics import score_run +from scoring.report import markdown, write_csvs + + +def study_error(tmp_path: Path, edit: Callable[[Files], Any]) -> str: + files = Files(tmp_path) + edit(files) + rules, truth, results = files.write_all() + with pytest.raises(InputError) as caught: + load_study(rules, [truth], [results]) + return str(caught.value) + + +def surveyed(files: Files, measurement_id: str) -> dict[str, Any]: + return next(m for m in files.truth["measurements"] if m["id"] == measurement_id) + + +def reported(files: Files, measurement_id: str) -> dict[str, Any]: + return next(m for m in files.results["measurements"] if m["id"] == measurement_id) + + +def test_valid_inputs_load(tmp_path: Path): + files = Files(tmp_path) + rules, truth, results = files.write_all() + study = load_study(rules, [truth], [results]) + (house,) = study.houses + assert house.truth.house == "h1" + assert house.truth.scale_reference == "scale" + assert house.truth.measurements["c1-gas"].value_ft == Decimal("4.5") + (run,) = house.runs + assert run.measurements["c1-gas"].plus_minus_ft == Decimal("0.3") + assert run.measurements["c1-route"].missing == "failed" + assert run.outcomes == {("c1", "gas"): "pass", ("c1", "route"): "unsure"} + assert run.capture_seconds == Decimal(300) + + +def test_error_names_file_field_and_problem(tmp_path: Path): + message = study_error(tmp_path, lambda f: surveyed(f, "c1-gas").update(value_ft="4.5")) + assert message == ( + f"{tmp_path / 'truth.json'}: measurements[1] (c1-gas).value_ft: " + "expected a number, got the string '4.5'" + ) + + +def test_scale_reference_may_be_left_out_of_a_run(tmp_path: Path): + files = Files(tmp_path) + rules, truth, results = files.write_all() + (house,) = load_study(rules, [truth], [results]).houses + assert "scale" not in house.runs[0].measurements + + +def test_review_band_loads(tmp_path: Path): + files = Files(tmp_path) + files.truth["checks"][1]["review_threshold"] = "review_route_ft" + rules, truth, results = files.write_all() + (house,) = load_study(rules, [truth], [results]).houses + assert house.truth.checks[1].review_threshold == "review_route_ft" + assert house.truth.checks[0].review_threshold is None + + +def test_run_without_decisions_loads(tmp_path: Path): + files = Files(tmp_path) + files.results["outcomes"] = None + rules, truth, results = files.write_all() + (house,) = load_study(rules, [truth], [results]).houses + assert house.runs[0].outcomes is None + + +def set_threshold(files: Files, **fields: Any) -> None: + files.rules["thresholds"]["gas_clearance_ft"].update(fields) + + +def add_threshold(files: Files, name: str) -> None: + files.rules["thresholds"][name] = {"value_ft": 3, "pass_when": "at_least", "source": "s"} + + +def make_route_absent(files: Files) -> None: + route = surveyed(files, "c1-route") + route.update(status="absent") + del route["value_ft"], route["plus_minus_ft"] + + +CASES: list[tuple[str, Callable[[Files], Any], str]] = [ + ("rules unit", lambda f: f.rules.update(unit="m"), 'every length must be in feet ("ft")'), + ("rules format", lambda f: f.rules.update(format=2), "this scorer reads format 1, got 2"), + ( + "boolean format", + lambda f: f.rules.update(format=True), + "reads format 1, got the boolean true", + ), + ("unknown field", lambda f: f.rules.update(extra=1), "unknown field 'extra'"), + ("threshold name", lambda f: add_threshold(f, "gas_clearance"), "ending in _ft"), + ( + "pass_when", + lambda f: set_threshold(f, pass_when="above"), + "thresholds.gas_clearance_ft.pass_when: expected one of at_least, at_most", + ), + ("negative", lambda f: set_threshold(f, value_ft=-3), "must not be negative, got -3"), + ("boolean value", lambda f: set_threshold(f, value_ft=True), "got the boolean true"), + ( + "measured without a value", + lambda f: surveyed(f, "c1-gas").pop("value_ft"), + "measurements[1] (c1-gas): missing required field 'value_ft'", + ), + ( + "absent with a value", + lambda f: surveyed(f, "c1-gas").update(status="absent"), + "measurements[1] (c1-gas).value_ft: status 'absent' has no value", + ), + ( + "survey status", + lambda f: surveyed(f, "c1-gas").update(status="guessed"), + "expected one of measured, absent, not_measured, got the string 'guessed'", + ), + ( + "duplicate measurement", + lambda f: f.truth["measurements"].append(dict(surveyed(f, "c1-gas"))), + "measurements[3].id: measurement 'c1-gas' is listed twice", + ), + ( + "unknown candidate", + lambda f: surveyed(f, "c1-gas").update(candidate="c9"), + "measurements[1] (c1-gas).candidate: no candidate 'c9'; candidates are c1", + ), + ( + "nobody measured", + lambda f: surveyed(f, "c1-gas").update(measured_by=[]), + "measured_by: expected a non-empty list", + ), + ( + "scale reference id", + lambda f: f.truth.update(scale_reference="S"), + "scale_reference: 'S' is not a measurement id", + ), + ( + "scale reference unmeasured", + lambda f: [ + surveyed(f, "scale").update(status="not_measured"), + surveyed(f, "scale").pop("value_ft"), + surveyed(f, "scale").pop("plus_minus_ft"), + ], + "scale_reference: 'scale' must be measured, but its status is 'not_measured'", + ), + ( + "check on the scale reference", + lambda f: f.truth["checks"][0].update(measurement="scale"), + "checks[0].measurement: 'scale' is the scale reference, which is never scored", + ), + ( + "check threshold", + lambda f: f.truth["checks"][0].update(threshold="gas_ft"), + "checks[0].threshold: 'gas_ft' is not in", + ), + ( + "check candidate", + lambda f: [ + f.truth["candidates"].append({"id": "c2", "marker": "chalk", "location": "wall"}), + f.truth["checks"][0].update(candidate="c2"), + ], + "the check is at candidate 'c2' but measurement 'c1-gas' belongs to 'c1'", + ), + ( + "spot missing a check", + lambda f: [ + f.truth["candidates"].append({"id": "c2", "marker": "chalk", "location": "wall"}), + f.truth["measurements"].append( + dict(surveyed(f, "c1-gas"), id="c2-gas", candidate="c2") + ), + f.truth["checks"].append( + { + "candidate": "c2", + "check": "gas", + "measurement": "c2-gas", + "threshold": "gas_clearance_ft", + } + ), + ], + "candidate 'c2' has no route check; every spot needs the same checks", + ), + ( + "unknown review threshold", + lambda f: f.truth["checks"][1].update(review_threshold="review_ft"), + "checks[1].review_threshold: 'review_ft' is not in", + ), + ( + "review threshold equal to threshold", + lambda f: f.truth["checks"][1].update(review_threshold="max_route_ft"), + "checks[1].review_threshold: must differ from threshold", + ), + ( + "review threshold in the other direction", + lambda f: f.truth["checks"][1].update(review_threshold="gas_clearance_ft"), + "passes at_least but 'max_route_ft' passes at_most", + ), + ( + "review threshold past the fail line", + lambda f: [ + f.rules["thresholds"]["review_route_ft"].update(value_ft=25), + f.truth["checks"][1].update(review_threshold="review_route_ft"), + ], + "'review_route_ft' (25 ft) must be on the passing side of 'max_route_ft' (20 ft, at_most)", + ), + ("absent at_most", make_route_absent, "only a clearance (at_least) passes"), + ( + "duplicate check", + lambda f: f.truth["checks"].append(dict(f.truth["checks"][0])), + "checks[2]: check 'gas' at 'c1' is listed twice", + ), + ( + "rules hash", + lambda f: f.results.update(rules_sha256="a" * 64), + "every run must use the same frozen rules", + ), + ( + "malformed hash", + lambda f: f.results.update(rules_sha256="abc"), + "rules_sha256: expected the 64 lowercase hex digits", + ), + ( + "unreported measurement", + lambda f: f.results["measurements"].pop(), + "no entry for measurements 'c1-route'", + ), + ( + "unknown measurement", + lambda f: f.results["measurements"].append( + {"id": "c1-door", "value_ft": 4, "plus_minus_ft": None} + ), + "measurements 'c1-door' are not in", + ), + ( + "null without a reason", + lambda f: reported(f, "c1-route").pop("missing"), + "measurements[1] (c1-route): missing required field 'missing'", + ), + ( + "value with a reason", + lambda f: reported(f, "c1-gas").update(missing="failed"), + "missing: only a null value_ft gives a reason it is missing", + ), + ( + "null with an uncertainty", + lambda f: reported(f, "c1-route").update(plus_minus_ft=0.3), + "plus_minus_ft: a null value has no uncertainty", + ), + ( + "value without an uncertainty key", + lambda f: reported(f, "c1-gas").pop("plus_minus_ft"), + "missing required field 'plus_minus_ft'", + ), + ( + "missing reason", + lambda f: reported(f, "c1-route").update(missing="timeout"), + "expected one of unsupported, failed, absent", + ), + ( + "missing outcome", + lambda f: f.results["outcomes"].pop(), + "no outcome for route at c1", + ), + ( + "extra outcome", + lambda f: f.results["outcomes"].append( + {"candidate": "c1", "check": "pool", "outcome": "pass"} + ), + "outcomes for checks not in the survey: pool at c1", + ), + ( + "outcome value", + lambda f: f.results["outcomes"][0].update(outcome="maybe"), + "outcomes[0].outcome: expected one of pass, unsure, fail", + ), + ( + "scale source", + lambda f: f.results.update(scale_source="guess"), + "scale_source: expected one of native_metric, scale_reference, ar_poses", + ), + ( + "unknown capture", + lambda f: f.results.update(capture="cap-2"), + "no survey lists capture 'cap-2'", + ), + ( + "negative timing", + lambda f: f.results["timing"].update(processing_s=-1), + "timing.processing_s: must not be negative", + ), +] + + +@pytest.mark.parametrize(("edit", "expected"), [c[1:] for c in CASES], ids=[c[0] for c in CASES]) +def test_invalid_input_is_rejected(tmp_path: Path, edit: Callable[[Files], Any], expected: str): + assert expected in study_error(tmp_path, edit) + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ('{"format": 1, "format": 1}', "key 'format' appears twice in one object"), + ('{"value": NaN}', "NaN is not a measurement"), + ('{"format": 1,', "not valid JSON"), + ], +) +def test_ambiguous_json_is_rejected(tmp_path: Path, text: str, expected: str): + rules = tmp_path / "rules.json" + rules.write_text(text) + with pytest.raises(InputError, match=expected): + load_study(rules, [], []) + + +def test_same_house_twice(tmp_path: Path): + files = Files(tmp_path) + rules, truth, _ = files.write_all() + files.truth["captures"] = ["cap-9"] + again = files.write("truth-2.json", files.truth) + with pytest.raises(InputError, match="house 'h1' is already surveyed"): + load_study(rules, [truth, again], []) + + +def test_same_run_twice(tmp_path: Path): + files = Files(tmp_path) + rules, truth, results = files.write_all() + again = files.write("results-2.json", files.results) + with pytest.raises(InputError, match="pipeline 'p1' on capture 'cap-1' is already scored"): + load_study(rules, [truth], [results, again]) + + +def test_runs_on_one_recording_share_its_capture_time(tmp_path: Path): + files = Files(tmp_path) + rules, truth, results = files.write_all() + files.results["pipeline"] = "p2" + files.results["timing"]["capture_s"] = 120 + shorter = files.write("results-2.json", files.results) + with pytest.raises(InputError, match="120 s differs from 300 s"): + load_study(rules, [truth], [results, shorter]) + files.results["timing"]["capture_s"] = None + unrecorded = files.write("results-3.json", files.results) + assert len(load_study(rules, [truth], [results, unrecorded]).houses[0].runs) == 2 + + +def test_unreadable_file(tmp_path: Path): + with pytest.raises(InputError, match="cannot read"): + load_study(tmp_path / "nope.json", [], []) + + +@pytest.mark.parametrize("different", ["threshold", "review_threshold"]) +def test_check_policy_must_match_across_candidates(tmp_path: Path, different: str): + files = Files(tmp_path) + files.truth["candidates"].append({"id": "c2", "marker": "chalk", "location": "wall"}) + for check in list(files.truth["checks"]): + measurement = dict(surveyed(files, check["measurement"])) + measurement["id"] = check["measurement"].replace("c1", "c2") + measurement["candidate"] = "c2" + files.truth["measurements"].append(measurement) + files.truth["checks"].append(dict(check, candidate="c2", measurement=measurement["id"])) + files.results["measurements"].append( + {"id": measurement["id"], "value_ft": None, "missing": "failed"} + ) + files.results["outcomes"].append( + {"candidate": "c2", "check": check["check"], "outcome": "unsure"} + ) + if different == "threshold": + files.truth["checks"][2]["threshold"] = "another_gas_ft" + files.rules["thresholds"]["another_gas_ft"] = { + "value_ft": 4, + "pass_when": "at_least", + "source": "synthetic", + } + else: + files.truth["checks"][3]["review_threshold"] = "review_route_ft" + rules, truth, results = files.write_all() + with pytest.raises(InputError, match="every spot needs the same threshold mapping") as caught: + load_study(rules, [truth], [results]) + assert str(truth) in str(caught.value) + assert "checks" in str(caught.value) + + +@pytest.mark.parametrize("missing", ["failed", "unsupported"]) +@pytest.mark.parametrize("outcome", ["pass", "fail"]) +def test_decision_without_its_measurement_is_accepted(tmp_path: Path, missing: str, outcome: str): + # The scorer records what the pipeline decided; metrics.py flags it instead of rejecting it. + files = Files(tmp_path) + reported(files, "c1-route")["missing"] = missing + files.results["outcomes"][1]["outcome"] = outcome + rules, truth, results = files.write_all() + (run,) = load_study(rules, [truth], [results]).houses[0].runs + assert run.outcomes[("c1", "route")] == outcome + assert run.measurements["c1-route"].missing == missing + + +def test_absent_feature_can_pass_clearance(tmp_path: Path): + files = Files(tmp_path) + measurement = surveyed(files, "c1-gas") + measurement["status"] = "absent" + del measurement["value_ft"], measurement["plus_minus_ft"] + files.results["measurements"][0] = {"id": "c1-gas", "value_ft": None, "missing": "absent"} + rules, truth, results = files.write_all() + assert load_study(rules, [truth], [results]).houses[0].runs[0].outcomes[("c1", "gas")] == "pass" + + +@pytest.mark.parametrize("field", ["value_ft", "plus_minus_ft"]) +def test_unformattable_large_length_names_input_field(tmp_path: Path, field: str): + def edit(files: Files) -> None: + reported(files, "c1-gas")[field] = 1e100 + + message = study_error(tmp_path, edit) + assert str(tmp_path / "results.json") in message + assert f"measurements[0] (c1-gas).{field}" in message + assert "at most 1000000000" in message + + +def test_maximum_length_and_precision_format_in_reports(tmp_path: Path): + files = Files(tmp_path) + surveyed(files, "c1-gas").update(value_ft=3.000000000001, plus_minus_ft=0) + reported(files, "c1-gas").update(value_ft=1e9, plus_minus_ft=0) + rules, truth, results = files.write_all() + study = load_study(rules, [truth], [results]) + house = study.houses[0] + run = score_run(house.truth, house.runs[0], study.rules.thresholds) + report = markdown(study, [run]) + assert report.startswith("# Capture pipeline scores") + assert "a claimed absence also cannot support an at_most decision or an at_least fail" in report + assert "the run's measurement failed or is unsupported" in report + assert "or the run has no value" not in report + paths = write_csvs([run], tmp_path / "out") + assert len(paths) == 3 + assert "1000000000.000" in paths[0].read_text() + assert "error_to_margin" in paths[1].read_text() + + +def test_unformattable_ratio_precision_names_input_field(tmp_path: Path): + message = study_error( + tmp_path, lambda files: surveyed(files, "c1-gas").update(value_ft=3.0000000000001) + ) + assert str(tmp_path / "truth.json") in message + assert "measurements[1] (c1-gas).value_ft" in message + assert "at most 12 decimal places" in message diff --git a/experiments/scoring/tests/test_measure_lab.py b/experiments/scoring/tests/test_measure_lab.py new file mode 100644 index 000000000..463002df3 --- /dev/null +++ b/experiments/scoring/tests/test_measure_lab.py @@ -0,0 +1,374 @@ +"""The Measure Lab importer on a synthetic format-2 session, with hand-computed feet. + +The committed session (fixtures/measure-lab/synthetic-session-01) holds, in meters: +m1 scale 1.524 (5 ft), m2 wall 9.87552 (32.4 ft), m3 c1 gas 1.4 (4.593175853... ft, written +4.593176), m4 c1 facing gap 1.49352 (4.9 ft), m5 c1 route 3.71856 (12.2 ft), m6 c2 gas 0.95 with +heightAboveGround -0.04 and accepted false, m7 c2 route 6.00456 (19.7 ft), and refusal r1 for the +c2 facing gap. The session starts at uptime 1000.25 and the last measurement is at 1412.75. +""" + +import copy +import csv +import hashlib +import json +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from helpers import FIXTURES, MEASURE_LAB, SESSION_ZIP, committed_session, session_zip + +from scoring.cli import main +from scoring.inputs import InputError, load_study +from scoring.measure_lab import import_session, meters_to_feet + +D = Decimal +RULES = FIXTURES / "rules.json" +TRUTH = FIXTURES / "truth" / "synthetic-01.json" +MAP = MEASURE_LAB / "map.json" + + +def committed_map() -> dict[str, Any]: + return json.loads(MAP.read_text()) + + +class Setup: + """Editable copies of the committed session, map and survey, written to a temp dir.""" + + def __init__(self, root: Path): + self.root = root + self.session = committed_session() + self.map = committed_map() + self.truth = json.loads(TRUTH.read_text()) + + def run(self, *, decide: bool = False) -> dict[str, Any]: + zip_path = session_zip(self.session, self.root / "session.zip") + capture = hashlib.sha256(zip_path.read_bytes()).hexdigest() + if capture not in self.truth["captures"]: + self.truth["captures"].append(capture) + map_path = self.root / "map.json" + map_path.write_text(json.dumps(self.map)) + truth_path = self.root / "truth.json" + truth_path.write_text(json.dumps(self.truth)) + return import_session( + zip_path, map_path, RULES, truth_path, self.root / "out.json", decide_outcomes=decide + ) + + def error(self, **kwargs: Any) -> str: + with pytest.raises(InputError) as caught: + self.run(**kwargs) + assert not (self.root / "out.json").exists() + return str(caught.value) + + def measurement(self, measurement_id: str) -> dict[str, Any]: + return next(m for m in self.session["measurements"] if m["id"] == measurement_id) + + +@pytest.fixture +def setup(tmp_path: Path) -> Setup: + return Setup(tmp_path) + + +def by_id(results: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {entry["id"]: entry for entry in results["measurements"]} + + +class TestMetersToFeet: + @pytest.mark.parametrize( + ("meters", "feet"), + [ + ("0.3048", "1.000000"), + ("9.87552", "32.400000"), + ("1.4", "4.593176"), # 4.59317585301837... + ("1", "3.280840"), # 3.28083989501312... + ("0.0000001524", "0.000001"), # exactly 0.0000005 ft: half rounds up + ("0.0000001523", "0.000000"), # 0.00000049967... ft + ("0", "0.000000"), + ], + ) + def test_exact_conversion_then_six_places(self, meters, feet): + result = meters_to_feet(D(meters)) + assert str(result) == feet + assert result.as_tuple().exponent == -6 + + +def test_committed_zip_is_the_committed_session(tmp_path: Path): + rebuilt = session_zip( + (MEASURE_LAB / "synthetic-session-01" / "session.json").read_bytes(), + tmp_path / "rebuilt.zip", + ) + assert rebuilt.read_bytes() == SESSION_ZIP.read_bytes(), ( + "fixtures/measure-lab/synthetic-session-01.zip is stale; run " + "`uv run python tests/helpers.py rebuild-session-zip` and put the printed sha256 in " + "fixtures/truth/synthetic-01.json captures" + ) + capture = hashlib.sha256(SESSION_ZIP.read_bytes()).hexdigest() + assert capture in json.loads(TRUTH.read_text())["captures"] + + +class TestImport: + def test_values_in_feet_with_the_mapped_uncertainty(self, setup: Setup): + results = setup.run() + entries = by_id(results) + assert [entry["id"] for entry in results["measurements"]] == [ + "scale", + "wall-length", + "c1-gas", + "c1-facing", + "c1-route", + "c1-pool", + "c2-gas", + "c2-facing", + "c2-route", + "c2-pool", + ] + assert entries["scale"]["value_ft"] == D("5.000000") + assert entries["wall-length"]["value_ft"] == D("32.400000") + assert entries["c1-gas"]["value_ft"] == D("4.593176") + assert entries["c1-facing"]["value_ft"] == D("4.900000") + assert entries["c1-route"]["value_ft"] == D("12.200000") + assert entries["c2-route"]["value_ft"] == D("19.700000") + assert {entry.get("plus_minus_ft") for entry in entries.values() if entry["value_ft"]} == { + D("0.3") + } + + def test_missing_entries(self, setup: Setup): + entries = by_id(setup.run()) + assert entries["c1-pool"] == {"id": "c1-pool", "value_ft": None, "missing": "absent"} + assert entries["c2-facing"] == {"id": "c2-facing", "value_ft": None, "missing": "failed"} + assert entries["c2-pool"] == {"id": "c2-pool", "value_ft": None, "missing": "unsupported"} + + def test_not_accepted_becomes_failed(self, setup: Setup): + # m6 has heightAboveGround -0.04 m, warning belowGround and accepted false. + assert by_id(setup.run())["c2-gas"] == { + "id": "c2-gas", + "value_ft": None, + "missing": "failed", + } + + def test_not_accepted_with_a_positive_value_also_becomes_failed(self, setup: Setup): + m3 = setup.measurement("m3") + m3.update(accepted=False, warnings=["estimatedPlane"]) + assert by_id(setup.run())["c1-gas"]["missing"] == "failed" + + def test_accepted_straight_does_not_validate_unchecked_along_wall(self, setup: Setup): + m5 = setup.measurement("m5") + m5.update(compared="straight", accepted=True, warnings=[]) + setup.session["walls"][0].update(validations=[], warnings=["wallNotValidated"]) + error = setup.error() + assert "map.json: measurements.c1-route.key" in error + assert "'alongWall'" in error and "'straight'" in error + + def test_missing_compared_quantity_is_rejected(self, setup: Setup): + del setup.measurement("m5")["compared"] + assert "measurements[4] (m5).compared: expected a non-empty string" in setup.error() + + def test_header_timing_and_ids(self, setup: Setup): + results = setup.run() + zip_bytes = (setup.root / "session.zip").read_bytes() + assert results["capture"] == hashlib.sha256(zip_bytes).hexdigest() + assert results["rules_sha256"] == hashlib.sha256(RULES.read_bytes()).hexdigest() + assert results["pipeline"] == "measure-lab" + assert results["scale_source"] == "ar_poses" + assert results["outcomes"] is None + assert results["timing"] == {"capture_s": D("412.500"), "processing_s": None} + + def test_per_entry_uncertainty_overrides_the_key_default(self, setup: Setup): + setup.map["measurements"]["c1-gas"]["plus_minus_ft"] = 0.25 + assert by_id(setup.run())["c1-gas"]["plus_minus_ft"] == D("0.25") + + def test_scale_reference_may_be_left_out(self, setup: Setup): + del setup.map["measurements"]["scale"] + assert "scale" not in by_id(setup.run()) + + def test_written_file_loads_with_exact_numbers(self, setup: Setup): + setup.run() + text = (setup.root / "out.json").read_text() + assert '"value_ft": 4.593176' in text + assert '"capture_s": 412.500' in text + + +class TestDecide: + def test_outcomes_follow_the_strict_rule(self, setup: Setup): + results = setup.run(decide=True) + assert results["pipeline"] == "measure-lab+rule" + outcomes = {(o["candidate"], o["check"]): o["outcome"] for o in results["outcomes"]} + assert outcomes == { + ("c1", "gas"): "pass", # 4.593176 - 3 = 1.593176 > 0.3 + ("c1", "facing_gap"): "pass", # 4.9 - 3 = 1.9 > 0.3 + ("c1", "route"): "pass", # 15 - 12.2 = 2.8 > 0.3 + ("c1", "pool"): "pass", # the rig saw no pool: an absent feature clears it + ("c2", "gas"): "unsure", # not accepted: no value + ("c2", "facing_gap"): "unsure", # refused: no value + ("c2", "route"): "unsure", # 19.7: past 15 + 0.3, not past 20 + 0.3: review + ("c2", "pool"): "unsure", # unsupported + } + + @pytest.mark.parametrize( + ("meters", "outcome"), + [ + ("1.00584", "unsure"), # 3.3 ft: margin 0.3 equals the uncertainty + ("1.00587", "pass"), # 3.300098 ft + ("0.82296", "unsure"), # 2.7 ft: miss equals the uncertainty + ("0.82293", "fail"), # 2.699902 ft + ], + ) + def test_equality_is_unsure(self, setup: Setup, meters, outcome): + setup.measurement("m3")["values"]["straight"] = float(meters) + outcomes = { + (o["candidate"], o["check"]): o["outcome"] for o in setup.run(decide=True)["outcomes"] + } + assert outcomes[("c1", "gas")] == outcome + + def test_absent_cannot_pass_an_at_most_rule(self, setup: Setup): + setup.map["measurements"]["c1-route"] = "absent" + outcomes = { + (o["candidate"], o["check"]): o["outcome"] for o in setup.run(decide=True)["outcomes"] + } + assert outcomes[("c1", "route")] == "unsure" + + +def edit_session(key: str, value: Any): + return lambda setup: setup.session.update({key: value}) + + +ERRORS: list[tuple[str, Any, str]] = [ + ( + "unknown survey id", + lambda s: s.map["measurements"].update({"c1-door": "absent"}), + "measurements 'c1-door' are not survey measurement ids", + ), + ( + "missing session measurement", + lambda s: s.map["measurements"]["c1-gas"].update(session_measurement="m99"), + "measurements.c1-gas.session_measurement: synthetic-session-01/session.json has no " + "measurement 'm99'", + ), + ( + "missing refusal", + lambda s: s.map["measurements"].update({"c2-facing": {"refusal": "r9"}}), + "measurements.c2-facing.refusal: synthetic-session-01/session.json has no refusal 'r9'", + ), + ( + "values key absent from the measurement", + lambda s: s.map["measurements"]["c1-gas"].update(key="alongWall"), + "measurements.c1-gas.key: measurement 'm3' has no 'alongWall' value; it has straight, " + "horizontal, vertical, gapToWall, heightAboveGround", + ), + ("format version 1", edit_session("formatVersion", 1), "formatVersion is 1; this importer"), + ("format version 3", edit_session("formatVersion", 3), "formatVersion is 3; this importer"), + ( + "format name", + edit_session("format", "something-else"), + "format is 'something-else', not 'measure-lab-session'", + ), + ( + "survey measurement left out", + lambda s: s.map["measurements"].pop("c2-pool"), + "no entry for survey measurements 'c2-pool'", + ), + ( + "map for another session", + lambda s: s.map.update(session="other-session"), + "session is 'other-session', but", + ), + ( + "no uncertainty", + lambda s: s.map.pop("plus_minus_ft_by_key"), + "measurements.scale: no uncertainty for values key 'straight'", + ), + ( + "unknown values key", + lambda s: s.map["measurements"]["c1-gas"].update(key="diagonal"), + "measurements.c1-gas.key: expected one of straight, horizontal", + ), + ( + "unknown map string", + lambda s: s.map["measurements"].update({"c2-pool": "failed"}), + 'measurements.c2-pool: expected "absent", "unsupported" or an object', + ), + ( + "refusal with extra fields", + lambda s: s.map["measurements"].update({"c2-facing": {"refusal": "r1", "key": "straight"}}), + "a refusal entry takes no key", + ), + ( + "accepted negative value", + lambda s: s.measurement("m3")["values"].update(straight=-1.4), + "measurement 'm3' is accepted but its straight is -1.4 m", + ), + ( + "length units", + lambda s: s.session["units"].update(length="feet"), + "units.length must be 'meters'", + ), +] + + +@pytest.mark.parametrize(("edit", "expected"), [e[1:] for e in ERRORS], ids=[e[0] for e in ERRORS]) +def test_errors(setup: Setup, edit, expected): + edit(setup) + assert expected in setup.error() + + +def test_zip_without_session_json(tmp_path: Path): + path = tmp_path / "empty.zip" + session_zip(b"{}", path, folder="a/b") # session.json two folders down is not the session + with pytest.raises(InputError, match=r"expected exactly one session\.json"): + import_session(path, MAP, RULES, TRUTH, tmp_path / "out.json", decide_outcomes=False) + + +def test_not_a_zip(tmp_path: Path): + path = tmp_path / "session.zip" + path.write_text("not a zip") + with pytest.raises(InputError, match="not a zip file"): + import_session(path, MAP, RULES, TRUTH, tmp_path / "out.json", decide_outcomes=False) + + +def test_capture_must_be_in_the_survey(tmp_path: Path): + session = copy.deepcopy(committed_session()) + session["session"]["appVersion"] = "changed" # different bytes, different sha256 + path = session_zip(session, tmp_path / "session.zip") + capture = hashlib.sha256(path.read_bytes()).hexdigest() + with pytest.raises(InputError, match=f"captures does not list {capture}"): + import_session(path, MAP, RULES, TRUTH, tmp_path / "out.json", decide_outcomes=False) + + +def test_end_to_end_scores_and_decide_row_lands_in_checks(tmp_path: Path, capsys): + outputs = [] + for flag in ([], ["--decide"]): + out = tmp_path / f"measure-lab{'-rule' if flag else ''}.json" + args = [str(SESSION_ZIP), "--map", str(MAP), "--rules", str(RULES), "--truth", str(TRUTH)] + assert main(["import-measure-lab", *args, *flag, "--out", str(out)]) == 0 + outputs.append(out) + assert "wrote" in capsys.readouterr().err + load_study(RULES, [TRUTH], outputs) # both rows load without errors + + csv_dir = tmp_path / "csv" + args = ["--rules", str(RULES), "--truth", str(TRUTH), "--out", str(csv_dir)] + assert main([*args, "--results", *map(str, outputs)]) == 0 + stdout = capsys.readouterr().out + assert ( + "| `measure-lab` | ar_poses | 5/9 | 1.20 | 2.40 | 5/5 | 0 | 2 | 0 | 0 | 1 | 1 |" in stdout + ) + assert "| `measure-lab+rule` | 6/7 | 0 | 0 | 0 | 0 | 0 | 3 | 0 | 1 |" in stdout + assert "| `measure-lab` | no decisions |" in stdout + + with (csv_dir / "checks.csv").open(newline="") as handle: + rows = [r for r in csv.DictReader(handle) if r["pipeline"] == "measure-lab+rule"] + assert len(rows) == 8 + route = next(r for r in rows if (r["candidate"], r["check"]) == ("c2", "route")) + assert (route["truth_outcome"], route["run_outcome"], route["agrees"]) == ( + "review", + "unsure", + "true", + ) + assert route["error_to_margin"] == "2.00" # 0.2 / max(min(0.1, 4.9), 0.05) + + +def test_cli_reports_errors_with_exit_2(tmp_path: Path, capsys): + args = [str(SESSION_ZIP), "--map", str(tmp_path / "nope.json"), "--rules", str(RULES)] + code = main(["import-measure-lab", *args, "--truth", str(TRUTH), "--out", str(tmp_path / "o")]) + assert code == 2 + assert "score import-measure-lab:" in capsys.readouterr().err + assert not (tmp_path / "o").exists() diff --git a/experiments/scoring/tests/test_metrics.py b/experiments/scoring/tests/test_metrics.py new file mode 100644 index 000000000..aa22bda26 --- /dev/null +++ b/experiments/scoring/tests/test_metrics.py @@ -0,0 +1,594 @@ +"""Each metric against answers worked out by hand. Values are feet unless a name says inches.""" + +from decimal import Decimal +from pathlib import Path + +import pytest +from helpers import reported, survey, threshold + +from scoring.inputs import Candidate, Check, PipelineMeasurement, Results, Truth +from scoring.metrics import ( + AT_THRESHOLD, + could_flip, + error_to_margin, + score_check, + score_measurement, + score_run, + truth_outcome, +) + +D = Decimal +CHECK = Check("c1", "gas", "m", "gas_clearance_ft") + + +class TestTruthOutcome: + # Strict, per docs/02-implementation-plan.md Lane C: pass when the margin s > u, fail when + # s < -u, borderline otherwise. A value exactly on the threshold, or exactly u from it, is + # borderline, including when u = 0. + + @pytest.mark.parametrize( + ("value", "plus_minus", "expected"), + [ + # at_least 3, u = 0 + ("3.001", "0", "pass"), + ("3", "0", "borderline"), # on the threshold + ("2.999", "0", "fail"), + # at_least 3, u = 0.3: lines at 3.3 (pass) and 2.7 (fail) + ("3.301", "0.3", "pass"), + ("3.3", "0.3", "borderline"), # margin equal to u + ("3.299", "0.3", "borderline"), + ("3", "0.3", "borderline"), + ("2.701", "0.3", "borderline"), + ("2.7", "0.3", "borderline"), # miss equal to u + ("2.699", "0.3", "fail"), + ], + ) + def test_at_least_boundaries(self, value, plus_minus, expected): + assert truth_outcome(survey(value, plus_minus), threshold("3")) == expected + + @pytest.mark.parametrize( + ("value", "plus_minus", "expected"), + [ + # at_most 20, u = 0 + ("19.999", "0", "pass"), + ("20", "0", "borderline"), # on the threshold + ("20.001", "0", "fail"), + # at_most 20, u = 0.05: lines at 19.95 (pass) and 20.05 (fail) + ("19.949", "0.05", "pass"), + ("19.95", "0.05", "borderline"), # margin equal to u + ("19.951", "0.05", "borderline"), + ("20.049", "0.05", "borderline"), + ("20.05", "0.05", "borderline"), # miss equal to u + ("20.051", "0.05", "fail"), + ], + ) + def test_at_most_boundaries(self, value, plus_minus, expected): + assert truth_outcome(survey(value, plus_minus), threshold("20", "at_most")) == expected + + def test_equality_is_exact_decimal(self): + # 3.1 - 3.0 is 0.10000000000000009 in floats, which would clear u = 0.1 and pass. + assert 3.1 - 3.0 > 0.1 + assert truth_outcome(survey("3.1", "0.1"), threshold("3")) == "borderline" + + def test_absent_feature_clears_an_at_least_rule(self): + assert truth_outcome(survey(None, None, status="absent"), threshold("3")) == "pass" + + def test_absent_feature_cannot_decide_an_at_most_rule(self): + with pytest.raises(ValueError, match="at_most"): + truth_outcome(survey(None, None, status="absent"), threshold("20", "at_most")) + + def test_unmeasured_distance_is_unknown(self): + assert truth_outcome(survey(None, None, status="not_measured"), threshold()) == "unknown" + + +class TestErrorToMargin: + def test_ratio_uses_the_margin_when_it_is_larger(self): + # error 0.4, margin |3.6 - 3| = 0.6, u 0.02: 0.4 / 0.6 + assert error_to_margin(D("0.4"), (D("0.6"),), D("0.02")) == D("0.4") / D("0.6") + + def test_ratio_uses_the_uncertainty_when_it_is_larger(self): + # error 0.33, margin 0.02, u 0.03: 0.33 / 0.03 = 11 + assert error_to_margin(D("0.33"), (D("0.02"),), D("0.03")) == D("11") + + def test_negative_margin_uses_its_size(self): + assert error_to_margin(D("0.4"), (D("-0.5"),), D("0.02")) == D("0.8") + + def test_zero_margin_and_zero_uncertainty_is_at_threshold(self): + assert error_to_margin(D("0.1"), (D("0"),), D("0")) == AT_THRESHOLD + assert error_to_margin(D("0"), (D("0"),), D("0")) == AT_THRESHOLD + + def test_ratio_of_exactly_one_can_flip(self): + # The run could land exactly on the line, which the strict rule does not pass. + assert could_flip(D("0.9999"), D("0.5")) is False + assert could_flip(D("1"), D("0.5")) is True + + def test_at_threshold_flips_on_any_error(self): + assert could_flip(AT_THRESHOLD, D("0.001")) is True + assert could_flip(AT_THRESHOLD, D("0")) is False + + +class TestScoreMeasurement: + def test_signed_and_absolute_error_in_inches(self): + over = score_measurement(survey("4.5"), reported("4.7", "0.3"), scale_reference=False) + assert over.status == "scored" + assert over.error_ft == D("0.2") + assert over.signed_error_in == D("2.4") + assert over.abs_error_in == D("2.4") + under = score_measurement(survey("5"), reported("4.6", "0.3"), scale_reference=False) + assert under.signed_error_in == D("-4.8") + assert under.abs_error_in == D("4.8") + + def test_truth_within_reported_uncertainty_is_inclusive(self): + edge = score_measurement(survey("12"), reported("11.5", "0.5"), scale_reference=False) + assert edge.truth_within_reported is True + out = score_measurement(survey("12"), reported("11.499", "0.5"), scale_reference=False) + assert out.truth_within_reported is False + + def test_no_reported_uncertainty_leaves_within_unset(self): + score = score_measurement(survey("32.5"), reported("32.6", None), scale_reference=False) + assert score.signed_error_in == D("1.2") + assert score.truth_within_reported is None + + def test_scale_reference_is_never_scored(self): + score = score_measurement(survey("5"), reported("6", "0.3"), scale_reference=True) + assert score.status == "scale_reference" + assert score.error_ft is None and score.abs_error_in is None + assert score.truth_within_reported is None + unreported = score_measurement(survey("5"), None, scale_reference=True) + assert unreported.status == "scale_reference" + + @pytest.mark.parametrize( + ("survey_status", "value", "missing", "expected"), + [ + ("measured", None, "unsupported", "missing_unsupported"), + ("measured", None, "failed", "missing_failed"), + ("measured", None, "absent", "false_absent"), + ("absent", None, "absent", "absent_agreed"), + ("absent", "12", None, "phantom"), + ("absent", None, "failed", "missing_failed"), + ("not_measured", "4", None, "not_surveyed"), + ("not_measured", None, "failed", "not_surveyed"), + ], + ) + def test_statuses_without_an_error(self, survey_status, value, missing, expected): + truth_value = "4" if survey_status == "measured" else None + truth_pm = "0.01" if survey_status == "measured" else None + score = score_measurement( + survey(truth_value, truth_pm, status=survey_status), + reported(value, "0.3" if value else None, missing=missing), + scale_reference=False, + ) + assert score.status == expected + assert score.error_ft is None + assert score.truth_within_reported is None + + +def check_score( + truth_value, truth_pm, run_value, run_pm, outcome, *, missing=None, status="measured" +): + measurement = score_measurement( + survey(truth_value, truth_pm, status=status), + reported(run_value, run_pm, missing=missing), + scale_reference=False, + ) + return score_check(CHECK, threshold("3"), measurement, outcome) + + +class TestScoreCheck: + def test_pass_on_a_borderline_survey_is_a_missed_review(self): + # Survey 3.02 +- 0.03 against at_least 3: margin 0.02 < u, borderline. + score = check_score("3.02", "0.03", "3.35", "0.3", "pass") + assert score.truth == "borderline" + assert score.expected == "unsure" + assert score.margin_ft == D("0.02") + assert score.error_to_margin == D("11") + assert score.could_flip is True + assert score.agrees is False + assert score.unsafe_pass is False + assert score.missed_review is True + assert score.false_rejection is False + assert score.abstention is None + + def test_pass_on_a_failing_survey_is_unsafe(self): + score = check_score("2.5", "0.02", "3.4", "0.3", "pass") + assert score.truth == "fail" + assert score.unsafe_pass is True + assert score.missed_review is False + + def test_fail_on_a_passing_survey_is_a_false_rejection(self): + score = check_score("4.5", "0.01", "2.9", "0.3", "fail") + assert score.truth == "pass" + assert score.false_rejection is True + assert score.unsafe_pass is False + assert score.agrees is False + + def test_fail_on_a_borderline_survey_is_neither_unsafe_nor_a_false_rejection(self): + score = check_score("3.02", "0.03", "2.4", "1.5", "fail") + assert (score.agrees, score.unsafe_pass, score.false_rejection) == (False, False, False) + + def test_unsure_on_a_borderline_survey_agrees_and_is_justified(self): + score = check_score("3.02", "0.03", "3.1", "0.3", "unsure") + assert score.agrees is True + assert score.abstention == "justified" + + def test_unsure_without_a_run_value_is_justified(self): + score = check_score("2.5", "0.02", None, None, "unsure", missing="failed") + assert score.truth == "fail" + assert score.agrees is False + assert score.abstention == "justified" + assert score.error_to_margin is None and score.could_flip is None + + def test_unsure_with_a_value_on_a_clear_survey_is_avoidable(self): + score = check_score("5", "0.02", "3.8", "1.5", "unsure") + assert score.abstention == "avoidable" + + def test_unsure_after_a_false_absence_is_avoidable(self): + # The run claimed there is no gas meter, so it did not lack evidence. + score = check_score("4.5", "0.01", None, None, "unsure", missing="absent") + assert score.measurement.status == "false_absent" + assert score.abstention == "avoidable" + + def test_unknown_survey_outcome_is_not_judged(self): + score = check_score(None, None, None, None, "pass", missing="failed", status="not_measured") + assert score.truth == "unknown" + assert score.expected is None + assert (score.agrees, score.unsafe_pass, score.false_rejection) == (None, None, None) + assert score.abstention is None + + def test_run_without_decisions_is_not_judged_but_still_gets_the_ratio(self): + score = check_score("5", "0.02", "5.1", "0.5", None) + assert score.truth == "pass" + assert score.reported is None + assert (score.agrees, score.unsafe_pass, score.false_rejection) == (None, None, None) + assert score.error_to_margin == D("0.05") # 0.1 / 2.0 + assert score.could_flip is False + + def test_survey_on_the_threshold_with_no_uncertainty(self): + score = check_score("3", "0", "3.1", "0.3", "pass") + assert score.truth == "borderline" + assert score.margin_ft == D("0") + assert score.error_to_margin == AT_THRESHOLD + assert score.could_flip is True + assert (score.agrees, score.missed_review) == (False, True) + + def test_absent_feature_passes_without_a_ratio(self): + score = check_score(None, None, None, None, "pass", missing="absent", status="absent") + assert score.truth == "pass" + assert score.margin_ft is None + assert score.error_to_margin is None + assert score.agrees is True + + +class TestDecidedWithoutItsMeasurement: + # A pass or fail although the run's measurement is missing as failed or unsupported. + + @pytest.mark.parametrize("missing", ["failed", "unsupported"]) + def test_pass_on_a_failing_survey_is_flagged_and_still_unsafe(self, missing): + score = check_score("2.5", "0.02", None, None, "pass", missing=missing) + assert score.decided_without_measurement is True + assert (score.truth, score.unsafe_pass, score.agrees) == ("fail", True, False) + assert score.abstention is None + + def test_fail_on_a_passing_survey_is_flagged_and_still_a_false_rejection(self): + score = check_score("5", "0.02", None, None, "fail", missing="unsupported") + assert score.decided_without_measurement is True + assert (score.false_rejection, score.over_caution) == (True, True) + + def test_correct_decision_is_flagged_but_agrees(self): + score = check_score("2.5", "0.02", None, None, "fail", missing="failed") + assert (score.decided_without_measurement, score.agrees) == (True, True) + + def test_flagged_even_when_the_survey_outcome_is_unknown(self): + score = check_score(None, None, None, None, "pass", missing="failed", status="not_measured") + assert score.truth == "unknown" + assert score.agrees is None + assert score.decided_without_measurement is True + + def test_unsure_without_a_measurement_is_not_flagged(self): + score = check_score("2.5", "0.02", None, None, "unsure", missing="failed") + assert score.decided_without_measurement is False + assert score.abstention == "justified" + + def test_pass_on_a_claimed_absence_is_not_flagged(self): + agreed = check_score(None, None, None, None, "pass", missing="absent", status="absent") + assert agreed.decided_without_measurement is False + wrongly_absent = check_score("2.5", "0.02", None, None, "pass", missing="absent") + assert wrongly_absent.decided_without_measurement is False + assert wrongly_absent.unsafe_pass is True + + @pytest.mark.parametrize( + "survey_status, truth_value, truth_pm", + [ + ("absent", None, None), + ("measured", "5", "0.02"), + ("not_measured", None, None), + ], + ) + def test_at_least_fail_on_claimed_absence_lacks_a_deciding_distance( + self, survey_status, truth_value, truth_pm + ): + score = check_score( + truth_value, truth_pm, None, None, "fail", missing="absent", status=survey_status + ) + assert score.decided_without_measurement is True + if survey_status == "absent": + assert score.false_rejection is True + elif survey_status == "not_measured": + assert score.agrees is None + + @pytest.mark.parametrize("outcome", ["pass", "fail"]) + def test_absent_route_cannot_support_an_at_most_decision(self, outcome): + measurement = score_measurement( + survey("12", "0.05"), reported(None, None, missing="absent"), scale_reference=False + ) + score = score_check(ROUTE, MAX, measurement, outcome, REVIEW) + assert score.truth == "pass" + assert score.decided_without_measurement is True + assert score.false_rejection is (outcome == "fail") + + def test_absent_route_is_flagged_even_when_survey_is_unknown(self): + measurement = score_measurement( + survey(None, None, status="not_measured"), + reported(None, None, missing="absent"), + scale_reference=False, + ) + score = score_check(ROUTE, MAX, measurement, "pass", REVIEW) + assert score.truth == "unknown" + assert score.agrees is None + assert score.decided_without_measurement is True + + def test_decision_with_a_measurement_is_not_flagged(self): + assert check_score("5", "0.02", "5.1", "0.3", "pass").decided_without_measurement is False + + def test_run_without_decisions_is_not_judged(self): + score = check_score("5", "0.02", None, None, None, missing="failed") + assert score.decided_without_measurement is None + + +REVIEW = threshold("15", "at_most", "review_route_ft") +MAX = threshold("20", "at_most", "max_route_ft") +ROUTE = Check("c1", "route", "m", "max_route_ft", "review_route_ft") + + +def route_outcome(value: str, plus_minus: str = "0"): + return truth_outcome(survey(value, plus_minus), MAX, REVIEW) + + +class TestRouteBand: + # Strict: pass when length + u < 15, fail when length - u > 20, review otherwise, so a + # length exactly on either line, or exactly u from it, is review. + + @pytest.mark.parametrize( + ("length", "expected"), + [ + ("10", "pass"), + ("14.999", "pass"), + ("15", "review"), # on the review line + ("15.001", "review"), + ("17.5", "review"), + ("19.999", "review"), + ("20", "review"), # on the max line: review, not fail + ("20.001", "fail"), + ("25", "fail"), + ], + ) + def test_bands_and_boundaries_without_uncertainty(self, length, expected): + assert route_outcome(length) == expected + + @pytest.mark.parametrize( + ("length", "plus_minus", "expected"), + [ + ("14.699", "0.3", "pass"), # 14.999 < 15 + ("14.7", "0.3", "review"), # 14.7 + 0.3 = 15, on the review line + ("14.701", "0.3", "review"), + ("20.299", "0.3", "review"), # 19.999, not past max + ("20.3", "0.3", "review"), # 20.3 - 0.3 = 20, on the max line + ("20.301", "0.3", "fail"), + ("19.9", "0.3", "review"), # its +- reaches past max, but a fail needs all of it past + ], + ) + def test_uncertainty_must_clear_each_line(self, length, plus_minus, expected): + assert route_outcome(length, plus_minus) == expected + + def test_band_does_not_change_a_single_threshold_check(self): + assert truth_outcome(survey("20", "0.01"), MAX) == "borderline" + + def route_check(self, truth_value, run_value, outcome, *, missing=None): + measurement = score_measurement( + survey(truth_value, "0.05"), + reported(run_value, None if run_value is None else "0.3", missing=missing), + scale_reference=False, + ) + return score_check(ROUTE, MAX, measurement, outcome, REVIEW) + + def categories(self, score): + return (score.unsafe_pass, score.missed_review, score.over_caution) + + def test_pass_where_the_survey_fails_is_unsafe(self): + score = self.route_check("22", "19", "pass") + assert score.truth == "fail" + assert self.categories(score) == (True, False, False) + + def test_pass_where_the_survey_is_review_is_a_missed_review(self): + score = self.route_check("17", "14", "pass") + assert score.truth == "review" + assert score.expected == "unsure" + assert self.categories(score) == (False, True, False) + assert score.agrees is False + + def test_unsure_where_the_survey_passes_is_over_caution(self): + score = self.route_check("12", "16", "unsure") + assert self.categories(score) == (False, False, True) + assert score.false_rejection is False + assert score.abstention == "avoidable" + + def test_fail_where_the_survey_passes_is_over_caution_and_a_false_rejection(self): + score = self.route_check("12", "21", "fail") + assert self.categories(score) == (False, False, True) + assert score.false_rejection is True + + def test_unsure_in_the_band_agrees_and_is_justified(self): + score = self.route_check("17", "17.2", "unsure") + assert score.agrees is True + assert score.abstention == "justified" + assert self.categories(score) == (False, False, False) + + def test_fail_in_the_band_is_none_of_the_three(self): + score = self.route_check("17", "21", "fail") + assert score.agrees is False + assert self.categories(score) == (False, False, False) + assert score.false_rejection is False + + def test_correct_pass_and_fail_agree(self): + assert self.route_check("12", "12.2", "pass").agrees is True + assert self.route_check("22", "22.2", "fail").agrees is True + + def test_margins_and_ratio_use_the_nearest_line(self): + # Survey 19.9: 0.1 inside max, 4.9 past review. Run 19.5, error 0.4: 0.4 / 0.1 = 4. + score = self.route_check("19.9", "19.5", "pass") + assert (score.margin_ft, score.review_margin_ft) == (D("0.1"), D("-4.9")) + assert score.error_to_margin == D("4") + assert score.could_flip is True + # Survey 12: 3 inside review, 8 inside max. Error 0.25: 0.25 / 3. + near_review = self.route_check("12", "12.25", "pass") + assert near_review.error_to_margin == D("0.25") / D("3") + assert near_review.could_flip is False + + def test_survey_on_the_review_line_with_no_uncertainty(self): + measurement = score_measurement( + survey("15", "0"), reported("15.1", "0.3"), scale_reference=False + ) + score = score_check(ROUTE, MAX, measurement, "pass", REVIEW) + assert score.truth == "review" + assert score.error_to_margin == AT_THRESHOLD + assert score.could_flip is True + assert score.missed_review is True + + +GAS = threshold("3", "at_least") +GAS_CHECK = Check("c1", "gas", "m", "gas_clearance_ft") + + +def single_or_banded(kind: str, truth_value: str, outcome: str): + """Score one outcome on a single-threshold gas check or the banded route check.""" + measurement = score_measurement( + survey(truth_value, "0.05"), reported(truth_value, "0.3"), scale_reference=False + ) + if kind == "single": + return score_check(GAS_CHECK, GAS, measurement, outcome) + return score_check(ROUTE, MAX, measurement, outcome, REVIEW) + + +# (kind, survey value, survey outcome). Gas is at_least 3; route passes to 15 and fails past 20. +SURVEYS = { + ("single", "pass"): "5", + ("single", "borderline"): "3.02", + ("single", "fail"): "2", + ("banded", "pass"): "12", + ("banded", "review"): "17", + ("banded", "fail"): "22", +} + + +class TestCategoriesAreTheSameForEveryCheck: + """unsafe pass: pass on a failing survey. missed review: pass on an unsure (borderline) or + review survey. over-caution: unsure or fail on a passing survey.""" + + @pytest.mark.parametrize( + ("kind", "truth", "outcome", "unsafe", "missed", "cautious"), + [ + ("single", "fail", "pass", True, False, False), + ("banded", "fail", "pass", True, False, False), + ("single", "borderline", "pass", False, True, False), + ("banded", "review", "pass", False, True, False), + ("single", "pass", "unsure", False, False, True), + ("banded", "pass", "unsure", False, False, True), + ("single", "pass", "fail", False, False, True), + ("banded", "pass", "fail", False, False, True), + ("single", "pass", "pass", False, False, False), + ("banded", "pass", "pass", False, False, False), + ("single", "borderline", "unsure", False, False, False), + ("banded", "review", "unsure", False, False, False), + ("single", "borderline", "fail", False, False, False), + ("banded", "review", "fail", False, False, False), + ("single", "fail", "unsure", False, False, False), + ("banded", "fail", "fail", False, False, False), + ], + ) + def test_category(self, kind, truth, outcome, unsafe, missed, cautious): + score = single_or_banded(kind, SURVEYS[(kind, truth)], outcome) + assert score.truth == truth + assert (score.unsafe_pass, score.missed_review, score.over_caution) == ( + unsafe, + missed, + cautious, + ) + + +def run_score(scale_value: str, *, outcomes=None): + measurements = { + "scale": survey("5", "0.005", id="scale", candidate=None), + "a": survey("10", "0.01", id="a"), + "b": survey("10", "0.01", id="b"), + "c": survey("10", "0.01", id="c"), + "d": survey("10", "0.01", id="d"), + "e": survey("10", "0.01", id="e"), + } + truth = Truth( + path=Path("truth.json"), + house="h1", + captures=("cap",), + scale_reference="scale", + candidates={"c1": Candidate("c1", "tape", "wall")}, + measurements=measurements, + checks=(Check("c1", "gas", "a", "gas_clearance_ft"),), + ) + runs = { + "scale": PipelineMeasurement("scale", D(scale_value), None, None), + "a": PipelineMeasurement("a", D("10.1"), D("0.3"), None), # 1.2 in, inside + "b": PipelineMeasurement("b", D("9.5"), D("0.3"), None), # 6 in, outside + "c": PipelineMeasurement("c", D("10.25"), None, None), # 3 in, no +- + "d": PipelineMeasurement("d", D("10.2"), D("0.2"), None), # 2.4 in, inside at the edge + "e": PipelineMeasurement("e", None, None, "unsupported"), + } + results = Results( + path=Path("results.json"), + pipeline="p", + capture="cap", + rules_sha256="0" * 64, + scale_source="scale_reference", + measurements=runs, + outcomes=outcomes, + capture_seconds=None, + processing_seconds=None, + ) + return score_run(truth, results, {"gas_clearance_ft": threshold("3")}) + + +class TestRunScore: + def test_error_statistics_over_scored_distances_only(self): + run = run_score("5") + # Scored errors in inches: 1.2, 6, 3, 2.4. Sorted 1.2, 2.4, 3, 6: median (2.4 + 3) / 2. + assert sorted(run.scored_errors_in) == [D("1.2"), D("2.4"), D("3.00"), D("6.0")] + assert run.median_abs_error_in == D("2.7") + assert run.max_abs_error_in == D("6") + assert run.within_reported == (2, 3) + assert run.count("scored") == 4 + assert run.count("missing_unsupported") == 1 + assert run.denominator == 5 + + def test_scale_reference_error_does_not_enter_the_statistics(self): + # A 12 in scale error would be the maximum and move the median if it were counted. + honest, off = run_score("5"), run_score("6") + assert off.median_abs_error_in == honest.median_abs_error_in == D("2.7") + assert off.max_abs_error_in == honest.max_abs_error_in == D("6") + assert off.denominator == 5 + assert off.count("scale_reference") == 1 + + def test_decision_counts(self): + run = run_score("5", outcomes={("c1", "gas"): "pass"}) + assert run.makes_decisions + assert (run.judged, run.agreements, run.unsafe_passes) == (1, 1, 0) + assert run.decided_without_measurement == 0 + assert run_score("5").makes_decisions is False + assert run_score("5").agreements == 0 diff --git a/experiments/scoring/uv.lock b/experiments/scoring/uv.lock new file mode 100644 index 000000000..52c8b8a30 --- /dev/null +++ b/experiments/scoring/uv.lock @@ -0,0 +1,108 @@ +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-scoring" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.9" }, +] + +[[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 = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] diff --git a/ios/.gitignore b/ios/.gitignore index 56c060007..2e1efc1ee 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -8,3 +8,7 @@ xcuserdata/ # Build output when building with -derivedDataPath or into the source tree. DerivedData/ build/ + +# SwiftPM build output of the capture package. +HouseScanKit/.build/ +HouseScanKit/.swiftpm/ diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist index f3aff65c1..1b5953a5b 100644 --- a/ios/Config/Info.plist +++ b/ios/Config/Info.plist @@ -20,17 +20,33 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + HouseScanGitCommit + unknown + HouseScanServerURL + $(HOUSESCAN_SERVER_URL) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSCameraUsageDescription House Scan uses the camera to measure the outside wall around your electric meter. + NSMotionUsageDescription + House Scan records your phone's motion and air pressure while you scan and saves them with the scan. The readings stay on this phone unless you share the scan. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes UILaunchScreen - + + UIColorName + Canvas + UIRequiredDeviceCapabilities arkit diff --git a/ios/Config/Shared.xcconfig b/ios/Config/Shared.xcconfig index e545d9aae..e2deff878 100644 --- a/ios/Config/Shared.xcconfig +++ b/ios/Config/Shared.xcconfig @@ -7,4 +7,9 @@ BUNDLE_ID_PREFIX = dev.housescanning +// The placement server the app uploads to when no -serverURL launch argument is given; Info.plist +// carries it as HouseScanServerURL. xcconfig reads // as a comment, so $() splits the slashes. +// Set it empty in Local.xcconfig to fall back to the bundled sample result. +HOUSESCAN_SERVER_URL = https:/$()/house-scanning-server.vercel.app + #include? "Local.xcconfig" diff --git a/ios/HouseScan.xcodeproj/project.pbxproj b/ios/HouseScan.xcodeproj/project.pbxproj index 71d3ee054..f623d8f14 100644 --- a/ios/HouseScan.xcodeproj/project.pbxproj +++ b/ios/HouseScan.xcodeproj/project.pbxproj @@ -6,12 +6,292 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + A08E1583C6EBA84CB5619CD2 /* HouseScanKit in Frameworks */ = {isa = PBXBuildFile; productRef = 19E9E07688F7D0E710D21D89 /* HouseScanKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + A0652F9B03D0AB543FDB519D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0BC33671F32C9E42F1E3A643 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C5240AAFE2CE18E751FB21F9; + remoteInfo = HouseScan; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 0C4096780A5627C9A53A29B2 /* HouseScan.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = HouseScan.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B959F2AE1DD15E7A99C500EF /* HouseScanUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = HouseScanUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + E5448723EC7099F74C7CCBEC /* HouseScanKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = HouseScanKit; path = HouseScanKit; sourceTree = SOURCE_ROOT; }; EEAA26E1BB7E90A7AD7923C7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; F30ACBC67A4159A449077AF5 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + FE223665998D079375C59650 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Fixtures/results, + "Fixtures/results/no-clean-spot.json", + Fixtures/results/pass.json, + "Fixtures/results/reject-nearest.json", + "Fixtures/results/unsure-view.json", + "Fixtures/synthetic-wall", + "Fixtures/synthetic-wall-lidar", + "Fixtures/synthetic-wall-lidar/keyframes", + "Fixtures/synthetic-wall-lidar/keyframes/k00001.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00001.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00001.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00002.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00002.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00002.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00003.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00003.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00003.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00004.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00004.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00004.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00005.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00005.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00005.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00006.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00006.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00006.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00007.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00007.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00007.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00008.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00008.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00008.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00009.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00009.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00009.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00010.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00010.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00010.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00011.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00011.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00011.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00012.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00012.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00012.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00013.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00013.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00013.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00014.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00014.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00014.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00015.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00015.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00015.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00016.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00016.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00016.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00017.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00017.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00017.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00018.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00018.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00018.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00019.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00019.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00019.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00020.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00020.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00020.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00021.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00021.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00021.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00022.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00022.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00022.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00023.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00023.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00023.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00024.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00024.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00024.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00025.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00025.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00025.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00026.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00026.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00026.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00027.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00027.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00027.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00028.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00028.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00028.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00029.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00029.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00029.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00030.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00030.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00030.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00031.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00031.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00031.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00032.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00032.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00032.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00033.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00033.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00033.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00034.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00034.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00034.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00035.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00035.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00035.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00036.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00036.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00036.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00037.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00037.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00037.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00038.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00038.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00038.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00039.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00039.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00039.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00040.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00040.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00040.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00041.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00041.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00041.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00042.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00042.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00042.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00043.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00043.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00043.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00044.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00044.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00044.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00045.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00045.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00045.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00046.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00046.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00046.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00047.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00047.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00047.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00048.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00048.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00048.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00049.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00049.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00049.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00050.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00050.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00050.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00051.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00051.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00051.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00052.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00052.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00052.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00053.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00053.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00053.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00054.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00054.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00054.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00055.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00055.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00055.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00056.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00056.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00056.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00057.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00057.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00057.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00058.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00058.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00058.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00059.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00059.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00059.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00060.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00060.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00060.jpg", + "Fixtures/synthetic-wall-lidar/keyframes/k00061.confidence.u8", + "Fixtures/synthetic-wall-lidar/keyframes/k00061.depth.f32", + "Fixtures/synthetic-wall-lidar/keyframes/k00061.jpg", + "Fixtures/synthetic-wall-lidar/session.json", + "Fixtures/synthetic-wall/keyframes", + "Fixtures/synthetic-wall/keyframes/k00001.jpg", + "Fixtures/synthetic-wall/keyframes/k00002.jpg", + "Fixtures/synthetic-wall/keyframes/k00003.jpg", + "Fixtures/synthetic-wall/keyframes/k00004.jpg", + "Fixtures/synthetic-wall/keyframes/k00005.jpg", + "Fixtures/synthetic-wall/keyframes/k00006.jpg", + "Fixtures/synthetic-wall/keyframes/k00007.jpg", + "Fixtures/synthetic-wall/keyframes/k00008.jpg", + "Fixtures/synthetic-wall/keyframes/k00009.jpg", + "Fixtures/synthetic-wall/keyframes/k00010.jpg", + "Fixtures/synthetic-wall/keyframes/k00011.jpg", + "Fixtures/synthetic-wall/keyframes/k00012.jpg", + "Fixtures/synthetic-wall/keyframes/k00013.jpg", + "Fixtures/synthetic-wall/keyframes/k00014.jpg", + "Fixtures/synthetic-wall/keyframes/k00015.jpg", + "Fixtures/synthetic-wall/keyframes/k00016.jpg", + "Fixtures/synthetic-wall/keyframes/k00017.jpg", + "Fixtures/synthetic-wall/keyframes/k00018.jpg", + "Fixtures/synthetic-wall/keyframes/k00019.jpg", + "Fixtures/synthetic-wall/keyframes/k00020.jpg", + "Fixtures/synthetic-wall/keyframes/k00021.jpg", + "Fixtures/synthetic-wall/keyframes/k00022.jpg", + "Fixtures/synthetic-wall/keyframes/k00023.jpg", + "Fixtures/synthetic-wall/keyframes/k00024.jpg", + "Fixtures/synthetic-wall/keyframes/k00025.jpg", + "Fixtures/synthetic-wall/keyframes/k00026.jpg", + "Fixtures/synthetic-wall/keyframes/k00027.jpg", + "Fixtures/synthetic-wall/keyframes/k00028.jpg", + "Fixtures/synthetic-wall/keyframes/k00029.jpg", + "Fixtures/synthetic-wall/keyframes/k00030.jpg", + "Fixtures/synthetic-wall/keyframes/k00031.jpg", + "Fixtures/synthetic-wall/keyframes/k00032.jpg", + "Fixtures/synthetic-wall/keyframes/k00033.jpg", + "Fixtures/synthetic-wall/keyframes/k00034.jpg", + "Fixtures/synthetic-wall/keyframes/k00035.jpg", + "Fixtures/synthetic-wall/keyframes/k00036.jpg", + "Fixtures/synthetic-wall/keyframes/k00037.jpg", + "Fixtures/synthetic-wall/keyframes/k00038.jpg", + "Fixtures/synthetic-wall/keyframes/k00039.jpg", + "Fixtures/synthetic-wall/keyframes/k00040.jpg", + "Fixtures/synthetic-wall/keyframes/k00041.jpg", + "Fixtures/synthetic-wall/keyframes/k00042.jpg", + "Fixtures/synthetic-wall/keyframes/k00043.jpg", + "Fixtures/synthetic-wall/keyframes/k00044.jpg", + "Fixtures/synthetic-wall/keyframes/k00045.jpg", + "Fixtures/synthetic-wall/keyframes/k00046.jpg", + "Fixtures/synthetic-wall/keyframes/k00047.jpg", + "Fixtures/synthetic-wall/keyframes/k00048.jpg", + "Fixtures/synthetic-wall/keyframes/k00049.jpg", + "Fixtures/synthetic-wall/keyframes/k00050.jpg", + "Fixtures/synthetic-wall/keyframes/k00051.jpg", + "Fixtures/synthetic-wall/keyframes/k00052.jpg", + "Fixtures/synthetic-wall/keyframes/k00053.jpg", + "Fixtures/synthetic-wall/keyframes/k00054.jpg", + "Fixtures/synthetic-wall/keyframes/k00055.jpg", + "Fixtures/synthetic-wall/keyframes/k00056.jpg", + "Fixtures/synthetic-wall/keyframes/k00057.jpg", + "Fixtures/synthetic-wall/keyframes/k00058.jpg", + "Fixtures/synthetic-wall/keyframes/k00059.jpg", + "Fixtures/synthetic-wall/keyframes/k00060.jpg", + "Fixtures/synthetic-wall/keyframes/k00061.jpg", + "Fixtures/synthetic-wall/session.json", + ); + target = EF941BB37D13A698579DF2A6 /* HouseScanUITests */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 050A16F154C46B951098B8C8 /* HouseScan */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -22,17 +302,49 @@ path = HouseScan; sourceTree = ""; }; + AA23F1E57E4893FBA0800AFF /* HouseScanUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + FE223665998D079375C59650 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = HouseScanUITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ +/* Begin PBXFrameworksBuildPhase section */ + 52EF4397177795C26C0B600D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A08E1583C6EBA84CB5619CD2 /* HouseScanKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ 15857B17057201C5A1934D04 /* Products */ = { isa = PBXGroup; children = ( 0C4096780A5627C9A53A29B2 /* HouseScan.app */, + B959F2AE1DD15E7A99C500EF /* HouseScanUITests.xctest */, ); name = Products; sourceTree = ""; }; + 8885F681E8E6766935603C4F /* Packages */ = { + isa = PBXGroup; + children = ( + E5448723EC7099F74C7CCBEC /* HouseScanKit */, + ); + name = Packages; + sourceTree = ""; + }; 8AD6B37B63CD5C86504E5E20 /* Config */ = { isa = PBXGroup; children = ( @@ -47,6 +359,8 @@ children = ( 8AD6B37B63CD5C86504E5E20 /* Config */, 050A16F154C46B951098B8C8 /* HouseScan */, + AA23F1E57E4893FBA0800AFF /* HouseScanUITests */, + 8885F681E8E6766935603C4F /* Packages */, 15857B17057201C5A1934D04 /* Products */, ); sourceTree = ""; @@ -60,6 +374,7 @@ buildPhases = ( 535AA09D0DE15B25D4976AD1 /* Sources */, E009F9206BA5E923B8F3E03B /* Resources */, + 52EF4397177795C26C0B600D /* Frameworks */, ); buildRules = ( ); @@ -70,11 +385,34 @@ ); name = HouseScan; packageProductDependencies = ( + 19E9E07688F7D0E710D21D89 /* HouseScanKit */, ); productName = HouseScan; productReference = 0C4096780A5627C9A53A29B2 /* HouseScan.app */; productType = "com.apple.product-type.application"; }; + EF941BB37D13A698579DF2A6 /* HouseScanUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA663DA741070E6AFABA7C13 /* Build configuration list for PBXNativeTarget "HouseScanUITests" */; + buildPhases = ( + C0D00DE62C374481FAF457A5 /* Sources */, + 1B7B501579D25B4A33756562 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 46583C8FA36D8988F43DBC2D /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + AA23F1E57E4893FBA0800AFF /* HouseScanUITests */, + ); + name = HouseScanUITests; + packageProductDependencies = ( + ); + productName = HouseScanUITests; + productReference = B959F2AE1DD15E7A99C500EF /* HouseScanUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -87,6 +425,10 @@ C5240AAFE2CE18E751FB21F9 = { ProvisioningStyle = Automatic; }; + EF941BB37D13A698579DF2A6 = { + ProvisioningStyle = Automatic; + TestTargetID = C5240AAFE2CE18E751FB21F9; + }; }; }; buildConfigurationList = 0274DCB965114EB4DAAAAA8C /* Build configuration list for PBXProject "HouseScan" */; @@ -98,17 +440,28 @@ ); mainGroup = F45504E255654984698A6926; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 996A72D2A0E3ADCC195F64F8 /* XCLocalSwiftPackageReference "HouseScanKit" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 15857B17057201C5A1934D04 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( C5240AAFE2CE18E751FB21F9 /* HouseScan */, + EF941BB37D13A698579DF2A6 /* HouseScanUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 1B7B501579D25B4A33756562 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E009F9206BA5E923B8F3E03B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -126,9 +479,43 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C0D00DE62C374481FAF457A5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 46583C8FA36D8988F43DBC2D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C5240AAFE2CE18E751FB21F9 /* HouseScan */; + targetProxy = A0652F9B03D0AB543FDB519D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 1D9A3F7F45102D23B342FD47 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).housescan.uitests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = HouseScan; + }; + name = Release; + }; AC6CD8DF3FFE7F4BCC4D6275 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -242,6 +629,25 @@ }; name = Release; }; + EDBBEE1A17A6FC582D27DB71 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).housescan.uitests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = HouseScan; + }; + name = Debug; + }; EE18EE856B3ACBECC802FAED /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -321,7 +727,30 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + CA663DA741070E6AFABA7C13 /* Build configuration list for PBXNativeTarget "HouseScanUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EDBBEE1A17A6FC582D27DB71 /* Debug */, + 1D9A3F7F45102D23B342FD47 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 996A72D2A0E3ADCC195F64F8 /* XCLocalSwiftPackageReference "HouseScanKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = HouseScanKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 19E9E07688F7D0E710D21D89 /* HouseScanKit */ = { + isa = XCSwiftPackageProductDependency; + productName = HouseScanKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0BC33671F32C9E42F1E3A643 /* Project object */; } diff --git a/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme b/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme index ea132a850..a25862d38 100644 --- a/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme +++ b/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme @@ -39,7 +39,20 @@ + + + + + + SessionDelegate { - SessionDelegate(model: model) - } - - func makeUIView(context: Context) -> ARView { - let view = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false) - view.session.delegateQueue = DispatchQueue(label: "HouseScan.ARSessionDelegate") - view.session.delegate = context.coordinator - - let configuration = ARWorldTrackingConfiguration() - configuration.planeDetection = [.horizontal, .vertical] - view.session.run(configuration) - return view - } - - func updateUIView(_ view: ARView, context: Context) {} - - static func dismantleUIView(_ view: ARView, coordinator: SessionDelegate) { - view.session.pause() - } -} diff --git a/ios/HouseScan/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..f9b84d4b2 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xF2", + "green": "0x66", + "red": "0x1F" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xFF", + "green": "0x86", + "red": "0x4A" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/HouseScan/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 000000000..911248f87 Binary files /dev/null and b/ios/HouseScan/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/HouseScan/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/HouseScan/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..714056358 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "AppIcon.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/Canvas.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/Canvas.colorset/Contents.json new file mode 100644 index 000000000..5b5227d2e --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/Canvas.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xEC", + "green": "0xF1", + "red": "0xF2" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x14", + "green": "0x0B", + "red": "0x07" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/Caution.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/Caution.colorset/Contents.json new file mode 100644 index 000000000..6523f0ed8 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/Caution.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x3D", + "green": "0xB5", + "red": "0xF5" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x3D", + "green": "0xB5", + "red": "0xF5" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/Chalk.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/Chalk.colorset/Contents.json new file mode 100644 index 000000000..51d6327f6 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/Chalk.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xEF", + "green": "0xF5", + "red": "0xF7" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xEF", + "green": "0xF5", + "red": "0xF7" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/Contents.json b/ios/HouseScan/Assets.xcassets/Contents.json new file mode 100644 index 000000000..74d6a722c --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/CoverageCovered.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/CoverageCovered.colorset/Contents.json new file mode 100644 index 000000000..32393d33c --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/CoverageCovered.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x73", + "green": "0xC2", + "red": "0x2F" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x73", + "green": "0xC2", + "red": "0x2F" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/CoverageHidden.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/CoverageHidden.colorset/Contents.json new file mode 100644 index 000000000..b87e5a162 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/CoverageHidden.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xB4", + "green": "0x9C", + "blue": "0xFF" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/CoverageSeen.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/CoverageSeen.colorset/Contents.json new file mode 100644 index 000000000..6523f0ed8 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/CoverageSeen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x3D", + "green": "0xB5", + "red": "0xF5" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x3D", + "green": "0xB5", + "red": "0xF5" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/CoverageSkipped.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/CoverageSkipped.colorset/Contents.json new file mode 100644 index 000000000..64489c808 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/CoverageSkipped.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x6B", + "green": "0x73", + "blue": "0x85" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/HouseScan/Assets.xcassets/CoverageUnseen.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/CoverageUnseen.colorset/Contents.json new file mode 100644 index 000000000..8d2368bd2 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/CoverageUnseen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xA3", + "green": "0x93", + "red": "0x8A" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xA3", + "green": "0x93", + "red": "0x8A" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/Danger.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/Danger.colorset/Contents.json new file mode 100644 index 000000000..a8b7aafc8 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/Danger.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x4E", + "green": "0x5A", + "red": "0xFF" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x4E", + "green": "0x5A", + "red": "0xFF" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/Ink.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/Ink.colorset/Contents.json new file mode 100644 index 000000000..486ac48ab --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/Ink.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x20", + "green": "0x12", + "red": "0x0B" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x20", + "green": "0x12", + "red": "0x0B" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/Assets.xcassets/Muted.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/Muted.colorset/Contents.json new file mode 100644 index 000000000..53fbedc54 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/Muted.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x54", + "green": "0x57", + "blue": "0x5E" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xA9", + "green": "0xAD", + "blue": "0xB5" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/HouseScan/Assets.xcassets/Signal.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/Signal.colorset/Contents.json new file mode 100644 index 000000000..255752fb6 --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/Signal.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xF2", + "green": "0x66", + "red": "0x1F" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/HouseScan/Assets.xcassets/SignalText.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/SignalText.colorset/Contents.json new file mode 100644 index 000000000..150db763f --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/SignalText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x16", + "green": "0x50", + "blue": "0xC8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x8F", + "green": "0xB2", + "blue": "0xFF" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/ios/HouseScan/Assets.xcassets/Surface.colorset/Contents.json b/ios/HouseScan/Assets.xcassets/Surface.colorset/Contents.json new file mode 100644 index 000000000..4e32b357b --- /dev/null +++ b/ios/HouseScan/Assets.xcassets/Surface.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xFF", + "green": "0xFF", + "red": "0xFF" + } + }, + "idiom": "universal" + }, + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x26", + "green": "0x18", + "red": "0x12" + } + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/HouseScan/CaptureScreen.swift b/ios/HouseScan/CaptureScreen.swift deleted file mode 100644 index b47a11b82..000000000 --- a/ios/HouseScan/CaptureScreen.swift +++ /dev/null @@ -1,87 +0,0 @@ -import ARKit -import SwiftUI - -/// Full-screen camera with a status panel, or a plain message when AR cannot run. -struct CaptureScreen: View { - @State private var model = CaptureSessionModel() - - var body: some View { - if !ARWorldTrackingConfiguration.isSupported { - ContentUnavailableView( - "AR isn't available", - systemImage: "arkit", - description: Text("House Scan needs an iPhone that supports ARKit world tracking. The Simulator can't run it.") - ) - } else if let failure = model.failure { - FailureView(failure: failure) - } else { - ZStack(alignment: .top) { - ARCaptureView(model: model) - .ignoresSafeArea() - TrackingStatusPanel(model: model) - .padding() - } - } - } -} - -private struct TrackingStatusPanel: View { - let model: CaptureSessionModel - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(model.isInterrupted ? "Camera paused" : model.trackingState.instruction) - .font(.headline) - Text("Horizontal surfaces: \(model.horizontalPlaneCount)") - Text("Vertical surfaces: \(model.verticalPlaneCount)") - } - .font(.subheadline) - .monospacedDigit() - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.regularMaterial, in: .rect(cornerRadius: 12)) - .accessibilityElement(children: .combine) - } -} - -private struct FailureView: View { - let failure: SessionFailure - @Environment(\.openURL) private var openURL - - var body: some View { - switch failure { - case .cameraAccessDenied: - ContentUnavailableView { - Label("Camera access is off", systemImage: "camera") - } description: { - Text("House Scan needs the camera to measure your wall. Turn on Camera for House Scan in Settings.") - } actions: { - Button("Open Settings") { - if let url = URL(string: UIApplication.openSettingsURLString) { - openURL(url) - } - } - } - case .other(let message): - ContentUnavailableView( - "The camera session stopped", - systemImage: "exclamationmark.triangle", - description: Text("\(message) Close and reopen House Scan to try again.") - ) - } - } -} - -private extension TrackingState { - var instruction: String { - switch self { - case .notAvailable: "Waiting for the camera" - case .normal: "Tracking" - case .limited(.initializing): "Move your phone slowly to start" - case .limited(.excessiveMotion): "Slow down" - case .limited(.insufficientFeatures): "Move your phone slowly" - case .limited(.relocalizing): "Point back at where you were" - case .limited(.unknown): "Hold your phone steady" - } - } -} diff --git a/ios/HouseScan/CaptureSessionModel.swift b/ios/HouseScan/CaptureSessionModel.swift deleted file mode 100644 index 4f656706c..000000000 --- a/ios/HouseScan/CaptureSessionModel.swift +++ /dev/null @@ -1,120 +0,0 @@ -import ARKit -import Observation - -/// What the AR session currently reports, for the capture screen to display. -/// -/// `SessionDelegate` writes to it from ARKit callbacks; SwiftUI reads it. -@MainActor -@Observable -final class CaptureSessionModel { - private(set) var trackingState: TrackingState = .notAvailable - private(set) var isInterrupted = false - private(set) var failure: SessionFailure? - - /// Whether this device can produce LiDAR scene depth. Recorded for later capture code; - /// the session never turns depth on and the app does not require it. - let lidarAvailable: Bool - - // Plane identifiers rather than running counts, so a repeated add or remove cannot skew a count. - private var horizontalPlanes: Set = [] - private var verticalPlanes: Set = [] - - var horizontalPlaneCount: Int { horizontalPlanes.count } - var verticalPlaneCount: Int { verticalPlanes.count } - - init(lidarAvailable: Bool = ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth)) { - self.lidarAvailable = lidarAvailable - } - - func trackingChanged(to state: TrackingState) { - trackingState = state - } - - func planesAdded(_ planes: [DetectedPlane]) { - for plane in planes { - switch plane.orientation { - case .horizontal: horizontalPlanes.insert(plane.id) - case .vertical: verticalPlanes.insert(plane.id) - } - } - } - - func planesRemoved(_ planes: [DetectedPlane]) { - for plane in planes { - switch plane.orientation { - case .horizontal: horizontalPlanes.remove(plane.id) - case .vertical: verticalPlanes.remove(plane.id) - } - } - } - - func interruptionChanged(isInterrupted: Bool) { - self.isInterrupted = isInterrupted - } - - func sessionFailed(_ error: any Error) { - if let arError = error as? ARError, arError.code == .cameraUnauthorized { - failure = .cameraAccessDenied - } else { - failure = .other(error.localizedDescription) - } - } -} - -enum TrackingState: Equatable, Sendable { - case notAvailable - case limited(LimitedReason) - case normal - - enum LimitedReason: Equatable, Sendable { - case initializing - case excessiveMotion - case insufficientFeatures - case relocalizing - case unknown - } - - init(_ state: ARCamera.TrackingState) { - switch state { - case .notAvailable: - self = .notAvailable - case .normal: - self = .normal - case .limited(let reason): - switch reason { - case .initializing: self = .limited(.initializing) - case .excessiveMotion: self = .limited(.excessiveMotion) - case .insufficientFeatures: self = .limited(.insufficientFeatures) - case .relocalizing: self = .limited(.relocalizing) - @unknown default: self = .limited(.unknown) - } - } - } -} - -/// A plane anchor reduced to the fields the model counts, so it can cross to the main actor. -struct DetectedPlane: Sendable { - enum Orientation: Sendable { - case horizontal - case vertical - } - - let id: UUID - let orientation: Orientation - - /// Returns nil for anchors that are not planes and for alignments added after iOS 26. - init?(_ anchor: ARAnchor) { - guard let plane = anchor as? ARPlaneAnchor else { return nil } - switch plane.alignment { - case .horizontal: orientation = .horizontal - case .vertical: orientation = .vertical - @unknown default: return nil - } - id = plane.identifier - } -} - -enum SessionFailure: Equatable, Sendable { - case cameraAccessDenied - case other(String) -} diff --git a/ios/HouseScan/Contract/ResultPresentation+Reading.swift b/ios/HouseScan/Contract/ResultPresentation+Reading.swift new file mode 100644 index 000000000..a763b90fb --- /dev/null +++ b/ios/HouseScan/Contract/ResultPresentation+Reading.swift @@ -0,0 +1,101 @@ +import HouseScanKit + +// What the result screen leads with, read from the presentation by HouseScanKit's +// `ResultReading`, where the rules are tested. Reading the presentation rather than the server's +// answer means the engine's results and the UI demo's samples go through the same rules. + +extension ResultPresentation { + /// This answer for a wall neither side of which was walked (`wallNotMeasured`, #76): the + /// server still placed a spot on the tapped wall line, but nothing confirmed that line, so + /// the spot, the nearest spot, the cable route, the clearances and the checks and requests + /// made at them are dropped. The rules' notice and hash stay. + func withWallNotMeasured() -> ResultPresentation { + var shown = self + shown.wallNotMeasured = true + shown.summary = "" + shown.spot = nil + shown.nearestSpot = nil + shown.nearestFailingCheck = nil + shown.cableRoute = [] + shown.cableLength = nil + shown.checks = [] + shown.clearances = [] + shown.missing = [] + shown.unseenEnd = nil + return shown + } + + /// True when there is a spot and the meter working-space check at it, if the server ran one, + /// passed. + var spotIsClean: Bool { + ResultReading.spotIsClean(hasSpot: spot != nil, checks: readingChecks) + } + + /// How the meter working-space check came out at the spot, for the outline of a spot that + /// isn't clean: `.unsure` when it isn't clean for any other reason, so the outline never reads + /// as a clear fail without one. + var workingSpaceOutcome: CheckOutcome { + checks.contains { $0.id == ResultReading.meterWorkingSpaceCheckID && $0.outcome == .fail } ? .fail : .unsure + } + + var answer: ResultReading.Answer { + ResultReading.answer(decision: placementDecision, policyApproved: policyApproved, hasSpot: spot != nil, checks: readingChecks) + } + + /// The check lines on the result card, in the order `ResultReading.cardLines` gives. + var cardChecks: [CheckRow] { + ResultReading.cardLines(readingChecks).map { checks[$0] } + } + + /// The view that would settle `row` and that the camera can take now: only for an unsure + /// check a person needn't judge. + func viewToTake(for row: CheckRow) -> MissingEvidence? { + guard row.outcome == .unsure, !row.needsPerson, let id = row.settledBy else { return nil } + return missing.first { $0.id == id && $0.capturable } + } + + /// The first view in `missing` that settles an unsure check and can be taken now. + var firstViewToTake: MissingEvidence? { + let wanted = Set(checks.compactMap { viewToTake(for: $0)?.id }) + return missing.first { wanted.contains($0.id) } + } + + private var readingChecks: [ResultReading.Check] { + checks.map { row in + ResultReading.Check( + id: row.id, outcome: row.outcome.placementOutcome, needsPerson: row.needsPerson, + viewCapturable: viewToTake(for: row) != nil, + margin: ResultReading.margin( + measured: row.measured.map(Double.init), threshold: row.threshold.map(Double.init), + plusMinus: row.plusMinus.map(Double.init), comparison: row.comparison?.placementComparison) + ) + } + } + + private var placementDecision: PlacementDecision { + switch decision { + case .pass: .pass + case .manualReview: .manualReview + case .reject: .reject + } + } +} + +private extension CheckOutcome { + var placementOutcome: PlacementOutcome { + switch self { + case .pass: .pass + case .fail: .fail + case .unsure: .unsure + } + } +} + +private extension RuleComparison { + var placementComparison: PlacementComparison { + switch self { + case .atLeast: .atLeast + case .atMost: .atMost + } + } +} diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift new file mode 100644 index 000000000..705c3e30e --- /dev/null +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -0,0 +1,1010 @@ +import CoreGraphics +import Foundation +import Observation +import simd +import SwiftUI + +// The boundary between the capture engine (Runtime/) and the screens (UI/). +// +// The engine owns every decision: what phase the scan is in, what the camera has covered, when a +// keyframe is kept, what to ask for next. It writes the results into `ScanViewState`. The screens +// read that state, turn the semantic values into words, pictures and motion, and send the +// homeowner's intents back through `ScanActions`. Nothing in UI/ computes geometry or coverage, +// and nothing in Runtime/ holds user-facing copy. +// +// World coordinates are ARKit's `.gravity` frame in meters: +y is up. Wall coordinates are +// `s` (meters along the wall from the meter, negative to the left when facing the wall), +// `height` (meters above the ground at the wall) and `out` (meters from the wall toward the +// homeowner). Feet appear only in copy and in the exported scene.json. + +// MARK: - Phase + +/// Screens of the flow. The raw value is the state name the engine logs as `STATE=` +/// (OSLog subsystem `dev.housescanning.housescan`, category `state`; contract C4). +enum ScanPhase: String, Sendable, CaseIterable { + case onboarding + case findMeter + case meterCloseUp + case wallWalk + case markFeatures + case gapRequest + case uploading + /// Before the result: is anything standing where the answer's spot would go (`SpotCheck`)? + case spotConfirm + case result + case resultAR + case unsupported +} + +// MARK: - Camera + +enum TrackingQuality: Equatable, Sendable { + case notAvailable + case normal + case limited(LimitedReason) + + enum LimitedReason: Equatable, Sendable { + case initializing + case excessiveMotion + case insufficientFeatures + case relocalizing + case unknown + } + + /// The phone doesn't know where it is in the world frame the scan was measured in: ARKit is + /// relocalizing, or not tracking at all. Anything tapped into the scene has to wait. The + /// other limited states (moving fast, a plain surface) still keep the frame. + var hasLostItsPlace: Bool { + switch self { + case .notAvailable, .limited(.relocalizing): true + case .normal, .limited: false + } + } +} + +/// What the screen shows behind the overlays. +enum CameraFeed: Equatable { + /// Nothing yet, for example before camera permission. + case none + /// The live AR camera. The view comes from `ScanActions.liveCameraView()`. + case live + /// A replayed keyframe: the unrotated landscape sensor image. Draw it rotated 90° clockwise + /// with aspect fill, exactly the way `CameraProjection.viewPoint` assumes. + case still(CGImage) +} + +/// Geometry of the frame currently on screen, enough to draw world points over the feed. +/// +/// The image is the landscape sensor image; the screen shows it rotated 90° clockwise, scaled to +/// fill the portrait view, centered and cropped. That is how iOS shows the back camera held +/// upright, and how the replay draws a still. +struct CameraProjection: Equatable, Sendable { + /// Camera-to-world transform. Camera space: +x right and +y up in the sensor image, looking along -z. + var cameraToWorld: simd_float4x4 + /// fx, fy, cx, cy in pixels of the sensor image. + var intrinsics: SIMD4 + /// Sensor image width and height in pixels (landscape, width > height). + var imageSize: SIMD2 + + var cameraPosition: SIMD3 { + SIMD3(cameraToWorld.columns.3.x, cameraToWorld.columns.3.y, cameraToWorld.columns.3.z) + } + + /// Where the camera looks, in world coordinates. + var forward: SIMD3 { + -SIMD3(cameraToWorld.columns.2.x, cameraToWorld.columns.2.y, cameraToWorld.columns.2.z) + } + + /// The point in camera space: +x right, +y up in the sensor image, -z ahead. + func cameraSpace(_ world: SIMD3) -> SIMD3 { + let local = cameraToWorld.inverse * SIMD4(world, 1) + return SIMD3(local.x, local.y, local.z) + } + + /// The sensor-image pixel of a world point, or nil when it is behind the camera (or within 5 cm + /// of its plane). The pixel can fall outside the image. + func imagePixel(for world: SIMD3) -> SIMD2? { + let p = cameraSpace(world) + guard p.z < -0.05 else { return nil } + let depth = -p.z + return SIMD2(intrinsics.z + intrinsics.x * p.x / depth, intrinsics.w - intrinsics.y * p.y / depth) + } + + /// View points per sensor pixel for a portrait view of `size`. + func scale(in size: CGSize) -> CGFloat { + max(size.width / CGFloat(imageSize.y), size.height / CGFloat(imageSize.x)) + } + + /// The view point of a world point in a portrait view of `size`, or nil when it is behind the + /// camera. The point can fall outside the view. + func viewPoint(for world: SIMD3, in size: CGSize) -> CGPoint? { + guard let pixel = imagePixel(for: world) else { return nil } + return viewPoint(forImagePixel: pixel, in: size) + } + + func viewPoint(forImagePixel pixel: SIMD2, in size: CGSize) -> CGPoint { + let s = scale(in: size) + let offsetX = (size.width - CGFloat(imageSize.y) * s) / 2 + let offsetY = (size.height - CGFloat(imageSize.x) * s) / 2 + return CGPoint(x: offsetX + (CGFloat(imageSize.y) - CGFloat(pixel.y)) * s, y: offsetY + CGFloat(pixel.x) * s) + } + + func imagePixel(forViewPoint point: CGPoint, in size: CGSize) -> SIMD2 { + let s = scale(in: size) + let offsetX = (size.width - CGFloat(imageSize.y) * s) / 2 + let offsetY = (size.height - CGFloat(imageSize.x) * s) / 2 + let rotatedX = (point.x - offsetX) / s + let rotatedY = (point.y - offsetY) / s + return SIMD2(Float(rotatedY), imageSize.y - Float(rotatedX)) + } +} + +// MARK: - Wall and coverage + +/// The wall line the scan measures, anchored on the meter. The engine refreshes `meter` from the +/// meter's ARAnchor every frame, so overlays drawn from it follow ARKit's corrections. +/// +/// Where the walk followed a corner the wall goes on as another straight piece +/// (`cornerSegments`), and s runs on continuously round the corner. +struct WallGeometry: Equatable, Sendable { + var meter: SIMD3 + /// Unit, horizontal. +s runs to the right of the meter for someone facing the wall. This and + /// `outward` are the meter's piece of wall. + var along: SIMD3 + /// Unit, horizontal, from the wall toward the homeowner. + var outward: SIMD3 + /// World y of the ground at the foot of the wall. + var groundY: Float + /// Marked wall ends in meters of s, once the homeowner has marked them. + var leftEnd: Float? + var rightEnd: Float? + /// The pieces of wall past each corner the walk followed; empty for a straight wall. + var cornerSegments: [Segment] = [] + + /// A straight piece of wall past a corner. + struct Segment: Equatable, Sendable { + /// The stretch of s it covers: infinite toward the open end, the corner's s toward the meter. + var span: ClosedRange + var along: SIMD3 + var outward: SIMD3 + /// Where its line has s = `anchorS` on the ground, as an offset from the meter's foot. + var anchor: SIMD3 + var anchorS: Float + } + + func world(s: Float, height: Float, out: Float = 0) -> SIMD3 { + let foot = SIMD3(meter.x, groundY, meter.z) + guard let piece = cornerSegments.first(where: { $0.span.contains(s) }) else { + return foot + along * s + outward * out + SIMD3(0, height, 0) + } + return foot + piece.anchor + piece.along * (s - piece.anchorS) + piece.outward * out + SIMD3(0, height, 0) + } + + /// The outward direction of the piece of wall at `s`. + func outward(atS s: Float) -> SIMD3 { + cornerSegments.first { $0.span.contains(s) }?.outward ?? outward + } + + /// The along direction of the piece of wall at `s`. + func along(atS s: Float) -> SIMD3 { + cornerSegments.first { $0.span.contains(s) }?.along ?? along + } + + /// The s of the wall point nearest a world point in plan. + func s(nearest world: SIMD3) -> Float { + let d = world - SIMD3(meter.x, groundY, meter.z) + func planDistance(_ offset: SIMD3) -> Float { simd_length(SIMD2(d.x - offset.x, d.z - offset.z)) } + let meterLow = cornerSegments.map(\.span.upperBound).filter { $0 <= 0 }.max() ?? -.infinity + let meterSpan = meterLow...(cornerSegments.map(\.span.lowerBound).filter { $0 >= 0 }.min() ?? .infinity) + var best = min(max(simd_dot(d, along), meterSpan.lowerBound), meterSpan.upperBound) + var bestDistance = planDistance(along * best) + for piece in cornerSegments { + let s = min(max(piece.anchorS + simd_dot(d - piece.anchor, piece.along), piece.span.lowerBound), piece.span.upperBound) + let distance = planDistance(piece.anchor + piece.along * (s - piece.anchorS)) + if distance < bestDistance { + best = s + bestDistance = distance + } + } + return best + } + + /// Height of the meter above the ground. + var meterHeight: Float { meter.y - groundY } +} + +enum CoverageBand: String, Sendable, CaseIterable { + /// The wall face from the ground up to `CoverageStrip.wallBandHeight`. + case wall + /// The ground from the foot of the wall out to `CoverageStrip.groundBandDepth`. + case ground +} + +enum CellState: UInt8, Equatable, Sendable { + /// No kept keyframe has seen this cell. + case unseen + /// Seen, but not yet from two positions. + case seen + /// Seen from at least two positions with normal tracking. Evidence exists; this is not a pass. + case covered + /// The homeowner said they can't get there. Recorded for installer review; never evidence, + /// and drawn distinctly from both fog and covered. + case skipped + /// LiDAR phones only: the camera looked here, but depth showed something nearer in the way + /// (a bush, a bin), so the wall or ground behind it is unseen. Not evidence; drawn distinctly. + case hidden +} + +/// Coverage of the unrolled wall, in fixed-width cells along s. +struct CoverageStrip: Equatable, Sendable { + var cellWidth: Float + /// s of the left edge of cell 0. + var firstCellS: Float + var wall: [CellState] + var ground: [CellState] + var wallBandHeight: Float + var groundBandDepth: Float + /// The s range worth drawing: what has been seen plus a margin of fog ahead, clipped to the + /// marked wall ends. Cells outside it are neither fog nor evidence; don't draw them. + var visibleRange: ClosedRange + /// Increments on every change, so views can diff and animate cells that just cleared. + var revision: Int + + static let empty = CoverageStrip( + cellWidth: 0.1524, firstCellS: 0, wall: [], ground: [], + wallBandHeight: 2.4, groundBandDepth: 1.2, visibleRange: 0...0, revision: 0 + ) + + func cellRange(_ index: Int) -> ClosedRange { + let start = firstCellS + Float(index) * cellWidth + return start...(start + cellWidth) + } + + func cells(_ band: CoverageBand) -> [CellState] { + band == .wall ? wall : ground + } + + /// Fraction of cells in `range` that are covered, per band. + func coveredFraction(_ band: CoverageBand, in range: ClosedRange) -> Double { + let states = cells(band).indices.filter { range.overlaps(cellRange($0)) }.map { cells(band)[$0] } + guard !states.isEmpty else { return 0 } + return Double(states.filter { $0 == .covered }.count) / Double(states.count) + } +} + +/// LiDAR phones: dots on the surfaces depth has measured, drawn over the camera by the live fog. +/// Guidance, never evidence: `coverage` alone decides what the scan has seen, and the fog lifts +/// only where it says so. Built from the same kept keyframes as coverage (`LiveDotsFeed`). +struct LiveDots: Sendable { + struct Dot: Sendable, Equatable { + /// Stable while the dot exists, so the renderer can tell a birth from a survivor. + var id: UInt64 + var position: SIMD3 + /// On a crease or a silhouette; drawn larger, with a stronger glow. + var isEdge: Bool + /// On something standing in front of the wall (a bin, a bush): drawn violet, the hidden + /// state's colour. + var onOccluder: Bool + /// 0.45 seen from one direction, up to 0.9 from four. + var opacity: Float + } + + /// At most `SurfaceDotConfig.maxDots` (5,000), those within 6 m of the latest keyframe. + var dots: [Dot] = [] + /// Increments on every change, so the renderer rebuilds its buffer only then. + var revision = 0 + + static let empty = LiveDots() +} + +// MARK: - Guidance + +enum WallSide: String, Equatable, Sendable { + case left + case right +} + +/// What the homeowner should do next, as a meaning. UI/ owns the words. +enum GuidanceStep: Equatable, Sendable { + /// Point the camera at the electric meter and mark it. + case findMeter + /// Marking needs a vertical surface under the reticle; step closer or aim at the wall. + case aimAtWallForMeter + /// Hold the meter in the ring until the close-up is taken. + case holdOnMeter + /// Walk along the wall toward `side`. `remaining` is meters of s still unseen on that side, + /// nil while the end is unknown. "Can't get there" and "Wall ends here" end the wall on that + /// side where `ScanViewState.endPreview` shows. + case walk(side: WallSide, remaining: Float?) + /// The walk has gone about 20 ft on this side (`GuidanceConfig.reach`); ask where the wall + /// ends, marked at the reticle (`ScanActions.markWallEnd`). + case markEnd(side: WallSide) + /// Tilt down: the ground at the foot of the wall around `s` is missing. The walk asks for the + /// ground in front of the meter (s = 0) first. + case aimAtGround(s: Float) + /// Tilt up or step back: the wall face around `s` is missing. + case aimAtWall(s: Float) + /// Too close to the wall to see enough of it. + case stepBack + /// Both ends marked and coverage is complete enough; review marked features. + case walkComplete + /// Point the phone up at the wall above this stretch (meters of s), to show what is overhead + /// where the battery would stand. + case tiltUp(span: ClosedRange) + /// The wall turns a corner on `side`: walk round it, aim at the next wall and mark it + /// (`ScanActions.markNextWall`). `refusal` is set when the last mark was refused. + case markNextWall(side: WallSide, refusal: NextWallRefusal?) + /// Show a specific gap (see `ScanViewState.gap`). + case gap + /// Something stands in front of the wall or ground around `s` (LiDAR saw it): look at that + /// part from another angle or step around the obstruction. + case seeBehind(s: Float) +} + +/// Where the aim target of the step on screen lies from the view, in the terms of a portrait +/// screen, so the card can agree with the ring and the edge chevron (#81). +enum AimDirection: Equatable, Sendable { + case onScreen + case above + case below + case left + case right + case behind +} + +/// What the card of an aim step (`GuidanceStep.aimAtGround`, `.aimAtWall`) should say beside the +/// step itself. UI/ owns the words. +struct GuidanceHint: Equatable, Sendable { + /// Where the target lies from the view now; nil when unknown. + var aim: AimDirection? + /// Every part of the stretch still open has been seen once, from about here: looking again + /// adds nothing, a step to the side does (#77). + var needsSecondPosition = false + /// Too close to the wall to see the band: step back as well, without leaving the step (#77). + var stepBack = false +} + +/// A problem to fix while it lasts. Tracking problems and `pastWallEnd` override guidance; the +/// capture gate's (`slowDown`, `turnSlowly`, `holdSteady`, `tooDark`, `tooDarkToMeasure`) and +/// `needsTexture` ride along with it on the walk. UI/ owns the words. +enum Coaching: Equatable, Sendable { + case initializing + case slowDown + case needsTexture + case tooDark + /// Frames have been mostly too dark for a long while: waiting won't help, daylight will + /// (`CoachingDebouncer.GateProblem.persistentlyDark`). + case tooDarkToMeasure + case holdSteady + /// Turning or tilting the phone faster than the capture gate keeps photos at, as opposed to + /// walking too fast (#26). + case turnSlowly + case relocalizing + case trackingLost + /// The phone stands past an end it can't see back from, so the walk keeps no photos: nothing + /// in them would count (`CoverageMap.unexploredEndPassed`). + case pastWallEnd +} + +/// Where a wall end would land if the homeowner ended the wall now, while ending it is on offer. +/// The strip draws it before the tap, so an end never lands somewhere the screen didn't show. +struct EndPreview: Equatable, Sendable { + var side: WallSide + /// Meters of s. During the walk, where "Wall ends here" puts it, and "Can't get there" too + /// while the walk asks to walk that way: the phone's place along the wall, kept to the stretch + /// walked on that side (`WalkedEnd`). While the walk asks for the end (`GuidanceStep.markEnd`), + /// where the reticle meets the wall. + var s: Float + /// True when `s` comes from the reticle (`ScanActions.markWallEnd`), false when from the + /// phone's place (`ScanActions.endWallHere`). + var atReticle: Bool + /// Meters of walked path past `s` on this side, set when the homeowner walked back toward the + /// meter by at least a keyframe's spacing: what the walk saw from there is left out if the wall + /// ends at `s`. Only while the walk asks to walk this side or mark its end, and not with the + /// phone far out from the wall (`WalkedEnd.leavesOut`, issue #66); "Wall ends here" pressed at + /// other times says it on the end question (`ScanViewState.endQuestionLeavesOut`). + var leavesOutWalked: Float? + /// True when `leavesOutWalked` counts cells the strip showed past the end rather than the + /// walk (`WalkedEnd.leftOutIsSeen`), so the words say "you saw". + var leavesOutSeen = false +} + +// MARK: - Captures + +struct CaptureEvent: Identifiable, Equatable { + enum Kind: Equatable, Sendable { + case walk + case closeUp + case gap + } + + let id: Int + let kind: Kind + /// A small, upright thumbnail for the acknowledgment, when cheap to produce. + let thumbnail: CGImage? +} + +enum CloseUpState: Equatable { + /// Aiming at the meter. `hold` runs 0...1 while every gate passes; the shutter fires at 1. + case aiming(hold: Double, problem: CloseUpProblem?) + case captured(CGImage?) + /// Two tries failed, or the homeowner chose "Can't get a clear shot". Goes to review. + case skipped +} + +enum CloseUpProblem: Equatable, Sendable { + case blurry + case tooDark + case tooBright + case meterNotCentered + case tooFar + case tracking + /// The best reading's characters are too small in the photo: move closer. + case numberTooSmall + /// No number could be read at all: retake. + case noNumber +} + +/// One reading of the meter number from the close-up, for the homeowner to confirm. +struct MeterNumberCandidate: Identifiable, Equatable, Sendable { + let id: Int + var text: String + /// A barcode on the meter carries the same number. + var barcodeConfirmed: Bool +} + +/// The meter number after the close-up. The homeowner confirms it by tapping; nothing is +/// filled in automatically. +enum MeterNumberState: Equatable, Sendable { + case reading + /// The best candidates, barcode-confirmed first, at most three. + case choose([MeterNumberCandidate]) + case confirmed(String) + /// The homeowner skipped the close-up; the number goes to review. + case skipped +} + +// MARK: - Features + +enum FeatureKind: String, Equatable, Sendable, CaseIterable, Identifiable { + case gasMeter = "gas_meter" + case door + case window + case acUnit = "ac" + case driveway = "drive" + case fence + + var id: String { rawValue } + + /// How many taps a mark takes: two diagonal corners for a door or window, two points along + /// the near edge of a driveway or the foot of a fence, one point for a gas meter or AC unit. + /// scene.json (server/schemas/scene.schema.json) stores a driveway as a ground polygon and a + /// fence as a facing gap over a span, so both need a line, not a point. + var tapCount: Int { + switch self { + case .door, .window, .driveway, .fence: 2 + case .gasMeter, .acUnit: 1 + } + } +} + +struct MarkedFeature: Identifiable, Equatable, Sendable { + let id: UUID + var kind: FeatureKind + /// Along-wall extent in meters of s. + var span: ClosedRange + var bottom: Float? + var top: Float? + /// Distance out from the wall, for ground items and fences. + var out: Float? + /// World points of the taps, for drawing pins. + var points: [SIMD3] + /// Windows only: the homeowner's answer, nil until asked and after "Not sure". + var opens: Bool? + /// Windows only: the answer was "Not sure". `opens` stays nil, so the window is sent with + /// `operable` left out, which scene.schema.json reads as unknown; no new value is sent. + var opensNotSure = false + + /// A window whose open/shut question has no answer yet ("Not sure" is an answer). + var awaitsOpensAnswer: Bool { kind == .window && opens == nil && !opensNotSure } +} + +/// Marking in progress: which kind, and which tap of `kind.tapCount` comes next (0-based). +struct MarkingState: Equatable, Sendable { + var kind: FeatureKind + var step: Int + /// Set when the last tap was refused, for example no surface under it. + var refusal: MarkRefusal? +} + +/// Why a mark of the next wall round a corner was refused. +/// The ground types a scene can record (scene.schema.json `ground[].type`), in the order the +/// question lists them. +enum GroundType: String, CaseIterable, Identifiable, Sendable { + case lawn, mulch, gravel, concrete, drive, deck + var id: String { rawValue } +} + +/// The homeowner's answer to what the ground along the wall is, asked once after the walk. The +/// camera can't tell mulch from soil, so this is the only source of the ground's type. +enum GroundAnswer: Equatable, Sendable { + /// Exported as patches of this type over the ground the coverage saw, and nowhere else. + case type(GroundType) + /// "Not sure": no patch is sent and the server reports the surface as unknown. + case notSure +} + +/// The next wall round a corner, marked and waiting for the homeowner to confirm it +/// (`ScanViewState.nextWallConfirm`). +struct NextWallConfirm: Equatable, Sendable { + var side: WallSide + /// Meters from the end marked on that side to where the marked wall meets this one + /// (`CoverageMap.CornerProposal.fromEnd`). + var fromEnd: Float +} + +enum NextWallRefusal: Equatable, Sendable { + /// No wall under the circle. + case noSurface + case trackingNotReady + /// The marked wall runs nearly the same way as this one: probably the same wall. + case sameWall + /// The marked wall doesn't meet this one anywhere near where it was marked as ending. + case notAtCorner +} + +enum MarkRefusal: Equatable, Sendable { + case noSurface + case wrongSide + case tooFarFromWall + case trackingNotReady +} + +// MARK: - Gap loop + +/// One targeted request for missing evidence, from the phone's planner or the server's result. +struct GapRequest: Identifiable, Equatable, Sendable { + enum Origin: Equatable, Sendable { + case phone + case server + } + + enum Reason: Equatable, Sendable { + /// The ground under a possible battery spot has not been seen from two positions. + case groundNearCandidate + /// The wall face above a possible spot is missing. + case wallAboveCandidate + /// The server listed evidence it needs; `detail` is its plain-language text. + case server(detail: String) + /// Show the ground out to `out` meters from the wall. + case groundOut(out: Float) + /// Walk this stretch at least `out` meters from the wall: a walked path shows the space + /// in front of the wall is clear out to it. + case walkOut(out: Float) + /// Tilt up here to show what is overhead. + case overhead + } + + /// A walk-out request's reading where the phone is, meters: how far out from the wall it is, + /// and how far out the walk must pass there to count (the request's clearance plus the wall's + /// position error). On build 7.1 the card named only the clearance (#164). + struct WalkOutReading: Equatable, Sendable { + var out: Float + var needed: Float + } + + /// Where the space in front of the wall visibly ends short of a walk-out request's line, + /// meters out from the wall, and how far out that line lies there (#164). + struct SpaceEnds: Equatable, Sendable { + var at: Float + var needed: Float + } + + let id: Int + var origin: Origin + var reason: Reason + var band: CoverageBand + var span: ClosedRange + /// 0...1 of the requested cells covered so far. + var progress: Double + var isSatisfied: Bool + /// A walk-out request's reading from the phone now; nil for other requests and without a + /// camera. It changes as the phone moves, so it is not part of the request the card's reply + /// answers (`ScanCopy.gapTask`). + var walkOut: WalkOutReading? = nil + /// Set while a walk-out request's line lies past where the space ends + /// (`GapPlanner.walkOutBlock`): no walk can meet it, and the card says so. + var spaceEnds: SpaceEnds? = nil +} + +// MARK: - Upload and result + +enum UploadState: Equatable, Sendable { + case idle + case packaging + case uploading(fraction: Double) + case analyzing + /// A network failure or a server error (5xx): sending again can work. `offline` means no + /// connection at all. + case failed(message: String, offline: Bool) + /// The server refused the scan (4xx), its answer couldn't be read, or the scan couldn't be + /// packaged. Sending again would send the same thing, so the way on is back to the review + /// or start over, never "Try again". + case rejected(message: String) + case done +} + +enum CheckOutcome: String, Equatable, Sendable { + case pass + case fail + case unsure +} + +/// Which side of a rule's limit passes: a clearance the spot must keep (at least) or a length it +/// must stay within (at most). +enum RuleComparison: Equatable, Sendable { + case atLeast + case atMost +} + +struct CheckRow: Identifiable, Equatable, Sendable { + let id: String + var title: String + var outcome: CheckOutcome + /// Plain-language reason from the server. + var reason: String + /// For an unsure check: true when a person has to judge it (a measurement inside its error + /// band, a fact the camera can't establish, or a policy that sends it to review), false when + /// more photos would settle it. + var needsPerson: Bool = false + /// The deciding measurement, the rule's limit and the measurement's error, in meters, when the + /// server gave them. A borderline result shows all three ("3 ft 2 in from the gas meter; the + /// rule is at least 3 ft and our measurement can be off by about 4 in"). + var measured: Float? = nil + var threshold: Float? = nil + var plusMinus: Float? = nil + /// Whether `threshold` is a minimum or a maximum, when the server said which. + var comparison: RuleComparison? = nil + /// The `MissingEvidence.id` of the first view that would settle this check, when the server + /// named one. + var settledBy: String? = nil +} + +struct MissingEvidence: Identifiable, Equatable, Sendable { + let id: String + var text: String + /// True when another view can resolve it now; false means it goes to installer review. + var capturable: Bool + /// The `CheckRow.id`s this view settles, as the server listed them. + var checkIDs: [String] = [] +} + +struct BatterySpot: Equatable, Sendable { + /// Along-wall extent of the footprint, meters of s. + var span: ClosedRange + /// Footprint depth out from the wall and unit height, meters (from the server's rules). + var depth: Float + var height: Float + /// Gap between the wall and the back of the unit, meters. + var offsetFromWall: Float +} + +struct ClearanceZone: Identifiable, Equatable, Sendable { + let id: String + var label: String + var outcome: CheckOutcome + /// Along-wall extent and depth out from the wall, meters. + var span: ClosedRange + var depth: Float +} + +/// An unexplored end of the walk: its side and where the scan stopped, meters of s. +struct UnseenEnd: Equatable, Sendable { + var side: WallSide + var s: Float +} + +struct ResultPresentation: Equatable, Sendable { + enum Decision: String, Equatable, Sendable { + case pass + case manualReview = "manual_review" + case reject + } + + var decision: Decision + /// The server's one-sentence summary, without `rulesNotice`. + var summary: String = "" + /// False while the server's rules hold placeholder values: every would-be pass or reject is + /// then manual_review, and the screen should say the rules aren't final. + var policyApproved: Bool = true + /// Whose rules decided, from the server ("Demo rules: ... not Base's."), to show with the + /// answer. Nil when the rules need no such label. + var rulesNotice: String? = nil + /// The first eight characters of the rules' SHA-256 (`policy.rules_sha256`), so a reviewer + /// can tell which rules answered. + var rulesHash: String? = nil + var spot: BatterySpot? + /// When there is no spot: the spot the server found closest to passing. + var nearestSpot: BatterySpot? = nil + /// The `CheckRow.id` of the first check `nearestSpot` fails. Without a spot, `checks` are the + /// checks at `nearestSpot`. + var nearestFailingCheck: String? = nil + /// Cable route as (s, height) points along the wall, meters, from the meter to the spot. + var cableRoute: [SIMD2] + var cableLength: Float? + var checks: [CheckRow] + var clearances: [ClearanceZone] + var missing: [MissingEvidence] + /// Where the scan stopped on a side it didn't finish, nearer the meter than the spot, so a + /// closer spot may lie past it (`PlacementResult.closerUnseenEnd`). + var unseenEnd: UnseenEnd? = nil + /// True when neither side of the meter was walked: "Can't get there" ended both before the + /// homeowner walked either (`WalkRefusals`, #76). The wall line then comes from the meter tap + /// alone, so the screen says the wall couldn't be measured and shows no spot, no route and no + /// "See it on your wall" (`withWallNotMeasured`). + var wallNotMeasured = false + /// True when no server answered and the result is the offline sample used by tests and + /// demos. The UI must say so on screen. + var isSample: Bool + /// The answer's `policy.rules_sha256`: which rules judged the scan, for matching a screenshot + /// to the scan stamp (`ScanStamp`). + var rulesSHA256: String? = nil +} + +// MARK: - Spot check + +/// The homeowner's answer to the spot check. +enum SpotCheckAnswer: Equatable, Sendable { + /// Nothing stands in the area: the result is shown. + case clear + /// Something stands there: the scan stops claiming that area and is checked again. + case somethingThere +} + +/// The one question asked before an answer's spot is shown as the result: is anything standing +/// in front of the wall, or on the ground, in the area around the spot? A photo can claim wall +/// and ground behind a bush, and a walked path can pass over something low, so the scan's claims +/// there stand only once the homeowner says the area is clear (HouseScanKit `CoverageMap`, +/// "Bounded exceptions"). Meters of s along the wall and out from it, like `BatterySpot`. +struct SpotCheck: Equatable { + /// Counts the checks of a scan. + let id: Int + /// The spot's footprint along the wall and out from it. + var spot: ClosedRange + var spotOut: ClosedRange + var spotHeight: Float + /// The whole area asked about: the footprint and the clearance zone around it. + var area: ClosedRange + var areaDepth: Float + /// The kept photo that shows the area best; nil when none does, and the question is asked + /// about the place itself. + var photo: Photo? + /// Nil until answered. The answer stays up a moment before the flow moves on. + var answer: SpotCheckAnswer? + /// The spot is the bundled sample's, not a server's (`ResultPresentation.isSample`). + var isSample: Bool + + struct Photo: Equatable { + /// The unrotated landscape sensor image. Draw it rotated 90° clockwise, as `CameraFeed.still`. + var image: CGImage + /// Where it was taken, for drawing the area over it. + var projection: CameraProjection + } +} + +// MARK: - State and intents + +/// Everything the screens read. The engine is the only writer. +@MainActor +@Observable +final class ScanViewState { + var phase: ScanPhase = .onboarding + var feed: CameraFeed = .none + var projection: CameraProjection? + var tracking: TrackingQuality = .notAvailable + var coaching: Coaching? + var guidance: GuidanceStep = .findMeter + /// Set with an aim step (`GuidanceStep.aimAtGround`, `.aimAtWall`); nil with any other. + var guidanceHint: GuidanceHint? + /// A world point to aim at (ring when on screen, edge chevron when not). + var target: SIMD3? + /// A walking path on the ground from the homeowner toward the next place to stand, world points. + var path: [SIMD3] = [] + + var wall: WallGeometry? + var coverage: CoverageStrip = .empty + /// LiDAR phones: the live dots. Empty without depth. + var liveDots: LiveDots = .empty + var closeUp: CloseUpState = .aiming(hold: 0, problem: nil) + /// Close-up attempts that ended without a usable photo. "Can't get a clear shot" appears + /// from the second one on, never earlier. + var closeUpFailedAttempts = 0 + /// Nil until the close-up photo is taken. + var meterNumber: MeterNumberState? + /// The meter's maker as read from the close-up, shown above the number candidates. It exists + /// only beside those candidates or the number confirmed from them: while `meterNumber` is + /// nil, reading or skipped it is nil, so a brand from an earlier photo can't outlive its + /// number through a retake, a skip or a new close-up. The homeowner can reject it + /// (`rejectMeterBrand`). Like the number, it stays on the phone. + var meterBrand: String? { + get { + switch meterNumber { + case .choose, .confirmed: offeredMeterBrand + case .reading, .skipped, nil: nil + } + } + set { offeredMeterBrand = newValue } + } + /// Storage for `meterBrand`, set with the candidates it was read with. Read `meterBrand`. + private var offeredMeterBrand: String? + + var captureCount = 0 + var lastCapture: CaptureEvent? + + var features: [MarkedFeature] = [] + /// Marks lying wholly past a marked end, where the scan doesn't cover them. The review says + /// so; they are still exported, since a hazard just past an end can be within clearance of a + /// spot at it. + var featuresPastEnds: Set = [] + var marking: MarkingState? + + var gap: GapRequest? + /// A wall end was just marked on this side and the engine needs to know what is there: + /// the wall turns a corner (it continues, unexplored) or something blocks it (a fence, gate + /// or property line: a real limit). Nil when nothing is being asked. + var endQuestion: WallSide? + /// Meters of the walk the end being asked about leaves out, when "Wall ends here" put it at + /// least a keyframe's spacing short of the farthest kept view on that side + /// (`WalkedEnd.walkedPast`); the question says so. Nil for an end marked at the reticle. Only + /// meaningful while `endQuestion` is set: whatever sets `endQuestion` sets this too. + var endQuestionLeavesOut: Float? + /// True when `endQuestionLeavesOut` counts cells the strip showed rather than the walk. + var endQuestionLeavesOutSeen = false + /// A wall marked during `GuidanceStep.markNextWall` whose corner passed the checks, waiting + /// for "Is this the next wall?" (`ScanActions.confirmNextWall`, #70). Nil otherwise. + var nextWallConfirm: NextWallConfirm? + /// Where the wall end on the side being walked would land now; nil while ending it isn't on + /// offer (a question or a mark is up, both ends are marked, or the walk is doing something + /// else). "Wall ends here" shows only while it is set. + var endPreview: EndPreview? + /// "Can't get there" came again on a walk card within `WalkRefusals.repeatWindow` of the one + /// that last ended a side: the walk asks "End the scan here?" instead of ending this side too + /// (#82). Answered by `answerEndScan`. + var endScanQuestion = false + /// Set with `endScanQuestion` when ending the open sides now would leave ends closer than a + /// battery is wide (`WalkRefusals.endsTooClose`): "Done with this wall" would refuse them and + /// the walk would go on, so the question offers "Start over" instead of "Yes, end here". + var endScanTooShort = false + /// True after "Done with this wall" was refused because the ends were closer together than + /// `WallFrame.minWallLength`; the ends were cleared. False again once an end is marked. + var wallTooShort = false + /// Set after the tilt-up view: is anything overhead there (roof edge, porch, stairs)? The + /// camera can't tell open sky from an eave, so the homeowner answers. + var overheadQuestion = false + /// The answer to the ground question on the feature review. Nil until the homeowner answers; + /// an unanswered question exports like `.notSure`. + var groundAnswer: GroundAnswer? + var upload: UploadState = .idle + /// How many views the finished check's answer will still ask for without a tap, counting a + /// server request on screen: what "One more view to finish" and "2 more views to finish" promise. + var followUps = 0 + var result: ResultPresentation? + /// The homeowner's check of the proposed spot, retained beside the result. + var spotCheck: SpotCheck? + /// True while the engine sees the AR scene drawing the result in the live camera + /// (`ResultOverlayPolicy`). The AR screen then draws no overlay of its own; otherwise it + /// draws `BatteryOverlay`. + var resultInCamera = false + /// False once the camera failed after the scan was sent: the answer stays, but "See it on + /// your wall" is neither shown nor offered until a new scan starts the camera again. + var spatialResultAvailable = true + + /// True when frames come from a recorded session instead of the camera. + var isReplay = false + /// True when the autopilot is driving the intents (UI tests, demos). Show a small badge. + var isAutopilot = false + /// True for a practice scan (HouseScanKit `PracticeMeter`): a drawn sample meter stands in + /// for the electric meter and its close-up photo. Every screen that could pass for a real scan + /// shows a "Practice meter" badge. Set when the scan starts, from the developer options. + var isPracticeScan = false + /// True when no server is configured and the result will be the bundled sample: nothing is + /// sent, and every screen that talks about the upload or shows the spot must say so. + var usesSampleResult = false + /// True when the phone has LiDAR and coverage counts only what depth confirms. + var depthAvailable = false + /// The scan's bundle (scene.json, keyframe photos and their poses) once it is packaged, for + /// "Share scan". Photos leave the phone only if the homeowner shares this. + var shareableScan: URL? + /// A camera permission or session failure the homeowner can act on. + var failure: ScanFailure? + + init() {} +} + +enum ScanFailure: Equatable, Sendable { + case cameraDenied + case arUnsupported + case sessionFailed(String) + case replayUnreadable(String) +} + +/// The homeowner's intents. View points are in the coordinate space of the full-screen camera +/// view of `viewSize`; nil means the reticle at the view's center. +@MainActor +protocol ScanActions: AnyObject { + func finishOnboarding() + func markMeter(at point: CGPoint?, viewSize: CGSize) + func skipCloseUp() + /// The homeowner's pick from `MeterNumberState.choose`; nil means "None of these", which + /// asks for a retake. + func chooseMeterNumber(_ candidate: MeterNumberCandidate?) + /// "Not " beside the number candidates: drops `ScanViewState.meterBrand`. + func rejectMeterBrand() + /// Marks a wall end where `point` meets the wall, on whichever side of the meter that is. + func markWallEnd(at point: CGPoint?, viewSize: CGSize) + /// "Wall ends here" during the walk: ends the wall on the side being walked where + /// `ScanViewState.endPreview` shows it (the phone's place), then asks what is there + /// (`ScanViewState.endQuestion`). Does nothing while `endPreview` is nil or at the reticle. + func endWallHere() + /// The answer to `ScanViewState.endQuestion`. During the walk a corner asks for the next wall + /// (`GuidanceStep.markNextWall`); a corner the walk doesn't follow exports as an unexplored + /// end, a blocked wall as a limit, and an end left unanswered stays unexplored. + func answerWallEnd(turnsCorner: Bool) + /// Marks the wall under `point` as the next wall round the corner, during + /// `GuidanceStep.markNextWall`. The walk then goes on along it; "I can't get there" + /// (`cannotAccessArea`) leaves the end as an unexplored corner instead. + func markNextWall(at point: CGPoint?, viewSize: CGSize) + /// "Back" during `GuidanceStep.markNextWall`: stops looking for the next wall and asks + /// `ScanViewState.endQuestion` about that end again (#70). + func cancelNextWall() + /// The answer to `ScanViewState.nextWallConfirm`: true follows the corner to the marked wall, + /// false drops it and goes on looking for the next wall (#70). + func confirmNextWall(_ isNextWall: Bool) + /// The answer to `ScanViewState.overheadQuestion`: true when nothing is overhead. + func answerOverhead(clear: Bool) + /// The answer to the ground question during `.markFeatures`; can be changed until upload. + func answerGround(_ answer: GroundAnswer) + func beginMarking(_ kind: FeatureKind) + func markFeaturePoint(at point: CGPoint?, viewSize: CGSize) + func cancelMarking() + func deleteFeature(_ id: UUID) + /// The window question on the review; nil is "Not sure". + func setWindowOpens(_ id: UUID, opens: Bool?) + /// Leave the walk for the feature review (allowed once both ends are marked). Ends closer + /// together than `WallFrame.minWallLength` are cleared instead, and `wallTooShort` is set. + func finishWalk() + /// The answer to `ScanViewState.endScanQuestion`. "Yes, end here" (`end`) ends every side + /// without an end where "Can't get there" would (`ScanViewState.endPreview`, unexplored) and + /// finishes the walk with what was walked, as `finishWalk` does. "Keep walking" dismisses the + /// question, and the next "Can't get there" ends its side without asking. + func answerEndScan(_ end: Bool) + /// Features confirmed; the engine runs the gap check, then uploads. + func confirmFeatures() + /// "I can't get there": the gap is recorded for installer review. On a request the finished + /// check sent back, the check's next request follows; the result shows once none is left. + func skipGap() + /// "Show my result" on a request the finished check sent back: the view is recorded for + /// installer review like a skipped one, and after one more upload the result shows instead + /// of the check's next request. + func showResultNow() + /// "I can't get to this part of the wall", during the walk: the cells the guidance is asking + /// for become `.skipped` and the guidance moves on to the next task. While the walk asks to + /// walk a side, that side's end goes where `ScanViewState.endPreview` shows, as an unexplored + /// end. + func cannotAccessArea() + func retryUpload() + /// After a rejected upload: back to the feature review, keeping the scan. + func backToReview() + /// The answer to `ScanViewState.spotCheck`: true when nothing stands in the area. + func answerSpotCheck(clear: Bool) + /// Start a capture for a server-listed missing item. + func captureMissing(_ id: String) + func showAR() + func closeAR() + func startOver() + /// The app came back to the foreground on the camera-access failure: if access is now on, + /// the scan goes on without Start over. Does nothing otherwise. + func recheckCameraAccess() + /// The live AR camera view. Only called while `feed == .live`. + func liveCameraView() -> AnyView +} diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index d791cf645..a136ba964 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -4,7 +4,15 @@ import SwiftUI struct HouseScanApp: App { var body: some Scene { WindowGroup { - CaptureScreen() + Group { + if ProcessInfo.processInfo.arguments.contains("-uiDemo") { + UIDemo.makeRoot() + } else { + AppRuntime.makeRoot() + } + } + // Compiles the live fog's shaders in the background long before the walk needs them. + .task { LiveFogSupport.shared.prepare() } } } } diff --git a/ios/HouseScan/Runtime/AppRuntime.swift b/ios/HouseScan/Runtime/AppRuntime.swift new file mode 100644 index 000000000..f0a1c7826 --- /dev/null +++ b/ios/HouseScan/Runtime/AppRuntime.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// Builds the engine from the launch arguments and returns `ScanRootView` bound to it. +@MainActor +enum AppRuntime { + /// One engine per process: the scene body can be re-evaluated, the scan must not restart. + static let engine = ScanEngine(options: LaunchOptions()) + + static func makeRoot() -> some View { + RuntimeRoot(engine: engine) + } +} + +private struct RuntimeRoot: View { + let engine: ScanEngine + @State private var started = false + + var body: some View { + ScanRootView(state: engine.state, actions: engine) + .task { + guard !started else { return } + started = true + engine.start() + if engine.options.autopilot { + await Autopilot(engine: engine).run() + } + } + } +} diff --git a/ios/HouseScan/Runtime/Autopilot.swift b/ios/HouseScan/Runtime/Autopilot.swift new file mode 100644 index 000000000..edde51070 --- /dev/null +++ b/ios/HouseScan/Runtime/Autopilot.swift @@ -0,0 +1,552 @@ +import CoreGraphics +import Foundation +import HouseScanKit +import OSLog +import simd + +/// Drives the intents through the whole flow on a replay, for UI tests and demos (`-autopilot`). +/// +/// It calls the same `ScanActions` a homeowner's taps call. Taps are simulated at view points +/// computed by projecting chosen wall points through the shown replay frame, so the engine +/// computes the geometry exactly as it does for a finger. Each state is held for about a second +/// so screenshots show it. +@MainActor +final class Autopilot { + private let engine: ScanEngine + /// A portrait iPhone screen in points. Any size works: view points and the engine's mapping + /// back to pixels use the same size. + private let viewSize = CGSize(width: 393, height: 852) + private var hold: Double { engine.options.autopilotHold } + /// Set once a spot check was answered "Something's there" (`-autopilotSomethingThere`): the + /// checks after it are answered "It's clear", so a server that keeps moving the spot can't + /// keep the run going. + private var answeredSomethingThere = false + + init(engine: ScanEngine) { + self.engine = engine + } + + func run() async { + guard engine.options.replayFolder != nil else { + log("needs -replay; the live camera can't be driven") + return + } + guard await waitUntil(timeout: 60, { self.engine.replay != nil || self.engine.state.failure != nil }), let replay = engine.replay else { + return fail("replay did not load") + } + async let prepared: Void = replay.prepareHeldBack(endsWherePhoneStood: engine.options.autopilotCantGetThere) + await pause(hold) + await engine.waitForGate(.onboarding) + engine.finishOnboarding() + await prepared + if let window = replay.heldBack { + log("holding back frames \(window.frames.lowerBound)..<\(window.frames.upperBound) for the gap loop; gap \(window.gap.band.rawValue) \(format(window.gap.span))") + } else { + log("no frames can be held back to make a closable gap on this replay; the gap step will be skipped") + } + await pause(hold) + + await engine.waitForGate(.findMeter) + engine.markMeter(at: nil, viewSize: viewSize) + guard await waitFor(.meterCloseUp, timeout: 10) else { return fail("meter was not marked") } + await takeCloseUp() + guard await waitFor(.wallWalk, timeout: 150) else { return fail("walk did not start") } + if engine.options.autopilotCantGetThere { await skipGroundByMeterBeforeItIsSeen(replay) } + + await pause(0.5) + guard await waitUntil(timeout: 120, { !replay.isPlaying }) else { return fail("walk replay did not finish") } + await pause(1.0) + + await markFeatures(replay) + if engine.options.autopilotCantGetThere { + await endWalkByCantGetThere(replay) + } else { + await markEnds(replay) + } + await tiltUp() + await pause(1.0) + await engine.waitForGate(.wallWalk) + engine.finishWalk() + guard await waitFor(.markFeatures, timeout: 5) else { return fail("could not finish the walk (ends marked: \(engine.bothEndsMarked))") } + await pause(hold) + + await engine.waitForGate(.markFeatures) + // Mulch, so the replay's scene carries a ground patch over the ground its walk saw. + // Answered after the UI test has finished with the screen, so its audit reads a still tree. + engine.answerGround(.type(.mulch)) + engine.confirmFeatures() + await pause(0.3) + if engine.state.phase == .gapRequest { + if engine.state.gap?.reason == .overhead { + await answerOverheadGap() + } else if replay.heldBack == nil { + await pause(hold) + log("skipping the gap: no held-back frames to show it") + await engine.waitForGate(.gapRequest) + engine.skipGap() + } else { + // The held-back frames were chosen on geometry alone; on a LiDAR replay depth can + // find part of the gap hidden in them, and then they don't settle it. + await playGapFrames(replay) + } + } + guard await waitFor(.uploading, timeout: 150) else { return fail("upload did not start") } + guard await driveToResult(replay) else { return } + writeSceneForTest() + await pause(hold) + await engine.waitForGate(.result) + engine.showAR() + guard await waitFor(.resultAR, timeout: 5) else { return fail("AR result did not open") } + await pause(hold) + await engine.waitForGate(.resultAR) + engine.closeAR() + _ = await waitFor(.result, timeout: 5) + log("done") + } + + // MARK: Steps + + /// What the close-up came to, as far as the autopilot acts on it. + private enum CloseUpOutcome { + case choose(MeterNumberCandidate) + /// The photo has to be retaken because no number could be read from it. + case retake(CloseUpProblem) + /// The flow left the close-up by itself. + case left + } + + private var closeUpOutcome: CloseUpOutcome? { + let state = engine.state + guard state.phase == .meterCloseUp else { return .left } + if case .choose(let candidates) = state.meterNumber, let first = candidates.first { return .choose(first) } + if case .aiming(_, let problem?) = state.closeUp, problem == .noNumber || problem == .numberTooSmall { return .retake(problem) } + return nil + } + + /// Picks the first meter-number candidate after the hold, as a homeowner confirming it would. + /// When the number can't be read (the synthetic fixture has none) or the close-up never + /// fires, skips the close-up instead of retaking forever. + private func takeCloseUp() async { + // 4 s for the shutter on a 3x replay, plus time for the reader. + _ = await waitUntil(timeout: 8) { self.closeUpOutcome != nil } + switch closeUpOutcome { + case .choose(let first): + await pause(hold) + engine.chooseMeterNumber(first) + log("chose meter-number candidate \(first.id)") + case .retake(let problem): + await pause(hold) + log("close-up needs a retake (\(String(describing: problem))); skipping it as a homeowner would") + await engine.waitForGate(.meterCloseUp) + engine.skipCloseUp() + case .left: + return + case nil: + log("close-up did not fire on this replay; skipping it as a homeowner would") + await engine.waitForGate(.meterCloseUp) + engine.skipCloseUp() + } + } + + /// A gas meter (one tap) and a window (two diagonal corners) on wall seen at least as high as + /// the highest tap, 1.85 m. The synthetic fixture paints them at s = -1.2 m and s = 2.0...3.0 + /// m; on other replays the nearest such wall stands in for them. + private func markFeatures(_ replay: ReplayPlayer) async { + guard let map = engine.coverage else { return } + let covered = map.wallSeenSpans().filter { $0.out >= 1.85 }.map(\.span) + guard !covered.isEmpty else { + log("no covered wall to mark features on") + return + } + let gasS = nearestCovered(-1.2, in: covered) + engine.beginMarking(.gasMeter) + await tap(map.wall.world(s: gasS, height: 0.55), replay: replay) { point in + self.engine.markFeaturePoint(at: point, viewSize: self.viewSize) + } + await pause(0.8) + + let windowS = nearestCovered(2.5, in: covered) + engine.beginMarking(.window) + // Near the corners of the fixture's window (0.9 to 2.0 m high), kept inside the band a + // walking camera sees so a frame shows each tap. + for corner in [map.wall.world(s: windowS - 0.45, height: 1.85), map.wall.world(s: windowS + 0.45, height: 0.95)] { + let tapped = await tap(corner, replay: replay) { point in + self.engine.markFeaturePoint(at: point, viewSize: self.viewSize) + } + if !tapped { log("no replay frame shows a window corner") } + await pause(0.4) + } + if engine.state.marking != nil { + log("window marking incomplete (\(String(describing: self.engine.state.marking?.refusal))); cancelling it") + engine.cancelMarking() + } + if let window = engine.state.features.first(where: { $0.kind == .window }) { + engine.setWindowOpens(window.id, opens: true) + } + log("marked \(engine.state.features.count) features") + } + + /// Ends at the covered extremes (the held-back window's plan, or the walk's coverage). + private func markEnds(_ replay: ReplayPlayer) async { + guard let map = engine.coverage else { return } + let intervals = map.wallSeenSpans().map(\.span) + map.coveredIntervals(.ground) + guard let low = intervals.map(\.lowerBound).min(), let high = intervals.map(\.upperBound).max() else { + log("nothing covered; marking ends 1 m either side of the meter") + engine.setEnd(.left, at: -1, kind: .limit) + engine.setEnd(.right, at: 1, kind: .limit) + return + } + for (side, s) in [(WallSide.left, min(low, -0.2)), (.right, max(high, 0.2))] { + let point = map.wall.world(s: s, height: 0.5) + let tapped = await tap(point, replay: replay) { viewPoint in + self.engine.markWallEnd(at: viewPoint, viewSize: self.viewSize) + } + let marked = side == .left ? engine.coverage?.leftEnd : engine.coverage?.rightEnd + if !tapped || marked == nil { + log("no replay frame shows the \(side.rawValue) end; setting it directly at s=\(s)") + engine.setEnd(side, at: s, kind: .limit) + } + if let asked = engine.state.endQuestion { + // Every end counts as a corner here: a replay has no fence or gate to report. + await pause(hold) + engine.answerWallEnd(turnsCorner: true) + log("answered the \(asked.rawValue) end: turns a corner") + if case .markNextWall = engine.state.guidance { + // A replay has no live wall round the corner to mark, so it doesn't follow + // the corner, as a homeowner who can't get round it wouldn't. + await pause(hold) + engine.cannotAccessArea() + log("can't get round the \(asked.rawValue) corner on a replay; the end stays unexplored") + } + } + await pause(0.6) + } + } + + // MARK: Can't get there (-autopilotCantGetThere) + + /// The walk's first request is the ground in front of the meter. This says "Can't get there" + /// to it as soon as it shows, before the replay has shown that ground from two places: on + /// device run 1 nothing had seen it when the ends were set, and the walk has to move on. + private func skipGroundByMeterBeforeItIsSeen(_ replay: ReplayPlayer) async { + _ = await waitUntil(timeout: 20) { self.isAskingForGroundByMeter || !replay.isPlaying } + guard isAskingForGroundByMeter, let map = engine.coverage else { + log("the walk did not ask for the ground by the meter while the replay played") + return + } + let window = map.indices(overlapping: -GuidancePlanner.aimHalfWidth...GuidancePlanner.aimHalfWidth) + let covered = window.filter { map.level(.ground, $0) == .covered }.count + engine.cannotAccessArea() + log("can't get there on the ground by the meter, \(covered) of its \(window.count) cells covered; now \(ScanEngine.name(self.engine.state.guidance))") + } + + private var isAskingForGroundByMeter: Bool { + if case .aimAtGround(let s) = engine.state.guidance { return abs(s) < 0.01 } + return false + } + + /// Ends each side with "Can't get there" where the walk went farthest that way, as a + /// homeowner who reached the end of what they can walk would, instead of marking the ends. + /// The phone stands there again (that frame plays once more), and any request about the wall + /// or ground in front of it gets "Can't get there" first, until the walk asks to walk on that + /// way: that is when the tap ends the wall. The engine logs where each end went. + private func endWalkByCantGetThere(_ replay: ReplayPlayer) async { + guard let wall = engine.coverage?.wall else { return } + let walkEnd = ScanEngine.tiltUpFrames(in: replay, map: engine.coverage ?? CoverageMap(wall: wall)).lowerBound + let played = (0.. Bool { + switch engine.state.guidance { + case .walk(let walking, _), .markEnd(let walking): walking == side + default: false + } + } + + private var asksAboutTheWallHere: Bool { + switch engine.state.guidance { + case .aimAtGround, .aimAtWall, .seeBehind: true + default: false + } + } + + /// With `-autopilotGate`, leaves the scene.json of the scan the result answers in the gate + /// folder, for the UI test to check its ends and what it reports as seen, and the scene.json + /// the last upload sent as `uploaded-scene.json`. + private func writeSceneForTest() { + guard let gate = engine.options.autopilotGate else { return } + do { + try engine.sceneJSON().write(to: gate.appending(path: "scene.json")) + try engine.spotConfirm.lastScene?.write(to: gate.appending(path: "uploaded-scene.json")) + log("wrote scene.json to the gate folder") + } catch { + log("could not write scene.json to the gate folder: \(error)") + } + } + + /// The tilt-up step: plays the recording's closing tilt-up frames and answers "Open sky or + /// nothing overhead", which is what the synthetic fixture shows above its wall. A recording + /// without tilt-up frames can't answer honestly, so it gets "Can't get there" instead. + private func tiltUp() async { + var played = false + let asked = await waitUntil(timeout: 5, { self.isTiltingUp }) + if !asked { + // The step waits for the phone to be back near the meter (#64), and the ends were + // tapped from frames far from it. The recording's closing tilt-up frames stand beside + // the meter: play them, as a homeowner walking back would, and the step comes up. + played = engine.playReplayTiltUp() + guard played, await waitUntil(timeout: 20, { self.isTiltingUp || self.engine.state.overheadQuestion }) else { + log("the walk did not ask to tilt up") + return + } + } + await pause(hold) + guard played || engine.playReplayTiltUp() else { + log("no tilt-up frames on this replay; skipping the step as a homeowner would") + engine.cannotAccessArea() + return + } + guard await waitUntil(timeout: 20, { self.engine.state.overheadQuestion }) else { + log("no tilt-up frame was kept; skipping the step") + if isTiltingUp { engine.cannotAccessArea() } + return + } + await pause(hold) + await answerOpenSky() + log("answered the overhead question: open sky; \(overheadSummary)") + // The rest of the tilt-up run (the synthetic replay's upper walk) plays out, so its + // views are kept before the walk ends. + guard let replay = engine.replay else { return } + _ = await waitUntil(timeout: 30) { !replay.isPlaying } + } + + /// An overhead gap request: the engine plays the recording's tilt-up frames for it, and the + /// question comes up only when one of them settles the request. The synthetic fixture shows + /// open sky above its wall, so the answer is "Open sky or nothing overhead". When no tilt-up + /// frame covers the requested span, "I can't get there", as a homeowner would. + private func answerOverheadGap() async { + guard await waitUntil(timeout: 20, { self.engine.state.overheadQuestion || self.engine.state.phase != .gapRequest }), + engine.state.overheadQuestion else { + log("no tilt-up frame covers the overhead request; skipping it") + await engine.waitForGate(.gapRequest) + engine.skipGap() + return + } + await pause(hold) + await answerOpenSky() + log("answered the overhead request: open sky; \(overheadSummary)") + } + + /// After an upload the engine raises the answer's capturable requests one at a time, uploading + /// after each, before it shows the result, and checks the answer's spot with the homeowner. + /// Drives each request and check, retries a failed upload once, and returns true once the + /// result is up. Bounded by the engine's own bound on requests, and by one refused check. + private func driveToResult(_ replay: ReplayPlayer) async -> Bool { + var retried = false + for _ in 0..<(ScanEngine.maxAutomaticGaps + 4) { + let settled = await waitUntil(timeout: 240) { + [.result, .gapRequest, .spotConfirm].contains(self.engine.state.phase) || self.uploadFailed + } + guard settled else { + fail("no result") + return false + } + switch engine.state.phase { + case .result: + return true + case .gapRequest: + await driveServerRequest(replay) + case .spotConfirm: + await answerSpotCheck() + default: + guard !retried else { + fail("no result after retry") + return false + } + retried = true + log("upload failed; retrying once") + engine.retryUpload() + } + } + fail("still no result after \(ScanEngine.maxAutomaticGaps) requests") + return false + } + + /// "It's clear", or with `-autopilotSomethingThere` "Something's there" the first time. After + /// a refusal the stretch it withdrew goes to the gate folder as `spot-refusal.json` (the + /// stretch's s in feet, as scene.json's spans), for the UI test to check the scene against. + private func answerSpotCheck() async { + guard let check = engine.state.spotCheck, check.answer == nil else { return } + await pause(hold) + await engine.waitForGate(.spotConfirm) + let refuse = engine.options.autopilotSomethingThere && !answeredSomethingThere + engine.answerSpotCheck(clear: !refuse) + log("spot check \(check.id) over \(format(check.area)): \(refuse ? "something's there" : "it's clear")") + if refuse { + answeredSomethingThere = true + writeRefusalForTest() + } + _ = await waitUntil(timeout: 20) { self.engine.state.phase != .spotConfirm } + } + + /// The whole cells the refusal withdrew, in feet: what the scene must no longer claim. + private func writeRefusalForTest() { + guard let gate = engine.options.autopilotGate, let map = engine.coverage, + let low = map.withdrawnCells.min(), let high = map.withdrawnCells.max() else { return } + let feet = { (meters: Float) in Double(meters) * SceneUnits.feetPerMeter } + let span = [feet(map.cellRange(low).lowerBound), feet(map.cellRange(high).upperBound)] + do { + try JSONSerialization.data(withJSONObject: ["withdrawn_span_ft": span]).write(to: gate.appending(path: "spot-refusal.json")) + } catch { + log("could not write spot-refusal.json to the gate folder: \(error)") + } + } + + private var uploadFailed: Bool { + if case .failed = engine.state.upload { return engine.state.phase == .uploading } + return false + } + + /// A request the engine raised from the server's answer. The engine plays the replay's frames + /// for it; a replay shows only what it recorded, so when they don't settle the request the + /// autopilot says "I can't get there", as a homeowner would. An overhead request is answered + /// like the one before the upload. + private func driveServerRequest(_ replay: ReplayPlayer) async { + guard let request = engine.state.gap else { return } + log("server request \(request.id) over \(format(request.span))") + if request.reason == .overhead { + await answerOverheadGap() + } else { + await playGapFrames(replay) + } + // A settled request stays on screen for a moment before the next upload. + _ = await waitUntil(timeout: 10) { self.engine.state.phase != .gapRequest || self.engine.state.gap?.id != request.id } + } + + /// Waits while the engine plays the replay's frames for the current request. A replay shows + /// only what it recorded, so when they don't settle the request the autopilot says "I can't + /// get there", as a homeowner would. + private func playGapFrames(_ replay: ReplayPlayer) async { + guard let request = engine.state.gap else { return } + _ = await waitUntil(timeout: 60) { !replay.isPlaying || self.engine.state.gap?.id != request.id } + // Time for the last frame's keyframe to be stored and counted. + await pause(hold) + if engine.state.phase == .gapRequest, let gap = engine.state.gap, gap.id == request.id, !gap.isSatisfied { + log("the replay does not settle request \(request.id) (\(Int(gap.progress * 100))% of it seen); skipping it") + await engine.waitForGate(.gapRequest) + engine.skipGap() + } + } + + /// "Open sky or nothing overhead", then a wait for the view to be stored: it counts as an + /// overhead view only once its photo is on disk. + private func answerOpenSky() async { + let before = engine.coverage?.overheadCameras.count ?? 0 + engine.answerOverhead(clear: true) + _ = await waitUntil(timeout: 5) { (self.engine.coverage?.overheadCameras.count ?? 0) > before } + } + + /// The overhead views kept and the stretches they show clear, as the export will send them. + private var overheadSummary: String { + guard let map = engine.coverage else { return "no wall" } + let spans = map.overheadSpans().map { "\(format($0.span)) to \(String(format: "%.2f", $0.out)) m" } + return "\(map.overheadCameras.count) overhead view(s) kept, clear over \(spans.isEmpty ? "nothing" : spans.joined(separator: ", "))" + } + + private var isTiltingUp: Bool { + if case .tiltUp = engine.state.guidance { return true } + return false + } + + // MARK: Helpers + + /// Shows the replay frame that best shows `point`, then taps where it appears on screen. + @discardableResult + private func tap(_ point: SIMD3, replay: ReplayPlayer, action: (CGPoint) -> Void) async -> Bool { + guard let (index, viewPoint) = frameShowing(point, replay: replay) else { return false } + replay.show(index: index) + await replay.waitUntilShown(index) + await pause(0.2) + action(viewPoint) + return true + } + + private func frameShowing(_ point: SIMD3, replay: ReplayPlayer) -> (Int, CGPoint)? { + let inset: CGFloat = 40 + var best: (Int, CGPoint, CGFloat)? + for index in replay.frames.indices { + let camera = replay.camera(at: index) + let projection = CameraProjection(cameraToWorld: camera.cameraToWorld, intrinsics: camera.intrinsics, imageSize: camera.imageSize) + guard let view = projection.viewPoint(for: point, in: viewSize), + view.x > inset, view.y > inset, view.x < viewSize.width - inset, view.y < viewSize.height - inset else { continue } + let offCenter = hypot(view.x - viewSize.width / 2, view.y - viewSize.height / 2) + if offCenter < best?.2 ?? .infinity { best = (index, view, offCenter) } + } + return best.map { ($0.0, $0.1) } + } + + private func nearestCovered(_ s: Float, in intervals: [ClosedRange]) -> Float { + if intervals.contains(where: { $0.contains(s) }) { return s } + let candidates = intervals.map { interval -> Float in + let inset = min(0.5, (interval.upperBound - interval.lowerBound) / 2) + return min(max(s, interval.lowerBound + inset), interval.upperBound - inset) + } + return candidates.min { abs($0 - s) < abs($1 - s) } ?? s + } + + private func waitFor(_ phase: ScanPhase, timeout: Double) async -> Bool { + await waitUntil(timeout: timeout) { self.engine.state.phase == phase } + } + + private func waitUntil(timeout: Double, _ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + .seconds(timeout) + while ContinuousClock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(50)) + } + return condition() + } + + private func pause(_ seconds: Double) async { + try? await Task.sleep(for: .seconds(seconds)) + } + + private func log(_ message: String) { + RuntimeLog.autopilot.info("AUTOPILOT \(message, privacy: .public)") + } + + private func fail(_ message: String) { + RuntimeLog.autopilot.error("AUTOPILOT stopped: \(message, privacy: .public) (phase \(self.engine.state.phase.rawValue, privacy: .public))") + } + + private func format(_ range: ClosedRange) -> String { + String(format: "%.2f...%.2f m", range.lowerBound, range.upperBound) + } +} diff --git a/ios/HouseScan/Runtime/CaptureRecorder.swift b/ios/HouseScan/Runtime/CaptureRecorder.swift new file mode 100644 index 000000000..c9f387e79 --- /dev/null +++ b/ios/HouseScan/Runtime/CaptureRecorder.swift @@ -0,0 +1,436 @@ +import CoreMotion +import Foundation +import HouseScanKit +import OSLog +import simd +import Synchronization + +/// The packet's sensor streams, written to disk as they arrive: the camera trajectory at every +/// ARFrame (from the AR delegate queue), Core Motion's samples (from `MotionSource`'s queue), and +/// on LiDAR phones depth frames, one file pair each, a few a second (`DepthFrameBudget`). +/// Nothing here runs on the main actor or holds an ARFrame; each sample is copied into a fixed +/// row of Doubles and appended to one raw file per stream, in 64 KB writes. +/// +/// Poses stay in ARKit's world frame on disk: the meter anchor, which defines the packet's frame, +/// is set after capture starts, and can move until the packet is written. Packaging reads the +/// rows back (`rows(_:)`) and converts them then, so samples from before the meter was marked are +/// kept too. +/// +/// One recorder is one packet session, in one world frame. `restart` starts a new session in the +/// same folder when the world frame is thrown away (a failed relocalization). +final class CaptureRecorder: Sendable { + /// The raw streams. Each row starts with `t`, seconds of device uptime. + enum Stream: Int, CaseIterable, Sendable { + /// t, tracking state code, tracking reason code, then camera-to-world as 16 floats, + /// column by column (`TrackingCode`). + case trajectory + /// t, x, y, z in g. + case accelerometer + /// t, x, y, z in rad/s. + case gyroscope + /// t, x, y, z in µT, uncalibrated. + case magnetometer + /// t, quaternion x y z w, gravity x y z (g), user acceleration x y z (g), rotation rate + /// x y z (rad/s), heading in degrees (negative: none). + case deviceMotion + /// t, pressure in kPa, relative altitude in m. + case barometer + + var width: Int { + switch self { + case .trajectory: 19 + case .accelerometer, .gyroscope, .magnetometer: 4 + case .deviceMotion: 15 + case .barometer: 3 + } + } + + var fileName: String { "\(self).f64" } + } + + /// What a session looked like when packaging flushed it. + struct Snapshot: Sendable { + var sessionID: String + /// Wall clock at the first trajectory row, from that row's uptime. + var startedAt: Date? + var firstUptime: Double? + var lastUptime: Double? + } + + /// A LiDAR depth map recorded between photos (`recordDepthFrame`). Its map and confidence are + /// on disk; this is what the packet needs to find and place them. + struct DepthFrame: Sendable { + var t: Double + var tracking: TrackingCode + var cameraToWorld: simd_float4x4 + /// [fx, fy, cx, cy] in pixels of the depth map. + var intrinsics: SIMD4 + var width: Int + var height: Int + /// Float32 meters and UInt8 confidence, relative to `directory`. + var map: String + var confidence: String + } + + private struct State { + var sessionID = UUID().uuidString + var recording = true + /// Depth frames record only while the camera is meant to be on the wall; the engine turns + /// them on for the close-up, the walk and a gap request (`setRecording`). + var recordingDepth = false + /// Rows older than this are from a world frame `restart` discarded; ARKit can deliver a + /// few after the reset. + var since: Double = 0 + var startedAt: Date? + var firstUptime: Double? + var lastUptime: Double? + var buffers: [Data] = Array(repeating: Data(), count: Stream.allCases.count) + var lastT: [Double] = Array(repeating: -.infinity, count: Stream.allCases.count) + var failed = false + var depthBudget = DepthFrameBudget() + var depthFrames: [DepthFrame] = [] + /// A failed depth-frame write stops depth frames only; the streams go on. + var depthFailed = false + } + + let directory: URL + private let state = Mutex(State()) + /// 64 KB: about 450 trajectory rows (7 s at 60 Hz) or 2000 accelerometer rows per write. + private static let flushBytes = 1 << 16 + private static let depthFolder = "depth_frames" + + init(directory: URL) { + self.directory = directory + Self.truncate(directory) + } + + /// A new session in a new world frame: the files start empty, and rows timed before now are + /// dropped. + func restart() { + state.withLock { state in + state = State() + state.since = ProcessInfo.processInfo.systemUptime + Self.truncate(directory) + } + } + + /// Off while no capture is under way (the result screens, a failed upload): rows and depth + /// frames are dropped. `depthFrames` narrows depth frames further, to the phases that aim the + /// camera at the wall. + func setRecording(_ on: Bool, depthFrames: Bool) { + state.withLock { state in + state.recording = on + state.recordingDepth = on && depthFrames + } + } + + func recordPose(t: Double, tracking: TrackingCode, cameraToWorld m: simd_float4x4) { + let columns = [m.columns.0, m.columns.1, m.columns.2, m.columns.3] + let pose = columns.flatMap { [Double($0.x), Double($0.y), Double($0.z), Double($0.w)] } + append(.trajectory, [t, Double(tracking.state), Double(tracking.reason)] + pose) + } + + /// Appends one row. A row not later than the stream's last one is dropped: every stream's `t` + /// strictly increases. + func append(_ stream: Stream, _ row: [Double]) { + precondition(row.count == stream.width, "\(stream) row has \(row.count) values, expected \(stream.width)") + let t = row[0] + state.withLock { state in + guard state.recording, !state.failed, t >= state.since, t > state.lastT[stream.rawValue] else { return } + state.lastT[stream.rawValue] = t + if stream == .trajectory { + if state.firstUptime == nil { + state.firstUptime = t + // The same instant on the wall clock: the frame is `now - t` seconds old. + state.startedAt = Date(timeIntervalSinceNow: t - ProcessInfo.processInfo.systemUptime) + } + state.lastUptime = t + } + row.withUnsafeBufferPointer { values in + for value in values { withUnsafeBytes(of: value.bitPattern.littleEndian) { state.buffers[stream.rawValue].append(contentsOf: $0) } } + } + if state.buffers[stream.rawValue].count >= Self.flushBytes { + write(stream, &state) + } + } + } + + /// Whether a depth frame at `t` would be recorded now: the AR delegate asks before copying the + /// depth map, so a frame the budget would drop costs nothing. + func wantsDepthFrame(at t: Double) -> Bool { + state.withLock { state in + state.recordingDepth && !state.depthFailed && t >= state.since && state.depthBudget.wants(t: t) + } + } + + /// Writes one LiDAR depth map with its confidence to `depth_frames/` as it arrives, so the + /// maps are never held in memory, and remembers where. At most `DepthFrameBudget.rateHz` a + /// second and `DepthFrameBudget.maxFrames` in a session; frames past either are dropped. + /// Called from the AR delegate queue: two 50 to 200 KB writes, twice a second at most. + func recordDepthFrame(t: Double, tracking: TrackingCode, cameraToWorld: simd_float4x4, intrinsics: SIMD4, depth: DepthPacket) { + guard let confidence = depth.confidence, depth.meters.count == depth.width * depth.height, confidence.count == depth.meters.count else { return } + state.withLock { state in + guard state.recordingDepth, !state.depthFailed, t >= state.since, state.depthBudget.admit(t: t) else { return } + let stem = "\(Self.depthFolder)/\(PacketDepthFrame.id(number: state.depthBudget.admitted))" + let frame = DepthFrame( + t: t, tracking: tracking, cameraToWorld: cameraToWorld, intrinsics: intrinsics, width: depth.width, height: depth.height, + map: "\(stem).f32", confidence: "\(stem).conf.u8" + ) + do { + try PacketFiles.depthData(meters: depth.meters).write(to: directory.appending(path: frame.map)) + try Data(confidence).write(to: directory.appending(path: frame.confidence)) + state.depthFrames.append(frame) + } catch { + state.depthFailed = true + RuntimeLog.capture.error("depth frames not recorded from here on: \(String(describing: error), privacy: .public)") + return + } + if state.depthBudget.isFull { + let limit = state.depthBudget.limit + RuntimeLog.capture.info("depth frames: the limit of \(limit) was reached at t=\(t); no more are recorded") + } + } + } + + /// Every depth frame recorded in this session, in time order. + func depthFrames() -> [DepthFrame] { + state.withLock { $0.depthFrames } + } + + /// A recorded depth frame's map and confidence, read back for the packet: ARKit's scene depth. + /// Nil when the files are gone or the wrong size. Call off the main actor. + func loadDepth(_ frame: DepthFrame) -> DepthPacket? { + let count = frame.width * frame.height + guard let map = try? Data(contentsOf: directory.appending(path: frame.map)), map.count == count * 4, + let confidence = try? Data(contentsOf: directory.appending(path: frame.confidence)), confidence.count == count + else { return nil } + return DepthPacket( + meters: PacketFiles.floats(littleEndian: map), width: frame.width, height: frame.height, confidence: [UInt8](confidence), + source: .arkitSceneDepth + ) + } + + /// Writes every buffered row and returns the session as it stands. + func flush() -> Snapshot { + state.withLock { state in + for stream in Stream.allCases { write(stream, &state) } + return Snapshot(sessionID: state.sessionID, startedAt: state.startedAt, firstUptime: state.firstUptime, lastUptime: state.lastUptime) + } + } + + /// Every row written for `stream`, after `flush`. Call off the main actor. + func rows(_ stream: Stream) -> [[Double]] { + guard let data = try? Data(contentsOf: directory.appending(path: stream.fileName)) else { return [] } + let values = data.withUnsafeBytes { raw in + (0..<(raw.count / 8)).map { Double(bitPattern: UInt64(littleEndian: raw.loadUnaligned(fromByteOffset: $0 * 8, as: UInt64.self))) } + } + return stride(from: 0, to: values.count - stream.width + 1, by: stream.width).map { Array(values[$0..<($0 + stream.width)]) } + } + + /// A failed write stops the recorder rather than leave a stream with a hole in it; packaging + /// then writes what reached the disk before the failure. + private func write(_ stream: Stream, _ state: inout State) { + let buffer = state.buffers[stream.rawValue] + guard !buffer.isEmpty else { return } + state.buffers[stream.rawValue] = Data() + let url = directory.appending(path: stream.fileName) + do { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: buffer) + } catch { + state.failed = true + RuntimeLog.capture.error("stream \(String(describing: stream), privacy: .public) not recorded from here on: \(String(describing: error), privacy: .public)") + } + } + + private static func truncate(_ directory: URL) { + let files = FileManager.default + try? files.createDirectory(at: directory, withIntermediateDirectories: true) + for stream in Stream.allCases { + files.createFile(atPath: directory.appending(path: stream.fileName).path, contents: Data()) + } + let depth = directory.appending(path: depthFolder, directoryHint: .isDirectory) + try? files.removeItem(at: depth) + try? files.createDirectory(at: depth, withIntermediateDirectories: true) + } +} + +/// Tracking state and limited reason as numbers for a raw trajectory row. +struct TrackingCode: Sendable, Equatable { + /// 0 normal, 1 limited, 2 not available. + var state: Int + /// 0 none or unknown, 1 initializing, 2 relocalizing, 3 excessive motion, 4 insufficient + /// features. + var reason: Int + + init(_ tracking: TrackingQuality) { + switch tracking { + case .normal: (state, reason) = (0, 0) + case .notAvailable: (state, reason) = (2, 0) + case .limited(let why): + state = 1 + reason = switch why { + case .initializing: 1 + case .relocalizing: 2 + case .excessiveMotion: 3 + case .insufficientFeatures: 4 + case .unknown: 0 + } + } + } + + init(state: Int, reason: Int) { + self.state = state + self.reason = reason + } + + var packetTracking: PacketTracking { + let why: PacketTracking.Reason? = switch reason { + case 1: .initializing + case 2: .relocalizing + case 3: .excessiveMotion + case 4: .insufficientFeatures + default: nil + } + return switch state { + case 0: .normal + case 1: .limited(why) + default: .notAvailable + } + } +} + +/// Core Motion for the packet: accelerometer, gyroscope, magnetometer and device motion at +/// `rate`, and the barometer at whatever rate CMAltimeter delivers. Samples go straight to the +/// recorder from a private queue. Location and heading are not recorded (no consent prompt this +/// round), so device motion runs in an arbitrary-yaw frame and its heading is Core Motion's +/// negative "none". +@MainActor +final class MotionSource { + /// 100 Hz, the packet's nominal rate for the four Core Motion streams. A choice, not + /// measured: ADVIO's iPhone IMU runs at 100 Hz, and it is well under Core Motion's limit. + nonisolated static let rate: Double = 100 + + private let manager = CMMotionManager() + private let altimeter = CMAltimeter() + private let queue: OperationQueue = { + let queue = OperationQueue() + queue.name = "dev.housescanning.housescan.motion" + queue.maxConcurrentOperationCount = 1 + queue.qualityOfService = .utility + return queue + }() + private(set) var isRunning = false + private let activity = CMMotionActivityManager() + + /// Whether Motion & Fitness is still to be asked for. Only the barometer needs it; declined, + /// the barometer records no rows and the other streams run as before. + static var needsPermission: Bool { + CMAltimeter.isRelativeAltitudeAvailable() && CMAltimeter.authorizationStatus() == .notDetermined + && CMMotionActivityManager.isActivityAvailable() + } + + enum Permission { case allowed, denied, unanswered, notNeeded } + + /// Shows the Motion & Fitness prompt and reports the answer. CMAltimeter has no call that only + /// asks; an activity query asks for the same permission and calls back once it is answered. + /// The handler runs on the main queue, so it may be a main-actor closure. Every answer lets + /// the scan go on; a denial only leaves the barometer without rows. + @discardableResult + func requestPermission() async -> Permission { + guard Self.needsPermission else { return .notNeeded } + let now = Date() + let error = await withCheckedContinuation { (continuation: CheckedContinuation<(any Error)?, Never>) in + activity.queryActivityStarting(from: now.addingTimeInterval(-60), to: now, to: .main) { _, error in + continuation.resume(returning: error) + } + } + if let error = error as NSError?, error.domain == CMErrorDomain, + error.code == Int(CMErrorMotionActivityNotAuthorized.rawValue) { + return .denied + } + switch CMMotionActivityManager.authorizationStatus() { + case .authorized: return .allowed + case .denied, .restricted: return .denied + case .notDetermined: return .unanswered + @unknown default: return .unanswered + } + } + + /// Which streams this phone has; the packet lists only streams with rows. + var available: Set { + var streams: Set = [] + if manager.isAccelerometerAvailable { streams.insert(.accelerometer) } + if manager.isGyroAvailable { streams.insert(.gyroscope) } + if manager.isMagnetometerAvailable { streams.insert(.magnetometer) } + if manager.isDeviceMotionAvailable { streams.insert(.deviceMotion) } + if CMAltimeter.isRelativeAltitudeAvailable() { streams.insert(.barometer) } + return streams + } + + func start(into recorder: CaptureRecorder) { + guard !isRunning else { return } + isRunning = true + Self.startUpdates(manager, altimeter, queue: queue, recorder: recorder, interval: 1 / Self.rate) + RuntimeLog.capture.info("motion recording on: \(self.available.map { "\($0)" }.sorted().joined(separator: ", "), privacy: .public)") + } + + func stop() { + guard isRunning else { return } + isRunning = false + manager.stopAccelerometerUpdates() + manager.stopGyroUpdates() + manager.stopMagnetometerUpdates() + manager.stopDeviceMotionUpdates() + altimeter.stopRelativeAltitudeUpdates() + RuntimeLog.capture.info("motion recording off") + } + + /// Nonisolated so the handlers are not main-actor closures: Core Motion calls them on `queue`. + private nonisolated static func startUpdates(_ manager: CMMotionManager, _ altimeter: CMAltimeter, queue: OperationQueue, recorder: CaptureRecorder, interval: Double) { + // Raw, as Core Motion reports it: the packet writer converts g to m/s². + if manager.isAccelerometerAvailable { + manager.accelerometerUpdateInterval = interval + manager.startAccelerometerUpdates(to: queue) { data, _ in + guard let data else { return } + let a = data.acceleration + recorder.append(.accelerometer, [data.timestamp, a.x, a.y, a.z]) + } + } + if manager.isGyroAvailable { + manager.gyroUpdateInterval = interval + manager.startGyroUpdates(to: queue) { data, _ in + guard let data else { return } + let r = data.rotationRate + recorder.append(.gyroscope, [data.timestamp, r.x, r.y, r.z]) + } + } + if manager.isMagnetometerAvailable { + manager.magnetometerUpdateInterval = interval + manager.startMagnetometerUpdates(to: queue) { data, _ in + guard let data else { return } + let m = data.magneticField + recorder.append(.magnetometer, [data.timestamp, m.x, m.y, m.z]) + } + } + if manager.isDeviceMotionAvailable { + manager.deviceMotionUpdateInterval = interval + manager.startDeviceMotionUpdates(using: .xArbitraryCorrectedZVertical, to: queue) { motion, _ in + guard let motion else { return } + let q = motion.attitude.quaternion + let g = motion.gravity + let a = motion.userAcceleration + let r = motion.rotationRate + recorder.append(.deviceMotion, [motion.timestamp, q.x, q.y, q.z, q.w, g.x, g.y, g.z, a.x, a.y, a.z, r.x, r.y, r.z, motion.heading]) + } + } + if CMAltimeter.isRelativeAltitudeAvailable() { + altimeter.startRelativeAltitudeUpdates(to: queue) { data, _ in + guard let data else { return } + recorder.append(.barometer, [data.timestamp, data.pressure.doubleValue, data.relativeAltitude.doubleValue]) + } + } + } +} diff --git a/ios/HouseScan/Runtime/GuidanceLog.swift b/ios/HouseScan/Runtime/GuidanceLog.swift new file mode 100644 index 000000000..314cbee9d --- /dev/null +++ b/ios/HouseScan/Runtime/GuidanceLog.swift @@ -0,0 +1,88 @@ +import Foundation +import HouseScanKit + +/// Every request the homeowner was shown during capture, and how each one ended: the packet's +/// `guidance` log. The server reads it to learn what the homeowner could not reach, which +/// scene.json cannot say. +/// +/// A request is identified by its `Topic`. The engine reports what is on screen after each +/// guidance change (`show`); a new topic closes the open request with the outcome the engine +/// judges (`show`'s `closing`), unless an action already closed it with a known outcome +/// (`resolve`: "I can't get there", an answered question, a satisfied gap). +/// +/// Times are on the capture clock (`ScanEngine.captureClock`): ARFrame.timestamp live. Spans are +/// meters of s along the wall; packaging converts them to meter-frame x. +struct GuidanceLog { + typealias Kind = PacketGuidanceEntry.Kind + typealias Origin = PacketGuidanceEntry.Origin + typealias Band = PacketGuidanceEntry.Band + typealias Outcome = PacketGuidanceEntry.Outcome + + /// What a request is about. Same topic, same request: a walk's remaining distance or a gap's + /// progress changing on screen does not make a new one. + enum Topic: Equatable, Sendable { + case closeUp + case walk(WallSide) + /// Walk round the corner on this side and mark the next wall. + case nextWall(WallSide) + case markEnd(WallSide) + /// Aim at a band at the coverage cell with this index. + case aimAtGround(cell: Int) + case aimAtWall(cell: Int) + case stepBack + case tiltUp + case seeBehind(cell: Int) + /// `GapRequest.id`. + case gap(id: Int) + /// `SpotCheck.id`. + case spotCheck(id: Int) + } + + struct Request: Equatable, Sendable { + var topic: Topic + var kind: Kind + var origin: Origin + var message: String + var band: Band? + /// Meters of s. + var span: ClosedRange? + } + + struct Entry: Sendable { + var id: String + var request: Request + var shown: Double + var resolved: Double? + var outcome: Outcome + } + + private(set) var entries: [Entry] = [] + /// The request on screen, whether or not it is still open. + private(set) var current: Request? + private var openIndex: Int? + + /// Records what is on screen now (nil: no request, such as the review or upload screens). + /// A different request closes the open one with `closing(open, next)`. + mutating func show(_ request: Request?, at t: Double, closing: (Request, Request?) -> Outcome) { + guard request?.topic != current?.topic else { return } + if let open = openIndex { + close(open, closing(entries[open].request, request), at: t) + } + current = request + guard let request else { return } + entries.append(Entry(id: "g\(entries.count + 1)", request: request, shown: t, resolved: nil, outcome: .unresolved)) + openIndex = entries.count - 1 + } + + /// Closes the open request with a known outcome; it may stay on screen a moment longer. + mutating func resolve(_ outcome: Outcome, at t: Double) { + guard let open = openIndex else { return } + close(open, outcome, at: t) + } + + private mutating func close(_ index: Int, _ outcome: Outcome, at t: Double) { + entries[index].outcome = outcome + entries[index].resolved = max(t, entries[index].shown) + openIndex = nil + } +} diff --git a/ios/HouseScan/Runtime/KeyframeStore.swift b/ios/HouseScan/Runtime/KeyframeStore.swift new file mode 100644 index 000000000..b081627b1 --- /dev/null +++ b/ios/HouseScan/Runtime/KeyframeStore.swift @@ -0,0 +1,269 @@ +import CoreGraphics +import Foundation +import HouseScanKit +import OSLog + +/// A kept photo (a keyframe, or a still such as the meter close-up): its JPEG on disk and what +/// the packet records about the frame it came from. +struct StoredKeyframe: Sendable { + /// Also the JPEG's file name without extension, for example "k00001". + let id: String + let camera: CameraFrame + /// The frame's time: ARFrame.timestamp live, the recording's time on a replay. + let t: Double + let tracking: TrackingQuality + let exposure: PhotoExposure? + /// Pixels of the stored JPEG, read from the file. + let width: Int + let height: Int + /// `laplacian_variance_luma_640` of the stored JPEG; nil when its luma could not be read. + let sharpness: Double? + /// ARKit's depth for this photo, written beside it as float32 meters (`depth/.f32`) with + /// its confidence (`depth/.conf.u8`). Live LiDAR frames only. + let depth: StoredDepth? + var fileName: String { "\(id).jpg" } +} + +struct StoredDepth: Sendable { + let file: String + let confidenceFile: String? + let width: Int + let height: Int + /// Nil when the depth's origin is unknown (`DepthPacket.source`); live frames say ARKit's. + let source: DepthPacket.Source? +} + +/// Keyframe JPEGs and close-up stills of one scan, in Caches/Scans//. File work runs off the +/// main actor; the list of what was stored lives here on the main actor. +@MainActor +final class KeyframeStore { + let directory: URL + private(set) var keyframes: [StoredKeyframe] = [] + /// The stills saved, by purpose (scene.json `stills` key), and which the homeowner accepted. + private var stillCatalog = StillCatalog() + /// Purpose to file name, for accepted stills only: scene.json's `stills`, and the stills the + /// packet links to their marks. + var stills: [String: String] { stillCatalog.acceptedFileNames } + /// Purpose to the still's frame, accepted or not, for the packet's photos. A still the + /// homeowner turned down is still a photo of the scan, just not the one for its purpose. + var stillFrames: [String: StoredKeyframe] { stillCatalog.frames } + private var nextIndex = 1 + /// Bumped by `discardKeyframes`, so a write that started before it doesn't land in the list. + private var epoch = 0 + + /// Makes a new, empty scan folder and deletes every other one: only the current scan is kept + /// on the phone. Covers both a start over (the previous scan's folder) and launch (folders a + /// quit or crashed run left behind), since both make a new store. The folders to delete are + /// listed here, before a newer store can exist, and deleted later off the main actor + /// (`ScanFolderCleanup`), so a deletion that runs late can't take a newer scan's folder. + init() { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let scans = caches.appending(path: "Scans", directoryHint: .isDirectory) + directory = scans.appending(path: UUID().uuidString, directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + Self.delete(ScanFolderCleanup(root: scans, keeping: directory.lastPathComponent)) + } + + func nextKeyframeIndex() -> Int { + defer { nextIndex += 1 } + return nextIndex + } + + /// Writes a keyframe's JPEG unrotated, as the sensor produced it, with the pose, time and + /// camera settings of the frame those bytes came from, its sharpness, and its ARKit depth when + /// there is some. Returns whether it was stored, and an upright thumbnail for the capture + /// acknowledgment. + /// + /// Bytes that don't decode are refused before anything is written: a photo no one can open + /// is not a view, so it must not become a keyframe the coverage counts. The thumbnail is that + /// decode. (Before, an undecodable photo was stored with a gray placeholder thumbnail.) + func saveKeyframe(_ frame: SourceFrame, index: Int) async -> (stored: Bool, thumbnail: CGImage?) { + let id = String(format: "k%05d", index) + let directory = directory + let startedIn = epoch + let written = await Task.detached(priority: .utility) { () -> Result<(CGImage, StoredKeyframe), KeyframeWriteFailure> in + guard let data = Self.data(of: frame.jpeg) else { return .failure(.noPhoto) } + guard let thumbnail = ImageWork.uprightThumbnail(jpeg: data) else { return .failure(.undecodable) } + do { + try data.write(to: directory.appending(path: "\(id).jpg"), options: .atomic) + } catch { + return .failure(.writeFailed) + } + return .success((thumbnail, Self.stored(frame, id: id, jpeg: data, in: directory))) + }.value + let thumbnail: CGImage + let stored: StoredKeyframe + switch written { + case .success(let (image, entry)): + thumbnail = image + stored = entry + case .failure(let failure): + RuntimeLog.capture.error("keyframe \(id, privacy: .public) not stored: \(failure.rawValue, privacy: .public)") + return (false, nil) + } + // Taken in a world frame that was discarded while the file was being written. + guard startedIn == epoch else { + RuntimeLog.capture.info("keyframe \(id, privacy: .public) not stored: its world frame was discarded") + return (false, nil) + } + keyframes.append(stored) + keyframes.sort { $0.id < $1.id } + return (true, thumbnail) + } + + /// What the packet records about a photo written as `id`: the frame's pose, time, tracking and + /// exposure, the JPEG's own pixel size and sharpness, and its depth files, written here. + nonisolated private static func stored(_ frame: SourceFrame, id: String, jpeg: Data, in directory: URL) -> StoredKeyframe { + let luma = ImageWork.luma(jpeg: jpeg) + let sharpness = luma.map { PacketSharpness.laplacianVarianceLuma640(luma: $0.pixels, width: $0.width, height: $0.height) } + return StoredKeyframe( + id: id, camera: frame.camera, t: frame.timestamp, tracking: frame.tracking, exposure: frame.exposure, + width: luma?.width ?? Int(frame.camera.imageSize.x), height: luma?.height ?? Int(frame.camera.imageSize.y), + sharpness: sharpness, depth: frame.sensorDepth.flatMap { writeDepth($0, id: id, in: directory) } + ) + } + + /// Writes ARKit's depth for a photo into the scan folder: float32 little-endian meters, + /// `depth/.f32`, and its confidence, `depth/.conf.u8`. A failed write loses only the + /// depth: the photo is the keyframe, and the packet then lists no depth for it. + nonisolated private static func writeDepth(_ depth: DepthPacket, id: String, in directory: URL) -> StoredDepth? { + let file = "depth/\(id).f32" + let confidenceFile = depth.confidence == nil ? nil : "depth/\(id).conf.u8" + do { + try FileManager.default.createDirectory(at: directory.appending(path: "depth", directoryHint: .isDirectory), withIntermediateDirectories: true) + var map = Data(capacity: depth.meters.count * 4) + for value in depth.meters { withUnsafeBytes(of: value.bitPattern.littleEndian) { map.append(contentsOf: $0) } } + try map.write(to: directory.appending(path: file), options: .atomic) + if let confidence = depth.confidence, let confidenceFile { + try Data(confidence).write(to: directory.appending(path: confidenceFile), options: .atomic) + } + return StoredDepth(file: file, confidenceFile: confidenceFile, width: depth.width, height: depth.height, source: depth.source) + } catch { + RuntimeLog.capture.error("keyframe \(id, privacy: .public) depth not stored: \(String(describing: error), privacy: .public)") + return nil + } + } + + /// A stored photo's depth, read back for the packet. Nil when the files are gone or the wrong + /// size. + nonisolated static func loadDepth(_ depth: StoredDepth, in directory: URL) -> DepthPacket? { + guard let map = try? Data(contentsOf: directory.appending(path: depth.file)), map.count == depth.width * depth.height * 4 else { return nil } + var confidence: [UInt8]? + if let file = depth.confidenceFile { + guard let data = try? Data(contentsOf: directory.appending(path: file)), data.count == depth.width * depth.height else { return nil } + confidence = [UInt8](data) + } + return DepthPacket(meters: PacketFiles.floats(littleEndian: map), width: depth.width, height: depth.height, confidence: confidence, source: depth.source) + } + + enum KeyframeWriteFailure: String, Error { + case noPhoto = "no photo data" + case undecodable = "photo does not decode" + case writeFailed = "write failed" + } + + /// Writes a still such as the meter close-up from `frame`. Returns false when there was + /// nothing to write. The still stands for its purpose only once accepted (`acceptStill`). + func saveStill(_ frame: SourceFrame, name: String) async -> Bool { + let directory = directory + let id = Self.purpose(of: name) + let startedIn = epoch + let written = await Task.detached(priority: .utility) { () -> StoredKeyframe? in + guard let data = Self.data(of: frame.jpeg), (try? data.write(to: directory.appending(path: name), options: .atomic)) != nil else { return nil } + return Self.stored(frame, id: id, jpeg: data, in: directory) + }.value + guard let written else { return false } + // Taken in a world frame that was discarded while the file was being written. The file is + // left alone: a close-up in the new frame may already have written the same name, and + // nothing reads a still that `stillFrames` doesn't list. + guard startedIn == epoch else { + RuntimeLog.capture.info("still \(name, privacy: .public) not kept: its world frame was discarded") + return false + } + stillCatalog.save(written, purpose: id, fileName: name) + return true + } + + /// The homeowner accepted the still saved as `name` (the meter number read from it was + /// confirmed). False when none is saved. + @discardableResult + func acceptStill(_ name: String) -> Bool { + stillCatalog.accept(Self.purpose(of: name)) + } + + /// The still saved as `name` no longer stands for its purpose (the step was skipped). It stays + /// a plain photo of the scan. + func withdrawStill(_ name: String) { + stillCatalog.withdraw(Self.purpose(of: name)) + } + + func thumbnail(ofStill name: String) async -> CGImage? { + let url = directory.appending(path: name) + return await Task.detached(priority: .utility) { () -> CGImage? in + guard let data = try? Data(contentsOf: url) else { return nil } + return ImageWork.uprightThumbnail(jpeg: data) + }.value + } + + /// Forgets keyframes and stills taken in a world frame that no longer exists (after a failed + /// relocalization), the meter close-up included: export and the packet read only what + /// `keyframes`, `stills` and `stillFrames` list, so a close-up skipped in the new frame exports + /// none. Keyframe files stay until the scan is discarded; still files go now, since the next + /// close-up reuses the name (`meter_close.jpg`). + func discardKeyframes() { + epoch += 1 + keyframes = [] + for name in stillCatalog.removeAll() { + try? FileManager.default.removeItem(at: directory.appending(path: name)) + } + } + + /// Where the packet is assembled, and the zip Share scan offers. + var packetFolder: URL { directory.appending(path: "packet", directoryHint: .isDirectory) } + var bundleURL: URL { directory.appending(path: "scan.zip") } + + /// Zips the packet folder into `scan.zip`, manifest.json at the zip's root, and removes the + /// folder. The zip is what "Share scan" offers; the upload sends scene.json alone. It stays on + /// the phone until the next scan unless the homeowner shares it. + /// + /// Streamed to disk one file at a time: a long walk's JPEGs held in memory twice (the entries + /// and the archive) is what an in-memory build cost. + nonisolated static func zipPacket(_ folder: URL, to url: URL) throws { + let files = FileManager.default + guard let walker = files.enumerator(at: folder, includingPropertiesForKeys: [.isRegularFileKey]) else { + throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: folder.path]) + } + let root = folder.standardizedFileURL.pathComponents.count + var names: [String] = [] + for case let file as URL in walker where (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true { + names.append(file.standardizedFileURL.pathComponents.dropFirst(root).joined(separator: "/")) + } + // manifest.json first, so a reader streaming the zip meets it before the files it names. + names.sort { ($0 == "manifest.json" ? 0 : 1, $0) < ($1 == "manifest.json" ? 0 : 1, $1) } + let entries = names.map { name in (name: name, load: { () throws -> Data in try Data(contentsOf: folder.appending(path: name)) }) } + try ZipWriter.write(entries, to: url, modified: Date()) + try? files.removeItem(at: folder) + } + + /// Off the main actor. A write still in flight for a deleted folder fails, because its + /// directory is gone, and is dropped like any failed write. + nonisolated private static func delete(_ cleanup: ScanFolderCleanup) { + Task.detached(priority: .utility) { + for (url, error) in cleanup.run() { + RuntimeLog.engine.error("could not delete old scan \(url.lastPathComponent, privacy: .public): \(String(describing: error), privacy: .public)") + } + } + } + + nonisolated private static func data(of payload: JPEGPayload) -> Data? { + switch payload { + case .data(let data): data + case .file(let url): try? Data(contentsOf: url) + case .none: nil + } + } + + nonisolated private static func purpose(of name: String) -> String { + (name as NSString).deletingPathExtension + } +} diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift new file mode 100644 index 000000000..58e7c8f4d --- /dev/null +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -0,0 +1,77 @@ +import Foundation +import OSLog + +/// Verification hooks from the launch arguments (contract C4). +/// +/// - `-replay `: play a recorded measure-lab-session v2 folder instead of the camera. +/// - `-autopilot`: drive the intents automatically (UI tests, demos). +/// - `-serverURL `: the placement server to upload to. Without it the app uses the build's +/// default, Info.plist `HouseScanServerURL` (set from `HOUSESCAN_SERVER_URL` in +/// Config/Shared.xcconfig); with neither, uploads answer with the bundled sample. +/// - `-sampleResult`: answer uploads with the bundled sample result, flagged as a sample, even +/// when a server is configured. UI tests pass it to stay offline and deterministic. +/// - `-autopilotHold `: how long the autopilot leaves each screen up (default 1.2 s). +/// UI tests raise it so each screen stays long enough to screenshot and audit. +/// - `-autopilotGate `: before the flow leaves a screen, wait until a file named after +/// that phase exists in the folder. UI tests write it once they have screenshotted and audited +/// the screen, so a slow audit can never miss a screen. After the result shows, the autopilot +/// also writes the scan's scene.json there, for the test to check. +/// - `-autopilotCantGetThere`: the autopilot ends the walk with "Can't get there" instead of +/// marking the ends (`Autopilot.endWalkByCantGetThere`). +/// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there" +/// instead of "It's clear", so the scan is checked again without that area. +/// - `-simulateAppStore`: run as an App Store install would, so the developer options and practice +/// meter are unavailable whatever the stored switch says (`DeveloperSettings`). It can only take +/// the switch away, never offer it. +struct LaunchOptions: Equatable { + var replayFolder: URL? + var autopilot = false + var serverURL: URL? + var sampleResult = false + var autopilotHold: Double = 1.2 + var autopilotGate: URL? + var autopilotCantGetThere = false + var autopilotSomethingThere = false + var simulateAppStore = false + + init( + arguments: [String] = ProcessInfo.processInfo.arguments, + defaultServerURL: String? = Bundle.main.object(forInfoDictionaryKey: "HouseScanServerURL") as? String + ) { + func value(after flag: String) -> String? { + guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else { return nil } + return arguments[index + 1] + } + if let path = value(after: "-replay") { + replayFolder = URL(fileURLWithPath: (path as NSString).expandingTildeInPath, isDirectory: true) + } + autopilot = arguments.contains("-autopilot") + autopilotCantGetThere = arguments.contains("-autopilotCantGetThere") + autopilotSomethingThere = arguments.contains("-autopilotSomethingThere") + simulateAppStore = arguments.contains("-simulateAppStore") + serverURL = (value(after: "-serverURL") ?? defaultServerURL).flatMap(Self.serverURL) + sampleResult = arguments.contains("-sampleResult") + if let gate = value(after: "-autopilotGate") { autopilotGate = URL(fileURLWithPath: gate, isDirectory: true) } + if let hold = value(after: "-autopilotHold").flatMap(Double.init), hold > 0 { autopilotHold = hold } + } + + /// An http(s) URL with a host, or nil. An empty build setting leaves the plist value empty, + /// and an unexpanded "$(HOUSESCAN_SERVER_URL)" has no scheme; both mean "no server". + private static func serverURL(_ text: String) -> URL? { + guard let url = URL(string: text.trimmingCharacters(in: .whitespaces)), + let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http", + url.host() != nil else { return nil } + return url + } +} + +/// Log channels. STATE lines are parsed by verification/hsverify/statelog.py, which needs them public. +enum RuntimeLog { + static let state = Logger(subsystem: "dev.housescanning.housescan", category: "state") + static let guidance = Logger(subsystem: "dev.housescanning.housescan", category: "guidance") + static let engine = Logger(subsystem: "dev.housescanning.housescan", category: "engine") + static let autopilot = Logger(subsystem: "dev.housescanning.housescan", category: "autopilot") + /// Tracking, relocalization and every capture-gate decision, for reading a real session back. + /// Only enum-valued reasons, frame ids and counts are public; never meter numbers or images. + static let capture = Logger(subsystem: "dev.housescanning.housescan", category: "capture") +} diff --git a/ios/HouseScan/Runtime/LiveCapture.swift b/ios/HouseScan/Runtime/LiveCapture.swift new file mode 100644 index 000000000..24f16f1c9 --- /dev/null +++ b/ios/HouseScan/Runtime/LiveCapture.swift @@ -0,0 +1,701 @@ +import ARKit +import CoreImage +import Foundation +import HouseScanKit +import ImageIO +import RealityKit +import Synchronization +import SwiftUI + +/// What the AR delegate should prepare with each sampled frame. +enum LiveMode: Sendable { + /// Pose and quality only. + case idle + /// Encode a JPEG about three times a second while the phone is held on the meter. + case closeUp + /// Encode a JPEG when the phone has moved or turned enough that the frame could be kept. + case walk +} + +enum LiveEvent: Sendable { + case interrupted + case interruptionEnded + case cameraDenied + case failed(String) +} + +struct VerticalPlaneHit { + let position: SIMD3 + let normal: SIMD3 + let transform: simd_float4x4 + /// Which kind of plane the ray hit; the export widens the meter's error for an estimated one. + let source: MeterPlaneSource +} + +/// The live ARKit source: RealityKit's ARView running world tracking with `.gravity` alignment and +/// horizontal and vertical plane detection. On an iPhone with LiDAR it also turns on per-frame +/// scene depth, which coverage uses to tell a wall from a bush in front of it and the packet +/// records with each photo and a few times a second between photos, and the scene mesh with +/// per-face classification, which the packet carries; without LiDAR it runs without either. +@MainActor +final class LiveCapture { + let arView: ARView + private let delegate: LiveSessionDelegate + + /// True when this phone gives per-frame LiDAR depth. + static var supportsDepth: Bool { ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) } + /// True when this phone builds a LiDAR mesh of the scene. + static var supportsMesh: Bool { ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) } + /// True when the mesh can carry ARKit's per-face classification (wall, floor, door, ...), + /// which the packet's mesh records. + static var supportsClassifiedMesh: Bool { ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification) } + /// True when this phone can hide virtual content behind a person standing in front of it. + static var supportsPeopleOcclusion: Bool { ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) } + + /// The AR result in the camera view, while one is shown (`showResult`). + private var result: AnchorEntity? + /// An empty entity in the result at the point it has to put on screen (the battery's + /// middle), for `resultIsDrawn`. + private var resultFocus: Entity? + /// The last `resultIsDrawn` findings logged, so they are logged when they change. + private var lastDrawnReport: String? + + init(onFrame: @escaping @MainActor @Sendable (SourceFrame) -> Void, onEvent: @escaping @MainActor @Sendable (LiveEvent) -> Void) { + arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false) + delegate = LiveSessionDelegate(onFrame: onFrame, onEvent: onEvent) + arView.session.delegateQueue = DispatchQueue(label: "dev.housescanning.housescan.ar-delegate", qos: .userInitiated) + arView.session.delegate = delegate + arView.renderOptions.insert(.disableMotionBlur) + } + + /// People occlusion is off: no caller asks for it. Turning it on while the AR result showed + /// re-ran the session under the result's freshly added anchor entity, and build 4.1 drew no + /// result on the phone it was tested on (#67). Whether and how to bring it back is #41. + private static func configuration(occludingPeople: Bool = false) -> ARWorldTrackingConfiguration { + let configuration = ARWorldTrackingConfiguration() + // .gravity, not .gravityAndHeading: compass heading drifts near a house's metal and wiring. + configuration.worldAlignment = .gravity + configuration.planeDetection = [.horizontal, .vertical] + if supportsClassifiedMesh { + configuration.sceneReconstruction = .meshWithClassification + } else if supportsMesh { + configuration.sceneReconstruction = .mesh + } + if supportsDepth { configuration.frameSemantics.insert(.sceneDepth) } + if occludingPeople, supportsPeopleOcclusion { configuration.frameSemantics.insert(.personSegmentationWithDepth) } + return configuration + } + + /// What the running session was configured with, for the packet's `session.device`. + struct Settings: Sendable { + var sceneDepth: Bool + var mesh: Bool + /// The mesh carries ARKit's per-face classification: `sceneReconstruction` included + /// `.meshWithClassification`, not only `.mesh`. + var meshClassification: Bool + /// The camera's frame rate: the trajectory's nominal rate. + var framesPerSecond: Int + } + + var settings: Settings { + let running = arView.session.configuration as? ARWorldTrackingConfiguration ?? Self.configuration() + return Settings( + sceneDepth: running.frameSemantics.contains(.sceneDepth), + mesh: running.sceneReconstruction.contains(.mesh), + meshClassification: running.sceneReconstruction.contains(.meshWithClassification), + framesPerSecond: running.videoFormat.framesPerSecond + ) + } + + /// Where every ARFrame's pose goes as it arrives (`CaptureRecorder.recordPose`). + func setRecorder(_ recorder: CaptureRecorder?) { + delegate.shared.withLock { $0.recorder = recorder } + } + + func start() { + RuntimeLog.engine.info("LiDAR: scene depth \(Self.supportsDepth ? "on" : "not available", privacy: .public), mesh \(Self.supportsMesh ? "on" : "not available", privacy: .public)") + arView.session.run(Self.configuration()) + } + + func pause() { + arView.session.pause() + } + + /// Starts world tracking over with a fresh map, after relocalization failed. The old mesh goes + /// with the old anchors. + func restart() { + arView.session.run(Self.configuration(), options: [.resetTracking, .removeExistingAnchors]) + delegate.shared.withLock { $0.meterAnchorID = nil } + } + + /// Shows `model` in the camera view, hung on the meter's anchor so it follows ARKit's + /// corrections to it. The model is in world axes with the meter at its origin, as they stood + /// when the anchor was at `pose` (`MeterAnchorTracking.pose`, the pose the engine's wall + /// agrees with). It is attached in that pose's frame, so a turn ARKit has made to the anchor + /// since carries the model with it. Cancelling the anchor's current turn instead drew the + /// wall as it stood before the turn. On a phone with LiDAR the scene mesh hides it where + /// something stands in front of it. `focus` is the point the model has to put on screen, in + /// the model's coordinates (`resultIsDrawn`). + /// + /// True means the model was handed to RealityKit, not that anything is drawn: build 4.1 took + /// it for that and hid the screen's own drawing on a phone where nothing appeared (#67). + /// False when the meter has no anchor in the session; nothing is shown then. + func showResult(_ model: Entity, builtFor pose: simd_float4x4, focus: SIMD3) -> Bool { + removeResult() + guard let id = delegate.shared.withLock({ $0.meterAnchorID }), + arView.session.currentFrame?.anchors.contains(where: { $0.identifier == id }) == true else { + RuntimeLog.engine.info("AR result not shown: the meter's anchor is not in the session") + return false + } + let holder = AnchorEntity(.anchor(identifier: id)) + model.orientation = simd_quatf(pose).inverse + let marker = Entity() + marker.position = focus + model.addChild(marker) + holder.addChild(model) + arView.scene.addAnchor(holder) + result = holder + resultFocus = marker + lastDrawnReport = nil + setOcclusion(true) + return true + } + + /// Whether RealityKit can be drawing the shown result now: its anchor entity is anchored to + /// the meter's ARKit anchor, it is enabled, and the result's focus point lands in front of + /// the camera and inside the view. A check of the chain, not of pixels: RealityKit says + /// nothing about what it drew. The findings are logged when they change. `held` is the chain + /// without the view: the AR scene still has the result, whether or not it is in view + /// (`ResultOverlayPolicy.update(drawn:held:time:)`). `anchored` leaves out whether it is + /// enabled: the engine disables it while tracking is limited (`setResultVisible`). + func resultIsDrawn() -> (drawn: Bool, held: Bool, anchored: Bool) { + guard let result, let resultFocus else { return (false, false, false) } + let id = delegate.shared.withLock { $0.meterAnchorID } + let inSession = id.map { id in arView.session.currentFrame?.anchors.contains(where: { $0.identifier == id }) == true } ?? false + let anchored = result.isAnchored + let enabled = result.isEnabledInHierarchy + let world = resultFocus.position(relativeTo: nil) + let inCamera = arView.cameraTransform.matrix.inverse * SIMD4(world, 1) + let point = inCamera.z < 0 ? arView.project(world) : nil + let onScreen = point.map { arView.bounds.contains($0) } ?? false + let report = "anchor in session \(inSession), anchored \(anchored), enabled \(enabled), on screen \(onScreen)" + if report != lastDrawnReport { + lastDrawnReport = report + let at = point.map { String(format: "(%.0f, %.0f) in %.0f x %.0f", Double($0.x), Double($0.y), Double(arView.bounds.width), Double(arView.bounds.height)) } ?? "none" + RuntimeLog.engine.info("AR result: \(report, privacy: .public), projected at \(at, privacy: .public)") + } + return (anchored && enabled && onScreen, anchored && enabled, anchored) + } + + func hideResult() { + removeResult() + setOcclusion(false) + } + + /// Hides the shown result without taking it out, while the anchor's pose can't be trusted. + func setResultVisible(_ visible: Bool) { + result?.isEnabled = visible + } + + private func removeResult() { + result?.removeFromParent() + result = nil + resultFocus = nil + } + + /// The LiDAR mesh hides the result where something real stands in front of it. A RealityKit + /// setting only: the session is not re-run (see `configuration`). + private func setOcclusion(_ on: Bool) { + guard Self.supportsMesh else { return } + if on { + arView.environment.sceneUnderstanding.options.insert(.occlusion) + } else { + arView.environment.sceneUnderstanding.options.remove(.occlusion) + } + } + + /// ARKit's mesh in world meters, with one `ARMeshClassification` raw value per triangle (0, + /// none, when the session runs without classification). + struct MeshSnapshot: Sendable { + var mesh: TriangleMesh + var classification: [UInt8] + } + + /// The LiDAR mesh ARKit has built so far, in world meters, or nil when there is none (no + /// LiDAR, or nothing reconstructed yet). Each anchor's vertices are read out of its Metal + /// buffer, never written, and moved into world coordinates by the anchor's transform. + func meshSnapshot() -> MeshSnapshot? { + guard let anchors = arView.session.currentFrame?.anchors.compactMap({ $0 as? ARMeshAnchor }), !anchors.isEmpty else { return nil } + var vertices: [SIMD3] = [] + var indices: [UInt32] = [] + var classification: [UInt8] = [] + for anchor in anchors { + let source = anchor.geometry.vertices + let faces = anchor.geometry.faces + guard source.format == .float3, faces.indexCountPerPrimitive == 3, faces.bytesPerIndex == 4 || faces.bytesPerIndex == 2 else { + RuntimeLog.engine.error("mesh anchor skipped: vertex format \(source.format.rawValue), \(faces.indexCountPerPrimitive) indices per face, \(faces.bytesPerIndex) bytes per index") + continue + } + let first = UInt32(vertices.count) + let points = UnsafeRawPointer(source.buffer.contents()).advanced(by: source.offset) + for i in 0..( + p.loadUnaligned(as: Float.self), p.loadUnaligned(fromByteOffset: 4, as: Float.self), + p.loadUnaligned(fromByteOffset: 8, as: Float.self), 1 + ) + let world = anchor.transform * local + vertices.append(SIMD3(world.x, world.y, world.z)) + } + let raw = UnsafeRawPointer(faces.buffer.contents()) + for i in 0..<(faces.count * 3) { + let index = faces.bytesPerIndex == 4 + ? raw.loadUnaligned(fromByteOffset: i * 4, as: UInt32.self) + : UInt32(raw.loadUnaligned(fromByteOffset: i * 2, as: UInt16.self)) + indices.append(first + index) + } + classification += Self.faceClasses(anchor.geometry) + } + return vertices.isEmpty ? nil : MeshSnapshot(mesh: TriangleMesh(vertices: vertices, indices: indices), classification: classification) + } + + /// One class byte per face of `geometry`: its classification source when it has one in the + /// layout ARKit documents (one uchar per face), else 0 (none) for every face. + private static func faceClasses(_ geometry: ARMeshGeometry) -> [UInt8] { + let faces = geometry.faces.count + guard let source = geometry.classification, source.format == .uchar, source.count == faces, source.componentsPerVector == 1 else { + return [UInt8](repeating: 0, count: faces) + } + let base = UnsafeRawPointer(source.buffer.contents()).advanced(by: source.offset) + return (0.. + /// `ARPlaneExtent.rotationOnYAxis`, radians. + var rotationOnYAxis: Float + /// `ARPlaneExtent.width` and `height`. + var extent: SIMD2 + /// `ARPlaneGeometry.boundaryVertices`, anchor coordinates. + var boundary: [SIMD3] + } + + /// Every plane ARKit has detected, as it stands now. + func planeSnapshot() -> [PlaneSnapshot] { + guard let anchors = arView.session.currentFrame?.anchors.compactMap({ $0 as? ARPlaneAnchor }) else { return [] } + return anchors.map { plane in + PlaneSnapshot( + id: plane.identifier.uuidString, vertical: plane.alignment == .vertical, + classification: Self.name(plane.classification), anchorToWorld: plane.transform, center: plane.center, + rotationOnYAxis: plane.planeExtent.rotationOnYAxis, + extent: SIMD2(plane.planeExtent.width, plane.planeExtent.height), boundary: plane.geometry.boundaryVertices + ) + } + } + + private static func name(_ classification: ARPlaneAnchor.Classification) -> PacketPlane.Classification? { + switch classification { + case .none: PacketPlane.Classification.none + case .wall: .wall + case .floor: .floor + case .ceiling: .ceiling + case .table: .table + case .seat: .seat + case .window: .window + case .door: .door + @unknown default: nil + } + } + + func setMode(_ mode: LiveMode) { + delegate.shared.withLock { $0.mode = mode } + } + + /// Raycast from a view point to a vertical surface: a detected plane's extent first, then a + /// vertical plane ARKit estimates from feature points at the tap. Never `.existingPlaneInfinite`, + /// which extends a fence's or another wall's plane past its edges, so a tap beside it lands on + /// a surface that isn't there. The hit says which kind it was. + func raycastVerticalPlane(from point: CGPoint) -> VerticalPlaneHit? { + let targets: [(ARRaycastQuery.Target, MeterPlaneSource)] = [(.existingPlaneGeometry, .detectedPlane), (.estimatedPlane, .estimatedPlane)] + for (target, source) in targets { + guard let result = arView.raycast(from: point, allowing: target, alignment: .vertical).first else { continue } + // An estimated plane has no anchor; the result's own y axis is the surface normal. + let transform = result.worldTransform + let planeTransform = result.anchor?.transform ?? transform + let normal = simd_normalize(SIMD3(planeTransform.columns.1.x, planeTransform.columns.1.y, planeTransform.columns.1.z)) + return VerticalPlaneHit( + position: SIMD3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z), + normal: normal, transform: transform, source: source + ) + } + return nil + } + + /// Anchors the meter so ARKit keeps refining its position; returns the anchor's id. + func addMeterAnchor(at transform: simd_float4x4) -> UUID { + let anchor = ARAnchor(name: "meter", transform: transform) + arView.session.add(anchor: anchor) + delegate.shared.withLock { $0.meterAnchorID = anchor.identifier } + return anchor.identifier + } + + func removeAnchor(_ id: UUID) { + if let anchor = arView.session.currentFrame?.anchors.first(where: { $0.identifier == id }) { + arView.session.remove(anchor: anchor) + } + delegate.shared.withLock { $0.meterAnchorID = nil } + } +} + +/// Shared between the main actor and the AR delegate queue. +struct LiveShared: Sendable { + var mode: LiveMode = .idle + var meterAnchorID: UUID? + var recorder: CaptureRecorder? +} + +/// Receives ARSession callbacks on a private serial queue. Each sampled frame is reduced to a +/// Sendable `SourceFrame` there (pose, intrinsics, a luma quality measure and, when it could be +/// kept, a JPEG of the unrotated sensor image) and then sent to the main actor. ARKit objects +/// never leave the delegate queue, except the camera image of the one frame being encoded. +/// +/// JPEG encoding runs on its own queue, one frame at a time. Encoding on the delegate queue held +/// it for the length of an encode, so ARKit's next frames queued up, each holding a camera buffer +/// from ARKit's small pool. A keyframe candidate that arrives while an encode is running is sent +/// without a photo instead of waiting: it can't be kept, and the next sampled frame can be. +final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable { + let shared = Mutex(LiveShared()) + private let onFrame: @MainActor @Sendable (SourceFrame) -> Void + private let onEvent: @MainActor @Sendable (LiveEvent) -> Void + private let queueState = Mutex(QueueState()) + private let context = CIContext(options: [.cacheIntermediates: false]) + /// Serial, and every frame the main actor gets passes through it, pose-only ones too, then + /// through the main queue (`deliver`), which is FIFO: frames reach the engine in capture + /// order even when a sampled frame waited on its JPEG. A pose-only frame used to go straight + /// to the main actor and overtake the sampled frame before it; the engine then saw time run + /// back. The cost is that a pose may wait behind one encode. + private let encodeQueue = DispatchQueue(label: "dev.housescanning.housescan.jpeg", qos: .userInitiated) + + private struct QueueState { + var frameCount = 0 + var lastEncode: (time: Double, camera: CameraFrame)? + var encoding = false + } + + /// The camera image handed to the encode queue. CVPixelBuffer is not Sendable; ARKit doesn't + /// write to a delivered frame's image, and only the encode queue reads it. + private struct PixelBufferBox: @unchecked Sendable { + let buffer: CVPixelBuffer + } + + /// Every sixth frame (about 10 per second at 60 fps) is sampled for capture; more adds cost, + /// not coverage. Every second frame carries the pose, so overlays drawn over the 60 fps camera + /// view move at 30 fps instead of 10. + private static let sampleEvery = 6 + private static let poseEvery = 2 + /// A walk JPEG is encoded once the phone moved 0.15 m or turned 5° since the last one: well + /// under auto-capture's 0.5 m / 15° spacing, so no keepable frame lacks an image. + private static let encodeMove: Float = 0.15 + private static let encodeTurn: Float = 5 * .pi / 180 + private static let encodeInterval = 0.3 + /// A walk JPEG is also encoded after this long without one, moved or not. The overhead + /// question is only asked on a frame with a photo (the answer stores it), and a phone held + /// still while tilted up would otherwise never get one. A guess: one encode a second while + /// standing still costs little; not measured on a phone. + private static let encodeStill = 1.0 + + init(onFrame: @escaping @MainActor @Sendable (SourceFrame) -> Void, onEvent: @escaping @MainActor @Sendable (LiveEvent) -> Void) { + self.onFrame = onFrame + self.onEvent = onEvent + } + + func session(_ session: ARSession, didUpdate frame: ARFrame) { + let count = queueState.withLock { state -> Int in + state.frameCount += 1 + return state.frameCount + } + let shared = shared.withLock { $0 } + let tracking = Self.tracking(frame.camera.trackingState) + // Every frame's pose goes to the packet's trajectory, before any sampling. + shared.recorder?.recordPose(t: frame.timestamp, tracking: TrackingCode(tracking), cameraToWorld: frame.camera.transform) + let intrinsics = frame.camera.intrinsics + let resolution = frame.camera.imageResolution + let camera = CameraFrame( + cameraToWorld: frame.camera.transform, + intrinsics: SIMD4(intrinsics.columns.0.x, intrinsics.columns.1.y, intrinsics.columns.2.x, intrinsics.columns.2.y), + imageSize: SIMD2(Float(resolution.width), Float(resolution.height)) + ) + if let recorder = shared.recorder, tracking == .normal { + Self.recordDepthFrame(frame, camera: camera, into: recorder) + } + guard count % Self.poseEvery == 0 else { return } + let meterAnchor = shared.meterAnchorID.flatMap { id in frame.anchors.first { $0.identifier == id }?.transform } + guard count % Self.sampleEvery == 0 else { + let pose = SourceFrame( + id: "live-\(count)", timestamp: frame.timestamp, camera: camera, tracking: tracking, + quality: nil, jpeg: .none, still: nil, meterAnchor: meterAnchor, meterAnchorID: shared.meterAnchorID, isPoseOnly: true + ) + encodeQueue.async { [self, pose] in deliver(pose) } + return + } + let quality = Self.quality(frame.capturedImage) + let planes = frame.anchors.compactMap { $0 as? ARPlaneAnchor } + let ground = planes.filter { $0.alignment == .horizontal }.map(Self.groundEvidence) + let walls = planes.filter { $0.alignment == .vertical }.map(Self.wallEvidence) + var snapshot = SourceFrame( + id: "live-\(count)", timestamp: frame.timestamp, camera: camera, tracking: tracking, + quality: quality, jpeg: .none, still: nil, meterAnchor: meterAnchor, meterAnchorID: shared.meterAnchorID, + groundPlanes: ground, wallPlanes: walls + ) + let image = tracking == .normal && shouldEncode(mode: shared.mode, time: frame.timestamp, camera: camera) + ? PixelBufferBox(buffer: frame.capturedImage) : nil + // Depth and camera settings go with the photo: a frame without one can't be kept. Copied + // here, so the ARFrame is not held past this callback. + if image != nil { + snapshot.exposure = Self.exposure(frame) + if let depth = frame.sceneDepth, let copied = Self.depthCopy(depth) { + // The packet takes ARKit's depth only with its confidence (required from 1.1); + // coverage uses the map either way. + snapshot.sensorDepth = copied.confidence == nil ? nil : copied + snapshot.depth = DepthImage( + meters: copied.meters, width: copied.width, height: copied.height, confidence: copied.confidence, + intrinsics: DepthImage.intrinsics(scaling: camera.intrinsics, from: camera.imageSize, toWidth: copied.width, height: copied.height) + ) + } + } + encodeQueue.async { [self, snapshot] in + var delivered = snapshot + if let image { + if let data = encode(image.buffer) { delivered.jpeg = .data(data) } + queueState.withLock { $0.encoding = false } + } + deliver(delivered) + } + } + + /// Hands a frame to the engine on the main queue. Called only from `encodeQueue`, in order; + /// `DispatchQueue.main` runs blocks in the order they were queued, where separate `Task`s + /// aimed at the main actor carry no such promise. + private func deliver(_ frame: SourceFrame) { + DispatchQueue.main.async { [onFrame] in + MainActor.assumeIsolated { onFrame(frame) } + } + } + + /// A depth frame for the packet: LiDAR depth between photos, a few a second, from frames with + /// normal tracking, so each sits on the trajectory with a pose worth fusing. The recorder's + /// budget decides first, so a frame it would drop is never copied. The intrinsics are the + /// camera's scaled to the depth map's grid, which covers the same view. + private static func recordDepthFrame(_ frame: ARFrame, camera: CameraFrame, into recorder: CaptureRecorder) { + guard let depth = frame.sceneDepth, recorder.wantsDepthFrame(at: frame.timestamp), + let copied = depthCopy(depth), copied.confidence != nil else { return } + recorder.recordDepthFrame( + t: frame.timestamp, tracking: TrackingCode(.normal), cameraToWorld: frame.camera.transform, + intrinsics: DepthImage.intrinsics(scaling: camera.intrinsics, from: camera.imageSize, toWidth: copied.width, height: copied.height), + depth: copied + ) + } + + /// Whether to encode this frame; claims the single encode slot when it says yes. + private func shouldEncode(mode: LiveMode, time: Double, camera: CameraFrame) -> Bool { + queueState.withLock { state in + guard !state.encoding else { return false } + let decision: Bool + switch mode { + case .idle: + decision = false + case .closeUp: + decision = time - (state.lastEncode?.time ?? -.infinity) >= Self.encodeInterval + case .walk: + if let last = state.lastEncode { + let moved = simd_distance(camera.position, last.camera.position) >= Self.encodeMove + || camera.rotationAngle(to: last.camera) >= Self.encodeTurn + decision = (moved && time - last.time >= Self.encodeInterval) || time - last.time >= Self.encodeStill + } else { + decision = true + } + } + if decision { + state.lastEncode = (time, camera) + state.encoding = true + } + return decision + } + } + + /// JPEG of the sensor image as captured: landscape, unrotated, matching the intrinsics. + private func encode(_ buffer: CVPixelBuffer) -> Data? { + let image = CIImage(cvPixelBuffer: buffer) + let space = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + let options = [CIImageRepresentationOption(rawValue: kCGImageDestinationLossyCompressionQuality as String): 0.8] + return context.jpegRepresentation(of: image, colorSpace: space, options: options) + } + + /// Sharpness and exposure from the luma plane, sampling every 8th pixel of every 8th row + /// (240 x 180 for a 1920 x 1440 buffer). + private static func quality(_ buffer: CVPixelBuffer) -> FrameQuality? { + CVPixelBufferLockBaseAddress(buffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) } + guard CVPixelBufferGetPlaneCount(buffer) >= 1, let base = CVPixelBufferGetBaseAddressOfPlane(buffer, 0) else { return nil } + let width = CVPixelBufferGetWidthOfPlane(buffer, 0) + let height = CVPixelBufferGetHeightOfPlane(buffer, 0) + let bytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(buffer, 0) + guard width >= 8, height >= 8 else { return nil } + let raw = UnsafeRawBufferPointer(start: base, count: bytesPerRow * height) + return FrameQuality(LumaImage(sampling: raw, width: width, height: height, bytesPerRow: bytesPerRow, step: 8)) + } + + /// The camera settings of the frame: exposure time and offset from ARCamera, and ISO, focal + /// length and f-number from the frame's EXIF (`kCGImagePropertyExif*` keys) when present. + private static func exposure(_ frame: ARFrame) -> PhotoExposure { + let exif = frame.exifData + func number(_ key: CFString) -> Double? { + (exif[key as String] as? NSNumber)?.doubleValue ?? (exif[key as String] as? [NSNumber])?.first?.doubleValue + } + let duration = frame.camera.exposureDuration + return PhotoExposure( + durationS: duration > 0 ? duration : nil, + offsetEV: Double(frame.camera.exposureOffset), + iso: number(kCGImagePropertyExifISOSpeedRatings).flatMap { $0 > 0 ? $0 : nil }, + focalLengthMM: number(kCGImagePropertyExifFocalLength).flatMap { $0 > 0 ? $0 : nil }, + fNumber: number(kCGImagePropertyExifFNumber).flatMap { $0 > 0 ? $0 : nil } + ) + } + + /// The LiDAR depth map (Float32 meters along the camera's -z, 256 x 192 on current iPhones) + /// with its confidence, copied row by row. The depth map covers the same view as the camera + /// image at a lower resolution, so coverage's copy takes the camera's intrinsics scaled by the + /// size ratio on each axis (`DepthImage.intrinsics(scaling:...)`). ARKit writes no value it + /// could not measure as NaN; those become 0, "no measurement" in the packet. + private static func depthCopy(_ data: ARDepthData) -> DepthPacket? { + let map = data.depthMap + CVPixelBufferLockBaseAddress(map, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(map, .readOnly) } + guard CVPixelBufferGetPixelFormatType(map) == kCVPixelFormatType_DepthFloat32, let base = CVPixelBufferGetBaseAddress(map) else { return nil } + let width = CVPixelBufferGetWidth(map) + let height = CVPixelBufferGetHeight(map) + let rowBytes = CVPixelBufferGetBytesPerRow(map) + guard width > 0, height > 0 else { return nil } + var meters = [Float](repeating: 0, count: width * height) + for y in 0.. 0 ? value : 0 + } + } + var confidence: [UInt8]? + if let levels = data.confidenceMap { + CVPixelBufferLockBaseAddress(levels, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(levels, .readOnly) } + if CVPixelBufferGetPixelFormatType(levels) == kCVPixelFormatType_OneComponent8, + CVPixelBufferGetWidth(levels) == width, CVPixelBufferGetHeight(levels) == height, + let levelBase = CVPixelBufferGetBaseAddress(levels) { + let levelRowBytes = CVPixelBufferGetBytesPerRow(levels) + var values = [UInt8](repeating: 0, count: width * height) + for y in 0.. TrackingQuality { + switch state { + case .notAvailable: .notAvailable + case .normal: .normal + case .limited(let reason): + switch reason { + case .initializing: .limited(.initializing) + case .excessiveMotion: .limited(.excessiveMotion) + case .insufficientFeatures: .limited(.insufficientFeatures) + case .relocalizing: .limited(.relocalizing) + @unknown default: .limited(.unknown) + } + } + } + + /// A horizontal plane as the ground choice reads it (`GroundPlaneChoice`): its height, its + /// class and its outline in plan, world meters. + private static func groundEvidence(_ plane: ARPlaneAnchor) -> GroundPlaneEvidence { + let center = plane.transform * SIMD4(plane.center, 1) + let kind: GroundPlaneEvidence.Kind = switch plane.classification { + case .floor: .floor + case .table, .seat: .furniture + case .none: .unclassified + default: .other + } + let boundary = plane.geometry.boundaryVertices.map { vertex -> SIMD2 in + let world = plane.transform * SIMD4(vertex, 1) + return SIMD2(world.x, world.z) + } + return GroundPlaneEvidence(y: center.y, kind: kind, boundary: boundary, id: plane.identifier.uuidString) + } + + /// A vertical plane as the meter tap's checks read it (`MeterTap`): its class, a point on it, + /// its normal (the anchor's y axis) and its outline, world meters. On a phone without plane + /// classification every plane is `.none`, so every one is unclassified. + private static func wallEvidence(_ plane: ARPlaneAnchor) -> WallPlaneEvidence { + let center = plane.transform * SIMD4(plane.center, 1) + let normal = plane.transform.columns.1 + let kind: WallPlaneEvidence.Kind = switch plane.classification { + case .wall: .wall + case .none: .unclassified + default: .other + } + let boundary = plane.geometry.boundaryVertices.map { vertex -> SIMD3 in + let world = plane.transform * SIMD4(vertex, 1) + return SIMD3(world.x, world.y, world.z) + } + return WallPlaneEvidence( + id: plane.identifier.uuidString, kind: kind, center: SIMD3(center.x, center.y, center.z), + normal: SIMD3(normal.x, normal.y, normal.z), boundary: boundary + ) + } + + // MARK: Session events + + func sessionShouldAttemptRelocalization(_ session: ARSession) -> Bool { + // Resume the walk in the same world frame after an interruption (checklist R4). + true + } + + func sessionWasInterrupted(_ session: ARSession) { + Task { @MainActor [onEvent] in onEvent(.interrupted) } + } + + func sessionInterruptionEnded(_ session: ARSession) { + Task { @MainActor [onEvent] in onEvent(.interruptionEnded) } + } + + func session(_ session: ARSession, didFailWithError error: any Error) { + let event: LiveEvent + if let arError = error as? ARError, arError.code == .cameraUnauthorized { + event = .cameraDenied + } else { + event = .failed(error.localizedDescription) + } + Task { @MainActor [onEvent] in onEvent(event) } + } +} + +/// The ARView as a SwiftUI view. +struct LiveCameraView: UIViewRepresentable { + let arView: ARView + + func makeUIView(context: Context) -> ARView { arView } + func updateUIView(_ view: ARView, context: Context) {} +} diff --git a/ios/HouseScan/Runtime/LiveDotsFeed.swift b/ios/HouseScan/Runtime/LiveDotsFeed.swift new file mode 100644 index 000000000..ea3a35f9d --- /dev/null +++ b/ios/HouseScan/Runtime/LiveDotsFeed.swift @@ -0,0 +1,81 @@ +import Foundation +import HouseScanKit +import os + +/// Runs the LiDAR dot field (`SurfaceDots`) off the main actor and publishes its dots for the +/// camera overlay (`ScanViewState.liveDots`). +/// +/// The engine hands it each kept keyframe that has depth, the same frames coverage observes, so +/// the dots and the fog move together. Fusing a 256 x 192 map takes a few milliseconds (3.4 ms +/// median on an M4 Pro in a Release build; not measured on a phone), so it runs on its own +/// serial queue. A keyframe arriving while two are still waiting is dropped: the next one covers +/// the same wall. An anchor correction is a matrix product (`SurfaceDots.apply`) and republishes +/// only when no keyframe is waiting to publish anyway, so neither kind of work can pile up. +final class LiveDotsFeed: @unchecked Sendable { + // `@unchecked Sendable`: everything below `queue` is read and written only on it; `pending` + // is a lock. + private let queue = DispatchQueue(label: "dev.housescanning.housescan.live-dots", qos: .utility) + private var field = SurfaceDots() + private var revision = 0 + private var last: (eye: SIMD3, wall: WallFrame)? + private let pending = OSAllocatedUnfairLock(initialState: 0) + private let publish: @MainActor @Sendable (LiveDots, _ generation: Int) -> Void + + /// Keyframes waiting on the queue past which new ones are dropped. + static let maxPending = 2 + + /// `publish` runs on the main actor with the scan generation the work was queued under, so + /// the engine can ignore dots from before a Start over. + init(publish: @escaping @MainActor @Sendable (LiveDots, _ generation: Int) -> Void) { + self.publish = publish + } + + func integrate(camera: CameraFrame, depth: DepthImage, wall: WallFrame, generation: Int) { + let accepted = pending.withLock { count -> Bool in + guard count < Self.maxPending else { return false } + count += 1 + return true + } + guard accepted else { + RuntimeLog.capture.info("live dots: dropped a keyframe, two still waiting") + return + } + queue.async { [self] in + field.integrate(camera: camera, depth: depth) + last = (camera.position, wall) + pending.withLock { $0 -= 1 } + send(generation) + } + } + + /// ARKit's correction to the meter's anchor, applied as coverage applies it. + func apply(_ correction: YawCorrection, wall: WallFrame, generation: Int) { + queue.async { [self] in + field.apply(correction) + if let eye = last?.eye { last = (correction.point(eye), wall) } + if pending.withLock({ $0 }) == 0 { send(generation) } + } + } + + func reset() { + queue.async { [self] in + field = SurfaceDots() + last = nil + } + } + + /// On `queue`. + private func send(_ generation: Int) { + guard let last else { return } + revision += 1 + let dots = LiveDots( + dots: field.dots(near: last.eye, wall: last.wall).map { + LiveDots.Dot(id: $0.id, position: $0.position, isEdge: $0.isEdge, onOccluder: $0.onOccluder, opacity: $0.opacity) + }, + revision: revision) + let publish = publish + DispatchQueue.main.async { + MainActor.assumeIsolated { publish(dots, generation) } + } + } +} diff --git a/ios/HouseScan/Runtime/MeterNumberReading.swift b/ios/HouseScan/Runtime/MeterNumberReading.swift new file mode 100644 index 000000000..30e3117d4 --- /dev/null +++ b/ios/HouseScan/Runtime/MeterNumberReading.swift @@ -0,0 +1,49 @@ +import Foundation + +// The boundary between the engine's close-up flow and the meter-number reader. The checks and +// their thresholds come from the close-up eval (experiments/meter-closeup/README.md on +// t3/meter-closeup at 944cbe1, "For the app: what to port"). + +/// What one close-up photo yielded. +struct MeterReadout: Sendable, Equatable { + /// The best candidates, barcode-confirmed first, at most three. Empty when the photo must be + /// retaken. + var candidates: [MeterNumberCandidate] + /// Why to retake (`.blurry` from the focus check, `.noNumber` when nothing was read); nil when + /// there are candidates to show. + var retake: CloseUpProblem? + /// The top candidate's characters are small in the photo, so after "None of these" the right + /// advice is "move closer" (`.numberTooSmall`) rather than a plain retake. + var numberTooSmall: Bool + /// The photo decoded and passed the focus check, whether or not a number was read in it. Only + /// such a photo's view goes into coverage (`CloseUpCredit`). + var photoPassedChecks: Bool + /// The maker named on the meter (`MeterBrand.read`), or nil when no line names one. Shown + /// beside the candidates; it counts only when the homeowner confirms a number without + /// rejecting it. + var brand: String? = nil +} + +/// Reads the meter number from the close-up JPEG. Runs off the main actor. +protocol MeterNumberReader: Sendable { + func read(jpeg: Data) async -> MeterReadout +} + +/// Reads nothing: every photo asks for a retake with `.noNumber`. Stands in until the Vision +/// reader lands. +struct UnavailableMeterNumberReader: MeterNumberReader { + func read(jpeg: Data) async -> MeterReadout { + // No check ran, so nothing vouches for the photo. + MeterReadout(candidates: [], retake: .noNumber, numberTooSmall: false, photoPassedChecks: false) + } +} + +/// The reader the engine uses. +enum MeterNumberReaders { + static func make() -> any MeterNumberReader { + // The close-up JPEG holds the landscape sensor image unrotated (LiveCapture.encode) and the + // app is portrait-only, so the upright photo is the stored one turned 90° clockwise, as + // ImageWork.uprightThumbnail shows it: EXIF `.right`. + VisionMeterNumberReader(orientation: .right) + } +} diff --git a/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift b/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift new file mode 100644 index 000000000..6a1c9c495 --- /dev/null +++ b/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift @@ -0,0 +1,99 @@ +import CoreGraphics +import Foundation +import HouseScanKit +import ImageIO +import Vision + +/// Reads the meter number from a close-up with Apple Vision, configured as the close-up eval +/// measured it (experiments/meter-closeup/README.md on t3/meter-closeup at 944cbe1, "For the app: +/// what to port"): focus check first, then text and barcodes, then HouseScanKit's ranking and size +/// check. +struct VisionMeterNumberReader: MeterNumberReader { + /// How the stored pixels turn upright, as an EXIF orientation. Vision reads and the size check + /// measures the upright photo. + let orientation: CGImagePropertyOrientation + + @concurrent func read(jpeg: Data) async -> MeterReadout { + Self.readout(jpeg: jpeg, orientation: orientation) + } + + /// Nothing read in a photo that decoded and is in focus. + private static let retakeNoNumber = MeterReadout(candidates: [], retake: .noNumber, numberTooSmall: false, photoPassedChecks: true) + private static let undecodable = MeterReadout(candidates: [], retake: .noNumber, numberTooSmall: false, photoPassedChecks: false) + + private static func readout(jpeg: Data, orientation: CGImagePropertyOrientation) -> MeterReadout { + guard let source = CGImageSourceCreateWithData(jpeg as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil), + let gray = luma(of: image) + else { return undecodable } + + // The eval's flow blocks a blurry photo before showing candidates, so Vision need not run. + if MeterPhotoChecks.isOutOfFocus(sharpness: MeterPhotoChecks.wholePhotoSharpness(gray)) { + return MeterReadout(candidates: [], retake: .blurry, numberTooSmall: false, photoPassedChecks: false) + } + + let text = VNRecognizeTextRequest() + text.revision = VNRecognizeTextRequestRevision3 + // The eval read 97% of meter numbers at .accurate and 75% at .fast; language correction + // added nothing. + text.recognitionLevel = .accurate + text.recognitionLanguages = ["en-US"] + text.usesLanguageCorrection = false + // 0 is the default (full resolution); set so a changed default cannot drop small labels. + text.minimumTextHeight = 0 + let barcodes = VNDetectBarcodesRequest() + barcodes.revision = VNDetectBarcodesRequestRevision4 + do { + try VNImageRequestHandler(cgImage: image, orientation: orientation).perform([text, barcodes]) + } catch { + return retakeNoNumber + } + + let lines = (text.results ?? []).compactMap { observation -> MeterTextLine? in + guard let best = observation.topCandidates(1).first else { return nil } + return MeterTextLine(text: best.string, box: topLeftBox(observation.boundingBox)) + } + let payloads = (barcodes.results ?? []).compactMap(\.payloadStringValue) + let found = MeterNumberRanking.candidates(lines: lines, barcodePayloads: payloads) + let turned: Set = [.left, .leftMirrored, .right, .rightMirrored] + let uprightHeight = turned.contains(orientation) ? image.width : image.height + guard let choices = MeterPhotoChecks.choices(from: found, photoHeight: uprightHeight) else { + return retakeNoNumber + } + return MeterReadout( + candidates: choices.candidates.enumerated().map { index, candidate in + MeterNumberCandidate(id: index, text: candidate.core, barcodeConfirmed: candidate.barcodeConfirmed) + }, + retake: nil, + numberTooSmall: choices.numberTooSmall, + photoPassedChecks: true, + brand: MeterBrand.read(lines) + ) + } + + /// Vision's normalized box has a bottom-left origin; the ranking uses a top-left one. + private static func topLeftBox(_ box: CGRect) -> MeterBox { + MeterBox(x: box.minX, y: 1 - box.maxY, width: box.width, height: box.height) + } + + /// 8-bit luma of the stored pixels. Drawn in the image's own RGB color space when it has one, + /// so the values are the file's, as PIL reads them, not color-matched. + private static func luma(of image: CGImage) -> MeterGrayImage? { + let width = image.width + let height = image.height + let space = image.colorSpace.flatMap { $0.model == .rgb ? $0 : nil } + ?? CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + let bytesPerRow = 4 * width + var bytes = [UInt8](repeating: 0, count: bytesPerRow * height) + let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return false } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drawn else { return nil } + return bytes.withUnsafeBytes { MeterGrayImage(rgbx: $0, width: width, height: height, bytesPerRow: bytesPerRow) } + } +} diff --git a/ios/HouseScan/Runtime/Practice/DeveloperSettings.swift b/ios/HouseScan/Runtime/Practice/DeveloperSettings.swift new file mode 100644 index 000000000..5e2423867 --- /dev/null +++ b/ios/HouseScan/Runtime/Practice/DeveloperSettings.swift @@ -0,0 +1,66 @@ +import Foundation +import HouseScanKit +import Observation +import StoreKit + +/// The developer options sheet's settings, and whether this install may show them. +/// +/// The switch lives in the app, not in the iOS Settings app: a Settings.bundle is a file in the +/// app bundle, and a TestFlight build is the same binary the App Store later ships, so its +/// switch would show in App Store installs too. Here the sheet and the switch appear only where +/// `PracticeMeter.isAvailable` says, decided at run time from where the install came from. +@MainActor +@Observable +final class DeveloperSettings { + static let shared = DeveloperSettings(simulateAppStore: LaunchOptions().simulateAppStore) + + /// The UserDefaults key the sheet's switch writes. A launch argument `-practiceMeter NO` + /// overrides it for one run (UserDefaults' argument domain), which the UI tests use. + static let practiceMeterKey = "practiceMeter" + + /// Where this install came from; `.unknown` until StoreKit answers. + private(set) var environment: PracticeMeter.InstallEnvironment + + init(simulateAppStore: Bool) { + if simulateAppStore { + environment = .appStore + return + } + #if DEBUG + environment = .development + #else + environment = .unknown + Task { [weak self] in + let found = await Self.storeEnvironment() + self?.environment = found + RuntimeLog.engine.info("install environment: \(found.rawValue, privacy: .public)") + } + #endif + } + + /// Whether the developer options are offered at all. + var isAvailable: Bool { PracticeMeter.isAvailable(in: environment) } + + /// Whether a scan started now is a practice scan. + var practiceMeterOn: Bool { + PracticeMeter.isOn(requested: UserDefaults.standard.bool(forKey: Self.practiceMeterKey), in: environment) + } + + /// StoreKit's signed app transaction says which server signed it: the sandbox for TestFlight, + /// production for the App Store, Xcode for StoreKit testing. Anything unverified or missing is + /// `.unknown`, which offers nothing. + nonisolated private static func storeEnvironment() async -> PracticeMeter.InstallEnvironment { + do { + guard case .verified(let transaction) = try await AppTransaction.shared else { return .unknown } + switch transaction.environment { + case .sandbox: return .testFlight + case .production: return .appStore + case .xcode: return .development + default: return .unknown + } + } catch { + RuntimeLog.engine.error("install environment unknown: \(String(describing: error), privacy: .public)") + return .unknown + } + } +} diff --git a/ios/HouseScan/Runtime/Practice/SampleMeterArt.swift b/ios/HouseScan/Runtime/Practice/SampleMeterArt.swift new file mode 100644 index 000000000..07d78cfab --- /dev/null +++ b/ios/HouseScan/Runtime/Practice/SampleMeterArt.swift @@ -0,0 +1,198 @@ +import CoreGraphics +import CoreText +import Foundation +import HouseScanKit +import ImageIO +import UniformTypeIdentifiers + +/// The practice meter's picture, drawn in code: a gray socket-meter box with a glass dome, the +/// made-up maker and the fake number (`PracticeMeter.printedLines`). The camera view draws it on +/// the wall at the tap (`PracticeMeterOverlay`), and a practice close-up stores a photo of it on a +/// sided wall (`closeUpJPEG`), which the meter-number reader then reads. +/// +/// Only Core Graphics, Core Text and ImageIO, with fonts both iOS and macOS ship, so the same +/// drawing renders on either. +enum SampleMeterArt { + /// Width over height of the drawn box, as `PracticeMeter.plateSize`. + static let aspect = CGFloat(PracticeMeter.plateSize.x / PracticeMeter.plateSize.y) + + /// The box alone on a clear background, upright, for the camera view. 600 px wide: sharp at + /// the size the close-up holds it on screen, about half the screen's width. + static let face: CGImage? = { + let width = 600 + let height = Int((CGFloat(width) / aspect).rounded()) + guard let context = uprightContext(width: width, height: height, opaque: false) else { return nil } + drawMeter(in: context, rect: CGRect(x: 0, y: 0, width: width, height: height)) + return context.makeImage() + }() + + /// A close-up photo of the sample on a sided wall, as the camera stores one: the landscape + /// sensor image of `width` by `height` pixels, unrotated, whose upright view is it turned 90° + /// clockwise (`ImageWork.uprightThumbnail`, and the reader's EXIF `.right`). The practice + /// close-up passes the live frame's size, so the stored photo's pixels still match the + /// frame's intrinsics. Nil when a context can't be made. + static func closeUpJPEG(width: Int, height: Int) -> Data? { + // Upright, the photo is portrait: the sensor's height across, its width down. + let uprightWidth = height + let uprightHeight = width + guard width > 0, height > 0, let upright = uprightContext(width: uprightWidth, height: uprightHeight, opaque: true) else { return nil } + let size = CGSize(width: uprightWidth, height: uprightHeight) + drawWall(in: upright, size: size) + // The box fills 78% of the photo's height, as a close-up held at the gate's distance does. + let boxHeight = size.height * 0.78 + let box = CGRect(x: (size.width - boxHeight * aspect) / 2, y: size.height * 0.47 - boxHeight / 2, width: boxHeight * aspect, height: boxHeight) + drawConduit(in: upright, below: box, size: size) + upright.saveGState() + upright.setShadow(offset: CGSize(width: 0, height: box.height * 0.02), blur: box.height * 0.05, color: CGColor(gray: 0, alpha: 0.35)) + upright.addPath(boxPath(box)) + upright.setFillColor(gray: 0.6, alpha: 1) + upright.fillPath() + upright.restoreGState() + drawMeter(in: upright, rect: box) + guard let image = upright.makeImage(), + let sensor = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return nil } + // Turned 90° counterclockwise: the inverse of `ImageWork.rotatedClockwise`, so turning the + // stored image clockwise gives the upright photo back. + sensor.translateBy(x: CGFloat(width), y: 0) + sensor.rotate(by: .pi / 2) + sensor.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + guard let stored = sensor.makeImage() else { return nil } + return jpeg(stored) + } + + // MARK: Drawing + + /// An sRGB context whose user space runs y down from the top left, as the layout below is + /// written, with Core Text set to draw upright glyphs in it. + private static func uprightContext(width: Int, height: Int, opaque: Bool) -> CGContext? { + guard let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: (opaque ? CGImageAlphaInfo.noneSkipLast : CGImageAlphaInfo.premultipliedLast).rawValue + ) else { return nil } + context.translateBy(x: 0, y: CGFloat(height)) + context.scaleBy(x: 1, y: -1) + context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) + return context + } + + private static func boxPath(_ box: CGRect) -> CGPath { + CGPath(roundedRect: box, cornerWidth: box.width * 0.08, cornerHeight: box.width * 0.08, transform: nil) + } + + /// Beige lap siding: a board every 4.5% of the height, each with a shadow line at its lower + /// edge. + private static func drawWall(in context: CGContext, size: CGSize) { + context.setFillColor(red: 0.86, green: 0.83, blue: 0.77, alpha: 1) + context.fill(CGRect(origin: .zero, size: size)) + let board = size.height * 0.045 + var y = board + while y < size.height { + context.setFillColor(red: 0.74, green: 0.71, blue: 0.65, alpha: 1) + context.fill(CGRect(x: 0, y: y - board * 0.08, width: size.width, height: board * 0.08)) + context.setFillColor(red: 0.91, green: 0.89, blue: 0.84, alpha: 1) + context.fill(CGRect(x: 0, y: y, width: size.width, height: board * 0.06)) + y += board + } + } + + /// The conduit that feeds the meter, from under the box to the bottom of the photo. + private static func drawConduit(in context: CGContext, below box: CGRect, size: CGSize) { + let pipe = CGRect(x: box.midX - box.width * 0.07, y: box.maxY - box.height * 0.05, width: box.width * 0.14, height: size.height - box.maxY + box.height * 0.05) + context.setFillColor(gray: 0.55, alpha: 1) + context.fill(pipe) + context.setFillColor(gray: 0.68, alpha: 1) + context.fill(CGRect(x: pipe.minX + pipe.width * 0.2, y: pipe.minY, width: pipe.width * 0.18, height: pipe.height)) + } + + /// The meter box filling `rect` (2:3), in an upright context. + static func drawMeter(in context: CGContext, rect box: CGRect) { + let w = box.width + let h = box.height + func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: box.minX + x * w, y: box.minY + y * h) } + let space = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + + // Box: brushed gray, lighter at the top, with a dark edge and a pale inner bevel. + context.saveGState() + context.addPath(boxPath(box)) + context.clip() + if let gradient = CGGradient(colorsSpace: space, colors: [CGColor(gray: 0.80, alpha: 1), CGColor(gray: 0.62, alpha: 1)] as CFArray, locations: [0, 1]) { + context.drawLinearGradient(gradient, start: point(0.5, 0), end: point(0.5, 1), options: []) + } + context.restoreGState() + context.addPath(boxPath(box.insetBy(dx: w * 0.03, dy: w * 0.03))) + context.setStrokeColor(gray: 1, alpha: 0.35) + context.setLineWidth(w * 0.008) + context.strokePath() + context.addPath(boxPath(box.insetBy(dx: w * 0.006, dy: w * 0.006))) + context.setStrokeColor(gray: 0.38, alpha: 1) + context.setLineWidth(w * 0.012) + context.strokePath() + for (x, y) in [(0.09, 0.06), (0.91, 0.06), (0.09, 0.94), (0.91, 0.94)] { + let center = point(x, y) + let r = w * 0.025 + context.setFillColor(gray: 0.5, alpha: 1) + context.fillEllipse(in: CGRect(x: center.x - r, y: center.y - r, width: 2 * r, height: 2 * r)) + context.setStrokeColor(gray: 0.3, alpha: 1) + context.setLineWidth(r * 0.35) + context.strokeLineSegments(between: [CGPoint(x: center.x - r * 0.6, y: center.y), CGPoint(x: center.x + r * 0.6, y: center.y)]) + } + + // Glass dome on a metal collar. + let dome = point(0.5, 0.36) + let collar = w * 0.44 + let glass = w * 0.40 + context.setFillColor(gray: 0.5, alpha: 1) + context.fillEllipse(in: CGRect(x: dome.x - collar, y: dome.y - collar, width: 2 * collar, height: 2 * collar)) + context.saveGState() + context.addEllipse(in: CGRect(x: dome.x - glass, y: dome.y - glass, width: 2 * glass, height: 2 * glass)) + context.clip() + if let gradient = CGGradient(colorsSpace: space, colors: [CGColor(gray: 0.99, alpha: 1), CGColor(gray: 0.86, alpha: 1)] as CFArray, locations: [0, 1]) { + context.drawRadialGradient(gradient, startCenter: CGPoint(x: dome.x - glass * 0.3, y: dome.y - glass * 0.35), startRadius: 0, endCenter: dome, endRadius: glass, options: []) + } + context.restoreGState() + + // The face: maker, practice warning, display and ratings. + let lines = PracticeMeter.printedLines + drawText(lines[0], font: "HelveticaNeue-Bold", size: w * 0.07, color: CGColor(gray: 0.12, alpha: 1), centeredAt: point(0.5, 0.228), in: context) + drawText(lines[1], font: "HelveticaNeue-Bold", size: w * 0.045, color: CGColor(srgbRed: 0.78, green: 0.30, blue: 0.05, alpha: 1), centeredAt: point(0.5, 0.27), in: context) + let display = CGRect(x: box.minX + w * 0.25, y: box.minY + h * 0.3, width: w * 0.5, height: h * 0.07) + context.setFillColor(red: 0.16, green: 0.20, blue: 0.18, alpha: 1) + context.fill(display) + drawText(lines[2], font: "Menlo-Bold", size: w * 0.055, color: CGColor(srgbRed: 0.62, green: 0.93, blue: 0.70, alpha: 1), centeredAt: CGPoint(x: display.midX, y: display.midY), in: context) + drawText(lines[3], font: "HelveticaNeue", size: w * 0.045, color: CGColor(gray: 0.25, alpha: 1), centeredAt: point(0.5, 0.435), in: context) + + // The number on its own white label under the dome, as utilities stick it on. + let label = CGRect(x: box.minX + w * 0.05, y: box.minY + h * 0.74, width: w * 0.9, height: h * 0.13) + context.addPath(CGPath(roundedRect: label, cornerWidth: w * 0.02, cornerHeight: w * 0.02, transform: nil)) + context.setFillColor(gray: 0.98, alpha: 1) + context.fillPath() + drawText(lines[4], font: "Menlo-Bold", size: w * 0.115, color: CGColor(gray: 0.08, alpha: 1), centeredAt: CGPoint(x: label.midX, y: label.midY), in: context) + } + + /// One line centered on `center`, its baseline placed so the line's ink box is centered. + private static func drawText(_ text: String, font name: String, size: CGFloat, color: CGColor, centeredAt center: CGPoint, in context: CGContext) { + let font = CTFontCreateWithName(name as CFString, size, nil) + let attributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key(kCTFontAttributeName as String): font, + NSAttributedString.Key(kCTForegroundColorAttributeName as String): color, + ] + let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: attributes)) + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + let width = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, nil)) + context.textPosition = CGPoint(x: center.x - width / 2, y: center.y + (ascent - descent) / 2) + CTLineDraw(line, context) + } + + private static func jpeg(_ image: CGImage) -> Data? { + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.9] as CFDictionary) + return CGImageDestinationFinalize(destination) ? data as Data : nil + } +} diff --git a/ios/HouseScan/Runtime/ReplayPlayer.swift b/ios/HouseScan/Runtime/ReplayPlayer.swift new file mode 100644 index 000000000..47b18dcfa --- /dev/null +++ b/ios/HouseScan/Runtime/ReplayPlayer.swift @@ -0,0 +1,191 @@ +import CoreGraphics +import Foundation +import HouseScanKit +import OSLog +import simd + +/// Plays a recorded measure-lab-session v2 folder (contract C3) as if it were the camera. +/// +/// Frames are delivered at their recorded timing divided by `speed`, each with its own pose and +/// intrinsics, and its LiDAR depth when the session recorded some (`keyframes[].depth`, read by +/// `ReplaySession.loadDepth`), through the same engine path as live frames. JPEGs and depth are +/// decoded off the main actor. +@MainActor +final class ReplayPlayer { + let folder: URL + let session: ReplaySession + var frames: [ReplayFrame] { session.frames } + let planned: [PlannedFrame] + /// The wall the replay measures: the recorded one, or one assumed from the trajectory. + let wall: WallFrame + let wallDescription: String + /// True when the ground height comes from the recording's wall taps; an assumed wall puts + /// the ground 1.4 m under the mean camera height, a guess. + let groundMeasured: Bool + /// Frames the autopilot holds back from the walk for the gap loop, once prepared. + private(set) var heldBack: ReplayPlanning.HeldBackWindow? + private(set) var isPlaying = false + private(set) var shownIndex: Int? + + private let onFrame: @MainActor (SourceFrame) -> Void + private var task: Task? + + /// What loading a replay produces: the session, its frames as cameras, and its wall. + struct Loaded: Sendable { + let session: ReplaySession + let planned: [PlannedFrame] + let wall: WallFrame + let wallDescription: String + let groundMeasured: Bool + } + + /// Reads session.json and settles the wall. Deriving an assumed wall projects every frame + /// against many candidate walls, so call this off the main actor. + nonisolated static func load(folder: URL) throws -> Loaded { + let session = try ReplaySession.load(folder: folder) + let planned = session.frames.map { + PlannedFrame( + camera: CameraFrame(cameraToWorld: $0.cameraToWorld, intrinsics: $0.intrinsics, imageSize: SIMD2(Float($0.width), Float($0.height))), + timestamp: $0.timestamp, trackingNormal: $0.trackingNormal + ) + } + if let declared = session.declaredWall, let frame = WallFrame(meter: declared.meter, outward: declared.outward, groundY: declared.groundY) { + return Loaded(session: session, planned: planned, wall: frame, wallDescription: "recorded (wall taps in session.json)", groundMeasured: true) + } + guard let assumed = ReplayPlanning.assumedWall(frames: planned) else { throw ReplayError.noFrames } + let description = String( + format: "assumed from the trajectory: parallel to the walk, %.2f m to the side the camera faces, meter where the walk covers most, %d cells covered within 20 ft of it with every frame; not a measured wall", + assumed.offset, assumed.coveredCells + ) + return Loaded(session: session, planned: planned, wall: assumed.wall, wallDescription: description, groundMeasured: false) + } + + /// One frame's depth, read off the main actor; nil when the session recorded none for it. A + /// frame whose depth files are missing or the wrong size plays without depth, logged. + nonisolated private static func loadDepth(_ frame: ReplayFrame, folder: URL) -> DepthImage? { + do { + return try ReplaySession.loadDepth(for: frame, folder: folder) + } catch { + RuntimeLog.engine.error("replay depth for \(frame.id, privacy: .public) unreadable: \(String(describing: error), privacy: .public)") + return nil + } + } + + init(folder: URL, loaded: Loaded, onFrame: @escaping @MainActor (SourceFrame) -> Void) { + self.folder = folder + self.onFrame = onFrame + session = loaded.session + planned = loaded.planned + wall = loaded.wall + wallDescription = loaded.wallDescription + groundMeasured = loaded.groundMeasured + } + + /// Finds the frames to hold back for the autopilot's gap loop. Heavy, so it runs off the + /// main actor; call once before the walk. + /// Planned over the walk's frames only: the recording's closing tilt-up run is played for the + /// tilt-up step and for requests above the walk (`ScanEngine.tiltUpFrames`), never in the walk. + /// With `endsWherePhoneStood`, against the ends a walk ended by "Can't get there" sets, + /// 1 m inside them (the cells skipped there are within 0.5 m of the phone). + func prepareHeldBack(endsWherePhoneStood: Bool = false) async { + let walkEnd = ScanEngine.tiltUpFrames(in: self, map: CoverageMap(wall: wall)).lowerBound + let planned = Array(planned[.. CameraFrame { planned[index].camera } + + // MARK: Playback + + /// Plays `range` in order, skipping `excluding`, at `speed` times the recorded pace. + func play(range: Range, excluding: Range? = nil, speed: Double) { + stop() + let indices = range.filter { !(excluding?.contains($0) ?? false) } + guard !indices.isEmpty else { return } + isPlaying = true + let frames = frames + let folder = folder + task = Task { [weak self] in + var previous: Double? + for index in indices { + let frame = frames[index] + let url = folder.appending(path: frame.imagePath) + let started = ContinuousClock.now + let (decoded, frameDepth) = await Task.detached(priority: .userInitiated) { + (ImageWork.decode(url), Self.loadDepth(frame, folder: folder)) + }.value + if let previous { + let wait = Duration.seconds(max(0, (frame.timestamp - previous) / speed)) - (ContinuousClock.now - started) + if wait > .zero { try? await Task.sleep(for: wait) } + } + previous = frame.timestamp + guard !Task.isCancelled, let self else { return } + self.deliver(index: index, decoded: decoded, depth: frameDepth, isReview: false) + } + self?.isPlaying = false + } + } + + /// Shows one frame for tapping or review, outside auto-capture. + func show(index: Int) { + stop() + guard frames.indices.contains(index) else { return } + let url = folder.appending(path: frames[index].imagePath) + task = Task { [weak self] in + let decoded = await Task.detached(priority: .userInitiated) { ImageWork.decode(url) }.value + guard !Task.isCancelled else { return } + self?.deliver(index: index, decoded: decoded, depth: nil, isReview: true) + } + } + + /// Waits until the frame being shown is `index` (after `show`). + func waitUntilShown(_ index: Int) async { + for _ in 0..<100 where shownIndex != index { + try? await Task.sleep(for: .milliseconds(20)) + } + } + + func stop() { + task?.cancel() + task = nil + isPlaying = false + } + + /// A frame shown for review is never kept, so it goes without depth. + private func deliver(index: Int, decoded: (CGImage, FrameQuality)?, depth: DepthImage?, isReview: Bool) { + let frame = frames[index] + shownIndex = index + onFrame(SourceFrame( + id: frame.id, + timestamp: frame.timestamp, + camera: planned[index].camera, + tracking: frame.trackingNormal ? .normal : .limited(.unknown), + quality: decoded?.1, + jpeg: .file(folder.appending(path: frame.imagePath)), + still: decoded?.0, + meterAnchor: nil, + depth: depth, + isReview: isReview + )) + } + + // MARK: Choosing frames + + /// The frame that shows `point` best: in front of the camera, inside the image with the + /// largest margin, preferring nearer cameras. + func bestFrame(showing point: SIMD3) -> Int? { + var best: (index: Int, score: Float)? + for index in planned.indices { + let camera = planned[index].camera + guard let pixel = camera.pixel(of: point), camera.contains(pixel: pixel, margin: 0.1) else { continue } + let centered = simd_length((pixel - camera.imageSize / 2) / camera.imageSize) + let score = centered + 0.05 * simd_distance(camera.position, point) + if score < best?.score ?? .infinity { best = (index, score) } + } + return best?.index + } +} diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift new file mode 100644 index 000000000..09a00d3f2 --- /dev/null +++ b/ios/HouseScan/Runtime/ResultClient.swift @@ -0,0 +1,160 @@ +import Foundation +import HouseScanKit + +/// Sends a scan's scene.json to the placement server and returns the result JSON (contract C2). +@MainActor +protocol ResultClient: AnyObject { + /// True when the answer is the bundled sample, not a server's analysis. + var isSample: Bool { get } + func submit(scene: Data, progress: @escaping @Sendable (Double) -> Void) async throws -> Data +} + +/// Posts scene.json to the placement server: `POST {serverURL}/v1/placements` with the JSON as +/// the body and `Content-Type: application/json`. Only the JSON goes: the server's solver reads no +/// photos and skips the image check for a bare scene.json, so the keyframes stay on the phone +/// (and in the scan folder's `scan.zip`, which leaves only if the homeowner shares it). The +/// response body is the result JSON. +@MainActor +final class HTTPResultClient: ResultClient { + let serverURL: URL + let isSample = false + + init(serverURL: URL) { + self.serverURL = serverURL + } + + nonisolated static func makeRequest(serverURL: URL) -> URLRequest { + var request = URLRequest(url: serverURL.appending(path: "v1/placements")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 120 + return request + } + + func submit(scene: Data, progress: @escaping @Sendable (Double) -> Void) async throws -> Data { + let request = Self.makeRequest(serverURL: serverURL) + let delegate = UploadProgressDelegate(progress: progress) + let (data, response) = try await URLSession.shared.upload(for: request, from: scene, delegate: delegate) + guard let http = response as? HTTPURLResponse else { throw UploadError.notHTTP } + guard (200..<300).contains(http.statusCode) else { + throw UploadError.server( + status: http.statusCode, body: String(decoding: data.prefix(300), as: UTF8.self), + retryAfter: http.value(forHTTPHeaderField: "Retry-After")) + } + progress(1) + return data + } +} + +enum UploadError: Error, CustomStringConvertible { + case notHTTP + case server(status: Int, body: String, retryAfter: String? = nil) + case missingSample + + var description: String { + switch self { + case .notHTTP: "The server's answer was not HTTP." + case .server(let status, let body, _): "The server answered \(status): \(body)" + case .missingSample: "SampleResult.json is missing from the app bundle." + } + } +} + +/// What the homeowner reads about a failed upload. Which failures are worth sending again is +/// decided (and tested) in HouseScanKit's `UploadFailureKind`; the technical detail goes to the +/// log, and the screen never shows raw error text. +enum UploadFailure { + /// A failure while sending or reading the answer (not while packaging the scan). + static func state(for error: any Error) -> UploadState { + let kind: UploadFailureKind = if case .server(let status, _, let retryAfter) = error as? UploadError { + UploadFailureKind.classify(httpStatus: status, retryAfter: retryAfter) + } else { + UploadFailureKind.classify(error) + } + return switch kind { + case .offline: + .failed(message: "Your phone isn't connected to the internet. Your scan is saved on this phone.", offline: true) + case .unreachable: + .failed(message: "We couldn't reach the House Scan server. Your scan is saved on this phone, so you can try again.", offline: false) + case .serverError: + .failed(message: "The House Scan server had a problem. Your scan is saved on this phone, so you can try again.", offline: false) + case .busy(let retryAfter): + .failed(message: busyMessage(retryAfter), offline: false) + case .refused: + .rejected(message: "The server couldn't use this scan. Go back to the review to check your marks, or start over.") + case .unreadableAnswer: + .rejected(message: "We couldn't read the server's answer. Go back to the review and send it again, or start over.") + } + } + + /// A busy server: when it said how long to wait (at most a day, `UploadFailureKind`), the + /// homeowner hears that, rounded up to a minute past 90 seconds; either way the scan is kept + /// and "Try again" is offered at once. + static func busyMessage(_ retryAfter: Int?) -> String { + let wait: String? = retryAfter.map { raw in + let seconds = min(max(raw, 1), UploadFailureKind.maxRetryAfterSeconds) + let minutes = seconds / 60 + (seconds % 60 == 0 ? 0 : 1) + return seconds <= 90 ? "about \(seconds) seconds" : "about \(minutes) minutes" + } + let when = wait.map { "Try again in \($0)." } ?? "Try again in a moment." + return "The House Scan server is busy. Your scan is saved on this phone. \(when)" + } + + /// The scan couldn't be turned into scene.json. A driveway or fence that has to be marked + /// again says which, so the homeowner knows what to fix in the review. + static func packaging(_ error: any Error) -> UploadState { + switch error as? ScanEngine.ExportError { + case .markCollapsed(.driveway)?: + .rejected(message: "Mark the driveway again: its two points came out on top of each other.") + case .markCollapsed(.fence)?: + .rejected(message: "Mark the fence again: its two points came out on top of each other.") + default: + .rejected(message: "This scan couldn't be prepared for sending. Go back to the review to check your marks, or start over.") + } + } +} + +final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable { + private let progress: @Sendable (Double) -> Void + + init(progress: @escaping @Sendable (Double) -> Void) { + self.progress = progress + } + + /// Once the whole body is sent the server is working out the spot in the same request, so + /// report 1 and let the screen move on to "Check clearances". Before that, stop at 0.99 so + /// rounding never shows 100%. Holding 0.99 until the answer left the screen on "Sending + /// measurements, 99%" for the whole analysis (field test run 1). + func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + guard totalBytesExpectedToSend > 0 else { return } + if totalBytesSent >= totalBytesExpectedToSend { + progress(1) + } else { + progress(min(0.99, Double(totalBytesSent) / Double(totalBytesExpectedToSend))) + } + } +} + +/// Answers with the bundled SampleResult.json, for tests and demos without a server. The result +/// is flagged `isSample` so the screen says it is not a real analysis. +@MainActor +final class SampleResultClient: ResultClient { + let isSample = true + /// Seconds the fake upload takes, so each upload state is visible in demos and UI tests. + let pace: Double + + init(pace: Double) { + self.pace = pace + } + + func submit(scene: Data, progress: @escaping @Sendable (Double) -> Void) async throws -> Data { + for step in 1...4 { + try await Task.sleep(for: .seconds(pace / 5)) + progress(Double(step) / 4) + } + try await Task.sleep(for: .seconds(pace)) + guard let url = Bundle.main.url(forResource: "SampleResult", withExtension: "json") else { throw UploadError.missingSample } + return try Data(contentsOf: url) + } +} diff --git a/ios/HouseScan/Runtime/SampleResult.json b/ios/HouseScan/Runtime/SampleResult.json new file mode 100644 index 000000000..3f6b2055b --- /dev/null +++ b/ios/HouseScan/Runtime/SampleResult.json @@ -0,0 +1,126 @@ +{ + "schema_version": "1.0", + "decision": "manual_review", + "summary": "Sample result for offline demos, not a real analysis: the spot size (2.5 ft wide, 1.0 ft deep, 4.5 ft tall) and every threshold here are illustrative values, not rules.", + "reasons": [ + {"code": "policy_not_approved", "message": "Sample: no approved policy is loaded, so a person makes the final call."}, + {"code": "unsure_checks", "message": "Sample: two checks at this spot are unsure.", "checks": ["window_clearance", "front_clearance"]}, + {"code": "unobserved_area", "message": "Sample: the ground in front of the spot was not seen.", "checks": ["front_clearance"]} + ], + "policy": { + "id": null, + "version": null, + "auto_approve": false, + "sources": ["public"], + "rules_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "spot": { + "outcome": "unsure", + "wall_id": "wall", + "segment": 0, + "span_ft": [3.0, 5.5], + "width_ft": 2.5, + "depth_ft": 1.0, + "height_ft": 4.5, + "footprint": [[3.0, 0.0], [5.5, 0.0], [5.5, 1.0], [3.0, 1.0]], + "center": [4.25, 0.5], + "along": [1.0, 0.0], + "outward": [0.0, 1.0], + "meter_offset_ft": [4.25, 0.5], + "route_length_ft": 3.0 + }, + "route": { + "outcome": "pass", + "length_ft": 3.0, + "plus_minus_ft": 0.3, + "height_ft": 1.5, + "polyline": [[0.0, 0.0], [3.0, 0.0]], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "gas_clearance", + "label": "Distance from the gas meter", + "outcome": "pass", + "reason": "Sample: the gas meter is well clear of the spot.", + "measured_ft": 6.2, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "objects[0] gas_meter", + "rule": {"key": "sample_gas_clearance_ft", "source": "Sample value, not a rule citation", "placeholder": true} + }, + { + "id": "window_clearance", + "label": "Distance from the window", + "outcome": "unsure", + "unsure_cause": "margin", + "reason": "Sample: the gap is within the measurement error of the limit, so a person must judge it.", + "measured_ft": 3.1, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "objects[1] window", + "rule": {"key": "sample_window_clearance_ft", "source": "Sample value, not a rule citation", "placeholder": true} + }, + { + "id": "front_clearance", + "label": "Clear space in front", + "outcome": "unsure", + "unsure_cause": "unobserved", + "reason": "Sample: the ground in front of the spot was not seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": {"key": "sample_front_clearance_ft", "source": "Sample value, not a rule citation", "placeholder": true} + }, + { + "id": "route_length", + "label": "Cable run from the meter", + "outcome": "pass", + "reason": "Sample: the cable run is short.", + "measured_ft": 3.0, + "plus_minus_ft": 0.3, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": {"key": "sample_route_length_ft", "source": "Sample value, not a rule citation", "placeholder": true} + } + ], + "nearest_considered": null, + "missing_evidence": [ + { + "kind": "band", + "band": "ground", + "span_ft": [3.0, 5.5], + "checks": ["front_clearance"], + "message": "Sample: film the ground in front of the spot." + }, + { + "kind": "past_end", + "side": "left", + "message": "Sample: walk past the left end of the wall to see whether it continues." + } + ], + "ends": { + "left": {"kind": "unexplored", "s_ft": -8.0, "point": [-8.0, 0.0]}, + "right": {"kind": "limit", "s_ft": 12.0, "point": [12.0, 0.0]} + }, + "sweep": [ + {"wall_id": "wall", "start_ft": [-8.0, -1.5], "outcome": "fail", "failing": ["gas_clearance"], "unsure": []}, + {"wall_id": "wall", "start_ft": [-1.0, 2.5], "outcome": "fail", "failing": ["meter_clearance"], "unsure": []}, + {"wall_id": "wall", "start_ft": [3.0, 6.0], "outcome": "unsure", "failing": [], "unsure": ["window_clearance", "front_clearance"]}, + {"wall_id": "wall", "start_ft": [6.5, 9.5], "outcome": "unsure", "failing": [], "unsure": ["front_clearance"]} + ], + "stats": { + "candidates": 36, + "pass": 0, + "unsure": 14, + "fail": 22, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift new file mode 100644 index 000000000..a2f858dc2 --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -0,0 +1,628 @@ +import CoreGraphics +import Foundation +import HouseScanKit +import OSLog +import simd +import SwiftUI + +extension ScanEngine: ScanActions { + func finishOnboarding() { + guard state.phase == .onboarding else { return } + startPracticeIfOn() + leaveOnboarding() + } + + func markMeter(at point: CGPoint?, viewSize: CGSize) { + guard state.phase == .findMeter else { return } + if let replay { + // A replay has no live surfaces to raycast; its wall comes from the recording (or is + // assumed from the trajectory, see ReplayPlayer.wallDescription). + let wall = replay.wall + meterTapCamera = nil + guard setWall(meter: wall.meter, outward: wall.outward, groundY: wall.groundY, groundMeasured: replay.groundMeasured) else { return } + markTimes[MarkKey.meter] = captureClock + go(.meterCloseUp) + return + } + guard let live = liveCapture, let frame = currentFrame else { return } + guard frame.tracking == .normal else { + state.guidance = .aimAtWallForMeter + return + } + let viewPoint = point ?? CGPoint(x: viewSize.width / 2, y: viewSize.height / 2) + // A detected vertical plane's own geometry first; failing that, ARKit's estimated vertical + // plane, whose wider error the export records. Never an infinite plane, which extends a + // fence or another wall past its edges. + guard let hit = live.raycastVerticalPlane(from: viewPoint) else { + state.guidance = .aimAtWallForMeter + RuntimeLog.engine.info("meter tap refused: no vertical plane") + return + } + // An estimated plane can put the wall far from the real one (#69): one out of reach, + // turned from the phone, or behind a detected plane is refused, and the homeowner is asked + // to let ARKit find the wall first. + if let refusal = MeterTap.refusal( + hit: hit.position, normal: hit.normal, source: hit.source, camera: frame.camera, planes: detectedWallPlanes + ) { + state.guidance = .aimAtWallForMeter + RuntimeLog.engine.info("meter tap refused: \(refusal.description, privacy: .public)") + return + } + meterPlaneSource = hit.source + meterTapCamera = frame.camera.position + var outward = SIMD3(hit.normal.x, 0, hit.normal.z) + if simd_dot(outward, frame.camera.position - hit.position) < 0 { outward = -outward } + // Until a horizontal plane shows up below the wall, the ground is a guess: a phone held at + // chest height, 1.4 m above it. `refineGround` replaces the guess as planes arrive. + let ground = groundBelow(hit.position, along: simd_normalize(simd_cross(-outward, SIMD3(0, 1, 0))), current: nil) + guard setWall(meter: hit.position, outward: outward, groundY: ground?.plane.y ?? frame.camera.position.y - 1.4, groundMeasured: ground != nil) else { + state.guidance = .aimAtWallForMeter + return + } + let groundNote = ground.map(Self.describe) ?? "a guess" + RuntimeLog.engine.info("meter marked on \(hit.source == .estimatedPlane ? "an estimated" : "a detected", privacy: .public) plane; ground from \(groundNote, privacy: .public)") + // The map carries the line's source: the export writes it and the walked clearance + // takes the server's error for it. + updateCoverage { $0.setWallLineSource(meterLineSource) } + setMeterAnchor(live.addMeterAnchor(at: hit.transform), pose: hit.transform) + markTimes[MarkKey.meter] = captureClock + go(.meterCloseUp) + } + + /// The ground at the wall of the meter at `meter`, running along `along`: a detected plane + /// the meter is a plausible height above, that reaches the wall's foot by the meter and isn't + /// furniture, floor-classified first, then the one under where the phone stood to mark the + /// meter, then the lowest (`GroundPlaneChoice`). With `current`, the ground already measured, + /// it is never raised more than `GroundPlaneChoice.maximumRaise`. Nil when no plane qualifies; + /// the ground stays as it is. + func groundBelow(_ meter: SIMD3, along: SIMD3, current: Float?) -> GroundPlaneChoice.Choice? { + GroundPlaneChoice.choose(meter: meter, along: along, phone: meterTapCamera, current: current, planes: detectedGroundPlanes) + } + + func skipCloseUp() { + guard state.phase == .meterCloseUp else { return } + state.closeUp = .skipped + state.meterNumber = .skipped + // A photo on disk (one the reader turned down, or one confirmed just before the skip) + // stays a plain photo of the scan: scene.json doesn't list it and the meter mark doesn't + // link it. + store.withdrawStill("meter_close.jpg") + RuntimeLog.engine.info("close-up skipped after \(self.state.closeUpFailedAttempts) failed attempts") + observeCloseUpView() + go(.wallWalk) + } + + /// The homeowner's pick of the meter number. The number stays on the phone, in + /// `state.meterNumber`: scene.json has no field for it and its schema allows no extra + /// properties, so the server never receives it. + func chooseMeterNumber(_ candidate: MeterNumberCandidate?) { + guard state.phase == .meterCloseUp, case .choose(let candidates) = state.meterNumber else { return } + guard let candidate else { + // "None of these": small characters mean the phone was too far for a clear read. + retakeCloseUp(currentMeterReadout?.numberTooSmall == true ? .numberTooSmall : .noNumber) + return + } + guard let chosen = candidates.first(where: { $0.id == candidate.id }) else { return } + state.meterNumber = .confirmed(chosen.text) + // The photo the number was read from is now the meter's close-up. A shot the reader or + // the homeowner turned down never gets here, so it is never listed as one. + store.acceptStill("meter_close.jpg") + RuntimeLog.engine.info("meter number confirmed (\(chosen.barcodeConfirmed ? "barcode-confirmed" : "text only", privacy: .public)), brand \(self.state.meterBrand == nil ? "none" : "kept", privacy: .public)") + observeCloseUpView() + finishCloseUp() + } + + func rejectMeterBrand() { + guard state.phase == .meterCloseUp, case .choose = state.meterNumber else { return } + state.meterBrand = nil + RuntimeLog.engine.info("meter brand rejected") + } + + /// Puts the close-up photo's view into coverage, under the same rules as a walk keyframe: a + /// cell counts once seen from two positions at least 0.25 m apart, and the view is replayed + /// with the kept keyframes whenever the wall moves (`CoverageMap.observedCameras`). + /// + /// Only when the close-up step ends (number confirmed, or skipped), because the photo on disk + /// is final then, and only the view `closeUpCredit` holds: the shot whose photo is on disk, + /// after the reader found it decodable and in focus. A skip after a photo the reader + /// rejected as blurry, or while a retake's photo is still saving or being read, adds nothing. + /// A photo in focus where only the number couldn't be read still counts. The map always + /// exists here: the close-up follows the meter mark, which sets the wall (`setWall`). No time + /// is passed, so the pose never joins the walked path: nothing is kept between the close-up + /// and the walk's first frame. + private func observeCloseUpView() { + guard let view = closeUpCredit.take() else { return } + var delta: CoverageMap.Delta? + updateCoverage { delta = $0.observe(view.camera, trackingNormal: true, depth: view.depth) } + RuntimeLog.capture.info("close-up view in coverage: \(delta?.newlySeen ?? 0) cells newly seen, \(delta?.newlyCovered ?? 0) newly covered") + } + + /// Marks a wall end during the walk, or, during a server past_end request, marks that + /// request's end again at wherever the wall is now seen to stop. + func markWallEnd(at point: CGPoint?, viewSize: CGSize) { + guard state.phase == .wallWalk || (state.phase == .gapRequest && pastEndSide != nil), + let wall = coverage?.wall, let frame = currentFrame else { return } + guard let hit = wallHit(point, viewSize: viewSize, frame: frame, wall: wall) else { return } + let side: WallSide = hit.s < 0 ? .left : .right + if state.phase == .gapRequest, side != pastEndSide { return } + // Unexplored until the homeowner says something blocks the wall there: an unanswered + // question must not tell the server the usable wall stops at this point. + state.endQuestion = side + state.endQuestionLeavesOut = nil + state.endQuestionLeavesOutSeen = false + setEnd(side, at: hit.s, kind: .unexplored) + } + + func answerWallEnd(turnsCorner: Bool) { + guard let side = state.endQuestion else { return } + state.endQuestion = nil + state.endQuestionLeavesOut = nil + state.endQuestionLeavesOutSeen = false + if wallEndKinds[side] != nil { setEndKind(side, turnsCorner ? .unexplored : .limit) } + // During the walk a corner is followed: the next wall is marked, and the walk goes on + // along it. Until then the end stays marked, as an unexplored corner. + if turnsCorner, state.phase == .wallWalk, wallEndKinds[side] != nil { + nextWallSide = side + nextWallRefusal = nil + } + if state.phase == .gapRequest, side == pastEndSide { settlePastEnd() } + if state.phase == .wallWalk, let frame = currentFrame { + resetGuidanceAfterSkip(camera: frame.camera, time: frame.timestamp) + } + } + + /// The wall round the corner: a raycast on a vertical plane under `point`, the same one the + /// meter tap uses. Where its line meets the current wall's line on the ground is the corner. + /// A corner that passes the checks (`CoverageMap.proposeCorner`) isn't followed yet: the + /// walk asks "Is this the next wall?" (`ScanViewState.nextWallConfirm`, #70) and + /// `confirmNextWall` follows it. + func markNextWall(at point: CGPoint?, viewSize: CGSize) { + guard state.phase == .wallWalk, let side = nextWallSide, pendingNextWall == nil, + let map = coverage, let frame = currentFrame else { return } + guard frame.tracking == .normal else { return refuseNextWall(side, .trackingNotReady, "tracking not normal") } + // A replay has no live surfaces to raycast, so it can't mark the next wall. + let viewPoint = point ?? CGPoint(x: viewSize.width / 2, y: viewSize.height / 2) + guard let hit = liveCapture?.raycastVerticalPlane(from: viewPoint) else { return refuseNextWall(side, .noSurface, "no vertical plane") } + var outward = SIMD3(hit.normal.x, 0, hit.normal.z) + if simd_dot(outward, frame.camera.position - hit.position) < 0 { outward = -outward } + // The new piece's line runs through the hit point facing the hit plane's normal, as the + // meter's does, so its source follows the same rule (`meterLineSource`). + let source = Self.lineSource(of: hit.source) + let proposed: Result = Result { () throws(CornerRefusal) in + try map.proposeCorner(side.walk, meeting: hit.position, outward: outward, source: source) + } + let proposal: CoverageMap.CornerProposal + switch proposed { + case .success(let found): proposal = found + case .failure(let refusal): return refuseNextWall(side, refusal) + } + let detected = hit.source == .detectedPlane + nextWallRefusal = nil + pendingNextWall = PendingNextWall( + side: side, point: hit.position, outward: outward, source: source, + detectedPlane: detected, s: proposal.corner.s, fromEnd: proposal.fromEnd + ) + state.guidance = .markNextWall(side: side, refusal: nil) + // The ring goes on the corner, not on the point marked: a surface behind the end post + // puts the corner past the post, where the homeowner can see it is wrong (#70, 3C). + state.target = map.wall.world(s: proposal.corner.s, height: Self.cornerRingHeight) + RuntimeLog.engine.info("next wall marked on the \(side.rawValue, privacy: .public): corner at s=\(proposal.corner.s), \(proposal.fromEnd) m from the end (\(detected ? "detected" : "estimated", privacy: .public) plane); asking to confirm") + } + + /// "Is this the next wall?": yes follows the corner, and the end on that side opens again and + /// the walk goes on along the new wall; no drops the marked wall and keeps looking (#70). + func confirmNextWall(_ isNextWall: Bool) { + guard state.phase == .wallWalk, let side = nextWallSide, let pending = pendingNextWall, pending.side == side else { return } + pendingNextWall = nil + let plane = pending.detectedPlane ? "detected" : "estimated" + guard isNextWall else { + state.target = nil + RuntimeLog.engine.info("next wall not confirmed on the \(side.rawValue, privacy: .public): corner at s=\(pending.s), \(pending.fromEnd) m from the end (\(plane, privacy: .public) plane); looking again") + return + } + var turned: Result = .failure(.notAWall) + updateCoverage { map in + turned = Result { () throws(CornerRefusal) in + try map.turnCorner(side.walk, meeting: pending.point, outward: pending.outward, source: pending.source) + } + } + let corner: WallCorner + switch turned { + case .success(let turn): corner = turn + case .failure(let refusal): return refuseNextWall(side, refusal) + } + // The end moves on with the walk; `clearEnd` also publishes the chain for the overlays. + clearEnd(side) + nextWallSide = nil + nextWallRefusal = nil + RuntimeLog.engine.info("corner followed on the \(side.rawValue, privacy: .public) at s=\(corner.s), \(pending.fromEnd) m from the end (\(plane, privacy: .public) plane), confirmed") + // Features tapped past the corner were placed on the old wall's line. + reprojectFeatures() + if let frame = currentFrame { resetGuidanceAfterSkip(camera: frame.camera, time: frame.timestamp) } + } + + /// "Back" on the next-wall step: the end stays marked, and the question about it comes back, + /// so "Something blocks it" or "The wall just ends" can still be picked (#70). + func cancelNextWall() { + guard state.phase == .wallWalk, let side = nextWallSide else { return } + // The next-wall request didn't happen: resolved before `nextWallSide` clears, which the + // log would read as met (`closingOutcome`), and withdrawn so a second "It turns a + // corner" logs a new one. + withdrawGuidance(.superseded) + nextWallSide = nil + nextWallRefusal = nil + state.target = nil + state.endQuestion = side + state.endQuestionLeavesOut = nil + state.endQuestionLeavesOutSeen = false + RuntimeLog.engine.info("next wall: back to the question about the \(side.rawValue, privacy: .public) end") + } + + private func refuseNextWall(_ side: WallSide, _ refusal: CornerRefusal) { + switch refusal { + case .nearlyParallel: refuseNextWall(side, .sameWall, "nearly parallel to the current wall") + case .notAWall: refuseNextWall(side, .noSurface, "not a wall") + case .implausible(let s): refuseNextWall(side, .notAtCorner, "the walls meet at s=\(s)") + } + } + + private func refuseNextWall(_ side: WallSide, _ refusal: NextWallRefusal, _ reason: String) { + nextWallRefusal = refusal + state.guidance = .markNextWall(side: side, refusal: refusal) + RuntimeLog.engine.info("next wall refused: \(reason, privacy: .public)") + } + + /// The export sends a type as patches over the ground the coverage saw, and "Not sure" as no + /// patch (`sceneJSON`). Every upload reads the latest answer. + func answerGround(_ answer: GroundAnswer) { + guard state.phase == .markFeatures else { return } + state.groundAnswer = answer + RuntimeLog.engine.info("ground answered: \(String(describing: answer), privacy: .public)") + } + + /// "Open sky or nothing overhead" records the tilt-up view for the export; "A roof edge, + /// porch or stairs" records nothing, so the server treats the stretch as unseen. During an + /// overhead gap request the answer settles the request either way (`settleOverheadGap`). + func answerOverhead(clear: Bool) { + guard state.overheadQuestion else { return } + switch state.phase { + case .wallWalk: + // Something overhead is an answer, not a view: the stretch goes to review unseen. + resolveGuidance(clear ? .met : .skipped) + settleTiltUp(clear: clear) + if let frame = currentFrame { + resetGuidanceAfterSkip(camera: frame.camera, time: frame.timestamp) + } + case .gapRequest: + settleOverheadGap(clear: clear) + default: + return + } + } + + func beginMarking(_ kind: FeatureKind) { + guard state.phase == .wallWalk || state.phase == .markFeatures else { return } + // A mark is a tap into the world frame. The review hides "Add something" while the phone + // has lost its place; this holds if a tap races the change. + guard state.phase != .markFeatures || !state.tracking.hasLostItsPlace else { return } + state.marking = MarkingState(kind: kind, step: 0, refusal: nil) + pendingTaps = [] + } + + func markFeaturePoint(at point: CGPoint?, viewSize: CGSize) { + guard var marking = state.marking, let wall = coverage?.wall, let frame = currentFrame else { return } + guard frame.tracking == .normal else { + marking.refusal = .trackingNotReady + state.marking = marking + return + } + let onGround = marking.kind == .driveway || marking.kind == .fence + let pixel = frame.projection.imagePixel(forViewPoint: point ?? CGPoint(x: viewSize.width / 2, y: viewSize.height / 2), in: viewSize) + let ray = frame.camera.ray(throughPixel: pixel) + if wall.wallPoint(frame.camera.position).out <= 0 { + marking.refusal = .wrongSide + state.marking = marking + return + } + let hit: WallPoint + if onGround { + guard let ground = wall.intersectGround(ray) else { + marking.refusal = .noSurface + state.marking = marking + return + } + // Past 8 m out a ground tap is not about this wall any more. + if ground.out > ObjectTap.maxGroundOut || ground.out < 0 { + marking.refusal = .tooFarFromWall + state.marking = marking + return + } + hit = ground + } else { + // A wall hit under the floor, or far along the wall from the phone, is where a ray aimed + // at the ground or nearly along the wall met the wall's plane: nothing the homeowner + // pointed at (#140). An AC unit stands on the ground in front of the wall, so a ray + // aimed at it meets the ground first: that's where it lands (#163). + let placement = ObjectTap.place( + ray, standsOnGround: marking.kind == .acUnit, camera: frame.camera.position, wall: wall, + reach: coverage?.config.maxDistance ?? CoverageConfig().maxDistance, groundError: coverage?.heightError ?? 0) + switch placement { + case .wall(let point): + hit = point + case .ground(let point): + RuntimeLog.engine.info("object tap on the ground: s=\(point.s) out=\(point.out)") + hit = point + case .refused(let refused): + RuntimeLog.engine.info("object tap refused: \(refused.description, privacy: .public)") + if case .tooFarOut = refused { + marking.refusal = .tooFarFromWall + } else { + marking.refusal = .noSurface + } + state.marking = marking + return + } + } + pendingTaps.append(hit) + marking.refusal = nil + marking.step += 1 + if marking.step < marking.kind.tapCount { + state.marking = marking + return + } + let marked = feature(marking.kind, taps: pendingTaps, wall: wall) + markTimes[MarkKey.feature(marked.id)] = captureClock + state.features.append(marked) + publishFeaturesPastEnds() + state.marking = nil + pendingTaps = [] + } + + private func feature(_ kind: FeatureKind, taps: [WallPoint], wall: WallFrame) -> MarkedFeature { + var marked = MarkedFeature(id: UUID(), kind: kind, span: 0...0, bottom: nil, top: nil, out: nil, points: taps.map { wall.world($0) }, opens: nil) + Self.project(&marked, onto: wall) + return marked + } + + /// Sets a feature's wall coordinates from its tapped world points. Run again whenever the + /// wall frame moves (meter anchor refined, ground measured), since only the world points + /// are what was tapped. + static func project(_ feature: inout MarkedFeature, onto wall: WallFrame) { + let taps = feature.points.map { wall.wallPoint($0) } + let ss = taps.map(\.s) + let span = (ss.min() ?? 0)...(ss.max() ?? 0) + switch feature.kind { + case .door, .window: + let heights = taps.map(\.height) + feature.span = span + feature.bottom = max(0, heights.min() ?? 0) + feature.top = heights.max() + case .gasMeter, .acUnit: + // One tap marks the object's middle; 0.3 m is a nominal width, not a measurement. + let s = ss.first ?? 0 + feature.span = (s - 0.15)...(s + 0.15) + case .driveway, .fence: + feature.span = span + // The nearer tap, as the export uses: the narrow end must not be overstated. + feature.out = taps.map(\.out).min() ?? 0 + } + } + + func cancelMarking() { + state.marking = nil + pendingTaps = [] + } + + func deleteFeature(_ id: UUID) { + state.features.removeAll { $0.id == id } + } + + func setWindowOpens(_ id: UUID, opens: Bool?) { + guard let index = state.features.firstIndex(where: { $0.id == id }) else { return } + state.features[index].opens = opens + state.features[index].opensNotSure = opens == nil + } + + func finishWalk() { + guard state.phase == .wallWalk, bothEndsMarked else { return } + if let map = coverage, map.endsTooClose, let left = map.leftEnd, let right = map.rightEnd { + // Ends closer than a battery is wide (`WallFrame.minWallLength`): both sides ended + // without walking, as "Wall ends here" or "Can't get there" at the meter does. + // Nothing between them to scan, and an end can't be moved, so both go and the walk + // goes on; the card says why. + RuntimeLog.engine.info("finish refused: ends at s=\(left) and s=\(right) are closer than \(WallFrame.minWallLength) m") + clearEnd(.left) + clearEnd(.right) + state.wallTooShort = true + if let frame = currentFrame { resetGuidanceAfterSkip(camera: frame.camera, time: frame.timestamp) } + return + } + // Done while a request is still up passes it by. + resolveGuidance(.superseded) + state.marking = nil + nextWallSide = nil + nextWallRefusal = nil + // Leaving with the overhead question unanswered records nothing. + if !tiltUpSettled { settleTiltUp(clear: false) } + go(.markFeatures) + } + + func confirmFeatures() { + guard state.phase == .markFeatures else { return } + runGapCheck() + } + + func skipGap() { + guard state.phase == .gapRequest else { return } + // The card said the space ends short of the walk-out line (#164): the same answer, with + // why in the log. + if let ends = state.gap?.spaceEnds { + skipCurrentGap(because: String(format: "the space ends about %.2f m out, short of the walk-out line at %.2f m", ends.at, ends.needed)) + } else { + skipCurrentGap() + } + } + + func showResultNow() { + guard state.phase == .gapRequest else { return } + stopGapRequests() + } + + func cannotAccessArea() { + switch state.phase { + case .gapRequest: + skipCurrentGap() + case .wallWalk: + guard coverage != nil, !state.endScanQuestion else { return } + let task = ScanEngine.name(state.guidance) + if isWalkTask, state.endQuestion == nil, state.marking == nil, !state.overheadQuestion, + state.nextWallConfirm == nil, walkRefusals.asksToEndScan(at: ScanEngine.refusalClock) { + // A second "Can't get there" on a walk card soon after the last ended a side: + // the homeowner may be trying to stop, so ask before ending this side too (#82). + // The task stays unresolved until the answer. + RuntimeLog.engine.info("cannot access area again during \(task, privacy: .public): asking to end the scan") + if let map = coverage { + // With nothing walked the ends would land too close to finish, and "Yes, end + // here" would only put the homeowner back on the walk. + state.endScanTooShort = WalkRefusals.endsTooClose(left: map.leftEnd ?? walkedEnd(.left), right: map.rightEnd ?? walkedEnd(.right)) + } + state.endScanQuestion = true + return + } + switch state.guidance { + case .aimAtGround, .aimAtWall, .seeBehind, .walk, .markEnd, .tiltUp, .markNextWall: + resolveGuidance(.cannotReach) + default: + return + } + switch state.guidance { + case .aimAtGround(let s): + updateCoverage { $0.markSkipped(.ground, (s - 0.5)...(s + 0.5)) } + case .aimAtWall(let s): + updateCoverage { $0.markSkipped(.wall, (s - 0.5)...(s + 0.5)) } + case .seeBehind(let s): + // Whatever is in the way can't be seen past: its hidden cells go to review. Only + // those: open cells beside them can still be seen and stay asked for. + updateCoverage { map in + for band in seeBehindBands { + for index in ScanEngine.hiddenCells(map, band: band, around: s) { map.markSkipped(band, map.cellRange(index)) } + } + } + case .walk(let side, _), .markEnd(let side): + // The walk can't continue this way: stop the wall where the phone is, as an + // unexplored end (`WalkedEnd`), where the strip's preview showed it. + endWalkCannotGoOn(side) + case .tiltUp: + settleTiltUp(clear: false) + case .markNextWall: + // Not following the corner: the end stays marked, as an unexplored corner. + nextWallSide = nil + nextWallRefusal = nil + default: + return + } + RuntimeLog.engine.info("cannot access area during \(task, privacy: .public)") + if let frame = currentFrame { + resetGuidanceAfterSkip(camera: frame.camera, time: frame.timestamp) + } + default: + return + } + } + + func answerEndScan(_ end: Bool) { + guard state.phase == .wallWalk, state.endScanQuestion else { return } + state.endScanQuestion = false + state.endScanTooShort = false + guard end else { + RuntimeLog.engine.info("end the scan here? keep walking") + walkRefusals.keepWalking() + return + } + RuntimeLog.engine.info("end the scan here? yes") + // The walk task the second "Can't get there" answered is met as refused. + if isWalkTask { resolveGuidance(.cannotReach) } + // Each side without an end ends where "Can't get there" would put it (`WalkedEnd`). + for side in [WallSide.left, .right] where (side == .left ? coverage?.leftEnd : coverage?.rightEnd) == nil { + endWalkCannotGoOn(side) + } + walkRefusals.keepWalking() + // Ends closer than a battery is wide are refused here as after "Done with this wall", + // and the walk goes on with the card saying so. + finishWalk() + } + + /// The walk asks to walk a side or to mark its end: the steps whose "Can't get there" ends + /// the wall on that side (`endWalkCannotGoOn`). + private var isWalkTask: Bool { + switch state.guidance { + case .walk, .markEnd: true + default: false + } + } + + func retryUpload() { + guard state.phase == .uploading else { return } + if case .failed = state.upload { startUpload() } + } + + /// Back to the feature review after a rejected upload. The scan (wall, coverage, keyframes, + /// features) stays; confirming the review runs the gap check and the upload again. + func backToReview() { + guard state.phase == .uploading, case .rejected = state.upload else { return } + state.upload = .idle + go(.markFeatures) + } + + func captureMissing(_ id: String) { + // Once the camera failed after the scan was sent, a capture would get no frames; the + // result screen doesn't offer one then either (ResultCardActions). + guard state.phase == .result || state.phase == .gapRequest || state.phase == .uploading, + state.spatialResultAvailable, + let missing = placement?.missingEvidence, + let index = Int(id.replacingOccurrences(of: "missing-", with: "")), + missing.indices.contains(index) else { return } + let item = missing[index] + guard let map = coverage, let plan = gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds) else { return } + beginServerGap(item, plan: plan) + } + + func showAR() { + // A wall neither side of which was walked has no spot to show (#76). + guard state.phase == .result, state.spatialResultAvailable, state.result?.wallNotMeasured != true else { return } + go(.resultAR) + } + + func closeAR() { + guard state.phase == .resultAR else { return } + go(.result) + } + + func startOver() { + releaseFailedSource() + resetAll() + } + + func liveCameraView() -> AnyView { + guard let live = liveCapture else { return AnyView(Color.black) } + return AnyView(LiveCameraView(arView: live.arView)) + } + + // MARK: Helpers + + /// Where a tap lands on the wall plane, in wall coordinates. + func wallHit(_ point: CGPoint?, viewSize: CGSize, frame: SourceFrame, wall: WallFrame) -> WallPoint? { + let pixel = frame.projection.imagePixel(forViewPoint: point ?? CGPoint(x: viewSize.width / 2, y: viewSize.height / 2), in: viewSize) + return nearbyWallHit(frame.camera.ray(throughPixel: pixel), camera: frame.camera, wall: wall) + } + + /// Where `ray` meets the wall, or nil when that is farther along the wall from the camera than + /// the coverage map lets a camera see (`maxDistance`). A ray nearly parallel to the wall meets + /// its line hundreds of meters away; an end placed there, or drawn on the strip, would be + /// nothing the homeowner pointed at. + func nearbyWallHit(_ ray: Ray, camera: CameraFrame, wall: WallFrame) -> WallPoint? { + guard let hit = wall.intersectWall(ray) else { return nil } + let reach = coverage?.config.maxDistance ?? CoverageConfig().maxDistance + return abs(hit.s - wall.wallPoint(camera.position).s) <= reach ? hit : nil + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift new file mode 100644 index 000000000..38045a056 --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift @@ -0,0 +1,189 @@ +import CoreGraphics +import Foundation +import HouseScanKit +import ImageIO +import OSLog + +/// The spot check's state on the engine (`ScanEngine.spotConfirm`). +struct SpotConfirmState { + /// Every answer of this scan, with what each was about. + var confirmations = SpotConfirmations() + /// The last upload's scene.json, and the sha256 of it and of the answer to it: what a check + /// asked now is bound to, and the scene the bundle is written again with after "It's clear". + var lastScene: Data? + var sceneSHA256: String? + var answerSHA256: String? + /// The check on screen: its area, the photo shown and the exchange it came from. + var pending: SpotConfirmation? + /// The check's entry in the guidance log while it is on screen. + var request: GuidanceLog.Request? + /// Checks asked in this scan, for their ids. + var asked = 0 +} + +/// The spot check: before an answer's spot is shown as the result, the homeowner is shown the +/// kept photo that best sees the spot and its clearance area (`SpotPhoto.best`) and asked whether +/// anything stands in front of the wall or on the ground there. +/// +/// Camera-only coverage and the walked path claim wall, ground and clear space they never saw +/// (HouseScanKit `CoverageMap`, "Bounded exceptions"). "It's clear" backs those claims for this +/// spot and shows the result. "Something's there" withdraws them over the area +/// (`CoverageMap.withdrawClaims`), so the scene reports it unseen, and uploads again, as a closed +/// gap does; the new answer's spot is asked about again unless an earlier answer already covers +/// it (`SpotConfirmations.settling`). Nothing new goes into scene.json: the answer shows only as +/// the stretch the scene no longer claims, and in the packet's guidance log. +/// +/// The bundled sample is checked like a server's answer, and the screen labels it a sample: the +/// UI tests and the offline demo only ever see the sample, so skipping it would leave the step +/// untested end to end, and "See it on your wall" already draws the sample spot on the real wall +/// with the same label. A refusal still withdraws the real scan's claims; the sample's answer +/// ignores them, and the result says what the homeowner answered. +/// +/// The homeowner could instead mark what stands there with the mark flow, but that needs the +/// camera tracking in the scan's world frame, and a bush, woodpile or bin has no mark kind or +/// scene.json object type to describe it. Withdrawing the claim works on every phone and says +/// only what is known: that stretch was not seen clear. +extension ScanEngine { + /// How long an answer stays on screen before the result or the upload: 1.2 s, as a closed + /// gap's check mark (`autoAdvanceDelay`), and the autopilot's hold when longer. Not measured. + private var spotAnsweredHold: Double { options.autopilot ? max(1.2, options.autopilotHold) : 1.2 } + + /// Records the upload's scene and the answer to it, which a check asked about this answer + /// is bound to. Call it once the answer has decoded. + func noteExchange(scene: Data, answer: Data) { + spotConfirm.lastScene = scene + spotConfirm.sceneSHA256 = PacketFiles.sha256(scene) + spotConfirm.answerSHA256 = PacketFiles.sha256(answer) + } + + /// Shows the answer once the gap loop is done with it: the spot check first when the answer + /// names a spot no earlier check settles, otherwise the result. + func presentAnswer() { + guard let result = state.result, let spot = result.spot, let wall = coverage?.wall, + let sceneSHA256 = spotConfirm.sceneSHA256, let answerSHA256 = spotConfirm.answerSHA256 else { + state.spotCheck = nil + go(.result) + return + } + let area = SpotArea( + spot: spot.span, spotOut: spot.offsetFromWall...(spot.offsetFromWall + spot.depth), + zones: result.clearances.map { (span: $0.span, depth: $0.depth) }) + spotConfirm.asked += 1 + let id = spotConfirm.asked + if let settled = spotConfirm.confirmations.settling(area) { + // Kept for the result, which says when the homeowner said something stands there. + state.spotCheck = Self.spotCheck(id: id, area: area, spot: spot, photo: nil, answer: Self.answer(settled.answer), isSample: result.isSample) + RuntimeLog.engine.info("spot check: s=\(area.spot.lowerBound)...\(area.spot.upperBound) was answered before (\(settled.answer.rawValue, privacy: .public)); showing the result") + go(.result) + return + } + let candidates = store.keyframes.map { SpotPhotoCandidate(id: $0.id, camera: $0.camera, trackingNormal: $0.tracking == .normal) } + let choice = SpotPhoto.best(candidates, area: area, wall: wall) + let stored = choice.flatMap { choice in store.keyframes.first { $0.id == choice.id } } + // `answer` is replaced by the homeowner's (`answerSpotCheck`) before it is recorded. + spotConfirm.pending = SpotConfirmation(area: area, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, photoID: stored?.id, answer: .clear) + spotConfirm.request = spotCheckRequest(area: area, photoID: stored?.id, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, id: id) + let file = stored.map { store.directory.appending(path: $0.fileName) } + if let choice { + RuntimeLog.engine.info("spot check: photo \(choice.id, privacy: .public) shows \(Int(choice.footprintInView * 100))% of the spot and \(Int(choice.areaInView * 100))% of s=\(area.span.lowerBound)...\(area.span.upperBound)") + } else { + RuntimeLog.engine.info("spot check: no kept photo shows s=\(area.span.lowerBound)...\(area.span.upperBound) from the front; asking without one") + } + Task { + let image = await Self.loadPhoto(file) + guard state.phase == .uploading, spotConfirm.asked == id, state.result == result else { return } + var photo: SpotCheck.Photo? + if let image, let camera = stored?.camera { + photo = SpotCheck.Photo(image: image, projection: CameraProjection( + cameraToWorld: camera.cameraToWorld, intrinsics: camera.intrinsics, imageSize: camera.imageSize)) + } + state.spotCheck = Self.spotCheck(id: id, area: area, spot: spot, photo: photo, answer: nil, isSample: result.isSample) + go(.spotConfirm) + } + } + + /// The homeowner's answer to the spot check on screen. + func answerSpotCheck(clear: Bool) { + guard state.phase == .spotConfirm, var check = state.spotCheck, check.answer == nil, var confirmation = spotConfirm.pending else { return } + confirmation.answer = clear ? .clear : .somethingThere + spotConfirm.confirmations.record(confirmation) + spotConfirm.pending = nil + check.answer = Self.answer(confirmation.answer) + state.spotCheck = check + // A refusal sends the area to review without the claims, like an overhead answer. + resolveGuidance(clear ? .met : .skipped) + let area = confirmation.area + if !clear { updateCoverage { $0.withdrawClaims(over: area.span) } } + RuntimeLog.engine.info("spot check \(check.id): \(clear ? "clear" : "something there, claims withdrawn", privacy: .public) over s=\(area.span.lowerBound)...\(area.span.upperBound), photo \(confirmation.photoID ?? "none", privacy: .public), answer \(confirmation.answerSHA256.prefix(12), privacy: .public), scene \(confirmation.sceneSHA256.prefix(12), privacy: .public)") + let id = check.id + Task { + try? await Task.sleep(for: .seconds(spotAnsweredHold)) + guard state.phase == .spotConfirm, state.spotCheck?.id == id else { return } + guard clear else { + // The scan without those claims goes to the server again. + startUpload() + return + } + // The bundle was written at the upload, before this answer: write it again so its + // guidance log holds the answer. + if let scene = spotConfirm.lastScene { saveBundle(scene: scene, mesh: liveCapture?.meshSnapshot()) } + go(.result) + } + } + + /// Whether a capture can settle a gap request: not over a stretch whose claims the homeowner + /// withdrew, where a new view would claim the same thing past the same obstruction. Overhead + /// requests are not about that stretch's wall or ground. + func captureCanSettle(_ plan: GapPlan) -> Bool { + if case .overhead = plan.need { return true } + return !(coverage?.hasWithdrawnClaims(overlapping: plan.span) ?? false) + } + + /// Forgets the scan's checks: they name spans along a wall that no longer exists. + func resetSpotChecks() { + spotConfirm = SpotConfirmState() + state.spotCheck = nil + } + + /// The check's guidance-log entry while it is on screen (`guidanceRequest`). + var spotCheckGuidance: GuidanceLog.Request? { spotConfirm.request } + + /// A gap_band request on the ground over the area. scene.json and the packet have no field + /// for the check, so the message carries what binds it: the photo shown and the sha256 of the + /// answer and scene it was about. + private func spotCheckRequest(area: SpotArea, photoID: String?, answerSHA256: String, sceneSHA256: String, id: Int) -> GuidanceLog.Request { + let format = { (value: Float) in String(format: "%.2f", value) } + let binding = "Spot check \(id): keyframe \(photoID ?? "none") shown; spot s \(format(area.spot.lowerBound)) to \(format(area.spot.upperBound)) m, " + + "area s \(format(area.span.lowerBound)) to \(format(area.span.upperBound)) m out to \(format(area.depth)) m; " + + "answer sha256 \(answerSHA256), scene sha256 \(sceneSHA256)." + return GuidanceLog.Request( + topic: .spotCheck(id: id), kind: .gapBand, origin: .phone, message: Self.text(ScanCopy.spotQuestion) + " " + binding, + band: .ground, span: area.span) + } + + private static func spotCheck(id: Int, area: SpotArea, spot: BatterySpot, photo: SpotCheck.Photo?, answer: SpotCheckAnswer?, isSample: Bool) -> SpotCheck { + SpotCheck( + id: id, spot: area.spot, spotOut: area.spotOut, spotHeight: spot.height, area: area.span, areaDepth: area.depth, + photo: photo, answer: answer, isSample: isSample) + } + + /// The photo at `url` as a landscape image no wider than 2048 px, decoded off the main actor. + private static func loadPhoto(_ url: URL?) async -> CGImage? { + guard let url else { return nil } + return await Task.detached(priority: .userInitiated) { () -> CGImage? in + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 2048, + ] + return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) + }.value + } + + private static func answer(_ answer: SpotConfirmationAnswer) -> SpotCheckAnswer { + switch answer { + case .clear: .clear + case .somethingThere: .somethingThere + } + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift new file mode 100644 index 000000000..5fda30149 --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -0,0 +1,175 @@ +import Foundation +import HouseScanKit +import OSLog +import simd + +/// Ending the wall during the walk without pointing at the end ("Can't get there", "Wall ends +/// here"), and the preview of where an end would land. +extension ScanEngine { + /// The side ending the wall applies to now, and whether the end lands at the reticle. The + /// walk's own side while it asks to walk or to mark the end; during a request about the wall + /// in front of the homeowner (tilt down, tilt up, step back), the side of the meter the phone + /// is on (`WalkedEnd.side`). Nil when both ends are marked or the walk is doing something else. + private var endOnOffer: (side: WallSide, atReticle: Bool)? { + guard state.phase == .wallWalk, state.marking == nil, state.endQuestion == nil, !state.overheadQuestion, + nextWallSide == nil, let map = coverage else { return nil } + switch state.guidance { + case .walk(let side, _): + return (side, false) + case .markEnd(let side): + return (side, true) + case .aimAtGround, .aimAtWall, .stepBack: + guard let side = WalkedEnd.side(phone: phonePosition, wall: map.wall, leftEnd: map.leftEnd, rightEnd: map.rightEnd) else { return nil } + return (side == .left ? .left : .right, false) + case .findMeter, .aimAtWallForMeter, .holdOnMeter, .walkComplete, .tiltUp, .markNextWall, .gap, .seeBehind: + return nil + } + } + + /// Where ending the walk on `side` puts the end now (`WalkedEnd`): the phone's place along the + /// wall on that side with tracking normal, else kept to what was walked on that side (#71). + /// The phone's place doesn't count while it has lost it. + func walkedEnd(_ side: WallSide) -> Float? { + walkedEndChoice(side)?.s + } + + /// `walkedEnd` with why it landed there, for the preview and the log. + private func walkedEndChoice(_ side: WallSide) -> WalkedEnd.Choice? { + guard let map = coverage else { return nil } + // Not debounced (review of #147): waiting for tracking to settle put run 2's end back at + // the meter after a brief drop. A pose that jumps as tracking returns is bounded by + // `WalkedEnd.maxPastEvidence` instead. + let trackingNormal = currentFrame?.tracking == .normal + return WalkedEnd.choose( + side.walk, phone: phonePosition, trackingNormal: trackingNormal, walked: map.walkedPositions, wall: map.wall, + seen: map.seenExtent + ) + } + + /// The strip's seen cells, when the cap decided the end: what an end short of them leaves + /// out is said (`WalkedEnd.leftOut`), not dropped silently (#71). Nil when the end is where + /// the phone stands, whose camera sees past it anyway; up to the phone when it is on that + /// side (`WalkedEnd.countedSeen`). + private func seenPastCap(_ side: WallSide, _ choice: WalkedEnd.Choice?, _ map: CoverageMap) -> ClosedRange? { + guard let choice else { return nil } + return WalkedEnd.countedSeen(side.walk, choice: choice, seen: map.seenExtent) + } + + /// Where the phone is; nil while it has lost its place. + private var phonePosition: SIMD3? { + currentFrame.flatMap { $0.tracking.hasLostItsPlace ? nil : $0.camera.position } + } + + /// The end preview for this moment (`ScanViewState.endPreview`). + func currentEndPreview() -> EndPreview? { + guard let offer = endOnOffer, let map = coverage, let frame = currentFrame else { return nil } + let side: WallSide + let s: Float + var seen: ClosedRange? + if offer.atReticle { + // `markWallEnd` with no point: the middle of the view, which is the sensor image's + // middle whatever the view's size. + guard frame.tracking == .normal, + let hit = nearbyWallHit(frame.camera.ray(throughPixel: frame.camera.imageSize / 2), camera: frame.camera, wall: map.wall) else { return nil } + side = hit.s < 0 ? .left : .right + s = hit.s + } else { + guard let choice = walkedEndChoice(offer.side) else { return nil } + side = offer.side + s = choice.s + seen = seenPastCap(offer.side, choice, map) + } + // What this end leaves out of the walk shows only while the walk asks to walk this side + // or mark its end, and not with the phone far out from the wall (#66). During a tilt or + // step-back request the dashed line and "Wall ends here" stay; the end question says what + // pressing it left out (`endWallHere`). + let onWalkTask: Bool + switch state.guidance { + case .walk, .markEnd: + onWalkTask = true + default: + onWalkTask = false + } + // The phone's distance from the wall matters only when the end is its place along it. + let phoneOut: Float? = offer.atReticle ? nil : phonePosition.map { map.wall.wallPoint($0).out } + let leavesOut = WalkedEnd.leavesOut( + side: side.walk, s: s, walked: map.walkedPositions, wall: map.wall, + phoneOut: phoneOut, onWalkTask: onWalkTask, seen: seen + ) + let isSeen = leavesOut != nil && WalkedEnd.leftOutIsSeen(side.walk, s: s, walked: map.walkedPositions, wall: map.wall, seen: seen) + return EndPreview(side: side, s: s, atReticle: offer.atReticle, leavesOutWalked: leavesOut, leavesOutSeen: isSeen) + } + + /// Republishes the end preview; called with every guidance update, since the phone moves. + func publishEndPreview() { + let preview = currentEndPreview() + if preview != state.endPreview { state.endPreview = preview } + } + + func endWallHere() { + guard let preview = currentEndPreview(), !preview.atReticle, let map = coverage else { return } + // Whatever the walk was asking, the end question says how much of the walk this end + // leaves out, now that the homeowner chose to end the wall here (#66). + let seen = seenPastCap(preview.side, walkedEndChoice(preview.side), map) + let leavesOut = WalkedEnd.leftOut(preview.side.walk, s: preview.s, walked: map.walkedPositions, wall: map.wall, seen: seen) + // The walk toward that side is met: the homeowner got to its end. + if case .walk(let side, _) = state.guidance, side == preview.side { resolveGuidance(.met) } + logEnd("wall ends here", side: preview.side, at: preview.s) + // Unexplored until the homeowner says something blocks the wall there, as for a marked end. + state.endQuestion = preview.side + state.endQuestionLeavesOut = leavesOut + state.endQuestionLeavesOutSeen = leavesOut != nil + && WalkedEnd.leftOutIsSeen(preview.side.walk, s: preview.s, walked: map.walkedPositions, wall: map.wall, seen: seen) + setEnd(preview.side, at: preview.s, kind: .unexplored) + } + + /// "Can't get there" while the walk asks to walk `side` or to mark its end: the end goes where + /// the preview showed, as an unexplored end. `walkRefusals` notes when, and whether the side + /// had been walked (#82, #76). + func endWalkCannotGoOn(_ side: WallSide) { + guard let s = walkedEnd(side), let map = coverage else { return } + logEnd("can't get there", side: side, at: s) + let walked = map.walkedFarthest(side.walk) + setEnd(side, at: s, kind: .unexplored) + walkRefusals.ended(side.walk, at: s, walked: walked, time: ScanEngine.refusalClock) + if walkRefusals.wasRefused(side.walk, end: s) { + RuntimeLog.engine.info("the \(side.rawValue, privacy: .public) side ended before it was walked (\(walked) m walked, end at s=\(s))") + } + } + + /// Seconds for `WalkRefusals`: the time since boot, which a replay's frame clock doesn't + /// hold back while its playback is paused. + static var refusalClock: TimeInterval { ProcessInfo.processInfo.systemUptime } + + /// Where the end went, and why (#71): the phone's place, the kept-photo cap, whether the cap + /// decided it, tracking, how many meters of cells the strip showed past the end, and what the + /// old rule, the unbroken covered reach, would have said. + private func logEnd(_ action: String, side: WallSide, at s: Float) { + guard let map = coverage else { return } + let phonePoint = currentFrame.map { map.wall.wallPoint($0.camera.position) } + let phone = phonePoint?.s ?? .nan + let out = phonePoint?.out ?? .nan + let tracking = currentFrame.map { String(describing: $0.tracking) } ?? "none" + let choice = walkedEndChoice(side) + let cap = choice?.cap ?? .nan + // "phone": at the phone; "2 m past evidence": short of it, `WalkedEnd.maxPastEvidence` + // past the farthest kept view or seen cell; "cap": the kept-photo cap. + let capped = choice?.pastEvidence == true ? "2 m past evidence" : choice?.capped == true ? "cap" : "phone" + let shown = WalkedEnd.shownPast(side.walk, s: s, seen: map.seenExtent, minimum: 0) ?? 0 + let covered = GuidancePlanner().reach(side.walk, coverage: map) + RuntimeLog.engine.info("\(action, privacy: .public) on the \(side.rawValue, privacy: .public): end at s=\(s), chose \(capped, privacy: .public) (phone at s=\(phone) \(out) m out, cap s=\(cap), tracking \(tracking, privacy: .public)); strip showed \(shown) m past it; covered reach \(covered) m") + } + + /// `ScanViewState.featuresPastEnds`, refreshed when the ends move (`publishWall`), the spans + /// follow the wall (`reprojectFeatures`) and a mark is added. + func publishFeaturesPastEnds() { + let left = coverage?.leftEnd + let right = coverage?.rightEnd + let past = Set(state.features.filter { WalkedEnd.liesPastAnEnd($0.span, leftEnd: left, rightEnd: right) }.map(\.id)) + if past != state.featuresPastEnds { state.featuresPastEnds = past } + } +} + +extension WallSide { + var walk: WalkSide { self == .left ? .left : .right } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift new file mode 100644 index 000000000..4a7d0b397 --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -0,0 +1,370 @@ +import Foundation +import HouseScanKit +import OSLog +import simd + +extension ScanEngine { + /// scene.json for the current scan (contract C1), with the LiDAR mesh's measurements on + /// phones that have one. + /// + /// The scene frame is the AR world frame moved down so the ground at the wall is y = 0: the + /// server reads heights (meter.pos y, pose translations) as height above that ground, and + /// scene.json has no field for the ground's height otherwise. + func sceneJSON(mesh: MeshMeasurements = MeshMeasurements()) throws -> Data { + guard let map = coverage else { throw ExportError.noWall } + let wall = map.wall + let drop = SIMD3(0, wall.groundY, 0) + // The corners carry their pieces' sources (`markNextWall`); the meter's piece, its own + // (`meterLineSource`, set on the map in `markMeter`). + let sceneWall = SceneWall( + meter: wall.meter - drop, outward: wall.outward, groundY: 0, leftCorners: wall.leftCorners, rightCorners: wall.rightCorners, + source: wall.source) + + // SceneExport rejects negative heights, a top below a bottom, ground points behind the + // wall and a zero-length driveway edge, and taps can produce each of them once the ground + // or the meter anchor moves after the tap (a window tapped wholly below a guessed ground, + // a fence foot that ends up behind the refined wall line). Such marks are clamped to the + // nearest valid shape here and logged. A driveway or fence with nothing valid left fails + // the export instead (`ExportError.markCollapsed`): dropping it would send the ground + // near it as seen and clear, which can pass its clearance check with the hazard unsent. + let features: [SceneFeature] = try state.features.map { feature in + let points = feature.points.map { $0 - drop } + switch feature.kind { + case .door, .window: + let heights = [feature.bottom ?? 0, feature.top ?? 0] + let bottom = max(0, heights.min() ?? 0) + let top = max(bottom, heights.max() ?? 0) + if bottom != feature.bottom || top != feature.top { + RuntimeLog.engine.info("export: \(feature.kind.rawValue, privacy: .public) heights \(feature.bottom ?? .nan)...\(feature.top ?? .nan) clamped to \(bottom)...\(top)") + } + return .opening(kind: feature.kind == .door ? .door : .window, span: feature.span, bottom: bottom, top: top, + operable: feature.kind == .window ? feature.opens : nil) + case .gasMeter: + return .pointObject(kind: .gasMeter, tap: points.first ?? wall.meter - drop, bottom: nil, top: nil) + case .acUnit: + return .pointObject(kind: .ac, tap: points.first ?? wall.meter - drop, bottom: nil, top: nil) + case .fence: + return .fence(foot: try Self.groundLine(feature, points, wall: sceneWall)) + case .driveway: + return .driveway(edge: try Self.groundLine(feature, points, wall: sceneWall)) + } + } + + let keyframes = store.keyframes.map { stored -> SceneKeyframe in + var pose = stored.camera.cameraToWorld + pose.columns.3.y -= wall.groundY + return SceneKeyframe( + id: stored.id, cameraToWorld: pose, intrinsics: stored.camera.intrinsics, + w: Int(stored.camera.imageSize.x), h: Int(stored.camera.imageSize.y), img: stored.fileName + ) + } + + // A guessed ground puts the same error into every height in the scene. scene.json has no + // field for "the ground was estimated", so the error bars say it instead. + let groundError: Float? = groundMeasured ? nil : Self.estimatedGroundError + let input = SceneInput( + wall: sceneWall, + baselineS: Self.exportSpan(map), + meterPlusMinus: groundError, + meterPlane: meterPlaneSource, + objectPlusMinus: groundError, + features: features, + coverage: SceneCoverage( + map, leftEndMarked: wallEndKinds[.left] == .limit, rightEndMarked: wallEndKinds[.right] == .limit + ), + keyframes: keyframes, + stills: store.stills, + // Written without plus_minus_ft: the server takes its mesh error for both. + meshFacing: mesh.facing, + meshOverheads: mesh.overheads, + // Unanswered exports like "Not sure": no patch, and the server reports the surface unknown. + groundType: state.groundAnswer.flatMap(Self.sceneGroundType) + ) + return try SceneExport.jsonData(input) + } + + static func sceneGroundType(_ answer: GroundAnswer) -> SceneGroundType? { + switch answer { + case .notSure: nil + case .type(.lawn): .lawn + case .type(.mulch): .mulch + case .type(.gravel): .gravel + case .type(.concrete): .concrete + case .type(.drive): .drive + case .type(.deck): .deck + } + } + + /// How the line of the meter's piece of wall was found, for scene.json's `walls[].source`. + /// + /// Live, `markMeter` puts the wall through the raycast's hit point, facing the normal of the + /// plane it hit. On a detected plane (an ARPlaneAnchor, `.existingPlaneGeometry`) the normal + /// is the anchor's and the hit point lies on the anchor's geometry, so both the line's + /// direction and its distance from the camera are the plane's: `plane`. The tap only picks + /// where along that line the meter is. On an estimated plane there is no anchor: ARKit fits a + /// plane to the feature points around the tapped pixel for that one raycast, which is what an + /// AR tap is, and the schema's `plane` means detected planes, so it is `tap`. Nothing in the + /// capture takes a wall line from the LiDAR mesh (`MeshProbe` only measures in front of and + /// above a line already set), so no piece is `mesh`. + /// + /// A replay's wall is `tap`: a recorded measure-lab wall runs through two tapped ground + /// contacts (its `contacts`), and a wall assumed from the trajectory was never measured at + /// all, which no source describes; replays run only in tests and demos. + var meterLineSource: WallLineSource { + replay == nil ? Self.lineSource(of: meterPlaneSource) : .tap + } + + /// The source of a wall line put through a live vertical-plane raycast's hit, facing the hit + /// plane's normal (`meterLineSource` has the reasons). + static func lineSource(of plane: MeterPlaneSource) -> WallLineSource { + switch plane { + case .detectedPlane: .plane + case .estimatedPlane: .tap + } + } + + /// The stretch of wall the scene describes, meters of s: between the marked ends, or out to + /// what was seen (at least 1 m) on a side without one, always containing the meter. + nonisolated static func exportSpan(_ map: CoverageMap) -> ClosedRange { + let seen = map.seenExtent + let low = min(map.leftEnd ?? min(seen?.lowerBound ?? -1, -1), -0.1) + let high = max(map.rightEnd ?? max(seen?.upperBound ?? 1, 1), 0.1) + return low...high + } + + /// What the LiDAR mesh measured over the exported stretch: the gap from the wall out to + /// whatever faces it, and the clear height under anything overhead, each per stretch of s in + /// meters. Empty without a mesh. + struct MeshMeasurements: Sendable { + var facing: [ObservedSpan] = [] + var overheads: [ObservedSpan] = [] + } + + /// Measures a world-space mesh (meters) against the wall (world meters) over `span`, the + /// exported stretch of s. + nonisolated static func measure(_ mesh: TriangleMesh, wall: WallFrame, over span: ClosedRange) -> MeshMeasurements { + MeshMeasurements(facing: mesh.facingSpans(wall: wall, over: span), overheads: mesh.overheadSpans(wall: wall, over: span)) + } + + /// Error of the chest-height ground guess (camera height minus 1.4 m), meters. Phones held + /// for scanning sit roughly 1.1 to 1.7 m up, so ±0.3 m. A hypothesis; no measured spread exists. + static let estimatedGroundError: Float = 0.3 + + /// A ground point on or behind the wall line moved to 1 mm in front of it, keeping its s. The + /// millimetre keeps float round-off from turning an on-the-line point into a negative depth. + private static func inFront(of wall: SceneWall, _ point: SIMD3) -> SIMD3 { + let c = wall.wallCoordinates(of: point) + return c.out >= 0.001 ? point : wall.world(s: c.s, height: c.height, out: 0.001) + } + + /// The two ground points of a driveway edge or a fence foot, each moved in front of the wall. + /// Throws `markCollapsed` when they are not two points 1 cm apart in plan: a wall line refined + /// past both taps puts them on one point. SceneExport's own degenerate-edge tolerance is + /// 1e-3 ft; 1 cm keeps well clear of it. + private static func groundLine(_ feature: MarkedFeature, _ points: [SIMD3], wall: SceneWall) throws(ExportError) -> [SIMD3] { + let line = points.map { inFront(of: wall, $0) } + guard line.count == 2, simd_distance(SIMD2(line[0].x, line[0].z), SIMD2(line[1].x, line[1].z)) > 0.01 else { + RuntimeLog.engine.error("export: \(feature.kind.rawValue, privacy: .public) \(feature.id.uuidString, privacy: .public) has \(points.count) taps that don't make a line; asking for it to be marked again") + throw .markCollapsed(feature.kind) + } + return line + } + + enum ExportError: Error, CustomStringConvertible { + case noWall + /// A driveway or fence whose taps no longer make a line. The homeowner has to mark it + /// again; `UploadFailure.packaging(_:)` says so. + case markCollapsed(FeatureKind) + + var description: String { + switch self { + case .noWall: "There is no wall to export yet." + case .markCollapsed(let kind): "The \(kind.rawValue) mark's taps don't make a line." + } + } + } + + /// The server's result in the terms the screens use (meters, wall coordinates). + func presentation(of result: PlacementResult, isSample: Bool) -> ResultPresentation { + let sceneWall = coverage.map { + SceneWall(meter: $0.wall.meter, outward: $0.wall.outward, groundY: $0.wall.groundY, leftCorners: $0.wall.leftCorners, rightCorners: $0.wall.rightCorners) + } + // A view is capturable only when a gap request can be built from it: a band item needs + // its span (and a facing item its out_ft, which a walk can reach), a past_end item its + // side. Otherwise the button would do nothing. A request the homeowner already skipped + // or answered with something overhead stays with the installer. + let shown = Self.presentation(of: result, isSample: isSample, wall: sceneWall) { [self, gapPlanner, coverage, skippedGaps] item in + gapPlanner.plan(for: item, leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd, limitEnds: coverage?.limitEnds ?? []) + .map { plan in !skippedGaps.contains { plan.asksForSameView(as: $0) } && captureCanSettle(plan) } ?? false + } + // "Can't get there" ended both sides before either was walked (#76): the server placed + // the spot on the meter tap's wall line alone, which nothing confirmed. + guard walkRefusals.neitherSideWalked(leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd) else { return shown } + RuntimeLog.engine.info("result: neither side of the meter was walked; showing no spot") + return shown.withWallNotMeasured() + } + + /// `presentation(of:isSample:)` without an engine, for the UI demo: `wall` is the scan's wall + /// in world meters (nil draws no route off a sample's frame), `capturable` says whether a + /// requested view can be taken now. + static func presentation( + of result: PlacementResult, isSample: Bool, wall sceneWall: SceneWall?, + capturable: (PlacementMissingEvidence) -> Bool + ) -> ResultPresentation { + let meters: (Double) -> Float = { Float($0 * 0.3048) } + + let spot = result.spot.map(Self.batterySpot) + let nearestSpot = result.nearestConsidered.map(Self.batterySpot) + + var route: [SIMD2] = [] + if let cable = result.route { + let height = meters(cable.heightFt) + // Each point as an offset from the meter: the result's meter is the spot's centre + // minus its offset from the meter. The answer to this scan is in the scene's plan + // frame (the AR world's x and z in feet, scene.json's frame; result.schema.json's + // meter_offset_ft is "in the scene frame's axes"), so the offsets are put on the + // current wall chain by `chainS`, which follows the route round corners and adds a + // vertex at each so the drawn line bends there. Offsets rather than the points + // themselves keep the route on the meter's anchor as it moves, like the spot's s. + // The bundled sample is in a frame of its own, unrelated to this wall, so its route + // is measured along the sample spot's own direction, as before. + if isSample { + if let placed = result.spot { + let meterPlan = placed.center - placed.meterOffsetFt + route = cable.polyline.map { point in + let d = point - meterPlan + return SIMD2(meters(d.x * placed.along.x + d.y * placed.along.y), height) + } + } + } else if let sceneWall { + // A route comes with a spot (result.schema.json); without one, the meter as it is now. + let meterNow = SIMD2(Double(sceneWall.meter.x), Double(sceneWall.meter.z)) * SceneUnits.feetPerMeter + let meterPlan = result.spot.map { $0.center - $0.meterOffsetFt } ?? meterNow + route = sceneWall.chainS(ofPlanOffsetsFeet: cable.polyline.map { $0 - meterPlan }).map { SIMD2($0, height) } + } + } + + let checks = result.checks.map { check in + CheckRow( + id: check.id, title: check.label, outcome: Self.outcome(check.outcome), reason: check.reason, + needsPerson: check.needsPerson, + measured: check.measuredFt.map(meters), threshold: check.thresholdFt.map(meters), plusMinus: check.plusMinusFt.map(meters), + comparison: check.comparison.map(Self.comparison), + settledBy: result.evidenceIndex(settling: check.id).map(Self.missingID) + ) + } + + // A run's start_ft is a range of battery LEFT edges, so the wall it describes reaches one + // battery width past its last start. The spot carries the size, or after a reject the + // nearest spot, which the server sized the same way. With neither, the zone is drawn over + // the starts alone at 0.3 m deep, which understates it. + let sized = spot ?? nearestSpot + let depth = sized?.depth ?? 0.3 + let width = sized.map { $0.span.upperBound - $0.span.lowerBound } ?? 0 + let clearances = result.sweep.enumerated().map { index, run in + let first = meters(min(run.startFt.x, run.startFt.y)) + let last = meters(max(run.startFt.x, run.startFt.y)) + return ClearanceZone( + id: "sweep-\(index)", + label: (run.failing + run.unsure).joined(separator: ", "), + outcome: Self.outcome(run.outcome), + span: first...(last + width), + depth: depth + ) + } + + let missing = result.missingEvidence.enumerated().map { index, item in + MissingEvidence( + id: Self.missingID(index), text: item.message, + capturable: capturable(item), + checkIDs: item.checks ?? [] + ) + } + + // The unexplored end nearer the meter than the spot, if any, not the first past_end + // request: the server lists those left first, whether or not a spot past the end could + // beat the one it chose (issue #83). + let unseen = result.closerUnseenEnd().map { end in + UnseenEnd(side: end.side == .left ? .left : .right, s: meters(end.sFt)) + } + + return ResultPresentation( + decision: Self.decision(result.decision), + // The solver ends the summary with the policy notice; the screen shows it apart. + summary: result.summaryWithoutNotice, + policyApproved: result.policy.autoApprove, + rulesNotice: result.policy.notice.flatMap { $0.isEmpty ? nil : $0 }, + rulesHash: result.policy.rulesShortHash, + spot: spot, + nearestSpot: nearestSpot, + nearestFailingCheck: result.nearestFailure?.id, + cableRoute: route, + cableLength: result.route.map { meters($0.lengthFt) }, + checks: checks, + clearances: clearances, + missing: missing, + unseenEnd: unseen, + isSample: isSample, + rulesSHA256: result.policy.rulesSHA256 + ) + } + + static func missingID(_ index: Int) -> String { + "missing-\(index)" + } + + /// A server spot in wall terms: its stretch of s, size and gap to the wall, meters. + /// + /// Placed by its `span_ft`, its stretch in s along the wall chain, which the screens turn into + /// world points piece by piece (`WallGeometry.world(s:)`), so a spot round a corner lands on + /// the right piece and still moves with the meter's anchor. The meter offset split along the + /// spot's own `along` gave s only on the meter's piece: past a corner it measured along the + /// other piece's direction and drew the spot on the meter's wall. Both s and the depth below + /// are frame-free, so the bundled sample (in its own frame) still means the same on any wall. + /// + /// The size is the server's (`span_ft`, `depth_ft`, `height_ft`), never a battery size of the + /// app's own, for `nearest_considered` as for `spot`. There is no fallback for a missing one: + /// decoding requires every dimension (`PlacementSpot.init(from:)`), so an answer without one + /// fails with its path, such as `nearest_considered.depth_ft`, and the upload screen says the + /// answer couldn't be read (`ResultReadingTests.aNearestSpotWithoutADimensionIsRefused`). + static func batterySpot(_ placed: PlacementSpot) -> BatterySpot { + let meters: (Double) -> Float = { Float($0 * 0.3048) } + let low = meters(min(placed.spanFt.x, placed.spanFt.y)) + let high = meters(max(placed.spanFt.x, placed.spanFt.y)) + let depth = meters(placed.depthFt) + // How far the footprint's centre stands out from its back edge (back-left and back-right + // corners come first), along the spot's own outward: the gap to the wall is what exceeds + // half the depth. The server puts the back on the wall line. Decoding refuses a footprint + // without exactly four corners. + let d = placed.center - (placed.footprint[0] + placed.footprint[1]) / 2 + let centerOut = meters(d.x * placed.outward.x + d.y * placed.outward.y) + return BatterySpot( + span: low...high, + depth: depth, height: meters(placed.heightFt), + offsetFromWall: max(0, centerOut - depth / 2) + ) + } + + static func outcome(_ outcome: PlacementOutcome) -> CheckOutcome { + switch outcome { + case .pass: .pass + case .fail: .fail + case .unsure: .unsure + } + } + + static func comparison(_ comparison: PlacementComparison) -> RuleComparison { + switch comparison { + case .atLeast: .atLeast + case .atMost: .atMost + } + } + + static func decision(_ decision: PlacementDecision) -> ResultPresentation.Decision { + switch decision { + case .pass: .pass + case .manualReview: .manualReview + case .reject: .reject + } + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift new file mode 100644 index 000000000..fb6bbdb59 --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift @@ -0,0 +1,135 @@ +import Foundation +import HouseScanKit + +/// How the app's steps map onto the packet's guidance kinds (`GuidanceLog`), and how a request +/// that leaves the screen without an action settling it is judged. +/// +/// | App step | Packet kind | band, span | +/// | --- | --- | --- | +/// | hold on the meter (close-up) | closeup | | +/// | walk to a side; walk round a corner and mark the next wall | walk | | +/// | mark the end on a side | mark_end | | +/// | tilt down to the ground at s | tilt_to_ground | ground, s ± 0.3 m | +/// | tilt up to show more wall at s | gap_band | wall, s ± 0.3 m | +/// | step back | step_back | | +/// | tilt up above the meter's stretch | gap_band | overhead, its stretch | +/// | something in front of the wall at s (LiDAR) | gap_band | its hidden band, s ± 1 m | +/// | a gap request, phone's or server's | gap_band | wall, ground, overhead, or facing for a walk-out | +/// | a server past_end request | gap_past_end | its span | +/// | the spot check before the result | gap_band | ground, the spot's clearance area | +/// +/// Finding and marking the meter, "that's the whole wall" and the questions (what is at an end, +/// what is overhead) are not requests of any packet kind and are not logged. Coaching (slow +/// down, hold steady) is not logged either: it pauses a request rather than replacing it. +extension ScanEngine { + /// The request on screen now, or nil. + func guidanceRequest() -> GuidanceLog.Request? { + switch state.phase { + case .meterCloseUp: + guard state.guidance == .holdOnMeter else { return nil } + return request(.closeUp, .closeup, ScanCopy.guidance(.holdOnMeter)) + case .wallWalk: + return walkRequest(state.guidance) + case .gapRequest: + guard let gap = state.gap, let plan = gapPlan else { return nil } + let band: GuidanceLog.Band = switch plan.need { + case .overhead: .overhead + case .walkOut: .facing + case .groundOut: .ground + case .cells, .wallUp: plan.band == .ground ? .ground : .wall + } + return GuidanceLog.Request( + topic: .gap(id: gap.id), kind: pastEndSide == nil ? .gapBand : .gapPastEnd, origin: gap.origin == .server ? .server : .phone, + message: Self.text(ScanCopy.gapTask(gap)), band: pastEndSide == nil ? band : nil, span: plan.span + ) + case .spotConfirm: + return spotCheckGuidance + case .onboarding, .findMeter, .markFeatures, .uploading, .result, .resultAR, .unsupported: + return nil + } + } + + /// The request's message is the step's copy without `ScanViewState.guidanceHint`: the hint + /// follows the camera frame by frame, and one request stays one entry. The title on screen + /// can differ from the logged message while a hint is showing. + private func walkRequest(_ step: GuidanceStep) -> GuidanceLog.Request? { + let copy = ScanCopy.guidance(step) + let cell = { (s: Float) in self.coverage?.cellIndex(forS: s) ?? 0 } + switch step { + case .walk(let side, _): + return request(.walk(side), .walk, copy) + case .markNextWall(let side, _): + return request(.nextWall(side), .walk, copy) + case .markEnd(let side): + return request(.markEnd(side), .markEnd, copy) + case .aimAtGround(let s): + // The planner's own window for an aim task (`GuidancePlanner.isSatisfied`). + return request(.aimAtGround(cell: cell(s)), .tiltToGround, copy, band: .ground, span: (s - 0.3)...(s + 0.3)) + case .aimAtWall(let s): + return request(.aimAtWall(cell: cell(s)), .gapBand, copy, band: .wall, span: (s - 0.3)...(s + 0.3)) + case .stepBack: + return request(.stepBack, .stepBack, copy) + case .tiltUp(let span): + return request(.tiltUp, .gapBand, copy, band: .overhead, span: span) + case .seeBehind(let s): + let band: GuidanceLog.Band = seeBehindBands.contains(.wall) ? .wall : .ground + return request(.seeBehind(cell: cell(s)), .gapBand, copy, band: band, span: (s - Self.seeBehindReach)...(s + Self.seeBehindReach)) + case .findMeter, .aimAtWallForMeter, .holdOnMeter, .walkComplete, .gap: + return nil + } + } + + private func request( + _ topic: GuidanceLog.Topic, _ kind: GuidanceLog.Kind, _ copy: Instruction, band: GuidanceLog.Band? = nil, span: ClosedRange? = nil + ) -> GuidanceLog.Request { + GuidanceLog.Request(topic: topic, kind: kind, origin: .phone, message: Self.text(copy), band: band, span: span) + } + + /// The words on the card: the title, then the detail. + static func text(_ copy: Instruction) -> String { + guard let detail = copy.detail else { return copy.title } + let ends = copy.title.last.map { ".?!".contains($0) } ?? false + return copy.title + (ends ? " " : ". ") + detail + } + + /// The outcome of a request that left the screen without an action closing it (`resolve` + /// covers "I can't get there", answered questions and closed gaps). Met when what it asked for + /// is now there, superseded when another request took its place first. + func closingOutcome(_ old: GuidanceLog.Request, next: GuidanceLog.Request?) -> GuidanceLog.Outcome { + let s = old.span.map { ($0.lowerBound + $0.upperBound) / 2 } + switch old.topic { + case .closeUp: + switch state.closeUp { + case .captured: return .met + case .skipped: return .skipped + case .aiming: return .superseded + } + case .walk(let side): + // Walked as far as that side goes: the planner asks for its end, or the walk is done. + return next?.topic == .markEnd(side) || state.guidance == .walkComplete ? .met : .superseded + case .nextWall: + // Marking the next wall clears the question; "I can't get there" and Done resolve it + // before it leaves. + return nextWallSide == nil ? .met : .superseded + case .markEnd(let side): + return wallEndKinds[side] != nil ? .met : .superseded + case .stepBack: + guard let camera = currentFrame?.camera, let wall = coverage?.wall else { return .superseded } + return wall.wallPoint(camera.position).out > GuidancePlanner().config.tooClose ? .met : .superseded + case .aimAtGround, .aimAtWall: + // Met as the planner meets it (`GuidancePlanner.isSatisfied`): enough of the stretch + // covered. The cell at s alone could be covered while the stretch wasn't. + guard let span = old.span, let map = coverage else { return .superseded } + let band: SurfaceBand = old.band == .ground ? .ground : .wall + return map.coveredFraction(band, in: span) >= GuidancePlanner.aimSatisfied ? .met : .superseded + case .seeBehind: + guard let s, let map = coverage else { return .superseded } + let band: SurfaceBand = old.band == .wall ? .wall : .ground + return Self.hiddenCells(map, band: band, around: s).isEmpty ? .met : .superseded + case .tiltUp, .gap, .spotCheck: + // These close through their answers (`resolveGuidance`); leaving any other way means + // something else took over. + return .superseded + } + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Packet.swift b/ios/HouseScan/Runtime/ScanEngine+Packet.swift new file mode 100644 index 000000000..382423027 --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+Packet.swift @@ -0,0 +1,469 @@ +import Foundation +import HouseScanKit +import OSLog +import simd +import UIKit + +/// The capture packet (packet/README.md on t3/packet, version 1.1): what Share scan hands over. +/// Everything in it is in meters, seconds of device uptime and the meter frame, except +/// scene.json, which travels inside unchanged. Nothing uploads it: photos leave the phone only +/// when the homeowner shares the scan. +extension ScanEngine { + /// Everything the packet needs from the main actor, read in one turn so it describes one + /// moment of the scan. The files are written from it off the main actor (`writePacket`). + struct PacketInputs: Sendable { + var folder: URL + var zip: URL + var storeDirectory: URL + var recorder: CaptureRecorder + /// A replay's own frames, which stand in for the live trajectory: nil live. + var replayTrajectory: [ReplayPose]? + /// A replay's frames that recorded depth, the source of its depth frames: nil live, where + /// the recorder holds them. + var replayDepth: ReplayDepth? + var producer: PacketManifest.Producer + var device: PacketManifest.Device + var meterFrame: MeterFrame + var groundWorldY: Float + /// Keyframes and stills; `writePacket` orders them by time. + var photos: [StoredKeyframe] + var mesh: LiveCapture.MeshSnapshot? + var planes: [PacketPlane] + /// `photoIDs` hold store ids ("meter_close"); `writePacket` swaps in packet ids. + var marks: [PacketMark] + var guidance: [PacketGuidanceEntry] + var scene: Data + /// The camera's frame rate live; nil on a replay, whose rate comes from its frames. + var trajectoryRate: Double? + var motionStreams: Set + } + + struct ReplayPose: Sendable { + var t: Double + var normal: Bool + var cameraToWorld: simd_float4x4 + } + + struct ReplayDepth: Sendable { + var folder: URL + var frames: [ReplayFrame] + } + + /// Nil before there is a wall: the meter frame is built from it. + func packetInputs(scene: Data, mesh: LiveCapture.MeshSnapshot?) -> PacketInputs? { + guard let map = coverage else { return nil } + let wall = map.wall + // The wall in world meters, as scene.json's wall type describes it, for the marks. + let sceneWall = SceneWall(meter: wall.meter, outward: wall.outward, groundY: wall.groundY, leftCorners: wall.leftCorners, rightCorners: wall.rightCorners) + guard let frame = MeterFrame(meter: wall.meter, outward: wall.outward) else { return nil } + let settings = liveCapture?.settings + let info = Bundle.main.infoDictionary ?? [:] + let version = [info["CFBundleShortVersionString"], info["CFBundleVersion"]].compactMap { $0 as? String } + return PacketInputs( + folder: store.packetFolder, + zip: store.bundleURL, + storeDirectory: store.directory, + recorder: recorder, + replayTrajectory: replay.map { player in + player.frames.map { ReplayPose(t: $0.timestamp, normal: $0.trackingNormal, cameraToWorld: $0.cameraToWorld) } + }, + replayDepth: replay.map { ReplayDepth(folder: $0.folder, frames: $0.frames.filter { $0.depth != nil }) }, + producer: PacketManifest.Producer( + kind: .app, name: info["CFBundleName"] as? String ?? "HouseScan", + version: version.count == 2 ? "\(version[0]) (\(version[1]))" : version.first ?? "unknown", + // The TestFlight job's "Stamp the git commit" step writes it (.github/workflows/testflight.yml). + commit: (info["HouseScanGitCommit"] as? String).flatMap { $0 == "unknown" ? nil : $0 } + ), + device: PacketManifest.Device( + model: Self.hardwareModel(), iosVersion: UIDevice.current.systemVersion, lidar: LiveCapture.supportsDepth, + // A replay runs no ARKit session, so none of them ran. + sceneDepthEnabled: settings?.sceneDepth ?? false, meshEnabled: settings?.mesh ?? false, + meshClassificationEnabled: settings?.meshClassification ?? false + ), + meterFrame: frame, + groundWorldY: wall.groundY, + photos: store.keyframes + store.stillFrames.keys.sorted().compactMap { store.stillFrames[$0] }, + mesh: mesh, + planes: (liveCapture?.planeSnapshot() ?? []).map { plane in + PacketPlane( + id: plane.id, alignment: plane.vertical ? .vertical : .horizontal, classification: plane.classification, + anchorToMeter: frame.pose(plane.anchorToWorld), center: plane.center, rotationOnYAxis: plane.rotationOnYAxis, + extent: plane.extent, boundaryVertices: plane.boundary + ) + }, + marks: packetMarks(map, wall: sceneWall, frame: frame), + guidance: guidanceLog.entries.map { Self.packetEntry($0, wall: sceneWall, frame: frame) }, + scene: scene, + trajectoryRate: settings.map { Double($0.framesPerSecond) }, + motionStreams: motionRunsLive ? motionAvailable : [] + ) + } + + /// `utsname.machine`, such as "iPhone16,1": the hardware, never the phone's name. "arm64" in + /// the Simulator. + nonisolated static func hardwareModel() -> String { + var system = utsname() + uname(&system) + return withUnsafeBytes(of: system.machine) { raw in + String(decoding: raw.prefix { $0 != 0 }, as: UTF8.self) + } + } + + // MARK: Marks + + /// The meter, the marked wall ends and every marked feature, in the meter frame. + /// The meter links the close-up only when the homeowner accepted it (`KeyframeStore.stills`); + /// a rejected or skipped shot stays among the photos, linked to nothing. + private func packetMarks(_ map: CoverageMap, wall: SceneWall, frame: MeterFrame) -> [PacketMark] { + var marks = [PacketMark.meter( + id: "meter", t: markTimes[MarkKey.meter], photoIDs: store.stills["meter_close"] == nil ? nil : ["meter_close"] + )] + for (side, s) in [(WallSide.left, map.leftEnd), (.right, map.rightEnd)] { + guard let s else { continue } + marks.append(.wallEnd( + id: "wall_end_\(side.rawValue)", side: side == .left ? .left : .right, + endKind: wallEndKinds[side] == .limit ? .limit : .unexplored, s: s, wall: wall, frame: frame, t: markTimes[MarkKey.end(side)] + )) + } + for feature in state.features { + let id = feature.id.uuidString.lowercased() + let t = markTimes[MarkKey.feature(feature.id)] + let points = feature.points.map { frame.point($0) } + switch feature.kind { + case .door, .window: + let bottom = feature.bottom ?? 0 + marks.append(.opening( + feature.kind == .door ? .door : .window, id: id, span: feature.span, bottom: bottom, top: feature.top ?? bottom, + operable: feature.kind == .window ? feature.opens : nil, wall: wall, frame: frame, t: t + )) + case .gasMeter, .acUnit: + guard let point = points.first else { continue } + marks.append(.pointObject(feature.kind == .gasMeter ? .gasMeter : .ac, id: id, point: point, t: t)) + case .driveway, .fence: + guard points.count == 2 else { + RuntimeLog.engine.error("packet: \(feature.kind.rawValue, privacy: .public) mark has \(points.count) taps, not 2; left out") + continue + } + marks.append(feature.kind == .fence + ? .fence(id: id, from: points[0], to: points[1], t: t) + : .driveEdge(id: id, from: points[0], to: points[1], t: t)) + } + } + return marks + } + + /// Keys of `markTimes`. + enum MarkKey { + static let meter = "meter" + static func end(_ side: WallSide) -> String { "end.\(side.rawValue)" } + static func feature(_ id: UUID) -> String { id.uuidString } + } + + // MARK: Guidance + + /// A logged request with its span moved from s to meter-frame x. + private static func packetEntry(_ entry: GuidanceLog.Entry, wall: SceneWall, frame: MeterFrame) -> PacketGuidanceEntry { + let span = entry.request.span.map { span -> ClosedRange in + let low = frame.point(on: wall, s: span.lowerBound, height: 0, out: 0).x + let high = frame.point(on: wall, s: span.upperBound, height: 0, out: 0).x + return min(low, high)...max(low, high) + } + return PacketGuidanceEntry( + id: entry.id, kind: entry.request.kind, origin: entry.request.origin, message: entry.request.message, + band: entry.request.band, span: span, tShown: entry.shown, tResolved: entry.resolved, outcome: entry.outcome + ) + } + + // MARK: Writing + + /// Writes the packet folder, zips it to `inputs.zip` and removes the folder. Returns the zip + /// and a one-line summary for the log. Off the main actor: it copies every photo and hashes + /// every file. + /// + /// A photo, a depth map, a trajectory row, a plane or a section the writer refuses is left + /// out and logged, so one bad input doesn't cost the homeowner the whole packet; the writer's + /// checks are the validator's, so what is written validates. + nonisolated static func writePacket(_ inputs: PacketInputs) throws -> (url: URL, summary: String) { + let frame = inputs.meterFrame + let trajectory = trajectoryRows(inputs) + guard let started = trajectory.rows.first?.t, let ended = trajectory.rows.last?.t, ended > started else { + throw PacketBuildError.noTrajectory + } + + try? FileManager.default.removeItem(at: inputs.folder) + var writer = try PacketWriter(folder: inputs.folder, session: PacketSessionInfo( + id: trajectory.sessionID, producer: inputs.producer, device: inputs.device, startedAt: trajectory.startedAt, + startedAtUptime: started, meterFrame: frame, groundWorldY: inputs.groundWorldY + )) + + // Photos in time order, one per source frame, inside the capture. + var packetID: [String: String] = [:] + var last: (t: Double, id: String)? + var added = 0 + // Times of photos written with depth: a depth frame at one of them would repeat it. + var photoDepthTimes: Set = [] + for stored in inputs.photos.sorted(by: { $0.t < $1.t }) { + if let last, last.t == stored.t { + // The same frame kept twice (a close-up that was also a walk frame, or a view kept + // again for the overhead answer): one photo. + packetID[stored.id] = last.id + continue + } + guard (started...ended).contains(stored.t), let sharpness = stored.sharpness else { + RuntimeLog.engine.error("packet: \(stored.id, privacy: .public) at t=\(stored.t) left out: outside the capture \(started)...\(ended) or no sharpness") + continue + } + let id = PacketPhoto.id(number: added + 1) + do { + var depth = stored.depth.flatMap { KeyframeStore.loadDepth($0, in: inputs.storeDirectory) }.flatMap(Self.measured) + do { + try writer.addPhoto(Self.photo(stored, id: id, sharpness: sharpness, depth: depth, inputs: inputs)) + } catch PacketError.invalidDepth(_, let reason) { + // The photo is worth more than its depth. + RuntimeLog.engine.error("packet: \(stored.id, privacy: .public) depth left out: \(reason, privacy: .public)") + depth = nil + try writer.addPhoto(Self.photo(stored, id: id, sharpness: sharpness, depth: nil, inputs: inputs)) + } + packetID[stored.id] = id + last = (stored.t, id) + added += 1 + if depth != nil { photoDepthTimes.insert(stored.t) } + } catch { + RuntimeLog.engine.error("packet: \(stored.id, privacy: .public) left out: \(String(describing: error), privacy: .public)") + } + } + + var skippedRows = 0 + try writer.setNominalRate(trajectory.rate, for: .trajectory) + for row in trajectory.rows { + do { try writer.appendTrajectory(t: row.t, tracking: row.tracking, pose: frame.pose(row.cameraToWorld)) } catch { skippedRows += 1 } + } + skippedRows += writeMotion(inputs, window: started...ended, into: &writer) + if skippedRows > 0 { RuntimeLog.engine.error("packet: \(skippedRows) stream rows refused and left out") } + + let depthFrames = writeDepthFrames(inputs, window: started...ended, skipping: photoDepthTimes, into: &writer) + + if let mesh = inputs.mesh { + do { try writer.setMesh(frame.mesh(mesh.mesh), classification: mesh.classification) } catch { Self.logLeftOut("mesh", error) } + } + var planes = 0 + for plane in inputs.planes { + do { + try writer.addPlane(plane) + planes += 1 + } catch let error where plane.boundary != nil { + // A boundary outside its extent means the two disagree; the extent still holds. + Self.logLeftOut("plane \(plane.id) boundary", error) + var bare = plane + bare.boundary = nil + do { + try writer.addPlane(bare) + planes += 1 + } catch { + Self.logLeftOut("plane \(plane.id)", error) + } + } catch { + Self.logLeftOut("plane \(plane.id)", error) + } + } + let clamp = { (t: Double) in min(max(t, started), ended) } + do { + try writer.setMarks(inputs.marks.map { mark in + var mark = mark + let ids = (mark.photoIDs ?? []).compactMap { packetID[$0] } + mark.photoIDs = ids.isEmpty ? nil : ids + mark.t = mark.t.map(clamp) + return mark + }) + } catch { Self.logLeftOut("marks", error) } + do { + try writer.setGuidance(inputs.guidance.map { entry in + var entry = entry + entry.tShown = clamp(entry.tShown) + entry.tResolved = entry.tResolved.map { max(clamp($0), entry.tShown) } + return entry + }) + } catch { Self.logLeftOut("guidance", error) } + try writer.setScene(inputs.scene) + let folder = try writer.finish() + try KeyframeStore.zipPacket(folder, to: inputs.zip) + let summary = "\(added) photos (\(photoDepthTimes.count) with depth), \(depthFrames) depth frames, " + + "\(trajectory.rows.count) trajectory rows over \(String(format: "%.1f", ended - started)) s, " + + "\(inputs.motionStreams.count) motion streams, \(inputs.mesh == nil ? "no mesh" : "mesh"), \(planes) planes, " + + "\(inputs.marks.count) marks, \(inputs.guidance.count) guidance entries" + return (inputs.zip, summary) + } + + private struct Trajectory { + var sessionID: String + var startedAt: Date? + /// Nominal rate, Hz. + var rate: Double + var rows: [(t: Double, tracking: PacketTracking, cameraToWorld: simd_float4x4)] + } + + /// The camera path, world frame: every recorded ARFrame live, or one row per replay frame. + /// Its first and last times are the capture's window. + private nonisolated static func trajectoryRows(_ inputs: PacketInputs) -> Trajectory { + if let poses = inputs.replayTrajectory { + var rows: [(t: Double, tracking: PacketTracking, cameraToWorld: simd_float4x4)] = [] + for pose in poses.sorted(by: { $0.t < $1.t }) where pose.t > (rows.last?.t ?? -.infinity) { + rows.append((pose.t, pose.normal ? .normal : .limited(nil), pose.cameraToWorld)) + } + // The recording's own rate. It has no wall-clock start. + let span = (rows.last?.t ?? 0) - (rows.first?.t ?? 0) + let rate = rows.count > 1 && span > 0 ? Double(rows.count - 1) / span : 1 + return Trajectory(sessionID: UUID().uuidString, startedAt: nil, rate: rate, rows: rows) + } + let snapshot = inputs.recorder.flush() + let rows = inputs.recorder.rows(.trajectory).map { row in + let pose = simd_float4x4( + SIMD4(Float(row[3]), Float(row[4]), Float(row[5]), Float(row[6])), + SIMD4(Float(row[7]), Float(row[8]), Float(row[9]), Float(row[10])), + SIMD4(Float(row[11]), Float(row[12]), Float(row[13]), Float(row[14])), + SIMD4(Float(row[15]), Float(row[16]), Float(row[17]), Float(row[18])) + ) + return (t: row[0], tracking: TrackingCode(state: Int(row[1]), reason: Int(row[2])).packetTracking, cameraToWorld: pose) + } + return Trajectory(sessionID: snapshot.sessionID, startedAt: snapshot.startedAt, rate: inputs.trajectoryRate ?? 60, rows: rows) + } + + private nonisolated static func photo(_ stored: StoredKeyframe, id: String, sharpness: Double, depth: DepthPacket?, inputs: PacketInputs) -> PacketPhoto { + // Intrinsics belong to the stored image; a JPEG of another size than the camera reported + // scales them with it. + let scale = SIMD2(Float(stored.width), Float(stored.height)) / stored.camera.imageSize + let k = stored.camera.intrinsics + return PacketPhoto( + id: id, jpeg: inputs.storeDirectory.appending(path: "\(stored.id).jpg"), width: stored.width, height: stored.height, + t: stored.t, pose: inputs.meterFrame.pose(stored.camera.cameraToWorld), + intrinsics: SIMD4(k.x * scale.x, k.y * scale.y, k.z * scale.x, k.w * scale.y), + tracking: TrackingCode(stored.tracking).packetTracking, + exposure: stored.exposure.map { PacketExposure(durationS: $0.durationS, iso: $0.iso, offsetEV: $0.offsetEV) }, + // ARKit's frames come from the back wide camera; a replay says nothing about its lens. + lens: stored.exposure.map { PacketLens(focalLengthMM: $0.focalLengthMM, fNumber: $0.fNumber, camera: "wide") }, + sharpness: sharpness, depth: depth + ) + } + + /// Nil for a depth map with under 1% of pixels measured, which the validator calls empty: the + /// photo goes in without it. + private nonisolated static func measured(_ depth: DepthPacket) -> DepthPacket? { + let count = depth.meters.reduce(0) { $0 + ($1.isFinite && $1 > 0 ? 1 : 0) } + return Double(count) >= 0.01 * Double(depth.meters.count) ? depth : nil + } + + /// Depth recorded between photos, inside the capture: the live recorder's LiDAR frames, or a + /// replay's recorded depth under the same `DepthFrameBudget` and the same rule of normal + /// tracking. A frame at the time of a photo written with depth would repeat it and is left + /// out. Each map is read, written and dropped in turn, so memory holds one at a time. Returns + /// how many were written. + private nonisolated static func writeDepthFrames( + _ inputs: PacketInputs, window: ClosedRange, skipping photoDepthTimes: Set, into writer: inout PacketWriter + ) -> Int { + var written = 0 + var refused: [String] = [] + func add(t: Double, tracking: PacketTracking, cameraToWorld: simd_float4x4, intrinsics: SIMD4, depth: DepthPacket?) { + guard let depth else { + refused.append("t=\(t): its files are missing or the wrong size") + return + } + do { + try writer.addDepthFrame(PacketDepthFrame( + id: PacketDepthFrame.id(number: written + 1), t: t, pose: inputs.meterFrame.pose(cameraToWorld), intrinsics: intrinsics, + tracking: tracking, depth: depth + )) + written += 1 + } catch { + refused.append(String(describing: error)) + } + } + if let replay = inputs.replayDepth { + var budget = DepthFrameBudget() + for frame in replay.frames where frame.trackingNormal && window.contains(frame.timestamp) && budget.admit(t: frame.timestamp) { + guard !photoDepthTimes.contains(frame.timestamp), let file = frame.depth else { continue } + let intrinsics = DepthImage.intrinsics( + scaling: frame.intrinsics, from: SIMD2(Float(frame.width), Float(frame.height)), toWidth: file.width, height: file.height + ) + add(t: frame.timestamp, tracking: .normal, cameraToWorld: frame.cameraToWorld, intrinsics: intrinsics, depth: replayDepth(file, in: replay.folder)) + } + } else { + for frame in inputs.recorder.depthFrames() where window.contains(frame.t) && !photoDepthTimes.contains(frame.t) { + add( + t: frame.t, tracking: frame.tracking.packetTracking, cameraToWorld: frame.cameraToWorld, intrinsics: frame.intrinsics, + depth: inputs.recorder.loadDepth(frame) + ) + } + } + if let first = refused.first { + RuntimeLog.engine.error("packet: \(refused.count) depth frames left out; the first: \(first, privacy: .public)") + } + return written + } + + /// A replay frame's recorded depth as the packet takes it: meters and, when recorded, + /// confidence, with no source, since the recording says neither which ARKit depth it saved + /// nor whether it was rendered (the synthetic fixtures' is). Nil when the files are missing + /// or the wrong size. + private nonisolated static func replayDepth(_ file: ReplayDepthFile, in folder: URL) -> DepthPacket? { + let count = file.width * file.height + guard let map = try? Data(contentsOf: folder.appending(path: file.file)), map.count == count * 4 else { return nil } + var confidence: [UInt8]? + if let path = file.confidenceFile { + guard let data = try? Data(contentsOf: folder.appending(path: path)), data.count == count else { return nil } + confidence = [UInt8](data) + } + return DepthPacket(meters: PacketFiles.floats(littleEndian: map), width: file.width, height: file.height, confidence: confidence, source: nil) + } + + /// Core Motion's streams, rows inside the capture only. Returns how many rows were refused. + private nonisolated static func writeMotion(_ inputs: PacketInputs, window: ClosedRange, into writer: inout PacketWriter) -> Int { + var refused = 0 + for stream in CaptureRecorder.Stream.allCases where stream != .trajectory && inputs.motionStreams.contains(stream) { + let rows = inputs.recorder.rows(stream).filter { window.contains($0[0]) } + guard !rows.isEmpty else { continue } + let packetStream: PacketStream = switch stream { + case .trajectory: .trajectory + case .accelerometer: .accelerometer + case .gyroscope: .gyroscope + case .magnetometer: .magnetometer + case .deviceMotion: .deviceMotion + case .barometer: .barometer + } + // CMAltimeter sets its own rate (about 1 Hz), so the barometer claims none. + if stream != .barometer { try? writer.setNominalRate(MotionSource.rate, for: packetStream) } + for r in rows { + do { + switch stream { + case .accelerometer: try writer.appendAccelerometer(t: r[0], g: SIMD3(r[1], r[2], r[3])) + case .gyroscope: try writer.appendGyroscope(t: r[0], radiansPerSecond: SIMD3(r[1], r[2], r[3])) + case .magnetometer: try writer.appendMagnetometer(t: r[0], microtesla: SIMD3(r[1], r[2], r[3])) + case .deviceMotion: + try writer.appendDeviceMotion(DeviceMotionSample( + t: r[0], attitude: SIMD4(r[1], r[2], r[3], r[4]), gravity: SIMD3(r[5], r[6], r[7]), + userAcceleration: SIMD3(r[8], r[9], r[10]), rotationRate: SIMD3(r[11], r[12], r[13]), headingDegrees: r[14] + )) + case .barometer: try writer.appendBarometer(t: r[0], pressureKPa: r[1], relativeAltitudeM: r[2]) + case .trajectory: break + } + } catch { + refused += 1 + } + } + } + return refused + } + + private nonisolated static func logLeftOut(_ section: String, _ error: any Error) { + RuntimeLog.engine.error("packet: \(section, privacy: .public) left out: \(String(describing: error), privacy: .public)") + } + + enum PacketBuildError: Error, CustomStringConvertible { + case noTrajectory + + var description: String { + switch self { + case .noTrajectory: "no trajectory was recorded, so the capture has no time span" + } + } + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Practice.swift b/ios/HouseScan/Runtime/ScanEngine+Practice.swift new file mode 100644 index 000000000..d1f2396ab --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+Practice.swift @@ -0,0 +1,36 @@ +import Foundation +import HouseScanKit + +/// Practice meter (HouseScanKit `PracticeMeter`). The meter tap is the usual one: the same raycast +/// and refusals, since they guard the wall's geometry, and the tapped spot becomes the meter's +/// anchor, where the camera view draws the sample (`PracticeMeterOverlay`). Only the close-up's +/// photo changes; its pose, depth and the view coverage takes from it stay the live frame's. +extension ScanEngine { + /// Reads the developer switch when a scan starts; the scan keeps the answer to its end. + func startPracticeIfOn() { + state.isPracticeScan = DeveloperSettings.shared.practiceMeterOn + guard state.isPracticeScan else { return } + RuntimeLog.engine.info("practice meter: a drawn sample stands in for the meter and its close-up photo") + } + + /// The frame whose photo the close-up stores and the reader reads: `frame` itself, or on a + /// practice scan `frame` with a photo of the sample in place of the camera's, at the camera + /// image's size so the frame's intrinsics still fit it. Without a photo when the sample can't + /// be drawn, so the save fails and the close-up asks for a retake rather than storing a photo + /// of whatever the camera saw. + func closeUpPhoto(_ frame: SourceFrame) async -> SourceFrame { + guard state.isPracticeScan else { return frame } + let size = frame.camera.imageSize + let jpeg = await Task.detached(priority: .userInitiated) { + SampleMeterArt.closeUpJPEG(width: Int(size.x), height: Int(size.y)) + }.value + var photo = frame + if let jpeg { + photo.jpeg = .data(jpeg) + } else { + RuntimeLog.engine.error("practice meter: the sample photo could not be drawn at \(Int(size.x))x\(Int(size.y))") + photo.jpeg = .none + } + return photo + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Stamp.swift b/ios/HouseScan/Runtime/ScanEngine+Stamp.swift new file mode 100644 index 000000000..e88230647 --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+Stamp.swift @@ -0,0 +1,28 @@ +import Foundation +import HouseScanKit +import OSLog + +extension ScanEngine { + /// Writes `scan-stamp.json` into the scan's folder: the app version, build and commit, the + /// server the scan goes to, and the latest answer's rules (`ScanStamp`). Written when the + /// scan is packaged and again when an answer arrives, so a scan pulled off the phone says what + /// built and judged it. A failed write is logged; the scan goes on without it. + func writeScanStamp(answer: PlacementResult?) { + let info = Bundle.main.infoDictionary ?? [:] + let stamp = ScanStamp( + app: .init( + version: info["CFBundleShortVersionString"] as? String ?? "unknown", + build: info["CFBundleVersion"] as? String ?? "unknown", + // Set in Config/Info.plist by the TestFlight job's "Stamp the git commit" step; "unknown" elsewhere. + commit: info["HouseScanGitCommit"] as? String ?? "unknown"), + server: .init(url: (resultClient as? HTTPResultClient)?.serverURL.absoluteString), + answer: answer.map { ScanStamp.Answer($0, sample: resultClient.isSample) }, + practice: state.isPracticeScan) + let file = store.directory.appending(path: ScanStamp.fileName) + do { + try stamp.jsonData().write(to: file, options: .atomic) + } catch { + RuntimeLog.engine.error("scan stamp not written: \(String(describing: error), privacy: .public)") + } + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift new file mode 100644 index 000000000..831682f49 --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -0,0 +1,2321 @@ +import ARKit +import AVFoundation +import CoreGraphics +import Foundation +import HouseScanKit +import OSLog +import simd +import SwiftUI + +/// The capture engine: the only writer of `ScanViewState`, and the implementation of the +/// homeowner's intents. Frames arrive from `LiveCapture` (ARKit) or `ReplayPlayer` (a recorded +/// session) and take the same path through auto-capture, coverage and guidance. +@MainActor +final class ScanEngine { + let state = ScanViewState() + let options: LaunchOptions + + // Sources + private(set) var replay: ReplayPlayer? + /// Which kind of plane the meter tap hit; an estimated plane widens the meter's error in the + /// export. A replay's wall comes from the recording, so it counts as detected. + var meterPlaneSource: MeterPlaneSource = .detectedPlane + /// Where the phone was when the meter was marked: the ground choice prefers the plane under + /// it, and a re-fit follows the tap's line of sight from it (`refitWallToDetectedPlane`). Nil + /// on a replay. + var meterTapCamera: SIMD3? + private var live: LiveCapture? + + // Capture logic (HouseScanKit) + private(set) var coverage: CoverageMap? + private var autoCapture = AutoCapture() + private var closeUpGate = CloseUpGate() + private var planner = GuidancePlanner() + let gapPlanner = GapPlanner() + + // Stored evidence + private(set) var store: KeyframeStore + private var keptSourceIDs: Set = [] + /// Debounces "past the end of the wall" during the walk (see `walkCoaching`). + private var gateProblem: (coaching: Coaching, since: Double)? + private var gateClearSince: Double? + /// Debounces the capture gate's own coaching during the walk (see `walkCoaching`). + private var gateCoaching = CoachingDebouncer() + private var closeUpPending = false + /// Why the last close-up has to be retaken (from the meter-number reader, or "None of + /// these"), and when that was said, in screen seconds. Shown until the next shot fires. + private var closeUpRetake: (problem: CloseUpProblem, since: Double)? + /// The reader's answer for the close-up on screen, for the advice after "None of these". + private var meterReadout: MeterReadout? + /// The close-up view coverage may take when the close-up step ends (`observeCloseUpView`): + /// that of the photo on disk, once the reader's image checks passed it. + var closeUpCredit = CloseUpCredit() + /// Keyframe writes still in flight, by the `generation` they started in; the bundle waits + /// for its own generation's. Keyed so a write finishing after a reset can't count against + /// the new scan (a plain counter went negative when `resetAll` zeroed it mid-write). + private var pendingSaves: [Int: Int] = [:] + /// Bumped whenever the world frame or the whole scan is thrown away, so work that finishes + /// afterwards (a keyframe write, an upload) can tell it belongs to a scan that no longer exists. + private var generation = 0 + + // Wall geometry inputs + private var meterAnchorID: UUID? + /// The meter anchor's pose the wall and everything captured agree with, and the corrections + /// still to apply to them as ARKit refines it (`refreshMeterFromAnchor`). Nil on a replay. + private var meterTracking: MeterAnchorTracking? + /// Which frames carried the meter's anchor since it was anchored, for the drift log + /// (`noteMeterAnchor`). Starts again with `meterTracking`. + private var meterAnchorPresence = MeterAnchorPresence() + /// Whether a frame source may start, and what a failure and Start over do to it. + private var sourceState = CaptureSourceState() + /// Detected horizontal planes, with their classes and outlines. + private var groundPlanes: [GroundPlaneEvidence] = [] + /// Detected vertical planes, with their classes, normals and outlines. + private var wallPlanes: [WallPlaneEvidence] = [] + /// What the coverage map's far surface was last measured from (`noteFarSurface`): the planes, + /// the wall and the stretch seen. + private var farSurfaceBasis: (planes: [WallPlaneEvidence], wall: WallFrame, seen: ClosedRange?)? + /// The detected plane the meter's wall was refit to (`refitWallToDetectedPlane`): the wall's + /// own, never where the space in front of it ends. + private var refitPlaneID: String? + private var lastFrame: SourceFrame? + /// Whether `WallFrame.groundY` comes from a detected plane (or a recording's wall taps) rather + /// than the chest-height guess. The export widens position errors while it is a guess. + private(set) var groundMeasured = false + private var endKinds: [WallSide: EndKind] = [:] + /// The side whose end turns a corner the walk is to follow, while it waits for the next wall + /// to be marked (`GuidanceStep.markNextWall`), and why the last mark was refused. + var nextWallSide: WallSide? { + didSet { if nextWallSide == nil { pendingNextWall = nil } } + } + var nextWallRefusal: NextWallRefusal? + /// "Can't get there" on the walk's card: when it last ended a side, and which ends it set + /// before the homeowner walked that side (#82, #76). Reset with the wall. + var walkRefusals = WalkRefusals() + /// The wall marked as the next one, waiting for "Is this the next wall?" (#70); cleared with + /// `nextWallSide`. Published as `ScanViewState.nextWallConfirm`. + var pendingNextWall: PendingNextWall? { + didSet { + let confirm = pendingNextWall.map { NextWallConfirm(side: $0.side, fromEnd: $0.fromEnd) } + if confirm != state.nextWallConfirm { state.nextWallConfirm = confirm } + } + } + + /// Meters up the wall the ring on a proposed corner sits: about chest height, where it shows + /// in the view of a homeowner aiming at the next wall. + static let cornerRingHeight: Float = 1 + + /// What `confirmNextWall` needs to follow the corner: the marked point and facing, as + /// `markNextWall` found them. + struct PendingNextWall { + var side: WallSide + var point: SIMD3 + var outward: SIMD3 + var source: WallLineSource + var detectedPlane: Bool + var s: Float + var fromEnd: Float + } + + // Gap loop + private(set) var gapPlan: GapPlan? + private var gapCounter = 0 + /// Keyframes stored when the current gap request began: a request is closed only by new views. + private var keyframesAtGapStart = 0 + /// Tilt-up views kept when the current gap request began: an overhead request is closed by a + /// new one (`keepOverheadView`); counting keyframes would not do, since the walk keeps them too. + private var overheadViewsAtGapStart = 0 + /// Requests the homeowner skipped or answered with something overhead: they go to installer + /// review, and the result doesn't offer them as captures again. + private(set) var skippedGaps: [GapPlan] = [] + /// The side of a server past_end request being captured: that end was cleared, and marking + /// it again settles the request (see `markWallEnd`). + var pastEndSide: WallSide? + /// The end the past_end request cleared: where it was, its kind and when it was marked, put + /// back or moved on when the request ends without it marked again (`settleClearedEnd`). + private var clearedEnd: (s: Float, kind: EndKind, t: Double?)? + /// Server requests raised without a tap since the review was confirmed (`automaticGapQueue`), + /// oldest first. Each is raised once, whether its view was taken or the homeowner couldn't + /// get there; the result still offers it as a capture. + private var automaticGaps: [GapPlan] = [] + /// Set when the homeowner taps "Show my result" on a server request (`stopGapRequests`): + /// after the upload that follows, the result shows instead of the next request. + private var automaticGapsStopped = false + /// At most this many server requests are raised without a tap per confirmed review, so a + /// server that keeps finding new gaps can't hold the homeowner in the loop. A guess, not + /// measured: an answer usually lists one to three capturable items, and each round is a + /// capture and an upload. + static let maxAutomaticGaps = 5 + /// How long the upload screen shows "One more view to finish" before a request raised from + /// the answer opens the camera: 1.5 s, about the time to read four words and see the new + /// step appear, and what the UI lane asked for. Not measured with homeowners. + static let followUpHold: Double = 1.5 + /// The least time the upload screen shows "Check clearances" at work before it ticks: 0.6 s, + /// enough to see the step start. The server answers in the request that carries the scene, + /// often a moment after the last byte, and the step was never drawn (issue #31). A guess + /// like `followUpHold`, not measured with homeowners. + static let analyzingMinimum: Double = 0.6 + /// How long the upload screen shows every step ticked before the result replaces it: 0.8 s, + /// about the time to see "Clearances checked" land. A guess like `followUpHold`, not measured. + static let resultHold: Double = 0.8 + /// When the upload screen moved to "Check clearances" in this upload, for `analyzingMinimum`. + private var analyzingSince: ContinuousClock.Instant? + + // LiDAR + /// The bands the see-behind step is about, while `state.guidance` is `.seeBehind`: those with + /// hidden cells near its s (`hiddenCells(_:band:around:)`). + private(set) var seeBehindBands: [SurfaceBand] = [] + + // Tilt-up step and overhead requests + /// Set once the tilt-up step is answered or skipped: the walk asks it once per scan. + var tiltUpSettled = false + /// The tilted-up view the overhead question is about, with the walked-path segment it was + /// captured in. "Open sky or nothing overhead" keeps it as a keyframe and in the coverage map + /// (`keepOverheadView`), which the export sends as the overhead band; "A roof edge, porch or + /// stairs" keeps nothing, so the server treats that stretch as unseen. + private var pendingOverhead: (frame: SourceFrame, segment: Int?)? + + // Tracking recovery + private var relocalizingSince: Double? + + // AR result + /// Watches whether the AR scene draws the result (`watchResultInCamera`) while "See it on + /// your wall" is up on the live camera. + private var resultWatch: Task? + /// The wall the model in the AR scene was built from; nil when none is in it. + private var resultBuiltFor: WallGeometry? + + // Upload + let resultClient: any ResultClient + private var uploadTask: Task? + private(set) var placement: PlacementResult? + /// The latest scan-bundle write (`saveBundle`), and a count of writes started, so only the + /// latest one offers its bundle. + private var bundleTask: Task? + private var bundleSerial = 0 + + // Packet + /// The packet's sensor streams for the current world frame. + private(set) var recorder: CaptureRecorder + private let motion = MotionSource() + private var askingForPermissions = false + + /// LiDAR phones: the dots over the camera, fused off the main actor from the keyframes + /// coverage observes. Guidance for the overlay only; nothing reads them back. + private lazy var liveDots = LiveDotsFeed { [weak self] dots, scan in + guard let self, scan == self.generation else { return } + self.state.liveDots = dots + } + /// Every request the homeowner was shown, for the packet. + var guidanceLog = GuidanceLog() + /// The spot check (`ScanEngine+Confirm.swift`). + var spotConfirm = SpotConfirmState() + /// When each mark was made, on the capture clock (`MarkKey`). + var markTimes: [String: Double] = [:] + /// The packet's clock for guidance and marks: the latest frame's time, ARFrame.timestamp + /// live. A replay plays parts of its recording more than once, so its clock is the latest + /// frame time seen and never runs back. Nil until the first frame. + private(set) var captureClock: Double? + + private var lastGuidanceLog = "" + private var lastGateLog = "" + /// Taps of the feature being marked, in wall coordinates. + var pendingTaps: [WallPoint] = [] + + enum EndKind { + /// The homeowner marked the end and said something blocks the wall there (a fence, gate + /// or property line). + case limit + /// The wall may go on past this end: it turns a corner, the walk stopped there ("I can't + /// get there"), or the homeowner has not said what is there yet. + case unexplored + } + + init(options: LaunchOptions) { + self.options = options + store = KeyframeStore() + recorder = Self.makeRecorder(store) + if let url = options.serverURL, !options.sampleResult { + resultClient = HTTPResultClient(serverURL: url) + } else { + resultClient = SampleResultClient(pace: options.autopilot ? options.autopilotHold : 1.2) + } + state.isAutopilot = options.autopilot + state.isReplay = options.replayFolder != nil + state.usesSampleResult = resultClient.isSample + } + + // MARK: Start + + func start() { + RuntimeLog.state.info("STATE=\(self.state.phase.rawValue, privacy: .public)") + if let folder = options.replayFolder { + Task { await loadReplay(folder) } + } else if !ARWorldTrackingConfiguration.isSupported { + fail(.arUnsupported) + } + } + + /// Every failure the homeowner has to act on (no AR, camera denied, a failed session, an + /// unreadable replay) ends on the failure screen, which reads `state.failure` for its words. + /// Setting the failure alone left the flow on whatever screen was up. + private func fail(_ failure: ScanFailure) { + _ = sourceState.sourceFailed(failure == .arUnsupported ? .unsupported : .recoverable, afterCapture: false) + state.failure = failure + go(.unsupported) + } + + private func loadReplay(_ folder: URL) async { + do { + let loaded = try await Task.detached(priority: .userInitiated) { try ReplayPlayer.load(folder: folder) }.value + let player = ReplayPlayer(folder: folder, loaded: loaded) { [weak self] frame in self?.ingest(frame) } + replay = player + sourceState.sourceStarted() + state.spatialResultAvailable = true + let withDepth = player.frames.filter { $0.depth != nil }.count + state.depthAvailable = withDepth > 0 + player.show(index: 0) + RuntimeLog.engine.info("replay \(player.session.id, privacy: .public): \(player.frames.count) frames, \(withDepth) with depth, wall \(player.wallDescription, privacy: .public)") + } catch { + RuntimeLog.engine.error("replay unreadable: \(String(describing: error), privacy: .public)") + fail(.replayUnreadable(String(describing: error))) + } + } + + // MARK: Phases + + func go(_ phase: ScanPhase) { + guard state.phase != phase else { return } + if state.phase == .wallWalk || state.phase == .gapRequest { + breakWalkedPath(because: "the walk paused (\(state.phase.rawValue) -> \(phase.rawValue))") + } + if state.phase == .resultAR { hideResultInCamera() } + // "End the scan here?" belongs to the walk it was asked on. + state.endScanQuestion = false + state.endScanTooShort = false + let previous = state.phase + state.phase = phase + RuntimeLog.state.info("STATE=\(phase.rawValue, privacy: .public)") + logMeterAnchorSummary(from: previous, to: phase) + switch phase { + case .findMeter: + state.guidance = .findMeter + startSourceIfNeeded() + live?.setMode(.idle) + case .meterCloseUp: + state.guidance = .holdOnMeter + state.closeUp = .aiming(hold: 0, problem: nil) + closeUpGate = CloseUpGate() + closeUpPending = false + closeUpRetake = nil + meterReadout = nil + closeUpCredit = CloseUpCredit() + state.meterNumber = nil + state.closeUpFailedAttempts = 0 + live?.setMode(.closeUp) + if let replay { replay.play(range: 0...held` says the autopilot has finished with the screen and holds it still, for a UI + /// test that must audit it settled rather than while the autopilot drives it. + func waitForGate(_ phase: ScanPhase) async { + guard options.autopilot, let gate = options.autopilotGate else { return } + let file = gate.appending(path: phase.rawValue) + if !FileManager.default.fileExists(atPath: file.path) { + try? Data().write(to: gate.appending(path: "\(phase.rawValue).held")) + } + let deadline = ContinuousClock.now + .seconds(120) + while ContinuousClock.now < deadline, !FileManager.default.fileExists(atPath: file.path) { + try? await Task.sleep(for: .milliseconds(100)) + } + } + + /// Leaves the onboarding for the meter search. Live, the camera and then Motion & Fitness are + /// asked for first, while the onboarding that says why is still on screen; otherwise the AR + /// session and the barometer raise both prompts over "Find your electric meter". Without the + /// camera there is no scan, so motion is not asked for and the camera failure screen shows. + func leaveOnboarding() { + guard options.replayFolder == nil else { + go(.findMeter) + return + } + let camera = AVCaptureDevice.authorizationStatus(for: .video) + if camera == .denied || camera == .restricted { + fail(.cameraDenied) + return + } + guard camera == .notDetermined || MotionSource.needsPermission else { + go(.findMeter) + return + } + guard !askingForPermissions else { return } + askingForPermissions = true + Task { + if camera == .notDetermined, !(await AVCaptureDevice.requestAccess(for: .video)) { + askingForPermissions = false + if state.phase == .onboarding { fail(.cameraDenied) } + return + } + await motion.requestPermission() + askingForPermissions = false + if state.phase == .onboarding { go(.findMeter) } + } + } + + /// Camera access may have been turned on in Settings while the failure screen said it was + /// off: the screen asks again each time the app comes back to the foreground. With access + /// granted the failed source is let go and the scan goes on without Start over. Before the + /// meter is marked nothing is lost: the onboarding's permission step runs again (Motion & + /// Fitness, if it was never asked) and the meter search follows. After it, a new camera + /// session is a new world frame, so the flow goes back to the meter as after a lost + /// relocalization (`resetSpatialState`), keeping what describes the house. Still denied, + /// nothing changes. + func recheckCameraAccess() { + guard state.phase == .unsupported, state.failure == .cameraDenied, options.replayFolder == nil else { return } + guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else { return } + RuntimeLog.engine.info("camera access granted: resuming the scan") + releaseFailedSource() + if coverage != nil { + resetSpatialState(reason: "camera access granted after a camera failure") + } else { + go(.onboarding) + leaveOnboarding() + } + } + + private func startSourceIfNeeded() { + // A replay is its own source (`loadReplay`); a failed source waits for Start over. + guard replay == nil, options.replayFolder == nil, live == nil, sourceState.mayStartSource else { return } + sourceState.sourceStarted() + state.spatialResultAvailable = true + let capture = LiveCapture( + onFrame: { [weak self] frame in self?.ingest(frame) }, + onEvent: { [weak self] event in self?.handle(event) } + ) + live = capture + state.feed = .live + state.depthAvailable = LiveCapture.supportsDepth + capture.setRecorder(recorder) + capture.start() + updateRecording() + } + + private static func makeRecorder(_ store: KeyframeStore) -> CaptureRecorder { + CaptureRecorder(directory: store.directory.appending(path: "streams-raw", directoryHint: .isDirectory)) + } + + /// Streams record while a capture is under way, from the meter search through the upload that + /// sends it. They stop on the result, and when an upload fails or is refused, since the phone + /// may then sit idle for minutes; a request raised from the result, going back to the review + /// or sending again starts them again. Depth frames record only where the camera is meant to + /// be on the wall (the close-up, the walk, a gap request), so the meter search and the review + /// don't spend `DepthFrameBudget`. Core Motion runs only with the live camera: a replay's + /// frames were recorded by another phone at another time. + private func updateRecording() { + let capturing = switch state.phase { + case .findMeter, .meterCloseUp, .wallWalk, .markFeatures, .gapRequest: true + case .uploading: + switch state.upload { + case .failed, .rejected: false + case .idle, .packaging, .uploading, .analyzing, .done: true + } + case .onboarding, .spotConfirm, .result, .resultAR, .unsupported: false + } + let depthFrames = switch state.phase { + case .meterCloseUp, .wallWalk, .gapRequest: true + case .onboarding, .findMeter, .markFeatures, .uploading, .spotConfirm, .result, .resultAR, .unsupported: false + } + recorder.setRecording(capturing, depthFrames: depthFrames) + guard live != nil else { return } + if capturing { motion.start(into: recorder) } else { motion.stop() } + } + + var motionRunsLive: Bool { live != nil } + var motionAvailable: Set { motion.available } + + // MARK: Frames + + func ingest(_ frame: SourceFrame) { + captureClock = max(captureClock ?? frame.timestamp, frame.timestamp) + if !frame.isPoseOnly { lastFrame = frame } + if let still = frame.still { state.feed = .still(still) } + state.projection = frame.projection + if state.tracking != frame.tracking { + RuntimeLog.capture.info("tracking \(Self.name(self.state.tracking), privacy: .public) -> \(Self.name(frame.tracking), privacy: .public)") + if state.tracking == .normal { breakWalkedPath(because: "tracking left normal") } + state.tracking = frame.tracking + live?.setResultVisible(frame.tracking == .normal) + } + if !frame.groundPlanes.isEmpty, frame.groundPlanes != groundPlanes { + groundPlanes = frame.groundPlanes + refineGround() + } + if !frame.wallPlanes.isEmpty, frame.wallPlanes != wallPlanes { + wallPlanes = frame.wallPlanes + refitWallToDetectedPlane() + } + noteMeterAnchor(frame) + // A frame made before the meter was anchored again carries the old anchor's pose. + if frame.meterAnchorID == meterAnchorID { refreshMeterFromAnchor(frame) } + guard !frame.isPoseOnly else { return } + noteFarSurface() + trackRelocalization(frame) + guard !frame.isReview else { + refreshCues(camera: frame.camera) + return + } + + switch state.phase { + case .meterCloseUp: + closeUp(frame) + case .wallWalk, .gapRequest: + walk(frame) + case .findMeter: + state.coaching = coaching(for: frame.tracking, skip: nil) + default: + break + } + } + + /// Re-runs the ground lookup as ARKit adds or grows horizontal planes, so a plane below the + /// wall always replaces the guess, and a better plane replaces an earlier one. A measured + /// ground is never raised by more than `GroundPlaneChoice.maximumRaise`. + private func refineGround() { + guard var wall = coverage?.wall, + let choice = groundBelow(wall.meter, along: wall.along, current: groundMeasured ? wall.groundY : nil) else { return } + let y = choice.plane.y + // 1 cm: far under tap error, and it keeps plane jitter from republishing every frame. + guard !groundMeasured || abs(y - wall.groundY) > 0.01 else { return } + RuntimeLog.engine.info("ground at y=\(y) from \(Self.describe(choice), privacy: .public) (was \(wall.groundY), \(self.groundMeasured ? "measured" : "estimated", privacy: .public))") + wall.groundY = y + groundMeasured = true + coverage?.heightError = 0 + // Rebuilds coverage from the kept cameras: the rows now sit at other heights. + coverage?.updateWall(wall) + publishWall() + publishCoverage() + reprojectFeatures() + } + + /// A ground choice for the log: the plane's id, its class and why it was chosen. + nonisolated static func describe(_ choice: GroundPlaneChoice.Choice) -> String { + "plane \(choice.plane.id) (\(choice.plane.kind), \(choice.reason.rawValue))" + } + + /// Moves the wall to a detected wall plane that disagrees with a meter tap on an estimated + /// plane (`MeterTap.refit`): the meter goes where the tap's line of sight meets the plane, the + /// wall faces the plane's normal, and the meter is anchored again there. Only during the + /// close-up, before the walk has kept any view: coverage can't turn a wall it has already + /// seen (`CoverageMap.updateWall`), so a wall found wrong later stays wrong. + private func refitWallToDetectedPlane() { + guard state.phase == .meterCloseUp, replay == nil, meterPlaneSource == .estimatedPlane, + let live, let tapCamera = meterTapCamera, let wall = coverage?.wall, + let refit = MeterTap.refit(meter: wall.meter, outward: wall.outward, tapCamera: tapCamera, planes: wallPlanes) else { return } + let along = simd_normalize(simd_cross(-refit.outward, SIMD3(0, 1, 0))) + let choice = groundBelow(refit.meter, along: along, current: nil) + let measured = choice != nil || groundMeasured + guard setWall(meter: refit.meter, outward: refit.outward, groundY: choice?.plane.y ?? wall.groundY, groundMeasured: measured) else { return } + meterPlaneSource = .detectedPlane + refitPlaneID = refit.planeID + updateCoverage { $0.setWallLineSource(meterLineSource) } + meterAnchorID.map { live.removeAnchor($0) } + // Axes as a wall hit's: y the wall's normal, z up the wall. + let x = simd_normalize(simd_cross(refit.outward, SIMD3(0, 1, 0))) + let pose = simd_float4x4(SIMD4(x, 0), SIMD4(refit.outward, 0), SIMD4(simd_cross(x, refit.outward), 0), SIMD4(refit.meter, 1)) + setMeterAnchor(live.addMeterAnchor(at: pose), pose: pose) + let degrees = refit.turned * 180 / .pi + let ground = choice.map(Self.describe) ?? "kept" + RuntimeLog.engine.info("wall re-fitted to detected plane \(refit.planeID, privacy: .public): meter moved \(refit.moved) m, wall turned \(degrees) degrees; ground \(ground, privacy: .public)") + } + + /// Door and window heights, spans and fence distances follow the wall frame; the tapped world + /// points stay put. An anchor correction moves the points and the wall together instead + /// (`refreshMeterFromAnchor`), so it needs no reprojection. + func reprojectFeatures() { + guard let wall = coverage?.wall, !state.features.isEmpty else { return } + var features = state.features + for index in features.indices { Self.project(&features[index], onto: wall) } + if features != state.features { state.features = features } + publishFeaturesPastEnds() + } + + /// ARKit's correction to the meter's anchor, turn and move alike, applied to everything the + /// scan captured in the old world: the wall and its corners, the kept cameras and the tapped + /// marks move with the anchor as one body (`CoverageMap.apply`), so what was seen of the wall + /// stays as it was and no correction undoes an earlier one. Small ones wait until they add up + /// (`MeterAnchorTracking`). + private func refreshMeterFromAnchor(_ frame: SourceFrame) { + guard let anchor = frame.meterAnchor, coverage != nil, let correction = meterTracking?.update(to: anchor) else { return } + // How far this correction moves the meter (`WallFrame.apply`). + let step = coverage.map { simd_distance(correction.point($0.wall.meter), $0.wall.meter) } ?? 0 + coverage?.apply(correction) + if let wall = coverage?.wall { liveDots.apply(correction, wall: wall, generation: generation) } + var features = state.features + for index in features.indices { features[index].points = features[index].points.map(correction.point) } + if features != state.features { state.features = features } + publishWall() + publishCoverage() + logAnchorCorrection(correction, step: step, frame: frame) + } + + // The drift log (#73). Its lines are `.notice`, which the unified log keeps on the device, so + // `log collect` after a walk and Console without "Include Info Messages" both show them. On a + // walk away and back, the lines that are there settle it, never a line being absent: + // corrections with the marks on their objects mean ARKit corrected its map and the marks + // followed; frames that had the anchor, no corrections, and marks off their objects mean + // drift ARKit never corrected, which no anchor can fix; frames that lost the anchor mean + // nothing could follow it. `t` is the capture clock the packet's manifest times use. + + /// One line per correction applied, with the total since the meter was anchored. + /// `MeterAnchorTracking` already limits the rate: a correction is applied only once the anchor + /// has moved 2 cm or turned 0.4 degrees since the last one. + private func logAnchorCorrection(_ correction: YawCorrection, step: Float, frame: SourceFrame) { + guard let tracking = meterTracking else { return } + let total = tracking.sinceAnchored + let turned = correction.yaw * 180 / .pi + let totalMoved = simd_length(total.moved) + let totalTurned = total.yaw * 180 / .pi + let count = tracking.corrections + let marks = state.features.count + let t = frame.timestamp + let fromMeter = coverage.map { simd_distance(frame.camera.position, $0.wall.meter) } ?? 0 + let phase = state.phase.rawValue + RuntimeLog.capture.notice( + "meter anchor corrected at t=\(t, format: .fixed(precision: 2)) s, phone \(fromMeter, format: .fixed(precision: 1)) m from the meter: moved \(step, format: .fixed(precision: 3)) m, turned \(turned, format: .fixed(precision: 2)) degrees; since anchored (\(count) corrections): moved \(totalMoved, format: .fixed(precision: 3)) m (x \(total.moved.x, format: .fixed(precision: 3)), y \(total.moved.y, format: .fixed(precision: 3)), z \(total.moved.z, format: .fixed(precision: 3))), turned \(totalTurned, format: .fixed(precision: 2)) degrees; \(marks) marks moved with it (phase \(phase, privacy: .public))" + ) + } + + /// One line when the frames stop carrying the meter's anchor, and one when they carry it + /// again: while it is missing, no correction can reach the wall or the marks. The first frame + /// after the meter is anchored writes one too. Live only: a replay has no anchor. + private func noteMeterAnchor(_ frame: SourceFrame) { + guard let id = meterAnchorID, let tracking = meterTracking else { return } + let sighting: MeterAnchorPresence.Sighting = + frame.meterAnchorID != id ? .otherAnchor : frame.meterAnchor == nil ? .missing : .present + guard let change = meterAnchorPresence.observe(sighting) else { return } + let t = frame.timestamp + let fromMeter = coverage.map { simd_distance(frame.camera.position, $0.wall.meter) } ?? 0 + let count = tracking.corrections + let phase = state.phase.rawValue + RuntimeLog.capture.notice( + "meter anchor in frames: \(change.rawValue, privacy: .public) at t=\(t, format: .fixed(precision: 2)) s, phone \(fromMeter, format: .fixed(precision: 1)) m from the meter (\(count) corrections so far, phase \(phase, privacy: .public))" + ) + } + + /// The tracking's totals on every phase change once the meter is anchored, zero corrections + /// included, and whether the latest frame carried the anchor. Sending the scan and opening the + /// AR result always write one, with no anchor too (a replay), so a walk's log ends with it. + private func logMeterAnchorSummary(from previous: ScanPhase, to phase: ScanPhase) { + let from = previous.rawValue, to = phase.rawValue + let t = captureClock.map { String(format: "%.2f", $0) } ?? "none" + guard let tracking = meterTracking else { + guard phase == .uploading || phase == .resultAR else { return } + RuntimeLog.capture.notice( + "meter anchor at \(from, privacy: .public) -> \(to, privacy: .public), t=\(t, privacy: .public): not tracked (no meter anchor)" + ) + return + } + let total = tracking.sinceAnchored + let totalMoved = simd_length(total.moved) + let totalTurned = total.yaw * 180 / .pi + let count = tracking.corrections + let frames = meterAnchorPresence + let last = frames.last?.rawValue ?? "no frame yet" + let wall = coverage == nil ? "no wall" : "wall set" + let marks = state.features.count + RuntimeLog.capture.notice( + "meter anchor at \(from, privacy: .public) -> \(to, privacy: .public), t=\(t, privacy: .public): \(count) corrections since anchored, moved \(totalMoved, format: .fixed(precision: 3)) m (x \(total.moved.x, format: .fixed(precision: 3)), y \(total.moved.y, format: .fixed(precision: 3)), z \(total.moved.z, format: .fixed(precision: 3))), turned \(totalTurned, format: .fixed(precision: 2)) degrees; latest frame: \(last, privacy: .public); frames with the anchor \(frames.present), without it \(frames.missing), naming another \(frames.otherAnchor); \(wall, privacy: .public), \(marks) marks" + ) + } + + private func closeUp(_ frame: SourceFrame) { + guard let wall = coverage?.wall else { return } + if case .captured = state.closeUp { return } + if case .skipped = state.closeUp { return } + state.coaching = coaching(for: frame.tracking, skip: nil) + // After a retake request the shutter waits long enough for the reason to be read (and, + // for "move closer", acted on) before the hold can start again. + if let retake = closeUpRetake, screenTime - retake.since < Self.retakeNotice { + state.closeUp = .aiming(hold: 0, problem: retake.problem) + return + } + let sample = FrameSample(timestamp: frame.timestamp, camera: frame.camera, tracking: frame.captureTracking, quality: frame.quality) + let status = closeUpGate.evaluate(sample, meter: wall.meter) + state.closeUpFailedAttempts = status.failedAttempts + if let issue = status.issue { + logGate("close-up held back: \(issue)") + } else if status.fire || closeUpPending, !frame.jpeg.isAvailable { + logGate("close-up waiting: no photo on this frame") + } else if !status.fire, !closeUpPending { + logGate("close-up holding") + } + if status.fire || closeUpPending, status.issue == nil, frame.jpeg.isAvailable { + logGate("close-up taken from \(frame.id)", always: true) + closeUpPending = false + closeUpRetake = nil + captureCloseUp(frame) + } else { + // A hold that finished on a frame without a photo waits for the next photo, but only + // while the gates keep passing; any problem restarts the hold. + closeUpPending = status.issue == nil && (closeUpPending || status.fire) + state.closeUp = .aiming(hold: status.hold, problem: status.issue.map(Self.problem) ?? closeUpRetake?.problem) + } + } + + /// Seconds a retake reason stays up before the next close-up can be taken. A guess to try + /// on a phone, not measured: long enough to read one short line. + private static let retakeNotice: Double = 2 + + private var screenTime: Double { Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 } + + /// Back to aiming because the close-up photo was not usable (not saved, no number read, or + /// "None of these"). Counts as a failed attempt, so "Can't get a clear shot" appears from + /// the second one on. + func retakeCloseUp(_ problem: CloseUpProblem) { + guard state.phase == .meterCloseUp else { return } + RuntimeLog.engine.info("close-up retake: \(String(describing: problem), privacy: .public)") + closeUpGate.photoRejected() + state.closeUpFailedAttempts = closeUpGate.failedAttempts + closeUpPending = false + closeUpRetake = (problem, screenTime) + meterReadout = nil + state.meterNumber = nil + state.closeUp = .aiming(hold: 0, problem: problem) + } + + /// The meter number was confirmed: on to the walk once the confirmation has been seen. + func finishCloseUp() { + let scan = generation + Task { + try? await Task.sleep(for: .seconds(autoAdvanceDelay)) + await waitForGate(.meterCloseUp) + if scan == generation, state.phase == .meterCloseUp { go(.wallWalk) } + } + } + + /// The reader's answer for the close-up on screen, or nil while none is showing. + var currentMeterReadout: MeterReadout? { meterReadout } + + private func captureCloseUp(_ frame: SourceFrame) { + state.closeUp = .captured(nil) + // This shot's save replaces the photo on disk: the last one's view no longer counts. + closeUpCredit.shotStarted() + let view = frame.tracking == .normal ? CloseUpView(camera: frame.camera, depth: frame.depth) : nil + let scan = generation + let store = store + Task { + // The task can start after a reset or after the flow left the close-up. + guard scan == generation, state.phase == .meterCloseUp else { return } + let photo = await closeUpPhoto(frame) + // Drawing a practice photo suspends: a reset meanwhile must not save into the new scan. + guard scan == generation, state.phase == .meterCloseUp else { return } + let saved = await store.saveStill(photo, name: "meter_close.jpg") + guard scan == generation, state.phase == .meterCloseUp else { return } + if !saved { + retakeCloseUp(.blurry) + return + } + let thumbnail = await store.thumbnail(ofStill: "meter_close.jpg") + guard scan == generation, state.phase == .meterCloseUp else { return } + state.closeUp = .captured(thumbnail) + state.captureCount += 1 + state.lastCapture = CaptureEvent(id: state.captureCount, kind: .closeUp, thumbnail: thumbnail) + await readMeterNumber(scan: scan, view: view) + } + } + + /// Reads the meter number from the saved close-up, off the main actor, then offers the + /// candidates for the homeowner to pick from (never filling one in) or asks for a retake. + /// `view` is the shot's, which coverage may take once the reader has passed its photo. + private func readMeterNumber(scan: Int, view: CloseUpView?) async { + state.meterNumber = .reading + let reader = MeterNumberReaders.make() + let photo = store.directory.appending(path: "meter_close.jpg") + let readout = await Task.detached(priority: .userInitiated) { () -> MeterReadout? in + guard let jpeg = try? Data(contentsOf: photo) else { return nil } + return await reader.read(jpeg: jpeg) + }.value + guard scan == generation, state.phase == .meterCloseUp, state.meterNumber == .reading else { return } + closeUpCredit.photoChecked(view, passed: readout?.photoPassedChecks == true) + guard let readout else { + RuntimeLog.engine.error("close-up photo could not be read back for the meter number") + retakeCloseUp(.noNumber) + return + } + guard !readout.candidates.isEmpty else { + retakeCloseUp(readout.retake ?? .noNumber) + return + } + RuntimeLog.engine.info("meter number: \(readout.candidates.count) candidates to choose from") + meterReadout = readout + state.meterBrand = readout.brand + state.meterNumber = .choose(readout.candidates) + } + + private func walk(_ frame: SourceFrame) { + guard let map = coverage else { return } + let sample = FrameSample(timestamp: frame.timestamp, camera: frame.camera, tracking: frame.captureTracking, quality: frame.quality) + let decision = autoCapture.evaluate(sample, newlySeenCells: map.newlySeenCount(from: frame.camera)) + // Past an end it can't see back from, a photo adds nothing: it is refused and the screen says so. + // Not past the end whose next wall is being looked for: that is where the walk asked the + // homeowner to go (#70). + let pastEnd = map.unexploredEndPassed(by: frame.camera, ignoring: nextWallSide?.walk) + var skip: CaptureDecision.SkipReason? + switch decision { + case .skip(let reason): + skip = reason + logGate("skipped: \(reason)") + case .keep where pastEnd != nil: + logGate("refused: past the \(pastEnd?.rawValue ?? "") end, nothing between the ends in view") + // The gate judges sharpness and exposure from this frame's own quality when it has one, + // else from the last measured frame's. A kept photo must have been judged itself. + case .keep where frame.quality == nil: + logGate("refused: no quality measured on this frame") + case .keep where !frame.jpeg.isAvailable: + logGate("refused: no photo on this frame") + case .keep where keptSourceIDs.contains(frame.id): + logGate("refused: frame already kept") + case .keep(let reason): + logGate("kept \(frame.id) (\(reason))", always: true) + autoCapture.didKeep(sample) + keptSourceIDs.insert(frame.id) + keep(frame) + } + state.coaching = walkCoaching(tracking: frame.tracking, skip: skip, meanLuma: frame.quality?.meanLuma, pastEnd: pastEnd != nil, time: frame.timestamp) + afterCoverageChange(camera: frame.camera, time: frame.timestamp) + askOverheadIfTiltedUp(frame) + } + + /// Tracking problems show at once. The capture gate's problems go through `gateCoaching` + /// (`CoachingDebouncer`): each has its own clock, darkness is judged from the frames' luma + /// with hysteresis, and "Slow down" is kept for walking, so while the homeowner stands and + /// aims (`isAiming`) only turning and blur are said. Blur reads as "Slow down" while walking + /// and "Hold steady" while aiming. Standing past an end the phone can't see back from + /// (`pastEnd`) shows once it has lasted `showAfter` seconds, clears after `clearAfter` + /// seconds without it, and comes before the gate's problems: no photo is kept there whatever + /// the gate says. Both durations are guesses to try on a phone, not measured. + private func walkCoaching(tracking: TrackingQuality, skip: CaptureDecision.SkipReason?, meanLuma: Double?, pastEnd: Bool, time: Double) -> Coaching? { + let showAfter = 0.7 + let clearAfter = 0.5 + let aiming = isAiming + let gate = gateCoaching.update(time: time, skip: skip, meanLuma: meanLuma, aiming: aiming) + guard tracking == .normal else { + gateProblem = nil + gateClearSince = nil + // The light is what it was, but the motion from before the phone lost its place is not. + gateCoaching.forgetMotion() + return coaching(for: tracking, skip: nil) + } + let candidate: Coaching? = pastEnd ? .pastWallEnd : nil + if let problem = gateProblem, time < problem.since { gateProblem = nil } // replay restarted + if let candidate { + gateClearSince = nil + if gateProblem?.coaching != candidate { + gateProblem = (candidate, time) + } + } else if let problem = gateProblem { + let clearSince = gateClearSince ?? time + gateClearSince = clearSince + if time - clearSince >= clearAfter || time - problem.since < showAfter { + gateProblem = nil + gateClearSince = nil + } + } + if let problem = gateProblem, time - problem.since >= showAfter { return problem.coaching } + return gate.map { Self.coaching(for: $0, aiming: aiming) } + } + + /// Whether the homeowner is asked to stand and aim rather than walk: an aim, tilt, step-back, + /// see-behind or mark-the-end step, marking, a question on screen, or a gap request's view. + /// Walking too fast is not coached then (#26). Two steps ask for walking and so still say + /// "Slow down": the corner ("walk round it, aim at the next wall and mark it"; the mark + /// itself is `state.marking`, which aims) and a request to walk the stretch far enough out + /// (`GapPlan.Need.asksToWalk`). Read before this frame's guidance update, so it is the step + /// on screen when the frame arrived. + private var isAiming: Bool { + if state.marking != nil || state.endQuestion != nil || state.overheadQuestion { return true } + switch state.guidance { + case .aimAtGround, .aimAtWall, .tiltUp, .stepBack, .seeBehind, .markEnd: return true + case .gap: return !(gapPlan?.need.asksToWalk ?? false) + case .findMeter, .aimAtWallForMeter, .holdOnMeter, .walk, .walkComplete, .markNextWall: return false + } + } + + /// The coaching for a gate problem. Blur reads as "Slow down" while walking and "Hold + /// steady" while aiming, where "Slow down" would tell someone standing still to walk slower. + static func coaching(for problem: CoachingDebouncer.GateProblem, aiming: Bool) -> Coaching { + switch problem { + case .tooDark: .tooDark + case .persistentlyDark: .tooDarkToMeasure + case .movingFast: .slowDown + case .turningFast: .turnSlowly + case .blurry: aiming ? .holdSteady : .slowDown + } + } + + private func afterCoverageChange(camera: CameraFrame?, time: Double) { + publishCoverage() + if state.phase == .wallWalk, let camera { + updateGuidance(camera: camera, time: time) + } else if state.phase == .gapRequest { + updateGap(camera: camera) + } + } + + /// Stores a kept frame; coverage and the capture count move only once its photo is on disk, + /// so the strip never claims a view the bundle lacks. The photo, pose and tracking all come + /// from the one `SourceFrame`, so what is credited is the pose of the stored image. With + /// `overhead`, the stored view is also kept as a clear overhead view. `segment` is the + /// walked-path segment the frame was captured in, when it was captured before now. + private func keep(_ frame: SourceFrame, overhead: Bool = false, capturedIn segment: Int? = nil) { + let kind: CaptureEvent.Kind = state.phase == .gapRequest ? .gap : .walk + let index = store.nextKeyframeIndex() + let scan = generation + let store = store + // The walked-path segment the frame was captured in: a break while its photo stores must + // not join it to frames captured after the break. + let segment = segment ?? coverage?.pathSegment + pendingSaves[scan, default: 0] += 1 + Task { + // Every exit drains this generation's count, so the upload never waits on a write + // that was refused or failed. + defer { + let left = (pendingSaves[scan] ?? 1) - 1 + pendingSaves[scan] = left > 0 ? left : nil + } + // A frame queued before a reset must not be written into the new scan's store. + guard scan == generation else { return } + let saved = await store.saveKeyframe(frame, index: index) + guard scan == generation else { return } + guard saved.stored else { + // Not stored, so not kept: a later pass over the same replay frame may keep it. + keptSourceIDs.remove(frame.id) + if overhead { RuntimeLog.engine.error("overhead: the view asked about was not stored; nothing recorded") } + return + } + // Coverage only moves on kept frames with normal tracking (checklist R3). + // The frame's own time lets the walked path join only poses kept close together in time. + // With LiDAR depth, a cell counts only where depth confirms the camera saw it. + let delta = coverage?.observe(frame.camera, trackingNormal: frame.tracking == .normal, time: frame.timestamp, depth: frame.depth, segment: segment) + if let depth = frame.depth, frame.tracking == .normal, let wall = coverage?.wall { + liveDots.integrate(camera: frame.camera, depth: depth, wall: wall, generation: scan) + } + RuntimeLog.capture.info("stored \(frame.id, privacy: .public) as keyframe \(index)\(frame.depth == nil ? "" : " with depth", privacy: .public): \(delta?.newlySeen ?? 0) cells newly seen, \(delta?.newlyCovered ?? 0) newly covered, \(delta?.newlyHidden ?? 0) newly hidden") + if overhead { recordOverhead(frame) } + state.captureCount += 1 + state.lastCapture = CaptureEvent(id: state.captureCount, kind: kind, thumbnail: saved.thumbnail) + afterCoverageChange(camera: lastFrame?.camera, time: lastFrame?.timestamp ?? frame.timestamp) + } + } + + // MARK: Guidance + + /// The planner's dwell runs on the screen's clock, not on `time` (the frame's timestamp). + /// "Don't change the instruction for 3 s" is about what the person looking at the screen + /// reads. Live, the two clocks agree; a replay at 3x speed on frame time changed the card + /// every second or so, which the verify lane's wall-clock check (I6) and the accessibility + /// audit both caught. + private func updateGuidance(camera: CameraFrame, time _: Double) { + // A question is on screen: the next step waits for its answer. + guard let map = coverage, state.endQuestion == nil, !state.overheadQuestion else { return } + if let side = nextWallSide { + state.guidance = .markNextWall(side: side, refusal: nextWallRefusal) + state.guidanceHint = nil + // While "Is this the next wall?" is up, the ring shows the corner: where the marked + // wall meets this one, on the wall chain so it moves with the meter's anchor. + state.target = pendingNextWall.map { map.wall.world(s: $0.s, height: Self.cornerRingHeight) } + state.path = [] + logGuidance() + return + } + if let span = tiltUpSpanIfDue(map, camera: camera) { + state.guidance = .tiltUp(span: span) + state.guidanceHint = nil + state.target = map.wall.world(s: (span.lowerBound + span.upperBound) / 2, height: Self.tiltUpHeight(map)) + state.path = [] + logGuidance() + return + } + let screenTime = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 + let output = planner.update(coverage: map, camera: camera, time: screenTime) + logPlannerSwitch(output) + if let hidden = hiddenBlock(output.task, map, camera: camera) { + seeBehindBands = hidden.bands + state.guidance = .seeBehind(s: hidden.s) + state.guidanceHint = nil + state.target = hidden.bands.contains(.wall) + ? map.wall.world(s: hidden.s, height: 1) + : map.wall.world(s: hidden.s, height: 0, out: map.config.groundBandDepth / 2) + // Where to stand for another angle depends on what is in the way, which the map + // doesn't know; no path. + state.path = [] + } else { + seeBehindBands = [] + let step = Self.step(output.task) + // The frame before's direction makes the new one sticky near the view's edges + // (`AimHint.classify`), but only for the same step and so the same target. + let previous = state.guidance == step ? state.guidanceHint?.aim : nil + state.guidance = step + state.guidanceHint = Self.hint(output, camera: camera, previous: previous) + state.target = output.target + state.path = output.path + } + logGuidance() + } + + /// What the card of an aim step says beside the step (`GuidanceHint`); nil for other steps. + /// `previous` is the direction shown for the same step on the frame before, if any. + static func hint(_ output: GuidanceOutput, camera: CameraFrame, previous: AimDirection? = nil) -> GuidanceHint? { + switch output.task { + case .aimAtGround, .aimAtWall: + return GuidanceHint( + aim: output.target.map { direction(AimHint.classify(target: $0, camera: camera, previous: previous.map(Self.aimHint))) }, + needsSecondPosition: output.needsSecondPosition, + stepBack: output.stepBack + ) + case .walk, .markEnd, .stepBack, .seeBehind, .complete: + return nil + } + } + + /// Why the planner changed the walk's task, and a stalled request, in the guidance log + /// channel: the build 4.1 logs couldn't say why a card changed (#84). The packet's guidance + /// entries keep their outcome only (`closingOutcome`). + private func logPlannerSwitch(_ output: GuidanceOutput) { + if let stalled = output.stalled { + RuntimeLog.guidance.info("stalled: \(String(describing: stalled), privacy: .public) gained nothing for \(self.planner.config.stallTimeout) s; deferred until both ends are marked") + } + guard let reason = output.switched else { return } + RuntimeLog.guidance.info("switch (\(reason.rawValue, privacy: .public)) to \(String(describing: output.task), privacy: .public)") + } + + // MARK: Where the space ends + + /// Measures where the space in front of the wall ends from the vertical planes ARKit found + /// (`FarSurface.spans`) and gives it to the coverage map, again whenever the planes, the wall + /// or the stretch seen change: a corridor's far wall or a side yard's fence is then the end of + /// the space, not something to look past (#160), and a walk-out line behind it is not asked + /// for as if it could be walked (#164). Over the stretch seen and `maxDistance` beyond it + /// either way, the farthest a kept frame looks along the wall. + private func noteFarSurface() { + guard let map = coverage else { return } + let seen = map.seenExtent + if let basis = farSurfaceBasis, basis.planes == wallPlanes, basis.wall == map.wall, basis.seen == seen { return } + farSurfaceBasis = (wallPlanes, map.wall, seen) + let around = seen ?? -1...1 + let reach = map.config.maxDistance + let spans = FarSurface.spans( + planes: wallPlanes, wall: map.wall, over: (around.lowerBound - reach)...(around.upperBound + reach), cellWidth: map.config.cellWidth, + excluding: Set(refitPlaneID.map { [$0] } ?? [])) + if spans.isEmpty != map.farSurface.isEmpty { + let found = spans.map(\.out).min().map { "found, nearest \($0) m out, over \(spans.count) stretches" } ?? "none" + RuntimeLog.engine.info("far surface: \(found, privacy: .public)") + } + coverage?.setFarSurface(spans) + } + + // MARK: See-behind step (LiDAR) + + /// How far past a hidden stretch the camera must be before the walk asks to look behind it + /// instead of walking on: 1 m, about two strides, so the stretch has had a chance to show from + /// the angles the walk passes through. A guess to try on a phone, not measured. + static let seeBehindPassed: Float = 1 + + /// LiDAR phones: the stretch the walk's task is waiting on, when what keeps it open is hidden + /// cells (the camera looked, and depth showed something nearer) rather than unseen ones. + /// Walking up to it again gives the same view; another angle, or stepping round, can show it. + /// + /// An aim task's stretch counts as hidden when at least half of its cells still open (neither + /// covered nor skipped) within 0.3 m of it are hidden: the planner's own window for the task + /// (`GuidancePlanner.isSatisfied`), and "half" is a guess. A walk counts as blocked when the + /// first open cell on its side, where the planner's reach stops, is hidden and the camera is + /// `seeBehindPassed` beyond it: without this, a bush hiding the whole band leaves the walk + /// saying "walk on" while its reach never moves. + /// + /// The planner raises `.seeBehind` itself when hidden cells lie near the camera; the step is + /// then about the bands holding hidden cells near its s. Nil when there are none, which leaves + /// no cell for "Can't see past it" to settle. + private func hiddenBlock(_ task: GuidanceTask, _ map: CoverageMap, camera: CameraFrame) -> (s: Float, bands: [SurfaceBand])? { + func open(_ band: SurfaceBand, _ index: Int) -> Bool { + let level = map.level(band, index) + return level != .covered && level != .skipped + } + func mostlyHidden(_ band: SurfaceBand, around s: Float) -> Bool { + let cells = map.indices(overlapping: (s - 0.3)...(s + 0.3)).filter { map.isWithinEnds($0) && open(band, $0) } + let hidden = cells.filter { map.level(band, $0) == .hidden }.count + return hidden > 0 && hidden * 2 >= cells.count + } + switch task { + case .seeBehind(let s): + let bands = SurfaceBand.allCases.filter { !Self.hiddenCells(map, band: $0, around: s).isEmpty } + return bands.isEmpty ? nil : (s, bands) + case .aimAtGround(let s): + return mostlyHidden(.ground, around: s) ? (s, [.ground]) : nil + case .aimAtWall(let s): + return mostlyHidden(.wall, around: s) ? (s, [.wall]) : nil + case .walk(let side): + let reach = planner.reach(side, coverage: map) + let index = map.cellIndex(forS: side.sign * (reach + map.config.cellWidth / 2)) + let bands = SurfaceBand.allCases.filter { map.level($0, index) == .hidden } + let beyond = side.sign * map.wall.wallPoint(camera.position).s - reach + guard !bands.isEmpty, beyond >= Self.seeBehindPassed else { return nil } + let cell = map.cellRange(index) + return ((cell.lowerBound + cell.upperBound) / 2, bands) + case .markEnd, .stepBack, .complete: + return nil + } + } + + /// How far either side of a see-behind step's s its hidden cells are looked for: 1 m, the + /// half-width of the window around the camera in which the planner looks for them + /// (`GuidancePlanner.preferredTask`). + static let seeBehindReach: Float = 1 + + /// The hidden cells of `band` within `seeBehindReach` of `s`: what the see-behind step asks + /// to see past, and what "Can't see past it" hands to review. + static func hiddenCells(_ map: CoverageMap, band: SurfaceBand, around s: Float) -> [Int] { + map.indices(overlapping: (s - seeBehindReach)...(s + seeBehindReach)).filter { map.level(band, $0) == .hidden } + } + + /// A frame shown for review (not captured) keeps the current task but re-aims its target and + /// path from the camera now on screen; otherwise the arrow points from where the walk last was. + private func refreshCues(camera: CameraFrame) { + guard state.phase == .wallWalk, state.endQuestion == nil, !state.overheadQuestion, nextWallSide == nil, + let map = coverage, let task = planner.current else { return } + if case .tiltUp = state.guidance { return } + if case .seeBehind = state.guidance { return } + let output = planner.cues(for: task, coverage: map, camera: camera) + state.target = output.target + state.path = output.path + // The card follows the target on the frame shown, as the ring does. + if var hint = state.guidanceHint { + let previous = hint.aim.map(Self.aimHint) + hint.aim = output.target.map { Self.direction(AimHint.classify(target: $0, camera: camera, previous: previous)) } + state.guidanceHint = hint + } + } + + /// "I can't get there" or an answered end question settled the current task: choose the next + /// one now. + func resetGuidanceAfterSkip(camera: CameraFrame, time: Double) { + planner.reset() + updateGuidance(camera: camera, time: time) + } + + // MARK: Tilt-up step + + /// Half the stretch the tilt-up step asks about: 1.5 m (about 5 ft) each side of the meter, + /// inside the marked ends. The spots the server tries first sit beside the meter, where the + /// cable run is shortest, and a 2.58 ft battery fits on either side within it. 3 m is also + /// about what one upright view takes in from 3 m out. A judgment call, not measured: a spot + /// farther along gets its own `.overhead` request from the server. + static let tiltUpReach: Float = 1.5 + + /// The stretch to ask about, once both ends are marked and answered, the step hasn't been + /// answered or skipped, and the phone is within reach of the stretch (`tiltUpInReach`) or the + /// step is already showing; nil otherwise, and when the marked ends leave no stretch. Out of + /// reach the walk's own tasks go on, and the step comes up when the phone comes back; still + /// unsettled when the walk ends, it is settled with nothing recorded (`finishWalk`). + private func tiltUpSpanIfDue(_ map: CoverageMap, camera: CameraFrame) -> ClosedRange? { + guard !tiltUpSettled, let left = map.leftEnd, let right = map.rightEnd else { return nil } + let low = max(left, -Self.tiltUpReach) + let high = min(right, Self.tiltUpReach) + guard low < high else { return nil } + if case .tiltUp = state.guidance { return low...high } + return Self.tiltUpInReach(low...high, cameraS: map.wall.wallPoint(camera.position).s) ? low...high : nil + } + + /// Whether a phone at `cameraS` along the wall is near enough the tilt-up stretch to be asked + /// about it: within `tiltUpReach` plus 1 m of its middle, so within about 1 m of the stretch + /// when it is the full 3 m. On build 4.1 the step came up the moment both ends were marked, + /// at the right end, 19 ft from the meter (#64). The 1 m is a guess, not measured. + static func tiltUpInReach(_ span: ClosedRange, cameraS: Float) -> Bool { + abs(cameraS - (span.lowerBound + span.upperBound) / 2) <= tiltUpReach + 1 + } + + /// How far above the top wall row (`CoverageConfig.wallCaptureHeight`, 7.5 ft) a view must + /// reach to count as tilted up: 0.7 m, so about 9.8 ft above the ground, past a one-storey + /// eave, where the view shows whether one is there. It is a height, not a pitch: a + /// level view from 2 m out reaches about 8.5 ft and does not count, one from 2.6 m out + /// reaches about 10 ft and does, and it shows what is overhead as well as a tilted one. A + /// guess to try on a phone, not measured. + static let tiltUpAbove: Float = 0.7 + + /// The height the tilt-up step and an overhead request aim at, meters above the ground. + static func tiltUpHeight(_ map: CoverageMap) -> Float { map.config.wallCaptureHeight + tiltUpAbove } + + /// The stretches a view shows at least `tiltUpHeight` up the wall (`CoverageMap.overheadReach`): + /// empty unless the camera is tilted up at the wall. + static func tiltedUp(_ camera: CameraFrame, _ map: CoverageMap) -> [ClosedRange] { + let height = tiltUpHeight(map) + return map.overheadReach(from: camera).filter { $0.out >= height }.map(\.span) + } + + /// Raises the overhead question when a frame with normal tracking is tilted up where the scan + /// needs it: over any of the tilt-up step's stretch during the walk, or, for an overhead gap + /// request, over enough of the requested span that recording it settles the request + /// (`GapPlanner.overheadViewSettles`), so "nothing overhead" always closes it. Only the + /// homeowner can say whether what is above is open sky or an eave. Any frame with a photo + /// counts, not only frames auto-capture kept: "nothing overhead" stores that photo as a + /// keyframe, and the view counts only once it is stored (`keepOverheadView`). + private func askOverheadIfTiltedUp(_ frame: SourceFrame) { + guard !state.overheadQuestion, frame.tracking == .normal, frame.jpeg.isAvailable, let map = coverage else { return } + let wanted: ClosedRange + switch state.phase { + case .wallWalk: + guard case .tiltUp(let span) = state.guidance else { return } + wanted = span + case .gapRequest: + guard let plan = gapPlan, case .overhead = plan.need, state.gap?.isSatisfied == false else { return } + wanted = plan.span + default: + return + } + let seen = Self.tiltedUp(frame.camera, map) + guard seen.contains(where: { $0.overlaps(wanted) }) else { return } + if state.phase == .gapRequest, let plan = gapPlan, !gapPlanner.overheadViewSettles(plan, map, camera: frame.camera) { return } + // The segment now: the answer can come after a tracking break. + pendingOverhead = (frame, coverage?.pathSegment) + state.overheadQuestion = true + RuntimeLog.engine.info("tilt-up view over s \(seen.first?.lowerBound ?? 0)...\(seen.last?.upperBound ?? 0): asking what is overhead") + } + + /// Ends the tilt-up step. `clear` is the homeowner's "Open sky or nothing overhead", which + /// keeps the view the question was about; false for "A roof edge, porch or stairs", "Can't + /// get there" or leaving the walk, which keep nothing. + func settleTiltUp(clear: Bool) { + let pending = pendingOverhead + pendingOverhead = nil + state.overheadQuestion = false + tiltUpSettled = true + // After settling, so the guidance recomputed once it is stored moves past the tilt-up step. + let storing = clear && pending.map { keepOverheadView($0.frame, capturedIn: $0.segment) } == true + RuntimeLog.engine.info("tilt-up step settled: \(storing ? "storing the clear overhead view" : "nothing recorded", privacy: .public)") + } + + /// The answer to the overhead question during an overhead gap request. "Nothing overhead" + /// keeps the view, which was checked to settle the request when the question was raised, so + /// the request closes through `updateGap`. Something overhead means no view can settle it: + /// the request goes to installer review and the gap loop moves on to the upload. + func settleOverheadGap(clear: Bool) { + let pending = pendingOverhead + pendingOverhead = nil + state.overheadQuestion = false + guard clear else { + skipCurrentGap(because: "something is overhead", refused: false) + return + } + if pending.map({ keepOverheadView($0.frame, capturedIn: $0.segment) }) != true { + RuntimeLog.engine.error("overhead answer: the view asked about could not be kept") + } + } + + /// The replay's closing run of tilted-up frames (`tiltedUp`): the walk leaves them out, and + /// the tilt-up step and overhead gap requests play them (`playReplayTiltUp`). Empty at the end + /// when the recording has none. + static func tiltUpFrames(in replay: ReplayPlayer, map: CoverageMap) -> Range { + var start = replay.frames.count + while start > 0, !tiltedUp(replay.camera(at: start - 1), map).isEmpty { start -= 1 } + return start.. Bool { + guard let replay, let map = coverage else { return false } + let frames = Self.tiltUpFrames(in: replay, map: map) + guard !frames.isEmpty else { return false } + replay.play(range: frames, speed: replaySpeed) + return true + } + + private func updateGap(camera: CameraFrame?) { + guard let map = coverage, let plan = gapPlan, var request = state.gap else { return } + request.progress = gapPlanner.progress(of: plan, map) + noteWalkOut(plan, map, camera: camera, into: &request) + let fresh = if case .overhead = plan.need { + map.overheadCameras.count > overheadViewsAtGapStart + } else { + store.keyframes.count > keyframesAtGapStart + } + let satisfied = gapPlanner.isSatisfied(plan, map) && fresh + state.guidance = .gap + let center = (plan.span.lowerBound + plan.span.upperBound) / 2 + let cue = gapCue(plan, map, center: center) + state.target = cue.target + if request.spaceEnds != nil { + // The walk-out line lies behind where the space ends (#164): no dotted line to it. + state.path = [] + } else if let camera { + let from = map.wall.wallPoint(camera.position).s + // Every request but an overhead one needs its whole span seen or walked, and the + // server's can run 20 ft or more (ground out to a pool's clearance), so the line runs + // on to the span's far end. One tilted-up view from the middle covers an overhead one. + let farEnd = abs(plan.span.lowerBound - from) > abs(plan.span.upperBound - from) ? plan.span.lowerBound : plan.span.upperBound + let to = if case .overhead = plan.need { center } else { farEnd } + state.path = [from, to].map { map.wall.world(s: $0, height: 0, out: cue.standOut) } + } + logGuidance() + if satisfied, !request.isSatisfied { + resolveGuidance(.met) + request.isSatisfied = true + request.progress = max(request.progress, gapPlanner.config.satisfiedFraction) + state.gap = request + RuntimeLog.engine.info("gap \(request.id) satisfied") + let id = request.id + Task { + try? await Task.sleep(for: .seconds(autoAdvanceDelay)) + await waitForGate(.gapRequest) + // Only if this same request is still showing (not skipped or replaced meanwhile). + guard state.phase == .gapRequest, state.gap?.id == id else { return } + afterGapResolved() + } + } else if !request.isSatisfied { + state.gap = request + } + } + + /// A walk-out request's reading and whether the space ends short of its line (#164): the + /// card then gives the distance that counts where the phone is, or says the space ends + /// before the line and "I can't get there" is the answer. The reading is rounded to 3 in, its + /// out down and what counts up, so it changes every few strides rather than every frame and + /// never asks for less than counts; a guess at what reads calmly. Where the space ends is + /// kept for the request once shown: ARKit refines its planes about ten times a second, and a + /// headline that moved or came and went with them would re-arm the reply's lock each time + /// (`InstructionCard.replyLock`). + private func noteWalkOut(_ plan: GapPlan, _ map: CoverageMap, camera: CameraFrame?, into request: inout GapRequest) { + guard case .walkOut = plan.need else { return } + let step: Float = 0.0762 + let block = gapPlanner.walkOutBlock(plan, map) + let ends = block.map { GapRequest.SpaceEnds(at: ($0.spaceEnds / step).rounded(.down) * step, needed: ($0.needed / step).rounded(.up) * step) } + if request.spaceEnds == nil, let block, let ends { + let found = String( + format: "the space ends %.2f m out over s %.2f...%.2f, short of the line at up to %.2f m", + block.spaceEnds, block.span.lowerBound, block.span.upperBound, block.needed) + // The log's message is an escaping autoclosure, which can't capture `request`. + let id = request.id + RuntimeLog.engine.info("gap \(id) walk-out: \(found, privacy: .public)") + request.spaceEnds = ends + } + request.walkOut = camera.flatMap { camera in + let at = map.wall.wallPoint(camera.position) + return gapPlanner.walkOutNeeded(plan, map, atS: at.s).map { + GapRequest.WalkOutReading(out: (max(0, at.out) / step).rounded(.down) * step, needed: ($0 / step).rounded(.up) * step) + } + } + } + + /// Where a gap request points the camera, and how far out from the wall to walk for it. + private func gapCue(_ plan: GapPlan, _ map: CoverageMap, center: Float) -> (target: SIMD3, standOut: Float) { + let standOff = planner.config.standOff + switch plan.need { + case .cells: + let target = plan.band == .ground + ? map.wall.world(s: center, height: 0, out: map.config.groundBandDepth / 2) + : map.wall.world(s: center, height: 1.2) + return (target, standOff) + case .groundOut(let out): + // 1 m beyond the requested depth: a phone at chest height (about 1.4 m) tilted down + // there sees the ground from about 2 m nearer the wall out to that depth, within the + // 65 degree view limit. Geometry only; not tried on a device. + return (map.wall.world(s: center, height: 0, out: out), max(standOff, out + 1)) + case .walkOut(let out): + // The walk has to pass `out` plus the wall's position error at the span's end where it + // is larger (farther from the meter, or on a piece with a larger default); 0.3 m more + // leaves room for drifting toward the wall. The 0.3 m is a guess. + let error = max(map.positionError(atS: plan.span.lowerBound), map.positionError(atS: plan.span.upperBound)) + return (map.wall.world(s: center, height: 1.2), max(standOff, out + error + 0.3)) + case .overhead(let height): + // Aim where a tilted-up view reaches, or at the height asked for when that is higher. + let aim = max(Self.tiltUpHeight(map), height ?? 0) + return (map.wall.world(s: center, height: aim), standOff) + case .wallUp(let height): + // At the height the view must pass, walked along the whole span like a cell request: + // each stretch needs it from two places. + return (map.wall.world(s: center, height: height), standOff) + } + } + + private func logGuidance() { + noteGuidance() + let name = Self.name(state.guidance) + guard name != lastGuidanceLog else { return } + lastGuidanceLog = name + RuntimeLog.guidance.info("GUIDANCE=\(name, privacy: .public)") + } + + /// Logs a capture-gate decision. The gate judges about ten frames a second, so a reason is + /// logged when it differs from the last one logged; `always` logs every time (a kept frame, + /// a shot taken). `decision` holds only enum reasons and frame ids, never image content. + private func logGate(_ decision: String, always: Bool = false) { + guard always || decision != lastGateLog else { return } + lastGateLog = decision + RuntimeLog.capture.info("gate \(decision, privacy: .public)") + } + + /// The homeowner's path is not continuous across this point (tracking left normal, the + /// session was interrupted, or the walk paused), so walked-path evidence must not join the + /// poses on either side. + private func breakWalkedPath(because reason: String) { + guard coverage != nil else { return } + coverage?.breakWalkedPath() + RuntimeLog.capture.info("walked path broken: \(reason, privacy: .public)") + } + + // MARK: Tracking recovery + + private func trackRelocalization(_ frame: SourceFrame) { + guard live != nil else { return } + guard case .limited(.relocalizing) = frame.tracking else { + if let since = relocalizingSince { + RuntimeLog.capture.info("relocalization ended after \(frame.timestamp - since, format: .fixed(precision: 1)) s: tracking \(Self.name(frame.tracking), privacy: .public)") + } + relocalizingSince = nil + return + } + if relocalizingSince == nil { + RuntimeLog.capture.info("relocalization started (phase \(self.state.phase.rawValue, privacy: .public))") + } + let since = relocalizingSince ?? frame.timestamp + relocalizingSince = since + // After 20 s ARKit is unlikely to relocalize; the old world frame is gone (checklist R5). + guard frame.timestamp - since > 20 else { return } + switch state.phase { + case .findMeter, .meterCloseUp, .wallWalk: + // The walk still needs the world frame: start again from the meter. + RuntimeLog.capture.info("relocalization timed out after 20 s: resetting to the meter") + resetSpatialState(reason: "relocalization timed out") + case .gapRequest: + // The scan so far is whole; only this request needs the lost frame. It goes to installer + // review as if the homeowner couldn't get there, and the upload that follows keeps the + // scan (and, for a request raised from the result, replaces the result with the new + // answer). The clock restarts, so a later request gets its own 20 s. + relocalizingSince = nil + // A request already met is on its way to the upload (`afterGapResolved`). + guard state.gap?.isSatisfied != true else { return } + RuntimeLog.capture.info("relocalization timed out after 20 s: leaving the gap for installer review") + skipCurrentGap(because: "the phone lost its place for 20 s") + case .markFeatures: + // The review needs no live frame: "Looks complete" uploads the scan as it is, and + // "Add something", which taps into the world frame, waits for tracking to return + // (`beginMarking`). Nothing is thrown away. + break + case .uploading, .spotConfirm, .result, .resultAR, .onboarding, .unsupported: + // The bundle is already packed and the server's answer does not depend on the live + // world frame, so the scan and the result stay. The AR result hides its overlay while + // tracking is not normal and shows it again if ARKit does relocalize. + relocalizingSince = nil + } + } + + private func handle(_ event: LiveEvent) { + switch event { + case .interrupted: + // The phase, captures and strip stay as they are; ARKit relocalizes into the same + // world frame when the session resumes (checklist R4). + RuntimeLog.capture.info("session interrupted") + breakWalkedPath(because: "session interrupted") + state.coaching = .relocalizing + case .interruptionEnded: + RuntimeLog.capture.info("session interruption ended") + state.coaching = .relocalizing + case .cameraDenied: + // As for a failed session: once the scan is sent, the answer stays on screen. + switch state.phase { + case .uploading, .result, .resultAR: + RuntimeLog.engine.error("camera access lost after capture") + _ = sourceState.sourceFailed(.recoverable, afterCapture: true) + loseSpatialResult() + default: + fail(.cameraDenied) + } + case .failed(let message): + // Once the scan is sent, the upload and its result no longer need the camera: keep + // them on screen. Only the AR view needs it, and it already hides the battery while + // the camera isn't tracking. + switch state.phase { + case .uploading, .result, .resultAR: + RuntimeLog.engine.error("camera session failed after capture: \(message, privacy: .public)") + _ = sourceState.sourceFailed(.recoverable, afterCapture: true) + loseSpatialResult() + default: + // The screen shows plain words, so the camera's own error is only recorded here. + RuntimeLog.engine.error("camera session failed: \(message, privacy: .public)") + fail(.sessionFailed(message)) + } + } + } + + /// Forgets everything tied to the old world frame and asks for the meter again. What + /// describes the house rather than a place in the old frame stays: the ground answer. The + /// close-up's own state (gate, readout, view) is reset when the close-up starts again + /// (`go(.meterCloseUp)`). + func resetSpatialState(reason: String) { + RuntimeLog.engine.info("spatial reset: \(reason, privacy: .public)") + generation += 1 + // A new world frame is a new packet session: what was recorded is in the old frame. + recorder.restart() + resetPacketLog() + relocalizingSince = nil + groundPlanes = [] + wallPlanes = [] + farSurfaceBasis = nil + refitPlaneID = nil + groundMeasured = false + lastFrame = nil + // A fresh map: the old world frame is gone, so its anchors and planes are meaningless. + live?.restart() + coverage = nil + meterAnchorID.map { live?.removeAnchor($0) } + meterAnchorID = nil + meterTracking = nil + meterAnchorPresence = MeterAnchorPresence() + meterPlaneSource = .detectedPlane + state.wall = nil + state.coverage = .empty + state.target = nil + state.path = [] + state.features = [] + // A mark half placed holds taps in the old frame's wall coordinates. + state.marking = nil + pendingTaps = [] + state.gap = nil + gapPlan = nil + pastEndSide = nil + clearedEnd = nil + // Skipped requests name spans along the old wall. + skippedGaps = [] + automaticGaps = [] + automaticGapsStopped = false + seeBehindBands = [] + endKinds = [:] + state.endQuestion = nil + state.wallTooShort = false + nextWallSide = nil + nextWallRefusal = nil + walkRefusals = WalkRefusals() + resetTiltUp() + resetSpotChecks() + // An answer describes a scan that no longer exists; the next upload brings a new one. + uploadTask?.cancel() + placement = nil + state.result = nil + state.upload = .idle + // The keyframes and stills, the meter close-up included, were taken in the old frame. + store.discardKeyframes() + // A bundle packed before this holds keyframes of the world frame just discarded. + state.shareableScan = nil + keptSourceIDs = [] + state.captureCount = 0 + state.lastCapture = nil + gateProblem = nil + gateClearSince = nil + gateCoaching = CoachingDebouncer() + autoCapture.reset() + planner.reset() + go(.findMeter) + } + + // MARK: Wall + + /// Sets the wall from a meter point and the wall's outward normal, and starts coverage. + func setWall(meter: SIMD3, outward: SIMD3, groundY: Float, groundMeasured: Bool) -> Bool { + guard let frame = WallFrame(meter: meter, outward: outward, groundY: groundY) else { return false } + coverage = CoverageMap(wall: frame) + coverage?.heightError = groundMeasured ? 0 : Self.estimatedGroundError + self.groundMeasured = groundMeasured + endKinds = [:] + state.endQuestion = nil + state.wallTooShort = false + nextWallSide = nil + nextWallRefusal = nil + walkRefusals = WalkRefusals() + resetTiltUp() + publishWall() + publishCoverage() + return true + } + + func publishWall() { + guard let map = coverage else { state.wall = nil; return } + let wall = map.wall + state.wall = WallGeometry( + meter: wall.meter, along: wall.along, outward: wall.outward, groundY: wall.groundY, + leftEnd: map.leftEnd, rightEnd: map.rightEnd, + cornerSegments: wall.segments.indices.filter { $0 != wall.meterSegmentIndex }.map { index in + let piece = wall.segments[index] + return WallGeometry.Segment(span: piece.span, along: piece.along, outward: piece.outward, anchor: piece.anchor, anchorS: piece.anchorS) + } + ) + if state.phase == .resultAR { showResultInCamera(rising: false) } + publishFeaturesPastEnds() + } + + /// "See it on your wall" on the live camera. The screen draws the result over the camera + /// itself (`BatteryOverlay`) unless the AR scene is seen drawing it (`watchResultInCamera`). + /// Build 4.1 trusted the AR scene as soon as it had the model and showed no battery at all + /// (#67). Only a phone with LiDAR tries the AR scene, where the mesh can hide the result + /// behind things in front of it; without LiDAR the screen draws it, as build 3.1 did. A + /// replay, or a meter without an anchor, leaves it to the screen too. + private func showResultInCamera(rising: Bool) { + guard LiveCapture.supportsMesh, let live, let wall = state.wall, let result = state.result, + let pose = meterTracking?.pose, state.spatialResultAvailable else { return } + if rising || resultWatch == nil { watchResultInCamera() } + // Each rebuild takes the model out of the scene and puts a new one in, so a wall that + // moved by a centimeter or two (the ground or the meter's anchor refined) keeps the one + // it has. It hangs on the meter's anchor, so it follows the anchor's corrections anyway. + if !rising, let built = resultBuiltFor, Self.movedLittle(from: built, to: wall) { return } + let model = ResultARModel.build(wall: wall, result: result) + // The battery's middle (the outline's, on the ground, for a spot that isn't clean), or the + // meter without a spot, in the model's coordinates. + let focus = (result.spotCenter(on: wall) ?? wall.meter) - wall.meter + guard live.showResult(model, builtFor: pose, focus: focus) else { + resultBuiltFor = nil + return + } + resultBuiltFor = wall + live.setResultVisible(state.tracking == .normal) + if rising { ResultARModel.rise(model) } + } + + /// Looks at the AR scene about ten times a second while "See it on your wall" is up, and + /// lets the screen's drawing step aside only while the scene is seen drawing the result + /// (`ResultOverlayPolicy`). On its own clock, not in `ingest`: it has to notice a scene that + /// stopped drawing whether frames arrive or not. + private func watchResultInCamera() { + resultWatch?.cancel() + resultWatch = Task { [weak self] in + var policy = ResultOverlayPolicy() + while !Task.isCancelled { + guard let self, self.state.phase == .resultAR, let live = self.live else { return } + let look = live.resultIsDrawn() + // While tracking is limited the model is disabled and the screen draws neither + // layer, so the AR scene keeps the result: when tracking comes back the two switch + // on together, rather than the Canvas showing until the next look. + let held = look.held || (look.anchored && self.state.tracking != .normal) + let usesRealityKit = policy.update(drawn: look.drawn, held: held, time: self.screenTime) + if self.state.resultInCamera != usesRealityKit { + RuntimeLog.engine.info("AR result drawn by \(usesRealityKit ? "the AR scene" : "the screen overlay", privacy: .public)") + self.state.resultInCamera = usesRealityKit + } + try? await Task.sleep(for: .milliseconds(100)) + } + } + } + + private func hideResultInCamera() { + resultWatch?.cancel() + resultWatch = nil + resultBuiltFor = nil + live?.hideResult() + state.resultInCamera = false + } + + /// A wall change under these leaves the AR result's model as built: a few centimeters is + /// within what the ground and the meter's anchor are known to. Display choices, not measured. + private static let resultRebuildDistance: Float = 0.05 + private static let resultRebuildTurnCosine: Float = cos(2 * Float.pi / 180) + + /// True when `new` is `old` moved by less than `resultRebuildDistance` and turned by less + /// than 2° in every part the model is built from: the meter, the ground, the ends and each + /// piece of wall. + private static func movedLittle(from old: WallGeometry, to new: WallGeometry) -> Bool { + func closePoints(_ a: SIMD3, _ b: SIMD3) -> Bool { + simd_distance(a, b) < Self.resultRebuildDistance + } + func closeValues(_ a: Float?, _ b: Float?) -> Bool { + guard let a, let b else { return a == nil && b == nil } + // Equal first: a corner piece's span is infinite toward its open end. + return a == b || abs(a - b) < Self.resultRebuildDistance + } + func closeDirections(_ a: SIMD3, _ b: SIMD3) -> Bool { + simd_dot(simd_normalize(a), simd_normalize(b)) > Self.resultRebuildTurnCosine + } + guard closePoints(old.meter, new.meter), closeValues(old.groundY, new.groundY), + closeValues(old.leftEnd, new.leftEnd), closeValues(old.rightEnd, new.rightEnd), + closeDirections(old.along, new.along), closeDirections(old.outward, new.outward), + old.cornerSegments.count == new.cornerSegments.count else { return false } + return zip(old.cornerSegments, new.cornerSegments).allSatisfy { a, b in + closeValues(a.span.lowerBound, b.span.lowerBound) && closeValues(a.span.upperBound, b.span.upperBound) + && closePoints(a.anchor, b.anchor) && closeValues(a.anchorS, b.anchorS) + && closeDirections(a.along, b.along) && closeDirections(a.outward, b.outward) + } + } + + func publishCoverage() { + guard let map = coverage else { return } + guard map.revision != state.coverage.revision || state.coverage.wall.isEmpty else { return } + let range = map.visibleRange + let indices = map.indices(overlapping: range) + state.coverage = CoverageStrip( + cellWidth: map.config.cellWidth, + firstCellS: map.cellRange(indices.lowerBound).lowerBound, + wall: indices.map { Self.cell(map.level(.wall, $0)) }, + ground: indices.map { Self.cell(map.level(.ground, $0)) }, + // The band the walk asks for; heights above it are still reported when seen. + wallBandHeight: map.config.wallWalkHeight, + groundBandDepth: map.config.groundBandDepth, + visibleRange: range, + revision: map.revision + ) + } + + func setEnd(_ side: WallSide, at s: Float, kind: EndKind) { + guard var map = coverage else { return } + markTimes[MarkKey.end(side)] = captureClock + map.setEnd(side == .left ? .left : .right, at: s) + // Ground past a limit end still counts toward clearances (server contract, "Ends and + // corners"); past an unexplored end it doesn't. + map.setEndIsLimit(side == .left ? .left : .right, kind == .limit) + coverage = map + endKinds[side] = kind + state.wallTooShort = false + publishWall() + publishCoverage() + RuntimeLog.engine.info("end \(side.rawValue, privacy: .public) at s=\(s) (\(kind == .limit ? "limit" : "unexplored", privacy: .public))") + if let camera = lastFrame?.camera, state.phase == .wallWalk { + updateGuidance(camera: camera, time: lastFrame?.timestamp ?? 0) + } + } + + /// What the homeowner said is at an already marked end. + func setEndKind(_ side: WallSide, _ kind: EndKind) { + endKinds[side] = kind + updateCoverage { $0.setEndIsLimit(side == .left ? .left : .right, kind == .limit) } + RuntimeLog.engine.info("end \(side.rawValue, privacy: .public) is \(kind == .limit ? "limit" : "unexplored", privacy: .public)") + } + + func clearEnd(_ side: WallSide) { + markTimes[MarkKey.end(side)] = nil + updateCoverage { $0.clearEnd(side == .left ? .left : .right) } + endKinds[side] = nil + if state.endQuestion == side { state.endQuestion = nil } + publishWall() + } + + var bothEndsMarked: Bool { coverage?.leftEnd != nil && coverage?.rightEnd != nil } + + // MARK: Gap loop + + /// After the review: ask for the planner's gap, or upload. Confirming the review starts a new + /// pass of server requests raised without a tap. + func runGapCheck() { + automaticGaps = [] + automaticGapsStopped = false + // A request needs the camera in the scan's world frame; while the phone has lost its + // place, the scan goes as it is and the server's answer lists what is still unseen. + guard !state.tracking.hasLostItsPlace else { + RuntimeLog.engine.info("gap check skipped: the phone has lost its place; uploading the scan as it is") + startUpload() + return + } + guard let map = coverage, let plan = gapPlanner.plan(map), !skippedGaps.contains(plan) else { + startUpload() + return + } + beginGap(plan, origin: .phone, reason: plan.band == .ground ? .groundNearCandidate : .wallAboveCandidate) + } + + func beginGap(_ plan: GapPlan, origin: GapRequest.Origin, reason: GapRequest.Reason) { + // A request with a reach says what to do in its own terms, whatever the caller passed. + let reason: GapRequest.Reason = switch plan.need { + case .cells: reason + case .groundOut(let out): .groundOut(out: out) + case .walkOut(let out): .walkOut(out: out) + case .overhead: .overhead + // The server's own words name the height ("seen at least 6 ft 6 in up the wall"). + case .wallUp: reason + } + gapCounter += 1 + gapPlan = plan + keyframesAtGapStart = store.keyframes.count + overheadViewsAtGapStart = coverage?.overheadCameras.count ?? 0 + let progress = coverage.map { gapPlanner.progress(of: plan, $0) } ?? 0 + state.gap = GapRequest(id: gapCounter, origin: origin, reason: reason, band: plan.band == .ground ? .ground : .wall, span: plan.span, progress: progress, isSatisfied: false) + state.guidance = .gap + go(.gapRequest) + updateGap(camera: lastFrame?.camera) + } + + /// A closed or skipped gap goes straight to the upload, which re-runs the server's checks + /// with the new evidence (the closed loop: gap, instruction, capture, updated result). The + /// answer then leads to the next capturable request or to the result (`upload`). + private func afterGapResolved() { + settleClearedEnd() + gapPlan = nil + pastEndSide = nil + pendingOverhead = nil + state.overheadQuestion = false + state.gap = nil + startUpload() + } + + /// The past_end request's end was marked again and its question answered: the request is + /// settled, so the scan goes to the upload like a closed gap. + func settlePastEnd() { + guard state.phase == .gapRequest, var request = state.gap, !request.isSatisfied else { return } + resolveGuidance(.met) + request.isSatisfied = true + request.progress = 1 + state.gap = request + RuntimeLog.engine.info("gap \(request.id) settled by marking the end again") + let id = request.id + Task { + try? await Task.sleep(for: .seconds(autoAdvanceDelay)) + await waitForGate(.gapRequest) + guard state.phase == .gapRequest, state.gap?.id == id else { return } + afterGapResolved() + } + } + + /// Keeps the tilted-up view the overhead question was about as overhead evidence. Call it + /// only once the homeowner answered that nothing is overhead (`answerOverhead(clear: true)`): + /// the camera can't tell open sky from an eave. The view's photo is stored as a keyframe + /// first, and it counts as overhead evidence only once stored (`recordOverhead`), like every + /// other view. Returns false when it can't be kept: no photo, tracking not normal, or the + /// view doesn't show the wall from the top of the sampled rows (7.5 ft, `wallCaptureHeight`) upward. + /// `segment` is the walked-path segment the view was captured in. + func keepOverheadView(_ frame: SourceFrame, capturedIn segment: Int?) -> Bool { + guard let map = coverage, frame.jpeg.isAvailable, frame.tracking == .normal, + !map.overheadReach(from: frame.camera).isEmpty else { return false } + keep(frame, overhead: true, capturedIn: segment) + return true + } + + /// Records a stored tilted-up view in the coverage map; the export then sends each stretch it + /// reached as an overhead entry with the height seen. + private func recordOverhead(_ frame: SourceFrame) { + guard var map = coverage else { return } + let reach = map.recordOverhead(frame.camera, trackingNormal: frame.tracking == .normal) + guard !reach.isEmpty else { + RuntimeLog.engine.error("overhead: the stored view no longer reaches above the wall band; nothing recorded") + return + } + coverage = map + RuntimeLog.engine.info("overhead: kept a view reaching \(reach.map(\.out).min() ?? 0) m over s=\(reach.first?.span.lowerBound ?? 0)...\(reach.last?.span.upperBound ?? 0)") + } + + /// Ends the current request without the view it asked for ("I can't get there", something + /// overhead, or "Show my result"): recorded for installer review, then on to the upload. The + /// answer that follows raises the next item it lists, never this one again + /// (`automaticGapQueue`); only "Show my result" (`stopGapRequests`) ends the requests. + func skipCurrentGap(because reason: String = "the homeowner can't get there", refused: Bool = true) { + guard let plan = gapPlan else { return } + // Something overhead is an answer: the request goes to review without its view. + resolveGuidance(refused ? .cannotReach : .skipped) + // Only a cell request marks cells: skipping a deeper, walked or overhead view says + // nothing about the band the strip draws. + if plan.need == .cells { coverage?.markSkipped(plan.band, plan.span) } + skippedGaps.append(plan) + publishCoverage() + RuntimeLog.engine.info("gap \(self.gapCounter) left for installer review: \(reason, privacy: .public)") + afterGapResolved() + } + + /// "Show my result" on a server request: this view goes to installer review like one the + /// homeowner can't get to, and after the upload that follows the result shows instead of + /// the answer's next request. + func stopGapRequests() { + guard gapPlan != nil, state.gap?.origin == .server else { return } + automaticGapsStopped = true + skipCurrentGap(because: "the homeowner asked for the result", refused: false) + } + + // MARK: Upload + + func startUpload() { + go(.uploading) + uploadTask?.cancel() + uploadTask = Task { await upload() } + } + + private func upload() async { + let scan = generation + state.upload = .packaging + // Sending again from a failed upload stays on this phase, so `go` doesn't restart them. + updateRecording() + // Keyframe writes still in flight belong in the scene's keyframe list. + for _ in 0..<200 where (pendingSaves[scan] ?? 0) > 0 { + try? await Task.sleep(for: .milliseconds(50)) + } + guard scan == generation else { return } + // LiDAR phones: the mesh ARKit built, measured for what faces the wall and what is + // overhead, off the main actor since ray casts over a whole mesh take a while. + let meshSnapshot = live?.meshSnapshot() + var measured = MeshMeasurements() + if let mesh = meshSnapshot?.mesh, let map = coverage { + let wall = map.wall + let span = Self.exportSpan(map) + measured = await Task.detached(priority: .userInitiated) { Self.measure(mesh, wall: wall, over: span) }.value + guard scan == generation else { return } + RuntimeLog.engine.info("mesh: \(mesh.vertices.count) vertices, \(mesh.indices.count / 3) triangles; \(measured.facing.count) facing and \(measured.overheads.count) overhead measurements") + } + let scene: Data + do { + scene = try sceneJSON(mesh: measured) + } catch { + RuntimeLog.engine.error("scene.json export failed: \(String(describing: error), privacy: .public)") + state.upload = UploadFailure.packaging(error) + updateRecording() + return + } + writeScanStamp(answer: placement) + saveBundle(scene: scene, mesh: meshSnapshot) + guard !Task.isCancelled else { return } + state.upload = .uploading(fraction: 0) + analyzingSince = nil + do { + let data = try await resultClient.submit(scene: scene) { [weak self] fraction in + Task { @MainActor in + guard let self, scan == self.generation, case .uploading = self.state.upload else { return } + if fraction >= 1 { self.analyzingSince = ContinuousClock.now } + self.state.upload = fraction >= 1 ? .analyzing : .uploading(fraction: fraction) + } + } + guard scan == generation else { return } + if analyzingSince == nil { analyzingSince = ContinuousClock.now } + state.upload = .analyzing + let result = try PlacementResult.decode(data) + // "Check clearances" stays up for `analyzingMinimum` however fast the answer came, + // so the step and then its tick are drawn (issue #31). + let dwell = UploadPacing.remaining(since: analyzingSince, minimum: .seconds(Self.analyzingMinimum), now: ContinuousClock.now) + if dwell > .zero { + try await Task.sleep(for: dwell) + guard scan == generation, state.phase == .uploading else { return } + } + placement = result + noteExchange(scene: scene, answer: data) + writeScanStamp(answer: result) + state.result = presentation(of: result, isSample: resultClient.isSample) + state.followUps = automaticGapQueue(result).count + state.upload = .done + await waitForGate(.uploading) + guard scan == generation else { return } + if let next = automaticGapQueue(result).first { + // The upload screen says "One more view to finish" once the answer is in + // (`state.result` set, upload `.done`, both kept through the request): long + // enough to read before the camera takes over. + try await Task.sleep(for: .seconds(Self.followUpHold)) + guard scan == generation, state.phase == .uploading else { return } + automaticGaps.append(next.plan) + RuntimeLog.engine.info("answer lists capturable evidence: asking for it (\(self.automaticGaps.count) of at most \(Self.maxAutomaticGaps))") + beginServerGap(next.item, plan: next.plan) + return + } + // Every step ticked, "Clearances checked" last, for `resultHold` before the result + // replaces the screen: going on in the same turn never drew the tick (issue #31). + try await Task.sleep(for: .seconds(Self.resultHold)) + guard scan == generation, state.phase == .uploading else { return } + // Keep the completion tick, then ask about the proposed spot before showing it. + presentAnswer() + } catch is CancellationError { + return + } catch { + guard scan == generation else { return } + RuntimeLog.engine.error("upload failed: \(String(describing: error), privacy: .public)") + state.upload = UploadFailure.state(for: error) + updateRecording() + } + } + + /// The answer's items this pass will still raise without a tap, in order, with their + /// requests: those a capture can settle (the result's "capturable"), not skipped and not yet + /// raised in this pass (`GapPlanner.serverRequests`), up to `maxAutomaticGaps` requests. + /// Empty once the homeowner tapped "Show my result". `asking`, a request about to be raised, + /// counts as raised. + private func automaticGapQueue(_ result: PlacementResult, asking: GapPlan? = nil) -> [(item: PlacementMissingEvidence, plan: GapPlan)] { + // A request raised while the phone has lost its place could only time out: show the result. + guard !automaticGapsStopped, !state.tracking.hasLostItsPlace, let map = coverage else { return [] } + let asked = automaticGaps + (asking.map { [$0] } ?? []) + // A new view cannot settle an area the homeowner has already said is obstructed. + // Filter before the request limit so refused areas do not consume the remaining slots. + let capturable = result.missingEvidence.filter { item in + gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds) + .map { captureCanSettle($0) } ?? false + } + return gapPlanner.serverRequests( + in: capturable, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds, + asked: asked, skipped: skippedGaps, limit: Self.maxAutomaticGaps - automaticGaps.count) + } + + /// Starts the capture for an item of the server's missing evidence, whether tapped on the + /// result or raised after an upload. + func beginServerGap(_ item: PlacementMissingEvidence, plan: GapPlan) { + // Counted before a past_end request clears its end, which would change the other requests. + state.followUps = 1 + (placement.map { automaticGapQueue($0, asking: plan).count } ?? 0) + var pastEnd: WallSide? + if item.kind == .pastEnd, let side = item.side { + // The walk has to go past the end it stopped at; that end is no longer a limit. It + // stays cleared until the homeowner marks it again (markWallEnd) or the request ends + // (settleClearedEnd). + let wallSide: WallSide = side == .left ? .left : .right + pastEnd = wallSide + let old = wallSide == .left ? coverage?.leftEnd : coverage?.rightEnd + clearedEnd = old.map { (s: $0, kind: endKinds[wallSide] ?? EndKind.unexplored, t: markTimes[MarkKey.end(wallSide)]) } + clearEnd(wallSide) + } + // Set first, so the guidance log records the request as a past-end one. + pastEndSide = pastEnd + beginGap(plan, origin: .server, reason: .server(detail: item.message)) + } + + /// A past_end request ending without its end marked again (the gap screen offers only "I + /// can't get there") must not leave that side without an end: the export would run the wall + /// out to whatever the fog saw, and the next past_end request would be planned from the + /// meter (issue #35). Met, the end moves on past the ground the request showed, still + /// unexplored (`GapPlanner.endAfterPastEnd`); skipped, the end it cleared comes back with its + /// kind and mark time. + private func settleClearedEnd() { + guard let side = pastEndSide, let old = clearedEnd, let plan = gapPlan else { return } + clearedEnd = nil + guard (side == .left ? coverage?.leftEnd : coverage?.rightEnd) == nil else { return } + if state.gap?.isSatisfied == true { + setEnd(side, at: gapPlanner.endAfterPastEnd(plan, side: side == .left ? .left : .right, clearedAt: old.s), kind: .unexplored) + } else { + setEnd(side, at: old.s, kind: old.kind) + markTimes[MarkKey.end(side)] = old.t + } + } + + /// Writes the capture packet (`ScanEngine+Packet.swift`) with this scene.json inside, zipped + /// into the scan folder's `scan.zip`: the bundle "Share scan" offers (`state.shareableScan`) + /// whatever the upload then does: it fails, is refused or answers. Nothing uploads the + /// packet, and the upload never waits for it or fails because of it. The zip is rewritten in + /// place, so it is not offered while a write is under way, and writes run one after another: + /// a retry's write waits for the last one, and a write already superseded is skipped. + func saveBundle(scene: Data, mesh: LiveCapture.MeshSnapshot?) { + state.shareableScan = nil + bundleSerial += 1 + let serial = bundleSerial + let scan = generation + let previous = bundleTask + guard let inputs = packetInputs(scene: scene, mesh: mesh) else { + RuntimeLog.engine.error("scan bundle not written: no wall") + return + } + bundleTask = Task { + await previous?.value + guard scan == generation, serial == bundleSerial else { return } + do { + let written = try await Task.detached(priority: .userInitiated) { try Self.writePacket(inputs) }.value + RuntimeLog.engine.info("bundle \(written.url.path, privacy: .public): packet \(PacketManifest.version, privacy: .public) with \(written.summary, privacy: .public) (kept on the phone)") + guard scan == generation, serial == bundleSerial else { return } + state.shareableScan = written.url + } catch { + RuntimeLog.engine.error("scan bundle not written: \(String(describing: error), privacy: .public)") + } + } + } + + // MARK: Result for AR + + /// The replay frame that sees the chosen spot best, for the AR result on a replay. + private func bestFrameForResult() -> Int? { + guard let replay, let wall = coverage?.wall else { return nil } + let spot = state.result?.spot + let s = spot.map { ($0.span.lowerBound + $0.span.upperBound) / 2 } ?? 0 + let point = wall.world(s: s, height: 0.5, out: 0.3) + return replay.bestFrame(showing: point) + } + + // MARK: Start over + + func resetAll() { + generation += 1 + uploadTask?.cancel() + replay?.stop() + coverage = nil + farSurfaceBasis = nil + refitPlaneID = nil + liveDots.reset() + state.liveDots = .empty + meterAnchorID.map { live?.removeAnchor($0) } + meterAnchorID = nil + meterTracking = nil + meterAnchorPresence = MeterAnchorPresence() + meterPlaneSource = .detectedPlane + store = KeyframeStore() + recorder = Self.makeRecorder(store) + live?.setRecorder(recorder) + motion.stop() + resetPacketLog() + keptSourceIDs = [] + autoCapture.reset() + gateCoaching = CoachingDebouncer() + planner.reset() + closeUpGate = CloseUpGate() + gapPlan = nil + skippedGaps = [] + pastEndSide = nil + clearedEnd = nil + automaticGaps = [] + automaticGapsStopped = false + seeBehindBands = [] + endKinds = [:] + state.endQuestion = nil + state.wallTooShort = false + nextWallSide = nil + nextWallRefusal = nil + walkRefusals = WalkRefusals() + resetTiltUp() + resetSpotChecks() + placement = nil + // The bundle belongs to the scan being thrown away; `generation` stops a write in flight + // from offering it again. + state.shareableScan = nil + state.wall = nil + state.coverage = .empty + state.features = [] + state.groundAnswer = nil + state.marking = nil + state.gap = nil + state.result = nil + state.upload = .idle + state.captureCount = 0 + state.lastCapture = nil + state.target = nil + state.path = [] + state.coaching = nil + state.guidance = .findMeter + state.guidanceHint = nil + state.closeUp = .aiming(hold: 0, problem: nil) + state.closeUpFailedAttempts = 0 + state.meterNumber = nil + closeUpRetake = nil + meterReadout = nil + state.isPracticeScan = false + go(.onboarding) + replay?.show(index: 0) + } + + /// The overhead views belong to the world frame and the scan they were seen in. + private func resetTiltUp() { + tiltUpSettled = false + pendingOverhead = nil + state.overheadQuestion = false + } + + // MARK: Internal accessors for marking and export + + var currentFrame: SourceFrame? { lastFrame } + var liveCapture: LiveCapture? { live } + var wallEndKinds: [WallSide: EndKind] { endKinds } + + /// The meter's anchor, and the pose the wall set with it agrees with. + func setMeterAnchor(_ id: UUID?, pose: simd_float4x4?) { + meterAnchorID = id + meterTracking = pose.map(MeterAnchorTracking.init) + meterAnchorPresence = MeterAnchorPresence() + } + + var detectedGroundPlanes: [GroundPlaneEvidence] { groundPlanes } + var detectedWallPlanes: [WallPlaneEvidence] { wallPlanes } + + /// The camera failed after the scan was sent: no frame will come to say tracking was lost, so + /// the last one's "normal" would keep the AR result up and offered. The answer stays; the + /// spatial result goes. + private func loseSpatialResult() { + state.tracking = .notAvailable + state.spatialResultAvailable = false + hideResultInCamera() + if state.phase == .resultAR { go(.result) } + } + + /// Start over after a failure: the failed source is let go and the failure cleared, so the + /// next scan starts a new session (`startSourceIfNeeded`) or reads the replay again. A + /// device that can't run world tracking stays failed. + func releaseFailedSource() { + let discard = sourceState.startOver() + if sourceState.failure == nil { state.failure = nil } + guard discard else { return } + live?.pause() + live = nil + state.feed = .none + if replay == nil, let folder = options.replayFolder { Task { await loadReplay(folder) } } + } + + func updateCoverage(_ body: (inout CoverageMap) -> Void) { + guard var map = coverage else { return } + body(&map) + coverage = map + publishCoverage() + } + + // MARK: Packet log + + /// Forgets the guidance log, the mark times and the clock: they belong to the packet session + /// being thrown away. + private func resetPacketLog() { + guidanceLog = GuidanceLog() + markTimes = [:] + captureClock = nil + } + + /// Records what request is on screen now in the guidance log. + func noteGuidance() { + publishEndPreview() + guard let t = captureClock else { return } + guidanceLog.show(guidanceRequest(), at: t) { old, next in closingOutcome(old, next: next) } + } + + /// Closes the open request with an outcome an action settled. + func resolveGuidance(_ outcome: GuidanceLog.Outcome) { + guard let t = captureClock else { return } + guidanceLog.resolve(outcome, at: t) + } + + /// Closes the open request and takes it off the log's screen, so the same step shown again + /// is a new request: after Back on the next-wall step, "It turns a corner" asks for the next + /// wall again while `state.guidance` never left it (review of #136). + func withdrawGuidance(_ outcome: GuidanceLog.Outcome) { + guard let t = captureClock else { return } + guidanceLog.resolve(outcome, at: t) + guidanceLog.show(nil, at: t) { _, _ in outcome } + } +} + +// MARK: - Mapping to contract values + +extension ScanEngine { + static func cell(_ level: CoverageLevel) -> CellState { + switch level { + case .unseen: .unseen + case .seen: .seen + case .covered: .covered + case .skipped: .skipped + case .hidden: .hidden + } + } + + static func step(_ task: GuidanceTask) -> GuidanceStep { + switch task { + case .walk(let side): .walk(side: side == .left ? .left : .right, remaining: nil) + case .markEnd(let side): .markEnd(side: side == .left ? .left : .right) + case .aimAtGround(let s): .aimAtGround(s: s) + case .aimAtWall(let s): .aimAtWall(s: s) + case .stepBack: .stepBack + case .seeBehind(let s): .seeBehind(s: s) + case .complete: .walkComplete + } + } + + static func direction(_ hint: AimHint) -> AimDirection { + switch hint { + case .onScreen: .onScreen + case .above: .above + case .below: .below + case .left: .left + case .right: .right + case .behind: .behind + } + } + + /// The inverse of `direction(_:)`. + static func aimHint(_ direction: AimDirection) -> AimHint { + switch direction { + case .onScreen: .onScreen + case .above: .above + case .below: .below + case .left: .left + case .right: .right + case .behind: .behind + } + } + + static func name(_ tracking: TrackingQuality) -> String { + switch tracking { + case .notAvailable: "notAvailable" + case .normal: "normal" + case .limited(let reason): "limited.\(reason)" + } + } + + static func name(_ step: GuidanceStep) -> String { + switch step { + case .findMeter: "findMeter" + case .aimAtWallForMeter: "aimAtWallForMeter" + case .holdOnMeter: "holdOnMeter" + case .walk(let side, _): "walk.\(side.rawValue)" + case .markEnd(let side): "markEnd.\(side.rawValue)" + case .aimAtGround: "aimAtGround" + case .aimAtWall: "aimAtWall" + case .stepBack: "stepBack" + case .walkComplete: "walkComplete" + case .tiltUp: "tiltUp" + case .markNextWall(let side, _): "markNextWall.\(side.rawValue)" + case .gap: "gap" + case .seeBehind: "seeBehind" + } + } + + static func problem(_ issue: CloseUpIssue) -> CloseUpProblem { + switch issue { + case .blurry: .blurry + case .tooDark: .tooDark + case .tooBright: .tooBright + case .notCentered: .meterNotCentered + case .tooFar: .tooFar + case .tracking: .tracking + } + } + + /// Tracking problems first (they block everything), then the capture gate's reason. + func coaching(for tracking: TrackingQuality, skip: CaptureDecision.SkipReason?) -> Coaching? { + switch tracking { + case .notAvailable: return .trackingLost + case .limited(.initializing): return .initializing + case .limited(.excessiveMotion): return .slowDown + case .limited(.insufficientFeatures): return .needsTexture + case .limited(.relocalizing): return .relocalizing + case .limited(.unknown): return .trackingLost + case .normal: + switch skip { + case .movingFast?, .blurry?: return .holdSteady + case .turningFast?: return .turnSlowly + case .tooDark?: return .tooDark + default: return nil + } + } + } +} diff --git a/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift new file mode 100644 index 000000000..9900cd915 --- /dev/null +++ b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift @@ -0,0 +1,42 @@ +import HouseScanKit + +extension ScanViewState { + /// How far the walk's aim task is toward done, 0...1, for the ring that fills (#81). Nil for + /// every step but an aim task. + var aimProgress: Double? { + aimProgress(for: guidance) + } + + /// How far `step` is toward done, 0...1: the covered share of its band over the stretch the + /// planner checks, `aimHalfWidth` either side of its s, over the share that completes it + /// (`aimSatisfied`). The planner's numbers are read here, on the engine's side of the + /// contract, so the screens don't import them. + /// + /// The overlay also scores the step that just ended with it. The planner moves on in the + /// same update that completes a step, so by the time a screen sees the full strip, + /// `guidance` is already the next step. + /// + /// It reads the published strip, which holds the cells in view on the wall map + /// (`CoverageMap.visibleRange`); the planner reads the whole map. Near an end of that range + /// the two can differ by a cell. The window is narrowed by the map's own tolerance + /// (`CoverageMap.indices(overlapping:)`), so a cell that only touches its edge counts for + /// neither, and the ring can't show done while the task stays up. Nil for every step but an + /// aim task. + func aimProgress(for step: GuidanceStep) -> Double? { + let band: CoverageBand + let s: Float + switch step { + case .aimAtGround(let at): + band = .ground + s = at + case .aimAtWall(let at): + band = .wall + s = at + default: + return nil + } + let half = GuidancePlanner.aimHalfWidth - coverage.cellWidth * 1e-3 + let covered = coverage.coveredFraction(band, in: (s - half)...(s + half)) + return min(1, covered / GuidancePlanner.aimSatisfied) + } +} diff --git a/ios/HouseScan/Runtime/SourceFrame.swift b/ios/HouseScan/Runtime/SourceFrame.swift new file mode 100644 index 000000000..a99f34a09 --- /dev/null +++ b/ios/HouseScan/Runtime/SourceFrame.swift @@ -0,0 +1,157 @@ +import CoreGraphics +import Foundation +import HouseScanKit +import ImageIO +import simd + +/// One camera frame from the live session or a replay, in the form the engine consumes. +struct SourceFrame: Sendable { + /// Replay keyframe id, or "live-". + var id: String + /// Seconds on the frame clock (ARFrame.timestamp, or the replay's timestamps). + var timestamp: Double + var camera: CameraFrame + var tracking: TrackingQuality + /// Measured on some frames only; auto-capture reuses the last measurement in between. + var quality: FrameQuality? + /// Where the full-resolution JPEG comes from if the frame is kept. + var jpeg: JPEGPayload + /// The replay image, drawn as the feed. Nil for live frames. + var still: CGImage? + /// World transform of the meter's ARAnchor in this frame, when one exists. + var meterAnchor: simd_float4x4? + /// Which anchor `meterAnchor` is. A frame made before the meter was anchored again + /// (`ScanEngine.refitWallToDetectedPlane`) still carries the old one's pose, which the engine + /// must not take as a correction of the new one. + var meterAnchorID: UUID? + /// Detected horizontal planes, with their classes and outlines. Empty when ARKit has found + /// none or the frame doesn't carry them. + var groundPlanes: [GroundPlaneEvidence] = [] + /// Detected vertical planes, with their classes, normals and outlines. Empty when ARKit has + /// found none or the frame doesn't carry them. + var wallPlanes: [WallPlaneEvidence] = [] + /// LiDAR depth copied with the photo of a frame that could be kept; nil without LiDAR, and on + /// frames without a photo. Coverage reads it; a replay's recorded depth plays in here too. + var depth: DepthImage? + /// The same ARKit depth map as float32 meters with its confidence, for the packet. Live frames + /// with a photo only: a replay's depth is not ARKit's own, so it never goes into the packet. + var sensorDepth: DepthPacket? + /// Camera settings of a live frame with a photo; nil on a replay. + var exposure: PhotoExposure? + /// Shown for review or tapping only; never offered to auto-capture. + var isReview = false + /// Carries only pose and tracking, so overlays follow the camera between sampled frames. + var isPoseOnly = false + + var projection: CameraProjection { + CameraProjection(cameraToWorld: camera.cameraToWorld, intrinsics: camera.intrinsics, imageSize: camera.imageSize) + } + + var captureTracking: TrackingStatus { + switch tracking { + case .normal: .normal + case .limited: .limited + case .notAvailable: .notAvailable + } + } +} + +/// The camera settings a photo was taken with: `ARCamera.exposureDuration` and +/// `exposureOffset`, and ISO, focal length and f-number from `ARFrame.exifData` when it has them. +struct PhotoExposure: Sendable, Equatable { + var durationS: Double? + var offsetEV: Double? + var iso: Double? + var focalLengthMM: Double? + var fNumber: Double? +} + +enum JPEGPayload: Sendable { + /// A JPEG already on disk (replay keyframes). + case file(URL) + /// A JPEG encoded on LiveCapture's encode queue, off the AR delegate queue. + case data(Data) + /// Not encoded; the frame can't be kept. + case none + + var isAvailable: Bool { + if case .none = self { return false } + return true + } +} + +/// Image helpers that run off the main actor. +enum ImageWork { + /// Decodes a JPEG and measures its quality from a quarter-size grayscale copy. + static func decode(_ url: URL) -> (CGImage, FrameQuality)? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return nil } + return (image, quality(of: image)) + } + + static func quality(of image: CGImage) -> FrameQuality { + let width = max(8, image.width / 4) + let height = max(8, image.height / 4) + var pixels = [UInt8](repeating: 0, count: width * height) + pixels.withUnsafeMutableBytes { buffer in + guard let context = CGContext( + data: buffer.baseAddress, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { return } + context.interpolationQuality = .medium + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + } + return FrameQuality(LumaImage(width: width, height: height, pixels: pixels)) + } + + /// The JPEG's luma as Pillow's `convert("L")` computes it (`PacketSharpness.luma`), row-major, + /// at the JPEG's full size: the input of the packet's sharpness. + /// The image is drawn into an RGB context in its own color space, so no color conversion + /// changes the decoded values. Nil when it does not decode. + static func luma(jpeg: Data) -> (pixels: [UInt8], width: Int, height: Int)? { + guard let source = CGImageSourceCreateWithData(jpeg as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return nil } + let width = image.width + let height = image.height + let space = image.colorSpace.flatMap { $0.model == .rgb ? $0 : nil } ?? CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + var rgbx = [UInt8](repeating: 0, count: width * height * 4) + let drawn = rgbx.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width * 4, space: space, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return false } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drawn else { return nil } + let luma = rgbx.withUnsafeBytes { PacketSharpness.luma(rgbx: $0, width: width, height: height, bytesPerRow: width * 4) } + return (luma, width, height) + } + + /// A small upright thumbnail: the landscape sensor image rotated 90° clockwise, as the + /// screen shows it. + static func uprightThumbnail(jpeg: Data, maxPixels: Int = 240) -> CGImage? { + guard let source = CGImageSourceCreateWithData(jpeg as CFData, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixels, + ] + guard let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } + return rotatedClockwise(thumb) + } + + static func rotatedClockwise(_ image: CGImage) -> CGImage? { + let width = image.height + let height = image.width + guard let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + // Core Graphics has y up: rotating the content by -90° about the origin and shifting it up + // by the new height turns the image clockwise as seen on screen. + context.translateBy(x: 0, y: CGFloat(height)) + context.rotate(by: -.pi / 2) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return context.makeImage() + } +} diff --git a/ios/HouseScan/SessionDelegate.swift b/ios/HouseScan/SessionDelegate.swift deleted file mode 100644 index 00ca7866e..000000000 --- a/ios/HouseScan/SessionDelegate.swift +++ /dev/null @@ -1,46 +0,0 @@ -import ARKit - -/// Receives ARSession callbacks and forwards them to `CaptureSessionModel`. -/// -/// ARKit calls these methods on the session's `delegateQueue`, which `ARCaptureView` sets to a -/// private serial queue so per-frame work added later stays off the main thread. Each callback -/// copies what it needs into Sendable values on that queue, then hops to the main actor with -/// `Task { @MainActor in ... }`. The model is main-actor isolated and therefore Sendable, so -/// no `@unchecked Sendable` or `nonisolated(unsafe)` is needed. ARKit's own objects stay on the -/// delegate queue. -final class SessionDelegate: NSObject, ARSessionDelegate { - private let model: CaptureSessionModel - - init(model: CaptureSessionModel) { - self.model = model - } - - func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { - let state = TrackingState(camera.trackingState) - Task { @MainActor [model] in model.trackingChanged(to: state) } - } - - func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { - let planes = anchors.compactMap(DetectedPlane.init) - guard !planes.isEmpty else { return } - Task { @MainActor [model] in model.planesAdded(planes) } - } - - func session(_ session: ARSession, didRemove anchors: [ARAnchor]) { - let planes = anchors.compactMap(DetectedPlane.init) - guard !planes.isEmpty else { return } - Task { @MainActor [model] in model.planesRemoved(planes) } - } - - func sessionWasInterrupted(_ session: ARSession) { - Task { @MainActor [model] in model.interruptionChanged(isInterrupted: true) } - } - - func sessionInterruptionEnded(_ session: ARSession) { - Task { @MainActor [model] in model.interruptionChanged(isInterrupted: false) } - } - - func session(_ session: ARSession, didFailWithError error: any Error) { - Task { @MainActor [model] in model.sessionFailed(error) } - } -} diff --git a/ios/HouseScan/UI/Camera/CameraBackdrop.swift b/ios/HouseScan/UI/Camera/CameraBackdrop.swift new file mode 100644 index 000000000..25adbfc2f --- /dev/null +++ b/ios/HouseScan/UI/Camera/CameraBackdrop.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// What sits behind every overlay: the live camera, a replayed still, or a dark placeholder. +/// +/// A still is the unrotated landscape sensor image. It is shown rotated 90° clockwise and +/// scaled to fill, centered and cropped, which is exactly what `CameraProjection.viewPoint` +/// assumes, so overlays drawn from the projection line up with the picture. +struct CameraBackdrop: View { + var feed: CameraFeed + var actions: any ScanActions + + var body: some View { + GeometryReader { proxy in + switch feed { + case .live: + actions.liveCameraView() + .frame(width: proxy.size.width, height: proxy.size.height) + case .still(let image): + Image(decorative: image, scale: 1, orientation: .right) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + case .none: + LinearGradient( + colors: [Color(white: 0.16), Color(white: 0.05)], + startPoint: .top, endPoint: .bottom + ) + } + } + .ignoresSafeArea() + .accessibilityHidden(true) + } +} diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift new file mode 100644 index 000000000..10b8fb1c6 --- /dev/null +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -0,0 +1,153 @@ +import SwiftUI + +/// The overlays every walking screen draws over the camera, in paint order. +struct CameraOverlays: View { + let state: ScanViewState + var highlight: GapRequest? + /// Set to the aim ring's legend while it shows, for the screen to draw under its card + /// (`CameraChrome.legend`). Nil on screens with no legend. + var cardLegend: Binding? = nil + + /// The step the aim ring's legend first showed with. The legend explains the first ring that + /// fills and retires once that step ends (#81); it isn't needed on every ring after. + @State private var legendStep: GuidanceStep? = nil + @State private var legendRetired = false + /// Whether a filling ring is on screen (`WayfindingOverlay.onFillingRingShown`): the legend + /// explains the ring, so it shows only with it. + @State private var fillingRingShown = false + /// The aim step on screen, with its target, so the update that ends it can still score it. + @State private var lastAim: AimSnapshot? = nil + /// The target of an aim step that just completed, held on screen as a full green ring with a + /// tick for `completedHold` (#81). The planner moves on in the same update that fills the + /// strip, so without the hold the ring would vanish short of full and never turn green. + @State private var completedTarget: SIMD3? = nil + /// Counts completions, so a second one inside the hold restarts its timer. + @State private var completions = 0 + + /// How long a completed ring stays: long enough to read as done, short enough not to hold up + /// the next step's marker. A display choice. + private static let completedHold: Duration = .seconds(1) + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.self) private var environment + + var body: some View { + ZStack { + overlays + } + .animation(.easeOut(duration: 0.2), value: state.tracking == .normal) + .onChange(of: AimSnapshot(step: state.guidance, target: state.target), initial: true) { old, new in + if new.step != old.step { + if let legendStep, new.step != legendStep { legendRetired = true } + if let target = completed(old) { + completedTarget = target + completions += 1 + } + } + lastAim = state.aimProgress(for: new.step) == nil ? nil : new + } + .task(id: completions) { + guard completedTarget != nil else { return } + // A newer completion replaces this task; its cancellation must not clear the new ring. + do { try await Task.sleep(for: Self.completedHold) } catch { return } + completedTarget = nil + } + .onChange(of: shownLegend, initial: true) { _, line in + if let cardLegend, cardLegend.wrappedValue != line { cardLegend.wrappedValue = line } + // Counted as shown only once drawn, so it retires with the step it showed on. + if line != nil, cardLegend != nil, legendStep == nil { legendStep = state.guidance } + } + } + + @ViewBuilder + private var overlays: some View { + // Hidden while tracking isn't normal: drawn from a pose the phone doesn't trust, the fog, + // path and marks would sit in the wrong place (checklist T3). They fade back on recovery. + if state.tracking == .normal, let projection = state.projection, let wall = state.wall { + ZStack { + fog(projection: projection, wall: wall) + .ignoresSafeArea() + WallMarksOverlay( + projection: projection, + wall: wall, + features: state.features, + wallBandHeight: state.coverage.wallBandHeight + ) + if state.marking == nil { + // While marking, the reticle is the only aim; the path and ring would compete. + WayfindingOverlay( + projection: projection, + wall: wall, + path: state.path, + target: state.target, + progress: state.aimProgress, + completed: heldTarget, + onFillingRingShown: { shown in fillingRingShown = shown } + ) + .transition(.opacity) + } + } + } + } + + /// The live fog, with dots when the phone has depth. The frosted strip stands in while the + /// shaders compile at launch, and for good if Metal fails, so unseen wall never shows clear. + @ViewBuilder + private func fog(projection: CameraProjection, wall: WallGeometry) -> some View { + switch LiveFogSupport.shared.status { + case let .ready(gpu): + ZStack { + LiveFogView(gpu: gpu, input: LiveFogInput( + projection: projection, wall: wall, coverage: state.coverage, highlight: highlight, + dots: state.depthAvailable ? state.liveDots : .empty, reduceMotion: reduceMotion, + requestedColor: requestedColor)) + FogMarks(coverage: state.coverage, wall: wall, projection: projection, highlight: highlight) + } + .transition(.opacity) + case .preparing, .failed: + FogOverlay(coverage: state.coverage, wall: wall, projection: projection, highlight: highlight) + } + } + + private var requestedColor: SIMD3 { + let resolved = Palette.caution.resolve(in: environment) + return SIMD3(resolved.red, resolved.green, resolved.blue) + } + + /// The completed ring to hold. The update that ends an aim step draws before `onChange` + /// runs, so the outgoing step is scored here too; otherwise that frame would show the next + /// step's marker between the filling ring and the green one. + private var heldTarget: SIMD3? { + if let completedTarget { return completedTarget } + guard let lastAim, lastAim.step != state.guidance else { return nil } + return completed(lastAim) + } + + /// The target of `snapshot`'s aim step if the strip as it is now completes it. The strip + /// holds the view that ended the step. + private func completed(_ snapshot: AimSnapshot) -> SIMD3? { + guard let target = snapshot.target, let progress = state.aimProgress(for: snapshot.step), progress >= 1 else { + return nil + } + return target + } + + /// The legend, while the first filling ring is on screen and short of full (#81). + /// + /// It goes under the instruction card, in the chrome's own stack, rather than beside the + /// ring: beside it, it went behind the card at accessibility text sizes (the card grows + /// and is drawn over the camera layers), and any layout that dropped it where there was no + /// room left the people with the largest text without it. In the stack it grows with the + /// card and scrolls with it at any size. + private var shownLegend: String? { + guard fillingRingShown, !legendRetired, state.marking == nil, heldTarget == nil, + let progress = state.aimProgress, progress < 1 else { return nil } + return ScanCopy.aimRingLegend + } +} + +/// The step and its target together, so a change of step still has the target it ended with. +private struct AimSnapshot: Equatable { + var step: GuidanceStep + var target: SIMD3? +} diff --git a/ios/HouseScan/UI/Camera/FogMarks.swift b/ios/HouseScan/UI/Camera/FogMarks.swift new file mode 100644 index 000000000..8b377feff --- /dev/null +++ b/ios/HouseScan/UI/Camera/FogMarks.swift @@ -0,0 +1,113 @@ +import SwiftUI +import simd + +/// The crisp lines over the live fog, which a soft mask can't draw: a dashed violet outline +/// round each stretch hidden behind something, a slate hatch where the homeowner said "I can't +/// get there", and an amber outline round the stretch a gap request asks for. The same marks +/// and dash the tape map uses, so the camera and the map read alike. +struct FogMarks: View { + var coverage: CoverageStrip + var wall: WallGeometry + var projection: CameraProjection + var highlight: GapRequest? + + var body: some View { + Canvas(rendersAsynchronously: false) { context, size in + let geometry = WallProjection(projection: projection, wall: wall, size: size) + let runs = runs() + // The map's dash, lengthened for the larger shapes on the camera. + let dash = StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round, dash: Palette.hiddenDash.map { $0 * 1.6 }) + for run in runs.hidden { + guard let outline = outline(run.band, run.edges, geometry) else { continue } + // A dark halo under the dash keeps it legible on a sunlit wall. + context.stroke(outline, with: .color(.black.opacity(0.35)), style: StrokeStyle(lineWidth: 4, lineJoin: .round)) + context.stroke(outline, with: .color(Palette.hidden), style: dash) + } + for run in runs.skipped { + guard let quad = outline(run.band, run.edges, geometry) else { continue } + context.fill(quad, with: .color(Palette.skipped.opacity(0.3))) + var hatched = context + hatched.clip(to: quad) + let bounds = quad.boundingRect + var lines = Path() + var x = bounds.minX - bounds.height + while x < bounds.maxX { + lines.move(to: CGPoint(x: x, y: bounds.maxY)) + lines.addLine(to: CGPoint(x: x + bounds.height, y: bounds.minY)) + x += 10 + } + hatched.stroke(lines, with: .color(.white.opacity(0.55)), lineWidth: 1.5) + } + for run in runs.requested { + guard let outline = outline(run.band, run.edges, geometry) else { continue } + context.stroke(outline, with: .color(.black.opacity(0.3)), style: StrokeStyle(lineWidth: 4, lineJoin: .round)) + context.stroke(outline, with: .color(Palette.caution), style: StrokeStyle(lineWidth: 2, lineJoin: .round)) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private enum Kind { case hidden, skipped, requested } + + private struct Run { + var band: CoverageBand + /// The s of every cell edge along the run, clipped to the visible range. + var edges: [Float] + } + + /// Runs of hidden, of skipped, and of requested-but-not-covered cells in each band. Coverage + /// filling the middle of a request splits it, and each piece keeps its outline. + private func runs() -> (hidden: [Run], skipped: [Run], requested: [Run]) { + var hidden: [Run] = [], skipped: [Run] = [], requested: [Run] = [] + let visible = coverage.visibleRange + guard visible.upperBound > visible.lowerBound else { return ([], [], []) } + for band in CoverageBand.allCases { + let states = coverage.cells(band) + var current: (kind: Kind, run: Run)? + func close() { + if let current { + switch current.kind { + case .hidden: hidden.append(current.run) + case .skipped: skipped.append(current.run) + case .requested: requested.append(current.run) + } + } + current = nil + } + for index in states.indices { + let range = coverage.cellRange(index) + guard range.upperBound > visible.lowerBound, range.lowerBound < visible.upperBound else { close(); continue } + let s0 = max(range.lowerBound, visible.lowerBound), s1 = min(range.upperBound, visible.upperBound) + let state = states[index] + let isRequested = highlight.map { $0.band == band && $0.span.lowerBound < range.upperBound && range.lowerBound < $0.span.upperBound } ?? false + let kind: Kind? = if isRequested, state != .covered { .requested } + else if state == .hidden { .hidden } + else if state == .skipped { .skipped } + else { nil } + guard let kind else { close(); continue } + if var open = current, open.kind == kind { + open.run.edges.append(s1) + current = open + } else { + close() + current = (kind, Run(band: band, edges: [s0, s1])) + } + } + close() + } + return (hidden, skipped, requested) + } + + /// The outline of a run through every cell edge, so it follows the wall round a corner. Nil + /// when any point is behind the camera. + private func outline(_ band: CoverageBand, _ edges: [Float], _ geometry: WallProjection) -> Path? { + let corners: [SIMD3] = switch band { + case .wall: + edges.map { wall.world(s: $0, height: coverage.wallBandHeight) } + edges.reversed().map { wall.world(s: $0, height: 0) } + case .ground: + edges.map { wall.world(s: $0, height: 0, out: coverage.groundBandDepth) } + edges.reversed().map { wall.world(s: $0, height: 0) } + } + return geometry.polygon(corners) + } +} diff --git a/ios/HouseScan/UI/Camera/FogOverlay.swift b/ios/HouseScan/UI/Camera/FogOverlay.swift new file mode 100644 index 000000000..98db723e6 --- /dev/null +++ b/ios/HouseScan/UI/Camera/FogOverlay.swift @@ -0,0 +1,336 @@ +import SwiftUI +import simd + +/// The haze over the parts of the wall and ground the phone hasn't seen yet: the frosted strip +/// that `LiveFogView` replaced. It still draws while the live fog's shaders compile at launch, +/// and for good if Metal fails, so unseen wall never shows clear. +/// +/// Unseen cells carry a soft frosted haze, seen cells a thinner one, covered cells none. When a +/// cell's state changes (a new `coverage.revision`), its haze fades and drifts upward over +/// `Motion.fogLift`, like mist lifting: the signature moment that tells the homeowner "the phone +/// saw that" without a word. With Reduce Motion the haze only fades. The haze is guidance, never +/// proof that space is clear. +/// +/// A gap request replaces the fog on its cells with an amber highlight, so the camera itself +/// shows where to aim. +/// +/// On a phone with depth, a stretch the camera looked at through something nearer (a bush, a +/// bin) is hidden: no frost, since the phone did look, but a soft dark veil with a dashed violet +/// outline round the whole stretch. It fades in without rising: the rising drift belongs to +/// "the phone saw that", which a hidden stretch is not. +struct FogOverlay: View { + var coverage: CoverageStrip + var wall: WallGeometry + var projection: CameraProjection + var highlight: GapRequest? + + @State private var memory = FogMemory() + + @State private var liftDeadline = Date.distantPast + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + TimelineView(.animation(paused: liftDeadline < .now)) { timeline in + let frame = FogFrame( + coverage: coverage, wall: wall, projection: projection, highlight: highlight, + lifts: memory.lifts, now: timeline.date, drift: reduceMotion ? 0 : 10 + ) + ZStack { + // The frost itself: a material blurs whatever the camera shows, so the haze + // reads on a white wall in sun as well as on dark brick. The canvas only + // decides where, and how thick. + Rectangle() + .fill(.thinMaterial) + .environment(\.colorScheme, .light) + .mask { + Canvas(rendersAsynchronously: false) { context, size in + frame.drawHaze(in: &context, size: size, color: .black) + } + } + Canvas(rendersAsynchronously: false) { context, size in + frame.drawHaze(in: &context, size: size, color: Color(white: 0.98).opacity(0.35)) + frame.drawMarks(in: &context, size: size) + } + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + .onAppear { memory.seed(coverage) } + .onChange(of: coverage.revision) { _, _ in + if memory.update(to: coverage, at: .now, highlight: highlight) { + liftDeadline = Date.now.addingTimeInterval(Motion.fogLift) + } + } + .task(id: liftDeadline) { + // Ends the timeline once the last lift has finished, so a still replay frame + // stops redrawing. + let remaining = liftDeadline.timeIntervalSinceNow + guard remaining > 0 else { return } + // A newer revision replaces this task; it must not pause the timeline for the lifts + // that revision started, so cancellation ends it here. + do { try await Task.sleep(for: .seconds(remaining + 0.05)) } catch { return } + memory.prune(at: .now) + liftDeadline = .distantPast + } + } + + // MARK: Drawing + + /// Haze strength per state. Hypothesis, picked by eye on the demo wall and the synthetic + /// replay: unseen must read as "not done" over a bright wall; seen must be visibly lighter + /// than unseen but still clearly not clear. Full strength (with the regular material and a + /// 50% wash) whited out the wall the homeowner is walking toward, so unseen stays below it + /// and the wall's shapes show through. Tune on device in sun. + nonisolated static func haze(_ state: CellState) -> Double { + switch state { + case .unseen: 0.75 + case .seen: 0.35 + // Hidden is drawn by `veil`: frost would say "not looked at yet", and it was. + case .covered, .skipped, .hidden: 0 + } + } + + /// Strength of the dark veil and outline per state: only a hidden stretch has one. + nonisolated static func veil(_ state: CellState) -> Double { + state == .hidden ? 1 : 0 + } + +} + +/// One frame of fog: which cells are hazy, lifting, veiled, skipped or requested, at a moment in time. +private struct FogFrame { + var coverage: CoverageStrip + var wall: WallGeometry + var projection: CameraProjection + var highlight: GapRequest? + var lifts: [FogMemory.Key: FogMemory.Lift] + var now: Date + /// Points a lifting cell rises; zero with Reduce Motion, which keeps only the fade. + var drift: CGFloat + + private struct Cell { + var quad: Path + var level: Double + var lift: CGFloat + } + + private struct Layers { + var haze: [Cell] = [] + var skipped: [Path] = [] + var amber: [Path] = [] + /// Per-cell veil fills, blurred together so a stretch reads as one shadow. + var veil: [(quad: Path, level: Double)] = [] + /// One outline per run of hidden cells in a band, so the dash runs on unbroken. + var outlines: [(path: Path, level: Double)] = [] + } + + private func layers(size: CGSize) -> Layers { + let geometry = WallProjection(projection: projection, wall: wall, size: size) + let visible = coverage.visibleRange + var layers = Layers() + guard visible.upperBound > visible.lowerBound else { return layers } + for band in CoverageBand.allCases { + let states = coverage.cells(band) + // The hidden run being collected: its clipped s edges and its strongest veil. + var run: (edges: [Float], level: Double)? + func closeRun() { + if let edges = run?.edges, let level = run?.level, let outline = outline(band, edges, geometry) { + layers.outlines.append((outline, level)) + } + run = nil + } + for index in states.indices { + let range = coverage.cellRange(index) + guard FogMemory.interiorsOverlap(range, visible) else { closeRun(); continue } + let clipped = max(range.lowerBound, visible.lowerBound)...min(range.upperBound, visible.upperBound) + guard let quad = quad(band, clipped, geometry) else { closeRun(); continue } + let state = states[index] + if let gap = highlight, gap.band == band, FogMemory.interiorsOverlap(gap.span, range), state != .covered { + layers.amber.append(quad) + closeRun() + continue + } + if state == .skipped { layers.skipped.append(quad) } + let hazeTarget = FogOverlay.haze(state) + let veilTarget = FogOverlay.veil(state) + var haze = hazeTarget, veil = veilTarget, rise: CGFloat = 0 + if let lift = lifts[FogMemory.Key(band: band, index: index)], let progress = lift.progress(at: now), progress < 1 { + let eased = 1 - pow(1 - progress, 3) + haze = lift.from + (hazeTarget - lift.from) * eased + veil = lift.fromVeil + (veilTarget - lift.fromVeil) * eased + rise = lift.drifts ? CGFloat(eased) : 0 + } + if haze > 0 { layers.haze.append(Cell(quad: quad, level: haze, lift: rise)) } + if veil > 0 { layers.veil.append((quad, veil)) } + if state == .hidden { + var current = run ?? ([clipped.lowerBound], 0) + current.edges.append(clipped.upperBound) + current.level = max(current.level, veil) + run = current + } else { + closeRun() + } + } + closeRun() + } + return layers + } + + /// Haze cells in one blurred layer, so neighbours merge into one bank of fog; lifting cells + /// rise a few points as they thin out. + func drawHaze(in context: inout GraphicsContext, size: CGSize, color: Color) { + let haze = layers(size: size).haze + context.drawLayer { layer in + layer.addFilter(.blur(radius: 9)) + for cell in haze { + var shifted = layer + shifted.translateBy(x: 0, y: -drift * cell.lift) + shifted.fill(cell.quad, with: .color(color.opacity(cell.level))) + } + } + } + + /// Skipped cells get a slate hatch, so "can't get there" never reads as clear or as fog; + /// hidden stretches get a dark veil and a dashed violet outline; requested cells glow amber. + func drawMarks(in context: inout GraphicsContext, size: CGSize) { + let layers = layers(size: size) + if !layers.veil.isEmpty { + context.drawLayer { layer in + layer.addFilter(.blur(radius: 4)) + for cell in layers.veil { + layer.fill(cell.quad, with: .color(.black.opacity(0.42 * cell.level))) + } + } + } + // The map's dash, lengthened for the larger shapes on the camera. + let dash = StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round, dash: Palette.hiddenDash.map { $0 * 1.6 }) + for outline in layers.outlines { + // A dark halo under the dash keeps it legible on a sunlit wall. + context.stroke(outline.path, with: .color(.black.opacity(0.35 * outline.level)), style: StrokeStyle(lineWidth: 4, lineJoin: .round)) + context.stroke(outline.path, with: .color(Palette.hidden.opacity(outline.level)), style: dash) + } + for quad in layers.skipped { + context.fill(quad, with: .color(Palette.skipped.opacity(0.3))) + var hatched = context + hatched.clip(to: quad) + let bounds = quad.boundingRect + var lines = Path() + var x = bounds.minX - bounds.height + while x < bounds.maxX { + lines.move(to: CGPoint(x: x, y: bounds.maxY)) + lines.addLine(to: CGPoint(x: x + bounds.height, y: bounds.minY)) + x += 10 + } + hatched.stroke(lines, with: .color(.white.opacity(0.55)), lineWidth: 1.5) + } + for quad in layers.amber { + context.fill(quad, with: .color(Palette.caution.opacity(0.34))) + context.stroke(quad, with: .color(Palette.caution.opacity(0.9)), lineWidth: 1.5) + } + } + + /// The outline of a run of cells whose s edges are `edges`, through every cell edge so it + /// follows the wall round a corner. Nil when any point is behind the camera. + private func outline(_ band: CoverageBand, _ edges: [Float], _ geometry: WallProjection) -> Path? { + let corners: [SIMD3] = switch band { + case .wall: + edges.map { wall.world(s: $0, height: coverage.wallBandHeight) } + edges.reversed().map { wall.world(s: $0, height: 0) } + case .ground: + edges.map { wall.world(s: $0, height: 0, out: coverage.groundBandDepth) } + edges.reversed().map { wall.world(s: $0, height: 0) } + } + return geometry.polygon(corners) + } + + private func quad(_ band: CoverageBand, _ range: ClosedRange, _ geometry: WallProjection) -> Path? { + switch band { + case .wall: + geometry.wallQuad(s: range, height: 0...coverage.wallBandHeight) + case .ground: + geometry.groundQuad(s: range, out: 0...coverage.groundBandDepth) + } + } +} + +/// Remembers the last cell states so a new revision can be diffed into lifts. +@MainActor +final class FogMemory { + struct Key: Hashable { + var band: CoverageBand + var index: Int + } + + struct Lift { + /// Haze and veil strength when the change began. + var from: Double + var fromVeil: Double + var start: Date + /// Rises as it thins: only fog lifting off something the phone saw. + var drifts: Bool + + func progress(at now: Date) -> Double? { + let t = now.timeIntervalSince(start) / Motion.fogLift + return t < 0 ? 0 : t + } + } + + private var firstCellS: Float = 0 + private var states: [Key: CellState] = [:] + private(set) var lifts: [Key: Lift] = [:] + + func seed(_ coverage: CoverageStrip) { + firstCellS = coverage.firstCellS + states = Self.snapshot(coverage) + } + + /// Records a lift for every cell whose haze went down or whose veil changed. Returns true + /// when any lift started. + /// Cells inside `highlight` are drawn amber, not fogged, so they get no lift. + func update(to coverage: CoverageStrip, at now: Date, highlight: GapRequest?) -> Bool { + // The strip can grow to the left, which shifts every index; realign by s. + let shift = Int(((firstCellS - coverage.firstCellS) / coverage.cellWidth).rounded()) + let next = Self.snapshot(coverage) + if shift != 0 { + lifts = Dictionary(uniqueKeysWithValues: lifts.map { (Key(band: $0.key.band, index: $0.key.index + shift), $0.value) }) + } + var started = false + for (key, state) in next { + let oldKey = Key(band: key.band, index: key.index - shift) + let old = states[oldKey] ?? .unseen + if let highlight, highlight.band == key.band, + Self.interiorsOverlap(highlight.span, coverage.cellRange(key.index)) { + continue + } + let thinned = FogOverlay.haze(state) < FogOverlay.haze(old) + if thinned || FogOverlay.veil(state) != FogOverlay.veil(old) { + lifts[key] = Lift( + from: FogOverlay.haze(old), fromVeil: FogOverlay.veil(old), start: now, + drifts: thinned && state != .hidden + ) + started = true + } + } + states = next + firstCellS = coverage.firstCellS + return started + } + + /// True when the ranges share more than an endpoint. `ClosedRange.overlaps` is also true + /// for ranges that only touch, which would tint the cells either side of a gap. + nonisolated static func interiorsOverlap(_ a: ClosedRange, _ b: ClosedRange) -> Bool { + a.lowerBound < b.upperBound && b.lowerBound < a.upperBound + } + + func prune(at now: Date) { + lifts = lifts.filter { ($0.value.progress(at: now) ?? 1) < 1 } + } + + private static func snapshot(_ coverage: CoverageStrip) -> [Key: CellState] { + var result: [Key: CellState] = [:] + for band in CoverageBand.allCases { + for (index, state) in coverage.cells(band).enumerated() { + result[Key(band: band, index: index)] = state + } + } + return result + } +} diff --git a/ios/HouseScan/UI/Camera/LiveFog/LiveFogRenderer.swift b/ios/HouseScan/UI/Camera/LiveFog/LiveFogRenderer.swift new file mode 100644 index 000000000..c74e2ae87 --- /dev/null +++ b/ios/HouseScan/UI/Camera/LiveFog/LiveFogRenderer.swift @@ -0,0 +1,347 @@ +import Metal +import os +import QuartzCore +import simd + +/// The compiled shaders and pipelines, made once per launch off the main thread +/// (`LiveFogSupport.prepare`). Immutable after init; Metal's devices, libraries and pipeline +/// states are safe to use from any thread. +final class LiveFogGPU: @unchecked Sendable { + enum SetupError: Error, CustomStringConvertible { + case noDevice + case missing(String) + + var description: String { + switch self { + case .noDevice: "no Metal device" + case let .missing(what): "Metal would not make the \(what)" + } + } + } + + static let drawableFormat = MTLPixelFormat.bgra8Unorm + /// Half floats: the blur sums eleven taps, and the fog's slow lift needs finer steps than 8 bits. + static let maskFormat = MTLPixelFormat.rgba16Float + static let depthFormat = MTLPixelFormat.depth32Float + + let device: MTLDevice + let queue: MTLCommandQueue + let mask: MTLRenderPipelineState + let pool: MTLRenderPipelineState + let blur: MTLRenderPipelineState + let fog: MTLRenderPipelineState + let dots: MTLRenderPipelineState + let depthTest: MTLDepthStencilState + + init() throws { + guard let device = MTLCreateSystemDefaultDevice() else { throw SetupError.noDevice } + guard let queue = device.makeCommandQueue() else { throw SetupError.missing("command queue") } + self.device = device + self.queue = queue + let library = try device.makeLibrary(source: LiveFogShaders.source, options: nil) + + func pipeline(_ vertex: String, _ fragment: String, format: MTLPixelFormat, depth: Bool = false, + blend: ((MTLRenderPipelineColorAttachmentDescriptor) -> Void)? = nil) throws -> MTLRenderPipelineState { + let descriptor = MTLRenderPipelineDescriptor() + guard let v = library.makeFunction(name: vertex), let f = library.makeFunction(name: fragment) else { + throw SetupError.missing("\(vertex) / \(fragment) functions") + } + descriptor.vertexFunction = v + descriptor.fragmentFunction = f + descriptor.colorAttachments[0].pixelFormat = format + if depth { descriptor.depthAttachmentPixelFormat = Self.depthFormat } + if let blend { + descriptor.colorAttachments[0].isBlendingEnabled = true + blend(descriptor.colorAttachments[0]) + } + return try device.makeRenderPipelineState(descriptor: descriptor) + } + mask = try pipeline("maskVertex", "maskFragment", format: Self.maskFormat, depth: true) + pool = try pipeline("fullVertex", "poolFragment", format: Self.maskFormat) + blur = try pipeline("fullVertex", "blurFragment", format: Self.maskFormat) + // The fog is the first thing drawn on a cleared target: premultiplied, written as is. + fog = try pipeline("fullVertex", "fogFragment", format: Self.drawableFormat) + // Additive light: colour adds, alpha stays, so a dot brightens the camera under it. + dots = try pipeline("dotVertex", "dotFragment", format: Self.drawableFormat) { a in + a.sourceRGBBlendFactor = .one + a.destinationRGBBlendFactor = .one + a.sourceAlphaBlendFactor = .zero + a.destinationAlphaBlendFactor = .one + } + let depthDescriptor = MTLDepthStencilDescriptor() + depthDescriptor.depthCompareFunction = .less + depthDescriptor.isDepthWriteEnabled = true + guard let depthTest = device.makeDepthStencilState(descriptor: depthDescriptor) else { throw SetupError.missing("depth state") } + self.depthTest = depthTest + } +} + +/// What one frame of the fog draws from, all of it read from `ScanViewState`. +struct LiveFogInput { + var projection: CameraProjection + var wall: WallGeometry + var coverage: CoverageStrip + var highlight: GapRequest? + var dots: LiveDots + var reduceMotion: Bool + /// The gap request's amber, sRGB. + var requestedColor: SIMD3 +} + +/// Draws the fog and dots into a transparent Metal layer over the camera (`LiveFogMTKView`), +/// every display frame while the walk is on screen. +/// +/// Per frame: the mask pass draws the coverage cells (`FogMaskGeometry`) into a texture 192 +/// texels wide with a depth test, a max-pool takes it to 64, two passes blur it, then the +/// drawable gets the fog and the dots. +/// Bounds: three frames in flight; the mask vertex ring holds 16,384 vertices a frame (a 60 m +/// strip of unmerged cells, two bands in two parts, needs about 9,600); the dot buffers hold `DotTimeline.capacity` dots +/// and are rewritten only when the field changes or a fading dot ends, at most once a frame. +/// Nothing grows with time: about 4.5 MB of buffers and 1 MB of textures, allocated once. +@MainActor +final class LiveFogRenderer { + private let gpu: LiveFogGPU + /// Seconds on the clock the animations run on. Replaceable so an offscreen render can step + /// time; the view leaves it on the display's media clock. + var clock: () -> Double = { CACurrentMediaTime() } + private var epoch: Double? + private let inFlight = DispatchSemaphore(value: LiveFogRenderer.framesInFlight) + private static let framesInFlight = 3 + private static let maskWidth = 64 + private static let maskVertexCapacity = 16_384 + private static let spriteCapacity = DotTimeline.capacity * 2 + + var input: LiveFogInput? + var queue: MTLCommandQueue { gpu.queue } + + private let animator = FogCellAnimator() + private let timeline = DotTimeline() + private var maskBuffers: [MTLBuffer] = [] + private var spriteBuffers: [MTLBuffer] = [] + private var frame = 0 + private var spriteBuffer = 0 + private var spriteCount = 0 + private var maskTextures: MaskTextures? + private var anchor: (point: SIMD2, scale: Float)? + + /// Called once if the mask textures can't be allocated, so the view can hand back to the + /// frosted strip instead of showing a clear layer. + var onFailure: (() -> Void)? + + /// Nil when Metal won't allocate the buffers. + init?(gpu: LiveFogGPU) { + self.gpu = gpu + for _ in 0...stride, options: .storageModeShared), + let sprites = gpu.device.makeBuffer(length: Self.spriteCapacity * MemoryLayout.stride, options: .storageModeShared) + else { return nil } + maskBuffers.append(masks) + spriteBuffers.append(sprites) + } + } + + /// Encodes one frame into `pass`, whose target is `pixels` in size for a view of `size` + /// points. Returns false, encoding nothing, when all three frames are still in flight: the + /// caller skips the frame rather than block the main thread on the GPU. `LiveFogMTKView` + /// calls this each display frame. + func encode(into commandBuffer: MTLCommandBuffer, pass: MTLRenderPassDescriptor, size: CGSize, pixels: SIMD2, pixelsPerPoint scale: Float) -> Bool { + guard let input, size.width > 1, size.height > 1 else { return false } + guard inFlight.wait(timeout: .now()) == .success else { return false } + guard let textures = textures(for: size) else { + inFlight.signal() + onFailure?() + onFailure = nil + return false + } + let semaphore = inFlight + commandBuffer.addCompletedHandler { _ in semaphore.signal() } + + let start = epoch ?? clock() + epoch = start + let now = clock() - start + let time = Float(now) + let slot = frame % Self.framesInFlight + frame &+= 1 + + var clip = Self.clipMatrix(input.projection, size: size) + let maskBuffer = maskBuffers[slot] + let maskCount = FogMaskGeometry.build( + coverage: input.coverage, wall: input.wall, highlight: input.highlight, animator: animator, + now: now, reduceMotion: input.reduceMotion, + into: maskBuffer.contents().bindMemory(to: FogMaskGeometry.Vertex.self, capacity: Self.maskVertexCapacity), + capacity: Self.maskVertexCapacity) + + let changed = timeline.update(input.dots, now: time) + let expired = timeline.expire(now: time) + if changed || expired { + spriteBuffer = (spriteBuffer + 1) % Self.framesInFlight + let buffer = spriteBuffers[spriteBuffer] + spriteCount = timeline.write(into: buffer.contents().bindMemory(to: DotTimeline.Sprite.self, capacity: Self.spriteCapacity), capacity: Self.spriteCapacity) + } + + // Mask: full fog wherever no cell reaches. + let maskPass = MTLRenderPassDescriptor() + maskPass.colorAttachments[0].texture = textures.fine + maskPass.colorAttachments[0].loadAction = .clear + maskPass.colorAttachments[0].clearColor = MTLClearColor(red: 1, green: 0, blue: 0, alpha: 0) + maskPass.colorAttachments[0].storeAction = .store + maskPass.depthAttachment.texture = textures.depth + maskPass.depthAttachment.loadAction = .clear + maskPass.depthAttachment.clearDepth = 1 + maskPass.depthAttachment.storeAction = .dontCare + if let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: maskPass) { + if maskCount > 0 { + encoder.setRenderPipelineState(gpu.mask) + encoder.setDepthStencilState(gpu.depthTest) + encoder.setVertexBuffer(maskBuffer, offset: 0, index: 0) + encoder.setVertexBytes(&clip, length: MemoryLayout.stride, index: 1) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: maskCount) + } + encoder.endEncoding() + } + let poolPass = MTLRenderPassDescriptor() + poolPass.colorAttachments[0].texture = textures.pooled + poolPass.colorAttachments[0].loadAction = .dontCare + poolPass.colorAttachments[0].storeAction = .store + if let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: poolPass) { + encoder.setRenderPipelineState(gpu.pool) + encoder.setFragmentTexture(textures.fine, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) + encoder.endEncoding() + } + for (source, target, direction) in [(textures.pooled, textures.horizontal, SIMD2(1, 0)), (textures.horizontal, textures.final, SIMD2(0, 1))] { + let blurPass = MTLRenderPassDescriptor() + blurPass.colorAttachments[0].texture = target + blurPass.colorAttachments[0].loadAction = .dontCare + blurPass.colorAttachments[0].storeAction = .store + guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: blurPass) else { continue } + var direction = direction + encoder.setRenderPipelineState(gpu.blur) + encoder.setFragmentTexture(source, index: 0) + encoder.setFragmentBytes(&direction, length: MemoryLayout>.stride, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) + encoder.endEncoding() + } + + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) + if let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) { + let pin = anchor(input, size: size, pixelsPerPoint: scale) + var fog = FogUniforms( + viewSize: pixels, anchor: pin.point, anchorScale: pin.scale, time: time, + motion: input.reduceMotion ? 0 : 1, pad: 0, requested: SIMD4(input.requestedColor, 1)) + encoder.setRenderPipelineState(gpu.fog) + encoder.setFragmentBytes(&fog, length: MemoryLayout.stride, index: 0) + encoder.setFragmentTexture(textures.final, index: 0) + encoder.setFragmentTexture(textures.pooled, index: 1) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) + + if spriteCount > 0 { + var dots = DotUniforms( + clip: clip, cameraAndTime: SIMD4(input.projection.cameraPosition, time), + pointScale: scale, reduceMotion: input.reduceMotion ? 1 : 0, pad: .zero) + encoder.setRenderPipelineState(gpu.dots) + encoder.setVertexBuffer(spriteBuffers[spriteBuffer], offset: 0, index: 0) + encoder.setVertexBytes(&dots, length: MemoryLayout.stride, index: 1) + encoder.setVertexTexture(textures.final, index: 0) + encoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: spriteCount) + } + encoder.endEncoding() + } + return true + } + + /// The mask keeps the view's aspect at 64 texels across, about 6 points a texel on a phone; + /// the blur's 1.4-texel sigma, about 8 points, is the feather, and the noise-warped lookup + /// (`fogFragment`) rags the edges. + private struct MaskTextures { + /// Where the cells are drawn, three times the coarse size, with its depth buffer. + var fine: MTLTexture + var depth: MTLTexture + /// The fine mask max-pooled to the coarse size, then the two blur passes. + var pooled: MTLTexture + var horizontal: MTLTexture + var final: MTLTexture + } + + private func textures(for size: CGSize) -> MaskTextures? { + let width = Self.maskWidth + let height = max(1, Int((Double(width) * size.height / size.width).rounded())) + if let maskTextures, maskTextures.pooled.width == width, maskTextures.pooled.height == height { return maskTextures } + func texture(_ format: MTLPixelFormat, _ usage: MTLTextureUsage, scale: Int = 1) -> MTLTexture? { + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: format, width: width * scale, height: height * scale, mipmapped: false) + descriptor.usage = usage + descriptor.storageMode = .private + return gpu.device.makeTexture(descriptor: descriptor) + } + guard let fine = texture(LiveFogGPU.maskFormat, [.renderTarget, .shaderRead], scale: 3), + let depth = texture(LiveFogGPU.depthFormat, [.renderTarget], scale: 3), + let pooled = texture(LiveFogGPU.maskFormat, [.renderTarget, .shaderRead]), + let horizontal = texture(LiveFogGPU.maskFormat, [.renderTarget, .shaderRead]), + let final = texture(LiveFogGPU.maskFormat, [.renderTarget, .shaderRead]) else { + RuntimeLog.engine.error("live fog: Metal would not allocate the mask textures; drawing the frosted strip instead") + return nil + } + maskTextures = MaskTextures(fine: fine, depth: depth, pooled: pooled, horizontal: horizontal, final: final) + return maskTextures + } + + /// Where the fog's noise is pinned: the meter on screen, one noise unit about 1.2 m at the + /// meter's distance, so the texture moves and scales with the wall. Kept from the last frame + /// that saw the meter in front of the camera. + private func anchor(_ input: LiveFogInput, size: CGSize, pixelsPerPoint: Float) -> (point: SIMD2, scale: Float) { + let width = Float(size.width) * pixelsPerPoint + let local = input.projection.cameraSpace(input.wall.meter) + if local.z < -0.1, let point = input.projection.viewPoint(for: input.wall.meter, in: size) { + let pointsPerMeter = input.projection.intrinsics.x * Float(input.projection.scale(in: size)) / -local.z + let scale = min(max(pointsPerMeter * pixelsPerPoint * 1.2, 0.25 * width), 2 * width) + anchor = (SIMD2(Float(point.x), Float(point.y)) * pixelsPerPoint, scale) + } + return anchor ?? (.zero, 0.45 * width) + } + + /// World to Metal clip space for a portrait view of `size` showing the landscape sensor image + /// turned 90 degrees clockwise and scaled to fill, the mapping `CameraProjection.viewPoint` + /// uses. Depth runs from 0 at 5 cm to 1 at 100 m, so the GPU clips anything behind the camera + /// and the mask's depth test keeps the nearest surface. + static func clipMatrix(_ projection: CameraProjection, size: CGSize, near: Float = 0.05, far: Float = 100) -> simd_float4x4 { + let k = projection.intrinsics + let imageHeight = projection.imageSize.y + let scale = Float(projection.scale(in: size)) + let width = Float(size.width), height = Float(size.height) + let offset = SIMD2( + (width - projection.imageSize.y * scale) / 2, + (height - projection.imageSize.x * scale) / 2) + // Rows act on camera-space (x, y, z, 1); w is the depth along -z. + let w = SIMD4(0, 0, -1, 0) + let portraitX = SIMD4(0, k.y, k.w - imageHeight, 0) + let portraitY = SIMD4(k.x, 0, -k.z, 0) + let screenX = offset.x * w + scale * portraitX + let screenY = offset.y * w + scale * portraitY + let clipX = (2 / width) * screenX - w + let clipY = w - (2 / height) * screenY + let clipZ = SIMD4(0, 0, -far / (far - near), -far * near / (far - near)) + let cameraToClip = simd_float4x4(rows: [clipX, clipY, clipZ, w]) + return cameraToClip * projection.cameraToWorld.inverse + } +} + +/// Matches `FogUniforms` in the shader. +struct FogUniforms { + var viewSize: SIMD2 + var anchor: SIMD2 + var anchorScale: Float + var time: Float + var motion: Float + var pad: Float + var requested: SIMD4 +} + +/// Matches `DotUniforms` in the shader. +struct DotUniforms { + var clip: simd_float4x4 + var cameraAndTime: SIMD4 + var pointScale: Float + var reduceMotion: Float + var pad: SIMD2 +} diff --git a/ios/HouseScan/UI/Camera/LiveFog/LiveFogScene.swift b/ios/HouseScan/UI/Camera/LiveFog/LiveFogScene.swift new file mode 100644 index 000000000..b9c8e7bb5 --- /dev/null +++ b/ios/HouseScan/UI/Camera/LiveFog/LiveFogScene.swift @@ -0,0 +1,320 @@ +import Foundation +import simd + +/// What the fog mask holds at one place: how thick the fog is, how much of it is the violet of a +/// hidden stretch, and how much is a gap request's amber. Channels of the mask texture, in order. +struct FogValue: Equatable { + var fog: Float + var hidden: Float + var requested: Float + + static let clear = FogValue(fog: 0, hidden: 0, requested: 0) + + /// The rule the fog follows, from the coverage map's own cell states: the fog lifts only + /// where coverage counts the cell, never where depth merely reached. + /// - unseen: full fog. + /// - seen from one position: thinned, not lifted, since one view is not evidence. + /// - covered: lifted. + /// - hidden (the camera looked, depth found something nearer): still fog, since the wall + /// behind is unseen, but thinner and violet, so the thing in front shows through with its + /// violet dots. + /// - skipped ("I can't get there"): lifted here; `FogMarks` hatches it, so it never reads as + /// seen. + /// - requested by a gap and not yet covered: amber instead of fog, so the camera shows where + /// to aim. + static func target(_ state: CellState, requested: Bool) -> FogValue { + if requested, state != .covered { return FogValue(fog: 0, hidden: 0, requested: 1) } + return fog(state) + } + + /// Above the wall band and past the ground band, where coverage measures nothing: fog while + /// the column below is unseen or seen once, clear once it is covered. A hidden stretch and a + /// gap request are about the band itself, so their violet and amber stop at its edge, and + /// the wall above a bin, which the camera did see past it, is not fogged. + static func beyond(_ state: CellState) -> FogValue { + switch state { + case .unseen, .seen: fog(state) + case .covered, .skipped, .hidden: .clear + } + } + + private static func fog(_ state: CellState) -> FogValue { + return switch state { + case .unseen: FogValue(fog: 1, hidden: 0, requested: 0) + // 0.42 and 0.4 from the taste check against the prototype's videos: at 0.55, seen once + // and unseen blurred into one grey; at 0.75 the hidden mist greyed out the bin in front. + case .seen: FogValue(fog: 0.42, hidden: 0, requested: 0) + case .hidden: FogValue(fog: 0.4, hidden: 1, requested: 0) + case .covered, .skipped: .clear + } + } + + func mixed(to other: FogValue, _ t: Float) -> FogValue { + FogValue(fog: fog + (other.fog - fog) * t, hidden: hidden + (other.hidden - hidden) * t, requested: requested + (other.requested - requested) * t) + } + + var total: Float { fog + hidden + requested } +} + +/// Eases each cell from its old fog to its new one when coverage changes it. Lifting is the +/// signature moment ("the phone saw that") and takes `Motion.fogLift` on the strong ease-out; +/// fog coming back (a cell turning hidden, a request raised) is quick, 150 ms, because it is the +/// system answering, not a reveal. Under Reduce Motion both are linear fades, 400 and 120 ms, as +/// in the prototype. Keyed by the cell's s, not its index in the strip, which shifts when the +/// strip grows to the left. +@MainActor +final class FogCellAnimator { + struct Key: Hashable { + var band: CoverageBand + /// The cell's left edge in whole cell widths from s = 0. + var cell: Int + /// The part above the wall band or past the ground band. + var beyond: Bool + } + + private struct Track { + var from: FogValue + var to: FogValue + var start: Double + var duration: Double + var linear: Bool + + func value(at now: Double) -> FogValue { + guard duration > 0 else { return to } + let p = Float(min(max((now - start) / duration, 0), 1)) + return from.mixed(to: to, linear ? p : Easing.strongEaseOut(p)) + } + + func isDone(at now: Double) -> Bool { now - start >= duration } + } + + private var tracks: [Key: Track] = [:] + private var seen: Set = [] + + /// The cell's value now. A cell seen for the first time starts at its target. + func value(_ key: Key, target: FogValue, now: Double, reduceMotion: Bool) -> FogValue { + seen.insert(key) + guard var track = tracks[key] else { + tracks[key] = Track(from: target, to: target, start: now, duration: 0, linear: false) + return target + } + if track.to != target { + let current = track.value(at: now) + let lifting = target.total < current.total + let duration = reduceMotion ? (lifting ? 0.4 : 0.12) : (lifting ? Motion.fogLift : 0.15) + track = Track(from: current, to: target, start: now, duration: duration, linear: reduceMotion) + tracks[key] = track + } + return track.value(at: now) + } + + /// True while the cell's fog is still easing toward its target. Call after `value`. + func isMoving(_ key: Key, now: Double) -> Bool { + tracks[key].map { !$0.isDone(at: now) } ?? false + } + + /// Forgets cells the last frame did not draw, so the table never outgrows the strip. + func endFrame() { + if tracks.count > seen.count { tracks = tracks.filter { seen.contains($0.key) } } + seen.removeAll(keepingCapacity: true) + } +} + +/// Builds the fog mask's geometry: each coverage cell as a wall column from the ground up +/// `columnHeight` and a stretch of ground out `groundReach`, carrying its `FogValue`. The column +/// and the stretch reach past the bands coverage measures (4.5 ft of wall, 4 ft of ground) on +/// purpose: fog there could never lift, and fog that walking can't clear stops meaning +/// "not seen yet". The wall above a covered column and the lawn in front of it are nothing the +/// scan decides on; past the band, a cell's part follows `FogValue.beyond`. Past a marked end the wall is not part of the scan, and is clear. Anything +/// no quad reaches (the wall past the strip, where the walk has not been) keeps the mask's clear +/// value, full fog. +enum FogMaskGeometry { + struct Vertex { + var position: SIMD4 + var value: SIMD4 + } + + static let columnHeight: Float = 8 + static let groundReach: Float = 8 + /// How far past a marked end the clear stretch runs. + static let pastEnd: Float = 30 + + /// Writes triangles into `vertices` (at most `capacity`) and returns how many it wrote. + /// Neighbouring cells with the same value on the same piece of wall merge into one quad. + @MainActor + static func build( + coverage: CoverageStrip, wall: WallGeometry, highlight: GapRequest?, animator: FogCellAnimator, + now: Double, reduceMotion: Bool, into vertices: UnsafeMutablePointer, capacity: Int + ) -> Int { + var count = 0 + let wallTop = coverage.wallBandHeight, groundEdge = coverage.groundBandDepth + /// A quad on the wall from height `near` to `far`, or on the ground from `near` to `far` + /// out; `beyond` picks the part past the band. + func quad(_ band: CoverageBand, beyond: Bool, _ s0: Float, _ s1: Float, _ value: FogValue, moving: Bool = false) { + guard count + 6 <= capacity else { return } + let (near, far) = switch (band, beyond) { + case (.wall, false): (Float(0), wallTop) + case (.wall, true): (wallTop, columnHeight) + case (.ground, false): (Float(0), groundEdge) + case (.ground, true): (groundEdge, groundReach) + } + func point(_ s: Float, _ t: Float) -> SIMD4 { + SIMD4(band == .wall ? wall.world(s: s, height: t) : wall.world(s: s, height: 0, out: t), 1) + } + let a = point(s0, near), b = point(s1, near), c = point(s1, far), d = point(s0, far) + let v = SIMD4(value.fog, value.hidden, value.requested, moving ? 1 : 0) + vertices[count] = Vertex(position: a, value: v) + vertices[count + 1] = Vertex(position: b, value: v) + vertices[count + 2] = Vertex(position: c, value: v) + vertices[count + 3] = Vertex(position: a, value: v) + vertices[count + 4] = Vertex(position: c, value: v) + vertices[count + 5] = Vertex(position: d, value: v) + count += 6 + } + + let visible = coverage.visibleRange + if visible.upperBound > visible.lowerBound { + for band in CoverageBand.allCases { + let states = coverage.cells(band) + for beyond in [false, true] { + var run: (s0: Float, s1: Float, value: FogValue, moving: Bool, along: SIMD3)? + for index in states.indices { + let range = coverage.cellRange(index) + guard range.upperBound > visible.lowerBound, range.lowerBound < visible.upperBound else { continue } + let s0 = max(range.lowerBound, visible.lowerBound), s1 = min(range.upperBound, visible.upperBound) + let requested = highlight.map { $0.band == band && $0.span.lowerBound < range.upperBound && range.lowerBound < $0.span.upperBound } ?? false + let key = FogCellAnimator.Key(band: band, cell: Int((range.lowerBound / coverage.cellWidth).rounded()), beyond: beyond) + let target = beyond ? FogValue.beyond(states[index]) : FogValue.target(states[index], requested: requested) + let value = animator.value(key, target: target, now: now, reduceMotion: reduceMotion) + let moving = animator.isMoving(key, now: now) + let along = wall.along(atS: (s0 + s1) / 2) + if var current = run, current.value == value, current.moving == moving, current.along == along, abs(current.s1 - s0) < 1e-4 { + current.s1 = s1 + run = current + } else { + if let current = run { quad(band, beyond: beyond, current.s0, current.s1, current.value, moving: current.moving) } + run = (s0, s1, value, moving, along) + } + } + if let current = run { quad(band, beyond: beyond, current.s0, current.s1, current.value, moving: current.moving) } + } + } + } + for band in CoverageBand.allCases { + for beyond in [false, true] { + if let left = wall.leftEnd { quad(band, beyond: beyond, left - pastEnd, left, .clear) } + if let right = wall.rightEnd { quad(band, beyond: beyond, right, right + pastEnd, .clear) } + } + } + animator.endFrame() + return count + } +} + +/// The dots' timing on the renderer's clock: when each was born, when its opacity last changed +/// and from what, when it turned edge, and, for a dot that left the field, when it died. The +/// shader evaluates the curves, so the buffer changes only when the field does or a death ends. +@MainActor +final class DotTimeline { + struct Sprite { + var a: SIMD4 + var b: SIMD4 + var c: SIMD4 + } + + private struct Track { + var birth: Float + var from: Float + var to: Float + var opacityTime: Float + var edgeSince: Float + var dot: LiveDots.Dot + + func opacity(at t: Float) -> Float { + from + (to - from) * Easing.strongEaseOut((t - opacityTime) / 0.25) + } + } + + /// Metal compiles with fast math, which assumes no infinities. + private static let never: Float = 1e6 + private static let deathFade: Float = 0.3 + + private var tracks: [UInt64: Track] = [:] + private var dying: [(track: Track, death: Float)] = [] + private var revision = -1 + /// Dots the renderer draws at once, living and dying: the field's own cap + /// (`SurfaceDotConfig.maxDots`, 5,000) and as many again fading out. + static let capacity = 10_000 + + /// Takes a new field. Returns true when the sprites changed. + func update(_ field: LiveDots, now: Float) -> Bool { + guard field.revision != revision else { return false } + revision = field.revision + var next: [UInt64: Track] = [:] + next.reserveCapacity(field.dots.count) + for dot in field.dots { + if var track = tracks[dot.id] { + if dot.opacity != track.to { + track.from = track.opacity(at: now) + track.to = dot.opacity + track.opacityTime = now + } + if dot.isEdge, track.edgeSince >= Self.never { track.edgeSince = now } + track.dot = dot + next[dot.id] = track + } else { + next[dot.id] = Track( + birth: now, from: dot.opacity, to: dot.opacity, opacityTime: -Self.never, + edgeSince: dot.isEdge ? -Self.never : Self.never, dot: dot) + } + } + for (id, track) in tracks where next[id] == nil { dying.append((track, now)) } + if dying.count > Self.capacity / 2 { dying.removeFirst(dying.count - Self.capacity / 2) } + tracks = next + return true + } + + /// Drops dots whose fade has finished. Returns true when any went. + func expire(now: Float) -> Bool { + let before = dying.count + dying.removeAll { now - $0.death > Self.deathFade } + return dying.count != before + } + + /// Two sprites per dot, its halo then its core, so the sharp cores sit on the glow. Returns + /// how many it wrote, at most `capacity`. + func write(into sprites: UnsafeMutablePointer, capacity: Int) -> Int { + var count = 0 + func add(_ track: Track, death: Float) { + guard count + 2 <= capacity else { return } + let d = track.dot + let b = SIMD4(track.birth, track.from, track.to, track.opacityTime) + let c = SIMD4(track.edgeSince, death, d.onOccluder ? 1 : 0, 0) + sprites[count] = Sprite(a: SIMD4(d.position, 1), b: b, c: c) + sprites[count + 1] = Sprite(a: SIMD4(d.position, 0), b: b, c: c) + count += 2 + } + for track in tracks.values { add(track, death: Self.never) } + for entry in dying { add(entry.track, death: entry.death) } + return count + } +} + +/// cubic-bezier(0.23, 1, 0.32, 1), the same curve the shader solves. +enum Easing { + static func strongEaseOut(_ progress: Float) -> Float { + if progress <= 0 { return 0 } + if progress >= 1 { return 1 } + let x1: Float = 0.23, x2: Float = 0.32 + var t = progress + for _ in 0..<8 { + let u = 1 - t + let x = 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t - progress + let dx = 3 * u * u * x1 + 6 * u * t * (x2 - x1) + 3 * t * t * (1 - x2) + if abs(dx) < 1e-6 { break } + t = min(max(t - x / dx, 0), 1) + } + let u = 1 - t + return 3 * u * u * t + 3 * u * t * t + t * t * t + } +} diff --git a/ios/HouseScan/UI/Camera/LiveFog/LiveFogShaders.swift b/ios/HouseScan/UI/Camera/LiveFog/LiveFogShaders.swift new file mode 100644 index 000000000..3672486a4 --- /dev/null +++ b/ios/HouseScan/UI/Camera/LiveFog/LiveFogShaders.swift @@ -0,0 +1,277 @@ +/// Metal source for the live fog and dots, compiled at runtime (`makeLibrary(source:)`) so the +/// build needs no Metal toolchain, as in the prototype (experiments/live-dots on t3/experience, +/// `Shaders.swift`). Struct layouts match the Swift structs in `LiveFogRenderer.swift`. +/// +/// Fog. Coverage decides where it lifts: `LiveFogScene` draws every wall column and ground +/// stretch of the coverage strip into a mask three times 64 texels wide (red: fog, green: hidden +/// behind something, blue: requested by a gap, alpha: still changing), clearing to full fog; a +/// max-pool takes it to 64 texels and a radius-3 Gaussian blur feathers it. The composite is the +/// prototype's: Chalk mixed toward a cool grey, textured by slow domain-warped fbm and breathing +/// slightly. Two changes: the noise is pinned to the meter +/// on screen and scaled by its distance, so the texture sits on the wall instead of on the lens +/// while the phone moves (the prototype cut between keyframes, where that never showed); and +/// part-lifted fog breaks up along the same noise instead of thinning evenly, so a lift reads as +/// mist burning off. +/// +/// Dots. The prototype's hologram scheme: additive point sprites, a halo under each core, births +/// that ease in over 350 ms from 60% size, opacity changes over 250 ms, deaths over 250 ms. A dot +/// under fog draws at 20%, so dots arrive where coverage lifts the fog; dots on an occluder +/// (violet) draw fully, since they are what the hidden state is about. +enum LiveFogShaders { + static let source = """ + #include + using namespace metal; + + // cubic-bezier(0.23, 1, 0.32, 1), solved by Newton. + static float strongEaseOut(float p) { + p = clamp(p, 0.0f, 1.0f); + if (p <= 0.0f || p >= 1.0f) { return p; } + const float x1 = 0.23f, x2 = 0.32f, y1 = 1.0f, y2 = 1.0f; + float t = p; + for (int i = 0; i < 8; i++) { + float u = 1.0f - t; + float x = 3.0f * u * u * t * x1 + 3.0f * u * t * t * x2 + t * t * t - p; + float dx = 3.0f * u * u * x1 + 6.0f * u * t * (x2 - x1) + 3.0f * t * t * (1.0f - x2); + if (fabs(dx) < 1e-6f) { break; } + t = clamp(t - x / dx, 0.0f, 1.0f); + } + float u = 1.0f - t; + return 3.0f * u * u * t * y1 + 3.0f * u * t * t * y2 + t * t * t; + } + + struct FullOut { float4 position [[position]]; }; + + vertex FullOut fullVertex(uint vid [[vertex_id]]) { + float2 p = float2((vid << 1) & 2, vid & 2); + FullOut out; + out.position = float4(p * 2.0f - 1.0f, 0.0f, 1.0f); + return out; + } + + // MARK: Mask + + struct MaskVertex { + float4 position; // world xyz + float4 value; // fog, hidden, requested, 1 while the cell's fog is changing + }; + + struct MaskOut { + float4 position [[position]]; + float4 value [[flat]]; + }; + + vertex MaskOut maskVertex(uint vid [[vertex_id]], + const device MaskVertex* vertices [[buffer(0)]], + constant float4x4& clip [[buffer(1)]]) { + MaskOut out; + out.position = clip * float4(vertices[vid].position.xyz, 1.0f); + out.value = vertices[vid].value; + return out; + } + + fragment float4 maskFragment(MaskOut in [[stage_in]]) { + return in.value; + } + + // Each texel takes the largest value of the 3 x 3 block under it in the mask drawn at three + // times the size, per channel: a cell as narrow as one fine texel (about 2 points) still + // reaches the coarse mask, where sampling alone could step over it. + fragment float4 poolFragment(FullOut in [[stage_in]], + texture2d fine [[texture(0)]]) { + uint2 base = uint2(in.position.xy) * 3u; + uint2 limit = uint2(fine.get_width() - 1, fine.get_height() - 1); + float4 m = float4(0.0f); + for (uint y = 0; y < 3; y++) { + for (uint x = 0; x < 3; x++) { + m = max(m, fine.read(min(base + uint2(x, y), limit))); + } + } + return m; + } + + // Separable Gaussian, radius 3 texels (sigma 1.4, about 8 points), all four channels. + fragment float4 blurFragment(FullOut in [[stage_in]], + texture2d source [[texture(0)]], + constant int2& direction [[buffer(0)]]) { + int2 p = int2(in.position.xy); + int2 limit = int2(source.get_width() - 1, source.get_height() - 1); + float4 sum = float4(0.0f); + float total = 0.0f; + for (int k = -3; k <= 3; k++) { + float w = exp(-float(k * k) / (2.0f * 1.4f * 1.4f)); + sum += w * source.read(uint2(clamp(p + k * direction, int2(0), limit))); + total += w; + } + return sum / total; + } + + // MARK: Fog + + struct FogUniforms { + float2 viewSize; // drawable pixels + float2 anchor; // pixels: the meter on screen, where the noise is pinned + float anchorScale; // pixels per noise unit (about 1.2 m at the meter) + float time; // seconds + float motion; // 0 under Reduce Motion: no drift, no breathing, no pulse + float pad; + float4 requested; // the gap request's amber, rgb + }; + + static float hash21(float2 p) { + p = fract(p * float2(123.34f, 456.21f)); + p += dot(p, p + 45.32f); + return fract(p.x * p.y); + } + + static float valueNoise(float2 p) { + float2 i = floor(p), f = fract(p); + float2 w = f * f * (3.0f - 2.0f * f); + float a = hash21(i), b = hash21(i + float2(1, 0)), c = hash21(i + float2(0, 1)), d = hash21(i + float2(1, 1)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); + } + + static float fbm(float2 p) { + float sum = 0.0f, amplitude = 0.5f; + for (int i = 0; i < 3; i++) { + sum += amplitude * valueNoise(p); + p = p * 2.03f + float2(17.1f, 9.2f); + amplitude *= 0.5f; + } + return sum / 0.875f; + } + + fragment float4 fogFragment(FullOut in [[stage_in]], + constant FogUniforms& u [[buffer(0)]], + texture2d mask [[texture(0)]], + texture2d pooled [[texture(1)]]) { + constexpr sampler linear(filter::linear, address::clamp_to_edge); + float2 screen = in.position.xy; + // Wrapped so the offsets stay small where Float precision is fine. + float2 uv = fmod((screen - u.anchor) / u.anchorScale, float2(256.0f)); + float2 q = uv + u.motion * u.time * 0.02f * float2(0.94f, 0.34f); + + // The mask is read through a fine noise offset, about 27 cm features at the meter and at + // most 2.5% of the screen's width either way, so the straight edges of cells come out + // ragged, as a fog bank's do (the noise-edged fog of war the prototype borrowed from + // Civilization VI). A coarser, larger offset (70 cm, 7%) smeared the edges into what + // read as a smudge on the lens in the first hosted screenshots. + float2 edgeNoise = float2(valueNoise(q * 4.5f + float2(7.3f, 2.1f)), valueNoise(q * 4.5f + float2(-4.1f, 8.6f))); + float2 look = screen + (edgeNoise - 0.5f) * 0.05f * u.viewSize.x; + // The blur and the noise shape the fog; neither may erase it. The unblurred pooled mask, + // read through a smaller offset, sets a floor: a cell coverage has not counted keeps 30% + // of its fog within a few points of where it is, however narrow. Read unwarped, the floor + // drew a straight step along every wide edge. + float2 near = screen + (edgeNoise - 0.5f) * 0.015f * u.viewSize.x; + float4 m = max(mask.sample(linear, look / u.viewSize), 0.3f * pooled.sample(linear, near / u.viewSize)); + float fog = saturate(m.r), hidden = saturate(m.g), requested = saturate(m.b); + if (fog < 0.002f && requested < 0.002f) { return float4(0.0f); } + + float2 warp = float2(fbm(q + float2(3.1f, 1.7f)), fbm(q + float2(-2.3f, 5.2f))); + float n = fbm(q + 0.8f * warp); + // The prototype's texture is barely there; deeper troughs read as dirt on the lens. + float noiseTerm = 0.88f + 0.12f * n; + float breathing = 1.0f + u.motion * 0.04f * sin(2.0f * M_PI_F * 0.08f * u.time); + + // Lifting fog breaks up along the noise, as mist burning off; fog that holds still (a cell + // seen once) stays an even, thinner veil, since held blotches read as a smudge. + float erosion = mix(0.4f, 1.6f, saturate(m.a)); + float shaped = saturate(fog + (n - 0.5f) * erosion * fog * (1.0f - fog)); + float a = saturate(0.66f * noiseTerm * shaped * breathing * mix(1.0f, 0.72f, hidden)); + + // Hidden is a tint of real violet, the tape map's colour, not a grey veil over the thing + // in front: half-mixed at 0.75 fog it read as a grey ghost over the bin. + float3 chalk = float3(0xF7, 0xF5, 0xEF) / 255.0f; + float3 cool = float3(0xD9, 0xE2, 0xEC) / 255.0f; + float3 violet = float3(0xA4, 0x8A, 0xFA) / 255.0f; + float3 color = mix(mix(chalk, cool, 0.2f), violet, 0.9f * hidden); + + float pulse = 1.0f - u.motion * 0.14f * (0.5f + 0.5f * sin(2.0f * M_PI_F * u.time / 1.8f)); + float r = saturate(0.28f * requested * noiseTerm * pulse); + + float3 rgb = color * a; + rgb = u.requested.rgb * r + rgb * (1.0f - r); + return float4(rgb, r + a * (1.0f - r)); + } + + // MARK: Dots + + struct Sprite { + float4 a; // world xyz, 1 for the halo sprite + float4 b; // birth time, from opacity, to opacity, opacity change time + float4 c; // edge since, death time, violet (0 or 1), unused + }; + + struct DotUniforms { + float4x4 clip; + float4 cameraAndTime; // camera xyz, seconds + float pointScale; // pixels per point + float reduceMotion; // 1 drops the birth scale + float2 pad; + }; + + struct DotOut { + float4 position [[position]]; + float size [[point_size]]; + float4 color; + float inner; // radius fraction where the soft edge starts + }; + + constant float3 hologram = float3(0xE6, 0xEC, 0xF4) / 255.0f; + constant float3 glow = float3(0x9C, 0xC8, 0xFF) / 255.0f; + constant float3 violetDot = float3(0xB4, 0x9C, 0xFF) / 255.0f; + + vertex DotOut dotVertex(uint vid [[vertex_id]], + const device Sprite* sprites [[buffer(0)]], + constant DotUniforms& u [[buffer(1)]], + texture2d mask [[texture(0)]]) { + constexpr sampler linear(filter::linear, address::clamp_to_edge); + Sprite s = sprites[vid]; + float t = u.cameraAndTime.w; + float birth = strongEaseOut((t - s.b.x) / 0.35f); + float evidence = mix(s.b.y, s.b.z, strongEaseOut((t - s.b.w) / 0.25f)); + float fade = 1.0f - strongEaseOut((t - s.c.y) / 0.25f); + float edge = strongEaseOut((t - s.c.x) / 0.25f); + bool halo = s.a.w > 0.5f; + bool violetOne = s.c.z > 0.5f; + + DotOut out; + out.position = u.clip * float4(s.a.xyz, 1.0f); + float fog = 1.0f; + if (out.position.w > 0.05f) { + float2 ndc = out.position.xy / out.position.w; + fog = mask.sample(linear, float2(0.5f + 0.5f * ndc.x, 0.5f - 0.5f * ndc.y), level(0)).r; + } + float veil = violetOne ? 1.0f : mix(1.0f, 0.2f, saturate(fog)); + float alpha = evidence * birth * fade * veil; + + // Flat 3.5 pt and edge 5 pt from 4 m out, growing to 5 and 7.5 pt at 1 m. The prototype's + // 2.5 to 4 pt over 1 to 2.5 m left dots at walking distance (1.5 to 3 m) as pinpricks. + float far = clamp((length(s.a.xyz - u.cameraAndTime.xyz) - 1.0f) / 3.0f, 0.0f, 1.0f); + float size = mix(mix(5.0f, 3.5f, far), mix(7.5f, 5.0f, far), edge); + float inner = 0.7f; + float3 color = hologram; + if (halo) { + // Edge 4x at 32%, flat 2.6x at 16%; a flat dot turning edge grows into it. The glow + // is what makes the dots read as light; the prototype's 8% flat halo didn't show. + size *= mix(2.6f, 4.0f, edge); + alpha *= mix(0.16f, 0.32f, edge); + inner = 0.0f; + color = glow; + } + if (violetOne) { color = violetDot; } + float scale = u.reduceMotion > 0.5f ? 1.0f : mix(0.6f, 1.0f, birth); + + if (alpha < 1.0f / 512.0f) { out.position = float4(0.0f, 0.0f, -1.0f, 1.0f); } + out.size = size * scale * u.pointScale; + // Alpha 0 with colour: added to what is under it, as light, when the layer composites. + out.color = float4(color * alpha, 0.0f); + out.inner = inner; + return out; + } + + fragment float4 dotFragment(DotOut in [[stage_in]], float2 pc [[point_coord]]) { + float r = length(pc * 2.0f - 1.0f); + return in.color * (1.0f - smoothstep(in.inner, 1.0f, r)); + } + """ +} diff --git a/ios/HouseScan/UI/Camera/LiveFog/LiveFogView.swift b/ios/HouseScan/UI/Camera/LiveFog/LiveFogView.swift new file mode 100644 index 000000000..28b75abab --- /dev/null +++ b/ios/HouseScan/UI/Camera/LiveFog/LiveFogView.swift @@ -0,0 +1,102 @@ +import MetalKit +import Observation +import SwiftUI + +/// Whether the live fog can draw. The shaders compile once per launch, off the main thread, as +/// soon as the app starts (`HouseScanApp`); until then, and if Metal fails, `CameraOverlays` +/// draws the frosted `FogOverlay` instead, so the camera never shows unseen wall as clear. +@MainActor +@Observable +final class LiveFogSupport { + enum Status { + case preparing + case ready(LiveFogGPU) + case failed + } + + static let shared = LiveFogSupport() + + private(set) var status: Status = .preparing + @ObservationIgnored private var started = false + + /// The renderer could not get its buffers or textures: back to the frosted strip for the + /// rest of the launch. + func fail(_ reason: String) { + RuntimeLog.engine.error("live fog unavailable, drawing the frosted strip instead: \(reason, privacy: .public)") + status = .failed + } + + func prepare() { + guard !started else { return } + started = true + Task.detached(priority: .userInitiated) { + let result: Result + do { result = .success(try LiveFogGPU()) } catch { result = .failure(error) } + await MainActor.run { + switch result { + case let .success(gpu): + RuntimeLog.engine.info("live fog ready") + LiveFogSupport.shared.status = .ready(gpu) + case let .failure(error): + // Logged as an error, not trapped: a trap in a Debug build would stop every UI + // test, while the frosted strip in their screenshots already shows the failure. + LiveFogSupport.shared.fail(String(describing: error)) + } + } + } + } +} + +/// The fog over what coverage has not counted and, on a LiDAR phone, the dots on what depth has +/// measured: a transparent Metal layer over the camera. See `LiveFogShaders` for the look and +/// `FogValue.target` for the rule. +struct LiveFogView: UIViewRepresentable { + let gpu: LiveFogGPU + let input: LiveFogInput + + func makeUIView(context: Context) -> LiveFogMTKView { + let view = LiveFogMTKView(frame: .zero, device: gpu.device) + view.colorPixelFormat = LiveFogGPU.drawableFormat + view.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) + view.isOpaque = false + view.backgroundColor = .clear + view.layer.isOpaque = false + view.framebufferOnly = true + view.autoResizeDrawable = true + // 60, not ProMotion's 120: the pose it draws from arrives at 30 Hz, and 60 keeps each new + // pose within one refresh of arriving. + view.preferredFramesPerSecond = 60 + view.isUserInteractionEnabled = false + view.accessibilityElementsHidden = true + guard let renderer = LiveFogRenderer(gpu: gpu) else { + // Not during this view update: the status change swaps this view out. + DispatchQueue.main.async { LiveFogSupport.shared.fail("Metal would not allocate the fog's buffers") } + return view + } + renderer.input = input + renderer.onFailure = { + DispatchQueue.main.async { LiveFogSupport.shared.fail("Metal would not allocate the fog's mask textures") } + } + view.renderer = renderer + return view + } + + func updateUIView(_ view: LiveFogMTKView, context: Context) { + view.renderer?.input = input + } +} + +/// An MTKView that hands each frame to its renderer. Subclassing keeps drawing on the main actor +/// without a delegate conformance. +final class LiveFogMTKView: MTKView { + var renderer: LiveFogRenderer? + + override func draw(_ rect: CGRect) { + guard let renderer, let pass = currentRenderPassDescriptor, let drawable = currentDrawable, + let commandBuffer = renderer.queue.makeCommandBuffer() else { return } + let pixels = SIMD2(Float(drawableSize.width), Float(drawableSize.height)) + guard renderer.encode(into: commandBuffer, pass: pass, size: bounds.size, pixels: pixels, pixelsPerPoint: Float(contentScaleFactor)) else { return } + commandBuffer.present(drawable) + commandBuffer.commit() + } +} diff --git a/ios/HouseScan/UI/Camera/WallMarksOverlay.swift b/ios/HouseScan/UI/Camera/WallMarksOverlay.swift new file mode 100644 index 000000000..775680e3b --- /dev/null +++ b/ios/HouseScan/UI/Camera/WallMarksOverlay.swift @@ -0,0 +1,138 @@ +import SwiftUI +import simd + +/// What the homeowner has marked, pinned onto the camera image: the meter, the wall ends and +/// the marked features. +/// +/// Things that stand on the wall get the prototype's corner brackets (after RoomPlan's +/// outlines) with a short caption: the meter in Signal blue, everything else in the dots' +/// Hologram white. A marked thing is settled, so its bracket is solid; the prototype's dashed, +/// unsettled bracket has no counterpart here. Driveways and fences stay dashed lines on the +/// ground, and the wall ends white posts. +struct WallMarksOverlay: View { + var projection: CameraProjection + var wall: WallGeometry + var features: [MarkedFeature] + var wallBandHeight: Float + + /// The dots' Hologram, #E6ECF4. + static let hologram = Color(red: 0xE6 / 255, green: 0xEC / 255, blue: 0xF4 / 255) + + /// Display sizes (width, height) for things marked with one tap, in meters: a bracket needs + /// a box, and one tap gives only the middle. Typical sizes, not measurements; the export uses + /// its own nominal width (`ScanEngine.project`). + static let meterBox = SIMD2(0.3, 0.4) + static let gasMeterBox = SIMD2(0.3, 0.4) + static let acUnitBox = SIMD2(0.6, 0.6) + + var body: some View { + Canvas { context, size in + let geometry = WallProjection(projection: projection, wall: wall, size: size) + drawEnds(in: &context, geometry) + for feature in features { + drawFeature(feature, in: &context, geometry) + } + let up = SIMD3(0, 1, 0) + let along = wall.along(atS: 0) + if let corners = box(center: wall.meter, along: along, up: up, size: Self.meterBox, geometry) { + drawBracket(corners, color: Palette.signal, caption: "Electric meter", in: &context) + } + } + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private func drawEnds(in context: inout GraphicsContext, _ geometry: WallProjection) { + for end in [wall.leftEnd, wall.rightEnd].compactMap(\.self) { + guard let bottom = geometry.point(s: end, height: 0), let top = geometry.point(s: end, height: wallBandHeight) else { continue } + var line = Path() + line.move(to: bottom) + line.addLine(to: top) + context.stroke(line, with: .color(.black.opacity(0.35)), style: StrokeStyle(lineWidth: 7, lineCap: .round)) + context.stroke(line, with: .color(.white), style: StrokeStyle(lineWidth: 4, lineCap: .round)) + let cap = CGRect(x: bottom.x - 7, y: bottom.y - 7, width: 14, height: 14) + context.fill(Path(ellipseIn: cap), with: .color(.white)) + } + } + + private func drawFeature(_ feature: MarkedFeature, in context: inout GraphicsContext, _ geometry: WallProjection) { + let caption = ScanCopy.name(feature.kind) + let up = SIMD3(0, 1, 0) + switch feature.kind { + case .door, .window: + guard let bottom = feature.bottom, let top = feature.top else { return } + let corners = [ + wall.world(s: feature.span.lowerBound, height: top, out: 0.01), + wall.world(s: feature.span.upperBound, height: top, out: 0.01), + wall.world(s: feature.span.upperBound, height: bottom, out: 0.01), + wall.world(s: feature.span.lowerBound, height: bottom, out: 0.01), + ].compactMap(geometry.point) + if corners.count == 4 { drawBracket(corners, color: Self.hologram, caption: caption, in: &context) } + case .gasMeter, .acUnit: + guard let point = feature.points.first else { return } + let s = (feature.span.lowerBound + feature.span.upperBound) / 2 + let size = feature.kind == .gasMeter ? Self.gasMeterBox : Self.acUnitBox + // An AC unit stands on the ground, and most taps on one land there (#163): its bracket + // stands on the ground under the tap rather than centred on it, half under the floor. + let center = feature.kind == .acUnit ? SIMD3(point.x, wall.groundY + size.y / 2, point.z) : point + if let corners = box(center: center, along: wall.along(atS: s), up: up, size: size, geometry) { + drawBracket(corners, color: Self.hologram, caption: caption, in: &context) + } + case .driveway, .fence: + let points = feature.points.compactMap(geometry.point) + guard points.count >= 2 else { return } + var line = Path() + line.addLines(points) + context.stroke(line, with: .color(.black.opacity(0.3)), style: StrokeStyle(lineWidth: 8, lineCap: .round)) + context.stroke(line, with: .color(.white), style: StrokeStyle(lineWidth: 4, lineCap: .round, dash: [10, 7])) + for point in points { + let dot = CGRect(x: point.x - 6, y: point.y - 6, width: 12, height: 12) + context.fill(Path(ellipseIn: dot.insetBy(dx: -2, dy: -2)), with: .color(.black.opacity(0.25))) + context.fill(Path(ellipseIn: dot), with: .color(.white)) + } + } + } + + /// Four corners on screen, clockwise from top-left, of a box facing out of the wall around + /// `center`; nil when any corner is behind the camera. + private func box(center: SIMD3, along: SIMD3, up: SIMD3, size: SIMD2, _ geometry: WallProjection) -> [CGPoint]? { + let w = along * (size.x / 2), h = up * (size.y / 2) + let corners = [center - w + h, center + w + h, center + w - h, center - w - h].compactMap(geometry.point) + return corners.count == 4 ? corners : nil + } + + /// The prototype's bracket: four L-shaped corners 6 pt outside the box, each arm 18 pt or 40% + /// of its edge, with a 4 pt rounded bend, over a soft dark halo so it holds on a bright wall; + /// the caption sits 13 pt above the top edge. + private func drawBracket(_ box: [CGPoint], color: Color, caption: String, in context: inout GraphicsContext) { + let cx = box.map(\.x).reduce(0, +) / 4, cy = box.map(\.y).reduce(0, +) / 4 + let corners = box.map { p -> CGPoint in + let dx = p.x - cx, dy = p.y - cy + let length = max((dx * dx + dy * dy).squareRoot(), 0.001) + return CGPoint(x: p.x + dx / length * 6, y: p.y + dy / length * 6) + } + var path = Path() + for i in corners.indices { + let corner = corners[i], previous = corners[(i + 3) % 4], next = corners[(i + 1) % 4] + func toward(_ other: CGPoint) -> CGPoint { + let dx = other.x - corner.x, dy = other.y - corner.y + let length = max((dx * dx + dy * dy).squareRoot(), 0.001) + let arm = min(18, 0.4 * length) + return CGPoint(x: corner.x + dx / length * arm, y: corner.y + dy / length * arm) + } + path.move(to: toward(previous)) + path.addArc(tangent1End: corner, tangent2End: toward(next), radius: 4) + path.addLine(to: toward(next)) + } + context.stroke(path, with: .color(.black.opacity(0.28)), style: StrokeStyle(lineWidth: 4.5, lineCap: .round, lineJoin: .round)) + context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + + let top = CGPoint(x: (corners[0].x + corners[1].x) / 2, y: min(corners[0].y, corners[1].y) - 13) + var layer = context + layer.addFilter(.shadow(color: .black.opacity(0.45), radius: 6, y: 1)) + layer.draw( + Text(caption).font(.system(size: 13, weight: .semibold, design: .rounded)).foregroundStyle(.white), + at: top, anchor: .center) + } +} diff --git a/ios/HouseScan/UI/Camera/WallProjection.swift b/ios/HouseScan/UI/Camera/WallProjection.swift new file mode 100644 index 000000000..9799f23fc --- /dev/null +++ b/ios/HouseScan/UI/Camera/WallProjection.swift @@ -0,0 +1,88 @@ +import SwiftUI +import simd + +/// Projects wall-frame shapes (s, height, out) onto the camera view. +/// +/// Every overlay is drawn from world points through `CameraProjection`, never from cached +/// screen positions, so when ARKit corrects the meter's anchor the overlays move with the wall. +struct WallProjection { + var projection: CameraProjection + var wall: WallGeometry + var size: CGSize + + func point(_ world: SIMD3) -> CGPoint? { + projection.viewPoint(for: world, in: size) + } + + func point(s: Float, height: Float, out: Float = 0) -> CGPoint? { + point(wall.world(s: s, height: height, out: out)) + } + + /// A quad on the wall face, or nil when any corner is behind the camera. Cells are small + /// (6 in wide), so dropping one that straddles the camera plane loses nothing visible. + func wallQuad(s: ClosedRange, height: ClosedRange, out: Float = 0) -> Path? { + polygon([ + wall.world(s: s.lowerBound, height: height.lowerBound, out: out), + wall.world(s: s.upperBound, height: height.lowerBound, out: out), + wall.world(s: s.upperBound, height: height.upperBound, out: out), + wall.world(s: s.lowerBound, height: height.upperBound, out: out), + ]) + } + + /// A quad on the ground in front of the wall. + func groundQuad(s: ClosedRange, out: ClosedRange, height: Float = 0) -> Path? { + polygon([ + wall.world(s: s.lowerBound, height: height, out: out.lowerBound), + wall.world(s: s.upperBound, height: height, out: out.lowerBound), + wall.world(s: s.upperBound, height: height, out: out.upperBound), + wall.world(s: s.lowerBound, height: height, out: out.upperBound), + ]) + } + + func polygon(_ corners: [SIMD3]) -> Path? { + var points: [CGPoint] = [] + points.reserveCapacity(corners.count) + for corner in corners { + guard let p = point(corner) else { return nil } + points.append(p) + } + var path = Path() + path.addLines(points) + path.closeSubpath() + return path + } + + /// Meters per view point at a world point, for sizing things that should shrink with + /// distance (path dots, pins). + func pointsPerMeter(at world: SIMD3) -> CGFloat? { + let local = projection.cameraSpace(world) + guard local.z < -0.05 else { return nil } + let focal = CGFloat(projection.intrinsics.x) + return focal * projection.scale(in: size) / CGFloat(-local.z) + } + + /// The camera's position along the wall, in meters of s: the nearest point of the wall, + /// round a corner when the walk followed one. + var cameraS: Float { + wall.s(nearest: projection.cameraPosition) + } +} + +extension CameraProjection { + /// Screen direction toward a world point, as a unit vector in view space, usable even when + /// the point is behind the camera. Camera +y (up in the sensor image) is screen right and + /// camera +x is screen down, because the sensor image is shown rotated 90° clockwise. + func screenDirection(toward world: SIMD3) -> CGVector? { + let local = cameraSpace(world) + var dx = CGFloat(local.y) + var dy = CGFloat(local.x) + if local.z > 0 { + // Behind: the point is reached by turning around, so point toward the nearer side. + dx = dx == 0 ? 1 : dx + dy = 0 + } + let length = (dx * dx + dy * dy).squareRoot() + guard length > 1e-5 else { return nil } + return CGVector(dx: dx / length, dy: dy / length) + } +} diff --git a/ios/HouseScan/UI/Camera/WallTape.swift b/ios/HouseScan/UI/Camera/WallTape.swift new file mode 100644 index 000000000..eef76b0aa --- /dev/null +++ b/ios/HouseScan/UI/Camera/WallTape.swift @@ -0,0 +1,426 @@ +import SwiftUI + +/// The live map of the wall, unrolled into a strip like a tape measure with the meter at zero. +/// +/// Top row is the wall face, bottom row the ground at its foot. Gray is not seen yet, amber +/// seen once, green seen well enough (docs/05 section 2); green is evidence, not approval. +/// Foot ticks run along the bottom edge so distances read at a glance without numbers. +/// +/// The two states that aren't about how well the phone saw get a pattern as well as a color, +/// so they read in grayscale, and a legend line under the strip while any is on it: skipped is +/// slate with a slash, hidden (depth saw something in front) a violet dashed outline round the +/// stretch, as on the camera. On a phone with depth the same line says the map is depth-checked. +/// +/// While ending the wall is on offer, a dashed chalk line shows where the end would land and the +/// strip past it on that side is dimmed: that part would be left out. The line follows the phone +/// (or the reticle) frame by frame, so it doesn't animate. +struct WallTape: View { + var coverage: CoverageStrip + var wall: WallGeometry + var features: [MarkedFeature] + /// The homeowner's position along the wall, meters of s, when known. + var cameraS: Float? + var highlight: GapRequest? + /// The phone has depth, so a cell counts only where depth confirms the wall itself. + var depthChecked = false + /// Where a wall end would land now (`ScanViewState.endPreview`); nil hides the line. + var endPreview: EndPreview? + + /// Glyph size for the meter and feature marks. It follows the text size, like every other + /// glyph in the app (fixed 9 and 10 pt sizes failed the audit's Dynamic Type check), and + /// stops growing at 16 pt so the marks still fit the strip's 58 pt height. + @ScaledMetric(relativeTo: .caption2) private var glyphSize: CGFloat = 10 + @Environment(\.dynamicTypeSize) private var typeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion + private var glyph: CGFloat { min(glyphSize, 16) } + private var glyphBox: CGFloat { glyph * 1.8 } + + private static let footInMeters: Float = 0.3048 + + var body: some View { + let extent = Self.extent(coverage: coverage, wall: wall, cameraS: cameraS, also: markedS + [endPreview?.s].compactMap(\.self)) + VStack(spacing: 4) { + GeometryReader { proxy in + let map = TapeMap(extent: extent, width: proxy.size.width) + ZStack(alignment: .topLeading) { + Canvas { context, size in + draw(in: &context, size: size, map: map) + } + ForEach(features) { feature in + let center = (feature.span.lowerBound + feature.span.upperBound) / 2 + Image(systemName: ScanCopy.symbol(feature.kind)) + .font(.system(size: glyph, weight: .bold)) + .foregroundStyle(Palette.chalk) + .frame(width: glyphBox, height: glyphBox * 0.9) + .background(Palette.ink.opacity(0.9), in: .rect(cornerRadius: 4)) + .position(x: Self.inside(map.x(center), halfWidth: glyphBox / 2, width: proxy.size.width), y: glyphBox / 2 - 1) + } + Image(systemName: "bolt.fill") + .font(.system(size: glyph * 0.9, weight: .black)) + .foregroundStyle(.white) + .frame(width: glyphBox, height: glyphBox) + .background(Palette.signal, in: .circle) + .position(x: Self.inside(map.x(0), halfWidth: glyphBox / 2, width: proxy.size.width), y: glyphBox / 2 - 1) + } + } + .frame(height: 58) + if showsFooter { + footer + .transition(.opacity) + } + } + .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: showsFooter) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(ScrimShape.rounded(18)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Map of the wall") + .accessibilityValue(accessibilitySummary) + .accessibilityIdentifier("wallTape") + } + + // MARK: Legend + + private var hiddenSections: [ClosedRange] { sections(of: .hidden) } + private var skippedSections: [ClosedRange] { sections(of: .skipped) } + + private var showsFooter: Bool { + depthChecked || !hiddenSections.isEmpty || !skippedSections.isEmpty || leavesOutWalked != nil + } + + private var leavesOutWalked: Float? { endPreview?.leavesOutWalked } + + private var footer: some View { + let layout = typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) + : AnyLayout(HStackLayout(spacing: 12)) + return layout { + if let leavesOutWalked { + LegendEntry(title: ScanCopy.endLeavesOut(leavesOutWalked, seen: endPreview?.leavesOutSeen == true)) { EndPreviewSwatch() } + } + if !hiddenSections.isEmpty { + LegendEntry(title: "Hidden behind something") { HiddenSwatch() } + } + if !skippedSections.isEmpty { + LegendEntry(title: "Skipped") { SkippedSwatch() } + } + if !typeSize.isAccessibilitySize { + Spacer(minLength: 0) + } + if depthChecked { + Label("Depth-checked", systemImage: "cube.transparent") + .font(Typeface.caption) + .foregroundStyle(Palette.chalk) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: Layout + + /// Keeps a glyph of `halfWidth` inside the strip. When the walk covers only one side, the + /// meter sits at the strip's end, and a glyph centred there hung half outside it, which the + /// accessibility audit reported as clipped on the real ADVIO replay. + static func inside(_ x: CGFloat, halfWidth: CGFloat, width: CGFloat) -> CGFloat { + min(max(x, halfWidth), max(halfWidth, width - halfWidth)) + } + + struct TapeMap { + var extent: ClosedRange + var width: CGFloat + + func x(_ s: Float) -> CGFloat { + let span = max(extent.upperBound - extent.lowerBound, 0.01) + return CGFloat((s - extent.lowerBound) / span) * width + } + } + + /// The middle of each marked feature. A mark past an end still counts (the server puts it on + /// the wall's line continued past the end), so the strip draws it where it is rather than + /// pinned to the strip's edge. + private var markedS: [Float] { + features.map { ($0.span.lowerBound + $0.span.upperBound) / 2 } + } + + /// The s range drawn: everything the engine considers worth drawing, the marked ends, the + /// meter, the homeowner and `also`, plus a little air on both sides. + static func extent(coverage: CoverageStrip, wall: WallGeometry, cameraS: Float?, also: [Float] = []) -> ClosedRange { + var lower = min(coverage.visibleRange.lowerBound, 0) + var upper = max(coverage.visibleRange.upperBound, 0) + for value in [wall.leftEnd, wall.rightEnd, cameraS].compactMap(\.self) + also { + lower = min(lower, value) + upper = max(upper, value) + } + let minimumSpan: Float = 3 + if upper - lower < minimumSpan { + let pad = (minimumSpan - (upper - lower)) / 2 + lower -= pad + upper += pad + } + return (lower - 0.25)...(upper + 0.25) + } + + // MARK: Drawing + + private func draw(in context: inout GraphicsContext, size: CGSize, map: TapeMap) { + let wallRow = CGRect(x: 0, y: 20, width: size.width, height: 14) + let groundRow = CGRect(x: 0, y: wallRow.maxY + 3, width: size.width, height: 9) + let tickTop = groundRow.maxY + 3 + + // Base track, so unknown stretches read as "tape" rather than nothing. + context.fill(Path(roundedRect: wallRow, cornerRadius: 3), with: .color(.white.opacity(0.08))) + context.fill(Path(roundedRect: groundRow, cornerRadius: 3), with: .color(.white.opacity(0.08))) + + for band in CoverageBand.allCases { + let row = band == .wall ? wallRow : groundRow + for (index, state) in coverage.cells(band).enumerated() { + let range = coverage.cellRange(index) + // Cells outside the engine's visible range are neither fog nor evidence. + guard FogMemory.interiorsOverlap(range, coverage.visibleRange), isInsideEnds(range) else { continue } + let x0 = map.x(range.lowerBound) + let x1 = map.x(range.upperBound) + let rect = CGRect(x: x0 + 0.5, y: row.minY, width: max(1, x1 - x0 - 1), height: row.height) + // Hidden is a tint under a dashed outline drawn per stretch below: hollow, so it + // never reads as a solid covered cell. + let opacity = switch state { + case .unseen: 0.55 + case .hidden: 0.22 + default: 1.0 + } + context.fill(Path(rect), with: .color(Palette.cell(state).opacity(opacity))) + if state == .skipped { + var slash = Path() + slash.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + slash.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + context.stroke(slash, with: .color(.white.opacity(0.7)), lineWidth: 1) + } + } + for run in runs(of: .hidden, in: band) { + let rect = CGRect(x: map.x(run.lowerBound), y: row.minY, width: map.x(run.upperBound) - map.x(run.lowerBound), height: row.height) + .insetBy(dx: 0.75, dy: 0.75) + context.stroke(Path(roundedRect: rect, cornerRadius: 2), with: .color(Palette.hidden), + style: StrokeStyle(lineWidth: 1.5, dash: Palette.hiddenDash)) + } + if let gap = highlight, gap.band == band { + let rect = CGRect(x: map.x(gap.span.lowerBound), y: row.minY - 2, + width: map.x(gap.span.upperBound) - map.x(gap.span.lowerBound), height: row.height + 4) + context.stroke(Path(roundedRect: rect, cornerRadius: 3), with: .color(Palette.caution), lineWidth: 2) + } + } + + // What ending the wall at the preview would leave out: everything past it on that side. + if let preview = endPreview { + let x = map.x(preview.s) + let band = CGRect(x: 0, y: wallRow.minY, width: size.width, height: groundRow.maxY - wallRow.minY) + let veil = preview.side == .left + ? band.divided(atDistance: max(0, x), from: .minXEdge).slice + : band.divided(atDistance: max(0, x), from: .minXEdge).remainder + context.fill(Path(veil), with: .color(.black.opacity(0.55))) + } + + // Foot ticks along the bottom edge; every fifth foot is longer. + let firstFoot = Int((map.extent.lowerBound / Self.footInMeters).rounded(.up)) + let lastFoot = Int((map.extent.upperBound / Self.footInMeters).rounded(.down)) + if firstFoot <= lastFoot { + var ticks = Path() + for foot in firstFoot...lastFoot { + let x = map.x(Float(foot) * Self.footInMeters) + ticks.move(to: CGPoint(x: x, y: tickTop)) + ticks.addLine(to: CGPoint(x: x, y: tickTop + (foot % 5 == 0 ? 7 : 3.5))) + } + context.stroke(ticks, with: .color(Palette.chalk.opacity(0.7)), lineWidth: 1) + } + + // Meter line through both rows. + var meterLine = Path() + meterLine.move(to: CGPoint(x: map.x(0), y: 16)) + meterLine.addLine(to: CGPoint(x: map.x(0), y: groundRow.maxY + 2)) + context.stroke(meterLine, with: .color(Palette.signal), lineWidth: 2) + + // Marked ends: tall chalk caps. + for end in [wall.leftEnd, wall.rightEnd].compactMap(\.self) { + let x = map.x(end) + let cap = CGRect(x: x - 1.5, y: wallRow.minY - 4, width: 3, height: groundRow.maxY - wallRow.minY + 8) + context.fill(Path(roundedRect: cap, cornerRadius: 1.5), with: .color(Palette.chalk)) + } + + // Where the end would land: a dashed chalk line as tall as the end caps, on a dark + // underlay so it reads over bright cells in sun. + if let preview = endPreview { + let x = map.x(preview.s) + var line = Path() + line.move(to: CGPoint(x: x, y: wallRow.minY - 4)) + line.addLine(to: CGPoint(x: x, y: groundRow.maxY + 4)) + context.stroke(line, with: .color(.black.opacity(0.6)), lineWidth: 4) + context.stroke(line, with: .color(Palette.chalk), style: StrokeStyle(lineWidth: 2, dash: [3, 2.5])) + } + + // The homeowner: a white pointer under the strip, like the cursor on a tape. + if let cameraS { + let x = map.x(min(max(cameraS, map.extent.lowerBound), map.extent.upperBound)) + var marker = Path() + marker.move(to: CGPoint(x: x, y: groundRow.maxY + 1)) + marker.addLine(to: CGPoint(x: x - 7, y: groundRow.maxY + 11)) + marker.addLine(to: CGPoint(x: x + 7, y: groundRow.maxY + 11)) + marker.closeSubpath() + context.fill(marker, with: .color(.white)) + context.stroke(marker, with: .color(Palette.signal), lineWidth: 1.5) + } + } + + /// Runs of cells in `state` within one band, as s ranges, over the cells the strip draws. + private func runs(of state: CellState, in band: CoverageBand) -> [ClosedRange] { + var runs: [ClosedRange] = [] + for (index, cell) in coverage.cells(band).enumerated() { + let range = coverage.cellRange(index) + guard cell == state, FogMemory.interiorsOverlap(range, coverage.visibleRange), isInsideEnds(range) else { continue } + if let last = runs.last, last.upperBound >= range.lowerBound - 0.001 { + runs[runs.count - 1] = last.lowerBound...range.upperBound + } else { + runs.append(range) + } + } + return runs + } + + /// Stretches of wall with cells in `state` in either band, overlapping runs merged: a bush + /// that hides both the wall and the ground in front of it is one section, not two. + private func sections(of state: CellState) -> [ClosedRange] { + let all = (runs(of: state, in: .wall) + runs(of: state, in: .ground)).sorted { $0.lowerBound < $1.lowerBound } + var merged: [ClosedRange] = [] + for run in all { + if let last = merged.last, last.upperBound >= run.lowerBound - 0.001 { + merged[merged.count - 1] = last.lowerBound...max(last.upperBound, run.upperBound) + } else { + merged.append(run) + } + } + return merged + } + + private func isInsideEnds(_ range: ClosedRange) -> Bool { + if let left = wall.leftEnd, range.upperBound <= left { return false } + if let right = wall.rightEnd, range.lowerBound >= right { return false } + return true + } + + // MARK: Accessibility + + private var accessibilitySummary: String { + var parts: [String] = [] + let lowerLeft = wall.leftEnd ?? min(coverage.visibleRange.lowerBound, 0) + let upperRight = wall.rightEnd ?? max(coverage.visibleRange.upperBound, 0) + if lowerLeft < -0.05 { + parts.append("Left of your meter: \(percentSeen(in: lowerLeft...0)) percent seen") + } + if upperRight > 0.05 { + parts.append("Right of your meter: \(percentSeen(in: 0...upperRight)) percent seen") + } + if let cameraS { + parts.append("You are \(Distance.spoken(cameraS)) \(cameraS < 0 ? "left" : "right") of your meter") + } + if let hidden = Self.count(hiddenSections.count, "section") { + parts.append("\(hidden) hidden behind something") + } + if let skipped = Self.count(skippedSections.count, "section") { + parts.append("\(skipped) skipped") + } + if let preview = endPreview { + var sentence = preview.atReticle + ? "Wall ends here marks the \(preview.side.rawValue) end \(Self.spokenFromMeter(preview.s))" + : "If you end the wall now, the \(preview.side.rawValue) end goes \(Self.spokenFromMeter(preview.s))" + if let leavesOutWalked { + sentence += ", leaving out \(Distance.spoken(leavesOutWalked)) \(ScanCopy.leftOutVerb(endPreview?.leavesOutSeen == true))" + } + parts.append(sentence) + } + for (side, end) in [("Left", wall.leftEnd), ("Right", wall.rightEnd)] { + if let end { parts.append("\(side) end \(Self.spokenFromMeter(end))") } + } + if wall.leftEnd != nil, wall.rightEnd != nil { + parts.append("Both ends marked") + } + if depthChecked { + parts.append("Checked with your phone's depth sensor") + } + return parts.joined(separator: ". ") + } + + /// "5 feet 2 inches left of your meter", or "at your meter" within 3 in, as `Distance.fromMeter`. + static func spokenFromMeter(_ s: Float) -> String { + if abs(s) < Distance.metersPerInch * 3 { return "at your meter" } + return "\(Distance.spoken(s)) \(s < 0 ? "left" : "right") of your meter" + } + + /// "1 section", "2 sections"; nil for none. + private static func count(_ n: Int, _ noun: String) -> String? { + n == 0 ? nil : "\(n) \(noun)\(n == 1 ? "" : "s")" + } + + private func percentSeen(in range: ClosedRange) -> Int { + let states = coverage.wall.indices.filter { range.overlaps(coverage.cellRange($0)) }.map { coverage.wall[$0] } + guard !states.isEmpty else { return 0 } + // Split up: as one expression it took the type checker over 60 ms. + let seen = Double(states.filter { $0 == .seen || $0 == .covered }.count) + let fraction = seen / Double(states.count) + return Int((fraction * 100).rounded()) + } +} + +// MARK: - Legend pieces + +private struct LegendEntry: View { + var title: String + @ViewBuilder var swatch: Swatch + + var body: some View { + HStack(spacing: 6) { + swatch + Text(title) + .font(Typeface.caption) + .foregroundStyle(Palette.chalk) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +/// A hidden cell as the strip draws it: violet tint inside a dashed violet outline. +private struct HiddenSwatch: View { + var body: some View { + RoundedRectangle(cornerRadius: 2) + .fill(Palette.hidden.opacity(0.22)) + .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.hidden, style: StrokeStyle(lineWidth: 1.5, dash: [3, 2]))) + .frame(width: 18, height: 12) + } +} + +/// The end preview as the strip draws it: a dashed chalk line with the stretch past it dimmed. +private struct EndPreviewSwatch: View { + var body: some View { + Canvas { context, size in + let rect = CGRect(origin: .zero, size: size) + context.fill(Path(roundedRect: rect, cornerRadius: 2), with: .color(Palette.cell(.covered))) + context.fill(Path(rect.divided(atDistance: size.width / 2, from: .minXEdge).remainder), with: .color(.black.opacity(0.55))) + var line = Path() + line.move(to: CGPoint(x: size.width / 2, y: 0)) + line.addLine(to: CGPoint(x: size.width / 2, y: size.height)) + context.stroke(line, with: .color(Palette.chalk), style: StrokeStyle(lineWidth: 2, dash: [3, 2.5])) + } + .frame(width: 18, height: 12) + } +} + +/// A skipped cell as the strip draws it: slate with a white slash. +private struct SkippedSwatch: View { + var body: some View { + Canvas { context, size in + let rect = CGRect(origin: .zero, size: size) + context.fill(Path(roundedRect: rect, cornerRadius: 2), with: .color(Palette.skipped)) + var slash = Path() + slash.move(to: CGPoint(x: rect.minX + 3, y: rect.maxY)) + slash.addLine(to: CGPoint(x: rect.maxX - 3, y: rect.minY)) + context.stroke(slash, with: .color(.white.opacity(0.7)), lineWidth: 1) + } + .frame(width: 18, height: 12) + } +} diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift new file mode 100644 index 000000000..129093187 --- /dev/null +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -0,0 +1,235 @@ +import simd +import SwiftUI + +/// Where to stand and where to aim: a blue dotted path on the ground toward the next place to +/// stand, and a ring on the point to aim at (or an edge arrow when it is off screen). +/// +/// Each part sets its own accessibility: the path and arrow are hidden, while the filling ring +/// is read (#81). A new part is read by VoiceOver unless it hides itself. The ring's legend is +/// drawn under the instruction card (`CameraChrome.legend`), not here. +struct WayfindingOverlay: View { + var projection: CameraProjection + var wall: WallGeometry? + var path: [SIMD3] + var target: SIMD3? + /// How far the aim task is toward done, 0...1 (`ScanViewState.aimProgress`): the ring fills + /// with it. Nil when the target only marks a place. + var progress: Double? = nil + /// An aim step's target that was just completed, drawn as a full ring in place of `target` + /// while it is on screen (`CameraOverlays` holds it for a moment). + var completed: SIMD3? = nil + /// Called with whether a filling ring is on screen, each time that changes (`CameraOverlays` + /// shows the ring's legend under the card only while it is). + var onFillingRingShown: ((Bool) -> Void)? = nil + + var body: some View { + GeometryReader { proxy in + let size = proxy.size + ZStack { + Canvas { context, size in + drawPath(in: &context, size: size) + } + .accessibilityHidden(true) + // One marker either way, so a ring that completes keeps its identity and animates + // from its last fill to green. + if let shown = marker(in: size) { + TargetMarker(placement: shown.placement, progress: shown.progress) + if progress != nil, completed == nil, case .onScreen = shown.placement { + Color.clear + .frame(width: 0, height: 0) + .onAppear { onFillingRingShown?(true) } + .onDisappear { onFillingRingShown?(false) } + } + } + } + .frame(width: size.width, height: size.height) + } + .ignoresSafeArea() + .allowsHitTesting(false) + } + + // MARK: Lane + + /// The band clear of the instruction card above and the buttons and map below, which are + /// drawn over this layer. The off-screen arrows keep to it. + private static let laneTop: CGFloat = 260 + private static let laneBottomInset: CGFloat = 300 + + // MARK: Path + + /// Dot spacing on the ground. A display choice: dense enough to read as a line at 3 m, + /// sparse enough that each dot is distinct up close. + private static let dotSpacing: Float = 0.22 + + private func drawPath(in context: inout GraphicsContext, size: CGSize) { + guard path.count >= 2 else { return } + var samples: [SIMD3] = [] + var carry: Float = 0 + for (a, b) in zip(path, path.dropFirst()) { + let length = simd_distance(a, b) + guard length > 0 else { continue } + var d = carry + while d < length { + samples.append(a + (b - a) * (d / length)) + d += Self.dotSpacing + } + carry = d - length + } + samples.append(path[path.count - 1]) + + for (index, sample) in samples.enumerated() { + let local = projection.cameraSpace(sample) + guard local.z < -0.15, let point = projection.viewPoint(for: sample, in: size) else { continue } + let pointsPerMeter = CGFloat(projection.intrinsics.x) * projection.scale(in: size) / CGFloat(-local.z) + // Dots are 7 cm across on the ground, so they shrink with distance like paint would. + let radius = min(14, max(2.5, pointsPerMeter * 0.035)) + let fade = index == samples.count - 1 ? 1 : min(1, 0.55 + Double(index) / Double(max(samples.count, 1))) + let rect = CGRect(x: point.x - radius, y: point.y - radius * 0.62, width: radius * 2, height: radius * 1.24) + context.fill(Path(ellipseIn: rect.insetBy(dx: -2, dy: -1.5)), with: .color(.white.opacity(0.7 * fade))) + context.fill(Path(ellipseIn: rect), with: .color(Palette.signal.opacity(fade))) + } + } + + // MARK: Target + + /// The completed ring while it is on screen, else the target's ring or arrow. + private func marker(in size: CGSize) -> (placement: TargetMarker.Placement, progress: Double?)? { + if let completed { + let held = placement(for: completed, in: size) + if case .onScreen = held { return (placement: held, progress: 1.0) } + } + guard let target else { return nil } + return (placement: placement(for: target, in: size), progress: progress) + } + + private func placement(for target: SIMD3, in size: CGSize) -> TargetMarker.Placement { + // The ring shows while its center is comfortably on screen; the chevron takes over + // near the edges, where a half-visible ring would be ambiguous. + let bounds = CGRect(x: 36, y: 150, width: size.width - 72, height: size.height - 330) + if let point = projection.viewPoint(for: target, in: size), bounds.contains(point) { + let scale = wall.flatMap { WallProjection(projection: projection, wall: $0, size: size).pointsPerMeter(at: target) } + let radius = min(64, max(30, (scale ?? 90) * 0.28)) + return .onScreen(point, radius: radius) + } + guard let direction = projection.screenDirection(toward: target) else { + return .hidden + } + // Arrows keep to the lane, clear of the card and the controls. + let lane = CGRect( + x: 40, + y: Self.laneTop, + width: size.width - 80, + height: max(size.height - Self.laneTop - Self.laneBottomInset, 80) + ) + let center = CGPoint(x: lane.midX, y: lane.midY) + let halfWidth = lane.width / 2 + let halfHeight = lane.height / 2 + let tx = direction.dx == 0 ? CGFloat.infinity : halfWidth / abs(direction.dx) + let ty = direction.dy == 0 ? CGFloat.infinity : halfHeight / abs(direction.dy) + let t = min(tx, ty) + let edge = CGPoint(x: center.x + direction.dx * t, y: center.y + direction.dy * t) + return .offScreen(edge, angle: .radians(atan2(direction.dy, direction.dx))) + } +} + +/// The aim ring, or an arrow at the screen edge pointing toward it. Also the arrow toward the +/// battery spot on "See it on your wall" (`ResultARScreen`). +struct TargetMarker: View { + enum Placement: Equatable { + case onScreen(CGPoint, radius: CGFloat) + case offScreen(CGPoint, angle: Angle) + case hidden + } + + var placement: Placement + /// 0...1 toward the aim task being done. The ring fills with it, then turns green with a tick + /// at 1, like the meter photo's ring; nil draws the plain pulsing ring. A ring that never + /// changed while it was held on target gave no sign of what it wanted (#81). + var progress: Double? = nil + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + switch placement { + case .onScreen(let point, let radius): + Group { + if let progress { + progressRing(min(max(progress, 0), 1), radius: radius) + } else { + plainRing(radius: radius) + } + } + .position(point) + .transition(.opacity) + case .offScreen(let point, let angle): + // An arrow with a shaft: a bare chevron in a blue disc, turned to point down, can + // read as a tick (#81). + Image(systemName: "arrow.right") + .font(.system(size: 22, weight: .heavy)) + .foregroundStyle(.white) + .rotationEffect(angle) + .frame(width: 52, height: 52) + .background(Palette.signal, in: .circle) + .overlay(Circle().strokeBorder(.white.opacity(0.85), lineWidth: 2)) + .shadow(color: .black.opacity(0.3), radius: 6, y: 2) + .position(point) + .transition(.opacity) + .accessibilityHidden(true) + case .hidden: + EmptyView() + } + } + + private func plainRing(radius: CGFloat) -> some View { + ZStack { + Circle() + .strokeBorder(.white.opacity(0.9), lineWidth: 7) + Circle() + .strokeBorder(Palette.signal, lineWidth: 4) + Circle() + .fill(Palette.signal) + .frame(width: 8, height: 8) + } + .frame(width: radius * 2, height: radius * 2) + .phaseAnimator(reduceMotion ? [1.0] : [1.0, 1.08]) { ring, scale in + ring.scaleEffect(scale) + } animation: { _ in + .easeInOut(duration: 0.9) + } + .accessibilityHidden(true) + } + + /// No pulse: the fill is what moves. VoiceOver (and the UI tests) read its percent. + private func progressRing(_ progress: Double, radius: CGFloat) -> some View { + let done = progress >= 1 + return ZStack { + Circle() + .strokeBorder(.black.opacity(0.3), lineWidth: 11) + Circle() + .strokeBorder(.white.opacity(0.85), lineWidth: 7) + Circle() + .inset(by: 3.5) + .trim(from: 0, to: done ? 1 : progress) + .stroke(done ? Palette.covered : Palette.signal, style: StrokeStyle(lineWidth: 7, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(reduceMotion ? nil : .linear(duration: 0.2), value: progress) + if done { + Image(systemName: "checkmark") + .font(.system(size: max(14, radius * 0.5), weight: .black)) + .foregroundStyle(.white) + .padding(radius * 0.2) + .background(Palette.covered, in: .circle) + .transition(reduceMotion ? .opacity : .scale(scale: 0.8).combined(with: .opacity)) + } else { + Circle() + .fill(Palette.signal) + .frame(width: 8, height: 8) + } + } + .frame(width: radius * 2, height: radius * 2) + .animation(Motion.pin, value: done) + .accessibilityElement() + .accessibilityLabel("Spot to show") + .accessibilityValue(done ? "Captured" : "\(Int((progress * 100).rounded())) percent captured") + .accessibilityIdentifier("aim.ring") + } +} diff --git a/ios/HouseScan/UI/Components/AnswerButton.swift b/ios/HouseScan/UI/Components/AnswerButton.swift new file mode 100644 index 000000000..b03018bb7 --- /dev/null +++ b/ios/HouseScan/UI/Components/AnswerButton.swift @@ -0,0 +1,44 @@ +import SwiftUI + +/// One answer to a question on the light review panel: a tinted capsule that fills blue once it +/// is the answer given. Shared by the window question and the ground question so every answer on +/// the panel looks and behaves the same. +struct AnswerButton: View { + let title: String + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(Typeface.button) + .foregroundStyle(selected ? .white : Palette.signalText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 46) + // The primary button's darker fill: white on it is about 6.2:1, on Signal 4.9:1. + .background(selected ? Palette.signalFill : Palette.signal.opacity(0.1), in: .capsule) + .contentShape(.capsule) + } + .buttonStyle(PressableStyle()) + .accessibilityAddTraits(selected ? .isSelected : []) + .animation(.easeOut(duration: 0.15), value: selected) + } +} + +/// The outline "Looks complete" puts round the first unanswered question on the review (#65), +/// in the result screen's review amber, which keeps over 3:1 on the light panel. +struct UnansweredOutline: View { + let shown: Bool + + var body: some View { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Palette.reviewInk, lineWidth: 3) + .opacity(shown ? 1 : 0) + .animation(Motion.text, value: shown) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} diff --git a/ios/HouseScan/UI/Components/Buttons.swift b/ios/HouseScan/UI/Components/Buttons.swift new file mode 100644 index 000000000..00126013b --- /dev/null +++ b/ios/HouseScan/UI/Components/Buttons.swift @@ -0,0 +1,77 @@ +import SwiftUI + +/// The one big action on a screen: solid blue, white bold text, full width. Solid rather than +/// glass because it has to stay legible over a sunlit wall; white on the fill is about 6:1. +struct PrimaryButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(Typeface.button) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, minHeight: Metrics.primaryButtonHeight) + .padding(.horizontal, 20) + .background(Palette.signalFill.opacity(isEnabled ? 1 : 0.45), in: .capsule) + .contentShape(.capsule) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +/// Second choice next to a primary action: a dark, heavy material so it reads over any camera +/// image without competing with the blue. +struct SecondaryButtonStyle: ButtonStyle { + var prominent = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(prominent ? Typeface.button : Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.chalk) + .multilineTextAlignment(.center) + .frame(minHeight: prominent ? Metrics.primaryButtonHeight : 50) + .padding(.horizontal, 20) + .background(ScrimShape.capsule) + .contentShape(.capsule) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +/// Buttons on the light text screens: tinted outline style for the second choice. +struct QuietButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(Typeface.button) + .foregroundStyle(Palette.signalText) + .frame(maxWidth: .infinity, minHeight: 52) + .padding(.horizontal, 16) + .background(Palette.signal.opacity(configuration.isPressed ? 0.16 : 0.1), in: .capsule) + .contentShape(.capsule) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +/// Scale-on-press for custom-drawn buttons. +struct PressableStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.96 : 1) + .opacity(configuration.isPressed ? 0.85 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +extension ButtonStyle where Self == PrimaryButtonStyle { + static var primary: PrimaryButtonStyle { PrimaryButtonStyle() } +} + +extension ButtonStyle where Self == SecondaryButtonStyle { + static var secondary: SecondaryButtonStyle { SecondaryButtonStyle() } + static var secondaryProminent: SecondaryButtonStyle { SecondaryButtonStyle(prominent: true) } +} + +extension ButtonStyle where Self == QuietButtonStyle { + static var quiet: QuietButtonStyle { QuietButtonStyle() } +} diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift new file mode 100644 index 000000000..003cbb99a --- /dev/null +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -0,0 +1,119 @@ +import SwiftUI + +/// The frame shared by every camera screen: status row and one instruction at the top, the +/// camera open in the middle, actions within thumb reach at the bottom. +struct CameraChrome: View { + var instruction: Instruction + var tone: InstructionCard.Tone = .normal + var reply: InstructionCard.Reply? + /// See `InstructionCard.eyebrow`. + var eyebrow: String? + var photoCount: Int? + var lastCaptureID: Int? + var isReplay: Bool + var isAutopilot: Bool + /// Taps on the open camera area, in the camera view's coordinates (full screen, which is + /// the window's global space). Nil when the screen has nothing to tap. + var onCameraTap: ((CGPoint) -> Void)? + /// A line under the card about something drawn on the camera: the aim ring's legend + /// (`CameraOverlays`). It is part of the chrome's stack, so it grows with the card and + /// scrolls with it at the largest text sizes, where the card covers most of the camera. + var legend: String? = nil + @ViewBuilder var bottom: Bottom + + var body: some View { + // One layout at every text size: the chrome fills the screen and, only when the largest + // text makes it taller than the screen, scrolls instead of squeezing the words. The + // scroll view would swallow taps meant for the camera, so camera taps are caught inside + // it, behind the chrome. + GeometryReader { proxy in + ScrollView { + stack + .frame(width: proxy.size.width) + .frame(minHeight: proxy.size.height) + .background { + if let onCameraTap { + Color.clear + .contentShape(.rect) + .onTapGesture(coordinateSpace: .global) { point in onCameraTap(point) } + .accessibilityHidden(true) + } + } + } + .scrollBounceBehavior(.basedOnSize) + .scrollIndicators(.hidden) + } + } + + private var stack: some View { + VStack(spacing: 10) { + HStack(alignment: .center) { + ModeBadge(isReplay: isReplay, isAutopilot: isAutopilot) + Spacer(minLength: 8) + if let photoCount { + PhotoCounter(count: photoCount, lastCaptureID: lastCaptureID) + } + } + .frame(minHeight: 36) + InstructionCard(instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow) + if let legend { + legendLine(legend) + } + Spacer(minLength: 0) + bottom + } + .animation(.easeOut(duration: 0.2), value: legend) + .padding(.horizontal, Metrics.edge) + .padding(.top, 4) + .padding(.bottom, 8) + } + + /// One Text with the symbol inline, as the result's sample badge is: the audit reported a + /// Label with fixedSize as partially supporting Dynamic Type. + private func legendLine(_ text: String) -> some View { + Text("\(Image(systemName: "scope")) \(text)") + .font(Typeface.hint) + .foregroundStyle(Palette.chalk) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ScrimShape.rounded(14)) + .accessibilityLabel(text) + .accessibilityIdentifier("aim.legend") + .transition(.opacity) + } +} + +/// Reads the full-screen camera view size so buttons can send "the reticle" (nil point) +/// with the right `viewSize`. +struct CameraSizeReader: View { + @Binding var size: CGSize + + var body: some View { + GeometryReader { proxy in + Color.clear + .onAppear { size = proxy.size } + .onChange(of: proxy.size) { _, newValue in size = newValue } + } + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +/// Crosshair ring at the center of the camera: "the phone is pointing here". +struct Reticle: View { + var diameter: CGFloat = 64 + + var body: some View { + ZStack { + Circle().strokeBorder(.black.opacity(0.35), lineWidth: 6) + Circle().strokeBorder(.white, lineWidth: 3) + Circle().fill(.white).frame(width: 6, height: 6) + } + .frame(width: diameter, height: diameter) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} diff --git a/ios/HouseScan/UI/Components/CenteredScroll.swift b/ios/HouseScan/UI/Components/CenteredScroll.swift new file mode 100644 index 000000000..f3c5fa1a8 --- /dev/null +++ b/ios/HouseScan/UI/Components/CenteredScroll.swift @@ -0,0 +1,18 @@ +import SwiftUI + +/// A scroll view whose content sits in the vertical middle when it fits, and scrolls when a +/// large text size makes it taller than the screen. +struct CenteredScroll: View { + @ViewBuilder var content: Content + + var body: some View { + GeometryReader { proxy in + ScrollView { + content + .frame(maxWidth: .infinity) + .frame(minHeight: proxy.size.height) + } + .scrollBounceBehavior(.basedOnSize) + } + } +} diff --git a/ios/HouseScan/UI/Components/GroundQuestion.swift b/ios/HouseScan/UI/Components/GroundQuestion.swift new file mode 100644 index 000000000..bfed1490a --- /dev/null +++ b/ios/HouseScan/UI/Components/GroundQuestion.swift @@ -0,0 +1,150 @@ +import SwiftUI + +/// "What's on the ground along this wall?", first on the feature review. The camera can't tell +/// mulch from soil, so the homeowner says; leaving it unanswered counts as "Not sure". +/// +/// Unanswered, the card asks: the six ground types in two columns, "Not sure" across the full +/// width under them (one column at accessibility text sizes). Answered, it folds into one line +/// with the answer and Change, which opens the answers again with the current one filled in. +struct GroundQuestion: View { + let answer: GroundAnswer? + let actions: any ScanActions + /// "Looks complete" pointed here while the question was unanswered (#65). + var highlighted = false + + /// Change was tapped: the answers show although there is an answer. + @State private var isChanging = false + @AccessibilityFocusState private var focus: Focus? + @Environment(\.dynamicTypeSize) private var typeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private enum Focus: Hashable { case question, answered } + + private var asks: Bool { answer == nil || isChanging } + + var body: some View { + // Overlaid, not stacked, so the outgoing content fades inside the card while the card + // resizes to the incoming one. + ZStack(alignment: .topLeading) { + if asks { + question + .transition(Self.swap) + } else if let answer { + answered(answer) + .transition(Self.swap) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.canvas, in: .rect(cornerRadius: 18, style: .continuous)) + .clipShape(.rect(cornerRadius: 18, style: .continuous)) + .overlay { UnansweredOutline(shown: highlighted) } + // The tapped button is gone after the swap, so VoiceOver moves to what replaced it. + .onChange(of: asks) { _, asks in + focus = asks ? .question : .answered + } + } + + /// The outgoing side leaves fast; the incoming side fades in once the card has mostly + /// resized, so the two never read as overlapping. + private static let swap = AnyTransition.asymmetric( + insertion: .opacity.animation(.easeOut(duration: 0.18).delay(0.1)), + removal: .opacity.animation(.easeOut(duration: 0.1)) + ) + + /// The card's resize and the review sliding up or down under it. Critically damped, so the + /// panel settles without a wobble. With Reduce Motion none: the review below would slide by the + /// height of the answers, so the card changes in place instead. + private var fold: Animation? { + reduceMotion ? nil : .spring(duration: 0.35, bounce: 0) + } + + private func give(_ newAnswer: GroundAnswer) { + withAnimation(fold) { + actions.answerGround(newAnswer) + isChanging = false + } + } + + // MARK: Asking + + private var question: some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text(ScanCopy.groundQuestion.title) + .font(Typeface.sectionTitle) + .foregroundStyle(.primary) + .accessibilityAddTraits(.isHeader) + .accessibilityFocused($focus, equals: .question) + if let detail = ScanCopy.groundQuestion.detail { + Text(detail) + .font(.subheadline) + .foregroundStyle(Palette.muted) + } + } + .fixedSize(horizontal: false, vertical: true) + answers + } + } + + private var answers: some View { + let columns = typeSize.isAccessibilitySize ? 1 : 2 + let types = GroundType.allCases + return Grid(horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(Array(stride(from: 0, to: types.count, by: columns)), id: \.self) { start in + GridRow { + ForEach(types[start.. some View { + AnswerButton(title: ScanCopy.groundAnswer(option), selected: answer == option) { + give(option) + } + } + + // MARK: Answered + + private func answered(_ answer: GroundAnswer) -> some View { + let layout = typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 10)) + : AnyLayout(HStackLayout(spacing: 12)) + return layout { + VStack(alignment: .leading, spacing: 2) { + Text(ScanCopy.groundAnsweredLabel) + .font(.subheadline) + .foregroundStyle(Palette.muted) + Text(ScanCopy.groundAnswer(answer)) + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(.primary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("ground.answered") + .accessibilityFocused($focus, equals: .answered) + Button { + withAnimation(fold) { isChanging = true } + } label: { + Text(ScanCopy.groundChange) + .font(Typeface.button) + .foregroundStyle(Palette.signalText) + .padding(.horizontal, 18) + .frame(minHeight: Metrics.minTarget) + .background(Palette.signal.opacity(0.1), in: .capsule) + .contentShape(.capsule) + } + .buttonStyle(PressableStyle()) + .accessibilityLabel("Change ground") + .accessibilityIdentifier("ground.change") + } + } +} diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift new file mode 100644 index 000000000..9a3773c21 --- /dev/null +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -0,0 +1,152 @@ +import SwiftUI + +/// The single instruction at the top of a camera screen. +/// +/// When the text changes, the words and the reply land at once and the scrim eases to its new +/// height. A blur or fade between them left both lines half-transparent for a moment, which the +/// accessibility audit reported as low contrast and, for the outgoing line, as clipped text +/// (the marking prompt, the end question). When the task changes, the reply under the words +/// swaps with them. Coaching takes the same slot with an amber icon so a problem replaces the +/// instruction instead of stacking on top of it. +struct InstructionCard: View { + enum Tone: Equatable { + case normal + case coaching(symbol: String) + case refusal + } + + /// A reply to the instruction itself, such as "Can't get there". Lives in the card so it + /// reads as an answer to what the card asks, not as a competing primary action. + struct Reply { + var title: String + var identifier: String + var hint: String + var perform: () -> Void + /// The task the reply answers, when that isn't the card's words: the task under a + /// coaching line, or the walk without its distance to go. The reply swaps with the words + /// and takes no taps for `replyLock` when this changes. Nil: the card's instruction. + var task: Instruction? = nil + } + + var instruction: Instruction + var tone: Tone = .normal + var reply: Reply? + /// A short line above the instruction that places it in the flow ("One more view to + /// finish"). Read with the instruction as one VoiceOver element. + var eyebrow: String? + + /// How long a new card's reply ignores taps, so a tap meant for the card that just left + /// can't answer the next one: in the 4.1 field test, quick taps on "Can't get there" each + /// answered a different card (#82). A guess, not measured: the card's own change + /// (`Motion.text`, 0.2 s) plus a tap's reaction time. + static let replyLock: Duration = .milliseconds(600) + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// The task whose reply has been on screen for `replyLock`. Compared with the current task + /// in the same update that changes it, so a new reply is locked from its first frame. + @State private var unlockedTask: Instruction? + + private var replyTask: Instruction { reply?.task ?? instruction } + + var body: some View { + VStack(alignment: .trailing, spacing: 0) { + message + .id(instruction) + .transition(.identity) + if let reply { + Button(reply.title, action: reply.perform) + .font(Typeface.caption.weight(.bold)) + .foregroundStyle(Palette.chalk) + .padding(.horizontal, 16) + .frame(minHeight: Metrics.minTarget) + .background(Palette.replyFill, in: .capsule) + .contentShape(.capsule) + .buttonStyle(PressableStyle()) + // The colours are set above and PressableStyle doesn't read isEnabled, so the + // pill looks the same while locked; VoiceOver and UI tests read it as dimmed. + .disabled(unlockedTask != replyTask) + .accessibilityHint(reply.hint) + .accessibilityIdentifier(reply.identifier) + .padding([.horizontal, .bottom], 12) + .padding(.top, -4) + // Appears and goes at once, like the message above it: the walk's request can + // change every few seconds, and a fading button spends those moments as faint + // text on the card, which the accessibility audit caught twice on CI (runs + // 36295565916, and the LiDAR replay after the coaching fix). + .transition(.identity) + } + } + // New words land at once with the reply at its final place; only the scrim, outside + // this stack, eases to the new height. When the stack's layout eased too, the reply slid + // from its old place over the new words for `Motion.text` (#64): on a coaching change, + // which swaps the words and keeps the reply, and comes and goes often while walking. + .transaction(value: instruction) { $0.animation = nil } + // A new task swaps the words and the reply together, as one new view. With the reply + // outside the swap it faded out over the new words (#64). The words alone still swap on + // their own (a coaching line, the distance to go), so the reply keeps a press that is + // under way. + .id(replyTask) + .transition(.identity) + .frame(maxWidth: .infinity) + .background(ScrimShape.rounded()) + .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.text, value: instruction) + .animation(Motion.text, value: reply == nil) + .task(id: replyTask) { + // Cleared first, so a task that comes back within the lock (A, B, A) is locked again. + let shown = replyTask + unlockedTask = nil + // A new id cancels this wait and starts its own. + do { try await Task.sleep(for: Self.replyLock) } catch { return } + unlockedTask = shown + } + } + + private var message: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + if let symbol = iconName { + Image(systemName: symbol) + .font(.title3.weight(.bold)) + .foregroundStyle(iconColor) + .accessibilityHidden(true) + } + VStack(alignment: .leading, spacing: 4) { + if let eyebrow { + Label(eyebrow, systemImage: "camera.viewfinder") + .font(Typeface.caption) + // The requested view's amber, as on the camera and the map. + .foregroundStyle(Palette.caution) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 2) + } + Text(instruction.title) + .font(Typeface.instruction) + .foregroundStyle(Palette.chalk) + .fixedSize(horizontal: false, vertical: true) + if let detail = instruction.detail { + Text(detail) + .font(Typeface.hint) + .foregroundStyle(Palette.chalk.opacity(0.92)) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.updatesFrequently) + .accessibilityIdentifier("instruction") + } + + private var iconName: String? { + switch tone { + case .normal: nil + case .coaching(let symbol): symbol + case .refusal: "exclamationmark.triangle.fill" + } + } + + private var iconColor: Color { + tone == .refusal ? Palette.danger : Palette.caution + } +} diff --git a/ios/HouseScan/UI/Components/OverheadAnswers.swift b/ios/HouseScan/UI/Components/OverheadAnswers.swift new file mode 100644 index 000000000..a1318d588 --- /dev/null +++ b/ios/HouseScan/UI/Components/OverheadAnswers.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// The two answers to the overhead question (`ScanViewState.overheadQuestion`), shared by the +/// walk's tilt-up step and an overhead gap request. Same shape as the end question: one question, +/// two equal full-width answers that say what they mean. +struct OverheadAnswers: View { + let actions: any ScanActions + + var body: some View { + VStack(spacing: 8) { + Button { + actions.answerOverhead(clear: true) + } label: { + Label(ScanCopy.overheadClear, systemImage: "sun.max") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityIdentifier("action.overheadClear") + Button { + actions.answerOverhead(clear: false) + } label: { + Label(ScanCopy.overheadCovered, systemImage: "house") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityIdentifier("action.overheadCovered") + } + } +} diff --git a/ios/HouseScan/UI/Components/PhotoCounter.swift b/ios/HouseScan/UI/Components/PhotoCounter.swift new file mode 100644 index 000000000..040f813b1 --- /dev/null +++ b/ios/HouseScan/UI/Components/PhotoCounter.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// Photo count with a small acknowledgment per capture: the icon flashes and the number changes. +/// The homeowner never presses a shutter, so this is how they learn the phone is taking photos. +/// +/// The number swaps without animation. A rolling digit passes through half-faded frames, and +/// the accessibility audit caught those as contrast failures ("22", "72" on the real replay). +struct PhotoCounter: View { + var count: Int + var lastCaptureID: Int? + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "camera.fill") + .font(.subheadline.weight(.bold)) + .phaseAnimator([false, true, false], trigger: lastCaptureID) { icon, lit in + icon + .foregroundStyle(lit ? Palette.covered : Palette.chalk) + .scaleEffect(lit && !reduceMotion ? 1.18 : 1) + } animation: { lit in + lit ? .easeOut(duration: 0.08) : .easeOut(duration: 0.35) + } + Text("\(count)") + .font(Typeface.caption.monospacedDigit()) + .foregroundStyle(Palette.chalk) + } + .padding(.horizontal, 12) + .frame(minWidth: Metrics.minTarget, minHeight: 36) + .background(ScrimShape.capsule) + .accessibilityElement(children: .ignore) + .accessibilityLabel(count == 1 ? "1 photo taken" : "\(count) photos taken") + .accessibilityIdentifier("photoCount") + } +} + +/// "Replay" or "Autopilot": tells a watcher that the camera or the taps are not live. A small +/// quiet pill rather than an amber one: amber means "look at this" on the camera screens, and a +/// test mode shouldn't compete with the instruction. Ink at 0.7 keeps Chalk text about 6.9:1 on +/// the light Canvas; at 0.55 it measured about 4.2:1, under the 4.5:1 small text needs. +/// The separate practice badge identifies the synthetic meter throughout the scan. +struct ModeBadge: View { + var isReplay: Bool + var isAutopilot: Bool + /// Set once for every screen by `ScanRootView`, so each badge says a practice scan is one. + @Environment(\.isPracticeScan) private var isPracticeScan + + var body: some View { + // Side by side, or stacked when the largest text sizes leave no room for both. + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { capsules } + VStack(alignment: .leading, spacing: 4) { capsules } + } + } + + @ViewBuilder + private var capsules: some View { + if isPracticeScan { + // Its own capsule, first: of everything on the screen, this is what says the meter + // and its number aren't real. + capsule("Practice meter") + .accessibilityIdentifier("practiceBadge") + } + if let text { + Text(text) + .font(.caption2.weight(.medium)) + .foregroundStyle(Palette.chalk) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Palette.ink.opacity(0.7), in: .capsule) + .accessibilityLabel(text) + .accessibilityIdentifier("modeBadge") + } + } + + private func capsule(_ text: String) -> some View { + Text(text) + .font(.caption2.weight(.bold)) + .textCase(.uppercase) + .tracking(0.6) + .foregroundStyle(Palette.ink) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Palette.caution, in: .capsule) + .accessibilityLabel(text) + } + + private var text: String? { + switch (isReplay, isAutopilot) { + case (true, true): "Replay · Autopilot" + case (true, false): "Replay" + case (false, true): "Autopilot" + case (false, false): nil + } + } +} diff --git a/ios/HouseScan/UI/Components/SavedMeterPhoto.swift b/ios/HouseScan/UI/Components/SavedMeterPhoto.swift new file mode 100644 index 000000000..9733ee81f --- /dev/null +++ b/ios/HouseScan/UI/Components/SavedMeterPhoto.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// The meter close-up taken at the start, shown while the phone finds its place again, so +/// "Point at the meter like this." has a picture to match (checklist T7). +struct SavedMeterPhoto: View { + let image: CGImage + + var body: some View { + Image(decorative: image, scale: 1) + .resizable() + .scaledToFill() + .frame(width: 168, height: 168) + .clipShape(.rect(cornerRadius: 24, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder(.white, lineWidth: 3) + } + .shadow(color: .black.opacity(0.35), radius: 12, y: 4) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityElement() + .accessibilityLabel("Your photo of the meter from the start of the scan") + .accessibilityIdentifier("relocalize.meterPhoto") + } +} diff --git a/ios/HouseScan/UI/Components/Scrim.swift b/ios/HouseScan/UI/Components/Scrim.swift new file mode 100644 index 000000000..3aeef5e17 --- /dev/null +++ b/ios/HouseScan/UI/Components/Scrim.swift @@ -0,0 +1,44 @@ +import SwiftUI + +/// The surface every piece of chrome over the camera sits on: solid Ink with a faint edge. +/// +/// Solid on purpose. Over a sunlit wall any translucency lets the photo wash out the chrome, and +/// XCUIApplication's accessibility audit flagged chalk text on camera screens even with 94% Ink +/// over the photo; with 100% it passes on every screen. Camera apps keep their controls on solid +/// bars for the same reason. +struct ScrimShape: View { + var shape: S + + var body: some View { + shape + .fill(Palette.ink) + .overlay(shape.strokeBorder(.white.opacity(0.1), lineWidth: 1)) + } +} + +extension ScrimShape where S == Capsule { + static var capsule: ScrimShape { ScrimShape(shape: Capsule()) } +} + +extension ScrimShape where S == RoundedRectangle { + static func rounded(_ radius: CGFloat = Metrics.cardRadius) -> ScrimShape { + ScrimShape(shape: RoundedRectangle(cornerRadius: radius, style: .continuous)) + } +} + +/// Soft dark gradients at the top and bottom of camera screens, under the chrome, so the +/// status bar and the edges of cards keep contrast without boxing the camera in. +struct CameraEdgeShade: View { + var body: some View { + VStack(spacing: 0) { + LinearGradient(colors: [.black.opacity(0.5), .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 180) + Spacer(minLength: 0) + LinearGradient(colors: [.clear, .black.opacity(0.55)], startPoint: .top, endPoint: .bottom) + .frame(height: 260) + } + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} diff --git a/ios/HouseScan/UI/Components/ShareScanButton.swift b/ios/HouseScan/UI/Components/ShareScanButton.swift new file mode 100644 index 000000000..e7ea3f0f0 --- /dev/null +++ b/ios/HouseScan/UI/Components/ShareScanButton.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// "Share scan": hands the scan bundle (`ScanViewState.shareableScan`) to the share sheet, so a +/// scan can get off the phone by AirDrop, Mail or Files. The keyframe photos leave the phone only +/// this way, so the line under the button says what goes. Always a quiet button: the screen's +/// one prominent action stays the way on. +struct ShareScanButton: View { + let url: URL + + var body: some View { + VStack(spacing: 8) { + ShareLink(item: url, subject: Text("House Scan")) { + Label(ScanCopy.shareScan, systemImage: "square.and.arrow.up") + } + .buttonStyle(.quiet) + .accessibilityIdentifier("action.shareScan") + Text(ScanCopy.shareScanContents) + .font(.footnote) + .foregroundStyle(Palette.muted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + } +} diff --git a/ios/HouseScan/UI/Components/TapRipple.swift b/ios/HouseScan/UI/Components/TapRipple.swift new file mode 100644 index 000000000..cbf65b40d --- /dev/null +++ b/ios/HouseScan/UI/Components/TapRipple.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// A ring that blooms outward and fades where the homeowner touched: immediate proof the tap +/// landed, independent of how long the engine takes to answer. +struct TapRipple: View { + struct Ripple: Identifiable, Equatable { + let id = UUID() + var point: CGPoint + } + + @Binding var ripples: [Ripple] + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack { + ForEach(ripples) { ripple in + RippleRing(reduceMotion: reduceMotion) + .position(ripple.point) + .task { + try? await Task.sleep(for: .milliseconds(650)) + ripples.removeAll { $0.id == ripple.id } + } + } + } + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +private struct RippleRing: View { + var reduceMotion: Bool + @State private var expanded = false + + var body: some View { + Circle() + .strokeBorder(.white, lineWidth: 4) + .frame(width: 70, height: 70) + .scaleEffect(reduceMotion ? 1 : (expanded ? 1.35 : 0.7)) + .opacity(expanded ? 0 : 1) + .onAppear { + withAnimation(.easeOut(duration: 0.55)) { expanded = true } + } + } +} diff --git a/ios/HouseScan/UI/Copy/Distance.swift b/ios/HouseScan/UI/Copy/Distance.swift new file mode 100644 index 000000000..079ba9929 --- /dev/null +++ b/ios/HouseScan/UI/Copy/Distance.swift @@ -0,0 +1,125 @@ +import Foundation + +/// Lengths in the words a homeowner uses: feet and inches, rounded to the nearest inch. +enum Distance { + static let metersPerInch: Float = 0.0254 + + /// "8 in", "3 ft", "3 ft 4 in". Negative input is treated as its magnitude. + static func feetAndInches(_ meters: Float) -> String { + let totalInches = Int((abs(meters) / metersPerInch).rounded()) + let feet = totalInches / 12 + let inches = totalInches % 12 + switch (feet, inches) { + case (0, let inches): return "\(inches) in" + case (let feet, 0): return "\(feet) ft" + default: return "\(feet) ft \(inches) in" + } + } + + /// Whole feet for rough amounts ("about 12 ft to go"), never below 1 ft. + /// How far is left to walk, in 5 ft steps above 5 ft. The walk card showed the nearest foot, + /// so its text changed about every stride; a coarse count reads calmer and changes rarely. + static func remainingWalk(_ meters: Float) -> String { + let feet = abs(meters) / (metersPerInch * 12) + guard feet > 5 else { return roughFeet(meters) } + return "\(Int((feet / 5).rounded()) * 5) ft" + } + + static func roughFeet(_ meters: Float) -> String { + let feet = max(1, Int((abs(meters) / (metersPerInch * 12)).rounded())) + return "\(feet) ft" + } + + /// Whole feet rounded up, for a distance the homeowner must reach at least: "out to about + /// 7 ft" must not ask for less than the request. + static func feetAtLeast(_ meters: Float) -> String { + "\(max(1, Int((abs(meters) / (metersPerInch * 12) - 0.001).rounded(.up)))) ft" + } + + /// "5 ft either side of your meter", or "from 3 ft left to 5 ft right of your meter" when the + /// stretch is lopsided, for the tilt-up step. + static func stretchAroundMeter(_ span: ClosedRange) -> String { + let left = roughFeet(span.lowerBound), right = roughFeet(span.upperBound) + if span.lowerBound >= 0 { return "from your meter to \(right) right of it" } + if span.upperBound <= 0 { return "from \(left) left of your meter to your meter" } + if left == right { return "\(left) either side of your meter" } + return "from \(left) left to \(right) right of your meter" + } + + /// VoiceOver reads "ft" as letters; spell the units out. + static func spoken(_ meters: Float) -> String { + let totalInches = Int((abs(meters) / metersPerInch).rounded()) + let feet = totalInches / 12 + let inches = totalInches % 12 + let feetWord = feet == 1 ? "foot" : "feet" + let inchWord = inches == 1 ? "inch" : "inches" + switch (feet, inches) { + case (0, let inches): return "\(inches) \(inchWord)" + case (let feet, 0): return "\(feet) \(feetWord)" + default: return "\(feet) \(feetWord) \(inches) \(inchWord)" + } + } + + /// "3 ft 4 in right of your meter" for a position s along the wall. + static func fromMeter(_ s: Float) -> String { + if abs(s) < metersPerInch * 3 { return "at your meter" } + return "\(feetAndInches(s)) \(s < 0 ? "left" : "right") of your meter" + } + + /// "from 4 ft to 19 ft right of your meter", or "from 3 ft left to 2 ft right of your meter": + /// a stretch by its two ends in whole feet, nearer end first, for a request that can run + /// along much of the wall (issue #75). Ends on the same foot read "about 5 ft right of your + /// meter". + /// + /// The whole stretch is named, as asked: a server request is met only over its whole span + /// (`GapPlanner.progress`), and the one kind raised past a marked end, ground past a limit + /// end, asks for the ground there too (#47). Clipped to the ends, a request for 4 to 33 ft + /// with a limit end at 20 ft read "from 4 ft to 20 ft right", and showing that much left the + /// bar near half way. For the same reason the ends round outward, the lower one down and the + /// upper one up: "from 2 ft to 7 ft" for 2.4...7.4 ft left the last 0.4 ft unasked. An end + /// within 0.001 ft of a whole foot counts as on it, so float noise (0.6096 m is 2.0000001 ft) + /// doesn't add a foot. 2.4...7.4 ft reads "from 2 ft to 8 ft right", -7.4...-2.4 ft "from 2 ft + /// to 8 ft left", and -2.6...1.2 ft "from 3 ft left to 2 ft right". + static func range(_ span: ClosedRange) -> String { + let foot = metersPerInch * 12 + let slack: Float = 0.001 + let lowFeet = Int((span.lowerBound / foot + slack).rounded(.down)) + let highFeet = max(lowFeet, Int((span.upperBound / foot - slack).rounded(.up))) + switch (lowFeet, highFeet) { + case (0, 0): + return "around your meter" + case let (a, b) where a == b: + return "about \(abs(a)) ft \(a < 0 ? "left" : "right") of your meter" + case (0, let b) where b > 0: + return "from your meter to \(b) ft right of it" + case (let a, 0) where a < 0: + return "from your meter to \(-a) ft left of it" + case let (a, b) where a > 0: + return "from \(a) ft to \(b) ft right of your meter" + case let (a, b) where b < 0: + return "from \(-b) ft to \(-a) ft left of your meter" + case let (a, b): + return "from \(-a) ft left to \(b) ft right of your meter" + } + } + + /// "about 5 ft right of your meter" for the middle of a span: a place to walk to, not a + /// measurement to check. + static func aroundFromMeter(_ span: ClosedRange) -> String { + let center = (span.lowerBound + span.upperBound) / 2 + if span.contains(0) || abs(center) < 0.3 { return "around your meter" } + return "about \(roughFeet(center)) \(center < 0 ? "left" : "right") of your meter" + } +} + +extension String { + /// "about 5 ft right of your meter" as the start of a sentence. + var capitalizedFirst: String { prefix(1).uppercased() + dropFirst() } + + /// A title in the middle of a sentence: "Distance from AC units" becomes "distance from AC + /// units". A leading acronym ("AC units nearby") keeps its capitals. + var lowercasedFirst: String { + guard let first, let second = dropFirst().first, second.isLowercase else { return self } + return first.lowercased() + dropFirst() + } +} diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift new file mode 100644 index 000000000..c0b349af2 --- /dev/null +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -0,0 +1,718 @@ +import Foundation +import HouseScanKit + +/// One instruction: a short line the homeowner acts on, and an optional second line that says +/// how. Every camera screen shows exactly one of these at a time. +struct Instruction: Hashable { + var title: String + var detail: String? +} + +/// All user-facing words for engine values. The engine sends meanings (contract `GuidanceStep`, +/// `Coaching`, ...); this file is the only place that turns them into copy, so the vocabulary +/// stays consistent: "photos", "the wall", "your meter", feet and inches. +enum ScanCopy { + // MARK: Guidance + + /// Finding the meter on a practice scan (`ScanViewState.isPracticeScan`): any wall will do. + static let practiceFindMeter = Instruction( + title: "Tap a spot on a wall", + detail: "A sample meter goes there, so you can practice the scan without a real one." + ) + static let practiceMarkMeter = "Put the sample meter here" + + /// `hint` changes the words of an aim step only (`aim(_:ground:hint:)`). + static func guidance(_ step: GuidanceStep, hint: GuidanceHint? = nil) -> Instruction { + switch step { + case .findMeter: + Instruction( + title: "Find your electric meter", + detail: "A gray box with a round glass dial or a small screen, usually on an outside wall." + ) + case .aimAtWallForMeter: + Instruction(title: "Hold on, finding your wall", detail: "Move your phone slowly side to side, then aim at your meter again.") + case .holdOnMeter: + Instruction(title: "Hold your meter in the circle", detail: "Your phone takes the photo by itself.") + case .walk(let side, let remaining): + Instruction( + title: "Walk slowly to your \(side.rawValue)", + detail: remaining.map { "Keep the wall and the ground in view. About \(Distance.remainingWalk($0)) to go." } + ?? "Keep the wall and the ground in view." + ) + case .markEnd(let side): + Instruction( + title: "Is this the \(side.rawValue) end of the wall?", + detail: "Aim where the wall stops or turns a corner, and tap Wall ends here." + ) + case .aimAtGround(let s): + // A cell counts once seen from two places at least 0.25 m apart (`coveringBaseline`), + // so tilting down without moving never clears it. + aim( + Instruction(title: "Tilt down to show the ground", detail: "The strip along the wall, \(Distance.fromMeter(s)). Take a small step sideways as you look."), + ground: true, hint: hint + ) + case .aimAtWall(let s): + // "Around at your meter" read wrong once the walk starts at the meter. + aim( + Instruction(title: "Tilt up to show more wall", detail: abs(s) < Distance.metersPerInch * 3 ? "At your meter." : "Around \(Distance.fromMeter(s))."), + ground: false, hint: hint + ) + case .stepBack: + Instruction(title: "Take a step back", detail: "Your phone needs to see more of the wall at once.") + case .tiltUp(let span): + Instruction( + title: "Tilt up to show above the area by your meter", + detail: "Aim at the wall \(Distance.stretchAroundMeter(span)) and whatever is above it." + ) + case .markNextWall(_, nil): + Instruction(title: "Walk round the corner, then aim at the next wall", detail: "Put the circle on it and tap Mark next wall.") + case .markNextWall(_, let refusal?): + nextWallRefusal(refusal) + case .walkComplete: + Instruction(title: "That's the whole wall", detail: "Tap Done when you're ready.") + case .seeBehind(let s): + Instruction( + title: "Something is in front of the wall here", + // A place to look, not a measurement: "About 5 ft", not "4 ft 11 in". + detail: "\(Distance.aroundFromMeter(s...s).capitalizedFirst). Look at it from the side or step around it." + ) + case .gap: + Instruction(title: "One more view", detail: nil) + } + } + + /// An aim step's card with what the hint adds. The title follows the target: on build 4.1 the + /// chevron pointed up while the card said "Tilt down" (#81). Seen once from here, the step to + /// the side is the whole instruction (#77). Too close, "step back" joins the same card + /// instead of replacing it with another (#77). + private static func aim(_ base: Instruction, ground: Bool, hint: GuidanceHint?) -> Instruction { + guard let hint else { return base } + var copy = base + if hint.needsSecondPosition { + copy.title = "Now take one step to the side and look again" + } else { + switch hint.aim { + case .above? where ground: + copy.title = "Tilt up a little" + case .below? where !ground: + copy.title = "Tilt down a little" + case .onScreen? where ground: + copy.title = "Put the ring on the strip" + default: + break + } + } + if hint.stepBack { + copy.detail = [copy.detail, "Step back a little."].compactMap { $0 }.joined(separator: " ") + } + return copy + } + + // MARK: Coaching + + static func coaching(_ coaching: Coaching) -> Instruction { + switch coaching { + case .initializing: + Instruction(title: "Move your phone slowly", detail: "It's getting its bearings.") + case .slowDown: + Instruction(title: "Slow down", detail: "Walk a little slower so the photos stay sharp.") + case .needsTexture: + Instruction(title: "Aim at a corner or somewhere with more texture", detail: "A plain wall or the sky gives your phone nothing to follow.") + case .tooDark: + Instruction(title: "It's dark here", detail: "Some photos won't count. Try your phone's flashlight, or come back in daylight.") + case .tooDarkToMeasure: + Instruction(title: "It's too dark to measure here", detail: "Try in daylight.") + case .holdSteady: + Instruction(title: "Hold steady", detail: nil) + case .turnSlowly: + Instruction(title: "Turn more slowly", detail: "Photos taken while turning come out blurred.") + case .relocalizing: + Instruction(title: "Point at the meter like this.", detail: "Your phone lost its place for a moment.") + case .trackingLost: + Instruction(title: "Your phone lost its place", detail: "Aim back at your meter and move slowly.") + case .pastWallEnd: + Instruction(title: "You're past the end of the wall", detail: "Photos here aren't kept. Walk back toward your meter.") + } + } + + /// Short symbol for a coaching pill. + static func coachingSymbol(_ coaching: Coaching) -> String { + switch coaching { + case .initializing: "iphone.gen3.radiowaves.left.and.right" + case .slowDown: "tortoise.fill" + case .needsTexture: "square.grid.3x3.middle.filled" + case .tooDark, .tooDarkToMeasure: "moon.fill" + case .holdSteady: "hand.raised.fill" + case .turnSlowly: "arrow.clockwise" + case .relocalizing, .trackingLost: "location.slash.fill" + case .pastWallEnd: "arrow.uturn.backward" + } + } + + /// One short line for coaching that rides along with the task instead of replacing it (the + /// walk's capture-gate coaching): the task's title and second line stay, and this goes under + /// them. Only the coaching's title, so the task's own words stay the bigger part of the card. + static func coachingNote(_ coaching: Coaching) -> String { + let title = ScanCopy.coaching(coaching).title + return title.hasSuffix(".") ? title : "\(title)." + } + + /// Tracking problems and standing past the end replace the task on the card: nothing the task + /// asks for counts until they clear. The capture gate's coaching and too little texture ride + /// along with it instead (`withCoaching`). No `default`, so a new case has to pick a side. + static func coachingReplacesTask(_ coaching: Coaching) -> Bool { + switch coaching { + case .initializing, .relocalizing, .trackingLost, .pastWallEnd: true + case .slowDown, .needsTexture, .tooDark, .tooDarkToMeasure, .holdSteady, .turnSlowly: false + } + } + + /// The task with ride-along coaching under it: the task's title and second line both stay (on + /// an aim step the second line is the only thing that says where to aim), and the coaching adds + /// its own short line (`coachingNote`). Replacing the whole card hid the task each time the + /// coaching came up (#80), and the dark coaching can stay up for a whole night walk. + static func withCoaching(_ task: Instruction, _ coaching: Coaching?) -> Instruction { + guard let coaching else { return task } + let detail = [task.detail, coachingNote(coaching)].compactMap { $0 }.joined(separator: "\n") + return Instruction(title: task.title, detail: detail) + } + + // MARK: Aim ring + + /// The line under the card while the walk's first aim ring is on screen; the ring fills as + /// its stretch is captured (#81). + /// A stretch counts only once it is seen from two places a step apart + /// (`CoverageConfig.coveringBaseline`), so holding still never fills the ring: the line asks + /// for a step, in the tilt-down card's own words ("Take a small step sideways as you look."), + /// so the two lines on screen together ask for the same thing. If the card's wording + /// changes, change this with it. + static let aimRingLegend = "Keep the ring in view and take a small step sideways. It fills as your phone captures this spot." + + // MARK: Wall ends + + /// Under the wall map when ending the wall at the dashed line would cut off part of the walk, + /// only while the walk asks to walk that way or mark the end (`EndPreview.leavesOutWalked`). + /// `seen`: the stretch is cells the camera saw, not the walk (`EndPreview.leavesOutSeen`). + static func endLeavesOut(_ meters: Float, seen: Bool = false) -> String { + "Ending the wall here leaves out \(Distance.roughFeet(meters)) \(leftOutVerb(seen))" + } + + /// "you walked", or "you saw" when the stretch left out was seen but not walked. + static func leftOutVerb(_ seen: Bool) -> String { seen ? "you saw" : "you walked" } + + /// Over the walk's own prompt after "Done with this wall" was refused and the ends cleared + /// (`ScanViewState.wallTooShort`). + static let wallTooShort = "The ends were too close. Walk along the wall first." + + // MARK: Close-up + + static func closeUpProblem(_ problem: CloseUpProblem) -> String { + switch problem { + case .blurry: "Hold still" + case .tooDark: "Too dark to read. Try your phone's flashlight." + case .tooBright: "Too much glare. Tilt the phone a little." + case .meterNotCentered: "Center the meter in the circle" + case .tooFar: "Move closer to the meter" + case .tracking: "Move slowly" + case .numberTooSmall: "Move closer so the number looks bigger" + case .noNumber: "Couldn't read the number. Hold still for another photo." + } + } + + /// The instruction while the meter number is read and confirmed; nil once it's settled or + /// skipped, when the close-up's own instruction applies. + static func meterNumber(_ state: MeterNumberState?) -> Instruction? { + switch state { + case .reading: + Instruction(title: "Reading your meter number", detail: "One moment.") + case .choose: + Instruction(title: "Which number is on your meter?", detail: "Check it against the meter, then tap it.") + case .confirmed(let number): + Instruction(title: "Meter number saved", detail: number) + case .skipped, nil: + nil + } + } + + static let barcodeMatch = "Matches the barcode" + /// The maker read on the close-up, above the number candidates. + static func meterBrand(_ brand: String) -> String { "\(brand) meter" } + static func notMeterBrand(_ brand: String) -> String { "Not \(brand)" } + static let noneOfThese = "None of these" + + // MARK: Features + + static func name(_ kind: FeatureKind) -> String { + switch kind { + case .gasMeter: "Gas meter" + case .door: "Door" + case .window: "Window" + case .acUnit: "AC unit" + case .driveway: "Driveway" + case .fence: "Fence" + } + } + + /// The name mid-sentence: "Tap the gas meter", "Mark AC unit". Lowercasing `name` read + /// "ac unit" (B-16). + static func noun(_ kind: FeatureKind) -> String { + switch kind { + case .acUnit: "AC unit" + default: name(kind).lowercased() + } + } + + static func symbol(_ kind: FeatureKind) -> String { + switch kind { + case .gasMeter: "flame.fill" + case .door: "door.left.hand.closed" + case .window: "window.vertical.closed" + case .acUnit: "fan.fill" + case .driveway: "car.fill" + case .fence: "square.split.2x1" + } + } + + /// Under an AC unit on the review: one tap gives no size, so the scene sends it as a square + /// of `SceneExport.acAssumedSide` (#72), and the homeowner is told the size is assumed. + static let acAssumedSize: String = { + let side = Distance.feetAndInches(SceneExport.acAssumedSide) + return "Assumed about \(side) \u{00D7} \(side)" + }() + /// `acAssumedSize` for VoiceOver, which reads "ft" as letters. + static let acAssumedSizeSpoken: String = { + let side = Distance.spoken(SceneExport.acAssumedSide) + return "Assumed about \(side) by \(side)" + }() + + /// The window question's way out: sent as unknown, like no answer. + static let windowNotSure = "Not sure" + + /// Over "Looks complete" after its first tap found a question unanswered (#65). + static let reviewUnanswered = "A question above has no answer yet. Answer it, or tap Looks complete again to send." + + /// Under a reviewed mark that lies wholly past a marked end (`ScanViewState.featuresPastEnds`). + static let featurePastEnd = "Past the end of your scan" + + static func markingPrompt(_ marking: MarkingState) -> Instruction { + let item = noun(marking.kind) + switch (marking.kind, marking.step) { + case (.door, 0), (.window, 0): + return Instruction(title: "Tap the \(item)'s bottom-left corner", detail: "Put the circle on it and tap Mark, or tap it on screen.") + case (.door, _), (.window, _): + return Instruction(title: "Now tap its top-right corner", detail: nil) + case (.driveway, 0): + return Instruction(title: "Tap one end of the driveway's edge", detail: "Use the edge closest to the wall.") + case (.driveway, _): + return Instruction(title: "Now tap the other end of that edge", detail: nil) + case (.fence, 0): + return Instruction(title: "Tap the bottom of the fence at one end", detail: "Where it meets the ground.") + case (.fence, _): + return Instruction(title: "Now tap the bottom at the other end", detail: nil) + case (.gasMeter, _), (.acUnit, _): + return Instruction(title: "Tap the \(item)", detail: "Put the circle on it and tap Mark, or tap it on screen.") + } + } + + static func refusal(_ refusal: MarkRefusal) -> String { + switch refusal { + case .noSurface: "Nothing to pin there. Aim at the wall or the ground and try again." + case .wrongSide: "That spot is behind the wall. Tap something on this side." + case .tooFarFromWall: "That's too far from the wall to matter. Tap something closer." + case .trackingNotReady: "One moment, your phone is still finding its place." + } + } + + /// Asked after the next wall is marked, before the walk follows the corner + /// (`ScanViewState.nextWallConfirm`, #70): a surface behind the end post passed the checks + /// on build 4.1. The ring is on the corner that wall makes with this one, so a wrong surface + /// shows as a corner in the wrong place. + static func nextWallConfirm(_ confirm: NextWallConfirm) -> Instruction { + Instruction( + title: "Is this the next wall?", + detail: "The ring shows where it meets this wall, about \(Distance.roughFeet(confirm.fromEnd)) from where you ended it. Tap Yes only if that's the corner." + ) + } + + /// A refused mark of the next wall: what went wrong, then what to do. + static func nextWallRefusal(_ refusal: NextWallRefusal) -> Instruction { + switch refusal { + case .noSurface: Instruction(title: "No wall under the circle", detail: "Step closer and aim at the next wall.") + case .trackingNotReady: Instruction(title: "One moment, your phone is still finding its place", detail: "Then aim at the next wall.") + case .sameWall: Instruction(title: "That looks like the same wall", detail: "Aim at the wall round the corner.") + case .notAtCorner: Instruction(title: "That wall doesn't meet this one at the corner", detail: "Aim at the wall right round the corner.") + } + } + + // MARK: Ground + + /// Asked on the feature review. The camera can't tell mulch from soil, so without this answer + /// the server's check of the ground under the battery always ends unsure. + static let groundQuestion = Instruction( + title: "What's on the ground along this wall?", + detail: "The battery can only stand on some kinds of ground." + ) + /// Names the homeowner would use: "Grass", not the schema's "lawn". + static func groundName(_ type: GroundType) -> String { + switch type { + case .lawn: "Grass" + case .mulch: "Mulch" + case .gravel: "Gravel" + case .concrete: "Concrete" + case .drive: "Driveway" + case .deck: "Deck" + } + } + static let groundNotSure = "Not sure" + static func groundAnswer(_ answer: GroundAnswer) -> String { + switch answer { + case .type(let type): groundName(type) + case .notSure: groundNotSure + } + } + /// The label over the answer once the question has folded into a row. + static let groundAnsweredLabel = "Ground along the wall" + static let groundChange = "Change" + + // MARK: Gap + + /// The card for a gap request. A server request can run along much of the wall, so its + /// stretch is named by its two ends, all of it (issue #75, `Distance.range`); the phone's own + /// requests are short and named by their middle. + static func gap(_ gap: GapRequest) -> Instruction { + let place = Distance.aroundFromMeter(gap.span) + let stretch = Distance.range(gap.span) + switch gap.reason { + case .groundNearCandidate: + return Instruction(title: "Show the ground \(place)", detail: "This might be a spot for the battery, so the ground there needs a clear look from two places.") + case .wallAboveCandidate: + return Instruction(title: "Show the wall \(place)", detail: "Tilt up so the wall above this spot is in view.") + case .server(let detail): + return Instruction(title: gap.band == .ground ? "Show the ground \(stretch)" : "Show the wall \(stretch)", detail: detail) + case .groundOut(let out): + return Instruction( + title: "Show the ground out to about \(Distance.feetAtLeast(out)) from the wall", + detail: "\(stretch.capitalizedFirst). Step back and tilt down until that much ground is in view." + ) + case .walkOut(let out): + // The space ends before the line (#164): no walk can meet it, so the card says so and + // points at the reply that ends the request, rather than a line behind a wall. + if let ends = gap.spaceEnds { + return Instruction( + title: "The space ends about \(Distance.feetAndInches(ends.at)) out from the wall", + detail: "\(stretch.capitalizedFirst). A wall or fence stands there, and this check needs a walk " + + "\(Distance.feetAndInches(ends.needed)) out. If that's as far as the space goes, tap I can't get there." + ) + } + // What counts where the phone is: the clearance asked for plus the wall's position + // error, which on build 7.1 took the walk from "about 5 ft" to 6 to 7 ft (#164). + let reading = gap.walkOut.map { + " You're \(Distance.feetAndInches($0.out)) out; this needs \(Distance.feetAndInches($0.needed)) here." + } ?? "" + return Instruction( + title: "Walk along this stretch about \(Distance.feetAtLeast(out)) out from the wall", + detail: "\(stretch.capitalizedFirst).\(reading) Follow the dotted line. Walking there shows nothing stands in front of the wall." + ) + case .overhead: + return Instruction( + title: "Tilt up here", + detail: "\(place.capitalizedFirst). Show the wall above this spot, up to the roof or the sky." + ) + } + } + + /// The request a gap card asks for, without the walk-out's reading from where the phone is + /// now: what the card's reply answers (`InstructionCard.Reply.task`) and what the guidance log + /// keeps. The reading changes as the phone moves, and each change would lock the reply again. + static func gapTask(_ gap: GapRequest) -> Instruction { + var steady = gap + steady.walkOut = nil + return ScanCopy.gap(steady) + } + + /// The reply on the see-behind step: the homeowner can't get a view past the obstruction. + static let cannotSeeBehind = "Can't see past it" + + // MARK: Card replies + + /// The walk card's reply (`ScanActions.cannotAccessArea`) and its VoiceOver hint, worded for + /// what it does on `step`; nil on a step that offers none. On an aim or tilt step the + /// homeowner is already at the spot and it's the view that can't be had, so "Can't get + /// there" read as the wrong answer and testers kept tilting (#63). It stays on the steps + /// that ask to go somewhere. The wall's end (`markEnd`) has "Wall ends here" instead. + static func reply(for step: GuidanceStep) -> (title: String, hint: String)? { + switch step { + case .aimAtGround, .aimAtWall: + (title: "Skip this spot", hint: "An installer will look at it instead.") + case .tiltUp: + (title: "Skip this", hint: "Skips the view above this part of the wall. An installer will look at it instead.") + case .walk: + (title: "Can't get there", hint: "Ends the wall at the dashed line on the map. An installer will look at what's past it.") + case .markNextWall: + (title: "Can't get there", hint: "Skips this part of the wall. An installer will look at it instead.") + case .seeBehind: + (title: cannotSeeBehind, hint: "Skips the part behind it. An installer will look at it instead.") + case .findMeter, .aimAtWallForMeter, .holdOnMeter, .markEnd, .stepBack, .walkComplete, .gap: + nil + } + } + + // MARK: Follow-up view + + /// The check came back asking for views the camera can take now; the scan goes straight + /// back to the camera for them before the result. Said on the upload screen as it hands + /// over and on the camera card that follows, so the two read as one step. + static func followUp(remaining: Int) -> String { + remaining <= 1 ? "One more view to finish" : "\(remaining) more views to finish" + } + + /// A sample result checked nothing, so it must not say the wall was checked. + static func followUpDetail(sample: Bool) -> String { + sample + ? "The example result asks for another view, so the camera opens again. Nothing leaves this phone." + : "The check needs a view the camera can take now. Your result comes right after." + } + + // MARK: Upload + + /// The question after the tilt-up view. The camera can't tell open sky from an eave. + static let overheadQuestion = Instruction( + title: "What's above that part of the wall?", + detail: "The battery needs clear space above it." + ) + static let overheadClear = "Open sky or nothing overhead" + static let overheadCovered = "A roof edge, porch or stairs" + + /// The question after "Wall ends here". A corner means the wall goes on out of sight, which + /// the result must not treat as the end of usable wall. `leavesOut` is how much of the walk + /// the end just made leaves out (`ScanViewState.endQuestionLeavesOut`), said here since the + /// strip says it only while the walk asks to walk that way (#66). + static func endQuestion(_ side: WallSide, leavesOut: Float? = nil, seen: Bool = false) -> Instruction { + let why = "This tells the installer whether the wall keeps going." + let detail = leavesOut.map { "This leaves out \(Distance.roughFeet($0)) \(leftOutVerb(seen)). \(why)" } ?? why + return Instruction(title: "What's at the \(side.rawValue) end?", detail: detail) + } + + /// Asked when "Can't get there" comes again soon after it ended a side + /// (`ScanViewState.endScanQuestion`, #82): the homeowner may be trying to stop. + static let endScanQuestion = Instruction( + title: "End the scan here?", + detail: "We'll check the part of the wall you've walked." + ) + /// The same question when too little of the wall was walked to check it + /// (`ScanViewState.endScanTooShort`): the way out is a new scan. + static let endScanTooShort = Instruction( + title: "End the scan here?", + detail: "You haven't walked enough of the wall to check it yet. Start over, or keep walking along the wall." + ) + static let endScanYes = "Yes, end here" + static let endScanStartOver = "Start over" + static let keepWalking = "Keep walking" + + /// With no server connected nothing is sent, and the words must not say it is. + /// `followUps` is how many views the finished check still wants from the camera. + static func upload(_ upload: UploadState, sample: Bool, followUps: Int = 0) -> Instruction { + if upload == .done, followUps > 0 { + return Instruction(title: followUp(remaining: followUps), detail: followUpDetail(sample: sample)) + } + if sample { + switch upload { + case .idle, .packaging, .uploading, .analyzing: + return Instruction(title: "Making a sample result", detail: "No server is connected, so nothing leaves this phone. The result you'll see is an example, not a check of your wall.") + case .failed, .rejected, .done: + break + } + } + return self.upload(upload) + } + + static func upload(_ upload: UploadState) -> Instruction { + switch upload { + // The upload carries the wall's measurements; the photos stay on the phone. + case .idle, .packaging: + Instruction(title: "Getting your measurements ready", detail: nil) + case .uploading: + Instruction(title: "Sending your measurements", detail: "Keep the app open until this finishes.") + case .analyzing: + Instruction(title: "Checking your wall", detail: "Measuring clearances around your meter.") + case .failed(let message, let offline): + offline + ? Instruction(title: "You're offline", detail: "Your scan is saved on this phone. Try again when you have signal.") + : Instruction(title: "That didn't go through", detail: message) + // The engine's message is already in the homeowner's words and says why. + case .rejected(let message): + Instruction(title: "We couldn't check this scan", detail: message) + case .done: + Instruction(title: "Done", detail: nil) + } + } + + // MARK: Result + + /// The answer in the homeowner's words (`ResultPresentation.answer`). + static func headline(_ answer: ResultReading.Answer) -> String { + switch answer { + case .fits: "A battery fits here" + case .oneMoreLook: "One more look" + case .installer: "An installer will confirm" + case .notHere: "Not on this wall" + } + } + + /// The one line under the headline: where, and how much cable, for a pass. + static func placement(_ result: ResultPresentation) -> String? { + guard let spot = result.spot else { return nil } + let center = (spot.span.lowerBound + spot.span.upperBound) / 2 + var line = Distance.fromMeter(center).capitalizedFirst + if let cable = result.cableLength { + line += ", \(Distance.roughFeet(cable)) of cable" + } + return line + } + + /// Why the closest spot doesn't work, for a result without a spot: "The closest spot, 4 ft + /// left of your meter, fails this check: distance from gas equipment. Measured 2 ft 4 in. The + /// rule is at least 3 ft." The check's title follows a colon because the server's titles name + /// what a passing spot has ("No box or vent above the battery"), so a sentence that used one + /// as the failure would say the opposite for some of them. + static func nearest(_ result: ResultPresentation, spoken: Bool = false) -> String? { + guard let spot = result.nearestSpot, let id = result.nearestFailingCheck, + let row = result.checks.first(where: { $0.id == id }) else { return nil } + let center = (spot.span.lowerBound + spot.span.upperBound) / 2 + let place = spoken && abs(center) >= Distance.metersPerInch * 3 + ? "\(Distance.spoken(center)) \(center < 0 ? "left" : "right") of your meter" + : Distance.fromMeter(center) + var line = "The closest spot, \(place), fails this check: \(row.title.lowercasedFirst)." + if let measurement = measurement(row, spoken: spoken) { + line += " \(measurement)" + } + return line + } + + /// The sentence under a failed or unsure line on the result card: the measurement against the + /// rule, or without a measurement, the server's reason (fail) or who settles it (unsure). + static func cardLine(_ row: CheckRow, spoken: Bool = false) -> String? { + switch row.outcome { + case .pass: nil + case .fail: measurement(row, spoken: spoken) ?? row.reason + case .unsure: measurement(row, spoken: spoken) ?? unsureNote(row) + } + } + + /// "Settles: distance from the gas meter, clear space in front": the checks a requested view + /// would settle, by their titles. A check a person has to judge (a borderline measurement, an + /// unknown attribute) stays off the list: another view doesn't settle it. Nil when none is left. + static func settles(_ item: MissingEvidence, checks: [CheckRow]) -> String? { + let titles = item.checkIDs.compactMap { id in + checks.first { $0.id == id && !$0.needsPerson }?.title.lowercasedFirst + } + guard !titles.isEmpty else { return nil } + return "Settles: \(titles.joined(separator: ", "))" + } + + static let seeOnWall = "See it on your wall" + /// For a spot an installer still has to confirm against the meter's working space. + static let seeClosest = "See the closest spot" + static let showMe = "Show me" + static let scanAnotherWall = "Scan another wall" + static let details = "Details" + + /// A result for a wall neither side of which was walked (`ResultPresentation.wallNotMeasured`, + /// #76): the spot would stand on the meter tap alone, so none is shown. + static let wallNotMeasured = "We couldn't measure your wall" + static let wallNotMeasuredDetail = "The scan stopped before you walked along the wall on either side of your meter, so we can't tell where a battery would fit. Scan again and walk a few steps each way." + static let scanAgain = "Scan again" + + static let installerConfirms = "An installer confirms this on site." + static let rulesNotFinal = "The placement rules aren't final yet, so an installer reviews every result for now." + // The server's result covers where the battery goes, not the panel itself. + static let panelReview = "Your electrical panel still needs an electrician's review. This scan only covers where the battery can go." + + /// Which rules answered, for a reviewer: "Rules 2f52ec35". + static func rulesHash(_ hash: String) -> String { + "Rules \(hash)" + } + + static func unseenSide(_ side: WallSide) -> String { + "A closer spot may exist on the \(side.rawValue) of your meter. The scan didn't reach that side." + } + + /// The note on an unexplored end nearer the meter than the spot (issue #83): where the scan + /// stopped, so the homeowner knows which end is meant. Without a spot, any spot past it. + static func unseenEnd(_ end: UnseenEnd, hasSpot: Bool) -> String { + "The scan stopped \(Distance.fromMeter(end.s)). \(hasSpot ? "A closer spot" : "A spot") may be past there." + } + + static let shareScan = "Share scan" + static let shareScanContents = "Your photos and measurements, for the House Scan team" + + static func unsureNote(_ row: CheckRow) -> String { + row.needsPerson ? "An installer will check this" : "One more photo would settle this" + } + + /// "Measured 3 ft 2 in. The rule is at least 3 ft, and the measurement can be off by about 4 in." + /// The limit says whether it is a minimum or a maximum: without it, the 20 ft cable limit read + /// like a minimum under "Measured 3 ft". + /// `spoken` spells out feet and inches for VoiceOver, which reads "ft" and "in" as letters. + static func measurement(_ row: CheckRow, spoken: Bool = false) -> String? { + guard let measured = row.measured else { return nil } + let length = spoken ? Distance.spoken : Distance.feetAndInches + var parts = [measuredLine(measured, length: length)] + if let threshold = row.threshold { + let limit = ruleLimit(threshold, row.comparison, length: length) + if let plusMinus = row.plusMinus, plusMinus > 0 { + parts.append("The rule is \(limit), and the measurement can be off by about \(length(plusMinus)).") + } else { + parts.append("The rule is \(limit).") + } + } + return parts.joined(separator: " ") + } + + /// "Measured 3 ft 2 in.", or "Overlaps by 1 ft 3 in." below zero. A clearance the server + /// measures to an area (the meter's working space, a box on the wall above) goes negative + /// once the battery is inside it, and the bare magnitude read as clearance (#40). Less than + /// half an inch of overlap stays "Measured 0 in.", not "Overlaps by 0 in.". + static func measuredLine(_ measured: Float, length: (Float) -> String = Distance.feetAndInches) -> String { + if measured <= -Distance.metersPerInch / 2 { + return "Overlaps by \(length(measured))." + } + return "Measured \(length(measured))." + } + + /// "at least 3 ft", "at most 20 ft", or the bare distance when the server didn't say which. + /// A minimum of 0 reads "no overlap": "at least 0 in" says the same thing less plainly. + static func ruleLimit(_ threshold: Float, _ comparison: RuleComparison?, length: (Float) -> String = Distance.feetAndInches) -> String { + let distance = length(threshold) + guard let comparison else { return distance } + switch comparison { + case .atLeast: return threshold < Distance.metersPerInch / 2 ? "no overlap" : "at least \(distance)" + case .atMost: return "at most \(distance)" + } + } + + static func outcomeWord(_ outcome: CheckOutcome) -> String { + switch outcome { + case .pass: "Looks good" + case .unsure: "Not sure yet" + case .fail: "Doesn't work" + } + } + + // MARK: Failure + + static func failure(_ failure: ScanFailure) -> Instruction { + switch failure { + case .cameraDenied: + Instruction(title: "House Scan needs your camera", detail: "It uses the camera to measure the wall around your meter. Turn on Camera for House Scan in Settings.") + case .arUnsupported: + Instruction(title: "This phone can't measure walls", detail: "House Scan needs an iPhone that supports motion tracking with the camera. Try another iPhone from the last few years.") + // The engine's messages for these two are system error text (ARKit's, or the replay + // loader's), not the homeowner's words, so they aren't shown. + case .sessionFailed: + Instruction(title: "The camera stopped", detail: "Something interrupted the camera partway through. Start over to try again.") + case .replayUnreadable: + Instruction(title: "This recording can't be opened", detail: "Some of its files are missing or damaged, so it can't be played back.") + } + } +} diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift new file mode 100644 index 000000000..26bf3de72 --- /dev/null +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -0,0 +1,40 @@ +import Foundation + +/// The spot check's words (`SpotConfirmScreen`, and the guidance log's record of the question). +extension ScanCopy { + /// One question about the wall and the ground, with the reason for asking. + static let spotQuestion = Instruction( + title: "Is anything standing in the marked area?", + detail: "Check the wall and the ground inside the blue outline. Photos can miss a low bush, a bin or a step." + ) + static let spotClear = "It's clear" + static let spotSomethingThere = "Something's there" + static let spotClearHint = "Shows your result." + static let spotSomethingThereHint = "Leaves this area out of your scan and checks your wall again. An installer will look at it." + + static func spotAnswered(_ answer: SpotCheckAnswer) -> Instruction { + switch answer { + case .clear: Instruction(title: "Thanks, it's clear", detail: "Showing your result.") + case .somethingThere: Instruction(title: "Thanks, we'll leave that area out", detail: "Checking your wall again. An installer will look at what's there.") + } + } + + /// Said on the result when the homeowner said something stands where its spot is. + static let spotRefused = "You said something stands where this spot is. Your scan leaves that area out, and an installer will check it." + + /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs. `spoken` spells the + /// units out for VoiceOver. + static func spotArea(_ span: ClosedRange, spoken: Bool = false) -> String { + let length = { (meters: Float) in spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) } + let low = span.lowerBound, high = span.upperBound + if low >= 0 { return "From \(length(low)) to \(length(high)) right of your meter" } + if high <= 0 { return "From \(length(high)) to \(length(low)) left of your meter" } + return "From \(length(low)) left to \(length(high)) right of your meter" + } + + /// What VoiceOver reads for the photo. + static func spotPhotoDescription(_ check: SpotCheck) -> String { + let place = spotArea(check.area, spoken: true) + return "A blue outline marks where the battery would stand and the space around it. \(place)." + } +} diff --git a/ios/HouseScan/UI/Practice/DeveloperOptions.swift b/ios/HouseScan/UI/Practice/DeveloperOptions.swift new file mode 100644 index 000000000..6eb0dacb0 --- /dev/null +++ b/ios/HouseScan/UI/Practice/DeveloperOptions.swift @@ -0,0 +1,84 @@ +import HouseScanKit +import SwiftUI + +/// The way into the developer options, on the first screen. Only development and TestFlight +/// installs show it (`DeveloperSettings.isAvailable`); an App Store install shows nothing here. +/// While practice meter is on it says so, so nobody starts a scan without knowing. +struct DeveloperOptionsButton: View { + @AppStorage(DeveloperSettings.practiceMeterKey) private var practiceMeter = false + @State private var showing = false + private let settings = DeveloperSettings.shared + + var body: some View { + if settings.isAvailable { + Button { + showing = true + } label: { + HStack(spacing: 6) { + Image(systemName: "wrench.and.screwdriver.fill") + if practiceMeter { + // Wraps at the largest text sizes rather than dropping out. + Text("Practice meter on") + .fixedSize(horizontal: false, vertical: true) + } + } + .font(Typeface.caption) + .foregroundStyle(practiceMeter ? Palette.ink : Palette.muted) + .padding(.horizontal, practiceMeter ? 10 : 0) + .padding(.vertical, 5) + .background { + if practiceMeter { Capsule().fill(Palette.caution) } + } + .frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget) + .contentShape(.rect) + } + .buttonStyle(PressableStyle()) + .accessibilityLabel(practiceMeter ? "Developer options. Practice meter is on." : "Developer options") + .accessibilityIdentifier("action.developerOptions") + .sheet(isPresented: $showing) { + DeveloperOptionsSheet(environment: settings.environment) + } + } + } +} + +/// One switch for now: practice meter. +private struct DeveloperOptionsSheet: View { + var environment: PracticeMeter.InstallEnvironment + @AppStorage(DeveloperSettings.practiceMeterKey) private var practiceMeter = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section { + Toggle("Practice meter", isOn: $practiceMeter) + .accessibilityIdentifier("developer.practiceMeter") + } footer: { + Text("For trying the scan where there's no electric meter. Tap any spot on a wall and a sample meter is drawn there. A photo of it stands in for the meter close-up, and the rest of the scan runs for real. Each screen and the scan's stamp say it's practice. Starts with the next scan.") + } + Section { + LabeledContent("Build", value: Self.name(environment)) + } + } + .navigationTitle("Developer options") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .accessibilityIdentifier("action.closeDeveloperOptions") + } + } + } + .presentationDetents([.medium, .large]) + } + + private static func name(_ environment: PracticeMeter.InstallEnvironment) -> String { + switch environment { + case .development: "Development" + case .testFlight: "TestFlight" + case .appStore: "App Store" + case .unknown: "Unknown" + } + } +} diff --git a/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift b/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift new file mode 100644 index 000000000..465f882a1 --- /dev/null +++ b/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift @@ -0,0 +1,58 @@ +import HouseScanKit +import simd +import SwiftUI + +/// On a practice scan, the sample meter drawn on the wall at the tapped spot, in perspective, on +/// every camera screen after the tap. It follows `state.wall.meter`, which the engine moves with +/// the meter's anchor as ARKit corrects it, so the drawing stays where the anchor is, as the fog +/// and marks do. Painted just above the camera image, so the fog and marks draw over it as they +/// would over a real meter. +struct PracticeMeterOverlay: View { + let state: ScanViewState + + var body: some View { + GeometryReader { proxy in + // Hidden while tracking isn't normal, like the other overlays: from a pose the phone + // doesn't trust, it would sit in the wrong place. + if state.tracking == .normal, let face = SampleMeterArt.face, let transform = transform(in: proxy.size) { + Image(decorative: face, scale: 1) + .resizable() + .frame(width: CGFloat(face.width), height: CGFloat(face.height)) + .projectionEffect(transform) + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .topLeading) + } + } + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + /// Takes the face image's rectangle onto the sample's four corners on screen; nil when the + /// wall isn't set yet or any corner is behind the camera. + private func transform(in size: CGSize) -> ProjectionTransform? { + guard let wall = state.wall, let projection = state.projection, let face = SampleMeterArt.face else { return nil } + var quad: [SIMD2] = [] + for corner in PracticeMeter.plateCorners(meter: wall.meter, along: wall.along, outward: wall.outward) { + guard let point = projection.viewPoint(for: corner, in: size) else { return nil } + quad.append(SIMD2(Double(point.x), Double(point.y))) + } + guard let m = QuadHomography.mapping(width: Double(face.width), height: Double(face.height), to: quad) else { return nil } + // `m[column][row]`; ProjectionTransform names its entries row first. + var transform = ProjectionTransform() + transform.m11 = m[0][0] + transform.m12 = m[1][0] + transform.m13 = m[2][0] + transform.m21 = m[0][1] + transform.m22 = m[1][1] + transform.m23 = m[2][1] + transform.m31 = m[0][2] + transform.m32 = m[1][2] + transform.m33 = m[2][2] + return transform + } +} + +extension EnvironmentValues { + /// True on every screen of a practice scan (`ScanViewState.isPracticeScan`). + @Entry var isPracticeScan = false +} diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift new file mode 100644 index 000000000..ccd671705 --- /dev/null +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -0,0 +1,1269 @@ +import HouseScanKit +import SwiftUI + +/// A fake engine for the UI demo: implements `ScanActions` by scripting `ScanViewState` over +/// the made-up wall in `DemoScene`. It mimics the real engine's phases and timing closely enough +/// to exercise every screen; it measures nothing. +@MainActor +final class DemoEngine: ScanActions { + let state = ScanViewState() + + private let freeze: Bool + private let noFeed: Bool + private let offline: Bool + private let passResult: Bool + /// `-uiDemoOverlap`: the sample's spot overlaps the meter's working space (#40). + private let overlapResult: Bool + /// `-uiDemoWallNotMeasured`: neither side of the meter was walked, so the result shows no + /// spot (#76). + private let wallNotMeasured: Bool + /// `-uiDemoResultFile ` (debug builds only): a server answer in a JSON file, read + /// through the engine's own mapping, in place of the hand-made samples. + private let resultFile: String? + private let rejectUpload: Bool + /// Which request the gap screen shows (`-uiDemoGap`); the phone's ground request by default. + private let gapKind: String? + /// The tilt-up step was answered or skipped. + private var tiltUpSettled = false + /// Walk-script ticks spent on the tilt-up step, standing in for the phone being tilted up. + private var tiltUpTicks = 0 + private var script: Task? + private var nextCaptureID = 1 + /// How far the walk has seen to each side of the meter, meters. + private var reachedLeft: Float = 0.3 + private var reachedRight: Float = 0.3 + /// Stretches the homeowner skipped, kept apart so a later skip doesn't undo an earlier one. + private var skippedSpans: [ClosedRange] = [] + /// `-uiDemoEndPreview`: the homeowner walked back 1.5 m, so ending the wall now leaves part + /// of the walk out. + private var walkedBack: Float? + /// Things standing in front of the wall, as depth would find them: a looked-at cell in the + /// span is hidden, or skipped once the homeowner said they can't see past it. + private var obstructions: [(span: ClosedRange, skipped: Bool)] = [] + /// Walk-script ticks spent on the see-behind step, standing in for stepping round the bush. + private var seeBehindTicks = 0 + /// The check's first answer asked for a view and the scan went back to the camera for it, + /// like the real engine does; the next answer goes to the result. + private var followedUp = false + private var followUpSkipped = false + /// The spot check was answered; the next answer goes straight to the result, as the engine's + /// does for a spot an earlier answer settles. + private var spotChecked = false + private var failedUploads = 0 + private var rejectedUploads = 0 + + private static let cellWidth: Float = 0.1524 + /// Where the demo wall ends on each side, meters of s. Following a corner moves the end on. + private var demoLeftEnd: Float = -2.9 + private var demoRightEnd: Float = 4.3 + /// How far the made-up wall goes on past a corner the walk follows, meters. + private static let pastCorner: Float = 1.5 + + init(arguments: [String]) { + func value(_ flag: String) -> String? { + guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else { return nil } + return arguments[index + 1] + } + freeze = arguments.contains("-uiDemoFreeze") + noFeed = arguments.contains("-uiDemoNoFeed") + offline = arguments.contains("-uiDemoOffline") + passResult = arguments.contains("-uiDemoPass") + overlapResult = arguments.contains("-uiDemoOverlap") + wallNotMeasured = arguments.contains("-uiDemoWallNotMeasured") + #if DEBUG + resultFile = value("-uiDemoResultFile") + #else + resultFile = nil + #endif + rejectUpload = arguments.contains("-uiDemoRejected") + gapKind = value("-uiDemoGap") + state.feed = DemoScene.image.map(CameraFeed.still) ?? .none + state.isReplay = true + state.tracking = .normal + + if let failure = value("-uiDemoFailure") { + state.failure = switch failure { + case "cameraDenied": .cameraDenied + case "sessionFailed": .sessionFailed("The operation couldn't be completed. (demo error 102.)") + case "replayUnreadable": .replayUnreadable("demo: frames.jsonl not found") + default: .arUnsupported + } + state.phase = .unsupported + return + } + // The ground question and its answer live on the feature review, so either argument opens it. + let groundAnswer = value("-uiDemoGroundAnswer").flatMap(Self.groundAnswer) + let opensReview = arguments.contains("-uiDemoGroundQuestion") || groundAnswer != nil + let phase: ScanPhase = opensReview ? .markFeatures : value("-uiDemoPhase").flatMap(ScanPhase.init(rawValue:)) ?? .onboarding + jump(to: phase) + state.groundAnswer = groundAnswer + if let raw = value("-uiDemoMarking"), let kind = FeatureKind(rawValue: raw) { + state.marking = MarkingState(kind: kind, step: 0, refusal: arguments.contains("-uiDemoRefusal") ? .noSurface : nil) + } + if let raw = value("-uiDemoCoaching") { + state.coaching = Self.coaching(raw) + // The real engine reports these two only while the phone has lost its place, which + // also hides the overlays drawn from its position. + switch state.coaching { + case .relocalizing: state.tracking = .limited(.relocalizing) + case .trackingLost: state.tracking = .notAvailable + default: break + } + } + if arguments.contains("-uiDemoEndPreview") { + walkedBack = 1.5 + refreshGuidance() + } + if arguments.contains("-uiDemoEndQuestion") { + if walkedBack != nil { + // With `-uiDemoEndPreview`: "Wall ends here" pressed short of the farthest view, + // so the question says what the end leaves out of the walk (#66). + endWallHere() + } else { + state.endQuestion = .left + } + } + if arguments.contains("-uiDemoEndScanQuestion") { + // "Can't get there" again soon after it ended the right side (#82). + state.endScanQuestion = true + // `-uiDemoEndScanTooShort`: too little walked to finish, so it offers "Start over". + state.endScanTooShort = arguments.contains("-uiDemoEndScanTooShort") + } + if arguments.contains("-uiDemoNextWall") { + state.wall?.rightEnd = demoRightEnd + reachedRight = demoRightEnd + refreshCoverage() + state.guidance = .markNextWall(side: .right, refusal: arguments.contains("-uiDemoRefusal") ? .notAtCorner : nil) + state.target = nil + state.path = [] + // With `-uiDemoNextWallConfirm`: the next wall marked, "Is this the next wall?" up (#70). + if arguments.contains("-uiDemoNextWallConfirm") { markNextWall(at: nil, viewSize: .zero) } + } + if arguments.contains("-uiDemoOverheadQuestion"), state.phase == .gapRequest { + // An overhead request whose tilted-up view just came in. + script?.cancel() + state.overheadQuestion = true + } else if arguments.contains("-uiDemoTiltUp") || arguments.contains("-uiDemoOverheadQuestion") { + enterTiltUp() + state.overheadQuestion = arguments.contains("-uiDemoOverheadQuestion") + } + if arguments.contains("-uiDemoSample") { + state.usesSampleResult = true + } + if arguments.contains("-uiDemoDepth") { + state.depthAvailable = true + } + if arguments.contains("-uiDemoHidden") || arguments.contains("-uiDemoSeeBehind") { + // A bush right of the meter and a bin left of it, on a phone with depth. + state.depthAvailable = true + reachedRight = max(reachedRight, 2.4) + obstructions = [(1.2...1.8, false), (-0.55 ... -0.25, false)] + refreshCoverage() + refreshGuidance() + if arguments.contains("-uiDemoSeeBehind") { + state.guidance = .seeBehind(s: 1.5) + state.target = DemoScene.wall.world(s: 1.5, height: 0.6) + state.path = DemoScene.path(toward: 2.4, out: 1.8) + } + } + if (arguments.contains("-uiDemoAim") || arguments.contains("-uiDemoAimOffScreen")), state.phase == .wallWalk { + enterAim(offScreen: arguments.contains("-uiDemoAimOffScreen")) + } + if arguments.contains("-uiDemoCorner") { + // The walk followed an outside corner right of the meter, between the battery spot and + // the window, and went on 1.6 m along the next wall: that wall runs away from the + // homeowner and faces right. + let corner: Float = 1.8 + state.wall?.cornerSegments = [WallGeometry.Segment( + span: corner...Float.infinity, along: SIMD3(0, 0, -1), outward: SIMD3(1, 0, 0), + anchor: SIMD3(corner, 0, 0), anchorS: corner)] + state.wall?.rightEnd = corner + 1.6 + } + if let raw = value("-uiDemoSpotAnswered"), state.phase == .spotConfirm { + state.spotCheck?.answer = raw == "somethingThere" ? .somethingThere : .clear + } + if arguments.contains("-uiDemoFollowUp") { + enterFollowUp(at: state.phase) + } + if arguments.contains("-uiDemoNoFeed") { + state.feed = .none + } + if arguments.contains("-uiDemoCloseUpFailed") { + state.closeUpFailedAttempts = 2 + state.closeUp = .aiming(hold: 0.2, problem: .tooBright) + } + if arguments.contains("-uiDemoMeterChoose") { + state.closeUp = .captured(DemoScene.meterThumbnail) + state.meterBrand = Self.demoBrand + state.meterNumber = .choose(Self.demoCandidates) + } + // Frozen, the upload script never runs, so show where it would end. + if freeze, state.phase == .uploading { + if rejectUpload || offline { + state.shareableScan = Self.demoScan + } + if rejectUpload { + state.upload = .rejected(message: Self.rejection) + } else if offline { + state.upload = .failed(message: "No internet connection.", offline: true) + } + } + } + + // MARK: Phase set-up + + /// Puts the state where the real engine would be on arriving at `phase`. + private func jump(to phase: ScanPhase) { + switch phase { + case .onboarding: + state.phase = .onboarding + case .findMeter: + enterFindMeter() + case .meterCloseUp: + enterCloseUp() + case .wallWalk: + placeMeter() + reachedLeft = 0.6 + reachedRight = 0.9 + enterWalk() + case .markFeatures: + placeMeter() + finishedWalkState() + state.phase = .markFeatures + case .gapRequest: + placeMeter() + finishedWalkState() + enterGap() + case .uploading: + placeMeter() + finishedWalkState() + enterUpload() + case .spotConfirm: + placeMeter() + finishedWalkState() + enterSpotCheck() + case .result: + placeMeter() + finishedWalkState() + showResult() + case .resultAR: + placeMeter() + finishedWalkState() + showResult() + state.phase = .resultAR + case .unsupported: + state.failure = .arUnsupported + state.phase = .unsupported + } + } + + private func enterFindMeter() { + state.phase = .findMeter + state.guidance = .findMeter + state.feed = noFeed ? .none : DemoScene.meterImage.map(CameraFeed.still) ?? .none + state.projection = DemoScene.meterProjection + state.wall = nil + state.coverage = .empty + } + + private func placeMeter() { + state.feed = noFeed ? .none : DemoScene.image.map(CameraFeed.still) ?? .none + state.projection = DemoScene.projection + state.wall = DemoScene.wall + state.captureCount = max(state.captureCount, 1) + } + + private func enterCloseUp() { + placeMeter() + state.feed = noFeed ? .none : DemoScene.closeUpImage.map(CameraFeed.still) ?? .none + state.projection = DemoScene.closeUpProjection + state.phase = .meterCloseUp + state.guidance = .holdOnMeter + state.closeUp = .aiming(hold: 0, problem: nil) + state.meterNumber = nil + run { engine in await engine.closeUpScript() } + } + + private func enterWalk() { + state.phase = .wallWalk + state.closeUp = .captured(DemoScene.meterThumbnail) + state.captureCount = max(state.captureCount, 6) + refreshCoverage() + refreshGuidance() + run { engine in await engine.walkScript() } + } + + private func finishedWalkState() { + reachedLeft = -demoLeftEnd + reachedRight = demoRightEnd + state.wall?.leftEnd = demoLeftEnd + state.wall?.rightEnd = demoRightEnd + state.captureCount = max(state.captureCount, 42) + if state.features.isEmpty { + state.features = [Self.demoFeature(.gasMeter), Self.demoFeature(.window)] + } + refreshCoverage() + state.guidance = .walkComplete + state.path = [] + state.target = nil + } + + /// Both ends are marked and answered: the walk asks to tilt up over the stretch by the meter. + private func enterTiltUp() { + placeMeter() + finishedWalkState() + state.phase = .wallWalk + state.closeUp = .captured(DemoScene.meterThumbnail) + refreshGuidance() + run { engine in await engine.walkScript() } + } + + /// `-uiDemoAim`: the ground lags the wall right of the meter, so the walk asks to tilt down + /// there, and the ring is half full: 2 of the 5 ground cells within 0.3 m of it are covered, + /// and 80 % completes it. `-uiDemoAimOffScreen`: the same ask for ground left of the view, so + /// the edge arrow stands in for the ring. + private func enterAim(offScreen: Bool) { + let s: Float = offScreen ? -2.4 : 0.4 + state.guidance = .aimAtGround(s: s) + state.target = DemoScene.wall.world(s: s, height: 0, out: state.coverage.groundBandDepth / 2) + state.path = DemoScene.path(toward: s) + state.endPreview = nil + } + + /// A gap request: the phone's own by default, or `serverItem` from the check's answer. + private func enterGap(serverItem: MissingEvidence? = nil) { + // Leave a hole in the ground right of the likely spot, as the phone's planner would find. + state.phase = .gapRequest + state.guidance = .gap + let span: ClosedRange = 1.3...2.1 + setGround(span, to: .seen) + state.gap = GapRequest(id: 1, origin: .phone, reason: .groundNearCandidate, band: .ground, span: span, progress: 0, isSatisfied: false) + state.target = DemoScene.wall.world(s: 1.7, height: 0, out: 0.5) + state.path = DemoScene.path(toward: 1.7) + if let serverItem { + // The demo's check asks for one view at most. + state.followUps = 1 + state.gap?.origin = .server + state.gap?.reason = .server(detail: serverItem.text) + run { engine in await engine.gapScript(span: span) } + return + } + // The server's requests, with the numbers its public rules would give (feet in the + // README, meters here): ground out to D + r + e = 5.1 ft, a walk past D + r = 4.83 ft. + switch gapKind { + case "groundOut": + state.gap?.origin = .server + state.gap?.reason = .groundOut(out: 1.56) + state.target = DemoScene.wall.world(s: 1.7, height: 0, out: 1.56) + case "walkOut": + let out: Float = 1.47 + // As the engine draws it: from the homeowner to the stretch's far end, the requested + // distance plus the position error there (0.09 m + 0.16 m per meter from the meter) + // plus 0.3 m out. + let standOut = out + 0.09 + 0.16 * span.upperBound + 0.3 + state.gap?.origin = .server + state.gap?.reason = .walkOut(out: out) + state.target = DemoScene.wall.world(s: (span.lowerBound + span.upperBound) / 2, height: 1.2) + state.path = [span.lowerBound - 0.8, span.upperBound].map { + DemoScene.wall.world(s: $0, height: 0, out: standOut) + } + case "overhead": + state.gap?.origin = .server + state.gap?.reason = .overhead + state.gap?.band = .wall + state.target = DemoScene.wall.world(s: 1.7, height: 3.0) + state.path = [] + default: + break + } + if gapKind == "overhead" { + run { engine in await engine.overheadGapScript() } + } else { + run { engine in await engine.gapScript(span: span) } + } + } + + private func enterUpload() { + state.phase = .uploading + state.gap = nil + state.path = [] + state.target = nil + state.upload = .packaging + state.followUps = 0 + run { engine in await engine.uploadScript() } + } + + /// The spot check of the answer's spot, before the result. The made-up zones don't hold the + /// spot, so the area is the footprint alone, as the engine would draw it. + private func enterSpotCheck() { + let result = sample + state.shareableScan = Self.demoScan + state.upload = .done + state.result = result + guard let spot = result.spot else { return showResult() } + let out = spot.offsetFromWall...(spot.offsetFromWall + spot.depth) + state.spotCheck = SpotCheck( + id: 1, spot: spot.span, spotOut: out, spotHeight: spot.height, area: spot.span, areaDepth: out.upperBound, + photo: DemoScene.image.map { SpotCheck.Photo(image: $0, projection: DemoScene.projection) }, + answer: nil, isSample: result.isSample) + state.phase = .spotConfirm + } + + private func showResult() { + state.shareableScan = Self.demoScan + state.upload = .done + state.result = sample + state.phase = .result + } + + // MARK: Scripts + + /// Runs a timed script unless frozen; a new script replaces the old one. + private func run(_ body: @escaping @MainActor (DemoEngine) async -> Void) { + script?.cancel() + guard !freeze else { return } + script = Task { [weak self] in + guard let self else { return } + await body(self) + } + } + + private func pause(_ seconds: Double) async -> Bool { + try? await Task.sleep(for: .seconds(seconds)) + return !Task.isCancelled + } + + private func closeUpScript() async { + guard await pause(0.6) else { return } + for step in 1...6 { + state.closeUp = .aiming(hold: Double(step) * 0.09, problem: nil) + guard await pause(0.12) else { return } + } + state.closeUp = .aiming(hold: 0.54, problem: .blurry) + guard await pause(1.4) else { return } + for step in 6...11 { + state.closeUp = .aiming(hold: Double(step) * 0.09, problem: nil) + guard await pause(0.12) else { return } + } + capture(.closeUp, thumbnail: DemoScene.meterThumbnail) + state.closeUp = .captured(DemoScene.meterThumbnail) + state.meterNumber = .reading + guard await pause(1.2) else { return } + // Waits here for the homeowner's pick (`chooseMeterNumber`). + state.meterBrand = Self.demoBrand + state.meterNumber = .choose(Self.demoCandidates) + } + + private func walkScript() async { + while !Task.isCancelled { + guard await pause(0.45) else { return } + guard state.marking == nil else { continue } + if case .tiltUp = state.guidance, !state.overheadQuestion { + // About two seconds of tilting up, then the kept view raises the question. + tiltUpTicks += 1 + if tiltUpTicks >= 4 { + capture(.walk) + state.overheadQuestion = true + } + } + if case .seeBehind(let s) = state.guidance { + // A few steps to the side, then depth sees the wall behind the bush. + seeBehindTicks += 1 + if seeBehindTicks >= 5 { + seeBehindTicks = 0 + obstructions.removeAll { $0.span.contains(s) } + capture(.walk) + refreshCoverage() + refreshGuidance() + } + } + if case .walk(let side, _) = state.guidance { + if side == .right { + reachedRight = min(reachedRight + 0.3, demoRightEnd) + } else { + reachedLeft = min(reachedLeft + 0.3, -demoLeftEnd) + } + capture(.walk) + refreshCoverage() + refreshGuidance() + } + if case .aimAtGround(let s) = state.guidance { + // A step to the side at a time: one more ground cell by the ring is covered each + // tick, from its middle out. Like the real engine, the walk goes on in the same + // update that fills the ring, so the demo shows the hold a phone shows (#81). + capture(.walk) + coverGroundCell(nearest: s) + if (state.aimProgress ?? 0) >= 1 { + refreshGuidance() + } + } + } + } + + private func gapScript(span: ClosedRange) async { + guard await pause(1.2) else { return } + let cells = Int(((span.upperBound - span.lowerBound) / Self.cellWidth).rounded(.up)) + for step in 1...cells { + let covered = span.lowerBound...(span.lowerBound + Float(step) * Self.cellWidth) + setGround(covered, to: .covered) + state.gap?.progress = Double(step) / Double(cells) + capture(.gap) + guard await pause(0.45) else { return } + } + state.gap?.isSatisfied = true + guard await pause(1.6) else { return } + enterUpload() + } + + /// About two seconds of tilting up, then the view covers the request and the question comes up. + private func overheadGapScript() async { + guard await pause(2.4) else { return } + capture(.gap) + state.overheadQuestion = true + } + + private func uploadScript() async { + state.upload = .packaging + guard await pause(1.0) else { return } + state.shareableScan = Self.demoScan + for step in 0...10 { + state.upload = .uploading(fraction: Double(step) / 10) + guard await pause(0.22) else { return } + if offline, failedUploads == 0, step == 4 { + failedUploads += 1 + state.upload = .failed(message: "No internet connection.", offline: true) + return + } + } + state.upload = .analyzing + guard await pause(1.8) else { return } + // Refused once; after the review the same scan goes through. + if rejectUpload, rejectedUploads == 0 { + rejectedUploads += 1 + state.upload = .rejected(message: Self.rejection) + return + } + // Like the real engine: an answer that lists a view the camera can take goes back to + // the camera for it, after the upload screen has said so; the next answer is the result. + if !followedUp, let item = sample.missing.first(where: \.capturable) { + state.shareableScan = Self.demoScan + state.result = sample + state.followUps = 1 + state.upload = .done + followedUp = true + guard await pause(1.6) else { return } + enterGap(serverItem: item) + return + } + // Like the real engine's `resultHold`: every step ticked before the result (#31). + state.upload = .done + guard await pause(0.8) else { return } + if spotChecked { showResult() } else { enterSpotCheck() } + } + + /// The check's answer, before or after its follow-up view. + private var sample: ResultPresentation { + if wallNotMeasured { return Self.reviewSample.withWallNotMeasured() } + if passResult { return Self.passSample } + if overlapResult { return Self.overlapSample } + if let fileResult { return fileResult } + guard followedUp else { return Self.reviewSample } + return followUpSkipped ? Self.reviewSample.withFollowUpSkipped : Self.reviewSample.withFollowUpTaken + } + + /// The answer in `-uiDemoResultFile`, mapped as `ScanEngine` maps a server's. A file that + /// doesn't decode stops the demo with the decoding error rather than showing a sample. + private var fileResult: ResultPresentation? { + guard let resultFile else { return nil } + let result: PlacementResult + do { + result = try PlacementResult.decode(Data(contentsOf: URL(fileURLWithPath: resultFile))) + } catch { + fatalError("-uiDemoResultFile \(resultFile): \(error)") + } + let planner = GapPlanner() + let wall = state.wall + return ScanEngine.presentation(of: result, isSample: true, wall: nil) { item in + planner.plan(for: item, leftEnd: wall?.leftEnd, rightEnd: wall?.rightEnd) != nil + } + } + + /// `-uiDemoFollowUp`: the check has answered and asked for a view. On the upload screen, + /// the moment before the camera opens; on the gap screen, the view itself. + private func enterFollowUp(at phase: ScanPhase) { + guard phase == .uploading || phase == .gapRequest, let item = Self.reviewSample.missing.first(where: \.capturable) else { return } + script?.cancel() + state.shareableScan = Self.demoScan + state.result = Self.reviewSample + state.followUps = 1 + state.upload = .done + followedUp = true + if phase == .gapRequest { + enterGap(serverItem: item) + } else { + run { engine in + guard await engine.pause(1.6) else { return } + engine.enterGap(serverItem: item) + } + } + } + + // MARK: Coverage + + private func refreshCoverage() { + let wallRange = min(DemoScene.wallRange.lowerBound, demoLeftEnd - 0.3)...max(DemoScene.wallRange.upperBound, demoRightEnd + 0.1) + let count = Int(((wallRange.upperBound - wallRange.lowerBound) / Self.cellWidth).rounded(.up)) + var wallCells: [CellState] = [] + var groundCells: [CellState] = [] + for index in 0..