diff --git a/.cruft.json b/.cruft.json index 6fb3fda..053d3c8 100644 --- a/.cruft.json +++ b/.cruft.json @@ -1,6 +1,6 @@ { "template": "https://github.com/ecmwf-projects/cookiecutter-conda-package", - "commit": "a9c290fa0e810e9dee6c61991afac6f0c969c3b1", + "commit": "5ad0c955478c9b0fe8772545ef46291f5f314f75", "checkout": null, "context": { "cookiecutter": { @@ -13,7 +13,7 @@ "integration_tests": "True", "pypi": true, "_template": "https://github.com/ecmwf-projects/cookiecutter-conda-package", - "_commit": "a9c290fa0e810e9dee6c61991afac6f0c969c3b1" + "_commit": "5ad0c955478c9b0fe8772545ef46291f5f314f75" } }, "directory": null diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml index abacbb2..6251794 100644 --- a/.github/workflows/on-push.yml +++ b/.github/workflows/on-push.yml @@ -22,20 +22,20 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: 3.x + python-version: '3.12' - uses: pre-commit/action@v3.0.1 combine-environments: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: 3.x + python-version: '3.12' - name: Install conda-merge run: | python -m pip install conda-merge @@ -44,7 +44,7 @@ jobs: for SUFFIX in ci integration; do conda-merge ci/environment-$SUFFIX.yml environment.yml > ci/combined-environment-$SUFFIX.yml || exit done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: combined-environments path: ci/combined-environment-*.yml @@ -55,11 +55,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.11'] + python-version: ['3.12', '3.13'] steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 with: name: combined-environments path: ci @@ -87,8 +87,8 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 with: name: combined-environments path: ci @@ -103,7 +103,7 @@ jobs: cache-environment-key: environment-${{ steps.date.outputs.date }} cache-downloads-key: downloads-${{ steps.date.outputs.date }} create-args: >- - python=3.11 + python=3.12 - name: Install package run: | python -m pip install --no-deps -e . @@ -116,8 +116,8 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 with: name: combined-environments path: ci @@ -132,7 +132,7 @@ jobs: cache-environment-key: environment-${{ steps.date.outputs.date }} cache-downloads-key: downloads-${{ steps.date.outputs.date }} create-args: >- - python=3.11 + python=3.12 - name: Install package run: | python -m pip install --no-deps -e . @@ -149,12 +149,12 @@ jobs: strategy: matrix: include: - - python-version: '3.11' + - python-version: '3.12' extra: -integration steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 with: name: combined-environments path: ci @@ -188,10 +188,10 @@ jobs: (needs.integration-tests.result == 'success' || needs.integration-tests.result == 'skipped') steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: '3.11' + python-version: '3.12' - name: Install package run: | python -m pip install --upgrade pip @@ -206,7 +206,7 @@ jobs: python -m twine check --strict * || exit python -c "import openptv_python" || exit cd .. - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: distribution path: dist @@ -226,10 +226,10 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publish steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: distribution path: dist - - uses: pypa/gh-action-pypi-publish@v1.12.4 + - uses: pypa/gh-action-pypi-publish@v1.13.0 with: verbose: true diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 6d12253..2391e33 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -31,13 +31,23 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install documentation dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[docs]" + - name: Build documentation + run: | + make docs-build - name: Setup Pages uses: actions/configure-pages@v4 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - # Upload entire repository - path: '.' + path: docs/_build/html - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 39c22cd..5194d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -471,3 +471,5 @@ $RECYCLE.BIN/ # End of https://www.toptal.com/developers/gitignore/api/python,jupyternotebooks,vim,visualstudiocode,pycharm,emacs,linux,macos,windows .cruft.json +/.tmp +/tmp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2922494..a7a6829 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -12,28 +12,29 @@ repos: - id: debug-statements - id: mixed-line-ending - repo: https://github.com/keewis/blackdoc - rev: v0.3.9 + rev: v0.4.6 hooks: - id: blackdoc - additional_dependencies: [black==23.11.0] + additional_dependencies: [black==25.9.0] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.11 + rev: v0.15.2 hooks: - id: ruff args: [--fix, --show-fixes] - id: ruff-format - repo: https://github.com/executablebooks/mdformat - rev: 0.7.22 + rev: 1.0.0 hooks: - id: mdformat - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.14.0 + rev: v2.16.0 hooks: - id: pretty-format-yaml args: [--autofix, --preserve-quotes] - id: pretty-format-toml args: [--autofix] + exclude: ^uv\.lock$ - repo: https://github.com/gitleaks/gitleaks - rev: v8.26.0 + rev: v8.30.0 hooks: - id: gitleaks diff --git a/Dockerfile b/Dockerfile index 372b11c..2fbf6f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /src/openptv-python COPY environment.yml /src/openptv-python/ -RUN conda install -c conda-forge gcc python=3.11 \ +RUN conda install -c conda-forge gcc python=3.12 \ && conda env update -n base -f environment.yml COPY . /src/openptv-python diff --git a/Makefile b/Makefile index 895ed00..2a48625 100644 --- a/Makefile +++ b/Makefile @@ -2,17 +2,18 @@ PROJECT := openptv_python CONDA := conda CONDAFLAGS := COV_REPORT := html +PYTHON ?= python default: qa unit-tests type-check qa: - pre-commit run --all-files + $(PYTHON) -m pre_commit run --all-files unit-tests: - python -m pytest -vv --cov=. --cov-report=$(COV_REPORT) --doctest-glob="*.md" --doctest-glob="*.rst" + $(PYTHON) -m pytest -vv --cov=. --cov-report=$(COV_REPORT) --doctest-glob="*.md" --doctest-glob="*.rst" type-check: - python -m mypy . + $(PYTHON) -m mypy . conda-env-update: $(CONDA) install -y -c conda-forge conda-merge diff --git a/README.md b/README.md index eb512ae..6466639 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,23 @@ Python version of the OpenPTV library - this is *a work in progress* +## What This Repo Provides + +`openptv-python` keeps the Python API as the main interface and combines three +execution modes behind that API: + +- Pure Python: the reference implementation and the easiest path for reading, + debugging, and extending the code. +- Python + Numba: several hot kernels are JIT-compiled automatically on first + use, so the Python implementation still benefits from acceleration. +- Native `optv` bindings: selected operations reuse the native OpenPTV + implementation when the `optv` package is available. + +At the moment, automatic native delegation is implemented for image +preprocessing and full-frame target recognition. The rest of the library keeps +the same Python API and remains usable even when those native paths are not in +use. + ## How this is started This work started from the https://github.com/OpenPTV/openptv/tree/pure_python branch. It's a long-standing idea to convert all the C code to Python and now it's possible with ChatGPT to save @@ -10,26 +27,175 @@ a lot of typing time. This repo is created using a *cookiecutter* and the rest of the readme describes the way to work with this structure -### Quick Start +## Supported Python Versions + +The project currently supports Python `>=3.12,<3.14`. + +## Installation + +### Default user install + +#### Recommended: uv + +Create the environment and install the runtime dependencies: + +```bash +uv venv +source .venv/bin/activate +uv sync +``` + +This gives you the standard runtime stack: NumPy, SciPy, Numba, and YAML +support. + +If you also want native `optv` delegation when bindings are available for your +platform and Python version, install the optional extra: + +```bash +uv sync --extra native +``` + +#### Alternative: pip + +```bash +conda create -n openptv-python -c conda-forge python=3.12 +conda activate openptv-python +pip install . +``` + +Optional native bindings: + +```bash +pip install ".[native]" +``` + +### Developer install + +#### Recommended: uv + +```bash +uv venv +source .venv/bin/activate +uv sync --extra dev +``` + +#### Alternative: conda + pip + +```bash +conda create -n openptv-python -c conda-forge python=3.12 +conda activate openptv-python +pip install -e ".[dev]" +``` + +### What gets installed + +- The default install contains the runtime dependencies only. +- The optional `native` extra adds `optv` bindings for automatic native + delegation on supported platforms. +- The optional `dev` extra adds test, docs, typing, and pre-commit tooling for + contributors. +- The public API stays the same regardless of which backend extras are + installed. + +## Backend Behavior + +### Pure Python backend + +This is the base implementation for the whole library. It is always the source +of truth for the Python API and remains the fallback behavior for code paths +that are not delegated to `optv`. + +### Python + Numba backend + +Numba accelerates selected computational kernels inside the Python +implementation. This is automatic; there is no separate API to enable it. +Expect the first call to a JIT-compiled function to be slower due to +compilation, with later calls running faster. + +### Native `optv` backend + +When `optv` imports successfully, `openptv-python` automatically reuses native +implementations for: + +- image preprocessing +- full-frame target recognition / segmentation + +These native paths are validated against the Python implementation by parity +tests, so results stay backend-independent. + +### Backend Capability Table + +| Operation | Pure Python | Python + Numba | Native `optv` | +| --- | --- | --- | --- | +| Image preprocessing | Yes | Yes | Yes, automatic delegation | +| Target recognition / segmentation | Yes | Yes | Yes, automatic delegation | +| Point reconstruction | Yes | Partial internal kernels | Not used by default | +| Correspondence search / stereo matching | Yes | Partial internal kernels | Not used by default | +| Tracking | Yes | Partial internal kernels | Not used by default | +| Sequence parameter I/O | Yes | No | Available in native bindings | + +`Not used by default` means the native path exists in benchmarks or conversion +helpers, but the regular `openptv-python` runtime path still uses the Python +implementation unless that operation is explicitly integrated later. + +## Getting Started + +### 1. Install the project + +Use one of the installation methods above. + +### 2. Verify imports + +```bash +uv run python - <<'PY' +import openptv_python +import numba +try: + import optv +except ImportError: + print("optv not installed; native delegation disabled") +else: + print("optv ok", optv.__version__) + +print("openptv_python ok") +print("numba ok", numba.__version__) +PY +``` + +### 3. Start using the Python API ```python >>> import openptv_python ``` +### 4. Run the test suite + +```bash +uv run make +``` + +Stress and performance tests are part of the default suite now. If you need a +faster validation pass locally, you can skip them explicitly: + +```bash +OPENPTV_SKIP_STRESS_BENCHMARKS=1 uv run make +``` + ### Workflow for developers/contributors -For the best experience create a new conda environment (e.g. DEVELOP) with Python 3.10: +For the best experience create a new conda environment (e.g. DEVELOP) with Python 3.12: ``` -conda create -n openptv-python -c conda-forge python=3.11 +conda create -n openptv-python -c conda-forge python=3.12 conda activate openptv-python ``` -Before pushing to GitHub, run the following commands: +Before pushing to GitHub, use the developer install above and then run the +following commands: -1. Update conda environment: `make conda-env-update` -1. Install this package: `pip install -e .` +1. Update conda environment: `make conda-env-update` or `uv venv` and `source .venv/bin/activate` followed by `uv sync --extra dev --upgrade` +1. If you are using pip instead of uv, install the editable developer environment: `pip install -e ".[dev]"` 1. Sync with the latest [template](https://github.com/ecmwf-projects/cookiecutter-conda-package) (optional): `make template-update` 1. Run quality assurance checks: `make qa` 1. Run tests: `make unit-tests` diff --git a/ci/environment-ci.yml b/ci/environment-ci.yml index b7136d7..7ea50bc 100644 --- a/ci/environment-ci.yml +++ b/ci/environment-ci.yml @@ -7,6 +7,8 @@ dependencies: - mypy - myst-parser - pip +- pip: + - types-PyYAML - pre-commit - pydata-sphinx-theme - pytest diff --git a/docs/bundle_adjustment.md b/docs/bundle_adjustment.md new file mode 100644 index 0000000..779f30e --- /dev/null +++ b/docs/bundle_adjustment.md @@ -0,0 +1,402 @@ +# Bundle Adjustment + +This page documents the bundle-adjustment routines implemented in `openptv_python.orientation` and the demo driver in `openptv_python.demo_bundle_adjustment`. + +## Overview + +The repository currently exposes two related calibration-refinement workflows: + +1. `multi_camera_bundle_adjustment`: jointly refine camera parameters and optionally 3D points by minimizing reprojection error. +1. `guarded_two_step_bundle_adjustment`: run a pose-focused bundle-adjustment stage first, then a tightly constrained intrinsic stage, and reject the second stage if it makes the solution worse. + +The demo module compares several configurations on a case folder such as `tests/testing_fodder/test_cavity` and can write one updated calibration folder per experiment. + +## Case Layout + +The demo expects a case folder with this structure: + +```text +case_dir/ + cal/ + cam1.tif.ori + cam1.tif.addpar + ... + parameters/ + ptv.par + sequence.par + res_orig/ + rt_is.* + img_orig/ + cam1.%05d_targets + ... +``` + +This is the same layout used by `tests/testing_fodder/test_cavity`. + +## Objective Function + +For each observed 2D image point $u\_{ij} = (x\_{ij}, y\_{ij})$ from camera $j$ and 3D point $X_i$, bundle adjustment minimizes reprojection residuals of the form + +$$ +r\_{ij} = +\\begin{bmatrix} +\\hat{x}_{ij}(X_i, \\theta_j) - x_{ij} \\ +\\hat{y}_{ij}(X_i, \\theta_j) - y_{ij} +\\end{bmatrix}, +$$ + +where $\\theta_j$ denotes the active calibration parameters for camera $j$. + +The solver minimizes a robust or linear least-squares objective + +$$ +\\min\_{\\Theta, X} +\\sum\_{i,j} \\rho\\left(\\left|W r\_{ij}\\right|^2\\right) + +- \\sum_k \\left(\\frac{p_k - p\_{k,0}}{\\sigma_k}\\right)^2 +- \\sum\_{m \\in \\mathcal{M}} \\left|\\frac{X_m - X_m^\*}{\\sigma^{(X)}\_m}\\right|^2. + $$ + +with: + +1. $W$ converting pixel residuals into normalized units using `pix_x` and `pix_y`. +1. $\\rho(\\cdot)$ chosen by the `loss` argument, for example `linear` or `soft_l1`. +1. Optional Gaussian-style priors on selected parameters via `prior_sigmas`. +1. Optional soft 3D geometry anchors on selected points via `known_points` and `known_point_sigmas`. + +The implementation evaluates the forward projection through OpenPTV's existing camera and multimedia model, so refraction and the Brown-affine distortion model remain part of the optimization. + +## Implemented Algorithms + +## `multi_camera_bundle_adjustment` + +This is the general solver. It can optimize: + +1. Exterior parameters: `x0`, `y0`, `z0`, `omega`, `phi`, `kappa`. +1. Optional intrinsic and distortion parameters controlled by `OrientPar` flags. +1. The 3D points themselves, if `optimize_points=True`. + +Important implementation details: + +1. It uses `scipy.optimize.least_squares`. +1. For `trf` and `dogbox`, the code supplies a Jacobian sparsity pattern so finite differencing scales with the true bundle-adjustment dependency graph instead of a dense matrix. +1. Residuals are assembled camera-by-camera to reduce Python overhead. +1. The function guards against scale ambiguity when points and camera poses are both free but too few cameras are fixed. +1. If `known_points` is provided, the solver appends three residuals per constrained point to keep selected 3D coordinates near supplied target positions. + +In practice, the free variables are partitioned as: + +$$ +z = [\\theta\_{j_1}, \\theta\_{j_2}, \\dots, X_1, X_2, \\dots]. +$$ + +Each observation residual depends only on: + +1. One camera parameter block. +1. One 3D point block. + +Each known-point anchor residual depends only on: + +1. One 3D point block. + +That sparse structure is the reason `jac_sparsity` matters so much for runtime. + +## `guarded_two_step_bundle_adjustment` + +This routine is designed for a more conservative refinement flow: + +1. Start from baseline calibrations and points. +1. Run a pose-oriented bundle-adjustment stage. +1. Run a second intrinsic-focused stage with all cameras fixed in pose. +1. Accept the intrinsic stage only if it does not degrade reprojection RMS and, by default, does not worsen mean ray convergence. + +When `known_points` is supplied, the same soft geometry anchors are applied consistently in both stages. + +This gives three possible final outcomes: + +1. `baseline`: both optimized stages are rejected. +1. `pose`: the pose stage is accepted but the intrinsic stage is rejected. +1. `intrinsics`: both stages are accepted. + +This is useful when the intrinsic update is intentionally tiny and should only be kept if it is clearly beneficial. + +## Metrics Reported + +The current routines report and the demo prints: + +1. `initial_reprojection_rms` and `final_reprojection_rms`. +1. Per-camera reprojection RMS for `multi_camera_bundle_adjustment`. +1. `baseline_mean_ray_convergence`, `pose_mean_ray_convergence`, and `final_mean_ray_convergence` for guarded runs. +1. `baseline_correspondence_rate`, `pose_correspondence_rate`, and `intrinsic_correspondence_rate` for guarded runs when original quadruplet identities are available. +1. Runtime in seconds for each experiment. +1. `known_point_indices` for constrained runs in `multi_camera_bundle_adjustment`. + +Reprojection RMS answers "how well do the updated cameras explain the observed image measurements?" + +Mean ray convergence answers "how tightly do back-projected camera rays meet in 3D?" + +Lower is better for both metrics. + +## Demo Script + +The demo entry point is: + +```bash +python -m openptv_python.demo_bundle_adjustment +``` + +By default it uses `tests/testing_fodder/test_cavity`, writes results into `tmp/bundle_adjustment_demo`, and evaluates several presets. + +### Demo Options + +The command-line options are: + +1. `case_dir`: optional positional argument pointing at a compatible case folder. +1. `--max-frames N`: only load the first `N` frames from `sequence.par`. +1. `--max-points-per-frame N`: only keep the first `N` fully observed points per frame. +1. `--perturbation-scale S`: scale the deterministic starting pose perturbation. +1. `--output-dir PATH`: write one output case folder per experiment under this directory. +1. `--skip-write`: run the experiments without writing updated calibration folders. +1. `--known-points N`: select `N` evenly spaced input 3D points as soft geometry anchors. `0` disables constrained presets. +1. `--known-point-sigma S`: apply the same object-space sigma to each anchored 3D coordinate. +1. `--diagnose-fixed-pairs`: run one selected experiment across every two-camera fixed pair instead of running the normal preset table. +1. `--diagnostic-experiment NAME`: choose which preset to sweep when `--diagnose-fixed-pairs` is enabled. +1. `--diagnose-epipolar`: compare pairwise epipolar consistency before and after the selected diagnostic experiment. +1. `--diagnose-quadruplets`: compare leave-one-camera-out quadruplet stability before and after the selected diagnostic experiment. +1. `--epipolar-curve-points N`: sample `N` points along each epipolar curve when approximating point-to-curve distance in pixels. +1. `--geometry-guard-mode {auto,off,soft,hard}`: control whether guarded two-step BA rejects stages that move a known 3D calibration target too far from a trusted reference calibration. `auto` enables `hard` when `parameters/cal_ori.par` points to a known 3D target file such as `cal/target_on_a_side.txt`. +1. `--geometry-guard-threshold S`: pixel threshold used by `hard` geometry guards. +1. `--geometry-export-threshold S`: refuse to write output case folders whose final calibration-body drift exceeds `S` pixels. Use `0` to disable export blocking. +1. `--correspondence-guard-mode {auto,off,soft,hard}`: control whether guarded two-step BA rejects stages that replace too many original quadruplet target identities after reprojection and nearest-target reassignment. `auto` derives a threshold from the trusted reference calibration when tracking data is available. +1. `--correspondence-guard-threshold S`: replacement-rate threshold used by `hard` correspondence guards. +1. `--correspondence-export-threshold S`: refuse to write output case folders whose final correspondence replacement rate exceeds `S`. Use `0` to disable correspondence-based export blocking. + +### Included Demo Presets + +The current demo always compares five unconstrained presets: + +1. `pose_trf_linear`: pose-only bundle adjustment with `method="trf"` and `loss="linear"`. +1. `pose_soft_l1`: pose-only bundle adjustment with robust `soft_l1` loss. +1. `pose_fixed_points`: pose-only bundle adjustment with `optimize_points=False`. +1. `intrinsics_only`: the demo resets to the reference camera poses, perturbs only `k1`, `p1`, and `p2`, and then refines just those intrinsic terms while all camera poses and 3D points remain fixed. +1. `guarded_two_step`: pose stage plus tightly constrained intrinsic stage. + +When a case exposes known 3D calibration-target points through `cal_ori.par`, the demo now defaults to a geometry-preserving workflow: + +1. Guarded presets use a `hard` acceptance check against the known target projections. +1. Output case folders are written only if the final calibration stays within the configured export drift threshold. +1. Cases without a known 3D target file still run normally; the geometry guard simply stays off unless you explicitly enable it another way. + +When a case also exposes original tracked quadruplets and per-camera target files, the demo adds a correspondence-preserving workflow on top: + +1. Guarded presets can compare original target identities with the identities implied by the refined calibration. +1. `auto` correspondence mode derives a threshold from the trusted reference calibration and rejects later stages that replace materially more quadruplets than that reference already does. +1. Output case folders are skipped if the final replacement rate exceeds the configured correspondence export threshold. + +If `--known-points` is greater than zero, the demo also compares two constrained presets: + +1. `pose_trf_known_points`: pose-only bundle adjustment with soft 3D anchors. +1. `guarded_two_step_known_points`: guarded two-step refinement with the same 3D anchors active in both stages. + +### Example Commands + +Small, fast smoke test: + +```bash +python -m openptv_python.demo_bundle_adjustment \ + --max-frames 1 \ + --max-points-per-frame 16 \ + --output-dir .tmp/demo_bundle_adjustment_check +``` + +Larger comparison on `test_cavity`: + +```bash +python -m openptv_python.demo_bundle_adjustment \ + tests/testing_fodder/test_cavity \ + --max-frames 2 \ + --max-points-per-frame 80 \ + --known-points 12 \ + --known-point-sigma 0.25 \ + --output-dir .tmp/demo_bundle_adjustment_runs +``` + +Run the same case but disable automatic geometry blocking if you intentionally want to inspect all candidate exports: + +```bash +python -m openptv_python.demo_bundle_adjustment \ + tests/testing_fodder/test_cavity \ + --max-frames 2 \ + --max-points-per-frame 80 \ + --known-points 12 \ + --known-point-sigma 0.25 \ + --geometry-guard-mode off \ + --geometry-export-threshold 0 \ + --output-dir .tmp/demo_bundle_adjustment_runs_all +``` + +Keep export blocking but switch guarded acceptance to a softer monotonic geometry check: + +```bash +python -m openptv_python.demo_bundle_adjustment \ + tests/testing_fodder/test_cavity \ + --max-frames 2 \ + --max-points-per-frame 80 \ + --geometry-guard-mode soft \ + --geometry-export-threshold 2.5 \ + --output-dir .tmp/demo_bundle_adjustment_runs_soft +``` + +Keep geometry blocking but also enforce a hard correspondence replacement limit derived from the trusted reference calibration: + +```bash +python -m openptv_python.demo_bundle_adjustment \ + tests/testing_fodder/test_cavity \ + --max-frames 2 \ + --max-points-per-frame 80 \ + --correspondence-guard-mode auto \ + --output-dir .tmp/demo_bundle_adjustment_runs_correspondence +``` + +Run without writing calibration folders: + +```bash +python -m openptv_python.demo_bundle_adjustment --skip-write +``` + +Disable constrained presets entirely: + +```bash +python -m openptv_python.demo_bundle_adjustment --known-points 0 --skip-write +``` + +Sweep all two-camera anchor pairs for the guarded solver: + +```bash +python -m openptv_python.demo_bundle_adjustment \ + tests/testing_fodder/test_cavity \ + --max-frames 2 \ + --max-points-per-frame 80 \ + --known-points 12 \ + --known-point-sigma 0.25 \ + --skip-write \ + --diagnose-fixed-pairs \ + --diagnostic-experiment guarded_two_step +``` + +Compare epipolar consistency and quadruplet sensitivity before and after one guarded run: + +```bash +python -m openptv_python.demo_bundle_adjustment \ + tests/testing_fodder/test_cavity \ + --max-frames 2 \ + --max-points-per-frame 80 \ + --known-points 12 \ + --known-point-sigma 0.25 \ + --skip-write \ + --diagnostic-experiment guarded_two_step \ + --diagnose-epipolar \ + --diagnose-quadruplets +``` + +## Output Folders + +When writing is enabled, each experiment produces a case copy like: + +```text +tmp/bundle_adjustment_demo/ + pose_trf_linear/ + cal/ + cam1.tif.ori + cam1.tif.addpar + ... + calibration_delta.txt + geometry_check.txt + correspondence_check.txt +``` + +`calibration_delta.txt` is generated with `openptv_python.calibration_compare` and shows camera-by-camera parameter differences relative to the source case. + +`geometry_check.txt` reports per-camera drift of the known 3D calibration-body projections relative to the trusted reference calibration. If the final drift exceeds `--geometry-export-threshold`, the demo does not write that experiment's case folder at all. + +`correspondence_check.txt` reports how many original quadruplet target identities are replaced by the refined calibration after nearest-target reassignment. If the final replacement rate exceeds `--correspondence-export-threshold`, the demo does not write that experiment's case folder at all. + +## Choosing Options + +These are the main tradeoffs when selecting settings. + +### Loss Function + +1. `linear`: use when correspondences are trusted and you want a pure least-squares solution. +1. `soft_l1`: use when some observations may behave like outliers. + +### Optimizing Points + +1. `optimize_points=True`: stronger joint refinement, but more variables and slower solves. +1. `optimize_points=False`: faster, more stable if initial 3D points are already good enough. + +The `intrinsics_only` preset deliberately uses `optimize_points=False` so the run changes only the selected intrinsic parameters and leaves both poses and 3D points untouched. + +Known-point constraints currently require `optimize_points=True` because the anchors act directly on the point coordinates. + +### Known 3D Point Anchors + +1. Use `known_points` when some reconstructed 3D points correspond to trusted reference-object coordinates or to positions you deliberately want to preserve. +1. Smaller `known_point_sigmas` keep the selected points closer to their supplied coordinates. +1. Larger `known_point_sigmas` let reprojection error dominate while still damping large geometric drift. +1. In the demo, anchored points are selected from the loaded input 3D points, so the constrained presets show how soft geometry anchoring changes the solution relative to the unconstrained runs rather than injecting an external ground-truth object. + +### Fixed Cameras + +Fixing multiple cameras helps remove similarity-gauge ambiguity. If both points and poses are free, the implementation requires either: + +1. At least two fixed cameras. +1. Or translation priors for `x0`, `y0`, and `z0`. + +If you suspect that one camera is drifting more than the others, the first thing to test is not a single "best" fixed camera. A single fixed camera is still gauge-ambiguous for free-point bundle adjustment. The more defensible diagnostic is to sweep all two-camera fixed pairs and compare: + +1. Final reprojection RMS. +1. Final mean ray convergence. +1. Whether the fixed cameras stayed numerically unchanged. +1. How much the free cameras moved relative to the starting calibration. + +The demo's `--diagnose-fixed-pairs` mode prints exactly this table so you can see whether the suspicious motion is tied to one anchor pair or appears across many pairs. + +### Epipolar And Quadruplet Diagnostics + +The new diagnostic mode is intended for the situation where reprojection error improves but calibration-body structure or correspondence quality still looks wrong. + +`--diagnose-epipolar` reports pairwise point-to-epipolar-curve distances in pixels for every camera pair. This is not yet an optimization term in bundle adjustment; it is a diagnostic on the observed image correspondences under the current calibration. + +`--diagnose-quadruplets` runs a leave-one-camera-out reconstruction check on fully observed points and reports how much the reconstructed 3D point moves when one camera is omitted. Large spreads indicate unstable quadruplets or geometry that is highly sensitive to one view. + +This split is useful because the two diagnostics answer different questions: + +1. Epipolar distance asks whether the image correspondences are pairwise consistent with the current camera geometry. +1. Quadruplet sensitivity asks whether a nominal four-camera match remains stable when one view is removed. +1. Reprojection RMS asks whether the final 3D points and cameras explain the full observation set. + +If epipolar distance stays bad while reprojection improves, the next step is usually not to add an epipolar term blindly. First identify whether a subset of camera pairs or quadruplets is driving the inconsistency, then decide whether to reject or downweight those observations. + +The correspondence guard is aimed at a related failure mode: a calibration can reduce RMS by drifting until many refined projections land closer to different detected targets than the original quadruplet used. In that case, the solution is numerically fitting the images better while no longer preserving the same tracked structure. The new replacement-rate check gives you an explicit acceptance and export criterion for that situation. + +### Priors and Bounds + +1. `prior_sigmas` softly regularize motion away from the starting calibration. +1. `parameter_bounds` clip the allowed movement relative to the starting calibration. + +This is especially useful for guarded intrinsic updates where you only want very small corrections. + +## Practical Notes + +1. `lm` can be effective on small synthetic problems, but `trf` is generally the better default for larger real cases because it supports bounds and Jacobian sparsity. +1. Real-case runtime depends strongly on the number of frames and points included. +1. `read_targets` currently prints filenames as it loads target files, so demo output includes those lines. + +## Related Code + +The main implementation lives in: + +1. `openptv_python.orientation` +1. `openptv_python.demo_bundle_adjustment` +1. `openptv_python.calibration_compare` diff --git a/docs/conf.py b/docs/conf.py index 6673d7d..5b1ba6f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,12 +27,14 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - "autoapi.extension", "myst_parser", "sphinx.ext.autodoc", "sphinx.ext.napoleon", ] +if os.environ.get("OPENPTV_ENABLE_AUTOAPI") == "1": + extensions.append("autoapi.extension") + # autodoc configuration autodoc_typehints = "none" diff --git a/docs/index.md b/docs/index.md index 6feb5cd..58315f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,12 +2,22 @@ Python version of the OpenPTV library. +Start with the README for installation and backend selection details. The short +version is: + +- install runtime dependencies with `uv sync` on Python 3.12 or 3.13 +- install contributor tooling with `uv sync --extra dev` +- use the same Python API regardless of backend +- get pure Python behavior everywhere, Numba acceleration in selected kernels, + and automatic native `optv` delegation for preprocessing and full-frame + target recognition when available + ```{toctree} :caption: 'Contents:' :maxdepth: 2 README.md -API Reference <_api/openptv_python/index> +bundle_adjustment.md ``` # Indices and tables diff --git a/mypy.ini b/mypy.ini index e5d1ae2..cc3fa74 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,6 @@ [mypy] ignore_missing_imports = True +exclude = (^build/|^docs/_build/|^tmp/) ; disallow_untyped_defs = True ; disallow_incomplete_defs = True ; check_untyped_defs = True diff --git a/openptv_python/_native_compat.py b/openptv_python/_native_compat.py new file mode 100644 index 0000000..cea39e1 --- /dev/null +++ b/openptv_python/_native_compat.py @@ -0,0 +1,69 @@ +"""Optional compatibility layer for reusing optv py_bind as a native provider.""" + +from __future__ import annotations + +from importlib import import_module +from types import ModuleType +from typing import Any + + +def _optional_import(module_name: str) -> ModuleType | None: + try: + return import_module(module_name) + except Exception: + return None + + +optv_calibration = _optional_import("optv.calibration") +optv_image_processing = _optional_import("optv.image_processing") +optv_parameters = _optional_import("optv.parameters") +optv_segmentation = _optional_import("optv.segmentation") +optv_tracking_framebuf = _optional_import("optv.tracking_framebuf") + +HAS_OPTV = any( + module is not None + for module in ( + optv_calibration, + optv_image_processing, + optv_parameters, + optv_segmentation, + optv_tracking_framebuf, + ) +) + +HAS_NATIVE_PREPROCESS = ( + optv_image_processing is not None + and hasattr(optv_image_processing, "preprocess_image") + and optv_parameters is not None + and hasattr(optv_parameters, "ControlParams") +) + +HAS_NATIVE_SEGMENTATION = ( + optv_segmentation is not None + and hasattr(optv_segmentation, "target_recognition") + and optv_parameters is not None + and hasattr(optv_parameters, "TargetParams") + and hasattr(optv_parameters, "ControlParams") + and optv_tracking_framebuf is not None + and hasattr(optv_tracking_framebuf, "Target") +) + +HAS_NATIVE_CALIBRATION = optv_calibration is not None and hasattr( + optv_calibration, "Calibration" +) + +HAS_NATIVE_TARGETS = optv_tracking_framebuf is not None and hasattr( + optv_tracking_framebuf, "Target" +) + + +def native_preprocess_image(*args: Any, **kwargs: Any) -> Any: + if not HAS_NATIVE_PREPROCESS or optv_image_processing is None: + raise RuntimeError("optv native preprocess_image is not available") + return optv_image_processing.preprocess_image(*args, **kwargs) + + +def native_target_recognition(*args: Any, **kwargs: Any) -> Any: + if not HAS_NATIVE_SEGMENTATION or optv_segmentation is None: + raise RuntimeError("optv native target_recognition is not available") + return optv_segmentation.target_recognition(*args, **kwargs) diff --git a/openptv_python/_native_convert.py b/openptv_python/_native_convert.py new file mode 100644 index 0000000..949581f --- /dev/null +++ b/openptv_python/_native_convert.py @@ -0,0 +1,135 @@ +"""Conversion helpers between openptv_python objects and optv py_bind objects.""" + +from __future__ import annotations + +from types import ModuleType +from typing import Iterable, List + +import numpy as np + +from ._native_compat import ( + HAS_NATIVE_CALIBRATION, + HAS_NATIVE_TARGETS, + HAS_OPTV, + optv_calibration, + optv_parameters, + optv_tracking_framebuf, +) +from .calibration import Calibration +from .parameters import ControlPar, TargetPar +from .tracking_frame_buf import Target + + +def _require_optv_parameters() -> None: + if not HAS_OPTV or optv_parameters is None: + raise RuntimeError("optv py_bind parameters are not available") + + +def _optv_parameters_module() -> ModuleType: + _require_optv_parameters() + assert optv_parameters is not None + return optv_parameters + + +def to_native_calibration(cal: Calibration): + if not HAS_NATIVE_CALIBRATION or optv_calibration is None: + raise RuntimeError("optv Calibration is not available") + + native = optv_calibration.Calibration() + native.set_pos(np.asarray(cal.get_pos(), dtype=np.float64)) + native.set_angles(np.asarray(cal.get_angles(), dtype=np.float64)) + native.set_primary_point(np.asarray(cal.get_primary_point(), dtype=np.float64)) + native.set_radial_distortion( + np.asarray(cal.get_radial_distortion(), dtype=np.float64) + ) + native.set_decentering(np.asarray(cal.get_decentering(), dtype=np.float64)) + native.set_affine_trans(np.asarray(cal.get_affine(), dtype=np.float64)) + native.set_glass_vec(np.asarray(cal.get_glass_vec(), dtype=np.float64)) + return native + + +def to_native_control_par(cpar: ControlPar): + parameters_module = _optv_parameters_module() + + flags = [ + flag_name + for enabled, flag_name in ( + (bool(cpar.hp_flag), "hp"), + (bool(cpar.all_cam_flag), "allcam"), + (bool(cpar.tiff_flag), "headers"), + ) + if enabled + ] + + native = parameters_module.ControlParams( + cpar.num_cams, + flags=flags, + image_size=(cpar.imx, cpar.imy), + pixel_size=(cpar.pix_x, cpar.pix_y), + cam_side_n=cpar.mm.n1, + wall_ns=list(cpar.mm.n2), + wall_thicks=list(cpar.mm.d), + object_side_n=cpar.mm.n3, + ) + native.set_chfield(cpar.chfield) + + for cam_index, img_base_name in enumerate(cpar.img_base_name): + native.set_img_base_name(cam_index, img_base_name) + + for cam_index, cal_img_base_name in enumerate(cpar.cal_img_base_name): + native.set_cal_img_base_name(cam_index, cal_img_base_name) + + return native + + +def to_native_target_par(tpar: TargetPar): + parameters_module = _optv_parameters_module() + + thresholds = list(tpar.gvthresh) + if len(thresholds) < 4: + thresholds.extend([0] * (4 - len(thresholds))) + + return parameters_module.TargetParams( + discont=tpar.discont, + gvthresh=thresholds[:4], + pixel_count_bounds=(tpar.nnmin, tpar.nnmax), + xsize_bounds=(tpar.nxmin, tpar.nxmax), + ysize_bounds=(tpar.nymin, tpar.nymax), + min_sum_grey=tpar.sumg_min, + cross_size=tpar.cr_sz, + ) + + +def to_native_target(target: Target): + if not HAS_NATIVE_TARGETS or optv_tracking_framebuf is None: + raise RuntimeError("optv Target is not available") + + return optv_tracking_framebuf.Target( + pnr=target.pnr, + x=target.x, + y=target.y, + n=target.n, + nx=target.nx, + ny=target.ny, + sumg=target.sumg, + tnr=target.tnr, + ) + + +def from_native_target(native_target) -> Target: + x, y = native_target.pos() + n, nx, ny = native_target.count_pixels() + return Target( + pnr=int(native_target.pnr()), + x=float(x), + y=float(y), + n=int(n), + nx=int(nx), + ny=int(ny), + sumg=int(native_target.sum_grey_value()), + tnr=int(native_target.tnr()), + ) + + +def from_native_target_array(native_targets: Iterable[object]) -> List[Target]: + return [from_native_target(target) for target in native_targets] diff --git a/openptv_python/calibration_compare.py b/openptv_python/calibration_compare.py new file mode 100644 index 0000000..2490c9d --- /dev/null +++ b/openptv_python/calibration_compare.py @@ -0,0 +1,152 @@ +"""Utilities for comparing calibration folders camera by camera.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable + +import numpy as np + +from .calibration import Calibration, read_calibration + + +@dataclass +class CalibrationDelta: + """Numeric parameter deltas for one camera calibration pair.""" + + camera_key: str + position_delta: np.ndarray + angle_delta: np.ndarray + primary_point_delta: np.ndarray + glass_delta: np.ndarray + added_par_delta: np.ndarray + + +def _camera_key_from_ori_path(ori_path: Path) -> str: + """Return the camera key shared by the .ori and .addpar file pair.""" + if not ori_path.name.endswith(".ori"): + raise ValueError(f"Expected a .ori file, got {ori_path}") + return ori_path.name[: -len(".ori")] + + +def _discover_calibration_pairs(folder: Path) -> Dict[str, tuple[Path, Path | None]]: + """Discover calibration files in a folder keyed by camera base name.""" + if not folder.exists(): + raise FileNotFoundError(folder) + + pairs: Dict[str, tuple[Path, Path | None]] = {} + for ori_path in sorted(folder.glob("*.ori")): + key = _camera_key_from_ori_path(ori_path) + addpar_path = folder / f"{key}.addpar" + pairs[key] = (ori_path, addpar_path if addpar_path.exists() else None) + + if not pairs: + raise ValueError(f"No calibration .ori files found in {folder}") + + return pairs + + +def _load_calibration_pair(pair: tuple[Path, Path | None]) -> Calibration: + """Load one calibration pair from disk.""" + ori_path, addpar_path = pair + return read_calibration(ori_path, addpar_path) + + +def compare_calibration_folders( + reference_dir: Path | str, candidate_dir: Path | str +) -> Dict[str, CalibrationDelta]: + """Compare two calibration folders and return numeric deltas per camera.""" + reference_dir = Path(reference_dir) + candidate_dir = Path(candidate_dir) + + reference_pairs = _discover_calibration_pairs(reference_dir) + candidate_pairs = _discover_calibration_pairs(candidate_dir) + + if set(reference_pairs) != set(candidate_pairs): + missing_from_candidate = sorted(set(reference_pairs) - set(candidate_pairs)) + missing_from_reference = sorted(set(candidate_pairs) - set(reference_pairs)) + raise ValueError( + "Calibration folders contain different camera sets: " + f"missing_from_candidate={missing_from_candidate}, " + f"missing_from_reference={missing_from_reference}" + ) + + deltas: Dict[str, CalibrationDelta] = {} + for camera_key in sorted(reference_pairs): + reference_cal = _load_calibration_pair(reference_pairs[camera_key]) + candidate_cal = _load_calibration_pair(candidate_pairs[camera_key]) + + deltas[camera_key] = CalibrationDelta( + camera_key=camera_key, + position_delta=candidate_cal.get_pos() - reference_cal.get_pos(), + angle_delta=candidate_cal.get_angles() - reference_cal.get_angles(), + primary_point_delta=( + candidate_cal.get_primary_point() - reference_cal.get_primary_point() + ), + glass_delta=candidate_cal.glass_par - reference_cal.glass_par, + added_par_delta=candidate_cal.added_par - reference_cal.added_par, + ) + + return deltas + + +def format_calibration_comparison( + deltas: Dict[str, CalibrationDelta], + reference_dir: Path | str | None = None, + candidate_dir: Path | str | None = None, +) -> str: + """Format camera-by-camera calibration deltas as readable text.""" + lines: list[str] = [] + if reference_dir is not None and candidate_dir is not None: + lines.append(f"Reference: {Path(reference_dir)}") + lines.append(f"Candidate: {Path(candidate_dir)}") + + for camera_key in sorted(deltas): + delta = deltas[camera_key] + lines.append(f"{camera_key}:") + lines.append( + " position_delta: " + + " ".join(f"{value:+.9f}" for value in delta.position_delta) + ) + lines.append( + " angle_delta: " + " ".join(f"{value:+.9f}" for value in delta.angle_delta) + ) + lines.append( + " primary_point_delta: " + + " ".join(f"{value:+.9f}" for value in delta.primary_point_delta) + ) + lines.append( + " glass_delta: " + " ".join(f"{value:+.9f}" for value in delta.glass_delta) + ) + lines.append( + " addpar_delta: " + + " ".join(f"{value:+.9f}" for value in delta.added_par_delta) + ) + + return "\n".join(lines) + + +def main(argv: Iterable[str] | None = None) -> int: + """Command-line entry point for comparing calibration folders.""" + parser = argparse.ArgumentParser( + description="Compare two calibration folders camera by camera." + ) + parser.add_argument("reference_dir", type=Path) + parser.add_argument("candidate_dir", type=Path) + args = parser.parse_args(list(argv) if argv is not None else None) + + deltas = compare_calibration_folders(args.reference_dir, args.candidate_dir) + print( + format_calibration_comparison( + deltas, + reference_dir=args.reference_dir, + candidate_dir=args.candidate_dir, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/openptv_python/demo_bundle_adjustment.py b/openptv_python/demo_bundle_adjustment.py new file mode 100644 index 0000000..9f0668f --- /dev/null +++ b/openptv_python/demo_bundle_adjustment.py @@ -0,0 +1,2855 @@ +"""Command-line demo for bundle-adjustment experiments on OpenPTV cases.""" + +from __future__ import annotations + +import argparse +import shutil +from dataclasses import dataclass +from pathlib import Path +from time import perf_counter +from typing import Dict, Iterable, List, Sequence, cast + +import numpy as np + +from .calibration import Calibration, read_calibration, write_calibration +from .calibration_compare import ( + compare_calibration_folders, + format_calibration_comparison, +) +from .epi import epipolar_curve +from .imgcoord import image_coordinates, img_coord +from .orientation import ( + alternating_bundle_adjustment, + guarded_two_step_bundle_adjustment, + initialize_bundle_adjustment_points, + mean_ray_convergence, + multi_camera_bundle_adjustment, + reprojection_rms, +) +from .parameters import ( + ControlPar, + OrientPar, + SequencePar, + VolumePar, + read_cal_ori_parameters, + read_volume_par, +) +from .sortgrid import read_calblock as read_sortgrid_calblock +from .tracking_frame_buf import read_path_frame, read_targets +from .trafo import arr_metric_to_pixel, metric_to_pixel + + +@dataclass(frozen=True) +class ExperimentSpec: + """Configuration for one bundle-adjustment demo run.""" + + name: str + description: str + mode: str + ba_kwargs: Dict[str, object] + + +@dataclass +class ExperimentResult: + """Collected metrics for one bundle-adjustment demo run.""" + + name: str + description: str + duration_sec: float + success: bool + initial_rms: float + final_rms: float + baseline_ray_convergence: float + final_ray_convergence: float + notes: str + cal_dir: Path | None + fixed_camera_indices: tuple[int, ...] + camera_position_shifts: List[float] + camera_angle_shifts: List[float] + geometry_projection_drift: List[ProjectionDriftSummary] | None = None + correspondence_replacement: CorrespondenceReplacementSummary | None = None + refined_cals: List[Calibration] | None = None + refined_points: np.ndarray | None = None + + +@dataclass +class FixedCameraDiagnostic: + """Summary of one anchor-pair diagnostic run.""" + + fixed_camera_indices: tuple[int, int] + final_rms: float + final_ray_convergence: float + fixed_position_shift: float + fixed_angle_shift: float + mean_free_position_shift: float + max_free_position_shift: float + mean_free_angle_shift: float + notes: str + + +@dataclass +class CameraPairEpipolarSummary: + """Summary of pairwise epipolar consistency for one camera pair.""" + + camera_pair: tuple[int, int] + mean_distance: float + median_distance: float + p95_distance: float + max_distance: float + acceptance_rates: Dict[float, float] + + +@dataclass +class QuadrupletSensitivitySummary: + """Leave-one-camera-out stability summary for fully observed points.""" + + num_points: int + mean_spread: float + median_spread: float + p95_spread: float + max_spread: float + mean_full_ray_convergence: float + mean_leave_one_out_ray_convergence: float + worst_point_indices: List[int] + + +@dataclass(frozen=True) +class ProjectionDriftSummary: + """Reference calibration-body projection drift for one camera.""" + + camera_index: int + mean_distance: float + p95_distance: float + max_distance: float + + +@dataclass(frozen=True) +class CorrespondenceReplacementSummary: + """How strongly a calibration would replace the original quadruplet identities.""" + + replacement_rate: float + camera_change_rates: List[float] + mean_nearest_distance: float + p95_nearest_distance: float + max_nearest_distance: float + + +@dataclass(frozen=True) +class ObservationTrackingData: + """Original correspondence identities and candidate detections for a case.""" + + original_target_ids: np.ndarray + point_frame_indices: np.ndarray + frame_target_pixels: List[List[np.ndarray]] + reference_replacement_rate: float | None = None + + +def clone_calibration(cal: Calibration) -> Calibration: + """Return a detached copy of a calibration object.""" + return Calibration( + ext_par=cal.ext_par.copy(), + int_par=cal.int_par.copy(), + glass_par=cal.glass_par.copy(), + added_par=cal.added_par.copy(), + mmlut=cal.mmlut, + mmlut_data=cal.mmlut_data, + ) + + +def perturb_calibrations(cals: List[Calibration], scale: float) -> List[Calibration]: + """Apply deterministic pose perturbations so BA has something to recover.""" + perturbed = [clone_calibration(cal) for cal in cals] + deltas = [ + (np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, 0.0])), + (np.array([0.5, -0.3, 0.2]), np.array([0.004, -0.003, 0.002])), + (np.array([-0.4, 0.3, -0.2]), np.array([-0.003, 0.003, -0.002])), + (np.array([0.3, 0.4, -0.2]), np.array([0.003, 0.002, -0.002])), + ] + for cal, (pos_delta, angle_delta) in zip(perturbed, deltas): + cal.set_pos(cal.get_pos() + pos_delta * scale) + cal.set_angles(cal.get_angles() + angle_delta * scale) + return perturbed + + +def perturb_intrinsic_parameters( + cals: List[Calibration], scale: float +) -> List[Calibration]: + """Apply deterministic distortion perturbations while keeping poses fixed.""" + perturbed = [clone_calibration(cal) for cal in cals] + deltas = [ + (2.5e-5, -8.0e-5, 6.0e-5), + (-2.0e-5, 7.0e-5, -5.0e-5), + (1.8e-5, 5.0e-5, 4.0e-5), + (-1.6e-5, -6.0e-5, 5.0e-5), + ] + for cal, (k1_delta, p1_delta, p2_delta) in zip(perturbed, deltas): + radial = cal.get_radial_distortion().copy() + radial[0] += k1_delta * scale + cal.set_radial_distortion(radial) + + decentering = cal.get_decentering().copy() + decentering[0] += p1_delta * scale + decentering[1] += p2_delta * scale + cal.set_decentering(decentering) + + return perturbed + + +def build_experiment_start_calibrations( + spec: ExperimentSpec, + *, + start_cals: List[Calibration], + reference_cals: List[Calibration], +) -> List[Calibration]: + """Construct the initial calibration set for one experiment.""" + if bool(spec.ba_kwargs.get("use_reference_cals", False)): + base_cals = reference_cals + else: + base_cals = start_cals + + working_cals = [clone_calibration(cal) for cal in base_cals] + if bool(spec.ba_kwargs.get("perturb_intrinsics_only", False)): + scale = cast(float, spec.ba_kwargs.get("intrinsic_perturbation_scale", 1.0)) + working_cals = perturb_intrinsic_parameters(working_cals, scale) + + fixed_camera_indices = tuple( + cast(List[int] | None, spec.ba_kwargs.get("fixed_camera_indices")) or [] + ) + for camera_index in fixed_camera_indices: + if camera_index < 0 or camera_index >= len(working_cals): + continue + working_cals[camera_index].set_pos( + reference_cals[camera_index].get_pos().copy() + ) + working_cals[camera_index].set_angles( + reference_cals[camera_index].get_angles().copy() + ) + + return working_cals + + +def load_reference_geometry_points( + case_dir: Path, + num_cams: int, +) -> np.ndarray | None: + """Load known calibration-body points referenced by cal_ori.par, if present.""" + cal_ori_path = case_dir / "parameters/cal_ori.par" + if not cal_ori_path.exists(): + return None + + calibration_par = read_cal_ori_parameters(cal_ori_path, num_cams) + if not calibration_par.fixp_name: + return None + + calblock_path = case_dir / calibration_par.fixp_name + if not calblock_path.exists(): + return None + + calblock = read_sortgrid_calblock(calblock_path) + if len(calblock) == 0: + return None + + return np.column_stack([calblock.x, calblock.y, calblock.z]).astype(float) + + +def calibration_body_projection_drift( + reference_cals: Sequence[Calibration], + candidate_cals: Sequence[Calibration], + control: ControlPar, + reference_points: np.ndarray | None, +) -> List[ProjectionDriftSummary] | None: + """Compare candidate calibration-body projections to a trusted reference set.""" + if reference_points is None: + return None + + summaries: List[ProjectionDriftSummary] = [] + for camera_index, (reference_cal, candidate_cal) in enumerate( + zip(reference_cals, candidate_cals), + start=1, + ): + reference_pixels = [] + candidate_pixels = [] + for point in reference_points: + ref_x, ref_y = img_coord(point, reference_cal, control.mm) + cand_x, cand_y = img_coord(point, candidate_cal, control.mm) + reference_pixels.append(metric_to_pixel(ref_x, ref_y, control)) + candidate_pixels.append(metric_to_pixel(cand_x, cand_y, control)) + + displacement = np.linalg.norm( + np.asarray(candidate_pixels) - np.asarray(reference_pixels), + axis=1, + ) + summaries.append( + ProjectionDriftSummary( + camera_index=camera_index, + mean_distance=float(displacement.mean()), + p95_distance=float(np.percentile(displacement, 95.0)), + max_distance=float(displacement.max()), + ) + ) + + return summaries + + +def format_projection_drift(summaries: Sequence[ProjectionDriftSummary] | None) -> str: + """Render calibration-body projection drift summaries.""" + if not summaries: + return "No reference calibration-body geometry available." + + lines = ["camera mean_px p95_px max_px", "------ ------- ------ ------"] + for item in summaries: + lines.append( + ( + f"{item.camera_index:>6} {item.mean_distance:>7.3f} " + f"{item.p95_distance:>6.3f} {item.max_distance:>6.3f}" + ) + ) + return "\n".join(lines) + + +def max_projection_drift( + summaries: Sequence[ProjectionDriftSummary] | None, +) -> float | None: + """Return the worst per-camera maximum projection drift in pixels.""" + if not summaries: + return None + return max(item.max_distance for item in summaries) + + +def should_block_export_on_geometry( + summaries: Sequence[ProjectionDriftSummary] | None, + threshold_px: float | None, +) -> tuple[bool, str]: + """Return whether an export should be blocked by geometry drift.""" + if summaries is None or threshold_px is None or threshold_px <= 0: + return False, "" + + drift_max = max_projection_drift(summaries) + if drift_max is None or drift_max <= threshold_px: + return False, "" + + return ( + True, + f"geometry_export_blocked=max_drift={drift_max:.3f}px>{threshold_px:.3f}px", + ) + + +def discover_num_cams(cal_dir: Path) -> int: + """Infer the number of cameras from calibration .ori files.""" + return len(sorted(cal_dir.glob("*.ori"))) + + +def load_calibrations(case_dir: Path, num_cams: int) -> List[Calibration]: + """Load all camera calibrations from a case folder.""" + cal_dir = case_dir / "cal" + return [ + read_calibration( + cal_dir / f"cam{cam_num}.tif.ori", + cal_dir / f"cam{cam_num}.tif.addpar", + ) + for cam_num in range(1, num_cams + 1) + ] + + +def load_case_observations( + case_dir: Path, + num_cams: int, + *, + max_frames: int | None, + max_points_per_frame: int | None, +) -> tuple[ControlPar, np.ndarray, np.ndarray]: + """Load quadruplet observations and initial 3D points from a case folder.""" + control = ControlPar(num_cams).from_file(case_dir / "parameters/ptv.par") + seq = SequencePar.from_file(case_dir / "parameters/sequence.par", num_cams) + + observed_batches = [] + point_batches = [] + frames = list(range(seq.first, seq.last + 1)) + if max_frames is not None: + frames = frames[:max_frames] + + for frame in frames: + cor_buf, path_buf = read_path_frame( + str(case_dir / "res_orig/rt_is"), + "", + "", + frame, + ) + targets = [ + read_targets(str(case_dir / f"img_orig/cam{cam_num}.%05d"), frame) + for cam_num in range(1, num_cams + 1) + ] + subset = [ + pt_num + for pt_num, corres in enumerate(cor_buf) + if np.all(corres.p[:num_cams] >= 0) + ] + if max_points_per_frame is not None: + subset = subset[:max_points_per_frame] + + observed_pixels = np.full((len(subset), num_cams, 2), np.nan, dtype=float) + point_init = np.empty((len(subset), 3), dtype=float) + for out_num, pt_num in enumerate(subset): + point_init[out_num] = path_buf[pt_num].x + for cam in range(num_cams): + target_index = cor_buf[pt_num].p[cam] + observed_pixels[out_num, cam, 0] = targets[cam][target_index].x + observed_pixels[out_num, cam, 1] = targets[cam][target_index].y + + observed_batches.append(observed_pixels) + point_batches.append(point_init) + + if not observed_batches: + raise ValueError(f"No observations loaded from {case_dir}") + + return ( + control, + np.concatenate(observed_batches, axis=0), + np.concatenate(point_batches, axis=0), + ) + + +def summarize_correspondence_replacements( + points: np.ndarray, + cals: Sequence[Calibration], + control: ControlPar, + tracking_data: ObservationTrackingData | None, +) -> CorrespondenceReplacementSummary | None: + """Measure how often reprojections switch to different target identities.""" + if tracking_data is None: + return None + + projected_pixels = np.empty((points.shape[0], len(cals), 2), dtype=float) + for camera_index, cal in enumerate(cals): + projected_pixels[:, camera_index, :] = arr_metric_to_pixel( + image_coordinates(points, cal, control.mm), + control, + ) + + replacement_ids = np.empty_like(tracking_data.original_target_ids) + nearest_distances = np.empty_like(tracking_data.original_target_ids, dtype=float) + for point_index in range(points.shape[0]): + frame_targets = tracking_data.frame_target_pixels[ + int(tracking_data.point_frame_indices[point_index]) + ] + for camera_index in range(len(cals)): + deltas = ( + frame_targets[camera_index] + - projected_pixels[point_index, camera_index] + ) + squared_distances = np.sum(deltas * deltas, axis=1) + nearest_index = int(np.argmin(squared_distances)) + replacement_ids[point_index, camera_index] = nearest_index + nearest_distances[point_index, camera_index] = float( + np.sqrt(squared_distances[nearest_index]) + ) + + changed_mask = np.any( + replacement_ids != tracking_data.original_target_ids, + axis=1, + ) + camera_change_rates = [ + float( + np.mean( + replacement_ids[:, camera_index] + != tracking_data.original_target_ids[:, camera_index] + ) + ) + for camera_index in range(len(cals)) + ] + return CorrespondenceReplacementSummary( + replacement_rate=float(np.mean(changed_mask)), + camera_change_rates=camera_change_rates, + mean_nearest_distance=float(np.mean(nearest_distances)), + p95_nearest_distance=float(np.percentile(nearest_distances, 95.0)), + max_nearest_distance=float(np.max(nearest_distances)), + ) + + +def load_case_tracking_data( + case_dir: Path, + num_cams: int, + *, + max_frames: int | None, + max_points_per_frame: int | None, + control: ControlPar, + reference_cals: Sequence[Calibration], +) -> ObservationTrackingData | None: + """Load original target identities and candidate detections for replacement guards.""" + seq = SequencePar.from_file(case_dir / "parameters/sequence.par", num_cams) + frames = list(range(seq.first, seq.last + 1)) + if max_frames is not None: + frames = frames[:max_frames] + + original_target_ids = [] + point_frame_indices = [] + frame_target_pixels: List[List[np.ndarray]] = [] + reference_points = [] + for frame_index, frame in enumerate(frames): + cor_buf, path_buf = read_path_frame( + str(case_dir / "res_orig/rt_is"), + "", + "", + frame, + ) + targets = [ + read_targets(str(case_dir / f"img_orig/cam{cam_num}.%05d"), frame) + for cam_num in range(1, num_cams + 1) + ] + subset = [ + point_num + for point_num, corres in enumerate(cor_buf) + if np.all(corres.p[:num_cams] >= 0) + ] + if max_points_per_frame is not None: + subset = subset[:max_points_per_frame] + + for point_num in subset: + original_target_ids.append(cor_buf[point_num].p[:num_cams].copy()) + point_frame_indices.append(frame_index) + reference_points.append(path_buf[point_num].x.copy()) + + frame_target_pixels.append( + [ + np.asarray( + [[target.x, target.y] for target in cam_targets], dtype=float + ) + for cam_targets in targets + ] + ) + + if not original_target_ids: + return None + + tracking_data = ObservationTrackingData( + original_target_ids=np.asarray(original_target_ids, dtype=int), + point_frame_indices=np.asarray(point_frame_indices, dtype=int), + frame_target_pixels=frame_target_pixels, + ) + reference_summary = summarize_correspondence_replacements( + np.asarray(reference_points, dtype=float), + reference_cals, + control, + tracking_data, + ) + return ObservationTrackingData( + original_target_ids=tracking_data.original_target_ids, + point_frame_indices=tracking_data.point_frame_indices, + frame_target_pixels=tracking_data.frame_target_pixels, + reference_replacement_rate=( + None if reference_summary is None else reference_summary.replacement_rate + ), + ) + + +def format_correspondence_replacement( + summary: CorrespondenceReplacementSummary | None, +) -> str: + """Render a compact correspondence-replacement summary.""" + if summary is None: + return "No correspondence replacement data available." + + camera_rates = " ".join( + f"cam{camera_index + 1}={100.0 * rate:.1f}%" + for camera_index, rate in enumerate(summary.camera_change_rates) + ) + return ( + f"quad_replacement_rate={100.0 * summary.replacement_rate:.1f}%\n" + f"camera_change_rates: {camera_rates}\n" + f"nearest_distance_px: mean={summary.mean_nearest_distance:.3f} " + f"p95={summary.p95_nearest_distance:.3f} max={summary.max_nearest_distance:.3f}" + ) + + +def should_block_export_on_correspondence( + summary: CorrespondenceReplacementSummary | None, + threshold: float | None, +) -> tuple[bool, str]: + """Return whether an export should be blocked by correspondence churn.""" + if summary is None or threshold is None or threshold <= 0: + return False, "" + if summary.replacement_rate <= threshold: + return False, "" + return ( + True, + "correspondence_export_blocked=" + f"replacement_rate={summary.replacement_rate:.3f}>{threshold:.3f}", + ) + + +def build_known_point_constraints( + point_init: np.ndarray, + count: int, +) -> Dict[int, np.ndarray]: + """Select a deterministic subset of 3D points to use as soft anchors.""" + if count <= 0: + return {} + + num_points = int(point_init.shape[0]) + if num_points == 0: + raise ValueError("Cannot build known-point constraints from an empty point set") + + target_count = min(count, num_points) + selected_indices = np.linspace(0, num_points - 1, num=target_count, dtype=int) + return { + int(point_index): point_init[int(point_index)].copy() + for point_index in selected_indices.tolist() + } + + +def all_fixed_camera_pairs(num_cams: int) -> List[tuple[int, int]]: + """Return all unique two-camera anchor pairs.""" + return [ + (first, second) + for first in range(num_cams - 1) + for second in range(first + 1, num_cams) + ] + + +def calibration_pose_shifts( + reference_cals: Sequence[Calibration], + candidate_cals: Sequence[Calibration], +) -> tuple[List[float], List[float]]: + """Measure per-camera pose changes relative to a reference calibration set.""" + position_shifts = [ + float(np.linalg.norm(candidate.get_pos() - reference.get_pos())) + for reference, candidate in zip(reference_cals, candidate_cals) + ] + angle_shifts = [ + float(np.linalg.norm(candidate.get_angles() - reference.get_angles())) + for reference, candidate in zip(reference_cals, candidate_cals) + ] + return position_shifts, angle_shifts + + +def _point_to_polyline_distance(point: np.ndarray, polyline: np.ndarray) -> float: + """Return the minimum Euclidean distance from a point to a polyline in pixels.""" + if polyline.shape[0] < 2: + raise ValueError("Polyline must have at least two vertices") + + best = np.inf + for start, end in zip(polyline[:-1], polyline[1:]): + segment = end - start + segment_length_sq = float(np.dot(segment, segment)) + if segment_length_sq == 0.0: + distance = float(np.linalg.norm(point - start)) + else: + t = float(np.dot(point - start, segment) / segment_length_sq) + t = min(1.0, max(0.0, t)) + projection = start + t * segment + distance = float(np.linalg.norm(point - projection)) + best = min(best, distance) + return float(best) + + +def symmetric_epipolar_distance( + origin_obs: np.ndarray, + target_obs: np.ndarray, + origin_cal: Calibration, + target_cal: Calibration, + cpar: ControlPar, + vpar: VolumePar, + num_curve_points: int, +) -> float: + """Return a symmetric epipolar inconsistency score in pixels.""" + forward_curve = epipolar_curve( + origin_obs, + origin_cal, + target_cal, + num_curve_points, + cpar, + vpar, + ) + backward_curve = epipolar_curve( + target_obs, + target_cal, + origin_cal, + num_curve_points, + cpar, + vpar, + ) + forward_distance = _point_to_polyline_distance(target_obs, forward_curve) + backward_distance = _point_to_polyline_distance(origin_obs, backward_curve) + return 0.5 * (forward_distance + backward_distance) + + +def summarize_epipolar_consistency( + observed_pixels: np.ndarray, + cals: Sequence[Calibration], + cpar: ControlPar, + vpar: VolumePar, + *, + num_curve_points: int = 64, + threshold_scales: Sequence[float] = (0.5, 1.0, 2.0), +) -> List[CameraPairEpipolarSummary]: + """Summarize pairwise epipolar distance statistics for observed correspondences.""" + thresholds = [float(vpar.eps0 * scale) for scale in threshold_scales] + summaries = [] + for cam_a, cam_b in all_fixed_camera_pairs(len(cals)): + distances = [] + for point_index in range(observed_pixels.shape[0]): + obs_a = observed_pixels[point_index, cam_a] + obs_b = observed_pixels[point_index, cam_b] + if not (np.all(np.isfinite(obs_a)) and np.all(np.isfinite(obs_b))): + continue + distances.append( + symmetric_epipolar_distance( + obs_a, + obs_b, + cals[cam_a], + cals[cam_b], + cpar, + vpar, + num_curve_points, + ) + ) + + if not distances: + continue + distance_array = np.asarray(distances, dtype=np.float64) + summaries.append( + CameraPairEpipolarSummary( + camera_pair=(cam_a, cam_b), + mean_distance=float(np.mean(distance_array)), + median_distance=float(np.median(distance_array)), + p95_distance=float(np.percentile(distance_array, 95.0)), + max_distance=float(np.max(distance_array)), + acceptance_rates={ + threshold: float(np.mean(distance_array <= threshold)) + for threshold in thresholds + }, + ) + ) + + summaries.sort(key=lambda item: item.mean_distance) + return summaries + + +def format_epipolar_diagnostics( + summaries: Sequence[CameraPairEpipolarSummary], +) -> str: + """Render pairwise epipolar statistics as a compact table.""" + thresholds = sorted( + {threshold for item in summaries for threshold in item.acceptance_rates} + ) + headers = [ + "pair", + "mean_epi", + "median_epi", + "p95_epi", + "max_epi", + ] + headers.extend([f"<= {threshold:.3f}" for threshold in thresholds]) + data = [] + for item in summaries: + row = [ + f"{item.camera_pair[0] + 1},{item.camera_pair[1] + 1}", + f"{item.mean_distance:.6f}", + f"{item.median_distance:.6f}", + f"{item.p95_distance:.6f}", + f"{item.max_distance:.6f}", + ] + row.extend( + [ + f"{100.0 * item.acceptance_rates[threshold]:.1f}%" + for threshold in thresholds + ] + ) + data.append(row) + + widths = [len(header) for header in headers] + for row in data: + for index, value in enumerate(row): + widths[index] = max(widths[index], len(value)) + + def render_row(values: List[str]) -> str: + return " ".join( + value.ljust(widths[index]) for index, value in enumerate(values) + ) + + separator = " ".join("-" * width for width in widths) + lines = [render_row(headers), separator] + lines.extend(render_row(row) for row in data) + return "\n".join(lines) + + +def summarize_quadruplet_sensitivity( + observed_pixels: np.ndarray, + cals: Sequence[Calibration], + cpar: ControlPar, +) -> QuadrupletSensitivitySummary: + """Measure leave-one-camera-out instability for fully observed points.""" + if observed_pixels.shape[1] < 4: + raise ValueError("Quadruplet sensitivity requires at least four cameras") + + full_mask = np.all(np.isfinite(observed_pixels), axis=(1, 2)) + full_indices = np.flatnonzero(full_mask) + if full_indices.size == 0: + raise ValueError( + "No fully observed quadruplets available for sensitivity analysis" + ) + + full_points, full_ray_convergence = initialize_bundle_adjustment_points( + observed_pixels[full_indices], + list(cals), + cpar, + ) + + spreads = np.empty(full_indices.size, dtype=np.float64) + leave_one_out_rays = np.empty(full_indices.size, dtype=np.float64) + for local_index, point_index in enumerate(full_indices): + subset_points = [] + subset_rays = [] + for omitted_camera in range(observed_pixels.shape[1]): + keep = [ + cam for cam in range(observed_pixels.shape[1]) if cam != omitted_camera + ] + subset_observed = observed_pixels[point_index : point_index + 1, keep, :] + subset_cals = [cals[cam] for cam in keep] + subset_point, subset_ray = initialize_bundle_adjustment_points( + subset_observed, + subset_cals, + cpar, + ) + subset_points.append(subset_point[0]) + subset_rays.append(float(subset_ray[0])) + + subset_stack = np.asarray(subset_points, dtype=np.float64) + spreads[local_index] = float( + np.max(np.linalg.norm(subset_stack - full_points[local_index], axis=1)) + ) + leave_one_out_rays[local_index] = float(np.mean(subset_rays)) + + worst_order = np.argsort(spreads)[::-1][:5] + return QuadrupletSensitivitySummary( + num_points=int(full_indices.size), + mean_spread=float(np.mean(spreads)), + median_spread=float(np.median(spreads)), + p95_spread=float(np.percentile(spreads, 95.0)), + max_spread=float(np.max(spreads)), + mean_full_ray_convergence=float(np.mean(full_ray_convergence)), + mean_leave_one_out_ray_convergence=float(np.mean(leave_one_out_rays)), + worst_point_indices=[ + int(full_indices[index]) for index in worst_order.tolist() + ], + ) + + +def format_quadruplet_sensitivity( + baseline: QuadrupletSensitivitySummary, + final: QuadrupletSensitivitySummary, +) -> str: + """Render before/after quadruplet sensitivity diagnostics.""" + headers = ( + "phase", + "points", + "mean_spread", + "median_spread", + "p95_spread", + "max_spread", + "mean_full_ray", + "mean_loo_ray", + "worst_points", + ) + data = [] + for phase, summary in (("baseline", baseline), ("final", final)): + data.append( + [ + phase, + str(summary.num_points), + f"{summary.mean_spread:.6f}", + f"{summary.median_spread:.6f}", + f"{summary.p95_spread:.6f}", + f"{summary.max_spread:.6f}", + f"{summary.mean_full_ray_convergence:.6f}", + f"{summary.mean_leave_one_out_ray_convergence:.6f}", + ",".join(str(index) for index in summary.worst_point_indices), + ] + ) + + widths = [len(header) for header in headers] + for row in data: + for index, value in enumerate(row): + widths[index] = max(widths[index], len(value)) + + def render_row(values: List[str]) -> str: + return " ".join( + value.ljust(widths[index]) for index, value in enumerate(values) + ) + + separator = " ".join("-" * width for width in widths) + lines = [render_row(list(headers)), separator] + lines.extend(render_row(row) for row in data) + return "\n".join(lines) + + +def summarize_fixed_camera_diagnostics( + results: Sequence[ExperimentResult], +) -> List[FixedCameraDiagnostic]: + """Collapse experiment results into anchor-pair diagnostic metrics.""" + diagnostics = [] + for result in results: + if len(result.fixed_camera_indices) != 2: + raise ValueError( + "Fixed-camera diagnostics require exactly two fixed cameras" + ) + + fixed_indices = set(result.fixed_camera_indices) + fixed_position_shifts = [ + result.camera_position_shifts[index] + for index in result.fixed_camera_indices + ] + fixed_angle_shifts = [ + result.camera_angle_shifts[index] for index in result.fixed_camera_indices + ] + free_position_shifts = [ + shift + for index, shift in enumerate(result.camera_position_shifts) + if index not in fixed_indices + ] + free_angle_shifts = [ + shift + for index, shift in enumerate(result.camera_angle_shifts) + if index not in fixed_indices + ] + + diagnostics.append( + FixedCameraDiagnostic( + fixed_camera_indices=cast(tuple[int, int], result.fixed_camera_indices), + final_rms=result.final_rms, + final_ray_convergence=result.final_ray_convergence, + fixed_position_shift=max(fixed_position_shifts, default=0.0), + fixed_angle_shift=max(fixed_angle_shifts, default=0.0), + mean_free_position_shift=float(np.mean(free_position_shifts)), + max_free_position_shift=max(free_position_shifts, default=0.0), + mean_free_angle_shift=float(np.mean(free_angle_shifts)), + notes=result.notes, + ) + ) + + diagnostics.sort( + key=lambda item: ( + item.fixed_position_shift, + item.fixed_angle_shift, + item.final_rms, + item.final_ray_convergence, + ) + ) + return diagnostics + + +def normalize_staged_release_order( + staged_release_order: Sequence[int] | None, + num_cams: int, +) -> List[int]: + """Return a validated zero-based staged camera release order.""" + if staged_release_order is None: + order = list(range(num_cams)) + else: + order = [int(camera_index) for camera_index in staged_release_order] + + if len(order) != num_cams: + raise ValueError( + f"staged_release_order must contain exactly {num_cams} cameras" + ) + if sorted(order) != list(range(num_cams)): + raise ValueError( + "staged_release_order must be a permutation of zero-based camera indices" + ) + return order + + +def default_experiments( + *, + num_cams: int = 4, + known_points: Dict[int, np.ndarray] | None = None, + known_point_sigmas: float | np.ndarray | None = None, + perturbation_scale: float = 1.0, + staged_release_order: Sequence[int] | None = None, + pose_stage_ray_slack: float = 0.0, + geometry_guard_mode: str = "off", + geometry_guard_threshold: float | None = None, + correspondence_guard_mode: str = "off", + correspondence_guard_threshold: float | None = None, + correspondence_guard_reference_rate: float | None = None, +) -> List[ExperimentSpec]: + """Return a set of representative BA configurations.""" + staged_order = normalize_staged_release_order(staged_release_order, num_cams) + staged_fixed = [ + camera_index + for camera_index in range(num_cams) + if camera_index != staged_order[0] + ] + pose_priors = { + "x0": 0.5, + "y0": 0.5, + "z0": 0.5, + "omega": 0.005, + "phi": 0.005, + "kappa": 0.005, + } + pose_bounds = { + "x0": (-2.0, 2.0), + "y0": (-2.0, 2.0), + "z0": (-2.0, 2.0), + "omega": (-0.02, 0.02), + "phi": (-0.02, 0.02), + "kappa": (-0.02, 0.02), + } + conservative_pose_stage_configs = [ + { + "prior_sigmas": { + "x0": 0.05, + "y0": 0.05, + "z0": 0.05, + "omega": 5e-4, + "phi": 5e-4, + "kappa": 5e-4, + }, + "parameter_bounds": { + "x0": (-0.1, 0.1), + "y0": (-0.1, 0.1), + "z0": (-0.1, 0.1), + "omega": (-0.002, 0.002), + "phi": (-0.002, 0.002), + "kappa": (-0.002, 0.002), + }, + "max_nfev": 4, + "optimize_points": False, + "x_scale": { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + "omega": 2e-4, + "phi": 2e-4, + "kappa": 2e-4, + }, + }, + { + "prior_sigmas": { + "x0": 0.1, + "y0": 0.1, + "z0": 0.1, + "omega": 1e-3, + "phi": 1e-3, + "kappa": 1e-3, + }, + "parameter_bounds": { + "x0": (-0.2, 0.2), + "y0": (-0.2, 0.2), + "z0": (-0.2, 0.2), + "omega": (-0.004, 0.004), + "phi": (-0.004, 0.004), + "kappa": (-0.004, 0.004), + }, + "max_nfev": 4, + "optimize_points": True, + "x_scale": { + "x0": 0.03, + "y0": 0.03, + "z0": 0.03, + "omega": 3e-4, + "phi": 3e-4, + "kappa": 3e-4, + }, + }, + { + "prior_sigmas": pose_priors, + "parameter_bounds": pose_bounds, + "max_nfev": 8, + "optimize_points": True, + "x_scale": { + "x0": 0.05, + "y0": 0.05, + "z0": 0.05, + "omega": 5e-4, + "phi": 5e-4, + "kappa": 5e-4, + "points": 0.1, + }, + }, + ] + intrinsics_first_pose_stage_configs = [ + { + "prior_sigmas": { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + "omega": 2e-4, + "phi": 2e-4, + "kappa": 2e-4, + }, + "parameter_bounds": { + "x0": (-0.05, 0.05), + "y0": (-0.05, 0.05), + "z0": (-0.05, 0.05), + "omega": (-0.001, 0.001), + "phi": (-0.001, 0.001), + "kappa": (-0.001, 0.001), + }, + "max_nfev": 4, + "optimize_points": False, + "x_scale": { + "x0": 0.01, + "y0": 0.01, + "z0": 0.01, + "omega": 1e-4, + "phi": 1e-4, + "kappa": 1e-4, + }, + }, + { + "prior_sigmas": { + "x0": 0.05, + "y0": 0.05, + "z0": 0.05, + "omega": 5e-4, + "phi": 5e-4, + "kappa": 5e-4, + }, + "parameter_bounds": { + "x0": (-0.1, 0.1), + "y0": (-0.1, 0.1), + "z0": (-0.1, 0.1), + "omega": (-0.002, 0.002), + "phi": (-0.002, 0.002), + "kappa": (-0.002, 0.002), + }, + "max_nfev": 4, + "optimize_points": True, + "x_scale": { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + "omega": 2e-4, + "phi": 2e-4, + "kappa": 2e-4, + "points": 0.1, + }, + }, + ] + intrinsics_first_alternating_pose_block_configs = [ + { + "name": "points_only", + "optimize_extrinsics": False, + "optimize_points": True, + "max_nfev": 4, + "x_scale": { + "points": 0.1, + }, + }, + { + "name": "omega_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "frozen_parameters": ["phi", "kappa"], + "prior_sigmas": { + "omega": 2e-4, + }, + "parameter_bounds": { + "omega": (-0.001, 0.001), + }, + "max_nfev": 3, + "x_scale": { + "omega": 1e-4, + }, + }, + { + "name": "phi_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "frozen_parameters": ["omega", "kappa"], + "prior_sigmas": { + "phi": 2e-4, + }, + "parameter_bounds": { + "phi": (-0.001, 0.001), + }, + "max_nfev": 3, + "x_scale": { + "phi": 1e-4, + }, + }, + { + "name": "kappa_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "frozen_parameters": ["omega", "phi"], + "prior_sigmas": { + "kappa": 2e-4, + }, + "parameter_bounds": { + "kappa": (-0.001, 0.001), + }, + "max_nfev": 3, + "x_scale": { + "kappa": 1e-4, + }, + }, + { + "name": "translation_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_rotation": True, + "prior_sigmas": { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + }, + "parameter_bounds": { + "x0": (-0.05, 0.05), + "y0": (-0.05, 0.05), + "z0": (-0.05, 0.05), + }, + "max_nfev": 4, + "x_scale": { + "x0": 0.01, + "y0": 0.01, + "z0": 0.01, + }, + }, + { + "name": "joint_pose_points", + "optimize_extrinsics": True, + "optimize_points": True, + "prior_sigmas": { + "x0": 0.05, + "y0": 0.05, + "z0": 0.05, + "omega": 5e-4, + "phi": 5e-4, + "kappa": 5e-4, + }, + "parameter_bounds": { + "x0": (-0.1, 0.1), + "y0": (-0.1, 0.1), + "z0": (-0.1, 0.1), + "omega": (-0.002, 0.002), + "phi": (-0.002, 0.002), + "kappa": (-0.002, 0.002), + }, + "max_nfev": 4, + "x_scale": { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + "omega": 2e-4, + "phi": 2e-4, + "kappa": 2e-4, + "points": 0.1, + }, + }, + ] + known_point_pose_stage_configs = [ + { + **stage_config, + "optimize_points": True, + } + for stage_config in conservative_pose_stage_configs + ] + tight_intrinsic_priors = { + "k1": 1e-12, + "k2": 1e-12, + "k3": 1e-12, + "p1": 1e-12, + "p2": 1e-12, + "scx": 1e-12, + "she": 1e-12, + "cc": 1e-12, + "xh": 1e-12, + "yh": 1e-12, + } + tight_intrinsic_bounds = { + "k1": (-1e-10, 1e-10), + "k2": (-1e-10, 1e-10), + "k3": (-1e-10, 1e-10), + "p1": (-1e-10, 1e-10), + "p2": (-1e-10, 1e-10), + "scx": (-1e-12, 1e-12), + "she": (-1e-12, 1e-12), + "cc": (-1e-12, 1e-12), + "xh": (-1e-12, 1e-12), + "yh": (-1e-12, 1e-12), + } + + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + + intrinsics_only = OrientPar() + intrinsics_only.k1flag = 1 + intrinsics_only.p1flag = 1 + intrinsics_only.p2flag = 1 + + experiments = [ + ExperimentSpec( + name="pose_trf_linear", + description="Pose-only BA with linear loss and TRF", + mode="multi", + ba_kwargs={ + "orient_par": OrientPar(), + "fixed_camera_indices": [0, 1], + "loss": "linear", + "method": "trf", + "prior_sigmas": pose_priors, + "parameter_bounds": pose_bounds, + "max_nfev": 12, + }, + ), + ExperimentSpec( + name="pose_soft_l1", + description="Pose-only BA with robust soft_l1 loss", + mode="multi", + ba_kwargs={ + "orient_par": OrientPar(), + "fixed_camera_indices": [0, 1], + "loss": "soft_l1", + "f_scale": 1.0, + "method": "trf", + "prior_sigmas": pose_priors, + "parameter_bounds": pose_bounds, + "max_nfev": 12, + }, + ), + ExperimentSpec( + name="pose_fixed_points", + description="Pose-only BA with fixed 3D points", + mode="multi", + ba_kwargs={ + "orient_par": OrientPar(), + "fixed_camera_indices": [0, 1], + "loss": "linear", + "method": "trf", + "prior_sigmas": pose_priors, + "parameter_bounds": pose_bounds, + "max_nfev": 12, + "optimize_points": False, + }, + ), + ExperimentSpec( + name="intrinsics_only", + description=( + "Intrinsics-only BA from fixed reference poses with tightly " + "bounded distortion updates" + ), + mode="multi", + ba_kwargs={ + "orient_par": intrinsics_only, + "fixed_camera_indices": list(range(num_cams)), + "loss": "linear", + "method": "trf", + "prior_sigmas": { + "k1": 5e-5, + "p1": 1e-4, + "p2": 1e-4, + }, + "parameter_bounds": { + "k1": (-5e-5, 5e-5), + "p1": (-2e-4, 2e-4), + "p2": (-2e-4, 2e-4), + }, + "max_nfev": 40, + "optimize_extrinsics": False, + "optimize_points": False, + "use_reference_cals": True, + "perturb_intrinsics_only": True, + "intrinsic_perturbation_scale": perturbation_scale, + }, + ), + ExperimentSpec( + name="guarded_two_step", + description="Pose stage followed by tightly constrained intrinsic stage", + mode="guarded", + ba_kwargs={ + "pose_orient_par": OrientPar(), + "intrinsic_orient_par": intrinsics, + "fixed_camera_indices": [0, 1], + "pose_prior_sigmas": pose_priors, + "pose_parameter_bounds": pose_bounds, + "pose_max_nfev": 8, + "pose_x_scale": { + "x0": 0.05, + "y0": 0.05, + "z0": 0.05, + "omega": 5e-4, + "phi": 5e-4, + "kappa": 5e-4, + "points": 0.1, + }, + "pose_stage_configs": conservative_pose_stage_configs, + "intrinsic_prior_sigmas": tight_intrinsic_priors, + "intrinsic_parameter_bounds": tight_intrinsic_bounds, + "intrinsic_max_nfev": 4, + "geometry_guard_mode": geometry_guard_mode, + "geometry_guard_threshold": geometry_guard_threshold, + "correspondence_guard_mode": correspondence_guard_mode, + "correspondence_guard_threshold": correspondence_guard_threshold, + "correspondence_guard_reference_rate": correspondence_guard_reference_rate, + }, + ), + ExperimentSpec( + name="guarded_stagewise_release", + description="Guarded BA that releases one camera at a time before tightly constrained intrinsics", + mode="guarded", + ba_kwargs={ + "pose_orient_par": OrientPar(), + "intrinsic_orient_par": intrinsics, + "fixed_camera_indices": staged_fixed, + "pose_release_camera_order": staged_order, + "pose_stage_ray_slack": pose_stage_ray_slack, + "pose_prior_sigmas": pose_priors, + "pose_parameter_bounds": pose_bounds, + "pose_max_nfev": 8, + "pose_x_scale": { + "x0": 0.05, + "y0": 0.05, + "z0": 0.05, + "omega": 5e-4, + "phi": 5e-4, + "kappa": 5e-4, + "points": 0.1, + }, + "pose_stage_configs": conservative_pose_stage_configs, + "intrinsic_prior_sigmas": tight_intrinsic_priors, + "intrinsic_parameter_bounds": tight_intrinsic_bounds, + "intrinsic_max_nfev": 4, + "geometry_guard_mode": geometry_guard_mode, + "geometry_guard_threshold": geometry_guard_threshold, + "correspondence_guard_mode": correspondence_guard_mode, + "correspondence_guard_threshold": correspondence_guard_threshold, + "correspondence_guard_reference_rate": correspondence_guard_reference_rate, + }, + ), + ExperimentSpec( + name="intrinsics_first_guarded_stagewise_release", + description=( + "Intrinsics-only warm start followed by a tiny guarded " + "stagewise pose release" + ), + mode="intrinsics_then_guarded", + ba_kwargs={ + "warmstart_orient_par": intrinsics_only, + "warmstart_fixed_camera_indices": list(range(num_cams)), + "warmstart_loss": "linear", + "warmstart_method": "trf", + "warmstart_prior_sigmas": { + "k1": 5e-5, + "p1": 1e-4, + "p2": 1e-4, + }, + "warmstart_parameter_bounds": { + "k1": (-5e-5, 5e-5), + "p1": (-2e-4, 2e-4), + "p2": (-2e-4, 2e-4), + }, + "warmstart_max_nfev": 40, + "warmstart_optimize_extrinsics": False, + "warmstart_optimize_points": False, + "pose_orient_par": OrientPar(), + "intrinsic_orient_par": intrinsics, + "fixed_camera_indices": staged_fixed, + "pose_release_camera_order": staged_order, + "pose_stage_ray_slack": pose_stage_ray_slack, + "pose_prior_sigmas": pose_priors, + "pose_parameter_bounds": pose_bounds, + "pose_max_nfev": 4, + "pose_x_scale": { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + "omega": 2e-4, + "phi": 2e-4, + "kappa": 2e-4, + "points": 0.1, + }, + "pose_stage_configs": intrinsics_first_pose_stage_configs, + "intrinsic_prior_sigmas": tight_intrinsic_priors, + "intrinsic_parameter_bounds": tight_intrinsic_bounds, + "intrinsic_max_nfev": 4, + "geometry_guard_mode": geometry_guard_mode, + "geometry_guard_threshold": geometry_guard_threshold, + "correspondence_guard_mode": correspondence_guard_mode, + "correspondence_guard_threshold": correspondence_guard_threshold, + "correspondence_guard_reference_rate": correspondence_guard_reference_rate, + }, + ), + ExperimentSpec( + name="intrinsics_first_alternating_stagewise_release", + description=( + "Intrinsics-only warm start followed by alternating " + "point/rotation/translation guarded updates" + ), + mode="alternating", + ba_kwargs={ + "pose_orient_par": OrientPar(), + "intrinsic_orient_par": intrinsics, + "fixed_camera_indices": staged_fixed, + "pose_release_camera_order": staged_order, + "pose_stage_ray_slack": pose_stage_ray_slack, + "pose_prior_sigmas": pose_priors, + "pose_parameter_bounds": pose_bounds, + "pose_max_nfev": 4, + "pose_x_scale": { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + "omega": 2e-4, + "phi": 2e-4, + "kappa": 2e-4, + "points": 0.1, + }, + "pose_block_configs": intrinsics_first_alternating_pose_block_configs, + "intrinsic_prior_sigmas": tight_intrinsic_priors, + "intrinsic_parameter_bounds": tight_intrinsic_bounds, + "intrinsic_max_nfev": 4, + "geometry_guard_mode": geometry_guard_mode, + "geometry_guard_threshold": geometry_guard_threshold, + "first_release_geometry_slack": 0.35, + "correspondence_guard_mode": correspondence_guard_mode, + "correspondence_guard_threshold": correspondence_guard_threshold, + "correspondence_guard_reference_rate": correspondence_guard_reference_rate, + "first_release_correspondence_slack": 0.02, + }, + ), + ] + + if known_points: + experiments.extend( + [ + ExperimentSpec( + name="pose_trf_known_points", + description="Pose-only BA with soft known-point geometry anchors", + mode="multi", + ba_kwargs={ + "orient_par": OrientPar(), + "fixed_camera_indices": [0, 1], + "loss": "linear", + "method": "trf", + "prior_sigmas": pose_priors, + "parameter_bounds": pose_bounds, + "max_nfev": 12, + "known_points": known_points, + "known_point_sigmas": known_point_sigmas, + }, + ), + ExperimentSpec( + name="guarded_two_step_known_points", + description="Guarded two-step BA with soft known-point geometry anchors", + mode="guarded", + ba_kwargs={ + "pose_orient_par": OrientPar(), + "intrinsic_orient_par": intrinsics, + "fixed_camera_indices": [0, 1], + "pose_prior_sigmas": pose_priors, + "pose_parameter_bounds": pose_bounds, + "pose_max_nfev": 8, + "pose_x_scale": { + "x0": 0.05, + "y0": 0.05, + "z0": 0.05, + "omega": 5e-4, + "phi": 5e-4, + "kappa": 5e-4, + }, + "pose_stage_configs": known_point_pose_stage_configs, + "intrinsic_prior_sigmas": tight_intrinsic_priors, + "intrinsic_parameter_bounds": tight_intrinsic_bounds, + "intrinsic_max_nfev": 4, + "known_points": known_points, + "known_point_sigmas": known_point_sigmas, + "geometry_guard_mode": geometry_guard_mode, + "geometry_guard_threshold": geometry_guard_threshold, + "correspondence_guard_mode": correspondence_guard_mode, + "correspondence_guard_threshold": correspondence_guard_threshold, + "correspondence_guard_reference_rate": correspondence_guard_reference_rate, + }, + ), + ExperimentSpec( + name="guarded_stagewise_release_known_points", + description="Stagewise guarded BA with soft known-point geometry anchors", + mode="guarded", + ba_kwargs={ + "pose_orient_par": OrientPar(), + "intrinsic_orient_par": intrinsics, + "fixed_camera_indices": staged_fixed, + "pose_release_camera_order": staged_order, + "pose_stage_ray_slack": pose_stage_ray_slack, + "pose_prior_sigmas": pose_priors, + "pose_parameter_bounds": pose_bounds, + "pose_max_nfev": 8, + "pose_x_scale": { + "x0": 0.05, + "y0": 0.05, + "z0": 0.05, + "omega": 5e-4, + "phi": 5e-4, + "kappa": 5e-4, + "points": 0.1, + }, + "pose_stage_configs": known_point_pose_stage_configs, + "intrinsic_prior_sigmas": tight_intrinsic_priors, + "intrinsic_parameter_bounds": tight_intrinsic_bounds, + "intrinsic_max_nfev": 4, + "known_points": known_points, + "known_point_sigmas": known_point_sigmas, + "geometry_guard_mode": geometry_guard_mode, + "geometry_guard_threshold": geometry_guard_threshold, + "correspondence_guard_mode": correspondence_guard_mode, + "correspondence_guard_threshold": correspondence_guard_threshold, + "correspondence_guard_reference_rate": correspondence_guard_reference_rate, + }, + ), + ] + ) + + return experiments + + +def ensure_output_case_layout(source_case_dir: Path, output_case_dir: Path) -> Path: + """Copy the source case and return the writable calibration directory.""" + shutil.copytree(source_case_dir, output_case_dir, dirs_exist_ok=True) + cal_dir = output_case_dir / "cal" + cal_dir.mkdir(parents=True, exist_ok=True) + return cal_dir + + +def write_calibration_folder(cals: List[Calibration], cal_dir: Path) -> None: + """Write one calibration folder using OpenPTV naming conventions.""" + cal_dir.mkdir(parents=True, exist_ok=True) + for cam_num, cal in enumerate(cals, start=1): + stem = f"cam{cam_num}.tif" + write_calibration(cal, cal_dir / f"{stem}.ori", cal_dir / f"{stem}.addpar") + + +def run_experiment( + spec: ExperimentSpec, + *, + observed_pixels: np.ndarray, + point_init: np.ndarray, + control: ControlPar, + start_cals: List[Calibration], + reference_cals: List[Calibration], + reference_geometry_points: np.ndarray | None, + tracking_data: ObservationTrackingData | None, + geometry_export_threshold: float | None, + correspondence_export_threshold: float | None, + source_case_dir: Path, + output_dir: Path | None, +) -> ExperimentResult: + """Execute one experiment and collect metrics and optional outputs.""" + working_cals = build_experiment_start_calibrations( + spec, + start_cals=start_cals, + reference_cals=reference_cals, + ) + fixed_camera_indices = tuple( + cast(List[int] | None, spec.ba_kwargs.get("fixed_camera_indices")) or [] + ) + baseline_rms = reprojection_rms(observed_pixels, point_init, working_cals, control) + baseline_ray = mean_ray_convergence(observed_pixels, working_cals, control) + + start = perf_counter() + notes = "" + if spec.mode == "multi": + orient_par = cast(OrientPar, spec.ba_kwargs["orient_par"]) + refined_cals, refined_points, result = multi_camera_bundle_adjustment( + observed_pixels, + working_cals, + control, + orient_par, + point_init=point_init, + fix_first_camera=cast(bool, spec.ba_kwargs.get("fix_first_camera", True)), + fixed_camera_indices=cast( + List[int] | None, + spec.ba_kwargs.get("fixed_camera_indices"), + ), + loss=cast(str, spec.ba_kwargs.get("loss", "soft_l1")), + f_scale=cast(float, spec.ba_kwargs.get("f_scale", 1.0)), + method=cast(str, spec.ba_kwargs.get("method", "trf")), + prior_sigmas=cast( + Dict[str, float] | None, + spec.ba_kwargs.get("prior_sigmas"), + ), + parameter_bounds=cast( + Dict[str, tuple[float, float]] | None, + spec.ba_kwargs.get("parameter_bounds"), + ), + max_nfev=cast(int | None, spec.ba_kwargs.get("max_nfev")), + optimize_extrinsics=cast( + bool, + spec.ba_kwargs.get("optimize_extrinsics", True), + ), + optimize_points=cast(bool, spec.ba_kwargs.get("optimize_points", True)), + known_points=cast( + Dict[int, np.ndarray] | None, + spec.ba_kwargs.get("known_points"), + ), + known_point_sigmas=cast( + float | np.ndarray | None, + spec.ba_kwargs.get("known_point_sigmas"), + ), + x_scale=cast( + float | Sequence[float] | Dict[str, float] | None, + spec.ba_kwargs.get("x_scale"), + ), + ftol=cast(float | None, spec.ba_kwargs.get("ftol")), + xtol=cast(float | None, spec.ba_kwargs.get("xtol")), + gtol=cast(float | None, spec.ba_kwargs.get("gtol")), + ) + success = bool(result.success) + final_rms = float(result["final_reprojection_rms"]) + final_ray = mean_ray_convergence(observed_pixels, refined_cals, control) + notes = str(result.message) + elif spec.mode == "guarded": + pose_orient_par = cast(OrientPar, spec.ba_kwargs["pose_orient_par"]) + intrinsic_orient_par = cast(OrientPar, spec.ba_kwargs["intrinsic_orient_par"]) + refined_cals, refined_points, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + working_cals, + control, + pose_orient_par, + intrinsic_orient_par, + point_init=point_init, + fixed_camera_indices=cast( + List[int] | None, + spec.ba_kwargs.get("fixed_camera_indices"), + ), + pose_release_camera_order=cast( + List[int] | None, + spec.ba_kwargs.get("pose_release_camera_order"), + ), + pose_stage_ray_slack=cast( + float, + spec.ba_kwargs.get("pose_stage_ray_slack", 0.0), + ), + pose_prior_sigmas=cast( + Dict[str, float] | None, + spec.ba_kwargs.get("pose_prior_sigmas"), + ), + pose_parameter_bounds=cast( + Dict[str, tuple[float, float]] | None, + spec.ba_kwargs.get("pose_parameter_bounds"), + ), + pose_loss=cast(str, spec.ba_kwargs.get("pose_loss", "linear")), + pose_method=cast(str, spec.ba_kwargs.get("pose_method", "trf")), + pose_max_nfev=cast(int | None, spec.ba_kwargs.get("pose_max_nfev")), + pose_x_scale=cast( + float | Sequence[float] | Dict[str, float] | None, + spec.ba_kwargs.get("pose_x_scale"), + ), + pose_stage_configs=cast( + Sequence[Dict[str, object]] | None, + spec.ba_kwargs.get("pose_stage_configs"), + ), + intrinsic_prior_sigmas=cast( + Dict[str, float] | None, + spec.ba_kwargs.get("intrinsic_prior_sigmas"), + ), + intrinsic_parameter_bounds=cast( + Dict[str, tuple[float, float]] | None, + spec.ba_kwargs.get("intrinsic_parameter_bounds"), + ), + intrinsic_loss=cast( + str, + spec.ba_kwargs.get("intrinsic_loss", "linear"), + ), + intrinsic_method=cast( + str, + spec.ba_kwargs.get("intrinsic_method", "trf"), + ), + intrinsic_max_nfev=cast( + int | None, + spec.ba_kwargs.get("intrinsic_max_nfev"), + ), + intrinsic_x_scale=cast( + float | Sequence[float] | Dict[str, float] | None, + spec.ba_kwargs.get("intrinsic_x_scale"), + ), + intrinsic_ftol=cast( + float | None, spec.ba_kwargs.get("intrinsic_ftol", 1e-12) + ), + intrinsic_xtol=cast( + float | None, spec.ba_kwargs.get("intrinsic_xtol", 1e-12) + ), + intrinsic_gtol=cast( + float | None, spec.ba_kwargs.get("intrinsic_gtol", 1e-12) + ), + pose_optimize_points=cast( + bool, + spec.ba_kwargs.get("pose_optimize_points", True), + ), + intrinsic_optimize_points=cast( + bool, + spec.ba_kwargs.get("intrinsic_optimize_points", True), + ), + reject_worse_solution=cast( + bool, + spec.ba_kwargs.get("reject_worse_solution", True), + ), + reject_on_ray_convergence=cast( + bool, + spec.ba_kwargs.get("reject_on_ray_convergence", True), + ), + known_points=cast( + Dict[int, np.ndarray] | None, + spec.ba_kwargs.get("known_points"), + ), + known_point_sigmas=cast( + float | np.ndarray | None, + spec.ba_kwargs.get("known_point_sigmas"), + ), + geometry_reference_points=reference_geometry_points, + geometry_reference_cals=reference_cals, + geometry_guard_mode=cast( + str, + spec.ba_kwargs.get("geometry_guard_mode", "off"), + ), + geometry_guard_threshold=cast( + float | None, + spec.ba_kwargs.get("geometry_guard_threshold"), + ), + correspondence_original_ids=( + None if tracking_data is None else tracking_data.original_target_ids + ), + correspondence_point_frame_indices=( + None if tracking_data is None else tracking_data.point_frame_indices + ), + correspondence_frame_target_pixels=( + None if tracking_data is None else tracking_data.frame_target_pixels + ), + correspondence_guard_mode=cast( + str, + spec.ba_kwargs.get("correspondence_guard_mode", "off"), + ), + correspondence_guard_threshold=cast( + float | None, + spec.ba_kwargs.get("correspondence_guard_threshold"), + ), + correspondence_guard_reference_rate=cast( + float | None, + spec.ba_kwargs.get("correspondence_guard_reference_rate"), + ), + ) + success = True + final_rms = cast(float, summary["final_reprojection_rms"]) + final_ray = cast(float, summary["final_mean_ray_convergence"]) + notes = f"accepted_stage={summary['accepted_stage']}" + elif spec.mode == "intrinsics_then_guarded": + warmstart_orient_par = cast(OrientPar, spec.ba_kwargs["warmstart_orient_par"]) + warmstart_cals, warmstart_points, warmstart_result = ( + multi_camera_bundle_adjustment( + observed_pixels, + working_cals, + control, + warmstart_orient_par, + point_init=point_init, + fix_first_camera=cast( + bool, spec.ba_kwargs.get("fix_first_camera", True) + ), + fixed_camera_indices=cast( + List[int] | None, + spec.ba_kwargs.get("warmstart_fixed_camera_indices"), + ), + loss=cast(str, spec.ba_kwargs.get("warmstart_loss", "linear")), + method=cast(str, spec.ba_kwargs.get("warmstart_method", "trf")), + prior_sigmas=cast( + Dict[str, float] | None, + spec.ba_kwargs.get("warmstart_prior_sigmas"), + ), + parameter_bounds=cast( + Dict[str, tuple[float, float]] | None, + spec.ba_kwargs.get("warmstart_parameter_bounds"), + ), + max_nfev=cast(int | None, spec.ba_kwargs.get("warmstart_max_nfev")), + optimize_extrinsics=cast( + bool, + spec.ba_kwargs.get("warmstart_optimize_extrinsics", False), + ), + optimize_points=cast( + bool, + spec.ba_kwargs.get("warmstart_optimize_points", False), + ), + x_scale=cast( + float | Sequence[float] | Dict[str, float] | None, + spec.ba_kwargs.get("warmstart_x_scale"), + ), + ) + ) + pose_orient_par = cast(OrientPar, spec.ba_kwargs["pose_orient_par"]) + intrinsic_orient_par = cast(OrientPar, spec.ba_kwargs["intrinsic_orient_par"]) + refined_cals, refined_points, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + warmstart_cals, + control, + pose_orient_par, + intrinsic_orient_par, + point_init=warmstart_points, + fixed_camera_indices=cast( + List[int] | None, + spec.ba_kwargs.get("fixed_camera_indices"), + ), + pose_release_camera_order=cast( + List[int] | None, + spec.ba_kwargs.get("pose_release_camera_order"), + ), + pose_stage_ray_slack=cast( + float, + spec.ba_kwargs.get("pose_stage_ray_slack", 0.0), + ), + pose_prior_sigmas=cast( + Dict[str, float] | None, + spec.ba_kwargs.get("pose_prior_sigmas"), + ), + pose_parameter_bounds=cast( + Dict[str, tuple[float, float]] | None, + spec.ba_kwargs.get("pose_parameter_bounds"), + ), + pose_loss=cast(str, spec.ba_kwargs.get("pose_loss", "linear")), + pose_method=cast(str, spec.ba_kwargs.get("pose_method", "trf")), + pose_max_nfev=cast(int | None, spec.ba_kwargs.get("pose_max_nfev")), + pose_x_scale=cast( + float | Sequence[float] | Dict[str, float] | None, + spec.ba_kwargs.get("pose_x_scale"), + ), + pose_stage_configs=cast( + Sequence[Dict[str, object]] | None, + spec.ba_kwargs.get("pose_stage_configs"), + ), + intrinsic_prior_sigmas=cast( + Dict[str, float] | None, + spec.ba_kwargs.get("intrinsic_prior_sigmas"), + ), + intrinsic_parameter_bounds=cast( + Dict[str, tuple[float, float]] | None, + spec.ba_kwargs.get("intrinsic_parameter_bounds"), + ), + intrinsic_loss=cast( + str, + spec.ba_kwargs.get("intrinsic_loss", "linear"), + ), + intrinsic_method=cast( + str, + spec.ba_kwargs.get("intrinsic_method", "trf"), + ), + intrinsic_max_nfev=cast( + int | None, + spec.ba_kwargs.get("intrinsic_max_nfev"), + ), + intrinsic_x_scale=cast( + float | Sequence[float] | Dict[str, float] | None, + spec.ba_kwargs.get("intrinsic_x_scale"), + ), + intrinsic_ftol=cast( + float | None, spec.ba_kwargs.get("intrinsic_ftol", 1e-12) + ), + intrinsic_xtol=cast( + float | None, spec.ba_kwargs.get("intrinsic_xtol", 1e-12) + ), + intrinsic_gtol=cast( + float | None, spec.ba_kwargs.get("intrinsic_gtol", 1e-12) + ), + pose_optimize_points=cast( + bool, + spec.ba_kwargs.get("pose_optimize_points", True), + ), + intrinsic_optimize_points=cast( + bool, + spec.ba_kwargs.get("intrinsic_optimize_points", True), + ), + reject_worse_solution=cast( + bool, + spec.ba_kwargs.get("reject_worse_solution", True), + ), + reject_on_ray_convergence=cast( + bool, + spec.ba_kwargs.get("reject_on_ray_convergence", True), + ), + known_points=cast( + Dict[int, np.ndarray] | None, + spec.ba_kwargs.get("known_points"), + ), + known_point_sigmas=cast( + float | np.ndarray | None, + spec.ba_kwargs.get("known_point_sigmas"), + ), + geometry_reference_points=reference_geometry_points, + geometry_reference_cals=reference_cals, + geometry_guard_mode=cast( + str, + spec.ba_kwargs.get("geometry_guard_mode", "off"), + ), + geometry_guard_threshold=cast( + float | None, + spec.ba_kwargs.get("geometry_guard_threshold"), + ), + correspondence_original_ids=( + None if tracking_data is None else tracking_data.original_target_ids + ), + correspondence_point_frame_indices=( + None if tracking_data is None else tracking_data.point_frame_indices + ), + correspondence_frame_target_pixels=( + None if tracking_data is None else tracking_data.frame_target_pixels + ), + correspondence_guard_mode=cast( + str, + spec.ba_kwargs.get("correspondence_guard_mode", "off"), + ), + correspondence_guard_threshold=cast( + float | None, + spec.ba_kwargs.get("correspondence_guard_threshold"), + ), + correspondence_guard_reference_rate=cast( + float | None, + spec.ba_kwargs.get("correspondence_guard_reference_rate"), + ), + ) + warmstart_success = bool( + getattr( + warmstart_result, + "success", + cast(Dict[str, object], warmstart_result).get("success", False), + ) + ) + final_rms = cast(float, summary["final_reprojection_rms"]) + final_ray = cast(float, summary["final_mean_ray_convergence"]) + warmstart_rms = cast( + float, + cast(Dict[str, object], warmstart_result)["final_reprojection_rms"], + ) + success = warmstart_success + notes = ( + f"warmstart_success={warmstart_success}; " + f"warmstart_rms={warmstart_rms:.6f}; " + f"accepted_stage={summary['accepted_stage']}" + ) + elif spec.mode == "alternating": + pose_orient_par = cast(OrientPar, spec.ba_kwargs["pose_orient_par"]) + intrinsic_orient_par = cast(OrientPar, spec.ba_kwargs["intrinsic_orient_par"]) + refined_cals, refined_points, summary = alternating_bundle_adjustment( + observed_pixels, + working_cals, + control, + pose_orient_par, + intrinsic_orient_par, + point_init=point_init, + fixed_camera_indices=cast( + List[int] | None, + spec.ba_kwargs.get("fixed_camera_indices"), + ), + pose_release_camera_order=cast( + List[int] | None, + spec.ba_kwargs.get("pose_release_camera_order"), + ), + pose_stage_ray_slack=cast( + float, + spec.ba_kwargs.get("pose_stage_ray_slack", 0.0), + ), + pose_prior_sigmas=cast( + Dict[str, float] | None, + spec.ba_kwargs.get("pose_prior_sigmas"), + ), + pose_parameter_bounds=cast( + Dict[str, tuple[float, float]] | None, + spec.ba_kwargs.get("pose_parameter_bounds"), + ), + pose_loss=cast(str, spec.ba_kwargs.get("pose_loss", "linear")), + pose_method=cast(str, spec.ba_kwargs.get("pose_method", "trf")), + pose_max_nfev=cast(int | None, spec.ba_kwargs.get("pose_max_nfev")), + pose_x_scale=cast( + float | Sequence[float] | Dict[str, float] | None, + spec.ba_kwargs.get("pose_x_scale"), + ), + pose_block_configs=cast( + Sequence[Dict[str, object]] | None, + spec.ba_kwargs.get("pose_block_configs"), + ), + intrinsic_prior_sigmas=cast( + Dict[str, float] | None, + spec.ba_kwargs.get("intrinsic_prior_sigmas"), + ), + intrinsic_parameter_bounds=cast( + Dict[str, tuple[float, float]] | None, + spec.ba_kwargs.get("intrinsic_parameter_bounds"), + ), + intrinsic_loss=cast( + str, + spec.ba_kwargs.get("intrinsic_loss", "linear"), + ), + intrinsic_method=cast( + str, + spec.ba_kwargs.get("intrinsic_method", "trf"), + ), + intrinsic_max_nfev=cast( + int | None, + spec.ba_kwargs.get("intrinsic_max_nfev"), + ), + intrinsic_x_scale=cast( + float | Sequence[float] | Dict[str, float] | None, + spec.ba_kwargs.get("intrinsic_x_scale"), + ), + intrinsic_ftol=cast( + float | None, spec.ba_kwargs.get("intrinsic_ftol", 1e-12) + ), + intrinsic_xtol=cast( + float | None, spec.ba_kwargs.get("intrinsic_xtol", 1e-12) + ), + intrinsic_gtol=cast( + float | None, spec.ba_kwargs.get("intrinsic_gtol", 1e-12) + ), + known_points=cast( + Dict[int, np.ndarray] | None, + spec.ba_kwargs.get("known_points"), + ), + known_point_sigmas=cast( + float | np.ndarray | None, + spec.ba_kwargs.get("known_point_sigmas"), + ), + geometry_reference_points=reference_geometry_points, + geometry_reference_cals=reference_cals, + geometry_guard_mode=cast( + str, + spec.ba_kwargs.get("geometry_guard_mode", "off"), + ), + geometry_guard_threshold=cast( + float | None, + spec.ba_kwargs.get("geometry_guard_threshold"), + ), + correspondence_original_ids=( + None if tracking_data is None else tracking_data.original_target_ids + ), + correspondence_point_frame_indices=( + None if tracking_data is None else tracking_data.point_frame_indices + ), + correspondence_frame_target_pixels=( + None if tracking_data is None else tracking_data.frame_target_pixels + ), + correspondence_guard_mode=cast( + str, + spec.ba_kwargs.get("correspondence_guard_mode", "off"), + ), + correspondence_guard_threshold=cast( + float | None, + spec.ba_kwargs.get("correspondence_guard_threshold"), + ), + correspondence_guard_reference_rate=cast( + float | None, + spec.ba_kwargs.get("correspondence_guard_reference_rate"), + ), + ) + success = True + final_rms = cast(float, summary["final_reprojection_rms"]) + final_ray = cast(float, summary["final_mean_ray_convergence"]) + notes = ( + f"warmstart_ok={summary['warmstart_ok']}; " + f"accepted_stage={summary['accepted_stage']}" + ) + else: + raise ValueError(f"Unknown experiment mode: {spec.mode}") + + duration_sec = perf_counter() - start + camera_position_shifts, camera_angle_shifts = calibration_pose_shifts( + working_cals, + refined_cals, + ) + geometry_projection_drift = calibration_body_projection_drift( + reference_cals, + refined_cals, + control, + reference_geometry_points, + ) + correspondence_replacement = summarize_correspondence_replacements( + refined_points, + refined_cals, + control, + tracking_data, + ) + export_blocked, export_note = should_block_export_on_geometry( + geometry_projection_drift, + geometry_export_threshold, + ) + correspondence_blocked, correspondence_note = should_block_export_on_correspondence( + correspondence_replacement, + correspondence_export_threshold, + ) + if export_note: + notes = f"{notes}; {export_note}" if notes else export_note + if correspondence_note: + notes = f"{notes}; {correspondence_note}" if notes else correspondence_note + if export_blocked or correspondence_blocked: + success = False + + cal_dir = None + if output_dir is not None and not (export_blocked or correspondence_blocked): + case_out_dir = output_dir / spec.name + cal_dir = ensure_output_case_layout(source_case_dir, case_out_dir) + write_calibration_folder(refined_cals, cal_dir) + comparison = compare_calibration_folders(source_case_dir / "cal", cal_dir) + (case_out_dir / "calibration_delta.txt").write_text( + format_calibration_comparison( + comparison, + reference_dir=source_case_dir / "cal", + candidate_dir=cal_dir, + ) + + "\n", + encoding="utf-8", + ) + (case_out_dir / "geometry_check.txt").write_text( + format_projection_drift(geometry_projection_drift) + "\n", + encoding="utf-8", + ) + (case_out_dir / "correspondence_check.txt").write_text( + format_correspondence_replacement(correspondence_replacement) + "\n", + encoding="utf-8", + ) + + return ExperimentResult( + name=spec.name, + description=spec.description, + duration_sec=duration_sec, + success=success, + initial_rms=baseline_rms, + final_rms=final_rms, + baseline_ray_convergence=baseline_ray, + final_ray_convergence=final_ray, + notes=notes, + cal_dir=cal_dir, + fixed_camera_indices=fixed_camera_indices, + camera_position_shifts=camera_position_shifts, + camera_angle_shifts=camera_angle_shifts, + geometry_projection_drift=geometry_projection_drift, + correspondence_replacement=correspondence_replacement, + refined_cals=refined_cals, + refined_points=refined_points, + ) + + +def format_results(results: Iterable[ExperimentResult]) -> str: + """Render a compact plain-text summary table.""" + rows = list(results) + headers = ( + "name", + "success", + "seconds", + "rms_before", + "rms_after", + "ray_before", + "ray_after", + "notes", + ) + data = [ + [ + row.name, + "yes" if row.success else "no", + f"{row.duration_sec:.2f}", + f"{row.initial_rms:.6f}", + f"{row.final_rms:.6f}", + f"{row.baseline_ray_convergence:.6f}", + f"{row.final_ray_convergence:.6f}", + row.notes, + ] + for row in rows + ] + widths = [len(header) for header in headers] + for row in data: + for index, value in enumerate(row): + widths[index] = max(widths[index], len(value)) + + def render_row(values: List[str]) -> str: + return " ".join( + value.ljust(widths[index]) for index, value in enumerate(values) + ) + + separator = " ".join("-" * width for width in widths) + lines = [render_row(list(headers)), separator] + lines.extend(render_row(row) for row in data) + return "\n".join(lines) + + +def format_fixed_camera_diagnostics( + diagnostics: Sequence[FixedCameraDiagnostic], +) -> str: + """Render a compact plain-text summary of anchor-pair diagnostics.""" + headers = ( + "fixed_pair", + "rms_after", + "ray_after", + "fixed_pos_shift", + "fixed_ang_shift", + "mean_free_pos", + "max_free_pos", + "mean_free_ang", + "notes", + ) + data = [ + [ + f"{item.fixed_camera_indices[0] + 1},{item.fixed_camera_indices[1] + 1}", + f"{item.final_rms:.6f}", + f"{item.final_ray_convergence:.6f}", + f"{item.fixed_position_shift:.6e}", + f"{item.fixed_angle_shift:.6e}", + f"{item.mean_free_position_shift:.6f}", + f"{item.max_free_position_shift:.6f}", + f"{item.mean_free_angle_shift:.6f}", + item.notes, + ] + for item in diagnostics + ] + widths = [len(header) for header in headers] + for row in data: + for index, value in enumerate(row): + widths[index] = max(widths[index], len(value)) + + def render_row(values: List[str]) -> str: + return " ".join( + value.ljust(widths[index]) for index, value in enumerate(values) + ) + + separator = " ".join("-" * width for width in widths) + lines = [render_row(list(headers)), separator] + lines.extend(render_row(row) for row in data) + return "\n".join(lines) + + +def summarize_anchor_participation( + diagnostics: Sequence[FixedCameraDiagnostic], + num_cams: int, +) -> str: + """Aggregate anchor-pair diagnostics by camera participation.""" + headers = ( + "camera", + "pair_count", + "avg_rms", + "avg_ray", + "avg_free_pos", + "avg_free_ang", + ) + rows = [] + for camera_index in range(num_cams): + participating = [ + item for item in diagnostics if camera_index in item.fixed_camera_indices + ] + if not participating: + continue + rows.append( + [ + str(camera_index + 1), + str(len(participating)), + f"{np.mean([item.final_rms for item in participating]):.6f}", + f"{np.mean([item.final_ray_convergence for item in participating]):.6f}", + f"{np.mean([item.mean_free_position_shift for item in participating]):.6f}", + f"{np.mean([item.mean_free_angle_shift for item in participating]):.6f}", + ] + ) + + widths = [len(header) for header in headers] + for row in rows: + for index, value in enumerate(row): + widths[index] = max(widths[index], len(value)) + + def render_row(values: List[str]) -> str: + return " ".join( + value.ljust(widths[index]) for index, value in enumerate(values) + ) + + separator = " ".join("-" * width for width in widths) + lines = [render_row(list(headers)), separator] + lines.extend(render_row(row) for row in rows) + return "\n".join(lines) + + +def find_experiment_spec( + experiments: Sequence[ExperimentSpec], + name: str, +) -> ExperimentSpec: + """Return one experiment by name.""" + for spec in experiments: + if spec.name == name: + return spec + raise ValueError(f"Unknown experiment: {name}") + + +def run_fixed_camera_pair_diagnostics( + spec: ExperimentSpec, + *, + observed_pixels: np.ndarray, + point_init: np.ndarray, + control: ControlPar, + start_cals: List[Calibration], + reference_cals: List[Calibration], + reference_geometry_points: np.ndarray | None, + tracking_data: ObservationTrackingData | None, + geometry_export_threshold: float | None, + correspondence_export_threshold: float | None, + source_case_dir: Path, +) -> List[FixedCameraDiagnostic]: + """Run one experiment over every two-camera anchor pair and summarize results.""" + diagnostic_results = [] + for fixed_pair in all_fixed_camera_pairs(len(start_cals)): + pair_kwargs = dict(spec.ba_kwargs) + pair_kwargs["fixed_camera_indices"] = list(fixed_pair) + pair_spec = ExperimentSpec( + name=f"{spec.name}_fixed_{fixed_pair[0] + 1}_{fixed_pair[1] + 1}", + description=f"{spec.description} with cameras {fixed_pair[0] + 1} and {fixed_pair[1] + 1} fixed", + mode=spec.mode, + ba_kwargs=pair_kwargs, + ) + diagnostic_results.append( + run_experiment( + pair_spec, + observed_pixels=observed_pixels, + point_init=point_init, + control=control, + start_cals=start_cals, + reference_cals=reference_cals, + reference_geometry_points=reference_geometry_points, + tracking_data=tracking_data, + geometry_export_threshold=geometry_export_threshold, + correspondence_export_threshold=correspondence_export_threshold, + source_case_dir=source_case_dir, + output_dir=None, + ) + ) + + return summarize_fixed_camera_diagnostics(diagnostic_results) + + +def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments for the BA demo.""" + parser = argparse.ArgumentParser( + description="Demonstrate bundle-adjustment options on test_cavity or another compatible case.", + ) + parser.add_argument( + "case_dir", + type=Path, + nargs="?", + default=Path("tests/testing_fodder/test_cavity"), + help="Case folder containing cal/, parameters/, res_orig/, and img_orig/.", + ) + parser.add_argument( + "--max-frames", + type=int, + default=None, + help="Only use the first N frames from sequence.par.", + ) + parser.add_argument( + "--max-points-per-frame", + type=int, + default=80, + help="Only use the first N fully observed points from each frame.", + ) + parser.add_argument( + "--perturbation-scale", + type=float, + default=1.0, + help="Scale factor for the deterministic starting-calibration perturbation.", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("tmp/bundle_adjustment_demo"), + help="Where to write one output case folder per experiment.", + ) + parser.add_argument( + "--skip-write", + action="store_true", + help="Run comparisons but do not write updated calibration folders.", + ) + parser.add_argument( + "--known-points", + type=int, + default=12, + help="Use N evenly spaced input 3D points as soft geometry anchors; 0 disables constrained presets.", + ) + parser.add_argument( + "--known-point-sigma", + type=float, + default=0.25, + help="Standard deviation for each anchored 3D coordinate in object-space units.", + ) + parser.add_argument( + "--diagnose-fixed-pairs", + action="store_true", + help="Run one experiment across every two-camera fixed pair and print anchor-pair diagnostics.", + ) + parser.add_argument( + "--diagnostic-experiment", + type=str, + default="guarded_two_step", + help="Experiment name to use with --diagnose-fixed-pairs.", + ) + parser.add_argument( + "--diagnose-epipolar", + action="store_true", + help="Compare pairwise epipolar consistency before and after the selected diagnostic experiment.", + ) + parser.add_argument( + "--diagnose-quadruplets", + action="store_true", + help=( + "Compare leave-one-camera-out quadruplet stability before and after " + "the selected diagnostic experiment." + ), + ) + parser.add_argument( + "--epipolar-curve-points", + type=int, + default=64, + help="Number of points sampled along each epipolar curve for the diagnostic distance calculation.", + ) + parser.add_argument( + "--geometry-guard-mode", + type=str, + choices=("auto", "off", "soft", "hard"), + default="auto", + help=( + "Guarded-BA geometry acceptance mode. 'auto' uses 'hard' when " + "cal_ori.par exposes known 3D target points." + ), + ) + parser.add_argument( + "--geometry-guard-threshold", + type=float, + default=2.5, + help="Maximum allowed calibration-body projection drift in pixels for hard geometry guards.", + ) + parser.add_argument( + "--geometry-export-threshold", + type=float, + default=None, + help=( + "Do not write output case folders whose final calibration-body " + "drift exceeds this many pixels; 0 disables export blocking." + ), + ) + parser.add_argument( + "--correspondence-guard-mode", + type=str, + choices=("auto", "off", "soft", "hard"), + default="auto", + help=( + "Guarded-BA correspondence acceptance mode. 'auto' uses a hard " + "replacement-rate guard when original quadruplet identities are " + "available." + ), + ) + parser.add_argument( + "--correspondence-guard-threshold", + type=float, + default=None, + help=( + "Maximum allowed quadruplet replacement rate for hard " + "correspondence guards. If omitted, the demo derives a case-" + "specific threshold from the trusted reference calibration." + ), + ) + parser.add_argument( + "--correspondence-export-threshold", + type=float, + default=None, + help=( + "Do not write output case folders whose final quadruplet " + "replacement rate exceeds this threshold; 0 disables " + "correspondence-based export blocking." + ), + ) + parser.add_argument( + "--staged-release-order", + type=str, + default=None, + help=( + "Comma-separated 1-based camera release order for staged guarded " + "presets, for example '1,2,3,4'." + ), + ) + parser.add_argument( + "--staged-ray-slack", + type=float, + default=0.0, + help=( + "Allow staged guarded pose-release steps to worsen mean ray " + "convergence by up to this amount relative to the last accepted " + "stage." + ), + ) + return parser.parse_args(list(argv) if argv is not None else None) + + +def main(argv: Iterable[str] | None = None) -> int: + """Run the bundle-adjustment demo.""" + args = parse_args(argv) + case_dir = args.case_dir.resolve() + num_cams = discover_num_cams(case_dir / "cal") + + control, observed_pixels, point_init = load_case_observations( + case_dir, + num_cams, + max_frames=args.max_frames, + max_points_per_frame=args.max_points_per_frame, + ) + true_cals = load_calibrations(case_dir, num_cams) + start_cals = perturb_calibrations(true_cals, args.perturbation_scale) + reference_geometry_points = load_reference_geometry_points(case_dir, num_cams) + tracking_data = load_case_tracking_data( + case_dir, + num_cams, + max_frames=args.max_frames, + max_points_per_frame=args.max_points_per_frame, + control=control, + reference_cals=true_cals, + ) + if args.geometry_guard_mode == "auto": + geometry_guard_mode = "hard" if reference_geometry_points is not None else "off" + else: + geometry_guard_mode = args.geometry_guard_mode + geometry_export_threshold = ( + ( + args.geometry_export_threshold + if args.geometry_export_threshold is not None + else args.geometry_guard_threshold + ) + if reference_geometry_points is not None + else None + ) + if args.correspondence_guard_mode == "auto": + correspondence_guard_mode = "hard" if tracking_data is not None else "off" + else: + correspondence_guard_mode = args.correspondence_guard_mode + if ( + tracking_data is not None + and tracking_data.reference_replacement_rate is not None + ): + auto_correspondence_threshold = min( + 0.25, + max(0.05, tracking_data.reference_replacement_rate + 0.02), + ) + else: + auto_correspondence_threshold = None + correspondence_guard_threshold = ( + args.correspondence_guard_threshold + if args.correspondence_guard_threshold is not None + else auto_correspondence_threshold + ) + correspondence_export_threshold = ( + ( + args.correspondence_export_threshold + if args.correspondence_export_threshold is not None + else correspondence_guard_threshold + ) + if tracking_data is not None + else None + ) + if args.staged_release_order is None: + staged_release_order = list(range(num_cams)) + else: + try: + staged_release_order = [ + int(token.strip()) - 1 + for token in args.staged_release_order.split(",") + if token.strip() + ] + except ValueError as exc: + raise ValueError( + "--staged-release-order must be a comma-separated list of integers" + ) from exc + staged_release_order = normalize_staged_release_order( + staged_release_order, + num_cams, + ) + known_points = build_known_point_constraints(point_init, args.known_points) + known_point_sigmas = args.known_point_sigma if known_points else None + + output_dir = None if args.skip_write else args.output_dir.resolve() + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"Case: {case_dir}") + print(f"Cameras: {num_cams}") + print( + f"Observations: {observed_pixels.shape[0]} points across {observed_pixels.shape[1]} cameras" + ) + if known_points: + print( + f"Known-point anchors: {len(known_points)} (sigma={args.known_point_sigma:.3f})" + ) + else: + print("Known-point anchors: disabled") + if reference_geometry_points is not None: + print( + "Geometry guard: " + f"mode={geometry_guard_mode}, " + f"guard_threshold={args.geometry_guard_threshold:.3f}px, " + f"export_threshold={geometry_export_threshold:.3f}px" + ) + else: + print( + "Geometry guard: unavailable for this case (no known 3D target file found)" + ) + if ( + tracking_data is not None + and tracking_data.reference_replacement_rate is not None + ): + print( + "Correspondence guard: " + f"mode={correspondence_guard_mode}, " + f"reference_rate={tracking_data.reference_replacement_rate:.3f}, " + f"guard_threshold={correspondence_guard_threshold:.3f}, " + f"export_threshold={correspondence_export_threshold:.3f}" + ) + else: + print("Correspondence guard: unavailable for this case") + print( + "Staged guarded release: " + f"order={[camera_index + 1 for camera_index in staged_release_order]}, " + f"ray_slack={args.staged_ray_slack:.6f}" + ) + print(f"Output folders: {output_dir if output_dir is not None else 'disabled'}") + print() + + experiments = default_experiments( + num_cams=num_cams, + known_points=known_points or None, + known_point_sigmas=known_point_sigmas, + perturbation_scale=args.perturbation_scale, + staged_release_order=staged_release_order, + pose_stage_ray_slack=args.staged_ray_slack, + geometry_guard_mode=geometry_guard_mode, + geometry_guard_threshold=args.geometry_guard_threshold, + correspondence_guard_mode=correspondence_guard_mode, + correspondence_guard_threshold=correspondence_guard_threshold, + correspondence_guard_reference_rate=( + None if tracking_data is None else tracking_data.reference_replacement_rate + ), + ) + + if args.diagnose_fixed_pairs: + spec = find_experiment_spec(experiments, args.diagnostic_experiment) + diagnostics = run_fixed_camera_pair_diagnostics( + spec, + observed_pixels=observed_pixels, + point_init=point_init, + control=control, + start_cals=start_cals, + reference_cals=true_cals, + reference_geometry_points=reference_geometry_points, + tracking_data=tracking_data, + geometry_export_threshold=geometry_export_threshold, + correspondence_export_threshold=correspondence_export_threshold, + source_case_dir=case_dir, + ) + print(f"Fixed-pair diagnostics for {spec.name}: {spec.description}") + print(format_fixed_camera_diagnostics(diagnostics)) + print() + print("Anchor participation summary") + print(summarize_anchor_participation(diagnostics, num_cams)) + return 0 + + if args.diagnose_epipolar or args.diagnose_quadruplets: + spec = find_experiment_spec(experiments, args.diagnostic_experiment) + diagnostic_start_cals = build_experiment_start_calibrations( + spec, + start_cals=start_cals, + reference_cals=true_cals, + ) + diagnostic_result = run_experiment( + spec, + observed_pixels=observed_pixels, + point_init=point_init, + control=control, + start_cals=start_cals, + reference_cals=true_cals, + reference_geometry_points=reference_geometry_points, + tracking_data=tracking_data, + geometry_export_threshold=geometry_export_threshold, + correspondence_export_threshold=correspondence_export_threshold, + source_case_dir=case_dir, + output_dir=None, + ) + if diagnostic_result.refined_cals is None: + raise ValueError( + "Diagnostic experiment did not return refined calibrations" + ) + + print(f"Diagnostics for {spec.name}: {spec.description}") + print( + f"Result RMS: {diagnostic_result.final_rms:.6f} px, " + f"ray: {diagnostic_result.final_ray_convergence:.6f}, " + f"notes: {diagnostic_result.notes}" + ) + print() + + if args.diagnose_epipolar: + vpar = read_volume_par(case_dir / "parameters/criteria.par") + baseline_epipolar = summarize_epipolar_consistency( + observed_pixels, + diagnostic_start_cals, + control, + vpar, + num_curve_points=args.epipolar_curve_points, + ) + final_epipolar = summarize_epipolar_consistency( + observed_pixels, + diagnostic_result.refined_cals, + control, + vpar, + num_curve_points=args.epipolar_curve_points, + ) + print(f"Epipolar diagnostics against criteria.par eps0={vpar.eps0:.6f}") + print("Baseline") + print(format_epipolar_diagnostics(baseline_epipolar)) + print() + print("Final") + print(format_epipolar_diagnostics(final_epipolar)) + print() + + if args.diagnose_quadruplets: + baseline_quadruplets = summarize_quadruplet_sensitivity( + observed_pixels, + diagnostic_start_cals, + control, + ) + final_quadruplets = summarize_quadruplet_sensitivity( + observed_pixels, + diagnostic_result.refined_cals, + control, + ) + print("Quadruplet leave-one-camera-out sensitivity") + print( + format_quadruplet_sensitivity(baseline_quadruplets, final_quadruplets) + ) + + return 0 + + results = [] + for spec in experiments: + print(f"Running {spec.name}: {spec.description}") + result = run_experiment( + spec, + observed_pixels=observed_pixels, + point_init=point_init, + control=control, + start_cals=start_cals, + reference_cals=true_cals, + reference_geometry_points=reference_geometry_points, + tracking_data=tracking_data, + geometry_export_threshold=geometry_export_threshold, + correspondence_export_threshold=correspondence_export_threshold, + source_case_dir=case_dir, + output_dir=output_dir, + ) + results.append(result) + if result.cal_dir is not None: + print(f" wrote calibration folder: {result.cal_dir}") + print(f" final RMS: {result.final_rms:.6f} px in {result.duration_sec:.2f} s") + print() + + print(format_results(results)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/openptv_python/generate_synthetic_cavity_case.py b/openptv_python/generate_synthetic_cavity_case.py new file mode 100644 index 0000000..f7cab0a --- /dev/null +++ b/openptv_python/generate_synthetic_cavity_case.py @@ -0,0 +1,563 @@ +"""Generate a deterministic synthetic case modeled after test_cavity.""" + +from __future__ import annotations + +import argparse +import json +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Sequence, cast + +import numpy as np + +from .calibration import Calibration, read_calibration, write_calibration +from .imgcoord import image_coordinates +from .orientation import ( + external_calibration, + full_calibration, + initialize_bundle_adjustment_points, + match_detection_to_ref, +) +from .parameters import ControlPar, OrientPar, VolumePar, read_volume_par +from .sortgrid import read_sortgrid_par +from .tracking_frame_buf import ( + Pathinfo, + Target, + n_tupel_dtype, + write_path_frame, + write_targets, +) +from .trafo import arr_metric_to_pixel + +DEFAULT_SOURCE_CASE = Path("tests/testing_fodder/test_cavity") +DEFAULT_OUTPUT_CASE = Path("tests/testing_fodder/test_cavity_synthetic") +DEFAULT_SEED = 20260306 +FRAME_NUMBERS = (10001, 10002) + + +@dataclass(frozen=True) +class SyntheticCaseSummary: + """Metadata describing the generated synthetic case.""" + + seed: int + num_calibration_points: int + num_frames: int + particles_per_frame: int + calibration_position_errors: List[float] + calibration_angle_errors: List[float] + + +def clone_calibration(cal: Calibration) -> Calibration: + """Return a detached calibration copy.""" + return Calibration( + ext_par=cal.ext_par.copy(), + int_par=cal.int_par.copy(), + glass_par=cal.glass_par.copy(), + added_par=cal.added_par.copy(), + mmlut=cal.mmlut, + mmlut_data=cal.mmlut_data, + ) + + +def make_target(x: float, y: float, pnr: int) -> Target: + """Create a simple synthetic target record.""" + return Target( + pnr=pnr, + x=float(x), + y=float(y), + n=21, + nx=5, + ny=5, + sumg=5000, + tnr=-1, + ) + + +def camera_points_in_bounds( + pixel_points: np.ndarray, cpar: ControlPar, margin: float = 8.0 +) -> np.ndarray: + """Return a mask of points lying safely inside the sensor bounds.""" + return ( + (pixel_points[:, 0] >= margin) + & (pixel_points[:, 0] <= cpar.imx - margin) + & (pixel_points[:, 1] >= margin) + & (pixel_points[:, 1] <= cpar.imy - margin) + ) + + +def z_bounds_at_x(x_coord: float, vpar: VolumePar) -> tuple[float, float]: + """Interpolate the admissible z range for one x coordinate.""" + x0, x1 = vpar.x_lay + zmin0, zmin1 = vpar.z_min_lay + zmax0, zmax1 = vpar.z_max_lay + if x1 == x0: + return zmin0, zmax0 + weight = (x_coord - x0) / (x1 - x0) + z_min = zmin0 + weight * (zmin1 - zmin0) + z_max = zmax0 + weight * (zmax1 - zmax0) + return float(z_min), float(z_max) + + +def project_pixels( + points_3d: np.ndarray, cals: Sequence[Calibration], cpar: ControlPar +) -> np.ndarray: + """Project 3D points into all cameras in pixel coordinates.""" + observed = np.empty((points_3d.shape[0], len(cals), 2), dtype=np.float64) + for cam_index, cal in enumerate(cals): + observed[:, cam_index, :] = arr_metric_to_pixel( + image_coordinates(points_3d, cal, cpar.mm), + cpar, + ) + return observed + + +def select_visible_points( + candidate_points: np.ndarray, + cals: Sequence[Calibration], + cpar: ControlPar, + count: int, +) -> np.ndarray: + """Return the first points that project inside every camera image.""" + projected = project_pixels(candidate_points, cals, cpar) + visibility = np.ones(candidate_points.shape[0], dtype=bool) + for cam_index in range(len(cals)): + visibility &= camera_points_in_bounds(projected[:, cam_index, :], cpar) + visible_points = candidate_points[visibility] + if visible_points.shape[0] < count: + raise ValueError("Not enough visible points for the requested synthetic case") + return visible_points[:count] + + +def build_calibration_body( + vpar: VolumePar, cals: Sequence[Calibration], cpar: ControlPar +) -> np.ndarray: + """Build a structured 3D calibration body visible in all cameras.""" + xs = np.linspace(vpar.x_lay[0] + 4.0, vpar.x_lay[1] - 4.0, 8) + ys = np.linspace(-16.0, 16.0, 6) + candidates = [] + for x_coord in xs: + z_min, z_max = z_bounds_at_x(float(x_coord), vpar) + if z_max - z_min < 10.0: + continue + zs = np.linspace(z_min + 4.0, z_max - 4.0, 4) + for y_coord in ys: + for z_coord in zs: + candidates.append([float(x_coord), float(y_coord), float(z_coord)]) + candidate_points = np.asarray(candidates, dtype=np.float64) + return select_visible_points(candidate_points, cals, cpar, count=48) + + +def generate_particle_cloud( + rng: np.random.Generator, + vpar: VolumePar, + cals: Sequence[Calibration], + cpar: ControlPar, + count: int, +) -> np.ndarray: + """Sample random 3D particles inside the observed volume and keep visible quadruplets.""" + accepted: List[np.ndarray] = [] + while len(accepted) < count: + x_coord = float(rng.uniform(vpar.x_lay[0] + 2.0, vpar.x_lay[1] - 2.0)) + z_min, z_max = z_bounds_at_x(x_coord, vpar) + y_coord = float(rng.uniform(-18.0, 18.0)) + z_coord = float(rng.uniform(z_min + 2.0, z_max - 2.0)) + point = np.asarray([[x_coord, y_coord, z_coord]], dtype=np.float64) + projected = project_pixels(point, cals, cpar)[0] + if all( + camera_points_in_bounds(projected[None, cam_index, :], cpar)[0] + for cam_index in range(len(cals)) + ): + accepted.append(point[0]) + return np.asarray(accepted, dtype=np.float64) + + +def shuffled_targets_from_pixels( + pixel_points: np.ndarray, + rng: np.random.Generator, + noise_sigma: float, +) -> tuple[List[Target], np.ndarray]: + """Shuffle projected pixels into a target list and return point-to-target indices.""" + noisy_pixels = pixel_points + rng.normal(0.0, noise_sigma, size=pixel_points.shape) + order = rng.permutation(pixel_points.shape[0]) + targets = [ + make_target( + noisy_pixels[target_index, 0], noisy_pixels[target_index, 1], list_index + ) + for list_index, target_index in enumerate(order) + ] + point_to_target = np.empty(pixel_points.shape[0], dtype=np.int32) + for list_index, target_index in enumerate(order): + point_to_target[target_index] = list_index + return targets, point_to_target + + +def write_calibration_body_points(points: np.ndarray, output_file: Path) -> None: + """Write a calblock-compatible calibration body file.""" + lines = [ + f"{index + 1:11d}{point[0]:11.3f}{point[1]:11.3f}{point[2]:11.3f}" + for index, point in enumerate(points) + ] + output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def perturb_calibration_for_recovery( + cal: Calibration, camera_index: int +) -> Calibration: + """Create a deterministic seed calibration for full_calibration recovery.""" + trial = clone_calibration(cal) + position_deltas = [ + np.array([1.0, -0.8, 0.4]), + np.array([-0.7, 0.5, -0.3]), + np.array([0.6, 0.4, -0.5]), + np.array([-0.5, -0.6, 0.4]), + ] + angle_deltas = [ + np.array([0.006, -0.004, 0.003]), + np.array([-0.005, 0.004, -0.003]), + np.array([0.004, 0.003, -0.002]), + np.array([-0.004, -0.003, 0.002]), + ] + trial.set_pos(trial.get_pos() + position_deltas[camera_index]) + trial.set_angles(trial.get_angles() + angle_deltas[camera_index]) + return trial + + +def select_external_seed_subset(ref_points: np.ndarray) -> np.ndarray: + """Pick a well-spread subset of reference points for external calibration.""" + mins = np.argmin(ref_points, axis=0) + maxs = np.argmax(ref_points, axis=0) + center = np.argmin(np.linalg.norm(ref_points - np.mean(ref_points, axis=0), axis=1)) + indices = [] + for index in np.concatenate([mins, maxs, np.array([center])]): + if int(index) not in indices: + indices.append(int(index)) + return np.asarray(indices[:6], dtype=np.int32) + + +def recover_calibrations_from_body( + truth_cals: Sequence[Calibration], + ref_points: np.ndarray, + calibration_targets_dir: Path, + output_cal_dir: Path, + truth_cal_dir: Path, + cpar: ControlPar, + orient_par: OrientPar, + sortgrid_eps: int, + rng: np.random.Generator, +) -> tuple[List[Calibration], List[float], List[float]]: + """Recover working calibrations from synthetic calibration-body targets.""" + recovered = [] + position_errors = [] + angle_errors = [] + + seed_indices = select_external_seed_subset(ref_points) + + for camera_index, truth_cal in enumerate(truth_cals, start=1): + projected = arr_metric_to_pixel( + image_coordinates(ref_points, truth_cal, cpar.mm), cpar + ) + targets, _ = shuffled_targets_from_pixels(projected, rng, noise_sigma=0.0) + write_targets( + targets, + len(targets), + str(calibration_targets_dir / f"cam{camera_index}.%05d"), + 1, + ) + + seed = perturb_calibration_for_recovery(truth_cal, camera_index - 1) + external_calibration( + seed, + ref_points[seed_indices], + projected[seed_indices], + cpar, + ) + sorted_targets = match_detection_to_ref( + seed, ref_points, targets, cpar, sortgrid_eps + ) + full_calibration( + seed, + ref_points, + cast(np.ndarray, sorted_targets), + cpar, + orient_par, + ) + recovered.append(seed) + + write_calibration( + seed, + output_cal_dir / f"cam{camera_index}.tif.ori", + output_cal_dir / f"cam{camera_index}.tif.addpar", + ) + write_calibration( + truth_cal, + truth_cal_dir / f"cam{camera_index}.tif.ori", + truth_cal_dir / f"cam{camera_index}.tif.addpar", + ) + + position_errors.append( + float(np.linalg.norm(seed.get_pos() - truth_cal.get_pos())) + ) + angle_errors.append( + float(np.linalg.norm(seed.get_angles() - truth_cal.get_angles())) + ) + + return recovered, position_errors, angle_errors + + +def build_frame_targets_and_paths( + frame_points: np.ndarray, + cals: Sequence[Calibration], + cpar: ControlPar, + rng: np.random.Generator, +) -> tuple[list[list[Target]], np.recarray, list[Pathinfo]]: + """Create target files plus rt_is-compatible correspondences for one frame.""" + projected = project_pixels(frame_points, cals, cpar) + per_camera_targets = [] + per_camera_mapping = [] + for cam_index in range(len(cals)): + targets, point_to_target = shuffled_targets_from_pixels( + projected[:, cam_index, :], rng, noise_sigma=0.08 + ) + per_camera_targets.append(targets) + per_camera_mapping.append(point_to_target) + + observed_pixels = np.empty_like(projected) + for point_index in range(frame_points.shape[0]): + for cam_index in range(len(cals)): + target_index = per_camera_mapping[cam_index][point_index] + observed_pixels[point_index, cam_index, 0] = per_camera_targets[cam_index][ + target_index + ].x + observed_pixels[point_index, cam_index, 1] = per_camera_targets[cam_index][ + target_index + ].y + + initial_points, _ = initialize_bundle_adjustment_points( + observed_pixels, list(cals), cpar + ) + cor_buf = np.recarray((frame_points.shape[0],), dtype=n_tupel_dtype) + path_buf = [Pathinfo() for _ in range(frame_points.shape[0])] + for point_index in range(frame_points.shape[0]): + cor_buf[point_index].p = np.array( + [ + per_camera_mapping[cam_index][point_index] + for cam_index in range(len(cals)) + ], + dtype=np.int32, + ) + cor_buf[point_index].corr = 1.0 + path_buf[point_index].x = initial_points[point_index] + return per_camera_targets, cor_buf, path_buf + + +def write_sequence_file(sequence_path: Path, frame_numbers: Sequence[int]) -> None: + """Write a sequence.par file consistent with img_orig target files.""" + lines = [ + "img_orig/cam1.%05d", + "img_orig/cam2.%05d", + "img_orig/cam3.%05d", + "img_orig/cam4.%05d", + str(frame_numbers[0]), + str(frame_numbers[-1]), + ] + sequence_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def prepare_output_case(source_case: Path, output_case: Path) -> None: + """Copy the source structure and clear generated data directories.""" + shutil.copytree(source_case, output_case, dirs_exist_ok=True) + for name in ("cal", "img_orig", "res_orig", "ground_truth", "calibration_targets"): + path = output_case / name + if path.exists(): + shutil.rmtree(path) + (output_case / "cal").mkdir(parents=True, exist_ok=True) + (output_case / "img_orig").mkdir(parents=True, exist_ok=True) + (output_case / "res_orig").mkdir(parents=True, exist_ok=True) + (output_case / "ground_truth" / "cal").mkdir(parents=True, exist_ok=True) + (output_case / "ground_truth" / "particles").mkdir(parents=True, exist_ok=True) + (output_case / "calibration_targets").mkdir(parents=True, exist_ok=True) + + +def write_case_readme(output_case: Path) -> None: + """Document the generated synthetic case contents.""" + text = """# Synthetic Cavity Case + +This case is generated deterministically from the geometry of `test_cavity`, +but all observations come from known ground truth. + +Contents: + +- `cal/`: working calibrations recovered from synthetic calibration-body targets using `full_calibration`. +- `ground_truth/cal/`: exact camera models used to project the synthetic data. +- `ground_truth/calibration_body_points.txt`: known 3D calibration-body points. +- `calibration_targets/`: synthetic target files for that calibration body. +- `img_orig/`: synthetic particle target files for two frames. +- `res_orig/`: synthetic `rt_is`, `ptv_is`, and `added` files for those frames. +- `ground_truth/particles/`: exact 3D particle coordinates per frame. +- `ground_truth/manifest.json`: generation seed and calibration-recovery errors. +""" + (output_case / "README.md").write_text(text, encoding="utf-8") + + +def generate_synthetic_case( + source_case: Path, + output_case: Path, + *, + seed: int, + particles_per_frame: int, +) -> SyntheticCaseSummary: + """Generate the synthetic cavity-like case on disk.""" + rng = np.random.default_rng(seed) + prepare_output_case(source_case, output_case) + write_case_readme(output_case) + + control = ControlPar(4).from_file(source_case / "parameters/ptv.par") + vpar = read_volume_par(source_case / "parameters/criteria.par") + orient_par = OrientPar().from_file(source_case / "parameters/orient.par") + sortgrid_eps = read_sortgrid_par(source_case / "parameters/sortgrid.par") + truth_cals = [ + read_calibration( + source_case / f"cal/cam{camera_index}.tif.ori", + source_case / f"cal/cam{camera_index}.tif.addpar", + ) + for camera_index in range(1, 5) + ] + + calibration_body = build_calibration_body(vpar, truth_cals, control) + write_calibration_body_points( + calibration_body, + output_case / "ground_truth/calibration_body_points.txt", + ) + shutil.copy2( + output_case / "ground_truth/calibration_body_points.txt", + output_case / "cal/calblock.txt", + ) + + recovered_cals, position_errors, angle_errors = recover_calibrations_from_body( + truth_cals, + calibration_body, + output_case / "calibration_targets", + output_case / "cal", + output_case / "ground_truth/cal", + control, + orient_par, + sortgrid_eps, + rng, + ) + + write_sequence_file(output_case / "parameters/sequence.par", FRAME_NUMBERS) + + for frame_number in FRAME_NUMBERS: + frame_points = generate_particle_cloud( + rng, + vpar, + truth_cals, + control, + particles_per_frame, + ) + np.savetxt( + output_case / "ground_truth/particles" / f"frame_{frame_number}.txt", + frame_points, + fmt="%.6f", + header="x y z", + comments="", + ) + + per_camera_targets, cor_buf, path_buf = build_frame_targets_and_paths( + frame_points, + recovered_cals, + control, + rng, + ) + for camera_index, targets in enumerate(per_camera_targets, start=1): + write_targets( + targets, + len(targets), + str(output_case / "img_orig" / f"cam{camera_index}.%05d"), + frame_number, + ) + + write_path_frame( + cor_buf, + path_buf, + len(path_buf), + str(output_case / "res_orig" / "rt_is"), + str(output_case / "res_orig" / "ptv_is"), + str(output_case / "res_orig" / "added"), + frame_number, + ) + + manifest = SyntheticCaseSummary( + seed=seed, + num_calibration_points=int(calibration_body.shape[0]), + num_frames=len(FRAME_NUMBERS), + particles_per_frame=particles_per_frame, + calibration_position_errors=position_errors, + calibration_angle_errors=angle_errors, + ) + (output_case / "ground_truth/manifest.json").write_text( + json.dumps(manifest.__dict__, indent=2) + "\n", + encoding="utf-8", + ) + return manifest + + +def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments for synthetic-case generation.""" + parser = argparse.ArgumentParser( + description="Generate a synthetic bundle-adjustment case modeled after test_cavity.", + ) + parser.add_argument( + "--source-case", + type=Path, + default=DEFAULT_SOURCE_CASE, + help="Empirical case used as the geometric template.", + ) + parser.add_argument( + "--output-case", + type=Path, + default=DEFAULT_OUTPUT_CASE, + help="Destination case folder to populate.", + ) + parser.add_argument( + "--seed", + type=int, + default=DEFAULT_SEED, + help="Random seed for deterministic particle generation.", + ) + parser.add_argument( + "--particles-per-frame", + type=int, + default=96, + help="Number of fully observed particles to synthesize per frame.", + ) + return parser.parse_args(list(argv) if argv is not None else None) + + +def main(argv: Iterable[str] | None = None) -> int: + """Generate the synthetic case and print a compact summary.""" + args = parse_args(argv) + summary = generate_synthetic_case( + args.source_case.resolve(), + args.output_case.resolve(), + seed=args.seed, + particles_per_frame=args.particles_per_frame, + ) + print(f"Wrote synthetic case to {args.output_case.resolve()}") + print(f"Calibration points: {summary.num_calibration_points}") + print(f"Frames: {summary.num_frames}") + print(f"Particles per frame: {summary.particles_per_frame}") + print( + "Calibration recovery position errors: " + + ", ".join(f"{value:.6f}" for value in summary.calibration_position_errors) + ) + print( + "Calibration recovery angle errors: " + + ", ".join(f"{value:.6f}" for value in summary.calibration_angle_errors) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/openptv_python/image_processing.py b/openptv_python/image_processing.py index bd47fdb..83156d2 100644 --- a/openptv_python/image_processing.py +++ b/openptv_python/image_processing.py @@ -3,10 +3,10 @@ import copy import numpy as np -from numba import njit, prange -from scipy import ndimage -from scipy.ndimage import uniform_filter +from numba import njit +from ._native_compat import HAS_NATIVE_PREPROCESS, native_preprocess_image +from ._native_convert import to_native_control_par from .parameters import ControlPar filter_t = np.zeros((3, 3), dtype=float) @@ -15,35 +15,169 @@ @njit def filter_3(img, kernel=None) -> np.ndarray: """Apply a 3x3 filter to an image.""" - if kernel is None: # default is a low pass - kernel = np.ones((3, 3)) / 9 - filtered_img = ndimage.convolve(img, kernel) - return filtered_img + if img.dtype != np.uint8: + raise TypeError("Image must be of type uint8") + + if kernel is None: + kernel = np.ones((3, 3), dtype=np.float64) + + kernel_sum = float(np.sum(kernel)) + if kernel_sum == 0: + raise ValueError("Filter kernel sum must not be zero") + + imx = img.shape[1] + imy = img.shape[0] + image_size = imx * imy + flat_in = img.reshape(-1) + flat_out = flat_in.copy() + + for index in range(imx + 1, image_size - imx - 1): + buf = ( + kernel[0, 0] * flat_in[index - imx - 1] + + kernel[0, 1] * flat_in[index - imx] + + kernel[0, 2] * flat_in[index - imx + 1] + + kernel[1, 0] * flat_in[index - 1] + + kernel[1, 1] * flat_in[index] + + kernel[1, 2] * flat_in[index + 1] + + kernel[2, 0] * flat_in[index + imx - 1] + + kernel[2, 1] * flat_in[index + imx] + + kernel[2, 2] * flat_in[index + imx + 1] + ) + buf = int(buf / kernel_sum) + if buf > 255: + buf = 255 + if buf < 8: + buf = 8 + flat_out[index] = np.uint8(buf) + + return flat_out.reshape(img.shape) @njit def lowpass_3(img: np.ndarray) -> np.ndarray: - """Lowpass filter of 3x3.""" - # Define the 3x3 lowpass filter kernel - kernel = np.ones((3, 3)) / 9 + """Lowpass filter matching the native 3x3 implementation.""" + if img.dtype != np.uint8: + raise TypeError("Image must be of type uint8") - # Apply the filter to the image using scipy.ndimage.convolve() - img_lp = ndimage.convolve(img, kernel, mode="constant", cval=0.0) + imx = img.shape[1] + imy = img.shape[0] + image_size = imx * imy + flat_in = img.reshape(-1) + flat_out = flat_in.copy() + + for index in range(imx + 1, image_size - imx - 1): + buf = ( + int(flat_in[index - imx - 1]) + + int(flat_in[index - imx]) + + int(flat_in[index - imx + 1]) + + int(flat_in[index - 1]) + + int(flat_in[index]) + + int(flat_in[index + 1]) + + int(flat_in[index + imx - 1]) + + int(flat_in[index + imx]) + + int(flat_in[index + imx + 1]) + ) + flat_out[index] = np.uint8(buf // 9) - return img_lp + return flat_out.reshape(img.shape) -@njit -def fast_box_blur(filt_span: int, src: np.ndarray, cpar: ControlPar) -> np.ndarray: - """Fast box blur.""" +def fast_box_blur( + filt_span: int, + src: np.ndarray, + cpar: ControlPar | None = None, +) -> np.ndarray: + """Fast box blur matching liboptv border handling.""" + if src.dtype != np.uint8: + raise TypeError("Image must be of type uint8") + + if src.ndim != 2: + raise TypeError("Input array must be two-dimensional") + + if cpar is not None: + imx = int(cpar.imx) + imy = int(cpar.imy) + if src.shape != (imy, imx): + raise ValueError("Image shape does not match control parameters") + else: + imy, imx = src.shape + + image_size = imx * imy n = 2 * filt_span + 1 - row_accum = uniform_filter( - src.reshape((cpar.imy, cpar.imx)), - size=n, - mode="constant", - cval=0, - ).reshape(-1) - return row_accum + nq = n * n + src_flat = src.reshape(-1) + dest_flat = np.zeros(image_size, dtype=np.uint8) + row_accum = np.zeros(image_size, dtype=np.int64) + col_accum = np.zeros(imx, dtype=np.int64) + + for row in range(imy): + row_start = row * imx + accum = int(src_flat[row_start]) + row_accum[row_start] = accum * n + + for col in range(1, min(filt_span + 1, imx)): + right = row_start + 2 * col + left = right - 1 + if right >= row_start + imx: + break + accum += int(src_flat[left]) + int(src_flat[right]) + row_accum[row_start + col] = accum * n // (2 * col + 1) + + for col in range(filt_span + 1, max(imx - filt_span, filt_span + 1)): + accum += int(src_flat[row_start + col + filt_span]) + accum -= int(src_flat[row_start + col - filt_span - 1]) + row_accum[row_start + col] = accum + + m = n - 2 + col = imx - filt_span + left = row_start + imx - n + right = left + 1 + while col < imx and m > 0 and right < row_start + imx: + accum -= int(src_flat[left]) + int(src_flat[right]) + row_accum[row_start + col] = accum * n // m + left += 2 + right += 2 + col += 1 + m -= 2 + + for col in range(imx): + col_accum[col] = row_accum[col] + dest_flat[col] = np.uint8(col_accum[col] // n) + + max_top = min(filt_span, (imy - 1) // 2) + for row in range(1, max_top + 1): + base1 = (2 * row - 1) * imx + base2 = base1 + imx + out_base = row * imx + for col in range(imx): + col_accum[col] += row_accum[base1 + col] + row_accum[base2 + col] + dest_flat[out_base + col] = np.uint8( + n * col_accum[col] // nq // (2 * row + 1) + ) + + for row in range(filt_span + 1, max(imy - filt_span, filt_span + 1)): + remove_base = (row - filt_span - 1) * imx + add_base = (row + filt_span) * imx + out_base = row * imx + for col in range(imx): + col_accum[col] += row_accum[add_base + col] - row_accum[remove_base + col] + dest_flat[out_base + col] = np.uint8(col_accum[col] // nq) + + for remaining in range(min(filt_span, imy - 1), 0, -1): + remove_base = (imy - 2 * remaining - 1) * imx + remove_base2 = remove_base + imx + out_base = (imy - remaining) * imx + if remove_base < 0: + continue + for col in range(imx): + col_accum[col] -= ( + row_accum[remove_base + col] + row_accum[remove_base2 + col] + ) + dest_flat[out_base + col] = np.uint8( + n * col_accum[col] // nq // (2 * remaining + 1) + ) + + return dest_flat.reshape(src.shape) # def split(img: np.ndarray, half_selector: int, cpar: ControlPar) -> np.ndarray: @@ -72,7 +206,9 @@ def subtract_img(img1: np.ndarray, img2: np.ndarray, img_new: np.ndarray) -> Non img1, img2: numpy arrays containing the original images. img_new: numpy array to store the result. """ - img_new[:] = ndimage.maximum(img1 - img2, 0) + img_new[:] = np.maximum(img1.astype(np.int16) - img2.astype(np.int16), 0).astype( + np.uint8 + ) @njit @@ -98,33 +234,19 @@ def prepare_image( filter on an image, optionally followed by additional user-defined filter. """ - # image_size = cpar.imx * cpar.imy - # img_lp = np.zeros_like(img, dtype=np.uint8) - - # Apply low-pass filter - # img = img.reshape((cpar.imy, cpar.imx)) # Reshape to 2D image if img.dtype != np.uint8: raise TypeError("Image must be of type uint8") - img_lp = ndimage.uniform_filter( - img, - size=dim_lp * 2 + 1, - mode="constant", - cval=0, - ) + if img.ndim != 2: + raise TypeError("Input array must be two-dimensional") - # Subtract low-pass filtered image from original image - img_hp = img | img_lp + img_lp = fast_box_blur(dim_lp, img) + img_hp = np.empty_like(img) + subtract_img(img, img_lp, img_hp) # Filter highpass image, if wanted, if filter_hp == 0, no highpass filtering if filter_hp == 1: - img_hp = ndimage.uniform_filter( - # img_hp.reshape((cpar.imy, cpar.imx)), - img_hp, - size=3, - mode="constant", - cval=0.0, - ) + img_hp = lowpass_3(img_hp) elif filter_hp == 2 and filter_file != "": try: with open(filter_file, "r", encoding="utf-8") as fp: @@ -132,19 +254,23 @@ def prepare_image( except Exception as exc: raise IOError(f"Could not open filter file: {filter_file}") from exc - img_hp = ndimage.convolve( - # img_hp.reshape((cpar.imy, cpar.imx)), - img_hp, - weights=filt, - mode="constant", - cval=0.0, - ) + img_hp = filter_3(img_hp, filt) return img_hp def preprocess_image(img, filter_hp, cpar, dim_lp) -> np.ndarray: """Decorate prepare_image with default parameters.""" + if HAS_NATIVE_PREPROCESS: + native_cpar = to_native_control_par(cpar) + return native_preprocess_image( + img, + filter_hp, + native_cpar, + lowpass_dim=dim_lp, + filter_file=None, + output_img=None, + ) return prepare_image(img=img, dim_lp=dim_lp, filter_hp=filter_hp, filter_file="") @@ -235,62 +361,4 @@ def preprocess_image(img, filter_hp, cpar, dim_lp) -> np.ndarray: # pass # replace this with the actual implementation of the function -@njit(parallel=True) -def fast_box_blur_numba(filt_span, src, cpar): - imx, imy = cpar["imx"], cpar["imy"] - n = 2 * filt_span + 1 - nq = n * n - - row_accum = np.zeros(imx * imy, dtype=np.int32) - col_accum = np.zeros(imx, dtype=np.int32) - dest = np.zeros_like(src, dtype=np.int32) - - # Sum over lines first [1]: - for i in prange(imy): - row_start = i * imx - accum = src[row_start] - row_accum[row_start] = accum * n - - for j in range(1, filt_span + 1): - accum += src[row_start + j - 1] + src[row_start + j + filt_span] - row_accum[row_start + j] = accum * n // (2 * j + 1) - - for j in range(filt_span + 1, imx - filt_span): - accum += src[row_start + j + filt_span] - src[row_start + j - filt_span - 1] - row_accum[row_start + j] = accum - - for j in range(imx - filt_span, imx): - accum -= src[row_start + j - filt_span - 1] + src[row_start + j + filt_span] - row_accum[row_start + j] = accum * n // (2 * (imx - j - 1) + 1) - - # Sum over columns: - col_accum[:imx] = row_accum[:imx] - dest[:imx] = col_accum[:imx] // n - - for i in range(1, filt_span + 1): - ptr1 = row_accum[(2 * i - 1) * imx : (2 * i + 1) * imx] - ptr2 = ptr1[imx:] - col_accum += ptr1 + ptr2 - dest[i * imx : (i + 1) * imx] = n * col_accum // nq // (2 * i + 1) - - for i in range(filt_span + 1, imy - filt_span): - ptr1 = row_accum[(i - filt_span - 1) * imx : i * imx] - ptr2 = row_accum[(i + filt_span) * imx : (i + filt_span + 1) * imx] - col_accum += ptr2 - ptr1 - dest[i * imx : (i + 1) * imx] = col_accum // nq - - for i in range(filt_span, 0, -1): - ptr1 = row_accum[(imy - 2 * i - 1) * imx : (imy - 2 * i + 1) * imx] - ptr2 = ptr1[imx:] - col_accum -= ptr1 + ptr2 - dest[(imy - i) * imx :] = n * col_accum // nq // (2 * i + 1) - - return dest - - -# # Example usage: -# filt_span = 3 -# src = np.random.randint(0, 256, size=(1000, 1000), dtype=np.uint8) -# cpar = {'imx': src.shape[1], 'imy': src.shape[0]} - # result = fast_box_blur_numba(filt_span, src, cpar) diff --git a/openptv_python/multimed.py b/openptv_python/multimed.py index d2a32c2..236f534 100644 --- a/openptv_python/multimed.py +++ b/openptv_python/multimed.py @@ -80,17 +80,18 @@ def fast_multimed_r_nlay( zdiff = 1.0 it = 0 rdiff = 0.1 # Initialize to enter the loop + beta2 = np.zeros(nlay, dtype=np.float64) while abs(rdiff) > 0.001 and it < n_iter: beta1 = np.arctan(rq / zdiff) - beta2 = np.arcsin(np.sin(beta1) * n1 / n2[0]) + sin_beta1 = np.sin(beta1) + for layer in range(nlay): + beta2[layer] = np.arcsin(sin_beta1 * n1 / n2[layer]) beta3 = np.arcsin(np.sin(beta1) * n1 / n3) - rbeta = ( - (z0 - d[0]) * np.tan(beta1) - - zout * np.tan(beta3) - + np.sum(d * np.tan(beta2)) - ) + rbeta = (z0 - d[0]) * np.tan(beta1) - zout * np.tan(beta3) + for layer in range(nlay): + rbeta += d[layer] * np.tan(beta2[layer]) rdiff = r - rbeta rq += rdiff diff --git a/openptv_python/orientation.py b/openptv_python/orientation.py index 3b7b1a8..9e8fc91 100644 --- a/openptv_python/orientation.py +++ b/openptv_python/orientation.py @@ -1,24 +1,30 @@ """Functions for the orientation of the camera.""" -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Sequence, Tuple, cast import numpy as np import scipy -from numba import njit +from numba import njit, prange from openptv_python.constants import COORD_UNUSED from .calibration import Calibration from .constants import CONVERGENCE, IDT, NPAR, NUM_ITER, POS_INF from .epi import epi_mm_2D -from .imgcoord import img_coord +from .imgcoord import image_coordinates, img_coord # from .lsqadj import ata, atl, matinv, matmul from .parameters import ControlPar, MultimediaPar, OrientPar, VolumePar -from .ray_tracing import ray_tracing +from .ray_tracing import fast_ray_tracing, ray_tracing from .sortgrid import sortgrid from .tracking_frame_buf import Target -from .trafo import correct_brown_affine, pixel_to_metric +from .trafo import ( + arr_metric_to_pixel, + correct_brown_affine, + dist_to_flat, + metric_to_pixel, + pixel_to_metric, +) from .vec_utils import unit_vector, vec_norm, vec_set @@ -49,6 +55,79 @@ def skew_midpoint( return float(scale), res +@njit(cache=True, fastmath=True, nogil=True, parallel=True) +def _multi_cam_point_positions_numba( + targets: np.ndarray, + distortion_matrices: np.ndarray, + primary_points: np.ndarray, + glass_vectors: np.ndarray, + ccs: np.ndarray, + distance_param: float, + refractive_index_medium1: float, + refractive_index_medium2: float, + refractive_index_medium3: float, + coord_unused: float, +) -> Tuple[np.ndarray, np.ndarray]: + """Calculate multi-camera point positions using compiled per-point loops.""" + num_targets = targets.shape[0] + num_cams = targets.shape[1] + res = np.empty((num_targets, 3), dtype=np.float64) + rcm = np.empty(num_targets, dtype=np.float64) + + for pt in prange(num_targets): + vertices = np.zeros((num_cams, 3), dtype=np.float64) + directs = np.zeros((num_cams, 3), dtype=np.float64) + point_tot = np.zeros(3, dtype=np.float64) + num_used_pairs = 0 + dtot = 0.0 + + for cam in range(num_cams): + if targets[pt, cam, 0] == coord_unused: + continue + + camera = np.empty(3, dtype=np.float64) + camera[0] = targets[pt, cam, 0] + camera[1] = targets[pt, cam, 1] + camera[2] = -ccs[cam] + vertices[cam], directs[cam] = fast_ray_tracing( + camera, + distortion_matrices[cam], + primary_points[cam], + glass_vectors[cam], + distance_param, + refractive_index_medium1, + refractive_index_medium2, + refractive_index_medium3, + ) + + for cam in range(num_cams): + if targets[pt, cam, 0] == coord_unused: + continue + + for pair in range(cam + 1, num_cams): + if targets[pt, pair, 0] == coord_unused: + continue + + num_used_pairs += 1 + tmp, point = skew_midpoint( + vertices[cam], + directs[cam], + vertices[pair], + directs[pair], + ) + dtot += tmp + point_tot += point + + if num_used_pairs == 0: + rcm[pt] = np.nan + res[pt, :] = np.nan + else: + rcm[pt] = dtot / num_used_pairs + res[pt, :] = point_tot / num_used_pairs + + return res, rcm + + def point_position( targets: np.ndarray, num_cams: int, @@ -1054,12 +1133,2227 @@ def multi_cam_point_positions( # So we can address targets.data directly instead of get_ptr stuff: - num_targets = targets.shape[0] - num_cams = targets.shape[1] - res = np.empty((num_targets, 3)) - rcm = np.empty(num_targets) + distortion_matrices = np.ascontiguousarray( + np.stack([cal.ext_par.dm for cal in cals]).astype(np.float64) + ) + primary_points = np.ascontiguousarray( + np.stack( + [np.array([cal.ext_par.x0, cal.ext_par.y0, cal.ext_par.z0]) for cal in cals] + ).astype(np.float64) + ) + glass_vectors = np.ascontiguousarray( + np.stack([cal.glass_par for cal in cals]).astype(np.float64) + ) + ccs = np.ascontiguousarray( + np.array([cal.int_par.cc for cal in cals], dtype=np.float64) + ) - for pt in range(num_targets): - rcm[pt], res = point_position(targets[pt], num_cams, mm_par, cals) + return _multi_cam_point_positions_numba( + np.ascontiguousarray(targets, dtype=np.float64), + distortion_matrices, + primary_points, + glass_vectors, + ccs, + float(mm_par.d[0]), + float(mm_par.n1), + float(mm_par.n2[0]), + float(mm_par.n3), + float(COORD_UNUSED), + ) - return res, rcm + +def _clone_calibration(cal: Calibration) -> Calibration: + """Create an isolated calibration copy for optimization updates.""" + return Calibration( + ext_par=cal.ext_par.copy(), + int_par=cal.int_par.copy(), + glass_par=cal.glass_par.copy(), + added_par=cal.added_par.copy(), + mmlut=cal.mmlut, + mmlut_data=cal.mmlut_data, + ) + + +def _bundle_optional_parameter_names(orient_par: OrientPar) -> List[str]: + """Return optional calibration parameter names enabled for optimization.""" + names = [] + mapping = ( + ("ccflag", "cc"), + ("xhflag", "xh"), + ("yhflag", "yh"), + ("k1flag", "k1"), + ("k2flag", "k2"), + ("k3flag", "k3"), + ("p1flag", "p1"), + ("p2flag", "p2"), + ("scxflag", "scx"), + ("sheflag", "she"), + ) + for flag_name, param_name in mapping: + if getattr(orient_par, flag_name): + names.append(param_name) + return names + + +def _get_optional_parameter(cal: Calibration, name: str) -> float: + """Read one optional calibration parameter from a camera model.""" + if name == "cc": + return float(cal.int_par.cc) + if name == "xh": + return float(cal.int_par.xh) + if name == "yh": + return float(cal.int_par.yh) + + added_map = { + "k1": 0, + "k2": 1, + "k3": 2, + "p1": 3, + "p2": 4, + "scx": 5, + "she": 6, + } + if name not in added_map: + raise ValueError(f"Unknown optional camera parameter: {name}") + return float(cal.added_par[added_map[name]]) + + +def _set_optional_parameter(cal: Calibration, name: str, value: float) -> None: + """Write one optional calibration parameter into a camera model.""" + if name == "cc": + cal.int_par.cc = value + return + if name == "xh": + cal.int_par.xh = value + return + if name == "yh": + cal.int_par.yh = value + return + + added_map = { + "k1": 0, + "k2": 1, + "k3": 2, + "p1": 3, + "p2": 4, + "scx": 5, + "she": 6, + } + if name not in added_map: + raise ValueError(f"Unknown optional camera parameter: {name}") + cal.added_par[added_map[name]] = value + + +def _glass_basis(glass_vec: np.ndarray) -> Tuple[np.ndarray, np.ndarray, float]: + """Build a stable tangent basis around the current glass vector.""" + norm = float(np.linalg.norm(glass_vec)) + if norm == 0.0: + raise ValueError("Glass vector norm must be non-zero") + + normal = glass_vec / norm + helper = ( + np.array([1.0, 0.0, 0.0]) if abs(normal[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) + ) + e1 = np.cross(normal, helper) + e1_norm = float(np.linalg.norm(e1)) + if e1_norm == 0.0: + helper = np.array([0.0, 0.0, 1.0]) + e1 = np.cross(normal, helper) + e1_norm = float(np.linalg.norm(e1)) + e1 /= e1_norm + e2 = np.cross(normal, e1) + e2 /= float(np.linalg.norm(e2)) + return e1, e2, norm + + +def _camera_parameter_block( + cal: Calibration, optional_names: List[str], include_interface: bool +) -> np.ndarray: + """Pack one camera block for bundle adjustment.""" + values = [ + float(cal.ext_par.x0), + float(cal.ext_par.y0), + float(cal.ext_par.z0), + float(cal.ext_par.omega), + float(cal.ext_par.phi), + float(cal.ext_par.kappa), + ] + values.extend(_get_optional_parameter(cal, name) for name in optional_names) + if include_interface: + values.extend([0.0, 0.0]) + return np.asarray(values, dtype=np.float64) + + +def _apply_camera_parameter_block( + cal: Calibration, + block: np.ndarray, + optional_names: List[str], + include_interface: bool, + base_glass: Optional[np.ndarray], +) -> None: + """Apply one bundle-adjustment camera block to a calibration object.""" + cal.set_pos(block[:3]) + cal.set_angles(block[3:6]) + + offset = 6 + for name in optional_names: + _set_optional_parameter(cal, name, float(block[offset])) + offset += 1 + + if include_interface: + if base_glass is None: + raise ValueError("Base glass vector is required when interfflag is enabled") + e1, e2, scale = _glass_basis(base_glass) + cal.glass_par = base_glass + scale * ( + block[offset] * e1 + block[offset + 1] * e2 + ) + + +def metric_observations_from_pixels( + observed_pixels: np.ndarray, + cals: List[Calibration], + cpar: ControlPar, +) -> np.ndarray: + """Convert pixel observations to flat metric coordinates for ray intersection.""" + num_points, num_cams, _ = observed_pixels.shape + metric_obs = np.full((num_points, num_cams, 2), COORD_UNUSED, dtype=np.float64) + + for pt in range(num_points): + for cam in range(num_cams): + obs = observed_pixels[pt, cam] + if not np.all(np.isfinite(obs)): + continue + x_metric, y_metric = pixel_to_metric(float(obs[0]), float(obs[1]), cpar) + metric_obs[pt, cam] = dist_to_flat(x_metric, y_metric, cals[cam]) + + return metric_obs + + +def initialize_bundle_adjustment_points( + observed_pixels: np.ndarray, + cals: List[Calibration], + cpar: ControlPar, +) -> Tuple[np.ndarray, np.ndarray]: + """Triangulate 3D starting points from pixel observations and current cameras.""" + metric_obs = metric_observations_from_pixels(observed_pixels, cals, cpar) + num_points, num_cams, _ = metric_obs.shape + points = np.empty((num_points, 3), dtype=np.float64) + ray_convergence = np.empty(num_points, dtype=np.float64) + + for pt in range(num_points): + if np.count_nonzero(metric_obs[pt, :, 0] != COORD_UNUSED) < 2: + raise ValueError( + "Each point must be observed by at least two cameras for bundle adjustment" + ) + ray_convergence[pt], points[pt] = point_position( + metric_obs[pt], num_cams, cpar.mm, cals + ) + + return points, ray_convergence + + +def reprojection_errors( + observed_pixels: np.ndarray, + points_3d: np.ndarray, + cals: List[Calibration], + cpar: ControlPar, +) -> np.ndarray: + """Return per-observation reprojection errors in pixels.""" + if observed_pixels.ndim != 3 or observed_pixels.shape[2] != 2: + raise ValueError("observed_pixels must have shape (num_points, num_cams, 2)") + if points_3d.shape != (observed_pixels.shape[0], 3): + raise ValueError("points_3d must have shape (num_points, 3)") + if observed_pixels.shape[1] != len(cals): + raise ValueError( + "Number of cameras in observations and calibrations must match" + ) + + residuals = np.full_like(observed_pixels, np.nan, dtype=np.float64) + for pt in range(observed_pixels.shape[0]): + for cam in range(observed_pixels.shape[1]): + obs = observed_pixels[pt, cam] + if not np.all(np.isfinite(obs)): + continue + x_metric, y_metric = img_coord(points_3d[pt], cals[cam], cpar.mm) + proj = metric_to_pixel(x_metric, y_metric, cpar) + residuals[pt, cam] = np.asarray(proj, dtype=np.float64) - obs + + return residuals + + +def reprojection_rms( + observed_pixels: np.ndarray, + points_3d: np.ndarray, + cals: List[Calibration], + cpar: ControlPar, +) -> float: + """Return the global RMS reprojection error in pixels.""" + residuals = reprojection_errors(observed_pixels, points_3d, cals, cpar) + valid = np.isfinite(residuals) + if not np.any(valid): + raise ValueError("No valid observations available for reprojection RMS") + return float(np.sqrt(np.mean(np.square(residuals[valid])))) + + +def reprojection_rms_per_camera( + observed_pixels: np.ndarray, + points_3d: np.ndarray, + cals: List[Calibration], + cpar: ControlPar, +) -> np.ndarray: + """Return per-camera RMS reprojection errors in pixels.""" + residuals = reprojection_errors(observed_pixels, points_3d, cals, cpar) + per_camera = np.full(observed_pixels.shape[1], np.nan, dtype=np.float64) + for cam in range(observed_pixels.shape[1]): + valid = np.isfinite(residuals[:, cam, :]) + if np.any(valid): + per_camera[cam] = float( + np.sqrt(np.mean(np.square(residuals[:, cam, :][valid]))) + ) + return per_camera + + +def mean_ray_convergence( + observed_pixels: np.ndarray, + cals: List[Calibration], + cpar: ControlPar, +) -> float: + """Return the mean ray convergence from triangulating observed pixels.""" + _, ray_convergence = initialize_bundle_adjustment_points( + observed_pixels, cals, cpar + ) + return float(np.mean(ray_convergence)) + + +def _expand_parameter_limits( + parameter_names: List[str], + limits: Optional[Dict[str, Tuple[float, float]]], + base_blocks: List[np.ndarray], + repeat: int, +) -> Tuple[np.ndarray, np.ndarray]: + """Expand per-parameter bounds for each optimized camera block.""" + lower = [] + upper = [] + + if repeat <= 0: + return np.empty(0, dtype=np.float64), np.empty(0, dtype=np.float64) + + for block_index in range(repeat): + for param_index, name in enumerate(parameter_names): + if limits is None or name not in limits: + lower.append(-np.inf) + upper.append(np.inf) + continue + low_delta, high_delta = limits[name] + base_value = base_blocks[block_index][param_index] + lower_bound = base_value + low_delta + upper_bound = base_value + high_delta + if ( + np.isfinite(lower_bound) + and np.isfinite(upper_bound) + and lower_bound == upper_bound + ): + epsilon = 32.0 * np.finfo(np.float64).eps * max(1.0, abs(lower_bound)) + lower_bound -= epsilon + upper_bound += epsilon + lower.append(lower_bound) + upper.append(upper_bound) + + return np.asarray(lower, dtype=np.float64), np.asarray(upper, dtype=np.float64) + + +def _bundle_adjustment_x_scale( + x_scale: Optional[float | Sequence[float] | Dict[str, float]], + parameter_names: List[str], + optimized_cam_indices: List[int], + optimize_points: bool, + num_points: int, + total_parameters: int, +) -> Optional[float | np.ndarray]: + """Normalize least_squares x_scale inputs to the packed BA parameter vector.""" + if x_scale is None: + return None + + if np.isscalar(x_scale): + value = float(cast(float, x_scale)) + if value <= 0: + raise ValueError("x_scale must be strictly positive") + return value + + if isinstance(x_scale, dict): + values: List[float] = [] + for _cam in optimized_cam_indices: + for name in parameter_names: + value = float(x_scale.get(name, 1.0)) + if value <= 0: + raise ValueError("x_scale entries must be strictly positive") + values.append(value) + + if optimize_points: + point_scale = float(x_scale.get("points", x_scale.get("point", 1.0))) + if point_scale <= 0: + raise ValueError("point x_scale must be strictly positive") + values.extend([point_scale] * (num_points * 3)) + + return np.asarray(values, dtype=np.float64) + + sequence_values = np.asarray(x_scale, dtype=np.float64) + if sequence_values.ndim != 1: + raise ValueError("x_scale sequence must be one-dimensional") + if sequence_values.size != total_parameters: + raise ValueError( + f"x_scale sequence must have length {total_parameters}, got {sequence_values.size}" + ) + if np.any(sequence_values <= 0): + raise ValueError("x_scale entries must be strictly positive") + return sequence_values.copy() + + +def _bundle_adjustment_jacobian_sparsity( + obs_mask: np.ndarray, + num_cams: int, + optimized_cam_indices: List[int], + camera_block_size: int, + point_offset: int, + optimize_points: bool, + active_priors: List[Tuple[int, int, float, float]], + active_known_points: List[Tuple[int, np.ndarray, np.ndarray]], +) -> scipy.sparse.csr_matrix: + """Build the Jacobian sparsity pattern for finite-difference bundle adjustment.""" + num_observation_residuals = int(np.count_nonzero(obs_mask) * 2) + num_prior_residuals = len(active_priors) + num_known_point_residuals = len(active_known_points) * 3 + total_residuals = ( + num_observation_residuals + num_prior_residuals + num_known_point_residuals + ) + total_parameters = point_offset + (obs_mask.shape[0] * 3 if optimize_points else 0) + + sparsity = scipy.sparse.lil_matrix( + (total_residuals, total_parameters), + dtype=np.int8, + ) + optimized_cam_lookup = { + cam: cam_index for cam_index, cam in enumerate(optimized_cam_indices) + } + + row = 0 + for point_index in range(obs_mask.shape[0]): + point_start = point_offset + point_index * 3 + for cam in range(num_cams): + if not obs_mask[point_index, cam]: + continue + + cam_index = optimized_cam_lookup.get(cam) + if cam_index is not None and camera_block_size > 0: + cam_start = cam_index * camera_block_size + sparsity[row : row + 2, cam_start : cam_start + camera_block_size] = 1 + + if optimize_points: + sparsity[row : row + 2, point_start : point_start + 3] = 1 + + row += 2 + + for cam_index, param_index, _prior_sigma, _base_value in active_priors: + cam_start = cam_index * camera_block_size + sparsity[row, cam_start + param_index] = 1 + row += 1 + + if optimize_points: + for point_index, _target, _point_sigma in active_known_points: + point_start = point_offset + point_index * 3 + sparsity[row : row + 3, point_start : point_start + 3] = 1 + row += 3 + + return sparsity.tocsr() + + +def _normalize_known_point_constraints( + num_points: int, + known_points: Optional[Dict[int, np.ndarray]], + known_point_sigmas: Optional[float | np.ndarray], +) -> List[Tuple[int, np.ndarray, np.ndarray]]: + """Normalize known-point priors into indexed target and sigma vectors.""" + if known_points is None: + return [] + + sigma_source: float | np.ndarray + sigma_source = 1.0 if known_point_sigmas is None else known_point_sigmas + sigma_array = np.asarray(sigma_source, dtype=np.float64) + + constraints = [] + for point_index in sorted(known_points): + if point_index < 0 or point_index >= num_points: + raise ValueError("known_points contains an out-of-range point index") + + target = np.asarray(known_points[point_index], dtype=np.float64) + if target.shape != (3,): + raise ValueError("Each known_points entry must have shape (3,)") + + if sigma_array.ndim == 0: + sigma = np.full(3, float(sigma_array), dtype=np.float64) + elif sigma_array.shape == (3,): + sigma = sigma_array.copy() + elif sigma_array.shape == (num_points, 3): + sigma = sigma_array[point_index].copy() + else: + raise ValueError( + "known_point_sigmas must be a scalar, shape (3,), or shape (num_points, 3)" + ) + + if np.any(sigma <= 0): + raise ValueError("known_point_sigmas must be strictly positive") + + constraints.append((point_index, target, sigma)) + + return constraints + + +def multi_camera_bundle_adjustment( + observed_pixels: np.ndarray, + cals: List[Calibration], + cpar: ControlPar, + orient_par: OrientPar, + point_init: Optional[np.ndarray] = None, + fix_first_camera: bool = True, + fixed_camera_indices: Optional[List[int]] = None, + loss: str = "soft_l1", + f_scale: float = 1.0, + method: str = "trf", + prior_sigmas: Optional[Dict[str, float]] = None, + parameter_bounds: Optional[Dict[str, Tuple[float, float]]] = None, + max_nfev: Optional[int] = None, + optimize_extrinsics: bool = True, + optimize_points: bool = True, + known_points: Optional[Dict[int, np.ndarray]] = None, + known_point_sigmas: Optional[float | np.ndarray] = None, + x_scale: Optional[float | Sequence[float] | Dict[str, float]] = None, + ftol: Optional[float] = None, + xtol: Optional[float] = None, + gtol: Optional[float] = None, +) -> Tuple[List[Calibration], np.ndarray, scipy.optimize.OptimizeResult]: + """Jointly refine multi-camera calibration and 3D points by reprojection error.""" + observed_pixels = np.asarray(observed_pixels, dtype=np.float64) + if observed_pixels.ndim != 3 or observed_pixels.shape[2] != 2: + raise ValueError("observed_pixels must have shape (num_points, num_cams, 2)") + if len(cals) < 2: + raise ValueError("Bundle adjustment requires at least two cameras") + if observed_pixels.shape[1] != len(cals): + raise ValueError( + "Number of cameras in observations and calibrations must match" + ) + + num_points, num_cams, _ = observed_pixels.shape + if num_points == 0: + raise ValueError("At least one 3D point is required for bundle adjustment") + + obs_counts = np.count_nonzero(np.all(np.isfinite(observed_pixels), axis=2), axis=1) + if np.any(obs_counts < 2): + raise ValueError("Each point must be observed by at least two cameras") + + base_cals = [_clone_calibration(cal) for cal in cals] + optional_names = _bundle_optional_parameter_names(orient_par) + include_interface = bool(orient_par.interfflag) + prior_sigmas = {} if prior_sigmas is None else prior_sigmas + + if fixed_camera_indices is None: + fixed_camera_indices = [0] if fix_first_camera else [] + + fixed_camera_indices = sorted(set(fixed_camera_indices)) + if any(cam < 0 or cam >= num_cams for cam in fixed_camera_indices): + raise ValueError("fixed_camera_indices contains an out-of-range camera index") + + if optimize_extrinsics: + optimized_cam_indices = [ + cam for cam in range(num_cams) if cam not in fixed_camera_indices + ] + else: + optimized_cam_indices = list(range(num_cams)) + + if ( + not optimize_extrinsics + and not optional_names + and not include_interface + and not optimize_points + ): + raise ValueError("No camera parameters are enabled for optimization") + + if not optimize_points and not optimized_cam_indices: + raise ValueError( + "Bundle adjustment must optimize points or at least one camera" + ) + if known_points is not None and not optimize_points: + raise ValueError( + "known_points constraints require optimize_points=True in the current implementation" + ) + + # Refining both 3D points and camera poses from image observations has a similarity + # gauge. One fixed camera removes global translation/rotation, but scale still drifts + # unless another camera is fixed or translation priors are applied. + has_translation_priors = all( + prior_sigmas.get(name, 0) > 0 for name in ("x0", "y0", "z0") + ) + if ( + optimize_points + and optimized_cam_indices + and optimize_extrinsics + and len(fixed_camera_indices) < 2 + and not has_translation_priors + ): + raise ValueError( + "Bundle adjustment with free 3D points and only one fixed camera is scale-ambiguous. " + "Fix at least two cameras via fixed_camera_indices or provide translation priors " + "for x0, y0, and z0." + ) + + if point_init is None: + points0, _ = initialize_bundle_adjustment_points( + observed_pixels, base_cals, cpar + ) + else: + points0 = np.asarray(point_init, dtype=np.float64) + if points0.shape != (num_points, 3): + raise ValueError("point_init must have shape (num_points, 3)") + + base_camera_blocks = [] + base_glass_vectors: Dict[int, np.ndarray] = {} + parameter_names = [] + if optimize_extrinsics: + parameter_names.extend(["x0", "y0", "z0", "omega", "phi", "kappa"]) + parameter_names.extend(optional_names) + if include_interface: + parameter_names.extend(["glass_e1", "glass_e2"]) + + initial_blocks = [] + for cam in optimized_cam_indices: + initial_block = _camera_parameter_block( + base_cals[cam], optional_names, include_interface + ) + if not optimize_extrinsics: + initial_block = initial_block[6:] + initial_blocks.append(initial_block) + base_camera_blocks.append(initial_block.copy()) + if include_interface: + base_glass_vectors[cam] = base_cals[cam].glass_par.copy() + + camera_block_size = len(parameter_names) + payloads = [] + if initial_blocks: + payloads.extend(initial_blocks) + if optimize_points: + payloads.append(points0.ravel()) + if payloads: + x0 = np.concatenate(payloads) + else: + x0 = np.empty(0, dtype=np.float64) + + point_offset = camera_block_size * len(optimized_cam_indices) + obs_mask = np.all(np.isfinite(observed_pixels), axis=2) + observation_point_indices = [ + np.flatnonzero(obs_mask[:, cam]) for cam in range(num_cams) + ] + + active_priors = [] + for cam_index, _cam in enumerate(optimized_cam_indices): + for param_index, (name, base_value) in enumerate( + zip(parameter_names, base_camera_blocks[cam_index]) + ): + sigma = prior_sigmas.get(name) + if sigma is None or sigma <= 0: + continue + active_priors.append((cam_index, param_index, sigma, base_value)) + + active_known_points = _normalize_known_point_constraints( + num_points, + known_points, + known_point_sigmas, + ) + + camera_lower, camera_upper = _expand_parameter_limits( + parameter_names, + parameter_bounds, + base_camera_blocks, + len(optimized_cam_indices), + ) + if optimize_points: + point_lower = np.full(points0.size, -np.inf, dtype=np.float64) + point_upper = np.full(points0.size, np.inf, dtype=np.float64) + bounds = ( + np.concatenate([camera_lower, point_lower]), + np.concatenate([camera_upper, point_upper]), + ) + else: + bounds = (camera_lower, camera_upper) + + normalized_x_scale = _bundle_adjustment_x_scale( + x_scale, + parameter_names, + optimized_cam_indices, + optimize_points, + num_points, + x0.size, + ) + + def unpack_parameters(params: np.ndarray) -> Tuple[List[Calibration], np.ndarray]: + trial_cals = [_clone_calibration(cal) for cal in base_cals] + offset = 0 + for cam in optimized_cam_indices: + block = params[offset : offset + camera_block_size] + if not optimize_extrinsics: + base_pose = _camera_parameter_block(base_cals[cam], [], False)[:6] + block = np.concatenate([base_pose, block]) + _apply_camera_parameter_block( + trial_cals[cam], + block, + optional_names, + include_interface, + base_glass_vectors.get(cam), + ) + offset += camera_block_size + + if optimize_points: + points = params[point_offset:].reshape(num_points, 3) + else: + points = points0.copy() + return trial_cals, points + + def residual_vector(params: np.ndarray) -> np.ndarray: + trial_cals, points = unpack_parameters(params) + residuals = np.empty( + int(np.count_nonzero(obs_mask) * 2) + + len(active_priors) + + 3 * len(active_known_points) + ) + row = 0 + + for cam, point_indices in enumerate(observation_point_indices): + if point_indices.size == 0: + continue + + projected_pixels = arr_metric_to_pixel( + image_coordinates(points[point_indices], trial_cals[cam], cpar.mm), + cpar, + ) + observed = observed_pixels[point_indices, cam, :] + diffs = projected_pixels - observed + residual_count = point_indices.size * 2 + residuals[row : row + residual_count : 2] = diffs[:, 0] / cpar.pix_x + residuals[row + 1 : row + residual_count : 2] = diffs[:, 1] / cpar.pix_y + row += residual_count + + for cam_index, param_index, sigma, base_value in active_priors: + value = params[cam_index * camera_block_size + param_index] + residuals[row] = (value - base_value) / sigma + row += 1 + + for point_index, target, point_sigma in active_known_points: + residuals[row : row + 3] = (points[point_index] - target) / point_sigma + row += 3 + + return residuals + + initial_cals, initial_points = unpack_parameters(x0) + initial_rms = reprojection_rms(observed_pixels, initial_points, initial_cals, cpar) + initial_per_camera = reprojection_rms_per_camera( + observed_pixels, initial_points, initial_cals, cpar + ) + + least_squares_kwargs = { + "method": method, + "loss": loss, + "f_scale": f_scale, + "max_nfev": max_nfev, + "bounds": bounds, + } + if normalized_x_scale is not None: + least_squares_kwargs["x_scale"] = normalized_x_scale + if method != "lm" and x0.size > 0: + least_squares_kwargs["jac_sparsity"] = _bundle_adjustment_jacobian_sparsity( + obs_mask, + num_cams, + optimized_cam_indices, + camera_block_size, + point_offset, + optimize_points, + active_priors, + active_known_points, + ) + if ftol is not None: + least_squares_kwargs["ftol"] = ftol + if xtol is not None: + least_squares_kwargs["xtol"] = xtol + if gtol is not None: + least_squares_kwargs["gtol"] = gtol + + result = scipy.optimize.least_squares( + residual_vector, + x0, + **least_squares_kwargs, + ) + + refined_cals, refined_points = unpack_parameters(result.x) + result["initial_reprojection_rms"] = initial_rms + result["final_reprojection_rms"] = reprojection_rms( + observed_pixels, refined_points, refined_cals, cpar + ) + result["initial_reprojection_rms_per_camera"] = initial_per_camera + result["final_reprojection_rms_per_camera"] = reprojection_rms_per_camera( + observed_pixels, refined_points, refined_cals, cpar + ) + result["optimized_camera_indices"] = optimized_cam_indices + result["known_point_indices"] = [ + point_index for point_index, _, _ in active_known_points + ] + + return refined_cals, refined_points, result + + +def guarded_two_step_bundle_adjustment( + observed_pixels: np.ndarray, + cals: List[Calibration], + cpar: ControlPar, + pose_orient_par: OrientPar, + intrinsic_orient_par: OrientPar, + *, + point_init: Optional[np.ndarray] = None, + fixed_camera_indices: Optional[List[int]] = None, + pose_release_camera_order: Optional[List[int]] = None, + pose_stage_ray_slack: float = 0.0, + pose_prior_sigmas: Optional[Dict[str, float]] = None, + pose_parameter_bounds: Optional[Dict[str, Tuple[float, float]]] = None, + pose_loss: str = "linear", + pose_method: str = "trf", + pose_max_nfev: Optional[int] = None, + pose_x_scale: Optional[float | Sequence[float] | Dict[str, float]] = None, + pose_stage_configs: Optional[Sequence[Dict[str, object]]] = None, + intrinsic_prior_sigmas: Optional[Dict[str, float]] = None, + intrinsic_parameter_bounds: Optional[Dict[str, Tuple[float, float]]] = None, + intrinsic_loss: str = "linear", + intrinsic_method: str = "trf", + intrinsic_max_nfev: Optional[int] = None, + intrinsic_x_scale: Optional[float | Sequence[float] | Dict[str, float]] = None, + intrinsic_ftol: Optional[float] = 1e-12, + intrinsic_xtol: Optional[float] = 1e-12, + intrinsic_gtol: Optional[float] = 1e-12, + pose_optimize_points: bool = True, + intrinsic_optimize_points: bool = True, + known_points: Optional[Dict[int, np.ndarray]] = None, + known_point_sigmas: Optional[float | np.ndarray] = None, + geometry_reference_points: Optional[np.ndarray] = None, + geometry_reference_cals: Optional[List[Calibration]] = None, + geometry_guard_mode: str = "off", + geometry_guard_threshold: Optional[float] = None, + correspondence_original_ids: Optional[np.ndarray] = None, + correspondence_point_frame_indices: Optional[np.ndarray] = None, + correspondence_frame_target_pixels: Optional[Sequence[Sequence[np.ndarray]]] = None, + correspondence_guard_mode: str = "off", + correspondence_guard_threshold: Optional[float] = None, + correspondence_guard_reference_rate: Optional[float] = None, + reject_worse_solution: bool = True, + reject_on_ray_convergence: bool = True, +) -> Tuple[List[Calibration], np.ndarray, Dict[str, object]]: + """Run pose-only BA then tightly constrained intrinsics BA with acceptance checks.""" + if pose_stage_ray_slack < 0: + raise ValueError("pose_stage_ray_slack must be non-negative") + + normalized_pose_stage_configs = list(pose_stage_configs or []) + + def pose_stage_variants() -> List[Dict[str, object]]: + if not normalized_pose_stage_configs: + return [ + { + "prior_sigmas": pose_prior_sigmas, + "parameter_bounds": pose_parameter_bounds, + "max_nfev": pose_max_nfev, + "optimize_points": pose_optimize_points, + "x_scale": pose_x_scale, + "loss": pose_loss, + "method": pose_method, + } + ] + + variants: List[Dict[str, object]] = [] + for variant in normalized_pose_stage_configs: + variant_dict = dict(variant) + variants.append( + { + "prior_sigmas": cast( + Optional[Dict[str, float]], + variant_dict.get("prior_sigmas", pose_prior_sigmas), + ), + "parameter_bounds": cast( + Optional[Dict[str, Tuple[float, float]]], + variant_dict.get("parameter_bounds", pose_parameter_bounds), + ), + "max_nfev": cast( + Optional[int], + variant_dict.get("max_nfev", pose_max_nfev), + ), + "optimize_points": cast( + bool, + variant_dict.get("optimize_points", pose_optimize_points), + ), + "x_scale": cast( + Optional[float | Sequence[float] | Dict[str, float]], + variant_dict.get("x_scale", pose_x_scale), + ), + "loss": cast(str, variant_dict.get("loss", pose_loss)), + "method": cast(str, variant_dict.get("method", pose_method)), + } + ) + return variants + + def projection_drift_summaries( + reference_cals: List[Calibration], + candidate_cals: List[Calibration], + reference_points: Optional[np.ndarray], + ) -> Optional[List[Dict[str, float]]]: + if reference_points is None: + return None + + summaries: List[Dict[str, float]] = [] + for camera_index, (reference_cal, candidate_cal) in enumerate( + zip(reference_cals, candidate_cals), + start=1, + ): + reference_pixels = [] + candidate_pixels = [] + for point in reference_points: + ref_x, ref_y = img_coord(point, reference_cal, cpar.mm) + cand_x, cand_y = img_coord(point, candidate_cal, cpar.mm) + reference_pixels.append(metric_to_pixel(ref_x, ref_y, cpar)) + candidate_pixels.append(metric_to_pixel(cand_x, cand_y, cpar)) + + displacement = np.linalg.norm( + np.asarray(candidate_pixels) - np.asarray(reference_pixels), + axis=1, + ) + summaries.append( + { + "camera_index": float(camera_index), + "mean_distance": float(displacement.mean()), + "p95_distance": float(np.percentile(displacement, 95.0)), + "max_distance": float(displacement.max()), + } + ) + + return summaries + + def max_projection_drift( + summaries: Optional[List[Dict[str, float]]], + ) -> Optional[float]: + if not summaries: + return None + return max(item["max_distance"] for item in summaries) + + def geometry_stage_ok( + candidate_metric: Optional[float], + baseline_metric: Optional[float], + ) -> bool: + if geometry_guard_mode == "off" or candidate_metric is None: + return True + if geometry_guard_mode == "soft": + if baseline_metric is None: + return True + return candidate_metric <= baseline_metric + 1e-12 + if geometry_guard_mode == "hard": + if geometry_guard_threshold is None or geometry_guard_threshold <= 0: + raise ValueError( + "geometry_guard_threshold must be positive when geometry_guard_mode='hard'" + ) + return candidate_metric <= geometry_guard_threshold + raise ValueError("geometry_guard_mode must be one of 'off', 'soft', or 'hard'") + + def correspondence_replacement_summary( + candidate_points: np.ndarray, + candidate_cals: List[Calibration], + ) -> Optional[Dict[str, object]]: + if ( + correspondence_original_ids is None + or correspondence_point_frame_indices is None + or correspondence_frame_target_pixels is None + ): + return None + + projected_pixels = np.empty( + (candidate_points.shape[0], len(candidate_cals), 2), + dtype=np.float64, + ) + for camera_index, cal in enumerate(candidate_cals): + projected_pixels[:, camera_index, :] = arr_metric_to_pixel( + image_coordinates(candidate_points, cal, cpar.mm), + cpar, + ) + + replacement_ids = np.empty_like(correspondence_original_ids) + nearest_distances = np.empty_like( + correspondence_original_ids, + dtype=np.float64, + ) + for point_index in range(candidate_points.shape[0]): + frame_targets = correspondence_frame_target_pixels[ + int(correspondence_point_frame_indices[point_index]) + ] + for camera_index in range(len(candidate_cals)): + deltas = ( + frame_targets[camera_index] + - projected_pixels[point_index, camera_index] + ) + squared_distances = np.sum(deltas * deltas, axis=1) + nearest_index = int(np.argmin(squared_distances)) + replacement_ids[point_index, camera_index] = nearest_index + nearest_distances[point_index, camera_index] = float( + np.sqrt(squared_distances[nearest_index]) + ) + + changed_mask = np.any( + replacement_ids != correspondence_original_ids, + axis=1, + ) + camera_change_rates = [ + float( + np.mean( + replacement_ids[:, camera_index] + != correspondence_original_ids[:, camera_index] + ) + ) + for camera_index in range(len(candidate_cals)) + ] + return { + "replacement_rate": float(np.mean(changed_mask)), + "camera_change_rates": camera_change_rates, + "mean_nearest_distance": float(np.mean(nearest_distances)), + "p95_nearest_distance": float(np.percentile(nearest_distances, 95.0)), + "max_nearest_distance": float(np.max(nearest_distances)), + } + + def correspondence_stage_ok( + candidate_rate: Optional[float], + prior_rate: Optional[float], + ) -> bool: + if correspondence_guard_mode == "off" or candidate_rate is None: + return True + if correspondence_guard_mode == "soft": + if correspondence_guard_reference_rate is not None: + return candidate_rate <= correspondence_guard_reference_rate + 1e-12 + if prior_rate is None: + return True + return candidate_rate <= prior_rate + 1e-12 + if correspondence_guard_mode == "hard": + if ( + correspondence_guard_threshold is None + or correspondence_guard_threshold <= 0 + ): + raise ValueError( + "correspondence_guard_threshold must be positive when correspondence_guard_mode='hard'" + ) + return candidate_rate <= correspondence_guard_threshold + raise ValueError( + "correspondence_guard_mode must be one of 'off', 'soft', or 'hard'" + ) + + base_cals = [_clone_calibration(cal) for cal in cals] + if point_init is None: + base_points, _ = initialize_bundle_adjustment_points( + observed_pixels, base_cals, cpar + ) + else: + base_points = np.asarray(point_init, dtype=np.float64) + if base_points.shape != (observed_pixels.shape[0], 3): + raise ValueError("point_init must have shape (num_points, 3)") + + baseline_rms = reprojection_rms(observed_pixels, base_points, base_cals, cpar) + baseline_ray_convergence = mean_ray_convergence(observed_pixels, base_cals, cpar) + if geometry_reference_cals is None: + geometry_reference_cals = [_clone_calibration(cal) for cal in base_cals] + + baseline_geometry = projection_drift_summaries( + geometry_reference_cals, + base_cals, + geometry_reference_points, + ) + baseline_geometry_max = max_projection_drift(baseline_geometry) + baseline_correspondence = correspondence_replacement_summary( + base_points, + base_cals, + ) + baseline_correspondence_rate = ( + None + if baseline_correspondence is None + else cast(float, baseline_correspondence["replacement_rate"]) + ) + + pose_stage_summaries: List[Dict[str, object]] = [] + pose_stage_variants_list = pose_stage_variants() + pose_result: object + pose_ok: bool + if pose_release_camera_order is None: + stage_cals = [_clone_calibration(cal) for cal in base_cals] + stage_points = np.asarray(base_points, dtype=np.float64).copy() + stage_rms = baseline_rms + stage_ray_convergence = baseline_ray_convergence + stage_geometry = baseline_geometry + stage_geometry_max = baseline_geometry_max + stage_correspondence = baseline_correspondence + stage_correspondence_rate = baseline_correspondence_rate + pose_result = {"staged": bool(normalized_pose_stage_configs), "stages": []} + pose_ok = False + pose_geometry_ok = True + pose_correspondence_ok = True + + for micro_stage_index, variant in enumerate(pose_stage_variants_list, start=1): + candidate_cals, candidate_points, candidate_result = ( + multi_camera_bundle_adjustment( + observed_pixels, + stage_cals, + cpar, + pose_orient_par, + point_init=stage_points, + fixed_camera_indices=fixed_camera_indices, + loss=cast(str, variant["loss"]), + method=cast(str, variant["method"]), + prior_sigmas=cast( + Optional[Dict[str, float]], variant["prior_sigmas"] + ), + parameter_bounds=cast( + Optional[Dict[str, Tuple[float, float]]], + variant["parameter_bounds"], + ), + max_nfev=cast(Optional[int], variant["max_nfev"]), + optimize_extrinsics=True, + optimize_points=cast(bool, variant["optimize_points"]), + known_points=known_points, + known_point_sigmas=known_point_sigmas, + x_scale=cast( + Optional[float | Sequence[float] | Dict[str, float]], + variant["x_scale"], + ), + ) + ) + candidate_rms = reprojection_rms( + observed_pixels, candidate_points, candidate_cals, cpar + ) + candidate_ray_convergence = mean_ray_convergence( + observed_pixels, candidate_cals, cpar + ) + candidate_geometry = projection_drift_summaries( + geometry_reference_cals, + candidate_cals, + geometry_reference_points, + ) + candidate_geometry_max = max_projection_drift(candidate_geometry) + candidate_correspondence = correspondence_replacement_summary( + candidate_points, + candidate_cals, + ) + candidate_correspondence_rate = ( + None + if candidate_correspondence is None + else cast(float, candidate_correspondence["replacement_rate"]) + ) + pose_geometry_ok = geometry_stage_ok( + candidate_geometry_max, + stage_geometry_max, + ) + pose_correspondence_ok = correspondence_stage_ok( + candidate_correspondence_rate, + stage_correspondence_rate, + ) + pose_ok = candidate_rms <= stage_rms and ( + not reject_on_ray_convergence + or candidate_ray_convergence + <= stage_ray_convergence + pose_stage_ray_slack + ) + pose_ok = pose_ok and pose_geometry_ok and pose_correspondence_ok + pose_stage_summaries.append( + { + "stage_index": 1, + "micro_stage_index": micro_stage_index, + "released_camera_index": None, + "free_camera_indices": [ + camera_index + for camera_index in range(len(cals)) + if camera_index not in (fixed_camera_indices or []) + ], + "fixed_camera_indices": fixed_camera_indices, + "reprojection_rms": candidate_rms, + "mean_ray_convergence": candidate_ray_convergence, + "geometry": candidate_geometry, + "geometry_max": candidate_geometry_max, + "geometry_ok": pose_geometry_ok, + "correspondence": candidate_correspondence, + "correspondence_rate": candidate_correspondence_rate, + "correspondence_ok": pose_correspondence_ok, + "accepted": pose_ok or not reject_worse_solution, + "optimize_points": cast(bool, variant["optimize_points"]), + "x_scale": variant["x_scale"], + "result": candidate_result, + } + ) + cast(List[object], pose_result["stages"]).append(candidate_result) + + if reject_worse_solution and not pose_ok: + break + + stage_cals = candidate_cals + stage_points = candidate_points + stage_rms = candidate_rms + stage_ray_convergence = candidate_ray_convergence + stage_geometry = candidate_geometry + stage_geometry_max = candidate_geometry_max + stage_correspondence = candidate_correspondence + stage_correspondence_rate = candidate_correspondence_rate + + pose_cals = stage_cals + pose_points = stage_points + pose_rms = stage_rms + pose_ray_convergence = stage_ray_convergence + pose_geometry = stage_geometry + pose_geometry_max = stage_geometry_max + pose_correspondence = stage_correspondence + pose_correspondence_rate = stage_correspondence_rate + else: + release_order = [ + int(camera_index) for camera_index in pose_release_camera_order + ] + if not release_order: + raise ValueError("pose_release_camera_order must not be empty") + if len(set(release_order)) != len(release_order): + raise ValueError("pose_release_camera_order must not contain duplicates") + if any( + camera_index < 0 or camera_index >= len(cals) + for camera_index in release_order + ): + raise ValueError( + "pose_release_camera_order contains an out-of-range camera index" + ) + + current_cals = [_clone_calibration(cal) for cal in base_cals] + current_points = np.asarray(base_points, dtype=np.float64).copy() + current_rms = baseline_rms + current_ray_convergence = baseline_ray_convergence + current_geometry = baseline_geometry + current_geometry_max = baseline_geometry_max + current_correspondence = baseline_correspondence + current_correspondence_rate = baseline_correspondence_rate + pose_result = {"staged": True, "stages": []} + pose_ok = False + pose_geometry_ok = True + pose_correspondence_ok = True + released_cameras: List[int] = [] + + for stage_index, released_camera in enumerate(release_order, start=1): + released_cameras.append(released_camera) + stage_fixed = [ + camera_index + for camera_index in range(len(cals)) + if camera_index not in released_cameras + ] + stage_ok = False + for micro_stage_index, variant in enumerate( + pose_stage_variants_list, + start=1, + ): + stage_cals, stage_points, stage_result = multi_camera_bundle_adjustment( + observed_pixels, + current_cals, + cpar, + pose_orient_par, + point_init=current_points, + fixed_camera_indices=stage_fixed, + loss=cast(str, variant["loss"]), + method=cast(str, variant["method"]), + prior_sigmas=cast( + Optional[Dict[str, float]], + variant["prior_sigmas"], + ), + parameter_bounds=cast( + Optional[Dict[str, Tuple[float, float]]], + variant["parameter_bounds"], + ), + max_nfev=cast(Optional[int], variant["max_nfev"]), + optimize_extrinsics=True, + optimize_points=cast(bool, variant["optimize_points"]), + known_points=known_points, + known_point_sigmas=known_point_sigmas, + x_scale=cast( + Optional[float | Sequence[float] | Dict[str, float]], + variant["x_scale"], + ), + ) + stage_rms = reprojection_rms( + observed_pixels, stage_points, stage_cals, cpar + ) + stage_ray_convergence = mean_ray_convergence( + observed_pixels, stage_cals, cpar + ) + stage_geometry = projection_drift_summaries( + geometry_reference_cals, + stage_cals, + geometry_reference_points, + ) + stage_geometry_max = max_projection_drift(stage_geometry) + stage_correspondence = correspondence_replacement_summary( + stage_points, + stage_cals, + ) + stage_correspondence_rate = ( + None + if stage_correspondence is None + else cast(float, stage_correspondence["replacement_rate"]) + ) + stage_geometry_ok = geometry_stage_ok( + stage_geometry_max, + current_geometry_max, + ) + stage_correspondence_ok = correspondence_stage_ok( + stage_correspondence_rate, + current_correspondence_rate, + ) + stage_ok = stage_rms <= current_rms and ( + not reject_on_ray_convergence + or stage_ray_convergence + <= current_ray_convergence + pose_stage_ray_slack + ) + stage_ok = stage_ok and stage_geometry_ok and stage_correspondence_ok + pose_stage_summaries.append( + { + "stage_index": stage_index, + "micro_stage_index": micro_stage_index, + "released_camera_index": released_camera, + "free_camera_indices": released_cameras.copy(), + "fixed_camera_indices": stage_fixed, + "reprojection_rms": stage_rms, + "mean_ray_convergence": stage_ray_convergence, + "geometry": stage_geometry, + "geometry_max": stage_geometry_max, + "geometry_ok": stage_geometry_ok, + "correspondence": stage_correspondence, + "correspondence_rate": stage_correspondence_rate, + "correspondence_ok": stage_correspondence_ok, + "accepted": stage_ok or not reject_worse_solution, + "optimize_points": cast(bool, variant["optimize_points"]), + "x_scale": variant["x_scale"], + "result": stage_result, + } + ) + cast(List[object], pose_result["stages"]).append(stage_result) + + if reject_worse_solution and not stage_ok: + break + + current_cals = stage_cals + current_points = stage_points + current_rms = stage_rms + current_ray_convergence = stage_ray_convergence + current_geometry = stage_geometry + current_geometry_max = stage_geometry_max + current_correspondence = stage_correspondence + current_correspondence_rate = stage_correspondence_rate + pose_ok = True + pose_geometry_ok = stage_geometry_ok + pose_correspondence_ok = stage_correspondence_ok + + if reject_worse_solution and not stage_ok: + break + + pose_cals = current_cals + pose_points = current_points + pose_rms = current_rms + pose_ray_convergence = current_ray_convergence + pose_geometry = current_geometry + pose_geometry_max = current_geometry_max + pose_correspondence = current_correspondence + pose_correspondence_rate = current_correspondence_rate + + intrinsic_fixed = list(range(len(cals))) + intrinsic_cals, intrinsic_points, intrinsic_result = multi_camera_bundle_adjustment( + observed_pixels, + pose_cals, + cpar, + intrinsic_orient_par, + point_init=pose_points, + fixed_camera_indices=intrinsic_fixed, + loss=intrinsic_loss, + method=intrinsic_method, + prior_sigmas=intrinsic_prior_sigmas, + parameter_bounds=intrinsic_parameter_bounds, + max_nfev=intrinsic_max_nfev, + optimize_extrinsics=False, + optimize_points=intrinsic_optimize_points, + known_points=known_points, + known_point_sigmas=known_point_sigmas, + x_scale=intrinsic_x_scale, + ftol=intrinsic_ftol, + xtol=intrinsic_xtol, + gtol=intrinsic_gtol, + ) + intrinsic_rms = reprojection_rms( + observed_pixels, intrinsic_points, intrinsic_cals, cpar + ) + intrinsic_ray_convergence = mean_ray_convergence( + observed_pixels, intrinsic_cals, cpar + ) + intrinsic_geometry = projection_drift_summaries( + geometry_reference_cals, + intrinsic_cals, + geometry_reference_points, + ) + intrinsic_geometry_max = max_projection_drift(intrinsic_geometry) + intrinsic_correspondence = correspondence_replacement_summary( + intrinsic_points, + intrinsic_cals, + ) + intrinsic_correspondence_rate = ( + None + if intrinsic_correspondence is None + else cast(float, intrinsic_correspondence["replacement_rate"]) + ) + + accepted_stage = "intrinsics" + final_cals = intrinsic_cals + final_points = intrinsic_points + final_rms = intrinsic_rms + final_ray_convergence = intrinsic_ray_convergence + intrinsic_ok = intrinsic_rms <= pose_rms and ( + not reject_on_ray_convergence + or intrinsic_ray_convergence <= pose_ray_convergence + ) + intrinsic_geometry_ok = geometry_stage_ok( + intrinsic_geometry_max, + pose_geometry_max, + ) + intrinsic_ok = intrinsic_ok and intrinsic_geometry_ok + intrinsic_correspondence_ok = correspondence_stage_ok( + intrinsic_correspondence_rate, + pose_correspondence_rate, + ) + intrinsic_ok = intrinsic_ok and intrinsic_correspondence_ok + + if reject_worse_solution: + if not pose_ok: + accepted_stage = "baseline" + final_cals = base_cals + final_points = base_points + final_rms = baseline_rms + final_ray_convergence = baseline_ray_convergence + elif not intrinsic_ok: + accepted_stage = "pose" + final_cals = pose_cals + final_points = pose_points + final_rms = pose_rms + final_ray_convergence = pose_ray_convergence + + summary = { + "baseline_reprojection_rms": baseline_rms, + "baseline_mean_ray_convergence": baseline_ray_convergence, + "baseline_cals": base_cals, + "baseline_points": base_points, + "baseline_geometry": baseline_geometry, + "baseline_geometry_max": baseline_geometry_max, + "baseline_correspondence": baseline_correspondence, + "baseline_correspondence_rate": baseline_correspondence_rate, + "pose_reprojection_rms": pose_rms, + "pose_mean_ray_convergence": pose_ray_convergence, + "pose_cals": pose_cals, + "pose_points": pose_points, + "pose_geometry": pose_geometry, + "pose_geometry_max": pose_geometry_max, + "pose_geometry_ok": pose_geometry_ok, + "pose_release_camera_order": pose_release_camera_order, + "pose_stage_ray_slack": pose_stage_ray_slack, + "pose_stage_configs": normalized_pose_stage_configs, + "pose_stage_summaries": pose_stage_summaries, + "accepted_pose_stage_count": len( + [summary for summary in pose_stage_summaries if summary["accepted"]] + ), + "pose_correspondence": pose_correspondence, + "pose_correspondence_rate": pose_correspondence_rate, + "pose_correspondence_ok": pose_correspondence_ok, + "intrinsic_reprojection_rms": intrinsic_rms, + "intrinsic_mean_ray_convergence": intrinsic_ray_convergence, + "intrinsic_cals": intrinsic_cals, + "intrinsic_points": intrinsic_points, + "intrinsic_geometry": intrinsic_geometry, + "intrinsic_geometry_max": intrinsic_geometry_max, + "intrinsic_geometry_ok": intrinsic_geometry_ok, + "intrinsic_correspondence": intrinsic_correspondence, + "intrinsic_correspondence_rate": intrinsic_correspondence_rate, + "intrinsic_correspondence_ok": intrinsic_correspondence_ok, + "geometry_guard_mode": geometry_guard_mode, + "geometry_guard_threshold": geometry_guard_threshold, + "correspondence_guard_mode": correspondence_guard_mode, + "correspondence_guard_threshold": correspondence_guard_threshold, + "correspondence_guard_reference_rate": correspondence_guard_reference_rate, + "accepted_stage": accepted_stage, + "final_reprojection_rms": final_rms, + "final_mean_ray_convergence": final_ray_convergence, + "pose_result": pose_result, + "intrinsic_result": intrinsic_result, + } + + return final_cals, final_points, summary + + +def alternating_bundle_adjustment( + observed_pixels: np.ndarray, + cals: List[Calibration], + cpar: ControlPar, + pose_orient_par: OrientPar, + intrinsic_orient_par: OrientPar, + *, + point_init: Optional[np.ndarray] = None, + fixed_camera_indices: Optional[List[int]] = None, + pose_release_camera_order: Optional[List[int]] = None, + pose_stage_ray_slack: float = 0.0, + pose_prior_sigmas: Optional[Dict[str, float]] = None, + pose_parameter_bounds: Optional[Dict[str, Tuple[float, float]]] = None, + pose_loss: str = "linear", + pose_method: str = "trf", + pose_max_nfev: Optional[int] = None, + pose_x_scale: Optional[float | Sequence[float] | Dict[str, float]] = None, + pose_block_configs: Optional[Sequence[Dict[str, object]]] = None, + intrinsic_prior_sigmas: Optional[Dict[str, float]] = None, + intrinsic_parameter_bounds: Optional[Dict[str, Tuple[float, float]]] = None, + intrinsic_loss: str = "linear", + intrinsic_method: str = "trf", + intrinsic_max_nfev: Optional[int] = None, + intrinsic_x_scale: Optional[float | Sequence[float] | Dict[str, float]] = None, + intrinsic_ftol: Optional[float] = 1e-12, + intrinsic_xtol: Optional[float] = 1e-12, + intrinsic_gtol: Optional[float] = 1e-12, + known_points: Optional[Dict[int, np.ndarray]] = None, + known_point_sigmas: Optional[float | np.ndarray] = None, + geometry_reference_points: Optional[np.ndarray] = None, + geometry_reference_cals: Optional[List[Calibration]] = None, + geometry_guard_mode: str = "off", + geometry_guard_threshold: Optional[float] = None, + first_release_geometry_slack: float = 0.0, + correspondence_original_ids: Optional[np.ndarray] = None, + correspondence_point_frame_indices: Optional[np.ndarray] = None, + correspondence_frame_target_pixels: Optional[Sequence[Sequence[np.ndarray]]] = None, + correspondence_guard_mode: str = "off", + correspondence_guard_threshold: Optional[float] = None, + correspondence_guard_reference_rate: Optional[float] = None, + first_release_correspondence_slack: float = 0.0, + reject_worse_solution: bool = True, + reject_on_ray_convergence: bool = True, +) -> Tuple[List[Calibration], np.ndarray, Dict[str, object]]: + """Run intrinsics-first alternating BA with point and pose sub-block updates.""" + if pose_stage_ray_slack < 0: + raise ValueError("pose_stage_ray_slack must be non-negative") + if first_release_geometry_slack < 0: + raise ValueError("first_release_geometry_slack must be non-negative") + if first_release_correspondence_slack < 0: + raise ValueError("first_release_correspondence_slack must be non-negative") + + def merge_bounds( + base: Optional[Dict[str, Tuple[float, float]]], + updates: Optional[Dict[str, Tuple[float, float]]], + ) -> Optional[Dict[str, Tuple[float, float]]]: + merged = dict(base or {}) + if updates is not None: + merged.update(updates) + return merged or None + + def default_pose_blocks() -> List[Dict[str, object]]: + return [ + { + "name": "points_only", + "optimize_extrinsics": False, + "optimize_points": True, + "loss": pose_loss, + "method": pose_method, + "max_nfev": 4 if pose_max_nfev is None else min(4, pose_max_nfev), + "x_scale": {"points": 0.1}, + }, + { + "name": "omega_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "loss": pose_loss, + "method": pose_method, + "max_nfev": 3 if pose_max_nfev is None else min(3, pose_max_nfev), + "x_scale": { + "omega": 2e-4, + }, + "frozen_parameters": ["phi", "kappa"], + }, + { + "name": "phi_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "loss": pose_loss, + "method": pose_method, + "max_nfev": 3 if pose_max_nfev is None else min(3, pose_max_nfev), + "x_scale": { + "phi": 2e-4, + }, + "frozen_parameters": ["omega", "kappa"], + }, + { + "name": "kappa_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "loss": pose_loss, + "method": pose_method, + "max_nfev": 3 if pose_max_nfev is None else min(3, pose_max_nfev), + "x_scale": { + "kappa": 2e-4, + }, + "frozen_parameters": ["omega", "phi"], + }, + { + "name": "translation_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_rotation": True, + "loss": pose_loss, + "method": pose_method, + "max_nfev": 4 if pose_max_nfev is None else min(4, pose_max_nfev), + "x_scale": { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + }, + }, + { + "name": "joint_pose_points", + "optimize_extrinsics": True, + "optimize_points": True, + "loss": pose_loss, + "method": pose_method, + "max_nfev": pose_max_nfev, + "x_scale": pose_x_scale, + }, + ] + + normalized_pose_block_configs = list( + default_pose_blocks() if pose_block_configs is None else pose_block_configs + ) + + def projection_drift_summaries( + reference_cals: List[Calibration], + candidate_cals: List[Calibration], + reference_points: Optional[np.ndarray], + ) -> Optional[List[Dict[str, float]]]: + if reference_points is None: + return None + + summaries: List[Dict[str, float]] = [] + for camera_index, (reference_cal, candidate_cal) in enumerate( + zip(reference_cals, candidate_cals), + start=1, + ): + reference_pixels = [] + candidate_pixels = [] + for point in reference_points: + ref_x, ref_y = img_coord(point, reference_cal, cpar.mm) + cand_x, cand_y = img_coord(point, candidate_cal, cpar.mm) + reference_pixels.append(metric_to_pixel(ref_x, ref_y, cpar)) + candidate_pixels.append(metric_to_pixel(cand_x, cand_y, cpar)) + + displacement = np.linalg.norm( + np.asarray(candidate_pixels) - np.asarray(reference_pixels), + axis=1, + ) + summaries.append( + { + "camera_index": float(camera_index), + "mean_distance": float(displacement.mean()), + "p95_distance": float(np.percentile(displacement, 95.0)), + "max_distance": float(displacement.max()), + } + ) + + return summaries + + def max_projection_drift( + summaries: Optional[List[Dict[str, float]]], + ) -> Optional[float]: + if not summaries: + return None + return max(item["max_distance"] for item in summaries) + + def geometry_stage_ok( + candidate_metric: Optional[float], + baseline_metric: Optional[float], + *, + threshold_override: Optional[float] = None, + ) -> bool: + if geometry_guard_mode == "off" or candidate_metric is None: + return True + if geometry_guard_mode == "soft": + if baseline_metric is None: + return True + return candidate_metric <= baseline_metric + 1e-12 + if geometry_guard_mode == "hard": + threshold = ( + geometry_guard_threshold + if threshold_override is None + else threshold_override + ) + if threshold is None or threshold <= 0: + raise ValueError( + "geometry_guard_threshold must be positive when geometry_guard_mode='hard'" + ) + return candidate_metric <= threshold + raise ValueError("geometry_guard_mode must be one of 'off', 'soft', or 'hard'") + + def correspondence_replacement_summary( + candidate_points: np.ndarray, + candidate_cals: List[Calibration], + ) -> Optional[Dict[str, object]]: + if ( + correspondence_original_ids is None + or correspondence_point_frame_indices is None + or correspondence_frame_target_pixels is None + ): + return None + + projected_pixels = np.empty( + (candidate_points.shape[0], len(candidate_cals), 2), + dtype=np.float64, + ) + for camera_index, cal in enumerate(candidate_cals): + projected_pixels[:, camera_index, :] = arr_metric_to_pixel( + image_coordinates(candidate_points, cal, cpar.mm), + cpar, + ) + + replacement_ids = np.empty_like(correspondence_original_ids) + nearest_distances = np.empty_like( + correspondence_original_ids, + dtype=np.float64, + ) + for point_index in range(candidate_points.shape[0]): + frame_targets = correspondence_frame_target_pixels[ + int(correspondence_point_frame_indices[point_index]) + ] + for camera_index in range(len(candidate_cals)): + deltas = ( + frame_targets[camera_index] + - projected_pixels[point_index, camera_index] + ) + squared_distances = np.sum(deltas * deltas, axis=1) + nearest_index = int(np.argmin(squared_distances)) + replacement_ids[point_index, camera_index] = nearest_index + nearest_distances[point_index, camera_index] = float( + np.sqrt(squared_distances[nearest_index]) + ) + + changed_mask = np.any( + replacement_ids != correspondence_original_ids, + axis=1, + ) + camera_change_rates = [ + float( + np.mean( + replacement_ids[:, camera_index] + != correspondence_original_ids[:, camera_index] + ) + ) + for camera_index in range(len(candidate_cals)) + ] + return { + "replacement_rate": float(np.mean(changed_mask)), + "camera_change_rates": camera_change_rates, + "mean_nearest_distance": float(np.mean(nearest_distances)), + "p95_nearest_distance": float(np.percentile(nearest_distances, 95.0)), + "max_nearest_distance": float(np.max(nearest_distances)), + } + + def correspondence_stage_ok( + candidate_rate: Optional[float], + prior_rate: Optional[float], + *, + threshold_override: Optional[float] = None, + ) -> bool: + if correspondence_guard_mode == "off" or candidate_rate is None: + return True + if correspondence_guard_mode == "soft": + if correspondence_guard_reference_rate is not None: + return candidate_rate <= correspondence_guard_reference_rate + 1e-12 + if prior_rate is None: + return True + return candidate_rate <= prior_rate + 1e-12 + if correspondence_guard_mode == "hard": + if threshold_override is None and correspondence_guard_threshold is None: + threshold = None + else: + threshold = ( + correspondence_guard_threshold + if threshold_override is None + else threshold_override + ) + if threshold is None or threshold <= 0: + raise ValueError( + "correspondence_guard_threshold must be positive when correspondence_guard_mode='hard'" + ) + return candidate_rate <= threshold + raise ValueError( + "correspondence_guard_mode must be one of 'off', 'soft', or 'hard'" + ) + + base_cals = [_clone_calibration(cal) for cal in cals] + if point_init is None: + base_points, _ = initialize_bundle_adjustment_points( + observed_pixels, base_cals, cpar + ) + else: + base_points = np.asarray(point_init, dtype=np.float64) + if base_points.shape != (observed_pixels.shape[0], 3): + raise ValueError("point_init must have shape (num_points, 3)") + + baseline_rms = reprojection_rms(observed_pixels, base_points, base_cals, cpar) + baseline_ray_convergence = mean_ray_convergence(observed_pixels, base_cals, cpar) + if geometry_reference_cals is None: + geometry_reference_cals = [_clone_calibration(cal) for cal in base_cals] + + baseline_geometry = projection_drift_summaries( + geometry_reference_cals, + base_cals, + geometry_reference_points, + ) + baseline_geometry_max = max_projection_drift(baseline_geometry) + baseline_correspondence = correspondence_replacement_summary( + base_points, + base_cals, + ) + baseline_correspondence_rate = ( + None + if baseline_correspondence is None + else cast(float, baseline_correspondence["replacement_rate"]) + ) + + warmstart_fixed = list(range(len(cals))) + warmstart_cals, warmstart_points, warmstart_result = multi_camera_bundle_adjustment( + observed_pixels, + base_cals, + cpar, + intrinsic_orient_par, + point_init=base_points, + fixed_camera_indices=warmstart_fixed, + loss=intrinsic_loss, + method=intrinsic_method, + prior_sigmas=intrinsic_prior_sigmas, + parameter_bounds=intrinsic_parameter_bounds, + max_nfev=intrinsic_max_nfev, + optimize_extrinsics=False, + optimize_points=False, + x_scale=intrinsic_x_scale, + ftol=intrinsic_ftol, + xtol=intrinsic_xtol, + gtol=intrinsic_gtol, + ) + warmstart_rms = reprojection_rms( + observed_pixels, warmstart_points, warmstart_cals, cpar + ) + warmstart_ray_convergence = mean_ray_convergence( + observed_pixels, warmstart_cals, cpar + ) + warmstart_geometry = projection_drift_summaries( + geometry_reference_cals, + warmstart_cals, + geometry_reference_points, + ) + warmstart_geometry_max = max_projection_drift(warmstart_geometry) + warmstart_correspondence = correspondence_replacement_summary( + warmstart_points, + warmstart_cals, + ) + warmstart_correspondence_rate = ( + None + if warmstart_correspondence is None + else cast(float, warmstart_correspondence["replacement_rate"]) + ) + warmstart_ok = warmstart_rms <= baseline_rms and ( + not reject_on_ray_convergence + or warmstart_ray_convergence <= baseline_ray_convergence + ) + warmstart_ok = warmstart_ok and geometry_stage_ok( + warmstart_geometry_max, + baseline_geometry_max, + ) + warmstart_ok = warmstart_ok and correspondence_stage_ok( + warmstart_correspondence_rate, + baseline_correspondence_rate, + ) + + if reject_worse_solution and not warmstart_ok: + current_cals = [_clone_calibration(cal) for cal in base_cals] + current_points = np.asarray(base_points, dtype=np.float64).copy() + current_rms = baseline_rms + current_ray_convergence = baseline_ray_convergence + current_geometry = baseline_geometry + current_geometry_max = baseline_geometry_max + current_correspondence = baseline_correspondence + current_correspondence_rate = baseline_correspondence_rate + else: + current_cals = warmstart_cals + current_points = warmstart_points + current_rms = warmstart_rms + current_ray_convergence = warmstart_ray_convergence + current_geometry = warmstart_geometry + current_geometry_max = warmstart_geometry_max + current_correspondence = warmstart_correspondence + current_correspondence_rate = warmstart_correspondence_rate + + if pose_release_camera_order is None: + release_order = [ + camera_index + for camera_index in range(len(cals)) + if camera_index not in (fixed_camera_indices or []) + ] + else: + release_order = [ + int(camera_index) for camera_index in pose_release_camera_order + ] + if not release_order: + raise ValueError("pose_release_camera_order must not be empty") + + alternating_result: Dict[str, object] = { + "warmstart_result": warmstart_result, + "stages": [], + } + block_summaries: List[Dict[str, object]] = [] + pose_ok = False + pose_geometry_ok = True + pose_correspondence_ok = True + released_cameras: List[int] = [] + + for stage_index, released_camera in enumerate(release_order, start=1): + released_cameras.append(released_camera) + stage_fixed = [ + camera_index + for camera_index in range(len(cals)) + if camera_index not in released_cameras + ] + stage_completed = True + for block_index, block in enumerate(normalized_pose_block_configs, start=1): + block_config = dict(block) + optimize_extrinsics = cast( + bool, + block_config.get("optimize_extrinsics", True), + ) + optimize_points = cast(bool, block_config.get("optimize_points", True)) + block_bounds = merge_bounds( + pose_parameter_bounds, + cast( + Optional[Dict[str, Tuple[float, float]]], + block_config.get("parameter_bounds"), + ), + ) + frozen_parameters = set( + cast(Sequence[str], block_config.get("frozen_parameters", [])) + ) + if cast(bool, block_config.get("freeze_translation", False)): + frozen_parameters.update({"x0", "y0", "z0"}) + if cast(bool, block_config.get("freeze_rotation", False)): + frozen_parameters.update({"omega", "phi", "kappa"}) + unknown_frozen = frozen_parameters.difference( + {"x0", "y0", "z0", "omega", "phi", "kappa"} + ) + if unknown_frozen: + raise ValueError( + "Unsupported frozen_parameters entries: " + + ", ".join(sorted(unknown_frozen)) + ) + if frozen_parameters: + block_bounds = merge_bounds( + block_bounds, + { + parameter_name: (0.0, 0.0) + for parameter_name in sorted(frozen_parameters) + }, + ) + + block_known_points = known_points if optimize_points else None + block_known_point_sigmas = known_point_sigmas if optimize_points else None + block_cals, block_points, block_result = multi_camera_bundle_adjustment( + observed_pixels, + current_cals, + cpar, + pose_orient_par, + point_init=current_points, + fixed_camera_indices=stage_fixed, + loss=cast(str, block_config.get("loss", pose_loss)), + method=cast(str, block_config.get("method", pose_method)), + prior_sigmas=cast( + Optional[Dict[str, float]], + block_config.get("prior_sigmas", pose_prior_sigmas), + ), + parameter_bounds=block_bounds, + max_nfev=cast( + Optional[int], block_config.get("max_nfev", pose_max_nfev) + ), + optimize_extrinsics=optimize_extrinsics, + optimize_points=optimize_points, + known_points=block_known_points, + known_point_sigmas=block_known_point_sigmas, + x_scale=cast( + Optional[float | Sequence[float] | Dict[str, float]], + block_config.get("x_scale", pose_x_scale), + ), + ) + block_rms = reprojection_rms( + observed_pixels, block_points, block_cals, cpar + ) + block_ray_convergence = mean_ray_convergence( + observed_pixels, + block_cals, + cpar, + ) + block_geometry = projection_drift_summaries( + geometry_reference_cals, + block_cals, + geometry_reference_points, + ) + block_geometry_max = max_projection_drift(block_geometry) + block_correspondence = correspondence_replacement_summary( + block_points, + block_cals, + ) + block_correspondence_rate = ( + None + if block_correspondence is None + else cast(float, block_correspondence["replacement_rate"]) + ) + first_release_soft_geometry = ( + stage_index == 1 + and optimize_extrinsics + and geometry_guard_mode == "hard" + and first_release_geometry_slack > 0 + and geometry_guard_threshold is not None + ) + first_release_soft_correspondence = ( + stage_index == 1 + and optimize_extrinsics + and correspondence_guard_mode == "hard" + and first_release_correspondence_slack > 0 + and correspondence_guard_threshold is not None + ) + geometry_threshold_override = None + if first_release_soft_geometry: + geometry_guard_limit = cast(float, geometry_guard_threshold) + geometry_threshold_override = ( + geometry_guard_limit + first_release_geometry_slack + ) + correspondence_threshold_override = None + if first_release_soft_correspondence: + correspondence_guard_limit = cast(float, correspondence_guard_threshold) + correspondence_threshold_override = ( + correspondence_guard_limit + first_release_correspondence_slack + ) + block_geometry_ok = geometry_stage_ok( + block_geometry_max, + current_geometry_max, + threshold_override=geometry_threshold_override, + ) + block_correspondence_ok = correspondence_stage_ok( + block_correspondence_rate, + current_correspondence_rate, + threshold_override=correspondence_threshold_override, + ) + block_ok = block_rms <= current_rms and ( + not reject_on_ray_convergence + or block_ray_convergence + <= current_ray_convergence + pose_stage_ray_slack + ) + block_ok = block_ok and block_geometry_ok and block_correspondence_ok + block_summaries.append( + { + "stage_index": stage_index, + "block_index": block_index, + "block_name": block_config.get("name", f"block_{block_index}"), + "released_camera_index": released_camera, + "free_camera_indices": released_cameras.copy(), + "fixed_camera_indices": stage_fixed, + "reprojection_rms": block_rms, + "mean_ray_convergence": block_ray_convergence, + "geometry": block_geometry, + "geometry_max": block_geometry_max, + "geometry_ok": block_geometry_ok, + "first_release_soft_geometry": first_release_soft_geometry, + "correspondence": block_correspondence, + "correspondence_rate": block_correspondence_rate, + "correspondence_ok": block_correspondence_ok, + "first_release_soft_correspondence": first_release_soft_correspondence, + "accepted": block_ok or not reject_worse_solution, + "optimize_extrinsics": optimize_extrinsics, + "optimize_points": optimize_points, + "result": block_result, + } + ) + cast(List[object], alternating_result["stages"]).append(block_result) + + if reject_worse_solution and not block_ok: + stage_completed = False + break + + current_cals = block_cals + current_points = block_points + current_rms = block_rms + current_ray_convergence = block_ray_convergence + current_geometry = block_geometry + current_geometry_max = block_geometry_max + current_correspondence = block_correspondence + current_correspondence_rate = block_correspondence_rate + pose_ok = True + pose_geometry_ok = block_geometry_ok + pose_correspondence_ok = block_correspondence_ok + + if reject_worse_solution and not stage_completed: + break + + pose_cals = current_cals + pose_points = current_points + pose_rms = current_rms + pose_ray_convergence = current_ray_convergence + pose_geometry = current_geometry + pose_geometry_max = current_geometry_max + pose_correspondence = current_correspondence + pose_correspondence_rate = current_correspondence_rate + + intrinsic_fixed = list(range(len(cals))) + intrinsic_cals, intrinsic_points, intrinsic_result = multi_camera_bundle_adjustment( + observed_pixels, + pose_cals, + cpar, + intrinsic_orient_par, + point_init=pose_points, + fixed_camera_indices=intrinsic_fixed, + loss=intrinsic_loss, + method=intrinsic_method, + prior_sigmas=intrinsic_prior_sigmas, + parameter_bounds=intrinsic_parameter_bounds, + max_nfev=intrinsic_max_nfev, + optimize_extrinsics=False, + optimize_points=False, + x_scale=intrinsic_x_scale, + ftol=intrinsic_ftol, + xtol=intrinsic_xtol, + gtol=intrinsic_gtol, + ) + intrinsic_rms = reprojection_rms( + observed_pixels, intrinsic_points, intrinsic_cals, cpar + ) + intrinsic_ray_convergence = mean_ray_convergence( + observed_pixels, intrinsic_cals, cpar + ) + intrinsic_geometry = projection_drift_summaries( + geometry_reference_cals, + intrinsic_cals, + geometry_reference_points, + ) + intrinsic_geometry_max = max_projection_drift(intrinsic_geometry) + intrinsic_correspondence = correspondence_replacement_summary( + intrinsic_points, + intrinsic_cals, + ) + intrinsic_correspondence_rate = ( + None + if intrinsic_correspondence is None + else cast(float, intrinsic_correspondence["replacement_rate"]) + ) + + accepted_stage = "intrinsics" + final_cals = intrinsic_cals + final_points = intrinsic_points + final_rms = intrinsic_rms + final_ray_convergence = intrinsic_ray_convergence + intrinsic_ok = intrinsic_rms <= pose_rms and ( + not reject_on_ray_convergence + or intrinsic_ray_convergence <= pose_ray_convergence + ) + intrinsic_ok = intrinsic_ok and geometry_stage_ok( + intrinsic_geometry_max, + pose_geometry_max, + ) + intrinsic_ok = intrinsic_ok and correspondence_stage_ok( + intrinsic_correspondence_rate, + pose_correspondence_rate, + ) + + if reject_worse_solution: + if not pose_ok: + if warmstart_ok: + accepted_stage = "warmstart" + final_cals = warmstart_cals + final_points = warmstart_points + final_rms = warmstart_rms + final_ray_convergence = warmstart_ray_convergence + else: + accepted_stage = "baseline" + final_cals = base_cals + final_points = base_points + final_rms = baseline_rms + final_ray_convergence = baseline_ray_convergence + elif not intrinsic_ok: + accepted_stage = "pose_blocks" + final_cals = pose_cals + final_points = pose_points + final_rms = pose_rms + final_ray_convergence = pose_ray_convergence + + summary = { + "baseline_reprojection_rms": baseline_rms, + "baseline_mean_ray_convergence": baseline_ray_convergence, + "baseline_cals": base_cals, + "baseline_points": base_points, + "baseline_geometry": baseline_geometry, + "baseline_geometry_max": baseline_geometry_max, + "baseline_correspondence": baseline_correspondence, + "baseline_correspondence_rate": baseline_correspondence_rate, + "warmstart_reprojection_rms": warmstart_rms, + "warmstart_mean_ray_convergence": warmstart_ray_convergence, + "warmstart_cals": warmstart_cals, + "warmstart_points": warmstart_points, + "warmstart_geometry": warmstart_geometry, + "warmstart_geometry_max": warmstart_geometry_max, + "warmstart_correspondence": warmstart_correspondence, + "warmstart_correspondence_rate": warmstart_correspondence_rate, + "warmstart_ok": warmstart_ok, + "pose_reprojection_rms": pose_rms, + "pose_mean_ray_convergence": pose_ray_convergence, + "pose_cals": pose_cals, + "pose_points": pose_points, + "pose_geometry": pose_geometry, + "pose_geometry_max": pose_geometry_max, + "pose_geometry_ok": pose_geometry_ok, + "pose_release_camera_order": release_order, + "pose_stage_ray_slack": pose_stage_ray_slack, + "pose_block_configs": normalized_pose_block_configs, + "pose_block_summaries": block_summaries, + "accepted_pose_block_count": len( + [block for block in block_summaries if block["accepted"]] + ), + "pose_correspondence": pose_correspondence, + "pose_correspondence_rate": pose_correspondence_rate, + "pose_correspondence_ok": pose_correspondence_ok, + "intrinsic_reprojection_rms": intrinsic_rms, + "intrinsic_mean_ray_convergence": intrinsic_ray_convergence, + "intrinsic_cals": intrinsic_cals, + "intrinsic_points": intrinsic_points, + "intrinsic_geometry": intrinsic_geometry, + "intrinsic_geometry_max": intrinsic_geometry_max, + "intrinsic_correspondence": intrinsic_correspondence, + "intrinsic_correspondence_rate": intrinsic_correspondence_rate, + "geometry_guard_mode": geometry_guard_mode, + "geometry_guard_threshold": geometry_guard_threshold, + "first_release_geometry_slack": first_release_geometry_slack, + "correspondence_guard_mode": correspondence_guard_mode, + "correspondence_guard_threshold": correspondence_guard_threshold, + "correspondence_guard_reference_rate": correspondence_guard_reference_rate, + "first_release_correspondence_slack": first_release_correspondence_slack, + "accepted_stage": accepted_stage, + "final_reprojection_rms": final_rms, + "final_mean_ray_convergence": final_ray_convergence, + "warmstart_result": warmstart_result, + "pose_result": alternating_result, + "intrinsic_result": intrinsic_result, + } + + return final_cals, final_points, summary diff --git a/openptv_python/parameters.py b/openptv_python/parameters.py index a5ea3d2..d9062e6 100644 --- a/openptv_python/parameters.py +++ b/openptv_python/parameters.py @@ -105,7 +105,7 @@ def set_layers(self, refr_index: list[float], thickness: list[float]): else: self.n2 = refr_index self.d = thickness - # self.nlay = len(refr_index) + self.nlay = len(refr_index) def __str__(self) -> str: return f"nlay = {self.nlay}, n1 = {self.n1}, n2 = {self.n2}, d = {self.d}, n3 = {self.n3}" diff --git a/openptv_python/segmentation.py b/openptv_python/segmentation.py index 9ae39d7..85c4090 100644 --- a/openptv_python/segmentation.py +++ b/openptv_python/segmentation.py @@ -5,6 +5,12 @@ from numba import njit from scipy.ndimage import center_of_mass, gaussian_filter, label, maximum_filter +from ._native_compat import HAS_NATIVE_SEGMENTATION, native_target_recognition +from ._native_convert import ( + from_native_target_array, + to_native_control_par, + to_native_target_par, +) from .constants import CORRES_NONE, MAX_TARGETS from .parameters import ControlPar, TargetPar from .tracking_frame_buf import Target @@ -40,6 +46,29 @@ def targ_rec( num_cam, ) -> List[Target]: """Target recognition function.""" + if ( + HAS_NATIVE_SEGMENTATION + and xmin <= 0 + and ymin <= 0 + and xmax >= cpar.imx + and ymax >= cpar.imy + ): + native_tpar = to_native_target_par(targ_par) + native_cpar = to_native_control_par(cpar) + native_targets = native_target_recognition( + img, + native_tpar, + int(num_cam), + native_cpar, + subrange_x=None, + subrange_y=None, + ) + targets = from_native_target_array(native_targets) + threshold = targ_par.gvthresh[int(num_cam)] + for target in targets: + target.sumg -= target.n * threshold + return targets + thres = targ_par.gvthresh[num_cam] disco = targ_par.discont diff --git a/openptv_python/track.py b/openptv_python/track.py index 8b19443..2a81377 100644 --- a/openptv_python/track.py +++ b/openptv_python/track.py @@ -253,7 +253,7 @@ def angle_acc(start: np.ndarray, pred: np.ndarray, cand: np.ndarray) -> np.ndarr acc = np.linalg.norm(v0 - v1) if np.all(v0 == -v1): - angle = 200 + angle = 200.0 elif np.all(v0 == v1): angle = 0 else: @@ -261,9 +261,12 @@ def angle_acc(start: np.ndarray, pred: np.ndarray, cand: np.ndarray) -> np.ndarr norm_start_pred = np.linalg.norm(start - pred) norm_start_cand = np.linalg.norm(start - cand) - angle = (200.0 / np.pi) * np.arccos( - dot_product / (norm_start_pred * norm_start_cand) - ) + if norm_start_pred == 0.0 or norm_start_cand == 0.0: + angle = 0.0 + else: + cosine = dot_product / (norm_start_pred * norm_start_cand) + cosine = min(1.0, max(-1.0, cosine)) + angle = (200.0 / np.pi) * np.arccos(cosine) return np.array([angle, acc]) diff --git a/openptv_python/tracking_frame_buf.py b/openptv_python/tracking_frame_buf.py index 8ea507c..3539887 100644 --- a/openptv_python/tracking_frame_buf.py +++ b/openptv_python/tracking_frame_buf.py @@ -341,6 +341,24 @@ def read( frame_num: int, ) -> bool: """Read a frame from the disk.""" + required_files = [Path(f"{corres_file_base}.{frame_num}")] + + if linkage_file_base != "": + required_files.append(Path(f"{linkage_file_base}.{frame_num}")) + + if prio_file_base != "": + required_files.append(Path(f"{prio_file_base}.{frame_num}")) + + for file_base in target_file_base: + if frame_num > 0: + required_files.append(Path(file_base % frame_num + "_targets")) + else: + required_files.append(Path(f"{file_base}_targets")) + + for path in required_files: + if not path.exists(): + return False + cor_buf, path_buf = read_path_frame( # self.correspond, self.path_info = read_path_frame( corres_file_base, diff --git a/pyproject.toml b/pyproject.toml index efcba21..1a8218b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,14 +10,49 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering" ] +dependencies = [ + "numba>=0.64.0", + "numpy>=2.4.2", + "pyyaml>=6.0.3", + "scipy>=1.17.1" +] description = "Python version of the OpenPTV library" dynamic = ["version"] license = {file = "LICENSE"} name = "openptv-python" readme = "README.md" +requires-python = ">=3.12,<3.14" + +[project.optional-dependencies] +dev = [ + "mypy>=1.19.1", + "myst-parser>=5.0.0", + "optv>=0.3.2", + "pre-commit>=4.5.1", + "pydata-sphinx-theme>=0.16.1", + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "sphinx>=9.1.0", + "sphinx-autoapi>=3.7.0", + "types-pyyaml>=6.0.12.20250915" +] +docs = [ + "myst-parser>=5.0.0", + "pydata-sphinx-theme>=0.16.1", + "sphinx>=9.1.0", + "sphinx-autoapi>=3.7.0" +] +native = [ + "optv>=0.3.2" +] +test = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0" +] [tool.coverage.run] branch = true diff --git a/tests/test_bundle_adjustment.py b/tests/test_bundle_adjustment.py new file mode 100644 index 0000000..421e03b --- /dev/null +++ b/tests/test_bundle_adjustment.py @@ -0,0 +1,1791 @@ +import unittest +from pathlib import Path +from unittest.mock import patch + +import numpy as np +import scipy.optimize + +from openptv_python.calibration import Calibration, read_calibration +from openptv_python.imgcoord import image_coordinates +from openptv_python.orientation import ( + alternating_bundle_adjustment, + guarded_two_step_bundle_adjustment, + mean_ray_convergence, + multi_camera_bundle_adjustment, + reprojection_rms, +) +from openptv_python.parameters import ControlPar, OrientPar, SequencePar +from openptv_python.tracking_frame_buf import read_path_frame, read_targets +from openptv_python.trafo import arr_metric_to_pixel + + +class TestBundleAdjustment(unittest.TestCase): + def setUp(self): + self.control = ControlPar(4).from_file( + Path("tests/testing_folder/control_parameters/control.par") + ) + self.add_file = Path("tests/testing_folder/calibration/cam1.tif.addpar") + self.true_cals = [ + read_calibration( + Path(f"tests/testing_folder/calibration/sym_cam{cam_num}.tif.ori"), + self.add_file, + ) + for cam_num in range(1, 5) + ] + + @staticmethod + def clone_calibration(cal: Calibration) -> Calibration: + return Calibration( + ext_par=cal.ext_par.copy(), + int_par=cal.int_par.copy(), + glass_par=cal.glass_par.copy(), + added_par=cal.added_par.copy(), + mmlut=cal.mmlut, + mmlut_data=cal.mmlut_data, + ) + + def perturb_calibrations(self, true_cals: list[Calibration]) -> list[Calibration]: + perturbed = [self.clone_calibration(cal) for cal in true_cals] + deltas = [ + (np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, 0.0])), + (np.array([1.2, -0.8, 0.7]), np.array([0.012, -0.010, 0.006])), + (np.array([-0.9, 0.7, -0.6]), np.array([-0.009, 0.008, -0.005])), + (np.array([0.8, 1.0, -0.5]), np.array([0.011, 0.007, -0.004])), + ] + for cal, (pos_delta, angle_delta) in zip(perturbed, deltas): + cal.set_pos(cal.get_pos() + pos_delta) + cal.set_angles(cal.get_angles() + angle_delta) + return perturbed + + def lightly_perturb_calibrations( + self, true_cals: list[Calibration] + ) -> list[Calibration]: + perturbed = [self.clone_calibration(cal) for cal in true_cals] + deltas = [ + (np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, 0.0])), + (np.array([0.5, -0.3, 0.2]), np.array([0.004, -0.003, 0.002])), + (np.array([-0.4, 0.3, -0.2]), np.array([-0.003, 0.003, -0.002])), + (np.array([0.3, 0.4, -0.2]), np.array([0.003, 0.002, -0.002])), + ] + for cal, (pos_delta, angle_delta) in zip(perturbed, deltas): + cal.set_pos(cal.get_pos() + pos_delta) + cal.set_angles(cal.get_angles() + angle_delta) + return perturbed + + @staticmethod + def cavity_quadruplet_observations(cavity_dir: Path, control: ControlPar): + seq = SequencePar.from_file(cavity_dir / "parameters/sequence.par", 4) + observed_batches = [] + point_batches = [] + + for frame in range(seq.first, seq.last + 1): + cor_buf, path_buf = read_path_frame( + str(cavity_dir / "res_orig/rt_is"), + "", + "", + frame, + ) + targets = [ + read_targets(str(cavity_dir / f"img_orig/cam{cam_num}.%05d"), frame) + for cam_num in range(1, 5) + ] + subset = [ + pt_num for pt_num, corres in enumerate(cor_buf) if np.all(corres.p >= 0) + ] + observed_pixels = np.full((len(subset), 4, 2), np.nan, dtype=float) + point_init = np.empty((len(subset), 3), dtype=float) + for out_num, pt_num in enumerate(subset): + point_init[out_num] = path_buf[pt_num].x + for cam in range(4): + target_index = cor_buf[pt_num].p[cam] + observed_pixels[out_num, cam, 0] = targets[cam][target_index].x + observed_pixels[out_num, cam, 1] = targets[cam][target_index].y + observed_batches.append(observed_pixels) + point_batches.append(point_init) + + return np.concatenate(observed_batches, axis=0), np.concatenate( + point_batches, axis=0 + ) + + def test_multi_camera_bundle_adjustment_improves_synthetic_reprojection(self): + xs = np.linspace(-20.0, 20.0, 3) + ys = np.linspace(-15.0, 15.0, 2) + zs = np.array([-4.0, 5.0]) + points = np.array([[x, y, z] for x in xs for y in ys for z in zs], dtype=float) + + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + start_cals = self.perturb_calibrations(self.true_cals) + refined_cals, refined_points, result = multi_camera_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + point_init=None, + fixed_camera_indices=[0, 1], + loss="linear", + method="lm", + prior_sigmas={ + "x0": 1.0, + "y0": 1.0, + "z0": 1.0, + "omega": 0.01, + "phi": 0.01, + "kappa": 0.01, + }, + max_nfev=50, + ) + + self.assertTrue(result.success, msg=result.message) + before_rms = result["initial_reprojection_rms"] + after_rms = result["final_reprojection_rms"] + self.assertLess(after_rms, before_rms * 0.4) + self.assertLess(after_rms, 3e-2) + + def test_multi_camera_bundle_adjustment_passes_x_scale_to_least_squares(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + start_cals = self.lightly_perturb_calibrations(self.true_cals) + + def fake_least_squares(_fun, x0, **kwargs): + self.assertIn("x_scale", kwargs) + np.testing.assert_allclose( + kwargs["x_scale"], + np.array([0.02, 0.02, 0.02, 2e-4, 2e-4, 2e-4]), + ) + return scipy.optimize.OptimizeResult( + x=x0, + success=True, + message="ok", + ) + + with patch( + "openptv_python.orientation.scipy.optimize.least_squares", + side_effect=fake_least_squares, + ): + _, refined_points, result = multi_camera_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + point_init=points.copy(), + fixed_camera_indices=[0, 1, 2], + optimize_points=False, + loss="linear", + method="trf", + x_scale={ + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + "omega": 2e-4, + "phi": 2e-4, + "kappa": 2e-4, + }, + ) + + self.assertTrue(result.success) + np.testing.assert_allclose(refined_points, points) + + def test_multi_camera_bundle_adjustment_accepts_zero_width_parameter_bounds(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + start_cals = self.lightly_perturb_calibrations(self.true_cals) + refined_cals, refined_points, result = multi_camera_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + point_init=points.copy(), + fixed_camera_indices=[0, 1, 2], + optimize_points=False, + loss="linear", + method="trf", + max_nfev=5, + parameter_bounds={ + "phi": (0.0, 0.0), + }, + prior_sigmas={ + "x0": 1.0, + "y0": 1.0, + "z0": 1.0, + "omega": 0.01, + "phi": 0.01, + "kappa": 0.01, + }, + ) + + self.assertTrue(result.success, msg=result.message) + self.assertEqual(refined_points.shape, points.shape) + self.assertEqual(len(refined_cals), 4) + + def test_cavity_reprojection_improves(self): + cavity_dir = Path("tests/testing_fodder/test_cavity") + control = ControlPar(4).from_file(cavity_dir / "parameters/ptv.par") + true_cals = [ + read_calibration( + cavity_dir / f"cal/cam{cam_num}.tif.ori", + cavity_dir / f"cal/cam{cam_num}.tif.addpar", + ) + for cam_num in range(1, 5) + ] + start_cals = self.lightly_perturb_calibrations(true_cals) + + cor_buf, path_buf = read_path_frame( + str(cavity_dir / "res_orig/rt_is"), + "", + "", + 10001, + ) + targets = [ + read_targets(str(cavity_dir / f"img_orig/cam{cam_num}.%05d"), 10001) + for cam_num in range(1, 5) + ] + + subset = [ + pt_num for pt_num, corres in enumerate(cor_buf) if np.all(corres.p >= 0) + ][:12] + observed_pixels = np.full((len(subset), 4, 2), np.nan, dtype=float) + point_init = np.empty((len(subset), 3), dtype=float) + for out_num, pt_num in enumerate(subset): + path_info = path_buf[pt_num] + point_init[out_num] = path_info.x + for cam in range(4): + target_index = cor_buf[pt_num].p[cam] + if target_index < 0: + continue + observed_pixels[out_num, cam, 0] = targets[cam][target_index].x + observed_pixels[out_num, cam, 1] = targets[cam][target_index].y + + before_rms = reprojection_rms(observed_pixels, point_init, start_cals, control) + refined_cals, refined_points, result = multi_camera_bundle_adjustment( + observed_pixels, + start_cals, + control, + OrientPar(), + point_init=point_init, + fixed_camera_indices=[0, 1], + loss="linear", + method="lm", + max_nfev=80, + ) + after_rms = reprojection_rms( + observed_pixels, refined_points, refined_cals, control + ) + + print( + f"test_cavity reprojection RMS improved from {before_rms:.6f} px to {after_rms:.6f} px" + ) + + self.assertTrue(result.success, msg=result.message) + self.assertLess(after_rms, before_rms * 0.6) + self.assertLess(after_rms, before_rms - 0.2) + np.testing.assert_allclose(after_rms, result["final_reprojection_rms"]) + + def test_bundle_adjustment_rejects_scale_ambiguous_configuration(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + with self.assertRaises(ValueError): + multi_camera_bundle_adjustment( + observed_pixels, + self.perturb_calibrations(self.true_cals), + self.control, + OrientPar(), + point_init=None, + fix_first_camera=True, + loss="linear", + method="lm", + max_nfev=10, + ) + + def test_multi_camera_bundle_adjustment_known_points_constrain_geometry(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + [0.0, 0.0, 3.0], + [6.0, -4.0, -2.0], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + point_init = points.copy() + point_init[:2] += np.array([[3.0, -2.0, 1.5], [-2.5, 1.0, -1.0]]) + start_cals = self.perturb_calibrations(self.true_cals) + ba_kwargs = { + "point_init": point_init, + "fixed_camera_indices": [0, 1], + "loss": "linear", + "method": "trf", + "prior_sigmas": { + "x0": 1.0, + "y0": 1.0, + "z0": 1.0, + "omega": 0.01, + "phi": 0.01, + "kappa": 0.01, + }, + "max_nfev": 8, + } + + unconstrained_cals, unconstrained_points, unconstrained_result = ( + multi_camera_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + **ba_kwargs, + ) + ) + constrained_cals, constrained_points, constrained_result = ( + multi_camera_bundle_adjustment( + observed_pixels, + self.perturb_calibrations(self.true_cals), + self.control, + OrientPar(), + known_points={0: points[0], 1: points[1]}, + known_point_sigmas=1e-3, + **ba_kwargs, + ) + ) + + unconstrained_error = float( + np.mean(np.linalg.norm(unconstrained_points[:2] - points[:2], axis=1)) + ) + constrained_error = float( + np.mean(np.linalg.norm(constrained_points[:2] - points[:2], axis=1)) + ) + + self.assertLess(constrained_error, unconstrained_error) + self.assertLess(constrained_error, 1e-2) + self.assertIn(0, constrained_result["known_point_indices"]) + self.assertIn(1, constrained_result["known_point_indices"]) + self.assertLess( + reprojection_rms( + observed_pixels, + constrained_points, + constrained_cals, + self.control, + ), + unconstrained_result["initial_reprojection_rms"], + ) + + def test_fixed_camera_indices_preserve_selected_camera_poses(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + [0.0, 0.0, 3.0], + [6.0, -4.0, -2.0], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + start_cals = self.perturb_calibrations(self.true_cals) + fixed_indices = [0, 1] + refined_cals, _, result = multi_camera_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + point_init=points, + fixed_camera_indices=fixed_indices, + loss="linear", + method="lm", + prior_sigmas={ + "x0": 1.0, + "y0": 1.0, + "z0": 1.0, + "omega": 0.01, + "phi": 0.01, + "kappa": 0.01, + }, + max_nfev=50, + ) + + self.assertTrue(result.success, msg=result.message) + for cam_index in fixed_indices: + np.testing.assert_allclose( + refined_cals[cam_index].get_pos(), + start_cals[cam_index].get_pos(), + atol=1e-12, + ) + np.testing.assert_allclose( + refined_cals[cam_index].get_angles(), + start_cals[cam_index].get_angles(), + atol=1e-12, + ) + self.assertEqual(result["optimized_camera_indices"], [2, 3]) + self.assertLess( + result["final_reprojection_rms"], result["initial_reprojection_rms"] + ) + + def test_known_points_require_point_optimization(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + with self.assertRaises(ValueError): + multi_camera_bundle_adjustment( + observed_pixels, + self.perturb_calibrations(self.true_cals), + self.control, + OrientPar(), + point_init=points, + fixed_camera_indices=[0, 1], + optimize_points=False, + known_points={0: points[0]}, + known_point_sigmas=1e-3, + ) + + def test_guarded_two_step_bundle_adjustment_rejects_bad_intrinsics_stage(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + [0.0, 0.0, 3.0], + [6.0, -4.0, -2.0], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + start_cals = self.lightly_perturb_calibrations(self.true_cals) + pose_cals = [self.clone_calibration(cal) for cal in self.true_cals] + bad_intrinsic_cals = [self.clone_calibration(cal) for cal in pose_cals] + for cal in bad_intrinsic_cals[1:]: + cal.added_par[0] += 1e-3 + cal.added_par[3] += 5e-4 + cal.added_par[4] -= 5e-4 + + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + + with patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=[ + (pose_cals, points.copy(), {"success": True, "stage": "pose"}), + ( + bad_intrinsic_cals, + points.copy(), + {"success": True, "stage": "intrinsics"}, + ), + ], + ) as mocked_adjustment: + final_cals, final_points, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + intrinsics, + point_init=points, + fixed_camera_indices=[0, 1], + pose_prior_sigmas={ + "x0": 0.5, + "y0": 0.5, + "z0": 0.5, + "omega": 0.005, + "phi": 0.005, + "kappa": 0.005, + }, + pose_parameter_bounds={ + "x0": (-2.0, 2.0), + "y0": (-2.0, 2.0), + "z0": (-2.0, 2.0), + "omega": (-0.02, 0.02), + "phi": (-0.02, 0.02), + "kappa": (-0.02, 0.02), + }, + pose_max_nfev=60, + intrinsic_prior_sigmas={ + "k1": 1e-12, + "k2": 1e-12, + "k3": 1e-12, + "p1": 1e-12, + "p2": 1e-12, + "scx": 1e-12, + "she": 1e-12, + "cc": 1e-12, + "xh": 1e-12, + "yh": 1e-12, + }, + intrinsic_parameter_bounds={ + "k1": (-1e-10, 1e-10), + "k2": (-1e-10, 1e-10), + "k3": (-1e-10, 1e-10), + "p1": (-1e-10, 1e-10), + "p2": (-1e-10, 1e-10), + "scx": (-1e-12, 1e-12), + "she": (-1e-12, 1e-12), + "cc": (-1e-12, 1e-12), + "xh": (-1e-12, 1e-12), + "yh": (-1e-12, 1e-12), + }, + intrinsic_max_nfev=20, + ) + + self.assertEqual(mocked_adjustment.call_count, 2) + for call in mocked_adjustment.call_args_list: + self.assertIsNone(call.kwargs.get("known_points")) + self.assertIsNone(call.kwargs.get("known_point_sigmas")) + self.assertEqual(summary["accepted_stage"], "pose") + self.assertLess( + summary["pose_reprojection_rms"], summary["baseline_reprojection_rms"] + ) + self.assertGreater( + summary["intrinsic_reprojection_rms"], summary["pose_reprojection_rms"] + ) + self.assertLessEqual( + summary["final_reprojection_rms"], summary["baseline_reprojection_rms"] + ) + self.assertLessEqual( + summary["final_mean_ray_convergence"], + summary["baseline_mean_ray_convergence"], + ) + np.testing.assert_allclose( + reprojection_rms(observed_pixels, final_points, final_cals, self.control), + summary["final_reprojection_rms"], + ) + + def test_guarded_two_step_bundle_adjustment_preserves_pose_when_intrinsics_are_tight( + self, + ): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + [0.0, 0.0, 3.0], + [6.0, -4.0, -2.0], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + start_cals = self.perturb_calibrations(self.true_cals) + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + final_cals, final_points, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + intrinsics, + point_init=points, + fixed_camera_indices=[0, 1], + pose_prior_sigmas={ + "x0": 1.0, + "y0": 1.0, + "z0": 1.0, + "omega": 0.01, + "phi": 0.01, + "kappa": 0.01, + }, + pose_max_nfev=50, + intrinsic_prior_sigmas={ + "k1": 1e-12, + "k2": 1e-12, + "k3": 1e-12, + "p1": 1e-12, + "p2": 1e-12, + "scx": 1e-12, + "she": 1e-12, + "cc": 1e-12, + "xh": 1e-12, + "yh": 1e-12, + }, + intrinsic_parameter_bounds={ + "k1": (-1e-10, 1e-10), + "k2": (-1e-10, 1e-10), + "k3": (-1e-10, 1e-10), + "p1": (-1e-10, 1e-10), + "p2": (-1e-10, 1e-10), + "scx": (-1e-12, 1e-12), + "she": (-1e-12, 1e-12), + "cc": (-1e-12, 1e-12), + "xh": (-1e-12, 1e-12), + "yh": (-1e-12, 1e-12), + }, + intrinsic_max_nfev=20, + ) + + self.assertIn(summary["accepted_stage"], {"pose", "intrinsics"}) + self.assertLess( + summary["final_reprojection_rms"], summary["baseline_reprojection_rms"] + ) + self.assertLessEqual( + mean_ray_convergence(observed_pixels, final_cals, self.control), + summary["baseline_mean_ray_convergence"], + ) + self.assertEqual(final_points.shape, points.shape) + + def test_guarded_two_step_bundle_adjustment_passes_known_point_constraints(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + known_points = {0: points[0], 1: points[1]} + + with patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=[ + (self.true_cals, points.copy(), {"success": True}), + (self.true_cals, points.copy(), {"success": True}), + ], + ) as mocked_adjustment: + guarded_two_step_bundle_adjustment( + observed_pixels, + self.lightly_perturb_calibrations(self.true_cals), + self.control, + OrientPar(), + intrinsics, + point_init=points, + fixed_camera_indices=[0, 1], + known_points=known_points, + known_point_sigmas=1e-3, + ) + + self.assertEqual(mocked_adjustment.call_count, 2) + for call in mocked_adjustment.call_args_list: + self.assertEqual(call.kwargs["known_points"], known_points) + self.assertEqual(call.kwargs["known_point_sigmas"], 1e-3) + + def test_guarded_two_step_bundle_adjustment_supports_staged_camera_release(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + start_cals = self.lightly_perturb_calibrations(self.true_cals) + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + + staged_returns = [ + (self.true_cals, points.copy(), {"success": True, "stage": f"pose_{idx}"}) + for idx in range(4) + ] + [(self.true_cals, points.copy(), {"success": True, "stage": "intrinsics"})] + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=staged_returns, + ) as mocked_adjustment, + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 9.0, 8.0, 7.0, 6.0, 5.0], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[6.0, 5.0, 4.0, 3.0, 2.0, 1.0], + ), + ): + _, final_points, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + intrinsics, + point_init=points, + pose_release_camera_order=[0, 1, 2, 3], + pose_prior_sigmas={ + "x0": 0.5, + "y0": 0.5, + "z0": 0.5, + "omega": 0.005, + "phi": 0.005, + "kappa": 0.005, + }, + pose_parameter_bounds={ + "x0": (-2.0, 2.0), + "y0": (-2.0, 2.0), + "z0": (-2.0, 2.0), + "omega": (-0.02, 0.02), + "phi": (-0.02, 0.02), + "kappa": (-0.02, 0.02), + }, + pose_max_nfev=20, + intrinsic_max_nfev=10, + ) + + self.assertEqual(mocked_adjustment.call_count, 5) + fixed_sequences = [ + call.kwargs.get("fixed_camera_indices") + for call in mocked_adjustment.call_args_list + ] + self.assertEqual( + fixed_sequences, + [[1, 2, 3], [2, 3], [3], [], [0, 1, 2, 3]], + ) + self.assertEqual(summary["pose_release_camera_order"], [0, 1, 2, 3]) + self.assertEqual(summary["accepted_pose_stage_count"], 4) + self.assertEqual(summary["accepted_stage"], "intrinsics") + self.assertEqual(len(summary["pose_stage_summaries"]), 4) + self.assertEqual( + [ + stage["released_camera_index"] + for stage in summary["pose_stage_summaries"] + ], + [0, 1, 2, 3], + ) + self.assertEqual(final_points.shape, points.shape) + + def test_guarded_two_step_bundle_adjustment_supports_pose_micro_stages(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + start_cals = self.lightly_perturb_calibrations(self.true_cals) + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + + staged_returns = [ + (self.true_cals, points.copy(), {"success": True, "stage": f"pose_{idx}"}) + for idx in range(4) + ] + [(self.true_cals, points.copy(), {"success": True, "stage": "intrinsics"})] + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=staged_returns, + ) as mocked_adjustment, + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 9.5, 9.0, 8.5, 8.0, 7.5], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[6.0, 5.5, 5.0, 4.5, 4.0, 3.5], + ), + ): + _, _, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + intrinsics, + point_init=points, + pose_release_camera_order=[0, 1], + pose_stage_configs=[ + { + "optimize_points": False, + "max_nfev": 3, + "x_scale": { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + "omega": 2e-4, + "phi": 2e-4, + "kappa": 2e-4, + }, + }, + { + "optimize_points": True, + "max_nfev": 4, + "x_scale": { + "x0": 0.05, + "y0": 0.05, + "z0": 0.05, + "omega": 5e-4, + "phi": 5e-4, + "kappa": 5e-4, + "points": 0.1, + }, + }, + ], + intrinsic_max_nfev=5, + ) + + self.assertEqual(mocked_adjustment.call_count, 5) + fixed_sequences = [ + call.kwargs.get("fixed_camera_indices") + for call in mocked_adjustment.call_args_list + ] + self.assertEqual( + fixed_sequences, + [[1, 2, 3], [1, 2, 3], [2, 3], [2, 3], [0, 1, 2, 3]], + ) + self.assertEqual( + [ + call.kwargs.get("optimize_points") + for call in mocked_adjustment.call_args_list[:-1] + ], + [False, True, False, True], + ) + self.assertEqual( + mocked_adjustment.call_args_list[0].kwargs.get("x_scale"), + { + "x0": 0.02, + "y0": 0.02, + "z0": 0.02, + "omega": 2e-4, + "phi": 2e-4, + "kappa": 2e-4, + }, + ) + self.assertEqual(summary["accepted_pose_stage_count"], 4) + self.assertEqual(len(summary["pose_stage_summaries"]), 4) + self.assertEqual( + [stage["micro_stage_index"] for stage in summary["pose_stage_summaries"]], + [1, 2, 1, 2], + ) + self.assertEqual( + [stage["optimize_points"] for stage in summary["pose_stage_summaries"]], + [False, True, False, True], + ) + + def test_alternating_bundle_adjustment_supports_block_schedule(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + staged_returns = [ + ( + self.true_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 9.5}, + ), + ( + self.true_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 9.0}, + ), + ( + self.true_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 8.5}, + ), + ( + self.true_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 8.0}, + ), + ( + self.true_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 7.5}, + ), + ( + self.true_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 7.0}, + ), + ] + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=staged_returns, + ) as mocked_adjustment, + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 9.5, 9.0, 8.5, 8.0, 7.5, 7.0], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[6.0, 5.5, 5.0, 4.5, 4.0, 3.5, 3.0], + ), + ): + _, _, summary = alternating_bundle_adjustment( + observed_pixels, + self.lightly_perturb_calibrations(self.true_cals), + self.control, + OrientPar(), + OrientPar(), + point_init=points, + pose_release_camera_order=[0, 1], + pose_block_configs=[ + { + "name": "points_only", + "optimize_extrinsics": False, + "optimize_points": True, + "max_nfev": 3, + }, + { + "name": "rotation_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "max_nfev": 3, + }, + ], + intrinsic_max_nfev=4, + ) + + self.assertEqual(mocked_adjustment.call_count, 6) + fixed_sequences = [ + call.kwargs.get("fixed_camera_indices") + for call in mocked_adjustment.call_args_list + ] + self.assertEqual( + fixed_sequences, + [[0, 1, 2, 3], [1, 2, 3], [1, 2, 3], [2, 3], [2, 3], [0, 1, 2, 3]], + ) + self.assertFalse( + mocked_adjustment.call_args_list[0].kwargs["optimize_extrinsics"] + ) + self.assertFalse(mocked_adjustment.call_args_list[0].kwargs["optimize_points"]) + self.assertFalse( + mocked_adjustment.call_args_list[1].kwargs["optimize_extrinsics"] + ) + self.assertTrue(mocked_adjustment.call_args_list[1].kwargs["optimize_points"]) + self.assertEqual( + mocked_adjustment.call_args_list[2].kwargs["parameter_bounds"]["x0"], + (0.0, 0.0), + ) + self.assertEqual(summary["accepted_stage"], "intrinsics") + self.assertEqual(summary["accepted_pose_block_count"], 4) + self.assertEqual( + [block["block_name"] for block in summary["pose_block_summaries"]], + ["points_only", "rotation_only", "points_only", "rotation_only"], + ) + + def test_alternating_bundle_adjustment_freezes_requested_rotation_axes(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + staged_returns = [ + ( + self.true_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 9.5}, + ), + ( + self.true_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 9.0}, + ), + ( + self.true_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 8.5}, + ), + ] + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=staged_returns, + ) as mocked_adjustment, + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 9.5, 9.0, 8.5], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[6.0, 5.5, 5.0, 4.5], + ), + ): + _, _, summary = alternating_bundle_adjustment( + observed_pixels, + self.lightly_perturb_calibrations(self.true_cals), + self.control, + OrientPar(), + OrientPar(), + point_init=points, + pose_release_camera_order=[0], + pose_block_configs=[ + { + "name": "omega_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "frozen_parameters": ["phi", "kappa"], + "max_nfev": 3, + }, + ], + intrinsic_max_nfev=4, + ) + + self.assertEqual(summary["accepted_pose_block_count"], 1) + self.assertEqual( + mocked_adjustment.call_args_list[1].kwargs["parameter_bounds"]["x0"], + (0.0, 0.0), + ) + self.assertEqual( + mocked_adjustment.call_args_list[1].kwargs["parameter_bounds"]["phi"], + (0.0, 0.0), + ) + self.assertEqual( + mocked_adjustment.call_args_list[1].kwargs["parameter_bounds"]["kappa"], + (0.0, 0.0), + ) + + def test_alternating_bundle_adjustment_softens_first_release_geometry_guard(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + shifted_cals = [self.clone_calibration(cal) for cal in self.true_cals] + shifted_cals[0].set_angles( + shifted_cals[0].get_angles() + np.array([0.0, 0.0015, 0.0]) + ) + geometry_reference_points = np.array( + [ + [-15.0, -10.0, -2.0], + [12.0, -8.0, 1.0], + [-9.0, 14.0, 3.0], + [8.0, 6.0, -1.0], + ], + dtype=float, + ) + + def run_with_threshold( + threshold: float, + slack: float, + ) -> dict[str, object]: + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=[ + ( + self.true_cals, + points.copy(), + {"success": True, "stage": "warmstart"}, + ), + ( + shifted_cals, + points.copy(), + {"success": True, "stage": "omega"}, + ), + ( + self.true_cals, + points.copy(), + {"success": True, "stage": "intrinsics"}, + ), + ], + ), + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 9.8, 9.6, 9.5], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[6.0, 5.9, 5.8, 5.7], + ), + ): + _, _, summary = alternating_bundle_adjustment( + observed_pixels, + [self.clone_calibration(cal) for cal in self.true_cals], + self.control, + OrientPar(), + OrientPar(), + point_init=points, + pose_release_camera_order=[0], + pose_block_configs=[ + { + "name": "omega_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "frozen_parameters": ["phi", "kappa"], + "max_nfev": 3, + }, + ], + geometry_reference_points=geometry_reference_points, + geometry_reference_cals=[ + self.clone_calibration(cal) for cal in self.true_cals + ], + geometry_guard_mode="hard", + geometry_guard_threshold=threshold, + first_release_geometry_slack=slack, + intrinsic_max_nfev=4, + ) + return summary + + measured = run_with_threshold(1e6, 0.0) + geometry_max = float(measured["pose_block_summaries"][0]["geometry_max"]) + + rejected = run_with_threshold(geometry_max - 1e-6, 0.0) + rejected_block = rejected["pose_block_summaries"][0] + self.assertFalse(rejected_block["accepted"]) + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=[ + ( + self.true_cals, + points.copy(), + {"success": True, "stage": "warmstart"}, + ), + (shifted_cals, points.copy(), {"success": True, "stage": "omega"}), + ( + self.true_cals, + points.copy(), + {"success": True, "stage": "intrinsics"}, + ), + ], + ), + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 9.8, 9.6, 9.5], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[6.0, 5.9, 5.8, 5.7], + ), + ): + _, _, softened = alternating_bundle_adjustment( + observed_pixels, + [self.clone_calibration(cal) for cal in self.true_cals], + self.control, + OrientPar(), + OrientPar(), + point_init=points, + pose_release_camera_order=[0], + pose_block_configs=[ + { + "name": "omega_only", + "optimize_extrinsics": True, + "optimize_points": False, + "freeze_translation": True, + "frozen_parameters": ["phi", "kappa"], + "max_nfev": 3, + }, + ], + geometry_reference_points=geometry_reference_points, + geometry_reference_cals=[ + self.clone_calibration(cal) for cal in self.true_cals + ], + geometry_guard_mode="hard", + geometry_guard_threshold=geometry_max - 1e-6, + first_release_geometry_slack=2e-6, + intrinsic_max_nfev=4, + ) + + softened_block = softened["pose_block_summaries"][0] + self.assertTrue(softened_block["accepted"]) + self.assertTrue(softened_block["first_release_soft_geometry"]) + self.assertEqual(softened["accepted_pose_block_count"], 1) + + def test_guarded_two_step_bundle_adjustment_stagewise_ray_slack_allows_near_miss( + self, + ): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(self.true_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, self.control.mm), self.control + ) + + start_cals = self.lightly_perturb_calibrations(self.true_cals) + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + + staged_returns = [ + (self.true_cals, points.copy(), {"success": True, "stage": f"pose_{idx}"}) + for idx in range(4) + ] + [(self.true_cals, points.copy(), {"success": True, "stage": "intrinsics"})] + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=staged_returns, + ), + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 9.0, 8.0, 7.0, 6.0, 5.5], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[6.0, 5.0, 4.0, 4.0005, 3.5, 3.4], + ), + ): + _, _, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + intrinsics, + point_init=points, + pose_release_camera_order=[0, 1, 2, 3], + pose_stage_ray_slack=1e-3, + pose_prior_sigmas={ + "x0": 0.5, + "y0": 0.5, + "z0": 0.5, + "omega": 0.005, + "phi": 0.005, + "kappa": 0.005, + }, + pose_parameter_bounds={ + "x0": (-2.0, 2.0), + "y0": (-2.0, 2.0), + "z0": (-2.0, 2.0), + "omega": (-0.02, 0.02), + "phi": (-0.02, 0.02), + "kappa": (-0.02, 0.02), + }, + ) + + self.assertEqual(summary["accepted_pose_stage_count"], 4) + self.assertEqual(summary["pose_stage_ray_slack"], 1e-3) + self.assertTrue(summary["pose_stage_summaries"][2]["accepted"]) + + def test_guarded_two_step_bundle_adjustment_rejects_on_hard_geometry_guard(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.zeros((len(points), 4, 2), dtype=float) + start_cals = self.lightly_perturb_calibrations(self.true_cals) + pose_cals = [self.clone_calibration(cal) for cal in self.true_cals] + bad_intrinsic_cals = [self.clone_calibration(cal) for cal in self.true_cals] + bad_intrinsic_cals[2].set_pos( + bad_intrinsic_cals[2].get_pos() + np.array([20.0, 0.0, 0.0]) + ) + bad_intrinsic_cals[2].set_angles( + bad_intrinsic_cals[2].get_angles() + np.array([0.0, 0.05, 0.0]) + ) + + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=[ + (pose_cals, points.copy(), {"success": True}), + (bad_intrinsic_cals, points.copy(), {"success": True}), + ], + ), + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 5.0, 4.0], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[3.0, 2.0, 1.0], + ), + patch( + "openptv_python.orientation.img_coord", + side_effect=lambda point, cal, _mm: ( + float(point[0] + cal.get_pos()[0]), + float(point[1] + cal.get_pos()[1]), + ), + ), + patch( + "openptv_python.orientation.metric_to_pixel", + side_effect=lambda x, y, _cpar: np.array([x, y], dtype=float), + ), + ): + final_cals, _, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + intrinsics, + point_init=points, + fixed_camera_indices=[0, 1], + geometry_reference_points=points, + geometry_reference_cals=self.true_cals, + geometry_guard_mode="hard", + geometry_guard_threshold=1.0, + ) + + self.assertEqual(summary["accepted_stage"], "pose") + self.assertTrue(summary["pose_geometry_ok"]) + self.assertFalse(summary["intrinsic_geometry_ok"]) + self.assertGreater(summary["intrinsic_geometry_max"], 1.0) + np.testing.assert_allclose(final_cals[2].get_pos(), pose_cals[2].get_pos()) + + def test_guarded_two_step_bundle_adjustment_rejects_on_soft_geometry_guard(self): + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.zeros((len(points), 4, 2), dtype=float) + start_cals = self.lightly_perturb_calibrations(self.true_cals) + pose_cals = [self.clone_calibration(cal) for cal in self.true_cals] + bad_intrinsic_cals = [self.clone_calibration(cal) for cal in self.true_cals] + bad_intrinsic_cals[3].set_pos( + bad_intrinsic_cals[3].get_pos() + np.array([2.0, 0.0, 0.0]) + ) + + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=[ + (pose_cals, points.copy(), {"success": True}), + (bad_intrinsic_cals, points.copy(), {"success": True}), + ], + ), + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 5.0, 4.0], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[3.0, 2.0, 1.0], + ), + patch( + "openptv_python.orientation.img_coord", + side_effect=lambda point, cal, _mm: ( + float(point[0] + cal.get_pos()[0]), + float(point[1] + cal.get_pos()[1]), + ), + ), + patch( + "openptv_python.orientation.metric_to_pixel", + side_effect=lambda x, y, _cpar: np.array([x, y], dtype=float), + ), + ): + final_cals, _, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + intrinsics, + point_init=points, + fixed_camera_indices=[0, 1], + geometry_reference_points=points, + geometry_reference_cals=self.true_cals, + geometry_guard_mode="soft", + ) + + self.assertEqual(summary["accepted_stage"], "pose") + self.assertTrue(summary["pose_geometry_ok"]) + self.assertFalse(summary["intrinsic_geometry_ok"]) + self.assertGreater( + summary["intrinsic_geometry_max"], + summary["pose_geometry_max"], + ) + np.testing.assert_allclose(final_cals[3].get_pos(), pose_cals[3].get_pos()) + + def test_guarded_two_step_bundle_adjustment_rejects_on_hard_correspondence_guard( + self, + ): + points = np.array( + [ + [0.0, 0.0, 0.0], + [20.0, 0.0, 0.0], + [40.0, 0.0, 0.0], + [60.0, 0.0, 0.0], + ], + dtype=float, + ) + observed_pixels = np.zeros((len(points), 4, 2), dtype=float) + pose_cals = [self.clone_calibration(cal) for cal in self.true_cals] + for cal in pose_cals: + cal.set_pos(np.zeros(3, dtype=float)) + cal.set_angles(np.zeros(3, dtype=float)) + start_cals = [self.clone_calibration(cal) for cal in pose_cals] + bad_intrinsic_cals = [self.clone_calibration(cal) for cal in self.true_cals] + for cal in bad_intrinsic_cals: + cal.set_pos(np.zeros(3, dtype=float)) + cal.set_angles(np.zeros(3, dtype=float)) + bad_intrinsic_cals[2].set_pos( + bad_intrinsic_cals[2].get_pos() + np.array([25.0, 0.0, 0.0]) + ) + + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + target_sets = [ + np.asarray([[point[0], point[1]] for point in points], dtype=float) + for _ in range(4) + ] + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=[ + (pose_cals, points.copy(), {"success": True}), + (bad_intrinsic_cals, points.copy(), {"success": True}), + ], + ), + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 5.0, 4.0], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[3.0, 2.0, 1.0], + ), + patch( + "openptv_python.orientation.image_coordinates", + side_effect=lambda pts, cal, _mm: pts[:, :2] + cal.get_pos()[:2], + ), + patch( + "openptv_python.orientation.arr_metric_to_pixel", + side_effect=lambda coords, _cpar: coords, + ), + ): + final_cals, _, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + intrinsics, + point_init=points, + fixed_camera_indices=[0, 1], + correspondence_original_ids=np.tile( + np.arange(len(points))[:, None], (1, 4) + ), + correspondence_point_frame_indices=np.zeros(len(points), dtype=int), + correspondence_frame_target_pixels=[target_sets], + correspondence_guard_mode="hard", + correspondence_guard_threshold=0.2, + ) + + self.assertEqual(summary["accepted_stage"], "pose") + self.assertTrue(summary["pose_correspondence_ok"]) + self.assertFalse(summary["intrinsic_correspondence_ok"]) + self.assertGreater(summary["intrinsic_correspondence_rate"], 0.2) + np.testing.assert_allclose(final_cals[2].get_pos(), pose_cals[2].get_pos()) + + def test_guarded_two_step_bundle_adjustment_rejects_on_soft_correspondence_guard( + self, + ): + points = np.array( + [ + [0.0, 0.0, 0.0], + [20.0, 0.0, 0.0], + [40.0, 0.0, 0.0], + [60.0, 0.0, 0.0], + ], + dtype=float, + ) + observed_pixels = np.zeros((len(points), 4, 2), dtype=float) + pose_cals = [self.clone_calibration(cal) for cal in self.true_cals] + for cal in pose_cals: + cal.set_pos(np.zeros(3, dtype=float)) + cal.set_angles(np.zeros(3, dtype=float)) + start_cals = [self.clone_calibration(cal) for cal in pose_cals] + bad_intrinsic_cals = [self.clone_calibration(cal) for cal in self.true_cals] + for cal in bad_intrinsic_cals: + cal.set_pos(np.zeros(3, dtype=float)) + cal.set_angles(np.zeros(3, dtype=float)) + bad_intrinsic_cals[3].set_pos( + bad_intrinsic_cals[3].get_pos() + np.array([25.0, 0.0, 0.0]) + ) + + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + target_sets = [ + np.asarray([[point[0], point[1]] for point in points], dtype=float) + for _ in range(4) + ] + + with ( + patch( + "openptv_python.orientation.multi_camera_bundle_adjustment", + side_effect=[ + (pose_cals, points.copy(), {"success": True}), + (bad_intrinsic_cals, points.copy(), {"success": True}), + ], + ), + patch( + "openptv_python.orientation.reprojection_rms", + side_effect=[10.0, 5.0, 4.0], + ), + patch( + "openptv_python.orientation.mean_ray_convergence", + side_effect=[3.0, 2.0, 1.0], + ), + patch( + "openptv_python.orientation.image_coordinates", + side_effect=lambda pts, cal, _mm: pts[:, :2] + cal.get_pos()[:2], + ), + patch( + "openptv_python.orientation.arr_metric_to_pixel", + side_effect=lambda coords, _cpar: coords, + ), + ): + final_cals, _, summary = guarded_two_step_bundle_adjustment( + observed_pixels, + start_cals, + self.control, + OrientPar(), + intrinsics, + point_init=points, + fixed_camera_indices=[0, 1], + correspondence_original_ids=np.tile( + np.arange(len(points))[:, None], (1, 4) + ), + correspondence_point_frame_indices=np.zeros(len(points), dtype=int), + correspondence_frame_target_pixels=[target_sets], + correspondence_guard_mode="soft", + correspondence_guard_reference_rate=0.0, + ) + + self.assertEqual(summary["accepted_stage"], "pose") + self.assertTrue(summary["pose_correspondence_ok"]) + self.assertFalse(summary["intrinsic_correspondence_ok"]) + self.assertGreater(summary["intrinsic_correspondence_rate"], 0.0) + np.testing.assert_allclose(final_cals[3].get_pos(), pose_cals[3].get_pos()) + + def test_cavity_intrinsics_only_improves_from_intrinsic_perturbation(self): + cavity_dir = Path("tests/testing_fodder/test_cavity") + control = ControlPar(4).from_file(cavity_dir / "parameters/ptv.par") + true_cals = [ + read_calibration( + cavity_dir / f"cal/cam{cam_num}.tif.ori", + cavity_dir / f"cal/cam{cam_num}.tif.addpar", + ) + for cam_num in range(1, 5) + ] + + cor_buf, path_buf = read_path_frame( + str(cavity_dir / "res_orig/rt_is"), + "", + "", + 10001, + ) + targets = [ + read_targets(str(cavity_dir / f"img_orig/cam{cam_num}.%05d"), 10001) + for cam_num in range(1, 5) + ] + subset = [ + pt_num for pt_num, corres in enumerate(cor_buf) if np.all(corres.p >= 0) + ][:24] + observed_pixels = np.full((len(subset), 4, 2), np.nan, dtype=float) + point_init = np.empty((len(subset), 3), dtype=float) + for out_num, pt_num in enumerate(subset): + point_init[out_num] = path_buf[pt_num].x + for cam in range(4): + target_index = cor_buf[pt_num].p[cam] + observed_pixels[out_num, cam, 0] = targets[cam][target_index].x + observed_pixels[out_num, cam, 1] = targets[cam][target_index].y + + start_cals = [self.clone_calibration(cal) for cal in true_cals] + for cal in start_cals: + cal.added_par[0] += 2e-5 + cal.added_par[3] += 8e-5 + cal.added_par[4] -= 6e-5 + + before_rms = reprojection_rms(observed_pixels, point_init, start_cals, control) + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + + refined_cals, refined_points, result = multi_camera_bundle_adjustment( + observed_pixels, + start_cals, + control, + intrinsics, + point_init=point_init.copy(), + fixed_camera_indices=[0, 1, 2, 3], + optimize_extrinsics=False, + optimize_points=False, + loss="linear", + method="trf", + prior_sigmas={ + "k1": 5e-5, + "p1": 1e-4, + "p2": 1e-4, + }, + parameter_bounds={ + "k1": (-5e-5, 5e-5), + "p1": (-2e-4, 2e-4), + "p2": (-2e-4, 2e-4), + }, + max_nfev=40, + ) + after_rms = reprojection_rms( + observed_pixels, + refined_points, + refined_cals, + control, + ) + + self.assertTrue(result.success, msg=result.message) + self.assertLess(after_rms, before_rms - 0.1) + for refined, start in zip(refined_cals, start_cals): + np.testing.assert_allclose(refined.get_pos(), start.get_pos(), atol=1e-12) + np.testing.assert_allclose( + refined.get_angles(), start.get_angles(), atol=1e-12 + ) + np.testing.assert_allclose(refined_points, point_init) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_calibration_compare.py b/tests/test_calibration_compare.py new file mode 100644 index 0000000..7ef910f --- /dev/null +++ b/tests/test_calibration_compare.py @@ -0,0 +1,64 @@ +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from openptv_python.calibration import read_calibration, write_calibration +from openptv_python.calibration_compare import ( + compare_calibration_folders, + format_calibration_comparison, +) + + +class TestCalibrationCompare(unittest.TestCase): + def test_compare_same_folder_is_zero(self): + cavity_cal_dir = Path("tests/testing_fodder/test_cavity/cal") + deltas = compare_calibration_folders(cavity_cal_dir, cavity_cal_dir) + + self.assertEqual(sorted(deltas.keys()), [f"cam{i}.tif" for i in range(1, 5)]) + for delta in deltas.values(): + np.testing.assert_allclose(delta.position_delta, 0.0) + np.testing.assert_allclose(delta.angle_delta, 0.0) + np.testing.assert_allclose(delta.primary_point_delta, 0.0) + np.testing.assert_allclose(delta.glass_delta, 0.0) + np.testing.assert_allclose(delta.added_par_delta, 0.0) + + def test_compare_modified_folder_reports_numeric_deltas(self): + cavity_cal_dir = Path("tests/testing_fodder/test_cavity/cal") + + with tempfile.TemporaryDirectory() as tmp_dir_name: + tmp_dir = Path(tmp_dir_name) + for cam_num in range(1, 5): + cal = read_calibration( + cavity_cal_dir / f"cam{cam_num}.tif.ori", + cavity_cal_dir / f"cam{cam_num}.tif.addpar", + ) + if cam_num == 2: + cal.set_pos(cal.get_pos() + np.array([1.0, -2.0, 3.0])) + cal.set_angles(cal.get_angles() + np.array([0.01, -0.02, 0.03])) + cal.added_par[0] += 1.5e-4 + cal.added_par[3] -= 2.5e-4 + write_calibration( + cal, + tmp_dir / f"cam{cam_num}.tif.ori", + tmp_dir / f"cam{cam_num}.tif.addpar", + ) + + deltas = compare_calibration_folders(cavity_cal_dir, tmp_dir) + cam2 = deltas["cam2.tif"] + np.testing.assert_allclose(cam2.position_delta, [1.0, -2.0, 3.0]) + np.testing.assert_allclose(cam2.angle_delta, [0.01, -0.02, 0.03]) + np.testing.assert_allclose( + cam2.added_par_delta, + [1.5e-4, 0.0, 0.0, -2.5e-4, 0.0, 0.0, 0.0], + ) + + rendered = format_calibration_comparison(deltas) + self.assertIn("cam2.tif:", rendered) + self.assertIn("+1.000000000 -2.000000000 +3.000000000", rendered) + self.assertIn("+0.000150000", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_demo_bundle_adjustment.py b/tests/test_demo_bundle_adjustment.py new file mode 100644 index 0000000..26c1b5d --- /dev/null +++ b/tests/test_demo_bundle_adjustment.py @@ -0,0 +1,603 @@ +import unittest +from pathlib import Path +from unittest.mock import patch + +import numpy as np + +from openptv_python.calibration import read_calibration +from openptv_python.demo_bundle_adjustment import ( + ExperimentResult, + all_fixed_camera_pairs, + build_experiment_start_calibrations, + build_known_point_constraints, + calibration_body_projection_drift, + default_experiments, + format_quadruplet_sensitivity, + load_calibrations, + load_reference_geometry_points, + normalize_staged_release_order, + perturb_calibrations, + run_experiment, + summarize_epipolar_consistency, + summarize_fixed_camera_diagnostics, + summarize_quadruplet_sensitivity, +) +from openptv_python.imgcoord import image_coordinates +from openptv_python.parameters import ControlPar, read_volume_par +from openptv_python.tracking_frame_buf import read_path_frame, read_targets +from openptv_python.trafo import arr_metric_to_pixel + + +class TestBundleAdjustmentDemo(unittest.TestCase): + def test_build_known_point_constraints_returns_copied_subset(self): + point_init = np.arange(30, dtype=float).reshape(10, 3) + + known_points = build_known_point_constraints(point_init, 4) + + self.assertEqual(sorted(known_points), [0, 3, 6, 9]) + np.testing.assert_allclose(known_points[6], point_init[6]) + + point_init[6, 0] = -999.0 + self.assertNotEqual(known_points[6][0], point_init[6, 0]) + + def test_default_experiments_adds_known_point_presets(self): + known_points = {0: np.array([0.0, 0.0, 0.0]), 5: np.array([1.0, 2.0, 3.0])} + + experiments = default_experiments( + num_cams=4, + known_points=known_points, + known_point_sigmas=0.25, + ) + + names = [spec.name for spec in experiments] + self.assertIn("intrinsics_only", names) + self.assertIn("intrinsics_first_guarded_stagewise_release", names) + self.assertIn("intrinsics_first_alternating_stagewise_release", names) + self.assertIn("pose_trf_known_points", names) + self.assertIn("guarded_two_step_known_points", names) + self.assertIn("guarded_stagewise_release", names) + self.assertIn("guarded_stagewise_release_known_points", names) + + intrinsics_only = next( + spec for spec in experiments if spec.name == "intrinsics_only" + ) + self.assertFalse(intrinsics_only.ba_kwargs["optimize_extrinsics"]) + self.assertFalse(intrinsics_only.ba_kwargs["optimize_points"]) + self.assertEqual( + intrinsics_only.ba_kwargs["fixed_camera_indices"], + [0, 1, 2, 3], + ) + self.assertTrue(intrinsics_only.ba_kwargs["use_reference_cals"]) + self.assertTrue(intrinsics_only.ba_kwargs["perturb_intrinsics_only"]) + self.assertEqual(intrinsics_only.ba_kwargs["intrinsic_perturbation_scale"], 1.0) + + pose_spec = next( + spec for spec in experiments if spec.name == "pose_trf_known_points" + ) + guarded_spec = next( + spec for spec in experiments if spec.name == "guarded_two_step_known_points" + ) + intrinsics_first_spec = next( + spec + for spec in experiments + if spec.name == "intrinsics_first_guarded_stagewise_release" + ) + alternating_spec = next( + spec + for spec in experiments + if spec.name == "intrinsics_first_alternating_stagewise_release" + ) + staged_spec = next( + spec for spec in experiments if spec.name == "guarded_stagewise_release" + ) + staged_known_spec = next( + spec + for spec in experiments + if spec.name == "guarded_stagewise_release_known_points" + ) + self.assertIs(pose_spec.ba_kwargs["known_points"], known_points) + self.assertEqual(pose_spec.ba_kwargs["known_point_sigmas"], 0.25) + self.assertIs(guarded_spec.ba_kwargs["known_points"], known_points) + self.assertEqual(guarded_spec.ba_kwargs["known_point_sigmas"], 0.25) + self.assertEqual(staged_spec.ba_kwargs["fixed_camera_indices"], [1, 2, 3]) + self.assertEqual( + staged_spec.ba_kwargs["pose_release_camera_order"], [0, 1, 2, 3] + ) + self.assertEqual(staged_spec.ba_kwargs["pose_stage_ray_slack"], 0.0) + self.assertEqual(len(staged_spec.ba_kwargs["pose_stage_configs"]), 3) + self.assertFalse( + staged_spec.ba_kwargs["pose_stage_configs"][0]["optimize_points"] + ) + self.assertTrue( + staged_spec.ba_kwargs["pose_stage_configs"][1]["optimize_points"] + ) + self.assertTrue( + staged_spec.ba_kwargs["pose_stage_configs"][2]["optimize_points"] + ) + self.assertEqual(intrinsics_first_spec.mode, "intrinsics_then_guarded") + self.assertFalse( + intrinsics_first_spec.ba_kwargs["warmstart_optimize_extrinsics"] + ) + self.assertFalse(intrinsics_first_spec.ba_kwargs["warmstart_optimize_points"]) + self.assertNotIn( + "first_release_geometry_slack", intrinsics_first_spec.ba_kwargs + ) + self.assertNotIn( + "first_release_correspondence_slack", + intrinsics_first_spec.ba_kwargs, + ) + self.assertEqual( + intrinsics_first_spec.ba_kwargs["pose_release_camera_order"], + [0, 1, 2, 3], + ) + self.assertEqual( + len(intrinsics_first_spec.ba_kwargs["pose_stage_configs"]), + 2, + ) + self.assertFalse( + intrinsics_first_spec.ba_kwargs["pose_stage_configs"][0]["optimize_points"] + ) + self.assertTrue( + intrinsics_first_spec.ba_kwargs["pose_stage_configs"][1]["optimize_points"] + ) + self.assertEqual(alternating_spec.mode, "alternating") + self.assertEqual( + len(alternating_spec.ba_kwargs["pose_block_configs"]), + 6, + ) + self.assertTrue( + alternating_spec.ba_kwargs["pose_block_configs"][0]["optimize_points"] + ) + self.assertEqual( + [ + block["name"] + for block in alternating_spec.ba_kwargs["pose_block_configs"] + ], + [ + "points_only", + "omega_only", + "phi_only", + "kappa_only", + "translation_only", + "joint_pose_points", + ], + ) + self.assertEqual( + alternating_spec.ba_kwargs["first_release_geometry_slack"], 0.35 + ) + self.assertEqual( + alternating_spec.ba_kwargs["first_release_correspondence_slack"], + 0.02, + ) + self.assertIs(staged_known_spec.ba_kwargs["known_points"], known_points) + self.assertEqual(staged_known_spec.ba_kwargs["known_point_sigmas"], 0.25) + self.assertTrue( + all( + stage_config["optimize_points"] + for stage_config in guarded_spec.ba_kwargs["pose_stage_configs"] + ) + ) + self.assertTrue( + all( + stage_config["optimize_points"] + for stage_config in staged_known_spec.ba_kwargs["pose_stage_configs"] + ) + ) + self.assertEqual(guarded_spec.ba_kwargs["geometry_guard_mode"], "off") + self.assertIsNone(guarded_spec.ba_kwargs["geometry_guard_threshold"]) + self.assertEqual(guarded_spec.ba_kwargs["correspondence_guard_mode"], "off") + self.assertIsNone(guarded_spec.ba_kwargs["correspondence_guard_threshold"]) + self.assertIsNone(guarded_spec.ba_kwargs["correspondence_guard_reference_rate"]) + + def test_run_experiment_intrinsics_then_guarded_executes_warmstart_first(self): + control = ControlPar(4).from_file( + Path("tests/testing_folder/control_parameters/control.par") + ) + add_file = Path("tests/testing_folder/calibration/cam1.tif.addpar") + reference_cals = [ + read_calibration( + Path(f"tests/testing_folder/calibration/sym_cam{cam_num}.tif.ori"), + add_file, + ) + for cam_num in range(1, 5) + ] + start_cals = perturb_calibrations(reference_cals, 1.0) + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(reference_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, control.mm), + control, + ) + + spec = next( + experiment + for experiment in default_experiments(perturbation_scale=1.0) + if experiment.name == "intrinsics_first_guarded_stagewise_release" + ) + + with ( + patch( + "openptv_python.demo_bundle_adjustment.multi_camera_bundle_adjustment", + return_value=( + reference_cals, + points.copy(), + {"success": True, "final_reprojection_rms": 3.8, "message": "warm"}, + ), + ) as warmstart, + patch( + "openptv_python.demo_bundle_adjustment.guarded_two_step_bundle_adjustment", + return_value=( + reference_cals, + points.copy(), + { + "accepted_stage": "intrinsics", + "final_reprojection_rms": 3.7, + "final_mean_ray_convergence": 0.2, + }, + ), + ) as guarded, + ): + result = run_experiment( + spec, + observed_pixels=observed_pixels, + point_init=points, + control=control, + start_cals=start_cals, + reference_cals=reference_cals, + reference_geometry_points=None, + tracking_data=None, + geometry_export_threshold=None, + correspondence_export_threshold=None, + source_case_dir=Path("tests/testing_folder"), + output_dir=None, + ) + + self.assertEqual(warmstart.call_count, 1) + self.assertEqual(guarded.call_count, 1) + self.assertFalse(warmstart.call_args.kwargs["optimize_extrinsics"]) + self.assertFalse(warmstart.call_args.kwargs["optimize_points"]) + np.testing.assert_allclose(guarded.call_args.kwargs["point_init"], points) + self.assertIn("warmstart_rms=3.800000", result.notes) + self.assertIn("accepted_stage=intrinsics", result.notes) + + def test_run_experiment_guarded_mode_does_not_forward_alternating_slack(self): + control = ControlPar(4).from_file( + Path("tests/testing_folder/control_parameters/control.par") + ) + add_file = Path("tests/testing_folder/calibration/cam1.tif.addpar") + reference_cals = [ + read_calibration( + Path(f"tests/testing_folder/calibration/sym_cam{cam_num}.tif.ori"), + add_file, + ) + for cam_num in range(1, 5) + ] + start_cals = perturb_calibrations(reference_cals, 1.0) + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(reference_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, control.mm), + control, + ) + + spec = next( + experiment + for experiment in default_experiments(perturbation_scale=1.0) + if experiment.name == "guarded_stagewise_release" + ) + + with patch( + "openptv_python.demo_bundle_adjustment.guarded_two_step_bundle_adjustment", + return_value=( + reference_cals, + points.copy(), + { + "accepted_stage": "intrinsics", + "final_reprojection_rms": 3.7, + "final_mean_ray_convergence": 0.2, + }, + ), + ) as guarded: + run_experiment( + spec, + observed_pixels=observed_pixels, + point_init=points, + control=control, + start_cals=start_cals, + reference_cals=reference_cals, + reference_geometry_points=None, + tracking_data=None, + geometry_export_threshold=None, + correspondence_export_threshold=None, + source_case_dir=Path("tests/testing_folder"), + output_dir=None, + ) + + self.assertEqual(guarded.call_count, 1) + self.assertNotIn("first_release_geometry_slack", guarded.call_args.kwargs) + self.assertNotIn( + "first_release_correspondence_slack", + guarded.call_args.kwargs, + ) + + def test_run_experiment_alternating_mode_uses_alternating_solver(self): + control = ControlPar(4).from_file( + Path("tests/testing_folder/control_parameters/control.par") + ) + add_file = Path("tests/testing_folder/calibration/cam1.tif.addpar") + reference_cals = [ + read_calibration( + Path(f"tests/testing_folder/calibration/sym_cam{cam_num}.tif.ori"), + add_file, + ) + for cam_num in range(1, 5) + ] + start_cals = perturb_calibrations(reference_cals, 1.0) + points = np.array( + [ + [-10.0, -10.0, 0.0], + [10.0, -10.0, 1.0], + [-10.0, 10.0, -1.0], + [10.0, 10.0, 0.5], + ], + dtype=float, + ) + observed_pixels = np.empty((len(points), 4, 2), dtype=float) + for cam_num, cal in enumerate(reference_cals): + observed_pixels[:, cam_num, :] = arr_metric_to_pixel( + image_coordinates(points, cal, control.mm), + control, + ) + + spec = next( + experiment + for experiment in default_experiments(perturbation_scale=1.0) + if experiment.name == "intrinsics_first_alternating_stagewise_release" + ) + + with patch( + "openptv_python.demo_bundle_adjustment.alternating_bundle_adjustment", + return_value=( + reference_cals, + points.copy(), + { + "warmstart_ok": True, + "accepted_stage": "warmstart", + "final_reprojection_rms": 3.9, + "final_mean_ray_convergence": 0.2, + }, + ), + ) as alternating: + result = run_experiment( + spec, + observed_pixels=observed_pixels, + point_init=points, + control=control, + start_cals=start_cals, + reference_cals=reference_cals, + reference_geometry_points=None, + tracking_data=None, + geometry_export_threshold=None, + correspondence_export_threshold=None, + source_case_dir=Path("tests/testing_folder"), + output_dir=None, + ) + + self.assertEqual(alternating.call_count, 1) + self.assertIn("warmstart_ok=True", result.notes) + self.assertIn("accepted_stage=warmstart", result.notes) + + def test_default_experiments_accepts_geometry_guard_configuration(self): + experiments = default_experiments( + num_cams=4, + perturbation_scale=1.0, + staged_release_order=[2, 0, 1, 3], + pose_stage_ray_slack=0.002, + geometry_guard_mode="hard", + geometry_guard_threshold=2.5, + correspondence_guard_mode="hard", + correspondence_guard_threshold=0.18, + correspondence_guard_reference_rate=0.15625, + ) + + guarded_spec = next( + spec for spec in experiments if spec.name == "guarded_two_step" + ) + staged_spec = next( + spec for spec in experiments if spec.name == "guarded_stagewise_release" + ) + + self.assertEqual(guarded_spec.ba_kwargs["geometry_guard_mode"], "hard") + self.assertEqual(guarded_spec.ba_kwargs["geometry_guard_threshold"], 2.5) + self.assertEqual(guarded_spec.ba_kwargs["correspondence_guard_mode"], "hard") + self.assertEqual(guarded_spec.ba_kwargs["correspondence_guard_threshold"], 0.18) + self.assertEqual( + guarded_spec.ba_kwargs["correspondence_guard_reference_rate"], + 0.15625, + ) + self.assertEqual( + staged_spec.ba_kwargs["pose_release_camera_order"], [2, 0, 1, 3] + ) + self.assertEqual(staged_spec.ba_kwargs["fixed_camera_indices"], [0, 1, 3]) + self.assertEqual(staged_spec.ba_kwargs["pose_stage_ray_slack"], 0.002) + + def test_normalize_staged_release_order_validates_camera_permutation(self): + self.assertEqual(normalize_staged_release_order(None, 4), [0, 1, 2, 3]) + self.assertEqual(normalize_staged_release_order([2, 0, 1, 3], 4), [2, 0, 1, 3]) + + with self.assertRaises(ValueError): + normalize_staged_release_order([0, 1, 1, 3], 4) + + with self.assertRaises(ValueError): + normalize_staged_release_order([0, 1, 2], 4) + + def test_pose_demo_keeps_fixed_cameras_on_reference_geometry(self): + cavity_dir = Path("tests/testing_fodder/test_cavity") + control = ControlPar(4).from_file(cavity_dir / "parameters/ptv.par") + true_cals = load_calibrations(cavity_dir, 4) + start_cals = perturb_calibrations(true_cals, 1.0) + spec = next( + experiment + for experiment in default_experiments(perturbation_scale=1.0) + if experiment.name == "pose_trf_linear" + ) + + working_cals = build_experiment_start_calibrations( + spec, + start_cals=start_cals, + reference_cals=true_cals, + ) + geometry_points = load_reference_geometry_points(cavity_dir, 4) + drift = calibration_body_projection_drift( + true_cals, + working_cals, + control, + geometry_points, + ) + + self.assertIsNotNone(drift) + np.testing.assert_allclose(working_cals[0].get_pos(), true_cals[0].get_pos()) + np.testing.assert_allclose(working_cals[1].get_pos(), true_cals[1].get_pos()) + np.testing.assert_allclose( + working_cals[0].get_angles(), + true_cals[0].get_angles(), + ) + np.testing.assert_allclose( + working_cals[1].get_angles(), + true_cals[1].get_angles(), + ) + self.assertLess(drift[0].max_distance, 1e-9) + self.assertLess(drift[1].max_distance, 1e-9) + self.assertGreater(drift[2].max_distance, 0.1) + + def test_all_fixed_camera_pairs_returns_all_unique_pairs(self): + self.assertEqual( + all_fixed_camera_pairs(4), + [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)], + ) + + def test_summarize_fixed_camera_diagnostics_orders_by_fixed_drift_then_rms(self): + results = [ + ExperimentResult( + name="run_b", + description="", + duration_sec=0.2, + success=True, + initial_rms=4.0, + final_rms=1.3, + baseline_ray_convergence=2.0, + final_ray_convergence=1.1, + notes="b", + cal_dir=None, + fixed_camera_indices=(0, 2), + camera_position_shifts=[0.0, 0.8, 0.0, 0.6], + camera_angle_shifts=[0.0, 0.02, 0.0, 0.01], + refined_cals=None, + refined_points=None, + ), + ExperimentResult( + name="run_a", + description="", + duration_sec=0.1, + success=True, + initial_rms=4.0, + final_rms=1.2, + baseline_ray_convergence=2.0, + final_ray_convergence=1.0, + notes="a", + cal_dir=None, + fixed_camera_indices=(0, 1), + camera_position_shifts=[0.0, 0.0, 0.5, 0.4], + camera_angle_shifts=[0.0, 0.0, 0.01, 0.02], + refined_cals=None, + refined_points=None, + ), + ] + + diagnostics = summarize_fixed_camera_diagnostics(results) + + self.assertEqual(diagnostics[0].fixed_camera_indices, (0, 1)) + self.assertEqual(diagnostics[1].fixed_camera_indices, (0, 2)) + self.assertEqual(diagnostics[0].fixed_position_shift, 0.0) + self.assertAlmostEqual(diagnostics[0].mean_free_position_shift, 0.45) + + def test_epipolar_and_quadruplet_diagnostics_detect_perturbation(self): + cavity_dir = Path("tests/testing_fodder/test_cavity") + control = ControlPar(4).from_file(cavity_dir / "parameters/ptv.par") + vpar = read_volume_par(cavity_dir / "parameters/criteria.par") + cals = [ + read_calibration( + cavity_dir / f"cal/cam{cam_num}.tif.ori", + cavity_dir / f"cal/cam{cam_num}.tif.addpar", + ) + for cam_num in range(1, 5) + ] + + cor_buf, path_buf = read_path_frame( + str(cavity_dir / "res_orig/rt_is"), "", "", 10001 + ) + targets = [ + read_targets(str(cavity_dir / f"img_orig/cam{cam_num}.%05d"), 10001) + for cam_num in range(1, 5) + ] + subset = [ + pt_num for pt_num, corres in enumerate(cor_buf) if np.all(corres.p >= 0) + ][:8] + observed_pixels = np.full((len(subset), 4, 2), np.nan, dtype=float) + for out_num, pt_num in enumerate(subset): + for cam in range(4): + target_index = cor_buf[pt_num].p[cam] + observed_pixels[out_num, cam, 0] = targets[cam][target_index].x + observed_pixels[out_num, cam, 1] = targets[cam][target_index].y + + baseline_epipolar = summarize_epipolar_consistency( + observed_pixels, + cals, + control, + vpar, + num_curve_points=16, + ) + baseline_quad = summarize_quadruplet_sensitivity(observed_pixels, cals, control) + + perturbed = observed_pixels.copy() + perturbed[0, 1, 0] += 25.0 + perturbed[0, 1, 1] -= 15.0 + perturbed_epipolar = summarize_epipolar_consistency( + perturbed, + cals, + control, + vpar, + num_curve_points=16, + ) + perturbed_quad = summarize_quadruplet_sensitivity(perturbed, cals, control) + + self.assertGreater( + max(item.max_distance for item in perturbed_epipolar), + max(item.max_distance for item in baseline_epipolar), + ) + self.assertGreater(perturbed_quad.max_spread, baseline_quad.max_spread) + self.assertIn( + "baseline", format_quadruplet_sensitivity(baseline_quad, perturbed_quad) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_image_processing.py b/tests/test_image_processing.py index c432473..7d6351b 100644 --- a/tests/test_image_processing.py +++ b/tests/test_image_processing.py @@ -4,6 +4,12 @@ import numpy as np +import openptv_python.image_processing as image_processing +from openptv_python._native_compat import ( + HAS_NATIVE_PREPROCESS, + native_preprocess_image, +) +from openptv_python._native_convert import to_native_control_par from openptv_python.image_processing import prepare_image from openptv_python.parameters import ControlPar @@ -38,24 +44,13 @@ def test_arguments(self): def test_preprocess_image(self): """Test that the function returns the correct result.""" - # correct_res = np.array( - # [ - # [0, 0, 0, 0, 0], - # [0, 142, 85, 142, 0], - # [0, 85, 0, 85, 0], - # [0, 142, 85, 142, 0], - # [0, 0, 0, 0, 0], - # ], - # dtype=np.uint8, - # ) - correct_res = np.array( [ - [28, 56, 85, 56, 28], - [56, 255, 255, 255, 56], - [85, 255, 255, 255, 85], - [56, 255, 255, 255, 56], - [28, 56, 85, 56, 28], + [0, 0, 0, 0, 0], + [0, 142, 85, 142, 0], + [0, 85, 0, 85, 0], + [0, 142, 85, 142, 0], + [0, 0, 0, 0, 0], ], dtype=np.uint8, ) @@ -67,12 +62,46 @@ def test_preprocess_image(self): # filter_file='', ) - # print(res) + np.testing.assert_array_equal(res, correct_res) + + @unittest.skipUnless( + HAS_NATIVE_PREPROCESS, + "optv native preprocess_image is not available", + ) + def test_preprocess_image_matches_optv_output(self): + """The Python and optv preprocessing implementations should agree.""" + input_img = np.array( + [ + [0, 15, 30, 45, 60, 45, 30], + [10, 80, 130, 180, 130, 80, 10], + [20, 120, 255, 255, 255, 120, 20], + [30, 150, 255, 255, 255, 150, 30], + [20, 120, 255, 255, 255, 120, 20], + [10, 80, 130, 180, 130, 80, 10], + [0, 15, 30, 45, 60, 45, 30], + ], + dtype=np.uint8, + ) + + control = ControlPar(1) + control.set_image_size((7, 7)) + + python_result = image_processing.prepare_image( + input_img, + dim_lp=1, + filter_hp=0, + filter_file="", + ) + native_result = native_preprocess_image( + input_img, + 0, + to_native_control_par(control), + lowpass_dim=1, + filter_file=None, + output_img=None, + ) - # this test fails as we changed the image processing - # to use Numpy approach - # np.testing.assert_array_equal(res, correct_res) - assert np.allclose(res[1:4, 1:4], correct_res[1:4, 1:4]) + np.testing.assert_array_equal(native_result, python_result) if __name__ == "__main__": diff --git a/tests/test_multimedia_n_lay.py b/tests/test_multimedia_n_lay.py index a4f14e4..aa7be0b 100644 --- a/tests/test_multimedia_n_lay.py +++ b/tests/test_multimedia_n_lay.py @@ -3,9 +3,128 @@ import numpy as np +from openptv_python._native_compat import HAS_OPTV +from openptv_python._native_convert import to_native_calibration, to_native_control_par from openptv_python.calibration import read_calibration +from openptv_python.epi import epipolar_curve +from openptv_python.imgcoord import image_coordinates from openptv_python.multimed import init_mmlut, multimed_nlay, multimed_r_nlay -from openptv_python.parameters import read_control_par, read_volume_par +from openptv_python.orientation import point_position, point_positions +from openptv_python.parameters import ( + ControlPar, + MultimediaPar, + VolumePar, + read_control_par, + read_volume_par, +) +from openptv_python.trafo import metric_to_pixel + +try: + from optv.epipolar import epipolar_curve as native_epipolar_curve + from optv.orientation import ( + multi_cam_point_positions as native_multi_cam_point_positions, + ) + from optv.parameters import VolumeParams as NativeVolumeParams + + HAS_NATIVE_MULTIMEDIA_PARITY = True +except ImportError: + native_epipolar_curve = None + native_multi_cam_point_positions = None + NativeVolumeParams = None + HAS_NATIVE_MULTIMEDIA_PARITY = False + + +def _multimed_r_nlay_reference(cal, mm, pos): + """Match the native C multilayer loop in a Python reference implementation.""" + if mm.n1 == 1 and mm.nlay == 1 and mm.n2[0] == 1 and mm.n3 == 1: + return 1.0 + + x, y, z = pos + zout = z + sum(mm.d[1 : mm.nlay]) + r = float(np.linalg.norm(np.array([x - cal.ext_par.x0, y - cal.ext_par.y0]))) + rq = r + rdiff = 0.1 + it = 0 + + while abs(rdiff) > 0.001 and it < 40: + beta1 = np.arctan(rq / (cal.ext_par.z0 - z)) + beta2 = [np.arcsin(np.sin(beta1) * mm.n1 / mm.n2[i]) for i in range(mm.nlay)] + beta3 = np.arcsin(np.sin(beta1) * mm.n1 / mm.n3) + + rbeta = (cal.ext_par.z0 - mm.d[0]) * np.tan(beta1) - zout * np.tan(beta3) + for layer in range(mm.nlay): + rbeta += mm.d[layer] * np.tan(beta2[layer]) + + rdiff = r - rbeta + rq += rdiff + it += 1 + + return 1.0 if r == 0 else float(rq / r) + + +def _python_multi_cam_point_positions_reference(targets, mm_par, cals): + """Reconstruct points with the original Python per-point loop.""" + num_targets = targets.shape[0] + points = np.empty((num_targets, 3), dtype=np.float64) + rcm = np.empty(num_targets, dtype=np.float64) + + for pt in range(num_targets): + rcm[pt], points[pt] = point_position(targets[pt], len(cals), mm_par, cals) + + return points, rcm + + +def _to_native_volume_par(vpar: VolumePar): + """Convert Python volume parameters into native VolumeParams.""" + if NativeVolumeParams is None: + raise RuntimeError("optv VolumeParams is not available") + + native_vpar = NativeVolumeParams() + native_vpar.set_X_lay(list(vpar.x_lay)) + native_vpar.set_Zmin_lay(list(vpar.z_min_lay)) + native_vpar.set_Zmax_lay(list(vpar.z_max_lay)) + native_vpar.set_cn(vpar.cn) + native_vpar.set_cnx(vpar.cnx) + native_vpar.set_cny(vpar.cny) + native_vpar.set_csumg(vpar.csumg) + native_vpar.set_eps0(vpar.eps0) + native_vpar.set_corrmin(vpar.corrmin) + return native_vpar + + +def _build_multilayer_case(): + """Build a deterministic multi-camera multilayer reconstruction fixture.""" + cpar = ControlPar(4).from_file( + Path("tests/testing_folder/control_parameters/control.par") + ) + cpar.mm.set_n1(1.0) + cpar.mm.set_layers([1.49, 1.10], [5.0, 10.0]) + cpar.mm.set_n3(1.33) + + vpar = read_volume_par(Path("tests/testing_folder/corresp/criteria.par")) + add_file = Path("tests/testing_folder/calibration/cam1.tif.addpar") + calibs = [ + read_calibration( + Path(f"tests/testing_folder/calibration/sym_cam{cam_num}.tif.ori"), + add_file, + ) + for cam_num in range(1, 5) + ] + + points = np.array( + [ + [17.0, 42.0, 0.0], + [8.0, 36.0, -4.0], + [-6.0, 55.0, 7.0], + ], + dtype=np.float64, + ) + targets = np.asarray( + [image_coordinates(points, cal, cpar.mm) for cal in calibs], + dtype=np.float64, + ).transpose(1, 0, 2) + + return cpar, vpar, calibs, points, targets class TestMultimedRnlay(unittest.TestCase): @@ -74,6 +193,127 @@ def test_multimed_r_nlay_2(self): self.assertAlmostEqual(Xq, correct_Xq, delta=1e-6) self.assertAlmostEqual(Yq, correct_Yq, delta=1e-6) + def test_multimed_r_nlay_matches_native_formula_for_multiple_layers(self): + """Multi-layer radial shift matches the native per-layer formulation.""" + mm = MultimediaPar( + nlay=2, + n1=1.0, + n2=[1.49, 1.10], + d=[5.0, 10.0], + n3=1.33, + ) + pos = np.array([10.0, 15.0, 1.23], dtype=np.float64) + + observed = multimed_r_nlay(self.cal, mm, pos) + expected = _multimed_r_nlay_reference(self.cal, mm, pos) + + self.assertAlmostEqual(observed, expected, delta=1e-10) + + def test_multimed_r_nlay_matches_reference_across_multilayer_cases(self): + """Multi-layer radial shift stays aligned with the native loop across cases.""" + cases = [ + ( + MultimediaPar(nlay=2, n1=1.0, n2=[1.49, 1.10], d=[5.0, 10.0], n3=1.33), + np.array([10.0, 15.0, 1.23], dtype=np.float64), + ), + ( + MultimediaPar(nlay=2, n1=1.0, n2=[1.49, 1.20], d=[5.0, 3.0], n3=1.33), + np.array([1.23, 1.23, 1.23], dtype=np.float64), + ), + ( + MultimediaPar( + nlay=3, + n1=1.0, + n2=[1.49, 1.20, 1.05], + d=[5.0, 3.0, 1.5], + n3=1.33, + ), + np.array([12.0, -6.0, 2.5], dtype=np.float64), + ), + ] + + for mm, pos in cases: + with self.subTest(mm=mm, pos=pos): + observed = multimed_r_nlay(self.cal, mm, pos) + expected = _multimed_r_nlay_reference(self.cal, mm, pos) + self.assertAlmostEqual(observed, expected, delta=1e-10) + + @unittest.skipUnless( + HAS_OPTV and HAS_NATIVE_MULTIMEDIA_PARITY, + "optv native multimedia comparison hooks are not available", + ) + def test_multilayer_reconstruction_matches_python_numba_and_native(self): + """Multi-layer reconstruction agrees across Python, Numba, and optv.""" + cpar, vpar, calibs, points, targets = _build_multilayer_case() + + python_points, python_rcm = _python_multi_cam_point_positions_reference( + targets, + cpar.mm, + calibs, + ) + compiled_points, compiled_rcm = point_positions(targets, cpar.mm, calibs, vpar) + + assert native_multi_cam_point_positions is not None + native_points, native_rcm = native_multi_cam_point_positions( + targets, + to_native_control_par(cpar), + [to_native_calibration(cal) for cal in calibs], + ) + + np.testing.assert_allclose(compiled_points, python_points, atol=1e-9) + np.testing.assert_allclose(compiled_rcm, python_rcm, atol=1e-9) + np.testing.assert_allclose(native_points, compiled_points, atol=1e-9) + np.testing.assert_allclose(native_rcm, compiled_rcm, atol=1e-9) + + reprojected = np.asarray( + [image_coordinates(compiled_points, cal, cpar.mm) for cal in calibs], + dtype=np.float64, + ).transpose(1, 0, 2) + self.assertLess(np.max(np.abs(reprojected - targets)), 0.25) + self.assertTrue(np.all(np.isfinite(compiled_rcm))) + + @unittest.skipUnless( + HAS_OPTV and HAS_NATIVE_MULTIMEDIA_PARITY, + "optv native multimedia comparison hooks are not available", + ) + def test_multilayer_epipolar_curve_matches_native(self): + """Multi-layer epipolar geometry matches the native binding output.""" + cpar, vpar, calibs, points, _targets = _build_multilayer_case() + origin_projection = image_coordinates(points[:1], calibs[0], cpar.mm)[0] + image_point = np.array( + metric_to_pixel(origin_projection[0], origin_projection[1], cpar) + ) + + python_line = epipolar_curve( + image_point, + calibs[0], + calibs[2], + 9, + cpar, + vpar, + ) + + assert native_epipolar_curve is not None + native_line = native_epipolar_curve( + image_point, + to_native_calibration(calibs[0]), + to_native_calibration(calibs[2]), + 9, + to_native_control_par(cpar), + _to_native_volume_par(vpar), + ) + + np.testing.assert_allclose(native_line, python_line, atol=1e-6) + + def test_set_layers_updates_multimedia_layer_count(self): + """set_layers keeps nlay in sync with the provided layer arrays.""" + mm = MultimediaPar() + mm.set_layers([1.49, 1.10], [5.0, 10.0]) + + self.assertEqual(mm.nlay, 2) + self.assertEqual(mm.n2, [1.49, 1.10]) + self.assertEqual(mm.d, [5.0, 10.0]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_native_stress_performance.py b/tests/test_native_stress_performance.py new file mode 100644 index 0000000..3b7a6c7 --- /dev/null +++ b/tests/test_native_stress_performance.py @@ -0,0 +1,814 @@ +"""Stress benchmarks comparing native and non-native backends.""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path +from statistics import median +from time import perf_counter +from unittest.mock import patch + +import numpy as np + +import openptv_python.correspondences as correspondences +import openptv_python.image_processing as image_processing +import openptv_python.orientation as orientation +import openptv_python.segmentation as segmentation +from openptv_python._native_compat import ( + HAS_NATIVE_PREPROCESS, + HAS_NATIVE_SEGMENTATION, + HAS_OPTV, +) +from openptv_python._native_convert import to_native_calibration, to_native_control_par +from openptv_python.calibration import Calibration +from openptv_python.epi import Coord2d_dtype +from openptv_python.imgcoord import image_coordinates, img_coord +from openptv_python.parameters import ( + ControlPar, + TargetPar, + VolumePar, + read_control_par, + read_sequence_par, + read_volume_par, +) +from openptv_python.track import ( + track_forward_start, + trackcorr_c_finish, + trackcorr_c_loop, +) +from openptv_python.tracking_frame_buf import Target +from openptv_python.tracking_run import tr_new +from openptv_python.trafo import dist_to_flat, metric_to_pixel, pixel_to_metric + +try: + from optv.orientation import ( + multi_cam_point_positions as native_multi_cam_point_positions, + ) + from optv.orientation import point_positions as native_point_positions + from optv.parameters import VolumeParams as NativeVolumeParams + + HAS_NATIVE_RECONSTRUCTION = True +except ImportError: + native_multi_cam_point_positions = None + native_point_positions = None + NativeVolumeParams = None + HAS_NATIVE_RECONSTRUCTION = False + +try: + from optv.correspondences import ( + MatchedCoords as NativeMatchedCoords, + ) + from optv.correspondences import ( + correspondences as native_correspondences, + ) + from optv.tracking_framebuf import TargetArray as NativeTargetArray + + HAS_NATIVE_STEREOMATCHING = True +except ImportError: + NativeMatchedCoords = None + NativeTargetArray = None + native_correspondences = None + HAS_NATIVE_STEREOMATCHING = False + +try: + from optv.parameters import ( + SequenceParams as NativeSequenceParams, + ) + from optv.parameters import ( + TrackingParams as NativeTrackingParams, + ) + from optv.tracker import Tracker as NativeTracker + + HAS_NATIVE_SEQUENCE = True + HAS_NATIVE_TRACKING = NativeVolumeParams is not None +except ImportError: + NativeSequenceParams = None + NativeTrackingParams = None + NativeTracker = None + HAS_NATIVE_SEQUENCE = False + HAS_NATIVE_TRACKING = False + + +def _env_flag_enabled(name: str) -> bool: + """Interpret common truthy environment variable values.""" + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +RUN_STRESS_BENCHMARKS = not _env_flag_enabled("OPENPTV_SKIP_STRESS_BENCHMARKS") + + +def _benchmark(function, *, warmups: int = 1, runs: int = 3): + """Run a callable repeatedly and return the final result and timings.""" + result = None + for _ in range(warmups): + result = function() + + timings = [] + for _ in range(runs): + start = perf_counter() + result = function() + timings.append(perf_counter() - start) + + return result, timings + + +def _timing_summary(label: str, timings: list[float]) -> str: + """Return a compact timing summary for benchmark output.""" + return ( + f"{label}: median={median(timings):.6f}s " + f"min={min(timings):.6f}s max={max(timings):.6f}s" + ) + + +def _serialize_targets( + targets, +) -> list[tuple[int, float, float, int, int, int, int, int]]: + """Convert target objects into stable tuples for comparisons.""" + return [ + ( + int(target.pnr), + round(float(target.x), 9), + round(float(target.y), 9), + int(target.n), + int(target.nx), + int(target.ny), + int(target.sumg), + int(target.tnr), + ) + for target in targets + ] + + +def _build_segmentation_stress_image( + width: int = 512, + height: int = 512, + spacing: int = 24, +) -> np.ndarray: + """Create a deterministic image with many isolated synthetic targets.""" + image = np.zeros((height, width), dtype=np.uint8) + patch = np.array( + [ + [0, 220, 230, 220, 0], + [220, 240, 248, 240, 220], + [230, 248, 255, 248, 230], + [220, 240, 248, 240, 220], + [0, 220, 230, 220, 0], + ], + dtype=np.uint8, + ) + + for center_y in range(12, height - 12, spacing): + for center_x in range(12, width - 12, spacing): + image[center_y - 2 : center_y + 3, center_x - 2 : center_x + 3] = patch + + return image + + +def _build_reconstruction_stress_case( + num_points: int = 4096, +) -> tuple[np.ndarray, np.ndarray, ControlPar, list[Calibration]]: + """Build a deterministic multi-camera reconstruction workload.""" + rng = np.random.default_rng(20260307) + points = np.empty((num_points, 3), dtype=np.float64) + points[:, 0] = rng.uniform(-25.0, 25.0, size=num_points) + points[:, 1] = rng.uniform(20.0, 65.0, size=num_points) + points[:, 2] = rng.uniform(-15.0, 15.0, size=num_points) + + cpar = ControlPar(4).from_file( + Path("tests/testing_folder/control_parameters/control.par") + ) + cpar.mm.set_n1(1.0) + cpar.mm.set_layers([1.0], [1.0]) + cpar.mm.set_n3(1.0) + + add_file = Path("tests/testing_folder/calibration/cam1.tif.addpar") + calibs = [ + Calibration().from_file( + ori_file=Path(f"tests/testing_folder/calibration/sym_cam{cam_num}.tif.ori"), + add_file=add_file, + ) + for cam_num in range(1, 5) + ] + + projections = [image_coordinates(points, cal, cpar.mm) for cal in calibs] + targets = np.asarray(projections, dtype=np.float64).transpose(1, 0, 2) + return points, targets, cpar, calibs + + +def _build_multilayer_reconstruction_stress_case( + num_points: int = 4096, +) -> tuple[np.ndarray, np.ndarray, ControlPar, list[Calibration]]: + """Build a deterministic multi-layer multi-camera reconstruction workload.""" + points, _targets, cpar, calibs = _build_reconstruction_stress_case(num_points) + cpar.mm.set_n1(1.0) + cpar.mm.set_layers([1.49, 1.10], [5.0, 10.0]) + cpar.mm.set_n3(1.33) + + projections = [image_coordinates(points, cal, cpar.mm) for cal in calibs] + targets = np.asarray(projections, dtype=np.float64).transpose(1, 0, 2) + return points, targets, cpar, calibs + + +def _python_point_positions_reference( + targets: np.ndarray, + mm_par, + cals: list[Calibration], +) -> tuple[np.ndarray, np.ndarray]: + """Reconstruct points with the original Python per-target loop.""" + num_targets = targets.shape[0] + points = np.empty((num_targets, 3), dtype=np.float64) + rcm = np.empty(num_targets, dtype=np.float64) + + for pt in range(num_targets): + rcm[pt], points[pt] = orientation.point_position( + targets[pt], + len(cals), + mm_par, + cals, + ) + + return points, rcm + + +def _build_stereomatching_stress_case( + grid_width: int = 8, + grid_height: int = 8, + spacing: float = 5.0, +): + """Build a deterministic multi-camera correspondence workload.""" + cpar = read_control_par(Path("tests/testing_fodder/parameters/ptv.par")) + vpar = read_volume_par(Path("tests/testing_fodder/parameters/criteria.par")) + cpar.mm.n2[0] = 1.0001 + cpar.mm.n3 = 1.0001 + + calibs = [ + Calibration().from_file( + ori_file=Path(f"tests/testing_fodder/cal/sym_cam{cam_num}.tif.ori"), + add_file=Path("tests/testing_fodder/cal/cam1.tif.addpar"), + ) + for cam_num in range(1, cpar.num_cams + 1) + ] + + img_pts: list[list[Target]] = [] + for cam in range(cpar.num_cams): + cam_targets = [Target() for _ in range(grid_width * grid_height)] + for row in range(grid_height): + for col in range(grid_width): + pnr = row * grid_width + col + if cam % 2: + pnr = grid_width * grid_height - 1 - pnr + + pos3d = np.array([col * spacing, row * spacing, 0.0], dtype=np.float64) + x, y = img_coord(pos3d, calibs[cam], cpar.mm) + x, y = metric_to_pixel(x, y, cpar) + + cam_targets[pnr] = Target( + pnr=pnr, + x=float(x), + y=float(y), + n=25, + nx=5, + ny=5, + sumg=10, + tnr=-1, + ) + + img_pts.append(cam_targets) + + corrected = np.recarray( + (cpar.num_cams, len(img_pts[0])), + dtype=Coord2d_dtype, + ) + for cam in range(cpar.num_cams): + for part, targ in enumerate(img_pts[cam]): + x, y = pixel_to_metric(targ.x, targ.y, cpar) + x, y = dist_to_flat(x, y, calibs[cam], 0.0001) + corrected[cam][part].pnr = targ.pnr + corrected[cam][part].x = x + corrected[cam][part].y = y + + corrected[cam].sort(order="x") + + return img_pts, corrected, calibs, vpar, cpar + + +def _to_native_target_array(targets: list[Target]): + """Convert Python targets into a native TargetArray.""" + if NativeTargetArray is None: + raise RuntimeError("optv TargetArray is not available") + + native_targets = NativeTargetArray(len(targets)) + for index, target in enumerate(targets): + native_target = native_targets[index] + native_target.set_pnr(int(target.pnr)) + native_target.set_tnr(int(target.tnr)) + native_target.set_pos((float(target.x), float(target.y))) + native_target.set_pixel_counts(int(target.n), int(target.nx), int(target.ny)) + native_target.set_sum_grey_value(int(target.sumg)) + + return native_targets + + +def _to_native_volume_par(vpar: VolumePar): + """Convert Python volume parameters into native VolumeParams.""" + if NativeVolumeParams is None: + raise RuntimeError("optv VolumeParams is not available") + + native_vpar = NativeVolumeParams() + native_vpar.set_X_lay(list(vpar.x_lay)) + native_vpar.set_Zmin_lay(list(vpar.z_min_lay)) + native_vpar.set_Zmax_lay(list(vpar.z_max_lay)) + native_vpar.set_cn(vpar.cn) + native_vpar.set_cnx(vpar.cnx) + native_vpar.set_cny(vpar.cny) + native_vpar.set_csumg(vpar.csumg) + native_vpar.set_eps0(vpar.eps0) + native_vpar.set_corrmin(vpar.corrmin) + return native_vpar + + +def _normalize_correspondence_output(sorted_pos, sorted_corresp): + """Sort correspondence outputs into a stable order for comparisons.""" + normalized_pos = [] + normalized_corresp = [] + + for positions, identifiers in zip(sorted_pos, sorted_corresp): + if identifiers.shape[1] == 0: + normalized_pos.append(positions) + normalized_corresp.append(identifiers) + continue + + sortable_ids = np.where(identifiers < 0, np.iinfo(np.int64).max, identifiers) + order = np.lexsort(sortable_ids[::-1]) + normalized_pos.append(positions[:, order, :]) + normalized_corresp.append(identifiers[:, order]) + + return normalized_pos, normalized_corresp + + +@contextmanager +def _working_directory(path: Path): + """Temporarily change the current working directory.""" + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def _read_track_calibrations(workdir: Path, num_cams: int) -> list[Calibration]: + """Read all tracking calibrations from a copied tracking fixture.""" + return [ + Calibration().from_file( + ori_file=workdir / f"cal/cam{cam_num}.tif.ori", + add_file=workdir / f"cal/cam{cam_num}.tif.addpar", + ) + for cam_num in range(1, num_cams + 1) + ] + + +def _snapshot_text_outputs(root: Path) -> dict[str, tuple[int, str]]: + """Summarize benchmark output files into a stable comparison dictionary.""" + return { + str(path.relative_to(root)): ( + len(path.read_text(encoding="utf-8").splitlines()), + path.read_text(encoding="utf-8").splitlines()[0] + if path.read_text(encoding="utf-8").splitlines() + else "", + ) + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def _run_python_tracking_fixture() -> dict[str, tuple[int, str]]: + """Execute the Python tracking loop in a temporary fixture directory.""" + source = Path("tests/testing_fodder/track") + with tempfile.TemporaryDirectory() as tmp_dir: + workdir = Path(tmp_dir) / "track" + shutil.copytree(source, workdir) + shutil.copytree(workdir / "img_orig", workdir / "img") + output_dir = workdir / "py_res" + shutil.copytree(workdir / "res_orig", output_dir) + + with _working_directory(workdir): + cpar = read_control_par(Path("parameters/ptv.par")) + calibs = _read_track_calibrations(workdir, cpar.num_cams) + run = tr_new( + Path("parameters/sequence.par"), + Path("parameters/track.par"), + Path("parameters/criteria.par"), + Path("parameters/ptv.par"), + 4, + 20000, + "py_res/rt_is", + "py_res/ptv_is", + "py_res/added", + calibs, + 10000.0, + ) + run.seq_par.first = 10240 + run.seq_par.last = 10250 + run.tpar = run.tpar._replace(add=1) + + track_forward_start(run) + trackcorr_c_loop(run, run.seq_par.first) + for step in range(run.seq_par.first + 1, run.seq_par.last): + trackcorr_c_loop(run, step) + trackcorr_c_finish(run, run.seq_par.last) + + return _snapshot_text_outputs(output_dir) + + +def _run_native_tracking_fixture() -> dict[str, tuple[int, str]]: + """Execute the native tracker in a temporary fixture directory.""" + if ( + NativeTracker is None + or NativeSequenceParams is None + or NativeTrackingParams is None + ): + raise RuntimeError("optv Tracker is not available") + + source = Path("tests/testing_fodder/track") + with tempfile.TemporaryDirectory() as tmp_dir: + workdir = Path(tmp_dir) / "track" + shutil.copytree(source, workdir) + shutil.copytree(workdir / "img_orig", workdir / "img") + output_dir = workdir / "native_res" + shutil.copytree(workdir / "res_orig", output_dir) + + with _working_directory(workdir): + py_cpar = read_control_par(Path("parameters/ptv.par")) + py_vpar = read_volume_par(Path("parameters/criteria.par")) + calibs = _read_track_calibrations(workdir, py_cpar.num_cams) + + native_cpar = to_native_control_par(py_cpar) + native_vpar = _to_native_volume_par(py_vpar) + native_tpar = NativeTrackingParams() + native_tpar.read_track_par("parameters/track.par") + native_tpar.set_add(1) + + native_spar = NativeSequenceParams(num_cams=py_cpar.num_cams) + native_spar.read_sequence_par("parameters/sequence.par", py_cpar.num_cams) + native_spar.set_first(10240) + native_spar.set_last(10250) + for cam, img_base_name in enumerate(py_cpar.img_base_name): + native_spar.set_img_base_name(cam, img_base_name.replace("%05d", "")) + + tracker = NativeTracker( + native_cpar, + native_vpar, + native_tpar, + native_spar, + [to_native_calibration(cal) for cal in calibs], + naming={ + "corres": "native_res/rt_is", + "linkage": "native_res/ptv_is", + "prio": "native_res/added", + }, + flatten_tol=10000.0, + ) + tracker.full_forward() + + return _snapshot_text_outputs(output_dir) + + +@unittest.skipUnless( + RUN_STRESS_BENCHMARKS, + "set OPENPTV_SKIP_STRESS_BENCHMARKS=1 to skip stress benchmarks", +) +class TestNativeStressPerformance(unittest.TestCase): + """Stress tests comparing native and non-native runtime paths.""" + + @unittest.skipUnless( + HAS_NATIVE_SEQUENCE, + "optv native SequenceParams is not available", + ) + def test_sequence_parameter_read_stress_timing(self): + """Compare sequence parameter loading timing against native bindings.""" + sequence_file = Path("tests/testing_folder/sequence_parameters/sequence.par") + + def python_path(): + return read_sequence_par(sequence_file, 4) + + def native_path(): + assert NativeSequenceParams is not None + seq = NativeSequenceParams(num_cams=4) + seq.read_sequence_par(str(sequence_file), 4) + return seq + + python_result, python_timings = _benchmark(python_path, runs=5) + native_result, native_timings = _benchmark(native_path, runs=5) + + self.assertEqual(python_result.first, native_result.get_first()) + self.assertEqual(python_result.last, native_result.get_last()) + self.assertEqual( + python_result.img_base_name, + [native_result.get_img_base_name(cam) for cam in range(4)], + ) + + speedup = median(python_timings) / median(native_timings) + print( + "sequence stress benchmark: " + f"{_timing_summary('python', python_timings)}; " + f"{_timing_summary('native', native_timings)}; " + f"speedup={speedup:.2f}x" + ) + + @unittest.skipUnless( + HAS_NATIVE_PREPROCESS, + "optv native preprocess_image is not available", + ) + def test_preprocess_image_stress_timing(self): + """Compare native preprocessing timing against the Python path.""" + rng = np.random.default_rng(20260307) + image = rng.integers(0, 256, size=(1024, 1024), dtype=np.uint8) + + cpar = ControlPar(1) + cpar.set_image_size((image.shape[1], image.shape[0])) + + def python_path(): + return image_processing.prepare_image( + image, + dim_lp=1, + filter_hp=0, + filter_file="", + ) + + def native_path(): + return image_processing.preprocess_image( + image, + filter_hp=0, + cpar=cpar, + dim_lp=1, + ) + + python_result, python_timings = _benchmark(python_path) + with patch.object( + image_processing, + "native_preprocess_image", + wraps=image_processing.native_preprocess_image, + ) as native_call: + native_result, native_timings = _benchmark(native_path) + + np.testing.assert_array_equal(native_result, python_result) + self.assertGreaterEqual(native_call.call_count, 3) + + speedup = median(python_timings) / median(native_timings) + print( + "preprocess stress benchmark: " + f"{_timing_summary('python', python_timings)}; " + f"{_timing_summary('native', native_timings)}; " + f"speedup={speedup:.2f}x" + ) + + @unittest.skipUnless( + HAS_NATIVE_SEGMENTATION, + "optv native target recognition is not available", + ) + def test_target_recognition_stress_timing(self): + """Compare native target recognition timing against Python+Numba.""" + image = _build_segmentation_stress_image() + + cpar = ControlPar(1) + cpar.set_image_size((image.shape[1], image.shape[0])) + tpar = TargetPar( + gvthresh=[200], + discont=20, + nnmin=1, + nnmax=100, + sumg_min=1, + nxmin=1, + nxmax=10, + nymin=1, + nymax=10, + ) + + def python_numba_path(): + with patch.object(segmentation, "HAS_NATIVE_SEGMENTATION", False): + return segmentation.target_recognition(image, tpar, 0, cpar) + + def native_path(): + return segmentation.target_recognition(image, tpar, 0, cpar) + + python_targets, python_timings = _benchmark(python_numba_path) + with patch.object( + segmentation, + "native_target_recognition", + wraps=segmentation.native_target_recognition, + ) as native_call: + native_targets, native_timings = _benchmark(native_path) + + self.assertEqual( + _serialize_targets(native_targets), + _serialize_targets(python_targets), + ) + self.assertGreaterEqual(native_call.call_count, 3) + + speedup = median(python_timings) / median(native_timings) + print( + "segmentation stress benchmark: " + f"{_timing_summary('python+numba', python_timings)}; " + f"{_timing_summary('native', native_timings)}; " + f"speedup={speedup:.2f}x" + ) + + @unittest.skipUnless( + HAS_NATIVE_STEREOMATCHING, + "optv native correspondences is not available", + ) + def test_stereomatching_stress_timing(self): + """Compare native correspondences timing against the Python path.""" + img_pts, corrected, calibs, vpar, cpar = _build_stereomatching_stress_case() + native_cpar = to_native_control_par(cpar) + native_vpar = _to_native_volume_par(vpar) + native_cals = [to_native_calibration(cal) for cal in calibs] + native_img_pts = [_to_native_target_array(targets) for targets in img_pts] + native_flat_coords = [ + NativeMatchedCoords( + native_img_pts[cam], native_cpar, native_cals[cam], 0.0001 + ) + for cam in range(cpar.num_cams) + ] + + def python_path(): + return correspondences.py_correspondences( + img_pts, corrected, calibs, vpar, cpar + ) + + def native_path(): + assert native_correspondences is not None + return native_correspondences( + native_img_pts, + native_flat_coords, + native_cals, + native_vpar, + native_cpar, + ) + + python_result, python_timings = _benchmark(python_path) + native_result, native_timings = _benchmark(native_path) + python_pos, python_corresp, python_num_targs = python_result + native_pos, native_corresp, native_num_targs = native_result + python_pos, python_corresp = _normalize_correspondence_output( + python_pos, + python_corresp, + ) + native_pos, native_corresp = _normalize_correspondence_output( + native_pos, + native_corresp, + ) + + self.assertEqual(python_num_targs, native_num_targs) + self.assertEqual(len(python_corresp), len(native_corresp)) + for python_positions, native_positions in zip(python_pos, native_pos): + np.testing.assert_allclose(native_positions, python_positions, atol=1e-9) + for python_ids, native_ids in zip(python_corresp, native_corresp): + np.testing.assert_array_equal(native_ids, python_ids) + + speedup = median(python_timings) / median(native_timings) + print( + "stereomatching stress benchmark: " + f"{_timing_summary('python', python_timings)}; " + f"{_timing_summary('native', native_timings)}; " + f"speedup={speedup:.2f}x" + ) + + @unittest.skipUnless( + HAS_OPTV and HAS_NATIVE_RECONSTRUCTION, + "optv native point_positions is not available", + ) + def test_point_reconstruction_stress_timing(self): + """Compare compiled Python, legacy Python, and native reconstruction.""" + expected_points, targets, cpar, calibs = _build_reconstruction_stress_case() + native_cpar = to_native_control_par(cpar) + native_cals = [to_native_calibration(cal) for cal in calibs] + native_vpar = NativeVolumeParams() + python_vpar = VolumePar() + + def compiled_python_path(): + return orientation.point_positions(targets, cpar.mm, calibs, python_vpar) + + def legacy_python_path(): + return _python_point_positions_reference(targets, cpar.mm, calibs) + + def native_path(): + assert native_point_positions is not None + return native_point_positions( + targets, native_cpar, native_cals, native_vpar + ) + + compiled_python_result, compiled_python_timings = _benchmark( + compiled_python_path + ) + legacy_python_result, legacy_python_timings = _benchmark(legacy_python_path) + native_result, native_timings = _benchmark(native_path) + python_points, python_rcm = compiled_python_result + legacy_points, legacy_rcm = legacy_python_result + native_points, native_rcm = native_result + + np.testing.assert_allclose(python_points, expected_points, atol=1e-6) + np.testing.assert_allclose(legacy_points, expected_points, atol=1e-6) + np.testing.assert_allclose(native_points, expected_points, atol=1e-6) + np.testing.assert_allclose(legacy_points, python_points, atol=1e-9) + np.testing.assert_allclose(legacy_rcm, python_rcm, atol=1e-9) + np.testing.assert_allclose(native_points, python_points, atol=1e-9) + np.testing.assert_allclose(native_rcm, python_rcm, atol=1e-9) + + compiled_vs_legacy = median(legacy_python_timings) / median( + compiled_python_timings + ) + compiled_vs_native = median(compiled_python_timings) / median(native_timings) + print( + "reconstruction stress benchmark: " + f"{_timing_summary('compiled-python', compiled_python_timings)}; " + f"{_timing_summary('legacy-python', legacy_python_timings)}; " + f"{_timing_summary('native', native_timings)}; " + f"compiled-vs-legacy={compiled_vs_legacy:.2f}x; " + f"compiled-vs-native={compiled_vs_native:.2f}x" + ) + + @unittest.skipUnless( + HAS_OPTV and HAS_NATIVE_RECONSTRUCTION, + "optv native point_positions is not available", + ) + def test_multilayer_point_reconstruction_stress_timing(self): + """Compare compiled Python, legacy Python, and native multilayer reconstruction.""" + _expected_points, targets, cpar, calibs = ( + _build_multilayer_reconstruction_stress_case() + ) + native_cpar = to_native_control_par(cpar) + native_cals = [to_native_calibration(cal) for cal in calibs] + + def compiled_python_path(): + return orientation.multi_cam_point_positions(targets, cpar.mm, calibs) + + def legacy_python_path(): + return _python_point_positions_reference(targets, cpar.mm, calibs) + + def native_path(): + assert native_multi_cam_point_positions is not None + return native_multi_cam_point_positions(targets, native_cpar, native_cals) + + compiled_python_result, compiled_python_timings = _benchmark( + compiled_python_path + ) + legacy_python_result, legacy_python_timings = _benchmark(legacy_python_path) + native_result, native_timings = _benchmark(native_path) + python_points, python_rcm = compiled_python_result + legacy_points, legacy_rcm = legacy_python_result + native_points, native_rcm = native_result + + np.testing.assert_allclose(legacy_points, python_points, atol=1e-9) + np.testing.assert_allclose(legacy_rcm, python_rcm, atol=1e-9) + np.testing.assert_allclose(native_points, python_points, atol=1e-9) + np.testing.assert_allclose(native_rcm, python_rcm, atol=1e-9) + + compiled_vs_legacy = median(legacy_python_timings) / median( + compiled_python_timings + ) + compiled_vs_native = median(compiled_python_timings) / median(native_timings) + print( + "multilayer reconstruction stress benchmark: " + f"{_timing_summary('compiled-python', compiled_python_timings)}; " + f"{_timing_summary('legacy-python', legacy_python_timings)}; " + f"{_timing_summary('native', native_timings)}; " + f"compiled-vs-legacy={compiled_vs_legacy:.2f}x; " + f"compiled-vs-native={compiled_vs_native:.2f}x" + ) + + @unittest.skipUnless( + HAS_NATIVE_TRACKING, + "optv native Tracker is not available", + ) + def test_tracking_sequence_stress_timing(self): + """Compare native tracking over a short sequence against Python.""" + + def python_path(): + return _run_python_tracking_fixture() + + def native_path(): + return _run_native_tracking_fixture() + + python_outputs, python_timings = _benchmark(python_path, warmups=0, runs=1) + native_outputs, native_timings = _benchmark(native_path, warmups=0, runs=1) + + self.assertTrue(python_outputs) + self.assertEqual(native_outputs, python_outputs) + + speedup = median(python_timings) / median(native_timings) + print( + "tracking stress benchmark: " + f"{_timing_summary('python', python_timings)}; " + f"{_timing_summary('native', native_timings)}; " + f"speedup={speedup:.2f}x" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_point_positions.py b/tests/test_point_positions.py index a2cf0f6..17a9ecb 100644 --- a/tests/test_point_positions.py +++ b/tests/test_point_positions.py @@ -3,12 +3,16 @@ import numpy as np +from openptv_python._native_compat import HAS_OPTV +from openptv_python._native_convert import to_native_calibration, to_native_control_par from openptv_python.calibration import ( Calibration, ) +from openptv_python.constants import COORD_UNUSED from openptv_python.imgcoord import flat_image_coordinates, image_coordinates from openptv_python.orientation import ( match_detection_to_ref, + point_position, point_positions, weighted_dumbbell_precision, ) @@ -16,6 +20,28 @@ from openptv_python.tracking_frame_buf import Target from openptv_python.trafo import arr_metric_to_pixel +try: + from optv.orientation import point_positions as native_point_positions + from optv.parameters import VolumeParams as NativeVolumeParams + + HAS_NATIVE_RECONSTRUCTION = True +except ImportError: + native_point_positions = None + NativeVolumeParams = None + HAS_NATIVE_RECONSTRUCTION = False + + +def _python_multi_cam_point_positions_reference(targets, mm_par, cals): + """Reconstruct points with the original per-target Python loop.""" + num_targets = targets.shape[0] + points = np.empty((num_targets, 3), dtype=np.float64) + rcm = np.empty(num_targets, dtype=np.float64) + + for pt in range(num_targets): + rcm[pt], points[pt] = point_position(targets[pt], len(cals), mm_par, cals) + + return points, rcm + class TestOrientation(unittest.TestCase): """Test the orientation module.""" @@ -165,6 +191,119 @@ def test_point_positions(self): if np.any(np.linalg.norm(points - skew_dist_jigged[0], axis=1) > 0.1): self.fail("Rays converge on wrong position after jigging.") + def test_multi_camera_point_positions_matches_python_reference_loop(self): + """Compiled multi-camera reconstruction matches the original Python loop.""" + mult_params = MultimediaPar() + mult_params.set_n1(1.0) + mult_params.set_layers([1.0], [1.0]) + mult_params.set_n3(1.0) + + points = np.array( + [ + [17.0, 42.0, 0.0], + [12.5, 35.0, -4.0], + [-8.0, 50.0, 7.5], + ], + dtype=np.float64, + ) + + add_file = Path("tests/testing_folder/calibration/cam1.tif.addpar") + calibs = [ + Calibration().from_file( + ori_file=Path( + f"tests/testing_folder/calibration/sym_cam{cam_num}.tif.ori" + ), + add_file=add_file, + ) + for cam_num in range(1, 5) + ] + + projections = [image_coordinates(points, cal, mult_params) for cal in calibs] + targets = np.asarray(projections, dtype=np.float64).transpose(1, 0, 2) + + partial_targets = targets.copy() + partial_targets[0, 3, :] = COORD_UNUSED + partial_targets[1, 1, :] = COORD_UNUSED + partial_targets[2, 0, :] = COORD_UNUSED + partial_targets[2, 2, :] = COORD_UNUSED + + compiled_points, compiled_rcm = point_positions( + partial_targets, + mult_params, + calibs, + self.vpar, + ) + reference_points, reference_rcm = _python_multi_cam_point_positions_reference( + partial_targets, + mult_params, + calibs, + ) + + np.testing.assert_allclose(compiled_points, reference_points, atol=1e-9) + np.testing.assert_allclose(compiled_rcm, reference_rcm, atol=1e-9) + np.testing.assert_allclose(compiled_points, points, atol=1e-6) + + @unittest.skipUnless( + HAS_OPTV and HAS_NATIVE_RECONSTRUCTION, + "optv native point_positions is not available", + ) + def test_multi_camera_point_positions_matches_native_backend(self): + """Compiled multi-camera reconstruction matches optv output.""" + mult_params = MultimediaPar() + mult_params.set_n1(1.0) + mult_params.set_layers([1.0], [1.0]) + mult_params.set_n3(1.0) + + points = np.array( + [ + [17.0, 42.0, 0.0], + [12.5, 35.0, -4.0], + [-8.0, 50.0, 7.5], + ], + dtype=np.float64, + ) + + cpar = ControlPar(4).from_file(self.control_file_name) + cpar.mm.set_n1(1.0) + cpar.mm.set_layers([1.0], [1.0]) + cpar.mm.set_n3(1.0) + + add_file = Path("tests/testing_folder/calibration/cam1.tif.addpar") + calibs = [ + Calibration().from_file( + ori_file=Path( + f"tests/testing_folder/calibration/sym_cam{cam_num}.tif.ori" + ), + add_file=add_file, + ) + for cam_num in range(1, 5) + ] + + projections = [image_coordinates(points, cal, cpar.mm) for cal in calibs] + targets = np.asarray(projections, dtype=np.float64).transpose(1, 0, 2) + + python_points, python_rcm = point_positions(targets, cpar.mm, calibs, self.vpar) + reference_points, reference_rcm = _python_multi_cam_point_positions_reference( + targets, + cpar.mm, + calibs, + ) + + assert native_point_positions is not None + assert NativeVolumeParams is not None + native_points, native_rcm = native_point_positions( + targets, + to_native_control_par(cpar), + [to_native_calibration(cal) for cal in calibs], + NativeVolumeParams(), + ) + + np.testing.assert_allclose(python_points, reference_points, atol=1e-9) + np.testing.assert_allclose(python_rcm, reference_rcm, atol=1e-9) + np.testing.assert_allclose(native_points, python_points, atol=1e-9) + np.testing.assert_allclose(native_rcm, python_rcm, atol=1e-9) + np.testing.assert_allclose(python_points, points, atol=1e-6) + def test_single_camera_point_positions(self): """Point positions for a single camera case.""" num_cams = 1 diff --git a/tests/test_read_frame.py b/tests/test_read_frame.py index 430482e..6409a38 100644 --- a/tests/test_read_frame.py +++ b/tests/test_read_frame.py @@ -1,6 +1,9 @@ """Test the Frame class.""" +import shutil +import tempfile import unittest +from pathlib import Path import numpy as np @@ -62,6 +65,30 @@ def test_read_frame(self): ) np.testing.assert_array_equal(targs, targs_correct) + def test_read_frame_returns_false_when_linkage_file_missing(self): + """Missing linkage data must fail the frame read instead of producing an empty frame.""" + source_dir = Path("tests/testing_folder/frame") + + with tempfile.TemporaryDirectory() as temp_dir: + work_dir = Path(temp_dir) + for path in source_dir.iterdir(): + shutil.copy2(path, work_dir / path.name) + + (work_dir / "ptv_is.333").unlink() + + targ_files = [str(work_dir / f"cam{c:d}.%04d") for c in range(1, 5)] + frm = Frame(num_cams=4) + + self.assertFalse( + frm.read( + corres_file_base=str(work_dir / "rt_is"), + linkage_file_base=str(work_dir / "ptv_is"), + prio_file_base=str(work_dir / "added"), + target_file_base=targ_files, + frame_num=333, + ) + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_segmentation.py b/tests/test_segmentation.py index 922d45c..99cfd49 100644 --- a/tests/test_segmentation.py +++ b/tests/test_segmentation.py @@ -8,9 +8,12 @@ """ import unittest +from unittest.mock import patch import numpy as np +import openptv_python.segmentation as segmentation +from openptv_python._native_compat import HAS_NATIVE_SEGMENTATION from openptv_python.parameters import ControlPar, TargetPar from openptv_python.segmentation import target_recognition @@ -18,6 +21,20 @@ class TestTargRec(unittest.TestCase): """Test the target recognition algorithm.""" + def assert_targets_match(self, native_targets, pure_targets): + """Compare target lists field by field to avoid backend coupling.""" + self.assertEqual(len(native_targets), len(pure_targets)) + + for native_target, pure_target in zip(native_targets, pure_targets): + self.assertEqual(native_target.pnr, pure_target.pnr) + self.assertAlmostEqual(native_target.x, pure_target.x, places=9) + self.assertAlmostEqual(native_target.y, pure_target.y, places=9) + self.assertEqual(native_target.n, pure_target.n) + self.assertEqual(native_target.nx, pure_target.nx) + self.assertEqual(native_target.ny, pure_target.ny) + self.assertEqual(native_target.sumg, pure_target.sumg) + self.assertEqual(native_target.tnr, pure_target.tnr) + def test_single_target(self): """Test a single target.""" img = np.array( @@ -122,6 +139,54 @@ def test_one_targets2(self): self.assertEqual(len(target_array), 1) self.assertEqual(target_array[0].count_pixels(), (4, 3, 2)) + @unittest.skipUnless( + HAS_NATIVE_SEGMENTATION, "optv native target recognition is not available" + ) + def test_target_recognition_matches_native_backend(self): + """Whole-image target detection should call native code and match Python.""" + img = np.array( + [ + [0, 0, 0, 0, 0, 0, 0], + [0, 240, 250, 245, 0, 0, 0], + [0, 242, 255, 244, 0, 0, 0], + [0, 0, 0, 0, 0, 253, 0], + [0, 0, 0, 0, 251, 255, 252], + [0, 0, 0, 0, 0, 250, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + dtype=np.uint8, + ) + + cpar = ControlPar(1) + cpar.set_image_size((7, 7)) + tpar = TargetPar( + gvthresh=[239], + discont=5, + nnmin=1, + nnmax=20, + sumg_min=1, + nxmin=1, + nxmax=10, + nymin=1, + nymax=10, + ) + + with patch.object(segmentation, "HAS_NATIVE_SEGMENTATION", False): + pure_targets = target_recognition(img, tpar, 0, cpar) + + with patch.object( + segmentation, + "native_target_recognition", + wraps=segmentation.native_target_recognition, + ) as native_call: + native_targets = target_recognition(img, tpar, 0, cpar) + + self.assertTrue( + native_call.called, "expected the native implementation to be called" + ) + + self.assert_targets_match(native_targets, pure_targets) + # def test_peak_fit_new(): # """ Test the peak_fit function.""" # import matplotlib.pyplot as plt diff --git a/tests/test_synthetic_cavity_case.py b/tests/test_synthetic_cavity_case.py new file mode 100644 index 0000000..50f0d87 --- /dev/null +++ b/tests/test_synthetic_cavity_case.py @@ -0,0 +1,280 @@ +from pathlib import Path + +import numpy as np + +from openptv_python.calibration import read_calibration +from openptv_python.demo_bundle_adjustment import load_case_observations +from openptv_python.generate_synthetic_cavity_case import ( + DEFAULT_OUTPUT_CASE, + project_pixels, +) +from openptv_python.orientation import multi_camera_bundle_adjustment, reprojection_rms +from openptv_python.parameters import ControlPar, OrientPar, SequencePar + + +def test_synthetic_cavity_case_exists_and_is_coherent(): + case_dir = Path(DEFAULT_OUTPUT_CASE) + assert case_dir.exists() + assert (case_dir / "ground_truth/manifest.json").exists() + assert (case_dir / "ground_truth/calibration_body_points.txt").exists() + + truth_cals = [ + read_calibration( + case_dir / f"ground_truth/cal/cam{camera_index}.tif.ori", + case_dir / f"ground_truth/cal/cam{camera_index}.tif.addpar", + ) + for camera_index in range(1, 5) + ] + working_cals = [ + read_calibration( + case_dir / f"cal/cam{camera_index}.tif.ori", + case_dir / f"cal/cam{camera_index}.tif.addpar", + ) + for camera_index in range(1, 5) + ] + + position_errors = [ + np.linalg.norm(working.get_pos() - truth.get_pos()) + for working, truth in zip(working_cals, truth_cals) + ] + angle_errors = [ + np.linalg.norm(working.get_angles() - truth.get_angles()) + for working, truth in zip(working_cals, truth_cals) + ] + assert max(position_errors) < 0.2 + assert max(angle_errors) < 0.01 + + control, observed_pixels, point_init = load_case_observations( + case_dir, + 4, + max_frames=1, + max_points_per_frame=16, + ) + assert observed_pixels.shape == (16, 4, 2) + rms = reprojection_rms(observed_pixels, point_init, working_cals, control) + assert rms < 0.5 + + +def load_truth_particles( + case_dir: Path, + *, + max_frames: int | None, + max_points_per_frame: int | None, +) -> np.ndarray: + """Load the exact synthetic particle coordinates in sequence order.""" + seq = SequencePar.from_file(case_dir / "parameters/sequence.par", 4) + frames = list(range(seq.first, seq.last + 1)) + if max_frames is not None: + frames = frames[:max_frames] + + truth_batches = [] + for frame in frames: + frame_points = np.loadtxt( + case_dir / "ground_truth/particles" / f"frame_{frame}.txt", + skiprows=1, + ) + if frame_points.ndim == 1: + frame_points = frame_points.reshape(1, 3) + if max_points_per_frame is not None: + frame_points = frame_points[:max_points_per_frame] + truth_batches.append(frame_points) + + return np.concatenate(truth_batches, axis=0) + + +def perturb_free_cameras(truth_cals): + """Perturb only the cameras that remain free during BA.""" + deltas = { + 2: (np.array([0.9, -0.6, 0.4]), np.array([0.008, -0.006, 0.004])), + 3: (np.array([-0.7, 0.5, -0.3]), np.array([-0.007, 0.005, -0.004])), + } + perturbed = [] + for camera_index, cal in enumerate(truth_cals): + trial = cal.__class__( + ext_par=cal.ext_par.copy(), + int_par=cal.int_par.copy(), + glass_par=cal.glass_par.copy(), + added_par=cal.added_par.copy(), + mmlut=cal.mmlut, + mmlut_data=cal.mmlut_data, + ) + if camera_index in deltas: + pos_delta, angle_delta = deltas[camera_index] + trial.set_pos(trial.get_pos() + pos_delta) + trial.set_angles(trial.get_angles() + angle_delta) + perturbed.append(trial) + return perturbed + + +def perturb_intrinsics(truth_cals): + """Perturb a small subset of intrinsic parameters while keeping poses fixed.""" + perturbed = [] + for cal in truth_cals: + trial = cal.__class__( + ext_par=cal.ext_par.copy(), + int_par=cal.int_par.copy(), + glass_par=cal.glass_par.copy(), + added_par=cal.added_par.copy(), + mmlut=cal.mmlut, + mmlut_data=cal.mmlut_data, + ) + trial.added_par[0] += 2e-5 + trial.added_par[3] += 8e-5 + trial.added_par[4] -= 6e-5 + perturbed.append(trial) + return perturbed + + +def test_synthetic_case_bundle_adjustment_recovers_ground_truth_from_controlled_perturbation(): + case_dir = Path(DEFAULT_OUTPUT_CASE) + control = ControlPar(4).from_file(case_dir / "parameters/ptv.par") + truth_cals = [ + read_calibration( + case_dir / f"ground_truth/cal/cam{camera_index}.tif.ori", + case_dir / f"ground_truth/cal/cam{camera_index}.tif.addpar", + ) + for camera_index in range(1, 5) + ] + truth_points = load_truth_particles( + case_dir, + max_frames=1, + max_points_per_frame=24, + ) + observed_pixels = project_pixels(truth_points, truth_cals, control) + + start_cals = perturb_free_cameras(truth_cals) + point_init = truth_points + np.array([0.35, -0.25, 0.18]) + + initial_camera_errors = [ + np.linalg.norm( + start_cals[camera_index].get_pos() - truth_cals[camera_index].get_pos() + ) + for camera_index in (2, 3) + ] + initial_angle_errors = [ + np.linalg.norm( + start_cals[camera_index].get_angles() + - truth_cals[camera_index].get_angles() + ) + for camera_index in (2, 3) + ] + initial_point_error = float( + np.mean(np.linalg.norm(point_init - truth_points, axis=1)) + ) + + refined_cals, refined_points, result = multi_camera_bundle_adjustment( + observed_pixels, + start_cals, + control, + OrientPar(), + point_init=point_init, + fixed_camera_indices=[0, 1], + loss="linear", + method="lm", + prior_sigmas={ + "x0": 1.0, + "y0": 1.0, + "z0": 1.0, + "omega": 0.01, + "phi": 0.01, + "kappa": 0.01, + }, + max_nfev=100, + ) + + final_camera_errors = [ + np.linalg.norm( + refined_cals[camera_index].get_pos() - truth_cals[camera_index].get_pos() + ) + for camera_index in (2, 3) + ] + final_angle_errors = [ + np.linalg.norm( + refined_cals[camera_index].get_angles() + - truth_cals[camera_index].get_angles() + ) + for camera_index in (2, 3) + ] + final_point_error = float( + np.mean(np.linalg.norm(refined_points - truth_points, axis=1)) + ) + + assert bool(result.success), result.message + assert max(final_camera_errors) < max(initial_camera_errors) * 0.01 + assert max(final_camera_errors) < 0.01 + assert max(final_angle_errors) < max(initial_angle_errors) * 0.01 + assert max(final_angle_errors) < 1e-4 + assert final_point_error < initial_point_error * 1e-3 + assert final_point_error < 1e-3 + assert result["final_reprojection_rms"] < 1e-3 + + +def test_synthetic_case_intrinsics_only_recovers_ground_truth_from_controlled_perturbation(): + case_dir = Path(DEFAULT_OUTPUT_CASE) + control = ControlPar(4).from_file(case_dir / "parameters/ptv.par") + truth_cals = [ + read_calibration( + case_dir / f"ground_truth/cal/cam{camera_index}.tif.ori", + case_dir / f"ground_truth/cal/cam{camera_index}.tif.addpar", + ) + for camera_index in range(1, 5) + ] + truth_points = load_truth_particles( + case_dir, + max_frames=1, + max_points_per_frame=24, + ) + observed_pixels = project_pixels(truth_points, truth_cals, control) + start_cals = perturb_intrinsics(truth_cals) + + intrinsics = OrientPar() + intrinsics.k1flag = 1 + intrinsics.p1flag = 1 + intrinsics.p2flag = 1 + + refined_cals, refined_points, result = multi_camera_bundle_adjustment( + observed_pixels, + start_cals, + control, + intrinsics, + point_init=truth_points.copy(), + fixed_camera_indices=[0, 1, 2, 3], + optimize_extrinsics=False, + optimize_points=False, + loss="linear", + method="trf", + prior_sigmas={ + "k1": 5e-5, + "p1": 1e-4, + "p2": 1e-4, + }, + parameter_bounds={ + "k1": (-5e-5, 5e-5), + "p1": (-2e-4, 2e-4), + "p2": (-2e-4, 2e-4), + }, + max_nfev=40, + ) + + k1_errors = [ + abs(refined.added_par[0] - truth.added_par[0]) + for refined, truth in zip(refined_cals, truth_cals) + ] + p1_errors = [ + abs(refined.added_par[3] - truth.added_par[3]) + for refined, truth in zip(refined_cals, truth_cals) + ] + p2_errors = [ + abs(refined.added_par[4] - truth.added_par[4]) + for refined, truth in zip(refined_cals, truth_cals) + ] + + assert bool(result.success), result.message + assert np.max(np.linalg.norm(refined_points - truth_points, axis=1)) == 0.0 + for refined, start in zip(refined_cals, start_cals): + np.testing.assert_allclose(refined.get_pos(), start.get_pos(), atol=1e-12) + np.testing.assert_allclose(refined.get_angles(), start.get_angles(), atol=1e-12) + assert max(k1_errors) < 1.5e-5 + assert max(p1_errors) < 1.5e-5 + assert max(p2_errors) < 3.0e-5 + assert result["final_reprojection_rms"] < result["initial_reprojection_rms"] * 0.2 diff --git a/tests/test_tracking.py b/tests/test_tracking.py index b4a0afe..b329788 100644 --- a/tests/test_tracking.py +++ b/tests/test_tracking.py @@ -180,6 +180,17 @@ def test_angle_acc(self): ) self.assertTrue(isclose(acc, 0.0, rel_tol=EPS), f"Expected 0.0 but found {acc}") + cand = np.array([2.0, 2.0, 2.0]) + + angle, acc = angle_acc(start, pred, cand) + self.assertTrue( + isclose(angle, 0.0, rel_tol=EPS), f"Expected 0.0 but found {angle}" + ) + self.assertTrue( + isclose(acc, np.sqrt(3.0), rel_tol=EPS), + f"Expected {np.sqrt(3.0)} but found {acc}", + ) + cand = vec_scalar_mul(pred, -1) angle, acc = angle_acc(start, pred, cand) diff --git a/tests/test_tracking_ground_truth.py b/tests/test_tracking_ground_truth.py new file mode 100644 index 0000000..39784eb --- /dev/null +++ b/tests/test_tracking_ground_truth.py @@ -0,0 +1,788 @@ +import io +import os +import re +import shutil +import tempfile +import unittest +from contextlib import ExitStack, redirect_stdout +from dataclasses import dataclass +from pathlib import Path +from typing import cast +from unittest.mock import patch + +import numpy as np + +import openptv_python.track as track +from openptv_python._native_compat import HAS_OPTV +from openptv_python._native_convert import to_native_calibration, to_native_control_par +from openptv_python.calibration import Calibration +from openptv_python.constants import CORRES_NONE +from openptv_python.imgcoord import image_coordinates +from openptv_python.parameters import ControlPar, read_volume_par +from openptv_python.track import ( + track_forward_start, + trackcorr_c_finish, + trackcorr_c_loop, +) +from openptv_python.tracking_frame_buf import ( + Frame, + Pathinfo, + Target, + read_path_frame, + write_path_frame, + write_targets, +) +from openptv_python.tracking_run import tr_new +from openptv_python.trafo import metric_to_pixel + +try: + from optv.parameters import SequenceParams as NativeSequenceParams + from optv.parameters import TrackingParams as NativeTrackingParams + from optv.parameters import VolumeParams as NativeVolumeParams + from optv.tracker import Tracker as NativeTracker + + HAS_NATIVE_TRACKING = True +except ImportError: + NativeSequenceParams = None + NativeTrackingParams = None + NativeVolumeParams = None + NativeTracker = None + HAS_NATIVE_TRACKING = False + + +FRAMES = tuple(range(10001, 10007)) +PERMISSIVE_BOUNDS = { + "dacc": 2.0, + "dangle": 100.0, + "dvxmin": -0.02, + "dvxmax": 0.02, + "dvymin": -0.02, + "dvymax": 0.02, + "dvzmin": -0.02, + "dvzmax": 0.02, +} +STRICT_BOUNDS = { + "dacc": 2.0, + "dangle": 100.0, + "dvxmin": -0.005, + "dvxmax": 0.005, + "dvymin": -0.005, + "dvymax": 0.005, + "dvzmin": -0.005, + "dvzmax": 0.005, +} +EXPECTED_PERMISSIVE_SNAPSHOT = { + 10001: [ + (-1, 0, (2, 1, 1, -1), (0.0, 0.0, 0.0)), + (-1, 1, (1, 0, 0, -1), (0.02, 0.01, 0.0)), + ], + 10002: [ + (0, 0, (2, 1, 1, -1), (0.01, 0.0, 0.0)), + (1, 1, (1, 0, 0, -1), (0.03, 0.01, 0.0)), + ], + 10003: [ + (0, 0, (2, 1, 1, -1), (0.02, 0.0, 0.0)), + (1, 1, (1, 0, 0, -1), (0.04, 0.01, 0.0)), + ], + 10004: [ + (0, 0, (2, 1, 1, -1), (0.03, 0.0, 0.0)), + (1, 1, (1, 0, 0, -1), (0.05, 0.01, 0.0)), + ], + 10005: [ + (0, 0, (2, 1, 1, -1), (0.04, 0.0, 0.0)), + (1, 1, (1, 0, 0, -1), (0.06, 0.01, 0.0)), + ], + 10006: [ + (0, -2, (2, 1, 1, -1), (0.05, 0.0, 0.0)), + (1, -2, (1, 0, 0, -1), (0.07, 0.01, 0.0)), + ], +} +EXPECTED_STRICT_SNAPSHOT = { + frame: [ + (-1, -2, (2, 1, 1, -1), (round(0.01 * (frame - 10001), 5), 0.0, 0.0)), + (-1, -2, (1, 0, 0, -1), (round(0.02 + 0.01 * (frame - 10001), 5), 0.01, 0.0)), + ] + for frame in FRAMES +} +EXPECTED_PERMISSIVE_STEP_STATS = {frame: (2, 0) for frame in FRAMES[:-1]} +EXPECTED_STRICT_STEP_STATS = {frame: (0, 2) for frame in FRAMES[:-1]} +BAD_FILE_LOOKUP_PATTERN = re.compile(r"Can't open ascii file|\d{10}_targets") + +CONSTANT_DIAGONAL_TRACKS = { + 0: [ + np.array([0.01 * index, 0.01 * index, 0.01 * index]) + for index in range(len(FRAMES)) + ], +} +TURNING_TRACKS = { + 0: [ + np.array([0.0, 0.0, 0.0]), + np.array([0.01, 0.0, 0.001]), + np.array([0.018, 0.006, 0.002]), + np.array([0.023, 0.014, 0.003]), + np.array([0.025, 0.023, 0.004]), + np.array([0.024, 0.033, 0.005]), + ], +} +JUMP_SWITCH_TRACKS = { + 0: [ + np.array([0.0, 0.0, 0.0]), + np.array([0.01, 0.01, 0.01]), + np.array([0.02, 0.02, 0.02]), + None, + None, + None, + None, + ], + 1: [ + None, + None, + np.array([0.05, 0.05, 0.05]), + np.array([0.06, 0.06, 0.06]), + np.array([0.07, 0.07, 0.07]), + np.array([0.08, 0.08, 0.08]), + ], +} +BRANCH_SWITCH_TRACKS = { + 0: [ + np.array([0.0, 0.0, 0.0]), + np.array([0.01, 0.0, 0.001]), + np.array([0.018, 0.006, 0.002]), + None, + None, + None, + ], + 1: [ + None, + None, + None, + np.array([0.024, 0.022, 0.004]), + np.array([0.030, 0.032, 0.005]), + np.array([0.036, 0.042, 0.006]), + ], +} + + +@dataclass(frozen=True) +class TrackingSnapshotResult: + snapshot: dict[ + int, + list[tuple[int, int, tuple[int, int, int, int], tuple[float, float, float]]], + ] + log: str + step_stats: dict[int, tuple[int, int]] + + +def _make_bounds(*, dv_limit: float, dacc: float, dangle: float) -> dict[str, float]: + return { + "dacc": dacc, + "dangle": dangle, + "dvxmin": -dv_limit, + "dvxmax": dv_limit, + "dvymin": -dv_limit, + "dvymax": dv_limit, + "dvzmin": -dv_limit, + "dvzmax": dv_limit, + } + + +def _rounded_position(pos: np.ndarray) -> tuple[float, float, float]: + values = tuple(round(float(value), 5) for value in pos) + return cast(tuple[float, float, float], values) + + +def _snapshot_graph(snapshot): + return { + frame: [(prev, next_, pos) for prev, next_, _corres, pos in rows] + for frame, rows in snapshot.items() + } + + +def _candidate_passes_tracking_gate( + prev_pos: np.ndarray, + curr_pos: np.ndarray, + cand_pos: np.ndarray, + bounds: dict[str, float], +) -> bool: + diff_pos = cand_pos - curr_pos + if not ( + bounds["dvxmin"] < diff_pos[0] < bounds["dvxmax"] + and bounds["dvymin"] < diff_pos[1] < bounds["dvymax"] + and bounds["dvzmin"] < diff_pos[2] < bounds["dvzmax"] + ): + return False + + pred_pos = track.search_volume_center_moving.py_func(prev_pos, curr_pos) + angle, acc = track.angle_acc.py_func(curr_pos, pred_pos, cand_pos) + return (acc < bounds["dacc"] and angle < bounds["dangle"]) or ( + acc < bounds["dacc"] / 10 + ) + + +def _single_track_chain_graph(positions: list[np.ndarray]): + return { + frame: [ + ( + -1 if index == 0 else 0, + -2 if index == len(FRAMES) - 1 else 0, + _rounded_position(pos), + ) + ] + for index, (frame, pos) in enumerate(zip(FRAMES, positions)) + } + + +def _single_track_delayed_chain_graph(positions: list[np.ndarray]): + return { + FRAMES[0]: [(-1, -2, _rounded_position(positions[0]))], + FRAMES[1]: [(-1, 0, _rounded_position(positions[1]))], + FRAMES[2]: [(0, 0, _rounded_position(positions[2]))], + FRAMES[3]: [(0, 0, _rounded_position(positions[3]))], + FRAMES[4]: [(0, 0, _rounded_position(positions[4]))], + FRAMES[5]: [(0, -2, _rounded_position(positions[5]))], + } + + +def _single_track_unlinked_graph(positions: list[np.ndarray]): + return { + frame: [(-1, -2, _rounded_position(pos))] + for frame, pos in zip(FRAMES, positions) + } + + +def _switch_graph(tracks, *, inherited: bool): + first_positions = tracks[0] + second_positions = tracks[1] + return { + FRAMES[0]: [(-1, 0, _rounded_position(first_positions[0]))], + FRAMES[1]: [(0, 0, _rounded_position(first_positions[1]))], + FRAMES[2]: [(0, 0 if inherited else -2, _rounded_position(first_positions[2]))], + FRAMES[3]: [((-1, 0)[inherited], 0, _rounded_position(second_positions[3]))], + FRAMES[4]: [(0, 0, _rounded_position(second_positions[4]))], + FRAMES[5]: [(0, -2, _rounded_position(second_positions[5]))], + } + + +EXPECTED_CONSTANT_DIAGONAL_CHAIN_GRAPH = _single_track_chain_graph( + CONSTANT_DIAGONAL_TRACKS[0] +) +EXPECTED_CONSTANT_DIAGONAL_REJECT_GRAPH = _single_track_unlinked_graph( + CONSTANT_DIAGONAL_TRACKS[0] +) +EXPECTED_TURNING_CHAIN_GRAPH = _single_track_delayed_chain_graph(TURNING_TRACKS[0]) +EXPECTED_TURNING_REJECT_GRAPH = _single_track_unlinked_graph(TURNING_TRACKS[0]) +EXPECTED_JUMP_FRESH_START_GRAPH = _switch_graph(JUMP_SWITCH_TRACKS, inherited=False) +EXPECTED_JUMP_FALSE_LINK_GRAPH = _switch_graph(JUMP_SWITCH_TRACKS, inherited=True) +EXPECTED_BRANCH_FRESH_START_GRAPH = _switch_graph(BRANCH_SWITCH_TRACKS, inherited=False) +EXPECTED_BRANCH_FALSE_LINK_GRAPH = _switch_graph(BRANCH_SWITCH_TRACKS, inherited=True) +EXPECTED_SINGLE_TRACK_LINK_STATS = {frame: (1, 0) for frame in FRAMES[:-1]} +EXPECTED_SINGLE_TRACK_REJECT_STATS = {frame: (0, 1) for frame in FRAMES[:-1]} +EXPECTED_TURNING_DELAYED_LINK_STATS = { + FRAMES[0]: (0, 1), + FRAMES[1]: (1, 0), + FRAMES[2]: (1, 0), + FRAMES[3]: (1, 0), + FRAMES[4]: (1, 0), +} + + +def _to_native_volume_par(vpar): + """Convert Python volume parameters into native VolumeParams.""" + if NativeVolumeParams is None: + raise RuntimeError("optv VolumeParams is not available") + + native_vpar = NativeVolumeParams() + native_vpar.set_X_lay(list(vpar.x_lay)) + native_vpar.set_Zmin_lay(list(vpar.z_min_lay)) + native_vpar.set_Zmax_lay(list(vpar.z_max_lay)) + native_vpar.set_cn(vpar.cn) + native_vpar.set_cnx(vpar.cnx) + native_vpar.set_cny(vpar.cny) + native_vpar.set_csumg(vpar.csumg) + native_vpar.set_eps0(vpar.eps0) + native_vpar.set_corrmin(vpar.corrmin) + return native_vpar + + +def _synthetic_track_positions(): + """Return deterministic per-frame 3D positions for two tracks.""" + return { + 0: [np.array([0.00 + 0.01 * index, 0.0, 0.0]) for index in range(len(FRAMES))], + 1: [np.array([0.02 + 0.01 * index, 0.01, 0.0]) for index in range(len(FRAMES))], + } + + +def _write_sequence_range(sequence_path: Path) -> None: + lines = sequence_path.read_text(encoding="utf-8").splitlines() + lines[-2] = str(FRAMES[0]) + lines[-1] = str(FRAMES[-1]) + sequence_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _parse_tracking_step_stats(log: str) -> dict[int, tuple[int, int]]: + return { + int(frame): (int(links), int(lost)) + for frame, links, lost in re.findall( + r"step:\s*(\d+),.*?links:\s*(-?\d+),\s*lost:\s*(-?\d+)", + log, + re.DOTALL, + ) + } + + +def _write_synthetic_tracking_fixture( + workdir: Path, + tracks: dict[int, list[np.ndarray | None]] | None = None, + *, + add_distractor: bool = True, +) -> list[Calibration]: + """Write a clean deterministic tracking fixture into a temporary workspace.""" + source = Path(__file__).resolve().parent / "testing_fodder" + if not source.exists(): + raise FileNotFoundError(f"Missing test fixture directory: {source}") + shutil.copytree(source, workdir) + os.chdir(workdir) + + calibrations = [ + Calibration().from_file( + Path(f"cal/sym_cam{cam + 1}.tif.ori"), + Path("cal/cam1.tif.addpar"), + ) + for cam in range(3) + ] + + os.chdir(workdir / "track") + Path("res").mkdir() + Path("newpart").mkdir(exist_ok=True) + _write_sequence_range(Path("parameters/sequence_newpart.par")) + + cpar = ControlPar(3).from_file(Path("parameters/control_newpart.par")) + tracks = tracks or _synthetic_track_positions() + + for frame_index, frame_num in enumerate(FRAMES): + frame = Frame(3, 16) + cam_targets: list[list[Target]] = [[] for _ in range(3)] + correspond_indices = { + track_id: [CORRES_NONE] * 4 + for track_id, positions in tracks.items() + if positions[frame_index] is not None + } + + for track_id, positions in tracks.items(): + pos = positions[frame_index] + if pos is None: + continue + projections = [ + image_coordinates(np.array([pos]), cal, cpar.mm)[0] + for cal in calibrations + ] + for cam, xy in enumerate(projections): + px, py = metric_to_pixel(xy[0], xy[1], cpar) + cam_targets[cam].append( + Target( + pnr=len(cam_targets[cam]), + x=float(px), + y=float(py), + n=10, + nx=3, + ny=3, + sumg=100, + tnr=track_id, + ) + ) + + # Keep one unmatched distractor near the search area in a single camera. + if add_distractor: + cam_targets[0].append( + Target( + pnr=99, + x=960.0, + y=540.0 + frame_index, + n=8, + nx=2, + ny=2, + sumg=50, + tnr=CORRES_NONE, + ) + ) + + for cam in range(3): + cam_targets[cam].sort(key=lambda target: target.y) + write_targets( + cam_targets[cam], + len(cam_targets[cam]), + f"newpart/cam{cam + 1}.%05d", + frame_num, + ) + for idx, target in enumerate(cam_targets[cam]): + if target.tnr in correspond_indices: + correspond_indices[target.tnr][cam] = idx + + active_track_ids = sorted(correspond_indices) + frame.num_parts = len(active_track_ids) + frame.correspond = np.recarray((frame.num_parts,), dtype=frame.correspond.dtype) + frame.path_info = [Pathinfo() for _ in range(frame.num_parts)] + + for row_index, track_id in enumerate(active_track_ids): + frame.correspond[row_index].nr = track_id + 1 + frame.correspond[row_index].p = np.array( + correspond_indices[track_id], + dtype=np.int32, + ) + position = tracks[track_id][frame_index] + assert position is not None + frame.path_info[row_index].x = position + + write_path_frame( + frame.correspond, + frame.path_info, + frame.num_parts, + "res/particles", + "res/linkage", + "res/whatever", + frame_num, + ) + + return calibrations + + +def _snapshot_tracking_outputs() -> dict[ + int, list[tuple[int, int, tuple[int, int, int, int], tuple[float, float, float]]] +]: + """Read the written tracking outputs into a stable frame-by-frame snapshot.""" + snapshots: dict[ + int, + list[tuple[int, int, tuple[int, int, int, int], tuple[float, float, float]]], + ] = {} + for frame in FRAMES: + correspond, path_info = read_path_frame( + "res/particles", + "res/linkage", + "res/whatever", + frame, + ) + snapshots[frame] = [ + ( + int(path.prev_frame), + int(path.next_frame), + cast( + tuple[int, int, int, int], + tuple(int(value) for value in correspond[idx].p), + ), + cast( + tuple[float, float, float], + tuple(round(float(value), 5) for value in path.x), + ), + ) + for idx, path in enumerate(path_info) + ] + return snapshots + + +def _run_python_tracking_snapshot( + mode: str, + bounds: dict[str, float], + *, + tracks: dict[int, list[np.ndarray | None]] | None = None, + add_distractor: bool = True, +) -> TrackingSnapshotResult: + """Run the Python tracker in either compiled or patched-Python mode.""" + with tempfile.TemporaryDirectory() as tmp_dir: + workdir = Path(tmp_dir) / "testing_fodder" + original_cwd = Path.cwd() + try: + calibrations = _write_synthetic_tracking_fixture( + workdir, + tracks=tracks, + add_distractor=add_distractor, + ) + run = tr_new( + Path("parameters/sequence_newpart.par"), + Path("parameters/track.par"), + Path("parameters/criteria.par"), + Path("parameters/control_newpart.par"), + 4, + 16, + "res/particles", + "res/linkage", + "res/whatever", + calibrations, + 0.1, + ) + run.tpar = run.tpar._replace(add=0, **bounds) + + with ExitStack() as stack: + if mode == "python": + stack.enter_context( + patch.object( + track, + "search_volume_center_moving", + track.search_volume_center_moving.py_func, + ) + ) + stack.enter_context( + patch.object( + track, + "pos3d_in_bounds", + track.pos3d_in_bounds.py_func, + ) + ) + stack.enter_context( + patch.object( + track, + "angle_acc", + track.angle_acc.py_func, + ) + ) + + def run_tracker() -> None: + track_forward_start(run) + for step in range(run.seq_par.first, run.seq_par.last): + trackcorr_c_loop(run, step) + trackcorr_c_finish(run, run.seq_par.last) + + log_buffer = io.StringIO() + with redirect_stdout(log_buffer): + run_tracker() + log = log_buffer.getvalue() + return TrackingSnapshotResult( + snapshot=_snapshot_tracking_outputs(), + log=log, + step_stats=_parse_tracking_step_stats(log), + ) + finally: + os.chdir(original_cwd) + + +def _run_native_tracking_snapshot( + bounds: dict[str, float], + *, + tracks: dict[int, list[np.ndarray | None]] | None = None, + add_distractor: bool = True, +): + """Run the native optv tracker on the same synthetic fixture.""" + if not HAS_NATIVE_TRACKING or NativeTracker is None or NativeTrackingParams is None: + raise RuntimeError("optv Tracker is not available") + + with tempfile.TemporaryDirectory() as tmp_dir: + workdir = Path(tmp_dir) / "testing_fodder" + original_cwd = Path.cwd() + try: + calibrations = _write_synthetic_tracking_fixture( + workdir, + tracks=tracks, + add_distractor=add_distractor, + ) + cpar = ControlPar(3).from_file(Path("parameters/control_newpart.par")) + cpar.img_base_name = [f"newpart/cam{cam + 1}." for cam in range(3)] + vpar = read_volume_par(Path("parameters/criteria.par")) + + native_tpar = NativeTrackingParams() + native_tpar.read_track_par("parameters/track.par") + native_tpar.set_add(0) + native_tpar.set_dacc(bounds["dacc"]) + native_tpar.set_dangle(bounds["dangle"]) + native_tpar.set_dvxmin(bounds["dvxmin"]) + native_tpar.set_dvxmax(bounds["dvxmax"]) + native_tpar.set_dvymin(bounds["dvymin"]) + native_tpar.set_dvymax(bounds["dvymax"]) + native_tpar.set_dvzmin(bounds["dvzmin"]) + native_tpar.set_dvzmax(bounds["dvzmax"]) + + native_spar = NativeSequenceParams(num_cams=3) + native_spar.read_sequence_par("parameters/sequence_newpart.par", 3) + native_spar.set_first(FRAMES[0]) + native_spar.set_last(FRAMES[-1]) + for cam in range(3): + native_spar.set_img_base_name(cam, f"newpart/cam{cam + 1}.") + + tracker = NativeTracker( + to_native_control_par(cpar), + _to_native_volume_par(vpar), + native_tpar, + native_spar, + [to_native_calibration(cal) for cal in calibrations], + naming={ + "corres": "res/particles", + "linkage": "res/linkage", + "prio": "res/whatever", + }, + flatten_tol=0.1, + ) + tracker.full_forward() + return TrackingSnapshotResult( + snapshot=_snapshot_tracking_outputs(), + log="", + step_stats={}, + ) + finally: + os.chdir(original_cwd) + + +class TestTrackingGroundTruth(unittest.TestCase): + """Ground-truth tracking comparisons across Python and native backends.""" + + def assert_python_modes_match_graph( + self, + *, + tracks, + bounds, + expected_graph, + expected_step_stats=None, + add_distractor=False, + ): + compiled_snapshot = _run_python_tracking_snapshot( + "python+numba", + bounds, + tracks=tracks, + add_distractor=add_distractor, + ) + python_snapshot = _run_python_tracking_snapshot( + "python", + bounds, + tracks=tracks, + add_distractor=add_distractor, + ) + + self.assertEqual(_snapshot_graph(compiled_snapshot.snapshot), expected_graph) + self.assertEqual(_snapshot_graph(python_snapshot.snapshot), expected_graph) + self.assertEqual(compiled_snapshot.snapshot, python_snapshot.snapshot) + if expected_step_stats is not None: + self.assertEqual(compiled_snapshot.step_stats, expected_step_stats) + self.assertEqual(python_snapshot.step_stats, expected_step_stats) + self.assertNotRegex(compiled_snapshot.log, BAD_FILE_LOOKUP_PATTERN) + self.assertNotRegex(python_snapshot.log, BAD_FILE_LOOKUP_PATTERN) + + def test_tracking_exact_link_graph_matches_ground_truth_across_backends(self): + """Match the exact permissive link graph across Python, Numba, and optv.""" + expected = EXPECTED_PERMISSIVE_SNAPSHOT + compiled_snapshot = _run_python_tracking_snapshot( + "python+numba", PERMISSIVE_BOUNDS + ) + python_snapshot = _run_python_tracking_snapshot("python", PERMISSIVE_BOUNDS) + + self.assertEqual(compiled_snapshot.snapshot, expected) + self.assertEqual(python_snapshot.snapshot, expected) + self.assertEqual(compiled_snapshot.snapshot, python_snapshot.snapshot) + self.assertEqual(compiled_snapshot.step_stats, EXPECTED_PERMISSIVE_STEP_STATS) + self.assertEqual(python_snapshot.step_stats, EXPECTED_PERMISSIVE_STEP_STATS) + self.assertNotRegex(compiled_snapshot.log, BAD_FILE_LOOKUP_PATTERN) + self.assertNotRegex(python_snapshot.log, BAD_FILE_LOOKUP_PATTERN) + + if HAS_OPTV and HAS_NATIVE_TRACKING: + native_snapshot = _run_native_tracking_snapshot(PERMISSIVE_BOUNDS) + self.assertEqual(native_snapshot.snapshot, expected) + self.assertEqual(native_snapshot.snapshot, compiled_snapshot.snapshot) + + def test_tracking_strict_bounds_reject_links_by_design(self): + """Reject links consistently across backends when the velocity window excludes the motion.""" + expected = EXPECTED_STRICT_SNAPSHOT + compiled_snapshot = _run_python_tracking_snapshot("python+numba", STRICT_BOUNDS) + python_snapshot = _run_python_tracking_snapshot("python", STRICT_BOUNDS) + + self.assertEqual(compiled_snapshot.snapshot, expected) + self.assertEqual(python_snapshot.snapshot, expected) + self.assertEqual(compiled_snapshot.snapshot, python_snapshot.snapshot) + self.assertEqual(compiled_snapshot.step_stats, EXPECTED_STRICT_STEP_STATS) + self.assertEqual(python_snapshot.step_stats, EXPECTED_STRICT_STEP_STATS) + self.assertNotRegex(compiled_snapshot.log, BAD_FILE_LOOKUP_PATTERN) + self.assertNotRegex(python_snapshot.log, BAD_FILE_LOOKUP_PATTERN) + + def test_tracking_velocity_window_has_valid_range_between_reject_and_false_link( + self, + ): + """A symmetric 3D velocity window must be wide enough for truth but narrow enough to block jumps.""" + self.assert_python_modes_match_graph( + tracks=CONSTANT_DIAGONAL_TRACKS, + bounds=_make_bounds(dv_limit=0.005, dacc=0.2, dangle=100.0), + expected_graph=EXPECTED_CONSTANT_DIAGONAL_REJECT_GRAPH, + expected_step_stats=EXPECTED_SINGLE_TRACK_REJECT_STATS, + ) + self.assert_python_modes_match_graph( + tracks=CONSTANT_DIAGONAL_TRACKS, + bounds=_make_bounds(dv_limit=0.015, dacc=0.2, dangle=100.0), + expected_graph=EXPECTED_CONSTANT_DIAGONAL_CHAIN_GRAPH, + expected_step_stats=EXPECTED_SINGLE_TRACK_LINK_STATS, + ) + self.assertFalse( + _candidate_passes_tracking_gate( + np.array([0.01, 0.01, 0.01]), + np.array([0.02, 0.02, 0.02]), + np.array([0.05, 0.05, 0.04]), + _make_bounds(dv_limit=0.015, dacc=0.2, dangle=100.0), + ) + ) + self.assertTrue( + _candidate_passes_tracking_gate( + np.array([0.01, 0.01, 0.01]), + np.array([0.02, 0.02, 0.02]), + np.array([0.05, 0.05, 0.04]), + _make_bounds(dv_limit=0.05, dacc=0.2, dangle=100.0), + ) + ) + + def test_tracking_dacc_has_valid_range_between_reject_and_false_link(self): + """Acceleration bounds should admit smooth curvature but still reject identity-switch jumps.""" + self.assert_python_modes_match_graph( + tracks=TURNING_TRACKS, + bounds=_make_bounds(dv_limit=0.02, dacc=0.002, dangle=40.0), + expected_graph=EXPECTED_TURNING_REJECT_GRAPH, + expected_step_stats=EXPECTED_SINGLE_TRACK_REJECT_STATS, + ) + self.assert_python_modes_match_graph( + tracks=TURNING_TRACKS, + bounds=_make_bounds(dv_limit=0.02, dacc=0.02, dangle=40.0), + expected_graph=EXPECTED_TURNING_CHAIN_GRAPH, + expected_step_stats=EXPECTED_TURNING_DELAYED_LINK_STATS, + ) + self.assertFalse( + _candidate_passes_tracking_gate( + np.array([0.01, 0.01, 0.01]), + np.array([0.02, 0.02, 0.02]), + np.array([0.05, 0.05, 0.04]), + _make_bounds(dv_limit=0.05, dacc=0.02, dangle=100.0), + ) + ) + self.assertTrue( + _candidate_passes_tracking_gate( + np.array([0.01, 0.01, 0.01]), + np.array([0.02, 0.02, 0.02]), + np.array([0.05, 0.05, 0.04]), + _make_bounds(dv_limit=0.05, dacc=0.06, dangle=100.0), + ) + ) + + def test_tracking_dangle_has_valid_range_between_reject_and_false_link(self): + """Angular bounds should admit smooth turning but still reject a sharp branch switch.""" + self.assert_python_modes_match_graph( + tracks=TURNING_TRACKS, + bounds=_make_bounds(dv_limit=0.02, dacc=0.02, dangle=10.0), + expected_graph=EXPECTED_TURNING_REJECT_GRAPH, + expected_step_stats=EXPECTED_SINGLE_TRACK_REJECT_STATS, + ) + self.assert_python_modes_match_graph( + tracks=TURNING_TRACKS, + bounds=_make_bounds(dv_limit=0.02, dacc=0.02, dangle=30.0), + expected_graph=EXPECTED_TURNING_CHAIN_GRAPH, + expected_step_stats=EXPECTED_TURNING_DELAYED_LINK_STATS, + ) + self.assertFalse( + _candidate_passes_tracking_gate( + np.array([0.01, 0.0, 0.001]), + np.array([0.018, 0.006, 0.002]), + np.array([0.024, 0.022, 0.004]), + _make_bounds(dv_limit=0.02, dacc=0.02, dangle=30.0), + ) + ) + self.assertTrue( + _candidate_passes_tracking_gate( + np.array([0.01, 0.0, 0.001]), + np.array([0.018, 0.006, 0.002]), + np.array([0.024, 0.022, 0.004]), + _make_bounds(dv_limit=0.02, dacc=0.02, dangle=40.0), + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/testing_fodder/test_cavity/parameters_Run1.yaml b/tests/testing_fodder/test_cavity/parameters_Run1.yaml new file mode 100644 index 0000000..9c58258 --- /dev/null +++ b/tests/testing_fodder/test_cavity/parameters_Run1.yaml @@ -0,0 +1,174 @@ +num_cams: 4 +plugins: + available_tracking: + - default + available_sequence: + - default + selected_tracking: default + selected_sequence: default +cal_ori: + chfield: 0 + fixp_name: cal/target_on_a_side.txt + img_cal_name: + - cal/cam1.tif + - cal/cam2.tif + - cal/cam3.tif + - cal/cam4.tif + img_ori: + - cal/cam1.tif.ori + - cal/cam2.tif.ori + - cal/cam3.tif.ori + - cal/cam4.tif.ori + pair_flag: true + tiff_flag: true + cal_splitter: false +criteria: + X_lay: + - -40 + - 40 + Zmax_lay: + - 20 + - 20 + Zmin_lay: + - -20 + - -20 + cn: 0.02 + cnx: 0.02 + cny: 0.02 + corrmin: 33.0 + csumg: 0.02 + eps0: 0.2 +detect_plate: + gvth_1: 40 + gvth_2: 40 + gvth_3: 40 + gvth_4: 40 + max_npix: 400 + max_npix_x: 50 + max_npix_y: 50 + min_npix: 25 + min_npix_x: 5 + min_npix_y: 5 + size_cross: 3 + sum_grey: 100 + tol_dis: 500 +dumbbell: + dumbbell_eps: 3.0 + dumbbell_gradient_descent: 0.05 + dumbbell_niter: 500 + dumbbell_penalty_weight: 1.0 + dumbbell_scale: 25.0 + dumbbell_step: 1 +examine: + Combine_Flag: false + Examine_Flag: false +man_ori: + nr: + - 3 + - 5 + - 72 + - 73 + - 3 + - 5 + - 72 + - 73 + - 1 + - 5 + - 71 + - 73 + - 1 + - 5 + - 71 + - 73 +multi_planes: + n_planes: 3 + plane_name: + - img/calib_a_cam + - img/calib_b_cam + - img/calib_c_cam +orient: + cc: 0 + interf: 0 + k1: 0 + k2: 0 + k3: 0 + p1: 0 + p2: 0 + pnfo: 0 + scale: 0 + shear: 0 + xh: 0 + yh: 0 +pft_version: + Existing_Target: 0 +ptv: + allcam_flag: false + chfield: 0 + hp_flag: true + img_cal: + - cal/cam1.tif + - cal/cam2.tif + - cal/cam3.tif + - cal/cam4.tif + img_name: + - img/cam1.10003 + - img/cam2.10003 + - img/cam3.10003 + - img/cam4.10003 + imx: 1280 + imy: 1024 + mmp_d: 6.0 + mmp_n1: 1.0 + mmp_n2: 1.33 + mmp_n3: 1.46 + pix_x: 0.012 + pix_y: 0.012 + tiff_flag: true + splitter: false +sequence: + base_name: + - img/cam1.%05d + - img/cam2.%05d + - img/cam3.%05d + - img/cam4.%05d + first: 10001 + last: 10004 +shaking: + shaking_first_frame: 10000 + shaking_last_frame: 10004 + shaking_max_num_frames: 5 + shaking_max_num_points: 10 +sortgrid: + radius: 20 +targ_rec: + cr_sz: 2 + disco: 100 + gvthres: + - 9 + - 9 + - 9 + - 11 + nnmax: 500 + nnmin: 4 + nxmax: 100 + nxmin: 2 + nymax: 100 + nymin: 2 + sumg_min: 150 +track: + angle: 110.0 + dacc: 1.0 + dvxmax: 5.5 + dvxmin: -5.5 + dvymax: 5.5 + dvymin: -5.5 + dvzmax: 5.5 + dvzmin: -5.5 + flagNewParticles: false +masking: + mask_flag: false + mask_base_name: '' +unsharp_mask: + flag: false + size: 3 + strength: 1.0 diff --git a/tests/testing_fodder/test_cavity_synthetic/README.md b/tests/testing_fodder/test_cavity_synthetic/README.md new file mode 100644 index 0000000..d1babae --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/README.md @@ -0,0 +1,14 @@ +# Synthetic Cavity Case + +This case is generated deterministically from the geometry of `test_cavity`, but all observations come from known ground truth. + +Contents: + +- `cal/`: working calibrations recovered from synthetic calibration-body targets using `full_calibration`. +- `ground_truth/cal/`: exact camera models used to project the synthetic data. +- `ground_truth/calibration_body_points.txt`: known 3D calibration-body points. +- `calibration_targets/`: synthetic target files for that calibration body. +- `img_orig/`: synthetic particle target files for two frames. +- `res_orig/`: synthetic `rt_is`, `ptv_is`, and `added` files for those frames. +- `ground_truth/particles/`: exact 3D particle coordinates per frame. +- `ground_truth/manifest.json`: generation seed and calibration-recovery errors. diff --git a/tests/testing_fodder/test_cavity_synthetic/cal/calblock.txt b/tests/testing_fodder/test_cavity_synthetic/cal/calblock.txt new file mode 100644 index 0000000..87bfe1f --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/cal/calblock.txt @@ -0,0 +1,48 @@ + 1 -36.000 -16.000 -16.000 + 2 -36.000 -16.000 -5.333 + 3 -36.000 -16.000 5.333 + 4 -36.000 -16.000 16.000 + 5 -36.000 -9.600 -16.000 + 6 -36.000 -9.600 -5.333 + 7 -36.000 -9.600 5.333 + 8 -36.000 -9.600 16.000 + 9 -36.000 -3.200 -16.000 + 10 -36.000 -3.200 -5.333 + 11 -36.000 -3.200 5.333 + 12 -36.000 -3.200 16.000 + 13 -36.000 3.200 -16.000 + 14 -36.000 3.200 -5.333 + 15 -36.000 3.200 5.333 + 16 -36.000 3.200 16.000 + 17 -36.000 9.600 -16.000 + 18 -36.000 9.600 -5.333 + 19 -36.000 9.600 5.333 + 20 -36.000 9.600 16.000 + 21 -36.000 16.000 -16.000 + 22 -36.000 16.000 -5.333 + 23 -36.000 16.000 5.333 + 24 -36.000 16.000 16.000 + 25 -25.714 -16.000 -16.000 + 26 -25.714 -16.000 -5.333 + 27 -25.714 -16.000 5.333 + 28 -25.714 -16.000 16.000 + 29 -25.714 -9.600 -16.000 + 30 -25.714 -9.600 -5.333 + 31 -25.714 -9.600 5.333 + 32 -25.714 -9.600 16.000 + 33 -25.714 -3.200 -16.000 + 34 -25.714 -3.200 -5.333 + 35 -25.714 -3.200 5.333 + 36 -25.714 -3.200 16.000 + 37 -25.714 3.200 -16.000 + 38 -25.714 3.200 -5.333 + 39 -25.714 3.200 5.333 + 40 -25.714 3.200 16.000 + 41 -25.714 9.600 -16.000 + 42 -25.714 9.600 -5.333 + 43 -25.714 9.600 5.333 + 44 -25.714 9.600 16.000 + 45 -25.714 16.000 -16.000 + 46 -25.714 16.000 -5.333 + 47 -25.714 16.000 5.333 + 48 -25.714 16.000 16.000 diff --git a/tests/testing_fodder/test_cavity_synthetic/cal/cam1.tif.addpar b/tests/testing_fodder/test_cavity_synthetic/cal/cam1.tif.addpar new file mode 100644 index 0000000..2916c6a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/cal/cam1.tif.addpar @@ -0,0 +1 @@ +0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 1.00000000 0.00000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/cal/cam1.tif.ori b/tests/testing_fodder/test_cavity_synthetic/cal/cam1.tif.ori new file mode 100644 index 0000000..2700f81 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/cal/cam1.tif.ori @@ -0,0 +1,11 @@ +82.96897532 12.21372353 -569.03076947 +-56.54284096 2.97360259 56.53126707 + +-0.9857736 -0.0171549 0.1672010 +-0.0164254 0.9998486 0.0057447 +-0.1672743 0.0029167 -0.9859061 + +0.0000 0.0000 +70.0000 + +0.000000000000000 0.000000000000000 -125.000000000000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/cal/cam2.tif.addpar b/tests/testing_fodder/test_cavity_synthetic/cal/cam2.tif.addpar new file mode 100644 index 0000000..2916c6a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/cal/cam2.tif.addpar @@ -0,0 +1 @@ +0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 1.00000000 0.00000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/cal/cam2.tif.ori b/tests/testing_fodder/test_cavity_synthetic/cal/cam2.tif.ori new file mode 100644 index 0000000..85cd577 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/cal/cam2.tif.ori @@ -0,0 +1,11 @@ +-128.26443576 26.36339680 -572.94429259 +0.03174008 -2.91375837 -0.02126352 + +-0.9739376 -0.0207125 -0.2258683 +-0.0284175 0.9991180 0.0309147 +0.2250288 0.0365276 -0.9736672 + +0.0000 0.0000 +70.0000 + +0.000000000000000 0.000000000000000 -125.000000000000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/cal/cam3.tif.addpar b/tests/testing_fodder/test_cavity_synthetic/cal/cam3.tif.addpar new file mode 100644 index 0000000..2916c6a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/cal/cam3.tif.addpar @@ -0,0 +1 @@ +0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 1.00000000 0.00000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/cal/cam3.tif.ori b/tests/testing_fodder/test_cavity_synthetic/cal/cam3.tif.ori new file mode 100644 index 0000000..bcf3c16 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/cal/cam3.tif.ori @@ -0,0 +1,11 @@ +-110.35331896 73.30467692 584.86753878 +-0.11168165 -0.19747520 -0.02792006 + +0.9801829 0.0273739 -0.1961942 +-0.0058853 0.9939932 0.1092836 +0.1980072 -0.1059633 0.9744562 + +0.0000 0.0000 +70.0000 + +0.000000000000000 0.000000000000000 125.000000000000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/cal/cam4.tif.addpar b/tests/testing_fodder/test_cavity_synthetic/cal/cam4.tif.addpar new file mode 100644 index 0000000..2916c6a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/cal/cam4.tif.addpar @@ -0,0 +1 @@ +0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 1.00000000 0.00000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/cal/cam4.tif.ori b/tests/testing_fodder/test_cavity_synthetic/cal/cam4.tif.ori new file mode 100644 index 0000000..3a95f4a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/cal/cam4.tif.ori @@ -0,0 +1,11 @@ +126.01664951 68.15570699 573.11474392 +-0.11944273 0.23908264 0.00944663 + +0.9715123 -0.0091778 0.2368115 +-0.0188378 0.9930975 0.1157695 +-0.2362394 -0.1169325 0.9646335 + +0.0000 0.0000 +70.0000 + +0.000000000000000 0.000000000000000 125.000000000000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam1.00001_targets b/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam1.00001_targets new file mode 100644 index 0000000..fc9d449 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam1.00001_targets @@ -0,0 +1,49 @@ +48 + 0 871.4976 574.4239 21 5 5 5000 -1 + 1 968.7627 780.1996 21 5 5 5000 -1 + 2 838.1284 503.3898 21 5 5 5000 -1 + 3 856.3282 643.2051 21 5 5 5000 -1 + 4 841.5711 710.1669 21 5 5 5000 -1 + 5 824.8968 639.3359 21 5 5 5000 -1 + 6 965.2590 571.2681 21 5 5 5000 -1 + 7 842.7487 779.0878 21 5 5 5000 -1 + 8 931.1273 569.2818 21 5 5 5000 -1 + 9 949.1127 638.9936 21 5 5 5000 -1 + 10 980.7167 431.1059 21 5 5 5000 -1 + 11 872.6715 645.2169 21 5 5 5000 -1 + 12 951.4404 776.4492 21 5 5 5000 -1 + 13 837.0107 434.4505 21 5 5 5000 -1 + 14 966.4111 640.9148 21 5 5 5000 -1 + 15 857.5034 713.0480 21 5 5 5000 -1 + 16 946.8500 501.5247 21 5 5 5000 -1 + 17 984.1661 642.8867 21 5 5 5000 -1 + 18 963.0019 431.9533 21 5 5 5000 -1 + 19 875.0614 786.7928 21 5 5 5000 -1 + 20 985.3463 713.4687 21 5 5 5000 -1 + 21 983.0011 572.3006 21 5 5 5000 -1 + 22 840.4085 641.2454 21 5 5 5000 -1 + 23 930.0169 501.4367 21 5 5 5000 -1 + 24 981.8513 501.7078 21 5 5 5000 -1 + 25 947.9733 570.2621 21 5 5 5000 -1 + 26 934.5580 772.7941 21 5 5 5000 -1 + 27 858.6931 782.8897 21 5 5 5000 -1 + 28 950.2685 707.7218 21 5 5 5000 -1 + 29 822.6427 503.2771 21 5 5 5000 -1 + 30 932.2542 637.1213 21 5 5 5000 -1 + 31 823.7620 571.3093 21 5 5 5000 -1 + 32 986.5417 784.0488 21 5 5 5000 -1 + 33 873.8594 716.0057 21 5 5 5000 -1 + 34 839.2610 572.3206 21 5 5 5000 -1 + 35 826.0468 707.3596 21 5 5 5000 -1 + 36 870.3378 503.6243 21 5 5 5000 -1 + 37 964.1225 501.6151 21 5 5 5000 -1 + 38 933.3978 704.9578 21 5 5 5000 -1 + 39 821.5386 435.2366 21 5 5 5000 -1 + 40 928.9232 433.5833 21 5 5 5000 -1 + 41 967.5790 710.5578 21 5 5 5000 -1 + 42 869.1919 432.8156 21 5 5 5000 -1 + 43 854.0215 503.5055 21 5 5 5000 -1 + 44 945.7430 432.7788 21 5 5 5000 -1 + 45 855.1676 573.3584 21 5 5 5000 -1 + 46 827.2123 775.3831 21 5 5 5000 -1 + 47 852.8899 433.6438 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam2.00001_targets b/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam2.00001_targets new file mode 100644 index 0000000..9f839c6 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam2.00001_targets @@ -0,0 +1,49 @@ +48 + 0 948.1816 578.7394 21 5 5 5000 -1 + 1 864.4369 504.9010 21 5 5 5000 -1 + 2 868.0688 641.8695 21 5 5 5000 -1 + 3 961.9179 574.9674 21 5 5 5000 -1 + 4 953.6742 790.3353 21 5 5 5000 -1 + 5 990.0954 635.5653 21 5 5 5000 -1 + 6 993.5576 771.1674 21 5 5 5000 -1 + 7 991.8334 703.3832 21 5 5 5000 -1 + 8 986.5779 499.8175 21 5 5 5000 -1 + 9 951.8562 719.8431 21 5 5 5000 -1 + 10 944.4550 437.4601 21 5 5 5000 -1 + 11 849.6602 507.6854 21 5 5 5000 -1 + 12 958.2539 435.5523 21 5 5 5000 -1 + 13 988.3436 567.7109 21 5 5 5000 -1 + 14 946.3248 508.1229 21 5 5 5000 -1 + 15 967.3140 783.7800 21 5 5 5000 -1 + 16 971.6972 433.6937 21 5 5 5000 -1 + 17 869.8626 710.2959 21 5 5 5000 -1 + 18 856.9919 785.0576 21 5 5 5000 -1 + 19 847.7908 438.2377 21 5 5 5000 -1 + 20 832.5949 440.1710 21 5 5 5000 -1 + 21 836.3833 580.8684 21 5 5 5000 -1 + 22 838.2562 651.1507 21 5 5 5000 -1 + 23 963.7299 644.6104 21 5 5 5000 -1 + 24 884.1750 704.9572 21 5 5 5000 -1 + 25 965.5285 714.2138 21 5 5 5000 -1 + 26 877.0319 434.5177 21 5 5 5000 -1 + 27 882.4120 637.4041 21 5 5 5000 -1 + 28 984.7982 431.8824 21 5 5 5000 -1 + 29 840.1151 721.3919 21 5 5 5000 -1 + 30 973.5059 502.5146 21 5 5 5000 -1 + 31 878.8406 502.1870 21 5 5 5000 -1 + 32 978.8499 708.7289 21 5 5 5000 -1 + 33 885.9230 772.4768 21 5 5 5000 -1 + 34 871.6416 778.6871 21 5 5 5000 -1 + 35 855.1806 715.7723 21 5 5 5000 -1 + 36 866.2603 573.4054 21 5 5 5000 -1 + 37 960.0927 505.2822 21 5 5 5000 -1 + 38 880.6339 569.8149 21 5 5 5000 -1 + 39 950.0253 649.3120 21 5 5 5000 -1 + 40 851.5149 577.0887 21 5 5 5000 -1 + 41 980.6042 777.3928 21 5 5 5000 -1 + 42 853.3550 646.4502 21 5 5 5000 -1 + 43 841.9600 791.5943 21 5 5 5000 -1 + 44 862.5984 436.3539 21 5 5 5000 -1 + 45 977.0822 640.0297 21 5 5 5000 -1 + 46 834.4962 510.5427 21 5 5 5000 -1 + 47 975.3009 571.2924 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam3.00001_targets b/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam3.00001_targets new file mode 100644 index 0000000..cc7b54d --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam3.00001_targets @@ -0,0 +1,49 @@ +48 + 0 378.6979 544.2027 21 5 5 5000 -1 + 1 281.0768 487.8945 21 5 5 5000 -1 + 2 272.4208 614.0183 21 5 5 5000 -1 + 3 274.3605 747.3727 21 5 5 5000 -1 + 4 262.1260 604.7482 21 5 5 5000 -1 + 5 406.0988 834.1688 21 5 5 5000 -1 + 6 367.0961 535.8339 21 5 5 5000 -1 + 7 265.0610 802.1153 21 5 5 5000 -1 + 8 390.5938 552.7836 21 5 5 5000 -1 + 9 392.2514 687.6544 21 5 5 5000 -1 + 10 292.8589 564.6654 21 5 5 5000 -1 + 11 273.3871 680.7657 21 5 5 5000 -1 + 12 380.3742 677.3935 21 5 5 5000 -1 + 13 382.0843 810.0263 21 5 5 5000 -1 + 14 264.0754 736.4600 21 5 5 5000 -1 + 15 381.2250 743.7784 21 5 5 5000 -1 + 16 261.1623 538.6863 21 5 5 5000 -1 + 17 368.7889 667.3850 21 5 5 5000 -1 + 18 283.9384 691.1156 21 5 5 5000 -1 + 19 391.4185 620.2926 21 5 5 5000 -1 + 20 402.7949 561.5846 21 5 5 5000 -1 + 21 285.8799 825.8643 21 5 5 5000 -1 + 22 282.0239 555.7848 21 5 5 5000 -1 + 23 293.8065 633.2751 21 5 5 5000 -1 + 24 403.6092 629.9570 21 5 5 5000 -1 + 25 377.8726 477.3912 21 5 5 5000 -1 + 26 404.4314 698.1768 21 5 5 5000 -1 + 27 370.5168 798.3971 21 5 5 5000 -1 + 28 369.6485 732.9571 21 5 5 5000 -1 + 29 366.2630 469.8495 21 5 5 5000 -1 + 30 275.3408 813.8420 21 5 5 5000 -1 + 31 284.9058 758.5614 21 5 5 5000 -1 + 32 389.7774 485.1246 21 5 5 5000 -1 + 33 393.9415 821.9466 21 5 5 5000 -1 + 34 263.0970 670.6719 21 5 5 5000 -1 + 35 282.9777 623.5243 21 5 5 5000 -1 + 36 294.7605 701.7313 21 5 5 5000 -1 + 37 291.9178 495.8995 21 5 5 5000 -1 + 38 260.2061 472.4834 21 5 5 5000 -1 + 39 405.2613 766.2466 21 5 5 5000 -1 + 40 401.9884 493.0570 21 5 5 5000 -1 + 41 271.4617 547.1278 21 5 5 5000 -1 + 42 379.5318 610.8692 21 5 5 5000 -1 + 43 367.9381 601.6781 21 5 5 5000 -1 + 44 295.7209 770.0364 21 5 5 5000 -1 + 45 393.0924 754.8715 21 5 5 5000 -1 + 46 270.5096 480.0916 21 5 5 5000 -1 + 47 296.6876 838.1932 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam4.00001_targets b/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam4.00001_targets new file mode 100644 index 0000000..a709b5e --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/calibration_targets/cam4.00001_targets @@ -0,0 +1,49 @@ +48 + 0 452.3771 641.8168 21 5 5 5000 -1 + 1 308.5756 728.1646 21 5 5 5000 -1 + 2 386.5472 469.4421 21 5 5 5000 -1 + 3 447.1862 379.0409 21 5 5 5000 -1 + 4 328.7642 650.9696 21 5 5 5000 -1 + 5 389.4610 605.7483 21 5 5 5000 -1 + 6 280.2059 468.8952 21 5 5 5000 -1 + 7 325.8714 518.8007 21 5 5 5000 -1 + 8 390.8869 673.6586 21 5 5 5000 -1 + 9 385.0587 401.0409 21 5 5 5000 -1 + 10 433.6599 718.2991 21 5 5 5000 -1 + 11 351.2782 705.7634 21 5 5 5000 -1 + 12 322.8866 386.0242 21 5 5 5000 -1 + 13 449.8258 510.7250 21 5 5 5000 -1 + 14 324.3906 452.4898 21 5 5 5000 -1 + 15 407.7031 461.0836 21 5 5 5000 -1 + 16 392.2924 741.4106 21 5 5 5000 -1 + 17 413.2227 729.7171 21 5 5 5000 -1 + 18 330.1767 716.8329 21 5 5 5000 -1 + 19 428.3542 452.9245 21 5 5 5000 -1 + 20 305.6135 594.6883 21 5 5 5000 -1 + 21 283.3762 604.6504 21 5 5 5000 -1 + 22 453.6201 707.1476 21 5 5 5000 -1 + 23 286.4594 739.7665 21 5 5 5000 -1 + 24 429.7131 519.4949 21 5 5 5000 -1 + 25 281.8021 536.8540 21 5 5 5000 -1 + 26 304.0990 527.7199 21 5 5 5000 -1 + 27 406.2699 393.5298 21 5 5 5000 -1 + 28 431.0502 585.9122 21 5 5 5000 -1 + 29 426.9734 386.1985 21 5 5 5000 -1 + 30 284.9286 672.2871 21 5 5 5000 -1 + 31 349.9202 640.6814 21 5 5 5000 -1 + 32 410.5051 595.7112 21 5 5 5000 -1 + 33 409.1148 528.4765 21 5 5 5000 -1 + 34 345.7087 444.5746 21 5 5 5000 -1 + 35 278.5874 400.7713 21 5 5 5000 -1 + 36 388.0145 537.6770 21 5 5 5000 -1 + 37 301.0022 393.3095 21 5 5 5000 -1 + 38 344.2578 378.9098 21 5 5 5000 -1 + 39 348.5393 575.4577 21 5 5 5000 -1 + 40 451.1124 576.3435 21 5 5 5000 -1 + 41 411.8743 662.7906 21 5 5 5000 -1 + 42 327.3292 584.9598 21 5 5 5000 -1 + 43 432.3657 652.1794 21 5 5 5000 -1 + 44 307.1056 661.5023 21 5 5 5000 -1 + 45 448.5172 444.9584 21 5 5 5000 -1 + 46 347.1361 510.0895 21 5 5 5000 -1 + 47 302.5620 460.5945 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam1.tif.addpar b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam1.tif.addpar new file mode 100644 index 0000000..2916c6a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam1.tif.addpar @@ -0,0 +1 @@ +0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 1.00000000 0.00000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam1.tif.ori b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam1.tif.ori new file mode 100644 index 0000000..2700f81 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam1.tif.ori @@ -0,0 +1,11 @@ +82.96897532 12.21372353 -569.03076947 +-56.54284096 2.97360259 56.53126707 + +-0.9857736 -0.0171549 0.1672010 +-0.0164254 0.9998486 0.0057447 +-0.1672743 0.0029167 -0.9859061 + +0.0000 0.0000 +70.0000 + +0.000000000000000 0.000000000000000 -125.000000000000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam2.tif.addpar b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam2.tif.addpar new file mode 100644 index 0000000..2916c6a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam2.tif.addpar @@ -0,0 +1 @@ +0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 1.00000000 0.00000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam2.tif.ori b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam2.tif.ori new file mode 100644 index 0000000..85cd577 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam2.tif.ori @@ -0,0 +1,11 @@ +-128.26443576 26.36339680 -572.94429259 +0.03174008 -2.91375837 -0.02126352 + +-0.9739376 -0.0207125 -0.2258683 +-0.0284175 0.9991180 0.0309147 +0.2250288 0.0365276 -0.9736672 + +0.0000 0.0000 +70.0000 + +0.000000000000000 0.000000000000000 -125.000000000000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam3.tif.addpar b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam3.tif.addpar new file mode 100644 index 0000000..2916c6a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam3.tif.addpar @@ -0,0 +1 @@ +0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 1.00000000 0.00000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam3.tif.ori b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam3.tif.ori new file mode 100644 index 0000000..bcf3c16 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam3.tif.ori @@ -0,0 +1,11 @@ +-110.35331896 73.30467692 584.86753878 +-0.11168165 -0.19747520 -0.02792006 + +0.9801829 0.0273739 -0.1961942 +-0.0058853 0.9939932 0.1092836 +0.1980072 -0.1059633 0.9744562 + +0.0000 0.0000 +70.0000 + +0.000000000000000 0.000000000000000 125.000000000000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam4.tif.addpar b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam4.tif.addpar new file mode 100644 index 0000000..2916c6a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam4.tif.addpar @@ -0,0 +1 @@ +0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 1.00000000 0.00000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam4.tif.ori b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam4.tif.ori new file mode 100644 index 0000000..3a95f4a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/cal/cam4.tif.ori @@ -0,0 +1,11 @@ +126.01664951 68.15570699 573.11474392 +-0.11944273 0.23908264 0.00944663 + +0.9715123 -0.0091778 0.2368115 +-0.0188378 0.9930975 0.1157695 +-0.2362394 -0.1169325 0.9646335 + +0.0000 0.0000 +70.0000 + +0.000000000000000 0.000000000000000 125.000000000000000 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/calibration_body_points.txt b/tests/testing_fodder/test_cavity_synthetic/ground_truth/calibration_body_points.txt new file mode 100644 index 0000000..87bfe1f --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/calibration_body_points.txt @@ -0,0 +1,48 @@ + 1 -36.000 -16.000 -16.000 + 2 -36.000 -16.000 -5.333 + 3 -36.000 -16.000 5.333 + 4 -36.000 -16.000 16.000 + 5 -36.000 -9.600 -16.000 + 6 -36.000 -9.600 -5.333 + 7 -36.000 -9.600 5.333 + 8 -36.000 -9.600 16.000 + 9 -36.000 -3.200 -16.000 + 10 -36.000 -3.200 -5.333 + 11 -36.000 -3.200 5.333 + 12 -36.000 -3.200 16.000 + 13 -36.000 3.200 -16.000 + 14 -36.000 3.200 -5.333 + 15 -36.000 3.200 5.333 + 16 -36.000 3.200 16.000 + 17 -36.000 9.600 -16.000 + 18 -36.000 9.600 -5.333 + 19 -36.000 9.600 5.333 + 20 -36.000 9.600 16.000 + 21 -36.000 16.000 -16.000 + 22 -36.000 16.000 -5.333 + 23 -36.000 16.000 5.333 + 24 -36.000 16.000 16.000 + 25 -25.714 -16.000 -16.000 + 26 -25.714 -16.000 -5.333 + 27 -25.714 -16.000 5.333 + 28 -25.714 -16.000 16.000 + 29 -25.714 -9.600 -16.000 + 30 -25.714 -9.600 -5.333 + 31 -25.714 -9.600 5.333 + 32 -25.714 -9.600 16.000 + 33 -25.714 -3.200 -16.000 + 34 -25.714 -3.200 -5.333 + 35 -25.714 -3.200 5.333 + 36 -25.714 -3.200 16.000 + 37 -25.714 3.200 -16.000 + 38 -25.714 3.200 -5.333 + 39 -25.714 3.200 5.333 + 40 -25.714 3.200 16.000 + 41 -25.714 9.600 -16.000 + 42 -25.714 9.600 -5.333 + 43 -25.714 9.600 5.333 + 44 -25.714 9.600 16.000 + 45 -25.714 16.000 -16.000 + 46 -25.714 16.000 -5.333 + 47 -25.714 16.000 5.333 + 48 -25.714 16.000 16.000 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/manifest.json b/tests/testing_fodder/test_cavity_synthetic/ground_truth/manifest.json new file mode 100644 index 0000000..7822255 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/manifest.json @@ -0,0 +1,18 @@ +{ + "seed": 20260306, + "num_calibration_points": 48, + "num_frames": 2, + "particles_per_frame": 96, + "calibration_position_errors": [ + 6.159861524155277e-13, + 1.608167152903132e-13, + 3.024609420349515e-13, + 5.895364129370234e-13 + ], + "calibration_angle_errors": [ + 8.881784197001252e-16, + 5.834553579603124e-16, + 5.215837820588841e-16, + 1.0547703616262308e-15 + ] +} diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/particles/frame_10001.txt b/tests/testing_fodder/test_cavity_synthetic/ground_truth/particles/frame_10001.txt new file mode 100644 index 0000000..ae9c38f --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/particles/frame_10001.txt @@ -0,0 +1,97 @@ +x y z +-9.004835 2.927509 -16.750501 +-7.649701 -9.018886 -6.178007 +19.349357 -17.385223 6.662090 +-32.732274 -5.765123 14.241608 +18.427344 10.835289 -10.594021 +36.951029 -5.346760 -1.165943 +-19.621321 -4.575388 4.475528 +8.585080 -1.119442 -11.452552 +30.312216 4.566054 -10.316668 +12.794282 -2.151459 -15.999535 +11.809191 -14.957691 3.005617 +-7.655285 -6.144198 0.624520 +-19.068770 3.149961 7.943458 +28.939217 11.428574 -1.552253 +10.937000 10.566049 1.146079 +-0.768273 5.705635 -4.286561 +15.322519 2.297270 -5.129089 +7.538092 0.333324 -12.397181 +10.428159 -14.451338 12.244161 +32.601978 -7.993106 4.920415 +-27.517787 8.319280 15.709022 +-7.154215 -15.115500 -3.731479 +-31.368099 7.717670 -17.866159 +22.598320 -8.817379 12.636423 +36.231206 -4.618367 -8.056307 +-18.729931 2.424087 6.254237 +36.209999 -12.163057 15.531557 +-17.262215 -4.645539 10.520935 +6.419334 -0.959543 16.232084 +-11.470633 -0.255429 -1.876678 +12.559839 1.016427 1.206560 +-28.187416 17.609123 17.732456 +18.252515 8.257974 -16.527974 +-31.636808 -0.820526 5.061469 +6.812190 -0.394813 -14.351415 +-35.520951 -12.285168 -12.965790 +-29.442653 -4.617777 -4.978286 +-29.122712 -17.082813 17.548087 +32.182994 -2.536154 4.209082 +-0.282648 13.600975 -14.131718 +13.390442 -8.510309 12.465171 +-34.501915 8.109042 -16.661865 +33.677706 4.877333 6.326409 +-28.002603 -2.311969 10.166887 +3.042524 -7.115969 -7.297907 +-37.930244 -1.532696 1.885356 +4.302062 10.273079 4.915763 +-0.892994 16.166421 12.892843 +13.431346 17.192127 13.881468 +13.840243 13.303489 10.878883 +34.570359 -8.232063 12.521135 +-15.390811 -8.777840 12.243990 +20.291769 2.781038 17.071646 +-3.226106 -2.078560 -13.231801 +21.466940 -15.699072 2.734152 +-32.602179 14.495653 -0.909771 +-18.858722 -1.436360 -8.804947 +24.958894 6.645643 16.307594 +2.695383 -17.606863 -11.001925 +-3.660997 15.604463 -2.850515 +15.987679 9.649215 -12.308764 +-10.749616 -6.337697 17.834690 +-31.505738 15.428744 -6.667206 +-26.744478 -14.993761 2.014296 +13.082148 -13.197912 10.331149 +17.057399 17.978989 7.922039 +-6.345041 -10.606396 -8.910120 +18.102808 6.131316 -4.288700 +-21.812015 9.252024 -10.632501 +-37.248133 16.716369 -6.914447 +22.396524 -6.294372 -3.303686 +15.227467 16.499758 -0.489815 +-36.240958 -0.053516 9.971913 +-0.212349 16.489640 -11.296255 +-7.593310 -13.629282 1.593718 +6.131071 -2.066680 -11.936591 +-8.136932 1.289874 1.422768 +33.396159 -4.391515 -13.318549 +-33.770236 -6.056359 -1.743087 +10.085961 12.123055 8.622585 +-2.668577 13.010837 7.796477 +-27.546173 -15.981290 -5.856841 +20.229369 11.768749 16.963026 +-36.907787 2.306576 -8.385496 +19.405988 11.197590 3.730228 +20.652808 4.800233 15.667643 +3.467258 8.710355 16.456719 +-5.559737 -9.969548 -11.017521 +2.171960 -7.275603 -2.604426 +22.965897 -5.975890 -5.598953 +-5.378142 9.603184 -14.608550 +-12.524768 13.787659 17.565771 +18.813132 -5.988243 12.042472 +25.931019 -9.206367 6.292844 +-30.575895 8.928450 -5.360647 +-20.195398 -7.880231 12.880970 diff --git a/tests/testing_fodder/test_cavity_synthetic/ground_truth/particles/frame_10002.txt b/tests/testing_fodder/test_cavity_synthetic/ground_truth/particles/frame_10002.txt new file mode 100644 index 0000000..83be55f --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/ground_truth/particles/frame_10002.txt @@ -0,0 +1,97 @@ +x y z +33.104389 -10.872280 -3.755599 +-1.276461 -5.540703 17.832293 +-25.617460 -7.420437 -8.547085 +-32.062522 9.466744 13.178667 +21.836250 7.907779 10.121107 +21.179789 8.109552 9.814606 +31.385881 4.361555 3.674192 +-22.379271 -14.704716 -11.036177 +-23.189485 4.767033 2.928617 +13.896577 5.172793 -12.451611 +3.079689 8.252285 2.371386 +-17.687921 -11.045332 -4.754318 +-4.776302 6.331159 6.150901 +34.351268 7.247273 15.833020 +16.039927 -2.150842 -8.151144 +37.414321 4.708428 -5.019521 +32.666564 -3.417784 11.893482 +-9.516009 -0.494849 -16.737998 +-36.749104 -12.132857 16.428506 +14.457588 7.266229 14.867945 +30.353869 -16.077797 -17.141300 +16.916110 15.004697 4.988611 +-17.859179 11.878296 -2.318000 +-32.028579 2.280382 10.542283 +-25.811409 -7.345323 2.584927 +1.803145 9.787628 -2.689545 +-23.763177 -0.429052 5.081737 +-18.961480 12.670967 17.110970 +-37.283036 -14.578460 -16.903897 +-6.163134 14.977071 10.381251 +34.833451 -11.171844 -16.317059 +-23.519053 -16.019391 0.962569 +31.478736 -10.411195 16.816865 +-10.907518 17.044802 -11.058911 +2.334175 -15.093639 8.558908 +-24.317235 3.802922 1.280903 +7.175480 -3.602055 15.419935 +-6.726319 11.591488 -15.331991 +-10.643992 0.961783 1.840879 +19.536722 4.372938 5.146245 +11.824139 11.676318 6.375628 +-14.052875 -1.273047 10.673917 +26.943971 6.258145 -14.174347 +-29.036769 15.651018 8.528567 +4.996032 11.961674 -15.893777 +-14.717247 -9.354877 3.118251 +-19.028373 -16.906327 4.682552 +12.763865 11.731536 -17.439620 +22.926399 -3.398060 -17.099254 +34.295453 14.015082 5.315808 +24.851832 -16.628967 -8.383341 +29.023279 7.452149 -12.736070 +-7.359767 -7.519720 5.658102 +-20.519402 -0.269845 -12.585480 +-14.361438 3.189792 13.535694 +-15.745571 -15.728232 -2.461477 +-26.588969 13.215854 -3.715778 +-10.501724 15.680141 -6.367624 +-14.989470 8.752016 1.127607 +26.290043 -1.299356 1.942553 +-11.953441 -3.574083 -1.629490 +23.334035 2.845580 -11.208691 +14.252242 -15.959440 14.148256 +33.551702 13.795561 -1.122564 +8.409184 -14.002681 -0.363069 +7.369943 -6.987929 14.637666 +22.415552 -2.327246 17.901142 +26.404882 -3.567311 5.110050 +-3.493007 -2.379322 6.966128 +-4.118330 9.997989 5.874630 +-1.303455 3.191303 -15.045083 +14.501357 9.043564 -14.158811 +-24.967128 2.443493 7.741993 +-24.209033 13.317319 13.212777 +3.280612 -7.577176 14.078099 +-16.630896 -1.082430 -11.616092 +33.912991 -6.317229 -9.377707 +37.658071 -4.932050 -12.498551 +-23.594928 -8.917137 3.516823 +24.985716 -9.773017 -14.055044 +12.404520 -5.279279 6.966119 +-36.113479 12.051428 -6.693231 +25.487721 -15.211498 -14.045956 +-32.639433 8.783989 0.120439 +-14.524117 16.278829 -7.333158 +26.708617 -17.203784 5.154325 +-28.325314 -16.516442 -15.796798 +13.572443 -16.191496 13.058760 +-11.425392 -2.987008 16.092634 +-35.698364 5.002007 -7.558434 +30.842816 -10.882623 -7.265986 +27.007981 5.269136 12.363942 +26.510122 9.554547 8.970011 +5.368834 -1.445615 2.648482 +-29.545642 15.436410 -9.097935 +-18.287969 2.209223 -16.054032 diff --git a/tests/testing_fodder/test_cavity_synthetic/img_orig/cam1.10001_targets b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam1.10001_targets new file mode 100644 index 0000000..354bcbc --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam1.10001_targets @@ -0,0 +1,97 @@ +96 + 0 375.8344 420.1521 21 5 5 5000 -1 + 1 646.2484 507.4573 21 5 5 5000 -1 + 2 611.8107 441.8715 21 5 5 5000 -1 + 3 248.1574 570.3210 21 5 5 5000 -1 + 4 978.9761 423.9222 21 5 5 5000 -1 + 5 409.6471 591.7594 21 5 5 5000 -1 + 6 517.8805 501.6333 21 5 5 5000 -1 + 7 681.3554 460.9577 21 5 5 5000 -1 + 8 451.2594 482.5749 21 5 5 5000 -1 + 9 697.7671 613.5248 21 5 5 5000 -1 + 10 422.1037 757.0054 21 5 5 5000 -1 + 11 548.2702 693.2840 21 5 5 5000 -1 + 12 772.2233 691.2423 21 5 5 5000 -1 + 13 860.8439 785.5578 21 5 5 5000 -1 + 14 182.2604 673.8492 21 5 5 5000 -1 + 15 433.9783 604.3410 21 5 5 5000 -1 + 16 169.5545 680.3567 21 5 5 5000 -1 + 17 513.6286 620.9917 21 5 5 5000 -1 + 18 689.9959 580.9569 21 5 5 5000 -1 + 19 657.5025 597.0278 21 5 5 5000 -1 + 20 518.6506 639.2304 21 5 5 5000 -1 + 21 906.3746 509.8786 21 5 5 5000 -1 + 22 285.3244 544.1997 21 5 5 5000 -1 + 23 653.1839 729.8943 21 5 5 5000 -1 + 24 937.3094 671.8229 21 5 5 5000 -1 + 25 588.8598 463.9373 21 5 5 5000 -1 + 26 654.4495 678.2827 21 5 5 5000 -1 + 27 514.5216 518.1966 21 5 5 5000 -1 + 28 819.9923 508.1427 21 5 5 5000 -1 + 29 966.9730 518.3309 21 5 5 5000 -1 + 30 408.5211 470.4200 21 5 5 5000 -1 + 31 844.5909 417.8207 21 5 5 5000 -1 + 32 563.5904 437.1854 21 5 5 5000 -1 + 33 879.2786 782.3961 21 5 5 5000 -1 + 34 975.6279 742.3689 21 5 5 5000 -1 + 35 647.2612 723.5755 21 5 5 5000 -1 + 36 743.8821 657.6862 21 5 5 5000 -1 + 37 448.9352 769.2966 21 5 5 5000 -1 + 38 403.7129 435.3353 21 5 5 5000 -1 + 39 935.1017 523.3286 21 5 5 5000 -1 + 40 449.3900 642.5092 21 5 5 5000 -1 + 41 217.6933 671.7899 21 5 5 5000 -1 + 42 858.3714 769.1733 21 5 5 5000 -1 + 43 358.5581 805.4848 21 5 5 5000 -1 + 44 589.0031 470.6977 21 5 5 5000 -1 + 45 332.0087 687.9430 21 5 5 5000 -1 + 46 217.9418 647.1200 21 5 5 5000 -1 + 47 338.7520 789.2533 21 5 5 5000 -1 + 48 917.2618 438.9172 21 5 5 5000 -1 + 49 787.2889 626.0809 21 5 5 5000 -1 + 50 485.0699 622.2580 21 5 5 5000 -1 + 51 415.7646 705.7300 21 5 5 5000 -1 + 52 327.5697 685.0196 21 5 5 5000 -1 + 53 387.0214 527.0608 21 5 5 5000 -1 + 54 552.3504 693.7950 21 5 5 5000 -1 + 55 187.2712 708.0098 21 5 5 5000 -1 + 56 583.9787 550.8124 21 5 5 5000 -1 + 57 896.0271 657.7583 21 5 5 5000 -1 + 58 213.9278 706.8086 21 5 5 5000 -1 + 59 316.7616 711.4221 21 5 5 5000 -1 + 60 763.9183 582.1183 21 5 5 5000 -1 + 61 654.0924 759.3575 21 5 5 5000 -1 + 62 332.6202 563.4162 21 5 5 5000 -1 + 63 842.1657 516.5745 21 5 5 5000 -1 + 64 449.9840 499.5648 21 5 5 5000 -1 + 65 408.1494 510.8354 21 5 5 5000 -1 + 66 902.9993 614.4171 21 5 5 5000 -1 + 67 920.0565 449.1931 21 5 5 5000 -1 + 68 974.8591 621.2660 21 5 5 5000 -1 + 69 409.2044 428.5535 21 5 5 5000 -1 + 70 355.1761 494.3036 21 5 5 5000 -1 + 71 558.3701 810.3811 21 5 5 5000 -1 + 72 255.1604 493.3458 21 5 5 5000 -1 + 73 980.1604 581.1237 21 5 5 5000 -1 + 74 443.4447 778.3303 21 5 5 5000 -1 + 75 943.6367 604.6330 21 5 5 5000 -1 + 76 377.5283 550.0026 21 5 5 5000 -1 + 77 857.3801 630.2844 21 5 5 5000 -1 + 78 656.3258 777.5241 21 5 5 5000 -1 + 79 666.0868 675.6030 21 5 5 5000 -1 + 80 168.1267 750.1217 21 5 5 5000 -1 + 81 198.6544 566.0490 21 5 5 5000 -1 + 82 335.6563 584.9485 21 5 5 5000 -1 + 83 357.5730 680.0513 21 5 5 5000 -1 + 84 379.1089 498.1361 21 5 5 5000 -1 + 85 503.2387 612.9579 21 5 5 5000 -1 + 86 777.3090 657.6464 21 5 5 5000 -1 + 87 285.8202 718.0797 21 5 5 5000 -1 + 88 663.6613 711.3493 21 5 5 5000 -1 + 89 765.0167 574.0378 21 5 5 5000 -1 + 90 334.7219 488.2299 21 5 5 5000 -1 + 91 623.0618 637.3091 21 5 5 5000 -1 + 92 901.3486 665.2433 21 5 5 5000 -1 + 93 584.4045 432.0303 21 5 5 5000 -1 + 94 490.9732 629.1366 21 5 5 5000 -1 + 95 722.5358 702.0648 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/img_orig/cam1.10002_targets b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam1.10002_targets new file mode 100644 index 0000000..cc77005 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam1.10002_targets @@ -0,0 +1,97 @@ +96 + 0 860.8597 690.0645 21 5 5 5000 -1 + 1 242.8033 742.0198 21 5 5 5000 -1 + 2 645.3623 691.9746 21 5 5 5000 -1 + 3 313.1509 730.1134 21 5 5 5000 -1 + 4 830.1218 566.5592 21 5 5 5000 -1 + 5 674.8081 640.1798 21 5 5 5000 -1 + 6 767.1505 623.0664 21 5 5 5000 -1 + 7 1001.6497 768.2951 21 5 5 5000 -1 + 8 184.5261 539.7623 21 5 5 5000 -1 + 9 326.8991 588.1724 21 5 5 5000 -1 + 10 331.3372 528.0376 21 5 5 5000 -1 + 11 729.9758 514.5280 21 5 5 5000 -1 + 12 478.3433 650.9049 21 5 5 5000 -1 + 13 622.8422 449.0050 21 5 5 5000 -1 + 14 380.4455 452.3846 21 5 5 5000 -1 + 15 846.1979 686.5036 21 5 5 5000 -1 + 16 790.6877 587.0177 21 5 5 5000 -1 + 17 225.8963 571.3950 21 5 5 5000 -1 + 18 565.8083 669.2133 21 5 5 5000 -1 + 19 218.0841 729.7357 21 5 5 5000 -1 + 20 534.1495 523.5112 21 5 5 5000 -1 + 21 700.6702 424.1040 21 5 5 5000 -1 + 22 266.7111 560.0047 21 5 5 5000 -1 + 23 214.9928 741.2451 21 5 5 5000 -1 + 24 209.4115 692.5131 21 5 5 5000 -1 + 25 426.2967 517.3027 21 5 5 5000 -1 + 26 772.7983 790.8386 21 5 5 5000 -1 + 27 533.1575 483.1537 21 5 5 5000 -1 + 28 337.5363 659.0470 21 5 5 5000 -1 + 29 614.6052 542.7083 21 5 5 5000 -1 + 30 553.3884 506.6382 21 5 5 5000 -1 + 31 603.2179 579.3671 21 5 5 5000 -1 + 32 256.8899 803.6003 21 5 5 5000 -1 + 33 431.1311 671.9949 21 5 5 5000 -1 + 34 825.9259 781.4130 21 5 5 5000 -1 + 35 831.1253 771.6404 21 5 5 5000 -1 + 36 707.8076 573.8016 21 5 5 5000 -1 + 37 661.5737 484.9278 21 5 5 5000 -1 + 38 205.0415 749.2965 21 5 5 5000 -1 + 39 207.2717 655.3969 21 5 5 5000 -1 + 40 399.2135 535.8331 21 5 5 5000 -1 + 41 749.9385 472.0116 21 5 5 5000 -1 + 42 601.4362 637.0111 21 5 5 5000 -1 + 43 900.0848 438.9133 21 5 5 5000 -1 + 44 690.3428 439.4448 21 5 5 5000 -1 + 45 821.7596 703.8697 21 5 5 5000 -1 + 46 405.6379 641.6755 21 5 5 5000 -1 + 47 898.1774 580.5424 21 5 5 5000 -1 + 48 406.2375 785.8762 21 5 5 5000 -1 + 49 764.8775 480.0461 21 5 5 5000 -1 + 50 965.2565 551.8385 21 5 5 5000 -1 + 51 477.8669 687.4639 21 5 5 5000 -1 + 52 703.2958 649.7412 21 5 5 5000 -1 + 53 448.6984 487.1080 21 5 5 5000 -1 + 54 274.0462 513.3715 21 5 5 5000 -1 + 55 522.1162 692.9344 21 5 5 5000 -1 + 56 940.9452 731.4275 21 5 5 5000 -1 + 57 279.8162 806.1724 21 5 5 5000 -1 + 58 414.7748 788.6488 21 5 5 5000 -1 + 59 510.9838 629.6314 21 5 5 5000 -1 + 60 770.7013 730.5755 21 5 5 5000 -1 + 61 727.5427 710.6933 21 5 5 5000 -1 + 62 308.7316 791.1004 21 5 5 5000 -1 + 63 539.5054 775.3177 21 5 5 5000 -1 + 64 169.2759 678.6232 21 5 5 5000 -1 + 65 683.8893 599.8833 21 5 5 5000 -1 + 66 313.1566 640.2398 21 5 5 5000 -1 + 67 903.2093 791.7420 21 5 5 5000 -1 + 68 288.5327 551.0993 21 5 5 5000 -1 + 69 432.2212 560.3685 21 5 5 5000 -1 + 70 434.8783 487.6141 21 5 5 5000 -1 + 71 311.3139 804.6544 21 5 5 5000 -1 + 72 323.9571 530.4601 21 5 5 5000 -1 + 73 696.3493 619.0681 21 5 5 5000 -1 + 74 263.9241 537.9141 21 5 5 5000 -1 + 75 967.1390 474.8477 21 5 5 5000 -1 + 76 283.9642 632.6596 21 5 5 5000 -1 + 77 810.3055 463.7940 21 5 5 5000 -1 + 78 919.5498 511.0685 21 5 5 5000 -1 + 79 353.8071 568.8262 21 5 5 5000 -1 + 80 709.1049 622.1943 21 5 5 5000 -1 + 81 827.9544 580.3177 21 5 5 5000 -1 + 82 815.7250 556.1823 21 5 5 5000 -1 + 83 203.7163 468.1730 21 5 5 5000 -1 + 84 165.1945 569.7486 21 5 5 5000 -1 + 85 893.3761 503.5065 21 5 5 5000 -1 + 86 607.3129 503.1196 21 5 5 5000 -1 + 87 860.3538 463.9992 21 5 5 5000 -1 + 88 483.4432 768.2797 21 5 5 5000 -1 + 89 810.5944 613.3286 21 5 5 5000 -1 + 90 280.6155 657.0733 21 5 5 5000 -1 + 91 747.3958 781.5821 21 5 5 5000 -1 + 92 819.6249 611.8175 21 5 5 5000 -1 + 93 867.4060 437.8991 21 5 5 5000 -1 + 94 734.9628 432.2598 21 5 5 5000 -1 + 95 190.8726 466.1689 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/img_orig/cam2.10001_targets b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam2.10001_targets new file mode 100644 index 0000000..30a4236 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam2.10001_targets @@ -0,0 +1,97 @@ +96 + 0 618.0741 447.6873 21 5 5 5000 -1 + 1 461.3457 473.6560 21 5 5 5000 -1 + 2 794.1701 582.8501 21 5 5 5000 -1 + 3 787.0019 656.6384 21 5 5 5000 -1 + 4 927.9882 451.9688 21 5 5 5000 -1 + 5 877.8434 769.8525 21 5 5 5000 -1 + 6 992.8946 623.1884 21 5 5 5000 -1 + 7 479.5620 632.7341 21 5 5 5000 -1 + 8 618.3607 514.8427 21 5 5 5000 -1 + 9 655.4889 587.5935 21 5 5 5000 -1 + 10 418.7260 676.0386 21 5 5 5000 -1 + 11 397.1375 518.7701 21 5 5 5000 -1 + 12 893.8202 530.6038 21 5 5 5000 -1 + 13 475.4788 701.1495 21 5 5 5000 -1 + 14 460.6774 605.7070 21 5 5 5000 -1 + 15 419.9942 595.0488 21 5 5 5000 -1 + 16 549.9201 504.7039 21 5 5 5000 -1 + 17 578.4861 517.9962 21 5 5 5000 -1 + 18 567.2678 440.6961 21 5 5 5000 -1 + 19 474.5412 504.1939 21 5 5 5000 -1 + 20 476.0205 751.3415 21 5 5 5000 -1 + 21 644.0292 731.5034 21 5 5 5000 -1 + 22 588.3394 554.8647 21 5 5 5000 -1 + 23 200.6881 673.6497 21 5 5 5000 -1 + 24 253.9263 575.8776 21 5 5 5000 -1 + 25 951.2766 747.7179 21 5 5 5000 -1 + 26 469.8182 432.3301 21 5 5 5000 -1 + 27 821.7790 689.1858 21 5 5 5000 -1 + 28 336.4629 713.6641 21 5 5 5000 -1 + 29 875.9144 784.8495 21 5 5 5000 -1 + 30 218.6113 673.1322 21 5 5 5000 -1 + 31 406.5789 798.0531 21 5 5 5000 -1 + 32 929.4726 525.1296 21 5 5 5000 -1 + 33 374.5235 506.0889 21 5 5 5000 -1 + 34 954.4599 664.0590 21 5 5 5000 -1 + 35 488.3977 617.4253 21 5 5 5000 -1 + 36 632.1976 725.9010 21 5 5 5000 -1 + 37 382.3029 705.8540 21 5 5 5000 -1 + 38 506.8627 763.1257 21 5 5 5000 -1 + 39 743.0110 460.8084 21 5 5 5000 -1 + 40 408.2172 489.9704 21 5 5 5000 -1 + 41 706.4791 615.9647 21 5 5 5000 -1 + 42 626.0396 473.4660 21 5 5 5000 -1 + 43 410.5473 583.2721 21 5 5 5000 -1 + 44 365.5632 535.4209 21 5 5 5000 -1 + 45 550.7381 619.5980 21 5 5 5000 -1 + 46 208.1010 678.4508 21 5 5 5000 -1 + 47 496.1810 485.6062 21 5 5 5000 -1 + 48 984.4418 604.3545 21 5 5 5000 -1 + 49 929.6213 615.3979 21 5 5 5000 -1 + 50 253.5656 567.5984 21 5 5 5000 -1 + 51 800.2336 574.3438 21 5 5 5000 -1 + 52 901.7834 513.9099 21 5 5 5000 -1 + 53 968.0020 585.6998 21 5 5 5000 -1 + 54 547.2336 809.3676 21 5 5 5000 -1 + 55 675.2023 757.9408 21 5 5 5000 -1 + 56 425.2402 442.6313 21 5 5 5000 -1 + 57 774.5333 630.0685 21 5 5 5000 -1 + 58 504.7245 642.6918 21 5 5 5000 -1 + 59 377.0038 782.9868 21 5 5 5000 -1 + 60 908.3522 443.3606 21 5 5 5000 -1 + 61 563.7229 472.4733 21 5 5 5000 -1 + 62 261.2080 701.7261 21 5 5 5000 -1 + 63 676.1558 598.6884 21 5 5 5000 -1 + 64 265.4675 702.5067 21 5 5 5000 -1 + 65 898.5433 629.9684 21 5 5 5000 -1 + 66 904.6987 416.7035 21 5 5 5000 -1 + 67 360.9775 544.2168 21 5 5 5000 -1 + 68 601.3971 641.8319 21 5 5 5000 -1 + 69 252.0067 741.1245 21 5 5 5000 -1 + 70 894.0836 661.1577 21 5 5 5000 -1 + 71 427.1133 646.7213 21 5 5 5000 -1 + 72 671.9737 678.5201 21 5 5 5000 -1 + 73 564.2231 693.7888 21 5 5 5000 -1 + 74 342.4155 684.7072 21 5 5 5000 -1 + 75 266.8087 645.6198 21 5 5 5000 -1 + 76 392.0076 498.8912 21 5 5 5000 -1 + 77 945.1123 674.3233 21 5 5 5000 -1 + 78 969.4552 427.7753 21 5 5 5000 -1 + 79 546.9427 694.4386 21 5 5 5000 -1 + 80 492.5124 625.8281 21 5 5 5000 -1 + 81 403.5543 562.5852 21 5 5 5000 -1 + 82 662.3829 776.9546 21 5 5 5000 -1 + 83 661.8655 712.6434 21 5 5 5000 -1 + 84 897.5884 515.3627 21 5 5 5000 -1 + 85 391.3936 554.5739 21 5 5 5000 -1 + 86 476.4418 773.7865 21 5 5 5000 -1 + 87 771.2045 699.6710 21 5 5 5000 -1 + 88 800.3488 513.8674 21 5 5 5000 -1 + 89 353.1047 686.9816 21 5 5 5000 -1 + 90 283.8743 499.8924 21 5 5 5000 -1 + 91 730.8034 672.0422 21 5 5 5000 -1 + 92 421.7273 425.9355 21 5 5 5000 -1 + 93 802.9814 657.9943 21 5 5 5000 -1 + 94 615.0185 439.6153 21 5 5 5000 -1 + 95 923.8115 782.6436 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/img_orig/cam2.10002_targets b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam2.10002_targets new file mode 100644 index 0000000..da90835 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam2.10002_targets @@ -0,0 +1,97 @@ +96 + 0 769.7391 484.1639 21 5 5 5000 -1 + 1 846.3159 612.7942 21 5 5 5000 -1 + 2 328.8022 798.1662 21 5 5 5000 -1 + 3 680.7273 431.7176 21 5 5 5000 -1 + 4 835.7399 558.1289 21 5 5 5000 -1 + 5 842.7132 781.7787 21 5 5 5000 -1 + 6 883.9474 443.9277 21 5 5 5000 -1 + 7 638.8456 505.5406 21 5 5 5000 -1 + 8 633.3983 665.6366 21 5 5 5000 -1 + 9 301.3729 721.7104 21 5 5 5000 -1 + 10 319.1517 662.5398 21 5 5 5000 -1 + 11 505.1882 492.1243 21 5 5 5000 -1 + 12 812.9201 774.8767 21 5 5 5000 -1 + 13 870.7146 796.7865 21 5 5 5000 -1 + 14 665.8212 451.3612 21 5 5 5000 -1 + 15 243.6297 737.3371 21 5 5 5000 -1 + 16 860.0949 467.7653 21 5 5 5000 -1 + 17 391.9735 636.2890 21 5 5 5000 -1 + 18 1000.8311 730.0702 21 5 5 5000 -1 + 19 866.3903 687.5156 21 5 5 5000 -1 + 20 755.1090 781.6284 21 5 5 5000 -1 + 21 931.2854 513.2801 21 5 5 5000 -1 + 22 562.9371 511.3371 21 5 5 5000 -1 + 23 333.0473 560.1979 21 5 5 5000 -1 + 24 259.6545 738.9506 21 5 5 5000 -1 + 25 684.2632 445.8580 21 5 5 5000 -1 + 26 538.3248 630.0578 21 5 5 5000 -1 + 27 712.9872 651.1903 21 5 5 5000 -1 + 28 409.2960 525.3205 21 5 5 5000 -1 + 29 786.3470 618.6491 21 5 5 5000 -1 + 30 271.7728 573.3383 21 5 5 5000 -1 + 31 542.3616 647.6969 21 5 5 5000 -1 + 32 647.2434 544.1877 21 5 5 5000 -1 + 33 199.2495 747.4881 21 5 5 5000 -1 + 34 558.7706 526.7348 21 5 5 5000 -1 + 35 800.6126 789.5737 21 5 5 5000 -1 + 36 734.1857 637.6042 21 5 5 5000 -1 + 37 939.8595 580.1340 21 5 5 5000 -1 + 38 476.8271 781.1508 21 5 5 5000 -1 + 39 327.6722 655.1411 21 5 5 5000 -1 + 40 902.0079 438.7968 21 5 5 5000 -1 + 41 408.3486 643.9726 21 5 5 5000 -1 + 42 758.9025 572.9825 21 5 5 5000 -1 + 43 474.3809 491.3810 21 5 5 5000 -1 + 44 380.2929 532.3563 21 5 5 5000 -1 + 45 245.4703 799.9631 21 5 5 5000 -1 + 46 844.7702 704.3536 21 5 5 5000 -1 + 47 464.2374 535.9334 21 5 5 5000 -1 + 48 176.4888 679.3093 21 5 5 5000 -1 + 49 420.5701 496.6241 21 5 5 5000 -1 + 50 261.1077 545.4145 21 5 5 5000 -1 + 51 750.9400 710.4175 21 5 5 5000 -1 + 52 809.3001 471.1188 21 5 5 5000 -1 + 53 265.4175 540.1946 21 5 5 5000 -1 + 54 637.7322 636.5080 21 5 5 5000 -1 + 55 395.3298 570.2385 21 5 5 5000 -1 + 56 324.8764 593.1372 21 5 5 5000 -1 + 57 420.1380 566.5084 21 5 5 5000 -1 + 58 745.7189 517.5005 21 5 5 5000 -1 + 59 966.1119 775.0092 21 5 5 5000 -1 + 60 955.2639 556.2102 21 5 5 5000 -1 + 61 540.1592 683.5670 21 5 5 5000 -1 + 62 752.7766 621.2785 21 5 5 5000 -1 + 63 662.0418 624.8679 21 5 5 5000 -1 + 64 958.8049 478.8634 21 5 5 5000 -1 + 65 242.5592 472.3497 21 5 5 5000 -1 + 66 192.2297 573.8294 21 5 5 5000 -1 + 67 305.8502 729.7687 21 5 5 5000 -1 + 68 505.4023 765.2125 21 5 5 5000 -1 + 69 630.9036 492.9898 21 5 5 5000 -1 + 70 581.0601 689.1271 21 5 5 5000 -1 + 71 576.1024 585.6281 21 5 5 5000 -1 + 72 941.9797 502.9645 21 5 5 5000 -1 + 73 746.3841 627.7416 21 5 5 5000 -1 + 74 471.7310 778.1799 21 5 5 5000 -1 + 75 418.6106 457.7037 21 5 5 5000 -1 + 76 861.9663 580.9197 21 5 5 5000 -1 + 77 756.3994 593.3718 21 5 5 5000 -1 + 78 330.2214 516.2498 21 5 5 5000 -1 + 79 583.7373 770.8770 21 5 5 5000 -1 + 80 277.7047 651.8552 21 5 5 5000 -1 + 81 845.6356 568.6782 21 5 5 5000 -1 + 82 321.7933 632.4274 21 5 5 5000 -1 + 83 770.9647 732.2370 21 5 5 5000 -1 + 84 858.2886 463.8604 21 5 5 5000 -1 + 85 386.1482 530.1523 21 5 5 5000 -1 + 86 302.2976 788.2662 21 5 5 5000 -1 + 87 703.4472 601.4629 21 5 5 5000 -1 + 88 474.6496 669.9736 21 5 5 5000 -1 + 89 236.8296 475.7488 21 5 5 5000 -1 + 90 280.2614 558.2461 21 5 5 5000 -1 + 91 677.3564 690.9606 21 5 5 5000 -1 + 92 848.9565 693.7717 21 5 5 5000 -1 + 93 222.1465 691.9188 21 5 5 5000 -1 + 94 725.5306 438.6246 21 5 5 5000 -1 + 95 320.8137 799.8329 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/img_orig/cam3.10001_targets b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam3.10001_targets new file mode 100644 index 0000000..0b89808 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam3.10001_targets @@ -0,0 +1,97 @@ +96 + 0 601.1951 651.4577 21 5 5 5000 -1 + 1 634.4300 512.3544 21 5 5 5000 -1 + 2 531.3491 645.3990 21 5 5 5000 -1 + 3 660.1068 482.3405 21 5 5 5000 -1 + 4 810.2614 507.0327 21 5 5 5000 -1 + 5 509.6486 749.8499 21 5 5 5000 -1 + 6 316.2159 483.9984 21 5 5 5000 -1 + 7 537.3049 598.9933 21 5 5 5000 -1 + 8 487.3316 705.0246 21 5 5 5000 -1 + 9 986.9809 661.1049 21 5 5 5000 -1 + 10 413.2921 542.4686 21 5 5 5000 -1 + 11 768.0404 518.7716 21 5 5 5000 -1 + 12 822.8365 515.4662 21 5 5 5000 -1 + 13 383.2713 575.4773 21 5 5 5000 -1 + 14 544.2481 514.3070 21 5 5 5000 -1 + 15 329.3526 664.2474 21 5 5 5000 -1 + 16 455.1836 699.0406 21 5 5 5000 -1 + 17 285.5170 662.5145 21 5 5 5000 -1 + 18 795.3380 527.0164 21 5 5 5000 -1 + 19 310.7868 498.2070 21 5 5 5000 -1 + 20 328.0234 552.5324 21 5 5 5000 -1 + 21 855.8265 522.2116 21 5 5 5000 -1 + 22 782.3461 627.6848 21 5 5 5000 -1 + 23 710.8756 624.3716 21 5 5 5000 -1 + 24 778.5715 794.7443 21 5 5 5000 -1 + 25 876.4030 798.6034 21 5 5 5000 -1 + 26 576.0529 743.7657 21 5 5 5000 -1 + 27 1025.2020 683.5462 21 5 5 5000 -1 + 28 307.4076 555.3027 21 5 5 5000 -1 + 29 665.3759 810.1510 21 5 5 5000 -1 + 30 464.2685 621.0520 21 5 5 5000 -1 + 31 765.0976 529.0827 21 5 5 5000 -1 + 32 711.5007 562.7207 21 5 5 5000 -1 + 33 566.1235 730.7761 21 5 5 5000 -1 + 34 671.0377 713.1794 21 5 5 5000 -1 + 35 697.5500 649.4582 21 5 5 5000 -1 + 36 565.4032 728.1858 21 5 5 5000 -1 + 37 377.4293 477.7895 21 5 5 5000 -1 + 38 875.3373 695.7392 21 5 5 5000 -1 + 39 968.1170 663.7444 21 5 5 5000 -1 + 40 272.2414 767.0136 21 5 5 5000 -1 + 41 379.2427 808.1782 21 5 5 5000 -1 + 42 576.3276 531.3339 21 5 5 5000 -1 + 43 942.6595 576.3269 21 5 5 5000 -1 + 44 927.4254 734.0532 21 5 5 5000 -1 + 45 886.5028 526.2590 21 5 5 5000 -1 + 46 810.7316 468.3697 21 5 5 5000 -1 + 47 1026.5429 727.2904 21 5 5 5000 -1 + 48 803.0444 783.9324 21 5 5 5000 -1 + 49 609.1388 477.4080 21 5 5 5000 -1 + 50 800.5768 608.1963 21 5 5 5000 -1 + 51 570.1772 631.3547 21 5 5 5000 -1 + 52 632.7028 461.5746 21 5 5 5000 -1 + 53 276.3314 553.1060 21 5 5 5000 -1 + 54 722.9814 639.6951 21 5 5 5000 -1 + 55 260.1671 620.8580 21 5 5 5000 -1 + 56 574.6840 707.7886 21 5 5 5000 -1 + 57 342.2636 693.7450 21 5 5 5000 -1 + 58 373.5259 683.7214 21 5 5 5000 -1 + 59 673.3538 706.5930 21 5 5 5000 -1 + 60 861.5122 821.2559 21 5 5 5000 -1 + 61 943.8137 513.3068 21 5 5 5000 -1 + 62 259.7618 670.8607 21 5 5 5000 -1 + 63 992.6786 717.8784 21 5 5 5000 -1 + 64 700.9374 630.0679 21 5 5 5000 -1 + 65 864.3589 708.6399 21 5 5 5000 -1 + 66 838.4354 455.0407 21 5 5 5000 -1 + 67 934.4910 578.3175 21 5 5 5000 -1 + 68 637.7565 578.4273 21 5 5 5000 -1 + 69 577.3542 786.4521 21 5 5 5000 -1 + 70 1005.8137 670.0663 21 5 5 5000 -1 + 71 459.8716 742.7320 21 5 5 5000 -1 + 72 446.9913 653.8770 21 5 5 5000 -1 + 73 300.6148 713.3311 21 5 5 5000 -1 + 74 466.0028 627.0809 21 5 5 5000 -1 + 75 574.9761 795.8757 21 5 5 5000 -1 + 76 758.5607 644.9335 21 5 5 5000 -1 + 77 1048.8948 770.5914 21 5 5 5000 -1 + 78 581.2234 734.9929 21 5 5 5000 -1 + 79 371.7600 848.8552 21 5 5 5000 -1 + 80 328.0621 726.0840 21 5 5 5000 -1 + 81 362.4486 809.8779 21 5 5 5000 -1 + 82 811.6552 537.1180 21 5 5 5000 -1 + 83 829.8878 568.7902 21 5 5 5000 -1 + 84 255.8207 471.8265 21 5 5 5000 -1 + 85 806.2112 465.0901 21 5 5 5000 -1 + 86 809.2206 737.2640 21 5 5 5000 -1 + 87 742.5455 664.0010 21 5 5 5000 -1 + 88 889.1032 598.5749 21 5 5 5000 -1 + 89 778.7694 800.0437 21 5 5 5000 -1 + 90 628.2620 489.4467 21 5 5 5000 -1 + 91 887.7784 621.1251 21 5 5 5000 -1 + 92 877.3362 690.0333 21 5 5 5000 -1 + 93 1005.8675 585.8276 21 5 5 5000 -1 + 94 702.6856 536.9321 21 5 5 5000 -1 + 95 904.3450 737.4220 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/img_orig/cam3.10002_targets b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam3.10002_targets new file mode 100644 index 0000000..525f173 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam3.10002_targets @@ -0,0 +1,97 @@ +96 + 0 532.6351 633.9436 21 5 5 5000 -1 + 1 250.8356 787.0811 21 5 5 5000 -1 + 2 412.1016 657.5044 21 5 5 5000 -1 + 3 414.8066 600.8104 21 5 5 5000 -1 + 4 409.8474 789.3017 21 5 5 5000 -1 + 5 273.0792 593.2234 21 5 5 5000 -1 + 6 859.6604 594.2071 21 5 5 5000 -1 + 7 858.0347 653.4859 21 5 5 5000 -1 + 8 616.8260 542.7744 21 5 5 5000 -1 + 9 977.6394 728.8559 21 5 5 5000 -1 + 10 738.7007 782.5199 21 5 5 5000 -1 + 11 561.6139 510.5491 21 5 5 5000 -1 + 12 902.3706 557.0739 21 5 5 5000 -1 + 13 782.3715 521.1949 21 5 5 5000 -1 + 14 601.4075 494.4655 21 5 5 5000 -1 + 15 803.3232 650.9165 21 5 5 5000 -1 + 16 777.7403 532.1760 21 5 5 5000 -1 + 17 812.5750 817.8395 21 5 5 5000 -1 + 18 707.0074 732.4712 21 5 5 5000 -1 + 19 344.0368 804.8922 21 5 5 5000 -1 + 20 925.5311 545.4898 21 5 5 5000 -1 + 21 544.6249 635.9762 21 5 5 5000 -1 + 22 497.8947 555.3517 21 5 5 5000 -1 + 23 889.0664 774.6374 21 5 5 5000 -1 + 24 411.9579 816.4832 21 5 5 5000 -1 + 25 686.4609 556.3958 21 5 5 5000 -1 + 26 463.4136 520.6149 21 5 5 5000 -1 + 27 990.8399 488.0587 21 5 5 5000 -1 + 28 1022.8472 577.2381 21 5 5 5000 -1 + 29 749.4334 690.7269 21 5 5 5000 -1 + 30 268.6146 520.5932 21 5 5 5000 -1 + 31 892.0950 795.1996 21 5 5 5000 -1 + 32 378.3315 718.0449 21 5 5 5000 -1 + 33 1002.8964 755.2967 21 5 5 5000 -1 + 34 923.4060 648.2880 21 5 5 5000 -1 + 35 520.8061 624.3591 21 5 5 5000 -1 + 36 415.1377 519.3158 21 5 5 5000 -1 + 37 331.8918 562.4556 21 5 5 5000 -1 + 38 610.7249 581.7134 21 5 5 5000 -1 + 39 821.2357 816.5273 21 5 5 5000 -1 + 40 832.2455 484.0968 21 5 5 5000 -1 + 41 330.5331 636.5947 21 5 5 5000 -1 + 42 425.5100 638.6136 21 5 5 5000 -1 + 43 679.3516 503.6131 21 5 5 5000 -1 + 44 750.5699 725.5231 21 5 5 5000 -1 + 45 980.4212 686.7744 21 5 5 5000 -1 + 46 463.1901 828.1096 21 5 5 5000 -1 + 47 953.5139 735.9423 21 5 5 5000 -1 + 48 883.7427 720.0533 21 5 5 5000 -1 + 49 475.5395 527.7797 21 5 5 5000 -1 + 50 555.6905 691.2134 21 5 5 5000 -1 + 51 933.5614 814.8643 21 5 5 5000 -1 + 52 711.2456 656.6839 21 5 5 5000 -1 + 53 288.5335 797.7969 21 5 5 5000 -1 + 54 1010.1143 490.1842 21 5 5 5000 -1 + 55 1030.2365 568.8392 21 5 5 5000 -1 + 56 930.0960 674.3395 21 5 5 5000 -1 + 57 491.3334 470.2510 21 5 5 5000 -1 + 58 664.8168 716.4996 21 5 5 5000 -1 + 59 334.0524 481.6249 21 5 5 5000 -1 + 60 401.4066 609.7590 21 5 5 5000 -1 + 61 625.9921 673.4063 21 5 5 5000 -1 + 62 823.5307 573.5371 21 5 5 5000 -1 + 63 689.7797 805.6602 21 5 5 5000 -1 + 64 413.0519 745.0033 21 5 5 5000 -1 + 65 948.6914 588.7258 21 5 5 5000 -1 + 66 755.1783 502.6599 21 5 5 5000 -1 + 67 523.8921 458.7800 21 5 5 5000 -1 + 68 790.3149 698.3434 21 5 5 5000 -1 + 69 358.2153 492.1084 21 5 5 5000 -1 + 70 774.5568 572.9953 21 5 5 5000 -1 + 71 977.7703 589.7398 21 5 5 5000 -1 + 72 665.9507 536.6031 21 5 5 5000 -1 + 73 871.2669 595.1664 21 5 5 5000 -1 + 74 1011.9525 668.6675 21 5 5 5000 -1 + 75 388.7270 728.6234 21 5 5 5000 -1 + 76 937.4524 541.4403 21 5 5 5000 -1 + 77 464.8992 756.5053 21 5 5 5000 -1 + 78 370.8768 508.0194 21 5 5 5000 -1 + 79 932.1215 778.6366 21 5 5 5000 -1 + 80 520.6845 668.8115 21 5 5 5000 -1 + 81 444.0039 609.6972 21 5 5 5000 -1 + 82 911.2410 674.8136 21 5 5 5000 -1 + 83 402.1527 630.2024 21 5 5 5000 -1 + 84 982.1991 738.7768 21 5 5 5000 -1 + 85 466.5098 646.7376 21 5 5 5000 -1 + 86 534.2196 476.0819 21 5 5 5000 -1 + 87 527.2560 680.2951 21 5 5 5000 -1 + 88 312.0425 559.1544 21 5 5 5000 -1 + 89 617.7815 595.5488 21 5 5 5000 -1 + 90 505.0907 746.1432 21 5 5 5000 -1 + 91 884.2659 558.7127 21 5 5 5000 -1 + 92 1005.7662 677.2316 21 5 5 5000 -1 + 93 584.5114 727.0614 21 5 5 5000 -1 + 94 488.4349 806.8121 21 5 5 5000 -1 + 95 891.4657 560.8911 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/img_orig/cam4.10001_targets b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam4.10001_targets new file mode 100644 index 0000000..7dcfafc --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam4.10001_targets @@ -0,0 +1,97 @@ +96 + 0 371.1196 391.2138 21 5 5 5000 -1 + 1 474.9862 610.0136 21 5 5 5000 -1 + 2 597.6101 546.5350 21 5 5 5000 -1 + 3 453.6529 653.0015 21 5 5 5000 -1 + 4 864.6529 637.0581 21 5 5 5000 -1 + 5 712.3557 634.0705 21 5 5 5000 -1 + 6 368.8663 482.8186 21 5 5 5000 -1 + 7 841.7468 569.0040 21 5 5 5000 -1 + 8 569.3261 559.7342 21 5 5 5000 -1 + 9 377.2535 590.5126 21 5 5 5000 -1 + 10 777.4997 551.7058 21 5 5 5000 -1 + 11 776.4110 726.6524 21 5 5 5000 -1 + 12 807.3044 711.6490 21 5 5 5000 -1 + 13 872.0125 547.2544 21 5 5 5000 -1 + 14 795.3412 449.4344 21 5 5 5000 -1 + 15 948.9002 666.5594 21 5 5 5000 -1 + 16 1015.5719 505.0770 21 5 5 5000 -1 + 17 647.1144 652.9336 21 5 5 5000 -1 + 18 673.2737 569.1282 21 5 5 5000 -1 + 19 479.2300 537.9232 21 5 5 5000 -1 + 20 651.7231 446.8463 21 5 5 5000 -1 + 21 321.1093 630.4111 21 5 5 5000 -1 + 22 292.8827 573.9318 21 5 5 5000 -1 + 23 605.3369 624.2477 21 5 5 5000 -1 + 24 851.6995 532.3502 21 5 5 5000 -1 + 25 781.8181 545.9167 21 5 5 5000 -1 + 26 487.8907 616.7814 21 5 5 5000 -1 + 27 354.2999 754.0260 21 5 5 5000 -1 + 28 416.1187 717.2082 21 5 5 5000 -1 + 29 348.6402 404.8706 21 5 5 5000 -1 + 30 1052.4921 596.9914 21 5 5 5000 -1 + 31 343.1853 617.9563 21 5 5 5000 -1 + 32 810.6442 427.6203 21 5 5 5000 -1 + 33 877.5139 523.9321 21 5 5 5000 -1 + 34 520.3918 426.3728 21 5 5 5000 -1 + 35 323.2252 525.5892 21 5 5 5000 -1 + 36 379.4957 459.5222 21 5 5 5000 -1 + 37 682.6625 495.8980 21 5 5 5000 -1 + 38 806.6610 663.7339 21 5 5 5000 -1 + 39 890.7500 438.0686 21 5 5 5000 -1 + 40 868.1364 449.0504 21 5 5 5000 -1 + 41 897.6575 460.5341 21 5 5 5000 -1 + 42 392.9366 600.5280 21 5 5 5000 -1 + 43 801.3003 387.7036 21 5 5 5000 -1 + 44 880.7569 445.1378 21 5 5 5000 -1 + 45 506.6770 565.1171 21 5 5 5000 -1 + 46 841.9756 385.2812 21 5 5 5000 -1 + 47 698.0027 377.5394 21 5 5 5000 -1 + 48 648.6748 392.4996 21 5 5 5000 -1 + 49 692.3970 481.1043 21 5 5 5000 -1 + 50 732.6081 733.0883 21 5 5 5000 -1 + 51 504.8848 662.4884 21 5 5 5000 -1 + 52 617.5721 647.5609 21 5 5 5000 -1 + 53 619.4533 713.4411 21 5 5 5000 -1 + 54 933.4753 619.8574 21 5 5 5000 -1 + 55 719.7978 455.1511 21 5 5 5000 -1 + 56 395.7412 462.5909 21 5 5 5000 -1 + 57 362.2571 459.2965 21 5 5 5000 -1 + 58 542.5172 642.7826 21 5 5 5000 -1 + 59 350.1138 569.9481 21 5 5 5000 -1 + 60 848.7971 375.0646 21 5 5 5000 -1 + 61 477.8698 452.6349 21 5 5 5000 -1 + 62 408.8665 715.5445 21 5 5 5000 -1 + 63 879.4677 753.3787 21 5 5 5000 -1 + 64 767.0927 571.1121 21 5 5 5000 -1 + 65 1030.2600 516.1408 21 5 5 5000 -1 + 66 725.5919 585.4083 21 5 5 5000 -1 + 67 619.3515 514.0184 21 5 5 5000 -1 + 68 1017.6796 593.9076 21 5 5 5000 -1 + 69 349.3044 670.9336 21 5 5 5000 -1 + 70 922.8250 504.8421 21 5 5 5000 -1 + 71 635.7461 661.3223 21 5 5 5000 -1 + 72 472.4459 531.7421 21 5 5 5000 -1 + 73 924.3041 625.4896 21 5 5 5000 -1 + 74 879.2912 492.6368 21 5 5 5000 -1 + 75 650.7620 397.7189 21 5 5 5000 -1 + 76 813.5699 551.1108 21 5 5 5000 -1 + 77 292.7349 565.8417 21 5 5 5000 -1 + 78 1075.9157 604.7165 21 5 5 5000 -1 + 79 605.6755 703.6866 21 5 5 5000 -1 + 80 1021.8978 653.2126 21 5 5 5000 -1 + 81 791.4794 561.8865 21 5 5 5000 -1 + 82 870.1937 449.3173 21 5 5 5000 -1 + 83 1047.6793 709.9529 21 5 5 5000 -1 + 84 355.5144 385.0012 21 5 5 5000 -1 + 85 702.3578 405.6451 21 5 5 5000 -1 + 86 988.9832 439.0539 21 5 5 5000 -1 + 87 641.4369 427.8650 21 5 5 5000 -1 + 88 904.8187 668.6487 21 5 5 5000 -1 + 89 805.2202 722.1173 21 5 5 5000 -1 + 90 774.5907 438.3923 21 5 5 5000 -1 + 91 313.3519 377.5902 21 5 5 5000 -1 + 92 1033.4481 663.8778 21 5 5 5000 -1 + 93 1075.3874 619.2595 21 5 5 5000 -1 + 94 907.2865 731.1871 21 5 5 5000 -1 + 95 728.7928 627.9919 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/img_orig/cam4.10002_targets b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam4.10002_targets new file mode 100644 index 0000000..6035436 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/img_orig/cam4.10002_targets @@ -0,0 +1,97 @@ +96 + 0 334.2275 542.1888 21 5 5 5000 -1 + 1 891.2837 483.1754 21 5 5 5000 -1 + 2 1025.2487 498.6441 21 5 5 5000 -1 + 3 1038.1758 674.8786 21 5 5 5000 -1 + 4 971.8808 708.4972 21 5 5 5000 -1 + 5 695.0504 513.2339 21 5 5 5000 -1 + 6 395.5890 389.3562 21 5 5 5000 -1 + 7 1037.2597 416.7359 21 5 5 5000 -1 + 8 697.3035 728.0674 21 5 5 5000 -1 + 9 430.9612 518.8569 21 5 5 5000 -1 + 10 444.0904 725.5541 21 5 5 5000 -1 + 11 943.7696 521.7831 21 5 5 5000 -1 + 12 774.7428 707.7518 21 5 5 5000 -1 + 13 630.1668 458.4806 21 5 5 5000 -1 + 14 502.4029 431.6898 21 5 5 5000 -1 + 15 1026.5036 714.9234 21 5 5 5000 -1 + 16 332.8651 498.3158 21 5 5 5000 -1 + 17 529.5336 719.8969 21 5 5 5000 -1 + 18 538.0450 604.9717 21 5 5 5000 -1 + 19 642.2064 635.3505 21 5 5 5000 -1 + 20 1004.5148 472.5241 21 5 5 5000 -1 + 21 326.5564 468.3127 21 5 5 5000 -1 + 22 614.9613 549.1795 21 5 5 5000 -1 + 23 811.9840 496.0940 21 5 5 5000 -1 + 24 954.8356 604.9586 21 5 5 5000 -1 + 25 957.7703 577.8421 21 5 5 5000 -1 + 26 950.5054 516.6514 21 5 5 5000 -1 + 27 852.0788 405.2528 21 5 5 5000 -1 + 28 365.8497 399.3532 21 5 5 5000 -1 + 29 696.9691 654.0124 21 5 5 5000 -1 + 30 814.1284 745.6007 21 5 5 5000 -1 + 31 571.0638 550.2134 21 5 5 5000 -1 + 32 636.8897 590.8675 21 5 5 5000 -1 + 33 528.6536 659.2827 21 5 5 5000 -1 + 34 897.9311 485.7766 21 5 5 5000 -1 + 35 564.5914 594.4487 21 5 5 5000 -1 + 36 496.5539 549.5979 21 5 5 5000 -1 + 37 994.4852 691.7361 21 5 5 5000 -1 + 38 706.0343 454.3036 21 5 5 5000 -1 + 39 510.9494 536.4765 21 5 5 5000 -1 + 40 808.7484 746.6436 21 5 5 5000 -1 + 41 1037.3282 414.1878 21 5 5 5000 -1 + 42 1013.1069 610.7966 21 5 5 5000 -1 + 43 804.2246 623.5048 21 5 5 5000 -1 + 44 412.2172 538.6797 21 5 5 5000 -1 + 45 894.3188 603.6603 21 5 5 5000 -1 + 46 759.5054 421.7247 21 5 5 5000 -1 + 47 587.8198 371.7142 21 5 5 5000 -1 + 48 406.8884 427.7058 21 5 5 5000 -1 + 49 524.7095 521.3157 21 5 5 5000 -1 + 50 430.2780 566.5758 21 5 5 5000 -1 + 51 415.6498 636.3913 21 5 5 5000 -1 + 52 477.6941 699.4492 21 5 5 5000 -1 + 53 347.5675 465.2593 21 5 5 5000 -1 + 54 957.8693 729.2962 21 5 5 5000 -1 + 55 735.3755 613.1903 21 5 5 5000 -1 + 56 966.0259 651.6222 21 5 5 5000 -1 + 57 959.6943 750.7472 21 5 5 5000 -1 + 58 415.4845 416.3501 21 5 5 5000 -1 + 59 864.0649 576.3931 21 5 5 5000 -1 + 60 427.4184 712.1307 21 5 5 5000 -1 + 61 599.8341 643.6205 21 5 5 5000 -1 + 62 796.4586 441.5889 21 5 5 5000 -1 + 63 736.3515 577.8625 21 5 5 5000 -1 + 64 855.1442 453.8349 21 5 5 5000 -1 + 65 1084.1237 508.4847 21 5 5 5000 -1 + 66 638.8138 425.5643 21 5 5 5000 -1 + 67 599.9554 408.7322 21 5 5 5000 -1 + 68 483.1244 739.5292 21 5 5 5000 -1 + 69 1009.2949 519.0909 21 5 5 5000 -1 + 70 520.1012 581.5095 21 5 5 5000 -1 + 71 438.9838 626.4676 21 5 5 5000 -1 + 72 584.1435 389.2201 21 5 5 5000 -1 + 73 984.8710 483.8782 21 5 5 5000 -1 + 74 739.2653 648.7865 21 5 5 5000 -1 + 75 841.6733 423.2225 21 5 5 5000 -1 + 76 948.0194 582.0485 21 5 5 5000 -1 + 77 1018.6098 670.9641 21 5 5 5000 -1 + 78 436.9171 654.1660 21 5 5 5000 -1 + 79 325.5123 426.1727 21 5 5 5000 -1 + 80 513.1246 668.6761 21 5 5 5000 -1 + 81 544.7242 382.0995 21 5 5 5000 -1 + 82 453.9529 437.8067 21 5 5 5000 -1 + 83 881.0844 519.3747 21 5 5 5000 -1 + 84 534.1729 559.1430 21 5 5 5000 -1 + 85 525.9841 467.2229 21 5 5 5000 -1 + 86 439.3035 510.1743 21 5 5 5000 -1 + 87 339.9972 690.3312 21 5 5 5000 -1 + 88 847.0584 495.4915 21 5 5 5000 -1 + 89 276.9638 699.2297 21 5 5 5000 -1 + 90 711.4458 474.6552 21 5 5 5000 -1 + 91 1096.4783 603.9216 21 5 5 5000 -1 + 92 1053.3147 621.0458 21 5 5 5000 -1 + 93 1071.7062 665.2577 21 5 5 5000 -1 + 94 949.2438 467.2970 21 5 5 5000 -1 + 95 623.5276 497.6648 21 5 5 5000 -1 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/cal_ori.par b/tests/testing_fodder/test_cavity_synthetic/parameters/cal_ori.par new file mode 100644 index 0000000..9dece9c --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/cal_ori.par @@ -0,0 +1,12 @@ +cal/target_on_a_side.txt +cal/cam1.tif +cal/cam1.tif.ori +cal/cam2.tif +cal/cam2.tif.ori +cal/cam3.tif +cal/cam3.tif.ori +cal/cam4.tif +cal/cam4.tif.ori +1 +1 +0 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/criteria.par b/tests/testing_fodder/test_cavity_synthetic/parameters/criteria.par new file mode 100644 index 0000000..67cc8fa --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/criteria.par @@ -0,0 +1,12 @@ +-40 +-20 +20 +40 +-20 +20 +0.02 +0.02 +0.02 +0.02 +33 +0.2 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/detect_plate.par b/tests/testing_fodder/test_cavity_synthetic/parameters/detect_plate.par new file mode 100644 index 0000000..1c67d03 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/detect_plate.par @@ -0,0 +1,13 @@ +40 +40 +40 +40 +500 +25 +400 +5 +50 +5 +50 +100 +3 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/dumbbell.par b/tests/testing_fodder/test_cavity_synthetic/parameters/dumbbell.par new file mode 100644 index 0000000..ea931f3 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/dumbbell.par @@ -0,0 +1,6 @@ +3.000000 +25.000000 +0.050000 +1.000000 +1 +500 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/examine.par b/tests/testing_fodder/test_cavity_synthetic/parameters/examine.par new file mode 100644 index 0000000..aa47d0d --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/examine.par @@ -0,0 +1,2 @@ +0 +0 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/man_ori.par b/tests/testing_fodder/test_cavity_synthetic/parameters/man_ori.par new file mode 100644 index 0000000..45acff6 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/man_ori.par @@ -0,0 +1,16 @@ +3 +5 +72 +73 +3 +5 +72 +73 +1 +5 +71 +73 +1 +5 +71 +73 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/multi_planes.par b/tests/testing_fodder/test_cavity_synthetic/parameters/multi_planes.par new file mode 100644 index 0000000..9b30724 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/multi_planes.par @@ -0,0 +1,4 @@ +3 +img/calib_a_cam +img/calib_b_cam +img/calib_c_cam diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/orient.par b/tests/testing_fodder/test_cavity_synthetic/parameters/orient.par new file mode 100644 index 0000000..66f4ca4 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/orient.par @@ -0,0 +1,12 @@ +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/pft_version b/tests/testing_fodder/test_cavity_synthetic/parameters/pft_version new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/pft_version @@ -0,0 +1 @@ +3 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/pft_version.par b/tests/testing_fodder/test_cavity_synthetic/parameters/pft_version.par new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/pft_version.par @@ -0,0 +1 @@ +0 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/ptv.par b/tests/testing_fodder/test_cavity_synthetic/parameters/ptv.par new file mode 100644 index 0000000..49c0f6f --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/ptv.par @@ -0,0 +1,21 @@ +4 +img/cam1.10003 +cal/cam1.tif +img/cam2.10003 +cal/cam2.tif +img/cam3.10003 +cal/cam3.tif +img/cam4.10003 +cal/cam4.tif +1 +0 +1 +1280 +1024 +0.012 +0.012 +0 +1 +1.33 +1.46 +6 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/sequence.par b/tests/testing_fodder/test_cavity_synthetic/parameters/sequence.par new file mode 100644 index 0000000..9d9b85c --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/sequence.par @@ -0,0 +1,6 @@ +img_orig/cam1.%05d +img_orig/cam2.%05d +img_orig/cam3.%05d +img_orig/cam4.%05d +10001 +10002 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/shaking.par b/tests/testing_fodder/test_cavity_synthetic/parameters/shaking.par new file mode 100644 index 0000000..06d8de2 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/shaking.par @@ -0,0 +1,4 @@ +10000 +10004 +10 +5 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/sortgrid.par b/tests/testing_fodder/test_cavity_synthetic/parameters/sortgrid.par new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/sortgrid.par @@ -0,0 +1 @@ +20 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/targ_rec.par b/tests/testing_fodder/test_cavity_synthetic/parameters/targ_rec.par new file mode 100644 index 0000000..7800c5a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/targ_rec.par @@ -0,0 +1,13 @@ +9 +9 +9 +11 +100 +4 +500 +2 +100 +2 +100 +150 +2 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/track.par b/tests/testing_fodder/test_cavity_synthetic/parameters/track.par new file mode 100644 index 0000000..31706ba --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/track.par @@ -0,0 +1,9 @@ +-5.5 +5.5 +-5.5 +5.5 +-5.5 +5.5 +110 +1.0 +0 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters/unsharp_mask.par b/tests/testing_fodder/test_cavity_synthetic/parameters/unsharp_mask.par new file mode 100644 index 0000000..48082f7 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters/unsharp_mask.par @@ -0,0 +1 @@ +12 diff --git a/tests/testing_fodder/test_cavity_synthetic/parameters_Run1.yaml b/tests/testing_fodder/test_cavity_synthetic/parameters_Run1.yaml new file mode 100644 index 0000000..9c58258 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/parameters_Run1.yaml @@ -0,0 +1,174 @@ +num_cams: 4 +plugins: + available_tracking: + - default + available_sequence: + - default + selected_tracking: default + selected_sequence: default +cal_ori: + chfield: 0 + fixp_name: cal/target_on_a_side.txt + img_cal_name: + - cal/cam1.tif + - cal/cam2.tif + - cal/cam3.tif + - cal/cam4.tif + img_ori: + - cal/cam1.tif.ori + - cal/cam2.tif.ori + - cal/cam3.tif.ori + - cal/cam4.tif.ori + pair_flag: true + tiff_flag: true + cal_splitter: false +criteria: + X_lay: + - -40 + - 40 + Zmax_lay: + - 20 + - 20 + Zmin_lay: + - -20 + - -20 + cn: 0.02 + cnx: 0.02 + cny: 0.02 + corrmin: 33.0 + csumg: 0.02 + eps0: 0.2 +detect_plate: + gvth_1: 40 + gvth_2: 40 + gvth_3: 40 + gvth_4: 40 + max_npix: 400 + max_npix_x: 50 + max_npix_y: 50 + min_npix: 25 + min_npix_x: 5 + min_npix_y: 5 + size_cross: 3 + sum_grey: 100 + tol_dis: 500 +dumbbell: + dumbbell_eps: 3.0 + dumbbell_gradient_descent: 0.05 + dumbbell_niter: 500 + dumbbell_penalty_weight: 1.0 + dumbbell_scale: 25.0 + dumbbell_step: 1 +examine: + Combine_Flag: false + Examine_Flag: false +man_ori: + nr: + - 3 + - 5 + - 72 + - 73 + - 3 + - 5 + - 72 + - 73 + - 1 + - 5 + - 71 + - 73 + - 1 + - 5 + - 71 + - 73 +multi_planes: + n_planes: 3 + plane_name: + - img/calib_a_cam + - img/calib_b_cam + - img/calib_c_cam +orient: + cc: 0 + interf: 0 + k1: 0 + k2: 0 + k3: 0 + p1: 0 + p2: 0 + pnfo: 0 + scale: 0 + shear: 0 + xh: 0 + yh: 0 +pft_version: + Existing_Target: 0 +ptv: + allcam_flag: false + chfield: 0 + hp_flag: true + img_cal: + - cal/cam1.tif + - cal/cam2.tif + - cal/cam3.tif + - cal/cam4.tif + img_name: + - img/cam1.10003 + - img/cam2.10003 + - img/cam3.10003 + - img/cam4.10003 + imx: 1280 + imy: 1024 + mmp_d: 6.0 + mmp_n1: 1.0 + mmp_n2: 1.33 + mmp_n3: 1.46 + pix_x: 0.012 + pix_y: 0.012 + tiff_flag: true + splitter: false +sequence: + base_name: + - img/cam1.%05d + - img/cam2.%05d + - img/cam3.%05d + - img/cam4.%05d + first: 10001 + last: 10004 +shaking: + shaking_first_frame: 10000 + shaking_last_frame: 10004 + shaking_max_num_frames: 5 + shaking_max_num_points: 10 +sortgrid: + radius: 20 +targ_rec: + cr_sz: 2 + disco: 100 + gvthres: + - 9 + - 9 + - 9 + - 11 + nnmax: 500 + nnmin: 4 + nxmax: 100 + nxmin: 2 + nymax: 100 + nymin: 2 + sumg_min: 150 +track: + angle: 110.0 + dacc: 1.0 + dvxmax: 5.5 + dvxmin: -5.5 + dvymax: 5.5 + dvymin: -5.5 + dvzmax: 5.5 + dvzmin: -5.5 + flagNewParticles: false +masking: + mask_flag: false + mask_base_name: '' +unsharp_mask: + flag: false + size: 3 + strength: 1.0 diff --git a/tests/testing_fodder/test_cavity_synthetic/res_orig/added.10001 b/tests/testing_fodder/test_cavity_synthetic/res_orig/added.10001 new file mode 100644 index 0000000..61f6b2c --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/res_orig/added.10001 @@ -0,0 +1,97 @@ +96 +-1 -2 -9.015 2.927 -16.720 2 +-1 -2 -7.645 -9.020 -6.160 2 +-1 -2 19.352 -17.386 6.728 2 +-1 -2 -32.744 -5.758 14.272 2 +-1 -2 18.431 10.832 -10.569 2 +-1 -2 36.949 -5.349 -1.172 2 +-1 -2 -19.617 -4.575 4.417 2 +-1 -2 8.582 -1.118 -11.440 2 +-1 -2 30.311 4.564 -10.305 2 +-1 -2 12.792 -2.158 -16.020 2 +-1 -2 11.807 -14.954 3.007 2 +-1 -2 -7.646 -6.150 0.638 2 +-1 -2 -19.070 3.150 7.961 2 +-1 -2 28.940 11.433 -1.501 2 +-1 -2 10.934 10.566 1.142 2 +-1 -2 -0.768 5.709 -4.230 2 +-1 -2 15.329 2.295 -5.081 2 +-1 -2 7.532 0.326 -12.395 2 +-1 -2 10.426 -14.451 12.228 2 +-1 -2 32.607 -7.990 4.928 2 +-1 -2 -27.516 8.319 15.666 2 +-1 -2 -7.156 -15.113 -3.747 2 +-1 -2 -31.370 7.721 -17.849 2 +-1 -2 22.606 -8.820 12.624 2 +-1 -2 36.226 -4.620 -8.021 2 +-1 -2 -18.723 2.422 6.242 2 +-1 -2 36.212 -12.161 15.533 2 +-1 -2 -17.260 -4.648 10.483 2 +-1 -2 6.424 -0.957 16.235 2 +-1 -2 -11.474 -0.256 -1.879 2 +-1 -2 12.559 1.021 1.212 2 +-1 -2 -28.189 17.607 17.740 2 +-1 -2 18.265 8.257 -16.566 2 +-1 -2 -31.630 -0.820 5.041 2 +-1 -2 6.814 -0.387 -14.351 2 +-1 -2 -35.523 -12.285 -13.003 2 +-1 -2 -29.445 -4.622 -4.994 2 +-1 -2 -29.126 -17.089 17.515 2 +-1 -2 32.183 -2.534 4.235 2 +-1 -2 -0.283 13.600 -14.159 2 +-1 -2 13.389 -8.503 12.506 2 +-1 -2 -34.498 8.116 -16.657 2 +-1 -2 33.681 4.878 6.307 2 +-1 -2 -28.009 -2.306 10.164 2 +-1 -2 3.044 -7.118 -7.259 2 +-1 -2 -37.934 -1.535 1.828 2 +-1 -2 4.302 10.272 4.930 2 +-1 -2 -0.895 16.169 12.916 2 +-1 -2 13.444 17.195 13.852 2 +-1 -2 13.841 13.308 10.860 2 +-1 -2 34.572 -8.234 12.524 2 +-1 -2 -15.387 -8.779 12.254 2 +-1 -2 20.289 2.773 17.085 2 +-1 -2 -3.234 -2.076 -13.225 2 +-1 -2 21.468 -15.696 2.713 2 +-1 -2 -32.606 14.493 -0.933 2 +-1 -2 -18.860 -1.441 -8.808 2 +-1 -2 24.961 6.651 16.351 2 +-1 -2 2.688 -17.609 -10.999 2 +-1 -2 -3.655 15.607 -2.903 2 +-1 -2 15.982 9.643 -12.345 2 +-1 -2 -10.752 -6.333 17.852 2 +-1 -2 -31.511 15.417 -6.714 2 +-1 -2 -26.751 -15.001 2.024 2 +-1 -2 13.083 -13.194 10.341 2 +-1 -2 17.056 17.982 7.902 2 +-1 -2 -6.340 -10.603 -8.938 2 +-1 -2 18.118 6.123 -4.304 2 +-1 -2 -21.811 9.253 -10.665 2 +-1 -2 -37.246 16.707 -6.957 2 +-1 -2 22.396 -6.297 -3.288 2 +-1 -2 15.233 16.496 -0.485 2 +-1 -2 -36.242 -0.055 9.997 2 +-1 -2 -0.222 16.488 -11.317 2 +-1 -2 -7.595 -13.632 1.601 2 +-1 -2 6.135 -2.063 -11.965 2 +-1 -2 -8.137 1.294 1.428 2 +-1 -2 33.390 -4.392 -13.338 2 +-1 -2 -33.770 -6.056 -1.755 2 +-1 -2 10.093 12.128 8.628 2 +-1 -2 -2.671 13.008 7.810 2 +-1 -2 -27.554 -15.977 -5.876 2 +-1 -2 20.226 11.765 16.945 2 +-1 -2 -36.900 2.302 -8.408 2 +-1 -2 19.409 11.193 3.726 2 +-1 -2 20.649 4.804 15.698 2 +-1 -2 3.480 8.709 16.416 2 +-1 -2 -5.562 -9.971 -10.975 2 +-1 -2 2.171 -7.285 -2.609 2 +-1 -2 22.975 -5.970 -5.607 2 +-1 -2 -5.377 9.600 -14.614 2 +-1 -2 -12.528 13.790 17.585 2 +-1 -2 18.812 -5.990 12.050 2 +-1 -2 25.926 -9.208 6.305 2 +-1 -2 -30.580 8.931 -5.441 2 +-1 -2 -20.187 -7.884 12.874 2 diff --git a/tests/testing_fodder/test_cavity_synthetic/res_orig/added.10002 b/tests/testing_fodder/test_cavity_synthetic/res_orig/added.10002 new file mode 100644 index 0000000..a6d62af --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/res_orig/added.10002 @@ -0,0 +1,97 @@ +96 +-1 -2 33.100 -10.874 -3.731 2 +-1 -2 -1.282 -5.544 17.803 2 +-1 -2 -25.620 -7.420 -8.545 2 +-1 -2 -32.080 9.467 13.174 2 +-1 -2 21.834 7.900 10.078 2 +-1 -2 21.174 8.109 9.754 2 +-1 -2 31.382 4.365 3.668 2 +-1 -2 -22.386 -14.708 -11.052 2 +-1 -2 -23.193 4.761 2.856 2 +-1 -2 13.888 5.177 -12.495 2 +-1 -2 3.080 8.242 2.359 2 +-1 -2 -17.690 -11.043 -4.786 2 +-1 -2 -4.770 6.324 6.119 2 +-1 -2 34.353 7.246 15.820 2 +-1 -2 16.035 -2.154 -8.124 2 +-1 -2 37.414 4.711 -4.991 2 +-1 -2 32.666 -3.420 11.879 2 +-1 -2 -9.519 -0.495 -16.794 2 +-1 -2 -36.751 -12.133 16.401 2 +-1 -2 14.459 7.267 14.869 2 +-1 -2 30.360 -16.077 -17.149 2 +-1 -2 16.930 15.001 4.989 2 +-1 -2 -17.861 11.878 -2.326 2 +-1 -2 -32.018 2.278 10.487 2 +-1 -2 -25.811 -7.346 2.617 2 +-1 -2 1.807 9.784 -2.707 2 +-1 -2 -23.763 -0.427 5.064 2 +-1 -2 -18.965 12.662 17.085 2 +-1 -2 -37.281 -14.578 -16.865 2 +-1 -2 -6.163 14.971 10.397 2 +-1 -2 34.834 -11.174 -16.321 2 +-1 -2 -23.527 -16.023 0.975 2 +-1 -2 31.479 -10.415 16.823 2 +-1 -2 -10.904 17.047 -10.996 2 +-1 -2 2.338 -15.091 8.570 2 +-1 -2 -24.309 3.802 1.276 2 +-1 -2 7.183 -3.599 15.415 2 +-1 -2 -6.721 11.602 -15.325 2 +-1 -2 -10.654 0.968 1.899 2 +-1 -2 19.542 4.372 5.107 2 +-1 -2 11.825 11.680 6.344 2 +-1 -2 -14.056 -1.276 10.696 2 +-1 -2 26.936 6.253 -14.169 2 +-1 -2 -29.039 15.648 8.516 2 +-1 -2 4.990 11.953 -15.933 2 +-1 -2 -14.723 -9.350 3.145 2 +-1 -2 -19.024 -16.906 4.685 2 +-1 -2 12.761 11.736 -17.431 2 +-1 -2 22.932 -3.406 -17.093 2 +-1 -2 34.301 14.013 5.332 2 +-1 -2 24.846 -16.626 -8.352 2 +-1 -2 29.010 7.454 -12.738 2 +-1 -2 -7.365 -7.525 5.655 2 +-1 -2 -20.517 -0.265 -12.633 2 +-1 -2 -14.358 3.194 13.554 2 +-1 -2 -15.743 -15.729 -2.462 2 +-1 -2 -26.586 13.217 -3.685 2 +-1 -2 -10.498 15.683 -6.299 2 +-1 -2 -15.002 8.755 1.199 2 +-1 -2 26.292 -1.299 1.988 2 +-1 -2 -11.952 -3.570 -1.630 2 +-1 -2 23.324 2.842 -11.248 2 +-1 -2 14.251 -15.957 14.159 2 +-1 -2 33.547 13.799 -1.132 2 +-1 -2 8.409 -14.003 -0.366 2 +-1 -2 7.368 -6.988 14.633 2 +-1 -2 22.414 -2.333 17.837 2 +-1 -2 26.398 -3.567 5.101 2 +-1 -2 -3.495 -2.383 6.971 2 +-1 -2 -4.115 9.992 5.818 2 +-1 -2 -1.304 3.194 -14.999 2 +-1 -2 14.499 9.047 -14.126 2 +-1 -2 -24.969 2.440 7.757 2 +-1 -2 -24.215 13.318 13.233 2 +-1 -2 3.281 -7.571 14.055 2 +-1 -2 -16.631 -1.079 -11.642 2 +-1 -2 33.910 -6.317 -9.369 2 +-1 -2 37.664 -4.931 -12.510 2 +-1 -2 -23.598 -8.924 3.462 2 +-1 -2 24.986 -9.769 -14.015 2 +-1 -2 12.407 -5.281 6.950 2 +-1 -2 -36.110 12.047 -6.674 2 +-1 -2 25.488 -15.206 -14.025 2 +-1 -2 -32.643 8.787 0.154 2 +-1 -2 -14.534 16.274 -7.342 2 +-1 -2 26.704 -17.204 5.115 2 +-1 -2 -28.329 -16.516 -15.822 2 +-1 -2 13.571 -16.186 13.031 2 +-1 -2 -11.429 -2.984 16.068 2 +-1 -2 -35.700 5.003 -7.533 2 +-1 -2 30.834 -10.881 -7.255 2 +-1 -2 27.009 5.267 12.371 2 +-1 -2 26.504 9.561 9.008 2 +-1 -2 5.367 -1.449 2.623 2 +-1 -2 -29.551 15.440 -9.108 2 +-1 -2 -18.286 2.204 -16.040 2 diff --git a/tests/testing_fodder/test_cavity_synthetic/res_orig/ptv_is.10001 b/tests/testing_fodder/test_cavity_synthetic/res_orig/ptv_is.10001 new file mode 100644 index 0000000..bb18c55 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/res_orig/ptv_is.10001 @@ -0,0 +1,97 @@ +96 +-1 -2 -9.015 2.927 -16.720 +-1 -2 -7.645 -9.020 -6.160 +-1 -2 19.352 -17.386 6.728 +-1 -2 -32.744 -5.758 14.272 +-1 -2 18.431 10.832 -10.569 +-1 -2 36.949 -5.349 -1.172 +-1 -2 -19.617 -4.575 4.417 +-1 -2 8.582 -1.118 -11.440 +-1 -2 30.311 4.564 -10.305 +-1 -2 12.792 -2.158 -16.020 +-1 -2 11.807 -14.954 3.007 +-1 -2 -7.646 -6.150 0.638 +-1 -2 -19.070 3.150 7.961 +-1 -2 28.940 11.433 -1.501 +-1 -2 10.934 10.566 1.142 +-1 -2 -0.768 5.709 -4.230 +-1 -2 15.329 2.295 -5.081 +-1 -2 7.532 0.326 -12.395 +-1 -2 10.426 -14.451 12.228 +-1 -2 32.607 -7.990 4.928 +-1 -2 -27.516 8.319 15.666 +-1 -2 -7.156 -15.113 -3.747 +-1 -2 -31.370 7.721 -17.849 +-1 -2 22.606 -8.820 12.624 +-1 -2 36.226 -4.620 -8.021 +-1 -2 -18.723 2.422 6.242 +-1 -2 36.212 -12.161 15.533 +-1 -2 -17.260 -4.648 10.483 +-1 -2 6.424 -0.957 16.235 +-1 -2 -11.474 -0.256 -1.879 +-1 -2 12.559 1.021 1.212 +-1 -2 -28.189 17.607 17.740 +-1 -2 18.265 8.257 -16.566 +-1 -2 -31.630 -0.820 5.041 +-1 -2 6.814 -0.387 -14.351 +-1 -2 -35.523 -12.285 -13.003 +-1 -2 -29.445 -4.622 -4.994 +-1 -2 -29.126 -17.089 17.515 +-1 -2 32.183 -2.534 4.235 +-1 -2 -0.283 13.600 -14.159 +-1 -2 13.389 -8.503 12.506 +-1 -2 -34.498 8.116 -16.657 +-1 -2 33.681 4.878 6.307 +-1 -2 -28.009 -2.306 10.164 +-1 -2 3.044 -7.118 -7.259 +-1 -2 -37.934 -1.535 1.828 +-1 -2 4.302 10.272 4.930 +-1 -2 -0.895 16.169 12.916 +-1 -2 13.444 17.195 13.852 +-1 -2 13.841 13.308 10.860 +-1 -2 34.572 -8.234 12.524 +-1 -2 -15.387 -8.779 12.254 +-1 -2 20.289 2.773 17.085 +-1 -2 -3.234 -2.076 -13.225 +-1 -2 21.468 -15.696 2.713 +-1 -2 -32.606 14.493 -0.933 +-1 -2 -18.860 -1.441 -8.808 +-1 -2 24.961 6.651 16.351 +-1 -2 2.688 -17.609 -10.999 +-1 -2 -3.655 15.607 -2.903 +-1 -2 15.982 9.643 -12.345 +-1 -2 -10.752 -6.333 17.852 +-1 -2 -31.511 15.417 -6.714 +-1 -2 -26.751 -15.001 2.024 +-1 -2 13.083 -13.194 10.341 +-1 -2 17.056 17.982 7.902 +-1 -2 -6.340 -10.603 -8.938 +-1 -2 18.118 6.123 -4.304 +-1 -2 -21.811 9.253 -10.665 +-1 -2 -37.246 16.707 -6.957 +-1 -2 22.396 -6.297 -3.288 +-1 -2 15.233 16.496 -0.485 +-1 -2 -36.242 -0.055 9.997 +-1 -2 -0.222 16.488 -11.317 +-1 -2 -7.595 -13.632 1.601 +-1 -2 6.135 -2.063 -11.965 +-1 -2 -8.137 1.294 1.428 +-1 -2 33.390 -4.392 -13.338 +-1 -2 -33.770 -6.056 -1.755 +-1 -2 10.093 12.128 8.628 +-1 -2 -2.671 13.008 7.810 +-1 -2 -27.554 -15.977 -5.876 +-1 -2 20.226 11.765 16.945 +-1 -2 -36.900 2.302 -8.408 +-1 -2 19.409 11.193 3.726 +-1 -2 20.649 4.804 15.698 +-1 -2 3.480 8.709 16.416 +-1 -2 -5.562 -9.971 -10.975 +-1 -2 2.171 -7.285 -2.609 +-1 -2 22.975 -5.970 -5.607 +-1 -2 -5.377 9.600 -14.614 +-1 -2 -12.528 13.790 17.585 +-1 -2 18.812 -5.990 12.050 +-1 -2 25.926 -9.208 6.305 +-1 -2 -30.580 8.931 -5.441 +-1 -2 -20.187 -7.884 12.874 diff --git a/tests/testing_fodder/test_cavity_synthetic/res_orig/ptv_is.10002 b/tests/testing_fodder/test_cavity_synthetic/res_orig/ptv_is.10002 new file mode 100644 index 0000000..ce22d4a --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/res_orig/ptv_is.10002 @@ -0,0 +1,97 @@ +96 +-1 -2 33.100 -10.874 -3.731 +-1 -2 -1.282 -5.544 17.803 +-1 -2 -25.620 -7.420 -8.545 +-1 -2 -32.080 9.467 13.174 +-1 -2 21.834 7.900 10.078 +-1 -2 21.174 8.109 9.754 +-1 -2 31.382 4.365 3.668 +-1 -2 -22.386 -14.708 -11.052 +-1 -2 -23.193 4.761 2.856 +-1 -2 13.888 5.177 -12.495 +-1 -2 3.080 8.242 2.359 +-1 -2 -17.690 -11.043 -4.786 +-1 -2 -4.770 6.324 6.119 +-1 -2 34.353 7.246 15.820 +-1 -2 16.035 -2.154 -8.124 +-1 -2 37.414 4.711 -4.991 +-1 -2 32.666 -3.420 11.879 +-1 -2 -9.519 -0.495 -16.794 +-1 -2 -36.751 -12.133 16.401 +-1 -2 14.459 7.267 14.869 +-1 -2 30.360 -16.077 -17.149 +-1 -2 16.930 15.001 4.989 +-1 -2 -17.861 11.878 -2.326 +-1 -2 -32.018 2.278 10.487 +-1 -2 -25.811 -7.346 2.617 +-1 -2 1.807 9.784 -2.707 +-1 -2 -23.763 -0.427 5.064 +-1 -2 -18.965 12.662 17.085 +-1 -2 -37.281 -14.578 -16.865 +-1 -2 -6.163 14.971 10.397 +-1 -2 34.834 -11.174 -16.321 +-1 -2 -23.527 -16.023 0.975 +-1 -2 31.479 -10.415 16.823 +-1 -2 -10.904 17.047 -10.996 +-1 -2 2.338 -15.091 8.570 +-1 -2 -24.309 3.802 1.276 +-1 -2 7.183 -3.599 15.415 +-1 -2 -6.721 11.602 -15.325 +-1 -2 -10.654 0.968 1.899 +-1 -2 19.542 4.372 5.107 +-1 -2 11.825 11.680 6.344 +-1 -2 -14.056 -1.276 10.696 +-1 -2 26.936 6.253 -14.169 +-1 -2 -29.039 15.648 8.516 +-1 -2 4.990 11.953 -15.933 +-1 -2 -14.723 -9.350 3.145 +-1 -2 -19.024 -16.906 4.685 +-1 -2 12.761 11.736 -17.431 +-1 -2 22.932 -3.406 -17.093 +-1 -2 34.301 14.013 5.332 +-1 -2 24.846 -16.626 -8.352 +-1 -2 29.010 7.454 -12.738 +-1 -2 -7.365 -7.525 5.655 +-1 -2 -20.517 -0.265 -12.633 +-1 -2 -14.358 3.194 13.554 +-1 -2 -15.743 -15.729 -2.462 +-1 -2 -26.586 13.217 -3.685 +-1 -2 -10.498 15.683 -6.299 +-1 -2 -15.002 8.755 1.199 +-1 -2 26.292 -1.299 1.988 +-1 -2 -11.952 -3.570 -1.630 +-1 -2 23.324 2.842 -11.248 +-1 -2 14.251 -15.957 14.159 +-1 -2 33.547 13.799 -1.132 +-1 -2 8.409 -14.003 -0.366 +-1 -2 7.368 -6.988 14.633 +-1 -2 22.414 -2.333 17.837 +-1 -2 26.398 -3.567 5.101 +-1 -2 -3.495 -2.383 6.971 +-1 -2 -4.115 9.992 5.818 +-1 -2 -1.304 3.194 -14.999 +-1 -2 14.499 9.047 -14.126 +-1 -2 -24.969 2.440 7.757 +-1 -2 -24.215 13.318 13.233 +-1 -2 3.281 -7.571 14.055 +-1 -2 -16.631 -1.079 -11.642 +-1 -2 33.910 -6.317 -9.369 +-1 -2 37.664 -4.931 -12.510 +-1 -2 -23.598 -8.924 3.462 +-1 -2 24.986 -9.769 -14.015 +-1 -2 12.407 -5.281 6.950 +-1 -2 -36.110 12.047 -6.674 +-1 -2 25.488 -15.206 -14.025 +-1 -2 -32.643 8.787 0.154 +-1 -2 -14.534 16.274 -7.342 +-1 -2 26.704 -17.204 5.115 +-1 -2 -28.329 -16.516 -15.822 +-1 -2 13.571 -16.186 13.031 +-1 -2 -11.429 -2.984 16.068 +-1 -2 -35.700 5.003 -7.533 +-1 -2 30.834 -10.881 -7.255 +-1 -2 27.009 5.267 12.371 +-1 -2 26.504 9.561 9.008 +-1 -2 5.367 -1.449 2.623 +-1 -2 -29.551 15.440 -9.108 +-1 -2 -18.286 2.204 -16.040 diff --git a/tests/testing_fodder/test_cavity_synthetic/res_orig/rt_is.10001 b/tests/testing_fodder/test_cavity_synthetic/res_orig/rt_is.10001 new file mode 100644 index 0000000..c8f93e6 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/res_orig/rt_is.10001 @@ -0,0 +1,97 @@ +96 +1 -9.015 2.927 -16.720 18 9 7 67 +2 -7.645 -9.020 -6.160 88 83 33 52 +3 19.352 -17.386 6.728 43 31 60 63 +4 -32.744 -5.758 14.272 92 34 80 21 +5 18.431 10.832 -10.569 84 33 12 39 +6 36.949 -5.349 -1.172 16 46 27 93 +7 -19.617 -4.575 4.417 86 93 16 1 +8 8.582 -1.118 -11.440 94 7 54 81 +9 30.311 4.564 -10.305 3 24 43 16 +10 12.792 -2.158 -16.020 40 71 76 7 +11 11.807 -14.954 3.007 74 86 24 89 +12 -7.646 -6.150 0.638 26 72 56 23 +13 -19.070 3.150 7.961 89 51 30 72 +14 28.940 11.433 -1.501 72 90 61 86 +15 10.934 10.566 1.142 64 19 31 14 +16 -0.768 5.709 -4.230 56 22 68 37 +17 15.329 2.295 -5.081 5 15 50 24 +18 7.532 0.326 -12.395 85 35 23 25 +19 10.426 -14.451 12.228 37 38 89 11 +20 32.607 -7.990 4.928 58 64 63 80 +21 -27.516 8.319 15.666 63 84 13 6 +22 -7.156 -15.113 -3.747 78 82 75 53 +23 -31.370 7.721 -17.849 39 12 28 56 +24 22.606 -8.820 12.624 59 37 95 88 +25 36.226 -4.620 -8.021 14 23 70 78 +26 -18.723 2.422 6.242 60 2 74 19 +27 36.212 -12.161 15.533 80 69 77 83 +28 -17.260 -4.648 10.483 36 3 8 26 +29 6.424 -0.957 16.235 50 45 87 66 +30 -11.474 -0.256 -1.879 9 41 2 8 +31 12.559 1.021 1.212 15 14 22 76 +32 -28.189 17.607 17.740 31 66 37 84 +33 18.265 8.257 -16.566 53 44 82 41 +34 -31.630 -0.820 5.041 66 49 15 59 +35 6.814 -0.387 -14.351 17 80 64 10 +36 -35.523 -12.285 -13.003 34 25 40 69 +37 -29.445 -4.622 -4.994 57 70 57 42 +38 -29.126 -17.089 17.515 13 95 79 27 +39 32.183 -2.534 4.235 46 75 9 68 +40 -0.283 13.600 -14.159 25 61 90 85 +41 13.389 -8.503 12.506 51 13 86 38 +42 -34.498 8.116 -16.657 29 32 53 57 +43 33.681 4.878 6.307 81 50 93 65 +44 -28.009 -2.306 10.164 77 65 58 9 +45 3.044 -7.118 -7.259 11 79 59 95 +46 -37.934 -1.535 1.828 68 6 62 22 +47 4.302 10.272 4.930 6 16 94 55 +48 -0.895 16.169 12.916 32 94 3 75 +49 13.444 17.195 13.852 69 26 46 43 +50 13.841 13.308 10.860 30 1 4 32 +51 34.572 -8.234 12.524 55 62 47 92 +52 -15.387 -8.779 12.254 95 87 5 51 +53 20.289 2.773 17.085 82 43 91 13 +54 -3.234 -2.076 -13.225 91 68 0 18 +55 21.468 -15.696 2.713 47 59 25 94 +56 -32.606 14.493 -0.933 67 4 19 29 +57 -18.860 -1.441 -8.808 49 57 72 45 +58 24.961 6.651 16.351 22 67 67 70 +59 2.688 -17.609 -10.999 71 54 29 50 +60 -3.655 15.607 -2.903 2 0 49 48 +61 15.982 9.643 -12.345 65 11 18 40 +62 -10.752 -6.333 17.852 79 91 36 58 +63 -31.511 15.417 -6.714 48 60 6 0 +64 -26.751 -15.001 2.024 42 5 41 62 +65 13.083 -13.194 10.341 10 20 48 12 +66 17.056 17.982 7.902 0 92 66 60 +67 -6.340 -10.603 -8.938 23 21 26 71 +68 18.118 6.123 -4.304 76 85 83 74 +69 -21.811 9.253 -10.665 28 88 10 61 +70 -37.246 16.707 -6.957 4 78 84 91 +71 22.396 -6.297 -3.288 45 89 38 73 +72 15.233 16.496 -0.485 38 56 85 46 +73 -36.242 -0.055 9.997 75 48 17 77 +74 -0.222 16.488 -11.317 93 18 52 47 +75 -7.595 -13.632 1.601 61 55 69 79 +76 6.135 -2.063 -11.965 20 58 35 64 +77 -8.137 1.294 1.428 19 63 51 2 +78 33.390 -4.392 -13.338 41 30 39 30 +79 -33.770 -6.056 -1.755 24 77 73 31 +80 10.093 12.128 8.628 8 47 11 90 +81 -2.671 13.008 7.810 44 42 1 87 +82 -27.554 -15.977 -5.876 33 29 81 28 +83 20.226 11.765 16.945 90 40 45 82 +84 -36.900 2.302 -8.408 73 53 55 35 +85 19.409 11.193 3.726 70 76 21 44 +86 20.649 4.804 15.698 62 81 88 33 +87 3.480 8.709 16.416 27 17 32 49 +88 -5.562 -9.971 -10.975 35 36 78 17 +89 2.171 -7.285 -2.609 54 73 34 5 +90 22.975 -5.970 -5.607 52 74 92 54 +91 -5.377 9.600 -14.614 1 8 42 20 +92 -12.528 13.790 17.585 7 39 14 34 +93 18.812 -5.990 12.050 83 10 65 4 +94 25.926 -9.208 6.305 87 28 44 15 +95 -30.580 8.931 -5.441 21 52 20 36 +96 -20.187 -7.884 12.874 12 27 71 3 diff --git a/tests/testing_fodder/test_cavity_synthetic/res_orig/rt_is.10002 b/tests/testing_fodder/test_cavity_synthetic/res_orig/rt_is.10002 new file mode 100644 index 0000000..26064a4 --- /dev/null +++ b/tests/testing_fodder/test_cavity_synthetic/res_orig/rt_is.10002 @@ -0,0 +1,97 @@ +96 +1 33.100 -10.874 -3.731 23 15 84 3 +2 -1.282 -5.544 17.803 18 8 58 19 +3 -25.620 -7.420 -8.545 0 92 32 71 +4 -32.080 9.467 13.174 85 72 37 21 +5 21.834 7.900 10.078 72 44 95 34 +6 21.174 8.109 9.754 10 85 91 1 +7 31.382 4.365 3.668 17 30 71 69 +8 -22.386 -14.708 -11.052 35 12 4 52 +9 -23.193 4.761 2.856 82 4 3 86 +10 13.888 5.177 -12.495 69 57 70 88 +11 3.080 8.242 2.359 20 34 25 90 +12 -17.690 -11.043 -4.786 60 83 77 80 +13 -4.770 6.324 6.119 29 32 38 95 +14 34.353 7.246 15.820 8 53 55 2 +15 16.035 -2.154 -8.124 46 41 15 59 +16 37.414 4.711 -4.991 84 66 28 65 +17 32.666 -3.420 11.879 39 80 92 42 +18 -9.519 -0.495 -16.794 73 63 0 22 +19 -36.751 -12.133 16.401 56 18 53 89 +20 14.459 7.267 14.869 40 47 62 23 +21 30.360 -16.077 -17.149 32 45 79 15 +22 16.930 15.001 4.989 14 75 40 27 +23 -17.861 11.878 -2.326 49 0 26 14 +24 -32.018 2.278 10.487 47 37 41 0 +25 -25.811 -7.346 2.617 15 19 75 51 +26 1.807 9.784 -2.707 30 22 72 38 +27 -23.763 -0.427 5.064 92 1 2 50 +28 -18.965 12.662 17.085 41 52 49 82 +29 -37.281 -14.578 -16.865 7 59 1 87 +30 -6.163 14.971 10.397 13 14 14 67 +31 34.834 -11.174 -16.321 38 33 9 93 +32 -23.527 -16.023 0.975 34 5 24 10 +33 31.479 -10.415 16.823 19 9 33 37 +34 -10.904 17.047 -10.996 21 3 67 47 +35 2.338 -15.091 8.570 63 79 63 8 +36 -24.309 3.802 1.276 4 81 60 9 +37 7.183 -3.599 15.415 12 31 29 55 +38 -6.721 11.602 -15.325 37 69 11 66 +39 -10.654 0.968 1.899 65 87 21 31 +40 19.542 4.372 5.107 79 55 6 83 +41 11.825 11.680 6.344 70 43 13 62 +42 -14.056 -1.276 10.696 80 62 80 70 +43 26.936 6.253 -14.169 68 90 12 73 +44 -29.039 15.648 8.516 93 40 69 28 +45 4.990 11.953 -15.933 27 11 43 46 +46 -14.723 -9.350 3.145 61 51 90 33 +47 -19.024 -16.906 4.685 26 35 46 68 +48 12.761 11.736 -17.431 53 49 66 75 +49 22.932 -3.406 -17.093 28 10 7 76 +50 34.301 14.013 5.332 95 65 54 7 +51 24.846 -16.626 -8.352 71 95 31 54 +52 29.010 7.454 -12.738 74 50 20 20 +53 -7.365 -7.525 5.655 2 91 93 61 +54 -20.517 -0.265 -12.633 89 29 42 36 +55 -14.358 3.194 13.554 36 42 35 39 +56 -15.743 -15.729 -2.462 91 20 94 17 +57 -26.586 13.217 -3.685 87 16 78 58 +58 -10.498 15.683 -6.299 44 25 86 72 +59 -15.002 8.755 1.199 11 58 22 85 +60 26.292 -1.299 1.988 76 82 34 25 +61 -11.952 -3.570 -1.630 52 27 87 35 +62 23.324 2.842 -11.248 9 56 73 11 +63 14.251 -15.957 14.159 48 74 39 30 +64 33.547 13.799 -1.132 83 89 27 41 +65 8.409 -14.003 -0.366 88 68 10 12 +66 7.368 -6.988 14.633 51 61 44 74 +67 22.414 -2.333 17.837 66 17 82 45 +68 26.398 -3.567 5.101 90 39 56 24 +69 -3.495 -2.383 6.971 42 54 61 32 +70 -4.115 9.992 5.818 86 7 8 13 +71 -1.304 3.194 -14.999 31 71 89 5 +72 14.499 9.047 -14.126 25 28 16 64 +73 -24.969 2.440 7.757 81 76 83 44 +74 -24.215 13.318 13.233 77 84 36 48 +75 3.281 -7.571 14.055 55 70 18 29 +76 -16.631 -1.079 -11.642 6 73 85 84 +77 33.910 -6.317 -9.369 24 93 45 92 +78 37.664 -4.931 -12.510 64 48 74 91 +79 -23.598 -8.924 3.462 45 46 64 78 +80 24.986 -9.769 -14.015 3 67 48 56 +81 12.407 -5.281 6.950 33 88 68 43 +82 -36.110 12.047 -6.674 75 64 30 79 +83 25.488 -15.206 -14.025 62 86 23 4 +84 -32.643 8.787 0.154 78 21 88 53 +85 -14.534 16.274 -7.342 94 94 57 81 +86 26.704 -17.204 5.115 57 2 51 57 +87 -28.329 -16.516 -15.822 67 13 19 60 +88 13.571 -16.186 13.031 58 38 17 40 +89 -11.429 -2.984 16.068 5 36 50 18 +90 -35.700 5.003 -7.533 50 60 5 16 +91 30.834 -10.881 -7.255 1 24 47 77 +92 27.009 5.267 12.371 22 23 65 26 +93 26.504 9.561 9.008 54 78 76 94 +94 5.367 -1.449 2.623 59 26 52 63 +95 -29.551 15.440 -9.108 43 6 59 6 +96 -18.286 2.204 -16.040 16 77 81 49 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a96057f --- /dev/null +++ b/uv.lock @@ -0,0 +1,954 @@ +requires-python = ">=3.12, <3.14" +revision = 3 +version = 1 + +[[package]] +dependencies = [ + {name = "pygments"} +] +name = "accessible-pygments" +sdist = {url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.0.5" +wheels = [ + {url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z"} +] + +[[package]] +name = "alabaster" +sdist = {url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z"} +source = {registry = "https://pypi.org/simple"} +version = "1.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z"} +] + +[[package]] +name = "astroid" +sdist = {url = "https://files.pythonhosted.org/packages/a2/4c/569eefb533ce71bc9f4f12a4a0d7f0ba27e500681dec1312d4657e849d20/astroid-4.1.1.tar.gz", hash = "sha256:445d831fe785df8c670bbb46b900b8424b82f85b4af187103f71a63a63ebed43", size = 412522, upload-time = "2026-02-23T02:36:30.315Z"} +source = {registry = "https://pypi.org/simple"} +version = "4.1.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/32/53/bac4724684064bfee95ece0bb6caf3887e509006845e25388a12cac26d0c/astroid-4.1.1-py3-none-any.whl", hash = "sha256:6b28522096f7e7a36ffcf3be60e77de15e2411ab3a713184beac33fb8f20c0c9", size = 279273, upload-time = "2026-02-23T02:36:28.676Z"} +] + +[[package]] +name = "babel" +sdist = {url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.18.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z"} +] + +[[package]] +dependencies = [ + {name = "soupsieve"}, + {name = "typing-extensions"} +] +name = "beautifulsoup4" +sdist = {url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z"} +source = {registry = "https://pypi.org/simple"} +version = "4.14.3" +wheels = [ + {url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z"} +] + +[[package]] +name = "certifi" +sdist = {url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z"} +source = {registry = "https://pypi.org/simple"} +version = "2026.2.25" +wheels = [ + {url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z"} +] + +[[package]] +name = "cfgv" +sdist = {url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z"} +source = {registry = "https://pypi.org/simple"} +version = "3.5.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z"} +] + +[[package]] +name = "charset-normalizer" +sdist = {url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z"} +source = {registry = "https://pypi.org/simple"} +version = "3.4.5" +wheels = [ + {url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z"}, + {url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z"}, + {url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z"}, + {url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z"}, + {url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z"}, + {url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z"}, + {url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z"}, + {url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z"}, + {url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z"}, + {url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z"}, + {url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z"}, + {url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z"}, + {url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z"}, + {url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z"}, + {url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z"}, + {url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z"}, + {url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z"}, + {url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z"}, + {url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z"}, + {url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z"}, + {url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z"}, + {url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z"}, + {url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z"}, + {url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z"}, + {url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z"}, + {url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z"}, + {url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z"}, + {url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z"}, + {url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z"}, + {url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z"}, + {url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z"}, + {url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z"}, + {url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z"} +] + +[[package]] +name = "colorama" +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"} +source = {registry = "https://pypi.org/simple"} +version = "0.4.6" +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 = "coverage" +sdist = {url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z"} +source = {registry = "https://pypi.org/simple"} +version = "7.13.4" +wheels = [ + {url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z"}, + {url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z"}, + {url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z"}, + {url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z"}, + {url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z"}, + {url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z"}, + {url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z"}, + {url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z"}, + {url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z"}, + {url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z"}, + {url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z"}, + {url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z"}, + {url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z"}, + {url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z"}, + {url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z"}, + {url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z"}, + {url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z"}, + {url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z"}, + {url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z"}, + {url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z"}, + {url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z"}, + {url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z"}, + {url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z"}, + {url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z"}, + {url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z"}, + {url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z"}, + {url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z"}, + {url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z"}, + {url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z"}, + {url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z"}, + {url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z"}, + {url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z"}, + {url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z"}, + {url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z"}, + {url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z"}, + {url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z"}, + {url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z"}, + {url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z"}, + {url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z"}, + {url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z"}, + {url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z"}, + {url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z"}, + {url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z"}, + {url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z"}, + {url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z"}, + {url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z"} +] + +[[package]] +name = "distlib" +sdist = {url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.4.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z"} +] + +[[package]] +name = "docutils" +sdist = {url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.22.4" +wheels = [ + {url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z"} +] + +[[package]] +name = "filelock" +sdist = {url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z"} +source = {registry = "https://pypi.org/simple"} +version = "3.25.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z"} +] + +[[package]] +name = "identify" +sdist = {url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.6.17" +wheels = [ + {url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z"} +] + +[[package]] +name = "idna" +sdist = {url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z"} +source = {registry = "https://pypi.org/simple"} +version = "3.11" +wheels = [ + {url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z"} +] + +[[package]] +name = "imagesize" +sdist = {url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z"} +] + +[[package]] +name = "iniconfig" +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"} +source = {registry = "https://pypi.org/simple"} +version = "2.3.0" +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]] +dependencies = [ + {name = "markupsafe"} +] +name = "jinja2" +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"} +source = {registry = "https://pypi.org/simple"} +version = "3.1.6" +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 = "librt" +sdist = {url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.8.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z"}, + {url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z"}, + {url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z"}, + {url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z"}, + {url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z"}, + {url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z"}, + {url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z"}, + {url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z"}, + {url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z"}, + {url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z"}, + {url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z"}, + {url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z"}, + {url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z"}, + {url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z"}, + {url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z"}, + {url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z"}, + {url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z"}, + {url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z"}, + {url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z"}, + {url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z"}, + {url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z"}, + {url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z"}, + {url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z"}, + {url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z"}, + {url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z"}, + {url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z"} +] + +[[package]] +name = "llvmlite" +sdist = {url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.46.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/2b/f8/4db016a5e547d4e054ff2f3b99203d63a497465f81ab78ec8eb2ff7b2304/llvmlite-0.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b9588ad4c63b4f0175a3984b85494f0c927c6b001e3a246a3a7fb3920d9a137", size = 37232767, upload-time = "2025-12-08T18:15:00.737Z"}, + {url = "https://files.pythonhosted.org/packages/aa/85/4890a7c14b4fa54400945cb52ac3cd88545bbdb973c440f98ca41591cdc5/llvmlite-0.46.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3535bd2bb6a2d7ae4012681ac228e5132cdb75fefb1bcb24e33f2f3e0c865ed4", size = 56275176, upload-time = "2025-12-08T18:15:03.936Z"}, + {url = "https://files.pythonhosted.org/packages/6a/07/3d31d39c1a1a08cd5337e78299fca77e6aebc07c059fbd0033e3edfab45c/llvmlite-0.46.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cbfd366e60ff87ea6cc62f50bc4cd800ebb13ed4c149466f50cf2163a473d1e", size = 55128630, upload-time = "2025-12-08T18:15:07.196Z"}, + {url = "https://files.pythonhosted.org/packages/2a/6b/d139535d7590a1bba1ceb68751bef22fadaa5b815bbdf0e858e3875726b2/llvmlite-0.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:398b39db462c39563a97b912d4f2866cd37cba60537975a09679b28fbbc0fb38", size = 38138940, upload-time = "2025-12-08T18:15:10.162Z"}, + {url = "https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172", size = 37232767, upload-time = "2025-12-08T18:15:13.22Z"}, + {url = "https://files.pythonhosted.org/packages/0e/54/737755c0a91558364b9200702c3c9c15d70ed63f9b98a2c32f1c2aa1f3ba/llvmlite-0.46.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6cc19b051753368a9c9f31dc041299059ee91aceec81bd57b0e385e5d5bf1a54", size = 56275176, upload-time = "2025-12-08T18:15:16.339Z"}, + {url = "https://files.pythonhosted.org/packages/e6/91/14f32e1d70905c1c0aa4e6609ab5d705c3183116ca02ac6df2091868413a/llvmlite-0.46.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bca185892908f9ede48c0acd547fe4dc1bafefb8a4967d47db6cf664f9332d12", size = 55128629, upload-time = "2025-12-08T18:15:19.493Z"}, + {url = "https://files.pythonhosted.org/packages/4a/a7/d526ae86708cea531935ae777b6dbcabe7db52718e6401e0fb9c5edea80e/llvmlite-0.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:67438fd30e12349ebb054d86a5a1a57fd5e87d264d2451bcfafbbbaa25b82a35", size = 38138941, upload-time = "2025-12-08T18:15:22.536Z"} +] + +[[package]] +dependencies = [ + {name = "mdurl"} +] +name = "markdown-it-py" +sdist = {url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z"} +source = {registry = "https://pypi.org/simple"} +version = "4.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z"} +] + +[[package]] +name = "markupsafe" +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"} +source = {registry = "https://pypi.org/simple"} +version = "3.0.3" +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"}, + {url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z"}, + {url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z"}, + {url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z"}, + {url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z"}, + {url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z"}, + {url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z"}, + {url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z"}, + {url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z"}, + {url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z"}, + {url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z"}, + {url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z"}, + {url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z"}, + {url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z"}, + {url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z"}, + {url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z"}, + {url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z"}, + {url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z"}, + {url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z"}, + {url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z"}, + {url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z"}, + {url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z"}, + {url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z"} +] + +[[package]] +dependencies = [ + {name = "markdown-it-py"} +] +name = "mdit-py-plugins" +sdist = {url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.5.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z"} +] + +[[package]] +name = "mdurl" +sdist = {url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.1.2" +wheels = [ + {url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z"} +] + +[[package]] +dependencies = [ + {name = "librt", marker = "platform_python_implementation != 'PyPy'"}, + {name = "mypy-extensions"}, + {name = "pathspec"}, + {name = "typing-extensions"} +] +name = "mypy" +sdist = {url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z"} +source = {registry = "https://pypi.org/simple"} +version = "1.19.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z"}, + {url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z"}, + {url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z"}, + {url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z"}, + {url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z"}, + {url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z"}, + {url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z"}, + {url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z"}, + {url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z"}, + {url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z"}, + {url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z"}, + {url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z"}, + {url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z"} +] + +[[package]] +name = "mypy-extensions" +sdist = {url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z"} +source = {registry = "https://pypi.org/simple"} +version = "1.1.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z"} +] + +[[package]] +dependencies = [ + {name = "docutils"}, + {name = "jinja2"}, + {name = "markdown-it-py"}, + {name = "mdit-py-plugins"}, + {name = "pyyaml"}, + {name = "sphinx"} +] +name = "myst-parser" +sdist = {url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z"} +source = {registry = "https://pypi.org/simple"} +version = "5.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z"} +] + +[[package]] +name = "nodeenv" +sdist = {url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z"} +source = {registry = "https://pypi.org/simple"} +version = "1.10.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z"} +] + +[[package]] +dependencies = [ + {name = "llvmlite"}, + {name = "numpy"} +] +name = "numba" +sdist = {url = "https://files.pythonhosted.org/packages/23/c9/a0fb41787d01d621046138da30f6c2100d80857bf34b3390dd68040f27a3/numba-0.64.0.tar.gz", hash = "sha256:95e7300af648baa3308127b1955b52ce6d11889d16e8cfe637b4f85d2fca52b1", size = 2765679, upload-time = "2026-02-18T18:41:20.974Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.64.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/70/a6/9fc52cb4f0d5e6d8b5f4d81615bc01012e3cf24e1052a60f17a68deb8092/numba-0.64.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69440a8e8bc1a81028446f06b363e28635aa67bd51b1e498023f03b812e0ce68", size = 2683418, upload-time = "2026-02-18T18:40:59.886Z"}, + {url = "https://files.pythonhosted.org/packages/9b/89/1a74ea99b180b7a5587b0301ed1b183a2937c4b4b67f7994689b5d36fc34/numba-0.64.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13721011f693ba558b8dd4e4db7f2640462bba1b855bdc804be45bbeb55031a", size = 3804087, upload-time = "2026-02-18T18:41:01.699Z"}, + {url = "https://files.pythonhosted.org/packages/91/e1/583c647404b15f807410510fec1eb9b80cb8474165940b7749f026f21cbc/numba-0.64.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0b180b1133f2b5d8b3f09d96b6d7a9e51a7da5dda3c09e998b5bcfac85d222c", size = 3504309, upload-time = "2026-02-18T18:41:03.252Z"}, + {url = "https://files.pythonhosted.org/packages/85/23/0fce5789b8a5035e7ace21216a468143f3144e02013252116616c58339aa/numba-0.64.0-cp312-cp312-win_amd64.whl", hash = "sha256:e63dc94023b47894849b8b106db28ccb98b49d5498b98878fac1a38f83ac007a", size = 2752740, upload-time = "2026-02-18T18:41:05.097Z"}, + {url = "https://files.pythonhosted.org/packages/52/80/2734de90f9300a6e2503b35ee50d9599926b90cbb7ac54f9e40074cd07f1/numba-0.64.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3bab2c872194dcd985f1153b70782ec0fbbe348fffef340264eacd3a76d59fd6", size = 2683392, upload-time = "2026-02-18T18:41:06.563Z"}, + {url = "https://files.pythonhosted.org/packages/42/e8/14b5853ebefd5b37723ef365c5318a30ce0702d39057eaa8d7d76392859d/numba-0.64.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:703a246c60832cad231d2e73c1182f25bf3cc8b699759ec8fe58a2dbc689a70c", size = 3812245, upload-time = "2026-02-18T18:41:07.963Z"}, + {url = "https://files.pythonhosted.org/packages/8a/a2/f60dc6c96d19b7185144265a5fbf01c14993d37ff4cd324b09d0212aa7ce/numba-0.64.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2e49a7900ee971d32af7609adc0cfe6aa7477c6f6cccdf6d8138538cf7756f", size = 3511328, upload-time = "2026-02-18T18:41:09.504Z"}, + {url = "https://files.pythonhosted.org/packages/9c/2a/fe7003ea7e7237ee7014f8eaeeb7b0d228a2db22572ca85bab2648cf52cb/numba-0.64.0-cp313-cp313-win_amd64.whl", hash = "sha256:396f43c3f77e78d7ec84cdfc6b04969c78f8f169351b3c4db814b97e7acf4245", size = 2752668, upload-time = "2026-02-18T18:41:11.455Z"} +] + +[[package]] +name = "numpy" +sdist = {url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.4.2" +wheels = [ + {url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z"}, + {url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z"}, + {url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z"}, + {url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z"}, + {url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z"}, + {url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z"}, + {url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z"}, + {url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z"}, + {url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z"}, + {url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z"}, + {url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z"}, + {url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z"}, + {url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z"}, + {url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z"}, + {url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z"}, + {url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z"}, + {url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z"}, + {url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z"}, + {url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z"}, + {url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z"}, + {url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z"}, + {url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z"}, + {url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z"}, + {url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z"}, + {url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z"}, + {url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z"}, + {url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z"}, + {url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z"}, + {url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z"}, + {url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z"}, + {url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z"}, + {url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z"} +] + +[[package]] +dependencies = [ + {name = "numba"}, + {name = "numpy"}, + {name = "pyyaml"}, + {name = "scipy"} +] +name = "openptv-python" +source = {editable = "."} + +[package.metadata] +provides-extras = ["docs", "native", "test", "dev"] +requires-dist = [ + {name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1"}, + {name = "myst-parser", marker = "extra == 'dev'", specifier = ">=5.0.0"}, + {name = "myst-parser", marker = "extra == 'docs'", specifier = ">=5.0.0"}, + {name = "numba", specifier = ">=0.64.0"}, + {name = "numpy", specifier = ">=2.4.2"}, + {name = "optv", marker = "extra == 'dev'", specifier = ">=0.3.2"}, + {name = "optv", marker = "extra == 'native'", specifier = ">=0.3.2"}, + {name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1"}, + {name = "pydata-sphinx-theme", marker = "extra == 'dev'", specifier = ">=0.16.1"}, + {name = "pydata-sphinx-theme", marker = "extra == 'docs'", specifier = ">=0.16.1"}, + {name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2"}, + {name = "pytest", marker = "extra == 'test'", specifier = ">=9.0.2"}, + {name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0"}, + {name = "pytest-cov", marker = "extra == 'test'", specifier = ">=7.0.0"}, + {name = "pyyaml", specifier = ">=6.0.3"}, + {name = "scipy", specifier = ">=1.17.1"}, + {name = "sphinx", marker = "extra == 'dev'", specifier = ">=9.1.0"}, + {name = "sphinx", marker = "extra == 'docs'", specifier = ">=9.1.0"}, + {name = "sphinx-autoapi", marker = "extra == 'dev'", specifier = ">=3.7.0"}, + {name = "sphinx-autoapi", marker = "extra == 'docs'", specifier = ">=3.7.0"}, + {name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.12.20250915"} +] + +[package.optional-dependencies] +dev = [ + {name = "mypy"}, + {name = "myst-parser"}, + {name = "optv"}, + {name = "pre-commit"}, + {name = "pydata-sphinx-theme"}, + {name = "pytest"}, + {name = "pytest-cov"}, + {name = "sphinx"}, + {name = "sphinx-autoapi"}, + {name = "types-pyyaml"} +] +docs = [ + {name = "myst-parser"}, + {name = "pydata-sphinx-theme"}, + {name = "sphinx"}, + {name = "sphinx-autoapi"} +] +native = [ + {name = "optv"} +] +test = [ + {name = "pytest"}, + {name = "pytest-cov"} +] + +[[package]] +dependencies = [ + {name = "numpy"}, + {name = "pyyaml"} +] +name = "optv" +source = {registry = "https://pypi.org/simple"} +version = "0.3.2" +wheels = [ + {url = "https://files.pythonhosted.org/packages/93/29/ac2ab83e885a53ae2d4b6537b26c9029ce663618a56eb8e9ab10a0c53330/optv-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9cf47f3756a95ce4aad6895ccd7a8ca9fdc51d513900fb68761a16450289f84e", size = 2347739, upload-time = "2026-03-01T23:03:35.73Z"}, + {url = "https://files.pythonhosted.org/packages/07/0a/cca02bf1a478450f1ba2a3e44db4a917b56e15c6eb6274d195ed217c0950/optv-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4404e00d0bf0d21dfeaa1595ac453fe66ffed26d889e70f6727630280895916b", size = 2262784, upload-time = "2026-03-01T23:03:39.472Z"}, + {url = "https://files.pythonhosted.org/packages/90/f6/47953d940fc82f78a629e59afe0251f936c49452065715349faf2803cb14/optv-0.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9df069358f9283560ba707e23c87f2071ad2c1815c201f3891ed95e3216eb457", size = 6179852, upload-time = "2026-03-01T23:03:47.464Z"}, + {url = "https://files.pythonhosted.org/packages/28/3b/1c38b9b6b9d19e432cd1dbba8c2bd658fd0b852692cd1c9574a67b3bc58d/optv-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555c6a06ab28e1ae93d9c3d21ec89a9a2d6bb573ebf09efd1606553ccf13eeb1", size = 12215450, upload-time = "2026-03-01T23:03:59.265Z"}, + {url = "https://files.pythonhosted.org/packages/c0/6b/6afeed9e26263fd3a6f1cded81efc031c43985aee42bea7686dbc554f7cc/optv-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:e36e3093abdee39a35680e4a2304e5fdcddbb392096741326b7632a13c65b991", size = 1731896, upload-time = "2026-03-01T23:04:03.206Z"}, + {url = "https://files.pythonhosted.org/packages/c6/40/9b8e9edda07f9f5f9d2469cf26edc8ee674a02069145f5d21b8a9b789ad0/optv-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c05949ea6c15dcc8fcdb36ca4e935aff273ac81d80405930e7a2b64ff2a710e", size = 2342514, upload-time = "2026-03-01T23:04:06.56Z"}, + {url = "https://files.pythonhosted.org/packages/b4/76/30921ef229074276aebfdd18f0603d7a14461c3cf74a9964678bbf82d80a/optv-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a549e36df4d3f1c3f30b7a667e6595f52da50bcb3727f57de045a652ab9a1194", size = 2257415, upload-time = "2026-03-01T23:04:10.218Z"}, + {url = "https://files.pythonhosted.org/packages/ae/e1/bbeb1dd50759ec6c88d074570a1790741fb373fde4c66915980e192eb51c/optv-0.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3fdc240569f2804c6d74951a27e0b7f168e85de2a9e4ab5b09a290efc8d70417", size = 6070665, upload-time = "2026-03-01T23:04:17.879Z"}, + {url = "https://files.pythonhosted.org/packages/f3/3a/76b420f2c7937fb4b5231a05dacc45eb66c46d88515b56b96701e89b2708/optv-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:80529429fed3f984cd400e77fcef986b7409322b0b14fdd6b8aad47fde69cef4", size = 5968060, upload-time = "2026-03-01T23:04:25.215Z"}, + {url = "https://files.pythonhosted.org/packages/31/d3/475e6f830eca6f886f5d0d74e542d059978d6dabfc16ddd61f004ea727d8/optv-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c863518e8c91e018887f24d0cf99ca1720f8f2155fe92b9b46fa794e97e43259", size = 1728112, upload-time = "2026-03-01T23:04:28.062Z"} +] + +[[package]] +name = "packaging" +sdist = {url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z"} +source = {registry = "https://pypi.org/simple"} +version = "26.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z"} +] + +[[package]] +name = "pathspec" +sdist = {url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z"} +source = {registry = "https://pypi.org/simple"} +version = "1.0.4" +wheels = [ + {url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z"} +] + +[[package]] +name = "platformdirs" +sdist = {url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z"} +source = {registry = "https://pypi.org/simple"} +version = "4.9.4" +wheels = [ + {url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z"} +] + +[[package]] +name = "pluggy" +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"} +source = {registry = "https://pypi.org/simple"} +version = "1.6.0" +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]] +dependencies = [ + {name = "cfgv"}, + {name = "identify"}, + {name = "nodeenv"}, + {name = "pyyaml"}, + {name = "virtualenv"} +] +name = "pre-commit" +sdist = {url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z"} +source = {registry = "https://pypi.org/simple"} +version = "4.5.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z"} +] + +[[package]] +dependencies = [ + {name = "accessible-pygments"}, + {name = "babel"}, + {name = "beautifulsoup4"}, + {name = "docutils"}, + {name = "pygments"}, + {name = "sphinx"}, + {name = "typing-extensions"} +] +name = "pydata-sphinx-theme" +sdist = {url = "https://files.pythonhosted.org/packages/00/20/bb50f9de3a6de69e6abd6b087b52fa2418a0418b19597601605f855ad044/pydata_sphinx_theme-0.16.1.tar.gz", hash = "sha256:a08b7f0b7f70387219dc659bff0893a7554d5eb39b59d3b8ef37b8401b7642d7", size = 2412693, upload-time = "2024-12-17T10:53:39.537Z"} +source = {registry = "https://pypi.org/simple"} +version = "0.16.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/e2/0d/8ba33fa83a7dcde13eb3c1c2a0c1cc29950a048bfed6d9b0d8b6bd710b4c/pydata_sphinx_theme-0.16.1-py3-none-any.whl", hash = "sha256:225331e8ac4b32682c18fcac5a57a6f717c4e632cea5dd0e247b55155faeccde", size = 6723264, upload-time = "2024-12-17T10:53:35.645Z"} +] + +[[package]] +name = "pygments" +sdist = {url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.19.2" +wheels = [ + {url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z"} +] + +[[package]] +dependencies = [ + {name = "colorama", marker = "sys_platform == 'win32'"}, + {name = "iniconfig"}, + {name = "packaging"}, + {name = "pluggy"}, + {name = "pygments"} +] +name = "pytest" +sdist = {url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z"} +source = {registry = "https://pypi.org/simple"} +version = "9.0.2" +wheels = [ + {url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z"} +] + +[[package]] +dependencies = [ + {name = "coverage"}, + {name = "pluggy"}, + {name = "pytest"} +] +name = "pytest-cov" +sdist = {url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z"} +source = {registry = "https://pypi.org/simple"} +version = "7.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z"} +] + +[[package]] +dependencies = [ + {name = "filelock"}, + {name = "platformdirs"} +] +name = "python-discovery" +sdist = {url = "https://files.pythonhosted.org/packages/ec/67/09765eacf4e44413c4f8943ba5a317fcb9c7b447c3b8b0b7fce7e3090b0b/python_discovery-1.1.1.tar.gz", hash = "sha256:584c08b141c5b7029f206b4e8b78b1a1764b22121e21519b89dec56936e95b0a", size = 56016, upload-time = "2026-03-07T00:00:56.354Z"} +source = {registry = "https://pypi.org/simple"} +version = "1.1.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/75/0f/2bf7e3b5a4a65f623cb820feb5793e243fad58ae561015ee15a6152f67a2/python_discovery-1.1.1-py3-none-any.whl", hash = "sha256:69f11073fa2392251e405d4e847d60ffffd25fd762a0dc4d1a7d6b9c3f79f1a3", size = 30732, upload-time = "2026-03-07T00:00:55.143Z"} +] + +[[package]] +name = "pyyaml" +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"} +source = {registry = "https://pypi.org/simple"} +version = "6.0.3" +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"} +] + +[[package]] +dependencies = [ + {name = "certifi"}, + {name = "charset-normalizer"}, + {name = "idna"}, + {name = "urllib3"} +] +name = "requests" +sdist = {url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.32.5" +wheels = [ + {url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z"} +] + +[[package]] +name = "roman-numerals" +sdist = {url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z"} +source = {registry = "https://pypi.org/simple"} +version = "4.1.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z"} +] + +[[package]] +dependencies = [ + {name = "numpy"} +] +name = "scipy" +sdist = {url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z"} +source = {registry = "https://pypi.org/simple"} +version = "1.17.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z"}, + {url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z"}, + {url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z"}, + {url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z"}, + {url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z"}, + {url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z"}, + {url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z"}, + {url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z"}, + {url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z"}, + {url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z"}, + {url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z"}, + {url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z"}, + {url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z"}, + {url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z"}, + {url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z"}, + {url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z"}, + {url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z"}, + {url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z"}, + {url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z"}, + {url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z"}, + {url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z"}, + {url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z"}, + {url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z"}, + {url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z"}, + {url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z"}, + {url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z"}, + {url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z"}, + {url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z"}, + {url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z"}, + {url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z"} +] + +[[package]] +name = "snowballstemmer" +sdist = {url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z"} +source = {registry = "https://pypi.org/simple"} +version = "3.0.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z"} +] + +[[package]] +name = "soupsieve" +sdist = {url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.8.3" +wheels = [ + {url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z"} +] + +[[package]] +dependencies = [ + {name = "alabaster"}, + {name = "babel"}, + {name = "colorama", marker = "sys_platform == 'win32'"}, + {name = "docutils"}, + {name = "imagesize"}, + {name = "jinja2"}, + {name = "packaging"}, + {name = "pygments"}, + {name = "requests"}, + {name = "roman-numerals"}, + {name = "snowballstemmer"}, + {name = "sphinxcontrib-applehelp"}, + {name = "sphinxcontrib-devhelp"}, + {name = "sphinxcontrib-htmlhelp"}, + {name = "sphinxcontrib-jsmath"}, + {name = "sphinxcontrib-qthelp"}, + {name = "sphinxcontrib-serializinghtml"} +] +name = "sphinx" +sdist = {url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z"} +source = {registry = "https://pypi.org/simple"} +version = "9.1.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z"} +] + +[[package]] +dependencies = [ + {name = "astroid"}, + {name = "jinja2"}, + {name = "pyyaml"}, + {name = "sphinx"} +] +name = "sphinx-autoapi" +sdist = {url = "https://files.pythonhosted.org/packages/47/22/7ea4b660e98ffa271dbec5847b64738127955746d887f9596940279d30bf/sphinx_autoapi-3.7.0.tar.gz", hash = "sha256:5c8a934d788f00d11b8c8092536c7373592cce986820de8dd7daf6dfd79afd91", size = 58136, upload-time = "2026-02-11T05:24:29.86Z"} +source = {registry = "https://pypi.org/simple"} +version = "3.7.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/d8/11/9825443890d3ef6b6a0db938054e7c806e9911509e0315fe984d00a090c1/sphinx_autoapi-3.7.0-py3-none-any.whl", hash = "sha256:5ea37271b50de08538cf6e33044ef4fb6e981ddb693060ec84f297005014fdfd", size = 36021, upload-time = "2026-02-11T05:24:28.657Z"} +] + +[[package]] +name = "sphinxcontrib-applehelp" +sdist = {url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z"} +] + +[[package]] +name = "sphinxcontrib-devhelp" +sdist = {url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z"} +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +sdist = {url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.1.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z"} +] + +[[package]] +name = "sphinxcontrib-jsmath" +sdist = {url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z"} +source = {registry = "https://pypi.org/simple"} +version = "1.0.1" +wheels = [ + {url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z"} +] + +[[package]] +name = "sphinxcontrib-qthelp" +sdist = {url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z"} +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +sdist = {url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.0.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z"} +] + +[[package]] +name = "types-pyyaml" +sdist = {url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z"} +source = {registry = "https://pypi.org/simple"} +version = "6.0.12.20250915" +wheels = [ + {url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z"} +] + +[[package]] +name = "typing-extensions" +sdist = {url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z"} +source = {registry = "https://pypi.org/simple"} +version = "4.15.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z"} +] + +[[package]] +name = "urllib3" +sdist = {url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z"} +source = {registry = "https://pypi.org/simple"} +version = "2.6.3" +wheels = [ + {url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z"} +] + +[[package]] +dependencies = [ + {name = "distlib"}, + {name = "filelock"}, + {name = "platformdirs"}, + {name = "python-discovery"} +] +name = "virtualenv" +sdist = {url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471, upload-time = "2026-02-27T08:49:29.702Z"} +source = {registry = "https://pypi.org/simple"} +version = "21.1.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z"} +]