From 55add0f206dd780e8c634f708b01450bdcb74767 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Fri, 3 Jul 2026 22:14:37 -0400 Subject: [PATCH 01/32] Port packaging from poetry to uv with PEP 621 metadata Convert pyproject.toml to a standard [project] table with the hatchling build backend, raising the supported Python floor to 3.10 (the version the postproc image runs) and dropping the per-dependency version markers that floor made necessary. ipdb moves to the dev dependency group. Remove the now-redundant poetry.lock, setup.py, and requirements.txt. --- README.md | 3 +- poetry.lock | 1421 ---------------------------------------------- pyproject.toml | 67 ++- requirements.txt | 18 - setup.py | 9 - 5 files changed, 39 insertions(+), 1479 deletions(-) delete mode 100644 poetry.lock delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/README.md b/README.md index 0cc85a39..1279a9f7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ KAMERA, or the **K**nowledge-guided Image **A**cquisition **M**anag**ER** and ** git clone https://github.com/Kitware/kamera.git cd kamera # For the pure post-processing and generating flight summary, you can install -# the requirements in requirements.txt, or use the provided dockerfile +# the package with uv (https://docs.astral.sh/uv/), or use the provided dockerfile +uv sync make postflight # Builds the core docker images for use in the onboard sytems make nuvo diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 689f34d6..00000000 --- a/poetry.lock +++ /dev/null @@ -1,1421 +0,0 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. - -[[package]] -name = "asttokens" -version = "2.4.1" -description = "Annotate AST trees with source code positions" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, - {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, -] - -[package.dependencies] -six = ">=1.12.0" - -[package.extras] -astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] -test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -category = "main" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "contourpy" -version = "1.3.0" -description = "Python library for calculating contours of 2D quadrilateral grids" -category = "main" -optional = false -python-versions = ">=3.9" -files = [ - {file = "contourpy-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:880ea32e5c774634f9fcd46504bf9f080a41ad855f4fef54f5380f5133d343c7"}, - {file = "contourpy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:76c905ef940a4474a6289c71d53122a4f77766eef23c03cd57016ce19d0f7b42"}, - {file = "contourpy-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92f8557cbb07415a4d6fa191f20fd9d2d9eb9c0b61d1b2f52a8926e43c6e9af7"}, - {file = "contourpy-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f965570cff02b874773c49bfe85562b47030805d7d8360748f3eca570f4cab"}, - {file = "contourpy-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cacd81e2d4b6f89c9f8a5b69b86490152ff39afc58a95af002a398273e5ce589"}, - {file = "contourpy-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69375194457ad0fad3a839b9e29aa0b0ed53bb54db1bfb6c3ae43d111c31ce41"}, - {file = "contourpy-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a52040312b1a858b5e31ef28c2e865376a386c60c0e248370bbea2d3f3b760d"}, - {file = "contourpy-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3faeb2998e4fcb256542e8a926d08da08977f7f5e62cf733f3c211c2a5586223"}, - {file = "contourpy-1.3.0-cp310-cp310-win32.whl", hash = "sha256:36e0cff201bcb17a0a8ecc7f454fe078437fa6bda730e695a92f2d9932bd507f"}, - {file = "contourpy-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:87ddffef1dbe5e669b5c2440b643d3fdd8622a348fe1983fad7a0f0ccb1cd67b"}, - {file = "contourpy-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fa4c02abe6c446ba70d96ece336e621efa4aecae43eaa9b030ae5fb92b309ad"}, - {file = "contourpy-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:834e0cfe17ba12f79963861e0f908556b2cedd52e1f75e6578801febcc6a9f49"}, - {file = "contourpy-1.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbc4c3217eee163fa3984fd1567632b48d6dfd29216da3ded3d7b844a8014a66"}, - {file = "contourpy-1.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4865cd1d419e0c7a7bf6de1777b185eebdc51470800a9f42b9e9decf17762081"}, - {file = "contourpy-1.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:303c252947ab4b14c08afeb52375b26781ccd6a5ccd81abcdfc1fafd14cf93c1"}, - {file = "contourpy-1.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:637f674226be46f6ba372fd29d9523dd977a291f66ab2a74fbeb5530bb3f445d"}, - {file = "contourpy-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:76a896b2f195b57db25d6b44e7e03f221d32fe318d03ede41f8b4d9ba1bff53c"}, - {file = "contourpy-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e1fd23e9d01591bab45546c089ae89d926917a66dceb3abcf01f6105d927e2cb"}, - {file = "contourpy-1.3.0-cp311-cp311-win32.whl", hash = "sha256:d402880b84df3bec6eab53cd0cf802cae6a2ef9537e70cf75e91618a3801c20c"}, - {file = "contourpy-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:6cb6cc968059db9c62cb35fbf70248f40994dfcd7aa10444bbf8b3faeb7c2d67"}, - {file = "contourpy-1.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:570ef7cf892f0afbe5b2ee410c507ce12e15a5fa91017a0009f79f7d93a1268f"}, - {file = "contourpy-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:da84c537cb8b97d153e9fb208c221c45605f73147bd4cadd23bdae915042aad6"}, - {file = "contourpy-1.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0be4d8425bfa755e0fd76ee1e019636ccc7c29f77a7c86b4328a9eb6a26d0639"}, - {file = "contourpy-1.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c0da700bf58f6e0b65312d0a5e695179a71d0163957fa381bb3c1f72972537c"}, - {file = "contourpy-1.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb8b141bb00fa977d9122636b16aa67d37fd40a3d8b52dd837e536d64b9a4d06"}, - {file = "contourpy-1.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3634b5385c6716c258d0419c46d05c8aa7dc8cb70326c9a4fb66b69ad2b52e09"}, - {file = "contourpy-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0dce35502151b6bd35027ac39ba6e5a44be13a68f55735c3612c568cac3805fd"}, - {file = "contourpy-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea348f053c645100612b333adc5983d87be69acdc6d77d3169c090d3b01dc35"}, - {file = "contourpy-1.3.0-cp312-cp312-win32.whl", hash = "sha256:90f73a5116ad1ba7174341ef3ea5c3150ddf20b024b98fb0c3b29034752c8aeb"}, - {file = "contourpy-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b11b39aea6be6764f84360fce6c82211a9db32a7c7de8fa6dd5397cf1d079c3b"}, - {file = "contourpy-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3e1c7fa44aaae40a2247e2e8e0627f4bea3dd257014764aa644f319a5f8600e3"}, - {file = "contourpy-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:364174c2a76057feef647c802652f00953b575723062560498dc7930fc9b1cb7"}, - {file = "contourpy-1.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32b238b3b3b649e09ce9aaf51f0c261d38644bdfa35cbaf7b263457850957a84"}, - {file = "contourpy-1.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d51fca85f9f7ad0b65b4b9fe800406d0d77017d7270d31ec3fb1cc07358fdea0"}, - {file = "contourpy-1.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:732896af21716b29ab3e988d4ce14bc5133733b85956316fb0c56355f398099b"}, - {file = "contourpy-1.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d73f659398a0904e125280836ae6f88ba9b178b2fed6884f3b1f95b989d2c8da"}, - {file = "contourpy-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6c7c2408b7048082932cf4e641fa3b8ca848259212f51c8c59c45aa7ac18f14"}, - {file = "contourpy-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f317576606de89da6b7e0861cf6061f6146ead3528acabff9236458a6ba467f8"}, - {file = "contourpy-1.3.0-cp313-cp313-win32.whl", hash = "sha256:31cd3a85dbdf1fc002280c65caa7e2b5f65e4a973fcdf70dd2fdcb9868069294"}, - {file = "contourpy-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:4553c421929ec95fb07b3aaca0fae668b2eb5a5203d1217ca7c34c063c53d087"}, - {file = "contourpy-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:345af746d7766821d05d72cb8f3845dfd08dd137101a2cb9b24de277d716def8"}, - {file = "contourpy-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3bb3808858a9dc68f6f03d319acd5f1b8a337e6cdda197f02f4b8ff67ad2057b"}, - {file = "contourpy-1.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:420d39daa61aab1221567b42eecb01112908b2cab7f1b4106a52caaec8d36973"}, - {file = "contourpy-1.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d63ee447261e963af02642ffcb864e5a2ee4cbfd78080657a9880b8b1868e18"}, - {file = "contourpy-1.3.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:167d6c890815e1dac9536dca00828b445d5d0df4d6a8c6adb4a7ec3166812fa8"}, - {file = "contourpy-1.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:710a26b3dc80c0e4febf04555de66f5fd17e9cf7170a7b08000601a10570bda6"}, - {file = "contourpy-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:75ee7cb1a14c617f34a51d11fa7524173e56551646828353c4af859c56b766e2"}, - {file = "contourpy-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:33c92cdae89ec5135d036e7218e69b0bb2851206077251f04a6c4e0e21f03927"}, - {file = "contourpy-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a11077e395f67ffc2c44ec2418cfebed032cd6da3022a94fc227b6faf8e2acb8"}, - {file = "contourpy-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e8134301d7e204c88ed7ab50028ba06c683000040ede1d617298611f9dc6240c"}, - {file = "contourpy-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e12968fdfd5bb45ffdf6192a590bd8ddd3ba9e58360b29683c6bb71a7b41edca"}, - {file = "contourpy-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fd2a0fc506eccaaa7595b7e1418951f213cf8255be2600f1ea1b61e46a60c55f"}, - {file = "contourpy-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4cfb5c62ce023dfc410d6059c936dcf96442ba40814aefbfa575425a3a7f19dc"}, - {file = "contourpy-1.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68a32389b06b82c2fdd68276148d7b9275b5f5cf13e5417e4252f6d1a34f72a2"}, - {file = "contourpy-1.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:94e848a6b83da10898cbf1311a815f770acc9b6a3f2d646f330d57eb4e87592e"}, - {file = "contourpy-1.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d78ab28a03c854a873787a0a42254a0ccb3cb133c672f645c9f9c8f3ae9d0800"}, - {file = "contourpy-1.3.0-cp39-cp39-win32.whl", hash = "sha256:81cb5ed4952aae6014bc9d0421dec7c5835c9c8c31cdf51910b708f548cf58e5"}, - {file = "contourpy-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:14e262f67bd7e6eb6880bc564dcda30b15e351a594657e55b7eec94b6ef72843"}, - {file = "contourpy-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fe41b41505a5a33aeaed2a613dccaeaa74e0e3ead6dd6fd3a118fb471644fd6c"}, - {file = "contourpy-1.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca7e17a65f72a5133bdbec9ecf22401c62bcf4821361ef7811faee695799779"}, - {file = "contourpy-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1ec4dc6bf570f5b22ed0d7efba0dfa9c5b9e0431aeea7581aa217542d9e809a4"}, - {file = "contourpy-1.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:00ccd0dbaad6d804ab259820fa7cb0b8036bda0686ef844d24125d8287178ce0"}, - {file = "contourpy-1.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ca947601224119117f7c19c9cdf6b3ab54c5726ef1d906aa4a69dfb6dd58102"}, - {file = "contourpy-1.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6ec93afeb848a0845a18989da3beca3eec2c0f852322efe21af1931147d12cb"}, - {file = "contourpy-1.3.0.tar.gz", hash = "sha256:7ffa0db17717a8ffb127efd0c95a4362d996b892c2904db72428d5b52e1938a4"}, -] - -[package.dependencies] -numpy = ">=1.23" - -[package.extras] -bokeh = ["bokeh", "selenium"] -docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] -mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.11.1)", "types-Pillow"] -test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] -test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] - -[[package]] -name = "cycler" -version = "0.12.1" -description = "Composable style cycles" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, - {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, -] - -[package.extras] -docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] -tests = ["pytest", "pytest-cov", "pytest-xdist"] - -[[package]] -name = "datetime" -version = "5.5" -description = "This package provides a DateTime data type, as known from Zope. Unless you need to communicate with Zope APIs, you're probably better off using Python's built-in datetime module." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "DateTime-5.5-py3-none-any.whl", hash = "sha256:0abf6c51cb4ba7cee775ca46ccc727f3afdde463be28dbbe8803631fefd4a120"}, - {file = "DateTime-5.5.tar.gz", hash = "sha256:21ec6331f87a7fcb57bd7c59e8a68bfffe6fcbf5acdbbc7b356d6a9a020191d3"}, -] - -[package.dependencies] -pytz = "*" -"zope.interface" = "*" - -[[package]] -name = "decorator" -version = "5.1.1" -description = "Decorators for Humans" -category = "main" -optional = false -python-versions = ">=3.5" -files = [ - {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, - {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "executing" -version = "2.1.0" -description = "Get the currently executing AST node of a frame, and other information" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf"}, - {file = "executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab"}, -] - -[package.extras] -tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] - -[[package]] -name = "exifread" -version = "3.0.0" -description = "Read Exif metadata from tiff and jpeg files." -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "ExifRead-3.0.0-py3-none-any.whl", hash = "sha256:2c5c59ef03b3bbee75b82b82d2498006b3c13509f35c9a76c7552faff73fa2d5"}, - {file = "ExifRead-3.0.0.tar.gz", hash = "sha256:0ac5a364169dbdf2bd62f94f5c073970ab6694a3166177f5e448b10c943e2ca4"}, -] - -[package.extras] -dev = ["mypy (==0.950)", "pylint (==2.13.8)"] - -[[package]] -name = "fonttools" -version = "4.54.1" -description = "Tools to manipulate font files" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fonttools-4.54.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ed7ee041ff7b34cc62f07545e55e1468808691dddfd315d51dd82a6b37ddef2"}, - {file = "fonttools-4.54.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41bb0b250c8132b2fcac148e2e9198e62ff06f3cc472065dff839327945c5882"}, - {file = "fonttools-4.54.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7965af9b67dd546e52afcf2e38641b5be956d68c425bef2158e95af11d229f10"}, - {file = "fonttools-4.54.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278913a168f90d53378c20c23b80f4e599dca62fbffae4cc620c8eed476b723e"}, - {file = "fonttools-4.54.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0e88e3018ac809b9662615072dcd6b84dca4c2d991c6d66e1970a112503bba7e"}, - {file = "fonttools-4.54.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4aa4817f0031206e637d1e685251ac61be64d1adef111060df84fdcbc6ab6c44"}, - {file = "fonttools-4.54.1-cp310-cp310-win32.whl", hash = "sha256:7e3b7d44e18c085fd8c16dcc6f1ad6c61b71ff463636fcb13df7b1b818bd0c02"}, - {file = "fonttools-4.54.1-cp310-cp310-win_amd64.whl", hash = "sha256:dd9cc95b8d6e27d01e1e1f1fae8559ef3c02c76317da650a19047f249acd519d"}, - {file = "fonttools-4.54.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5419771b64248484299fa77689d4f3aeed643ea6630b2ea750eeab219588ba20"}, - {file = "fonttools-4.54.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:301540e89cf4ce89d462eb23a89464fef50915255ece765d10eee8b2bf9d75b2"}, - {file = "fonttools-4.54.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ae5091547e74e7efecc3cbf8e75200bc92daaeb88e5433c5e3e95ea8ce5aa7"}, - {file = "fonttools-4.54.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82834962b3d7c5ca98cb56001c33cf20eb110ecf442725dc5fdf36d16ed1ab07"}, - {file = "fonttools-4.54.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d26732ae002cc3d2ecab04897bb02ae3f11f06dd7575d1df46acd2f7c012a8d8"}, - {file = "fonttools-4.54.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58974b4987b2a71ee08ade1e7f47f410c367cdfc5a94fabd599c88165f56213a"}, - {file = "fonttools-4.54.1-cp311-cp311-win32.whl", hash = "sha256:ab774fa225238986218a463f3fe151e04d8c25d7de09df7f0f5fce27b1243dbc"}, - {file = "fonttools-4.54.1-cp311-cp311-win_amd64.whl", hash = "sha256:07e005dc454eee1cc60105d6a29593459a06321c21897f769a281ff2d08939f6"}, - {file = "fonttools-4.54.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:54471032f7cb5fca694b5f1a0aaeba4af6e10ae989df408e0216f7fd6cdc405d"}, - {file = "fonttools-4.54.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fa92cb248e573daab8d032919623cc309c005086d743afb014c836636166f08"}, - {file = "fonttools-4.54.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a911591200114969befa7f2cb74ac148bce5a91df5645443371aba6d222e263"}, - {file = "fonttools-4.54.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93d458c8a6a354dc8b48fc78d66d2a8a90b941f7fec30e94c7ad9982b1fa6bab"}, - {file = "fonttools-4.54.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5eb2474a7c5be8a5331146758debb2669bf5635c021aee00fd7c353558fc659d"}, - {file = "fonttools-4.54.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9c563351ddc230725c4bdf7d9e1e92cbe6ae8553942bd1fb2b2ff0884e8b714"}, - {file = "fonttools-4.54.1-cp312-cp312-win32.whl", hash = "sha256:fdb062893fd6d47b527d39346e0c5578b7957dcea6d6a3b6794569370013d9ac"}, - {file = "fonttools-4.54.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4564cf40cebcb53f3dc825e85910bf54835e8a8b6880d59e5159f0f325e637e"}, - {file = "fonttools-4.54.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6e37561751b017cf5c40fce0d90fd9e8274716de327ec4ffb0df957160be3bff"}, - {file = "fonttools-4.54.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:357cacb988a18aace66e5e55fe1247f2ee706e01debc4b1a20d77400354cddeb"}, - {file = "fonttools-4.54.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e953cc0bddc2beaf3a3c3b5dd9ab7554677da72dfaf46951e193c9653e515a"}, - {file = "fonttools-4.54.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58d29b9a294573d8319f16f2f79e42428ba9b6480442fa1836e4eb89c4d9d61c"}, - {file = "fonttools-4.54.1-cp313-cp313-win32.whl", hash = "sha256:9ef1b167e22709b46bf8168368b7b5d3efeaaa746c6d39661c1b4405b6352e58"}, - {file = "fonttools-4.54.1-cp313-cp313-win_amd64.whl", hash = "sha256:262705b1663f18c04250bd1242b0515d3bbae177bee7752be67c979b7d47f43d"}, - {file = "fonttools-4.54.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ed2f80ca07025551636c555dec2b755dd005e2ea8fbeb99fc5cdff319b70b23b"}, - {file = "fonttools-4.54.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9dc080e5a1c3b2656caff2ac2633d009b3a9ff7b5e93d0452f40cd76d3da3b3c"}, - {file = "fonttools-4.54.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d152d1be65652fc65e695e5619e0aa0982295a95a9b29b52b85775243c06556"}, - {file = "fonttools-4.54.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8583e563df41fdecef31b793b4dd3af8a9caa03397be648945ad32717a92885b"}, - {file = "fonttools-4.54.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0d1d353ef198c422515a3e974a1e8d5b304cd54a4c2eebcae708e37cd9eeffb1"}, - {file = "fonttools-4.54.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fda582236fee135d4daeca056c8c88ec5f6f6d88a004a79b84a02547c8f57386"}, - {file = "fonttools-4.54.1-cp38-cp38-win32.whl", hash = "sha256:e7d82b9e56716ed32574ee106cabca80992e6bbdcf25a88d97d21f73a0aae664"}, - {file = "fonttools-4.54.1-cp38-cp38-win_amd64.whl", hash = "sha256:ada215fd079e23e060157aab12eba0d66704316547f334eee9ff26f8c0d7b8ab"}, - {file = "fonttools-4.54.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5b8a096e649768c2f4233f947cf9737f8dbf8728b90e2771e2497c6e3d21d13"}, - {file = "fonttools-4.54.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e10d2e0a12e18f4e2dd031e1bf7c3d7017be5c8dbe524d07706179f355c5dac"}, - {file = "fonttools-4.54.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31c32d7d4b0958600eac75eaf524b7b7cb68d3a8c196635252b7a2c30d80e986"}, - {file = "fonttools-4.54.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c39287f5c8f4a0c5a55daf9eaf9ccd223ea59eed3f6d467133cc727d7b943a55"}, - {file = "fonttools-4.54.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a7a310c6e0471602fe3bf8efaf193d396ea561486aeaa7adc1f132e02d30c4b9"}, - {file = "fonttools-4.54.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d3b659d1029946f4ff9b6183984578041b520ce0f8fb7078bb37ec7445806b33"}, - {file = "fonttools-4.54.1-cp39-cp39-win32.whl", hash = "sha256:e96bc94c8cda58f577277d4a71f51c8e2129b8b36fd05adece6320dd3d57de8a"}, - {file = "fonttools-4.54.1-cp39-cp39-win_amd64.whl", hash = "sha256:e8a4b261c1ef91e7188a30571be6ad98d1c6d9fa2427244c545e2fa0a2494dd7"}, - {file = "fonttools-4.54.1-py3-none-any.whl", hash = "sha256:37cddd62d83dc4f72f7c3f3c2bcf2697e89a30efb152079896544a93907733bd"}, - {file = "fonttools-4.54.1.tar.gz", hash = "sha256:957f669d4922f92c171ba01bef7f29410668db09f6c02111e22b2bce446f3285"}, -] - -[package.extras] -all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0)", "xattr", "zopfli (>=0.1.4)"] -graphite = ["lz4 (>=1.7.4.2)"] -interpolatable = ["munkres", "pycairo", "scipy"] -lxml = ["lxml (>=4.0)"] -pathops = ["skia-pathops (>=0.5.0)"] -plot = ["matplotlib"] -repacker = ["uharfbuzz (>=0.23.0)"] -symfont = ["sympy"] -type1 = ["xattr"] -ufo = ["fs (>=2.2.0,<3)"] -unicode = ["unicodedata2 (>=15.1.0)"] -woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] - -[[package]] -name = "GDAL" -version = "3.9.2" -description = "GDAL: Geospatial Data Abstraction Library" -category = "main" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "GDAL-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ccdfd68893818a86d2225c1efe6b4a64a6d84676f6fb1e7ec5a4138f70d837b"}, -] - -[package.extras] -numpy = ["numpy (>1.0.0)"] - -[package.source] -type = "url" -url = "https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=0ccdfd68893818a86d2225c1efe6b4a64a6d84676f6fb1e7ec5a4138f70d837b" - -[[package]] -name = "ipdb" -version = "0.13.13" -description = "IPython-enabled pdb" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "ipdb-0.13.13-py3-none-any.whl", hash = "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4"}, - {file = "ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726"}, -] - -[package.dependencies] -decorator = {version = "*", markers = "python_version > \"3.6\""} -ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\""} -tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} - -[[package]] -name = "ipython" -version = "8.28.0" -description = "IPython: Productive Interactive Computing" -category = "main" -optional = false -python-versions = ">=3.10" -files = [ - {file = "ipython-8.28.0-py3-none-any.whl", hash = "sha256:530ef1e7bb693724d3cdc37287c80b07ad9b25986c007a53aa1857272dac3f35"}, - {file = "ipython-8.28.0.tar.gz", hash = "sha256:0d0d15ca1e01faeb868ef56bc7ee5a0de5bd66885735682e8a322ae289a13d1a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -decorator = "*" -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} -jedi = ">=0.16" -matplotlib-inline = "*" -pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} -prompt-toolkit = ">=3.0.41,<3.1.0" -pygments = ">=2.4.0" -stack-data = "*" -traitlets = ">=5.13.0" -typing-extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} - -[package.extras] -all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] -black = ["black"] -doc = ["docrepr", "exceptiongroup", "intersphinx-registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing-extensions"] -kernel = ["ipykernel"] -matplotlib = ["matplotlib"] -nbconvert = ["nbconvert"] -nbformat = ["nbformat"] -notebook = ["ipywidgets", "notebook"] -parallel = ["ipyparallel"] -qtconsole = ["qtconsole"] -test = ["packaging", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] -test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] - -[[package]] -name = "jedi" -version = "0.19.1" -description = "An autocompletion tool for Python that can be used for text editors." -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, - {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, -] - -[package.dependencies] -parso = ">=0.8.3,<0.9.0" - -[package.extras] -docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] -qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] -testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] - -[[package]] -name = "kiwisolver" -version = "1.4.7" -description = "A fast implementation of the Cassowary constraint solver" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8a9c83f75223d5e48b0bc9cb1bf2776cf01563e00ade8775ffe13b0b6e1af3a6"}, - {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58370b1ffbd35407444d57057b57da5d6549d2d854fa30249771775c63b5fe17"}, - {file = "kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa0abdf853e09aff551db11fce173e2177d00786c688203f52c87ad7fcd91ef9"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8d53103597a252fb3ab8b5845af04c7a26d5e7ea8122303dd7a021176a87e8b9"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:88f17c5ffa8e9462fb79f62746428dd57b46eb931698e42e990ad63103f35e6c"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a9ca9c710d598fd75ee5de59d5bda2684d9db36a9f50b6125eaea3969c2599"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4d742cb7af1c28303a51b7a27aaee540e71bb8e24f68c736f6f2ffc82f2bf05"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28c7fea2196bf4c2f8d46a0415c77a1c480cc0724722f23d7410ffe9842c407"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e968b84db54f9d42046cf154e02911e39c0435c9801681e3fc9ce8a3c4130278"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0c18ec74c0472de033e1bebb2911c3c310eef5649133dd0bedf2a169a1b269e5"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8f0ea6da6d393d8b2e187e6a5e3fb81f5862010a40c3945e2c6d12ae45cfb2ad"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:f106407dda69ae456dd1227966bf445b157ccc80ba0dff3802bb63f30b74e895"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84ec80df401cfee1457063732d90022f93951944b5b58975d34ab56bb150dfb3"}, - {file = "kiwisolver-1.4.7-cp310-cp310-win32.whl", hash = "sha256:71bb308552200fb2c195e35ef05de12f0c878c07fc91c270eb3d6e41698c3bcc"}, - {file = "kiwisolver-1.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:44756f9fd339de0fb6ee4f8c1696cfd19b2422e0d70b4cefc1cc7f1f64045a8c"}, - {file = "kiwisolver-1.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:78a42513018c41c2ffd262eb676442315cbfe3c44eed82385c2ed043bc63210a"}, - {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d2b0e12a42fb4e72d509fc994713d099cbb15ebf1103545e8a45f14da2dfca54"}, - {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a8781ac3edc42ea4b90bc23e7d37b665d89423818e26eb6df90698aa2287c95"}, - {file = "kiwisolver-1.4.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46707a10836894b559e04b0fd143e343945c97fd170d69a2d26d640b4e297935"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef97b8df011141c9b0f6caf23b29379f87dd13183c978a30a3c546d2c47314cb"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab58c12a2cd0fc769089e6d38466c46d7f76aced0a1f54c77652446733d2d02"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:803b8e1459341c1bb56d1c5c010406d5edec8a0713a0945851290a7930679b51"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9a9e8a507420fe35992ee9ecb302dab68550dedc0da9e2880dd88071c5fb052"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18077b53dc3bb490e330669a99920c5e6a496889ae8c63b58fbc57c3d7f33a18"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6af936f79086a89b3680a280c47ea90b4df7047b5bdf3aa5c524bbedddb9e545"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3abc5b19d24af4b77d1598a585b8a719beb8569a71568b66f4ebe1fb0449460b"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:933d4de052939d90afbe6e9d5273ae05fb836cc86c15b686edd4b3560cc0ee36"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:65e720d2ab2b53f1f72fb5da5fb477455905ce2c88aaa671ff0a447c2c80e8e3"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3bf1ed55088f214ba6427484c59553123fdd9b218a42bbc8c6496d6754b1e523"}, - {file = "kiwisolver-1.4.7-cp311-cp311-win32.whl", hash = "sha256:4c00336b9dd5ad96d0a558fd18a8b6f711b7449acce4c157e7343ba92dd0cf3d"}, - {file = "kiwisolver-1.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:929e294c1ac1e9f615c62a4e4313ca1823ba37326c164ec720a803287c4c499b"}, - {file = "kiwisolver-1.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:e33e8fbd440c917106b237ef1a2f1449dfbb9b6f6e1ce17c94cd6a1e0d438376"}, - {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5360cc32706dab3931f738d3079652d20982511f7c0ac5711483e6eab08efff2"}, - {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942216596dc64ddb25adb215c3c783215b23626f8d84e8eff8d6d45c3f29f75a"}, - {file = "kiwisolver-1.4.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:48b571ecd8bae15702e4f22d3ff6a0f13e54d3d00cd25216d5e7f658242065ee"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad42ba922c67c5f219097b28fae965e10045ddf145d2928bfac2eb2e17673640"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:612a10bdae23404a72941a0fc8fa2660c6ea1217c4ce0dbcab8a8f6543ea9e7f"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e838bba3a3bac0fe06d849d29772eb1afb9745a59710762e4ba3f4cb8424483"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22f499f6157236c19f4bbbd472fa55b063db77a16cd74d49afe28992dff8c258"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693902d433cf585133699972b6d7c42a8b9f8f826ebcaf0132ff55200afc599e"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e77f2126c3e0b0d055f44513ed349038ac180371ed9b52fe96a32aa071a5107"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:657a05857bda581c3656bfc3b20e353c232e9193eb167766ad2dc58b56504948"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4bfa75a048c056a411f9705856abfc872558e33c055d80af6a380e3658766038"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:34ea1de54beef1c104422d210c47c7d2a4999bdecf42c7b5718fbe59a4cac383"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90da3b5f694b85231cf93586dad5e90e2d71b9428f9aad96952c99055582f520"}, - {file = "kiwisolver-1.4.7-cp312-cp312-win32.whl", hash = "sha256:18e0cca3e008e17fe9b164b55735a325140a5a35faad8de92dd80265cd5eb80b"}, - {file = "kiwisolver-1.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:58cb20602b18f86f83a5c87d3ee1c766a79c0d452f8def86d925e6c60fbf7bfb"}, - {file = "kiwisolver-1.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:f5a8b53bdc0b3961f8b6125e198617c40aeed638b387913bf1ce78afb1b0be2a"}, - {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2e6039dcbe79a8e0f044f1c39db1986a1b8071051efba3ee4d74f5b365f5226e"}, - {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a1ecf0ac1c518487d9d23b1cd7139a6a65bc460cd101ab01f1be82ecf09794b6"}, - {file = "kiwisolver-1.4.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ab9ccab2b5bd5702ab0803676a580fffa2aa178c2badc5557a84cc943fcf750"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f816dd2277f8d63d79f9c8473a79fe54047bc0467754962840782c575522224d"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf8bcc23ceb5a1b624572a1623b9f79d2c3b337c8c455405ef231933a10da379"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dea0bf229319828467d7fca8c7c189780aa9ff679c94539eed7532ebe33ed37c"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c06a4c7cf15ec739ce0e5971b26c93638730090add60e183530d70848ebdd34"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:913983ad2deb14e66d83c28b632fd35ba2b825031f2fa4ca29675e665dfecbe1"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5337ec7809bcd0f424c6b705ecf97941c46279cf5ed92311782c7c9c2026f07f"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c26ed10c4f6fa6ddb329a5120ba3b6db349ca192ae211e882970bfc9d91420b"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c619b101e6de2222c1fcb0531e1b17bbffbe54294bfba43ea0d411d428618c27"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:073a36c8273647592ea332e816e75ef8da5c303236ec0167196793eb1e34657a"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ce6b2b0231bda412463e152fc18335ba32faf4e8c23a754ad50ffa70e4091ee"}, - {file = "kiwisolver-1.4.7-cp313-cp313-win32.whl", hash = "sha256:f4c9aee212bc89d4e13f58be11a56cc8036cabad119259d12ace14b34476fd07"}, - {file = "kiwisolver-1.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:8a3ec5aa8e38fc4c8af308917ce12c536f1c88452ce554027e55b22cbbfbff76"}, - {file = "kiwisolver-1.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:76c8094ac20ec259471ac53e774623eb62e6e1f56cd8690c67ce6ce4fcb05650"}, - {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5d5abf8f8ec1f4e22882273c423e16cae834c36856cac348cfbfa68e01c40f3a"}, - {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aeb3531b196ef6f11776c21674dba836aeea9d5bd1cf630f869e3d90b16cfade"}, - {file = "kiwisolver-1.4.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7d755065e4e866a8086c9bdada157133ff466476a2ad7861828e17b6026e22c"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08471d4d86cbaec61f86b217dd938a83d85e03785f51121e791a6e6689a3be95"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bbfcb7165ce3d54a3dfbe731e470f65739c4c1f85bb1018ee912bae139e263b"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d34eb8494bea691a1a450141ebb5385e4b69d38bb8403b5146ad279f4b30fa3"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9242795d174daa40105c1d86aba618e8eab7bf96ba8c3ee614da8302a9f95503"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a0f64a48bb81af7450e641e3fe0b0394d7381e342805479178b3d335d60ca7cf"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8e045731a5416357638d1700927529e2b8ab304811671f665b225f8bf8d8f933"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4322872d5772cae7369f8351da1edf255a604ea7087fe295411397d0cfd9655e"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e1631290ee9271dffe3062d2634c3ecac02c83890ada077d225e081aca8aab89"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:edcfc407e4eb17e037bca59be0e85a2031a2ac87e4fed26d3e9df88b4165f92d"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4d05d81ecb47d11e7f8932bd8b61b720bf0b41199358f3f5e36d38e28f0532c5"}, - {file = "kiwisolver-1.4.7-cp38-cp38-win32.whl", hash = "sha256:b38ac83d5f04b15e515fd86f312479d950d05ce2368d5413d46c088dda7de90a"}, - {file = "kiwisolver-1.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:d83db7cde68459fc803052a55ace60bea2bae361fc3b7a6d5da07e11954e4b09"}, - {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f9362ecfca44c863569d3d3c033dbe8ba452ff8eed6f6b5806382741a1334bd"}, - {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e8df2eb9b2bac43ef8b082e06f750350fbbaf2887534a5be97f6cf07b19d9583"}, - {file = "kiwisolver-1.4.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f32d6edbc638cde7652bd690c3e728b25332acbadd7cad670cc4a02558d9c417"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e2e6c39bd7b9372b0be21456caab138e8e69cc0fc1190a9dfa92bd45a1e6e904"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dda56c24d869b1193fcc763f1284b9126550eaf84b88bbc7256e15028f19188a"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79849239c39b5e1fd906556c474d9b0439ea6792b637511f3fe3a41158d89ca8"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e3bc157fed2a4c02ec468de4ecd12a6e22818d4f09cde2c31ee3226ffbefab2"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3da53da805b71e41053dc670f9a820d1157aae77b6b944e08024d17bcd51ef88"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8705f17dfeb43139a692298cb6637ee2e59c0194538153e83e9ee0c75c2eddde"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82a5c2f4b87c26bb1a0ef3d16b5c4753434633b83d365cc0ddf2770c93829e3c"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce8be0466f4c0d585cdb6c1e2ed07232221df101a4c6f28821d2aa754ca2d9e2"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:409afdfe1e2e90e6ee7fc896f3df9a7fec8e793e58bfa0d052c8a82f99c37abb"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5b9c3f4ee0b9a439d2415012bd1b1cc2df59e4d6a9939f4d669241d30b414327"}, - {file = "kiwisolver-1.4.7-cp39-cp39-win32.whl", hash = "sha256:a79ae34384df2b615eefca647a2873842ac3b596418032bef9a7283675962644"}, - {file = "kiwisolver-1.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:cf0438b42121a66a3a667de17e779330fc0f20b0d97d59d2f2121e182b0505e4"}, - {file = "kiwisolver-1.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:764202cc7e70f767dab49e8df52c7455e8de0df5d858fa801a11aa0d882ccf3f"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94252291e3fe68001b1dd747b4c0b3be12582839b95ad4d1b641924d68fd4643"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b7dfa3b546da08a9f622bb6becdb14b3e24aaa30adba66749d38f3cc7ea9706"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd3de6481f4ed8b734da5df134cd5a6a64fe32124fe83dde1e5b5f29fe30b1e6"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a91b5f9f1205845d488c928e8570dcb62b893372f63b8b6e98b863ebd2368ff2"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fa14dbd66b8b8f470d5fc79c089a66185619d31645f9b0773b88b19f7223c4"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:eb542fe7933aa09d8d8f9d9097ef37532a7df6497819d16efe4359890a2f417a"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bfa1acfa0c54932d5607e19a2c24646fb4c1ae2694437789129cf099789a3b00"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:eee3ea935c3d227d49b4eb85660ff631556841f6e567f0f7bda972df6c2c9935"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f3160309af4396e0ed04db259c3ccbfdc3621b5559b5453075e5de555e1f3a1b"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a17f6a29cf8935e587cc8a4dbfc8368c55edc645283db0ce9801016f83526c2d"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10849fb2c1ecbfae45a693c070e0320a91b35dd4bcf58172c023b994283a124d"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:ac542bf38a8a4be2dc6b15248d36315ccc65f0743f7b1a76688ffb6b5129a5c2"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8b01aac285f91ca889c800042c35ad3b239e704b150cfd3382adfc9dcc780e39"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48be928f59a1f5c8207154f935334d374e79f2b5d212826307d072595ad76a2e"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f37cfe618a117e50d8c240555331160d73d0411422b59b5ee217843d7b693608"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599b5c873c63a1f6ed7eead644a8a380cfbdf5db91dcb6f85707aaab213b1674"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:801fa7802e5cfabe3ab0c81a34c323a319b097dfb5004be950482d882f3d7225"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0c6c43471bc764fad4bc99c5c2d6d16a676b1abf844ca7c8702bdae92df01ee0"}, - {file = "kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60"}, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "matplotlib" -version = "3.9.2" -description = "Python plotting package" -category = "main" -optional = false -python-versions = ">=3.9" -files = [ - {file = "matplotlib-3.9.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9d78bbc0cbc891ad55b4f39a48c22182e9bdaea7fc0e5dbd364f49f729ca1bbb"}, - {file = "matplotlib-3.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c375cc72229614632c87355366bdf2570c2dac01ac66b8ad048d2dabadf2d0d4"}, - {file = "matplotlib-3.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d94ff717eb2bd0b58fe66380bd8b14ac35f48a98e7c6765117fe67fb7684e64"}, - {file = "matplotlib-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab68d50c06938ef28681073327795c5db99bb4666214d2d5f880ed11aeaded66"}, - {file = "matplotlib-3.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:65aacf95b62272d568044531e41de26285d54aec8cb859031f511f84bd8b495a"}, - {file = "matplotlib-3.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:3fd595f34aa8a55b7fc8bf9ebea8aa665a84c82d275190a61118d33fbc82ccae"}, - {file = "matplotlib-3.9.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8dd059447824eec055e829258ab092b56bb0579fc3164fa09c64f3acd478772"}, - {file = "matplotlib-3.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c797dac8bb9c7a3fd3382b16fe8f215b4cf0f22adccea36f1545a6d7be310b41"}, - {file = "matplotlib-3.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d719465db13267bcef19ea8954a971db03b9f48b4647e3860e4bc8e6ed86610f"}, - {file = "matplotlib-3.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8912ef7c2362f7193b5819d17dae8629b34a95c58603d781329712ada83f9447"}, - {file = "matplotlib-3.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7741f26a58a240f43bee74965c4882b6c93df3e7eb3de160126d8c8f53a6ae6e"}, - {file = "matplotlib-3.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:ae82a14dab96fbfad7965403c643cafe6515e386de723e498cf3eeb1e0b70cc7"}, - {file = "matplotlib-3.9.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ac43031375a65c3196bee99f6001e7fa5bdfb00ddf43379d3c0609bdca042df9"}, - {file = "matplotlib-3.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be0fc24a5e4531ae4d8e858a1a548c1fe33b176bb13eff7f9d0d38ce5112a27d"}, - {file = "matplotlib-3.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf81de2926c2db243c9b2cbc3917619a0fc85796c6ba4e58f541df814bbf83c7"}, - {file = "matplotlib-3.9.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6ee45bc4245533111ced13f1f2cace1e7f89d1c793390392a80c139d6cf0e6c"}, - {file = "matplotlib-3.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:306c8dfc73239f0e72ac50e5a9cf19cc4e8e331dd0c54f5e69ca8758550f1e1e"}, - {file = "matplotlib-3.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:5413401594cfaff0052f9d8b1aafc6d305b4bd7c4331dccd18f561ff7e1d3bd3"}, - {file = "matplotlib-3.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:18128cc08f0d3cfff10b76baa2f296fc28c4607368a8402de61bb3f2eb33c7d9"}, - {file = "matplotlib-3.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4876d7d40219e8ae8bb70f9263bcbe5714415acfdf781086601211335e24f8aa"}, - {file = "matplotlib-3.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d9f07a80deab4bb0b82858a9e9ad53d1382fd122be8cde11080f4e7dfedb38b"}, - {file = "matplotlib-3.9.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7c0410f181a531ec4e93bbc27692f2c71a15c2da16766f5ba9761e7ae518413"}, - {file = "matplotlib-3.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:909645cce2dc28b735674ce0931a4ac94e12f5b13f6bb0b5a5e65e7cea2c192b"}, - {file = "matplotlib-3.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:f32c7410c7f246838a77d6d1eff0c0f87f3cb0e7c4247aebea71a6d5a68cab49"}, - {file = "matplotlib-3.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37e51dd1c2db16ede9cfd7b5cabdfc818b2c6397c83f8b10e0e797501c963a03"}, - {file = "matplotlib-3.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b82c5045cebcecd8496a4d694d43f9cc84aeeb49fe2133e036b207abe73f4d30"}, - {file = "matplotlib-3.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f053c40f94bc51bc03832a41b4f153d83f2062d88c72b5e79997072594e97e51"}, - {file = "matplotlib-3.9.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbe196377a8248972f5cede786d4c5508ed5f5ca4a1e09b44bda889958b33f8c"}, - {file = "matplotlib-3.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5816b1e1fe8c192cbc013f8f3e3368ac56fbecf02fb41b8f8559303f24c5015e"}, - {file = "matplotlib-3.9.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cef2a73d06601437be399908cf13aee74e86932a5ccc6ccdf173408ebc5f6bb2"}, - {file = "matplotlib-3.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0830e188029c14e891fadd99702fd90d317df294c3298aad682739c5533721a"}, - {file = "matplotlib-3.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ba9c1299c920964e8d3857ba27173b4dbb51ca4bab47ffc2c2ba0eb5e2cbc5"}, - {file = "matplotlib-3.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cd93b91ab47a3616b4d3c42b52f8363b88ca021e340804c6ab2536344fad9ca"}, - {file = "matplotlib-3.9.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6d1ce5ed2aefcdce11904fc5bbea7d9c21fff3d5f543841edf3dea84451a09ea"}, - {file = "matplotlib-3.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:b2696efdc08648536efd4e1601b5fd491fd47f4db97a5fbfd175549a7365c1b2"}, - {file = "matplotlib-3.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d52a3b618cb1cbb769ce2ee1dcdb333c3ab6e823944e9a2d36e37253815f9556"}, - {file = "matplotlib-3.9.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:039082812cacd6c6bec8e17a9c1e6baca230d4116d522e81e1f63a74d01d2e21"}, - {file = "matplotlib-3.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6758baae2ed64f2331d4fd19be38b7b4eae3ecec210049a26b6a4f3ae1c85dcc"}, - {file = "matplotlib-3.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:050598c2b29e0b9832cde72bcf97627bf00262adbc4a54e2b856426bb2ef0697"}, - {file = "matplotlib-3.9.2.tar.gz", hash = "sha256:96ab43906269ca64a6366934106fa01534454a69e471b7bf3d79083981aaab92"}, -] - -[package.dependencies] -contourpy = ">=1.0.1" -cycler = ">=0.10" -fonttools = ">=4.22.0" -kiwisolver = ">=1.3.1" -numpy = ">=1.23" -packaging = ">=20.0" -pillow = ">=8" -pyparsing = ">=2.3.1" -python-dateutil = ">=2.7" - -[package.extras] -dev = ["meson-python (>=0.13.1)", "numpy (>=1.25)", "pybind11 (>=2.6)", "setuptools (>=64)", "setuptools_scm (>=7)"] - -[[package]] -name = "matplotlib-inline" -version = "0.1.7" -description = "Inline Matplotlib backend for Jupyter" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, - {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, -] - -[package.dependencies] -traitlets = "*" - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "numpy" -version = "2.1.1" -description = "Fundamental package for array computing in Python" -category = "main" -optional = false -python-versions = ">=3.10" -files = [ - {file = "numpy-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8a0e34993b510fc19b9a2ce7f31cb8e94ecf6e924a40c0c9dd4f62d0aac47d9"}, - {file = "numpy-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dd86dfaf7c900c0bbdcb8b16e2f6ddf1eb1fe39c6c8cca6e94844ed3152a8fd"}, - {file = "numpy-2.1.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:5889dd24f03ca5a5b1e8a90a33b5a0846d8977565e4ae003a63d22ecddf6782f"}, - {file = "numpy-2.1.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:59ca673ad11d4b84ceb385290ed0ebe60266e356641428c845b39cd9df6713ab"}, - {file = "numpy-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ce49a34c44b6de5241f0b38b07e44c1b2dcacd9e36c30f9c2fcb1bb5135db7"}, - {file = "numpy-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:913cc1d311060b1d409e609947fa1b9753701dac96e6581b58afc36b7ee35af6"}, - {file = "numpy-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:caf5d284ddea7462c32b8d4a6b8af030b6c9fd5332afb70e7414d7fdded4bfd0"}, - {file = "numpy-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:57eb525e7c2a8fdee02d731f647146ff54ea8c973364f3b850069ffb42799647"}, - {file = "numpy-2.1.1-cp310-cp310-win32.whl", hash = "sha256:9a8e06c7a980869ea67bbf551283bbed2856915f0a792dc32dd0f9dd2fb56728"}, - {file = "numpy-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d10c39947a2d351d6d466b4ae83dad4c37cd6c3cdd6d5d0fa797da56f710a6ae"}, - {file = "numpy-2.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d07841fd284718feffe7dd17a63a2e6c78679b2d386d3e82f44f0108c905550"}, - {file = "numpy-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b5613cfeb1adfe791e8e681128f5f49f22f3fcaa942255a6124d58ca59d9528f"}, - {file = "numpy-2.1.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0b8cc2715a84b7c3b161f9ebbd942740aaed913584cae9cdc7f8ad5ad41943d0"}, - {file = "numpy-2.1.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:b49742cdb85f1f81e4dc1b39dcf328244f4d8d1ded95dea725b316bd2cf18c95"}, - {file = "numpy-2.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8d5f8a8e3bc87334f025194c6193e408903d21ebaeb10952264943a985066ca"}, - {file = "numpy-2.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d51fc141ddbe3f919e91a096ec739f49d686df8af254b2053ba21a910ae518bf"}, - {file = "numpy-2.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:98ce7fb5b8063cfdd86596b9c762bf2b5e35a2cdd7e967494ab78a1fa7f8b86e"}, - {file = "numpy-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:24c2ad697bd8593887b019817ddd9974a7f429c14a5469d7fad413f28340a6d2"}, - {file = "numpy-2.1.1-cp311-cp311-win32.whl", hash = "sha256:397bc5ce62d3fb73f304bec332171535c187e0643e176a6e9421a6e3eacef06d"}, - {file = "numpy-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:ae8ce252404cdd4de56dcfce8b11eac3c594a9c16c231d081fb705cf23bd4d9e"}, - {file = "numpy-2.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c803b7934a7f59563db459292e6aa078bb38b7ab1446ca38dd138646a38203e"}, - {file = "numpy-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6435c48250c12f001920f0751fe50c0348f5f240852cfddc5e2f97e007544cbe"}, - {file = "numpy-2.1.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3269c9eb8745e8d975980b3a7411a98976824e1fdef11f0aacf76147f662b15f"}, - {file = "numpy-2.1.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fac6e277a41163d27dfab5f4ec1f7a83fac94e170665a4a50191b545721c6521"}, - {file = "numpy-2.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fcd8f556cdc8cfe35e70efb92463082b7f43dd7e547eb071ffc36abc0ca4699b"}, - {file = "numpy-2.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b9cd92c8f8e7b313b80e93cedc12c0112088541dcedd9197b5dee3738c1201"}, - {file = "numpy-2.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:afd9c680df4de71cd58582b51e88a61feed4abcc7530bcd3d48483f20fc76f2a"}, - {file = "numpy-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8661c94e3aad18e1ea17a11f60f843a4933ccaf1a25a7c6a9182af70610b2313"}, - {file = "numpy-2.1.1-cp312-cp312-win32.whl", hash = "sha256:950802d17a33c07cba7fd7c3dcfa7d64705509206be1606f196d179e539111ed"}, - {file = "numpy-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:3fc5eabfc720db95d68e6646e88f8b399bfedd235994016351b1d9e062c4b270"}, - {file = "numpy-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:046356b19d7ad1890c751b99acad5e82dc4a02232013bd9a9a712fddf8eb60f5"}, - {file = "numpy-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6e5a9cb2be39350ae6c8f79410744e80154df658d5bea06e06e0ac5bb75480d5"}, - {file = "numpy-2.1.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:d4c57b68c8ef5e1ebf47238e99bf27657511ec3f071c465f6b1bccbef12d4136"}, - {file = "numpy-2.1.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:8ae0fd135e0b157365ac7cc31fff27f07a5572bdfc38f9c2d43b2aff416cc8b0"}, - {file = "numpy-2.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:981707f6b31b59c0c24bcda52e5605f9701cb46da4b86c2e8023656ad3e833cb"}, - {file = "numpy-2.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ca4b53e1e0b279142113b8c5eb7d7a877e967c306edc34f3b58e9be12fda8df"}, - {file = "numpy-2.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e097507396c0be4e547ff15b13dc3866f45f3680f789c1a1301b07dadd3fbc78"}, - {file = "numpy-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7506387e191fe8cdb267f912469a3cccc538ab108471291636a96a54e599556"}, - {file = "numpy-2.1.1-cp313-cp313-win32.whl", hash = "sha256:251105b7c42abe40e3a689881e1793370cc9724ad50d64b30b358bbb3a97553b"}, - {file = "numpy-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:f212d4f46b67ff604d11fff7cc62d36b3e8714edf68e44e9760e19be38c03eb0"}, - {file = "numpy-2.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:920b0911bb2e4414c50e55bd658baeb78281a47feeb064ab40c2b66ecba85553"}, - {file = "numpy-2.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bab7c09454460a487e631ffc0c42057e3d8f2a9ddccd1e60c7bb8ed774992480"}, - {file = "numpy-2.1.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:cea427d1350f3fd0d2818ce7350095c1a2ee33e30961d2f0fef48576ddbbe90f"}, - {file = "numpy-2.1.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:e30356d530528a42eeba51420ae8bf6c6c09559051887196599d96ee5f536468"}, - {file = "numpy-2.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8dfa9e94fc127c40979c3eacbae1e61fda4fe71d84869cc129e2721973231ef"}, - {file = "numpy-2.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:910b47a6d0635ec1bd53b88f86120a52bf56dcc27b51f18c7b4a2e2224c29f0f"}, - {file = "numpy-2.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:13cc11c00000848702322af4de0147ced365c81d66053a67c2e962a485b3717c"}, - {file = "numpy-2.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53e27293b3a2b661c03f79aa51c3987492bd4641ef933e366e0f9f6c9bf257ec"}, - {file = "numpy-2.1.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7be6a07520b88214ea85d8ac8b7d6d8a1839b0b5cb87412ac9f49fa934eb15d5"}, - {file = "numpy-2.1.1-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:52ac2e48f5ad847cd43c4755520a2317f3380213493b9d8a4c5e37f3b87df504"}, - {file = "numpy-2.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a95ca3560a6058d6ea91d4629a83a897ee27c00630aed9d933dff191f170cd"}, - {file = "numpy-2.1.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:99f4a9ee60eed1385a86e82288971a51e71df052ed0b2900ed30bc840c0f2e39"}, - {file = "numpy-2.1.1.tar.gz", hash = "sha256:d0cf7d55b1051387807405b3898efafa862997b4cba8aa5dbe657be794afeafd"}, -] - -[[package]] -name = "opencv-python" -version = "4.10.0.84" -description = "Wrapper package for OpenCV python bindings." -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236"}, - {file = "opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe"}, -] - -[package.dependencies] -numpy = [ - {version = ">=1.21.2", markers = "python_version >= \"3.10\""}, - {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\""}, - {version = ">=1.23.5", markers = "python_version >= \"3.11\""}, - {version = ">=1.19.3", markers = "python_version >= \"3.6\" and platform_system == \"Linux\" and platform_machine == \"aarch64\" or python_version >= \"3.9\""}, - {version = ">=1.17.0", markers = "python_version >= \"3.7\""}, - {version = ">=1.17.3", markers = "python_version >= \"3.8\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, -] - -[[package]] -name = "packaging" -version = "24.1" -description = "Core utilities for Python packages" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, -] - -[[package]] -name = "parso" -version = "0.8.4" -description = "A Python Parser" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, - {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, -] - -[package.extras] -qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] -testing = ["docopt", "pytest"] - -[[package]] -name = "pexpect" -version = "4.9.0" -description = "Pexpect allows easy control of interactive console applications." -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, - {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, -] - -[package.dependencies] -ptyprocess = ">=0.5" - -[[package]] -name = "pillow" -version = "10.4.0" -description = "Python Imaging Library (Fork)" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, - {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, - {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, - {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, - {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, - {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, - {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, - {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, - {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, - {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, - {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, - {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, - {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, - {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, - {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, - {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, - {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, - {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, - {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, - {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions"] -xmp = ["defusedxml"] - -[[package]] -name = "prompt-toolkit" -version = "3.0.48" -description = "Library for building powerful interactive command lines in Python" -category = "main" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, - {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, -] - -[package.dependencies] -wcwidth = "*" - -[[package]] -name = "ptyprocess" -version = "0.7.0" -description = "Run a subprocess in a pseudo terminal" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, - {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, -] - -[[package]] -name = "pure-eval" -version = "0.2.3" -description = "Safely evaluate AST nodes without side effects" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, - {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, -] - -[package.extras] -tests = ["pytest"] - -[[package]] -name = "pygeodesy" -version = "24.9.29" -description = "Pure Python geodesy tools" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "PyGeodesy-24.9.29-py2.py3-none-any.whl", hash = "sha256:754f2cf603bb54315257e8eb619c286457cea0ae64d802abb6c0088155dc45c4"}, - {file = "PyGeodesy-24.9.29.zip", hash = "sha256:c28ce86d450f7a7e8d5c432cec15cc7c01fded6b4e240aa0b07d20c46cc84038"}, -] - -[[package]] -name = "pygments" -version = "2.18.0" -description = "Pygments is a syntax highlighting package written in Python." -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyparsing" -version = "3.1.4" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "main" -optional = false -python-versions = ">=3.6.8" -files = [ - {file = "pyparsing-3.1.4-py3-none-any.whl", hash = "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c"}, - {file = "pyparsing-3.1.4.tar.gz", hash = "sha256:f86ec8d1a83f11977c9a6ea7598e8c27fc5cddfa5b07ea2241edbbde1d7bc032"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pyshp" -version = "2.3.1" -description = "Pure Python read/write support for ESRI Shapefile format" -category = "main" -optional = false -python-versions = ">=2.7" -files = [ - {file = "pyshp-2.3.1-py2.py3-none-any.whl", hash = "sha256:67024c0ccdc352ba5db777c4e968483782dfa78f8e200672a90d2d30fd8b7b49"}, - {file = "pyshp-2.3.1.tar.gz", hash = "sha256:4caec82fd8dd096feba8217858068bacb2a3b5950f43c048c6dc32a3489d5af1"}, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -category = "main" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pytz" -version = "2024.2" -description = "World timezone definitions, modern and historical" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, - {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "rich" -version = "13.9.1" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -category = "main" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "rich-13.9.1-py3-none-any.whl", hash = "sha256:b340e739f30aa58921dc477b8adaa9ecdb7cecc217be01d93730ee1bc8aa83be"}, - {file = "rich-13.9.1.tar.gz", hash = "sha256:097cffdf85db1babe30cc7deba5ab3a29e1b9885047dab24c57e9a7f8a9c1466"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "scipy" -version = "1.14.1" -description = "Fundamental algorithms for scientific computing in Python" -category = "main" -optional = false -python-versions = ">=3.10" -files = [ - {file = "scipy-1.14.1-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:b28d2ca4add7ac16ae8bb6632a3c86e4b9e4d52d3e34267f6e1b0c1f8d87e389"}, - {file = "scipy-1.14.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d0d2821003174de06b69e58cef2316a6622b60ee613121199cb2852a873f8cf3"}, - {file = "scipy-1.14.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8bddf15838ba768bb5f5083c1ea012d64c9a444e16192762bd858f1e126196d0"}, - {file = "scipy-1.14.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:97c5dddd5932bd2a1a31c927ba5e1463a53b87ca96b5c9bdf5dfd6096e27efc3"}, - {file = "scipy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ff0a7e01e422c15739ecd64432743cf7aae2b03f3084288f399affcefe5222d"}, - {file = "scipy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e32dced201274bf96899e6491d9ba3e9a5f6b336708656466ad0522d8528f69"}, - {file = "scipy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8426251ad1e4ad903a4514712d2fa8fdd5382c978010d1c6f5f37ef286a713ad"}, - {file = "scipy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:a49f6ed96f83966f576b33a44257d869756df6cf1ef4934f59dd58b25e0327e5"}, - {file = "scipy-1.14.1-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:2da0469a4ef0ecd3693761acbdc20f2fdeafb69e6819cc081308cc978153c675"}, - {file = "scipy-1.14.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c0ee987efa6737242745f347835da2cc5bb9f1b42996a4d97d5c7ff7928cb6f2"}, - {file = "scipy-1.14.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3a1b111fac6baec1c1d92f27e76511c9e7218f1695d61b59e05e0fe04dc59617"}, - {file = "scipy-1.14.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8475230e55549ab3f207bff11ebfc91c805dc3463ef62eda3ccf593254524ce8"}, - {file = "scipy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:278266012eb69f4a720827bdd2dc54b2271c97d84255b2faaa8f161a158c3b37"}, - {file = "scipy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fef8c87f8abfb884dac04e97824b61299880c43f4ce675dd2cbeadd3c9b466d2"}, - {file = "scipy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b05d43735bb2f07d689f56f7b474788a13ed8adc484a85aa65c0fd931cf9ccd2"}, - {file = "scipy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:716e389b694c4bb564b4fc0c51bc84d381735e0d39d3f26ec1af2556ec6aad94"}, - {file = "scipy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:631f07b3734d34aced009aaf6fedfd0eb3498a97e581c3b1e5f14a04164a456d"}, - {file = "scipy-1.14.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:af29a935803cc707ab2ed7791c44288a682f9c8107bc00f0eccc4f92c08d6e07"}, - {file = "scipy-1.14.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2843f2d527d9eebec9a43e6b406fb7266f3af25a751aa91d62ff416f54170bc5"}, - {file = "scipy-1.14.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:eb58ca0abd96911932f688528977858681a59d61a7ce908ffd355957f7025cfc"}, - {file = "scipy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30ac8812c1d2aab7131a79ba62933a2a76f582d5dbbc695192453dae67ad6310"}, - {file = "scipy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f9ea80f2e65bdaa0b7627fb00cbeb2daf163caa015e59b7516395fe3bd1e066"}, - {file = "scipy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edaf02b82cd7639db00dbff629995ef185c8df4c3ffa71a5562a595765a06ce1"}, - {file = "scipy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2ff38e22128e6c03ff73b6bb0f85f897d2362f8c052e3b8ad00532198fbdae3f"}, - {file = "scipy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1729560c906963fc8389f6aac023739ff3983e727b1a4d87696b7bf108316a79"}, - {file = "scipy-1.14.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:4079b90df244709e675cdc8b93bfd8a395d59af40b72e339c2287c91860deb8e"}, - {file = "scipy-1.14.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e0cf28db0f24a38b2a0ca33a85a54852586e43cf6fd876365c86e0657cfe7d73"}, - {file = "scipy-1.14.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0c2f95de3b04e26f5f3ad5bb05e74ba7f68b837133a4492414b3afd79dfe540e"}, - {file = "scipy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b99722ea48b7ea25e8e015e8341ae74624f72e5f21fc2abd45f3a93266de4c5d"}, - {file = "scipy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5149e3fd2d686e42144a093b206aef01932a0059c2a33ddfa67f5f035bdfe13e"}, - {file = "scipy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4f5a7c49323533f9103d4dacf4e4f07078f360743dec7f7596949149efeec06"}, - {file = "scipy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:baff393942b550823bfce952bb62270ee17504d02a1801d7fd0719534dfb9c84"}, - {file = "scipy-1.14.1.tar.gz", hash = "sha256:5a275584e726026a5699459aa72f828a610821006228e841b94275c4a7c08417"}, -] - -[package.dependencies] -numpy = ">=1.23.5,<2.3" - -[package.extras] -dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] -doc = ["jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.13.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<=7.3.7)", "sphinx-design (>=0.4.0)"] -test = ["Cython", "array-api-strict (>=2.0)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] - -[[package]] -name = "scriptconfig" -version = "0.8.0" -description = "Easy dict-based script configuration with CLI support" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "scriptconfig-0.8.0-py3-none-any.whl", hash = "sha256:c327e5cc4f136d4c87d08b97b4ae7ac20cbcb4f9120fd029442066758c3d2d36"}, - {file = "scriptconfig-0.8.0.tar.gz", hash = "sha256:d641365029d784cee577cfb8c4a213b83f05e9c2ea17329af0f707941326c975"}, -] - -[package.dependencies] -PyYAML = [ - {version = ">=6.0", markers = "python_version >= \"3.10\" and python_version < \"3.12\""}, - {version = ">=6.0.1", markers = "python_version < \"4.0\" and python_version >= \"3.12\""}, -] -ubelt = ">=1.3.6" - -[package.extras] -all = ["PyYAML (>=5.4.1)", "PyYAML (>=5.4.1)", "PyYAML (>=5.4.1)", "PyYAML (>=5.4.1)", "PyYAML (>=6.0)", "PyYAML (>=6.0)", "PyYAML (>=6.0.1)", "argcomplete (>=3.0.5)", "coverage (>=4.3.4)", "coverage (>=4.5)", "coverage (>=5.3.1)", "coverage (>=5.3.1)", "coverage (>=5.3.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "numpy (>=1.11.1)", "numpy (>=1.11.1)", "numpy (>=1.11.1)", "numpy (>=1.12.0)", "numpy (>=1.14.5)", "numpy (>=1.19.2)", "numpy (>=1.19.3)", "numpy (>=1.21.6)", "numpy (>=1.23.2)", "numpy (>=1.26.0)", "omegaconf (>=2.2.2)", "pytest (>=4.6.0)", "pytest (>=4.6.0)", "pytest (>=4.6.0,<=4.6.11)", "pytest (>=4.6.0,<=4.6.11)", "pytest (>=4.6.0,<=6.1.2)", "pytest (>=6.2.5)", "pytest-cov (>=2.8.1)", "pytest-cov (>=2.8.1)", "pytest-cov (>=2.9.0)", "pytest-cov (>=3.0.0)", "rich-argparse (>=1.1.0)", "ubelt (>=1.3.6)", "xdoctest (>=1.1.5)"] -all-strict = ["PyYAML (==5.4.1)", "PyYAML (==5.4.1)", "PyYAML (==5.4.1)", "PyYAML (==5.4.1)", "PyYAML (==6.0)", "PyYAML (==6.0)", "PyYAML (==6.0.1)", "argcomplete (==3.0.5)", "coverage (==4.3.4)", "coverage (==4.5)", "coverage (==5.3.1)", "coverage (==5.3.1)", "coverage (==5.3.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "numpy (==1.11.1)", "numpy (==1.11.1)", "numpy (==1.11.1)", "numpy (==1.12.0)", "numpy (==1.14.5)", "numpy (==1.19.2)", "numpy (==1.19.3)", "numpy (==1.21.6)", "numpy (==1.23.2)", "numpy (==1.26.0)", "omegaconf (==2.2.2)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==2.8.1)", "pytest-cov (==2.8.1)", "pytest-cov (==2.9.0)", "pytest-cov (==3.0.0)", "rich-argparse (==1.1.0)", "ubelt (==1.3.6)", "xdoctest (==1.1.5)"] -optional = ["argcomplete (>=3.0.5)", "numpy (>=1.11.1)", "numpy (>=1.11.1)", "numpy (>=1.11.1)", "numpy (>=1.12.0)", "numpy (>=1.14.5)", "numpy (>=1.19.2)", "numpy (>=1.19.3)", "numpy (>=1.21.6)", "numpy (>=1.23.2)", "numpy (>=1.26.0)", "omegaconf (>=2.2.2)", "rich-argparse (>=1.1.0)"] -optional-strict = ["argcomplete (==3.0.5)", "numpy (==1.11.1)", "numpy (==1.11.1)", "numpy (==1.11.1)", "numpy (==1.12.0)", "numpy (==1.14.5)", "numpy (==1.19.2)", "numpy (==1.19.3)", "numpy (==1.21.6)", "numpy (==1.23.2)", "numpy (==1.26.0)", "omegaconf (==2.2.2)", "rich-argparse (==1.1.0)"] -runtime-strict = ["PyYAML (==5.4.1)", "PyYAML (==5.4.1)", "PyYAML (==5.4.1)", "PyYAML (==5.4.1)", "PyYAML (==6.0)", "PyYAML (==6.0)", "PyYAML (==6.0.1)", "ubelt (==1.3.6)"] -tests = ["coverage (>=4.3.4)", "coverage (>=4.5)", "coverage (>=5.3.1)", "coverage (>=5.3.1)", "coverage (>=5.3.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "pytest (>=4.6.0)", "pytest (>=4.6.0)", "pytest (>=4.6.0,<=4.6.11)", "pytest (>=4.6.0,<=4.6.11)", "pytest (>=4.6.0,<=6.1.2)", "pytest (>=6.2.5)", "pytest-cov (>=2.8.1)", "pytest-cov (>=2.8.1)", "pytest-cov (>=2.9.0)", "pytest-cov (>=3.0.0)", "xdoctest (>=1.1.5)"] -tests-strict = ["coverage (==4.3.4)", "coverage (==4.5)", "coverage (==5.3.1)", "coverage (==5.3.1)", "coverage (==5.3.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==2.8.1)", "pytest-cov (==2.8.1)", "pytest-cov (==2.9.0)", "pytest-cov (==3.0.0)", "xdoctest (==1.1.5)"] - -[[package]] -name = "setuptools" -version = "75.1.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, - {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (>=1.11.0,<1.12.0)", "pytest-mypy"] - -[[package]] -name = "shapely" -version = "2.0.6" -description = "Manipulation and analysis of geometric objects" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "shapely-2.0.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29a34e068da2d321e926b5073539fd2a1d4429a2c656bd63f0bd4c8f5b236d0b"}, - {file = "shapely-2.0.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c84c3f53144febf6af909d6b581bc05e8785d57e27f35ebaa5c1ab9baba13b"}, - {file = "shapely-2.0.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad2fae12dca8d2b727fa12b007e46fbc522148a584f5d6546c539f3464dccde"}, - {file = "shapely-2.0.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3304883bd82d44be1b27a9d17f1167fda8c7f5a02a897958d86c59ec69b705e"}, - {file = "shapely-2.0.6-cp310-cp310-win32.whl", hash = "sha256:3ec3a0eab496b5e04633a39fa3d5eb5454628228201fb24903d38174ee34565e"}, - {file = "shapely-2.0.6-cp310-cp310-win_amd64.whl", hash = "sha256:28f87cdf5308a514763a5c38de295544cb27429cfa655d50ed8431a4796090c4"}, - {file = "shapely-2.0.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5aeb0f51a9db176da9a30cb2f4329b6fbd1e26d359012bb0ac3d3c7781667a9e"}, - {file = "shapely-2.0.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9a7a78b0d51257a367ee115f4d41ca4d46edbd0dd280f697a8092dd3989867b2"}, - {file = "shapely-2.0.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f32c23d2f43d54029f986479f7c1f6e09c6b3a19353a3833c2ffb226fb63a855"}, - {file = "shapely-2.0.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3dc9fb0eb56498912025f5eb352b5126f04801ed0e8bdbd867d21bdbfd7cbd0"}, - {file = "shapely-2.0.6-cp311-cp311-win32.whl", hash = "sha256:d93b7e0e71c9f095e09454bf18dad5ea716fb6ced5df3cb044564a00723f339d"}, - {file = "shapely-2.0.6-cp311-cp311-win_amd64.whl", hash = "sha256:c02eb6bf4cfb9fe6568502e85bb2647921ee49171bcd2d4116c7b3109724ef9b"}, - {file = "shapely-2.0.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cec9193519940e9d1b86a3b4f5af9eb6910197d24af02f247afbfb47bcb3fab0"}, - {file = "shapely-2.0.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83b94a44ab04a90e88be69e7ddcc6f332da7c0a0ebb1156e1c4f568bbec983c3"}, - {file = "shapely-2.0.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:537c4b2716d22c92036d00b34aac9d3775e3691f80c7aa517c2c290351f42cd8"}, - {file = "shapely-2.0.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fea108334be345c283ce74bf064fa00cfdd718048a8af7343c59eb40f59726"}, - {file = "shapely-2.0.6-cp312-cp312-win32.whl", hash = "sha256:42fd4cd4834747e4990227e4cbafb02242c0cffe9ce7ef9971f53ac52d80d55f"}, - {file = "shapely-2.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:665990c84aece05efb68a21b3523a6b2057e84a1afbef426ad287f0796ef8a48"}, - {file = "shapely-2.0.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:42805ef90783ce689a4dde2b6b2f261e2c52609226a0438d882e3ced40bb3013"}, - {file = "shapely-2.0.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d2cb146191a47bd0cee8ff5f90b47547b82b6345c0d02dd8b25b88b68af62d7"}, - {file = "shapely-2.0.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3fdef0a1794a8fe70dc1f514440aa34426cc0ae98d9a1027fb299d45741c381"}, - {file = "shapely-2.0.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c665a0301c645615a107ff7f52adafa2153beab51daf34587170d85e8ba6805"}, - {file = "shapely-2.0.6-cp313-cp313-win32.whl", hash = "sha256:0334bd51828f68cd54b87d80b3e7cee93f249d82ae55a0faf3ea21c9be7b323a"}, - {file = "shapely-2.0.6-cp313-cp313-win_amd64.whl", hash = "sha256:d37d070da9e0e0f0a530a621e17c0b8c3c9d04105655132a87cfff8bd77cc4c2"}, - {file = "shapely-2.0.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fa7468e4f5b92049c0f36d63c3e309f85f2775752e076378e36c6387245c5462"}, - {file = "shapely-2.0.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed5867e598a9e8ac3291da6cc9baa62ca25706eea186117034e8ec0ea4355653"}, - {file = "shapely-2.0.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81d9dfe155f371f78c8d895a7b7f323bb241fb148d848a2bf2244f79213123fe"}, - {file = "shapely-2.0.6-cp37-cp37m-win32.whl", hash = "sha256:fbb7bf02a7542dba55129062570211cfb0defa05386409b3e306c39612e7fbcc"}, - {file = "shapely-2.0.6-cp37-cp37m-win_amd64.whl", hash = "sha256:837d395fac58aa01aa544495b97940995211e3e25f9aaf87bc3ba5b3a8cd1ac7"}, - {file = "shapely-2.0.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c6d88ade96bf02f6bfd667ddd3626913098e243e419a0325ebef2bbd481d1eb6"}, - {file = "shapely-2.0.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8b3b818c4407eaa0b4cb376fd2305e20ff6df757bf1356651589eadc14aab41b"}, - {file = "shapely-2.0.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbc783529a21f2bd50c79cef90761f72d41c45622b3e57acf78d984c50a5d13"}, - {file = "shapely-2.0.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2423f6c0903ebe5df6d32e0066b3d94029aab18425ad4b07bf98c3972a6e25a1"}, - {file = "shapely-2.0.6-cp38-cp38-win32.whl", hash = "sha256:2de00c3bfa80d6750832bde1d9487e302a6dd21d90cb2f210515cefdb616e5f5"}, - {file = "shapely-2.0.6-cp38-cp38-win_amd64.whl", hash = "sha256:3a82d58a1134d5e975f19268710e53bddd9c473743356c90d97ce04b73e101ee"}, - {file = "shapely-2.0.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:392f66f458a0a2c706254f473290418236e52aa4c9b476a072539d63a2460595"}, - {file = "shapely-2.0.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eba5bae271d523c938274c61658ebc34de6c4b33fdf43ef7e938b5776388c1be"}, - {file = "shapely-2.0.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7060566bc4888b0c8ed14b5d57df8a0ead5c28f9b69fb6bed4476df31c51b0af"}, - {file = "shapely-2.0.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b02154b3e9d076a29a8513dffcb80f047a5ea63c897c0cd3d3679f29363cf7e5"}, - {file = "shapely-2.0.6-cp39-cp39-win32.whl", hash = "sha256:44246d30124a4f1a638a7d5419149959532b99dfa25b54393512e6acc9c211ac"}, - {file = "shapely-2.0.6-cp39-cp39-win_amd64.whl", hash = "sha256:2b542d7f1dbb89192d3512c52b679c822ba916f93479fa5d4fc2fe4fa0b3c9e8"}, - {file = "shapely-2.0.6.tar.gz", hash = "sha256:997f6159b1484059ec239cacaa53467fd8b5564dabe186cd84ac2944663b0bf6"}, -] - -[package.dependencies] -numpy = ">=1.14,<3" - -[package.extras] -docs = ["matplotlib", "numpydoc (>=1.1.0,<1.2.0)", "sphinx", "sphinx-book-theme", "sphinx-remove-toctrees"] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "simplekml" -version = "1.3.6" -description = "A Simple KML creator" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "simplekml-1.3.6.tar.gz", hash = "sha256:cda687be2754395fcab664e908ebf589facd41e8436d233d2be37a69efb1c536"}, -] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "stack-data" -version = "0.6.3" -description = "Extract data from python stack frames and tracebacks for informative displays" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, - {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, -] - -[package.dependencies] -asttokens = ">=2.1.0" -executing = ">=1.2.0" -pure-eval = "*" - -[package.extras] -tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] - -[[package]] -name = "tomli" -version = "2.0.2" -description = "A lil' TOML parser" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, - {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, -] - -[[package]] -name = "traitlets" -version = "5.14.3" -description = "Traitlets Python configuration system" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, - {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, -] - -[package.extras] -docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] -test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] - -[[package]] -name = "transformations" -version = "2024.5.24" -description = "Homogeneous Transformation Matrices and Quaternions" -category = "main" -optional = false -python-versions = ">=3.9" -files = [ - {file = "transformations-2024.5.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b538ec7fc815c4ab6f427e339eb1a4eddd46860fff306f8f7c6eb80167d08d28"}, - {file = "transformations-2024.5.24-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c3c6b07e1d0d9714f3ccd163785845ff780df26ab094ed125b11d0922c4fee7"}, - {file = "transformations-2024.5.24-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc36351fed3d7bc2d413bab358828870f02a41d71723033032d08e2e75361892"}, - {file = "transformations-2024.5.24-cp310-cp310-win32.whl", hash = "sha256:5ddcec2e35a05a3f81a318c4d7184a4c347b85030cdbc36fdad92c7eb2ba70d4"}, - {file = "transformations-2024.5.24-cp310-cp310-win_amd64.whl", hash = "sha256:c599f0140f18f590c50992f5011593ce08e4b893ff26fde4bf7bf5e0db1d9c60"}, - {file = "transformations-2024.5.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1290cdcb65067809cd8dd4b3c3ab552fb6b50e99c015c1678618de2b67913b72"}, - {file = "transformations-2024.5.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:575d0b5d8c2c92931da46fc55568f17f7b306334ba25fa6091ac5708808bfbfc"}, - {file = "transformations-2024.5.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fba5109b455836829ce6476ba53c16f6e72187340084d004dcfac9bf11a2bfd7"}, - {file = "transformations-2024.5.24-cp311-cp311-win32.whl", hash = "sha256:c76e772b77d4665440dac82b36485565e0e0d4ef3679b48ef4449b73935e44fb"}, - {file = "transformations-2024.5.24-cp311-cp311-win_amd64.whl", hash = "sha256:22fd2283b4aa51609ce3d6929add2fef656f049c5cfa291cc6abfdae2a329f7f"}, - {file = "transformations-2024.5.24-cp311-cp311-win_arm64.whl", hash = "sha256:be69c3055aa341db7bd231d979ebaae1e64417246a6792c1d03631193edde442"}, - {file = "transformations-2024.5.24-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cf0a5f141dcf84a7b5efb27747c2b852d1f9cc8d621d3bdc17fb2f9ac37eaee3"}, - {file = "transformations-2024.5.24-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a655966a893ade762f12946c02e3ffa5cd826cf0866e9587e46f812d494ad56e"}, - {file = "transformations-2024.5.24-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f87541e8bb87337b8bc1839188d07ad922124f23ce1eca1ce4c6f5bcf5f1e4f"}, - {file = "transformations-2024.5.24-cp312-cp312-win32.whl", hash = "sha256:f713ac891a46abfd98158c8a72166a1f92ebaba1644dc8aa59b5357391d6d9e0"}, - {file = "transformations-2024.5.24-cp312-cp312-win_amd64.whl", hash = "sha256:d81ed1df5dbdd204cd447728cc5b44d4bf1ccd4af5401ee61032477f5de5788b"}, - {file = "transformations-2024.5.24-cp312-cp312-win_arm64.whl", hash = "sha256:bbb74d4701be2fc7852c80493e2b92cd52485d4712ce8bfc03c92fb7bdd365c5"}, - {file = "transformations-2024.5.24-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d70a175f7baff6cc70236f74d51c5adb14a3cc3d89fc2e108c1f086f9a834e88"}, - {file = "transformations-2024.5.24-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:29f4ebee83c2fb6857f7dbb15917e39bc6bed1f309179452a0dfeda37e2d4d0d"}, - {file = "transformations-2024.5.24-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b20a57cfe4b5f5fadf957bff68c40a86437f85e75b2ed655b87beb45d62940"}, - {file = "transformations-2024.5.24-cp39-cp39-win32.whl", hash = "sha256:873a7d09a428ccf0156949b1541eae93ee954771c7b431e7d15a79ab2c32d5e4"}, - {file = "transformations-2024.5.24-cp39-cp39-win_amd64.whl", hash = "sha256:dda37a7683acb10068c491b6d33bdbdbfb1a1ea4a3291639b23ee4a0af42138c"}, - {file = "transformations-2024.5.24.tar.gz", hash = "sha256:960328ce2f5e1dad8025b1b82c8588afbc57644c609899c5b9508af965cd7bc0"}, -] - -[package.dependencies] -numpy = "*" - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "ubelt" -version = "1.3.6" -description = "A Python utility belt containing simple tools, a stdlib like feel, and extra batteries" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "ubelt-1.3.6-py3-none-any.whl", hash = "sha256:2a38f260e7f3c25d3618f653d5c900230dc5af56bf0bc1ff85cfdbe9d0c88f0f"}, - {file = "ubelt-1.3.6.tar.gz", hash = "sha256:327a516a1fc95595096727ae3ae879379bc56fc11fb945857b971ef85a74f698"}, -] - -[package.extras] -all = ["Pygments (>=2.2.0)", "colorama (>=0.4.3)", "coverage (>=4.3.4)", "coverage (>=4.5)", "coverage (>=5.3.1)", "coverage (>=5.3.1)", "coverage (>=5.3.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=7.3.0)", "jaraco.windows (>=3.9.1)", "numpy (>=1.12.0,<2.0.0)", "numpy (>=1.14.5,<2.0.0)", "numpy (>=1.19.2)", "numpy (>=1.19.3)", "numpy (>=1.21.1)", "numpy (>=1.23.2)", "numpy (>=1.26.0)", "packaging (>=21.0)", "pydantic (<2.0)", "pytest (>=4.6.0)", "pytest (>=4.6.0)", "pytest (>=4.6.0,<=4.6.11)", "pytest (>=4.6.0,<=4.6.11)", "pytest (>=4.6.0,<=6.1.2)", "pytest (>=6.2.5)", "pytest (>=8.1.1)", "pytest (>=8.1.1)", "pytest (>=8.1.1)", "pytest-cov (>=2.8.1)", "pytest-cov (>=2.8.1)", "pytest-cov (>=2.9.0)", "pytest-cov (>=3.0.0)", "pytest-timeout (>=1.4.2)", "pytest-timeout (>=2.3.1)", "python-dateutil (>=2.8.1)", "requests (>=2.25.1)", "xdoctest (>=1.1.3)", "xxhash (>=1.3.0)", "xxhash (>=1.3.0)", "xxhash (>=1.4.3)", "xxhash (>=2.0.2)", "xxhash (>=3.0.0)", "xxhash (>=3.2.0)", "xxhash (>=3.4.1)"] -all-strict = ["Pygments (==2.2.0)", "colorama (==0.4.3)", "coverage (==4.3.4)", "coverage (==4.5)", "coverage (==5.3.1)", "coverage (==5.3.1)", "coverage (==5.3.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==7.3.0)", "jaraco.windows (==3.9.1)", "numpy (==1.12.0)", "numpy (==1.14.5)", "numpy (==1.19.2)", "numpy (==1.19.3)", "numpy (==1.21.1)", "numpy (==1.23.2)", "numpy (==1.26.0)", "packaging (==21.0)", "pydantic (<2.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest (==8.1.1)", "pytest (==8.1.1)", "pytest (==8.1.1)", "pytest-cov (==2.8.1)", "pytest-cov (==2.8.1)", "pytest-cov (==2.9.0)", "pytest-cov (==3.0.0)", "pytest-timeout (==1.4.2)", "pytest-timeout (==2.3.1)", "python-dateutil (==2.8.1)", "requests (==2.25.1)", "xdoctest (==1.1.3)", "xxhash (==1.3.0)", "xxhash (==1.3.0)", "xxhash (==1.4.3)", "xxhash (==2.0.2)", "xxhash (==3.0.0)", "xxhash (==3.2.0)", "xxhash (==3.4.1)"] -docs = ["Pygments (>=2.9.0)", "myst-parser (>=0.16.1)", "sphinx (>=4.3.2)", "sphinx-autoapi (>=1.8.4)", "sphinx-autobuild (>=2021.3.14)", "sphinx-reredirects (>=0.0.1)", "sphinx-rtd-theme (>=1.0.0)", "sphinxcontrib-napoleon (>=0.7)"] -docs-strict = ["Pygments (==2.9.0)", "myst-parser (==0.16.1)", "sphinx (==4.3.2)", "sphinx-autoapi (==1.8.4)", "sphinx-autobuild (==2021.3.14)", "sphinx-reredirects (==0.0.1)", "sphinx-rtd-theme (==1.0.0)", "sphinxcontrib-napoleon (==0.7)"] -optional = ["Pygments (>=2.2.0)", "colorama (>=0.4.3)", "jaraco.windows (>=3.9.1)", "numpy (>=1.12.0,<2.0.0)", "numpy (>=1.14.5,<2.0.0)", "numpy (>=1.19.2)", "numpy (>=1.19.3)", "numpy (>=1.21.1)", "numpy (>=1.23.2)", "numpy (>=1.26.0)", "packaging (>=21.0)", "pydantic (<2.0)", "python-dateutil (>=2.8.1)", "xxhash (>=1.3.0)", "xxhash (>=1.3.0)", "xxhash (>=1.4.3)", "xxhash (>=2.0.2)", "xxhash (>=3.0.0)", "xxhash (>=3.2.0)", "xxhash (>=3.4.1)"] -optional-strict = ["Pygments (==2.2.0)", "colorama (==0.4.3)", "jaraco.windows (==3.9.1)", "numpy (==1.12.0)", "numpy (==1.14.5)", "numpy (==1.19.2)", "numpy (==1.19.3)", "numpy (==1.21.1)", "numpy (==1.23.2)", "numpy (==1.26.0)", "packaging (==21.0)", "pydantic (<2.0)", "python-dateutil (==2.8.1)", "xxhash (==1.3.0)", "xxhash (==1.3.0)", "xxhash (==1.4.3)", "xxhash (==2.0.2)", "xxhash (==3.0.0)", "xxhash (==3.2.0)", "xxhash (==3.4.1)"] -tests = ["coverage (>=4.3.4)", "coverage (>=4.5)", "coverage (>=5.3.1)", "coverage (>=5.3.1)", "coverage (>=5.3.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=6.1.1)", "coverage (>=7.3.0)", "pytest (>=4.6.0)", "pytest (>=4.6.0)", "pytest (>=4.6.0,<=4.6.11)", "pytest (>=4.6.0,<=4.6.11)", "pytest (>=4.6.0,<=6.1.2)", "pytest (>=6.2.5)", "pytest (>=8.1.1)", "pytest (>=8.1.1)", "pytest (>=8.1.1)", "pytest-cov (>=2.8.1)", "pytest-cov (>=2.8.1)", "pytest-cov (>=2.9.0)", "pytest-cov (>=3.0.0)", "pytest-timeout (>=1.4.2)", "pytest-timeout (>=2.3.1)", "requests (>=2.25.1)", "xdoctest (>=1.1.3)"] -tests-strict = ["coverage (==4.3.4)", "coverage (==4.5)", "coverage (==5.3.1)", "coverage (==5.3.1)", "coverage (==5.3.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==6.1.1)", "coverage (==7.3.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest (==8.1.1)", "pytest (==8.1.1)", "pytest (==8.1.1)", "pytest-cov (==2.8.1)", "pytest-cov (==2.8.1)", "pytest-cov (==2.9.0)", "pytest-cov (==3.0.0)", "pytest-timeout (==1.4.2)", "pytest-timeout (==2.3.1)", "requests (==2.25.1)", "xdoctest (==1.1.3)"] -types = ["autoflake (>=1.4)", "mypy", "yapf (>=0.32.0)"] -types-strict = ["autoflake (==1.4)", "mypy", "yapf (==0.32.0)"] - -[[package]] -name = "wcwidth" -version = "0.2.13" -description = "Measures the displayed width of unicode strings in a terminal" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, - {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, -] - -[[package]] -name = "zope-interface" -version = "7.0.3" -description = "Interfaces for Python" -category = "main" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zope.interface-7.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b9369671a20b8d039b8e5a1a33abd12e089e319a3383b4cc0bf5c67bd05fe7b"}, - {file = "zope.interface-7.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db6237e8fa91ea4f34d7e2d16d74741187e9105a63bbb5686c61fea04cdbacca"}, - {file = "zope.interface-7.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53d678bb1c3b784edbfb0adeebfeea6bf479f54da082854406a8f295d36f8386"}, - {file = "zope.interface-7.0.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3aa8fcbb0d3c2be1bfd013a0f0acd636f6ed570c287743ae2bbd467ee967154d"}, - {file = "zope.interface-7.0.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6195c3c03fef9f87c0dbee0b3b6451df6e056322463cf35bca9a088e564a3c58"}, - {file = "zope.interface-7.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:11fa1382c3efb34abf16becff8cb214b0b2e3144057c90611621f2d186b7e1b7"}, - {file = "zope.interface-7.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:af94e429f9d57b36e71ef4e6865182090648aada0cb2d397ae2b3f7fc478493a"}, - {file = "zope.interface-7.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dd647fcd765030638577fe6984284e0ebba1a1008244c8a38824be096e37fe3"}, - {file = "zope.interface-7.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bee1b722077d08721005e8da493ef3adf0b7908e0cd85cc7dc836ac117d6f32"}, - {file = "zope.interface-7.0.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2545d6d7aac425d528cd9bf0d9e55fcd47ab7fd15f41a64b1c4bf4c6b24946dc"}, - {file = "zope.interface-7.0.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d04b11ea47c9c369d66340dbe51e9031df2a0de97d68f442305ed7625ad6493"}, - {file = "zope.interface-7.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:064ade95cb54c840647205987c7b557f75d2b2f7d1a84bfab4cf81822ef6e7d1"}, - {file = "zope.interface-7.0.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3fcdc76d0cde1c09c37b7c6b0f8beba2d857d8417b055d4f47df9c34ec518bdd"}, - {file = "zope.interface-7.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3d4b91821305c8d8f6e6207639abcbdaf186db682e521af7855d0bea3047c8ca"}, - {file = "zope.interface-7.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35062d93bc49bd9b191331c897a96155ffdad10744ab812485b6bad5b588d7e4"}, - {file = "zope.interface-7.0.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c96b3e6b0d4f6ddfec4e947130ec30bd2c7b19db6aa633777e46c8eecf1d6afd"}, - {file = "zope.interface-7.0.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e0c151a6c204f3830237c59ee4770cc346868a7a1af6925e5e38650141a7f05"}, - {file = "zope.interface-7.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:3de1d553ce72868b77a7e9d598c9bff6d3816ad2b4cc81c04f9d8914603814f3"}, - {file = "zope.interface-7.0.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab985c566a99cc5f73bc2741d93f1ed24a2cc9da3890144d37b9582965aff996"}, - {file = "zope.interface-7.0.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d976fa7b5faf5396eb18ce6c132c98e05504b52b60784e3401f4ef0b2e66709b"}, - {file = "zope.interface-7.0.3-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a207c6b2c58def5011768140861a73f5240f4f39800625072ba84e76c9da0b"}, - {file = "zope.interface-7.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:382d31d1e68877061daaa6499468e9eb38eb7625d4369b1615ac08d3860fe896"}, - {file = "zope.interface-7.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c4316a30e216f51acbd9fb318aa5af2e362b716596d82cbb92f9101c8f8d2e7"}, - {file = "zope.interface-7.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01e6e58078ad2799130c14a1d34ec89044ada0e1495329d72ee0407b9ae5100d"}, - {file = "zope.interface-7.0.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:799ef7a444aebbad5a145c3b34bff012b54453cddbde3332d47ca07225792ea4"}, - {file = "zope.interface-7.0.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3b7ce6d46fb0e60897d62d1ff370790ce50a57d40a651db91a3dde74f73b738"}, - {file = "zope.interface-7.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:f418c88f09c3ba159b95a9d1cfcdbe58f208443abb1f3109f4b9b12fd60b187c"}, - {file = "zope.interface-7.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:84f8794bd59ca7d09d8fce43ae1b571be22f52748169d01a13d3ece8394d8b5b"}, - {file = "zope.interface-7.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7d92920416f31786bc1b2f34cc4fc4263a35a407425319572cbf96b51e835cd3"}, - {file = "zope.interface-7.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95e5913ec718010dc0e7c215d79a9683b4990e7026828eedfda5268e74e73e11"}, - {file = "zope.interface-7.0.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1eeeb92cb7d95c45e726e3c1afe7707919370addae7ed14f614e22217a536958"}, - {file = "zope.interface-7.0.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecd32f30f40bfd8511b17666895831a51b532e93fc106bfa97f366589d3e4e0e"}, - {file = "zope.interface-7.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:5112c530fa8aa2108a3196b9c2f078f5738c1c37cfc716970edc0df0414acda8"}, - {file = "zope.interface-7.0.3.tar.gz", hash = "sha256:cd2690d4b08ec9eaf47a85914fe513062b20da78d10d6d789a792c0b20307fb1"}, -] - -[package.dependencies] -setuptools = "*" - -[package.extras] -docs = ["Sphinx", "repoze.sphinx.autointerface", "sphinx-rtd-theme"] -test = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] -testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.10" -content-hash = "f12fd4cb5e2ba880d79c054b211f719ad02260180998d7090af72a2cf619e730" diff --git a/pyproject.toml b/pyproject.toml index 1268c081..c7619831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,39 +1,46 @@ -[tool.poetry] +[project] name = "kamera" version = "0.1.0" description = "KAMERA: Kitware's Image Acquisition ManagER and Archiver" -authors = ["Adam Romlein "] +authors = [ + { name = "Adam Romlein", email = "adam.romlein@kitware.com" }, +] license = "Apache-2.0" readme = "README.md" - -[tool.poetry.dependencies] -python = ">=3.8,<4" -numpy = {version = "^2.1.1", markers = "python_version >= '3.10'"} -scipy = {version = "^1.14.1", markers = "python_version >= '3.10'"} -matplotlib = {version = "^3.9.2", markers = "python_version >= '3.10'"} -opencv-python = {version = "^4.10.0.84", markers = "python_version >= '3.10'"} -pillow = {version = "^10.4.0", markers = "python_version >= '3.10'"} -pyyaml = {version = "^6.0.2", markers = "python_version >= '3.10'"} -datetime = {version = "^5.5", markers = "python_version >= '3.10'"} -exifread = {version = "^3.0.0", markers = "python_version >= '3.10'"} -pygeodesy = {version = "^24.9.29", markers = "python_version >= '3.10'"} -pyshp = {version = "^2.3.1", markers = "python_version >= '3.10'"} -simplekml = {version = "^1.3.6", markers = "python_version >= '3.10'"} -shapely = {version = "^2.0.6", markers = "python_version >= '3.10'"} -transformations = {version = "^2024.5.24", markers = "python_version >= '3.9'"} -scriptconfig = {version = "^0.8.0", markers = "python_version >= '3.10'"} -ubelt = {version = "^1.3.6", markers = "python_version >= '3.10'"} -rich = {version = "^13.9.1", markers = "python_version >= '3.10'"} -gdal = [ - { url = "https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=8c150cc85623d136734eb2fad91036933cbb2d6ba58a76095b82ddf53d9bf961", markers = "python_version >= '3.8' and python_version < '3.9'" }, - { url = "https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=7894fddd09d31530d764d5f5e52faafa3585b906c2e49c79fe593af8c6a34c24", markers = "python_version >= '3.9' and python_version < '3.10'" }, - { url = "https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=4a09086631d81808a97c8c7a605aa6230ca045874aa688863cf91794e94880c7", markers = "python_version >= '3.10' and python_version < '3.11'" }, - { url = "https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=aee8c49c3528b8613ad3fe14a9d0066ad6990e143f277aff5be1143f371258a2", markers = "python_version >= '3.11' and python_version < '3.12'" }, - { url = "https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=19cd80ad4bd684e8c7a3f712e5a1c284cce59e5750155f0db0c31d875d3c6321", markers = "python_version >= '3.12' and python_version < '3.13'" }, +requires-python = ">=3.10" +dependencies = [ + "numpy>=2.1.1", + "scipy>=1.14.1", + "matplotlib>=3.9.2", + "opencv-python>=4.10.0.84", + "pillow>=10.4.0", + "pyyaml>=6.0.2", + "datetime>=5.5", + "exifread>=3.0.0", + "pygeodesy>=24.9.29", + "pyshp>=2.3.1", + "simplekml>=1.3.6", + "shapely>=2.0.6", + "transformations>=2024.5.24", + "scriptconfig>=0.8.0", + "ubelt>=1.3.6", + "rich>=13.9.1", + "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=4a09086631d81808a97c8c7a605aa6230ca045874aa688863cf91794e94880c7 ; python_version >= '3.10' and python_version < '3.11'", + "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=aee8c49c3528b8613ad3fe14a9d0066ad6990e143f277aff5be1143f371258a2 ; python_version >= '3.11' and python_version < '3.12'", + "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=19cd80ad4bd684e8c7a3f712e5a1c284cce59e5750155f0db0c31d875d3c6321 ; python_version >= '3.12' and python_version < '3.13'", ] -ipdb = {version = "^0.13.13", markers = "python_version >= '3.10'"} +[dependency-groups] +dev = [ + "ipdb>=0.13.13", +] [build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["kamera"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9237998f..00000000 --- a/requirements.txt +++ /dev/null @@ -1,18 +0,0 @@ -numpy -scipy -matplotlib -opencv-python -Pillow -pyyaml -datetime -exifread -matplotlib -numpy -pygeodesy -pyshp -simplekml -shapely -transformations -scriptconfig -ubelt -rich diff --git a/setup.py b/setup.py deleted file mode 100644 index 674e86ae..00000000 --- a/setup.py +++ /dev/null @@ -1,9 +0,0 @@ -from setuptools import setup, find_packages -setup( - name='kamera', - version='0.1', - description="KAMERA: Kitware's Image Acquisition ManagER and Archiver", - author='Adam Romlein', - author_email='adam.romlein@kitware.com', - packages=find_packages("."), - ) From 6267277a4923be5c579078836ad92a15db3db990 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Fri, 3 Jul 2026 22:15:26 -0400 Subject: [PATCH 02/32] Install the postproc image with uv sync --frozen Replace the pip editable install in kamerapy.dockerfile with the standard uv Docker pattern: the uv binary is copied from the official distroless image, dependencies are synced from the lockfile in a cached layer before the source copy, and the project venv is put on PATH. Add a .dockerignore so a local .venv can't leak into the build context, and gitignore .venv. --- .dockerignore | 3 +++ .gitignore | 1 + docker/kamerapy.dockerfile | 21 +++++++++++++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a63d738e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.venv +**/__pycache__ +**/*.pyc diff --git a/.gitignore b/.gitignore index 1797505c..e2f8a04e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build devel provision/ansible/.password .catkin_tools +.venv diff --git a/docker/kamerapy.dockerfile b/docker/kamerapy.dockerfile index e00f1bf7..90968ae2 100644 --- a/docker/kamerapy.dockerfile +++ b/docker/kamerapy.dockerfile @@ -1,5 +1,7 @@ FROM python:3.10.15-bookworm +COPY --from=ghcr.io/astral-sh/uv:0.6.1 /uv /uvx /bin/ + RUN apt-get update && apt-get install -yq \ libgdal-dev \ python3-gdal \ @@ -10,11 +12,22 @@ RUN apt-get update && apt-get install -yq \ dnsutils \ gdal-bin -RUN pip install --upgrade pip -RUN pip install setuptools==57.0.0 +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy -COPY ./ /src/kamera WORKDIR /src/kamera -RUN pip install -e . + +# Install dependencies before copying source so this layer caches across +# code-only changes. uv.lock is gitignored: run `uv lock` before building. +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-project + +COPY ./ /src/kamera +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen + +ENV PATH="/src/kamera/.venv/bin:$PATH" ENTRYPOINT ["bash"] From ddb34915eb14a890c1301e819f2a63b3f97b8d91 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Fri, 3 Jul 2026 22:15:50 -0400 Subject: [PATCH 03/32] Let the py3.8 GUI image install past the new requires-python floor The gui image installs kamera editable with --no-deps under ROS Noetic's Python 3.8, so pip's metadata check would reject the new requires-python >= 3.10. Bypass it with --ignore-requires-python; the modules the GUI imports still run on 3.8. --- docker/gui.dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/gui.dockerfile b/docker/gui.dockerfile index f46b5651..15e009f1 100644 --- a/docker/gui.dockerfile +++ b/docker/gui.dockerfile @@ -39,8 +39,10 @@ RUN find /home/user -not -user user -execdir chown user {} \+ # Install kamera for wxpython_gui imports (e.g. colmap_processing.camera_models). # Use --no-deps: base images already provide runtime deps, and a full install # fails trying to replace distutils-installed PyYAML from ROS/Noetic. +# --ignore-requires-python: the package targets >=3.10 but ROS Noetic pins +# this image to Python 3.8; the modules the GUI imports still run there. RUN pip install --no-cache-dir matplotlib \ - && pip install --no-cache-dir --no-deps -e $REPO_DIR + && pip install --no-cache-dir --no-deps --ignore-requires-python -e $REPO_DIR # use the exec form of run because we need bash syntax USER user From 5a5ce6977625fd325bfcc40579f17c43606c454f Mon Sep 17 00:00:00 2001 From: romleiaj Date: Sun, 5 Jul 2026 10:12:56 -0400 Subject: [PATCH 04/32] Add a conda env to support gdal cross-platform --- README.md | 11 ++++++-- docker/kamerapy.dockerfile | 4 +-- environment.yml | 12 ++++++++ pyproject.toml | 15 ++++++++-- scripts/setup_postproc.ps1 | 56 ++++++++++++++++++++++++++++++++++++++ scripts/setup_postproc.sh | 53 ++++++++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 environment.yml create mode 100644 scripts/setup_postproc.ps1 create mode 100755 scripts/setup_postproc.sh diff --git a/README.md b/README.md index 1279a9f7..7a39a19e 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,14 @@ KAMERA, or the **K**nowledge-guided Image **A**cquisition **M**anag**ER** and ** ```bash git clone https://github.com/Kitware/kamera.git cd kamera -# For the pure post-processing and generating flight summary, you can install -# the package with uv (https://docs.astral.sh/uv/), or use the provided dockerfile -uv sync +# For the pure post-processing and generating flight summary, install natively +# (works on Windows and Linux): GDAL comes from conda-forge, and uv +# (https://docs.astral.sh/uv/) layers the rest of the environment on top. +# Requires conda (e.g. miniforge) on PATH. +./scripts/setup_postproc.sh # Linux/macOS +# .\scripts\setup_postproc.ps1 # Windows (PowerShell) +conda activate kamera && source .venv/bin/activate +# Or build the post-processing docker image instead: make postflight # Builds the core docker images for use in the onboard sytems make nuvo diff --git a/docker/kamerapy.dockerfile b/docker/kamerapy.dockerfile index 90968ae2..136f0ef7 100644 --- a/docker/kamerapy.dockerfile +++ b/docker/kamerapy.dockerfile @@ -22,11 +22,11 @@ WORKDIR /src/kamera RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project + uv sync --frozen --no-install-project --extra gdal COPY ./ /src/kamera RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen + uv sync --frozen --extra gdal ENV PATH="/src/kamera/.venv/bin:$PATH" diff --git a/environment.yml b/environment.yml new file mode 100644 index 00000000..20d31ccc --- /dev/null +++ b/environment.yml @@ -0,0 +1,12 @@ +# Conda environment providing the binary geo stack (GDAL) that has no +# reliable cross-platform wheels. The project itself is installed by uv into +# a venv created on top of this env with --system-site-packages, so python +# here must satisfy requires-python in pyproject.toml. +# Setup: scripts/setup_postproc.sh (Linux/macOS) or setup_postproc.ps1 (Windows) +name: kamera +channels: + - conda-forge +dependencies: + - python>=3.10 + - gdal>=3.10 + - uv diff --git a/pyproject.toml b/pyproject.toml index c7619831..1b7495b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,18 @@ dependencies = [ "scriptconfig>=0.8.0", "ubelt>=1.3.6", "rich>=13.9.1", - "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=4a09086631d81808a97c8c7a605aa6230ca045874aa688863cf91794e94880c7 ; python_version >= '3.10' and python_version < '3.11'", - "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=aee8c49c3528b8613ad3fe14a9d0066ad6990e143f277aff5be1143f371258a2 ; python_version >= '3.11' and python_version < '3.12'", - "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=19cd80ad4bd684e8c7a3f712e5a1c284cce59e5750155f0db0c31d875d3c6321 ; python_version >= '3.12' and python_version < '3.13'", +] + +# GDAL is intentionally not a core dependency: there are no reliable +# cross-platform wheels. For native installs (Windows/Linux/macOS), it comes +# from conda-forge and the uv venv is built on top of the conda env with +# --system-site-packages (see scripts/setup_postproc.{sh,ps1}). The +# Linux-only wheel extra below exists for the Docker images. +[project.optional-dependencies] +gdal = [ + "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=4a09086631d81808a97c8c7a605aa6230ca045874aa688863cf91794e94880c7 ; python_version >= '3.10' and python_version < '3.11' and sys_platform == 'linux'", + "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=aee8c49c3528b8613ad3fe14a9d0066ad6990e143f277aff5be1143f371258a2 ; python_version >= '3.11' and python_version < '3.12' and sys_platform == 'linux'", + "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=19cd80ad4bd684e8c7a3f712e5a1c284cce59e5750155f0db0c31d875d3c6321 ; python_version >= '3.12' and python_version < '3.13' and sys_platform == 'linux'", ] [dependency-groups] diff --git a/scripts/setup_postproc.ps1 b/scripts/setup_postproc.ps1 new file mode 100644 index 00000000..efd25ae9 --- /dev/null +++ b/scripts/setup_postproc.ps1 @@ -0,0 +1,56 @@ +# Set up the native post-processing environment (Windows). +# +# GDAL comes from conda-forge (environment.yml); everything else is installed +# by uv into .venv, which is created from the conda python with +# --system-site-packages so the conda GDAL is importable. +# +# Requires conda, mamba, or micromamba on PATH. Usage (PowerShell): +# .\scripts\setup_postproc.ps1 +# The env name defaults to 'kamera'; override with $env:KAMERA_CONDA_ENV. +# Afterwards (conda activation is required on Windows so GDAL's DLLs resolve): +# conda activate kamera; .\.venv\Scripts\Activate.ps1 +$ErrorActionPreference = "Stop" + +Set-Location (Join-Path $PSScriptRoot "..") + +$EnvName = if ($env:KAMERA_CONDA_ENV) { $env:KAMERA_CONDA_ENV } else { "kamera" } + +$CondaTool = $null +foreach ($tool in @("conda", "mamba", "micromamba")) { + if (Get-Command $tool -ErrorAction SilentlyContinue) { + $CondaTool = $tool + break + } +} +if (-not $CondaTool) { + throw "conda, mamba, or micromamba is required on PATH" +} + +$Yes = @() +if ($CondaTool -eq "micromamba") { $Yes = @("-y") } + +$envExists = (& $CondaTool env list) | Where-Object { ($_.Trim() -split '\s+')[0] -eq $EnvName } +if ($envExists) { + # No --prune: the env may hold other tools (e.g. colmap) we shouldn't remove. + Write-Host "Updating existing env '$EnvName' with $CondaTool..." + & $CondaTool env update -n $EnvName -f environment.yml @Yes +} else { + Write-Host "Creating env '$EnvName' with $CondaTool..." + & $CondaTool env create -n $EnvName -f environment.yml @Yes +} +if ($LASTEXITCODE -ne 0) { throw "$CondaTool env setup failed" } + +$CondaPy = (& $CondaTool run -n $EnvName python -c "import sys; print(sys.executable)").Trim() +Write-Host "Conda python: $CondaPy" + +& $CondaTool run -n $EnvName uv venv --clear --python $CondaPy --system-site-packages .venv +if ($LASTEXITCODE -ne 0) { throw "uv venv failed" } +& $CondaTool run -n $EnvName uv sync --python $CondaPy +if ($LASTEXITCODE -ne 0) { throw "uv sync failed" } + +& $CondaTool run -n $EnvName .venv\Scripts\python.exe -c "from osgeo import gdal; print(f'GDAL {gdal.__version__} OK')" +if ($LASTEXITCODE -ne 0) { throw "GDAL import check failed" } +Write-Host "" +Write-Host "Done. To use:" +Write-Host " $CondaTool activate $EnvName" +Write-Host " .\.venv\Scripts\Activate.ps1" diff --git a/scripts/setup_postproc.sh b/scripts/setup_postproc.sh new file mode 100755 index 00000000..85b3a182 --- /dev/null +++ b/scripts/setup_postproc.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Set up the native post-processing environment (Linux/macOS). +# +# GDAL comes from conda-forge (environment.yml); everything else is installed +# by uv into .venv, which is created from the conda python with +# --system-site-packages so the conda GDAL is importable. +# +# Requires conda, mamba, or micromamba on PATH. Usage: +# ./scripts/setup_postproc.sh +# The env name defaults to 'kamera'; override with KAMERA_CONDA_ENV. +# Afterwards: +# conda activate kamera && source .venv/bin/activate +set -euo pipefail + +cd "$(dirname "$0")/.." + +ENV_NAME="${KAMERA_CONDA_ENV:-kamera}" + +CONDA_TOOL="" +for tool in conda mamba micromamba; do + if command -v "$tool" >/dev/null 2>&1; then + CONDA_TOOL="$tool" + break + fi +done +if [ -z "$CONDA_TOOL" ]; then + echo "error: conda, mamba, or micromamba is required on PATH" >&2 + exit 1 +fi + +YES="" +[ "$CONDA_TOOL" = "micromamba" ] && YES="-y" + +if "$CONDA_TOOL" env list | awk '{print $1}' | grep -qx "$ENV_NAME"; then + # No --prune: the env may hold other tools (e.g. colmap) we shouldn't remove. + echo "Updating existing env '$ENV_NAME' with $CONDA_TOOL..." + "$CONDA_TOOL" env update -n "$ENV_NAME" -f environment.yml $YES +else + echo "Creating env '$ENV_NAME' with $CONDA_TOOL..." + "$CONDA_TOOL" env create -n "$ENV_NAME" -f environment.yml $YES +fi + +CONDA_PY=$("$CONDA_TOOL" run -n "$ENV_NAME" python -c 'import sys; print(sys.executable)') +echo "Conda python: $CONDA_PY" + +"$CONDA_TOOL" run -n "$ENV_NAME" uv venv --clear --python "$CONDA_PY" --system-site-packages .venv +"$CONDA_TOOL" run -n "$ENV_NAME" uv sync --python "$CONDA_PY" + +.venv/bin/python -c 'from osgeo import gdal; print(f"GDAL {gdal.__version__} OK")' +echo +echo "Done. To use:" +echo " $CONDA_TOOL activate $ENV_NAME" +echo " source .venv/bin/activate" From 7ed5fc069cce82a23127fb3b23866a893cdc4f7e Mon Sep 17 00:00:00 2001 From: romleiaj Date: Mon, 6 Jul 2026 14:37:52 -0400 Subject: [PATCH 05/32] Unify postproc install around conda + make install, in Docker and natively The conda env (environment.yml) supplies python + GDAL + uv; `make install` then builds .venv on top of it with uv venv --system-site-packages and uv sync --frozen. The kamerapy docker image now uses micromamba and the same make install as the native setup scripts, replacing the Linux-only GDAL wheel extra, and setup_postproc.{sh,ps1} are thin wrappers around the same steps for Linux/macOS and Windows. Pin python-preference = "only-system" in [tool.uv]: uv's default otherwise substitutes a managed standalone interpreter for the conda one, leaving the conda GDAL invisible through --system-site-packages. Commit uv.lock, which --frozen requires, and fix bookworm-slim apt deps (libgl1 + libglib2.0-0; libgl1-mesa-glx no longer exists in bookworm). --- .gitignore | 1 - Makefile | 11 +- README.md | 8 +- docker/kamerapy.dockerfile | 43 +- environment.yml | 3 +- pyproject.toml | 24 +- scripts/setup_postproc.ps1 | 16 +- scripts/setup_postproc.sh | 10 +- uv.lock | 1816 ++++++++++++++++++++++++++++++++++++ 9 files changed, 1881 insertions(+), 51 deletions(-) create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index e2f8a04e..e7b7ec1b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ **.swp **.pyc **.DS_Store -uv.lock artifacts build devel diff --git a/Makefile b/Makefile index 0d0026fd..96568bdb 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,15 @@ ROS_DISTRO ?= noetic +PYTHON_VERSION ?= 3.10 -.PHONY: build core viame gui postflight follower leader all clean +.PHONY: install build core viame gui postflight follower leader all clean + +# Install the post-processing environment into .venv. Run inside an activated +# conda env built from environment.yml, which supplies python + GDAL + uv; +# --system-site-packages makes the conda GDAL importable from the venv. +install: + @echo "🚀 Creating virtual environment using uv" + @uv venv --system-site-packages --python=$(PYTHON_VERSION) + @uv sync --frozen --no-cache build: docker compose build diff --git a/README.md b/README.md index 7a39a19e..5e391574 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,14 @@ KAMERA, or the **K**nowledge-guided Image **A**cquisition **M**anag**ER** and ** git clone https://github.com/Kitware/kamera.git cd kamera # For the pure post-processing and generating flight summary, install natively -# (works on Windows and Linux): GDAL comes from conda-forge, and uv -# (https://docs.astral.sh/uv/) layers the rest of the environment on top. -# Requires conda (e.g. miniforge) on PATH. +# (works on Windows and Linux): GDAL comes from conda-forge (environment.yml), +# and uv (https://docs.astral.sh/uv/) layers the rest of the environment into +# .venv on top of it. Requires conda (e.g. miniforge) on PATH. ./scripts/setup_postproc.sh # Linux/macOS # .\scripts\setup_postproc.ps1 # Windows (PowerShell) conda activate kamera && source .venv/bin/activate +# (If you already have a conda env from environment.yml activated, the +# scripts are equivalent to just running: make install) # Or build the post-processing docker image instead: make postflight # Builds the core docker images for use in the onboard sytems diff --git a/docker/kamerapy.dockerfile b/docker/kamerapy.dockerfile index 136f0ef7..55a3c5cb 100644 --- a/docker/kamerapy.dockerfile +++ b/docker/kamerapy.dockerfile @@ -1,33 +1,42 @@ -FROM python:3.10.15-bookworm +FROM debian:bookworm-slim -COPY --from=ghcr.io/astral-sh/uv:0.6.1 /uv /uvx /bin/ +SHELL ["/bin/bash", "-c"] RUN apt-get update && apt-get install -yq \ - libgdal-dev \ - python3-gdal \ - libgl1-mesa-glx \ + curl \ + bzip2 \ + ca-certificates \ + make \ + libgl1 \ + libglib2.0-0 \ libsm6 \ libxext6 \ redis \ dnsutils \ - gdal-bin + && rm -rf /var/lib/apt/lists/* + +# Install micromamba +ARG MAMBA_VERSION=2.3.3 +RUN curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/${MAMBA_VERSION} \ + | tar -xvj -C /usr/local/bin --strip-components=1 bin/micromamba +ENV MAMBA_ROOT_PREFIX=/opt/conda + +# The conda env supplies python + GDAL + uv; uv layers everything else into +# .venv on top of it (same flow as the native setup_postproc scripts). +COPY environment.yml /tmp/environment.yml +RUN micromamba create -y -n kamera -f /tmp/environment.yml \ + && micromamba clean --all -y ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy +COPY ./ /src/kamera WORKDIR /src/kamera -# Install dependencies before copying source so this layer caches across -# code-only changes. uv.lock is gitignored: run `uv lock` before building. -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project --extra gdal - -COPY ./ /src/kamera -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --extra gdal +RUN eval "$(micromamba shell hook --shell bash)" \ + && micromamba activate kamera \ + && make install -ENV PATH="/src/kamera/.venv/bin:$PATH" +ENV PATH="/src/kamera/.venv/bin:/opt/conda/envs/kamera/bin:$PATH" ENTRYPOINT ["bash"] diff --git a/environment.yml b/environment.yml index 20d31ccc..a3cfc2cf 100644 --- a/environment.yml +++ b/environment.yml @@ -7,6 +7,7 @@ name: kamera channels: - conda-forge dependencies: - - python>=3.10 + - python=3.10 # must match PYTHON_VERSION in the Makefile - gdal>=3.10 + - pip - uv diff --git a/pyproject.toml b/pyproject.toml index 1b7495b3..20c0179f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,29 +27,25 @@ dependencies = [ "rich>=13.9.1", ] -# GDAL is intentionally not a core dependency: there are no reliable -# cross-platform wheels. For native installs (Windows/Linux/macOS), it comes -# from conda-forge and the uv venv is built on top of the conda env with -# --system-site-packages (see scripts/setup_postproc.{sh,ps1}). The -# Linux-only wheel extra below exists for the Docker images. -[project.optional-dependencies] -gdal = [ - "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=4a09086631d81808a97c8c7a605aa6230ca045874aa688863cf91794e94880c7 ; python_version >= '3.10' and python_version < '3.11' and sys_platform == 'linux'", - "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=aee8c49c3528b8613ad3fe14a9d0066ad6990e143f277aff5be1143f371258a2 ; python_version >= '3.11' and python_version < '3.12' and sys_platform == 'linux'", - "gdal @ https://github.com/girder/large_image_wheels/raw/wheelhouse/GDAL-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=19cd80ad4bd684e8c7a3f712e5a1c284cce59e5750155f0db0c31d875d3c6321 ; python_version >= '3.12' and python_version < '3.13' and sys_platform == 'linux'", -] +# GDAL is intentionally not a dependency: there are no reliable +# cross-platform wheels. It comes from conda-forge (environment.yml), and the +# uv venv is built on top of the conda env with --system-site-packages so the +# conda GDAL is importable (see `make install` and scripts/setup_postproc.*). [dependency-groups] dev = [ "ipdb>=0.13.13", ] +[tool.uv] +# Never let uv substitute its own managed python: .venv must be built on the +# active conda env's interpreter or --system-site-packages won't see the +# conda GDAL. +python-preference = "only-system" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.metadata] -allow-direct-references = true - [tool.hatch.build.targets.wheel] packages = ["kamera"] diff --git a/scripts/setup_postproc.ps1 b/scripts/setup_postproc.ps1 index efd25ae9..0242dc7c 100644 --- a/scripts/setup_postproc.ps1 +++ b/scripts/setup_postproc.ps1 @@ -1,8 +1,9 @@ # Set up the native post-processing environment (Windows). # # GDAL comes from conda-forge (environment.yml); everything else is installed -# by uv into .venv, which is created from the conda python with -# --system-site-packages so the conda GDAL is importable. +# by uv into .venv, created on top of the conda env with +# --system-site-packages so the conda GDAL is importable. These are the same +# steps as `make install` (make usually isn't available on Windows). # # Requires conda, mamba, or micromamba on PATH. Usage (PowerShell): # .\scripts\setup_postproc.ps1 @@ -11,6 +12,8 @@ # conda activate kamera; .\.venv\Scripts\Activate.ps1 $ErrorActionPreference = "Stop" +$PythonVersion = "3.10" # must match PYTHON_VERSION in the Makefile + Set-Location (Join-Path $PSScriptRoot "..") $EnvName = if ($env:KAMERA_CONDA_ENV) { $env:KAMERA_CONDA_ENV } else { "kamera" } @@ -40,12 +43,11 @@ if ($envExists) { } if ($LASTEXITCODE -ne 0) { throw "$CondaTool env setup failed" } -$CondaPy = (& $CondaTool run -n $EnvName python -c "import sys; print(sys.executable)").Trim() -Write-Host "Conda python: $CondaPy" - -& $CondaTool run -n $EnvName uv venv --clear --python $CondaPy --system-site-packages .venv +# Same steps as `make install` +Write-Host "🚀 Creating virtual environment using uv" +& $CondaTool run -n $EnvName uv venv --system-site-packages --python=$PythonVersion if ($LASTEXITCODE -ne 0) { throw "uv venv failed" } -& $CondaTool run -n $EnvName uv sync --python $CondaPy +& $CondaTool run -n $EnvName uv sync --frozen --no-cache if ($LASTEXITCODE -ne 0) { throw "uv sync failed" } & $CondaTool run -n $EnvName .venv\Scripts\python.exe -c "from osgeo import gdal; print(f'GDAL {gdal.__version__} OK')" diff --git a/scripts/setup_postproc.sh b/scripts/setup_postproc.sh index 85b3a182..c9705372 100755 --- a/scripts/setup_postproc.sh +++ b/scripts/setup_postproc.sh @@ -2,8 +2,8 @@ # Set up the native post-processing environment (Linux/macOS). # # GDAL comes from conda-forge (environment.yml); everything else is installed -# by uv into .venv, which is created from the conda python with -# --system-site-packages so the conda GDAL is importable. +# by `make install`, which runs uv to create .venv on top of the conda env +# with --system-site-packages so the conda GDAL is importable. # # Requires conda, mamba, or micromamba on PATH. Usage: # ./scripts/setup_postproc.sh @@ -40,11 +40,7 @@ else "$CONDA_TOOL" env create -n "$ENV_NAME" -f environment.yml $YES fi -CONDA_PY=$("$CONDA_TOOL" run -n "$ENV_NAME" python -c 'import sys; print(sys.executable)') -echo "Conda python: $CONDA_PY" - -"$CONDA_TOOL" run -n "$ENV_NAME" uv venv --clear --python "$CONDA_PY" --system-site-packages .venv -"$CONDA_TOOL" run -n "$ENV_NAME" uv sync --python "$CONDA_PY" +"$CONDA_TOOL" run -n "$ENV_NAME" make install .venv/bin/python -c 'from osgeo import gdal; print(f"GDAL {gdal.__version__} OK")' echo diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..dd7f9ac3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1816 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", + "python_full_version >= '3.13'", +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551 }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399 }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061 }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956 }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872 }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027 }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641 }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075 }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534 }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188 }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636 }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636 }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053 }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985 }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750 }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246 }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728 }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762 }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196 }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017 }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580 }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530 }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688 }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331 }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963 }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681 }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674 }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480 }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489 }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042 }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630 }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670 }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694 }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986 }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060 }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747 }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895 }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098 }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535 }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096 }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090 }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643 }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443 }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865 }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162 }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355 }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935 }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168 }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550 }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214 }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681 }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101 }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599 }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807 }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729 }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791 }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version >= '3.13'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773 }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149 }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222 }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234 }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555 }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238 }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218 }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867 }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677 }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234 }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123 }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419 }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979 }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653 }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536 }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397 }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601 }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288 }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386 }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018 }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567 }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655 }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257 }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034 }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672 }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234 }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169 }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859 }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062 }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932 }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024 }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578 }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524 }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730 }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897 }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751 }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486 }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106 }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548 }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297 }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023 }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157 }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570 }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713 }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189 }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251 }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810 }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871 }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264 }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819 }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650 }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833 }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692 }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424 }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300 }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769 }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892 }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748 }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554 }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118 }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555 }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295 }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027 }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428 }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331 }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831 }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809 }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593 }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202 }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207 }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315 }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 }, +] + +[[package]] +name = "datetime" +version = "6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/decbfd165e9985ba9d8c2d34a39afe5aeba2fc3fe390eb6e9ef1aab98fa8/datetime-6.0.tar.gz", hash = "sha256:c1514936d2f901e10c8e08d83bf04e6c9dbd7ca4f244da94fec980980a3bc4d5", size = 64167 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/7a/ea0f3e3ea74be36fc7cf54f966cde732a3de72697983cdb5646b0a4dacde/datetime-6.0-py3-none-any.whl", hash = "sha256:d19988f0657a4e72c9438344157254a8dcad6aea8cd5ae70a5d1b5a75e5dc930", size = 52637 }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 }, +] + +[[package]] +name = "exifread" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/4e/d8fce8810d819db47f5b159e75223511c5ccd7ad07c2feca64cf7fab2477/exifread-3.5.1.tar.gz", hash = "sha256:9f998f80d3062741c976dfc4fd033424bc40932937994e4d2181eb70c4b6aedd", size = 56552 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a3/20e34a55c7b225110d3822d07c3cab9e8653d9c179e36783f2ed632a96a7/exifread-3.5.1-py3-none-any.whl", hash = "sha256:e5426ce2857423ad401e575ea9d159dc97449dc041fb6e61b35109caea72c311", size = 59742 }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632 }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441 }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346 }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184 }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967 }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799 }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704 }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666 }, + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793 }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130 }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952 }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308 }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932 }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271 }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473 }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389 }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131 }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704 }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298 }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800 }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666 }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598 }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575 }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211 }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062 }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594 }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840 }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801 }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009 }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892 }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313 }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299 }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338 }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661 }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526 }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946 }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489 }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870 }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026 }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454 }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152 }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809 }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649 }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147 }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237 }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933 }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326 }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829 }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562 }, +] + +[[package]] +name = "ipdb" +version = "0.13.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/1b/7e07e7b752017f7693a0f4d41c13e5ca29ce8cbcfdcc1fd6c4ad8c0a27a0/ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726", size = 17042 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/4c/b075da0092003d9a55cf2ecc1cae9384a1ca4f650d51b00fc59875fe76f6/ipdb-0.13.13-py3-none-any.whl", hash = "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4", size = 12130 }, +] + +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849 }, +] + +[[package]] +name = "ipython" +version = "9.15.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version >= '3.13'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895 }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812 }, +] + +[[package]] +name = "kamera" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "datetime" }, + { name = "exifread" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "pygeodesy" }, + { name = "pyshp" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scriptconfig" }, + { name = "shapely" }, + { name = "simplekml" }, + { name = "transformations", version = "2025.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "transformations", version = "2026.1.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ubelt" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ipdb" }, +] + +[package.metadata] +requires-dist = [ + { name = "datetime", specifier = ">=5.5" }, + { name = "exifread", specifier = ">=3.0.0" }, + { name = "matplotlib", specifier = ">=3.9.2" }, + { name = "numpy", specifier = ">=2.1.1" }, + { name = "opencv-python", specifier = ">=4.10.0.84" }, + { name = "pillow", specifier = ">=10.4.0" }, + { name = "pygeodesy", specifier = ">=24.9.29" }, + { name = "pyshp", specifier = ">=2.3.1" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "rich", specifier = ">=13.9.1" }, + { name = "scipy", specifier = ">=1.14.1" }, + { name = "scriptconfig", specifier = ">=0.8.0" }, + { name = "shapely", specifier = ">=2.0.6" }, + { name = "simplekml", specifier = ">=1.3.6" }, + { name = "transformations", specifier = ">=2024.5.24" }, + { name = "ubelt", specifier = ">=1.3.6" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "ipdb", specifier = ">=0.13.13" }] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802 }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216 }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917 }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776 }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164 }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656 }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562 }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473 }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035 }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217 }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196 }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389 }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782 }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798 }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216 }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911 }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209 }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888 }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304 }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650 }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949 }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125 }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783 }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726 }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738 }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718 }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480 }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930 }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158 }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388 }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068 }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934 }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537 }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685 }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024 }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241 }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742 }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966 }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417 }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238 }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947 }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569 }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997 }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166 }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395 }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065 }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903 }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751 }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793 }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041 }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292 }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865 }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369 }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989 }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645 }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237 }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573 }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998 }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700 }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537 }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514 }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848 }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542 }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447 }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918 }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856 }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580 }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018 }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804 }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482 }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328 }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410 }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231 }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489 }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063 }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913 }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782 }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815 }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925 }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322 }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857 }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376 }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549 }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680 }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905 }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086 }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577 }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794 }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646 }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511 }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858 }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539 }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310 }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244 }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154 }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377 }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288 }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158 }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260 }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403 }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687 }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032 }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606 }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537 }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888 }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584 }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390 }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532 }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420 }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892 }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603 }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cycler", marker = "python_full_version < '3.11'" }, + { name = "fonttools", marker = "python_full_version < '3.11'" }, + { name = "kiwisolver", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "pyparsing", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625 }, + { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790 }, + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389 }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657 }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983 }, + { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701 }, + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860 }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254 }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092 }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691 }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771 }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112 }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310 }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908 }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016 }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336 }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602 }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966 }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462 }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688 }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331 }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461 }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091 }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027 }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269 }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588 }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913 }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019 }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645 }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194 }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684 }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790 }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571 }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292 }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276 }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218 }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145 }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085 }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358 }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970 }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785 }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999 }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543 }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800 }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561 }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884 }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333 }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785 }, + { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058 }, + { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627 }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117 }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420 }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981 }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002 }, +] + +[[package]] +name = "matplotlib" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version >= '3.13'", +] +dependencies = [ + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler", marker = "python_full_version >= '3.11'" }, + { name = "fonttools", marker = "python_full_version >= '3.11'" }, + { name = "kiwisolver", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pyparsing", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a2/78f662f1b18968531f67d3fcde1b7ea8496920bacd4f16ddb5b79d112e46/matplotlib-3.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f857524b442f0f36e641868ce2171aafa88cb0bc0644f4e1d8a5df9b32649fef", size = 9436261 }, + { url = "https://files.pythonhosted.org/packages/5e/92/044f1de43901310202f4c79acf4f141be53b2ca8d8380e2fcefb3d523a75/matplotlib-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57baa92fdc82948ed716eae6d2579d4d6f40965cd8d2f416755b4a72580a3233", size = 9264669 }, + { url = "https://files.pythonhosted.org/packages/53/f4/f0b4f9ba7ec14a7af8151f3ad71ecfe3561e6ba38cfab1db3681ba4ca112/matplotlib-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630eee0e67d35cce2019a0e670719f4816e3b86aff0fa72729f6c69786fceb45", size = 10021076 }, + { url = "https://files.pythonhosted.org/packages/d7/33/4d679c6dcd594a156542080ac907ddccf7b09ca11655c4b28eca8e9ee5da/matplotlib-3.11.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5106c444d0bf966eee2853548c03772af4ab7199118e086c62fbac8ccb07c055", size = 10828999 }, + { url = "https://files.pythonhosted.org/packages/07/74/0a3683802037d8cd013144d77c247219b47f2aabace6fdde74faa12bacf7/matplotlib-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d7aea652b58e686444079be3376ef546bffa1eee9b9bb9c472b9fcf6cf410d3", size = 10913103 }, + { url = "https://files.pythonhosted.org/packages/d0/9f/970fcbf381e82ec66fdf5da8ea76e2e9240f61a24011ce9fd1d42c37ac2d/matplotlib-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:70a5b3e9a5dab708c0f039709ae7c68d5b4d254e291ef76492cdba230c8bb5e4", size = 9310945 }, + { url = "https://files.pythonhosted.org/packages/14/4e/6e7cfed23611265ded53806852343b5c59339e506e84c474a9b5afc3b249/matplotlib-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d68266213e73823ac3be90615bab0cf31f88851e114cdb1dd25dacf3b01e1a7", size = 8999304 }, + { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976 }, + { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307 }, + { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353 }, + { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232 }, + { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899 }, + { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528 }, + { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413 }, + { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532 }, + { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760 }, + { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623 }, + { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372 }, + { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099 }, + { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727 }, + { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506 }, + { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838 }, + { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298 }, + { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491 }, + { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059 }, + { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576 }, + { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519 }, + { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456 }, + { url = "https://files.pythonhosted.org/packages/57/52/a94102ac99eb78e2fe9b826674f9ef9ee23327110ea6ab4776c1b4eb6209/matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79", size = 9452137 }, + { url = "https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3", size = 9281514 }, + { url = "https://files.pythonhosted.org/packages/b7/2d/4e1240ea82ee197dfb3851e71f71c87eeeb975f1753b56a0588e4e80739a/matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9", size = 10843005 }, + { url = "https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430", size = 11127459 }, + { url = "https://files.pythonhosted.org/packages/6f/41/795c405aa7560443a3b01309424cde4a1113b85c90b8a63417444a749617/matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba", size = 10925160 }, + { url = "https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2", size = 9485186 }, + { url = "https://files.pythonhosted.org/packages/8b/c0/396478ee7cf2091d182db8b4a8695f6a37f1ddb978989cf9dbb84cd5c123/matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d", size = 9160349 }, + { url = "https://files.pythonhosted.org/packages/c5/6f/1c3bd51bb2b34eaacdcf3c3d859dbb357f952fc8020c617dc118ad7c9e38/matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847", size = 9500921 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/4d861d0121840cb1a3fd4a10deb211efd6fccd481ed23e553f31f4f4da4a/matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e", size = 9332190 }, + { url = "https://files.pythonhosted.org/packages/4b/cb/22f6bc35711a0b5639a784e74e653e77c86210bd4304449dd399a482f74e/matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0", size = 10854181 }, + { url = "https://files.pythonhosted.org/packages/3f/7e/9a9eaca731a2939589da520f0ebe8fd8753d0f51fca98c7d20af6dbe261a/matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb", size = 11137715 }, + { url = "https://files.pythonhosted.org/packages/ef/f9/9b030b6088354acb0296871bb624b25befc1c42509d3c6cd17420c83a5b8/matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9", size = 10939427 }, + { url = "https://files.pythonhosted.org/packages/59/94/6b273eaee4ee250863567d100865da61a5c1527fa67f527b7ed22e0dd29c/matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6", size = 9535809 }, + { url = "https://files.pythonhosted.org/packages/60/95/1d36bddf2b7e2692c1540e78a6e5bc88bc1496b137e3e35a611f91b65ac3/matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159", size = 9209226 }, + { url = "https://files.pythonhosted.org/packages/0f/c2/f5da6cd37ed6871f5c9b3c0507ddb69f14d6c36fac4541e4e0c60cb8cdfc/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62", size = 9434094 }, + { url = "https://files.pythonhosted.org/packages/f8/07/56f66906e0f87a0c6d0d0acbd34dbc9432b1931d8f26ef618bd6f92932a9/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd", size = 9262183 }, + { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653 }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245 }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048 }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542 }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301 }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320 }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050 }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034 }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185 }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149 }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620 }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963 }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743 }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616 }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579 }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005 }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570 }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548 }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521 }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866 }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455 }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348 }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362 }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103 }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382 }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462 }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618 }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511 }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783 }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506 }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190 }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828 }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006 }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765 }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736 }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719 }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072 }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213 }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632 }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532 }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885 }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467 }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144 }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217 }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014 }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935 }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122 }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143 }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260 }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225 }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374 }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391 }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754 }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476 }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666 }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194 }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111 }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159 }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936 }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692 }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164 }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877 }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487 }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945 }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406 }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528 }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119 }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246 }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410 }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240 }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012 }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538 }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706 }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541 }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825 }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687 }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482 }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648 }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902 }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992 }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944 }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392 }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220 }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800 }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600 }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134 }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598 }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272 }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197 }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287 }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763 }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070 }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752 }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024 }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398 }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971 }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532 }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881 }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458 }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559 }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716 }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947 }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197 }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245 }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587 }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226 }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196 }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334 }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678 }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672 }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731 }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805 }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496 }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616 }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145 }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813 }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982 }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908 }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867 }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511 }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064 }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157 }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728 }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374 }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286 }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263 }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.12.*'", + "python_full_version >= '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987 }, + { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322 }, + { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605 }, + { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628 }, + { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691 }, + { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066 }, + { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638 }, + { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647 }, + { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688 }, + { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237 }, + { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912 }, + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890 }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584 }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904 }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504 }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086 }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250 }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864 }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407 }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431 }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420 }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533 }, + { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807 }, + { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215 }, + { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493 }, + { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211 }, + { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004 }, + { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797 }, + { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647 }, + { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841 }, + { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361 }, + { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749 }, + { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072 }, + { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067 }, + { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290 }, + { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371 }, + { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643 }, + { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128 }, + { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902 }, + { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814 }, + { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168 }, + { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286 }, + { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451 }, +] + +[[package]] +name = "opencv-python" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443 }, + { url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755 }, + { url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064 }, + { url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711 }, + { url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576 }, + { url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032 }, + { url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734 }, + { url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418 }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287 }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754 }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605 }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788 }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288 }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396 }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887 }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039 }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415 }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266 }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814 }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408 }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160 }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172 }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232 }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653 }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195 }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969 }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323 }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838 }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830 }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383 }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934 }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684 }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137 }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267 }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684 }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487 }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433 }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889 }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109 }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736 }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129 }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562 }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439 }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287 }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691 }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185 }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736 }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435 }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262 }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344 }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131 }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757 }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962 }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171 }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116 }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209 }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707 }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995 }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503 }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956 }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855 }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642 }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281 }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716 }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125 }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939 }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506 }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063 }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549 }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331 }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370 }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147 }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659 }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439 }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577 }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394 }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375 }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048 }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006 }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509 }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167 }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237 }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047 }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440 }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895 }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384 }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537 }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491 }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510 }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058 }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776 }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358 }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595 }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082 }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476 }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062 }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893 }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589 }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664 }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087 }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383 }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210 }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228 }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284 }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090 }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859 }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560 }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997 }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972 }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266 }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737 }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pygeodesy" +version = "26.6.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/6b/a56c73cdec8d86d10bb78c59218af1e629861bf9b5397867d294152c2201/pygeodesy-26.6.24.tar.gz", hash = "sha256:d9d79e0522af7fc3728433b89ddab1449758a6cbd9bcd05ea29cf25a83b5d870", size = 9033914 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/b6/24fb52082c535ceead8f001b32a48fcd1623d50aa086a2b856b76cff4def/pygeodesy-26.6.24-py2.py3-none-any.whl", hash = "sha256:4928d670d7b606c71ad958eb7da7b9f01edb952e4639a92694c2d0121543fa72", size = 1117094 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781 }, +] + +[[package]] +name = "pyshp" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/58/8d9461f328b2878a4b1632db4189a68c77e0c87689830a7834f204302704/pyshp-3.1.4.tar.gz", hash = "sha256:0663762be72c67684c890decd888fb8e849e01a85ee6ef0346214b7329270453", size = 2225442 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/f1c777c07866b9de9420627c79bd22141268256d78afb890feabc4a85d70/pyshp-3.1.4-py3-none-any.whl", hash = "sha256:5bdd39c8fe02a47fe8de9c3355c5efaa312ff5c7fc6b8156592df738246f6167", size = 73189 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511 }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151 }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732 }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617 }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964 }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749 }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383 }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201 }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255 }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035 }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499 }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602 }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415 }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622 }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796 }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684 }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504 }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735 }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284 }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958 }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454 }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199 }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455 }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140 }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549 }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184 }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256 }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540 }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115 }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884 }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018 }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716 }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342 }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869 }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851 }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011 }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407 }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030 }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709 }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045 }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062 }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132 }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503 }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097 }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675 }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057 }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032 }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533 }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057 }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300 }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333 }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314 }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512 }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682 }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943 }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980 }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543 }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510 }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131 }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032 }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766 }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007 }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333 }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066 }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763 }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984 }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877 }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750 }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858 }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723 }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098 }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397 }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163 }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291 }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317 }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327 }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165 }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.12.*'", + "python_full_version >= '3.13'", +] +dependencies = [ + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519 }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889 }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580 }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441 }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720 }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115 }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989 }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717 }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428 }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481 }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107 }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303 }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960 }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074 }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038 }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390 }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324 }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785 }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943 }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911 }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253 }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758 }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514 }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398 }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032 }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333 }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216 }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960 }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845 }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971 }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325 }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110 }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811 }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644 }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318 }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320 }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541 }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480 }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390 }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661 }, +] + +[[package]] +name = "scriptconfig" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version < '4.0'" }, + { name = "ubelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/4c/6d0ecc5e29b498dc6b5402a0c561e97dd00ce1aeebdc5e494cc5fa8a0b6b/scriptconfig-0.9.1.tar.gz", hash = "sha256:9766c2ee601c4b8d97753ba13a914f8c0a212da4f6d2aec18ad628fd8b53d7ef", size = 112424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/00/2bf691dfc2b0170013d466fcdbbfc9d036e26d6049d74efaf7775401321d/scriptconfig-0.9.1-py3-none-any.whl", hash = "sha256:4b69e2f4eb681ec4480e9bb5d6c82d2f8c82a9badc703a261ee8940736101d86", size = 87103 }, +] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959 }, + { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196 }, + { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065 }, + { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666 }, + { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905 }, + { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260 }, + { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301 }, + { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074 }, + { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038 }, + { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039 }, + { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519 }, + { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842 }, + { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316 }, + { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586 }, + { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961 }, + { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856 }, + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550 }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556 }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308 }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844 }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842 }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714 }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745 }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861 }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644 }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887 }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931 }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855 }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960 }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851 }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890 }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151 }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130 }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802 }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460 }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223 }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760 }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078 }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178 }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756 }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290 }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463 }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145 }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806 }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803 }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301 }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247 }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019 }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137 }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884 }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320 }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931 }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406 }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511 }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607 }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682 }, +] + +[[package]] +name = "simplekml" +version = "1.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/e4/c333a93b7e3346437ad1ff42b8e362b853eb405ad6243ab6163f9af2a460/simplekml-1.3.6.tar.gz", hash = "sha256:cda687be2754395fcab664e908ebf589facd41e8436d233d2be37a69efb1c536", size = 52999 } + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858 }, +] + +[[package]] +name = "transformations" +version = "2025.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/ba/678cbd4f558ec587e6ced36206dec0649d4c383dfcdf76c0cdd09d889e30/transformations-2025.1.1.tar.gz", hash = "sha256:b8411a456cd506e4b77cdac884b217836125840e2f1400247b5bc02c46d1b333", size = 48065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/dc/679989c58a3646740b27ab6dcce4dd3d6991ddd81f2fed7b145c57e0c6c3/transformations-2025.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5eb808d255c02fba6cfdbc92b7c706432db76b7e55e0cfe921bddad95e1d4285", size = 58017 }, + { url = "https://files.pythonhosted.org/packages/b6/c9/348fa3320c5cd3a403eedaebc6750c20f45f04b39d1eb6da8d9e339619b8/transformations-2025.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cbc758a858185b38022722ab6f5bb39369b74e69a8e7a861894870269241064f", size = 54316 }, + { url = "https://files.pythonhosted.org/packages/7e/46/8d70ff706699b44107f1c649ca4af7c9665da313b706cf5f4bb1ec4db1f4/transformations-2025.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ed30be8eed56680dd1405117dfae58f2ebba99cff1333503b8cdac46c85f976", size = 140582 }, + { url = "https://files.pythonhosted.org/packages/f0/62/3f0f3ac6df0b68dadb354e27e3a6a010214ee3026a9ec8996b499c9dd4d8/transformations-2025.1.1-cp310-cp310-win32.whl", hash = "sha256:b50e7bff1d3a776d71b69a3e38c7a776666756011bf5a071739c84b93c481094", size = 53712 }, + { url = "https://files.pythonhosted.org/packages/f6/cd/16986f29515dd295abb570120c9526ba3958f252cb1241d8632bfcf2a358/transformations-2025.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:1af88add05b44c752379c4ff0649a17d0853dea65ca20f162661bcc3488ab48c", size = 58532 }, + { url = "https://files.pythonhosted.org/packages/d4/48/eaf3f8e4ef8f65fd35bae43d0e4bb9da939032d1d0ca5af4b0174302a0e4/transformations-2025.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba5810bbcc403a6fdb0ad55f2d1654ae434ad5e0ed6411c4fd5ee95d3c24a785", size = 58016 }, + { url = "https://files.pythonhosted.org/packages/99/2d/1af87336b9028430abb2c6a5243761f54c69c718995d25d3f2eeb1b400ff/transformations-2025.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ee99aee395cbc033e97f3df6c74e6f65e77ea29399e6c2265a4aaada22bc1af3", size = 54307 }, + { url = "https://files.pythonhosted.org/packages/24/43/c12e5e5f4648257be1a1c2deacefffccff05b0cd334f7108dc60ee34c87b/transformations-2025.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce9fa1663d16126cfd746ca79dfc484db531cd68b1ee99dfd5960f65b82331a", size = 141600 }, + { url = "https://files.pythonhosted.org/packages/69/b5/254149892bbbdaa7da0cfc7858319fdd4bf73ef50ce5c38fffafb83dc902/transformations-2025.1.1-cp311-cp311-win32.whl", hash = "sha256:0480bf56c375070e374994586acfc7366a91a4ec2356e8111394212699a11c0a", size = 53712 }, + { url = "https://files.pythonhosted.org/packages/2d/42/687cacb4689dd25fddfa8b284d3a3f4d6f44915d039a5da2d0fe9f381d2f/transformations-2025.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:6053d98f6e9d631df2ef467dcd534138c23532b425ca44cc57049197be8a2df0", size = 58536 }, + { url = "https://files.pythonhosted.org/packages/22/46/8602a4ded8f5ebf66d366848e8da90eb6d3f0cb5167b8c2657754e4de0e5/transformations-2025.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:609ea776cbd25d81f83be08c6e19576a430b888dfda3f8a9b833c91fee624d23", size = 51550 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7abfdc715b065868f77e1e03381e34d3c526b11a21882afc2d7e4bba9d7e/transformations-2025.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:96b9dae9b63d27878422de914630983de937de6699f5e0114c440e2d49bb28df", size = 58108 }, + { url = "https://files.pythonhosted.org/packages/b6/2c/2af560efb7e63ca92d6acf69d172fd714abf698b1331c5c2fc808801d509/transformations-2025.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c0f7b8980053c0e4f93e33a6899adbef85d6473d4b0f004a0addcfd1c310461", size = 54274 }, + { url = "https://files.pythonhosted.org/packages/7d/3b/b436ef8290dfbd82d31f21ac91a6f792ebc2085315fccaba3859a2f880b1/transformations-2025.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c31602afe8480d8b1ec3ef57fad91b655f5aaf13d6913e04a9d16200ebbd0b9d", size = 145584 }, + { url = "https://files.pythonhosted.org/packages/02/9e/da0ae005d9ef81ae0ea92cdb28597cc563a90b74f9dea3e90065021e6c91/transformations-2025.1.1-cp312-cp312-win32.whl", hash = "sha256:fbe073d36a4f5132778aae0f837c9fa1797dc0b5f86e67efacc9be34cf886222", size = 53778 }, + { url = "https://files.pythonhosted.org/packages/6a/72/2affe2931de75bb4c8ddee0c3b3c6fd60988b66ab4f096c1e9a59f508a71/transformations-2025.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:182c883f06ee9cdaa57b33ecadd694b519e682d5c050026613f260716250fbfc", size = 58817 }, + { url = "https://files.pythonhosted.org/packages/c4/f8/eeab0b9e542a8870968a4b8d6a5f9fde1eaf5925972d9050c962c2437bf4/transformations-2025.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:90a494108ff1b37ba3505492098170006559aca453b7715ae41983b325f731f4", size = 51709 }, + { url = "https://files.pythonhosted.org/packages/04/59/8d3526b3a19da156767088c9bb5c5872cdeedece31a9f829a32ad429a4fc/transformations-2025.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:264c71652cb98c35bfa0293fb2e0d3a811a645f3ca4011756321baaa6953b287", size = 58115 }, + { url = "https://files.pythonhosted.org/packages/43/c3/968c56642c3f53c89d4aeb816f29e6ac3f8cf05656904116c176df57c19c/transformations-2025.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e68e475256cfe03e3fa360693d6a39495b2b67d86033640504067ce8febf29b", size = 54276 }, + { url = "https://files.pythonhosted.org/packages/be/16/aa7213cb432ec16a2c91138522616cf4971502e6c385dd04cdfd1d467c52/transformations-2025.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abdba200ec5abfac55fab25c5ef399fd65357d296c304f1499c0af6b9442fc8a", size = 145562 }, + { url = "https://files.pythonhosted.org/packages/8f/b7/ad2c0c9f9ec54ce649c50de8098d81a4786d2cf35703f7da9f3ad5df12a5/transformations-2025.1.1-cp313-cp313-win32.whl", hash = "sha256:26950745a1d7fcdbdb2f1ba217496a2ac4c1eeb4cacbd0e866fa48e09c9d01ee", size = 53779 }, + { url = "https://files.pythonhosted.org/packages/0b/06/be24e40b458cf3d276c6aa39b36bf7cb16ee50ca4d6922b9d2f0bbea5db5/transformations-2025.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:bffdd9c419cd395af9e1de5a642099dfc9ec0cb2b1d9d87c0421271083603e7c", size = 58818 }, + { url = "https://files.pythonhosted.org/packages/cb/c3/b6d0d31c7fd8c702e6222d62d02f32db9228fccc3908e4ee46be4861edd0/transformations-2025.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:4a44eec2c7ae4058316ae75fbfd3c3ab8d15489d0bbeb753f6a4392283378d94", size = 51708 }, +] + +[[package]] +name = "transformations" +version = "2026.1.18" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version >= '3.13'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/48/be4f992f75aacc805442d28e2a237e085bc54dc88fc57fc13736a5c24bbe/transformations-2026.1.18.tar.gz", hash = "sha256:d7bdb5f7753b520facb42df6b21c6756126dd89a90700867a9162bebc6095c73", size = 48524 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/29/1b49f789a7e53b71ffaca395081e4d8d324ed454666ecdb9a47d502e6e6f/transformations-2026.1.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b3ac3d84d8543860bcf428feeb142b084fc372c640253ec448a7322f27a06996", size = 57696 }, + { url = "https://files.pythonhosted.org/packages/06/27/c55edbe588e89f3b75ed0b4ce2655e6f3e9a912f9b740fe3083f61aebcdb/transformations-2026.1.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfba16f6c9ec4090bee358af7ac131af19d6fb68843a0e4f59960016940e23ec", size = 54727 }, + { url = "https://files.pythonhosted.org/packages/5f/5f/68acb365814c371faa841afc196b025ba3156e8bf43ba8586cca299813f7/transformations-2026.1.18-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:612d74d463f6c830a5e293cbd432b1077ba443f529877594a58b359d39c32050", size = 139220 }, + { url = "https://files.pythonhosted.org/packages/b4/67/769f7d64d981b704044570155709ce9dc68b4d69683c099b798fada3e740/transformations-2026.1.18-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af5147e613a053c5abbc49f4f1f168e2e181b81d6c8249ff9d4b03b856361eb1", size = 144451 }, + { url = "https://files.pythonhosted.org/packages/50/7c/1d044f0e9ce59c9ffeb0e3835f37dc91fb34f9d93a8a4ad24a5181cb12f7/transformations-2026.1.18-cp311-cp311-win32.whl", hash = "sha256:176bfaa55570a04e260847e37c007fb5b2dc78633b18e20bfa212d46b1f8cb1f", size = 53631 }, + { url = "https://files.pythonhosted.org/packages/ce/55/740fe6e04b13fe16c316440bce48ebd3707fd5c2dfddef4c16561f8aec2a/transformations-2026.1.18-cp311-cp311-win_amd64.whl", hash = "sha256:cfca85bda82df8403437d643a4dea7886e0cb6a5fe1b25c35d000c4680161eed", size = 59185 }, + { url = "https://files.pythonhosted.org/packages/45/81/ac07f93cdfc800c692530c44699390fca21a3fe6bdc73203c84e3f573ceb/transformations-2026.1.18-cp311-cp311-win_arm64.whl", hash = "sha256:9ef0264227f5139c164329df5fc03566b1948b5b8640f8c03801d6c691d0af92", size = 51843 }, + { url = "https://files.pythonhosted.org/packages/a3/58/f68661320cc1835c208baf6bbaff7398dfc0b6bac3d66d01fa88ee49c1d2/transformations-2026.1.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ea335cc8097d7b453dedf9db5fccd71827c6748c814f5dcad1db03fa5bae4029", size = 57834 }, + { url = "https://files.pythonhosted.org/packages/10/9c/47e489665240f48540a1db1e6357f1f26ce289ef0620cf46476a187b1129/transformations-2026.1.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5cb91276a23fabd8b0f7fc362d5aa63d41cdc50dbf38da34bdc6dfb1f3bc7462", size = 54658 }, + { url = "https://files.pythonhosted.org/packages/62/f8/93d8214875bce0afd25401b2e39d2912cc7e4b3dcaa504ad61f1d459e3b0/transformations-2026.1.18-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fe34e7119e07566e01b91cf9f19c57d2c0cc9330e2cdd49a1a513ca8a53842f", size = 143393 }, + { url = "https://files.pythonhosted.org/packages/4a/d1/c140c92c8e159806d8ffd5c1c3f9e6dab562ff9b2ce7fc007266b98c9b68/transformations-2026.1.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc2e52cb0c0f4b838c7b6da964fb18f43058abfe767c6c3f6a465b9fc0b3dd12", size = 149295 }, + { url = "https://files.pythonhosted.org/packages/c3/af/b060381ba35ad84490934e93a499c668c6123c02f75e1763c859b0c9e4db/transformations-2026.1.18-cp312-cp312-win32.whl", hash = "sha256:374c54c0ffb61d34b811134322472db49bad2216ccb31ee175133e0f46d97de9", size = 53913 }, + { url = "https://files.pythonhosted.org/packages/99/d0/0ef42e50df0e44e88866a2694b4712375055b1230945038b9532a172e073/transformations-2026.1.18-cp312-cp312-win_amd64.whl", hash = "sha256:91237452998cacbcf04b243482f0de3be0899acaf5e7ca18831c0a9e1ad0f221", size = 59367 }, + { url = "https://files.pythonhosted.org/packages/2e/26/e0b0da1a26cdf326442af70c709f00779ce34f37c496842bd3fcc44896cb/transformations-2026.1.18-cp312-cp312-win_arm64.whl", hash = "sha256:da26c1740844dc6b58c3ad8a0e03b36244ac924423c25fc10387dcb2891b9b44", size = 52006 }, + { url = "https://files.pythonhosted.org/packages/33/70/a3aed6a6355435cb8ea826edba647bf80daa725a89ec679baa0364df1a6d/transformations-2026.1.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b51fadf3c8cd0ef9b01bfce7a72db590b58e2a6222cf137f69d98d6059aba6a0", size = 57838 }, + { url = "https://files.pythonhosted.org/packages/9b/a7/bdc6e9aa53be0dec78133650d83b84bf30319dc80ce51c1381b46a819bfe/transformations-2026.1.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b768e73e68245a7478537bcaefaf7338cdf2ed804a6c32e7462269cc5342c0b6", size = 54657 }, + { url = "https://files.pythonhosted.org/packages/b9/66/66da44457cdbd7ccccb614847cf42e02b6196986faf7452770b0f5924cf9/transformations-2026.1.18-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db2304ce26c1c928b7c7fc818902e4ef1ce4326be44cdefcee37628954b72cad", size = 143416 }, + { url = "https://files.pythonhosted.org/packages/a8/9e/db2e1a3a04a87a129035dc0ee4ec2fc84110cb06cd0b2260d5995224e3ac/transformations-2026.1.18-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3c4a582888d06bc7b08dfc28259ca156b3dcdc5d3ccee367d02c3dd5f9b378e", size = 149370 }, + { url = "https://files.pythonhosted.org/packages/76/e6/fd237abe3905d85c107651ace841605019771adfade0a241c9f470524dcd/transformations-2026.1.18-cp313-cp313-win32.whl", hash = "sha256:9d0222368e7990bd338aa88292a0dad201db3f2a465e84f50a2c05eeb342c5f8", size = 53915 }, + { url = "https://files.pythonhosted.org/packages/43/fa/dfc8bffafe39fb52973c70da89f0623fd9a9e01c05b3dce22677d9f2ee9a/transformations-2026.1.18-cp313-cp313-win_amd64.whl", hash = "sha256:3ccf0fdb0d952e5e055815fb5a2831866bf65ecfd4d0b767a1ac3c205702091c", size = 59365 }, + { url = "https://files.pythonhosted.org/packages/79/89/6086f41f4f7365eb101d7c8fed7ae127475e4a578edb4de5f36c9bcd3562/transformations-2026.1.18-cp313-cp313-win_arm64.whl", hash = "sha256:a293f5bba9b3d6795ceb341b8234b1f9ff3531274bbea687d45280ca600f014e", size = 51998 }, + { url = "https://files.pythonhosted.org/packages/c1/6e/d78321d9d16aef583e6ee162b904121364cfe7eb96d6d66ed19a6a5bbac5/transformations-2026.1.18-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e10bd48b1e3a1c857da0d06c847d235c0bb7399f62d2f9e6a022690712b1059e", size = 57911 }, + { url = "https://files.pythonhosted.org/packages/83/d5/96b224d3ae9e396da2006c9ceeb90304d35bacf1ab941e1d5eb5052eaa6a/transformations-2026.1.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:08e7b58e6ff25d941a52c04389c08832e62c027fbb8c53f3b87d3e0ce526647e", size = 54682 }, + { url = "https://files.pythonhosted.org/packages/58/91/308ccd53b2374ecb43bda4798c0ad28cac350391fe8214445e8e173cd190/transformations-2026.1.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c5f0b85930957929fb8b5e5676c3aa54cb5754ce399f5cdd32b34f18a1fc05f", size = 143474 }, + { url = "https://files.pythonhosted.org/packages/99/32/d4df0cf7cffeefeb2bad6a8cd5bc337d6d41884c4b3a042a67a6b2dd3ba6/transformations-2026.1.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:960404eb5d62ee2cb5e03259938d618ee20891a57ae4bc87808b197ce9e81933", size = 149309 }, + { url = "https://files.pythonhosted.org/packages/79/d7/a0420228e998937845da9a361d4cf13715bd2db8429a8514781b334e62e9/transformations-2026.1.18-cp314-cp314-win32.whl", hash = "sha256:2ece1dec9aef2e354adab4c3443f226026bed0a207b771ba698fdbd5c1607484", size = 54810 }, + { url = "https://files.pythonhosted.org/packages/bb/79/ef05d9f0fec576d79db67388aec8e22e83798b4498aebbbe8c36bdd2e715/transformations-2026.1.18-cp314-cp314-win_amd64.whl", hash = "sha256:37dae6e61e1113cdf5e31e96e29665fb86ad8f0d1d95da7fdd5561756da5f56c", size = 60601 }, + { url = "https://files.pythonhosted.org/packages/ed/ae/67eba54cb4926d94434395b447478159a8666e2cf82b38e126eb5a2af73b/transformations-2026.1.18-cp314-cp314-win_arm64.whl", hash = "sha256:ceca0b448e55113827ec145bb6dedf8b6968a25151af58d8651524e860ab0a95", size = 53526 }, + { url = "https://files.pythonhosted.org/packages/bb/b9/53fca78dc33172b839e088bd240b35856e00d97292e4ef6f2d86e2310300/transformations-2026.1.18-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8ef426d4939a4fd3d31671239f81a0bea4be65132f44985cad85570ec8ba7a94", size = 59541 }, + { url = "https://files.pythonhosted.org/packages/bd/e9/b9922e80d7dacfe859b7dccbaff7bfe73176b2378470eea43f2078be2c0d/transformations-2026.1.18-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8a213c6b7399b3bcf794124b20e7b2ec45cd73d39bad47a30f840e62ed26577d", size = 55910 }, + { url = "https://files.pythonhosted.org/packages/81/3c/9472122b19e2fdc8ab5b2ba5301b2342ade8bac48995ce3915a0454e1ad7/transformations-2026.1.18-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ad97e2941e75311fb97515926f5764701a0687e0f587c0aaa88c8d7f0e1fd0", size = 165424 }, + { url = "https://files.pythonhosted.org/packages/a4/44/30d8c122e30cce19e6a089a4519303a7761226be4222e5f3df2aa7d0b27a/transformations-2026.1.18-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9188136a4a93690b857d6d9b37f87f61cf87f311c6cf1e642e326d09a9791c2a", size = 169540 }, + { url = "https://files.pythonhosted.org/packages/7b/38/ad5fa7c5fb52d9ae14887fa85e230535de6453abc2ec64e8e4405edf9e35/transformations-2026.1.18-cp314-cp314t-win32.whl", hash = "sha256:b4ac6a478752a88eceacdc885eae927fbfd4fc60fd903450a93a9894d51768f1", size = 58067 }, + { url = "https://files.pythonhosted.org/packages/f3/1b/81b9da43d58b3239fcaf5b48059e3548cc7fc079ab1f1b7877989d4f3bda/transformations-2026.1.18-cp314-cp314t-win_amd64.whl", hash = "sha256:ffbf7bc8cd2a3a95414ebe34add218e61fd859b7bae966ea04987ce48d41b180", size = 64072 }, + { url = "https://files.pythonhosted.org/packages/33/19/95496c9e1d053f6c8eb4e8c759a9603abf54bcf6fd775cb1e1c0c48e6040/transformations-2026.1.18-cp314-cp314t-win_arm64.whl", hash = "sha256:3983f220574d0cc84c87fb4f22c8df1176bdfedde4e92a79d91747705c27d098", size = 54421 }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, +] + +[[package]] +name = "ubelt" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/97/39/a2ca653ee2500ae963e38b6b3a38d02f7c4ea72a3e5af410c6d66f058ed6/ubelt-1.4.2.tar.gz", hash = "sha256:40322cf3bb59c05b7567222b10bd0d553c6abc463cd480c23ea3dbcf2ef332fb", size = 305863 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/b9/b6ee2a03975b398b428b1ed8ce2f1a598e2b9cc01ad2c6d49cfce3bb5a9b/ubelt-1.4.2-py3-none-any.whl", hash = "sha256:6cdbcbba75cd7c3e1ef9556a13b7c0e29a55f67cb454f022a981e42808f2b06e", size = 231820 }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166 }, +] + +[[package]] +name = "zope-interface" +version = "8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/43/9cd98bee951d23848de690ba2809f87e3b22c67c370987acc960da15ad37/zope_interface-8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0c8aa2bf8f3911ef37b87deb1bbe225a310e6eb6522a16d77f5d8330c4f6fbe", size = 210951 }, + { url = "https://files.pythonhosted.org/packages/17/0f/8f1a29966bcf863e3a2121edcafb81c55715de7886bcc9544749cc79e7da/zope_interface-8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:efe234a0fafb4b6b1602e9be9245b97c2bf06d67c07af5a4bc3c0438978b555c", size = 211309 }, + { url = "https://files.pythonhosted.org/packages/9f/9f/37e564eaaf85e3abc1ada40a79fa43f2ab45bdb67431b0ec0fe29e4763e2/zope_interface-8.5-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dabeb6fe1228d411994f300811edc6866fff0cdcbc9cef98a78f05ea0da42e37", size = 254881 }, + { url = "https://files.pythonhosted.org/packages/06/61/e6501d8ea7a2cac3217e03f404e1f98c1df7191d83cfe86b1895fbba5dac/zope_interface-8.5-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:147a9442dcc2b7339ecdb1be2b3cdb098e90462e39425054053ebfb50d99125a", size = 259811 }, + { url = "https://files.pythonhosted.org/packages/91/15/bfa25ef480b02af6e9452c478483fec75e87c9e2b60c407fd0b1f6054b9c/zope_interface-8.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a17e681224267880707c9ec9e730ad9a1ad2d65c371256843efba6cf48711b58", size = 260358 }, + { url = "https://files.pythonhosted.org/packages/64/51/2b518072fea76242da64451d501c69b7b5ccdef9b57fead584ccf1c180d5/zope_interface-8.5-cp310-cp310-win_amd64.whl", hash = "sha256:d178968a1a611df30549a717d1624cb38ca810347339e3e37b7baa6f6781a170", size = 214822 }, + { url = "https://files.pythonhosted.org/packages/ce/f1/83ad110fb847413affe71609bb50e59e1aa082e1236030122227c7c283d3/zope_interface-8.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:afc66ccaef2a3c0bef6ca02aad40d29a39276389dad16a8eac36f9f385e4d057", size = 211426 }, + { url = "https://files.pythonhosted.org/packages/bb/a7/6b6e0c31ac240cb9fc015ae9ed45ca54be886c18fcf7bfa2377a4d7a8785/zope_interface-8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28044972187245d7a309e4699319bfdbd2ffcbf7176d1d4ddf5adffb2dea80f", size = 211850 }, + { url = "https://files.pythonhosted.org/packages/37/36/7599ecabcf80ce4fef2e1ef3c5ac0d4696b61f03f724cc44022f4d226af9/zope_interface-8.5-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:03bbecc7982af713d7499d4084bc03916413d17ffd45f89009348cc0c1d9e376", size = 260711 }, + { url = "https://files.pythonhosted.org/packages/03/3e/1774b0ee46ccbb5498ee3c33ece40315b6ef58bc71957be94bd345340bc1/zope_interface-8.5-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf917009a4a7457c7290225a019f4a0aa706d96accd2cfdba2418d3bc1fcde2f", size = 265277 }, + { url = "https://files.pythonhosted.org/packages/b6/09/e533b2ffabaae4e5d5730d6768a591cf335defe8e37bec2ad905d09be656/zope_interface-8.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31cff25b2aaedb5267e6e77b1e9be6b0ec4f622032de8a069202b8ffacda7dc2", size = 266369 }, + { url = "https://files.pythonhosted.org/packages/49/4a/3ebe6a4c122b2d5340db45cbe7e490663d3228b172710ec71060cd5d541e/zope_interface-8.5-cp311-cp311-win_amd64.whl", hash = "sha256:17a3114bbdddb5e75e5784cdf318944636190cbbc72d357ef9fb1a8b0351f955", size = 215161 }, + { url = "https://files.pythonhosted.org/packages/d2/59/056ad97af5b16db1975ee98ec7ab03d2ce3f3355efad904ced1dbce0e39f/zope_interface-8.5-cp311-cp311-win_arm64.whl", hash = "sha256:aab6bb5bee10f38ea688b95ba054396b67f613552d2c8378be7fcb2d2fba7646", size = 213481 }, + { url = "https://files.pythonhosted.org/packages/97/cc/b84123a948f3162a34623e188922827cd845244fdd043ed20f8d02228caa/zope_interface-8.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8e6ee90c2e6de7c37058d5fa41f123c8b13a312db8d1e0fb5840d7f4bcdff9c9", size = 212165 }, + { url = "https://files.pythonhosted.org/packages/4e/78/cbceec44f1b27208a76c1a688c131302685852406a23df5aab68324109cc/zope_interface-8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1adc90d3576b3b4c4de4953e6002c37bef28b78d7fa54c1bbfd0c50f022fe7c", size = 212341 }, + { url = "https://files.pythonhosted.org/packages/e1/c3/005032195ff3b210c139b7c560ed5c534e844b0907d8e44d2b3d8919305e/zope_interface-8.5-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e6347b8d8d12c5eca6502450a92be30079b7acfade2c4f693efa0deb8871b06e", size = 265296 }, + { url = "https://files.pythonhosted.org/packages/c5/66/1036543d6a66bc04c19df3cf650f3ad938a002ab0a443c24e23e8de5e8b9/zope_interface-8.5-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e970dabea777a24b0b0bbf9dae3ab75ce8b2d8e948edf4875627034b21f3560", size = 270689 }, + { url = "https://files.pythonhosted.org/packages/30/4c/8b56259558cace4414e753ca6740396a1f59d4a95ddb55b4658600408670/zope_interface-8.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0b48ccadaa9839e09ff81e969703cecb3f402c813bfe8b958652e699bea69f5", size = 270280 }, + { url = "https://files.pythonhosted.org/packages/f9/ea/649908c83aa8fdb7faf2ddca4d3cf6fb8f2157121267dc56e8f72681e26c/zope_interface-8.5-cp312-cp312-win_amd64.whl", hash = "sha256:e0e311f1277468c08fd59a2b41f71b43d25dff639789d364747acd1705c0df6e", size = 215019 }, + { url = "https://files.pythonhosted.org/packages/9f/97/da13037b4c563e4df32eedbc819f8c00b754af494f68211e3dffd48d52da/zope_interface-8.5-cp312-cp312-win_arm64.whl", hash = "sha256:652b73107a04159ec6c020db6c1543d4f1e8f4d069bd2aac88a947820923517b", size = 213569 }, + { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512 }, + { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541 }, + { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191 }, + { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626 }, + { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444 }, + { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021 }, + { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610 }, + { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597 }, + { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626 }, + { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827 }, + { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139 }, + { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338 }, + { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789 }, + { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757 }, + { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591 }, + { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733 }, + { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905 }, + { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885 }, + { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672 }, + { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241 }, +] From f06bd0610f5bab940994b53f82676115d99697d1 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Mon, 6 Jul 2026 14:43:15 -0400 Subject: [PATCH 06/32] Add pycolmap>=4.0 to the conda env Verified py310 builds exist on conda-forge for linux-64, win-64, and osx-arm64. Provided via conda like GDAL, so the --system-site-packages venv sees it with no pyproject/lock change. --- environment.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/environment.yml b/environment.yml index a3cfc2cf..f614d6d1 100644 --- a/environment.yml +++ b/environment.yml @@ -1,5 +1,5 @@ -# Conda environment providing the binary geo stack (GDAL) that has no -# reliable cross-platform wheels. The project itself is installed by uv into +# Conda environment providing the binary geo/SfM stack (GDAL, pycolmap) that +# has no reliable cross-platform wheels. The project itself is installed by uv into # a venv created on top of this env with --system-site-packages, so python # here must satisfy requires-python in pyproject.toml. # Setup: scripts/setup_postproc.sh (Linux/macOS) or setup_postproc.ps1 (Windows) @@ -9,5 +9,6 @@ channels: dependencies: - python=3.10 # must match PYTHON_VERSION in the Makefile - gdal>=3.10 + - pycolmap>=4.0 - pip - uv From 57a8d6d833f0960d313510ec65c8d42c70e3da85 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Mon, 6 Jul 2026 15:10:21 -0400 Subject: [PATCH 07/32] Replace setup_postproc scripts with documented conda + make install steps The scripts were wrappers around three commands; the README now gives those directly for Linux/macOS and Windows (which runs the two uv commands from `make install`, since make usually isn't available there). Use explicit `conda env create` rather than update-as-create: micromamba's `env update` errors on a missing env. Also note GPU pycolmap selection: CUDA builds need driver 575+ (CUDA 12.9), older drivers silently fall back to CPU, and GPU only matters for full camera model calibration. --- README.md | 51 ++++++++++++++++++++++++++------- docker/kamerapy.dockerfile | 2 +- environment.yml | 3 +- pyproject.toml | 2 +- scripts/setup_postproc.ps1 | 58 -------------------------------------- scripts/setup_postproc.sh | 49 -------------------------------- 6 files changed, 45 insertions(+), 120 deletions(-) delete mode 100644 scripts/setup_postproc.ps1 delete mode 100755 scripts/setup_postproc.sh diff --git a/README.md b/README.md index 5e391574..dfab8584 100644 --- a/README.md +++ b/README.md @@ -22,19 +22,50 @@ KAMERA, or the **K**nowledge-guided Image **A**cquisition **M**anag**ER** and ** ## Installation +### Post-processing / flight summaries (native, Windows or Linux) + +The binary geo/SfM stack (GDAL, pycolmap) comes from conda-forge +(`environment.yml`); [uv](https://docs.astral.sh/uv/) layers the rest of the +environment into `.venv` on top of it. The only prerequisite is conda +(e.g. [miniforge](https://conda-forge.org/download/)) on your PATH. + +Linux/macOS: + ```bash git clone https://github.com/Kitware/kamera.git cd kamera -# For the pure post-processing and generating flight summary, install natively -# (works on Windows and Linux): GDAL comes from conda-forge (environment.yml), -# and uv (https://docs.astral.sh/uv/) layers the rest of the environment into -# .venv on top of it. Requires conda (e.g. miniforge) on PATH. -./scripts/setup_postproc.sh # Linux/macOS -# .\scripts\setup_postproc.ps1 # Windows (PowerShell) -conda activate kamera && source .venv/bin/activate -# (If you already have a conda env from environment.yml activated, the -# scripts are equivalent to just running: make install) -# Or build the post-processing docker image instead: +conda env create -f environment.yml # later: conda env update -f environment.yml +conda activate kamera +make install +source .venv/bin/activate +``` + +Windows (PowerShell) — identical, except `make` usually isn't available, so +run the two commands from the Makefile's `install` target directly: + +```powershell +git clone https://github.com/Kitware/kamera.git +cd kamera +conda env create -f environment.yml # later: conda env update -f environment.yml +conda activate kamera +uv venv --system-site-packages --python=3.10 +uv sync --frozen --no-cache +.\.venv\Scripts\Activate.ps1 +``` + +Note for Windows: keep the conda env activated alongside `.venv` so GDAL's +DLLs resolve. + +Note on GPU support: conda picks the CUDA build of pycolmap automatically if +your NVIDIA driver supports CUDA >=12.9 (driver 575+); otherwise it silently +falls back to the CPU build. GPU acceleration is only needed for full camera +model calibration — routine post-processing and flight summaries are fine on +the CPU build. + +### Docker images + +```bash +# Post-processing image (same conda + uv flow as above, containerized): make postflight # Builds the core docker images for use in the onboard sytems make nuvo diff --git a/docker/kamerapy.dockerfile b/docker/kamerapy.dockerfile index 55a3c5cb..12eacff7 100644 --- a/docker/kamerapy.dockerfile +++ b/docker/kamerapy.dockerfile @@ -22,7 +22,7 @@ RUN curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/${MAMBA_VERSION} \ ENV MAMBA_ROOT_PREFIX=/opt/conda # The conda env supplies python + GDAL + uv; uv layers everything else into -# .venv on top of it (same flow as the native setup_postproc scripts). +# .venv on top of it (same flow as the native install in the README). COPY environment.yml /tmp/environment.yml RUN micromamba create -y -n kamera -f /tmp/environment.yml \ && micromamba clean --all -y diff --git a/environment.yml b/environment.yml index f614d6d1..2eae41b2 100644 --- a/environment.yml +++ b/environment.yml @@ -2,7 +2,8 @@ # has no reliable cross-platform wheels. The project itself is installed by uv into # a venv created on top of this env with --system-site-packages, so python # here must satisfy requires-python in pyproject.toml. -# Setup: scripts/setup_postproc.sh (Linux/macOS) or setup_postproc.ps1 (Windows) +# Setup: `conda env create -f environment.yml && conda activate kamera`, +# then `make install` (see README.md for the Windows equivalent). name: kamera channels: - conda-forge diff --git a/pyproject.toml b/pyproject.toml index 20c0179f..6537dccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ # GDAL is intentionally not a dependency: there are no reliable # cross-platform wheels. It comes from conda-forge (environment.yml), and the # uv venv is built on top of the conda env with --system-site-packages so the -# conda GDAL is importable (see `make install` and scripts/setup_postproc.*). +# conda GDAL is importable (see `make install` and the README). [dependency-groups] dev = [ diff --git a/scripts/setup_postproc.ps1 b/scripts/setup_postproc.ps1 deleted file mode 100644 index 0242dc7c..00000000 --- a/scripts/setup_postproc.ps1 +++ /dev/null @@ -1,58 +0,0 @@ -# Set up the native post-processing environment (Windows). -# -# GDAL comes from conda-forge (environment.yml); everything else is installed -# by uv into .venv, created on top of the conda env with -# --system-site-packages so the conda GDAL is importable. These are the same -# steps as `make install` (make usually isn't available on Windows). -# -# Requires conda, mamba, or micromamba on PATH. Usage (PowerShell): -# .\scripts\setup_postproc.ps1 -# The env name defaults to 'kamera'; override with $env:KAMERA_CONDA_ENV. -# Afterwards (conda activation is required on Windows so GDAL's DLLs resolve): -# conda activate kamera; .\.venv\Scripts\Activate.ps1 -$ErrorActionPreference = "Stop" - -$PythonVersion = "3.10" # must match PYTHON_VERSION in the Makefile - -Set-Location (Join-Path $PSScriptRoot "..") - -$EnvName = if ($env:KAMERA_CONDA_ENV) { $env:KAMERA_CONDA_ENV } else { "kamera" } - -$CondaTool = $null -foreach ($tool in @("conda", "mamba", "micromamba")) { - if (Get-Command $tool -ErrorAction SilentlyContinue) { - $CondaTool = $tool - break - } -} -if (-not $CondaTool) { - throw "conda, mamba, or micromamba is required on PATH" -} - -$Yes = @() -if ($CondaTool -eq "micromamba") { $Yes = @("-y") } - -$envExists = (& $CondaTool env list) | Where-Object { ($_.Trim() -split '\s+')[0] -eq $EnvName } -if ($envExists) { - # No --prune: the env may hold other tools (e.g. colmap) we shouldn't remove. - Write-Host "Updating existing env '$EnvName' with $CondaTool..." - & $CondaTool env update -n $EnvName -f environment.yml @Yes -} else { - Write-Host "Creating env '$EnvName' with $CondaTool..." - & $CondaTool env create -n $EnvName -f environment.yml @Yes -} -if ($LASTEXITCODE -ne 0) { throw "$CondaTool env setup failed" } - -# Same steps as `make install` -Write-Host "🚀 Creating virtual environment using uv" -& $CondaTool run -n $EnvName uv venv --system-site-packages --python=$PythonVersion -if ($LASTEXITCODE -ne 0) { throw "uv venv failed" } -& $CondaTool run -n $EnvName uv sync --frozen --no-cache -if ($LASTEXITCODE -ne 0) { throw "uv sync failed" } - -& $CondaTool run -n $EnvName .venv\Scripts\python.exe -c "from osgeo import gdal; print(f'GDAL {gdal.__version__} OK')" -if ($LASTEXITCODE -ne 0) { throw "GDAL import check failed" } -Write-Host "" -Write-Host "Done. To use:" -Write-Host " $CondaTool activate $EnvName" -Write-Host " .\.venv\Scripts\Activate.ps1" diff --git a/scripts/setup_postproc.sh b/scripts/setup_postproc.sh deleted file mode 100755 index c9705372..00000000 --- a/scripts/setup_postproc.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Set up the native post-processing environment (Linux/macOS). -# -# GDAL comes from conda-forge (environment.yml); everything else is installed -# by `make install`, which runs uv to create .venv on top of the conda env -# with --system-site-packages so the conda GDAL is importable. -# -# Requires conda, mamba, or micromamba on PATH. Usage: -# ./scripts/setup_postproc.sh -# The env name defaults to 'kamera'; override with KAMERA_CONDA_ENV. -# Afterwards: -# conda activate kamera && source .venv/bin/activate -set -euo pipefail - -cd "$(dirname "$0")/.." - -ENV_NAME="${KAMERA_CONDA_ENV:-kamera}" - -CONDA_TOOL="" -for tool in conda mamba micromamba; do - if command -v "$tool" >/dev/null 2>&1; then - CONDA_TOOL="$tool" - break - fi -done -if [ -z "$CONDA_TOOL" ]; then - echo "error: conda, mamba, or micromamba is required on PATH" >&2 - exit 1 -fi - -YES="" -[ "$CONDA_TOOL" = "micromamba" ] && YES="-y" - -if "$CONDA_TOOL" env list | awk '{print $1}' | grep -qx "$ENV_NAME"; then - # No --prune: the env may hold other tools (e.g. colmap) we shouldn't remove. - echo "Updating existing env '$ENV_NAME' with $CONDA_TOOL..." - "$CONDA_TOOL" env update -n "$ENV_NAME" -f environment.yml $YES -else - echo "Creating env '$ENV_NAME' with $CONDA_TOOL..." - "$CONDA_TOOL" env create -n "$ENV_NAME" -f environment.yml $YES -fi - -"$CONDA_TOOL" run -n "$ENV_NAME" make install - -.venv/bin/python -c 'from osgeo import gdal; print(f"GDAL {gdal.__version__} OK")' -echo -echo "Done. To use:" -echo " $CONDA_TOOL activate $ENV_NAME" -echo " source .venv/bin/activate" From db1ed0d1876e9af76a90bb524c2bd74adee7e4be Mon Sep 17 00:00:00 2001 From: romleiaj Date: Mon, 6 Jul 2026 15:24:02 -0400 Subject: [PATCH 08/32] Document pip install for Windows, tighten README and comments pip install -e . into the conda env is the simplest Windows path: one env, one activation, no uv.lock but pyproject floors keep it sane. Trim install docs and packaging comments to the essentials. --- Makefile | 4 +--- README.md | 33 ++++++++++++--------------------- docker/gui.dockerfile | 8 +++----- docker/kamerapy.dockerfile | 3 +-- environment.yml | 8 ++------ pyproject.toml | 11 ++++------- 6 files changed, 23 insertions(+), 44 deletions(-) diff --git a/Makefile b/Makefile index 96568bdb..55d03bed 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,7 @@ PYTHON_VERSION ?= 3.10 .PHONY: install build core viame gui postflight follower leader all clean -# Install the post-processing environment into .venv. Run inside an activated -# conda env built from environment.yml, which supplies python + GDAL + uv; -# --system-site-packages makes the conda GDAL importable from the venv. +# Build .venv on top of an activated conda env from environment.yml install: @echo "🚀 Creating virtual environment using uv" @uv venv --system-site-packages --python=$(PYTHON_VERSION) diff --git a/README.md b/README.md index dfab8584..69a9a4ea 100644 --- a/README.md +++ b/README.md @@ -22,50 +22,41 @@ KAMERA, or the **K**nowledge-guided Image **A**cquisition **M**anag**ER** and ** ## Installation -### Post-processing / flight summaries (native, Windows or Linux) +### Post-processing (native, Windows or Linux) -The binary geo/SfM stack (GDAL, pycolmap) comes from conda-forge -(`environment.yml`); [uv](https://docs.astral.sh/uv/) layers the rest of the -environment into `.venv` on top of it. The only prerequisite is conda -(e.g. [miniforge](https://conda-forge.org/download/)) on your PATH. +GDAL and pycolmap come from conda-forge; [uv](https://docs.astral.sh/uv/) +installs the rest into `.venv`. Requires +[conda](https://conda-forge.org/download/). Linux/macOS: ```bash git clone https://github.com/Kitware/kamera.git cd kamera -conda env create -f environment.yml # later: conda env update -f environment.yml +conda env create -f environment.yml conda activate kamera make install source .venv/bin/activate ``` -Windows (PowerShell) — identical, except `make` usually isn't available, so -run the two commands from the Makefile's `install` target directly: +Windows (PowerShell or Anaconda Prompt): ```powershell git clone https://github.com/Kitware/kamera.git cd kamera -conda env create -f environment.yml # later: conda env update -f environment.yml +conda env create -f environment.yml conda activate kamera -uv venv --system-site-packages --python=3.10 -uv sync --frozen --no-cache -.\.venv\Scripts\Activate.ps1 +pip install -e . ``` -Note for Windows: keep the conda env activated alongside `.venv` so GDAL's -DLLs resolve. - -Note on GPU support: conda picks the CUDA build of pycolmap automatically if -your NVIDIA driver supports CUDA >=12.9 (driver 575+); otherwise it silently -falls back to the CPU build. GPU acceleration is only needed for full camera -model calibration — routine post-processing and flight summaries are fine on -the CPU build. +Afterwards, `conda activate kamera` is all you need. Conda installs the CUDA +build of pycolmap automatically with NVIDIA driver 575+ (CUDA 12.9), +otherwise the CPU build; GPU only matters for full camera model calibration. ### Docker images ```bash -# Post-processing image (same conda + uv flow as above, containerized): +# post-processing / flight summary image make postflight # Builds the core docker images for use in the onboard sytems make nuvo diff --git a/docker/gui.dockerfile b/docker/gui.dockerfile index 15e009f1..fdbf0e66 100644 --- a/docker/gui.dockerfile +++ b/docker/gui.dockerfile @@ -36,11 +36,9 @@ RUN mkdir -p /home/user/.config/kamera && \ RUN ln -sv /usr/bin/python3 /usr/bin/python || true RUN find /home/user -not -user user -execdir chown user {} \+ -# Install kamera for wxpython_gui imports (e.g. colmap_processing.camera_models). -# Use --no-deps: base images already provide runtime deps, and a full install -# fails trying to replace distutils-installed PyYAML from ROS/Noetic. -# --ignore-requires-python: the package targets >=3.10 but ROS Noetic pins -# this image to Python 3.8; the modules the GUI imports still run there. +# Install kamera for wxpython_gui imports. --no-deps: deps come from the base +# image (a full install trips on ROS's distutils PyYAML). +# --ignore-requires-python: ROS Noetic pins python 3.8, below our 3.10 floor. RUN pip install --no-cache-dir matplotlib \ && pip install --no-cache-dir --no-deps --ignore-requires-python -e $REPO_DIR diff --git a/docker/kamerapy.dockerfile b/docker/kamerapy.dockerfile index 12eacff7..65469fe7 100644 --- a/docker/kamerapy.dockerfile +++ b/docker/kamerapy.dockerfile @@ -21,8 +21,7 @@ RUN curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/${MAMBA_VERSION} \ | tar -xvj -C /usr/local/bin --strip-components=1 bin/micromamba ENV MAMBA_ROOT_PREFIX=/opt/conda -# The conda env supplies python + GDAL + uv; uv layers everything else into -# .venv on top of it (same flow as the native install in the README). +# Conda env supplies python + GDAL + uv; make install layers .venv on top COPY environment.yml /tmp/environment.yml RUN micromamba create -y -n kamera -f /tmp/environment.yml \ && micromamba clean --all -y diff --git a/environment.yml b/environment.yml index 2eae41b2..043eee12 100644 --- a/environment.yml +++ b/environment.yml @@ -1,9 +1,5 @@ -# Conda environment providing the binary geo/SfM stack (GDAL, pycolmap) that -# has no reliable cross-platform wheels. The project itself is installed by uv into -# a venv created on top of this env with --system-site-packages, so python -# here must satisfy requires-python in pyproject.toml. -# Setup: `conda env create -f environment.yml && conda activate kamera`, -# then `make install` (see README.md for the Windows equivalent). +# Binary deps (GDAL, pycolmap) with no reliable cross-platform wheels. +# `make install` layers the rest on top; see README.md for setup. name: kamera channels: - conda-forge diff --git a/pyproject.toml b/pyproject.toml index 6537dccb..81dcbf04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,10 +27,8 @@ dependencies = [ "rich>=13.9.1", ] -# GDAL is intentionally not a dependency: there are no reliable -# cross-platform wheels. It comes from conda-forge (environment.yml), and the -# uv venv is built on top of the conda env with --system-site-packages so the -# conda GDAL is importable (see `make install` and the README). +# GDAL has no reliable cross-platform wheels; it comes from conda-forge +# (environment.yml) and reaches .venv via --system-site-packages. [dependency-groups] dev = [ @@ -38,9 +36,8 @@ dev = [ ] [tool.uv] -# Never let uv substitute its own managed python: .venv must be built on the -# active conda env's interpreter or --system-site-packages won't see the -# conda GDAL. +# Build .venv on the conda python, never a uv-managed one, so +# --system-site-packages sees the conda GDAL. python-preference = "only-system" [build-system] From 0375745c95246e26031286e270f376a8af615a93 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Wed, 16 Sep 2026 10:08:33 -0400 Subject: [PATCH 09/32] Add kamera-calibrate: multi-sensor rig calibration with pycolmap 4.2 Rewrite the calibration pipeline as the kamera.calibration package. One COLMAP model holds all nine cameras: trigger-synchronized images form rig frames, INS positions are pose priors, and a prior-anchored rig bundle adjustment refines sensor_from_rig and intrinsics. Outputs per flight: camera model yamls in the INS frame, rig.yaml with the INS boresight and lever arm, DIVE v2 registration JSON and GIFs for every modality pair per channel, and a PDF report. - pass 1 maps every camera independently with position priors; pass 2 puts the rig from pass 1 onto the largest model, adds the IR images to their frames, triangulates and bundle adjusts twice - UV and IR frames are contrast-normalized; thermal-to-visible SIFT pairs are pruned; rig extrinsics are seeded from the densest cluster of per-frame estimates so a folded sub-model cannot bias them - environment moves to Python 3.13 and the conda-forge CUDA pycolmap 4.2; make install recreates .venv - remove the superseded per-camera calibration scripts --- CHANGELOG.md | 16 + Makefile | 4 +- README.md | 8 +- environment.yml | 4 +- kamera/calibration/README.md | 54 + kamera/calibration/__init__.py | 5 + kamera/calibration/cli.py | 121 ++ kamera/calibration/config.py | 24 + kamera/calibration/flight.py | 134 ++ kamera/calibration/registration.py | 81 ++ kamera/calibration/report.py | 151 ++ kamera/calibration/rig.py | 180 +++ kamera/calibration/sfm.py | 219 +++ kamera/colmap_processing/camera_models.py | 1291 +++++++++-------- kamera/colmap_processing/geo_conversions.py | 2 +- .../scripts/calibrate_from_colmap.py | 242 --- .../scripts/calibrate_ir_from_rgb.py | 169 --- .../postflight/scripts/camera_calibration.py | 1213 ---------------- .../scripts/intercam_homography_from_yaml.py | 138 -- kamera/sensor_models/nav_conversions.py | 2 +- pyproject.toml | 7 +- 21 files changed, 1726 insertions(+), 2339 deletions(-) create mode 100644 kamera/calibration/README.md create mode 100644 kamera/calibration/__init__.py create mode 100644 kamera/calibration/cli.py create mode 100644 kamera/calibration/config.py create mode 100644 kamera/calibration/flight.py create mode 100644 kamera/calibration/registration.py create mode 100644 kamera/calibration/report.py create mode 100644 kamera/calibration/rig.py create mode 100644 kamera/calibration/sfm.py delete mode 100644 kamera/postflight/scripts/calibrate_from_colmap.py delete mode 100644 kamera/postflight/scripts/calibrate_ir_from_rgb.py delete mode 100644 kamera/postflight/scripts/camera_calibration.py delete mode 100644 kamera/postflight/scripts/intercam_homography_from_yaml.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f7319a1a..f8fd70b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `kamera-calibrate`: multi-sensor rig calibration from a calibration flight + (`kamera/calibration`). One COLMAP model with trigger-synchronized frames, INS + position priors, rig bundle adjustment; writes camera model yamls, `rig.yaml`, + DIVE v2 registration JSON, GIFs and a PDF report. + +### Changed + +- Post-processing env moves to Python 3.13 and pycolmap 4.2 (conda-forge, CUDA build). +- `make install` recreates `.venv` instead of failing when it exists. + +### Removed + +- Old per-camera calibration scripts under `kamera/postflight/scripts`. + ## [0.5.0] - 2026-07-21 ### Added diff --git a/Makefile b/Makefile index 55d03bed..f58c3fda 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,12 @@ ROS_DISTRO ?= noetic -PYTHON_VERSION ?= 3.10 +PYTHON_VERSION ?= 3.13 .PHONY: install build core viame gui postflight follower leader all clean # Build .venv on top of an activated conda env from environment.yml install: @echo "🚀 Creating virtual environment using uv" - @uv venv --system-site-packages --python=$(PYTHON_VERSION) + @uv venv --clear --system-site-packages --python=$(PYTHON_VERSION) @uv sync --frozen --no-cache build: diff --git a/README.md b/README.md index 69a9a4ea..01b14806 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ KAMERA, or the **K**nowledge-guided Image **A**cquisition **M**anag**ER** and ** ### Post-processing (native, Windows or Linux) -GDAL and pycolmap come from conda-forge; [uv](https://docs.astral.sh/uv/) +GDAL and pycolmap come from conda-forge (Python 3.13); [uv](https://docs.astral.sh/uv/) installs the rest into `.venv`. Requires [conda](https://conda-forge.org/download/). @@ -53,6 +53,12 @@ Afterwards, `conda activate kamera` is all you need. Conda installs the CUDA build of pycolmap automatically with NVIDIA driver 575+ (CUDA 12.9), otherwise the CPU build; GPU only matters for full camera model calibration. +### Rig calibration + +`kamera-calibrate ` calibrates every camera on the rig from a calibration +flight and writes camera models, the rig geometry, DIVE registration files and a PDF +report. See [kamera/calibration/README.md](kamera/calibration/README.md). + ### Docker images ```bash diff --git a/environment.yml b/environment.yml index 043eee12..f279049b 100644 --- a/environment.yml +++ b/environment.yml @@ -4,8 +4,8 @@ name: kamera channels: - conda-forge dependencies: - - python=3.10 # must match PYTHON_VERSION in the Makefile + - python=3.13 # must match PYTHON_VERSION in the Makefile (pycolmap>=4.2 needs 3.11+) - gdal>=3.10 - - pycolmap>=4.0 + - pycolmap>=4.2 - pip - uv diff --git a/kamera/calibration/README.md b/kamera/calibration/README.md new file mode 100644 index 00000000..4de103e4 --- /dev/null +++ b/kamera/calibration/README.md @@ -0,0 +1,54 @@ +# Rig calibration + +Calibrates every camera on a KAMERA rig from one calibration flight (figure eights at +several altitudes) and expresses them in the INS body frame. One COLMAP model holds all +modalities: the trigger-synchronized images of each event form a *frame* with a single rig +pose, so IR ties into the EO model through the rig without cross-modal matching. + +```bash +conda activate kamera +kamera-calibrate /data/052025_Calibration # everything +kamera-calibrate /data/052025_Calibration --max_frames 150 --frame_stride 3 # quick look +kamera-calibrate --help +``` + +## Stages (each resumes from `/calibration/`) + +1. **frames** — `*_meta.json` grouped by trigger time into frames; camera names are + `_` (`C_rgb`, `L_ir`, ...). IR is stretched to 8 bit; EO is symlinked. +2. **features** — SIFT per camera with an initial focal length per modality, then an INS + position prior per image (`InsTrajectory` interpolates the meta.json samples). +3. **match** — spatial matching from the priors, across all cameras, so figure-eight + crossovers are matched as well as neighbours in time. Thermal-to-visible pairs are dropped: + SIFT cannot match them and their few spurious inliers mislead the mapper. +4. **pass1** — incremental mapping with independent cameras and position priors. EO and IR + come out as separate models, both in INS ENU. Needs three-view overlap along track: at + 64 m/s and 1 frame/s that means flying above roughly 600 m AGL for these lenses; lower legs + only register through crossovers with higher passes. +5. **pass2** — `cam_from_rig` for every camera is averaged from pass 1 (frames shared with + the reference camera, both models being in INS ENU), the rig and frames are written to the + database and onto the largest pass-1 model, and the images pass 1 never posed (IR) are added + to their frames. Every image is then triangulated from the rig poses and bundle adjusted + against the INS position priors, twice: rig poses and `sensor_from_rig` first, then with the + intrinsics free as well. +6. **calibrate** — INS boresight (`ins_from_rig`) and lever arm as a robust average over + frames, per-camera models, and `rig.yaml`. +7. **registration** — per channel `ir->uv`, `ir->rgb`, `uv->rgb` homographies as DIVE + camera-registration JSON (format v2), plus flip GIFs. +8. **report** — PDF with intrinsics, rig angles, boresight residuals, overlays and the error budget. + +## Outputs (`/calibration/models/`) + +- `_.yaml` — `standard` camera model readable by + `kamera.colmap_processing.camera_models.load_from_file`, with the rig and calibration + provenance in extra keys. +- `_rig.yaml` — `cam_from_rig` per camera, `ins_from_rig`, lever arm, quality statistics. +- `dive_registration/_to__registration.json`, `gifs/`, `_calibration_report.pdf`. + +## Conventions + +- `camera_quaternion` (x, y, z, w) rotates camera vectors into the INS body frame + (forward, right, down); `camera_position` is in that frame, metres. +- COLMAP's `cam_from_rig` maps rig (= reference camera) coordinates into the camera. +- The INS body-to-ENU rotation is `NED_TO_ENU * R_z(heading) R_y(pitch) R_x(roll)`, identical + to `kamera.sensor_models.nav_state`. diff --git a/kamera/calibration/__init__.py b/kamera/calibration/__init__.py new file mode 100644 index 00000000..7bebaacc --- /dev/null +++ b/kamera/calibration/__init__.py @@ -0,0 +1,5 @@ +"""Multi-sensor rig calibration from a KAMERA calibration flight. + +Pipeline: synchronized frames -> COLMAP SfM with INS position priors -> rig bundle +adjustment -> camera models, rig geometry, INS boresight, DIVE homographies, PDF report. +""" diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py new file mode 100644 index 00000000..66e03661 --- /dev/null +++ b/kamera/calibration/cli.py @@ -0,0 +1,121 @@ +"""``kamera-calibrate``: run the rig calibration pipeline stage by stage, resuming from disk.""" + +from __future__ import annotations + +import json +import os +import sys + +import cv2 +import numpy as np +import pycolmap as pc +from rich import print + +from kamera.calibration import registration, rig, sfm +from kamera.calibration.config import CalibrateConfig +from kamera.calibration.flight import build_image_tree, discover_flight +from kamera.calibration.report import write_report +from kamera.colmap_processing.camera_models import StandardCamera + +# Homography pairs per channel, left -> right (DIVE registers the left onto the right). +PAIRS = [("ir", "uv"), ("ir", "rgb"), ("uv", "rgb")] + + +def main(argv=None) -> None: + cfg = CalibrateConfig.cli(argv=argv, strict=True) + work = cfg.work_dir or os.path.join(cfg.flight_dir, "calibration") + image_dir, db_path = os.path.join(work, "images"), os.path.join(work, "database.db") + pass1_dir, rig_dir, model_dir = os.path.join(work, "pass1"), os.path.join(work, "rig"), os.path.join(work, "models") + os.makedirs(work, exist_ok=True) + + def done(path: str) -> bool: + return os.path.exists(path) and not cfg.force + + print("[blue]Discovering frames[/blue]") + frames, ins, rig_name = discover_flight(cfg.flight_dir) + rig_name = cfg.rig_name or rig_name.replace("images_", "") or "rig" + full = [f for f in frames if len(f.images) == len({c for fr in frames for c in fr.images})] + stop = cfg.frame_start + cfg.max_frames * cfg.frame_stride if cfg.max_frames else None + frames = full[cfg.frame_start:stop:cfg.frame_stride] + print(f"{len(full)} frames with every camera, using {len(frames)}; INS median sample gap {np.median([ins.sample_gap(f.time) for f in frames]) * 1000:.1f} ms") + names = build_image_tree(frames, image_dir) + with open(os.path.join(work, "images.json"), "w") as f: + json.dump(names, f) + + if not done(db_path): + print("[blue]Extracting features and writing INS priors[/blue]") + if os.path.exists(db_path): + os.remove(db_path) + sfm.extract_features(db_path, image_dir, names, cfg.focal_px, cfg.max_image_size, cfg.num_features) + sfm.write_pose_priors(db_path, names, ins, cfg.prior_std_m) + db = pc.Database.open(db_path) + matched = db.num_verified_image_pairs() > 0 + db.close() + if not matched or cfg.force: + print("[blue]Matching[/blue]") + sfm.match_features(db_path, cfg.match_distance_m, cfg.match_neighbors) + + if not done(pass1_dir): + print("[blue]Pass 1: mapping with independent cameras[/blue]") + sfm.run_mapping(db_path, image_dir, pass1_dir) + pass1 = sfm.load_models(pass1_dir) + for k, r in pass1.items(): + print(f" model {k}: {r.num_reg_images()} images, {r.num_points3D()} points, {r.compute_mean_reprojection_error():.2f} px") + + if not done(rig_dir): + print("[blue]Pass 2: rig bundle adjustment[/blue]") + for name, v in sfm.derive_rig(pass1, names, cfg.reference_camera).items(): + print(f" {name}: {v['frames']} frames, rotation scatter {v['rotation_scatter_deg']:.3f} deg, translation std {np.round(v['translation_std_m'], 2)} m") + rig_in = os.path.join(work, "rig_init") + sfm.rigged_model(db_path, pass1, names, cfg.reference_camera, rig_in) + sfm.refine_rig(db_path, names, rig_in, rig_dir) + model = pc.Reconstruction(rig_dir) + print(f" rig model: {model.num_reg_frames()} frames, {model.num_reg_images()} images, {model.compute_mean_reprojection_error():.2f} px") + + print("[blue]Extracting camera models and boresight[/blue]") + cal = rig.calibrate_rig(model, names, ins, cfg.reference_camera, rig_name, os.path.basename(os.path.abspath(cfg.flight_dir))) + for p in rig.write_outputs(cal, model_dir): + print(f" wrote {p}") + + print("[blue]Fitting homographies and writing DIVE registration files[/blue]") + reg_dir = os.path.join(model_dir, "dive_registration") + cams = {n: StandardCamera(c.width, c.height, c.K, c.dist, cal.camera_position(n), cal.camera_quaternion(n)) for n, c in cal.cameras.items()} + pairs = [] + for channel in sorted({n.split("_")[0] for n in cams}): + for left_mod, right_mod in PAIRS: + left, right = f"{channel}_{left_mod}", f"{channel}_{right_mod}" + if left not in cams or right not in cams: + continue + try: + h, stats = registration.model_homography(cams[left], cams[right]) + except ValueError as e: + print(f" [yellow]{left} -> {right}: {e}[/yellow]") + continue + path = registration.write_dive_registration(reg_dir, left, right, h, stats, registration.source_stamp(cfg.flight_dir, {"rig": rig_name})) + print(f" wrote {path} (fit rms {stats['rmsPx']:.2f} px)") + pairs.append({"left": left, "right": right, "h": h, "stats": stats, **write_gifs(frames, names, image_dir, left, right, h, os.path.join(model_dir, "gifs"), cfg.gif_frames)}) + + report_path = os.path.join(model_dir, f"{rig_name}_calibration_report.pdf") + write_report(report_path, cal, pairs) + print(f"[green]Report written to {report_path}[/green]") + + +def write_gifs(frames, names, image_dir, left, right, h, gif_dir, count) -> dict: + """Flip GIFs of the left image warped onto the right for evenly spaced frames; returns report images from the middle one.""" + os.makedirs(gif_dir, exist_ok=True) + by_time = {t: n for n, (c, t) in names.items() if c == left} + usable = [f for f in frames if left in f.images and right in f.images] + out = {} + chosen = usable[:: max(1, len(usable) // max(count, 1))][:count] + for k, frame in enumerate(chosen): + left_img = cv2.imread(os.path.join(image_dir, by_time[frame.time]), cv2.IMREAD_COLOR) + right_img = cv2.imread(frame.images[right], cv2.IMREAD_COLOR) + warped, ref = registration.warp_pair(left_img, right_img, h) + registration.write_gif(os.path.join(gif_dir, f"{left}_to_{right}_{k}.gif"), warped, ref) + if k == len(chosen) // 2: + out = {"warped_img": warped, "right_img": ref, "overlay_img": registration.blend_overlay(warped, ref)} + return out + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/kamera/calibration/config.py b/kamera/calibration/config.py new file mode 100644 index 00000000..1d662a68 --- /dev/null +++ b/kamera/calibration/config.py @@ -0,0 +1,24 @@ +"""Command-line configuration for the rig calibration.""" + +from __future__ import annotations + +import scriptconfig as scfg + + +class CalibrateConfig(scfg.DataConfig): + __command__ = "kamera-calibrate" + flight_dir = scfg.Value(None, position=1, help="KAMERA flight directory (contains /_view/*_meta.json)") + work_dir = scfg.Value(None, help="Scratch and output root (default: /calibration)") + rig_name = scfg.Value(None, help="Name used in output file names (default: sys_cfg from the meta json)") + reference_camera = scfg.Value("C_rgb", help="Rig reference sensor; every other camera is expressed relative to it") + frame_start = scfg.Value(0, help="Index of the first synchronized frame to use") + frame_stride = scfg.Value(1, help="Use every Nth frame") + max_frames = scfg.Value(0, help="Cap on frames used (0 = all)") + focal_px = scfg.Value({"rgb": 31363.0, "uv": 14120.0, "ir": 1712.0}, help="Initial focal length per modality in pixels; refined by SfM") + max_image_size = scfg.Value(3200, help="Images are downsampled to this longest side for SIFT") + num_features = scfg.Value(8192, help="Max SIFT features per image") + match_distance_m = scfg.Value(250.0, help="Spatial matching radius from INS positions") + match_neighbors = scfg.Value(90, help="Spatial matching neighbours per image (about 10 frames times the number of cameras)") + prior_std_m = scfg.Value(2.0, help="Standard deviation assigned to INS position priors") + gif_frames = scfg.Value(5, help="Registration GIFs written per camera pair") + force = scfg.Value(False, isflag=True, help="Rerun stages whose outputs already exist") diff --git a/kamera/calibration/flight.py b/kamera/calibration/flight.py new file mode 100644 index 00000000..12193ea1 --- /dev/null +++ b/kamera/calibration/flight.py @@ -0,0 +1,134 @@ +"""Flight discovery: synchronized frames, camera naming, INS trajectory, COLMAP image tree.""" + +from __future__ import annotations + +import bisect +import glob +import json +import os +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass, field + +import cv2 +import numpy as np +from scipy.spatial.transform import Rotation, Slerp + +from kamera.sensor_models.nav_conversions import llh_to_enu + +# Image suffixes written by the KAMERA archiver, keyed by modality. +MODALITY_EXT = {"rgb": ".jpg", "uv": ".jpg", "ir": ".tif"} +# NED body attitude -> ENU: swap north/east and flip down (a 180 deg turn about (1,1,0)). +NED_TO_ENU = Rotation.from_quat([np.sqrt(0.5), np.sqrt(0.5), 0.0, 0.0]) + + +@dataclass +class Frame: + """All images captured on one trigger event, keyed by camera name (e.g. ``C_rgb``).""" + + time: float + images: dict[str, str] = field(default_factory=dict) + + +class InsTrajectory: + """INS attitude and ENU position interpolated to any time. + + Quaternions follow the KAMERA convention: ``rotation`` maps body (forward, right, down) + vectors into the local ENU frame. Any source with times, lat/lon/alt and heading/pitch/roll + can build one, so a future high-rate or event-stamped log drops in via ``__init__``. + """ + + def __init__(self, times, llh_deg, hpr_deg, lat0=None, lon0=None, h0=0.0): + order = np.argsort(times) + self.times = np.asarray(times, float)[order] + self.llh = np.asarray(llh_deg, float)[order] + hpr = np.radians(np.asarray(hpr_deg, float)[order]) + self.lat0 = float(np.median(self.llh[:, 0]) if lat0 is None else lat0) + self.lon0 = float(np.median(self.llh[:, 1]) if lon0 is None else lon0) + self.h0 = float(h0) + self.enu = np.array([llh_to_enu(*r, self.lat0, self.lon0, self.h0, in_degrees=True) for r in self.llh]) + self.rotations = NED_TO_ENU * Rotation.from_euler("ZYX", hpr) + + @classmethod + def from_meta(cls, samples: dict[float, tuple]) -> InsTrajectory: + rows = np.array([samples[t] for t in sorted(samples)]) + return cls(sorted(samples), rows[:, :3], rows[:, 3:]) + + def pose(self, t: float) -> tuple[np.ndarray, Rotation]: + i = int(np.clip(bisect.bisect(self.times, t), 1, len(self.times) - 1)) + w = float(np.clip((t - self.times[i - 1]) / (self.times[i] - self.times[i - 1]), 0.0, 1.0)) + pos = (1 - w) * self.enu[i - 1] + w * self.enu[i] + rot = Slerp([0.0, 1.0], self.rotations[[i - 1, i]])([w])[0] + return pos, rot + + def sample_gap(self, t: float) -> float: + """Seconds from ``t`` to the nearest INS sample (how stale the attitude is).""" + i = int(np.clip(bisect.bisect(self.times, t), 1, len(self.times) - 1)) + return float(min(abs(t - self.times[i - 1]), abs(t - self.times[i]))) + + # Duck-type the NavStateProvider interface used by camera_models. + def pos(self, t): + return self.pose(t)[0] + + def quat(self, t): + return self.pose(t)[1].as_quat() + + +def discover_flight(flight_dir: str) -> tuple[list[Frame], InsTrajectory, str]: + """Group every ``*_meta.json`` under ``flight_dir`` into trigger-synchronized frames. + + Camera names are ``_`` with the channel taken from the view + directory (``center_view`` -> ``C``). Returns frames sorted by time, the INS + trajectory assembled from the per-image INS samples, and the rig name from ``sys_cfg``. + """ + frames: dict[float, Frame] = {} + samples: dict[float, tuple] = {} + rig_name = "" + for meta in glob.glob(os.path.join(flight_dir, "**", "*_view", "*_meta.json"), recursive=True): + with open(meta) as f: + d = json.load(f) + channel = os.path.basename(os.path.dirname(meta)).split("_")[0][0].upper() + stem = meta[: -len("_meta.json")] + t = float(d["evt"]["time"]) + frame = frames.setdefault(round(t, 3), Frame(time=t)) + for modality, ext in MODALITY_EXT.items(): + if os.path.exists(stem + f"_{modality}{ext}"): + frame.images[f"{channel}_{modality}"] = stem + f"_{modality}{ext}" + ins = d["ins"] + samples[float(ins["time"])] = (ins["latitude"], ins["longitude"], ins["altitude"], + ins["heading"], ins["pitch"], ins["roll"]) + rig_name = rig_name or d.get("sys_cfg", "") + if not frames: + raise FileNotFoundError(f"No *_meta.json files found under {flight_dir}") + return [frames[k] for k in sorted(frames)], InsTrajectory.from_meta(samples), rig_name + + +def normalize(src: str, dst: str) -> None: + """Percentile-stretch (0.1-99.9) a dim or 16-bit frame to 8 bits and apply CLAHE, so SIFT has contrast.""" + im = cv2.imread(src, cv2.IMREAD_UNCHANGED).astype(np.float32) + lo, hi = np.percentile(im, [0.1, 99.9]) + im = np.clip((im - lo) / max(hi - lo, 1.0) * 255.0, 0, 255).astype(np.uint8) + cv2.imwrite(dst, cv2.createCLAHE(clipLimit=1.0, tileGridSize=(5, 5)).apply(im), [cv2.IMWRITE_JPEG_QUALITY, 95]) + + +def build_image_tree(frames: list[Frame], image_dir: str) -> dict[str, tuple[str, float]]: + """Lay frames out as ``image_dir//.jpg`` for COLMAP's per-folder cameras. + + COLMAP groups images into rig frames by identical file names across folders, hence the + time-based names. RGB is symlinked; the dim UV and 16-bit IR frames are contrast-normalized. + Returns ``{colmap image name: (camera name, frame time)}``. + """ + names: dict[str, tuple[str, float]] = {} + jobs = [] + for frame in frames: + for camera, src in frame.images.items(): + stretch = not camera.endswith("_rgb") + name = f"{camera}/{frame.time:.3f}.jpg" + dst = os.path.join(image_dir, name) + os.makedirs(os.path.dirname(dst), exist_ok=True) + names[name] = (camera, frame.time) + if os.path.exists(dst): + continue + jobs.append((src, dst)) if stretch else os.symlink(os.path.abspath(src), dst) + with ProcessPoolExecutor() as pool: + list(pool.map(normalize, *zip(*jobs)) if jobs else []) + return names diff --git a/kamera/calibration/registration.py b/kamera/calibration/registration.py new file mode 100644 index 00000000..f962afed --- /dev/null +++ b/kamera/calibration/registration.py @@ -0,0 +1,81 @@ +"""Inter-camera homographies: DIVE camera-registration JSON (format v2) and GIF overlays. + +Each ``_to__registration.json`` holds one matrix-only pair whose +``leftToRight`` homography maps left-camera pixels onto right-camera pixels. The +matrix is fit to the calibrated models by casting a grid of left pixels to +effective infinity and projecting them into the right camera, so it is exact up to +lens distortion and the (negligible) rig baseline; the fit residual is reported. +""" + +from __future__ import annotations + +import datetime +import json +import os + +import cv2 +import numpy as np +import PIL.Image + +DIVE_TYPE = "dive-camera-registration" +DIVE_VERSION = 2 +RAY_DISTANCE = 1e6 + + +def model_homography(src_cm, dst_cm, grid: int = 40) -> tuple[np.ndarray, dict]: + """Least-squares homography from ``src_cm`` pixels to ``dst_cm`` pixels, plus fit stats.""" + xg, yg = np.meshgrid(np.linspace(0, src_cm.width - 1, grid), np.linspace(0, src_cm.height - 1, grid)) + src = np.vstack([xg.ravel(), yg.ravel()]) + ray_pos, ray_dir = src_cm.unproject(src, -np.inf) + dst = np.asarray(dst_cm.project(ray_pos + ray_dir * RAY_DISTANCE, -np.inf), dtype=np.float64) + inside = np.all(np.isfinite(dst), 0) & (dst[0] >= 0) & (dst[0] <= dst_cm.width) & (dst[1] >= 0) & (dst[1] <= dst_cm.height) + if inside.sum() < 4: + raise ValueError(f"only {inside.sum()} of {src.shape[1]} samples land in the destination image") + h, _ = cv2.findHomography(src[:, inside].T, dst[:, inside].T, 0) + err = np.linalg.norm(cv2.perspectiveTransform(src[:, inside].T.reshape(-1, 1, 2), h).reshape(-1, 2) - dst[:, inside].T, axis=1) + stats = {"rmsPx": float(np.sqrt(np.mean(err**2))), "p95Px": float(np.percentile(err, 95)), + "maxPx": float(np.max(err)), "coverage": float(inside.mean())} + return h, stats + + +def write_dive_registration(out_dir: str, left: str, right: str, h: np.ndarray, stats: dict, source: dict) -> str: + """Write one matrix-only v2 pair file and return its path.""" + inv = np.linalg.inv(h) + pair = {"left": left, "right": right, "transformType": "homography", + "leftToRight": h.tolist(), "rightToLeft": (inv / inv[2, 2]).tolist(), "observations": [], + "stats": {f"modelFit{k[0].upper()}{k[1:]}": v for k, v in stats.items()}} + body = {"type": DIVE_TYPE, "version": DIVE_VERSION, "source": source, "pairs": [pair]} + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, f"{left}_to_{right}_registration.json") + with open(path, "w") as f: + json.dump(body, f, indent=2) + return path + + +def source_stamp(flight_dir: str, extra: dict | None = None) -> dict: + stamp = {"producer": "kamera-rig-calibration", "flight": os.path.basename(os.path.abspath(flight_dir)), + "generated": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")} + return {**stamp, **(extra or {})} + + +def warp_pair(left_img: np.ndarray, right_img: np.ndarray, h: np.ndarray, width: int = 1280) -> tuple[np.ndarray, np.ndarray]: + """Warp the left image into the right image's pixels; both returned resized to ``width`` wide, RGB.""" + scale = width / right_img.shape[1] + size = (width, round(right_img.shape[0] * scale)) + s = np.diag([scale, scale, 1.0]) + warped = cv2.warpPerspective(left_img, s @ h, size, flags=cv2.INTER_LINEAR) + return _rgb(warped), _rgb(cv2.resize(right_img, size, interpolation=cv2.INTER_AREA)) + + +def _rgb(im: np.ndarray) -> np.ndarray: + return cv2.cvtColor(im, cv2.COLOR_GRAY2RGB) if im.ndim == 2 else im[:, :, ::-1] + + +def write_gif(path: str, a: np.ndarray, b: np.ndarray, duration_ms: int = 400) -> None: + PIL.Image.fromarray(a).save(path, save_all=True, append_images=[PIL.Image.fromarray(b)], duration=duration_ms, loop=0) + + +def blend_overlay(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """Magenta/green false-colour blend: misregistration shows as coloured fringes.""" + ga, gb = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY), cv2.cvtColor(b, cv2.COLOR_RGB2GRAY) + return np.dstack([ga, gb, ga]) diff --git a/kamera/calibration/report.py b/kamera/calibration/report.py new file mode 100644 index 00000000..29328fa8 --- /dev/null +++ b/kamera/calibration/report.py @@ -0,0 +1,151 @@ +"""PDF report: camera table, rig geometry, INS boresight residuals, homography overlays, error budget.""" + +from __future__ import annotations + +import textwrap + +import matplotlib +import numpy as np +from matplotlib.backends.backend_pdf import PdfPages + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from kamera.calibration.rig import RigCalibration + +PAGE = (11, 8.5) + +ERROR_NOTES = """\ +Error budget and what limits it + +INS attitude at the trigger. Each meta.json carries one 100 Hz INS sample taken before the +event, so the attitude used here is up to 10 ms stale (median gap reported above). At the +turn rates of a figure-eight (about 5 deg/s) that is up to 0.05 deg, or roughly 25 RGB +pixels, and it enters every frame's boresight estimate as noise. A hardware event-stamped +INS sample or a full-rate log removes it; InsTrajectory accepts either without code changes. + +SfM drift. Bundle adjustment with INS position priors pins scale, heading and position to +the INS, but the relative orientation drift of the model over the flight is what dominates the +per-frame boresight scatter. The rig constraint removes the intra-frame freedom entirely, so +the relative camera geometry (and therefore the homographies) is far better determined than +the absolute boresight. + +Lever arms. At the flight ranges of 400 to 900 m a 30 cm baseline subtends less than one IR +pixel, so the rig translations are estimated only weakly and the reported standard +deviations should be read as such. The INS lever arm is likewise noise dominated; it is the +median offset of the rig origin from the INS position over all frames. + +Homographies. A single homography represents the model-to-model mapping exactly only for a +pure rotation with no lens distortion. The fit residual (rms and p95, in right-image pixels) +quantifies what the distortion costs; the warped overlays show it visually. +""" + + +def _text_page(pdf: PdfPages, title: str, body: str) -> None: + fig = plt.figure(figsize=PAGE) + fig.text(0.06, 0.94, title, fontsize=16, weight="bold", va="top") + fig.text(0.06, 0.88, body, fontsize=9.5, va="top", family="monospace", linespacing=1.4) + pdf.savefig(fig) + plt.close(fig) + + +def _table_page(pdf: PdfPages, title: str, header: list[str], rows: list[list], widths=None) -> None: + fig, ax = plt.subplots(figsize=PAGE) + ax.axis("off") + ax.set_title(title, fontsize=15, weight="bold", loc="left", pad=20) + table = ax.table(cellText=rows, colLabels=header, loc="upper center", cellLoc="center", colWidths=widths) + table.auto_set_font_size(False) + table.set_fontsize(8) + table.scale(1, 1.5) + pdf.savefig(fig) + plt.close(fig) + + +def camera_page(pdf: PdfPages, cal: RigCalibration) -> None: + header = ["camera", "size", "fx", "fy", "cx", "cy", "k1", "k2", "p1", "p2", "frames", "rms px", "ifov deg"] + rows = [] + for name in sorted(cal.cameras): + c = cal.cameras[name] + rows.append([name, f"{c.width}x{c.height}", f"{c.K[0, 0]:.1f}", f"{c.K[1, 1]:.1f}", f"{c.K[0, 2]:.1f}", f"{c.K[1, 2]:.1f}", + *[f"{v:.5f}" for v in c.dist], c.frames, f"{c.reproj_rms_px:.2f}", f"{np.degrees(1 / c.K[0, 0]):.5f}"]) + _table_page(pdf, f"{cal.rig}: camera intrinsics ({cal.flight})", header, rows) + + +def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: + ref = cal.cameras[cal.reference] + fig = plt.figure(figsize=PAGE) + fig.suptitle(f"Rig geometry relative to {cal.reference}", fontsize=15, weight="bold", x=0.06, ha="left") + ax = fig.add_subplot(1, 2, 1) + ax.axis("off") + rows = [] + for name in sorted(cal.cameras): + rel = ref.rig_from_cam.inv() * cal.cameras[name].rig_from_cam + rv, c = rel.as_rotvec(degrees=True), cal.cameras[name].center_in_rig + rows.append([name, f"{np.degrees(rel.magnitude()):.3f}", f"{rv[0]:+.3f} {rv[1]:+.3f} {rv[2]:+.3f}", f"{c[0]:+.2f} {c[1]:+.2f} {c[2]:+.2f}"]) + t = ax.table(cellText=rows, colLabels=["camera", "angle deg", "rotvec deg (ref axes)", "centre m (rig)"], loc="center", cellLoc="center", colWidths=[0.16, 0.16, 0.4, 0.36]) + t.auto_set_font_size(False) + t.set_fontsize(7.5) + t.scale(1, 1.6) + ax3 = fig.add_subplot(1, 2, 2, projection="3d") + for i, name in enumerate(sorted(cal.cameras)): + z = cal.cameras[name].rig_from_cam.apply([0, 0, 1]) + ax3.quiver(0, 0, 0, *z, length=1.0, label=name, arrow_length_ratio=0.08, color=f"C{i}") + ax3.set_xlim(-1, 1); ax3.set_ylim(-1, 1); ax3.set_zlim(0, 1) + ax3.set_xlabel("rig x"); ax3.set_ylabel("rig y"); ax3.set_zlabel("rig z (optical)") + ax3.set_title("optical axes in the rig frame", fontsize=10) + ax3.legend(fontsize=6, loc="upper left") + pdf.savefig(fig) + plt.close(fig) + + +def boresight_page(pdf: PdfPages, cal: RigCalibration) -> None: + keep = cal.inlier + t = cal.frame_times - cal.frame_times[0] + res, pos = cal.rotation_residual_deg, cal.position_residual_m + mag = np.linalg.norm(res[keep], axis=1) + fig, axes = plt.subplots(2, 2, figsize=PAGE) + e = cal.ins_from_rig.as_euler("ZYX", degrees=True) + fig.suptitle(f"INS boresight: ins_from_rig euler ZYX = ({e[0]:.4f}, {e[1]:.4f}, {e[2]:.4f}) deg, lever arm = " + f"({cal.lever_arm_m[0]:.2f}, {cal.lever_arm_m[1]:.2f}, {cal.lever_arm_m[2]:.2f}) m; " + f"{keep.sum()} frames, {(~keep).sum()} rejected", fontsize=10, weight="bold") + for i, lbl in enumerate("xyz"): + axes[0, 0].plot(t[keep], res[keep, i], ".", ms=2, label=f"rot {lbl}") + axes[1, 0].plot(t[keep], pos[keep, i], ".", ms=2, label=f"pos {lbl}") + axes[0, 0].set(title="per-frame boresight residual (deg, rig axes)", xlabel="s since first frame"); axes[0, 0].legend(fontsize=7) + axes[1, 0].set(title="rig origin vs INS minus lever arm (m, body axes)", xlabel="s since first frame"); axes[1, 0].legend(fontsize=7) + axes[0, 1].hist(mag, bins=50, color="gray") + axes[0, 1].set(title=f"residual magnitude: median {np.median(mag):.3f}, p90 {np.percentile(mag, 90):.3f} deg", xlabel="deg") + axes[1, 1].hist(cal.ins_gap_s * 1000, bins=40, color="gray") + axes[1, 1].set(title=f"INS sample staleness: median {np.median(cal.ins_gap_s) * 1000:.1f} ms", xlabel="ms") + fig.tight_layout(rect=(0, 0, 1, 0.95)) + pdf.savefig(fig) + plt.close(fig) + + +def homography_page(pdf: PdfPages, pair: dict) -> None: + s = pair["stats"] + fig = plt.figure(figsize=PAGE) + fig.suptitle(f"{pair['left']} -> {pair['right']}: fit rms {s['rmsPx']:.2f} px, p95 {s['p95Px']:.2f} px, max {s['maxPx']:.2f} px, " + f"coverage {100 * s['coverage']:.0f}%", fontsize=11, weight="bold") + for i, (key, title) in enumerate([("warped_img", f"{pair['left']} warped into {pair['right']}"), ("right_img", pair["right"]), ("overlay_img", "overlay (magenta/green)")]): + ax = fig.add_subplot(1, 3, i + 1) + ax.imshow(pair[key]); ax.set_title(title, fontsize=9); ax.axis("off") + fig.text(0.06, 0.04, "H (left -> right) = " + np.array2string(np.asarray(pair["h"]), precision=5, suppress_small=True, max_line_width=200).replace("\n", " "), fontsize=7, family="monospace") + pdf.savefig(fig) + plt.close(fig) + + +def write_report(path: str, cal: RigCalibration, pairs: list[dict], notes: str = "") -> None: + with PdfPages(path) as pdf: + _text_page(pdf, f"KAMERA rig calibration: {cal.rig}", textwrap.dedent(f"""\ + flight: {cal.flight} + reference camera: {cal.reference} + cameras: {', '.join(sorted(cal.cameras))} + frames used: {int(cal.inlier.sum())} + """) + notes) + camera_page(pdf, cal) + rig_page(pdf, cal) + boresight_page(pdf, cal) + for pair in pairs: + homography_page(pdf, pair) + _text_page(pdf, "Error sources", ERROR_NOTES) diff --git a/kamera/calibration/rig.py b/kamera/calibration/rig.py new file mode 100644 index 00000000..c36d2e05 --- /dev/null +++ b/kamera/calibration/rig.py @@ -0,0 +1,180 @@ +"""Turn the rigged reconstruction into the deliverables: per-camera models (in the INS frame), +the rig geometry, and the INS boresight with its per-frame residuals.""" + +from __future__ import annotations + +import datetime +import os +from dataclasses import dataclass, field + +import numpy as np +import pycolmap as pc +import yaml +from scipy.spatial.transform import Rotation + +from kamera.calibration.flight import InsTrajectory +from kamera.calibration.sfm import robust_mean + + +@dataclass +class CameraCalibration: + name: str + width: int + height: int + K: np.ndarray + dist: np.ndarray + cam_from_rig: pc.Rigid3d + colmap_params: dict + frames: int + reproj_rms_px: float + + @property + def rig_from_cam(self) -> Rotation: + return Rotation.from_quat(self.cam_from_rig.rotation.quat).inv() + + @property + def center_in_rig(self) -> np.ndarray: + return self.cam_from_rig.inverse().translation + + +@dataclass +class RigCalibration: + rig: str + flight: str + reference: str + cameras: dict[str, CameraCalibration] + ins_from_rig: Rotation + lever_arm_m: np.ndarray + frame_times: np.ndarray + rotation_residual_deg: np.ndarray # (N, 3) rotvec of each frame's boresight about the mean, rig axes + position_residual_m: np.ndarray # (N, 3) rig origin relative to INS, body axes, minus the lever arm + ins_gap_s: np.ndarray # (N,) staleness of the INS sample behind each frame + inlier: np.ndarray = field(default_factory=lambda: np.zeros(0, bool)) + + def camera_quaternion(self, name: str) -> np.ndarray: + """(x, y, z, w) rotating camera vectors into the INS body frame, the KAMERA yaml convention.""" + return (self.ins_from_rig * self.cameras[name].rig_from_cam).as_quat() + + def camera_position(self, name: str) -> np.ndarray: + return self.lever_arm_m + self.ins_from_rig.apply(self.cameras[name].center_in_rig) + + +def per_camera_reprojection(model: pc.Reconstruction, names: dict) -> dict[str, list[float]]: + errors: dict[str, list[float]] = {} + for im in model.images.values(): + if not im.has_pose: + continue + cam = names[im.name][0] + for p in im.points2D: + if p.has_point3D(): + proj = im.project_point(model.points3D[p.point3D_id].xyz) + if proj is not None: + errors.setdefault(cam, []).append(float(np.linalg.norm(proj - p.xy))) + return errors + + +def calibrate_rig(model: pc.Reconstruction, names: dict, ins: InsTrajectory, reference: str, rig_name: str, flight: str) -> RigCalibration: + """Read the rig geometry out of the reconstruction and solve the INS boresight over all frames.""" + rig = next(iter(model.rigs.values())) + errors = per_camera_reprojection(model, names) + cameras = {} + frames_per_camera: dict[str, int] = {} + for im in model.images.values(): + if im.has_pose: + frames_per_camera[names[im.name][0]] = frames_per_camera.get(names[im.name][0], 0) + 1 + for im in model.images.values(): + name = names[im.name][0] + if name in cameras or not im.has_pose: + continue + cam = model.cameras[im.camera_id] + cam_from_rig = pc.Rigid3d() if rig.is_ref_sensor(cam.sensor_id) else rig.sensor_from_rig(cam.sensor_id) + fx, fy, cx, cy, k1, k2, p1, p2 = cam.params + cameras[name] = CameraCalibration( + name=name, width=cam.width, height=cam.height, K=np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]), + dist=np.array([k1, k2, p1, p2]), cam_from_rig=cam_from_rig, + colmap_params={"model": cam.model_name, "params": [float(v) for v in cam.params]}, + frames=frames_per_camera[name], reproj_rms_px=float(np.sqrt(np.mean(np.square(errors.get(name, [np.nan])))))) + + times, ins_from_rig, lever, gaps = [], [], [], [] + for frame in model.frames.values(): + if not frame.has_pose(): + continue + t = names[model.images[next(iter(frame.data_ids)).id].name][1] + world_from_rig = frame.rig_from_world.inverse() + pos, enu_from_body = ins.pose(t) + times.append(t) + ins_from_rig.append((enu_from_body.inv() * Rotation.from_quat(world_from_rig.rotation.quat)).as_quat()) + lever.append(enu_from_body.inv().apply(world_from_rig.translation - pos)) + gaps.append(ins.sample_gap(t)) + order = np.argsort(times) + rotations = Rotation.from_quat(np.array(ins_from_rig)[order]) + lever = np.array(lever)[order] + mean_rot, mean_lever, _, keep = robust_mean(rotations, lever) + return RigCalibration( + rig=rig_name, flight=flight, reference=reference, cameras=cameras, ins_from_rig=mean_rot, lever_arm_m=mean_lever, + frame_times=np.array(times)[order], rotation_residual_deg=(mean_rot.inv() * rotations).as_rotvec(degrees=True), + position_residual_m=lever - mean_lever, ins_gap_s=np.array(gaps)[order], inlier=keep) + + +def _floats(a) -> list[float]: + return [float(v) for v in np.asarray(a).ravel()] + + +def write_camera_yaml(cal: RigCalibration, name: str, path: str) -> None: + """KAMERA ``standard`` camera model plus rig and calibration provenance (loader ignores the extras).""" + cam = cal.cameras[name] + body = { + "model_type": "standard", "image_width": int(cam.width), "image_height": int(cam.height), + "fx": float(cam.K[0, 0]), "fy": float(cam.K[1, 1]), "cx": float(cam.K[0, 2]), "cy": float(cam.K[1, 2]), + "distortion_coefficients": _floats(cam.dist), + "camera_quaternion": _floats(cal.camera_quaternion(name)), "camera_position": _floats(cal.camera_position(name)), + "camera_name": name, "channel": name.split("_")[0], "modality": name.split("_")[1], "rig": cal.rig, + "reference_camera": cal.reference, + "cam_from_rig": {"quaternion_xyzw": _floats(cam.cam_from_rig.rotation.quat), "translation_m": _floats(cam.cam_from_rig.translation)}, + "colmap_camera": cam.colmap_params, + "calibration": {"flight": cal.flight, "generated": datetime.datetime.now(datetime.timezone.utc).date().isoformat(), "frames": cam.frames, + "reprojection_rms_px": cam.reproj_rms_px, "ifov_deg": float(np.degrees(1.0 / cam.K[0, 0]))}, + } + header = ("# KAMERA camera model. camera_quaternion (x, y, z, w) rotates camera vectors into the INS body\n" + "# frame; camera_position is the camera centre in that frame (metres). distortion_coefficients\n" + "# follow OpenCV (k1, k2, p1, p2). The extra keys record the rig calibration this came from.\n") + with open(path, "w") as f: + f.write(header) + yaml.safe_dump(body, f, sort_keys=False) + + +def write_rig_yaml(cal: RigCalibration, path: str) -> None: + ref = cal.cameras[cal.reference] + cams = {} + for name, cam in cal.cameras.items(): + rel = ref.rig_from_cam.inv() * cam.rig_from_cam + cams[name] = {"cam_from_rig": {"quaternion_xyzw": _floats(cam.cam_from_rig.rotation.quat), "translation_m": _floats(cam.cam_from_rig.translation)}, + "rotation_from_reference_deg": _floats(rel.as_rotvec(degrees=True)), "angle_from_reference_deg": float(np.degrees(rel.magnitude())), + "centre_in_rig_m": _floats(cam.center_in_rig), "frames": cam.frames, "reprojection_rms_px": cam.reproj_rms_px} + keep = cal.inlier + res = np.linalg.norm(cal.rotation_residual_deg[keep], axis=1) + body = { + "rig": cal.rig, "flight": cal.flight, "reference_camera": cal.reference, "generated": datetime.datetime.now(datetime.timezone.utc).date().isoformat(), + "ins_from_rig": {"quaternion_xyzw": _floats(cal.ins_from_rig.as_quat()), "rotvec_deg": _floats(cal.ins_from_rig.as_rotvec(degrees=True)), + "euler_zyx_deg": _floats(cal.ins_from_rig.as_euler("ZYX", degrees=True)), "lever_arm_m": _floats(cal.lever_arm_m)}, + "boresight_quality": {"frames": int(keep.sum()), "frames_rejected": int((~keep).sum()), + "rotation_scatter_deg": {"median": float(np.median(res)), "p90": float(np.percentile(res, 90)), "max": float(res.max())}, + "rotation_axis_std_deg": _floats(cal.rotation_residual_deg[keep].std(0)), + "lever_arm_std_m": _floats(cal.position_residual_m[keep].std(0)), + "ins_sample_gap_s": {"median": float(np.median(cal.ins_gap_s)), "max": float(cal.ins_gap_s.max())}}, + "cameras": cams, + } + with open(path, "w") as f: + f.write("# Rig geometry (cam_from_rig maps rig -> camera, COLMAP convention) and INS boresight (ins_from_rig maps rig -> INS body).\n") + yaml.safe_dump(body, f, sort_keys=False) + + +def write_outputs(cal: RigCalibration, out_dir: str) -> list[str]: + os.makedirs(out_dir, exist_ok=True) + paths = [] + for name in sorted(cal.cameras): + paths.append(os.path.join(out_dir, f"{cal.rig}_{name}.yaml")) + write_camera_yaml(cal, name, paths[-1]) + paths.append(os.path.join(out_dir, f"{cal.rig}_rig.yaml")) + write_rig_yaml(cal, paths[-1]) + return paths diff --git a/kamera/calibration/sfm.py b/kamera/calibration/sfm.py new file mode 100644 index 00000000..e864290b --- /dev/null +++ b/kamera/calibration/sfm.py @@ -0,0 +1,219 @@ +"""Structure from motion with pycolmap: features, INS position priors, matching, and the +two mapping passes (trivial rigs to bootstrap, then the full multi-sensor rig).""" + +from __future__ import annotations + +import os +import shutil + +import cv2 +import numpy as np +import PIL.Image +import pycolmap as pc +from scipy.spatial.transform import Rotation + +CAMERA_MODEL = "OPENCV" +# Only image headers are read here; the 100 MP RGB frames trip PIL's default bomb limit. +PIL.Image.MAX_IMAGE_PIXELS = None + + +def device() -> pc.Device: + return pc.Device.cuda if pc.has_cuda else pc.Device.cpu + + +def extract_features(db_path: str, image_dir: str, names: dict, focal_px: dict, max_image_size: int, num_features: int) -> None: + """SIFT per camera folder, seeding each camera with its modality's prior focal length.""" + for camera in sorted({c for c, _ in names.values()}): + image_names = sorted(n for n in names if n.startswith(camera + "/")) + w, h = PIL.Image.open(os.path.join(image_dir, image_names[0])).size + f = focal_px[camera.split("_")[1]] + reader = pc.ImageReaderOptions(camera_model=CAMERA_MODEL, camera_params=f"{f},{f},{w / 2},{h / 2},0,0,0,0") + # Each thread decodes a full-resolution image, so large sensors get fewer threads. + opts = pc.FeatureExtractionOptions(max_image_size=max_image_size, use_gpu=pc.has_cuda, num_threads=4 if w * h > 40e6 else 16) + opts.sift.max_num_features = num_features + pc.extract_features(db_path, image_dir, image_names=image_names, camera_mode=pc.CameraMode.PER_FOLDER, + reader_options=reader, extraction_options=opts, device=device()) + + +def write_pose_priors(db_path: str, names: dict, ins, std_m: float) -> None: + """Attach the INS ENU position at each image's trigger time as a COLMAP pose prior.""" + db = pc.Database.open(db_path) + for image in db.read_all_images(): + prior = pc.PosePrior(position=ins.pose(names[image.name][1])[0], position_covariance=np.eye(3) * std_m**2, + coordinate_system=pc.PosePriorCoordinateSystem.CARTESIAN) + prior.corr_data_id = pc.data_t(pc.sensor_t(pc.SensorType.CAMERA, image.camera_id), image.image_id) + db.write_pose_prior(prior) + db.close() + + +def match_features(db_path: str, max_distance_m: float, max_neighbors: int) -> None: + """Match each image against its spatial neighbours (from the priors), across all cameras.""" + pairing = pc.SpatialPairingOptions(max_num_neighbors=max_neighbors, max_distance=max_distance_m, ignore_z=True) + pc.match_spatial(db_path, matching_options=pc.FeatureMatchingOptions(use_gpu=pc.has_cuda), pairing_options=pairing, device=device()) + prune_cross_spectral(db_path) + + +def prune_cross_spectral(db_path: str) -> int: + """Drop thermal-to-visible pairs: SIFT cannot match them, so their few 'inliers' only mislead the mapper.""" + db = pc.Database.open(db_path) + is_ir = {im.image_id: im.name.split("/")[0].endswith("_ir") for im in db.read_all_images()} + pair_ids, _ = db.read_two_view_geometries() + dropped = 0 + for pair_id in pair_ids: + i, j = pc.pair_id_to_image_pair(pair_id) + if is_ir[i] != is_ir[j]: + db.delete_matches(i, j) + db.delete_two_view_geometry(i, j) + dropped += 1 + db.close() + return dropped + + +def mapping_options(refine_rig: bool) -> pc.IncrementalPipelineOptions: + # Colours are unused and extracting them re-decodes every 100 MP frame; keep memory down. + opts = pc.IncrementalPipelineOptions(use_prior_position=True, ba_refine_sensor_from_rig=refine_rig, extract_colors=False) + # Nadir aerial pairs subtend small angles; the default 16 deg init threshold rejects them. + opts.mapper.init_min_tri_angle = 4.0 + # Global BA every 30% of growth instead of 10%: it dominates runtime on thousands of frames. + opts.ba_global_frames_ratio = opts.ba_global_points_ratio = 1.3 + opts.ba_global_max_refinements = 2 + return opts + + +def run_mapping(db_path: str, image_dir: str, out_dir: str) -> dict[int, pc.Reconstruction]: + """Incremental mapping from scratch with every camera independent (trivial rigs).""" + shutil.rmtree(out_dir, ignore_errors=True) + os.makedirs(out_dir) + return pc.incremental_mapping(db_path, image_dir, out_dir, options=mapping_options(refine_rig=False)) + + +def rig_bundle_adjust(model: pc.Reconstruction, priors: list, refine_intrinsics: bool, max_iterations: int) -> str: + """Refine rig poses, sensor_from_rig and optionally intrinsics, anchored to the INS position priors.""" + opts = pc.BundleAdjustmentOptions(refine_sensor_from_rig=True, refine_rig_from_world=True, refine_principal_point=False, + refine_focal_length=refine_intrinsics, refine_extra_params=refine_intrinsics, print_summary=False) + opts.ceres.solver_options.max_num_iterations = max_iterations + config = pc.BundleAdjustmentConfig() + for image in model.images.values(): + if image.has_pose: + config.add_image(image.image_id) + prior_opts = pc.PosePriorBundleAdjustmentOptions() + prior_opts.alignment_ransac.max_error = 5.0 + summary = pc.create_pose_prior_bundle_adjuster(opts, prior_opts, config, priors, model).solve() + model.update_point_3d_errors() + return summary.brief_report() + + +def refine_rig(db_path: str, names: dict, init_dir: str, out_dir: str, max_iterations: int = 200) -> pc.Reconstruction: + """Pass 2: triangulate every image from the rig poses, bundle adjust, retriangulate, and bundle + adjust again with the intrinsics free. Returns the final model, also written to ``out_dir``.""" + shutil.rmtree(out_dir, ignore_errors=True) + os.makedirs(out_dir) + # The triangulator always colours points from disk; 8x8 stand-ins spare it the 100 MP frames. + image_dir = os.path.join(os.path.dirname(out_dir), "placeholders") + for name in names: + os.makedirs(os.path.dirname(os.path.join(image_dir, name)), exist_ok=True) + cv2.imwrite(os.path.join(image_dir, name), np.zeros((8, 8, 3), np.uint8)) + db = pc.Database.open(db_path) + priors = db.read_all_pose_priors() + db.close() + opts = mapping_options(refine_rig=True) + model = pc.Reconstruction(init_dir) + for refine_intrinsics in (False, True): + model = pc.triangulate_points(model, db_path, image_dir, out_dir, clear_points=True, options=opts, refine_intrinsics=False) + print(f" triangulated {model.num_points3D()} points, {model.compute_mean_reprojection_error():.2f} px", flush=True) + print(f" {rig_bundle_adjust(model, priors, refine_intrinsics, max_iterations)}", flush=True) + model.write(out_dir) + return model + + +def load_models(out_dir: str) -> dict[int, pc.Reconstruction]: + return {int(d): pc.Reconstruction(os.path.join(out_dir, d)) for d in sorted(os.listdir(out_dir)) if d.isdigit()} + + +def image_poses(models: dict[int, pc.Reconstruction], names: dict) -> dict[tuple[str, float], pc.Rigid3d]: + """``{(camera, time): cam_from_world}`` over every posed image in every model (all in INS ENU).""" + return {names[im.name]: im.cam_from_world() for r in models.values() for im in r.images.values() if im.has_pose} + + +def robust_mean(rotations: Rotation, translations: np.ndarray, cluster_deg: float = 1.0) -> tuple[Rotation, np.ndarray, np.ndarray, np.ndarray]: + """Mean rotation of the densest cluster and median translation over its members. + + Seeds from the sample with the most neighbours within ``cluster_deg``, so a wrongly + registered majority (a folded sub-model) cannot drag the estimate; then keeps everything + within 3x that cluster's median residual. Returns mean, translation, per-sample residual + angles (deg) and the inlier mask. + """ + q = rotations.as_quat() + pairwise = np.degrees(2.0 * np.arccos(np.clip(np.abs(q @ q.T), 0.0, 1.0))) + keep = pairwise[np.argmax((pairwise < cluster_deg).sum(1))] < cluster_deg + mean = rotations[keep].mean() + angles = np.degrees((mean.inv() * rotations).magnitude()) + keep = angles <= max(3.0 * np.median(angles[keep]), 0.05) + mean = rotations[keep].mean() + return mean, np.median(translations[keep], 0), np.degrees((mean.inv() * rotations).magnitude()), keep + + +def derive_rig(models: dict[int, pc.Reconstruction], names: dict, reference: str) -> dict[str, dict]: + """Initial ``cam_from_rig`` per camera from frames where it and the reference camera are both posed.""" + poses = image_poses(models, names) + rig = {} + for camera in sorted({c for c, _ in names.values()}): + rel = [poses[(camera, t)] * poses[(reference, t)].inverse() for (c, t) in poses if c == camera and (reference, t) in poses] + if len(rel) < 3: + raise RuntimeError(f"{camera}: only {len(rel)} frames shared with {reference}; cannot initialise the rig") + rot, trans, angles, keep = robust_mean(Rotation.from_quat([x.rotation.quat for x in rel]), np.array([x.translation for x in rel])) + rig[camera] = {"cam_from_rig": pc.Rigid3d(pc.Rotation3d(rot.as_quat()), trans), "frames": int(keep.sum()), + "rotation_scatter_deg": float(np.median(angles[keep])), "translation_std_m": np.array([x.translation for x in rel])[keep].std(0)} + return rig + + +def model_cameras(models: dict[int, pc.Reconstruction], names: dict) -> dict[str, pc.Camera]: + """Refined intrinsics per camera name from whichever model registered it.""" + cams = {} + for r in models.values(): + for im in r.images.values(): + if im.has_pose: + cams.setdefault(names[im.name][0], r.cameras[im.camera_id]) + return cams + + +def rigged_model(db_path: str, models: dict[int, pc.Reconstruction], names: dict, reference: str, out_dir: str) -> pc.Reconstruction: + """Put the rig onto the largest pass-1 model and complete its frames with every sensor's image. + + Writes the rig and frames into the database, copies each registered frame's pose from the + model, and adds the images (IR, typically) that pass 1 never posed: they inherit their pose + from the frame through the initial ``cam_from_rig``. The result is written to ``out_dir`` as + the starting point for the rig-refining mapping pass. + """ + rig = derive_rig(models, names, reference) + cameras = model_cameras(models, names) + config = pc.RigConfig(cameras=[ + pc.RigConfigCamera(ref_sensor=(name == reference), image_prefix=name + "/", camera=cameras[name], + cam_from_rig=None if name == reference else rig[name]["cam_from_rig"]) + for name in [reference] + sorted(set(rig) - {reference})]) + model = largest(models) + for cam in cameras.values(): + if not model.exists_camera(cam.camera_id): + model.add_camera(cam) + db = pc.Database.open(db_path) + db.clear_frames() + db.clear_rigs() + pc.apply_rig_config([config], db, model) + poses = {f.frame_id: f.rig_from_world for f in model.frames.values() if f.has_pose()} + frames = db.read_all_frames() + for frame in frames: + if frame.frame_id in poses: + frame.rig_from_world = poses[frame.frame_id] + model.set_rigs_and_frames(db.read_all_rigs(), frames) + for image in db.read_all_images(): + if not model.exists_image(image.image_id): + model.add_image(image) + db.close() + shutil.rmtree(out_dir, ignore_errors=True) + os.makedirs(out_dir) + model.write(out_dir) + return model + + +def largest(models: dict[int, pc.Reconstruction]) -> pc.Reconstruction: + return max(models.values(), key=lambda r: r.num_reg_images()) diff --git a/kamera/colmap_processing/camera_models.py b/kamera/colmap_processing/camera_models.py index c1211f85..dc745ae8 100644 --- a/kamera/colmap_processing/camera_models.py +++ b/kamera/colmap_processing/camera_models.py @@ -4,37 +4,28 @@ import cv2 import time import yaml -from scipy.interpolate import interp1d, RectBivariateSpline -from scipy.optimize import fmin, fminbound, minimize +from scipy.interpolate import RectBivariateSpline +from scipy.spatial.transform import Rotation import PIL from math import sqrt -import matplotlib.pyplot as plt - # Repository imports. -from kamera.colmap_processing.image_renderer import stitch_images from kamera.colmap_processing.platform_pose import PlatformPoseFixed from kamera.colmap_processing.geo_conversions import enu_to_llh, llh_to_enu from kamera.colmap_processing.rotations import ( - euler_from_quaternion, - quaternion_multiply, - quaternion_matrix, - quaternion_from_euler, - quaternion_inverse, - quaternion_from_matrix, - ) -import kamera.colmap_processing.dp as dp + quaternion_matrix, + quaternion_inverse, + quaternion_from_matrix, +) def to_str(v): - """Convert numerical values (scalar or float) to string for saving to yaml - - """ + """Convert numerical values (scalar or float) to string for saving to yaml""" if isinstance(v, np.ndarray): v = v.tolist() else: return str(v) - if isinstance(v, list): + if isinstance(v, list): if len(v) > 1: return repr(list(v)) else: @@ -57,13 +48,18 @@ class CamToCamTform(object): the view such that we can ignore parallax during transformation. """ + def __init__(self, src_cm, dst_cm): - if src_cm.platform_pose_provider != dst_cm.platform_pose_provider and \ - not isinstance(src_cm.platform_pose_provider, PlatformPoseFixed) and \ - not isinstance(dst_cm.platform_pose_provider, PlatformPoseFixed): - raise Exception('src_cm and dst_cm must have the same ' - 'platform_pose_provider indicating that the cameras ' - 'are rigidly mounted to the same platform') + if ( + src_cm.platform_pose_provider != dst_cm.platform_pose_provider + and not isinstance(src_cm.platform_pose_provider, PlatformPoseFixed) + and not isinstance(dst_cm.platform_pose_provider, PlatformPoseFixed) + ): + raise Exception( + "src_cm and dst_cm must have the same " + "platform_pose_provider indicating that the cameras " + "are rigidly mounted to the same platform" + ) self._src_cm = src_cm self._dst_cm = dst_cm @@ -84,11 +80,11 @@ def fit(self, tol=0.1, k=1): # the number of tiles. N = 10 while True: - dx = np.sqrt(w*h/N) - x = np.linspace(0, w, int(np.ceil(w/dx))) - y = np.linspace(0, h, int(np.ceil(h/dx))) - X,Y = np.meshgrid(x, y) - points = np.vstack([X.ravel(),Y.ravel()]) + dx = np.sqrt(w * h / N) + x = np.linspace(0, w, int(np.ceil(w / dx))) + y = np.linspace(0, h, int(np.ceil(h / dx))) + X, Y = np.meshgrid(x, y) + points = np.vstack([X.ravel(), Y.ravel()]) out_points = self.tform_rigorous(points) @@ -99,14 +95,14 @@ def fit(self, tol=0.1, k=1): self._model_y = RectBivariateSpline(x, y, out_y.T, kx=k, ky=k) # Test - x = np.linspace(0, w, int(np.ceil(w/dx))*2) - y = np.linspace(0, h, int(np.ceil(h/dx))*2) - X,Y = np.meshgrid(x, y) - points = np.vstack([X.ravel(),Y.ravel()]) + x = np.linspace(0, w, int(np.ceil(w / dx)) * 2) + y = np.linspace(0, h, int(np.ceil(h / dx)) * 2) + X, Y = np.meshgrid(x, y) + points = np.vstack([X.ravel(), Y.ravel()]) points_out = self.tform(points) points_out_truth = self.tform_rigorous(points) - err = np.sqrt(np.sum((points_out_truth - points_out)**2, 0)) + err = np.sqrt(np.sum((points_out_truth - points_out) ** 2, 0)) if np.max(err) < tol or N > 2000: break @@ -123,8 +119,8 @@ def tform(self, points): :rtype: numpy.ndarray of size (2,n) """ - if not hasattr(self, '_model_x'): - raise Exception('Must call \'fit\' before calling \'tform\'') + if not hasattr(self, "_model_x"): + raise Exception("Must call 'fit' before calling 'tform'") out_points = np.zeros_like(points) out_points[0] = self._model_x.ev(points[0], points[1]) @@ -148,7 +144,7 @@ def tform_rigorous(self, points): # We don't have a world model to intersect with, so we send it out # to "infinity". - point = (ray_pos + ray_dir*1e5) + point = ray_pos + ray_dir * 1e5 return self._dst_cm.project(point, -np.inf) @@ -182,35 +178,32 @@ def rt_from_quat_pos(position, quaternion): # system. So, we invert each quaternion. quaternion = quaternion_inverse(quaternion) - p = quaternion_matrix(quaternion) # R - p[:3,3] = -np.dot(p[:3,:3], position) # T + p = quaternion_matrix(quaternion) # R + p[:3, 3] = -np.dot(p[:3, :3], position) # T return p def load_from_file(filename, platform_pose_provider=None): - """Load from configuration yaml for any of the Camera subclasses. - - """ - with open(filename, 'r') as f: + """Load from configuration yaml for any of the Camera subclasses.""" + with open(filename, "r") as f: calib = yaml.safe_load(f) - if calib['model_type'] == 'standard': + if calib["model_type"] == "standard": return StandardCamera.load_from_file(filename, platform_pose_provider) - if calib['model_type'] == 'rolling_shutter': + if calib["model_type"] == "rolling_shutter": return RollingShutterCamera.load_from_file(filename, platform_pose_provider) - if calib['model_type'] == 'depth': + if calib["model_type"] == "depth": return DepthCamera.load_from_file(filename, platform_pose_provider) - if calib['model_type'] == 'static': + if calib["model_type"] == "static": return GeoStaticCamera.load_from_file(filename, platform_pose_provider) raise Exception() -def ray_intersect_plane(plane_point, plane_normal, ray_pos, ray_dir, - epsilon=1e-6): +def ray_intersect_plane(plane_point, plane_normal, ray_pos, ray_dir, epsilon=1e-6): """From https://rosettacode.org/wiki/Find_the_intersection_of_a_line_with_a_plane#Python :param ray_pos: Ray starting positions. @@ -260,6 +253,7 @@ class Camera(object): any time-varying parameters (e.g., navigation coordinate system state). """ + def __init__(self, width, height, platform_pose_provider=None): """ :param width: Width of the image provided by the imaging sensor, @@ -285,6 +279,7 @@ def __init__(self, width, height, platform_pose_provider=None): self._platform_pose_provider = platform_pose_provider self._depth_map = None + self.model_type = "standard" @property def width(self): @@ -312,35 +307,35 @@ def depth_map(self, value): @property def platform_pose_provider(self): - """Instance of a subclass of NavStateProvider - - """ + """Instance of a subclass of NavStateProvider""" return self._platform_pose_provider @platform_pose_provider.setter def platform_pose_provider(self, value): - """Instance of a subclass of NavStateProvider - - """ + """Instance of a subclass of NavStateProvider""" self._platform_pose_provider = value def __str__(self): - string = [''.join(['image_width: ',repr(self._width),'\n'])] - string.append(''.join(['image_height: ',repr(self._height),'\n'])) - string.append(''.join(['platform_pose_provider: ', - repr(self._platform_pose_provider)])) + string = ["".join(["image_width: ", repr(self._width), "\n"])] + string.append("".join(["image_height: ", repr(self._height), "\n"])) + string.append( + "".join(["platform_pose_provider: ", repr(self._platform_pose_provider)]) + ) try: # Some time-dependent cameras may not have a queue of values. - string.append(''.join(['\nifov: ', - '({:.6g},{:.6g})'.format(*self.ifov(np.inf)), - '\n'])) - string.append(''.join(['fov: ', - '({:.6},{:.6},{:.6})'.format(*self.fov(np.inf))])) + string.append( + "".join( + ["\nifov: ", "({:.6g},{:.6g})".format(*self.ifov(np.inf)), "\n"] + ) + ) + string.append( + "".join(["fov: ", "({:.6},{:.6},{:.6})".format(*self.fov(np.inf))]) + ) except: pass - return ''.join(string) + return "".join(string) def __repr__(self): return self.__str__() @@ -364,7 +359,7 @@ def get_param_array(self, param_list): """ params = np.zeros(0) for param in param_list: - params = np.hstack([params,getattr(self, param)]) + params = np.hstack([params, getattr(self, param)]) return params @@ -381,8 +376,8 @@ def set_param_array(self, param_list, params): ind = 0 for param in param_list: p0 = getattr(self, param) - if hasattr(p0, '__len__') and len(p0) > 1: - setattr(self, param, params[ind:ind+len(p0)]) + if hasattr(p0, "__len__") and len(p0) > 1: + setattr(self, param, params[ind : ind + len(p0)]) ind += len(p0) else: setattr(self, param, params[ind]) @@ -483,8 +478,10 @@ def unproject_to_llh(self, points, t=None, cov=None): h0 = self.platform_pose_provider.h0 if lat0 is None or lon0 is None or h0 is None: - raise Exception('\'platform_pose_provider\' must have \'lat0\', ' - '\'lon0\', and \'ho\' defined.') + raise Exception( + "'platform_pose_provider' must have 'lat0', " + "'lon0', and 'ho' defined." + ) points = np.array(points) if points.ndim == 1: @@ -493,13 +490,13 @@ def unproject_to_llh(self, points, t=None, cov=None): else: was_1d = False points = np.array(points) - points = np.reshape(points, (2,-1)) + points = np.reshape(points, (2, -1)) llh = [] geo_cov = [] for i in range(points.shape[1]): - xyz = self.unproject_to_depth(points[:,i], t) + xyz = self.unproject_to_depth(points[:, i], t) if np.all(np.isfinite(xyz)): llh.append(enu_to_llh(xyz[0], xyz[1], xyz[2], lat0, lon0, h0)) else: @@ -508,16 +505,14 @@ def unproject_to_llh(self, points, t=None, cov=None): if cov is not None: # Sample 10 random points and project each into enu coordinate # system - rpoints = np.random.multivariate_normal(points[:,i], cov[i], - 20) + rpoints = np.random.multivariate_normal(points[:, i], cov[i], 20) # Points must be inside image. - ind = np.logical_and(rpoints[:,0] > 0, rpoints[:,1] > 0) - ind = np.logical_and(ind, rpoints[:,0] < self.width) - ind = np.logical_and(ind, rpoints[:,1] < self.height) + ind = np.logical_and(rpoints[:, 0] > 0, rpoints[:, 1] > 0) + ind = np.logical_and(ind, rpoints[:, 0] < self.width) + ind = np.logical_and(ind, rpoints[:, 1] < self.height) rpoints = rpoints[ind] - enu_pts = ([self.unproject_to_depth(_, t).ravel() - for _ in rpoints]) + enu_pts = [self.unproject_to_depth(_, t).ravel() for _ in rpoints] enu_pts = [_ for _ in enu_pts if np.all(np.isfinite(enu_pts))] @@ -527,7 +522,7 @@ def unproject_to_llh(self, points, t=None, cov=None): enu_pts = np.array(enu_pts) - if xyz[0]**2 + xyz[1]**2 > 6250000: + if xyz[0] ** 2 + xyz[1] ** 2 > 6250000: # If the point is further than 2.5km from the camera, we # want the covariance defined in an east/north/up # coordinate system centered at xyz, the most-likely @@ -538,12 +533,17 @@ def unproject_to_llh(self, points, t=None, cov=None): llh0 = enu_to_llh(xyz[0], xyz[1], xyz[2], lat0, lon0, h0) for i in range(len(enu_pts)): - llhi = enu_to_llh(enu_pts[i,0], enu_pts[i,1], - enu_pts[i,2], llh0[0], llh0[1], - llh0[2]) - enu_pts[i,:] = llh_to_enu(llhi[0], llhi[1], llhi[2], - llh0[0], llh0[1], llh0[2]) - + llhi = enu_to_llh( + enu_pts[i, 0], + enu_pts[i, 1], + enu_pts[i, 2], + llh0[0], + llh0[1], + llh0[2], + ) + enu_pts[i, :] = llh_to_enu( + llhi[0], llhi[1], llhi[2], llh0[0], llh0[1], llh0[2] + ) geo_cov.append(np.cov(enu_pts.T)) @@ -553,7 +553,7 @@ def unproject_to_llh(self, points, t=None, cov=None): llh = np.array(llh).T if cov is not None: - return llh,geo_cov + return llh, geo_cov else: return llh @@ -568,25 +568,34 @@ def points_along_image_border(self, num_points=4): :rtype: numpy.ndarry with shape (3,n) """ - perimeter = 2*(self.height + self.width) - ds = num_points/float(perimeter) - xn = np.max([2,int(ds*self.width)]) - yn = np.max([2,int(ds*self.height)]) + perimeter = 2 * (self.height + self.width) + ds = num_points / float(perimeter) + xn = np.max([2, int(ds * self.width)]) + yn = np.max([2, int(ds * self.height)]) x = np.linspace(0, self.width, xn) y = np.linspace(0, self.height, yn)[1:-1] - pts = np.vstack([np.hstack([x, - np.full(len(y), self.width, - dtype=np.float64), - x[::-1], - np.zeros(len(y))]), - np.hstack([np.zeros(xn), - y, - np.full(xn, self.height, - dtype=np.float64), - y[::-1]])]) + pts = np.vstack( + [ + np.hstack( + [ + x, + np.full(len(y), self.width, dtype=np.float64), + x[::-1], + np.zeros(len(y)), + ] + ), + np.hstack( + [ + np.zeros(xn), + y, + np.full(xn, self.height, dtype=np.float64), + y[::-1], + ] + ), + ] + ) return pts - def ifov(self, t=None): """Instantaneous field of view (ifov) at the image center. @@ -603,13 +612,13 @@ def ifov(self, t=None): if t is None: t = time.time() - cx = self.width/2 - cy = self.height/2 - ray1 = self.unproject([cx,cy], t)[1] + cx = self.width / 2 + cy = self.height / 2 + ray1 = self.unproject([cx, cy], t)[1] ray1 /= np.sqrt(np.sum(ray1**2, 0)) - ray2 = self.unproject([cx,cy+1], t)[1] + ray2 = self.unproject([cx, cy + 1], t)[1] ray2 /= np.sqrt(np.sum(ray2**2, 0)) - ray3 = self.unproject([cx+1,cy], t)[1] + ray3 = self.unproject([cx + 1, cy], t)[1] ray3 /= np.sqrt(np.sum(ray3**2, 0)) ifovx = np.arccos(np.dot(ray1.ravel(), ray3.ravel())) @@ -632,33 +641,31 @@ def fov(self, t=None): if t is None: t = time.time() - cx = self.width/2 - cy = self.height/2 + cx = self.width / 2 + cy = self.height / 2 - ray1 = self.unproject([cx,0], t)[1] + ray1 = self.unproject([cx, 0], t)[1] ray1 /= np.sqrt(np.sum(ray1**2, 0)) - ray2 = self.unproject([cx,self.height], t)[1] + ray2 = self.unproject([cx, self.height], t)[1] ray2 /= np.sqrt(np.sum(ray2**2, 0)) - fov_v = np.arccos(np.dot(ray1.ravel(), ray2.ravel()))*180/np.pi + fov_v = np.arccos(np.dot(ray1.ravel(), ray2.ravel())) * 180 / np.pi - ray1 = self.unproject([0,cy], t)[1] + ray1 = self.unproject([0, cy], t)[1] ray1 /= np.sqrt(np.sum(ray1**2, 0)) ray2 = self.unproject([self.width, cy], t)[1] ray2 /= np.sqrt(np.sum(ray2**2, 0)) - fov_h = np.arccos(np.dot(ray1.ravel(), ray2.ravel()))*180/np.pi + fov_h = np.arccos(np.dot(ray1.ravel(), ray2.ravel())) * 180 / np.pi - ray1 = self.unproject([0,0], t)[1] + ray1 = self.unproject([0, 0], t)[1] ray1 /= np.sqrt(np.sum(ray1**2, 0)) - ray2 = self.unproject([self.width,self.height], t)[1] + ray2 = self.unproject([self.width, self.height], t)[1] ray2 /= np.sqrt(np.sum(ray2**2, 0)) - fov_d = np.arccos(np.dot(ray1.ravel(), ray2.ravel()))*180/np.pi + fov_d = np.arccos(np.dot(ray1.ravel(), ray2.ravel())) * 180 / np.pi return fov_h, fov_v, fov_d def unproject_to_depth(self, points, t=None): - """See Camera.unproject_to_depth documentation. - - """ + """See Camera.unproject_to_depth documentation.""" points = self._unproject_to_depth(points, self.depth_map, t=t) return points @@ -672,7 +679,7 @@ def save_depth_viz(self, fname): depth_image[depth_image < 0] = 0 v = vmax - vmin if v > 0: - depth_image /= v/255 + depth_image /= v / 255 depth_image = np.round(depth_image).astype(np.uint8) @@ -706,14 +713,15 @@ class StandardCamera(Camera): :type dist: numpy.ndarray """ - def __init__(self, width, height, K, dist, cam_pos, cam_quat, - platform_pose_provider=None): + + def __init__( + self, width, height, K, dist, cam_pos, cam_quat, platform_pose_provider=None + ): """ See additional documentation from base class above. """ - super(StandardCamera, self).__init__(width, height, - platform_pose_provider) + super(StandardCamera, self).__init__(width, height, platform_pose_provider) self._K = np.array(K, dtype=np.float64) self._dist = np.atleast_1d(dist).astype(np.float32) @@ -721,76 +729,72 @@ def __init__(self, width, height, K, dist, cam_pos, cam_quat, self._cam_quat = np.array(cam_quat, dtype=np.float64) self._cam_quat /= np.linalg.norm(self._cam_quat) self._min_ray_cos = None + self.model_type = "standard" def __str__(self): - string = ['model_type: standard\n'] + string = [f"model_type: {self.model_type}\n"] string.append(super(StandardCamera, self).__str__()) - string.append('\n') - string.append(''.join(['fx: ',repr(self._K[0,0]),'\n'])) - string.append(''.join(['fy: ',repr(self._K[1,1]),'\n'])) - string.append(''.join(['cx: ',repr(self._K[0,2]),'\n'])) - string.append(''.join(['cy: ',repr(self._K[1,2]),'\n'])) - string.append(''.join(['distortion_coefficients: ', - repr(tuple(self._dist)), - '\n'])) - string.append(''.join(['camera_quaternion: ', - repr(tuple(self._cam_quat)),'\n'])) - string.append(''.join(['camera_position: ',repr(tuple(self._cam_pos)), - '\n'])) - return ''.join(string) + string.append("\n") + string.append("".join(["fx: ", repr(self._K[0, 0]), "\n"])) + string.append("".join(["fy: ", repr(self._K[1, 1]), "\n"])) + string.append("".join(["cx: ", repr(self._K[0, 2]), "\n"])) + string.append("".join(["cy: ", repr(self._K[1, 2]), "\n"])) + string.append( + "".join(["distortion_coefficients: ", repr(tuple(self._dist)), "\n"]) + ) + string.append( + "".join(["camera_quaternion: ", repr(tuple(self._cam_quat)), "\n"]) + ) + string.append("".join(["camera_position: ", repr(tuple(self._cam_pos)), "\n"])) + return "".join(string) @classmethod def load_from_file(cls, filename, platform_pose_provider=None): - """See base class Camera documentation. - - """ - with open(filename, 'r') as f: + """See base class Camera documentation.""" + with open(filename, "r") as f: calib = yaml.safe_load(f) - assert calib['model_type'] == 'standard' + assert calib["model_type"] == "standard" # fill in CameraInfo fields - width = int(calib['image_width']) - height = int(calib['image_height']) - dist = calib['distortion_coefficients'] + width = int(calib["image_width"]) + height = int(calib["image_height"]) + dist = calib["distortion_coefficients"] - if dist == 'None': + if dist == "None": dist = np.zeros(4) dist = np.float64(dist) - fx = np.float64(calib['fx']) - fy = np.float64(calib['fy']) - cx = np.float64(calib['cx']) - cy = np.float64(calib['cy']) - K = np.array([[fx,0,cx],[0,fy,cy],[0,0,1]]) + fx = np.float64(calib["fx"]) + fy = np.float64(calib["fy"]) + cx = np.float64(calib["cx"]) + cy = np.float64(calib["cy"]) + K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) - cam_quat = calib['camera_quaternion'] - cam_pos = calib['camera_position'] + cam_quat = calib["camera_quaternion"] + cam_pos = calib["camera_position"] - return cls(width, height, K, dist, cam_pos, cam_quat, - platform_pose_provider) + return cls(width, height, K, dist, cam_pos, cam_quat, platform_pose_provider) @classmethod def load_from_krtd(cls, filename): - """See base class Camera documentation. - - """ + """See base class Camera documentation.""" data = [] with open(filename) as f: for line in f.readlines(): - data.append(line.strip('\n')) + data.append(line.strip("\n")) - fx = float(data[0].split(' ' )[0]) - fy = float(data[1].split(' ' )[1]) - cx = float(data[0].split(' ' )[2]) - cy = float(data[1].split(' ' )[2]) + fx = float(data[0].split(" ")[0]) + fy = float(data[1].split(" ")[1]) + cx = float(data[0].split(" ")[2]) + cy = float(data[1].split(" ")[2]) R = np.zeros((3, 3)) for i in range(3): - R[i] = [float(d) for d in data[4 + i].split(' ')] + R[i] = [float(d) for d in data[4 + i].split(" ")] - tvec = [float(d) for d in data[8].split(' ')] + tvec = [float(d) for d in data[8].split(" ")] cam_pos = -np.dot(R.T, tvec).ravel() @@ -800,59 +804,102 @@ def load_from_krtd(cls, filename): width = None height = None - dist = [float(d) for d in data[10].split(' ') if len(d) > 0] + dist = [float(d) for d in data[10].split(" ") if len(d) > 0] dist = np.array(dist) - K = np.array([[fx, 0, cx], [0, fy, cy],[0, 0, 1]]) + K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) return cls(width, height, K, dist, cam_pos, cam_quat) def save_to_file(self, filename): - """See base class Camera documentation. - - """ - with open(filename, 'w') as f: - f.write(''.join(['# The type of camera model.\n', - 'model_type: standard\n\n', - '# Image dimensions\n'])) - - f.write(''.join(['image_width: ',to_str(self.width),'\n'])) - f.write(''.join(['image_height: ',to_str(self.height),'\n\n'])) - - f.write('# Focal length along the image\'s x-axis.\n') - f.write(''.join(['fx: ',to_str(self.K[0,0]),'\n\n'])) - - f.write('# Focal length along the image\'s y-axis.\n') - f.write(''.join(['fy: ',to_str(self.K[1,1]),'\n\n'])) - - f.write('# Principal point is located at (cx,cy).\n') - f.write(''.join(['cx: ',to_str(self.K[0,2]),'\n'])) - f.write(''.join(['cy: ',to_str(self.K[1,2]),'\n\n'])) - - f.write(''.join(['# Distortion coefficients following OpenCv\'s ', - 'convention\n'])) + """See base class Camera documentation.""" + with open(filename, "w") as f: + f.write( + "".join( + [ + "# The type of camera model.\n", + f"model_type: {self.model_type}\n\n", + "# Image dimensions\n", + ] + ) + ) + + f.write("".join(["image_width: ", to_str(self.width), "\n"])) + f.write("".join(["image_height: ", to_str(self.height), "\n\n"])) + + f.write("# Focal length along the image's x-axis.\n") + f.write("".join(["fx: ", to_str(self.K[0, 0]), "\n\n"])) + + f.write("# Focal length along the image's y-axis.\n") + f.write("".join(["fy: ", to_str(self.K[1, 1]), "\n\n"])) + + f.write("# Principal point is located at (cx,cy).\n") + f.write("".join(["cx: ", to_str(self.K[0, 2]), "\n"])) + f.write("".join(["cy: ", to_str(self.K[1, 2]), "\n\n"])) + + f.write( + "".join( + ["# Distortion coefficients following OpenCv's ", "convention\n"] + ) + ) dist = self.dist if np.all(dist == 0): - dist = 'None' - - f.write(''.join(['distortion_coefficients: ', - to_str(self.dist),'\n\n'])) - - f.write(''.join(['# Quaternion (x, y, z, w) specifying the ', - 'orientation of the camera relative to\n# the ', - 'platform coordinate system. The quaternion ', - 'represents a coordinate\n# system rotation that ', - 'takes the platform coordinate system and ', - 'rotates it\n# into the camera coordinate ', - 'system.\ncamera_quaternion: ', - to_str(self.cam_quat),'\n\n'])) - - f.write(''.join(['# Position of the camera\'s center of ', - 'projection within the navigation\n# coordinate ', - 'system.\n', - 'camera_position: ', to_str(self.cam_pos), - '\n\n'])) + dist = "None" + + f.write("".join(["distortion_coefficients: ", to_str(self.dist), "\n\n"])) + + f.write( + "".join( + [ + "# Quaternion (x, y, z, w) specifying the ", + "orientation of the camera relative to\n# the ", + "platform coordinate system. The quaternion ", + "represents a coordinate\n# system rotation that ", + "takes the platform coordinate system and ", + "rotates it\n# into the camera coordinate ", + "system.\ncamera_quaternion: ", + to_str(self.cam_quat), + "\n\n", + ] + ) + ) + + f.write( + "".join( + [ + "# Position of the camera's center of ", + "projection within the navigation\n# coordinate ", + "system.\n", + "camera_position: ", + to_str(self.cam_pos), + "\n\n", + ] + ) + ) + + def save_to_krtd(self, filename): + """Write a single camera in ASCII KRTD format to the file object. + + Args: + camera (list[np.ndarray]): A length-4 of type (K, R, t, d) + fout (str | os.PathLike): _description_ + """ + K = self.K + R = Rotation.from_quat(self.cam_quat).as_matrix() + t = self.cam_pos + d = self.dist + t = np.reshape(np.array(t), 3) + with open(filename, "w") as fout: + fout.write("%.12g %.12g %.12g\n" % tuple(K.tolist()[0])) + fout.write("%.12g %.12g %.12g\n" % tuple(K.tolist()[1])) + fout.write("%.12g %.12g %.12g\n\n" % tuple(K.tolist()[2])) + fout.write("%.12g %.12g %.12g\n" % tuple(R.tolist()[0])) + fout.write("%.12g %.12g %.12g\n" % tuple(R.tolist()[1])) + fout.write("%.12g %.12g %.12g\n\n" % tuple(R.tolist()[2])) + fout.write("%.12g %.12g %.12g\n\n" % tuple(t.tolist())) + for v in d: + fout.write("%.12g " % v) @property def K(self): @@ -860,77 +907,74 @@ def K(self): @property def K_no_skew(self): - """Returns a compact version of K assuming no skew. - - """ + """Returns a compact version of K assuming no skew.""" K = self.K - return np.array([K[0,0],K[1,1],K[0,2],K[1,2]]) + return np.array([K[0, 0], K[1, 1], K[0, 2], K[1, 2]]) @K_no_skew.setter def K_no_skew(self, value): - """fx, fy, cx, cy - """ - K = np.zeros((3,3), dtype=np.float64) - K[0,0] = value[0] - K[1,1] = value[1] - K[0,2] = value[2] - K[1,2] = value[3] + """fx, fy, cx, cy""" + K = np.zeros((3, 3), dtype=np.float64) + K[0, 0] = value[0] + K[1, 1] = value[1] + K[0, 2] = value[2] + K[1, 2] = value[3] self._K = K self._min_ray_cos = None @property def focal_length(self): - return self._K[0,0] + return self._K[0, 0] @focal_length.setter def focal_length(self, value): - self._K[0,0] = value - self._K[1,1] = value + self._K[0, 0] = value + self._K[1, 1] = value self._min_ray_cos = None @property def fx(self): - return self._K[0,0] + return self._K[0, 0] @property def fy(self): - return self._K[1,1] + return self._K[1, 1] @fx.setter def fx(self, value): - self._K[0,0] = value + self._K[0, 0] = value self._min_ray_cos = None @fy.setter def fy(self, value): - self._K[1,1] = value + self._K[1, 1] = value self._min_ray_cos = None @property def cx(self): - return self._K[0,2] + return self._K[0, 2] @property def cy(self): - return self._K[1,2] + return self._K[1, 2] @cx.setter def cx(self, value): - self._K[0,2] = value + self._K[0, 2] = value self._min_ray_cos = None @cy.setter def cy(self, value): - self._K[1,2] = value + self._K[1, 2] = value self._min_ray_cos = None @property def aspect_ratio(self): - return self._K[0,0]/self._K[1,1] + return self._K[0, 0] / self._K[1, 1] @aspect_ratio.setter def aspect_ratio(self, value): - self._K[1,1] = self._K[0,0]*value + self._K[1, 1] = self._K[0, 0] * value @property def dist(self): @@ -948,6 +992,10 @@ def dist(self, value): def cam_pos(self): return self._cam_pos + @cam_pos.setter + def cam_pos(self, value): + self._cam_pos = value + @property def cam_quat(self): return self._cam_quat @@ -977,8 +1025,8 @@ def min_ray_cos(self): ray0 = self.unproject(center, t, normalize_ray_dir=True)[1].ravel() w, h = self.width, self.height self._min_ray_cos = 1 - for x,y in [[0,0],[w,0],[w,h],[0,h]]: - ray1 = self.unproject([x, y], t, normalize_ray_dir=True)[1] + for x, y in [[0, 0], [w, 0], [w, h], [0, h]]: + ray1 = self.unproject([x, y], t, normalize_ray_dir=True)[1] ray1 = ray1.ravel() ray_cosi = np.dot(ray0, ray1) self._min_ray_cos = np.minimum(self._min_ray_cos, ray_cosi) @@ -988,8 +1036,7 @@ def min_ray_cos(self): return self._min_ray_cos def update_intrinsics(self, K=None, cam_quat=None, dist=None): - """ - """ + """ """ if K is not None: self._K = K.astype(np.float64) if cam_quat is not None: @@ -1022,9 +1069,7 @@ def get_camera_pose(self, t=None): return np.dot(p_cam, p_ins)[:3] def project(self, points, t=None): - """See Camera.project documentation. - - """ + """See Camera.project documentation.""" points = np.array(points, dtype=np.float64) if points.ndim == 1: points = np.atleast_2d(points).T @@ -1033,53 +1078,50 @@ def project(self, points, t=None): t = time.time() pose_mat = self.get_camera_pose(t) - pose_mat = np.vstack((pose_mat, np.array([0,0,0,1]))) + pose_mat = np.vstack((pose_mat, np.array([0, 0, 0, 1]))) # Project rays into camera coordinate system. - rvec = cv2.Rodrigues(pose_mat[:3,:3])[0].ravel() + rvec = cv2.Rodrigues(pose_mat[:3, :3])[0].ravel() tvec = pose_mat[:3, 3] - im_pts = cv2.projectPoints(points.T, rvec, tvec, self._K, - self._dist)[0] + im_pts = cv2.projectPoints(points.T, rvec, tvec, self._K, self._dist)[0] im_pts = np.squeeze(im_pts, 1).T # Make homogeneous points = np.vstack([points, np.ones(points.shape[1])]) points = np.dot(pose_mat, points) - #points /= np.sqrt(np.sum(points**2, 0)) + # points /= np.sqrt(np.sum(points**2, 0)) points /= points[3, :] # Add the 1e-8 to avoid "falling off the focal plane" due to rounding # error. # ind = points[2] <= self.min_ray_cos - #im_pts[:, ind] = np.nan + # im_pts[:, ind] = np.nan return im_pts def unproject(self, points, t=None, normalize_ray_dir=True): - """See Camera.unproject documentation. - - """ + """See Camera.unproject documentation.""" points = np.array(points, dtype=np.float64) if points.ndim == 1: points = np.atleast_2d(points).T - points = np.reshape(points, (2,-1)) + points = np.reshape(points, (2, -1)) if t is None: t = time.time() ins_pos, ins_quat = self.platform_pose_provider.pose(t) - #print('ins_pos', ins_pos) - #print('ins_quat', ins_quat) # Unproject rays into the camera coordinate system. - ray_dir = np.ones((3,points.shape[1]), dtype=points.dtype) - ray_dir0 = cv2.undistortPoints(np.expand_dims(points.T, 1), - self._K, self._dist, R=None) + ray_dir = np.ones((3, points.shape[1]), dtype=points.dtype) + ray_dir0 = cv2.undistortPoints( + np.expand_dims(points.T, 1), self._K, self._dist, R=None + ) ray_dir[:2] = np.squeeze(ray_dir0, 1).T + R_cam_to_world = Rotation.from_quat(self._cam_quat).as_matrix() # Rotate rays into the navigation coordinate system. - ray_dir = np.dot(quaternion_matrix(self._cam_quat)[:3,:3], ray_dir) + ray_dir = np.dot(R_cam_to_world, ray_dir) # Translate ray positions into their navigation coordinate system # definition. @@ -1089,13 +1131,13 @@ def unproject(self, points, t=None, normalize_ray_dir=True): ray_pos[2] = self._cam_pos[2] # Rotate and translate rays into the world coordinate system. - R_ins_to_world = quaternion_matrix(ins_quat)[:3,:3] + R_ins_to_world = Rotation.from_quat(ins_quat).as_matrix() ray_dir = np.dot(R_ins_to_world, ray_dir) ray_pos = np.dot(R_ins_to_world, ray_pos) + np.atleast_2d(ins_pos).T if normalize_ray_dir: # Normalize - ray_dir /= np.sqrt(np.sum(ray_dir**2, 0)) + ray_dir /= np.sqrt(np.sum(ray_dir ** 2, 0)) return ray_pos, ray_dir @@ -1126,108 +1168,143 @@ class RollingShutterCamera(StandardCamera): :type dist: numpy.ndarray """ - def __init__(self, width, height, K, dist, cam_pos, cam_quat, - shutter_roll_time, platform_pose_provider=None): + + def __init__( + self, + width, + height, + K, + dist, + cam_pos, + cam_quat, + shutter_roll_time, + platform_pose_provider=None, + ): """ See additional documentation from base class above. """ - super(RollingShutterCamera, self).__init__(width, height, K, dist, - cam_pos, cam_quat, - platform_pose_provider) + super(RollingShutterCamera, self).__init__( + width, height, K, dist, cam_pos, cam_quat, platform_pose_provider + ) self.shutter_roll_time = shutter_roll_time def __str__(self): - string = ['model_type: rolling_shutter\n'] + string = ["model_type: rolling_shutter\n"] string.append(super(RollingShutterCamera, self).__str__()) - string.append('shutter_roll_time: %s\n' %self.shutter_roll_time) - return ''.join(string) + string.append("shutter_roll_time: %s\n" % self.shutter_roll_time) + return "".join(string) @classmethod def load_from_file(cls, filename, platform_pose_provider=None): - """See base class Camera documentation. - - """ - with open(filename, 'r') as f: + """See base class Camera documentation.""" + with open(filename, "r") as f: calib = yaml.safe_load(f) - assert calib['model_type'] == 'rolling_shutter' + assert calib["model_type"] == "rolling_shutter" # fill in CameraInfo fields - width = calib['image_width'] - height = calib['image_height'] - shutter_roll_time = calib['shutter_roll_time'] - dist = calib['distortion_coefficients'] + width = calib["image_width"] + height = calib["image_height"] + shutter_roll_time = calib["shutter_roll_time"] + dist = calib["distortion_coefficients"] - if dist == 'None': + if dist == "None": dist = np.zeros(4) - fx = calib['fx'] - fy = calib['fy'] - cx = calib['cx'] - cy = calib['cy'] - K = np.array([[fx,0,cx],[0,fy,cy],[0,0,1]]) - - cam_quat = calib['camera_quaternion'] - cam_pos = calib['camera_position'] - - return cls(width, height, K, dist, cam_pos, cam_quat, - shutter_roll_time, platform_pose_provider) + fx = calib["fx"] + fy = calib["fy"] + cx = calib["cx"] + cy = calib["cy"] + K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) + + cam_quat = calib["camera_quaternion"] + cam_pos = calib["camera_position"] + + return cls( + width, + height, + K, + dist, + cam_pos, + cam_quat, + shutter_roll_time, + platform_pose_provider, + ) def save_to_file(self, filename): - """See base class Camera documentation. - - """ - with open(filename, 'w') as f: - f.write(''.join(['# The type of camera model.\n', - 'model_type: rolling_shutter\n\n', - '# Image dimensions\n'])) - - f.write(''.join(['image_width: ',to_str(self.width),'\n'])) - f.write(''.join(['image_height: ',to_str(self.height),'\n\n'])) - - f.write(''.join(['shutter_roll_time: ', - to_str(self.shutter_roll_time),'\n\n'])) - - f.write('# Focal length along the image\'s x-axis.\n') - f.write(''.join(['fx: ',to_str(self.K[0,0]),'\n\n'])) - - f.write('# Focal length along the image\'s y-axis.\n') - f.write(''.join(['fy: ',to_str(self.K[1,1]),'\n\n'])) - - f.write('# Principal point is located at (cx,cy).\n') - f.write(''.join(['cx: ',to_str(self.K[0,2]),'\n'])) - f.write(''.join(['cy: ',to_str(self.K[1,2]),'\n\n'])) - - f.write(''.join(['# Distortion coefficients following OpenCv\'s ', - 'convention\n'])) + """See base class Camera documentation.""" + with open(filename, "w") as f: + f.write( + "".join( + [ + "# The type of camera model.\n", + "model_type: rolling_shutter\n\n", + "# Image dimensions\n", + ] + ) + ) + + f.write("".join(["image_width: ", to_str(self.width), "\n"])) + f.write("".join(["image_height: ", to_str(self.height), "\n\n"])) + + f.write( + "".join(["shutter_roll_time: ", to_str(self.shutter_roll_time), "\n\n"]) + ) + + f.write("# Focal length along the image's x-axis.\n") + f.write("".join(["fx: ", to_str(self.K[0, 0]), "\n\n"])) + + f.write("# Focal length along the image's y-axis.\n") + f.write("".join(["fy: ", to_str(self.K[1, 1]), "\n\n"])) + + f.write("# Principal point is located at (cx,cy).\n") + f.write("".join(["cx: ", to_str(self.K[0, 2]), "\n"])) + f.write("".join(["cy: ", to_str(self.K[1, 2]), "\n\n"])) + + f.write( + "".join( + ["# Distortion coefficients following OpenCv's ", "convention\n"] + ) + ) dist = self.dist if np.all(dist == 0): - dist = 'None' - - f.write(''.join(['distortion_coefficients: ', - to_str(self.dist),'\n\n'])) - - f.write(''.join(['# Quaternion (x, y, z, w) specifying the ', - 'orientation of the camera relative to\n# the ', - 'platform coordinate system. The quaternion ', - 'represents a coordinate\n# system rotation that ', - 'takes the platform coordinate system and ', - 'rotates it\n# into the camera coordinate ', - 'system.\ncamera_quaternion: ', - to_str(self.cam_quat),'\n\n'])) - - f.write(''.join(['# Position of the camera\'s center of ', - 'projection within the navigation\n# coordinate ', - 'system.\n', - 'camera_position: ',to_str(self.cam_pos), - '\n\n'])) + dist = "None" + + f.write("".join(["distortion_coefficients: ", to_str(self.dist), "\n\n"])) + + f.write( + "".join( + [ + "# Quaternion (x, y, z, w) specifying the ", + "orientation of the camera relative to\n# the ", + "platform coordinate system. The quaternion ", + "represents a coordinate\n# system rotation that ", + "takes the platform coordinate system and ", + "rotates it\n# into the camera coordinate ", + "system.\ncamera_quaternion: ", + to_str(self.cam_quat), + "\n\n", + ] + ) + ) + + f.write( + "".join( + [ + "# Position of the camera's center of ", + "projection within the navigation\n# coordinate ", + "system.\n", + "camera_position: ", + to_str(self.cam_pos), + "\n\n", + ] + ) + ) def project(self, points, t=None): - """See Camera.project documentation. - - """ + """See Camera.project documentation.""" # The challenge projecting into a rolling shutter camera is that every # row of the image is exposed at a different time. So, if you assume a # particular time to evaluate the pose at and then project into the @@ -1238,7 +1315,7 @@ def project(self, points, t=None): # We start by projecting assuming all points are at the time associated # with the center of the field of view. - im_pts = proj_fun(points, t + 0.5*self.shutter_roll_time) + im_pts = proj_fun(points, t + 0.5 * self.shutter_roll_time) if False: # Slower but more accurate. @@ -1255,27 +1332,29 @@ def project(self, points, t=None): if ind[i]: # The fraction of the rolling shutter time this y # coordinate has accumulated. - alpha = np.clip(im_pts[1, i]/self.height, 0, 1) - t_ = t + alpha*self.shutter_roll_time - im_pt_ = proj_fun(points[:, i:i+1], t_) - d = sqrt(np.sum((im_pt_ - im_pts[:, i:i+1])**2)) + alpha = np.clip(im_pts[1, i] / self.height, 0, 1) + t_ = t + alpha * self.shutter_roll_time + im_pt_ = proj_fun(points[:, i : i + 1], t_) + d = sqrt(np.sum((im_pt_ - im_pts[:, i : i + 1]) ** 2)) if d > 0.01: cont = True else: ind[i] = False - im_pts[:, i:i+1] = im_pt_ + im_pts[:, i : i + 1] = im_pt_ if not cont: break else: N = 10 alphas = np.linspace(0, 1, N) - im_pts_list = [proj_fun(points, t + alpha*self.shutter_roll_time).T - for alpha in alphas] + im_pts_list = [ + proj_fun(points, t + alpha * self.shutter_roll_time).T + for alpha in alphas + ] im_pts_list = np.array(im_pts_list).T - alphas2 = np.clip(im_pts_list[1]/self.height, 0, 1) + alphas2 = np.clip(im_pts_list[1] / self.height, 0, 1) alpha_err = alphas2 - alphas # We want to interpolate to the zero-valued alpha error. @@ -1295,32 +1374,40 @@ def project(self, points, t=None): delta = alpha_err1 - alpha_err2 w = np.ones(len(alpha_err1)) ind = delta != 0 - w[ind] = (alpha_err1[ind])/delta[ind] + w[ind] = (alpha_err1[ind]) / delta[ind] - im_pts1 = np.hstack([np.take_along_axis(im_pts_list[0], ind1, axis=1), - np.take_along_axis(im_pts_list[1], ind1, axis=1)]).T + im_pts1 = np.hstack( + [ + np.take_along_axis(im_pts_list[0], ind1, axis=1), + np.take_along_axis(im_pts_list[1], ind1, axis=1), + ] + ).T - im_pts2 = np.hstack([np.take_along_axis(im_pts_list[0], ind2, axis=1), - np.take_along_axis(im_pts_list[1], ind2, axis=1)]).T + im_pts2 = np.hstack( + [ + np.take_along_axis(im_pts_list[0], ind2, axis=1), + np.take_along_axis(im_pts_list[1], ind2, axis=1), + ] + ).T - im_pts = w*im_pts2 + (1-w)*im_pts1 + im_pts = w * im_pts2 + (1 - w) * im_pts1 return im_pts def unproject(self, points, t, normalize_ray_dir=True): - """See Camera.unproject documentation. - - """ + """See Camera.unproject documentation.""" points = np.array(points, dtype=np.float64) if points.ndim == 1: points = np.atleast_2d(points).T - points = np.reshape(points, (2,-1)) + points = np.reshape(points, (2, -1)) - alphas = np.clip(points[1]/self.height, 0, 1) - ts_ = t + (alphas*self.shutter_roll_time).astype(np.float64) + alphas = np.clip(points[1] / self.height, 0, 1) + ts_ = t + (alphas * self.shutter_roll_time).astype(np.float64) - ret = [super(RollingShutterCamera, self).unproject(points[:, i:i+1], ts_[i]) - for i in range(len(ts_))] + ret = [ + super(RollingShutterCamera, self).unproject(points[:, i : i + 1], ts_[i]) + for i in range(len(ts_)) + ] ray_pos = np.hstack([ret_[0] for ret_ in ret]) ray_dir = np.hstack([ret_[1] for ret_ in ret]) @@ -1328,125 +1415,166 @@ def unproject(self, points, t, normalize_ray_dir=True): class DepthCamera(StandardCamera): - """Camera with depth map. - - """ - def __init__(self, width, height, K, dist, cam_pos, cam_quat, depth_map, - platform_pose_provider=None): + """Camera with depth map.""" + + def __init__( + self, + width, + height, + K, + dist, + cam_pos, + cam_quat, + depth_map, + platform_pose_provider=None, + ): """ See additional documentation from base class above. """ - super(DepthCamera, self).__init__(width=width, height=height, K=K, - dist=dist, cam_pos=cam_pos, - cam_quat=cam_quat, - platform_pose_provider=platform_pose_provider) + super(DepthCamera, self).__init__( + width=width, + height=height, + K=K, + dist=dist, + cam_pos=cam_pos, + cam_quat=cam_quat, + platform_pose_provider=platform_pose_provider, + ) self._depth_map = depth_map + self.model_type = "depth" @classmethod def load_from_file(cls, filename, platform_pose_provider=None): - """See base class Camera documentation. - - """ - with open(filename, 'r') as f: + """See base class Camera documentation.""" + with open(filename, "r") as f: calib = yaml.safe_load(f) - assert calib['model_type'] == 'depth' + assert calib["model_type"] == "depth" # fill in CameraInfo fields - width = calib['image_width'] - height = calib['image_height'] - dist = calib['distortion_coefficients'] + width = calib["image_width"] + height = calib["image_height"] + dist = calib["distortion_coefficients"] - if dist == 'None': + if dist == "None": dist = np.zeros(4) - fx = calib['fx'] - fy = calib['fy'] - cx = calib['cx'] - cy = calib['cy'] - K = np.array([[fx,0,cx],[0,fy,cy],[0,0,1]]) - - cam_quat = calib['camera_quaternion'] - cam_pos = calib['camera_position'] - - return cls(width, height, K, dist, cam_pos, cam_quat, - platform_pose_provider) - - def save_to_file(self, filename, save_depth_viz=True): - """See base class Camera documentation. - - """ - with open(filename, 'w') as f: - f.write(''.join(['# The type of camera model.\n', - 'model_type: depth\n\n', - '# Image dimensions\n'])) - - f.write(''.join(['image_width: ',to_str(self.width),'\n'])) - f.write(''.join(['image_height: ',to_str(self.height),'\n\n'])) + fx = calib["fx"] + fy = calib["fy"] + cx = calib["cx"] + cy = calib["cy"] + K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) - f.write('# Focal length along the image\'s x-axis.\n') - f.write(''.join(['fx: ',to_str(self.K[0,0]),'\n\n'])) + cam_quat = calib["camera_quaternion"] + cam_pos = calib["camera_position"] - f.write('# Focal length along the image\'s y-axis.\n') - f.write(''.join(['fy: ',to_str(self.K[1,1]),'\n\n'])) + depth_map_fname = "%s_depth_map.tif" % os.path.splitext(filename)[0] + try: + depth_map = np.asarray(PIL.Image.open(depth_map_fname)) + except OSError: + depth_map = None - f.write('# Principal point is located at (cx,cy).\n') - f.write(''.join(['cx: ',to_str(self.K[0,2]),'\n'])) - f.write(''.join(['cy: ',to_str(self.K[1,2]),'\n\n'])) + return cls( + width, height, K, dist, cam_pos, cam_quat, depth_map, platform_pose_provider + ) - f.write(''.join(['# Distortion coefficients following OpenCv\'s ', - 'convention\n'])) + def save_to_file(self, filename, save_depth_viz=True): + """See base class Camera documentation.""" + with open(filename, "w") as f: + f.write( + "".join( + [ + "# The type of camera model.\n", + "model_type: depth\n\n", + "# Image dimensions\n", + ] + ) + ) + + f.write("".join(["image_width: ", to_str(self.width), "\n"])) + f.write("".join(["image_height: ", to_str(self.height), "\n\n"])) + + f.write("# Focal length along the image's x-axis.\n") + f.write("".join(["fx: ", to_str(self.K[0, 0]), "\n\n"])) + + f.write("# Focal length along the image's y-axis.\n") + f.write("".join(["fy: ", to_str(self.K[1, 1]), "\n\n"])) + + f.write("# Principal point is located at (cx,cy).\n") + f.write("".join(["cx: ", to_str(self.K[0, 2]), "\n"])) + f.write("".join(["cy: ", to_str(self.K[1, 2]), "\n\n"])) + + f.write( + "".join( + ["# Distortion coefficients following OpenCv's ", "convention\n"] + ) + ) dist = self.dist if np.all(dist == 0): - dist = 'None' - - f.write(''.join(['distortion_coefficients: ', - to_str(self.dist),'\n\n'])) - - f.write(''.join(['# Quaternion (x, y, z, w) specifying the ', - 'orientation of the camera relative to\n# the ', - 'navigation coordinate system. The quaternion ', - 'represents a coordinate\n# system rotation that ', - 'takes the navigation coordinate system and ', - 'rotates it\n# into the camera coordinate ', - 'system.\n camera_quaternion: ', - to_str(self.cam_quat),'\n\n'])) - - f.write(''.join(['# Position of the camera\'s center of ', - 'projection within the navigation\n# coordinate ', - 'system.\n', - 'camera_position: ',to_str(self.cam_pos), - '\n\n'])) + dist = "None" + + f.write("".join(["distortion_coefficients: ", to_str(self.dist), "\n\n"])) + + f.write( + "".join( + [ + "# Quaternion (x, y, z, w) specifying the ", + "orientation of the camera relative to\n# the ", + "navigation coordinate system. The quaternion ", + "represents a coordinate\n# system rotation that ", + "takes the navigation coordinate system and ", + "rotates it\n# into the camera coordinate ", + "system.\n camera_quaternion: ", + to_str(self.cam_quat), + "\n\n", + ] + ) + ) + + f.write( + "".join( + [ + "# Position of the camera's center of ", + "projection within the navigation\n# coordinate ", + "system.\n", + "camera_position: ", + to_str(self.cam_pos), + "\n\n", + ] + ) + ) if self.depth_map is not None: - im = PIL.Image.fromarray(self.depth_map.astype(np.float32), - mode='F') # float32 - depth_map_fname = '%s_depth_map.tif' % os.path.splitext(filename)[0] + im = PIL.Image.fromarray( + self.depth_map.astype(np.float32), mode="F" + ) # float32 + depth_map_fname = "%s_depth_map.tif" % os.path.splitext(filename)[0] im.save(depth_map_fname) if save_depth_viz: - depth_viz_fname = ('%s/depth_vizualization.png' % - os.path.split(filename)[0]) + depth_viz_fname = ( + "%s/depth_vizualization.png" % os.path.splitext(filename)[0] + ) self.save_depth_viz(depth_viz_fname) def __str__(self): - string = ['model_type: depth\n'] + string = ["model_type: depth\n"] string.append(super(DepthCamera, self).__str__()) - string.append('\n') - string.append(''.join(['fx: ',repr(self._K[0,0]),'\n'])) - string.append(''.join(['fy: ',repr(self._K[1,1]),'\n'])) - string.append(''.join(['cx: ',repr(self._K[0,2]),'\n'])) - string.append(''.join(['cy: ',repr(self._K[1,2]),'\n'])) - string.append(''.join(['distortion_coefficients: ', - repr(tuple(self._dist)), - '\n'])) - string.append(''.join(['camera_quaternion: ', - repr(tuple(self._cam_quat)),'\n'])) - string.append(''.join(['camera_position: ',repr(tuple(self._cam_pos)), - '\n'])) - return ''.join(string) + string.append("\n") + string.append("".join(["fx: ", repr(self._K[0, 0]), "\n"])) + string.append("".join(["fy: ", repr(self._K[1, 1]), "\n"])) + string.append("".join(["cx: ", repr(self._K[0, 2]), "\n"])) + string.append("".join(["cy: ", repr(self._K[1, 2]), "\n"])) + string.append( + "".join(["distortion_coefficients: ", repr(tuple(self._dist)), "\n"]) + ) + string.append( + "".join(["camera_quaternion: ", repr(tuple(self._cam_quat)), "\n"]) + ) + string.append("".join(["camera_position: ", repr(tuple(self._cam_pos)), "\n"])) + return "".join(string) def _unproject_to_depth(self, points, depth_map, t=None): """Unproject image points into the world at a particular time. @@ -1468,11 +1596,11 @@ def _unproject_to_depth(self, points, depth_map, t=None): """ points = np.atleast_2d(points) - points = np.reshape(points, (2,-1)) + points = np.reshape(points, (2, -1)) ray_pos, ray_dir = self.unproject(points, t=t, normalize_ray_dir=False) for i in range(points.shape[1]): - x,y = points[:,i] + x, y = points[:, i] # Get ray distance traveled until intersection. Therefore, we need # to evaluate the depth map at x,y. We need to convert from image # coordinates (i.e., upper-left corner of upper-left pixel is 0,0) @@ -1495,11 +1623,12 @@ def _unproject_to_depth(self, points, depth_map, t=None): if ix < 0 or iy < 0 or ix >= self.width or iy >= self.height: print(x == self.width) print(y == self.height) - raise ValueError('Coordinates (%0.1f,%0.f) are outside the ' - '%ix%i image' % - (x,y,self.width,self.height)) + raise ValueError( + "Coordinates (%0.1f,%0.f) are outside the " + "%ix%i image" % (x, y, self.width, self.height) + ) - ray_pos[:,i] += ray_dir[:,i]*depth_map[iy,ix] + ray_pos[:, i] += ray_dir[:, i] * depth_map[iy, ix] return ray_pos @@ -1510,8 +1639,10 @@ class GeoStaticCamera(DepthCamera): width, height, K, dist, lat, lon, altitude, cam_quat """ - def __init__(self, width, height, K, dist, depth_map, latitude, longitude, - altitude, R): + + def __init__( + self, width, height, K, dist, depth_map, latitude, longitude, altitude, R + ): """ See additional documentation from base class above. @@ -1527,140 +1658,160 @@ def __init__(self, width, height, K, dist, depth_map, latitude, longitude, """ R = np.array(R) R /= np.linalg.det(R) - cam_pos = np.array([0,0,0]) - cam_quat = np.array([0,0,0,1]) + cam_pos = np.array([0, 0, 0]) + cam_quat = np.array([0, 0, 0, 1]) # Quaternion for level system (z down) with x-axis pointing north. - enu_quat = np.array([1/np.sqrt(2),1/np.sqrt(2),0,0]) - - platform_pose_provider = PlatformPoseFixed(pos=np.array([0,0,0]), - quat=enu_quat, - lat0=latitude, lon0=longitude, - h0=altitude) - - super(GeoStaticCamera, self).__init__(width=width, height=height, K=K, - dist=dist, cam_pos=cam_pos, - cam_quat=cam_quat, - depth_map=depth_map, - platform_pose_provider=platform_pose_provider) + enu_quat = np.array([1 / np.sqrt(2), 1 / np.sqrt(2), 0, 0]) + + platform_pose_provider = PlatformPoseFixed( + pos=np.array([0, 0, 0]), + quat=enu_quat, + lat0=latitude, + lon0=longitude, + h0=altitude, + ) + + super(GeoStaticCamera, self).__init__( + width=width, + height=height, + K=K, + dist=dist, + cam_pos=cam_pos, + cam_quat=cam_quat, + depth_map=depth_map, + platform_pose_provider=platform_pose_provider, + ) self._R = R self._depth_map = depth_map # The local ENU coordinate system is located at the camera. - self._tvec = np.array([[0],[0],[0]], dtype=np.float64) - self._camera_pose = np.hstack([R,self._tvec]) + self._tvec = np.array([[0], [0], [0]], dtype=np.float64) + self._camera_pose = np.hstack([R, self._tvec]) def __str__(self): - string = ['model_type: static\n'] + string = ["model_type: static\n"] string.append(super(GeoStaticCamera, self).__str__()) - string.append('\n') - string.append(''.join(['fx: ',repr(self._K[0,0]),'\n'])) - string.append(''.join(['fy: ',repr(self._K[1,1]),'\n'])) - string.append(''.join(['cx: ',repr(self._K[0,2]),'\n'])) - string.append(''.join(['cy: ',repr(self._K[1,2]),'\n'])) - string.append(''.join(['distortion_coefficients: ', - repr(tuple(self._dist)), - '\n'])) - string.append(''.join(['latitude: %0.8f' % self.latitude, - '\n'])) - string.append(''.join(['longitude: %0.8f' % self.longitude, - '\n'])) - string.append(''.join(['altitude: %0.8f' % self.altitude, - '\n'])) - string.append(''.join(['R: ', - repr(tuple(self.R)),'\n'])) - return ''.join(string) + string.append("\n") + string.append("".join(["fx: ", repr(self._K[0, 0]), "\n"])) + string.append("".join(["fy: ", repr(self._K[1, 1]), "\n"])) + string.append("".join(["cx: ", repr(self._K[0, 2]), "\n"])) + string.append("".join(["cy: ", repr(self._K[1, 2]), "\n"])) + string.append( + "".join(["distortion_coefficients: ", repr(tuple(self._dist)), "\n"]) + ) + string.append("".join(["latitude: %0.8f" % self.latitude, "\n"])) + string.append("".join(["longitude: %0.8f" % self.longitude, "\n"])) + string.append("".join(["altitude: %0.8f" % self.altitude, "\n"])) + string.append("".join(["R: ", repr(tuple(self.R)), "\n"])) + return "".join(string) @classmethod def load_from_file(cls, filename, platform_pose_provider=None): - """See base class Camera documentation. - - """ - with open(filename, 'r') as f: + """See base class Camera documentation.""" + with open(filename, "r") as f: calib = yaml.safe_load(f) - assert calib['model_type'] == 'static' + assert calib["model_type"] == "static" # fill in CameraInfo fields - width = calib['image_width'] - height = calib['image_height'] - dist = np.array(calib['distortion_coefficients'], dtype=np.float64) + width = calib["image_width"] + height = calib["image_height"] + dist = np.array(calib["distortion_coefficients"], dtype=np.float64) - if isinstance(dist, str) and dist == 'None': + if isinstance(dist, str) and dist == "None": dist = np.zeros(4, dtype=np.float64) - fx = calib['fx'] - fy = calib['fy'] - cx = calib['cx'] - cy = calib['cy'] - K = np.array([[fx,0,cx],[0,fy,cy],[0,0,1]]) - R = np.reshape(np.array(calib['R']), (3,3)) - latitude = calib['latitude'] - longitude = calib['longitude'] - altitude = calib['altitude'] - - depth_map_fname = '%s_depth_map.tif' % os.path.splitext(filename)[0] + fx = calib["fx"] + fy = calib["fy"] + cx = calib["cx"] + cy = calib["cy"] + K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) + R = np.reshape(np.array(calib["R"]), (3, 3)) + latitude = calib["latitude"] + longitude = calib["longitude"] + altitude = calib["altitude"] + + depth_map_fname = "%s_depth_map.tif" % os.path.splitext(filename)[0] try: depth_map = np.asarray(PIL.Image.open(depth_map_fname)) except OSError: depth_map = None - return cls(width, height, K, dist, depth_map, latitude, longitude, - altitude, R) + return cls(width, height, K, dist, depth_map, latitude, longitude, altitude, R) def save_to_file(self, filename): - """See base class Camera documentation. - - """ - with open(filename, 'w') as f: - f.write(''.join(['# The type of camera model.\n', - 'model_type: static\n\n', - '# Image dimensions\n'])) - - f.write(''.join(['image_width: ',to_str(self.width),'\n'])) - f.write(''.join(['image_height: ',to_str(self.height),'\n\n'])) - - f.write('# Focal length along the image\'s x-axis.\n') - f.write(''.join(['fx: ',to_str(self._K[0,0]),'\n\n'])) - - f.write('# Focal length along the image\'s y-axis.\n') - f.write(''.join(['fy: ',to_str(self._K[1,1]),'\n\n'])) - - f.write('# Principal point is located at (cx,cy).\n') - f.write(''.join(['cx: ',to_str(self._K[0,2]),'\n'])) - f.write(''.join(['cy: ',to_str(self._K[1,2]),'\n\n'])) - - f.write(''.join(['# Distortion coefficients following OpenCv\'s ', - 'convention\n'])) + """See base class Camera documentation.""" + with open(filename, "w") as f: + f.write( + "".join( + [ + "# The type of camera model.\n", + "model_type: static\n\n", + "# Image dimensions\n", + ] + ) + ) + + f.write("".join(["image_width: ", to_str(self.width), "\n"])) + f.write("".join(["image_height: ", to_str(self.height), "\n\n"])) + + f.write("# Focal length along the image's x-axis.\n") + f.write("".join(["fx: ", to_str(self._K[0, 0]), "\n\n"])) + + f.write("# Focal length along the image's y-axis.\n") + f.write("".join(["fy: ", to_str(self._K[1, 1]), "\n\n"])) + + f.write("# Principal point is located at (cx,cy).\n") + f.write("".join(["cx: ", to_str(self._K[0, 2]), "\n"])) + f.write("".join(["cy: ", to_str(self._K[1, 2]), "\n\n"])) + + f.write( + "".join( + ["# Distortion coefficients following OpenCv's ", "convention\n"] + ) + ) dist = self._dist if np.all(dist == 0): - dist = 'None' - - f.write(''.join(['distortion_coefficients: ', - to_str(self._dist),'\n\n'])) - - f.write(''.join(['# Rotation matrix mapping vectors defined in an ' - 'east/north/up coordinate system\n# centered at ' - 'the camera into vectors defined in the camera' - 'coordinate system.\n', - 'R: [%0.10f, %0.10f, %0.10f,\n' - ' %0.10f, %0.10f, %0.10f,\n' - ' %0.10f, %0.10f, %0.10f]' % - tuple(self.R.ravel()), '\n\n'])) - - f.write(''.join(['# Location of the camera\'s center of ' - 'projection. Latitude and longitude are in\n# ' - 'degrees, and altitude is meters above the WGS84 ' - 'ellipsoid.\n', - 'latitude: %0.10f\n' % self.latitude, - 'longitude: %0.10f\n' % self.longitude, - 'altitude: %0.10f' % self.altitude,'\n\n'])) + dist = "None" + + f.write("".join(["distortion_coefficients: ", to_str(self._dist), "\n\n"])) + + f.write( + "".join( + [ + "# Rotation matrix mapping vectors defined in an " + "east/north/up coordinate system\n# centered at " + "the camera into vectors defined in the camera" + "coordinate system.\n", + "R: [%0.10f, %0.10f, %0.10f,\n" + " %0.10f, %0.10f, %0.10f,\n" + " %0.10f, %0.10f, %0.10f]" % tuple(self.R.ravel()), + "\n\n", + ] + ) + ) + + f.write( + "".join( + [ + "# Location of the camera's center of " + "projection. Latitude and longitude are in\n# " + "degrees, and altitude is meters above the WGS84 " + "ellipsoid.\n", + "latitude: %0.10f\n" % self.latitude, + "longitude: %0.10f\n" % self.longitude, + "altitude: %0.10f" % self.altitude, + "\n\n", + ] + ) + ) if self.depth_map is not None: - im = PIL.Image.fromarray(self.depth_map, mode='F') # float32 - depth_map_fname = '%s_depth_map.tif' % os.path.splitext(filename)[0] + im = PIL.Image.fromarray(self.depth_map, mode="F") # float32 + depth_map_fname = "%s_depth_map.tif" % os.path.splitext(filename)[0] im.save(depth_map_fname) @property @@ -1669,7 +1820,7 @@ def R(self): @R.setter def R(self, value): - self._R /= value/np.linalg.det(value) + self._R /= value / np.linalg.det(value) self._rvec = cv2.Rodrigues(self.R)[0].ravel() @property @@ -1716,8 +1867,9 @@ def project(self, points, t=None): points = np.atleast_2d(points).T # Project rays into camera coordinate system. - im_pts = cv2.projectPoints(points.T, self._rvec, self._tvec, self.K, - self.dist)[0] + im_pts = cv2.projectPoints(points.T, self._rvec, self._tvec, self.K, self.dist)[ + 0 + ] return np.squeeze(im_pts, 1).T def unproject(self, points, t=None, normalize_ray_dir=True): @@ -1734,19 +1886,20 @@ def unproject(self, points, t=None, normalize_ray_dir=True): points = np.array(points, dtype=np.float64) if points.ndim == 1: points = np.atleast_2d(points).T - points = np.reshape(points, (2,-1)) + points = np.reshape(points, (2, -1)) # Unproject rays into the camera coordinate system. - ray_dir = np.ones((3,points.shape[1]), dtype=points.dtype) - ray_dir0 = cv2.undistortPoints(np.expand_dims(points.T, 1), - self.K, self.dist, R=None) + ray_dir = np.ones((3, points.shape[1]), dtype=points.dtype) + ray_dir0 = cv2.undistortPoints( + np.expand_dims(points.T, 1), self.K, self.dist, R=None + ) ray_dir[:2] = np.squeeze(ray_dir0, 1).T # Rotate rays into the local east/north/up coordinate system. ray_dir = np.dot(self.R.T, ray_dir) if normalize_ray_dir: - ray_dir /= np.sqrt(np.sum(ray_dir**2, 0)) + ray_dir /= np.sqrt(np.sum(ray_dir ** 2, 0)) ray_pos = np.zeros_like(ray_dir) @@ -1759,10 +1912,12 @@ class MapCamera(Camera): This object is primarily built around GDAL. """ + def __init__(self, base_layer): - super(MapCamera, self).__init__(width=base_layer.res_x, - height=base_layer.res_y) + super(MapCamera, self).__init__(width=base_layer.res_x, height=base_layer.res_y) self.base_layer = base_layer def project(self, points, t=None): - return np.array([self.base_layer.meters_to_raster(point) for point in points.T]).T + return np.array( + [self.base_layer.meters_to_raster(point) for point in points.T] + ).T diff --git a/kamera/colmap_processing/geo_conversions.py b/kamera/colmap_processing/geo_conversions.py index 8f63b198..92952982 100644 --- a/kamera/colmap_processing/geo_conversions.py +++ b/kamera/colmap_processing/geo_conversions.py @@ -621,7 +621,7 @@ def geocentric_rotation(sphi, cphi, slam, clam): def sincosd(x): - """ + r""" * Evaluate the sine and cosine function with the argument in degrees * * @tparam T the type of the arguments. diff --git a/kamera/postflight/scripts/calibrate_from_colmap.py b/kamera/postflight/scripts/calibrate_from_colmap.py deleted file mode 100644 index fec90eab..00000000 --- a/kamera/postflight/scripts/calibrate_from_colmap.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python -""" -Library handling projection operations of a standard camera model. - -Note: the image coordiante system has its origin at the center of the top left -pixel. -""" -from __future__ import division, print_function -import numpy as np -from numpy import pi -import matplotlib.pyplot as plt -from mpl_toolkits.mplot3d import Axes3D -import cv2 -import time -import os -import glob -import matplotlib.pyplot as plt -import bisect -import json -from scipy.optimize import minimize - -# Custom package imports. -from sensor_models import ( - quaternion_multiply, - quaternion_from_matrix, - quaternion_from_euler, - quaternion_slerp, - quaternion_inverse, - quaternion_matrix - ) -from colmap_processing.camera_models import load_from_file, StandardCamera -from sensor_models.nav_state import NavStateINSBinary, NavStateINSJson - - -class ColmapImage(object): - def __init__(self, image_id, qw, qx, qy, qz, tx, ty, tz, cam_id, name, - pts): - self.image_id = image_id - self.qw = qw - self.qx = qx - self.qy = qy - self.qz = qz - self.tx = tx - self.ty = ty - self.tz = tz - self.cam_id = cam_id - self.name = name - self.pts = pts - - def get_camera_pose(self): - R = quaternion_matrix([self.qx,self.qy,self.qz,self.qw])[:3,:3] - return np.hstack([R,np.array([[self.tx,self.ty,self.tz]]).T]) - - def pos(self): - return np.dot(R.T,-t) - - -# Colmap text camera model directory. -flight_dir = '00' -colmap_dir = '00/colmap' -camera_model_dir = '/root/kamera/src/cfg/camera_models' - -# Read in the nav binary. -#nav_state_provider = NavStateINSBinary(nav_binary_fname) - -# Recover the mapping between filename and high-precision time. -image_globs = ['%s/CENT/*rgb.tif' % flight_dir, - '%s/LEFT/*rgb.tif' % flight_dir, - '%s/RIGHT/*rgb.tif' % flight_dir] -camera_model_fnames = ['%s/left_sys/rgb.yaml' % camera_model_dir, - '%s/center_sys/rgb.yaml' % camera_model_dir, - '%s/right_sys/rgb.yaml' % camera_model_dir] - -img_fnames = {} -fname_to_time = {} -camera_models = [] -for i in range(3): - image_glob = image_globs[i] - nav_dir = os.path.split(image_glob)[0] - nav_state_provider = NavStateINSJson('%s/*meta.json' % nav_dir) - - for img_fname in glob.glob(image_glob): - json_fname = img_fname - json_fname = json_fname.replace('rgb.tif', 'meta.json') - json_fname = json_fname.replace('ir.tif', 'meta.json') - json_fname = json_fname.replace('uv.tif', 'meta.json') - - try: - with open(json_fname) as json_file: - d = json.load(json_file) - - # Time that the image was taken. - img_fnames[d['evt']['time']] = img_fname - fname = os.path.splitext(os.path.split(img_fname)[1])[0] - fname_to_time[fname] = d['evt']['time'] - except OSError: - pass - - lat0 = nav_state_provider.lat0 - lon0 = nav_state_provider.lon0 - h0 = nav_state_provider.h0 - - camera_models.append(load_from_file(camera_model_fnames[i], - nav_state_provider)) - - - -if False: - camera_model_fnames = '%s/cameras.txt' % colmap_dir - camera_models = [] - with open(camera_model_fnames, 'r') as infile: - for line in infile: - if not line.startswith('#'): - p = np.array(line.split('\n')[0].split(' ')[2:], np.float) - width, height, fx, fy, cx, cy, k1, k2, k3, k4 = p - K = np.array([[fx,0,cx],[0,fy,cy],[0,0,1]]) - image_topic = None - camera_models.append(StandardCamera(width, height, K, - (k1,k2,k3,k4), (0,0,0), - (1,0,0,0), image_topic, - frame_id=None, - nav_state_provider=nav_state_provider)) - - -image_fname = '%s/output/images.txt' % colmap_dir -colmap_images = [] -with open(image_fname, 'r') as infile: - while True: - line = infile.readline() - if line == '': - break - - if not line.startswith('#'): - # IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME - # POINTS2D[] as (X, Y, POINT3D_ID) - p = line.split('\n')[0].split(' ') - image_id, qw, qx, qy, qz, tx, ty, tz, cam_id, name = p - qw, qx, qy, qz, tx, ty, tz = [float(_) for _ in (qw, qx, qy, qz, tx, ty, tz)] - cam_id = int(cam_id) - - line = infile.readline() - p = line.split('\n')[0].split(' ') - pts = np.reshape(np.array([float(_) for _ in p]), (-1,3)) - - colmap_image = ColmapImage(image_id, qw, qx, qy, qz, tx, ty, tz, - cam_id, name, pts) - colmap_images.append(colmap_image) - - -points_fname = '%s/points3D.txt' % colmap_dir -points = [] -with open(points_fname, 'r') as infile: - for line in infile: - pass - - -nav_times = nav_state_provider.pose_time_series[:,0] - - -camera_model = camera_models[0] - - -def err_fun(cam_quat): - #cam_quat = np.hstack([0.5, cam_quat]) - cam_quat /= np.linalg.norm(cam_quat) - camera_model.update_intrinsics(cam_quat=cam_quat) - theta = 0 - for i in range(len(colmap_images)): - colmap_image = colmap_images[i] - fname = os.path.splitext(os.path.split(colmap_image.name)[1])[0] - t = fname_to_time[fname] - P1 = camera_models[0].get_camera_pose(t) - P2 = colmap_image.get_camera_pose() - R1 = np.identity(4); R1[:3,:3] = P1[:,:3] - R2 = np.identity(4); R2[:3,:3] = P2[:,:3] - q1 = quaternion_from_matrix(R1) - q2 = quaternion_from_matrix(R2) - dq = quaternion_multiply(q1, quaternion_inverse(q2)) - theta += 2*np.arccos(max([min([dq[3],1]),-1])) - - theta /= len(colmap_images) - print(cam_quat, theta) - return theta - -if False: - min_err = np.inf - for _ in range(10000): - cam_quat = random_quaternion() - err = err_fun(cam_quat) - if err < min_err: - min_err = err - best_cam_quat = cam_quat - else: - cam_quat = camera_model.cam_quat - -x = minimize(err_fun, cam_quat, tol=1-9).x - - -plt.plot(nav_state_provider.pose_time_series[:,3]) - -# ---------------------------------------------------------------------------- -fig = plt.figure() -ax = fig.add_subplot(111, projection='3d') -r = 5 -times = nav_times -times = [image_times[_.name] for _ in colmap_images] -times = np.sort(times) -pos = np.array([nav_state_provider.pose(t)[0] for t in times]).T - -plt.plot(pos[0], pos[1], pos[2], 'k-') -plt.plot(pos[0], pos[1], pos[2], 'ro') - -for t in times: - pos,quat = nav_state_provider.pose(t) - R = quaternion_matrix(quaternion_inverse(quat))[:3,:3] - - s = ['r-','g-','b-'] - for i in range(3): - plt.plot([pos[0],pos[0]+R[i][0]*r], [pos[1],pos[1]+R[i][1]*r], - [pos[2],pos[2]+R[i][2]*r], s[i], linewidth=3) - -plt.xlabel('Easting') -plt.ylabel('Northing') - - -fig = plt.figure() -ax = fig.add_subplot(111, projection='3d') -r = 5 -for i in range(len(colmap_images)): - colmap_image = colmap_images[i] - P = colmap_image.get_camera_pose() - R = np.identity(4); R[:3,:3] = P[:,:3] - pos = colmap_image.pos() - plt.plot([pos[0]], [pos[1]], [pos[2]], 'ro') - - s = ['r-','g-','b-'] - for i in range(3): - plt.plot([pos[0],pos[0]+R[i][0]*r], [pos[1],pos[1]+R[i][1]*r], - [pos[2],pos[2]+R[i][2]*r], s[i], linewidth=3) - -plt.xlabel('Easting') -plt.ylabel('Northing') diff --git a/kamera/postflight/scripts/calibrate_ir_from_rgb.py b/kamera/postflight/scripts/calibrate_ir_from_rgb.py deleted file mode 100644 index fbf0d96f..00000000 --- a/kamera/postflight/scripts/calibrate_ir_from_rgb.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python -""" -Library handling projection operations of a standard camera model. -""" -from __future__ import division, print_function -import copy -import cv2 -import time -import os -import glob -import random -import json -import PIL -import numpy as np -import matplotlib.pyplot as plt -from numpy import pi -from mpl_toolkits.mplot3d import Axes3D -from scipy.optimize import minimize - -# Custom package imports. -from sensor_models import ( - quaternion_multiply, - quaternion_from_matrix, - quaternion_from_euler, - quaternion_slerp, - quaternion_inverse, - quaternion_matrix - ) -from colmap_processing.camera_models import load_from_file, StandardCamera -from colmap_processing.image_renderer import render_view - - -def calibrate_ir(rgb_camera_model_fname, ir_camera_model_fname, - image_point_pairs_fname): - image_pts = np.loadtxt(image_point_pairs_fname) - - rgb_camera = load_from_file(rgb_camera_model_fname) - ir_camera = load_from_file(ir_camera_model_fname) - - def get_new_cm(x): - tmp_cm = copy.deepcopy(ir_camera) - cam_quat = x[:4] - cam_quat /= np.linalg.norm(cam_quat) - tmp_cm.update_intrinsics(cam_quat=cam_quat) - - if len(x) > 4: - tmp_cm.fx = x[4] - - if len(x) > 5: - tmp_cm.fy = x[5] - - return tmp_cm - - def proj_err(x): - tmp_cm = get_new_cm(x) - - wrld_pts = rgb_camera.unproject(image_pts[:, 2:].T)[1] - im_pts = tmp_cm.project(wrld_pts) - - err = np.sqrt(np.sum(np.sum((image_pts[:, :2].T - im_pts)**2, 1))) - err /= len(wrld_pts) - print(err, x) - return err - - min_err = np.inf - best_x = None - for _ in range(10000): - x = np.random.rand(4)*2-1 - x /= np.linalg.norm(x) - err = proj_err(x) - if err < min_err: - min_err = err - best_x = x - - if True: - x = np.hstack([best_x, ir_camera.fx, ir_camera.fy]) - else: - x = best_x - - x = minimize(proj_err, x).x - x = minimize(proj_err, x, method='Powell').x - x = minimize(proj_err, x, method='BFGS').x - x = minimize(proj_err, x).x - - tmp_cm = get_new_cm(x) - - print('Final mean error:', proj_err(x), 'pixels') - tmp_cm.image_topic = '' - tmp_cm.frame_id = '' - tmp_cm.save_to_file(ir_camera_model_fname) - print('Saved updated camera model to', ir_camera_model_fname) - - -# Process three RGB cameras. -rgb_camera_model_fname = '' -ir_camera_model_fname = '' -image_point_pairs_fname = '' -calibrate_ir(rgb_camera_model_fname, ir_camera_model_fname, - image_point_pairs_fname) -# ---------------------------------- IR Gifs --------------------------------- - -# Location to save KAMERA camera models. -rgb_img_dir = '' -ir_img_dir = '' - -base_dir, base = os.path.split(ir_camera_model_fname) -out_dir = '%s/registration_gifs/%s' % (base_dir, os.path.splitext(base)[0]) -num_gifs = 50 - -try: - os.makedirs(out_dir) -except (IOError, OSError): - pass - - -def stretch_contrast(img, clip_limit=3, stretch_percentiles=[0.1, 99.9]): - img = img.astype(np.float32) - img -= np.percentile(img.ravel(), stretch_percentiles[0]) - img[img < 0] = 0 - img /= np.percentile(img.ravel(), stretch_percentiles[1])/255 - img[img > 255] = 255 - img = np.round(img).astype(np.uint8) - - hls = cv2.cvtColor(img, cv2.COLOR_RGB2HLS) - - clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(16, 16)) - hls[:, :, 1] = clahe.apply(hls[:, :, 1]) - - img = cv2.cvtColor(hls, cv2.COLOR_HLS2RGB) - return img - -cm_rgb = load_from_file(rgb_camera_model_fname) -cm_ir = load_from_file(ir_camera_model_fname) - -rgb_fnames = glob.glob('%s/*.jpg' % rgb_img_dir) -random.shuffle(rgb_fnames) -k = 0 - - -for rgb_fname in rgb_fnames[:num_gifs]: - if k == num_gifs: - break - - ir_fname = '%s/%sir.tif' % (ir_img_dir, os.path.split(rgb_fname[:-7])[1]) - img2 = cv2.imread(ir_fname, cv2.IMREAD_COLOR) - - if img2 is None: - continue - - img1 = cv2.imread(rgb_fname, cv2.IMREAD_COLOR) - - if img1 is None: - continue - - k += 1 - - img1 = img1[:, :, ::-1] - img2 = img2[:, :, ::-1] - - img1 = stretch_contrast(img1, clip_limit=3, stretch_percentiles=[0.1, 99.9]) - img2 = stretch_contrast(img2, clip_limit=3, stretch_percentiles=[0.1, 99.9]) - - img3, mask = render_view(cm_rgb, img1, 0, cm_ir, 0, block_size=10) - - img2_ = PIL.Image.fromarray(img2) - img3_ = PIL.Image.fromarray(img3) - fname_out = '%s/registration_%i.gif' % (out_dir, k+1) - img2_.save(fname_out, save_all=True, append_images=[img3_], - duration=250, loop=0) diff --git a/kamera/postflight/scripts/camera_calibration.py b/kamera/postflight/scripts/camera_calibration.py deleted file mode 100644 index 8917548a..00000000 --- a/kamera/postflight/scripts/camera_calibration.py +++ /dev/null @@ -1,1213 +0,0 @@ -#!/usr/bin/env python -import os -import os.path as osp -import json -import cv2 -import PIL -import pathlib -import numpy as np -import matplotlib.pyplot as plt -import ubelt as ub -from random import shuffle -from scipy.optimize import minimize, fminbound -from matplotlib.backends.backend_pdf import PdfPages - -# Custom package imports. -from kamera.sensor_models import ( - quaternion_multiply, - quaternion_from_matrix, - quaternion_inverse, - ) -from kamera.sensor_models.nav_conversions import enu_to_llh -from kamera.sensor_models.nav_state import NavStateINSJson, NavStateFixed -from kamera.colmap_processing.camera_models import StandardCamera -from kamera.colmap_processing.colmap_interface import ( - read_images_binary, - read_points3D_binary, - read_cameras_binary, - qvec2rotmat, - ) -from kamera.colmap_processing.image_renderer import render_view - - -def get_base_name(fname): - """ Given an arbitrary filename (could be UV, IR, RGB, json), - extract the portion of the filename that is just the time, flight, - machine (C, L, R), and effort name. - """ - # get base - base = osp.basename(fname) - # get it without an extension and modality - modality_agnostic = "_".join(base.split("_")[:-1]) - return modality_agnostic - - -def get_modality(fname): - base = osp.basename(fname) - modality = base.split("_")[-1].split('.')[0] - return modality - - -def get_channel(fname): - base = osp.basename(fname) - channel = base.split("_")[3] - return channel - - -def get_basename_to_time(flight_dir) -> dict: - # Establish correspondence between real-world exposure times base of file - # names. - basename_to_time = {} - for json_fname in pathlib.Path(flight_dir).rglob('*_meta.json'): - try: - with open(json_fname) as json_file: - d = json.load(json_file) - # Time that the image was taken. - basename = get_base_name(json_fname) - basename_to_time[basename] = float(d['evt']['time']) - except (OSError, IOError): - pass - return basename_to_time - - -def process_images(colmap_images, basename_to_time, nav_state_provider): - """ - - Returns: - :param img_fnames: Image filename associated with each of the images in - 'colmap_images'. - :type img_fnames: list of str - - :param img_times: INS-reported time associated with the trigger of each - image in 'colmap_images'. - :type img_times: - - :param ins_poses: INS-reported pose, (x, y, z) position and (x, y, z, w) - quaternion, associated with the trigger of time of each image in - 'colmap_images'. - :type ins_poses: - - :param sfm_poses: Colmap-reported reported pose, (x, y, z) position and - (x, y, z, w) quaternion, associated with the trigger time of each image - in 'colmap_images'. - :type sfm_poses: - - """ - img_fnames = [] - img_times = [] - ins_poses = [] - sfm_poses = [] - llhs = [] - for image_num in colmap_images: - image = colmap_images[image_num] - base_name = get_base_name(image.name) - try: - t = basename_to_time[base_name] - - # Query the navigation state recorded by the INS for this time. - pose = nav_state_provider.pose(t) - llh = nav_state_provider.llh(t) - - # Query Colmaps pose for the camera. - R = qvec2rotmat(image.qvec) - pos = -np.dot(R.T, image.tvec) - - # The qvec used by Colmap is a (w, x, y, z) quaternion - # representing the rotation of a vector defined in the world - # coordinate system into the camera coordinate system. However, - # the 'camera_models' module assumes (x, y, z, w) quaternions - # representing a coordinate system rotation. Also, the quaternion - # used by 'camera_models' represents a coordinate system rotation - # versus the coordinate system transform of Colmap's convention, - # so we need an inverse. - - #quat = transformations.quaternion_inverse(image.qvec) - quat = image.qvec / np.linalg.norm(image.qvec) - quat[0] = -quat[0] - - quat = [quat[1], quat[2], quat[3], quat[0]] - - sfm_pose = [pos, quat] - - img_times.append(t) - ins_poses.append(pose) - img_fnames.append(image.name) - sfm_poses.append(sfm_pose) - llhs.append(llh) - except KeyError: - print('Couldn\'t find a _meta.json file associated with \'%s\'' % - base_name) - - ind = np.argsort(img_fnames) - img_fnames = [img_fnames[i] for i in ind] - img_times = [img_times[i] for i in ind] - ins_poses = [ins_poses[i] for i in ind] - sfm_poses = [sfm_poses[i] for i in ind] - llhs = [llhs[i] for i in ind] - - return img_fnames, img_times, ins_poses, sfm_poses, llhs - - -def write_image_locations(locations_fname, img_fnames, ins_poses): - with open(locations_fname, 'w') as fo: - for i in range(len(img_fnames)): - name = img_fnames[i] - pos = ins_poses[i][0] - fo.write('%s %0.8f %0.8f %0.8f\n' % (name, pos[0], pos[1], pos[2])) - - -def get_colmap_data(colmap_images, colmap_cameras, - points3d, basename_to_time) -> tuple: - # Load in all of the Colmap results into more-convenient structures. - points_per_image = {} - camera_from_camera_str = {} - for image_num in colmap_images: - image = colmap_images[image_num] - camera_str = osp.basename(osp.dirname(image.name)) - camera_from_camera_str[camera_str] = colmap_cameras[image.camera_id] - - xys = image.xys - pt_ids = image.point3D_ids - ind = pt_ids != -1 - pt_ids = pt_ids[ind] - xys = xys[ind] - xyzs = np.array([points3d[pt_id].xyz for pt_id in pt_ids]) - base_name = get_base_name(image.name) - try: - t = basename_to_time[base_name] - points_per_image[image.name] = (xys, xyzs, t) - except KeyError: - pass - return points_per_image, camera_from_camera_str - - -def perform_error_analysis(camera_model, points_per_image_, save_dir, camera_str): - """ - Perform error analysis and save all plots into a single PDF. - - Parameters: - - camera_model: The calibrated camera model. - - points_per_image_: List of tuples containing image points, corresponding 3D points, and timestamp. - - save_dir: Directory where the PDF will be saved. - - camera_str: String identifier for the camera (used in PDF filename). - """ - err_meters = [] - err_pixels = [] - err_pixels_per_frame = [] - err_angle = [] - ifov = np.mean(camera_model.ifov()) # Assuming 'ifov' stands for 'instantaneous field of view' - - for xys, xyzs, t in points_per_image_: - # Project 3D points to 2D image points - xys2 = camera_model.project(xyzs.T, t) - err_pixels_ = np.sqrt(np.sum((xys2 - xys.T)**2, axis=0)) - err_pixels_per_frame.append([t, err_pixels_.mean()]) - err_pixels.extend(err_pixels_.tolist()) - - # Unproject image points to camera rays - ray_pos, ray_dir = camera_model.unproject(xys.T, t) - - # Compute direction from camera to 3D points - ray_dir2 = xyzs.T - ray_pos - dist = np.linalg.norm(ray_dir2, axis=0) - ray_dir2 /= dist - - # Calculate angular deviation - dp = np.clip(np.sum(ray_dir * ray_dir2, axis=0), -1, 1) - theta = np.arccos(dp) - err_angle.extend(theta.tolist()) - - # Calculate orthogonal distance in meters - err_meters.extend((np.sin(theta) * dist).tolist()) - - # Convert lists to numpy arrays for easier manipulation - err_meters = np.array(err_meters) - err_pixels = np.array(err_pixels) - err_angle = np.array(err_angle) - err_pixels_per_frame = np.array(err_pixels_per_frame).T - - # Sort the errors - sorted_err_meters = np.sort(err_meters) - sorted_err_pixels = np.sort(err_pixels) - sorted_err_angle = np.sort(err_angle) - - # Initialize PdfPages object - pdf_filename = f"{save_dir}/error_analysis_{camera_str}.pdf" - with PdfPages(pdf_filename) as pdf: - # --- Plot 1: Histogram of Pixel Errors --- - plt.figure(figsize=(8, 6)) - plt.hist(sorted_err_pixels, bins=50, color='blue', alpha=0.7) - plt.title('Pixel Errors') - plt.xlabel('Error (pixels)') - plt.ylabel('Frequency') - plt.grid(True) - pdf.savefig() # Save the current figure into the PDF - plt.close() - - # --- Plot 2: Histogram of Meter Errors --- - plt.figure(figsize=(8, 6)) - plt.hist(sorted_err_meters, bins=50, color='green', alpha=0.7) - plt.title('Meter Errors') - plt.xlabel('Error (meters)') - plt.ylabel('Frequency') - plt.grid(True) - pdf.savefig() - plt.close() - - # --- Plot 3: Histogram of Angular Errors --- - plt.figure(figsize=(8, 6)) - plt.hist(np.degrees(sorted_err_angle), bins=50, color='red', alpha=0.7) - plt.title('Angular Errors') - plt.xlabel('Error (degrees)') - plt.ylabel('Frequency') - plt.grid(True) - pdf.savefig() - plt.close() - - # --- Plot 4: Pixel Errors per Frame --- - plt.figure(figsize=(10, 6)) - plt.plot(err_pixels_per_frame[0], err_pixels_per_frame[1], marker='o', linestyle='-', color='purple') - plt.title('Average Pixel Error per Frame') - plt.xlabel('Frame Index or Timestamp') - plt.ylabel('Average Pixel Error (pixels)') - plt.grid(True) - pdf.savefig() - plt.close() - - # --- Optional: Additional Plots --- - # If you have more plots to include, add them here following the same pattern. - - print(f"Error analysis plots have been saved to {pdf_filename}") - - -def calibrate_rgb(rgb_camera_strs, img_fnames, ins_poses, sfm_poses, - points_per_image, camera_from_camera_str, - nav_state_provider, save_dir): - for camera_str in rgb_camera_strs: - ins_quat_ = [] - sfm_quat_ = [] - points_per_image_ = [] - for i in range(len(img_fnames)): - fname = img_fnames[i] - if osp.basename(osp.dirname(fname)) == camera_str: - ins_quat_.append(ins_poses[i][1]) - sfm_quat_.append(sfm_poses[i][1]) - points_per_image_.append(points_per_image[fname]) - - # Both quaternions are of the form (x, y, z, w) and represent a coordinate - # system rotation. - #q_sfm = quaternion_inverse(q_cam)*quaternion_inverse(q_ins) - cam_quats = [quaternion_inverse(quaternion_multiply(sfm_quat_[k], - ins_quat_[k])) - for k in range(len(ins_quat_))] - - colmap_camera = camera_from_camera_str[camera_str] - - if colmap_camera.model == 'OPENCV': - fx, fy, cx, cy, d1, d2, d3, d4 = colmap_camera.params - elif colmap_camera.model == 'PINHOLE': - fx, fy, cx, cy = colmap_camera.params - d1 = d2 = d3 = d4 = 0 - - K = K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) - dist = np.array([d1, d2, d3, d4]) - - def cam_quat_error(cam_quat) -> float: - cam_quat = cam_quat/np.linalg.norm(cam_quat) - camera_model = StandardCamera(colmap_camera.width, - colmap_camera.height, - K, dist, [0, 0, 0], cam_quat, - platform_pose_provider=nav_state_provider) - - err = [] - for xys, xyzs, t in points_per_image_: - if False: - # Reprojection error. - xys2 = camera_model.project(xyzs.T, t) - err_ = np.sqrt(np.sum((xys2 - xys.T)**2, axis=0)) - else: - # Error in meters. - - # Rays coming out of the camera in the direction of the imaged points. - ray_pos, ray_dir = camera_model.unproject(xys.T, t) - - # Direction coming out of the camera pointing at the actual 3-D points' - # locatinos. - ray_dir2 = xyzs.T - ray_pos - d = np.sqrt(np.sum((ray_dir2)**2, axis=0)) - ray_dir2 /= d - - dp = np.minimum(np.sum(ray_dir*ray_dir2, axis=0), 1) - dp = np.maximum(dp, -1) - theta = np.arccos(dp) - err_ = np.sin(theta)*d - #err.append(np.percentile(err_, 90)) - err.append(np.mean(err_)) - - err = np.array(err) - #err = err[err < np.percentile(err, 90)] - - err = np.mean(err) - #print('RMS reproject error for quat', cam_quat, ': %0.8f' % err) - return err - - print("Iterating through %s quaternion guesses." % len(cam_quats)) - shuffle(cam_quats) - best_quat = None - best_err = np.inf - for i in range(len(cam_quats)): - if True: - cam_quat = cam_quats[i] - else: - cam_quat = np.random.rand(4)*2-1 - - err = cam_quat_error(cam_quat) - if err < best_err: - best_err = err - best_quat = cam_quat - - if best_err < 10: - break - - print("Best error: ", best_err) - print("Best quat: ") - print(cam_quat) - - print("Minimizing error over camera quaternions") - - ret = minimize(cam_quat_error, best_quat) - best_quat = ret.x/np.linalg.norm(ret.x) - ret = minimize(cam_quat_error, best_quat, method='BFGS') - best_quat = ret.x/np.linalg.norm(ret.x) - ret = minimize(cam_quat_error, best_quat, method='Powell') - best_quat = ret.x/np.linalg.norm(ret.x) - - # Sequential 1-D optimizations. - for i in range(4): - def set_x(x): - quat = best_quat.copy() - quat = quat/np.linalg.norm(quat) - while abs(quat[i] - x) > 1e-6: - quat[i] = x - quat = quat/np.linalg.norm(quat) - - return quat - - def func(x): - return cam_quat_error(set_x(x)) - - x = np.linspace(-1, 1, 100); x = sorted(np.hstack([x, best_quat[i]])) - y = [func(x_) for x_ in x] - x = fminbound(func, x[np.argmin(y) - 1], x[np.argmin(y) + 1], xtol=1e-8) - best_quat = set_x(x) - - camera_model = StandardCamera(colmap_camera.width, colmap_camera.height, - K, dist, [0, 0, 0], best_quat, - platform_pose_provider=nav_state_provider) - - ub.ensuredir(save_dir) - - camera_model.save_to_file('%s/%s.yaml' % (save_dir, camera_str)) - - perform_error_analysis(camera_model, - points_per_image_, - save_dir, - camera_str) - - -def create_time_modality_mapping(colmap_images, basename_to_time): - print("Creating mapping between RGB and UV images...") - time_to_modality = ub.AutoDict() - for image in colmap_images.values(): - base_name = get_base_name(image.name) - try: - t = basename_to_time[base_name] - except Exception as e: - print(e) - print(f"No ins time found for image {base_name}.") - continue - modality = get_modality(image.name) - time_to_modality[t][modality] = image - return time_to_modality - - -def create_fname_to_time_channel_modality(img_fnames, basename_to_time): - print("Creating mapping between RGB and UV images...") - time_to_modality = ub.AutoDict() - for fname in img_fnames: - base_name = get_base_name(fname) - try: - t = basename_to_time[base_name] - except Exception as e: - print(e) - print(f"No ins time found for image {base_name}.") - continue - modality = get_modality(fname) - channel = get_channel(fname) - time_to_modality[t][channel][modality] = fname - return time_to_modality - - -def write_gifs(gif_dir, colmap_dir, img_fnames, - fname_to_time_channel_modality, - basename_to_time, rgb_str, camera_str, - cm_rgb, cm_uv): - print(f"Writing a registration gif for cameras {rgb_str} " - f"and {camera_str}.") - # Pick an image pair and register. - ub.ensuredir(gif_dir) - - for k in range(10): - inds = list(range(len(img_fnames))) - shuffle(inds) - for i in range(len(img_fnames)): - uv_img = rgb_img = None - fname1 = img_fnames[inds[i]] - if osp.basename(osp.dirname(fname1)) != rgb_str: - continue - t1 = basename_to_time[get_base_name(fname1)] - channel = get_channel(fname1) # L/C/R - try: - rgb_fname = fname_to_time_channel_modality[t1][channel]["rgb"] - abs_rgb_fname = os.path.join(colmap_dir, 'images0', rgb_fname) - rgb_img = cv2.imread(abs_rgb_fname, cv2.IMREAD_COLOR)[:, :, ::-1] - except Exception as e: - print(f"No rgb image found at time {t1}") - continue - try: - uv_fname = fname_to_time_channel_modality[t1][channel]["uv"] - abs_uv_fname = os.path.join(colmap_dir, 'images0', uv_fname) - uv_img = cv2.imread(abs_uv_fname, cv2.IMREAD_COLOR)[:, :, ::-1] - break - except Exception as e: - print(f"No uv image found at time {t1}") - continue - - if uv_img is None or rgb_img is None: - print("Failed to find matching image pair, skipping.") - continue - print(f"Writing {rgb_fname} and {uv_fname} to gif.") - - # Warps the color image img1 into the uv camera model cm_uv - warped_rgb_img, mask = render_view(cm_rgb, rgb_img, 0, - cm_uv, 0, block_size=10) - - ds_warped_rgb_img = PIL.Image.fromarray(cv2.pyrDown( - cv2.pyrDown(cv2.pyrDown(warped_rgb_img)))) - ds_uv_img = PIL.Image.fromarray(cv2.pyrDown( - cv2.pyrDown(cv2.pyrDown(uv_img)))) - fname_out = osp.join(gif_dir, - f"{rgb_str}_to_{camera_str}_registration_{k+1}.gif") - print(f"Writing gif to {fname_out}.") - ds_uv_img.save(fname_out, save_all=True, - append_images=[ds_warped_rgb_img], - duration=350, loop=0) - - -def calibrate_uv(uv_camera_strs, img_fnames, colmap_images, - camera_from_camera_str, save_dir, - basename_to_time, time_to_modality, - fname_to_time_channel_modality, - colmap_dir, points_per_image): - nav_state_fixed = NavStateFixed(np.zeros(3), [0, 0, 0, 1]) - skipped = 0 - total = 0 - for uv_str in uv_camera_strs: - print(f"Matching images to camera {uv_str}.") - rgb_str = uv_str.replace('uv', 'rgb') - cm_rgb = StandardCamera.load_from_file(osp.join(save_dir, rgb_str + '.yaml'), - platform_pose_provider=nav_state_fixed) - im_pts_uv = [] - im_pts_rgb = [] - - # Build up pairs of image coordinates between the two cameras from image - # pairs acquired from the same time. - image_nums = sorted(list(colmap_images.keys())) - for image_num in image_nums: - #print('%i/%i' % (image_num + 1, image_nums[-1])) - image = colmap_images[image_num] - im_str = osp.basename(osp.dirname(image.name)) - if im_str != uv_str: - #print(f"{im_str} does not match {camera_str}, skipping.") - continue - - # now we know it's uv - image_uv = image - base_name = get_base_name(image_uv.name) - - try: - t1 = basename_to_time[base_name] - except KeyError: - print(f"No time found for {base_name}.") - continue - - try: - image_rgb = time_to_modality[t1]["rgb"] - except KeyError: - print(f"No rgb image found at {t1}.") - continue - - # Both 'uv_image' and 'image_rgb' are from the same time. - pt_ids1 = image_uv.point3D_ids - ind = pt_ids1 != -1 - xys1 = dict(zip(pt_ids1[ind], image_uv.xys[ind])) - - pt_ids2 = image_rgb.point3D_ids - ind = pt_ids2 != -1 - xys2 = dict(zip(pt_ids2[ind], image_rgb.xys[ind])) - - match_ids = set(xys1.keys()).intersection(set(xys2.keys())) - total += 1 - if len(match_ids) < 1: - #print("No match IDs found.") - skipped += 1 - continue - - for match_id in match_ids: - im_pts_uv.append(xys1[match_id]) - im_pts_rgb.append(xys2[match_id]) - - print(f"Matched {total-skipped}/{total} image pairs, resulting in " - f"{len(im_pts_uv)} matching UV and RGB points.") - - im_pts_uv = np.array(im_pts_uv) - im_pts_rgb = np.array(im_pts_rgb) - # Arbitrary cut off - minimum_pts_required = 10 - if len(im_pts_rgb) < minimum_pts_required or \ - len(im_pts_uv) < minimum_pts_required: - print("[ERROR] Not enough matching RGB/UV image points were found " - f"for camera {uv_str}.") - continue - - if False: - plt.subplot(121) - plt.plot(im_pts_uv[:, 0], im_pts_uv[:, 1], 'ro') - plt.subplot(122) - plt.plot(im_pts_rgb[:, 0], im_pts_rgb[:, 1], 'bo') - - # Treat as co-located cameras (they are) and unproject out of RGB and into - # the other camera. - ray_pos, ray_dir = cm_rgb.unproject(im_pts_rgb.T) - wrld_pts = ray_dir.T*1e4 - assert np.all(np.isfinite(wrld_pts)), "World points contain non-finite values." - - colmap_camera = camera_from_camera_str[uv_str] - - if colmap_camera.model == 'OPENCV': - fx, fy, cx, cy, d1, d2, d3, d4 = colmap_camera.params - elif colmap_camera.model == 'PINHOLE': - fx, fy, cx, cy = colmap_camera.params - d1 = d2 = d3 = d4 = 0 - - K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) - dist = np.array([d1, d2, d3, d4], dtype=np.float32) - - flags = cv2.CALIB_ZERO_TANGENT_DIST - flags = flags | cv2.CALIB_USE_INTRINSIC_GUESS - flags = flags | cv2.CALIB_FIX_PRINCIPAL_POINT - flags = flags | cv2.CALIB_FIX_K1 - flags = flags | cv2.CALIB_FIX_K2 - flags = flags | cv2.CALIB_FIX_K3 - flags = flags | cv2.CALIB_FIX_K4 - flags = flags | cv2.CALIB_FIX_K5 - flags = flags | cv2.CALIB_FIX_K6 - - criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30000, - 0.0000001) - - ret = cv2.calibrateCamera([wrld_pts.astype(np.float32)], - [im_pts_uv.astype(np.float32)], - (colmap_camera.width, colmap_camera.height), - cameraMatrix=K.copy(), distCoeffs=dist.copy(), - flags=flags, criteria=criteria) - - err, _, _, rvecs, tvecs = ret - - R = np.identity(4) - R[:3, :3] = cv2.Rodrigues(rvecs[0])[0] - cam_quat = quaternion_from_matrix(R.T) - - # Only optimize 3/4 components of the quaternion. - static_quat_ind = np.argmax(np.abs(cam_quat)) - dynamic_quat_ind = [ i for i in range(4) if i != static_quat_ind ] - #static_quat_ind = 3 # Fixing the 'w' component - #dynamic_quat_ind = [0, 1, 2] # Optimizing 'x', 'y', 'z' components - dynamic_quat_ind = np.array(dynamic_quat_ind) - cam_quat = np.asarray(cam_quat) - cam_quat /= np.linalg.norm(cam_quat) - x0 = cam_quat[dynamic_quat_ind].copy() # [x, y, z] - - def get_cm(x): - """ - Create a camera model with updated quaternion and intrinsic parameters. - - Parameters: - - x: array-like, shape (N,) - Optimization variables where the first 3 elements correspond to - the dynamic quaternion components ('x', 'y', 'z'), optionally - followed by intrinsic parameters ('fx', 'fy', etc.). - - Returns: - - cm: StandardCamera instance - Updated camera model with new parameters. - """ - # Ensure 'x' has at least 3 elements for quaternion - assert len(x) > 2, "Optimization variable 'x' must have at least 3 elements for quaternion." - - # Validate 'x[:3]' are finite numbers - assert np.all(np.isfinite(x[:3])), "Quaternion components contain non-finite values." - - # Initialize quaternion with fixed 'w' component - cam_quat_new = np.ones(4) - - # Assign dynamic components from optimization variables - cam_quat_new[dynamic_quat_ind] = x[:3] - - # Normalize to ensure it's a unit quaternion - norm = np.linalg.norm(cam_quat_new) - assert norm > 1e-6, "Quaternion has zero or near-zero magnitude." - cam_quat_new /= norm - - # Extract intrinsic parameters - if len(x) > 3: - fx_ = x[3] - fy_ = x[4] - else: - fx_ = fx - fy_ = fy - - if len(x) > 5: - dist_ = x[5:] - else: - dist_ = dist - - # Construct the intrinsic matrix - K = np.array([[fx_, 0, cx], [0, fy_, cy], [0, 0, 1]]) - - # Create the camera model - cm = StandardCamera( - colmap_camera.width, - colmap_camera.height, - K, - dist_, - [0, 0, 0], - cam_quat_new, - platform_pose_provider=nav_state_fixed - ) - return cm - - def error(x): - try: - cm = get_cm(x) - projected_uv = cm.project(wrld_pts.T).T # Shape: (N, 2) - - # Compute Euclidean distances - err = np.sqrt(np.sum((im_pts_uv - projected_uv) ** 2, axis=1)) - - # Apply Huber loss - delta = 20 - ind = err < delta - err[ind] = err[ind] ** 2 - err[~ind] = 2 * (err[~ind] - delta / 2) * delta - - # Sort and trim the error - err = sorted(err)[:len(err) - len(err) // 5] - - # Compute mean error - mean_err = np.sqrt(np.mean(err)) - - # Add regularization term (e.g., L2 penalty) - reg_strength = 1e-3 # Adjust as needed - reg_term = reg_strength * np.linalg.norm(x[:3])**2 - - total_error = mean_err + reg_term - return total_error - except Exception as e: - print(f"Error in error function: {e}") - return np.inf # Assign a high error if computation fails - - # Optional: Define a callback function to monitor optimization - def callback(xk): - try: - cm = get_cm(xk) - projected_uv = cm.project(wrld_pts.T).T - err = np.sqrt(np.sum((im_pts_uv - projected_uv) ** 2, axis=1)) - mean_err = np.mean(err) - print(f"Current x: {xk}, Mean Error: {mean_err}") - except Exception as e: - print(f"Error in callback: {e}") - - def plot_results1(x): - cm = get_cm(x) - err = np.sqrt(np.sum((im_pts_uv - cm.project(wrld_pts.T).T)**2, 1)) - err = sorted(err) - plt.plot(np.linspace(0, 100, len(err)), err) - - print("Optimizing error for UV models.") - x = x0.copy() - # Example bounds for [x, y, z] components - bounds = [(-1.0, 1.0), # x - (-1.0, 1.0), # y - (-1.0, 1.0)] # z - print("First pass") - # Perform optimization on [x, y, z] - ret = minimize( - error, - x, - method='L-BFGS-B', - bounds=bounds, - callback=None, # Optional: Monitor progress - options={'disp': False, 'maxiter': 30000, 'ftol': 1e-7} - ) - assert ret.success, "Minimization of UV error failed." - x = np.hstack([ret.x, fx, fy]) - print("Second pass") - assert np.all(np.isfinite(x)), "Input quaternion with locked fx, fy, is not finite." - ret = minimize(error, x, method='Powell') - x = ret.x - print("Third pass") - assert np.all(np.isfinite(x)), "Input quaternion for BFGS is not finite." - ret = minimize(error, x, method='BFGS') - - print("Final pass") - if True: - x = np.hstack([ret.x, dist]) - ret = minimize(error, x, method='Powell'); x = ret.x - ret = minimize(error, x, method='BFGS'); x = ret.x - - assert np.all(np.isfinite(x)), "Input quaternion for final model is not finite." - cm_uv = get_cm(x) - cm_uv.save_to_file('%s/%s.yaml' % (save_dir, uv_str)) - - perform_error_analysis_and_save_pdf(cm_uv, cm_rgb, points_per_image, - save_dir, - uv_str, rgb_str) - gif_dir = osp.join(save_dir, 'registration_gifs') - write_gifs(gif_dir, colmap_dir, img_fnames, - fname_to_time_channel_modality, - basename_to_time, rgb_str, uv_str, - cm_rgb, cm_uv) - - -def perform_error_analysis_and_save_pdf(camera_model_uv, camera_model_rgb, - points_per_image, - save_dir, - uv_str, - rgb_str): - """ - Perform error analysis for both UV and RGB models and save all plots into a single PDF. - - Parameters: - - camera_model_uv: Calibrated UV camera model. - - camera_model_rgb: Calibrated RGB camera model. - - points_per_image_uv: List of tuples containing (image points, corresponding 3D points, timestamp) for UV. - - points_per_image_rgb: List of tuples containing (image points, corresponding 3D points, timestamp) for RGB. - - save_dir: Directory where the PDF will be saved. - - uv_str: String identifier for the UV camera. - - rgb_str: String identifier for the RGB camera. - """ - - # Initialize error lists for UV - err_meters_uv = [] - err_pixels_uv = [] - err_pixels_per_frame_uv = [] - err_angle_uv = [] - im_pts_uv = [] - im_pts_rgb = [] - - # Mean IFOV for UV (assuming similar to RGB) - ifov_uv = np.mean(camera_model_uv.ifov()) - - #import ipdb; ipdb.set_trace() - # Error analysis for UV - for fname, (xys, xyzs, t) in points_per_image.items(): - im_str = osp.basename(osp.dirname(fname)) - if im_str != uv_str: - continue - im_pts_uv.extend(xys) - # Project 3D points to 2D image points using UV model - xys2 = camera_model_uv.project(xyzs.T, t) - err_pixels_ = np.sqrt(np.sum((xys2 - xys.T)**2, axis=0)) - err_pixels_per_frame_uv.append([t, err_pixels_.mean()]) - err_pixels_uv.extend(err_pixels_.tolist()) - - # Unproject image points to camera rays - ray_pos, ray_dir = camera_model_uv.unproject(xys.T, t) - - # Compute direction from camera to 3D points - ray_dir2 = xyzs.T - ray_pos - dist = np.linalg.norm(ray_dir2, axis=0) - ray_dir2 /= dist - - # Calculate angular deviation - dp = np.clip(np.sum(ray_dir * ray_dir2, axis=0), -1, 1) - theta = np.arccos(dp) - err_angle_uv.extend(theta.tolist()) - - # Calculate orthogonal distance in meters - err_meters_uv.extend((np.sin(theta) * dist).tolist()) - - # Initialize error lists for RGB - err_meters_rgb = [] - err_pixels_rgb = [] - err_pixels_per_frame_rgb = [] - err_angle_rgb = [] - - # Mean IFOV for RGB - ifov_rgb = np.mean(camera_model_rgb.ifov()) - - # Error analysis for RGB - for fname, (xys, xyzs, t) in points_per_image.items(): - im_str = osp.basename(osp.dirname(fname)) - if im_str != rgb_str: - continue - im_pts_rgb.extend(xys) - # Project 3D points to 2D image points using RGB model - xys2 = camera_model_rgb.project(xyzs.T, t) - err_pixels_ = np.sqrt(np.sum((xys2 - xys.T)**2, axis=0)) - err_pixels_per_frame_rgb.append([t, err_pixels_.mean()]) - err_pixels_rgb.extend(err_pixels_.tolist()) - - # Unproject image points to camera rays - ray_pos, ray_dir = camera_model_rgb.unproject(xys.T, t) - - # Compute direction from camera to 3D points - ray_dir2 = xyzs.T - ray_pos - dist = np.linalg.norm(ray_dir2, axis=0) - ray_dir2 /= dist - - # Calculate angular deviation - dp = np.clip(np.sum(ray_dir * ray_dir2, axis=0), -1, 1) - theta = np.arccos(dp) - err_angle_rgb.extend(theta.tolist()) - - # Calculate orthogonal distance in meters - err_meters_rgb.extend((np.sin(theta) * dist).tolist()) - - # Convert lists to numpy arrays for easier manipulation - err_meters_uv = np.array(err_meters_uv) - err_pixels_uv = np.array(err_pixels_uv) - err_angle_uv = np.array(err_angle_uv) - err_pixels_per_frame_uv = np.array(err_pixels_per_frame_uv).T - - err_meters_rgb = np.array(err_meters_rgb) - err_pixels_rgb = np.array(err_pixels_rgb) - err_angle_rgb = np.array(err_angle_rgb) - err_pixels_per_frame_rgb = np.array(err_pixels_per_frame_rgb).T - - # Sort the errors - sorted_err_meters_uv = np.sort(err_meters_uv) - sorted_err_pixels_uv = np.sort(err_pixels_uv) - sorted_err_angle_uv = np.sort(err_angle_uv) - - sorted_err_meters_rgb = np.sort(err_meters_rgb) - sorted_err_pixels_rgb = np.sort(err_pixels_rgb) - sorted_err_angle_rgb = np.sort(err_angle_rgb) - - im_pts_uv = np.asarray(im_pts_uv) - im_pts_rgb = np.asarray(im_pts_rgb) - - # Initialize PdfPages object - pdf_filename = f"{save_dir}/error_analysis_{uv_str}_{rgb_str}.pdf" - with PdfPages(pdf_filename) as pdf: - # --- Plot 1: Summary Statistics for UV --- - plt.figure(figsize=(11.69, 8.27)) # A4 size in inches - plt.axis('off') # Hide axes - - summary_text_uv = f""" - Error Analysis Summary for UV Camera: {uv_str} - - Pixel Errors: - - Mean: {np.mean(err_pixels_uv):.2f} pixels - - Median: {np.median(err_pixels_uv):.2f} pixels - - Max: {np.max(err_pixels_uv):.2f} pixels - - Meter Errors: - - Mean: {np.mean(err_meters_uv):.2f} meters - - Median: {np.median(err_meters_uv):.2f} meters - - Max: {np.max(err_meters_uv):.2f} meters - - Angular Errors: - - Mean: {np.degrees(np.mean(err_angle_uv)):.2f} degrees - - Median: {np.degrees(np.median(err_angle_uv)):.2f} degrees - - Max: {np.degrees(np.max(err_angle_uv)):.2f} degrees - """ - - plt.text(0.5, 0.5, summary_text_uv, fontsize=20, ha='center', va='center', wrap=True) - plt.title('Error Analysis Summary for UV Camera', fontsize=24) - pdf.savefig() - plt.close() - - # --- Plot 2: Histogram of Pixel Errors for UV --- - plt.figure(figsize=(11.69, 8.27)) - plt.hist(sorted_err_pixels_uv, bins=50, color='blue', alpha=0.7) - plt.title('UV Camera - Pixel Errors', fontsize=24) - plt.xlabel('Error (pixels)', fontsize=20) - plt.ylabel('Frequency', fontsize=20) - plt.grid(True) - pdf.savefig() - plt.close() - - # --- Plot 3: Histogram of Meter Errors for UV --- - plt.figure(figsize=(11.69, 8.27)) - plt.hist(sorted_err_meters_uv, bins=50, color='green', alpha=0.7) - plt.title('UV Camera - Meter Errors', fontsize=24) - plt.xlabel('Error (meters)', fontsize=20) - plt.ylabel('Frequency', fontsize=20) - plt.grid(True) - pdf.savefig() - plt.close() - - # --- Plot 4: Histogram of Angular Errors for UV --- - plt.figure(figsize=(11.69, 8.27)) - plt.hist(np.degrees(sorted_err_angle_uv), bins=50, color='red', alpha=0.7) - plt.title('UV Camera - Angular Errors', fontsize=24) - plt.xlabel('Error (degrees)', fontsize=20) - plt.ylabel('Frequency', fontsize=20) - plt.grid(True) - pdf.savefig() - plt.close() - - # --- Plot 5: Average Pixel Error per Frame for UV --- - plt.figure(figsize=(11.69, 8.27)) - plt.plot(err_pixels_per_frame_uv[0], err_pixels_per_frame_uv[1], marker='o', linestyle='-', color='purple') - plt.title('UV Camera - Average Pixel Error per Frame', fontsize=24) - plt.xlabel('Frame Index or Timestamp', fontsize=20) - plt.ylabel('Average Pixel Error (pixels)', fontsize=20) - plt.grid(True) - pdf.savefig() - plt.close() - - # --- Plot 6: Scatter Plot of UV and RGB Points --- - plt.figure(figsize=(11.69, 8.27)) - plt.subplot(1, 2, 1) - plt.scatter(im_pts_uv[:, 0], im_pts_uv[:, 1], c='blue', marker='o', alpha=0.5, label='UV Observed') - plt.title('UV Camera - Observed UV Points', fontsize=24) - plt.xlabel('U', fontsize=20) - plt.ylabel('V', fontsize=20) - plt.legend() - plt.grid(True) - - plt.subplot(1, 2, 2) - plt.scatter(im_pts_rgb[:, 0], im_pts_rgb[:, 1], c='green', marker='x', alpha=0.5, label='RGB Observed') - plt.title('RGB Camera - Observed RGB Points', fontsize=24) - plt.xlabel('R', fontsize=20) - plt.ylabel('G', fontsize=20) - plt.legend() - plt.grid(True) - - plt.suptitle('Scatter Plots of Observed Points', fontsize=28) - plt.tight_layout(rect=[0, 0.03, 1, 0.95]) - pdf.savefig() - plt.close() - - # --- Plot 7: Reprojection Error Histograms for Both UV and RGB --- - plt.figure(figsize=(11.69, 8.27)) - plt.subplot(1, 2, 1) - plt.hist(sorted_err_pixels_uv, bins=50, color='blue', alpha=0.7, label='UV Pixel Errors') - plt.hist(sorted_err_pixels_rgb, bins=50, color='red', alpha=0.5, label='RGB Pixel Errors') - plt.title('Reprojection Pixel Errors', fontsize=24) - plt.xlabel('Error (pixels)', fontsize=20) - plt.ylabel('Frequency', fontsize=20) - plt.legend() - plt.grid(True) - - plt.subplot(1, 2, 2) - plt.hist(np.degrees(sorted_err_angle_uv), bins=50, color='blue', alpha=0.7, label='UV Angular Errors') - plt.hist(np.degrees(sorted_err_angle_rgb), bins=50, color='red', alpha=0.5, label='RGB Angular Errors') - plt.title('Reprojection Angular Errors', fontsize=24) - plt.xlabel('Error (degrees)', fontsize=20) - plt.ylabel('Frequency', fontsize=20) - plt.legend() - plt.grid(True) - - plt.suptitle('Reprojection Error Histograms for UV and RGB Cameras', fontsize=28) - plt.tight_layout(rect=[0, 0.03, 1, 0.95]) - pdf.savefig() - plt.close() - - -def process_aligned_results(aligned_sparse_recon_subdir, colmap_dir, save_dir, - nav_state_provider, basename_to_time): - # --------------------------------------------------------------------------- - # Sanity check, pick the coordinates for a point in the 3-D model and - # convert them to latitude and longitude. - enu = np.array((640.446167, 822.111633, -9.576390)) - print(enu_to_llh(enu[0], enu[1], enu[2], nav_state_provider.lat0, - nav_state_provider.lon0, nav_state_provider.h0)) - - # Read in the Colmap details of all images. - images_bin_fname = osp.join(colmap_dir, - aligned_sparse_recon_subdir, - 'images.bin') - colmap_images = read_images_binary(images_bin_fname) - points_bin_fname = osp.join(colmap_dir, - aligned_sparse_recon_subdir, - 'points3D.bin') - points3d = read_points3D_binary(points_bin_fname) - camera_bin_fname = osp.join(colmap_dir, - aligned_sparse_recon_subdir, - 'cameras.bin') - colmap_cameras = read_cameras_binary(camera_bin_fname) - - """ - # For sanity checking that the original unadjusted results line up and the - # code itself is sound. - images_bin_fname = '%s/%s/images.bin' % (colmap_dir, sparse_recon_subdir) - colmap_images = read_images_binary(images_bin_fname) - points_bin_fname = '%s/%s/points3D.bin' % (colmap_dir, sparse_recon_subdir) - points3d = read_points3D_binary(points_bin_fname) - camera_bin_fname = '%s/%s/cameras.bin' % (colmap_dir, sparse_recon_subdir) - colmap_cameras = read_cameras_binary(camera_bin_fname) - """ - - if False: - pts_3d = [] - for pt_id in points3d: - pts_3d.append(points3d[pt_id].xyz) - - pts_3d = np.array(pts_3d).T - plt.plot(pts_3d[0], pts_3d[1], 'ro') - - - points_per_image, camera_from_camera_str = get_colmap_data(colmap_images, - colmap_cameras, - points3d, - basename_to_time) - - img_fnames, img_times, ins_poses, sfm_poses, llhs = process_images(colmap_images, - basename_to_time, - nav_state_provider) - - if False: - # Loop over all images and apply the camera model to project 3-D points - # into the image and compare to the measured versions to calculate - # reprojection error. - err = [] - for i in range(len(img_fnames)): - print('%i/%i' % (i + 1, len(img_fnames))) - fname = img_fnames[i] - sfm_pose = sfm_poses[i] - camera_str = osp.basename(osp.dirname(fname)) - - colmap_camera = camera_from_camera_str[camera_str] - - if colmap_camera.model == 'OPENCV': - fx, fy, cx, cy, d1, d2, d3, d4 = colmap_camera.params - elif colmap_camera.model == 'PINHOLE': - fx, fy, cx, cy = colmap_camera.params - d1 = d2 = d3 = d4 = 0 - - K = K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) - dist = np.array([d1, d2, d3, d4]) - - cm = StandardCamera(colmap_camera.width, colmap_camera.height, K, dist, - [0, 0, 0], [0, 0, 0, 1], - platform_pose_provider=NavStateFixed(*sfm_pose)) - xy, xyz, t = points_per_image[fname] - err_ = np.sqrt(np.sum((xy - cm.project(xyz.T, t).T)**2, axis=1)) - err = err + err_.tolist() - - print("Errors: ") - print(np.mean(err)) - print(np.median(err)) - #plt.hist(err, 1000) - - - camera_strs = set([osp.basename(osp.dirname(fname)) for fname in img_fnames]) - rgb_camera_strs = set([ cam for cam in camera_strs if 'rgb' in cam ]) - uv_camera_strs = set([ cam for cam in camera_strs if 'uv' in cam ]) - - print("Calibrating RGB cameras.") - calibrate_rgb(rgb_camera_strs, img_fnames, ins_poses, sfm_poses, - points_per_image, camera_from_camera_str, - nav_state_provider, save_dir) - - time_to_modality = create_time_modality_mapping(colmap_images, basename_to_time) - fname_to_time_channel_modality = create_fname_to_time_channel_modality( - img_fnames, basename_to_time) - - print("Calibrating UV cameras.") - calibrate_uv(uv_camera_strs, img_fnames, colmap_images, - camera_from_camera_str, save_dir, - basename_to_time, time_to_modality, - fname_to_time_channel_modality, - colmap_dir, points_per_image) - print("Finished calibration!") - - -def main(): - # ---------------------------- Define Paths ---------------------------------- - # KAMERA flight directory where each sub-directory contains meta.json files. - flight_dir = '/home/local/KHQ/adam.romlein/noaa/data/2024_AOC_AK_Calibration/fl09' - - # You should have a colmap directory where all of the Colmap-generated files - # reside. - colmap_dir = '/home/local/KHQ/adam.romlein/noaa/data/2024_AOC_AK_Calibration/colmap' - - # Sub-directory containing the images.bin and cameras.bin. Set to '' if in the - # top-level Colmap directory. - sparse_recon_subdir = 'sparse/1' - aligned_sparse_recon_subdir = 'aligned/1' - - # Location to save KAMERA camera models. - save_dir = osp.join(flight_dir, 'kamera_models') - # ---------------------------------------------------------------------------- - - basename_to_time = get_basename_to_time(flight_dir) - - json_glob = pathlib.Path(flight_dir).rglob('*_meta.json') - try: - next(json_glob) - except StopIteration: - raise SystemExit("No meta jsons were found, please check your filepaths.") - nav_state_provider = NavStateINSJson(json_glob) - - # We take the INS-reported position (converted from latitude, longitude, and - # altitude into easting/northing/up coordinates) and assign it to each image. - print('Latiude of ENU coordinate system:', nav_state_provider.lat0, 'degrees') - print('Longitude of ENU coordinate system:', nav_state_provider.lon0, - 'degrees') - print('Height above the WGS84 ellipsoid of the ENU coordinate system:', - nav_state_provider.h0, 'meters') - - # ---------------------------------------------------------------------------- - # Assemble the list of filenames with paths relative to the 'images0' directory - # that we point Colmap to as the raw image directory. This may be a directory - # of images, or it might be a directory of subdirectories, each of which - # contains images from one camera. - - # Colmap then uses this pairing to solve for a similarity transform to best- - # match the SfM poses it recovered into these positions. All Colmap coordinates - # in this aligned version of its reconstruction will then be in easting/ - # northing/up meters coordinates - align_fname = os.path.join(colmap_dir, 'image_locations.txt') - print(align_fname) - if osp.exists(align_fname) and osp.exists(osp.join(colmap_dir, - aligned_sparse_recon_subdir)): - print(f"{align_fname} and {aligned_sparse_recon_subdir} exists," - " assuming model is aligned.") - else: - # Read in the Colmap details of all images. - images_bin_fname = osp.join(colmap_dir, sparse_recon_subdir, 'images.bin') - colmap_images = read_images_binary(images_bin_fname) - - img_fnames, img_times, ins_poses, sfm_poses, llhs = process_images(colmap_images, - basename_to_time, - nav_state_provider) - write_image_locations(align_fname, img_fnames, ins_poses) - ub.ensuredir(osp.join(colmap_dir, aligned_sparse_recon_subdir)) - print('Now run\nkamera/src/kitware-ros-pkg/postflight_scripts/scripts/' - 'colmap/model_aligner.sh %s %s %s %s' % (colmap_dir.replace('/host_filesystem', ''), - sparse_recon_subdir, - 'image_locations.txt', - aligned_sparse_recon_subdir)) - return - - process_aligned_results(aligned_sparse_recon_subdir, colmap_dir, - save_dir, nav_state_provider, - basename_to_time) - -if __name__ == "__main__": - main() diff --git a/kamera/postflight/scripts/intercam_homography_from_yaml.py b/kamera/postflight/scripts/intercam_homography_from_yaml.py deleted file mode 100644 index 928248b5..00000000 --- a/kamera/postflight/scripts/intercam_homography_from_yaml.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python -""" -Library handling projection operations of a standard camera model. -""" -from __future__ import division, print_function -import cv2 -import time -import os -import copy -import glob -import random -import json -import PIL -import numpy as np -import matplotlib.pyplot as plt -from numpy import pi -from mpl_toolkits.mplot3d import Axes3D -from scipy.optimize import minimize - -# Custom package imports. -from sensor_models import ( - quaternion_multiply, - quaternion_from_matrix, - quaternion_from_euler, - quaternion_slerp, - quaternion_inverse, - quaternion_matrix - ) -from colmap_processing.camera_models import load_from_file, StandardCamera -from colmap_processing.image_renderer import render_view - - -def process(camera_model_fname1, camera_model_fname2, out_fname): - src_cm = load_from_file(camera_model_fname1) - dst_cm = load_from_file(camera_model_fname2) - - x = np.linspace(0, dst_cm.width-1, dst_cm.width//2) - y = np.linspace(0, dst_cm.height-1, dst_cm.height//2) - X,Y = np.meshgrid(x, y) - im_pts = np.vstack([X.ravel(),Y.ravel()]) - - # Unproject rays into camera coordinate system. - _, ray_dir = dst_cm.unproject(im_pts, 0) - points = ray_dir*1e6 - - im_pts_src = src_cm.project(points, 0).astype(np.float32) - - # Remove coordinates outside of src camera. - ind = np.logical_and(im_pts_src[0] >= 0, im_pts_src[0] <= src_cm.width) - ind = np.logical_and(ind, im_pts_src[1] >= 0) - ind = np.logical_and(ind, im_pts_src[1] <= src_cm.height) - - im_pts = im_pts[:, ind] - im_pts_src = im_pts_src[:, ind] - - h, status = cv2.findHomography(im_pts_src.T, im_pts.T) - - # Error in using homography to represent transformation. - im_pts2 = np.dot(h, np.vstack([im_pts_src, np.ones(im_pts_src.shape[1])])) - im_pts2 = im_pts2[:2]/im_pts2[2] - err_forward = np.sqrt(np.sum((im_pts2 - im_pts)**2, axis=0)) - - im_pts2 = np.dot(np.linalg.inv(h), - np.vstack([im_pts, np.ones(im_pts.shape[1])])) - im_pts2 = im_pts2[:2]/im_pts2[2] - err_reverse = np.sqrt(np.sum((im_pts2 - im_pts_src)**2, axis=0)) - - base_dir, base_fname = os.path.split(out_fname) - base_fname = os.path.splitext(base_fname)[0] - - try: - os.makedirs(base_dir) - except (IOError, OSError): - pass - - np.savetxt(out_fname, h) - - fig = plt.figure(num=None, figsize=(15.3, 10.7), dpi=80) - plt.rc('font', **{'size': 40}) - plt.rc('axes', linewidth=4) - plt.subplot(121) - plt.plot(np.linspace(0, 100, len(err_forward)), np.sort(err_forward), - linewidth=6) - plt.xlabel('Percentile', fontsize=50) - plt.ylabel('Error (pixels)', fontsize=50) - plt.title('Forward', fontsize=50) - ax = plt.subplot(122) - plt.plot(np.linspace(0, 100, len(err_reverse)), np.sort(err_reverse), - linewidth=6) - plt.xlabel('Percentile', fontsize=50) - plt.ylabel('Error (pixels)', fontsize=50) - plt.title('Reverse', fontsize=50) - ax.yaxis.tick_right() - ax.yaxis.set_label_position("right") - fig.subplots_adjust(bottom=0.13) - fig.subplots_adjust(top=0.93) - fig.subplots_adjust(right=0.85) - fig.subplots_adjust(left=0.12) - plt.savefig('%s/%s_homog_approx_error.png' % (base_dir, base_fname)) - - -# Process all cameras. -base_dir = '/host_filesystem/mnt/homenas2/kamera/Calibration/fl08/kamera_models' -for dirname in os.listdir(base_dir): - if not os.path.isdir('%s/%s' % (base_dir, dirname)): - continue - - fnames = glob.glob('%s/%s/*_rgb.yaml' % (base_dir, dirname)) - if len(fnames) != 1: - continue - - rgb_camera_model_fname = fnames[0] - - fnames = glob.glob('%s/%s/*_uv.yaml' % (base_dir, dirname)) - if len(fnames) != 1: - continue - - uv_camera_model_fname = fnames[0] - - out_fname = '%s/%s/%s_to_%s_homography.txt' % (base_dir, dirname, 'uv', 'rgb') - process(uv_camera_model_fname, rgb_camera_model_fname, out_fname) - - out_fname = '%s/%s/%s_to_%s_homography.txt' % (base_dir, dirname, 'rgb', 'uv') - process(rgb_camera_model_fname, uv_camera_model_fname, out_fname) - - fnames = glob.glob('%s/%s/*_ir.yaml' % (base_dir, dirname)) - if len(fnames) != 1: - continue - - ir_camera_model_fname = fnames[0] - - print('Processing', dirname) - - out_fname = '%s/%s/%s_to_%s_homography.txt' % (base_dir, dirname, 'ir', 'rgb') - process(ir_camera_model_fname, rgb_camera_model_fname, out_fname) - - out_fname = '%s/%s/%s_to_%s_homography.txt' % (base_dir, dirname, 'rgb', 'ir') - process(rgb_camera_model_fname, ir_camera_model_fname, out_fname) diff --git a/kamera/sensor_models/nav_conversions.py b/kamera/sensor_models/nav_conversions.py index 6246e3b1..4c45c2b9 100644 --- a/kamera/sensor_models/nav_conversions.py +++ b/kamera/sensor_models/nav_conversions.py @@ -464,7 +464,7 @@ def geocentric_rotation(sphi, cphi, slam, clam): def sincosd(x): - """ + r""" * Evaluate the sine and cosine function with the argument in degrees * * @tparam T the type of the arguments. diff --git a/pyproject.toml b/pyproject.toml index 81dcbf04..ed6ae424 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,11 @@ dependencies = [ "rich>=13.9.1", ] -# GDAL has no reliable cross-platform wheels; it comes from conda-forge -# (environment.yml) and reaches .venv via --system-site-packages. +# GDAL and pycolmap (>=4.2, CUDA build) have no reliable cross-platform wheels; they +# come from conda-forge (environment.yml) and reach .venv via --system-site-packages. + +[project.scripts] +kamera-calibrate = "kamera.calibration.cli:main" [dependency-groups] dev = [ From 53c628afeee0ba58bcdeb9f6320afd456f48fcc2 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Wed, 16 Sep 2026 10:27:09 -0400 Subject: [PATCH 10/32] Fit homographies at a ground range and report implied exposure delays Cameras whose exposure midpoint does not coincide with the trigger sit an effective metre or so along track in the rig model (bundle adjustment cannot tell a delay from a lever arm on a translating rig), and a homography fit at infinity drops that baseline. Fit each pair for a nominal ground range instead (--registration_range_m, default the calibration flight's median scene range), read each camera's forward offset back into an exposure delay in rig.yaml and the report, and only write GIFs for frames the rig model registered. --- kamera/calibration/cli.py | 8 ++++++-- kamera/calibration/config.py | 1 + kamera/calibration/registration.py | 16 +++++++-------- kamera/calibration/report.py | 31 +++++++++++++++++++----------- kamera/calibration/rig.py | 20 ++++++++++++++++--- 5 files changed, 52 insertions(+), 24 deletions(-) diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index 66e03661..1ddedf51 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -80,6 +80,9 @@ def done(path: str) -> bool: print("[blue]Fitting homographies and writing DIVE registration files[/blue]") reg_dir = os.path.join(model_dir, "dive_registration") cams = {n: StandardCamera(c.width, c.height, c.K, c.dist, cal.camera_position(n), cal.camera_quaternion(n)) for n, c in cal.cameras.items()} + range_m = cfg.registration_range_m or cal.scene_range_m + registered = {names[im.name][1] for im in model.images.values() if im.has_pose} + print(f" homographies exact at {range_m:.0f} m range; ground speed {cal.ground_speed_mps:.0f} m/s") pairs = [] for channel in sorted({n.split("_")[0] for n in cams}): for left_mod, right_mod in PAIRS: @@ -87,13 +90,14 @@ def done(path: str) -> bool: if left not in cams or right not in cams: continue try: - h, stats = registration.model_homography(cams[left], cams[right]) + h, stats = registration.model_homography(cams[left], cams[right], range_m) except ValueError as e: print(f" [yellow]{left} -> {right}: {e}[/yellow]") continue path = registration.write_dive_registration(reg_dir, left, right, h, stats, registration.source_stamp(cfg.flight_dir, {"rig": rig_name})) print(f" wrote {path} (fit rms {stats['rmsPx']:.2f} px)") - pairs.append({"left": left, "right": right, "h": h, "stats": stats, **write_gifs(frames, names, image_dir, left, right, h, os.path.join(model_dir, "gifs"), cfg.gif_frames)}) + gif_frames = [f for f in frames if f.time in registered] + pairs.append({"left": left, "right": right, "h": h, "stats": stats, **write_gifs(gif_frames, names, image_dir, left, right, h, os.path.join(model_dir, "gifs"), cfg.gif_frames)}) report_path = os.path.join(model_dir, f"{rig_name}_calibration_report.pdf") write_report(report_path, cal, pairs) diff --git a/kamera/calibration/config.py b/kamera/calibration/config.py index 1d662a68..b3fc4324 100644 --- a/kamera/calibration/config.py +++ b/kamera/calibration/config.py @@ -20,5 +20,6 @@ class CalibrateConfig(scfg.DataConfig): match_distance_m = scfg.Value(250.0, help="Spatial matching radius from INS positions") match_neighbors = scfg.Value(90, help="Spatial matching neighbours per image (about 10 frames times the number of cameras)") prior_std_m = scfg.Value(2.0, help="Standard deviation assigned to INS position priors") + registration_range_m = scfg.Value(0.0, help="Ground range the homographies are exact at; 0 = median scene range of the calibration model. Set to the survey AGL") gif_frames = scfg.Value(5, help="Registration GIFs written per camera pair") force = scfg.Value(False, isflag=True, help="Rerun stages whose outputs already exist") diff --git a/kamera/calibration/registration.py b/kamera/calibration/registration.py index f962afed..3bc6ccd4 100644 --- a/kamera/calibration/registration.py +++ b/kamera/calibration/registration.py @@ -2,9 +2,10 @@ Each ``_to__registration.json`` holds one matrix-only pair whose ``leftToRight`` homography maps left-camera pixels onto right-camera pixels. The -matrix is fit to the calibrated models by casting a grid of left pixels to -effective infinity and projecting them into the right camera, so it is exact up to -lens distortion and the (negligible) rig baseline; the fit residual is reported. +matrix is fit to the calibrated models by casting a grid of left pixels to a nominal +ground range and projecting them into the right camera. The range matters: cameras +whose exposure lags the trigger sit an effective metre or so along track, and that +baseline only vanishes at infinity. The fit residual is reported. """ from __future__ import annotations @@ -19,22 +20,21 @@ DIVE_TYPE = "dive-camera-registration" DIVE_VERSION = 2 -RAY_DISTANCE = 1e6 -def model_homography(src_cm, dst_cm, grid: int = 40) -> tuple[np.ndarray, dict]: - """Least-squares homography from ``src_cm`` pixels to ``dst_cm`` pixels, plus fit stats.""" +def model_homography(src_cm, dst_cm, range_m: float, grid: int = 40) -> tuple[np.ndarray, dict]: + """Least-squares homography from ``src_cm`` pixels to ``dst_cm`` pixels for ground ``range_m`` away, plus fit stats.""" xg, yg = np.meshgrid(np.linspace(0, src_cm.width - 1, grid), np.linspace(0, src_cm.height - 1, grid)) src = np.vstack([xg.ravel(), yg.ravel()]) ray_pos, ray_dir = src_cm.unproject(src, -np.inf) - dst = np.asarray(dst_cm.project(ray_pos + ray_dir * RAY_DISTANCE, -np.inf), dtype=np.float64) + dst = np.asarray(dst_cm.project(ray_pos + ray_dir * range_m, -np.inf), dtype=np.float64) inside = np.all(np.isfinite(dst), 0) & (dst[0] >= 0) & (dst[0] <= dst_cm.width) & (dst[1] >= 0) & (dst[1] <= dst_cm.height) if inside.sum() < 4: raise ValueError(f"only {inside.sum()} of {src.shape[1]} samples land in the destination image") h, _ = cv2.findHomography(src[:, inside].T, dst[:, inside].T, 0) err = np.linalg.norm(cv2.perspectiveTransform(src[:, inside].T.reshape(-1, 1, 2), h).reshape(-1, 2) - dst[:, inside].T, axis=1) stats = {"rmsPx": float(np.sqrt(np.mean(err**2))), "p95Px": float(np.percentile(err, 95)), - "maxPx": float(np.max(err)), "coverage": float(inside.mean())} + "maxPx": float(np.max(err)), "coverage": float(inside.mean()), "rangeM": float(range_m)} return h, stats diff --git a/kamera/calibration/report.py b/kamera/calibration/report.py index 29328fa8..1bd1827e 100644 --- a/kamera/calibration/report.py +++ b/kamera/calibration/report.py @@ -30,14 +30,23 @@ the relative camera geometry (and therefore the homographies) is far better determined than the absolute boresight. -Lever arms. At the flight ranges of 400 to 900 m a 30 cm baseline subtends less than one IR -pixel, so the rig translations are estimated only weakly and the reported standard -deviations should be read as such. The INS lever arm is likewise noise dominated; it is the -median offset of the rig origin from the INS position over all frames. - -Homographies. A single homography represents the model-to-model mapping exactly only for a -pure rotation with no lens distortion. The fit residual (rms and p95, in right-image pixels) -quantifies what the distortion costs; the warped overlays show it visually. +Exposure timing. A camera whose exposure midpoint lags the shared trigger sees the ground +further along track by ground speed x delay, and a bundle adjustment on a translating rig +cannot tell that from a camera mounted that far forward. The rig table's "delay ms" column +reads the forward offset of each camera back into a delay at the flight's ground speed; an +IR core with a 30 ms integration shows about 15 ms. The camera yaml positions carry this +offset, which is correct at similar ground speeds. + +Lever arms. Beyond that timing signal, at 400 to 900 m a 30 cm baseline subtends less than one +IR pixel, so the rig translations are weakly determined and the reported standard deviations +should be read as such. The INS lever arm is the median offset of the rig origin from the INS +position over all frames. + +Homographies. A homography maps one camera onto another exactly only for a plane at one +range, and the timing baseline above makes the range matter. Each pair is fit for the range +in its title (the survey AGL if given, else the calibration flight's median scene range); the +fit residual (rms and p95, in right-image pixels) then measures the lens distortion a single +matrix cannot carry, and the warped overlays show it visually. """ @@ -81,8 +90,8 @@ def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: for name in sorted(cal.cameras): rel = ref.rig_from_cam.inv() * cal.cameras[name].rig_from_cam rv, c = rel.as_rotvec(degrees=True), cal.cameras[name].center_in_rig - rows.append([name, f"{np.degrees(rel.magnitude()):.3f}", f"{rv[0]:+.3f} {rv[1]:+.3f} {rv[2]:+.3f}", f"{c[0]:+.2f} {c[1]:+.2f} {c[2]:+.2f}"]) - t = ax.table(cellText=rows, colLabels=["camera", "angle deg", "rotvec deg (ref axes)", "centre m (rig)"], loc="center", cellLoc="center", colWidths=[0.16, 0.16, 0.4, 0.36]) + rows.append([name, f"{np.degrees(rel.magnitude()):.3f}", f"{rv[0]:+.3f} {rv[1]:+.3f} {rv[2]:+.3f}", f"{c[0]:+.2f} {c[1]:+.2f} {c[2]:+.2f}", f"{cal.implied_delay_ms(name):+.0f}"]) + t = ax.table(cellText=rows, colLabels=["camera", "angle deg", "rotvec deg (ref axes)", "centre m (rig)", "delay ms"], loc="center", cellLoc="center", colWidths=[0.14, 0.14, 0.36, 0.3, 0.14]) t.auto_set_font_size(False) t.set_fontsize(7.5) t.scale(1, 1.6) @@ -125,7 +134,7 @@ def boresight_page(pdf: PdfPages, cal: RigCalibration) -> None: def homography_page(pdf: PdfPages, pair: dict) -> None: s = pair["stats"] fig = plt.figure(figsize=PAGE) - fig.suptitle(f"{pair['left']} -> {pair['right']}: fit rms {s['rmsPx']:.2f} px, p95 {s['p95Px']:.2f} px, max {s['maxPx']:.2f} px, " + fig.suptitle(f"{pair['left']} -> {pair['right']} at {s['rangeM']:.0f} m: fit rms {s['rmsPx']:.2f} px, p95 {s['p95Px']:.2f} px, max {s['maxPx']:.2f} px, " f"coverage {100 * s['coverage']:.0f}%", fontsize=11, weight="bold") for i, (key, title) in enumerate([("warped_img", f"{pair['left']} warped into {pair['right']}"), ("right_img", pair["right"]), ("overlay_img", "overlay (magenta/green)")]): ax = fig.add_subplot(1, 3, i + 1) diff --git a/kamera/calibration/rig.py b/kamera/calibration/rig.py index c36d2e05..51f7f39b 100644 --- a/kamera/calibration/rig.py +++ b/kamera/calibration/rig.py @@ -49,6 +49,8 @@ class RigCalibration: rotation_residual_deg: np.ndarray # (N, 3) rotvec of each frame's boresight about the mean, rig axes position_residual_m: np.ndarray # (N, 3) rig origin relative to INS, body axes, minus the lever arm ins_gap_s: np.ndarray # (N,) staleness of the INS sample behind each frame + ground_speed_mps: float + scene_range_m: float # median distance from the reference camera to its 3D points inlier: np.ndarray = field(default_factory=lambda: np.zeros(0, bool)) def camera_quaternion(self, name: str) -> np.ndarray: @@ -58,6 +60,10 @@ def camera_quaternion(self, name: str) -> np.ndarray: def camera_position(self, name: str) -> np.ndarray: return self.lever_arm_m + self.ins_from_rig.apply(self.cameras[name].center_in_rig) + def implied_delay_ms(self, name: str) -> float: + """Exposure delay after the trigger that a camera's forward offset from the reference implies.""" + return 1000.0 * float(self.ins_from_rig.apply(self.cameras[name].center_in_rig)[0]) / self.ground_speed_mps + def per_camera_reprojection(model: pc.Reconstruction, names: dict) -> dict[str, list[float]]: errors: dict[str, list[float]] = {} @@ -110,10 +116,15 @@ def calibrate_rig(model: pc.Reconstruction, names: dict, ins: InsTrajectory, ref rotations = Rotation.from_quat(np.array(ins_from_rig)[order]) lever = np.array(lever)[order] mean_rot, mean_lever, _, keep = robust_mean(rotations, lever) + positions = np.array([ins.pose(t)[0] for t in np.array(times)[order]]) + speed = float(np.median(np.linalg.norm(np.diff(positions, axis=0), axis=1) / np.diff(np.array(times)[order]))) + ranges = [np.linalg.norm(model.points3D[p.point3D_id].xyz - im.projection_center()) for im in model.images.values() + if im.has_pose and names[im.name][0] == reference for p in im.points2D[::50] if p.has_point3D()] return RigCalibration( rig=rig_name, flight=flight, reference=reference, cameras=cameras, ins_from_rig=mean_rot, lever_arm_m=mean_lever, frame_times=np.array(times)[order], rotation_residual_deg=(mean_rot.inv() * rotations).as_rotvec(degrees=True), - position_residual_m=lever - mean_lever, ins_gap_s=np.array(gaps)[order], inlier=keep) + position_residual_m=lever - mean_lever, ins_gap_s=np.array(gaps)[order], ground_speed_mps=speed, + scene_range_m=float(np.median(ranges)), inlier=keep) def _floats(a) -> list[float]: @@ -150,13 +161,15 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: rel = ref.rig_from_cam.inv() * cam.rig_from_cam cams[name] = {"cam_from_rig": {"quaternion_xyzw": _floats(cam.cam_from_rig.rotation.quat), "translation_m": _floats(cam.cam_from_rig.translation)}, "rotation_from_reference_deg": _floats(rel.as_rotvec(degrees=True)), "angle_from_reference_deg": float(np.degrees(rel.magnitude())), - "centre_in_rig_m": _floats(cam.center_in_rig), "frames": cam.frames, "reprojection_rms_px": cam.reproj_rms_px} + "centre_in_rig_m": _floats(cam.center_in_rig), "centre_in_ins_body_m": _floats(cal.ins_from_rig.apply(cam.center_in_rig)), + "implied_exposure_delay_ms": cal.implied_delay_ms(name), "frames": cam.frames, "reprojection_rms_px": cam.reproj_rms_px} keep = cal.inlier res = np.linalg.norm(cal.rotation_residual_deg[keep], axis=1) body = { "rig": cal.rig, "flight": cal.flight, "reference_camera": cal.reference, "generated": datetime.datetime.now(datetime.timezone.utc).date().isoformat(), "ins_from_rig": {"quaternion_xyzw": _floats(cal.ins_from_rig.as_quat()), "rotvec_deg": _floats(cal.ins_from_rig.as_rotvec(degrees=True)), "euler_zyx_deg": _floats(cal.ins_from_rig.as_euler("ZYX", degrees=True)), "lever_arm_m": _floats(cal.lever_arm_m)}, + "flight_stats": {"ground_speed_mps": cal.ground_speed_mps, "scene_range_m": cal.scene_range_m}, "boresight_quality": {"frames": int(keep.sum()), "frames_rejected": int((~keep).sum()), "rotation_scatter_deg": {"median": float(np.median(res)), "p90": float(np.percentile(res, 90)), "max": float(res.max())}, "rotation_axis_std_deg": _floats(cal.rotation_residual_deg[keep].std(0)), @@ -165,7 +178,8 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: "cameras": cams, } with open(path, "w") as f: - f.write("# Rig geometry (cam_from_rig maps rig -> camera, COLMAP convention) and INS boresight (ins_from_rig maps rig -> INS body).\n") + f.write("# Rig geometry (cam_from_rig maps rig -> camera, COLMAP convention) and INS boresight (ins_from_rig maps rig -> INS body).\n" + "# A camera whose exposure lags the trigger sits ahead along track by speed x delay; implied_exposure_delay_ms reads that off.\n") yaml.safe_dump(body, f, sort_keys=False) From f473cf826224494f4cb9dbb0a8187d2f7ed80b95 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Wed, 16 Sep 2026 10:37:46 -0400 Subject: [PATCH 11/32] Report exposure timing relative to the reference camera Only the relative exposure midpoints are observable: the position priors absorb any delay common to the rig. Rename the field and fix the report text. --- kamera/calibration/report.py | 15 ++++++++------- kamera/calibration/rig.py | 10 +++++++--- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/kamera/calibration/report.py b/kamera/calibration/report.py index 1bd1827e..9ada1557 100644 --- a/kamera/calibration/report.py +++ b/kamera/calibration/report.py @@ -30,12 +30,13 @@ the relative camera geometry (and therefore the homographies) is far better determined than the absolute boresight. -Exposure timing. A camera whose exposure midpoint lags the shared trigger sees the ground -further along track by ground speed x delay, and a bundle adjustment on a translating rig -cannot tell that from a camera mounted that far forward. The rig table's "delay ms" column -reads the forward offset of each camera back into a delay at the flight's ground speed; an -IR core with a 30 ms integration shows about 15 ms. The camera yaml positions carry this -offset, which is correct at similar ground speeds. +Exposure timing. A camera whose exposure midpoint differs from the reference camera's sees +the ground further along track by ground speed x time difference, and a bundle adjustment on a +translating rig cannot tell that from a camera mounted that far forward. The rig table's +"exposure vs ref" column reads each camera's forward offset back into a time difference at +the flight's ground speed (negative = earlier than the reference). Only the relative timing is +observable: the position priors absorb any delay shared by the whole rig. The camera yaml +positions carry these offsets, which is correct at similar ground speeds. Lever arms. Beyond that timing signal, at 400 to 900 m a 30 cm baseline subtends less than one IR pixel, so the rig translations are weakly determined and the reported standard deviations @@ -91,7 +92,7 @@ def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: rel = ref.rig_from_cam.inv() * cal.cameras[name].rig_from_cam rv, c = rel.as_rotvec(degrees=True), cal.cameras[name].center_in_rig rows.append([name, f"{np.degrees(rel.magnitude()):.3f}", f"{rv[0]:+.3f} {rv[1]:+.3f} {rv[2]:+.3f}", f"{c[0]:+.2f} {c[1]:+.2f} {c[2]:+.2f}", f"{cal.implied_delay_ms(name):+.0f}"]) - t = ax.table(cellText=rows, colLabels=["camera", "angle deg", "rotvec deg (ref axes)", "centre m (rig)", "delay ms"], loc="center", cellLoc="center", colWidths=[0.14, 0.14, 0.36, 0.3, 0.14]) + t = ax.table(cellText=rows, colLabels=["camera", "angle deg", "rotvec deg (ref axes)", "centre m (rig)", "exposure vs ref ms"], loc="center", cellLoc="center", colWidths=[0.14, 0.14, 0.36, 0.3, 0.14]) t.auto_set_font_size(False) t.set_fontsize(7.5) t.scale(1, 1.6) diff --git a/kamera/calibration/rig.py b/kamera/calibration/rig.py index 51f7f39b..a5e789e8 100644 --- a/kamera/calibration/rig.py +++ b/kamera/calibration/rig.py @@ -61,7 +61,11 @@ def camera_position(self, name: str) -> np.ndarray: return self.lever_arm_m + self.ins_from_rig.apply(self.cameras[name].center_in_rig) def implied_delay_ms(self, name: str) -> float: - """Exposure delay after the trigger that a camera's forward offset from the reference implies.""" + """Exposure midpoint of a camera relative to the reference camera's, from its along-track offset. + + Positive means it exposes later than the reference. Only this relative timing is + observable: the position priors absorb any delay common to the whole rig. + """ return 1000.0 * float(self.ins_from_rig.apply(self.cameras[name].center_in_rig)[0]) / self.ground_speed_mps @@ -162,7 +166,7 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: cams[name] = {"cam_from_rig": {"quaternion_xyzw": _floats(cam.cam_from_rig.rotation.quat), "translation_m": _floats(cam.cam_from_rig.translation)}, "rotation_from_reference_deg": _floats(rel.as_rotvec(degrees=True)), "angle_from_reference_deg": float(np.degrees(rel.magnitude())), "centre_in_rig_m": _floats(cam.center_in_rig), "centre_in_ins_body_m": _floats(cal.ins_from_rig.apply(cam.center_in_rig)), - "implied_exposure_delay_ms": cal.implied_delay_ms(name), "frames": cam.frames, "reprojection_rms_px": cam.reproj_rms_px} + "exposure_offset_from_reference_ms": cal.implied_delay_ms(name), "frames": cam.frames, "reprojection_rms_px": cam.reproj_rms_px} keep = cal.inlier res = np.linalg.norm(cal.rotation_residual_deg[keep], axis=1) body = { @@ -179,7 +183,7 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: } with open(path, "w") as f: f.write("# Rig geometry (cam_from_rig maps rig -> camera, COLMAP convention) and INS boresight (ins_from_rig maps rig -> INS body).\n" - "# A camera whose exposure lags the trigger sits ahead along track by speed x delay; implied_exposure_delay_ms reads that off.\n") + "# A camera exposing later than the reference sits ahead along track by speed x delay; exposure_offset_from_reference_ms reads that off.\n") yaml.safe_dump(body, f, sort_keys=False) From 178e61ee3ded65403feaeace410fc3559a4c6dff Mon Sep 17 00:00:00 2001 From: romleiaj Date: Wed, 16 Sep 2026 11:04:33 -0400 Subject: [PATCH 12/32] Document the camera exposure timing findings --- kamera/calibration/README.md | 6 ++ kamera/calibration/exposure_timing.md | 128 ++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 kamera/calibration/exposure_timing.md diff --git a/kamera/calibration/README.md b/kamera/calibration/README.md index 4de103e4..7993410c 100644 --- a/kamera/calibration/README.md +++ b/kamera/calibration/README.md @@ -45,6 +45,12 @@ kamera-calibrate --help - `_rig.yaml` — `cam_from_rig` per camera, `ins_from_rig`, lever arm, quality statistics. - `dive_registration/_to__registration.json`, `gifs/`, `_calibration_report.pdf`. +## Exposure timing + +The cameras do not expose at the same instant after the shared trigger; see +[exposure_timing.md](exposure_timing.md) for the measurements, the manuals, and what the +pipeline does about it. + ## Conventions - `camera_quaternion` (x, y, z, w) rotates camera vectors into the INS body frame diff --git a/kamera/calibration/exposure_timing.md b/kamera/calibration/exposure_timing.md new file mode 100644 index 00000000..2c7ab1ba --- /dev/null +++ b/kamera/calibration/exposure_timing.md @@ -0,0 +1,128 @@ +# Camera exposure timing on the KAMERA rig + +Written 2026-09-16 from the May 2025 calibration flight (`052025_Calibration`). + +## The short version + +All nine cameras get the same trigger pulse, but they do not take their pictures at +the same moment. Each camera type has its own delay between the trigger and the middle +of its exposure: + +| camera | when the middle of the exposure happens | how we know | +|---|---|---| +| RGB (Phase One iXM-GS120, electronic shutter) | about 20 ms after the trigger | Phase One guide | +| UV (Prosilica GT4907) | about 10 ms after the trigger (half of a 20 ms exposure) | Prosilica manual + measurement | +| IR (FLIR A6750) | within a few ms of the trigger | FLIR manual + measurement | + +On top of that, the INS reading saved with each image is the last 100 Hz sample +*before* the trigger, so it is on average about 8 to 10 ms old. + +At 65 m/s, 20 ms is 1.3 m on the ground. So the RGB, UV and IR pictures of one +"frame" were taken from three slightly different places along the flight line, and +the INS reading belongs to a fourth. + +For now the calibration handles this in software (see "What we do about it now"). +The right long-term fix is in hardware (see "What we should do later"). + +## How we found it + +1. **The GIFs looked wrong.** The IR-to-RGB registration GIFs showed the IR image + sitting a metre or two off from the RGB image, even though the calibration itself + reported sub-pixel fits. + +2. **We measured the offset instead of eyeballing it.** For each GIF we lined up the + edges of the warped IR frame with the RGB frame (phase correlation on gradient + images) and converted the shift to metres using the INS altitude. Result: the shift + was about 1.5 m, almost entirely along the direction of flight, and it was the + *same in metres* at 400 m and at 900 m altitude. A camera pointing error would grow + with altitude. A constant distance along the flight line is what a time delay looks + like. + +3. **The 3D model said the same thing.** Bundle adjustment had placed all three IR + camera centres about 1.0 m behind the RGB camera along the flight line, and the UV + centres about 0.6 m behind, on a rig whose real spacing is a few tens of + centimetres. A rig flying in a straight line cannot tell "this camera exposed 15 ms + earlier" from "this camera is mounted 1 m further back", so the adjustment turned + the timing into a fake lever arm. We confirmed the direction with nothing but the + model's camera positions and the INS velocity: IR behind RGB by 0.93 to 1.01 m, UV + behind RGB by 0.60 to 0.67 m, consistent over all 740 frames. + +4. **We checked whether the RGB is "on time".** It cannot be told from the model: + the INS position priors define where the model sits, so a delay shared by every + camera is invisible. Only the differences between cameras are measurable. Trying to + read the shared delay off the turns (a delay shows up as a pointing error that + scales with turn rate) gave a weak +16 ms with most of the residual unexplained, + so the manuals had to settle the absolute numbers. + +5. **We checked how long the IR actually exposes.** A 30 ms integration at 65 m/s + would smear the IR picture by 2 m, which is 8 pixels at 400 m altitude, and would + visibly blur edges along the flight line. The raw IR frames have the same sharpness + along and across track, so the integration in use is a few milliseconds at most. + +6. **We read the manuals** (next section) and the numbers lined up. + +## Supporting documentation + +- **Phase One, iXM-GS120 Operation Guide, Rev 1.0.0, section 3.1 "Exposure Sequence".** + The table "Hardware Pulses and Delay Parameter Signals" gives Trigger IN to Mid + Exposure as "~20 msec + 0.5 x Exposure Time" for the electronic shutter and + "~25 msec + 0.5 x Exposure Time" for the leaf shutter. The camera was run with the + electronic shutter and a 0.3 ms exposure (from the image metadata), so its + mid-exposure is about 20 ms after the trigger. The same table shows the camera + outputs a **Mid-Exposure Pulse** on its own signal line. + +- **Teledyne FLIR, A6000 and A8500 Series User's Manual, section 5.4.2 "Frame Sync + Starts".** There is no single "latency" number. The camera has two sync modes: Frame + Sync Starts Integration ("take a picture now") and Frame Sync Starts Readout (the + sync reads out the previous frame and the exposure is placed automatically). Either + way the exposure sits within a few ms of the sync edge, plus or minus half the + integration time. Section 6.5.5 describes the Sync In as a rising-edge TTL signal + with no stated delay. The KAMERA driver (`genicam_a6750.launch`) sets the frame sync + source to External and does not set the sync mode or the integration time, so both + come from the preset stored in the camera. Note: the older "A6xx series" manual + covers the uncooled A615/A655 (640 x 480) and does not apply to the A6750 + (640 x 512). + +- **Allied Vision, Prosilica GT Technical Manual V3.3.3, "Trigger timing concept" + (camera interfaces chapter).** Trigger latency is defined as the delay from the user + trigger to the start of exposure; the sibling models list 0.7 to 25.8 microseconds. + So the UV exposure starts at the trigger for all practical purposes and its middle + is half the exposure later. The GT4907 itself was removed from this manual in + V3.2.1 as a discontinued model, so it has no spec table there. KAMERA runs the UV + with auto-exposure (driver default, capped at 100 ms); the measured 10 ms offset + matches a 20 ms exposure, which is also the value written in the comments of + `prosilica.launch`. + +Putting the three together: RGB middle at +20 ms, UV middle at +10 ms, IR middle +near 0 ms. The model measured IR 15 ms before RGB and UV 10 ms before RGB. That agrees +to within the "~" in the Phase One table. + +## What we do about it now + +- The rig calibration keeps the timing as along-track offsets in the camera + positions. `rig.yaml` reports each camera's forward offset and the exposure time + difference it implies (`exposure_offset_from_reference_ms`, negative = earlier than + the RGB). The camera yaml positions carry the same offsets, which is correct as long + as the survey flies at a similar ground speed. +- The DIVE homographies are fit for a nominal ground range + (`--registration_range_m`, default: the calibration flight's median scene range) + instead of at infinity, because a homography at infinity throws the offset away. + Set it to the survey altitude above ground for the best registration there. +- `InsTrajectory` interpolates between INS samples, so a denser INS log removes the + 8 to 10 ms staleness without code changes. + +## What we should do later + +1. Log the full-rate INS stream (or post-process with POSPac) so the navigation + solution can be interpolated to any timestamp. +2. Feed the Phase One mid-exposure pulse into an INS event input. That timestamps + the RGB exposure directly and removes the "~20 ms". +3. Trigger the IR from that same pulse; its exposure then starts at the RGB + mid-exposure and ends a few ms later. +4. Fix the UV exposure (no auto) and delay its trigger by 20 ms minus half the + exposure, using the GT trigger-delay feature, so its middle lands on the pulse too. + At minimum, record the UV exposure in the meta json. + +With all three mid-exposures on one timestamped pulse, the "one pose per frame" +assumption in the rig model becomes exactly true, the rig offsets become physical, +and the homographies stop depending on speed and altitude. From 289a6644a66b4befde022bd14704c692bf4b8641 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Wed, 16 Sep 2026 11:07:47 -0400 Subject: [PATCH 13/32] Add a plain-language walkthrough of the rig calibration --- kamera/calibration/README.md | 2 + kamera/calibration/how_it_works.md | 214 +++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 kamera/calibration/how_it_works.md diff --git a/kamera/calibration/README.md b/kamera/calibration/README.md index 7993410c..a14ec5e5 100644 --- a/kamera/calibration/README.md +++ b/kamera/calibration/README.md @@ -12,6 +12,8 @@ kamera-calibrate /data/052025_Calibration --max_frames 150 --frame_stride 3 # kamera-calibrate --help ``` +For a plain-language walkthrough of every stage, see [how_it_works.md](how_it_works.md). + ## Stages (each resumes from `/calibration/`) 1. **frames** — `*_meta.json` grouped by trigger time into frames; camera names are diff --git a/kamera/calibration/how_it_works.md b/kamera/calibration/how_it_works.md new file mode 100644 index 00000000..1e7a0d95 --- /dev/null +++ b/kamera/calibration/how_it_works.md @@ -0,0 +1,214 @@ +# How the rig calibration works, step by step + +This follows one run of `kamera-calibrate ` from the raw KAMERA flight +folder to the camera models, using plain language. File names in `kamera/calibration/` +are given so you can read along in the code. + +## What goes in + +A KAMERA flight folder, for example `052025_Calibration/`, with one folder per view +(`center_view`, `left_view`, `right_view`) and, for every trigger, four files with a +common stem: + +``` +taiga_calibration_2025_fl118_C_20250503_203245.017993_meta.json +taiga_calibration_2025_fl118_C_20250503_203245.017993_rgb.jpg +taiga_calibration_2025_fl118_C_20250503_203245.017993_uv.jpg +taiga_calibration_2025_fl118_C_20250503_203245.017993_ir.tif +``` + +The meta json holds the trigger time (`evt.time`), the INS reading nearest to it +(`ins`: latitude, longitude, altitude, heading, pitch, roll), and the camera metadata. + +## What comes out + +Everything lands in `/calibration/models/`: + +- one yaml per camera (`_.yaml`), readable by the existing + `camera_models.load_from_file` +- `_rig.yaml` with the rig geometry and the INS boresight +- `dive_registration/*.json`, one homography file per camera pair per channel +- `gifs/`, flip animations of one camera warped onto another +- `_calibration_report.pdf` + +Everything else under `/calibration/` is intermediate and can be deleted; +the tool rebuilds whatever is missing and skips whatever exists. + +## The idea in one paragraph + +Structure from motion (COLMAP) can work out where every picture was taken from and +what it was looking at, just from the pictures overlapping each other. It does this +in its own arbitrary coordinate system, so we hand it the INS positions to pin the +model to the real world. Because all nine cameras fire on the same trigger, the nine +pictures of one trigger share one rig position and orientation; COLMAP 4 can enforce +that ("rigs" and "frames"). Once the model is solved with that constraint, the fixed +rotation and offset of each camera relative to the reference camera drops out, and +comparing the rig orientation with the INS orientation over hundreds of frames gives +the boresight. The only thing COLMAP cannot do for us is match thermal pictures to +visible ones, and with the rig constraint it does not need to. + +## Step 1: find the frames (`flight.py`, `discover_flight`) + +Read every `*_meta.json`. Group them by trigger time, rounded to a millisecond, so the +L, C and R files of one trigger become one **frame**. Name each image +`_`, for example `C_rgb` or `L_ir`. Collect the INS readings from +every json into one time-ordered trajectory. Frames missing any of the nine images are +dropped, so every frame used has all nine. + +The INS trajectory (`InsTrajectory`) converts latitude/longitude/altitude to metres in a +local east-north-up frame centred on the flight, and heading/pitch/roll into a +rotation, using the same convention as the rest of KAMERA (`sensor_models.nav_state`). +Asked for the pose at any time, it interpolates between the two nearest samples. With +the meta json this is coarse, one sample per second; a denser INS log would drop in +here unchanged. + +## Step 2: lay the images out for COLMAP (`flight.py`, `build_image_tree`) + +COLMAP wants one folder per camera and, for rigs, the *same file name* across folders +for pictures of the same frame. So the tree is +`calibration/images//.jpg`. RGB files are symlinks. UV and IR are +rewritten: the UV frames are very dark (median value 9 of 255) and the IR frames are +16-bit, so both are stretched between their 0.1 and 99.9 percentiles and given a mild +local contrast boost (CLAHE). Without this, SIFT found about 30 features per UV frame +instead of thousands. This runs in parallel because there are thousands of files. + +## Step 3: features and INS priors (`sfm.py`, `extract_features`, `write_pose_priors`) + +SIFT features are extracted per camera folder on the GPU, with the image downsampled +to 3200 px on the long side (a 12768 px RGB frame gives about 12,000 features). Each +camera folder gets one COLMAP camera with the OPENCV model (focal length, principal +point, k1, k2, p1, p2), seeded with a rough focal length per modality from the config +so the first frames register cleanly. + +Then every image gets a **position prior**: the INS position at its trigger time, with a +2 m standard deviation. COLMAP uses these priors in two ways later: to decide which +images to try to match, and to keep the model in real-world metres and orientation. + +## Step 4: matching (`sfm.py`, `match_features`, `prune_cross_spectral`) + +Rather than matching every image against every other (8,800 images would be 39 +million pairs), each image is matched against its 90 nearest neighbours by INS +position within 250 m. That covers the frames just before and after, and also the +crossovers of the figure eights, which are what make the geometry strong. + +The neighbours include every camera, so the same-frame RGB and UV pictures get matched +too, which is useful: they share features and tie the UV into the RGB model directly. +Thermal-to-visible pairs also get "matched", but those matches are garbage (about 25 +random inliers), and left in they pull IR images to wrong places. They are deleted from +the database right after matching. + +## Step 5: pass 1, mapping with independent cameras (`sfm.py`, `run_mapping`) + +COLMAP's incremental mapper builds the 3D model: it picks a good starting pair, +triangulates points, adds the next image by matching its features to points already in +3D, and periodically re-optimises everything (bundle adjustment). The position priors +are switched on, so the model comes out in INS coordinates rather than an arbitrary +frame, with the right scale. + +At this stage every camera is still independent. The result is normally two models: +one with all the EO cameras (RGB and UV of L, C and R, linked by same-frame matches +and by the overlap strips between channels) and one with the IR cameras, which only +match each other. Both are in INS coordinates thanks to the priors, so they can be +compared. + +Two practical notes. The mapper needs a point to be seen from three pictures to add +a third picture; at 300 to 400 m above ground with one frame per second the along-track +overlap is under 50%, so those legs only register through crossovers with higher +passes. And the global bundle adjustment is set to run every 30% of growth instead of +10%, which halved the run time on the full flight (about 2.5 hours for 8,800 images). + +## Step 6: work out the rig from pass 1 (`sfm.py`, `derive_rig`, `robust_mean`) + +For every camera and every frame where both that camera and the reference camera +(`C_rgb`) were placed, compute the camera's pose relative to the reference. On a rigid +rig that relative pose is the same every frame, so the hundreds of estimates should +agree. Take the densest cluster of them (the estimate with the most neighbours within +one degree, then the mean of that cluster) rather than a plain median, because a badly +registered part of a model can put half the estimates 20 degrees off, and the cluster +ignores those. For the IR cameras this comparison goes across the two models; it works +because both models are in INS coordinates, and it is accurate to about 0.3 degrees, +which is plenty for a starting point. + +## Step 7: pass 2, the rig bundle adjustment (`sfm.py`, `rigged_model`, `refine_rig`) + +Now tell COLMAP about the rig: + +1. Write the rig definition into the database: `C_rgb` is the reference sensor and + every other camera has the starting `cam_from_rig` from step 6. COLMAP groups the + images into frames by their shared file name. +2. Put the rig onto the largest pass 1 model. Its frames now hold one pose each, taken + from the `C_rgb` image. Add the images pass 1 never placed, mostly IR: they inherit + their pose from the frame pose and the rig offsets. +3. Triangulate every image afresh from those poses. IR features now become 3D points + too, because the IR images have poses even though nothing matched them to EO. +4. Bundle adjust with the INS position priors, refining the frame poses and the + `cam_from_rig` of every camera, with intrinsics held fixed. +5. Triangulate again from the refined poses, and bundle adjust once more with the + intrinsics free (focal length and distortion). + +The result is one model with all nine cameras. On the full May 2025 flight: 740 +frames, 6,660 images, 0.71 px mean reprojection error, matching a 250-frame subset +to about 0.05 degrees on the rig angles. + +Two things that did not work, so nobody repeats them: continuing COLMAP's incremental +mapper from the rigged model (it throws the model away as "insufficient size"), and +COLMAP's plain bundle adjuster on the rigged model (without a fixed gauge it diverges). +The triangulate-then-adjust route above is stable. + +## Step 8: read off the calibration (`rig.py`, `calibrate_rig`) + +From the final model: + +- **Intrinsics** per camera: focal lengths, principal point, distortion, straight from + COLMAP's OPENCV camera. The per-camera reprojection error is computed over every + observation of that camera. +- **Rig geometry**: `cam_from_rig` for each camera, which maps rig coordinates (the + `C_rgb` camera frame) into that camera. From it, the rotation relative to the + reference (the L and R channels come out at about 30 degrees, UV within half a degree + of its RGB, IR within about a degree) and the camera centre in the rig. +- **INS boresight**: for every frame, take the rig's orientation in the world from the + model and the INS orientation at the same time, and compute the rotation between + them. That should be one fixed rotation; the densest-cluster mean of it over all + frames is `ins_from_rig`, and the spread of the individual frames about it (about + 0.15 degrees median) is the honest per-frame uncertainty. The same comparison of + positions gives the lever arm from the INS to the rig, which is noise dominated. +- **Camera models in the INS frame**: each camera's rotation into the INS body is + `ins_from_rig` composed with the camera's rotation into the rig, and its position is + the lever arm plus its rig centre rotated into the body frame. These two numbers are + the `camera_quaternion` and `camera_position` in the yaml, exactly as the existing + KAMERA georegistration code expects. + +`write_camera_yaml` and `write_rig_yaml` put all of this on disk, with the original +keys first so old readers still work and the provenance after. + +## Step 9: homographies for DIVE (`registration.py`, `cli.py`) + +For each channel and each pair `ir->uv`, `ir->rgb`, `uv->rgb`: take a grid of pixels in +the first camera, cast them out to a nominal ground range through the calibrated +model, project them into the second camera, and fit one 3x3 homography to the result. +The fit residual says how much a single matrix loses to lens distortion. The range +matters because the cameras do not expose at exactly the same instant, which shows up +as an along-track offset of about a metre in the rig (see `exposure_timing.md`); it +defaults to the calibration flight's median scene range and should be set to the +survey altitude. The files use DIVE's registration format version 2, one matrix-only +pair each. + +The GIFs warp the first camera's picture onto the second with that homography for +five frames spread across the flight and flip between the two, so a misregistration is +visible at a glance. + +## Step 10: the report (`report.py`) + +A PDF with the camera table, the rig angles and a sketch of the optical axes, the +boresight numbers with per-frame residual plots, one page per homography pair with the +warped overlay, and a page on the error budget: INS sample staleness, model drift, the +weak observability of lever arms, and exposure timing. + +## Running it again + +Every stage checks for its outputs and skips itself when they exist, so rerunning the +same command after a crash or a code change in a late stage takes minutes, not hours. +Delete `calibration/pass1` to redo the mapping, `calibration/rig` to redo the rig +adjustment, or pass `--force` to redo everything. `--max_frames` and `--frame_start` +select a subset for quick experiments; pick frames from the high-altitude part of the +flight for those. From 84ea0b48ee211d0f8078dfe157f1fdd5d22aa25b Mon Sep 17 00:00:00 2001 From: romleiaj Date: Mon, 21 Sep 2026 10:38:37 -0400 Subject: [PATCH 14/32] Adopt ruff as the formatter and linter at line length 88 Configure ruff in pyproject.toml (the classic E4/E7/E9/F rule set, pinned so results do not depend on the ruff version), add it to the dev dependency group, and run ruff format over the calibration package and camera_models.py. No code changes beyond formatting. --- CHANGELOG.md | 1 + kamera/calibration/cli.py | 114 ++++++++++-- kamera/calibration/config.py | 51 ++++-- kamera/calibration/flight.py | 47 ++++- kamera/calibration/registration.py | 84 +++++++-- kamera/calibration/report.py | 170 +++++++++++++++--- kamera/calibration/rig.py | 206 +++++++++++++++++----- kamera/calibration/sfm.py | 199 ++++++++++++++++----- kamera/colmap_processing/camera_models.py | 7 +- pyproject.toml | 7 + uv.lock | 36 +++- 11 files changed, 747 insertions(+), 175 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8fd70b6..3dc3144a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Post-processing env moves to Python 3.13 and pycolmap 4.2 (conda-forge, CUDA build). - `make install` recreates `.venv` instead of failing when it exists. +- ruff (line length 88) is the project formatter and linter, installed with the `dev` group. ### Removed diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index 1ddedf51..fbe8ed51 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -25,7 +25,11 @@ def main(argv=None) -> None: cfg = CalibrateConfig.cli(argv=argv, strict=True) work = cfg.work_dir or os.path.join(cfg.flight_dir, "calibration") image_dir, db_path = os.path.join(work, "images"), os.path.join(work, "database.db") - pass1_dir, rig_dir, model_dir = os.path.join(work, "pass1"), os.path.join(work, "rig"), os.path.join(work, "models") + pass1_dir, rig_dir, model_dir = ( + os.path.join(work, "pass1"), + os.path.join(work, "rig"), + os.path.join(work, "models"), + ) os.makedirs(work, exist_ok=True) def done(path: str) -> bool: @@ -34,10 +38,18 @@ def done(path: str) -> bool: print("[blue]Discovering frames[/blue]") frames, ins, rig_name = discover_flight(cfg.flight_dir) rig_name = cfg.rig_name or rig_name.replace("images_", "") or "rig" - full = [f for f in frames if len(f.images) == len({c for fr in frames for c in fr.images})] - stop = cfg.frame_start + cfg.max_frames * cfg.frame_stride if cfg.max_frames else None - frames = full[cfg.frame_start:stop:cfg.frame_stride] - print(f"{len(full)} frames with every camera, using {len(frames)}; INS median sample gap {np.median([ins.sample_gap(f.time) for f in frames]) * 1000:.1f} ms") + full = [ + f + for f in frames + if len(f.images) == len({c for fr in frames for c in fr.images}) + ] + stop = ( + cfg.frame_start + cfg.max_frames * cfg.frame_stride if cfg.max_frames else None + ) + frames = full[cfg.frame_start : stop : cfg.frame_stride] + print( + f"{len(full)} frames with every camera, using {len(frames)}; INS median sample gap {np.median([ins.sample_gap(f.time) for f in frames]) * 1000:.1f} ms" + ) names = build_image_tree(frames, image_dir) with open(os.path.join(work, "images.json"), "w") as f: json.dump(names, f) @@ -46,7 +58,14 @@ def done(path: str) -> bool: print("[blue]Extracting features and writing INS priors[/blue]") if os.path.exists(db_path): os.remove(db_path) - sfm.extract_features(db_path, image_dir, names, cfg.focal_px, cfg.max_image_size, cfg.num_features) + sfm.extract_features( + db_path, + image_dir, + names, + cfg.focal_px, + cfg.max_image_size, + cfg.num_features, + ) sfm.write_pose_priors(db_path, names, ins, cfg.prior_std_m) db = pc.Database.open(db_path) matched = db.num_verified_image_pairs() > 0 @@ -60,29 +79,54 @@ def done(path: str) -> bool: sfm.run_mapping(db_path, image_dir, pass1_dir) pass1 = sfm.load_models(pass1_dir) for k, r in pass1.items(): - print(f" model {k}: {r.num_reg_images()} images, {r.num_points3D()} points, {r.compute_mean_reprojection_error():.2f} px") + print( + f" model {k}: {r.num_reg_images()} images, {r.num_points3D()} points, {r.compute_mean_reprojection_error():.2f} px" + ) if not done(rig_dir): print("[blue]Pass 2: rig bundle adjustment[/blue]") for name, v in sfm.derive_rig(pass1, names, cfg.reference_camera).items(): - print(f" {name}: {v['frames']} frames, rotation scatter {v['rotation_scatter_deg']:.3f} deg, translation std {np.round(v['translation_std_m'], 2)} m") + print( + f" {name}: {v['frames']} frames, rotation scatter {v['rotation_scatter_deg']:.3f} deg, translation std {np.round(v['translation_std_m'], 2)} m" + ) rig_in = os.path.join(work, "rig_init") sfm.rigged_model(db_path, pass1, names, cfg.reference_camera, rig_in) sfm.refine_rig(db_path, names, rig_in, rig_dir) model = pc.Reconstruction(rig_dir) - print(f" rig model: {model.num_reg_frames()} frames, {model.num_reg_images()} images, {model.compute_mean_reprojection_error():.2f} px") + print( + f" rig model: {model.num_reg_frames()} frames, {model.num_reg_images()} images, {model.compute_mean_reprojection_error():.2f} px" + ) print("[blue]Extracting camera models and boresight[/blue]") - cal = rig.calibrate_rig(model, names, ins, cfg.reference_camera, rig_name, os.path.basename(os.path.abspath(cfg.flight_dir))) + cal = rig.calibrate_rig( + model, + names, + ins, + cfg.reference_camera, + rig_name, + os.path.basename(os.path.abspath(cfg.flight_dir)), + ) for p in rig.write_outputs(cal, model_dir): print(f" wrote {p}") print("[blue]Fitting homographies and writing DIVE registration files[/blue]") reg_dir = os.path.join(model_dir, "dive_registration") - cams = {n: StandardCamera(c.width, c.height, c.K, c.dist, cal.camera_position(n), cal.camera_quaternion(n)) for n, c in cal.cameras.items()} + cams = { + n: StandardCamera( + c.width, + c.height, + c.K, + c.dist, + cal.camera_position(n), + cal.camera_quaternion(n), + ) + for n, c in cal.cameras.items() + } range_m = cfg.registration_range_m or cal.scene_range_m registered = {names[im.name][1] for im in model.images.values() if im.has_pose} - print(f" homographies exact at {range_m:.0f} m range; ground speed {cal.ground_speed_mps:.0f} m/s") + print( + f" homographies exact at {range_m:.0f} m range; ground speed {cal.ground_speed_mps:.0f} m/s" + ) pairs = [] for channel in sorted({n.split("_")[0] for n in cams}): for left_mod, right_mod in PAIRS: @@ -90,14 +134,40 @@ def done(path: str) -> bool: if left not in cams or right not in cams: continue try: - h, stats = registration.model_homography(cams[left], cams[right], range_m) + h, stats = registration.model_homography( + cams[left], cams[right], range_m + ) except ValueError as e: print(f" [yellow]{left} -> {right}: {e}[/yellow]") continue - path = registration.write_dive_registration(reg_dir, left, right, h, stats, registration.source_stamp(cfg.flight_dir, {"rig": rig_name})) + path = registration.write_dive_registration( + reg_dir, + left, + right, + h, + stats, + registration.source_stamp(cfg.flight_dir, {"rig": rig_name}), + ) print(f" wrote {path} (fit rms {stats['rmsPx']:.2f} px)") gif_frames = [f for f in frames if f.time in registered] - pairs.append({"left": left, "right": right, "h": h, "stats": stats, **write_gifs(gif_frames, names, image_dir, left, right, h, os.path.join(model_dir, "gifs"), cfg.gif_frames)}) + pairs.append( + { + "left": left, + "right": right, + "h": h, + "stats": stats, + **write_gifs( + gif_frames, + names, + image_dir, + left, + right, + h, + os.path.join(model_dir, "gifs"), + cfg.gif_frames, + ), + } + ) report_path = os.path.join(model_dir, f"{rig_name}_calibration_report.pdf") write_report(report_path, cal, pairs) @@ -112,12 +182,20 @@ def write_gifs(frames, names, image_dir, left, right, h, gif_dir, count) -> dict out = {} chosen = usable[:: max(1, len(usable) // max(count, 1))][:count] for k, frame in enumerate(chosen): - left_img = cv2.imread(os.path.join(image_dir, by_time[frame.time]), cv2.IMREAD_COLOR) + left_img = cv2.imread( + os.path.join(image_dir, by_time[frame.time]), cv2.IMREAD_COLOR + ) right_img = cv2.imread(frame.images[right], cv2.IMREAD_COLOR) warped, ref = registration.warp_pair(left_img, right_img, h) - registration.write_gif(os.path.join(gif_dir, f"{left}_to_{right}_{k}.gif"), warped, ref) + registration.write_gif( + os.path.join(gif_dir, f"{left}_to_{right}_{k}.gif"), warped, ref + ) if k == len(chosen) // 2: - out = {"warped_img": warped, "right_img": ref, "overlay_img": registration.blend_overlay(warped, ref)} + out = { + "warped_img": warped, + "right_img": ref, + "overlay_img": registration.blend_overlay(warped, ref), + } return out diff --git a/kamera/calibration/config.py b/kamera/calibration/config.py index b3fc4324..9ee525c3 100644 --- a/kamera/calibration/config.py +++ b/kamera/calibration/config.py @@ -7,19 +7,48 @@ class CalibrateConfig(scfg.DataConfig): __command__ = "kamera-calibrate" - flight_dir = scfg.Value(None, position=1, help="KAMERA flight directory (contains /_view/*_meta.json)") - work_dir = scfg.Value(None, help="Scratch and output root (default: /calibration)") - rig_name = scfg.Value(None, help="Name used in output file names (default: sys_cfg from the meta json)") - reference_camera = scfg.Value("C_rgb", help="Rig reference sensor; every other camera is expressed relative to it") + flight_dir = scfg.Value( + None, + position=1, + help="KAMERA flight directory (contains /_view/*_meta.json)", + ) + work_dir = scfg.Value( + None, help="Scratch and output root (default: /calibration)" + ) + rig_name = scfg.Value( + None, + help="Name used in output file names (default: sys_cfg from the meta json)", + ) + reference_camera = scfg.Value( + "C_rgb", + help="Rig reference sensor; every other camera is expressed relative to it", + ) frame_start = scfg.Value(0, help="Index of the first synchronized frame to use") frame_stride = scfg.Value(1, help="Use every Nth frame") max_frames = scfg.Value(0, help="Cap on frames used (0 = all)") - focal_px = scfg.Value({"rgb": 31363.0, "uv": 14120.0, "ir": 1712.0}, help="Initial focal length per modality in pixels; refined by SfM") - max_image_size = scfg.Value(3200, help="Images are downsampled to this longest side for SIFT") + focal_px = scfg.Value( + {"rgb": 31363.0, "uv": 14120.0, "ir": 1712.0}, + help="Initial focal length per modality in pixels; refined by SfM", + ) + max_image_size = scfg.Value( + 3200, help="Images are downsampled to this longest side for SIFT" + ) num_features = scfg.Value(8192, help="Max SIFT features per image") - match_distance_m = scfg.Value(250.0, help="Spatial matching radius from INS positions") - match_neighbors = scfg.Value(90, help="Spatial matching neighbours per image (about 10 frames times the number of cameras)") - prior_std_m = scfg.Value(2.0, help="Standard deviation assigned to INS position priors") - registration_range_m = scfg.Value(0.0, help="Ground range the homographies are exact at; 0 = median scene range of the calibration model. Set to the survey AGL") + match_distance_m = scfg.Value( + 250.0, help="Spatial matching radius from INS positions" + ) + match_neighbors = scfg.Value( + 90, + help="Spatial matching neighbours per image (about 10 frames times the number of cameras)", + ) + prior_std_m = scfg.Value( + 2.0, help="Standard deviation assigned to INS position priors" + ) + registration_range_m = scfg.Value( + 0.0, + help="Ground range the homographies are exact at; 0 = median scene range of the calibration model. Set to the survey AGL", + ) gif_frames = scfg.Value(5, help="Registration GIFs written per camera pair") - force = scfg.Value(False, isflag=True, help="Rerun stages whose outputs already exist") + force = scfg.Value( + False, isflag=True, help="Rerun stages whose outputs already exist" + ) diff --git a/kamera/calibration/flight.py b/kamera/calibration/flight.py index 12193ea1..9dae0e9b 100644 --- a/kamera/calibration/flight.py +++ b/kamera/calibration/flight.py @@ -45,7 +45,12 @@ def __init__(self, times, llh_deg, hpr_deg, lat0=None, lon0=None, h0=0.0): self.lat0 = float(np.median(self.llh[:, 0]) if lat0 is None else lat0) self.lon0 = float(np.median(self.llh[:, 1]) if lon0 is None else lon0) self.h0 = float(h0) - self.enu = np.array([llh_to_enu(*r, self.lat0, self.lon0, self.h0, in_degrees=True) for r in self.llh]) + self.enu = np.array( + [ + llh_to_enu(*r, self.lat0, self.lon0, self.h0, in_degrees=True) + for r in self.llh + ] + ) self.rotations = NED_TO_ENU * Rotation.from_euler("ZYX", hpr) @classmethod @@ -55,7 +60,11 @@ def from_meta(cls, samples: dict[float, tuple]) -> InsTrajectory: def pose(self, t: float) -> tuple[np.ndarray, Rotation]: i = int(np.clip(bisect.bisect(self.times, t), 1, len(self.times) - 1)) - w = float(np.clip((t - self.times[i - 1]) / (self.times[i] - self.times[i - 1]), 0.0, 1.0)) + w = float( + np.clip( + (t - self.times[i - 1]) / (self.times[i] - self.times[i - 1]), 0.0, 1.0 + ) + ) pos = (1 - w) * self.enu[i - 1] + w * self.enu[i] rot = Slerp([0.0, 1.0], self.rotations[[i - 1, i]])([w])[0] return pos, rot @@ -83,7 +92,9 @@ def discover_flight(flight_dir: str) -> tuple[list[Frame], InsTrajectory, str]: frames: dict[float, Frame] = {} samples: dict[float, tuple] = {} rig_name = "" - for meta in glob.glob(os.path.join(flight_dir, "**", "*_view", "*_meta.json"), recursive=True): + for meta in glob.glob( + os.path.join(flight_dir, "**", "*_view", "*_meta.json"), recursive=True + ): with open(meta) as f: d = json.load(f) channel = os.path.basename(os.path.dirname(meta)).split("_")[0][0].upper() @@ -94,12 +105,22 @@ def discover_flight(flight_dir: str) -> tuple[list[Frame], InsTrajectory, str]: if os.path.exists(stem + f"_{modality}{ext}"): frame.images[f"{channel}_{modality}"] = stem + f"_{modality}{ext}" ins = d["ins"] - samples[float(ins["time"])] = (ins["latitude"], ins["longitude"], ins["altitude"], - ins["heading"], ins["pitch"], ins["roll"]) + samples[float(ins["time"])] = ( + ins["latitude"], + ins["longitude"], + ins["altitude"], + ins["heading"], + ins["pitch"], + ins["roll"], + ) rig_name = rig_name or d.get("sys_cfg", "") if not frames: raise FileNotFoundError(f"No *_meta.json files found under {flight_dir}") - return [frames[k] for k in sorted(frames)], InsTrajectory.from_meta(samples), rig_name + return ( + [frames[k] for k in sorted(frames)], + InsTrajectory.from_meta(samples), + rig_name, + ) def normalize(src: str, dst: str) -> None: @@ -107,10 +128,16 @@ def normalize(src: str, dst: str) -> None: im = cv2.imread(src, cv2.IMREAD_UNCHANGED).astype(np.float32) lo, hi = np.percentile(im, [0.1, 99.9]) im = np.clip((im - lo) / max(hi - lo, 1.0) * 255.0, 0, 255).astype(np.uint8) - cv2.imwrite(dst, cv2.createCLAHE(clipLimit=1.0, tileGridSize=(5, 5)).apply(im), [cv2.IMWRITE_JPEG_QUALITY, 95]) + cv2.imwrite( + dst, + cv2.createCLAHE(clipLimit=1.0, tileGridSize=(5, 5)).apply(im), + [cv2.IMWRITE_JPEG_QUALITY, 95], + ) -def build_image_tree(frames: list[Frame], image_dir: str) -> dict[str, tuple[str, float]]: +def build_image_tree( + frames: list[Frame], image_dir: str +) -> dict[str, tuple[str, float]]: """Lay frames out as ``image_dir//.jpg`` for COLMAP's per-folder cameras. COLMAP groups images into rig frames by identical file names across folders, hence the @@ -128,7 +155,9 @@ def build_image_tree(frames: list[Frame], image_dir: str) -> dict[str, tuple[str names[name] = (camera, frame.time) if os.path.exists(dst): continue - jobs.append((src, dst)) if stretch else os.symlink(os.path.abspath(src), dst) + jobs.append((src, dst)) if stretch else os.symlink( + os.path.abspath(src), dst + ) with ProcessPoolExecutor() as pool: list(pool.map(normalize, *zip(*jobs)) if jobs else []) return names diff --git a/kamera/calibration/registration.py b/kamera/calibration/registration.py index 3bc6ccd4..7e29262b 100644 --- a/kamera/calibration/registration.py +++ b/kamera/calibration/registration.py @@ -22,29 +22,65 @@ DIVE_VERSION = 2 -def model_homography(src_cm, dst_cm, range_m: float, grid: int = 40) -> tuple[np.ndarray, dict]: +def model_homography( + src_cm, dst_cm, range_m: float, grid: int = 40 +) -> tuple[np.ndarray, dict]: """Least-squares homography from ``src_cm`` pixels to ``dst_cm`` pixels for ground ``range_m`` away, plus fit stats.""" - xg, yg = np.meshgrid(np.linspace(0, src_cm.width - 1, grid), np.linspace(0, src_cm.height - 1, grid)) + xg, yg = np.meshgrid( + np.linspace(0, src_cm.width - 1, grid), np.linspace(0, src_cm.height - 1, grid) + ) src = np.vstack([xg.ravel(), yg.ravel()]) ray_pos, ray_dir = src_cm.unproject(src, -np.inf) - dst = np.asarray(dst_cm.project(ray_pos + ray_dir * range_m, -np.inf), dtype=np.float64) - inside = np.all(np.isfinite(dst), 0) & (dst[0] >= 0) & (dst[0] <= dst_cm.width) & (dst[1] >= 0) & (dst[1] <= dst_cm.height) + dst = np.asarray( + dst_cm.project(ray_pos + ray_dir * range_m, -np.inf), dtype=np.float64 + ) + inside = ( + np.all(np.isfinite(dst), 0) + & (dst[0] >= 0) + & (dst[0] <= dst_cm.width) + & (dst[1] >= 0) + & (dst[1] <= dst_cm.height) + ) if inside.sum() < 4: - raise ValueError(f"only {inside.sum()} of {src.shape[1]} samples land in the destination image") + raise ValueError( + f"only {inside.sum()} of {src.shape[1]} samples land in the destination image" + ) h, _ = cv2.findHomography(src[:, inside].T, dst[:, inside].T, 0) - err = np.linalg.norm(cv2.perspectiveTransform(src[:, inside].T.reshape(-1, 1, 2), h).reshape(-1, 2) - dst[:, inside].T, axis=1) - stats = {"rmsPx": float(np.sqrt(np.mean(err**2))), "p95Px": float(np.percentile(err, 95)), - "maxPx": float(np.max(err)), "coverage": float(inside.mean()), "rangeM": float(range_m)} + err = np.linalg.norm( + cv2.perspectiveTransform(src[:, inside].T.reshape(-1, 1, 2), h).reshape(-1, 2) + - dst[:, inside].T, + axis=1, + ) + stats = { + "rmsPx": float(np.sqrt(np.mean(err**2))), + "p95Px": float(np.percentile(err, 95)), + "maxPx": float(np.max(err)), + "coverage": float(inside.mean()), + "rangeM": float(range_m), + } return h, stats -def write_dive_registration(out_dir: str, left: str, right: str, h: np.ndarray, stats: dict, source: dict) -> str: +def write_dive_registration( + out_dir: str, left: str, right: str, h: np.ndarray, stats: dict, source: dict +) -> str: """Write one matrix-only v2 pair file and return its path.""" inv = np.linalg.inv(h) - pair = {"left": left, "right": right, "transformType": "homography", - "leftToRight": h.tolist(), "rightToLeft": (inv / inv[2, 2]).tolist(), "observations": [], - "stats": {f"modelFit{k[0].upper()}{k[1:]}": v for k, v in stats.items()}} - body = {"type": DIVE_TYPE, "version": DIVE_VERSION, "source": source, "pairs": [pair]} + pair = { + "left": left, + "right": right, + "transformType": "homography", + "leftToRight": h.tolist(), + "rightToLeft": (inv / inv[2, 2]).tolist(), + "observations": [], + "stats": {f"modelFit{k[0].upper()}{k[1:]}": v for k, v in stats.items()}, + } + body = { + "type": DIVE_TYPE, + "version": DIVE_VERSION, + "source": source, + "pairs": [pair], + } os.makedirs(out_dir, exist_ok=True) path = os.path.join(out_dir, f"{left}_to_{right}_registration.json") with open(path, "w") as f: @@ -53,12 +89,20 @@ def write_dive_registration(out_dir: str, left: str, right: str, h: np.ndarray, def source_stamp(flight_dir: str, extra: dict | None = None) -> dict: - stamp = {"producer": "kamera-rig-calibration", "flight": os.path.basename(os.path.abspath(flight_dir)), - "generated": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")} + stamp = { + "producer": "kamera-rig-calibration", + "flight": os.path.basename(os.path.abspath(flight_dir)), + "generated": datetime.datetime.now(datetime.timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z"), + } return {**stamp, **(extra or {})} -def warp_pair(left_img: np.ndarray, right_img: np.ndarray, h: np.ndarray, width: int = 1280) -> tuple[np.ndarray, np.ndarray]: +def warp_pair( + left_img: np.ndarray, right_img: np.ndarray, h: np.ndarray, width: int = 1280 +) -> tuple[np.ndarray, np.ndarray]: """Warp the left image into the right image's pixels; both returned resized to ``width`` wide, RGB.""" scale = width / right_img.shape[1] size = (width, round(right_img.shape[0] * scale)) @@ -72,7 +116,13 @@ def _rgb(im: np.ndarray) -> np.ndarray: def write_gif(path: str, a: np.ndarray, b: np.ndarray, duration_ms: int = 400) -> None: - PIL.Image.fromarray(a).save(path, save_all=True, append_images=[PIL.Image.fromarray(b)], duration=duration_ms, loop=0) + PIL.Image.fromarray(a).save( + path, + save_all=True, + append_images=[PIL.Image.fromarray(b)], + duration=duration_ms, + loop=0, + ) def blend_overlay(a: np.ndarray, b: np.ndarray) -> np.ndarray: diff --git a/kamera/calibration/report.py b/kamera/calibration/report.py index 9ada1557..1701bd05 100644 --- a/kamera/calibration/report.py +++ b/kamera/calibration/report.py @@ -54,16 +54,26 @@ def _text_page(pdf: PdfPages, title: str, body: str) -> None: fig = plt.figure(figsize=PAGE) fig.text(0.06, 0.94, title, fontsize=16, weight="bold", va="top") - fig.text(0.06, 0.88, body, fontsize=9.5, va="top", family="monospace", linespacing=1.4) + fig.text( + 0.06, 0.88, body, fontsize=9.5, va="top", family="monospace", linespacing=1.4 + ) pdf.savefig(fig) plt.close(fig) -def _table_page(pdf: PdfPages, title: str, header: list[str], rows: list[list], widths=None) -> None: +def _table_page( + pdf: PdfPages, title: str, header: list[str], rows: list[list], widths=None +) -> None: fig, ax = plt.subplots(figsize=PAGE) ax.axis("off") ax.set_title(title, fontsize=15, weight="bold", loc="left", pad=20) - table = ax.table(cellText=rows, colLabels=header, loc="upper center", cellLoc="center", colWidths=widths) + table = ax.table( + cellText=rows, + colLabels=header, + loc="upper center", + cellLoc="center", + colWidths=widths, + ) table.auto_set_font_size(False) table.set_fontsize(8) table.scale(1, 1.5) @@ -72,36 +82,94 @@ def _table_page(pdf: PdfPages, title: str, header: list[str], rows: list[list], def camera_page(pdf: PdfPages, cal: RigCalibration) -> None: - header = ["camera", "size", "fx", "fy", "cx", "cy", "k1", "k2", "p1", "p2", "frames", "rms px", "ifov deg"] + header = [ + "camera", + "size", + "fx", + "fy", + "cx", + "cy", + "k1", + "k2", + "p1", + "p2", + "frames", + "rms px", + "ifov deg", + ] rows = [] for name in sorted(cal.cameras): c = cal.cameras[name] - rows.append([name, f"{c.width}x{c.height}", f"{c.K[0, 0]:.1f}", f"{c.K[1, 1]:.1f}", f"{c.K[0, 2]:.1f}", f"{c.K[1, 2]:.1f}", - *[f"{v:.5f}" for v in c.dist], c.frames, f"{c.reproj_rms_px:.2f}", f"{np.degrees(1 / c.K[0, 0]):.5f}"]) + rows.append( + [ + name, + f"{c.width}x{c.height}", + f"{c.K[0, 0]:.1f}", + f"{c.K[1, 1]:.1f}", + f"{c.K[0, 2]:.1f}", + f"{c.K[1, 2]:.1f}", + *[f"{v:.5f}" for v in c.dist], + c.frames, + f"{c.reproj_rms_px:.2f}", + f"{np.degrees(1 / c.K[0, 0]):.5f}", + ] + ) _table_page(pdf, f"{cal.rig}: camera intrinsics ({cal.flight})", header, rows) def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: ref = cal.cameras[cal.reference] fig = plt.figure(figsize=PAGE) - fig.suptitle(f"Rig geometry relative to {cal.reference}", fontsize=15, weight="bold", x=0.06, ha="left") + fig.suptitle( + f"Rig geometry relative to {cal.reference}", + fontsize=15, + weight="bold", + x=0.06, + ha="left", + ) ax = fig.add_subplot(1, 2, 1) ax.axis("off") rows = [] for name in sorted(cal.cameras): rel = ref.rig_from_cam.inv() * cal.cameras[name].rig_from_cam rv, c = rel.as_rotvec(degrees=True), cal.cameras[name].center_in_rig - rows.append([name, f"{np.degrees(rel.magnitude()):.3f}", f"{rv[0]:+.3f} {rv[1]:+.3f} {rv[2]:+.3f}", f"{c[0]:+.2f} {c[1]:+.2f} {c[2]:+.2f}", f"{cal.implied_delay_ms(name):+.0f}"]) - t = ax.table(cellText=rows, colLabels=["camera", "angle deg", "rotvec deg (ref axes)", "centre m (rig)", "exposure vs ref ms"], loc="center", cellLoc="center", colWidths=[0.14, 0.14, 0.36, 0.3, 0.14]) + rows.append( + [ + name, + f"{np.degrees(rel.magnitude()):.3f}", + f"{rv[0]:+.3f} {rv[1]:+.3f} {rv[2]:+.3f}", + f"{c[0]:+.2f} {c[1]:+.2f} {c[2]:+.2f}", + f"{cal.implied_delay_ms(name):+.0f}", + ] + ) + t = ax.table( + cellText=rows, + colLabels=[ + "camera", + "angle deg", + "rotvec deg (ref axes)", + "centre m (rig)", + "exposure vs ref ms", + ], + loc="center", + cellLoc="center", + colWidths=[0.14, 0.14, 0.36, 0.3, 0.14], + ) t.auto_set_font_size(False) t.set_fontsize(7.5) t.scale(1, 1.6) ax3 = fig.add_subplot(1, 2, 2, projection="3d") for i, name in enumerate(sorted(cal.cameras)): z = cal.cameras[name].rig_from_cam.apply([0, 0, 1]) - ax3.quiver(0, 0, 0, *z, length=1.0, label=name, arrow_length_ratio=0.08, color=f"C{i}") - ax3.set_xlim(-1, 1); ax3.set_ylim(-1, 1); ax3.set_zlim(0, 1) - ax3.set_xlabel("rig x"); ax3.set_ylabel("rig y"); ax3.set_zlabel("rig z (optical)") + ax3.quiver( + 0, 0, 0, *z, length=1.0, label=name, arrow_length_ratio=0.08, color=f"C{i}" + ) + ax3.set_xlim(-1, 1) + ax3.set_ylim(-1, 1) + ax3.set_zlim(0, 1) + ax3.set_xlabel("rig x") + ax3.set_ylabel("rig y") + ax3.set_zlabel("rig z (optical)") ax3.set_title("optical axes in the rig frame", fontsize=10) ax3.legend(fontsize=6, loc="upper left") pdf.savefig(fig) @@ -115,18 +183,36 @@ def boresight_page(pdf: PdfPages, cal: RigCalibration) -> None: mag = np.linalg.norm(res[keep], axis=1) fig, axes = plt.subplots(2, 2, figsize=PAGE) e = cal.ins_from_rig.as_euler("ZYX", degrees=True) - fig.suptitle(f"INS boresight: ins_from_rig euler ZYX = ({e[0]:.4f}, {e[1]:.4f}, {e[2]:.4f}) deg, lever arm = " - f"({cal.lever_arm_m[0]:.2f}, {cal.lever_arm_m[1]:.2f}, {cal.lever_arm_m[2]:.2f}) m; " - f"{keep.sum()} frames, {(~keep).sum()} rejected", fontsize=10, weight="bold") + fig.suptitle( + f"INS boresight: ins_from_rig euler ZYX = ({e[0]:.4f}, {e[1]:.4f}, {e[2]:.4f}) deg, lever arm = " + f"({cal.lever_arm_m[0]:.2f}, {cal.lever_arm_m[1]:.2f}, {cal.lever_arm_m[2]:.2f}) m; " + f"{keep.sum()} frames, {(~keep).sum()} rejected", + fontsize=10, + weight="bold", + ) for i, lbl in enumerate("xyz"): axes[0, 0].plot(t[keep], res[keep, i], ".", ms=2, label=f"rot {lbl}") axes[1, 0].plot(t[keep], pos[keep, i], ".", ms=2, label=f"pos {lbl}") - axes[0, 0].set(title="per-frame boresight residual (deg, rig axes)", xlabel="s since first frame"); axes[0, 0].legend(fontsize=7) - axes[1, 0].set(title="rig origin vs INS minus lever arm (m, body axes)", xlabel="s since first frame"); axes[1, 0].legend(fontsize=7) + axes[0, 0].set( + title="per-frame boresight residual (deg, rig axes)", + xlabel="s since first frame", + ) + axes[0, 0].legend(fontsize=7) + axes[1, 0].set( + title="rig origin vs INS minus lever arm (m, body axes)", + xlabel="s since first frame", + ) + axes[1, 0].legend(fontsize=7) axes[0, 1].hist(mag, bins=50, color="gray") - axes[0, 1].set(title=f"residual magnitude: median {np.median(mag):.3f}, p90 {np.percentile(mag, 90):.3f} deg", xlabel="deg") + axes[0, 1].set( + title=f"residual magnitude: median {np.median(mag):.3f}, p90 {np.percentile(mag, 90):.3f} deg", + xlabel="deg", + ) axes[1, 1].hist(cal.ins_gap_s * 1000, bins=40, color="gray") - axes[1, 1].set(title=f"INS sample staleness: median {np.median(cal.ins_gap_s) * 1000:.1f} ms", xlabel="ms") + axes[1, 1].set( + title=f"INS sample staleness: median {np.median(cal.ins_gap_s) * 1000:.1f} ms", + xlabel="ms", + ) fig.tight_layout(rect=(0, 0, 1, 0.95)) pdf.savefig(fig) plt.close(fig) @@ -135,24 +221,52 @@ def boresight_page(pdf: PdfPages, cal: RigCalibration) -> None: def homography_page(pdf: PdfPages, pair: dict) -> None: s = pair["stats"] fig = plt.figure(figsize=PAGE) - fig.suptitle(f"{pair['left']} -> {pair['right']} at {s['rangeM']:.0f} m: fit rms {s['rmsPx']:.2f} px, p95 {s['p95Px']:.2f} px, max {s['maxPx']:.2f} px, " - f"coverage {100 * s['coverage']:.0f}%", fontsize=11, weight="bold") - for i, (key, title) in enumerate([("warped_img", f"{pair['left']} warped into {pair['right']}"), ("right_img", pair["right"]), ("overlay_img", "overlay (magenta/green)")]): + fig.suptitle( + f"{pair['left']} -> {pair['right']} at {s['rangeM']:.0f} m: fit rms {s['rmsPx']:.2f} px, p95 {s['p95Px']:.2f} px, max {s['maxPx']:.2f} px, " + f"coverage {100 * s['coverage']:.0f}%", + fontsize=11, + weight="bold", + ) + for i, (key, title) in enumerate( + [ + ("warped_img", f"{pair['left']} warped into {pair['right']}"), + ("right_img", pair["right"]), + ("overlay_img", "overlay (magenta/green)"), + ] + ): ax = fig.add_subplot(1, 3, i + 1) - ax.imshow(pair[key]); ax.set_title(title, fontsize=9); ax.axis("off") - fig.text(0.06, 0.04, "H (left -> right) = " + np.array2string(np.asarray(pair["h"]), precision=5, suppress_small=True, max_line_width=200).replace("\n", " "), fontsize=7, family="monospace") + ax.imshow(pair[key]) + ax.set_title(title, fontsize=9) + ax.axis("off") + fig.text( + 0.06, + 0.04, + "H (left -> right) = " + + np.array2string( + np.asarray(pair["h"]), precision=5, suppress_small=True, max_line_width=200 + ).replace("\n", " "), + fontsize=7, + family="monospace", + ) pdf.savefig(fig) plt.close(fig) -def write_report(path: str, cal: RigCalibration, pairs: list[dict], notes: str = "") -> None: +def write_report( + path: str, cal: RigCalibration, pairs: list[dict], notes: str = "" +) -> None: with PdfPages(path) as pdf: - _text_page(pdf, f"KAMERA rig calibration: {cal.rig}", textwrap.dedent(f"""\ + _text_page( + pdf, + f"KAMERA rig calibration: {cal.rig}", + textwrap.dedent(f"""\ flight: {cal.flight} reference camera: {cal.reference} - cameras: {', '.join(sorted(cal.cameras))} + cameras: {", ".join(sorted(cal.cameras))} frames used: {int(cal.inlier.sum())} - """) + notes) + """) + + notes, + ) camera_page(pdf, cal) rig_page(pdf, cal) boresight_page(pdf, cal) diff --git a/kamera/calibration/rig.py b/kamera/calibration/rig.py index a5e789e8..d2cc021a 100644 --- a/kamera/calibration/rig.py +++ b/kamera/calibration/rig.py @@ -46,8 +46,12 @@ class RigCalibration: ins_from_rig: Rotation lever_arm_m: np.ndarray frame_times: np.ndarray - rotation_residual_deg: np.ndarray # (N, 3) rotvec of each frame's boresight about the mean, rig axes - position_residual_m: np.ndarray # (N, 3) rig origin relative to INS, body axes, minus the lever arm + rotation_residual_deg: ( + np.ndarray + ) # (N, 3) rotvec of each frame's boresight about the mean, rig axes + position_residual_m: ( + np.ndarray + ) # (N, 3) rig origin relative to INS, body axes, minus the lever arm ins_gap_s: np.ndarray # (N,) staleness of the INS sample behind each frame ground_speed_mps: float scene_range_m: float # median distance from the reference camera to its 3D points @@ -58,7 +62,9 @@ def camera_quaternion(self, name: str) -> np.ndarray: return (self.ins_from_rig * self.cameras[name].rig_from_cam).as_quat() def camera_position(self, name: str) -> np.ndarray: - return self.lever_arm_m + self.ins_from_rig.apply(self.cameras[name].center_in_rig) + return self.lever_arm_m + self.ins_from_rig.apply( + self.cameras[name].center_in_rig + ) def implied_delay_ms(self, name: str) -> float: """Exposure midpoint of a camera relative to the reference camera's, from its along-track offset. @@ -66,10 +72,16 @@ def implied_delay_ms(self, name: str) -> float: Positive means it exposes later than the reference. Only this relative timing is observable: the position priors absorb any delay common to the whole rig. """ - return 1000.0 * float(self.ins_from_rig.apply(self.cameras[name].center_in_rig)[0]) / self.ground_speed_mps + return ( + 1000.0 + * float(self.ins_from_rig.apply(self.cameras[name].center_in_rig)[0]) + / self.ground_speed_mps + ) -def per_camera_reprojection(model: pc.Reconstruction, names: dict) -> dict[str, list[float]]: +def per_camera_reprojection( + model: pc.Reconstruction, names: dict +) -> dict[str, list[float]]: errors: dict[str, list[float]] = {} for im in model.images.values(): if not im.has_pose: @@ -79,11 +91,20 @@ def per_camera_reprojection(model: pc.Reconstruction, names: dict) -> dict[str, if p.has_point3D(): proj = im.project_point(model.points3D[p.point3D_id].xyz) if proj is not None: - errors.setdefault(cam, []).append(float(np.linalg.norm(proj - p.xy))) + errors.setdefault(cam, []).append( + float(np.linalg.norm(proj - p.xy)) + ) return errors -def calibrate_rig(model: pc.Reconstruction, names: dict, ins: InsTrajectory, reference: str, rig_name: str, flight: str) -> RigCalibration: +def calibrate_rig( + model: pc.Reconstruction, + names: dict, + ins: InsTrajectory, + reference: str, + rig_name: str, + flight: str, +) -> RigCalibration: """Read the rig geometry out of the reconstruction and solve the INS boresight over all frames.""" rig = next(iter(model.rigs.values())) errors = per_camera_reprojection(model, names) @@ -91,19 +112,36 @@ def calibrate_rig(model: pc.Reconstruction, names: dict, ins: InsTrajectory, ref frames_per_camera: dict[str, int] = {} for im in model.images.values(): if im.has_pose: - frames_per_camera[names[im.name][0]] = frames_per_camera.get(names[im.name][0], 0) + 1 + frames_per_camera[names[im.name][0]] = ( + frames_per_camera.get(names[im.name][0], 0) + 1 + ) for im in model.images.values(): name = names[im.name][0] if name in cameras or not im.has_pose: continue cam = model.cameras[im.camera_id] - cam_from_rig = pc.Rigid3d() if rig.is_ref_sensor(cam.sensor_id) else rig.sensor_from_rig(cam.sensor_id) + cam_from_rig = ( + pc.Rigid3d() + if rig.is_ref_sensor(cam.sensor_id) + else rig.sensor_from_rig(cam.sensor_id) + ) fx, fy, cx, cy, k1, k2, p1, p2 = cam.params cameras[name] = CameraCalibration( - name=name, width=cam.width, height=cam.height, K=np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]), - dist=np.array([k1, k2, p1, p2]), cam_from_rig=cam_from_rig, - colmap_params={"model": cam.model_name, "params": [float(v) for v in cam.params]}, - frames=frames_per_camera[name], reproj_rms_px=float(np.sqrt(np.mean(np.square(errors.get(name, [np.nan])))))) + name=name, + width=cam.width, + height=cam.height, + K=np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]), + dist=np.array([k1, k2, p1, p2]), + cam_from_rig=cam_from_rig, + colmap_params={ + "model": cam.model_name, + "params": [float(v) for v in cam.params], + }, + frames=frames_per_camera[name], + reproj_rms_px=float( + np.sqrt(np.mean(np.square(errors.get(name, [np.nan])))) + ), + ) times, ins_from_rig, lever, gaps = [], [], [], [] for frame in model.frames.values(): @@ -113,7 +151,11 @@ def calibrate_rig(model: pc.Reconstruction, names: dict, ins: InsTrajectory, ref world_from_rig = frame.rig_from_world.inverse() pos, enu_from_body = ins.pose(t) times.append(t) - ins_from_rig.append((enu_from_body.inv() * Rotation.from_quat(world_from_rig.rotation.quat)).as_quat()) + ins_from_rig.append( + ( + enu_from_body.inv() * Rotation.from_quat(world_from_rig.rotation.quat) + ).as_quat() + ) lever.append(enu_from_body.inv().apply(world_from_rig.translation - pos)) gaps.append(ins.sample_gap(t)) order = np.argsort(times) @@ -121,14 +163,34 @@ def calibrate_rig(model: pc.Reconstruction, names: dict, ins: InsTrajectory, ref lever = np.array(lever)[order] mean_rot, mean_lever, _, keep = robust_mean(rotations, lever) positions = np.array([ins.pose(t)[0] for t in np.array(times)[order]]) - speed = float(np.median(np.linalg.norm(np.diff(positions, axis=0), axis=1) / np.diff(np.array(times)[order]))) - ranges = [np.linalg.norm(model.points3D[p.point3D_id].xyz - im.projection_center()) for im in model.images.values() - if im.has_pose and names[im.name][0] == reference for p in im.points2D[::50] if p.has_point3D()] + speed = float( + np.median( + np.linalg.norm(np.diff(positions, axis=0), axis=1) + / np.diff(np.array(times)[order]) + ) + ) + ranges = [ + np.linalg.norm(model.points3D[p.point3D_id].xyz - im.projection_center()) + for im in model.images.values() + if im.has_pose and names[im.name][0] == reference + for p in im.points2D[::50] + if p.has_point3D() + ] return RigCalibration( - rig=rig_name, flight=flight, reference=reference, cameras=cameras, ins_from_rig=mean_rot, lever_arm_m=mean_lever, - frame_times=np.array(times)[order], rotation_residual_deg=(mean_rot.inv() * rotations).as_rotvec(degrees=True), - position_residual_m=lever - mean_lever, ins_gap_s=np.array(gaps)[order], ground_speed_mps=speed, - scene_range_m=float(np.median(ranges)), inlier=keep) + rig=rig_name, + flight=flight, + reference=reference, + cameras=cameras, + ins_from_rig=mean_rot, + lever_arm_m=mean_lever, + frame_times=np.array(times)[order], + rotation_residual_deg=(mean_rot.inv() * rotations).as_rotvec(degrees=True), + position_residual_m=lever - mean_lever, + ins_gap_s=np.array(gaps)[order], + ground_speed_mps=speed, + scene_range_m=float(np.median(ranges)), + inlier=keep, + ) def _floats(a) -> list[float]: @@ -139,20 +201,41 @@ def write_camera_yaml(cal: RigCalibration, name: str, path: str) -> None: """KAMERA ``standard`` camera model plus rig and calibration provenance (loader ignores the extras).""" cam = cal.cameras[name] body = { - "model_type": "standard", "image_width": int(cam.width), "image_height": int(cam.height), - "fx": float(cam.K[0, 0]), "fy": float(cam.K[1, 1]), "cx": float(cam.K[0, 2]), "cy": float(cam.K[1, 2]), + "model_type": "standard", + "image_width": int(cam.width), + "image_height": int(cam.height), + "fx": float(cam.K[0, 0]), + "fy": float(cam.K[1, 1]), + "cx": float(cam.K[0, 2]), + "cy": float(cam.K[1, 2]), "distortion_coefficients": _floats(cam.dist), - "camera_quaternion": _floats(cal.camera_quaternion(name)), "camera_position": _floats(cal.camera_position(name)), - "camera_name": name, "channel": name.split("_")[0], "modality": name.split("_")[1], "rig": cal.rig, + "camera_quaternion": _floats(cal.camera_quaternion(name)), + "camera_position": _floats(cal.camera_position(name)), + "camera_name": name, + "channel": name.split("_")[0], + "modality": name.split("_")[1], + "rig": cal.rig, "reference_camera": cal.reference, - "cam_from_rig": {"quaternion_xyzw": _floats(cam.cam_from_rig.rotation.quat), "translation_m": _floats(cam.cam_from_rig.translation)}, + "cam_from_rig": { + "quaternion_xyzw": _floats(cam.cam_from_rig.rotation.quat), + "translation_m": _floats(cam.cam_from_rig.translation), + }, "colmap_camera": cam.colmap_params, - "calibration": {"flight": cal.flight, "generated": datetime.datetime.now(datetime.timezone.utc).date().isoformat(), "frames": cam.frames, - "reprojection_rms_px": cam.reproj_rms_px, "ifov_deg": float(np.degrees(1.0 / cam.K[0, 0]))}, + "calibration": { + "flight": cal.flight, + "generated": datetime.datetime.now(datetime.timezone.utc) + .date() + .isoformat(), + "frames": cam.frames, + "reprojection_rms_px": cam.reproj_rms_px, + "ifov_deg": float(np.degrees(1.0 / cam.K[0, 0])), + }, } - header = ("# KAMERA camera model. camera_quaternion (x, y, z, w) rotates camera vectors into the INS body\n" - "# frame; camera_position is the camera centre in that frame (metres). distortion_coefficients\n" - "# follow OpenCV (k1, k2, p1, p2). The extra keys record the rig calibration this came from.\n") + header = ( + "# KAMERA camera model. camera_quaternion (x, y, z, w) rotates camera vectors into the INS body\n" + "# frame; camera_position is the camera centre in that frame (metres). distortion_coefficients\n" + "# follow OpenCV (k1, k2, p1, p2). The extra keys record the rig calibration this came from.\n" + ) with open(path, "w") as f: f.write(header) yaml.safe_dump(body, f, sort_keys=False) @@ -163,27 +246,58 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: cams = {} for name, cam in cal.cameras.items(): rel = ref.rig_from_cam.inv() * cam.rig_from_cam - cams[name] = {"cam_from_rig": {"quaternion_xyzw": _floats(cam.cam_from_rig.rotation.quat), "translation_m": _floats(cam.cam_from_rig.translation)}, - "rotation_from_reference_deg": _floats(rel.as_rotvec(degrees=True)), "angle_from_reference_deg": float(np.degrees(rel.magnitude())), - "centre_in_rig_m": _floats(cam.center_in_rig), "centre_in_ins_body_m": _floats(cal.ins_from_rig.apply(cam.center_in_rig)), - "exposure_offset_from_reference_ms": cal.implied_delay_ms(name), "frames": cam.frames, "reprojection_rms_px": cam.reproj_rms_px} + cams[name] = { + "cam_from_rig": { + "quaternion_xyzw": _floats(cam.cam_from_rig.rotation.quat), + "translation_m": _floats(cam.cam_from_rig.translation), + }, + "rotation_from_reference_deg": _floats(rel.as_rotvec(degrees=True)), + "angle_from_reference_deg": float(np.degrees(rel.magnitude())), + "centre_in_rig_m": _floats(cam.center_in_rig), + "centre_in_ins_body_m": _floats(cal.ins_from_rig.apply(cam.center_in_rig)), + "exposure_offset_from_reference_ms": cal.implied_delay_ms(name), + "frames": cam.frames, + "reprojection_rms_px": cam.reproj_rms_px, + } keep = cal.inlier res = np.linalg.norm(cal.rotation_residual_deg[keep], axis=1) body = { - "rig": cal.rig, "flight": cal.flight, "reference_camera": cal.reference, "generated": datetime.datetime.now(datetime.timezone.utc).date().isoformat(), - "ins_from_rig": {"quaternion_xyzw": _floats(cal.ins_from_rig.as_quat()), "rotvec_deg": _floats(cal.ins_from_rig.as_rotvec(degrees=True)), - "euler_zyx_deg": _floats(cal.ins_from_rig.as_euler("ZYX", degrees=True)), "lever_arm_m": _floats(cal.lever_arm_m)}, - "flight_stats": {"ground_speed_mps": cal.ground_speed_mps, "scene_range_m": cal.scene_range_m}, - "boresight_quality": {"frames": int(keep.sum()), "frames_rejected": int((~keep).sum()), - "rotation_scatter_deg": {"median": float(np.median(res)), "p90": float(np.percentile(res, 90)), "max": float(res.max())}, - "rotation_axis_std_deg": _floats(cal.rotation_residual_deg[keep].std(0)), - "lever_arm_std_m": _floats(cal.position_residual_m[keep].std(0)), - "ins_sample_gap_s": {"median": float(np.median(cal.ins_gap_s)), "max": float(cal.ins_gap_s.max())}}, + "rig": cal.rig, + "flight": cal.flight, + "reference_camera": cal.reference, + "generated": datetime.datetime.now(datetime.timezone.utc).date().isoformat(), + "ins_from_rig": { + "quaternion_xyzw": _floats(cal.ins_from_rig.as_quat()), + "rotvec_deg": _floats(cal.ins_from_rig.as_rotvec(degrees=True)), + "euler_zyx_deg": _floats(cal.ins_from_rig.as_euler("ZYX", degrees=True)), + "lever_arm_m": _floats(cal.lever_arm_m), + }, + "flight_stats": { + "ground_speed_mps": cal.ground_speed_mps, + "scene_range_m": cal.scene_range_m, + }, + "boresight_quality": { + "frames": int(keep.sum()), + "frames_rejected": int((~keep).sum()), + "rotation_scatter_deg": { + "median": float(np.median(res)), + "p90": float(np.percentile(res, 90)), + "max": float(res.max()), + }, + "rotation_axis_std_deg": _floats(cal.rotation_residual_deg[keep].std(0)), + "lever_arm_std_m": _floats(cal.position_residual_m[keep].std(0)), + "ins_sample_gap_s": { + "median": float(np.median(cal.ins_gap_s)), + "max": float(cal.ins_gap_s.max()), + }, + }, "cameras": cams, } with open(path, "w") as f: - f.write("# Rig geometry (cam_from_rig maps rig -> camera, COLMAP convention) and INS boresight (ins_from_rig maps rig -> INS body).\n" - "# A camera exposing later than the reference sits ahead along track by speed x delay; exposure_offset_from_reference_ms reads that off.\n") + f.write( + "# Rig geometry (cam_from_rig maps rig -> camera, COLMAP convention) and INS boresight (ins_from_rig maps rig -> INS body).\n" + "# A camera exposing later than the reference sits ahead along track by speed x delay; exposure_offset_from_reference_ms reads that off.\n" + ) yaml.safe_dump(body, f, sort_keys=False) diff --git a/kamera/calibration/sfm.py b/kamera/calibration/sfm.py index e864290b..f6ff8635 100644 --- a/kamera/calibration/sfm.py +++ b/kamera/calibration/sfm.py @@ -21,42 +21,77 @@ def device() -> pc.Device: return pc.Device.cuda if pc.has_cuda else pc.Device.cpu -def extract_features(db_path: str, image_dir: str, names: dict, focal_px: dict, max_image_size: int, num_features: int) -> None: +def extract_features( + db_path: str, + image_dir: str, + names: dict, + focal_px: dict, + max_image_size: int, + num_features: int, +) -> None: """SIFT per camera folder, seeding each camera with its modality's prior focal length.""" for camera in sorted({c for c, _ in names.values()}): image_names = sorted(n for n in names if n.startswith(camera + "/")) w, h = PIL.Image.open(os.path.join(image_dir, image_names[0])).size f = focal_px[camera.split("_")[1]] - reader = pc.ImageReaderOptions(camera_model=CAMERA_MODEL, camera_params=f"{f},{f},{w / 2},{h / 2},0,0,0,0") + reader = pc.ImageReaderOptions( + camera_model=CAMERA_MODEL, camera_params=f"{f},{f},{w / 2},{h / 2},0,0,0,0" + ) # Each thread decodes a full-resolution image, so large sensors get fewer threads. - opts = pc.FeatureExtractionOptions(max_image_size=max_image_size, use_gpu=pc.has_cuda, num_threads=4 if w * h > 40e6 else 16) + opts = pc.FeatureExtractionOptions( + max_image_size=max_image_size, + use_gpu=pc.has_cuda, + num_threads=4 if w * h > 40e6 else 16, + ) opts.sift.max_num_features = num_features - pc.extract_features(db_path, image_dir, image_names=image_names, camera_mode=pc.CameraMode.PER_FOLDER, - reader_options=reader, extraction_options=opts, device=device()) + pc.extract_features( + db_path, + image_dir, + image_names=image_names, + camera_mode=pc.CameraMode.PER_FOLDER, + reader_options=reader, + extraction_options=opts, + device=device(), + ) def write_pose_priors(db_path: str, names: dict, ins, std_m: float) -> None: """Attach the INS ENU position at each image's trigger time as a COLMAP pose prior.""" db = pc.Database.open(db_path) for image in db.read_all_images(): - prior = pc.PosePrior(position=ins.pose(names[image.name][1])[0], position_covariance=np.eye(3) * std_m**2, - coordinate_system=pc.PosePriorCoordinateSystem.CARTESIAN) - prior.corr_data_id = pc.data_t(pc.sensor_t(pc.SensorType.CAMERA, image.camera_id), image.image_id) + prior = pc.PosePrior( + position=ins.pose(names[image.name][1])[0], + position_covariance=np.eye(3) * std_m**2, + coordinate_system=pc.PosePriorCoordinateSystem.CARTESIAN, + ) + prior.corr_data_id = pc.data_t( + pc.sensor_t(pc.SensorType.CAMERA, image.camera_id), image.image_id + ) db.write_pose_prior(prior) db.close() def match_features(db_path: str, max_distance_m: float, max_neighbors: int) -> None: """Match each image against its spatial neighbours (from the priors), across all cameras.""" - pairing = pc.SpatialPairingOptions(max_num_neighbors=max_neighbors, max_distance=max_distance_m, ignore_z=True) - pc.match_spatial(db_path, matching_options=pc.FeatureMatchingOptions(use_gpu=pc.has_cuda), pairing_options=pairing, device=device()) + pairing = pc.SpatialPairingOptions( + max_num_neighbors=max_neighbors, max_distance=max_distance_m, ignore_z=True + ) + pc.match_spatial( + db_path, + matching_options=pc.FeatureMatchingOptions(use_gpu=pc.has_cuda), + pairing_options=pairing, + device=device(), + ) prune_cross_spectral(db_path) def prune_cross_spectral(db_path: str) -> int: """Drop thermal-to-visible pairs: SIFT cannot match them, so their few 'inliers' only mislead the mapper.""" db = pc.Database.open(db_path) - is_ir = {im.image_id: im.name.split("/")[0].endswith("_ir") for im in db.read_all_images()} + is_ir = { + im.image_id: im.name.split("/")[0].endswith("_ir") + for im in db.read_all_images() + } pair_ids, _ = db.read_two_view_geometries() dropped = 0 for pair_id in pair_ids: @@ -71,7 +106,11 @@ def prune_cross_spectral(db_path: str) -> int: def mapping_options(refine_rig: bool) -> pc.IncrementalPipelineOptions: # Colours are unused and extracting them re-decodes every 100 MP frame; keep memory down. - opts = pc.IncrementalPipelineOptions(use_prior_position=True, ba_refine_sensor_from_rig=refine_rig, extract_colors=False) + opts = pc.IncrementalPipelineOptions( + use_prior_position=True, + ba_refine_sensor_from_rig=refine_rig, + extract_colors=False, + ) # Nadir aerial pairs subtend small angles; the default 16 deg init threshold rejects them. opts.mapper.init_min_tri_angle = 4.0 # Global BA every 30% of growth instead of 10%: it dominates runtime on thousands of frames. @@ -80,17 +119,29 @@ def mapping_options(refine_rig: bool) -> pc.IncrementalPipelineOptions: return opts -def run_mapping(db_path: str, image_dir: str, out_dir: str) -> dict[int, pc.Reconstruction]: +def run_mapping( + db_path: str, image_dir: str, out_dir: str +) -> dict[int, pc.Reconstruction]: """Incremental mapping from scratch with every camera independent (trivial rigs).""" shutil.rmtree(out_dir, ignore_errors=True) os.makedirs(out_dir) - return pc.incremental_mapping(db_path, image_dir, out_dir, options=mapping_options(refine_rig=False)) + return pc.incremental_mapping( + db_path, image_dir, out_dir, options=mapping_options(refine_rig=False) + ) -def rig_bundle_adjust(model: pc.Reconstruction, priors: list, refine_intrinsics: bool, max_iterations: int) -> str: +def rig_bundle_adjust( + model: pc.Reconstruction, priors: list, refine_intrinsics: bool, max_iterations: int +) -> str: """Refine rig poses, sensor_from_rig and optionally intrinsics, anchored to the INS position priors.""" - opts = pc.BundleAdjustmentOptions(refine_sensor_from_rig=True, refine_rig_from_world=True, refine_principal_point=False, - refine_focal_length=refine_intrinsics, refine_extra_params=refine_intrinsics, print_summary=False) + opts = pc.BundleAdjustmentOptions( + refine_sensor_from_rig=True, + refine_rig_from_world=True, + refine_principal_point=False, + refine_focal_length=refine_intrinsics, + refine_extra_params=refine_intrinsics, + print_summary=False, + ) opts.ceres.solver_options.max_num_iterations = max_iterations config = pc.BundleAdjustmentConfig() for image in model.images.values(): @@ -98,12 +149,16 @@ def rig_bundle_adjust(model: pc.Reconstruction, priors: list, refine_intrinsics: config.add_image(image.image_id) prior_opts = pc.PosePriorBundleAdjustmentOptions() prior_opts.alignment_ransac.max_error = 5.0 - summary = pc.create_pose_prior_bundle_adjuster(opts, prior_opts, config, priors, model).solve() + summary = pc.create_pose_prior_bundle_adjuster( + opts, prior_opts, config, priors, model + ).solve() model.update_point_3d_errors() return summary.brief_report() -def refine_rig(db_path: str, names: dict, init_dir: str, out_dir: str, max_iterations: int = 200) -> pc.Reconstruction: +def refine_rig( + db_path: str, names: dict, init_dir: str, out_dir: str, max_iterations: int = 200 +) -> pc.Reconstruction: """Pass 2: triangulate every image from the rig poses, bundle adjust, retriangulate, and bundle adjust again with the intrinsics free. Returns the final model, also written to ``out_dir``.""" shutil.rmtree(out_dir, ignore_errors=True) @@ -119,23 +174,50 @@ def refine_rig(db_path: str, names: dict, init_dir: str, out_dir: str, max_itera opts = mapping_options(refine_rig=True) model = pc.Reconstruction(init_dir) for refine_intrinsics in (False, True): - model = pc.triangulate_points(model, db_path, image_dir, out_dir, clear_points=True, options=opts, refine_intrinsics=False) - print(f" triangulated {model.num_points3D()} points, {model.compute_mean_reprojection_error():.2f} px", flush=True) - print(f" {rig_bundle_adjust(model, priors, refine_intrinsics, max_iterations)}", flush=True) + model = pc.triangulate_points( + model, + db_path, + image_dir, + out_dir, + clear_points=True, + options=opts, + refine_intrinsics=False, + ) + print( + f" triangulated {model.num_points3D()} points, {model.compute_mean_reprojection_error():.2f} px", + flush=True, + ) + print( + f" {rig_bundle_adjust(model, priors, refine_intrinsics, max_iterations)}", + flush=True, + ) model.write(out_dir) return model def load_models(out_dir: str) -> dict[int, pc.Reconstruction]: - return {int(d): pc.Reconstruction(os.path.join(out_dir, d)) for d in sorted(os.listdir(out_dir)) if d.isdigit()} + return { + int(d): pc.Reconstruction(os.path.join(out_dir, d)) + for d in sorted(os.listdir(out_dir)) + if d.isdigit() + } -def image_poses(models: dict[int, pc.Reconstruction], names: dict) -> dict[tuple[str, float], pc.Rigid3d]: +def image_poses( + models: dict[int, pc.Reconstruction], names: dict +) -> dict[tuple[str, float], pc.Rigid3d]: """``{(camera, time): cam_from_world}`` over every posed image in every model (all in INS ENU).""" - return {names[im.name]: im.cam_from_world() for r in models.values() for im in r.images.values() if im.has_pose} + return { + names[im.name]: im.cam_from_world() + for r in models.values() + for im in r.images.values() + if im.has_pose + } -def robust_mean(rotations: Rotation, translations: np.ndarray, cluster_deg: float = 1.0) -> tuple[Rotation, np.ndarray, np.ndarray, np.ndarray]: +def robust_mean( + rotations: Rotation, translations: np.ndarray, cluster_deg: float = 1.0 +) -> tuple[Rotation, np.ndarray, np.ndarray, np.ndarray]: """Mean rotation of the densest cluster and median translation over its members. Seeds from the sample with the most neighbours within ``cluster_deg``, so a wrongly @@ -150,24 +232,46 @@ def robust_mean(rotations: Rotation, translations: np.ndarray, cluster_deg: floa angles = np.degrees((mean.inv() * rotations).magnitude()) keep = angles <= max(3.0 * np.median(angles[keep]), 0.05) mean = rotations[keep].mean() - return mean, np.median(translations[keep], 0), np.degrees((mean.inv() * rotations).magnitude()), keep + return ( + mean, + np.median(translations[keep], 0), + np.degrees((mean.inv() * rotations).magnitude()), + keep, + ) -def derive_rig(models: dict[int, pc.Reconstruction], names: dict, reference: str) -> dict[str, dict]: +def derive_rig( + models: dict[int, pc.Reconstruction], names: dict, reference: str +) -> dict[str, dict]: """Initial ``cam_from_rig`` per camera from frames where it and the reference camera are both posed.""" poses = image_poses(models, names) rig = {} for camera in sorted({c for c, _ in names.values()}): - rel = [poses[(camera, t)] * poses[(reference, t)].inverse() for (c, t) in poses if c == camera and (reference, t) in poses] + rel = [ + poses[(camera, t)] * poses[(reference, t)].inverse() + for (c, t) in poses + if c == camera and (reference, t) in poses + ] if len(rel) < 3: - raise RuntimeError(f"{camera}: only {len(rel)} frames shared with {reference}; cannot initialise the rig") - rot, trans, angles, keep = robust_mean(Rotation.from_quat([x.rotation.quat for x in rel]), np.array([x.translation for x in rel])) - rig[camera] = {"cam_from_rig": pc.Rigid3d(pc.Rotation3d(rot.as_quat()), trans), "frames": int(keep.sum()), - "rotation_scatter_deg": float(np.median(angles[keep])), "translation_std_m": np.array([x.translation for x in rel])[keep].std(0)} + raise RuntimeError( + f"{camera}: only {len(rel)} frames shared with {reference}; cannot initialise the rig" + ) + rot, trans, angles, keep = robust_mean( + Rotation.from_quat([x.rotation.quat for x in rel]), + np.array([x.translation for x in rel]), + ) + rig[camera] = { + "cam_from_rig": pc.Rigid3d(pc.Rotation3d(rot.as_quat()), trans), + "frames": int(keep.sum()), + "rotation_scatter_deg": float(np.median(angles[keep])), + "translation_std_m": np.array([x.translation for x in rel])[keep].std(0), + } return rig -def model_cameras(models: dict[int, pc.Reconstruction], names: dict) -> dict[str, pc.Camera]: +def model_cameras( + models: dict[int, pc.Reconstruction], names: dict +) -> dict[str, pc.Camera]: """Refined intrinsics per camera name from whichever model registered it.""" cams = {} for r in models.values(): @@ -177,7 +281,13 @@ def model_cameras(models: dict[int, pc.Reconstruction], names: dict) -> dict[str return cams -def rigged_model(db_path: str, models: dict[int, pc.Reconstruction], names: dict, reference: str, out_dir: str) -> pc.Reconstruction: +def rigged_model( + db_path: str, + models: dict[int, pc.Reconstruction], + names: dict, + reference: str, + out_dir: str, +) -> pc.Reconstruction: """Put the rig onto the largest pass-1 model and complete its frames with every sensor's image. Writes the rig and frames into the database, copies each registered frame's pose from the @@ -187,10 +297,17 @@ def rigged_model(db_path: str, models: dict[int, pc.Reconstruction], names: dict """ rig = derive_rig(models, names, reference) cameras = model_cameras(models, names) - config = pc.RigConfig(cameras=[ - pc.RigConfigCamera(ref_sensor=(name == reference), image_prefix=name + "/", camera=cameras[name], - cam_from_rig=None if name == reference else rig[name]["cam_from_rig"]) - for name in [reference] + sorted(set(rig) - {reference})]) + config = pc.RigConfig( + cameras=[ + pc.RigConfigCamera( + ref_sensor=(name == reference), + image_prefix=name + "/", + camera=cameras[name], + cam_from_rig=None if name == reference else rig[name]["cam_from_rig"], + ) + for name in [reference] + sorted(set(rig) - {reference}) + ] + ) model = largest(models) for cam in cameras.values(): if not model.exists_camera(cam.camera_id): @@ -199,7 +316,9 @@ def rigged_model(db_path: str, models: dict[int, pc.Reconstruction], names: dict db.clear_frames() db.clear_rigs() pc.apply_rig_config([config], db, model) - poses = {f.frame_id: f.rig_from_world for f in model.frames.values() if f.has_pose()} + poses = { + f.frame_id: f.rig_from_world for f in model.frames.values() if f.has_pose() + } frames = db.read_all_frames() for frame in frames: if frame.frame_id in poses: diff --git a/kamera/colmap_processing/camera_models.py b/kamera/colmap_processing/camera_models.py index dc745ae8..3236a7e4 100644 --- a/kamera/colmap_processing/camera_models.py +++ b/kamera/colmap_processing/camera_models.py @@ -479,8 +479,7 @@ def unproject_to_llh(self, points, t=None, cov=None): if lat0 is None or lon0 is None or h0 is None: raise Exception( - "'platform_pose_provider' must have 'lat0', " - "'lon0', and 'ho' defined." + "'platform_pose_provider' must have 'lat0', 'lon0', and 'ho' defined." ) points = np.array(points) @@ -1137,7 +1136,7 @@ def unproject(self, points, t=None, normalize_ray_dir=True): if normalize_ray_dir: # Normalize - ray_dir /= np.sqrt(np.sum(ray_dir ** 2, 0)) + ray_dir /= np.sqrt(np.sum(ray_dir**2, 0)) return ray_pos, ray_dir @@ -1899,7 +1898,7 @@ def unproject(self, points, t=None, normalize_ray_dir=True): ray_dir = np.dot(self.R.T, ray_dir) if normalize_ray_dir: - ray_dir /= np.sqrt(np.sum(ray_dir ** 2, 0)) + ray_dir /= np.sqrt(np.sum(ray_dir**2, 0)) ray_pos = np.zeros_like(ray_dir) diff --git a/pyproject.toml b/pyproject.toml index ed6ae424..070edb0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,8 +36,15 @@ kamera-calibrate = "kamera.calibration.cli:main" [dependency-groups] dev = [ "ipdb>=0.13.13", + "ruff>=0.6", ] +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] + [tool.uv] # Build .venv on the conda python, never a uv-managed one, so # --system-site-packages sees the conda GDAL. diff --git a/uv.lock b/uv.lock index dd7f9ac3..27ff24e1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.10" resolution-markers = [ "python_full_version == '3.12.*'", @@ -426,6 +425,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "ipdb" }, + { name = "ruff" }, ] [package.metadata] @@ -449,7 +449,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ipdb", specifier = ">=0.13.13" }] +dev = [ + { name = "ipdb", specifier = ">=0.13.13" }, + { name = "ruff", specifier = ">=0.6" }, +] [[package]] name = "kiwisolver" @@ -559,6 +562,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403 }, { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687 }, { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032 }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262 }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036 }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295 }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987 }, { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606 }, { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537 }, { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888 }, @@ -1289,6 +1296,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, ] +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966 }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498 }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004 }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558 }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332 }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125 }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694 }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448 }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796 }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115 }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582 }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644 }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569 }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666 }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472 }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899 }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316 }, +] + [[package]] name = "scipy" version = "1.15.3" From f2a32920268138fd232fb9b3baef2750df0b3c05 Mon Sep 17 00:00:00 2001 From: romleiaj Date: Mon, 21 Sep 2026 10:38:37 -0400 Subject: [PATCH 15/32] Tidy the calibration package for clarity - calibrate_rig builds the per-camera entries and counts frames in one pass, sorts the per-frame arrays once, and reads scene ranges through a named helper instead of a nested comprehension - RigCalibration gains center_in_ins_body and rotation_from_reference, which the yaml writer, the report and the delay estimate all used to spell out - InsTrajectory shares the segment lookup between pose and sample_gap - build_image_tree uses a plain if/else instead of a side-effect conditional expression and only starts a process pool when there is work - cli hoists the camera set and GIF frame list out of the loops they were recomputed in, and helpers come before main - derive_rig builds the translation array once; the triangulator's refine_intrinsics=False gets a comment explaining why it stays off - report drops semicolon-joined statements - docstrings, comments and long strings wrapped to 88 columns, which the formatter leaves alone --- kamera/calibration/cli.py | 111 +++++++++--------- kamera/calibration/config.py | 6 +- kamera/calibration/flight.py | 56 +++++---- kamera/calibration/registration.py | 14 ++- kamera/calibration/report.py | 126 ++++++++++---------- kamera/calibration/rig.py | 179 ++++++++++++++++------------- kamera/calibration/sfm.py | 79 ++++++++----- 7 files changed, 317 insertions(+), 254 deletions(-) diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index fbe8ed51..a320134f 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -1,4 +1,4 @@ -"""``kamera-calibrate``: run the rig calibration pipeline stage by stage, resuming from disk.""" +"""``kamera-calibrate``: run the calibration pipeline stage by stage, resumable.""" from __future__ import annotations @@ -21,6 +21,34 @@ PAIRS = [("ir", "uv"), ("ir", "rgb"), ("uv", "rgb")] +def write_gifs(frames, names, image_dir, left, right, h, gif_dir, count) -> dict: + """Flip GIFs of the left image warped onto the right, for evenly spaced frames. + + Returns the report images (warped, right, overlay) from the middle frame. + """ + os.makedirs(gif_dir, exist_ok=True) + by_time = {t: n for n, (c, t) in names.items() if c == left} + usable = [f for f in frames if left in f.images and right in f.images] + out = {} + chosen = usable[:: max(1, len(usable) // max(count, 1))][:count] + for k, frame in enumerate(chosen): + left_img = cv2.imread( + os.path.join(image_dir, by_time[frame.time]), cv2.IMREAD_COLOR + ) + right_img = cv2.imread(frame.images[right], cv2.IMREAD_COLOR) + warped, ref = registration.warp_pair(left_img, right_img, h) + registration.write_gif( + os.path.join(gif_dir, f"{left}_to_{right}_{k}.gif"), warped, ref + ) + if k == len(chosen) // 2: + out = { + "warped_img": warped, + "right_img": ref, + "overlay_img": registration.blend_overlay(warped, ref), + } + return out + + def main(argv=None) -> None: cfg = CalibrateConfig.cli(argv=argv, strict=True) work = cfg.work_dir or os.path.join(cfg.flight_dir, "calibration") @@ -38,17 +66,16 @@ def done(path: str) -> bool: print("[blue]Discovering frames[/blue]") frames, ins, rig_name = discover_flight(cfg.flight_dir) rig_name = cfg.rig_name or rig_name.replace("images_", "") or "rig" - full = [ - f - for f in frames - if len(f.images) == len({c for fr in frames for c in fr.images}) - ] + all_cameras = {camera for frame in frames for camera in frame.images} + full = [f for f in frames if len(f.images) == len(all_cameras)] stop = ( cfg.frame_start + cfg.max_frames * cfg.frame_stride if cfg.max_frames else None ) frames = full[cfg.frame_start : stop : cfg.frame_stride] + median_gap_ms = 1000 * np.median([ins.sample_gap(f.time) for f in frames]) print( - f"{len(full)} frames with every camera, using {len(frames)}; INS median sample gap {np.median([ins.sample_gap(f.time) for f in frames]) * 1000:.1f} ms" + f"{len(full)} frames with every camera, using {len(frames)}; " + f"INS median sample gap {median_gap_ms:.1f} ms" ) names = build_image_tree(frames, image_dir) with open(os.path.join(work, "images.json"), "w") as f: @@ -80,21 +107,26 @@ def done(path: str) -> bool: pass1 = sfm.load_models(pass1_dir) for k, r in pass1.items(): print( - f" model {k}: {r.num_reg_images()} images, {r.num_points3D()} points, {r.compute_mean_reprojection_error():.2f} px" + f" model {k}: {r.num_reg_images()} images, {r.num_points3D()} points, " + f"{r.compute_mean_reprojection_error():.2f} px" ) if not done(rig_dir): print("[blue]Pass 2: rig bundle adjustment[/blue]") for name, v in sfm.derive_rig(pass1, names, cfg.reference_camera).items(): print( - f" {name}: {v['frames']} frames, rotation scatter {v['rotation_scatter_deg']:.3f} deg, translation std {np.round(v['translation_std_m'], 2)} m" + f" {name}: {v['frames']} frames, " + f"rotation scatter {v['rotation_scatter_deg']:.3f} deg, " + f"translation std {np.round(v['translation_std_m'], 2)} m" ) rig_in = os.path.join(work, "rig_init") sfm.rigged_model(db_path, pass1, names, cfg.reference_camera, rig_in) sfm.refine_rig(db_path, names, rig_in, rig_dir) model = pc.Reconstruction(rig_dir) print( - f" rig model: {model.num_reg_frames()} frames, {model.num_reg_images()} images, {model.compute_mean_reprojection_error():.2f} px" + f" rig model: {model.num_reg_frames()} frames, " + f"{model.num_reg_images()} images, " + f"{model.compute_mean_reprojection_error():.2f} px" ) print("[blue]Extracting camera models and boresight[/blue]") @@ -124,8 +156,11 @@ def done(path: str) -> bool: } range_m = cfg.registration_range_m or cal.scene_range_m registered = {names[im.name][1] for im in model.images.values() if im.has_pose} + gif_frames = [f for f in frames if f.time in registered] + gif_dir = os.path.join(model_dir, "gifs") print( - f" homographies exact at {range_m:.0f} m range; ground speed {cal.ground_speed_mps:.0f} m/s" + f" homographies exact at {range_m:.0f} m range; " + f"ground speed {cal.ground_speed_mps:.0f} m/s" ) pairs = [] for channel in sorted({n.split("_")[0] for n in cams}): @@ -140,33 +175,16 @@ def done(path: str) -> bool: except ValueError as e: print(f" [yellow]{left} -> {right}: {e}[/yellow]") continue + source = registration.source_stamp(cfg.flight_dir, {"rig": rig_name}) path = registration.write_dive_registration( - reg_dir, - left, - right, - h, - stats, - registration.source_stamp(cfg.flight_dir, {"rig": rig_name}), + reg_dir, left, right, h, stats, source ) print(f" wrote {path} (fit rms {stats['rmsPx']:.2f} px)") - gif_frames = [f for f in frames if f.time in registered] + images = write_gifs( + gif_frames, names, image_dir, left, right, h, gif_dir, cfg.gif_frames + ) pairs.append( - { - "left": left, - "right": right, - "h": h, - "stats": stats, - **write_gifs( - gif_frames, - names, - image_dir, - left, - right, - h, - os.path.join(model_dir, "gifs"), - cfg.gif_frames, - ), - } + {"left": left, "right": right, "h": h, "stats": stats, **images} ) report_path = os.path.join(model_dir, f"{rig_name}_calibration_report.pdf") @@ -174,30 +192,5 @@ def done(path: str) -> bool: print(f"[green]Report written to {report_path}[/green]") -def write_gifs(frames, names, image_dir, left, right, h, gif_dir, count) -> dict: - """Flip GIFs of the left image warped onto the right for evenly spaced frames; returns report images from the middle one.""" - os.makedirs(gif_dir, exist_ok=True) - by_time = {t: n for n, (c, t) in names.items() if c == left} - usable = [f for f in frames if left in f.images and right in f.images] - out = {} - chosen = usable[:: max(1, len(usable) // max(count, 1))][:count] - for k, frame in enumerate(chosen): - left_img = cv2.imread( - os.path.join(image_dir, by_time[frame.time]), cv2.IMREAD_COLOR - ) - right_img = cv2.imread(frame.images[right], cv2.IMREAD_COLOR) - warped, ref = registration.warp_pair(left_img, right_img, h) - registration.write_gif( - os.path.join(gif_dir, f"{left}_to_{right}_{k}.gif"), warped, ref - ) - if k == len(chosen) // 2: - out = { - "warped_img": warped, - "right_img": ref, - "overlay_img": registration.blend_overlay(warped, ref), - } - return out - - if __name__ == "__main__": main(sys.argv[1:]) diff --git a/kamera/calibration/config.py b/kamera/calibration/config.py index 9ee525c3..e03884ed 100644 --- a/kamera/calibration/config.py +++ b/kamera/calibration/config.py @@ -39,14 +39,16 @@ class CalibrateConfig(scfg.DataConfig): ) match_neighbors = scfg.Value( 90, - help="Spatial matching neighbours per image (about 10 frames times the number of cameras)", + help="Spatial matching neighbours per image " + "(about 10 frames times the number of cameras)", ) prior_std_m = scfg.Value( 2.0, help="Standard deviation assigned to INS position priors" ) registration_range_m = scfg.Value( 0.0, - help="Ground range the homographies are exact at; 0 = median scene range of the calibration model. Set to the survey AGL", + help="Ground range the homographies are exact at; 0 = median scene range of " + "the calibration model. Set to the survey AGL", ) gif_frames = scfg.Value(5, help="Registration GIFs written per camera pair") force = scfg.Value( diff --git a/kamera/calibration/flight.py b/kamera/calibration/flight.py index 9dae0e9b..1214e033 100644 --- a/kamera/calibration/flight.py +++ b/kamera/calibration/flight.py @@ -1,4 +1,4 @@ -"""Flight discovery: synchronized frames, camera naming, INS trajectory, COLMAP image tree.""" +"""Flight discovery: synchronized frames, camera names, INS trajectory, image tree.""" from __future__ import annotations @@ -17,13 +17,13 @@ # Image suffixes written by the KAMERA archiver, keyed by modality. MODALITY_EXT = {"rgb": ".jpg", "uv": ".jpg", "ir": ".tif"} -# NED body attitude -> ENU: swap north/east and flip down (a 180 deg turn about (1,1,0)). +# NED body attitude -> ENU: swap north/east and flip down (180 deg turn about (1,1,0)). NED_TO_ENU = Rotation.from_quat([np.sqrt(0.5), np.sqrt(0.5), 0.0, 0.0]) @dataclass class Frame: - """All images captured on one trigger event, keyed by camera name (e.g. ``C_rgb``).""" + """All images captured on one trigger event, keyed by camera name (``C_rgb``).""" time: float images: dict[str, str] = field(default_factory=dict) @@ -32,9 +32,10 @@ class Frame: class InsTrajectory: """INS attitude and ENU position interpolated to any time. - Quaternions follow the KAMERA convention: ``rotation`` maps body (forward, right, down) - vectors into the local ENU frame. Any source with times, lat/lon/alt and heading/pitch/roll - can build one, so a future high-rate or event-stamped log drops in via ``__init__``. + Quaternions follow the KAMERA convention: ``rotation`` maps body (forward, right, + down) vectors into the local ENU frame. Any source with times, lat/lon/alt and + heading/pitch/roll can build one, so a future high-rate or event-stamped log drops + in via ``__init__``. """ def __init__(self, times, llh_deg, hpr_deg, lat0=None, lon0=None, h0=0.0): @@ -58,8 +59,12 @@ def from_meta(cls, samples: dict[float, tuple]) -> InsTrajectory: rows = np.array([samples[t] for t in sorted(samples)]) return cls(sorted(samples), rows[:, :3], rows[:, 3:]) + def _segment(self, t: float) -> int: + """Index ``i`` with ``t`` between samples ``i - 1`` and ``i`` (clamped).""" + return int(np.clip(bisect.bisect(self.times, t), 1, len(self.times) - 1)) + def pose(self, t: float) -> tuple[np.ndarray, Rotation]: - i = int(np.clip(bisect.bisect(self.times, t), 1, len(self.times) - 1)) + i = self._segment(t) w = float( np.clip( (t - self.times[i - 1]) / (self.times[i] - self.times[i - 1]), 0.0, 1.0 @@ -71,7 +76,7 @@ def pose(self, t: float) -> tuple[np.ndarray, Rotation]: def sample_gap(self, t: float) -> float: """Seconds from ``t`` to the nearest INS sample (how stale the attitude is).""" - i = int(np.clip(bisect.bisect(self.times, t), 1, len(self.times) - 1)) + i = self._segment(t) return float(min(abs(t - self.times[i - 1]), abs(t - self.times[i]))) # Duck-type the NavStateProvider interface used by camera_models. @@ -83,11 +88,12 @@ def quat(self, t): def discover_flight(flight_dir: str) -> tuple[list[Frame], InsTrajectory, str]: - """Group every ``*_meta.json`` under ``flight_dir`` into trigger-synchronized frames. + """Group every ``*_meta.json`` under ``flight_dir`` into synchronized frames. Camera names are ``_`` with the channel taken from the view directory (``center_view`` -> ``C``). Returns frames sorted by time, the INS - trajectory assembled from the per-image INS samples, and the rig name from ``sys_cfg``. + trajectory assembled from the per-image INS samples, and the rig name from + ``sys_cfg``. """ frames: dict[float, Frame] = {} samples: dict[float, tuple] = {} @@ -97,7 +103,8 @@ def discover_flight(flight_dir: str) -> tuple[list[Frame], InsTrajectory, str]: ): with open(meta) as f: d = json.load(f) - channel = os.path.basename(os.path.dirname(meta)).split("_")[0][0].upper() + view_dir = os.path.basename(os.path.dirname(meta)) # e.g. center_view + channel = view_dir[0].upper() stem = meta[: -len("_meta.json")] t = float(d["evt"]["time"]) frame = frames.setdefault(round(t, 3), Frame(time=t)) @@ -124,7 +131,10 @@ def discover_flight(flight_dir: str) -> tuple[list[Frame], InsTrajectory, str]: def normalize(src: str, dst: str) -> None: - """Percentile-stretch (0.1-99.9) a dim or 16-bit frame to 8 bits and apply CLAHE, so SIFT has contrast.""" + """Percentile-stretch (0.1-99.9) a dim or 16-bit frame to 8 bits and apply CLAHE. + + Gives SIFT some contrast to work with on UV and IR. + """ im = cv2.imread(src, cv2.IMREAD_UNCHANGED).astype(np.float32) lo, hi = np.percentile(im, [0.1, 99.9]) im = np.clip((im - lo) / max(hi - lo, 1.0) * 255.0, 0, 255).astype(np.uint8) @@ -138,26 +148,28 @@ def normalize(src: str, dst: str) -> None: def build_image_tree( frames: list[Frame], image_dir: str ) -> dict[str, tuple[str, float]]: - """Lay frames out as ``image_dir//.jpg`` for COLMAP's per-folder cameras. + """Lay frames out as ``image_dir//.jpg`` for COLMAP. - COLMAP groups images into rig frames by identical file names across folders, hence the - time-based names. RGB is symlinked; the dim UV and 16-bit IR frames are contrast-normalized. + COLMAP assigns one camera per folder and groups images into rig frames by identical + file names across folders, hence the time-based names. RGB is symlinked; the dim UV + and 16-bit IR frames are contrast-normalized. Returns ``{colmap image name: (camera name, frame time)}``. """ names: dict[str, tuple[str, float]] = {} - jobs = [] + to_normalize: list[tuple[str, str]] = [] for frame in frames: for camera, src in frame.images.items(): - stretch = not camera.endswith("_rgb") name = f"{camera}/{frame.time:.3f}.jpg" dst = os.path.join(image_dir, name) os.makedirs(os.path.dirname(dst), exist_ok=True) names[name] = (camera, frame.time) if os.path.exists(dst): continue - jobs.append((src, dst)) if stretch else os.symlink( - os.path.abspath(src), dst - ) - with ProcessPoolExecutor() as pool: - list(pool.map(normalize, *zip(*jobs)) if jobs else []) + if camera.endswith("_rgb"): + os.symlink(os.path.abspath(src), dst) + else: + to_normalize.append((src, dst)) + if to_normalize: + with ProcessPoolExecutor() as pool: + list(pool.map(normalize, *zip(*to_normalize))) return names diff --git a/kamera/calibration/registration.py b/kamera/calibration/registration.py index 7e29262b..a9b95c39 100644 --- a/kamera/calibration/registration.py +++ b/kamera/calibration/registration.py @@ -1,4 +1,4 @@ -"""Inter-camera homographies: DIVE camera-registration JSON (format v2) and GIF overlays. +"""Inter-camera homographies: DIVE camera-registration JSON (v2) and GIF overlays. Each ``_to__registration.json`` holds one matrix-only pair whose ``leftToRight`` homography maps left-camera pixels onto right-camera pixels. The @@ -25,7 +25,10 @@ def model_homography( src_cm, dst_cm, range_m: float, grid: int = 40 ) -> tuple[np.ndarray, dict]: - """Least-squares homography from ``src_cm`` pixels to ``dst_cm`` pixels for ground ``range_m`` away, plus fit stats.""" + """Least-squares homography from ``src_cm`` pixels to ``dst_cm`` pixels. + + Exact for ground ``range_m`` away from the source camera. Also returns fit stats. + """ xg, yg = np.meshgrid( np.linspace(0, src_cm.width - 1, grid), np.linspace(0, src_cm.height - 1, grid) ) @@ -43,7 +46,7 @@ def model_homography( ) if inside.sum() < 4: raise ValueError( - f"only {inside.sum()} of {src.shape[1]} samples land in the destination image" + f"only {inside.sum()} of {src.shape[1]} samples land in the destination" ) h, _ = cv2.findHomography(src[:, inside].T, dst[:, inside].T, 0) err = np.linalg.norm( @@ -103,7 +106,10 @@ def source_stamp(flight_dir: str, extra: dict | None = None) -> dict: def warp_pair( left_img: np.ndarray, right_img: np.ndarray, h: np.ndarray, width: int = 1280 ) -> tuple[np.ndarray, np.ndarray]: - """Warp the left image into the right image's pixels; both returned resized to ``width`` wide, RGB.""" + """Warp the left image into the right image's pixels. + + Both are returned resized to ``width`` wide, RGB. + """ scale = width / right_img.shape[1] size = (width, round(right_img.shape[0] * scale)) s = np.diag([scale, scale, 1.0]) diff --git a/kamera/calibration/report.py b/kamera/calibration/report.py index 1701bd05..32ae60b1 100644 --- a/kamera/calibration/report.py +++ b/kamera/calibration/report.py @@ -1,4 +1,4 @@ -"""PDF report: camera table, rig geometry, INS boresight residuals, homography overlays, error budget.""" +"""PDF report: cameras, rig geometry, boresight residuals, overlays, error budget.""" from __future__ import annotations @@ -18,36 +18,38 @@ ERROR_NOTES = """\ Error budget and what limits it -INS attitude at the trigger. Each meta.json carries one 100 Hz INS sample taken before the -event, so the attitude used here is up to 10 ms stale (median gap reported above). At the -turn rates of a figure-eight (about 5 deg/s) that is up to 0.05 deg, or roughly 25 RGB -pixels, and it enters every frame's boresight estimate as noise. A hardware event-stamped -INS sample or a full-rate log removes it; InsTrajectory accepts either without code changes. +INS attitude at the trigger. Each meta.json carries one 100 Hz INS sample taken before +the event, so the attitude used here is up to 10 ms stale (median gap reported above). +At the turn rates of a figure-eight (about 5 deg/s) that is up to 0.05 deg, or roughly +25 RGB pixels, and it enters every frame's boresight estimate as noise. A hardware +event-stamped INS sample or a full-rate log removes it; InsTrajectory accepts either +without code changes. -SfM drift. Bundle adjustment with INS position priors pins scale, heading and position to -the INS, but the relative orientation drift of the model over the flight is what dominates the -per-frame boresight scatter. The rig constraint removes the intra-frame freedom entirely, so -the relative camera geometry (and therefore the homographies) is far better determined than -the absolute boresight. +SfM drift. Bundle adjustment with INS position priors pins scale, heading and position +to the INS, but the relative orientation drift of the model over the flight is what +dominates the per-frame boresight scatter. The rig constraint removes the intra-frame +freedom entirely, so the relative camera geometry (and therefore the homographies) is +far better determined than the absolute boresight. -Exposure timing. A camera whose exposure midpoint differs from the reference camera's sees -the ground further along track by ground speed x time difference, and a bundle adjustment on a -translating rig cannot tell that from a camera mounted that far forward. The rig table's -"exposure vs ref" column reads each camera's forward offset back into a time difference at -the flight's ground speed (negative = earlier than the reference). Only the relative timing is -observable: the position priors absorb any delay shared by the whole rig. The camera yaml -positions carry these offsets, which is correct at similar ground speeds. +Exposure timing. A camera whose exposure midpoint differs from the reference camera's +sees the ground further along track by ground speed x time difference, and a bundle +adjustment on a translating rig cannot tell that from a camera mounted that far forward. +The rig table's "exposure vs ref" column reads each camera's forward offset back into a +time difference at the flight's ground speed (negative = earlier than the reference). +Only the relative timing is observable: the position priors absorb any delay shared by +the whole rig. The camera yaml positions carry these offsets, which is correct at +similar ground speeds. -Lever arms. Beyond that timing signal, at 400 to 900 m a 30 cm baseline subtends less than one -IR pixel, so the rig translations are weakly determined and the reported standard deviations -should be read as such. The INS lever arm is the median offset of the rig origin from the INS -position over all frames. +Lever arms. Beyond that timing signal, at 400 to 900 m a 30 cm baseline subtends less +than one IR pixel, so the rig translations are weakly determined and the reported +standard deviations should be read as such. The INS lever arm is the median offset of +the rig origin from the INS position over all frames. Homographies. A homography maps one camera onto another exactly only for a plane at one -range, and the timing baseline above makes the range matter. Each pair is fit for the range -in its title (the survey AGL if given, else the calibration flight's median scene range); the -fit residual (rms and p95, in right-image pixels) then measures the lens distortion a single -matrix cannot carry, and the warped overlays show it visually. +range, and the timing baseline above makes the range matter. Each pair is fit for the +range in its title (the survey AGL if given, else the calibration flight's median scene +range); the fit residual (rms and p95, in right-image pixels) then measures the lens +distortion a single matrix cannot carry, and the warped overlays show it visually. """ @@ -118,7 +120,6 @@ def camera_page(pdf: PdfPages, cal: RigCalibration) -> None: def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: - ref = cal.cameras[cal.reference] fig = plt.figure(figsize=PAGE) fig.suptitle( f"Rig geometry relative to {cal.reference}", @@ -131,7 +132,7 @@ def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: ax.axis("off") rows = [] for name in sorted(cal.cameras): - rel = ref.rig_from_cam.inv() * cal.cameras[name].rig_from_cam + rel = cal.rotation_from_reference(name) rv, c = rel.as_rotvec(degrees=True), cal.cameras[name].center_in_rig rows.append( [ @@ -142,15 +143,16 @@ def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: f"{cal.implied_delay_ms(name):+.0f}", ] ) + header = [ + "camera", + "angle deg", + "rotvec deg (ref axes)", + "centre m (rig)", + "exposure vs ref ms", + ] t = ax.table( cellText=rows, - colLabels=[ - "camera", - "angle deg", - "rotvec deg (ref axes)", - "centre m (rig)", - "exposure vs ref ms", - ], + colLabels=header, loc="center", cellLoc="center", colWidths=[0.14, 0.14, 0.36, 0.3, 0.14], @@ -164,12 +166,14 @@ def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: ax3.quiver( 0, 0, 0, *z, length=1.0, label=name, arrow_length_ratio=0.08, color=f"C{i}" ) - ax3.set_xlim(-1, 1) - ax3.set_ylim(-1, 1) - ax3.set_zlim(0, 1) - ax3.set_xlabel("rig x") - ax3.set_ylabel("rig y") - ax3.set_zlabel("rig z (optical)") + ax3.set( + xlim=(-1, 1), + ylim=(-1, 1), + zlim=(0, 1), + xlabel="rig x", + ylabel="rig y", + zlabel="rig z (optical)", + ) ax3.set_title("optical axes in the rig frame", fontsize=10) ax3.legend(fontsize=6, loc="upper left") pdf.savefig(fig) @@ -183,9 +187,11 @@ def boresight_page(pdf: PdfPages, cal: RigCalibration) -> None: mag = np.linalg.norm(res[keep], axis=1) fig, axes = plt.subplots(2, 2, figsize=PAGE) e = cal.ins_from_rig.as_euler("ZYX", degrees=True) + lever = cal.lever_arm_m fig.suptitle( - f"INS boresight: ins_from_rig euler ZYX = ({e[0]:.4f}, {e[1]:.4f}, {e[2]:.4f}) deg, lever arm = " - f"({cal.lever_arm_m[0]:.2f}, {cal.lever_arm_m[1]:.2f}, {cal.lever_arm_m[2]:.2f}) m; " + f"INS boresight: ins_from_rig euler ZYX = " + f"({e[0]:.4f}, {e[1]:.4f}, {e[2]:.4f}) deg, " + f"lever arm = ({lever[0]:.2f}, {lever[1]:.2f}, {lever[2]:.2f}) m; " f"{keep.sum()} frames, {(~keep).sum()} rejected", fontsize=10, weight="bold", @@ -197,15 +203,16 @@ def boresight_page(pdf: PdfPages, cal: RigCalibration) -> None: title="per-frame boresight residual (deg, rig axes)", xlabel="s since first frame", ) - axes[0, 0].legend(fontsize=7) axes[1, 0].set( title="rig origin vs INS minus lever arm (m, body axes)", xlabel="s since first frame", ) + axes[0, 0].legend(fontsize=7) axes[1, 0].legend(fontsize=7) axes[0, 1].hist(mag, bins=50, color="gray") axes[0, 1].set( - title=f"residual magnitude: median {np.median(mag):.3f}, p90 {np.percentile(mag, 90):.3f} deg", + title=f"residual magnitude: median {np.median(mag):.3f}, " + f"p90 {np.percentile(mag, 90):.3f} deg", xlabel="deg", ) axes[1, 1].hist(cal.ins_gap_s * 1000, bins=40, color="gray") @@ -222,31 +229,28 @@ def homography_page(pdf: PdfPages, pair: dict) -> None: s = pair["stats"] fig = plt.figure(figsize=PAGE) fig.suptitle( - f"{pair['left']} -> {pair['right']} at {s['rangeM']:.0f} m: fit rms {s['rmsPx']:.2f} px, p95 {s['p95Px']:.2f} px, max {s['maxPx']:.2f} px, " + f"{pair['left']} -> {pair['right']} at {s['rangeM']:.0f} m: " + f"fit rms {s['rmsPx']:.2f} px, p95 {s['p95Px']:.2f} px, " + f"max {s['maxPx']:.2f} px, " f"coverage {100 * s['coverage']:.0f}%", fontsize=11, weight="bold", ) - for i, (key, title) in enumerate( - [ - ("warped_img", f"{pair['left']} warped into {pair['right']}"), - ("right_img", pair["right"]), - ("overlay_img", "overlay (magenta/green)"), - ] - ): + panels = [ + ("warped_img", f"{pair['left']} warped into {pair['right']}"), + ("right_img", pair["right"]), + ("overlay_img", "overlay (magenta/green)"), + ] + for i, (key, title) in enumerate(panels): ax = fig.add_subplot(1, 3, i + 1) ax.imshow(pair[key]) ax.set_title(title, fontsize=9) ax.axis("off") + h_text = np.array2string( + np.asarray(pair["h"]), precision=5, suppress_small=True, max_line_width=200 + ).replace("\n", " ") fig.text( - 0.06, - 0.04, - "H (left -> right) = " - + np.array2string( - np.asarray(pair["h"]), precision=5, suppress_small=True, max_line_width=200 - ).replace("\n", " "), - fontsize=7, - family="monospace", + 0.06, 0.04, "H (left -> right) = " + h_text, fontsize=7, family="monospace" ) pdf.savefig(fig) plt.close(fig) diff --git a/kamera/calibration/rig.py b/kamera/calibration/rig.py index d2cc021a..a5182415 100644 --- a/kamera/calibration/rig.py +++ b/kamera/calibration/rig.py @@ -1,5 +1,5 @@ -"""Turn the rigged reconstruction into the deliverables: per-camera models (in the INS frame), -the rig geometry, and the INS boresight with its per-frame residuals.""" +"""Turn the rigged reconstruction into the deliverables: per-camera models (in the INS +frame), the rig geometry, and the INS boresight with its per-frame residuals.""" from __future__ import annotations @@ -58,25 +58,30 @@ class RigCalibration: inlier: np.ndarray = field(default_factory=lambda: np.zeros(0, bool)) def camera_quaternion(self, name: str) -> np.ndarray: - """(x, y, z, w) rotating camera vectors into the INS body frame, the KAMERA yaml convention.""" + """(x, y, z, w) rotating camera vectors into the INS body frame (yaml order).""" return (self.ins_from_rig * self.cameras[name].rig_from_cam).as_quat() + def center_in_ins_body(self, name: str) -> np.ndarray: + """Camera centre in INS body axes (forward, right, down) from the rig origin.""" + return self.ins_from_rig.apply(self.cameras[name].center_in_rig) + def camera_position(self, name: str) -> np.ndarray: - return self.lever_arm_m + self.ins_from_rig.apply( - self.cameras[name].center_in_rig + return self.lever_arm_m + self.center_in_ins_body(name) + + def rotation_from_reference(self, name: str) -> Rotation: + """Rotation of a camera relative to the reference camera, in reference axes.""" + return ( + self.cameras[self.reference].rig_from_cam.inv() + * self.cameras[name].rig_from_cam ) def implied_delay_ms(self, name: str) -> float: - """Exposure midpoint of a camera relative to the reference camera's, from its along-track offset. + """Exposure midpoint relative to the reference camera, from the forward offset. Positive means it exposes later than the reference. Only this relative timing is observable: the position priors absorb any delay common to the whole rig. """ - return ( - 1000.0 - * float(self.ins_from_rig.apply(self.cameras[name].center_in_rig)[0]) - / self.ground_speed_mps - ) + return 1000.0 * float(self.center_in_ins_body(name)[0]) / self.ground_speed_mps def per_camera_reprojection( @@ -97,6 +102,23 @@ def per_camera_reprojection( return errors +def reference_scene_ranges( + model: pc.Reconstruction, names: dict, reference: str, stride: int = 50 +) -> list[float]: + """Distance from the reference camera to every ``stride``-th of its 3D points.""" + ranges = [] + for im in model.images.values(): + if not im.has_pose or names[im.name][0] != reference: + continue + center = im.projection_center() + for p in im.points2D[::stride]: + if p.has_point3D(): + ranges.append( + float(np.linalg.norm(model.points3D[p.point3D_id].xyz - center)) + ) + return ranges + + def calibrate_rig( model: pc.Reconstruction, names: dict, @@ -105,49 +127,50 @@ def calibrate_rig( rig_name: str, flight: str, ) -> RigCalibration: - """Read the rig geometry out of the reconstruction and solve the INS boresight over all frames.""" + """Read the rig geometry out of the reconstruction and solve the INS boresight.""" rig = next(iter(model.rigs.values())) errors = per_camera_reprojection(model, names) - cameras = {} - frames_per_camera: dict[str, int] = {} - for im in model.images.values(): - if im.has_pose: - frames_per_camera[names[im.name][0]] = ( - frames_per_camera.get(names[im.name][0], 0) + 1 - ) + cameras: dict[str, CameraCalibration] = {} for im in model.images.values(): - name = names[im.name][0] - if name in cameras or not im.has_pose: + if not im.has_pose: continue - cam = model.cameras[im.camera_id] - cam_from_rig = ( - pc.Rigid3d() - if rig.is_ref_sensor(cam.sensor_id) - else rig.sensor_from_rig(cam.sensor_id) - ) - fx, fy, cx, cy, k1, k2, p1, p2 = cam.params - cameras[name] = CameraCalibration( - name=name, - width=cam.width, - height=cam.height, - K=np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]), - dist=np.array([k1, k2, p1, p2]), - cam_from_rig=cam_from_rig, - colmap_params={ - "model": cam.model_name, - "params": [float(v) for v in cam.params], - }, - frames=frames_per_camera[name], - reproj_rms_px=float( - np.sqrt(np.mean(np.square(errors.get(name, [np.nan])))) - ), - ) + name = names[im.name][0] + if name not in cameras: + cam = model.cameras[im.camera_id] + cam_from_rig = ( + pc.Rigid3d() + if rig.is_ref_sensor(cam.sensor_id) + else rig.sensor_from_rig(cam.sensor_id) + ) + fx, fy, cx, cy, k1, k2, p1, p2 = cam.params + cameras[name] = CameraCalibration( + name=name, + width=cam.width, + height=cam.height, + K=np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]), + dist=np.array([k1, k2, p1, p2]), + cam_from_rig=cam_from_rig, + colmap_params={ + "model": cam.model_name, + "params": [float(v) for v in cam.params], + }, + frames=0, + reproj_rms_px=float( + np.sqrt(np.mean(np.square(errors.get(name, [np.nan])))) + ), + ) + cameras[name].frames += 1 + # Per frame: the boresight (INS body <- rig) and the rig origin relative to the INS + # position, in body axes. times, ins_from_rig, lever, gaps = [], [], [], [] for frame in model.frames.values(): if not frame.has_pose(): continue - t = names[model.images[next(iter(frame.data_ids)).id].name][1] + any_image = model.images[ + next(iter(frame.data_ids)).id + ] # every image in a frame shares the trigger time + t = names[any_image.name][1] world_from_rig = frame.rig_from_world.inverse() pos, enu_from_body = ins.pose(t) times.append(t) @@ -159,23 +182,15 @@ def calibrate_rig( lever.append(enu_from_body.inv().apply(world_from_rig.translation - pos)) gaps.append(ins.sample_gap(t)) order = np.argsort(times) + times = np.array(times)[order] rotations = Rotation.from_quat(np.array(ins_from_rig)[order]) lever = np.array(lever)[order] + gaps = np.array(gaps)[order] mean_rot, mean_lever, _, keep = robust_mean(rotations, lever) - positions = np.array([ins.pose(t)[0] for t in np.array(times)[order]]) + positions = np.array([ins.pose(t)[0] for t in times]) speed = float( - np.median( - np.linalg.norm(np.diff(positions, axis=0), axis=1) - / np.diff(np.array(times)[order]) - ) + np.median(np.linalg.norm(np.diff(positions, axis=0), axis=1) / np.diff(times)) ) - ranges = [ - np.linalg.norm(model.points3D[p.point3D_id].xyz - im.projection_center()) - for im in model.images.values() - if im.has_pose and names[im.name][0] == reference - for p in im.points2D[::50] - if p.has_point3D() - ] return RigCalibration( rig=rig_name, flight=flight, @@ -183,12 +198,12 @@ def calibrate_rig( cameras=cameras, ins_from_rig=mean_rot, lever_arm_m=mean_lever, - frame_times=np.array(times)[order], + frame_times=times, rotation_residual_deg=(mean_rot.inv() * rotations).as_rotvec(degrees=True), position_residual_m=lever - mean_lever, - ins_gap_s=np.array(gaps)[order], + ins_gap_s=gaps, ground_speed_mps=speed, - scene_range_m=float(np.median(ranges)), + scene_range_m=float(np.median(reference_scene_ranges(model, names, reference))), inlier=keep, ) @@ -197,8 +212,15 @@ def _floats(a) -> list[float]: return [float(v) for v in np.asarray(a).ravel()] +def _today() -> str: + return datetime.datetime.now(datetime.timezone.utc).date().isoformat() + + def write_camera_yaml(cal: RigCalibration, name: str, path: str) -> None: - """KAMERA ``standard`` camera model plus rig and calibration provenance (loader ignores the extras).""" + """KAMERA ``standard`` camera model plus rig and calibration provenance. + + The loader ignores the extra keys. + """ cam = cal.cameras[name] body = { "model_type": "standard", @@ -223,18 +245,17 @@ def write_camera_yaml(cal: RigCalibration, name: str, path: str) -> None: "colmap_camera": cam.colmap_params, "calibration": { "flight": cal.flight, - "generated": datetime.datetime.now(datetime.timezone.utc) - .date() - .isoformat(), + "generated": _today(), "frames": cam.frames, "reprojection_rms_px": cam.reproj_rms_px, "ifov_deg": float(np.degrees(1.0 / cam.K[0, 0])), }, } header = ( - "# KAMERA camera model. camera_quaternion (x, y, z, w) rotates camera vectors into the INS body\n" - "# frame; camera_position is the camera centre in that frame (metres). distortion_coefficients\n" - "# follow OpenCV (k1, k2, p1, p2). The extra keys record the rig calibration this came from.\n" + "# KAMERA camera model. camera_quaternion (x, y, z, w) rotates camera\n" + "# vectors into the INS body frame; camera_position is the camera centre in\n" + "# that frame (metres). distortion_coefficients follow OpenCV (k1, k2, p1,\n" + "# p2). The extra keys record the rig calibration this came from.\n" ) with open(path, "w") as f: f.write(header) @@ -242,10 +263,9 @@ def write_camera_yaml(cal: RigCalibration, name: str, path: str) -> None: def write_rig_yaml(cal: RigCalibration, path: str) -> None: - ref = cal.cameras[cal.reference] cams = {} for name, cam in cal.cameras.items(): - rel = ref.rig_from_cam.inv() * cam.rig_from_cam + rel = cal.rotation_from_reference(name) cams[name] = { "cam_from_rig": { "quaternion_xyzw": _floats(cam.cam_from_rig.rotation.quat), @@ -254,7 +274,7 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: "rotation_from_reference_deg": _floats(rel.as_rotvec(degrees=True)), "angle_from_reference_deg": float(np.degrees(rel.magnitude())), "centre_in_rig_m": _floats(cam.center_in_rig), - "centre_in_ins_body_m": _floats(cal.ins_from_rig.apply(cam.center_in_rig)), + "centre_in_ins_body_m": _floats(cal.center_in_ins_body(name)), "exposure_offset_from_reference_ms": cal.implied_delay_ms(name), "frames": cam.frames, "reprojection_rms_px": cam.reproj_rms_px, @@ -265,7 +285,7 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: "rig": cal.rig, "flight": cal.flight, "reference_camera": cal.reference, - "generated": datetime.datetime.now(datetime.timezone.utc).date().isoformat(), + "generated": _today(), "ins_from_rig": { "quaternion_xyzw": _floats(cal.ins_from_rig.as_quat()), "rotvec_deg": _floats(cal.ins_from_rig.as_rotvec(degrees=True)), @@ -295,18 +315,23 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: } with open(path, "w") as f: f.write( - "# Rig geometry (cam_from_rig maps rig -> camera, COLMAP convention) and INS boresight (ins_from_rig maps rig -> INS body).\n" - "# A camera exposing later than the reference sits ahead along track by speed x delay; exposure_offset_from_reference_ms reads that off.\n" + "# Rig geometry (cam_from_rig maps rig -> camera, COLMAP convention) and\n" + "# INS boresight (ins_from_rig maps rig -> INS body). A camera exposing\n" + "# later than the reference sits ahead along track by speed x delay;\n" + "# exposure_offset_from_reference_ms reads that off.\n" ) yaml.safe_dump(body, f, sort_keys=False) def write_outputs(cal: RigCalibration, out_dir: str) -> list[str]: + """Write one yaml per camera plus the rig yaml; returns the paths written.""" os.makedirs(out_dir, exist_ok=True) paths = [] for name in sorted(cal.cameras): - paths.append(os.path.join(out_dir, f"{cal.rig}_{name}.yaml")) - write_camera_yaml(cal, name, paths[-1]) - paths.append(os.path.join(out_dir, f"{cal.rig}_rig.yaml")) - write_rig_yaml(cal, paths[-1]) + path = os.path.join(out_dir, f"{cal.rig}_{name}.yaml") + write_camera_yaml(cal, name, path) + paths.append(path) + rig_path = os.path.join(out_dir, f"{cal.rig}_rig.yaml") + write_rig_yaml(cal, rig_path) + paths.append(rig_path) return paths diff --git a/kamera/calibration/sfm.py b/kamera/calibration/sfm.py index f6ff8635..d050513d 100644 --- a/kamera/calibration/sfm.py +++ b/kamera/calibration/sfm.py @@ -29,7 +29,7 @@ def extract_features( max_image_size: int, num_features: int, ) -> None: - """SIFT per camera folder, seeding each camera with its modality's prior focal length.""" + """SIFT per camera folder, seeding each camera with its modality's focal length.""" for camera in sorted({c for c, _ in names.values()}): image_names = sorted(n for n in names if n.startswith(camera + "/")) w, h = PIL.Image.open(os.path.join(image_dir, image_names[0])).size @@ -37,7 +37,7 @@ def extract_features( reader = pc.ImageReaderOptions( camera_model=CAMERA_MODEL, camera_params=f"{f},{f},{w / 2},{h / 2},0,0,0,0" ) - # Each thread decodes a full-resolution image, so large sensors get fewer threads. + # Each thread decodes a full-resolution image; large sensors get fewer threads. opts = pc.FeatureExtractionOptions( max_image_size=max_image_size, use_gpu=pc.has_cuda, @@ -56,7 +56,7 @@ def extract_features( def write_pose_priors(db_path: str, names: dict, ins, std_m: float) -> None: - """Attach the INS ENU position at each image's trigger time as a COLMAP pose prior.""" + """Attach the INS ENU position at each image's trigger time as a pose prior.""" db = pc.Database.open(db_path) for image in db.read_all_images(): prior = pc.PosePrior( @@ -72,7 +72,10 @@ def write_pose_priors(db_path: str, names: dict, ins, std_m: float) -> None: def match_features(db_path: str, max_distance_m: float, max_neighbors: int) -> None: - """Match each image against its spatial neighbours (from the priors), across all cameras.""" + """Match each image against its spatial neighbours (from the priors). + + Pairs are formed across all cameras. + """ pairing = pc.SpatialPairingOptions( max_num_neighbors=max_neighbors, max_distance=max_distance_m, ignore_z=True ) @@ -86,7 +89,10 @@ def match_features(db_path: str, max_distance_m: float, max_neighbors: int) -> N def prune_cross_spectral(db_path: str) -> int: - """Drop thermal-to-visible pairs: SIFT cannot match them, so their few 'inliers' only mislead the mapper.""" + """Drop thermal-to-visible pairs. + + SIFT cannot match them, so their few 'inliers' only mislead the mapper. + """ db = pc.Database.open(db_path) is_ir = { im.image_id: im.name.split("/")[0].endswith("_ir") @@ -105,15 +111,17 @@ def prune_cross_spectral(db_path: str) -> int: def mapping_options(refine_rig: bool) -> pc.IncrementalPipelineOptions: - # Colours are unused and extracting them re-decodes every 100 MP frame; keep memory down. + # Colours are unused and extracting them re-decodes every 100 MP frame. opts = pc.IncrementalPipelineOptions( use_prior_position=True, ba_refine_sensor_from_rig=refine_rig, extract_colors=False, ) - # Nadir aerial pairs subtend small angles; the default 16 deg init threshold rejects them. + # Nadir aerial pairs subtend small angles; the default 16 deg init threshold + # rejects them. opts.mapper.init_min_tri_angle = 4.0 - # Global BA every 30% of growth instead of 10%: it dominates runtime on thousands of frames. + # Global BA every 30% of growth instead of 10%: it dominates runtime on thousands + # of frames. opts.ba_global_frames_ratio = opts.ba_global_points_ratio = 1.3 opts.ba_global_max_refinements = 2 return opts @@ -133,7 +141,10 @@ def run_mapping( def rig_bundle_adjust( model: pc.Reconstruction, priors: list, refine_intrinsics: bool, max_iterations: int ) -> str: - """Refine rig poses, sensor_from_rig and optionally intrinsics, anchored to the INS position priors.""" + """Refine rig poses, sensor_from_rig and optionally intrinsics. + + Anchored to the INS position priors. + """ opts = pc.BundleAdjustmentOptions( refine_sensor_from_rig=True, refine_rig_from_world=True, @@ -159,11 +170,15 @@ def rig_bundle_adjust( def refine_rig( db_path: str, names: dict, init_dir: str, out_dir: str, max_iterations: int = 200 ) -> pc.Reconstruction: - """Pass 2: triangulate every image from the rig poses, bundle adjust, retriangulate, and bundle - adjust again with the intrinsics free. Returns the final model, also written to ``out_dir``.""" + """Pass 2: triangulate every image from the rig poses, bundle adjust, retriangulate, + and bundle adjust again with the intrinsics free. + + Returns the final model, also written to ``out_dir``. + """ shutil.rmtree(out_dir, ignore_errors=True) os.makedirs(out_dir) - # The triangulator always colours points from disk; 8x8 stand-ins spare it the 100 MP frames. + # The triangulator always colours points from disk; 8x8 stand-ins spare it the + # 100 MP frames. image_dir = os.path.join(os.path.dirname(out_dir), "placeholders") for name in names: os.makedirs(os.path.dirname(os.path.join(image_dir, name)), exist_ok=True) @@ -174,6 +189,8 @@ def refine_rig( opts = mapping_options(refine_rig=True) model = pc.Reconstruction(init_dir) for refine_intrinsics in (False, True): + # Intrinsics are only ever refined in the rig bundle adjustment below, never by + # the triangulator. model = pc.triangulate_points( model, db_path, @@ -184,7 +201,8 @@ def refine_rig( refine_intrinsics=False, ) print( - f" triangulated {model.num_points3D()} points, {model.compute_mean_reprojection_error():.2f} px", + f" triangulated {model.num_points3D()} points, " + f"{model.compute_mean_reprojection_error():.2f} px", flush=True, ) print( @@ -206,7 +224,10 @@ def load_models(out_dir: str) -> dict[int, pc.Reconstruction]: def image_poses( models: dict[int, pc.Reconstruction], names: dict ) -> dict[tuple[str, float], pc.Rigid3d]: - """``{(camera, time): cam_from_world}`` over every posed image in every model (all in INS ENU).""" + """``{(camera, time): cam_from_world}`` over every posed image in every model. + + All models are in INS ENU. + """ return { names[im.name]: im.cam_from_world() for r in models.values() @@ -221,9 +242,9 @@ def robust_mean( """Mean rotation of the densest cluster and median translation over its members. Seeds from the sample with the most neighbours within ``cluster_deg``, so a wrongly - registered majority (a folded sub-model) cannot drag the estimate; then keeps everything - within 3x that cluster's median residual. Returns mean, translation, per-sample residual - angles (deg) and the inlier mask. + registered majority (a folded sub-model) cannot drag the estimate; then keeps + everything within 3x that cluster's median residual. Returns mean, translation, + per-sample residual angles (deg) and the inlier mask. """ q = rotations.as_quat() pairwise = np.degrees(2.0 * np.arccos(np.clip(np.abs(q @ q.T), 0.0, 1.0))) @@ -243,7 +264,7 @@ def robust_mean( def derive_rig( models: dict[int, pc.Reconstruction], names: dict, reference: str ) -> dict[str, dict]: - """Initial ``cam_from_rig`` per camera from frames where it and the reference camera are both posed.""" + """Initial ``cam_from_rig`` per camera from frames shared with the reference.""" poses = image_poses(models, names) rig = {} for camera in sorted({c for c, _ in names.values()}): @@ -254,17 +275,17 @@ def derive_rig( ] if len(rel) < 3: raise RuntimeError( - f"{camera}: only {len(rel)} frames shared with {reference}; cannot initialise the rig" + f"{camera}: only {len(rel)} frames shared with {reference}; " + "cannot initialise the rig" ) - rot, trans, angles, keep = robust_mean( - Rotation.from_quat([x.rotation.quat for x in rel]), - np.array([x.translation for x in rel]), - ) + rotations = Rotation.from_quat([x.rotation.quat for x in rel]) + translations = np.array([x.translation for x in rel]) + rot, trans, angles, keep = robust_mean(rotations, translations) rig[camera] = { "cam_from_rig": pc.Rigid3d(pc.Rotation3d(rot.as_quat()), trans), "frames": int(keep.sum()), "rotation_scatter_deg": float(np.median(angles[keep])), - "translation_std_m": np.array([x.translation for x in rel])[keep].std(0), + "translation_std_m": translations[keep].std(0), } return rig @@ -288,12 +309,12 @@ def rigged_model( reference: str, out_dir: str, ) -> pc.Reconstruction: - """Put the rig onto the largest pass-1 model and complete its frames with every sensor's image. + """Put the rig onto the largest pass-1 model and fill its frames with every image. - Writes the rig and frames into the database, copies each registered frame's pose from the - model, and adds the images (IR, typically) that pass 1 never posed: they inherit their pose - from the frame through the initial ``cam_from_rig``. The result is written to ``out_dir`` as - the starting point for the rig-refining mapping pass. + Writes the rig and frames into the database, copies each registered frame's pose + from the model, and adds the images (IR, typically) that pass 1 never posed: they + inherit their pose from the frame through the initial ``cam_from_rig``. The result + is written to ``out_dir`` as the starting point for the rig-refining mapping pass. """ rig = derive_rig(models, names, reference) cameras = model_cameras(models, names) From cf40a828619f81a9cdd507d606a57d226230dbad Mon Sep 17 00:00:00 2001 From: romleiaj Date: Mon, 21 Sep 2026 10:38:37 -0400 Subject: [PATCH 16/32] camera_models: drop save_to_krtd, fix the two pre-existing lint errors save_to_krtd had no callers. unproject_to_depth and save_depth_viz were defined on the base Camera class, where they shadowed the abstract unproject_to_depth and referenced a depth_map only DepthCamera has; they now live on DepthCamera. The bare except in Camera.__str__ catches Exception. --- kamera/colmap_processing/camera_models.py | 69 ++++++++--------------- 1 file changed, 23 insertions(+), 46 deletions(-) diff --git a/kamera/colmap_processing/camera_models.py b/kamera/colmap_processing/camera_models.py index 3236a7e4..7085ab03 100644 --- a/kamera/colmap_processing/camera_models.py +++ b/kamera/colmap_processing/camera_models.py @@ -332,7 +332,7 @@ def __str__(self): string.append( "".join(["fov: ", "({:.6},{:.6},{:.6})".format(*self.fov(np.inf))]) ) - except: + except Exception: pass return "".join(string) @@ -663,28 +663,6 @@ def fov(self, t=None): return fov_h, fov_v, fov_d - def unproject_to_depth(self, points, t=None): - """See Camera.unproject_to_depth documentation.""" - points = self._unproject_to_depth(points, self.depth_map, t=t) - return points - - def save_depth_viz(self, fname): - depth_image = self.depth_map.copy() - v = depth_image[np.isfinite(depth_image)] - if len(v) > 0: - vmin = np.percentile(v, 1) - vmax = np.percentile(v, 99) - depth_image -= vmin - depth_image[depth_image < 0] = 0 - v = vmax - vmin - if v > 0: - depth_image /= v / 255 - - depth_image = np.round(depth_image).astype(np.uint8) - - depth_image = cv2.applyColorMap(depth_image, cv2.COLORMAP_JET) - cv2.imwrite(fname, depth_image[:, :, ::-1]) - class StandardCamera(Camera): """Standard camera model. @@ -877,29 +855,6 @@ def save_to_file(self, filename): ) ) - def save_to_krtd(self, filename): - """Write a single camera in ASCII KRTD format to the file object. - - Args: - camera (list[np.ndarray]): A length-4 of type (K, R, t, d) - fout (str | os.PathLike): _description_ - """ - K = self.K - R = Rotation.from_quat(self.cam_quat).as_matrix() - t = self.cam_pos - d = self.dist - t = np.reshape(np.array(t), 3) - with open(filename, "w") as fout: - fout.write("%.12g %.12g %.12g\n" % tuple(K.tolist()[0])) - fout.write("%.12g %.12g %.12g\n" % tuple(K.tolist()[1])) - fout.write("%.12g %.12g %.12g\n\n" % tuple(K.tolist()[2])) - fout.write("%.12g %.12g %.12g\n" % tuple(R.tolist()[0])) - fout.write("%.12g %.12g %.12g\n" % tuple(R.tolist()[1])) - fout.write("%.12g %.12g %.12g\n\n" % tuple(R.tolist()[2])) - fout.write("%.12g %.12g %.12g\n\n" % tuple(t.tolist())) - for v in d: - fout.write("%.12g " % v) - @property def K(self): return self._K @@ -1631,6 +1586,28 @@ def _unproject_to_depth(self, points, depth_map, t=None): return ray_pos + def unproject_to_depth(self, points, t=None): + """See Camera.unproject_to_depth documentation.""" + points = self._unproject_to_depth(points, self.depth_map, t=t) + return points + + def save_depth_viz(self, fname): + depth_image = self.depth_map.copy() + v = depth_image[np.isfinite(depth_image)] + if len(v) > 0: + vmin = np.percentile(v, 1) + vmax = np.percentile(v, 99) + depth_image -= vmin + depth_image[depth_image < 0] = 0 + v = vmax - vmin + if v > 0: + depth_image /= v / 255 + + depth_image = np.round(depth_image).astype(np.uint8) + + depth_image = cv2.applyColorMap(depth_image, cv2.COLORMAP_JET) + cv2.imwrite(fname, depth_image[:, :, ::-1]) + class GeoStaticCamera(DepthCamera): """Stationary camera with a fixed pose at some geo-fixed location. From 6a00e9947603038e9088a7bb1cb927ed9849b91c Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 11:12:23 -0400 Subject: [PATCH 17/32] Add bootstrap.py: one-step environment build on Linux, macOS and Windows Creates or updates the conda env from environment.yml and builds .venv on top of it with uv from the lockfile, via conda run so nothing needs activating first. Windows previously used pip install -e . and skipped the lockfile. make install now calls the script; the Python version is read from environment.yml instead of being duplicated in the Makefile. README recommends Miniforge, with Miniconda/Anaconda as alternatives. --- CHANGELOG.md | 3 +- Makefile | 8 ++--- README.md | 34 ++++++++----------- bootstrap.py | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 26 deletions(-) create mode 100644 bootstrap.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc3144a..1e6c4b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Post-processing env moves to Python 3.13 and pycolmap 4.2 (conda-forge, CUDA build). -- `make install` recreates `.venv` instead of failing when it exists. +- `bootstrap.py` builds the conda env and `.venv` in one step on Linux, macOS and + Windows; `make install` calls it. `.venv` is recreated instead of failing when it exists. - ruff (line length 88) is the project formatter and linter, installed with the `dev` group. ### Removed diff --git a/Makefile b/Makefile index f58c3fda..9ca67349 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,11 @@ ROS_DISTRO ?= noetic -PYTHON_VERSION ?= 3.13 +CONDA_ENV ?= kamera .PHONY: install build core viame gui postflight follower leader all clean -# Build .venv on top of an activated conda env from environment.yml +# Conda env from environment.yml plus .venv on top of it (see bootstrap.py) install: - @echo "🚀 Creating virtual environment using uv" - @uv venv --clear --system-site-packages --python=$(PYTHON_VERSION) - @uv sync --frozen --no-cache + @python bootstrap.py --name $(CONDA_ENV) build: docker compose build diff --git a/README.md b/README.md index 01b14806..53deb41e 100644 --- a/README.md +++ b/README.md @@ -25,33 +25,27 @@ KAMERA, or the **K**nowledge-guided Image **A**cquisition **M**anag**ER** and ** ### Post-processing (native, Windows or Linux) GDAL and pycolmap come from conda-forge (Python 3.13); [uv](https://docs.astral.sh/uv/) -installs the rest into `.venv`. Requires -[conda](https://conda-forge.org/download/). - -Linux/macOS: +installs the rest into `.venv` from the lockfile. Requires conda: +[Miniforge](https://conda-forge.org/download/) is recommended since it defaults to the +conda-forge channel these packages come from, but +[Miniconda](https://www.anaconda.com/download/success) or a full Anaconda install also +work because `environment.yml` pins the channel. The same steps work on Linux, macOS +and Windows (PowerShell or Miniforge/Anaconda Prompt): ```bash git clone https://github.com/Kitware/kamera.git cd kamera -conda env create -f environment.yml -conda activate kamera -make install -source .venv/bin/activate -``` - -Windows (PowerShell or Anaconda Prompt): - -```powershell -git clone https://github.com/Kitware/kamera.git -cd kamera -conda env create -f environment.yml +python bootstrap.py conda activate kamera -pip install -e . +source .venv/bin/activate # Windows: .venv\Scripts\activate ``` -Afterwards, `conda activate kamera` is all you need. Conda installs the CUDA -build of pycolmap automatically with NVIDIA driver 575+ (CUDA 12.9), -otherwise the CPU build; GPU only matters for full camera model calibration. +`bootstrap.py` creates the `kamera` conda env from `environment.yml` (or updates it +if it exists) and builds `.venv` on top of it; `make install` does the same on Linux. +Pass `--name` to build a second env beside an existing one. Afterwards, activating +the conda env and then `.venv` is all you need. Conda installs the CUDA build of +pycolmap automatically with NVIDIA driver 575+ (CUDA 12.9), otherwise the CPU build; +GPU only matters for full camera model calibration. ### Rig calibration diff --git a/bootstrap.py b/bootstrap.py new file mode 100644 index 00000000..04d6be42 --- /dev/null +++ b/bootstrap.py @@ -0,0 +1,96 @@ +"""Build the post-processing environment on Linux, macOS or Windows. + +Creates (or updates) the conda env from environment.yml, then builds .venv on top +of it with uv from the lockfile. Run it with any Python, e.g. the conda base one: + + python bootstrap.py # env named as in environment.yml + python bootstrap.py --name test # a second env beside it + +Afterwards activate the conda env and then .venv (the script prints the commands). +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys + +ROOT = os.path.dirname(os.path.abspath(__file__)) +ENV_FILE = os.path.join(ROOT, "environment.yml") + + +def read_environment_yml() -> tuple[str, str]: + """Return (env name, python version) without needing pyyaml.""" + text = open(ENV_FILE).read() + name = re.search(r"^name:\s*(\S+)", text, re.M) + python = re.search(r"^\s*-\s*python\s*=\s*([\d.]+)", text, re.M) + if not name or not python: + sys.exit(f"could not read name and python version from {ENV_FILE}") + return name.group(1), python.group(1) + + +def find_conda() -> str: + """CONDA_EXE if it still points at a real conda (a shell can carry a stale one + after an uninstall), else whatever conda is on PATH.""" + conda = os.environ.get("CONDA_EXE", "") + if not os.path.isfile(conda): + conda = shutil.which("conda") + if not conda: + sys.exit( + "conda not found; install Miniforge from https://conda-forge.org/download/" + ) + return conda + + +def run(cmd: list[str], dry_run: bool) -> None: + print("+", " ".join(cmd), flush=True) + if not dry_run: + subprocess.run(cmd, cwd=ROOT, check=True) + + +def conda_env_exists(conda: str, name: str) -> bool: + """Ask this conda whether it can resolve the env by name (a path match is not + enough when several conda installs share a machine).""" + probe = [conda, "run", "-n", name, "python", "--version"] + return subprocess.run(probe, capture_output=True).returncode == 0 + + +def main() -> None: + default_name, python_version = read_environment_yml() + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--name", default=default_name, help="conda env name") + parser.add_argument( + "--dry-run", action="store_true", help="print the commands without running" + ) + args = parser.parse_args() + + conda = find_conda() + if conda_env_exists(conda, args.name): + run([conda, "env", "update", "-n", args.name, "-f", ENV_FILE], args.dry_run) + else: + run([conda, "env", "create", "-n", args.name, "-f", ENV_FILE], args.dry_run) + + # uv runs inside the conda env so .venv is built on the conda python and sees + # the conda GDAL and pycolmap through --system-site-packages. + uv = [conda, "run", "-n", args.name, "--no-capture-output", "uv"] + run( + uv + + ["venv", "--clear", "--system-site-packages", f"--python={python_version}"], + args.dry_run, + ) + run(uv + ["sync", "--frozen", "--no-cache"], args.dry_run) + + activate = ( + r".venv\Scripts\activate" if os.name == "nt" else "source .venv/bin/activate" + ) + print( + "\nInstallation finished. To use kamera:" + f"\n conda activate {args.name}\n {activate}" + ) + + +if __name__ == "__main__": + main() From 016688228034bf95d3a5b1bfb8b19b32236c73ae Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 11:15:18 -0400 Subject: [PATCH 18/32] Adjust language in conda yaml --- environment.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/environment.yml b/environment.yml index f279049b..62d7b891 100644 --- a/environment.yml +++ b/environment.yml @@ -1,10 +1,10 @@ # Binary deps (GDAL, pycolmap) with no reliable cross-platform wheels. -# `make install` layers the rest on top; see README.md for setup. +# `make install` layers the rest on top. See README.md for setup. name: kamera channels: - conda-forge dependencies: - - python=3.13 # must match PYTHON_VERSION in the Makefile (pycolmap>=4.2 needs 3.11+) + - python=3.13 # must match PYTHON_VERSION in the Makefile - gdal>=3.10 - pycolmap>=4.2 - pip From ea2f4946d9d50291c860a490c8a24f138bfde505 Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 12:43:10 -0400 Subject: [PATCH 19/32] Warn on an unreliable rig seed and report per-camera observation counts Pass 2 refines each camera's rig offset only through tracks that survive COLMAP's 4 px triangulation filter, so a seed a degree off silently stalls near the seed while the reprojection RMS still looks fine. Print how many of the shared frames fell in the seed cluster, warn when the scatter is over half a degree or under half the frames made the cluster, and carry the observation count per camera into the terminal summary, the camera and rig yamls, and the report table, since that count collapses when the IR tracks were dropped. --- kamera/calibration/cli.py | 26 +++++++++++++++++++++++++- kamera/calibration/how_it_works.md | 13 ++++++++++++- kamera/calibration/report.py | 2 ++ kamera/calibration/rig.py | 6 ++++++ kamera/calibration/sfm.py | 1 + 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index a320134f..f1a565fa 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -20,6 +20,12 @@ # Homography pairs per channel, left -> right (DIVE registers the left onto the right). PAIRS = [("ir", "uv"), ("ir", "rgb"), ("uv", "rgb")] +# A rig seed is trusted only when the per-frame estimates behind it agree. The rig +# bundle adjustment drops tracks over 4 px of reprojection error (about 0.13 deg for +# the IR cameras), so a seed a degree off stalls near the seed instead of converging. +SEED_MAX_SCATTER_DEG = 0.5 +SEED_MIN_CLUSTER_FRACTION = 0.5 + def write_gifs(frames, names, image_dir, left, right, h, gif_dir, count) -> dict: """Flip GIFs of the left image warped onto the right, for evenly spaced frames. @@ -114,11 +120,23 @@ def done(path: str) -> bool: if not done(rig_dir): print("[blue]Pass 2: rig bundle adjustment[/blue]") for name, v in sfm.derive_rig(pass1, names, cfg.reference_camera).items(): + fraction = v["frames"] / v["frames_total"] print( - f" {name}: {v['frames']} frames, " + f" {name}: {v['frames']}/{v['frames_total']} frames in cluster, " f"rotation scatter {v['rotation_scatter_deg']:.3f} deg, " f"translation std {np.round(v['translation_std_m'], 2)} m" ) + if ( + v["rotation_scatter_deg"] > SEED_MAX_SCATTER_DEG + or fraction < SEED_MIN_CLUSTER_FRACTION + ): + print( + f" [yellow]{name}: rig seed is unreliable (scatter over " + f"{SEED_MAX_SCATTER_DEG} deg or under " + f"{SEED_MIN_CLUSTER_FRACTION:.0%} of frames in the cluster). " + "Pass 2 may stall near this seed: check its observation count " + "below and its registration GIFs.[/yellow]" + ) rig_in = os.path.join(work, "rig_init") sfm.rigged_model(db_path, pass1, names, cfg.reference_camera, rig_in) sfm.refine_rig(db_path, names, rig_in, rig_dir) @@ -138,6 +156,12 @@ def done(path: str) -> bool: rig_name, os.path.basename(os.path.abspath(cfg.flight_dir)), ) + for name in sorted(cal.cameras): + c = cal.cameras[name] + print( + f" {name}: {c.frames} frames, {c.observations} observations, " + f"{c.reproj_rms_px:.2f} px rms" + ) for p in rig.write_outputs(cal, model_dir): print(f" wrote {p}") diff --git a/kamera/calibration/how_it_works.md b/kamera/calibration/how_it_works.md index 1e7a0d95..f94caaed 100644 --- a/kamera/calibration/how_it_works.md +++ b/kamera/calibration/how_it_works.md @@ -129,6 +129,14 @@ ignores those. For the IR cameras this comparison goes across the two models; it because both models are in INS coordinates, and it is accurate to about 0.3 degrees, which is plenty for a starting point. +The tool prints, per camera, how many of the shared frames fell in the cluster and how +tightly they agree, and warns when the scatter is over half a degree or under half the +frames made the cluster. Pass 2 only refines a seed it can triangulate from: the +triangulator drops tracks over 4 px of reprojection error, about 0.13 degrees for IR, +and a seed a degree off loses the crossover tracks that pin the offset, so the offset +stalls near the seed rather than blowing up. A warning here means the IR result needs +checking, not that the run failed. + ## Step 7: pass 2, the rig bundle adjustment (`sfm.py`, `rigged_model`, `refine_rig`) Now tell COLMAP about the rig: @@ -161,7 +169,10 @@ From the final model: - **Intrinsics** per camera: focal lengths, principal point, distortion, straight from COLMAP's OPENCV camera. The per-camera reprojection error is computed over every - observation of that camera. + observation of that camera, and the observation count is reported next to it. That + count is the check on a stalled IR seed: the reprojection error only covers tracks + that survived triangulation, so it stays small even when most IR tracks were dropped, + while the observation count collapses. - **Rig geometry**: `cam_from_rig` for each camera, which maps rig coordinates (the `C_rgb` camera frame) into that camera. From it, the rotation relative to the reference (the L and R channels come out at about 30 degrees, UV within half a degree diff --git a/kamera/calibration/report.py b/kamera/calibration/report.py index 32ae60b1..e0794925 100644 --- a/kamera/calibration/report.py +++ b/kamera/calibration/report.py @@ -96,6 +96,7 @@ def camera_page(pdf: PdfPages, cal: RigCalibration) -> None: "p1", "p2", "frames", + "obs", "rms px", "ifov deg", ] @@ -112,6 +113,7 @@ def camera_page(pdf: PdfPages, cal: RigCalibration) -> None: f"{c.K[1, 2]:.1f}", *[f"{v:.5f}" for v in c.dist], c.frames, + c.observations, f"{c.reproj_rms_px:.2f}", f"{np.degrees(1 / c.K[0, 0]):.5f}", ] diff --git a/kamera/calibration/rig.py b/kamera/calibration/rig.py index a5182415..134d0460 100644 --- a/kamera/calibration/rig.py +++ b/kamera/calibration/rig.py @@ -26,6 +26,9 @@ class CameraCalibration: cam_from_rig: pc.Rigid3d colmap_params: dict frames: int + # 2D features with a 3D point. Collapses for a camera whose rig seed was too far + # off for its tracks to survive triangulation, while reproj_rms_px stays small. + observations: int reproj_rms_px: float @property @@ -155,6 +158,7 @@ def calibrate_rig( "params": [float(v) for v in cam.params], }, frames=0, + observations=len(errors.get(name, [])), reproj_rms_px=float( np.sqrt(np.mean(np.square(errors.get(name, [np.nan])))) ), @@ -247,6 +251,7 @@ def write_camera_yaml(cal: RigCalibration, name: str, path: str) -> None: "flight": cal.flight, "generated": _today(), "frames": cam.frames, + "observations": cam.observations, "reprojection_rms_px": cam.reproj_rms_px, "ifov_deg": float(np.degrees(1.0 / cam.K[0, 0])), }, @@ -277,6 +282,7 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: "centre_in_ins_body_m": _floats(cal.center_in_ins_body(name)), "exposure_offset_from_reference_ms": cal.implied_delay_ms(name), "frames": cam.frames, + "observations": cam.observations, "reprojection_rms_px": cam.reproj_rms_px, } keep = cal.inlier diff --git a/kamera/calibration/sfm.py b/kamera/calibration/sfm.py index d050513d..00abaa86 100644 --- a/kamera/calibration/sfm.py +++ b/kamera/calibration/sfm.py @@ -284,6 +284,7 @@ def derive_rig( rig[camera] = { "cam_from_rig": pc.Rigid3d(pc.Rotation3d(rot.as_quat()), trans), "frames": int(keep.sum()), + "frames_total": len(rel), "rotation_scatter_deg": float(np.median(angles[keep])), "translation_std_m": translations[keep].std(0), } From a968646a276de4d0fcb9560b382051ef094281ae Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 13:09:03 -0400 Subject: [PATCH 20/32] Hold distortion fixed during pass 1 mapping Refining the distortion coefficients from the initial pair is degenerate on flat ground. On a 250-frame subset of the May 2025 flight it drove L_ir to a 30% focal error and k2 of -3, so every L_ir model died at three images and derive_rig failed with no frames shared with the reference; C_ir and R_ir only survived by luck. With distortion frozen, L_ir alone builds an 83-image model. Focal length stays free, and pass 2 refines the full intrinsics once the whole rig is posed. --- kamera/calibration/how_it_works.md | 6 ++++++ kamera/calibration/sfm.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/kamera/calibration/how_it_works.md b/kamera/calibration/how_it_works.md index f94caaed..1ce664b2 100644 --- a/kamera/calibration/how_it_works.md +++ b/kamera/calibration/how_it_works.md @@ -117,6 +117,12 @@ overlap is under 50%, so those legs only register through crossovers with higher passes. And the global bundle adjustment is set to run every 30% of growth instead of 10%, which halved the run time on the full flight (about 2.5 hours for 8,800 images). +Lens distortion is held at zero throughout pass 1, with only the focal length free. +Two or three views of flat ground cannot pin distortion down, and on a 250-frame subset +refining it from the initial pair drove the L_ir camera to a focal length 30% off and +a k2 of -3, so no L_ir model ever grew past three images while C_ir and R_ir happened +to survive. Pass 2 refines the full intrinsics once every camera is posed on the rig. + ## Step 6: work out the rig from pass 1 (`sfm.py`, `derive_rig`, `robust_mean`) For every camera and every frame where both that camera and the reference camera diff --git a/kamera/calibration/sfm.py b/kamera/calibration/sfm.py index 00abaa86..fd104eac 100644 --- a/kamera/calibration/sfm.py +++ b/kamera/calibration/sfm.py @@ -116,6 +116,11 @@ def mapping_options(refine_rig: bool) -> pc.IncrementalPipelineOptions: use_prior_position=True, ba_refine_sensor_from_rig=refine_rig, extract_colors=False, + # Distortion cannot be recovered from two or three views of flat ground: on + # the May 2025 flight, refining it from the initial pair drove L_ir to a 30% + # focal error and k2 of -3, so no L_ir model ever grew past three images. + # Pass 2 refines the full intrinsics once the whole rig is posed. + ba_refine_extra_params=False, ) # Nadir aerial pairs subtend small angles; the default 16 deg init threshold # rejects them. From f4646b3f8b3a4711201e031bba656ea593ec624d Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 14:19:42 -0400 Subject: [PATCH 21/32] Fix review findings in the calibration pipeline and bootstrap - bootstrap.py falls back to micromamba, which is all the kamerapy image has; micromamba needs -y and rejects conda's --no-capture-output. - Stages build into .partial and are moved into place when they finish, so an interrupted run redoes the stage instead of skipping it. Matching moves into the database stage for the same reason. - Registration GIFs read both images from the normalized tree; the raw UV frames are nearly black. - The report keeps a homography page when there are no GIF images (--gif_frames 0, or no frame with both cameras) instead of crashing. --- bootstrap.py | 28 ++++++++++++------ kamera/calibration/cli.py | 55 +++++++++++++++++++++++++----------- kamera/calibration/report.py | 4 ++- 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/bootstrap.py b/bootstrap.py index 04d6be42..63d8db4a 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -34,10 +34,15 @@ def read_environment_yml() -> tuple[str, str]: def find_conda() -> str: """CONDA_EXE if it still points at a real conda (a shell can carry a stale one - after an uninstall), else whatever conda is on PATH.""" + after an uninstall), else whatever conda is on PATH, else micromamba (the docker + image has nothing else).""" conda = os.environ.get("CONDA_EXE", "") if not os.path.isfile(conda): conda = shutil.which("conda") + if not conda: + conda = os.environ.get("MAMBA_EXE", "") + if not os.path.isfile(conda): + conda = shutil.which("micromamba") if not conda: sys.exit( "conda not found; install Miniforge from https://conda-forge.org/download/" @@ -45,6 +50,10 @@ def find_conda() -> str: return conda +def is_micromamba(conda: str) -> bool: + return "micromamba" in os.path.basename(conda).lower() + + def run(cmd: list[str], dry_run: bool) -> None: print("+", " ".join(cmd), flush=True) if not dry_run: @@ -68,14 +77,16 @@ def main() -> None: args = parser.parse_args() conda = find_conda() - if conda_env_exists(conda, args.name): - run([conda, "env", "update", "-n", args.name, "-f", ENV_FILE], args.dry_run) - else: - run([conda, "env", "create", "-n", args.name, "-f", ENV_FILE], args.dry_run) + # micromamba prompts before installing unless told not to; conda env does not. + yes = ["-y"] if is_micromamba(conda) else [] + verb = "update" if conda_env_exists(conda, args.name) else "create" + run([conda, "env", verb, "-n", args.name, "-f", ENV_FILE] + yes, args.dry_run) # uv runs inside the conda env so .venv is built on the conda python and sees - # the conda GDAL and pycolmap through --system-site-packages. - uv = [conda, "run", "-n", args.name, "--no-capture-output", "uv"] + # the conda GDAL and pycolmap through --system-site-packages. micromamba run + # never captures output and rejects conda's flag for that. + stream = [] if is_micromamba(conda) else ["--no-capture-output"] + uv = [conda, "run", "-n", args.name] + stream + ["uv"] run( uv + ["venv", "--clear", "--system-site-packages", f"--python={python_version}"], @@ -86,9 +97,10 @@ def main() -> None: activate = ( r".venv\Scripts\activate" if os.name == "nt" else "source .venv/bin/activate" ) + tool = "micromamba" if is_micromamba(conda) else "conda" print( "\nInstallation finished. To use kamera:" - f"\n conda activate {args.name}\n {activate}" + f"\n {tool} activate {args.name}\n {activate}" ) diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index f1a565fa..99bf79b8 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -4,6 +4,7 @@ import json import os +import shutil import sys import cv2 @@ -30,18 +31,23 @@ def write_gifs(frames, names, image_dir, left, right, h, gif_dir, count) -> dict: """Flip GIFs of the left image warped onto the right, for evenly spaced frames. - Returns the report images (warped, right, overlay) from the middle frame. + Returns the report images (warped, right, overlay) from the middle frame, or an + empty dict when no frame has both images or ``count`` is 0. """ os.makedirs(gif_dir, exist_ok=True) - by_time = {t: n for n, (c, t) in names.items() if c == left} + # Both sides come from the normalized tree: the raw UV frames are nearly black. + by_time = {(c, t): n for n, (c, t) in names.items() if c in (left, right)} usable = [f for f in frames if left in f.images and right in f.images] out = {} chosen = usable[:: max(1, len(usable) // max(count, 1))][:count] for k, frame in enumerate(chosen): - left_img = cv2.imread( - os.path.join(image_dir, by_time[frame.time]), cv2.IMREAD_COLOR + left_img, right_img = ( + cv2.imread( + os.path.join(image_dir, by_time[(camera, frame.time)]), + cv2.IMREAD_COLOR, + ) + for camera in (left, right) ) - right_img = cv2.imread(frame.images[right], cv2.IMREAD_COLOR) warped, ref = registration.warp_pair(left_img, right_img, h) registration.write_gif( os.path.join(gif_dir, f"{left}_to_{right}_{k}.gif"), warped, ref @@ -69,6 +75,23 @@ def main(argv=None) -> None: def done(path: str) -> bool: return os.path.exists(path) and not cfg.force + def staging(path: str) -> str: + """A clean scratch path for a stage. Stages build there and ``publish`` moves + the result into place, so ``path`` only ever exists once its stage finished + and an interrupted run redoes the stage instead of skipping it.""" + tmp = path + ".partial" + for stale in (tmp, tmp + "-wal", tmp + "-shm", tmp + "-journal"): + if os.path.isdir(stale): + shutil.rmtree(stale) + elif os.path.exists(stale): + os.remove(stale) + return tmp + + def publish(tmp: str, path: str) -> None: + if os.path.isdir(path): + shutil.rmtree(path) + os.replace(tmp, path) + print("[blue]Discovering frames[/blue]") frames, ins, rig_name = discover_flight(cfg.flight_dir) rig_name = cfg.rig_name or rig_name.replace("images_", "") or "rig" @@ -89,27 +112,25 @@ def done(path: str) -> bool: if not done(db_path): print("[blue]Extracting features and writing INS priors[/blue]") - if os.path.exists(db_path): - os.remove(db_path) + tmp = staging(db_path) sfm.extract_features( - db_path, + tmp, image_dir, names, cfg.focal_px, cfg.max_image_size, cfg.num_features, ) - sfm.write_pose_priors(db_path, names, ins, cfg.prior_std_m) - db = pc.Database.open(db_path) - matched = db.num_verified_image_pairs() > 0 - db.close() - if not matched or cfg.force: + sfm.write_pose_priors(tmp, names, ins, cfg.prior_std_m) print("[blue]Matching[/blue]") - sfm.match_features(db_path, cfg.match_distance_m, cfg.match_neighbors) + sfm.match_features(tmp, cfg.match_distance_m, cfg.match_neighbors) + publish(tmp, db_path) if not done(pass1_dir): print("[blue]Pass 1: mapping with independent cameras[/blue]") - sfm.run_mapping(db_path, image_dir, pass1_dir) + tmp = staging(pass1_dir) + sfm.run_mapping(db_path, image_dir, tmp) + publish(tmp, pass1_dir) pass1 = sfm.load_models(pass1_dir) for k, r in pass1.items(): print( @@ -139,7 +160,9 @@ def done(path: str) -> bool: ) rig_in = os.path.join(work, "rig_init") sfm.rigged_model(db_path, pass1, names, cfg.reference_camera, rig_in) - sfm.refine_rig(db_path, names, rig_in, rig_dir) + tmp = staging(rig_dir) + sfm.refine_rig(db_path, names, rig_in, tmp) + publish(tmp, rig_dir) model = pc.Reconstruction(rig_dir) print( f" rig model: {model.num_reg_frames()} frames, " diff --git a/kamera/calibration/report.py b/kamera/calibration/report.py index e0794925..78e7f0f5 100644 --- a/kamera/calibration/report.py +++ b/kamera/calibration/report.py @@ -245,7 +245,9 @@ def homography_page(pdf: PdfPages, pair: dict) -> None: ] for i, (key, title) in enumerate(panels): ax = fig.add_subplot(1, 3, i + 1) - ax.imshow(pair[key]) + # No GIF frame had both images (or --gif_frames 0): keep the page for its fit. + if key in pair: + ax.imshow(pair[key]) ax.set_title(title, fontsize=9) ax.axis("off") h_text = np.array2string( From 5ce5507207f301b765b0223ee3284582cc717f51 Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 14:19:42 -0400 Subject: [PATCH 22/32] camera_models: share the yaml writer across save_to_file The four save_to_file copies had drifted: DepthCamera wrote camera_quaternion with a leading space (invalid yaml) and its depth visualization into a directory that does not exist. model_type becomes a class attribute so it is right for every subclass, and the dead dist = "None" branch is dropped. Output is unchanged for the other three classes. --- kamera/colmap_processing/camera_models.py | 305 ++++++---------------- 1 file changed, 77 insertions(+), 228 deletions(-) diff --git a/kamera/colmap_processing/camera_models.py b/kamera/colmap_processing/camera_models.py index 7085ab03..02497fab 100644 --- a/kamera/colmap_processing/camera_models.py +++ b/kamera/colmap_processing/camera_models.py @@ -254,6 +254,8 @@ class Camera(object): """ + model_type = "standard" + def __init__(self, width, height, platform_pose_provider=None): """ :param width: Width of the image provided by the imaging sensor, @@ -279,7 +281,6 @@ def __init__(self, width, height, platform_pose_provider=None): self._platform_pose_provider = platform_pose_provider self._depth_map = None - self.model_type = "standard" @property def width(self): @@ -706,7 +707,6 @@ def __init__( self._cam_quat = np.array(cam_quat, dtype=np.float64) self._cam_quat /= np.linalg.norm(self._cam_quat) self._min_ray_cos = None - self.model_type = "standard" def __str__(self): string = [f"model_type: {self.model_type}\n"] @@ -788,72 +788,73 @@ def load_from_krtd(cls, filename): return cls(width, height, K, dist, cam_pos, cam_quat) - def save_to_file(self, filename): - """See base class Camera documentation.""" - with open(filename, "w") as f: - f.write( - "".join( - [ - "# The type of camera model.\n", - f"model_type: {self.model_type}\n\n", - "# Image dimensions\n", - ] - ) + def _write_intrinsics(self, f, extra=""): + """Write the yaml fields every camera model shares: model type, image + dimensions, any ``extra`` lines, intrinsics and distortion.""" + f.write( + "".join( + [ + "# The type of camera model.\n", + f"model_type: {self.model_type}\n\n", + "# Image dimensions\n", + ] ) + ) - f.write("".join(["image_width: ", to_str(self.width), "\n"])) - f.write("".join(["image_height: ", to_str(self.height), "\n\n"])) - - f.write("# Focal length along the image's x-axis.\n") - f.write("".join(["fx: ", to_str(self.K[0, 0]), "\n\n"])) + f.write("".join(["image_width: ", to_str(self.width), "\n"])) + f.write("".join(["image_height: ", to_str(self.height), "\n\n"])) - f.write("# Focal length along the image's y-axis.\n") - f.write("".join(["fy: ", to_str(self.K[1, 1]), "\n\n"])) + f.write(extra) - f.write("# Principal point is located at (cx,cy).\n") - f.write("".join(["cx: ", to_str(self.K[0, 2]), "\n"])) - f.write("".join(["cy: ", to_str(self.K[1, 2]), "\n\n"])) + f.write("# Focal length along the image's x-axis.\n") + f.write("".join(["fx: ", to_str(self.K[0, 0]), "\n\n"])) - f.write( - "".join( - ["# Distortion coefficients following OpenCv's ", "convention\n"] - ) - ) + f.write("# Focal length along the image's y-axis.\n") + f.write("".join(["fy: ", to_str(self.K[1, 1]), "\n\n"])) - dist = self.dist - if np.all(dist == 0): - dist = "None" + f.write("# Principal point is located at (cx,cy).\n") + f.write("".join(["cx: ", to_str(self.K[0, 2]), "\n"])) + f.write("".join(["cy: ", to_str(self.K[1, 2]), "\n\n"])) - f.write("".join(["distortion_coefficients: ", to_str(self.dist), "\n\n"])) + f.write("# Distortion coefficients following OpenCv's convention\n") + f.write("".join(["distortion_coefficients: ", to_str(self.dist), "\n\n"])) - f.write( - "".join( - [ - "# Quaternion (x, y, z, w) specifying the ", - "orientation of the camera relative to\n# the ", - "platform coordinate system. The quaternion ", - "represents a coordinate\n# system rotation that ", - "takes the platform coordinate system and ", - "rotates it\n# into the camera coordinate ", - "system.\ncamera_quaternion: ", - to_str(self.cam_quat), - "\n\n", - ] - ) + def _write_pose(self, f): + """Write the camera's orientation and position on the platform.""" + f.write( + "".join( + [ + "# Quaternion (x, y, z, w) specifying the ", + "orientation of the camera relative to\n# the ", + "platform coordinate system. The quaternion ", + "represents a coordinate\n# system rotation that ", + "takes the platform coordinate system and ", + "rotates it\n# into the camera coordinate ", + "system.\ncamera_quaternion: ", + to_str(self.cam_quat), + "\n\n", + ] ) + ) - f.write( - "".join( - [ - "# Position of the camera's center of ", - "projection within the navigation\n# coordinate ", - "system.\n", - "camera_position: ", - to_str(self.cam_pos), - "\n\n", - ] - ) + f.write( + "".join( + [ + "# Position of the camera's center of ", + "projection within the navigation\n# coordinate ", + "system.\n", + "camera_position: ", + to_str(self.cam_pos), + "\n\n", + ] ) + ) + + def save_to_file(self, filename): + """See base class Camera documentation.""" + with open(filename, "w") as f: + self._write_intrinsics(f) + self._write_pose(f) @property def K(self): @@ -1123,6 +1124,8 @@ class RollingShutterCamera(StandardCamera): """ + model_type = "rolling_shutter" + def __init__( self, width, @@ -1144,8 +1147,7 @@ def __init__( self.shutter_roll_time = shutter_roll_time def __str__(self): - string = ["model_type: rolling_shutter\n"] - string.append(super(RollingShutterCamera, self).__str__()) + string = [super(RollingShutterCamera, self).__str__()] string.append("shutter_roll_time: %s\n" % self.shutter_roll_time) return "".join(string) @@ -1189,73 +1191,13 @@ def load_from_file(cls, filename, platform_pose_provider=None): def save_to_file(self, filename): """See base class Camera documentation.""" with open(filename, "w") as f: - f.write( - "".join( - [ - "# The type of camera model.\n", - "model_type: rolling_shutter\n\n", - "# Image dimensions\n", - ] - ) - ) - - f.write("".join(["image_width: ", to_str(self.width), "\n"])) - f.write("".join(["image_height: ", to_str(self.height), "\n\n"])) - - f.write( - "".join(["shutter_roll_time: ", to_str(self.shutter_roll_time), "\n\n"]) - ) - - f.write("# Focal length along the image's x-axis.\n") - f.write("".join(["fx: ", to_str(self.K[0, 0]), "\n\n"])) - - f.write("# Focal length along the image's y-axis.\n") - f.write("".join(["fy: ", to_str(self.K[1, 1]), "\n\n"])) - - f.write("# Principal point is located at (cx,cy).\n") - f.write("".join(["cx: ", to_str(self.K[0, 2]), "\n"])) - f.write("".join(["cy: ", to_str(self.K[1, 2]), "\n\n"])) - - f.write( - "".join( - ["# Distortion coefficients following OpenCv's ", "convention\n"] - ) - ) - - dist = self.dist - if np.all(dist == 0): - dist = "None" - - f.write("".join(["distortion_coefficients: ", to_str(self.dist), "\n\n"])) - - f.write( + self._write_intrinsics( + f, "".join( - [ - "# Quaternion (x, y, z, w) specifying the ", - "orientation of the camera relative to\n# the ", - "platform coordinate system. The quaternion ", - "represents a coordinate\n# system rotation that ", - "takes the platform coordinate system and ", - "rotates it\n# into the camera coordinate ", - "system.\ncamera_quaternion: ", - to_str(self.cam_quat), - "\n\n", - ] - ) - ) - - f.write( - "".join( - [ - "# Position of the camera's center of ", - "projection within the navigation\n# coordinate ", - "system.\n", - "camera_position: ", - to_str(self.cam_pos), - "\n\n", - ] - ) + ["shutter_roll_time: ", to_str(self.shutter_roll_time), "\n\n"] + ), ) + self._write_pose(f) def project(self, points, t=None): """See Camera.project documentation.""" @@ -1371,6 +1313,8 @@ def unproject(self, points, t, normalize_ray_dir=True): class DepthCamera(StandardCamera): """Camera with depth map.""" + model_type = "depth" + def __init__( self, width, @@ -1396,7 +1340,6 @@ def __init__( platform_pose_provider=platform_pose_provider, ) self._depth_map = depth_map - self.model_type = "depth" @classmethod def load_from_file(cls, filename, platform_pose_provider=None): @@ -1436,69 +1379,8 @@ def load_from_file(cls, filename, platform_pose_provider=None): def save_to_file(self, filename, save_depth_viz=True): """See base class Camera documentation.""" with open(filename, "w") as f: - f.write( - "".join( - [ - "# The type of camera model.\n", - "model_type: depth\n\n", - "# Image dimensions\n", - ] - ) - ) - - f.write("".join(["image_width: ", to_str(self.width), "\n"])) - f.write("".join(["image_height: ", to_str(self.height), "\n\n"])) - - f.write("# Focal length along the image's x-axis.\n") - f.write("".join(["fx: ", to_str(self.K[0, 0]), "\n\n"])) - - f.write("# Focal length along the image's y-axis.\n") - f.write("".join(["fy: ", to_str(self.K[1, 1]), "\n\n"])) - - f.write("# Principal point is located at (cx,cy).\n") - f.write("".join(["cx: ", to_str(self.K[0, 2]), "\n"])) - f.write("".join(["cy: ", to_str(self.K[1, 2]), "\n\n"])) - - f.write( - "".join( - ["# Distortion coefficients following OpenCv's ", "convention\n"] - ) - ) - - dist = self.dist - if np.all(dist == 0): - dist = "None" - - f.write("".join(["distortion_coefficients: ", to_str(self.dist), "\n\n"])) - - f.write( - "".join( - [ - "# Quaternion (x, y, z, w) specifying the ", - "orientation of the camera relative to\n# the ", - "navigation coordinate system. The quaternion ", - "represents a coordinate\n# system rotation that ", - "takes the navigation coordinate system and ", - "rotates it\n# into the camera coordinate ", - "system.\n camera_quaternion: ", - to_str(self.cam_quat), - "\n\n", - ] - ) - ) - - f.write( - "".join( - [ - "# Position of the camera's center of ", - "projection within the navigation\n# coordinate ", - "system.\n", - "camera_position: ", - to_str(self.cam_pos), - "\n\n", - ] - ) - ) + self._write_intrinsics(f) + self._write_pose(f) if self.depth_map is not None: im = PIL.Image.fromarray( @@ -1508,14 +1390,13 @@ def save_to_file(self, filename, save_depth_viz=True): im.save(depth_map_fname) if save_depth_viz: - depth_viz_fname = ( - "%s/depth_vizualization.png" % os.path.splitext(filename)[0] + depth_viz_fname = os.path.join( + os.path.dirname(filename), "depth_vizualization.png" ) self.save_depth_viz(depth_viz_fname) def __str__(self): - string = ["model_type: depth\n"] - string.append(super(DepthCamera, self).__str__()) + string = [super(DepthCamera, self).__str__()] string.append("\n") string.append("".join(["fx: ", repr(self._K[0, 0]), "\n"])) string.append("".join(["fy: ", repr(self._K[1, 1]), "\n"])) @@ -1616,6 +1497,8 @@ class GeoStaticCamera(DepthCamera): """ + model_type = "static" + def __init__( self, width, height, K, dist, depth_map, latitude, longitude, altitude, R ): @@ -1667,8 +1550,7 @@ def __init__( self._camera_pose = np.hstack([R, self._tvec]) def __str__(self): - string = ["model_type: static\n"] - string.append(super(GeoStaticCamera, self).__str__()) + string = [super(GeoStaticCamera, self).__str__()] string.append("\n") string.append("".join(["fx: ", repr(self._K[0, 0]), "\n"])) string.append("".join(["fy: ", repr(self._K[1, 1]), "\n"])) @@ -1720,40 +1602,7 @@ def load_from_file(cls, filename, platform_pose_provider=None): def save_to_file(self, filename): """See base class Camera documentation.""" with open(filename, "w") as f: - f.write( - "".join( - [ - "# The type of camera model.\n", - "model_type: static\n\n", - "# Image dimensions\n", - ] - ) - ) - - f.write("".join(["image_width: ", to_str(self.width), "\n"])) - f.write("".join(["image_height: ", to_str(self.height), "\n\n"])) - - f.write("# Focal length along the image's x-axis.\n") - f.write("".join(["fx: ", to_str(self._K[0, 0]), "\n\n"])) - - f.write("# Focal length along the image's y-axis.\n") - f.write("".join(["fy: ", to_str(self._K[1, 1]), "\n\n"])) - - f.write("# Principal point is located at (cx,cy).\n") - f.write("".join(["cx: ", to_str(self._K[0, 2]), "\n"])) - f.write("".join(["cy: ", to_str(self._K[1, 2]), "\n\n"])) - - f.write( - "".join( - ["# Distortion coefficients following OpenCv's ", "convention\n"] - ) - ) - - dist = self._dist - if np.all(dist == 0): - dist = "None" - - f.write("".join(["distortion_coefficients: ", to_str(self._dist), "\n\n"])) + self._write_intrinsics(f) f.write( "".join( From 918a1b2039c8598b07e05dde83d74aabf061c520 Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 14:19:43 -0400 Subject: [PATCH 23/32] Remove the legacy test_camera_models script --- .../test/test_camera_models.py | 98 ------------------- 1 file changed, 98 deletions(-) delete mode 100644 kamera/colmap_processing/test/test_camera_models.py diff --git a/kamera/colmap_processing/test/test_camera_models.py b/kamera/colmap_processing/test/test_camera_models.py deleted file mode 100644 index 438c4c03..00000000 --- a/kamera/colmap_processing/test/test_camera_models.py +++ /dev/null @@ -1,98 +0,0 @@ -#! /usr/bin/python -from __future__ import division, print_function -import numpy as np -import os -import cv2 -import matplotlib.pyplot as plt -from osgeo import osr, gdal -from scipy.optimize import fmin, minimize, fminbound -import transformations - -# Colmap Processing imports. -from colmap_processing.geo_conversions import llh_to_enu -from colmap_processing.colmap_interface import read_images_binary, Image, \ - read_points3d_binary, read_cameras_binary, qvec2rotmat -from colmap_processing.camera_models import StandardCamera -from colmap_processing.platform_pose import PlatformPoseInterp - - -# ---------------------------------------------------------------------------- -# Define the directory where all of the relavant COLMAP files are saved. -# If you are placing your data within the 'data' folder of this repository, -# this will be mapped to '/home_user/adapt_postprocessing/data' inside the -# Docker container. -data_dir = '/media/data' - -image_subdirs = ['1', '2'] - -# COLMAP data directory. -images_bin_fname = '%s/images.bin' % data_dir -camera_bin_fname = '%s/cameras.bin' % data_dir -points_bin_fname = '%s/points3D.bin' % data_dir -# ---------------------------------------------------------------------------- - - -# Read in the details of all images. -images = read_images_binary(images_bin_fname) -cameras = read_cameras_binary(camera_bin_fname) -points = read_points3d_binary(points_bin_fname) - - -# Pretend image index is the time. -platform_pose_provider = PlatformPoseInterp() -for image_id in images: - image = images[image_id] - - R = qvec2rotmat(image.qvec) - pos = -np.dot(R.T, image.tvec) - - # The qvec used by Colmap is a (w, x, y, z) quaternion representing the - # rotation of a vector defined in the world coordinate system into the - # camera coordinate system. However, the 'camera_models' module assumes - # (x, y, z, w) quaternions representing a coordinate system rotation. - quat = transformations.quaternion_inverse(image.qvec) - quat = [quat[1], quat[2], quat[3], quat[0]] - - t = image_id - platform_pose_provider.add_to_pose_time_series(t, pos, quat) - - -std_cams = {} -for camera_id in set([images[image_id].camera_id for image_id in images]): - colmap_camera = cameras[image.camera_id] - - if colmap_camera.model == 'OPENCV': - fx, fy, cx, cy, d1, d2, d3, d4 = colmap_camera.params - - K = K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) - dist = np.array([d1, d2, d3, d4]) - std_cams[image.camera_id] = StandardCamera(colmap_camera.width, - colmap_camera.height, K, dist, - [0, 0, 0], [0, 0, 0, 1], - platform_pose_provider) - - -# Calculate reprojection error. -for image_id in images: - image = images[image_id] - colmap_camera = cameras[image.camera_id] - fname = '%s/%s.txt' % (data_dir, os.path.splitext(image.name)[0]) - R = qvec2rotmat(image.qvec) - - ind = image.point3D_ids >= 0 - - im_pts = image.xys[ind].T - wrld_pts = np.array([points[i].xyz for i in image.point3D_ids[ind]]).T - - if colmap_camera.model == 'OPENCV': - fx, fy, cx, cy, d1, d2, d3, d4 = colmap_camera.params - - K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) - dist = np.array([d1, d2, d3, d4]) - tvec = image.tvec - rvec = cv2.Rodrigues(R)[0] - im_pts2 = np.squeeze(cv2.projectPoints(wrld_pts.T, rvec, tvec, K, dist)[0]).T - err = np.sqrt(np.sum(im_pts2 - im_pts, axis=0)) - - std_cams[image.camera_id].project(wrld_pts, t=image_id) - From 98d6c96bfa05f03bea7ba9dcc5a8a7c85b70dc7a Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 14:19:43 -0400 Subject: [PATCH 24/32] create_flight_summary: honour output_dir The script passed output_dir as save_shapefile_per_image, turning on per-image shapefiles and ignoring the directory. The function now takes output_dir (default /processed_results) for all of its outputs, and the script checks flight_dir before using it. --- .../scripts/create_flight_summary.py | 10 ++-------- kamera/postflight/utilities.py | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/kamera/postflight/scripts/create_flight_summary.py b/kamera/postflight/scripts/create_flight_summary.py index 7fca5b81..1b98aa9f 100644 --- a/kamera/postflight/scripts/create_flight_summary.py +++ b/kamera/postflight/scripts/create_flight_summary.py @@ -1,8 +1,6 @@ #!/usr/bin/env python from __future__ import division, print_function import argparse -import os -import pathlib # Custom package imports. from kamera.postflight import utilities @@ -22,7 +20,7 @@ def main(): ) parser.add_argument( "-output_dir", - help="Output directory (defaults to 'processed_results'.).", + help="Output directory (defaults to /processed_results).", type=str, default=None, ) @@ -36,14 +34,10 @@ def main(): # flight_dir = '/example_flight_dir' # output_dir = '/example_output_dir' - if not output_dir: - base_dir = pathlib.Path(flight_dir).parents[0] - output_dir = os.path.join(base_dir, "processed_results") - if not flight_dir: raise SystemError("No flight dir specified! Please pass one as an argument or hardcode one in the file.") - utilities.create_flight_summary(flight_dir, output_dir) + utilities.create_flight_summary(flight_dir, output_dir=output_dir) if __name__ == '__main__': diff --git a/kamera/postflight/utilities.py b/kamera/postflight/utilities.py index 0d6fd05e..70f9cc34 100644 --- a/kamera/postflight/utilities.py +++ b/kamera/postflight/utilities.py @@ -1577,7 +1577,9 @@ def get_basename_to_time(flight_dir) -> dict: return basename_to_time -def create_flight_summary(flight_dir, save_shapefile_per_image=False): +def create_flight_summary( + flight_dir, save_shapefile_per_image=False, output_dir=None +): """Create flight summary for a flight directory. A flight directory contains a folder structure where different @@ -1592,7 +1594,12 @@ def create_flight_summary(flight_dir, save_shapefile_per_image=False): //right_view //center_view + Results are written under ``output_dir``, by default + /processed_results. + """ + if output_dir is None: + output_dir = "%s/processed_results" % flight_dir top_tic = time.time() flight_id = os.path.basename(flight_dir) project_id = os.path.basename(os.path.dirname(flight_dir)) @@ -1784,7 +1791,7 @@ def create_flight_summary(flight_dir, save_shapefile_per_image=False): # ------------------------------------------------------------------------ # Save homographies estimated by INS. - homog_dir = "%s/processed_results/homographies_img_to_lonlat" % (flight_dir) + homog_dir = "%s/homographies_img_to_lonlat" % (output_dir) for sys_str in fnames_by_system: homog_dir2 = "%s/%s" % (homog_dir, sys_str) @@ -1887,7 +1894,7 @@ def create_flight_summary(flight_dir, save_shapefile_per_image=False): shape_img_basenames.append(os.path.split(img_fname)[1]) shp_shapes_fnames.append(img_fname) if len(shp_shapes) > 0: - shapefile_dir = "%s/processed_results/fov_shapefiles/" % flight_dir + shapefile_dir = "%s/fov_shapefiles/" % output_dir try: os.makedirs(shapefile_dir) @@ -1950,8 +1957,8 @@ def create_flight_summary(flight_dir, save_shapefile_per_image=False): if save_shapefile_per_image: # Write each individual frame as a seperate shapefile. - shapefile_dir = "%s/processed_results/fov_shapefiles/%s_fovs" % ( - flight_dir, + shapefile_dir = "%s/fov_shapefiles/%s_fovs" % ( + output_dir, sys_str, ) @@ -2020,7 +2027,7 @@ def create_flight_summary(flight_dir, save_shapefile_per_image=False): # Convert INS tracks to CSVs # ------------------------------------------------------------------------ - dir_out = "%s/processed_results/ins_csvs" % flight_dir + dir_out = "%s/ins_csvs" % output_dir try: os.makedirs(dir_out) except OSError: From d25443675011c0c56a4e029a12544401e9d9b274 Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 14:19:43 -0400 Subject: [PATCH 25/32] kamera-calibrate: write a postflight sys_config.json for the new models Postflight finds camera models through /sys_config.json. The pipeline now writes a copy of the flight's file with the yaml paths pointed at the calibrated models, and --install_sys_config puts it in place, keeping the original as sys_config.json.orig. --- kamera/calibration/cli.py | 8 ++++++ kamera/calibration/config.py | 7 +++++ kamera/calibration/rig.py | 53 +++++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index 99bf79b8..f290201d 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -187,6 +187,14 @@ def publish(tmp: str, path: str) -> None: ) for p in rig.write_outputs(cal, model_dir): print(f" wrote {p}") + # /_view/: postflight reads /sys_config.json. + config_dirs = { + os.path.dirname(os.path.dirname(p)) for f in frames for p in f.images.values() + } + for p in rig.write_sys_configs( + cal, model_dir, sorted(config_dirs), cfg.install_sys_config + ): + print(f" wrote {p}") print("[blue]Fitting homographies and writing DIVE registration files[/blue]") reg_dir = os.path.join(model_dir, "dive_registration") diff --git a/kamera/calibration/config.py b/kamera/calibration/config.py index e03884ed..3e1f09c2 100644 --- a/kamera/calibration/config.py +++ b/kamera/calibration/config.py @@ -50,6 +50,13 @@ class CalibrateConfig(scfg.DataConfig): help="Ground range the homographies are exact at; 0 = median scene range of " "the calibration model. Set to the survey AGL", ) + install_sys_config = scfg.Value( + False, + isflag=True, + help="Point the flight's sys_config.json at the calibrated camera models, so " + "postflight (flight summary, footprint KMLs) uses them; the original is kept " + "as sys_config.json.orig. A copy is always written beside the models", + ) gif_frames = scfg.Value(5, help="Registration GIFs written per camera pair") force = scfg.Value( False, isflag=True, help="Rerun stages whose outputs already exist" diff --git a/kamera/calibration/rig.py b/kamera/calibration/rig.py index 134d0460..768bc405 100644 --- a/kamera/calibration/rig.py +++ b/kamera/calibration/rig.py @@ -4,7 +4,9 @@ from __future__ import annotations import datetime +import json import os +import shutil from dataclasses import dataclass, field import numpy as np @@ -329,15 +331,64 @@ def write_rig_yaml(cal: RigCalibration, path: str) -> None: yaml.safe_dump(body, f, sort_keys=False) +def camera_yaml_path(cal: RigCalibration, name: str, out_dir: str) -> str: + return os.path.join(out_dir, f"{cal.rig}_{name}.yaml") + + def write_outputs(cal: RigCalibration, out_dir: str) -> list[str]: """Write one yaml per camera plus the rig yaml; returns the paths written.""" os.makedirs(out_dir, exist_ok=True) paths = [] for name in sorted(cal.cameras): - path = os.path.join(out_dir, f"{cal.rig}_{name}.yaml") + path = camera_yaml_path(cal, name, out_dir) write_camera_yaml(cal, name, path) paths.append(path) rig_path = os.path.join(out_dir, f"{cal.rig}_rig.yaml") write_rig_yaml(cal, rig_path) paths.append(rig_path) return paths + + +# Camera channel -> the field-of-view name postflight uses in its sys_config.json keys. +SYS_CONFIG_FOV = {"L": "left", "C": "center", "R": "right"} + + +def write_sys_configs( + cal: RigCalibration, out_dir: str, config_dirs: list[str], install: bool +) -> list[str]: + """A postflight ``sys_config.json`` per system configuration directory of the + flight, pointing ``__yaml_path`` at the calibrated camera models. + + Postflight (flight summary, footprint KMLs, geotiffs) finds its camera models + through ``/sys_config.json``. Each file written here is the flight's + own one with only those keys replaced, saved as + ``out_dir/_sys_config.json``. With ``install`` it also replaces + the flight's file, keeping the original as ``sys_config.json.orig``. Returns the + paths written. + """ + models = {} + for name in cal.cameras: + channel, modality = name.split("_") + if channel in SYS_CONFIG_FOV: + key = f"{SYS_CONFIG_FOV[channel]}_{modality}_yaml_path" + models[key] = os.path.abspath(camera_yaml_path(cal, name, out_dir)) + paths = [] + for config_dir in sorted(config_dirs): + flight_path = os.path.join(config_dir, "sys_config.json") + body = {} + if os.path.exists(flight_path): + with open(flight_path) as f: + body = json.load(f) + body.update(models) + name = os.path.basename(os.path.normpath(config_dir)) + path = os.path.join(out_dir, f"{name}_sys_config.json") + with open(path, "w") as f: + json.dump(body, f, indent=4, sort_keys=True) + paths.append(path) + if install: + backup = flight_path + ".orig" + if os.path.exists(flight_path) and not os.path.exists(backup): + shutil.copy2(flight_path, backup) + shutil.copy2(path, flight_path) + paths.append(flight_path) + return paths From 650bd074a7012f792e0c2db42e01e98cae05a183 Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 14:21:30 -0400 Subject: [PATCH 26/32] Seed lens distortion per modality instead of holding it at zero Holding distortion at zero registers L_ir but puts the RGB corners about 25 px off, the mapper drops those observations, and the rig seed comes out several times looser (L_rgb scatter 0.05 -> 0.94 deg on the 250-frame subset). The config now carries k1, k2 per modality next to focal_px, rounded from the May 2025 calibration, and pass 1 keeps them fixed with only the focal length free. On the same subset that puts all three IR cameras in one 369-image model with every seed under 0.3 deg. Pass 2 refines the full intrinsics once the whole rig is posed, as before. --- kamera/calibration/README.md | 2 +- kamera/calibration/cli.py | 1 + kamera/calibration/config.py | 6 ++++++ kamera/calibration/how_it_works.md | 18 +++++++++++------- kamera/calibration/sfm.py | 12 ++++++++---- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/kamera/calibration/README.md b/kamera/calibration/README.md index a14ec5e5..8b97b97e 100644 --- a/kamera/calibration/README.md +++ b/kamera/calibration/README.md @@ -18,7 +18,7 @@ For a plain-language walkthrough of every stage, see [how_it_works.md](how_it_wo 1. **frames** — `*_meta.json` grouped by trigger time into frames; camera names are `_` (`C_rgb`, `L_ir`, ...). IR is stretched to 8 bit; EO is symlinked. -2. **features** — SIFT per camera with an initial focal length per modality, then an INS +2. **features** — SIFT per camera with an initial focal length and distortion per modality, then an INS position prior per image (`InsTrajectory` interpolates the meta.json samples). 3. **match** — spatial matching from the priors, across all cameras, so figure-eight crossovers are matched as well as neighbours in time. Thermal-to-visible pairs are dropped: diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index f290201d..5bd38c25 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -118,6 +118,7 @@ def publish(tmp: str, path: str) -> None: image_dir, names, cfg.focal_px, + cfg.distortion, cfg.max_image_size, cfg.num_features, ) diff --git a/kamera/calibration/config.py b/kamera/calibration/config.py index 3e1f09c2..79baf8e0 100644 --- a/kamera/calibration/config.py +++ b/kamera/calibration/config.py @@ -30,6 +30,12 @@ class CalibrateConfig(scfg.DataConfig): {"rgb": 31363.0, "uv": 14120.0, "ir": 1712.0}, help="Initial focal length per modality in pixels; refined by SfM", ) + distortion = scfg.Value( + {"rgb": [0.065, -0.13], "uv": [-0.22, 0.0], "ir": [-0.24, -0.4]}, + help="Initial OpenCV k1, k2 per modality. Held fixed while the pass 1 model " + "grows (flat ground cannot pin distortion down from a few views) and refined " + "with the whole rig in pass 2", + ) max_image_size = scfg.Value( 3200, help="Images are downsampled to this longest side for SIFT" ) diff --git a/kamera/calibration/how_it_works.md b/kamera/calibration/how_it_works.md index 1ce664b2..d8ad1f3d 100644 --- a/kamera/calibration/how_it_works.md +++ b/kamera/calibration/how_it_works.md @@ -77,8 +77,8 @@ instead of thousands. This runs in parallel because there are thousands of files SIFT features are extracted per camera folder on the GPU, with the image downsampled to 3200 px on the long side (a 12768 px RGB frame gives about 12,000 features). Each camera folder gets one COLMAP camera with the OPENCV model (focal length, principal -point, k1, k2, p1, p2), seeded with a rough focal length per modality from the config -so the first frames register cleanly. +point, k1, k2, p1, p2), seeded with a rough focal length and k1, k2 per modality from +the config so the first frames register cleanly. Then every image gets a **position prior**: the INS position at its trigger time, with a 2 m standard deviation. COLMAP uses these priors in two ways later: to decide which @@ -117,11 +117,15 @@ overlap is under 50%, so those legs only register through crossovers with higher passes. And the global bundle adjustment is set to run every 30% of growth instead of 10%, which halved the run time on the full flight (about 2.5 hours for 8,800 images). -Lens distortion is held at zero throughout pass 1, with only the focal length free. -Two or three views of flat ground cannot pin distortion down, and on a 250-frame subset -refining it from the initial pair drove the L_ir camera to a focal length 30% off and -a k2 of -3, so no L_ir model ever grew past three images while C_ir and R_ir happened -to survive. Pass 2 refines the full intrinsics once every camera is posed on the rig. +Lens distortion is held at its per-modality seed throughout pass 1, with only the +focal length free. Two or three views of flat ground cannot pin distortion down, and +on a 250-frame subset refining it from the initial pair drove the L_ir camera to a +focal length 30% off and a k2 of -3, so no L_ir model ever grew past three images +while C_ir and R_ir happened to survive. Holding it at zero instead is not enough: +these lenses put the RGB corners about 25 px off, the mapper then drops the corner +observations, and the rig seed comes out several times looser. The seeds are rounded +from the May 2025 calibration and land within a few pixels for every camera. Pass 2 +refines the full intrinsics once every camera is posed on the rig. ## Step 6: work out the rig from pass 1 (`sfm.py`, `derive_rig`, `robust_mean`) diff --git a/kamera/calibration/sfm.py b/kamera/calibration/sfm.py index fd104eac..5ae7c2b2 100644 --- a/kamera/calibration/sfm.py +++ b/kamera/calibration/sfm.py @@ -26,16 +26,19 @@ def extract_features( image_dir: str, names: dict, focal_px: dict, + distortion: dict, max_image_size: int, num_features: int, ) -> None: - """SIFT per camera folder, seeding each camera with its modality's focal length.""" + """SIFT per camera folder, seeding each camera with its modality's intrinsics.""" for camera in sorted({c for c, _ in names.values()}): image_names = sorted(n for n in names if n.startswith(camera + "/")) w, h = PIL.Image.open(os.path.join(image_dir, image_names[0])).size - f = focal_px[camera.split("_")[1]] + modality = camera.split("_")[1] + f, (k1, k2) = focal_px[modality], distortion[modality] reader = pc.ImageReaderOptions( - camera_model=CAMERA_MODEL, camera_params=f"{f},{f},{w / 2},{h / 2},0,0,0,0" + camera_model=CAMERA_MODEL, + camera_params=f"{f},{f},{w / 2},{h / 2},{k1},{k2},0,0", ) # Each thread decodes a full-resolution image; large sensors get fewer threads. opts = pc.FeatureExtractionOptions( @@ -119,7 +122,8 @@ def mapping_options(refine_rig: bool) -> pc.IncrementalPipelineOptions: # Distortion cannot be recovered from two or three views of flat ground: on # the May 2025 flight, refining it from the initial pair drove L_ir to a 30% # focal error and k2 of -3, so no L_ir model ever grew past three images. - # Pass 2 refines the full intrinsics once the whole rig is posed. + # It stays at the per-modality seed; pass 2 refines the full intrinsics once + # the whole rig is posed. ba_refine_extra_params=False, ) # Nadir aerial pairs subtend small angles; the default 16 deg init threshold From aa2ae682f16d9f5db128e1f3cfa8191997223552 Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 16:50:02 -0400 Subject: [PATCH 27/32] Drop the ir->uv homography pair DIVE only uses ir->rgb and uv->rgb; the ir->uv files and GIFs follow from those two and only add noise to the outputs and the report. --- kamera/calibration/README.md | 2 +- kamera/calibration/cli.py | 3 ++- kamera/calibration/how_it_works.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/kamera/calibration/README.md b/kamera/calibration/README.md index 8b97b97e..16b4aedb 100644 --- a/kamera/calibration/README.md +++ b/kamera/calibration/README.md @@ -35,7 +35,7 @@ For a plain-language walkthrough of every stage, see [how_it_works.md](how_it_wo intrinsics free as well. 6. **calibrate** — INS boresight (`ins_from_rig`) and lever arm as a robust average over frames, per-camera models, and `rig.yaml`. -7. **registration** — per channel `ir->uv`, `ir->rgb`, `uv->rgb` homographies as DIVE +7. **registration** — per channel `ir->rgb` and `uv->rgb` homographies as DIVE camera-registration JSON (format v2), plus flip GIFs. 8. **report** — PDF with intrinsics, rig angles, boresight residuals, overlays and the error budget. diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index 5bd38c25..560e7259 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -19,7 +19,8 @@ from kamera.colmap_processing.camera_models import StandardCamera # Homography pairs per channel, left -> right (DIVE registers the left onto the right). -PAIRS = [("ir", "uv"), ("ir", "rgb"), ("uv", "rgb")] +# Only the pairs DIVE uses; ir->uv follows from the other two and only adds noise. +PAIRS = [("ir", "rgb"), ("uv", "rgb")] # A rig seed is trusted only when the per-frame estimates behind it agree. The rig # bundle adjustment drops tracks over 4 px of reprojection error (about 0.13 deg for diff --git a/kamera/calibration/how_it_works.md b/kamera/calibration/how_it_works.md index d8ad1f3d..aaf6826f 100644 --- a/kamera/calibration/how_it_works.md +++ b/kamera/calibration/how_it_works.md @@ -204,7 +204,7 @@ keys first so old readers still work and the provenance after. ## Step 9: homographies for DIVE (`registration.py`, `cli.py`) -For each channel and each pair `ir->uv`, `ir->rgb`, `uv->rgb`: take a grid of pixels in +For each channel and each pair `ir->rgb`, `uv->rgb`: take a grid of pixels in the first camera, cast them out to a nominal ground range through the calibrated model, project them into the second camera, and fit one 3x3 homography to the result. The fit residual says how much a single matrix loses to lens distortion. The range From 5457d4e8ed03b3aacad665253e87f2c7ffedf81d Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Mon, 21 Sep 2026 16:53:26 -0400 Subject: [PATCH 28/32] Rename 'models' to 'camera_models' to avoid name collision with the 3d models --- kamera/calibration/README.md | 2 +- kamera/calibration/cli.py | 14 +++++++------- kamera/calibration/how_it_works.md | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/kamera/calibration/README.md b/kamera/calibration/README.md index 16b4aedb..055d4dbc 100644 --- a/kamera/calibration/README.md +++ b/kamera/calibration/README.md @@ -39,7 +39,7 @@ For a plain-language walkthrough of every stage, see [how_it_works.md](how_it_wo camera-registration JSON (format v2), plus flip GIFs. 8. **report** — PDF with intrinsics, rig angles, boresight residuals, overlays and the error budget. -## Outputs (`/calibration/models/`) +## Outputs (`/calibration/camera_models/`) - `_.yaml` — `standard` camera model readable by `kamera.colmap_processing.camera_models.load_from_file`, with the rig and calibration diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index 560e7259..3ba55aa6 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -66,10 +66,10 @@ def main(argv=None) -> None: cfg = CalibrateConfig.cli(argv=argv, strict=True) work = cfg.work_dir or os.path.join(cfg.flight_dir, "calibration") image_dir, db_path = os.path.join(work, "images"), os.path.join(work, "database.db") - pass1_dir, rig_dir, model_dir = ( + pass1_dir, rig_dir, camera_model_dir = ( os.path.join(work, "pass1"), os.path.join(work, "rig"), - os.path.join(work, "models"), + os.path.join(work, "camera_models"), ) os.makedirs(work, exist_ok=True) @@ -187,19 +187,19 @@ def publish(tmp: str, path: str) -> None: f" {name}: {c.frames} frames, {c.observations} observations, " f"{c.reproj_rms_px:.2f} px rms" ) - for p in rig.write_outputs(cal, model_dir): + for p in rig.write_outputs(cal, camera_model_dir): print(f" wrote {p}") # /_view/: postflight reads /sys_config.json. config_dirs = { os.path.dirname(os.path.dirname(p)) for f in frames for p in f.images.values() } for p in rig.write_sys_configs( - cal, model_dir, sorted(config_dirs), cfg.install_sys_config + cal, camera_model_dir, sorted(config_dirs), cfg.install_sys_config ): print(f" wrote {p}") print("[blue]Fitting homographies and writing DIVE registration files[/blue]") - reg_dir = os.path.join(model_dir, "dive_registration") + reg_dir = os.path.join(camera_model_dir, "dive_registration") cams = { n: StandardCamera( c.width, @@ -214,7 +214,7 @@ def publish(tmp: str, path: str) -> None: range_m = cfg.registration_range_m or cal.scene_range_m registered = {names[im.name][1] for im in model.images.values() if im.has_pose} gif_frames = [f for f in frames if f.time in registered] - gif_dir = os.path.join(model_dir, "gifs") + gif_dir = os.path.join(camera_model_dir, "gifs") print( f" homographies exact at {range_m:.0f} m range; " f"ground speed {cal.ground_speed_mps:.0f} m/s" @@ -244,7 +244,7 @@ def publish(tmp: str, path: str) -> None: {"left": left, "right": right, "h": h, "stats": stats, **images} ) - report_path = os.path.join(model_dir, f"{rig_name}_calibration_report.pdf") + report_path = os.path.join(camera_model_dir, f"{rig_name}_calibration_report.pdf") write_report(report_path, cal, pairs) print(f"[green]Report written to {report_path}[/green]") diff --git a/kamera/calibration/how_it_works.md b/kamera/calibration/how_it_works.md index aaf6826f..f5b343df 100644 --- a/kamera/calibration/how_it_works.md +++ b/kamera/calibration/how_it_works.md @@ -22,7 +22,7 @@ The meta json holds the trigger time (`evt.time`), the INS reading nearest to it ## What comes out -Everything lands in `/calibration/models/`: +Everything lands in `/calibration/camera_models/`: - one yaml per camera (`_.yaml`), readable by the existing `camera_models.load_from_file` From a8327611c2ed93254031460deac3102cdd5ba005 Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Tue, 22 Sep 2026 10:57:20 -0400 Subject: [PATCH 29/32] Rework the calibration report and rename the output directory Outputs now land in /calibration/camera_models/ so they stop colliding with the SfM models in pass1/ and rig/. The report is rebuilt page by page: - a flight summary page: dates and duration, triggers on disk versus complete, selected and registered frames, what a frame is, images per camera, the flight track zoomed to the registered frames, and the INS altitude profile - the intrinsics table ordered by modality so focal lengths compare at a glance, with distortion at three decimals and the per-pixel angle replaced by the full field of view and the ground sample distance at scene range - the rig geometry table grouped by swathe with rotation and lever arm split into x, y, z columns, and the optical-axes sketch drawn in aircraft body axes via the boresight, hanging from the mount plate, seen from behind - one full-page overlay per homography pair: the RGB frame in colour with the warped camera blended magenta over green inside its footprint, as DIVE shows a registration; the GIFs flip the same way - the boresight residual page and the error notes page are dropped --- kamera/calibration/README.md | 2 +- kamera/calibration/cli.py | 38 ++- kamera/calibration/how_it_works.md | 185 ++++------- kamera/calibration/registration.py | 42 ++- kamera/calibration/report.py | 490 +++++++++++++++++++---------- 5 files changed, 455 insertions(+), 302 deletions(-) diff --git a/kamera/calibration/README.md b/kamera/calibration/README.md index 055d4dbc..afcf78b5 100644 --- a/kamera/calibration/README.md +++ b/kamera/calibration/README.md @@ -37,7 +37,7 @@ For a plain-language walkthrough of every stage, see [how_it_works.md](how_it_wo frames, per-camera models, and `rig.yaml`. 7. **registration** — per channel `ir->rgb` and `uv->rgb` homographies as DIVE camera-registration JSON (format v2), plus flip GIFs. -8. **report** — PDF with intrinsics, rig angles, boresight residuals, overlays and the error budget. +8. **report** — PDF with the flight summary, intrinsics, rig geometry and registration overlays. ## Outputs (`/calibration/camera_models/`) diff --git a/kamera/calibration/cli.py b/kamera/calibration/cli.py index 3ba55aa6..51deef9d 100644 --- a/kamera/calibration/cli.py +++ b/kamera/calibration/cli.py @@ -6,6 +6,7 @@ import os import shutil import sys +from collections import Counter import cv2 import numpy as np @@ -15,7 +16,7 @@ from kamera.calibration import registration, rig, sfm from kamera.calibration.config import CalibrateConfig from kamera.calibration.flight import build_image_tree, discover_flight -from kamera.calibration.report import write_report +from kamera.calibration.report import FlightSummary, write_report from kamera.colmap_processing.camera_models import StandardCamera # Homography pairs per channel, left -> right (DIVE registers the left onto the right). @@ -32,8 +33,8 @@ def write_gifs(frames, names, image_dir, left, right, h, gif_dir, count) -> dict: """Flip GIFs of the left image warped onto the right, for evenly spaced frames. - Returns the report images (warped, right, overlay) from the middle frame, or an - empty dict when no frame has both images or ``count`` is 0. + Returns the report overlay from the middle frame, or an empty dict when no frame + has both images or ``count`` is 0. """ os.makedirs(gif_dir, exist_ok=True) # Both sides come from the normalized tree: the raw UV frames are nearly black. @@ -49,16 +50,16 @@ def write_gifs(frames, names, image_dir, left, right, h, gif_dir, count) -> dict ) for camera in (left, right) ) - warped, ref = registration.warp_pair(left_img, right_img, h) + warped, ref, mask = registration.warp_pair(left_img, right_img, h) + # Flip between the right image and the same with the warped left pasted over + # its footprint, the way DIVE shows a registration. registration.write_gif( - os.path.join(gif_dir, f"{left}_to_{right}_{k}.gif"), warped, ref + os.path.join(gif_dir, f"{left}_to_{right}_{k}.gif"), + ref, + registration.composite(warped, ref, mask), ) if k == len(chosen) // 2: - out = { - "warped_img": warped, - "right_img": ref, - "overlay_img": registration.blend_overlay(warped, ref), - } + out = {"overlay_img": registration.blend_overlay(warped, ref, mask)} return out @@ -96,6 +97,7 @@ def publish(tmp: str, path: str) -> None: print("[blue]Discovering frames[/blue]") frames, ins, rig_name = discover_flight(cfg.flight_dir) rig_name = cfg.rig_name or rig_name.replace("images_", "") or "rig" + discovered = frames all_cameras = {camera for frame in frames for camera in frame.images} full = [f for f in frames if len(f.images) == len(all_cameras)] stop = ( @@ -244,8 +246,22 @@ def publish(tmp: str, path: str) -> None: {"left": left, "right": right, "h": h, "stats": stats, **images} ) + last = cfg.frame_start + (len(frames) - 1) * cfg.frame_stride + summary = FlightSummary( + discovered=len(discovered), + complete=len(full), + selected=frames, + selection=( + "all complete frames" + if len(frames) == len(full) + else f"frames {cfg.frame_start} to {last} of {len(full)}, " + f"stride {cfg.frame_stride}" + ), + images_on_disk=Counter(c for f in discovered for c in f.images), + ins=ins, + ) report_path = os.path.join(camera_model_dir, f"{rig_name}_calibration_report.pdf") - write_report(report_path, cal, pairs) + write_report(report_path, cal, pairs, summary) print(f"[green]Report written to {report_path}[/green]") diff --git a/kamera/calibration/how_it_works.md b/kamera/calibration/how_it_works.md index f5b343df..bb81f502 100644 --- a/kamera/calibration/how_it_works.md +++ b/kamera/calibration/how_it_works.md @@ -1,10 +1,9 @@ -# How the rig calibration works, step by step +# Rig Calibration -This follows one run of `kamera-calibrate ` from the raw KAMERA flight -folder to the camera models, using plain language. File names in `kamera/calibration/` +This follows one run of `kamera-calibrate ` from the raw KAMERA flight folder to the camera models. File names in `kamera/calibration/` are given so you can read along in the code. -## What goes in +## Inputs A KAMERA flight folder, for example `052025_Calibration/`, with one folder per view (`center_view`, `left_view`, `right_view`) and, for every trigger, four files with a @@ -20,34 +19,27 @@ taiga_calibration_2025_fl118_C_20250503_203245.017993_ir.tif The meta json holds the trigger time (`evt.time`), the INS reading nearest to it (`ins`: latitude, longitude, altitude, heading, pitch, roll), and the camera metadata. -## What comes out +## Outputs -Everything lands in `/calibration/camera_models/`: +All output is directed to `/calibration/camera_models/`: -- one yaml per camera (`_.yaml`), readable by the existing - `camera_models.load_from_file` +- one yaml file per camera (`_.yaml`) - `_rig.yaml` with the rig geometry and the INS boresight -- `dive_registration/*.json`, one homography file per camera pair per channel +- `dive_registration/*.json`, one homography file per camera pair per channel (usable in DIVE / VIAME) - `gifs/`, flip animations of one camera warped onto another -- `_calibration_report.pdf` +- `_calibration_report.pdf`, a summary of the camera models, their positions, and a +single overlay for each camera pair, with EO as the reference. -Everything else under `/calibration/` is intermediate and can be deleted; -the tool rebuilds whatever is missing and skips whatever exists. +Everything else under `/calibration/` is intermediate and can be deleted +as the tool rebuilds whatever is missing and skips whatever exists. -## The idea in one paragraph +## Summary -Structure from motion (COLMAP) can work out where every picture was taken from and -what it was looking at, just from the pictures overlapping each other. It does this -in its own arbitrary coordinate system, so we hand it the INS positions to pin the -model to the real world. Because all nine cameras fire on the same trigger, the nine -pictures of one trigger share one rig position and orientation; COLMAP 4 can enforce -that ("rigs" and "frames"). Once the model is solved with that constraint, the fixed -rotation and offset of each camera relative to the reference camera drops out, and -comparing the rig orientation with the INS orientation over hundreds of frames gives -the boresight. The only thing COLMAP cannot do for us is match thermal pictures to -visible ones, and with the rig constraint it does not need to. +Structure from motion (COLMAP) can work out where every picture was taken from and what it was looking at, just from the pictures overlapping each other and using structure from motion (SfM). It does this in its own arbitrary coordinate system, so we hand it the INS positions to pin the model to the real world. Because all nine cameras fire on the same trigger, the nine pictures of one trigger share one rig position and orientation, so COLMAP (3.12+) can enforce that ("rigs" and "frames"). -## Step 1: find the frames (`flight.py`, `discover_flight`) +Once the model is solved with that constraint, the fixed rotation and offset of each camera relative to the reference camera is obtained, and comparing the rig orientation with the INS orientation over hundreds of frames gives the boresight. The boresight is the relative position and angle of the whole camera mount to the INS. The only thing COLMAP cannot do for us is match thermal pictures to visible ones due to limitations with SIFT features, but with the rig constraint it does not need to, since thermal images can match intra-modal with SIFT, just not inter-modal. + +## Step 1: Find Imagery (`flight.py`, `discover_flight`) Read every `*_meta.json`. Group them by trigger time, rounded to a millisecond, so the L, C and R files of one trigger become one **frame**. Name each image @@ -55,24 +47,15 @@ L, C and R files of one trigger become one **frame**. Name each image every json into one time-ordered trajectory. Frames missing any of the nine images are dropped, so every frame used has all nine. -The INS trajectory (`InsTrajectory`) converts latitude/longitude/altitude to metres in a -local east-north-up frame centred on the flight, and heading/pitch/roll into a -rotation, using the same convention as the rest of KAMERA (`sensor_models.nav_state`). -Asked for the pose at any time, it interpolates between the two nearest samples. With -the meta json this is coarse, one sample per second; a denser INS log would drop in -here unchanged. +The INS trajectory (`InsTrajectory`) converts latitude/longitude/altitude to metres in a local east-north-up (ENU) frame centred on the flight, and heading/pitch/roll into a rotation, using the same convention as the rest of KAMERA (`sensor_models.nav_state`). Asked for the pose at any time, it interpolates between the two nearest samples. -## Step 2: lay the images out for COLMAP (`flight.py`, `build_image_tree`) +## Step 2: Organize Imagery (`flight.py`, `build_image_tree`) COLMAP wants one folder per camera and, for rigs, the *same file name* across folders for pictures of the same frame. So the tree is -`calibration/images//.jpg`. RGB files are symlinks. UV and IR are -rewritten: the UV frames are very dark (median value 9 of 255) and the IR frames are -16-bit, so both are stretched between their 0.1 and 99.9 percentiles and given a mild -local contrast boost (CLAHE). Without this, SIFT found about 30 features per UV frame -instead of thousands. This runs in parallel because there are thousands of files. +`calibration/images//.jpg`. RGB files are symlinks. UV and IR are rewritten: the UV frames are very dark and the IR frames are 16-bit, so both are stretched between their 0.1 and 99.9 percentiles and given a mild local contrast boost (CLAHE). This runs in parallel because there are generally thousands of files. -## Step 3: features and INS priors (`sfm.py`, `extract_features`, `write_pose_priors`) +## Step 3: Extract Features and Geotag (`sfm.py`, `extract_features`, `write_pose_priors`) SIFT features are extracted per camera folder on the GPU, with the image downsampled to 3200 px on the long side (a 12768 px RGB frame gives about 12,000 features). Each @@ -80,24 +63,15 @@ camera folder gets one COLMAP camera with the OPENCV model (focal length, princi point, k1, k2, p1, p2), seeded with a rough focal length and k1, k2 per modality from the config so the first frames register cleanly. -Then every image gets a **position prior**: the INS position at its trigger time, with a -2 m standard deviation. COLMAP uses these priors in two ways later: to decide which -images to try to match, and to keep the model in real-world metres and orientation. +Then every image gets a **position prior**: the INS position (lat,lon,alt) at its trigger time, with a 2 m standard deviation. COLMAP uses these priors in two ways later: to decide which images to try to match, and to keep the model in real-world metres and orientation. -## Step 4: matching (`sfm.py`, `match_features`, `prune_cross_spectral`) +## Step 4: Feature Matching (`sfm.py`, `match_features`, `prune_cross_spectral`) -Rather than matching every image against every other (8,800 images would be 39 -million pairs), each image is matched against its 90 nearest neighbours by INS -position within 250 m. That covers the frames just before and after, and also the -crossovers of the figure eights, which are what make the geometry strong. +Rather than matching every image against every other (8,800 images would be 39 million pairs) that exhaustive matching would require, each image is matched against its 90 nearest neighbours by INS position within 250 m. That covers the frames just before and after, and also the crossovers of the figure eights, which are what make the geometry strong. -The neighbours include every camera, so the same-frame RGB and UV pictures get matched -too, which is useful: they share features and tie the UV into the RGB model directly. -Thermal-to-visible pairs also get "matched", but those matches are garbage (about 25 -random inliers), and left in they pull IR images to wrong places. They are deleted from -the database right after matching. +The neighbours include every camera, so the same-frame RGB and UV pictures get matched too, which is useful: they share features and tie the UV into the RGB model directly. Thermal-to-visible pairs also get "matched" occasionally, but those matches are mostly noise (about 25 random inliers), and left in they pull IR images to wrong places. They are deleted from the database right after matching. -## Step 5: pass 1, mapping with independent cameras (`sfm.py`, `run_mapping`) +## Step 5: Incremental Mapping (`sfm.py`, `run_mapping`) COLMAP's incremental mapper builds the 3D model: it picks a good starting pair, triangulates points, adds the next image by matching its features to points already in @@ -117,52 +91,31 @@ overlap is under 50%, so those legs only register through crossovers with higher passes. And the global bundle adjustment is set to run every 30% of growth instead of 10%, which halved the run time on the full flight (about 2.5 hours for 8,800 images). -Lens distortion is held at its per-modality seed throughout pass 1, with only the -focal length free. Two or three views of flat ground cannot pin distortion down, and -on a 250-frame subset refining it from the initial pair drove the L_ir camera to a -focal length 30% off and a k2 of -3, so no L_ir model ever grew past three images -while C_ir and R_ir happened to survive. Holding it at zero instead is not enough: -these lenses put the RGB corners about 25 px off, the mapper then drops the corner -observations, and the rig seed comes out several times looser. The seeds are rounded -from the May 2025 calibration and land within a few pixels for every camera. Pass 2 -refines the full intrinsics once every camera is posed on the rig. +Lens distortion is held at its per-modality seed throughout pass 1, with only the focal length free. Two or three views of flat ground cannot pin distortion down. Pass 2 refines the full intrinsics once every camera is posed on the rig. -## Step 6: work out the rig from pass 1 (`sfm.py`, `derive_rig`, `robust_mean`) +## Step 6: Extract Rig (`sfm.py`, `derive_rig`, `robust_mean`) For every camera and every frame where both that camera and the reference camera -(`C_rgb`) were placed, compute the camera's pose relative to the reference. On a rigid -rig that relative pose is the same every frame, so the hundreds of estimates should -agree. Take the densest cluster of them (the estimate with the most neighbours within -one degree, then the mean of that cluster) rather than a plain median, because a badly -registered part of a model can put half the estimates 20 degrees off, and the cluster -ignores those. For the IR cameras this comparison goes across the two models; it works -because both models are in INS coordinates, and it is accurate to about 0.3 degrees, -which is plenty for a starting point. - -The tool prints, per camera, how many of the shared frames fell in the cluster and how -tightly they agree, and warns when the scatter is over half a degree or under half the -frames made the cluster. Pass 2 only refines a seed it can triangulate from: the -triangulator drops tracks over 4 px of reprojection error, about 0.13 degrees for IR, -and a seed a degree off loses the crossover tracks that pin the offset, so the offset -stalls near the seed rather than blowing up. A warning here means the IR result needs -checking, not that the run failed. - -## Step 7: pass 2, the rig bundle adjustment (`sfm.py`, `rigged_model`, `refine_rig`) - -Now tell COLMAP about the rig: +(`C_rgb`) were placed, compute the camera's pose relative to the reference. On a rigid rig that relative pose is the same every frame, so the hundreds of estimates should agree. Take the densest cluster of them (the estimate with the most neighbours within one degree, then the mean of that cluster) rather than a plain median, because a badly registered part of a model can put half the estimates 20 degrees off, and the cluster ignores those. For the IR cameras this comparison goes across the two models, it works because both models are in INS coordinates, and it is accurate to about 0.3 degrees, which is plenty for a starting seed. + +The script prints, per camera, how many of the shared frames fell in the cluster and how tightly they agree, and warns when the scatter is over half a degree or under half the frames made the cluster. The rig bundle adjustment only refines a seed it can triangulate from: the triangulator drops tracks over 4 px of reprojection error, about 0.13 degrees for IR, and a seed a degree off loses the crossover tracks that pin the offset, so the offset stalls near the seed rather than blowing up. A warning here means the IR result needs checking, not that the run failed. + +## Step 7: Rig Bundle Adjustment (`sfm.py`, `rigged_model`, `refine_rig`) + +Now we tell COLMAP about the rig with the following: 1. Write the rig definition into the database: `C_rgb` is the reference sensor and - every other camera has the starting `cam_from_rig` from step 6. COLMAP groups the + every other camera has the starting `cam_from_rig` from step 6. COLMAP groups the images into frames by their shared file name. 2. Put the rig onto the largest pass 1 model. Its frames now hold one pose each, taken - from the `C_rgb` image. Add the images pass 1 never placed, mostly IR: they inherit + from the `C_rgb` image. Add the images pass 1 never placed, mostly IR: they inherit their pose from the frame pose and the rig offsets. -3. Triangulate every image afresh from those poses. IR features now become 3D points - too, because the IR images have poses even though nothing matched them to EO. +3. Triangulate every image again from those poses. IR features now become 3D points + too, because the IR images have poses even though nothing matched them to EO. 4. Bundle adjust with the INS position priors, refining the frame poses and the - `cam_from_rig` of every camera, with intrinsics held fixed. + `cam_from_rig` of every camera, with intrinsics held fixed. 5. Triangulate again from the refined poses, and bundle adjust once more with the - intrinsics free (focal length and distortion). + intrinsics free (focal length and distortion). The result is one model with all nine cameras. On the full May 2025 flight: 740 frames, 6,660 images, 0.71 px mean reprojection error, matching a 250-frame subset @@ -173,43 +126,29 @@ mapper from the rigged model (it throws the model away as "insufficient size"), COLMAP's plain bundle adjuster on the rigged model (without a fixed gauge it diverges). The triangulate-then-adjust route above is stable. -## Step 8: read off the calibration (`rig.py`, `calibrate_rig`) +## Step 8: Extract Calibrations (`rig.py`, `calibrate_rig`) From the final model: - **Intrinsics** per camera: focal lengths, principal point, distortion, straight from - COLMAP's OPENCV camera. The per-camera reprojection error is computed over every - observation of that camera, and the observation count is reported next to it. That - count is the check on a stalled IR seed: the reprojection error only covers tracks - that survived triangulation, so it stays small even when most IR tracks were dropped, - while the observation count collapses. +COLMAP's OPENCV camera. The per-camera reprojection error is computed over every +observation of that camera, and the observation count is reported next to it. That +count is the check on a stalled IR seed: the reprojection error only covers tracks +that survived triangulation, so it stays small even when most IR tracks were dropped, +while the observation count collapses. - **Rig geometry**: `cam_from_rig` for each camera, which maps rig coordinates (the - `C_rgb` camera frame) into that camera. From it, the rotation relative to the - reference (the L and R channels come out at about 30 degrees, UV within half a degree - of its RGB, IR within about a degree) and the camera centre in the rig. -- **INS boresight**: for every frame, take the rig's orientation in the world from the - model and the INS orientation at the same time, and compute the rotation between - them. That should be one fixed rotation; the densest-cluster mean of it over all - frames is `ins_from_rig`, and the spread of the individual frames about it (about - 0.15 degrees median) is the honest per-frame uncertainty. The same comparison of - positions gives the lever arm from the INS to the rig, which is noise dominated. +`C_rgb` camera frame) into that camera. This gives the relative static poses for each camera. +- **INS boresight**: for every frame, take the rig's orientation in the world from the model and the INS orientation at the same time, and compute the rotation between them. That should be one fixed rotation - the densest-cluster mean of it over all frames is `ins_from_rig`, and the spread of the individual frames about it is the grounded per-frame uncertainty. The same comparison of positions gives the lever arm from the INS to the rig, which is noise dominated. - **Camera models in the INS frame**: each camera's rotation into the INS body is - `ins_from_rig` composed with the camera's rotation into the rig, and its position is - the lever arm plus its rig centre rotated into the body frame. These two numbers are - the `camera_quaternion` and `camera_position` in the yaml, exactly as the existing - KAMERA georegistration code expects. +`ins_from_rig` composed with the camera's rotation into the rig, and its position is the lever arm plus its rig center rotated into the body frame. These two numbers are the `camera_quaternion` and `camera_position` in the yaml, exactly as the existing +KAMERA georegistration code expects. `write_camera_yaml` and `write_rig_yaml` put all of this on disk, with the original keys first so old readers still work and the provenance after. -## Step 9: homographies for DIVE (`registration.py`, `cli.py`) +## Step 9: Registration Homographies for DIVE / VIAME(`registration.py`, `cli.py`) -For each channel and each pair `ir->rgb`, `uv->rgb`: take a grid of pixels in -the first camera, cast them out to a nominal ground range through the calibrated -model, project them into the second camera, and fit one 3x3 homography to the result. -The fit residual says how much a single matrix loses to lens distortion. The range -matters because the cameras do not expose at exactly the same instant, which shows up -as an along-track offset of about a metre in the rig (see `exposure_timing.md`); it +For each channel and each pair `ir->rgb`, `uv->rgb`: take a grid of pixels in the first camera, cast them out to a nominal ground range through the calibrated model, project them into the second camera, and fit one 3x3 homography to the result. The fit residual says how much a single matrix loses to lens distortion. The range matters because the cameras do not expose at exactly the same instant, which shows up as an along-track offset of about a meter in the rig (see `exposure_timing.md`); it defaults to the calibration flight's median scene range and should be set to the survey altitude. The files use DIVE's registration format version 2, one matrix-only pair each. @@ -218,18 +157,16 @@ The GIFs warp the first camera's picture onto the second with that homography fo five frames spread across the flight and flip between the two, so a misregistration is visible at a glance. -## Step 10: the report (`report.py`) +## Step 10: Report (`report.py`) -A PDF with the camera table, the rig angles and a sketch of the optical axes, the -boresight numbers with per-frame residual plots, one page per homography pair with the -warped overlay, and a page on the error budget: INS sample staleness, model drift, the -weak observability of lever arms, and exposure timing. +A PDF with a flight summary page (dates, frames on disk, selected and registered, +images per camera, the flight track), the camera intrinsics table, the rig geometry +with a sketch of the optical axes in aircraft body axes, and one page per homography +pair showing the RGB frame with the warped camera blended over its footprint, as DIVE +displays a registration. The boresight numbers and their per-frame scatter are in the +rig yaml. ## Running it again -Every stage checks for its outputs and skips itself when they exist, so rerunning the -same command after a crash or a code change in a late stage takes minutes, not hours. -Delete `calibration/pass1` to redo the mapping, `calibration/rig` to redo the rig -adjustment, or pass `--force` to redo everything. `--max_frames` and `--frame_start` -select a subset for quick experiments; pick frames from the high-altitude part of the -flight for those. +Every stage checks for its outputs and skips itself when they exist, so rerunning the same command after a crash or a code change in a late stage is much shorter. Delete `calibration/pass1` to redo the mapping, `calibration/rig` to redo the rig +adjustment, or pass `--force` to redo everything. `--max_frames` and `--frame_start` select a subset for quick experiments, try to pick frames from the high-altitude part of the flight for those to increase chance of overlap. \ No newline at end of file diff --git a/kamera/calibration/registration.py b/kamera/calibration/registration.py index a9b95c39..999247c9 100644 --- a/kamera/calibration/registration.py +++ b/kamera/calibration/registration.py @@ -104,17 +104,35 @@ def source_stamp(flight_dir: str, extra: dict | None = None) -> dict: def warp_pair( - left_img: np.ndarray, right_img: np.ndarray, h: np.ndarray, width: int = 1280 -) -> tuple[np.ndarray, np.ndarray]: + left_img: np.ndarray, right_img: np.ndarray, h: np.ndarray, width: int = 1600 +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Warp the left image into the right image's pixels. - Both are returned resized to ``width`` wide, RGB. + Returns the warped left, the right, and the warped footprint mask, all resized to + ``width`` wide, the images RGB. """ scale = width / right_img.shape[1] size = (width, round(right_img.shape[0] * scale)) s = np.diag([scale, scale, 1.0]) warped = cv2.warpPerspective(left_img, s @ h, size, flags=cv2.INTER_LINEAR) - return _rgb(warped), _rgb(cv2.resize(right_img, size, interpolation=cv2.INTER_AREA)) + mask = ( + cv2.warpPerspective( + np.full(left_img.shape[:2], 255, np.uint8), + s @ h, + size, + flags=cv2.INTER_NEAREST, + ) + > 0 + ) + right = _rgb(cv2.resize(right_img, size, interpolation=cv2.INTER_AREA)) + return _rgb(warped), right, mask + + +def composite(warped: np.ndarray, right: np.ndarray, mask: np.ndarray) -> np.ndarray: + """The right image with the warped left pasted over its footprint, as DIVE shows it.""" + out = right.copy() + out[mask] = warped[mask] + return out def _rgb(im: np.ndarray) -> np.ndarray: @@ -131,7 +149,15 @@ def write_gif(path: str, a: np.ndarray, b: np.ndarray, duration_ms: int = 400) - ) -def blend_overlay(a: np.ndarray, b: np.ndarray) -> np.ndarray: - """Magenta/green false-colour blend: misregistration shows as coloured fringes.""" - ga, gb = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY), cv2.cvtColor(b, cv2.COLOR_RGB2GRAY) - return np.dstack([ga, gb, ga]) +def blend_overlay( + warped: np.ndarray, right: np.ndarray, mask: np.ndarray +) -> np.ndarray: + """The right image in colour with a magenta/green blend over the warped footprint. + + Misregistration shows as coloured fringes inside the footprint. + """ + gw = cv2.cvtColor(warped, cv2.COLOR_RGB2GRAY) + gr = cv2.cvtColor(right, cv2.COLOR_RGB2GRAY) + out = right.copy() + out[mask] = np.dstack([gw, gr, gw])[mask] + return out diff --git a/kamera/calibration/report.py b/kamera/calibration/report.py index 78e7f0f5..53642618 100644 --- a/kamera/calibration/report.py +++ b/kamera/calibration/report.py @@ -1,8 +1,10 @@ -"""PDF report: cameras, rig geometry, boresight residuals, overlays, error budget.""" +"""PDF report: flight summary, cameras, rig geometry, registration overlays.""" from __future__ import annotations +import datetime import textwrap +from dataclasses import dataclass import matplotlib import numpy as np @@ -11,64 +13,54 @@ matplotlib.use("Agg") import matplotlib.pyplot as plt +from kamera.calibration.flight import Frame, InsTrajectory from kamera.calibration.rig import RigCalibration PAGE = (11, 8.5) -ERROR_NOTES = """\ -Error budget and what limits it - -INS attitude at the trigger. Each meta.json carries one 100 Hz INS sample taken before -the event, so the attitude used here is up to 10 ms stale (median gap reported above). -At the turn rates of a figure-eight (about 5 deg/s) that is up to 0.05 deg, or roughly -25 RGB pixels, and it enters every frame's boresight estimate as noise. A hardware -event-stamped INS sample or a full-rate log removes it; InsTrajectory accepts either -without code changes. - -SfM drift. Bundle adjustment with INS position priors pins scale, heading and position -to the INS, but the relative orientation drift of the model over the flight is what -dominates the per-frame boresight scatter. The rig constraint removes the intra-frame -freedom entirely, so the relative camera geometry (and therefore the homographies) is -far better determined than the absolute boresight. - -Exposure timing. A camera whose exposure midpoint differs from the reference camera's -sees the ground further along track by ground speed x time difference, and a bundle -adjustment on a translating rig cannot tell that from a camera mounted that far forward. -The rig table's "exposure vs ref" column reads each camera's forward offset back into a -time difference at the flight's ground speed (negative = earlier than the reference). -Only the relative timing is observable: the position priors absorb any delay shared by -the whole rig. The camera yaml positions carry these offsets, which is correct at -similar ground speeds. - -Lever arms. Beyond that timing signal, at 400 to 900 m a 30 cm baseline subtends less -than one IR pixel, so the rig translations are weakly determined and the reported -standard deviations should be read as such. The INS lever arm is the median offset of -the rig origin from the INS position over all frames. - -Homographies. A homography maps one camera onto another exactly only for a plane at one -range, and the timing baseline above makes the range matter. Each pair is fit for the -range in its title (the survey AGL if given, else the calibration flight's median scene -range); the fit residual (rms and p95, in right-image pixels) then measures the lens -distortion a single matrix cannot carry, and the warped overlays show it visually. -""" - - -def _text_page(pdf: PdfPages, title: str, body: str) -> None: - fig = plt.figure(figsize=PAGE) - fig.text(0.06, 0.94, title, fontsize=16, weight="bold", va="top") - fig.text( - 0.06, 0.88, body, fontsize=9.5, va="top", family="monospace", linespacing=1.4 - ) - pdf.savefig(fig) - plt.close(fig) +FRAME_NOTES = """A frame is one trigger event. Every camera fires on it and writes one image, so the +images of a trigger share a single rig position and orientation, and that shared pose +is what the rig bundle adjustment enforces. Only triggers where every camera wrote an +image are used. A frame is registered when structure from motion placed it; the +boresight uses the registered frames whose INS-to-rig rotation is not an outlier.""" + + +@dataclass +class FlightSummary: + """What the flight folder held and which of it went into the calibration.""" + + discovered: int # triggers found in the flight folder + complete: int # triggers with an image from every camera + selected: list[Frame] # complete frames handed to structure from motion + selection: str # how they were picked, in words + images_on_disk: dict[str, int] # per camera, over every discovered trigger + ins: InsTrajectory + + +def _utc(t: float) -> datetime.datetime: + return datetime.datetime.fromtimestamp(t, datetime.timezone.utc) def _table_page( - pdf: PdfPages, title: str, header: list[str], rows: list[list], widths=None + pdf: PdfPages, + title: str, + header: list[str], + rows: list[list], + widths=None, + note: str = "", ) -> None: fig, ax = plt.subplots(figsize=PAGE) ax.axis("off") ax.set_title(title, fontsize=15, weight="bold", loc="left", pad=20) + if note: + fig.text( + 0.06, + 0.86 - 0.033 * (len(rows) + 1) - 0.04, + "\n".join(textwrap.wrap(note, 120)), + fontsize=8, + va="top", + linespacing=1.4, + ) table = ax.table( cellText=rows, colLabels=header, @@ -83,10 +75,166 @@ def _table_page( plt.close(fig) +def summary_page(pdf: PdfPages, cal: RigCalibration, fs: FlightSummary) -> None: + """Page 1: the flight, the frames, and how many of them each camera contributed.""" + sel = fs.selected + t0, t1 = sel[0].time, sel[-1].time + ins_t0, ins_t1 = fs.ins.times[0], fs.ins.times[-1] + pos = np.array([fs.ins.pose(f.time)[0] for f in sel]) + track_km = np.linalg.norm(np.diff(pos, axis=0), axis=1).sum() / 1000 + window = (fs.ins.times >= t0) & (fs.ins.times <= t1) + alt = fs.ins.llh[window if window.any() else slice(None), 2] + interval = np.median(np.diff([f.time for f in sel])) + registered = np.isin( + np.round([f.time for f in sel], 3), np.round(cal.frame_times, 3) + ) + facts = [ + ("flight", cal.flight), + ("rig", f"{cal.rig}: {len(cal.cameras)} cameras, reference {cal.reference}"), + ( + "date (UTC)", + f"{_utc(ins_t0):%Y-%m-%d}, {_utc(ins_t0):%H:%M} to {_utc(ins_t1):%H:%M} " + f"({(ins_t1 - ins_t0) / 60:.0f} min of INS samples)", + ), + ( + "frames on disk", + f"{fs.discovered} triggers, {fs.complete} with every camera", + ), + ( + "frames selected", + f"{len(sel)} ({fs.selection}), {_utc(t0):%H:%M} to {_utc(t1):%H:%M}, " + f"{(t1 - t0) / 60:.0f} min", + ), + ( + "frames registered", + f"{registered.sum()} placed by SfM, {int(cal.inlier.sum())} used for " + "the boresight", + ), + ("trigger interval", f"median {interval:.2f} s"), + ( + "ground speed", + f"median {cal.ground_speed_mps:.0f} m/s, {track_km:.1f} km flown over " + "the selected frames", + ), + ( + "altitude", + f"INS {alt.min():.0f} to {alt.max():.0f} m above the ellipsoid, " + f"median scene range {cal.scene_range_m:.0f} m", + ), + ] + fig = plt.figure(figsize=PAGE) + fig.text( + 0.05, 0.94, f"KAMERA rig calibration: {cal.rig}", fontsize=16, weight="bold" + ) + width = max(len(k) for k, _ in facts) + fig.text( + 0.05, + 0.88, + "\n".join(f"{k:<{width}} {v}" for k, v in facts), + fontsize=8.5, + va="top", + family="monospace", + linespacing=1.5, + ) + fig.text(0.05, 0.66, "What a frame is", fontsize=11, weight="bold", va="top") + fig.text( + 0.05, + 0.625, + "\n".join(textwrap.wrap(" ".join(FRAME_NOTES.split()), 78)), + fontsize=8.5, + va="top", + linespacing=1.4, + ) + ax = fig.add_axes((0.05, 0.08, 0.5, 0.38)) + ax.axis("off") + ax.set_title("images per camera", fontsize=11, weight="bold", loc="left") + rows = [ + [ + name, + f"{c.width}x{c.height}", + fs.images_on_disk.get(name, 0), + len(sel), + c.frames, + c.observations, + ] + for name, c in sorted(cal.cameras.items()) + ] + table = ax.table( + cellText=rows, + colLabels=[ + "camera", + "size", + "on disk", + "selected", + "registered", + "observations", + ], + loc="upper center", + cellLoc="center", + ) + table.auto_set_font_size(False) + table.set_fontsize(8) + table.scale(1, 1.4) + + ax = fig.add_axes((0.63, 0.42, 0.33, 0.46)) + enu = fs.ins.enu / 1000 + ax.plot(enu[:, 0], enu[:, 1], "-", color="0.8", lw=0.8, label="whole flight") + minutes = (np.array([f.time for f in sel]) - t0) / 60 + sc = ax.scatter( + pos[:, 0] / 1000, pos[:, 1] / 1000, c=minutes, s=6, cmap="viridis", zorder=3 + ) + if not registered.all(): + ax.plot( + pos[~registered, 0] / 1000, + pos[~registered, 1] / 1000, + ".", + color="red", + ms=3, + zorder=4, + label="selected, not registered", + ) + # Zoom to the registered frames: the ferry legs run off the plot. + area = pos[registered] if registered.any() else pos + lo, hi = area[:, :2].min(0) / 1000, area[:, :2].max(0) / 1000 + margin = 0.1 * max(hi - lo) + 0.1 + ax.set( + xlim=(lo[0] - margin, hi[0] + margin), + ylim=(lo[1] - margin, hi[1] + margin), + xlabel="east (km)", + ylabel="north (km)", + title="flight track", + ) + ax.set_aspect("equal") + ax.legend(fontsize=7, loc="best") + fig.colorbar(sc, ax=ax, fraction=0.04, pad=0.02).set_label( + "minutes since first selected frame", fontsize=7 + ) + + ax = fig.add_axes((0.63, 0.08, 0.33, 0.22)) + ax.plot((fs.ins.times - ins_t0) / 60, fs.ins.llh[:, 2], color="0.4", lw=0.8) + ax.axvspan((t0 - ins_t0) / 60, (t1 - ins_t0) / 60, color="C0", alpha=0.2) + ax.set( + xlabel="minutes since first INS sample", + ylabel="altitude (m)", + title="INS altitude, selected window shaded", + ) + pdf.savefig(fig) + plt.close(fig) + + +MODALITY_ORDER = {"rgb": 0, "uv": 1, "ir": 2} +CHANNEL_ORDER = {"L": 0, "C": 1, "R": 2} + + +def camera_order(name: str) -> tuple[int, int]: + """Sort key: modality first so focal lengths sit side by side, then L, C, R.""" + channel, modality = name.split("_") + return MODALITY_ORDER.get(modality, 9), CHANNEL_ORDER.get(channel, 9) + + def camera_page(pdf: PdfPages, cal: RigCalibration) -> None: header = [ "camera", - "size", "fx", "fy", "cx", @@ -95,134 +243,173 @@ def camera_page(pdf: PdfPages, cal: RigCalibration) -> None: "k2", "p1", "p2", - "frames", + "fov deg (h x v)", + "gsd cm", "obs", "rms px", - "ifov deg", ] rows = [] - for name in sorted(cal.cameras): + for name in sorted(cal.cameras, key=camera_order): c = cal.cameras[name] + fov_h = 2 * np.degrees(np.arctan(c.width / 2 / c.K[0, 0])) + fov_v = 2 * np.degrees(np.arctan(c.height / 2 / c.K[1, 1])) rows.append( [ name, - f"{c.width}x{c.height}", f"{c.K[0, 0]:.1f}", f"{c.K[1, 1]:.1f}", f"{c.K[0, 2]:.1f}", f"{c.K[1, 2]:.1f}", - *[f"{v:.5f}" for v in c.dist], - c.frames, + f"{c.dist[0]:.3f}", + f"{c.dist[1]:.3f}", + f"{c.dist[2]:.4f}", + f"{c.dist[3]:.4f}", + f"{fov_h:.1f} x {fov_v:.1f}", + f"{100 * cal.scene_range_m / c.K[0, 0]:.1f}", c.observations, f"{c.reproj_rms_px:.2f}", - f"{np.degrees(1 / c.K[0, 0]):.5f}", ] ) - _table_page(pdf, f"{cal.rig}: camera intrinsics ({cal.flight})", header, rows) + _table_page( + pdf, + f"{cal.rig}: camera intrinsics ({cal.flight})", + header, + rows, + widths=[ + 0.07, + 0.07, + 0.07, + 0.07, + 0.07, + 0.065, + 0.065, + 0.065, + 0.065, + 0.11, + 0.06, + 0.07, + 0.06, + ], + note=( + "OpenCV model: fx, fy, cx, cy in pixels; k1, k2 radial and p1, p2 " + "tangential distortion. fov is the full field of view from the focal " + "length and image size. gsd is the ground footprint of one pixel at the " + f"flight's median scene range of {cal.scene_range_m:.0f} m. obs is the " + "number of features with a 3D point; rms is their reprojection error." + ), + ) + + +def swathe_order(name: str) -> tuple[int, int]: + """Sort key: L, C, R first so each swathe's three cameras sit together.""" + channel, modality = name.split("_") + return CHANNEL_ORDER.get(channel, 9), MODALITY_ORDER.get(modality, 9) def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: fig = plt.figure(figsize=PAGE) - fig.suptitle( + fig.text( + 0.06, + 0.94, f"Rig geometry relative to {cal.reference}", fontsize=15, weight="bold", - x=0.06, - ha="left", ) - ax = fig.add_subplot(1, 2, 1) + ax = fig.add_axes((0.06, 0.5, 0.88, 0.4)) ax.axis("off") rows = [] - for name in sorted(cal.cameras): + for name in sorted(cal.cameras, key=swathe_order): rel = cal.rotation_from_reference(name) rv, c = rel.as_rotvec(degrees=True), cal.cameras[name].center_in_rig rows.append( [ name, f"{np.degrees(rel.magnitude()):.3f}", - f"{rv[0]:+.3f} {rv[1]:+.3f} {rv[2]:+.3f}", - f"{c[0]:+.2f} {c[1]:+.2f} {c[2]:+.2f}", + *[f"{v:+.3f}" for v in rv], + *[f"{v:+.2f}" for v in c], f"{cal.implied_delay_ms(name):+.0f}", ] ) header = [ "camera", - "angle deg", - "rotvec deg (ref axes)", - "centre m (rig)", - "exposure vs ref ms", + "angle (deg)", + "rot x (deg)", + "rot y (deg)", + "rot z (deg)", + "lever arm x (m)", + "lever arm y (m)", + "lever arm z (m)", + "exposure offset (ms)", ] - t = ax.table( + table = ax.table( cellText=rows, colLabels=header, - loc="center", + loc="upper center", cellLoc="center", - colWidths=[0.14, 0.14, 0.36, 0.3, 0.14], + colWidths=[0.08, 0.09, 0.09, 0.09, 0.09, 0.11, 0.11, 0.11, 0.14], ) - t.auto_set_font_size(False) - t.set_fontsize(7.5) - t.scale(1, 1.6) - ax3 = fig.add_subplot(1, 2, 2, projection="3d") - for i, name in enumerate(sorted(cal.cameras)): - z = cal.cameras[name].rig_from_cam.apply([0, 0, 1]) + table.auto_set_font_size(False) + table.set_fontsize(8) + table.scale(1, 1.4) + fig.text( + 0.06, + 0.6, + "\n".join( + textwrap.wrap( + "Rotation of each camera relative to the reference, as a rotation " + "vector in the reference camera's axes (x right, y down the image, " + "z along the optical axis); angle is its magnitude. Lever arm is the " + "camera centre in that frame. Exposure offset reads the along-track " + "part of the lever arm as a timing difference at the flight's ground " + "speed, positive when the camera exposes after the reference; a " + "bundle adjustment on a moving rig cannot separate the two. Lever arms " + "are weakly determined at these ranges and should be read as such.", + 125, + ) + ), + fontsize=8, + va="top", + linespacing=1.4, + ) + + ax3 = fig.add_axes((0.2, 0.0, 0.6, 0.46), projection="3d") + # Draw the rig as mounted, in INS body axes (forward, right, down) via the + # boresight: the cameras hang from the mount plate and look down, so the down + # axis is inverted to point down the page. + grid = np.array([[-1, -1], [1, -1], [1, 1], [-1, 1]], float) + ax3.plot_trisurf(grid[:, 0], grid[:, 1], np.zeros(4), color="0.85", alpha=0.5) + colours = {"rgb": "C0", "uv": "C2", "ir": "C3"} + for name in sorted(cal.cameras, key=swathe_order): + z = cal.ins_from_rig.apply(cal.cameras[name].rig_from_cam.apply([0, 0, 1])) ax3.quiver( - 0, 0, 0, *z, length=1.0, label=name, arrow_length_ratio=0.08, color=f"C{i}" + 0, + 0, + 0, + *z, + length=1.0, + label=name, + arrow_length_ratio=0.06, + color=colours.get(name.split("_")[1], "k"), ) ax3.set( xlim=(-1, 1), ylim=(-1, 1), - zlim=(0, 1), - xlabel="rig x", - ylabel="rig y", - zlabel="rig z (optical)", + zlim=(1, 0), + xticks=[], + yticks=[], + zticks=[], ) - ax3.set_title("optical axes in the rig frame", fontsize=10) - ax3.legend(fontsize=6, loc="upper left") - pdf.savefig(fig) - plt.close(fig) - - -def boresight_page(pdf: PdfPages, cal: RigCalibration) -> None: - keep = cal.inlier - t = cal.frame_times - cal.frame_times[0] - res, pos = cal.rotation_residual_deg, cal.position_residual_m - mag = np.linalg.norm(res[keep], axis=1) - fig, axes = plt.subplots(2, 2, figsize=PAGE) - e = cal.ins_from_rig.as_euler("ZYX", degrees=True) - lever = cal.lever_arm_m - fig.suptitle( - f"INS boresight: ins_from_rig euler ZYX = " - f"({e[0]:.4f}, {e[1]:.4f}, {e[2]:.4f}) deg, " - f"lever arm = ({lever[0]:.2f}, {lever[1]:.2f}, {lever[2]:.2f}) m; " - f"{keep.sum()} frames, {(~keep).sum()} rejected", + ax3.set_xlabel("forward", fontsize=8) + ax3.set_ylabel("right (starboard)", fontsize=8) + ax3.set_zlabel("down", fontsize=8) + ax3.tick_params(labelsize=7) + ax3.view_init(elev=22, azim=20) + ax3.set_title( + "optical axes in aircraft body axes, seen from behind the aircraft", fontsize=10, - weight="bold", - ) - for i, lbl in enumerate("xyz"): - axes[0, 0].plot(t[keep], res[keep, i], ".", ms=2, label=f"rot {lbl}") - axes[1, 0].plot(t[keep], pos[keep, i], ".", ms=2, label=f"pos {lbl}") - axes[0, 0].set( - title="per-frame boresight residual (deg, rig axes)", - xlabel="s since first frame", - ) - axes[1, 0].set( - title="rig origin vs INS minus lever arm (m, body axes)", - xlabel="s since first frame", + y=0.98, ) - axes[0, 0].legend(fontsize=7) - axes[1, 0].legend(fontsize=7) - axes[0, 1].hist(mag, bins=50, color="gray") - axes[0, 1].set( - title=f"residual magnitude: median {np.median(mag):.3f}, " - f"p90 {np.percentile(mag, 90):.3f} deg", - xlabel="deg", - ) - axes[1, 1].hist(cal.ins_gap_s * 1000, bins=40, color="gray") - axes[1, 1].set( - title=f"INS sample staleness: median {np.median(cal.ins_gap_s) * 1000:.1f} ms", - xlabel="ms", - ) - fig.tight_layout(rect=(0, 0, 1, 0.95)) + ax3.legend(fontsize=7, loc="center left", bbox_to_anchor=(1.12, 0.5)) pdf.savefig(fig) plt.close(fig) @@ -230,54 +417,41 @@ def boresight_page(pdf: PdfPages, cal: RigCalibration) -> None: def homography_page(pdf: PdfPages, pair: dict) -> None: s = pair["stats"] fig = plt.figure(figsize=PAGE) - fig.suptitle( + fig.text( + 0.03, + 0.955, f"{pair['left']} -> {pair['right']} at {s['rangeM']:.0f} m: " f"fit rms {s['rmsPx']:.2f} px, p95 {s['p95Px']:.2f} px, " - f"max {s['maxPx']:.2f} px, " - f"coverage {100 * s['coverage']:.0f}%", + f"max {s['maxPx']:.2f} px, coverage {100 * s['coverage']:.0f}%", fontsize=11, weight="bold", ) - panels = [ - ("warped_img", f"{pair['left']} warped into {pair['right']}"), - ("right_img", pair["right"]), - ("overlay_img", "overlay (magenta/green)"), - ] - for i, (key, title) in enumerate(panels): - ax = fig.add_subplot(1, 3, i + 1) - # No GIF frame had both images (or --gif_frames 0): keep the page for its fit. - if key in pair: - ax.imshow(pair[key]) - ax.set_title(title, fontsize=9) - ax.axis("off") + ax = fig.add_axes((0.03, 0.07, 0.94, 0.86)) + # No GIF frame had both images (or --gif_frames 0): keep the page for its fit. + if "overlay_img" in pair: + ax.imshow(pair["overlay_img"]) + ax.set_title( + f"{pair['right']} in colour; inside the {pair['left']} footprint, " + f"{pair['left']} warped in magenta over {pair['right']} in green", + fontsize=8, + ) + ax.axis("off") h_text = np.array2string( np.asarray(pair["h"]), precision=5, suppress_small=True, max_line_width=200 ).replace("\n", " ") fig.text( - 0.06, 0.04, "H (left -> right) = " + h_text, fontsize=7, family="monospace" + 0.03, 0.03, "H (left -> right) = " + h_text, fontsize=7, family="monospace" ) - pdf.savefig(fig) + pdf.savefig(fig, dpi=150) plt.close(fig) def write_report( - path: str, cal: RigCalibration, pairs: list[dict], notes: str = "" + path: str, cal: RigCalibration, pairs: list[dict], flight: FlightSummary ) -> None: with PdfPages(path) as pdf: - _text_page( - pdf, - f"KAMERA rig calibration: {cal.rig}", - textwrap.dedent(f"""\ - flight: {cal.flight} - reference camera: {cal.reference} - cameras: {", ".join(sorted(cal.cameras))} - frames used: {int(cal.inlier.sum())} - """) - + notes, - ) + summary_page(pdf, cal, flight) camera_page(pdf, cal) rig_page(pdf, cal) - boresight_page(pdf, cal) for pair in pairs: homography_page(pdf, pair) - _text_page(pdf, "Error sources", ERROR_NOTES) From c376ca0d8d875b6113bd2b3e6be3eea24359e68c Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Tue, 22 Sep 2026 10:59:58 -0400 Subject: [PATCH 30/32] Update md language, move error source section to README --- kamera/calibration/README.md | 79 ++++++++++++++---------------- kamera/calibration/how_it_works.md | 11 +---- 2 files changed, 39 insertions(+), 51 deletions(-) diff --git a/kamera/calibration/README.md b/kamera/calibration/README.md index afcf78b5..30379457 100644 --- a/kamera/calibration/README.md +++ b/kamera/calibration/README.md @@ -1,9 +1,6 @@ -# Rig calibration +# Rig Calibration -Calibrates every camera on a KAMERA rig from one calibration flight (figure eights at -several altitudes) and expresses them in the INS body frame. One COLMAP model holds all -modalities: the trigger-synchronized images of each event form a *frame* with a single rig -pose, so IR ties into the EO model through the rig without cross-modal matching. +Calibrates every camera on a KAMERA rig from one calibration flight (figure eights at several altitudes) and expresses them in the INS body frame. One COLMAP model holds all modalities: the trigger-synchronized images of each event form a *frame* with a single rig pose, so IR ties into the EO model through the rig without any cross-modal matching. ```bash conda activate kamera @@ -12,51 +9,49 @@ kamera-calibrate /data/052025_Calibration --max_frames 150 --frame_stride 3 # kamera-calibrate --help ``` -For a plain-language walkthrough of every stage, see [how_it_works.md](how_it_works.md). - -## Stages (each resumes from `/calibration/`) - -1. **frames** — `*_meta.json` grouped by trigger time into frames; camera names are - `_` (`C_rgb`, `L_ir`, ...). IR is stretched to 8 bit; EO is symlinked. -2. **features** — SIFT per camera with an initial focal length and distortion per modality, then an INS - position prior per image (`InsTrajectory` interpolates the meta.json samples). -3. **match** — spatial matching from the priors, across all cameras, so figure-eight - crossovers are matched as well as neighbours in time. Thermal-to-visible pairs are dropped: - SIFT cannot match them and their few spurious inliers mislead the mapper. -4. **pass1** — incremental mapping with independent cameras and position priors. EO and IR - come out as separate models, both in INS ENU. Needs three-view overlap along track: at - 64 m/s and 1 frame/s that means flying above roughly 600 m AGL for these lenses; lower legs - only register through crossovers with higher passes. -5. **pass2** — `cam_from_rig` for every camera is averaged from pass 1 (frames shared with - the reference camera, both models being in INS ENU), the rig and frames are written to the - database and onto the largest pass-1 model, and the images pass 1 never posed (IR) are added - to their frames. Every image is then triangulated from the rig poses and bundle adjusted - against the INS position priors, twice: rig poses and `sensor_from_rig` first, then with the - intrinsics free as well. -6. **calibrate** — INS boresight (`ins_from_rig`) and lever arm as a robust average over - frames, per-camera models, and `rig.yaml`. -7. **registration** — per channel `ir->rgb` and `uv->rgb` homographies as DIVE - camera-registration JSON (format v2), plus flip GIFs. +For a walkthrough of every stage, see [how_it_works.md](how_it_works.md). + +## Stages + +Each stage resumes from `/calibration/`, skipping whatever already exists. + +1. **frames** — `*_meta.json` grouped by trigger time into frames; camera names are `_` (`C_rgb`, `L_ir`, ...). IR and UV are contrast stretched to 8 bit; RGB is symlinked. +2. **features** — SIFT per camera with an initial focal length and distortion per modality, then an INS position prior per image (`InsTrajectory` interpolates the meta.json samples). +3. **match** — spatial matching from the priors, across all cameras, so figure-eight crossovers are matched as well as neighbours in time. Thermal-to-visible pairs are dropped: SIFT cannot match them and their few spurious inliers mislead the mapper. +4. **pass1** — incremental mapping with independent cameras and position priors. EO and IR come out as separate models, both in INS ENU. Needs three-view overlap along track: at 64 m/s and 1 frame/s that means flying above roughly 600 m AGL for these lenses; lower legs only register through crossovers with higher passes. +5. **pass2** — `cam_from_rig` for every camera is averaged from pass 1 (frames shared with the reference camera, both models being in INS ENU), the rig and frames are written to the database and onto the largest pass-1 model, and the images pass 1 never posed (IR) are added to their frames. Every image is then triangulated from the rig poses and bundle adjusted against the INS position priors, twice: rig poses and `sensor_from_rig` first, then with the intrinsics free as well. +6. **calibrate** — INS boresight (`ins_from_rig`) and lever arm as a robust average over frames, per-camera models, and `rig.yaml`. +7. **registration** — per channel `ir->rgb` and `uv->rgb` homographies as DIVE camera-registration JSON (format v2), plus flip GIFs. 8. **report** — PDF with the flight summary, intrinsics, rig geometry and registration overlays. -## Outputs (`/calibration/camera_models/`) +## Outputs -- `_.yaml` — `standard` camera model readable by - `kamera.colmap_processing.camera_models.load_from_file`, with the rig and calibration - provenance in extra keys. +All output is directed to `/calibration/camera_models/`: + +- `_.yaml` — `standard` camera model readable by `kamera.colmap_processing.camera_models.load_from_file`, with the rig and calibration provenance in extra keys. - `_rig.yaml` — `cam_from_rig` per camera, `ins_from_rig`, lever arm, quality statistics. - `dive_registration/_to__registration.json`, `gifs/`, `_calibration_report.pdf`. -## Exposure timing +## Exposure Timing + +The cameras do not expose at the same instant after the shared trigger; see [exposure_timing.md](exposure_timing.md) for the measurements, the manuals, and what the pipeline does about it. + +## Error Sources + +What limits the accuracy of the result, and how each source shows up in the outputs. + +**INS attitude at the trigger.** Each meta.json carries one INS sample taken shortly before the trigger, so the attitude used for a frame can be up to 10 ms old. In a figure-eight turn at about 5 degrees per second that is up to 0.05 degrees, roughly 25 RGB pixels on the ground, and it enters every frame's boresight estimate as noise. An event-stamped INS sample or a full-rate INS log would remove it; `InsTrajectory` accepts either without code changes. + +**Model drift.** The INS position priors pin the model's scale, heading and position, but its orientation still drifts slowly along the flight, and that drift is what dominates the per-frame boresight scatter reported in the rig yaml. The rig constraint removes any freedom between cameras within a frame, so the relative camera geometry, and therefore the homographies, is far better determined than the absolute boresight. + +**Exposure timing.** A camera that exposes later than the reference camera sees the ground further along track, by ground speed times the delay. A bundle adjustment on a moving rig cannot tell that from a camera mounted that far forward, so the delay shows up as an along-track lever arm. The rig table in the report reads that lever arm back into a time difference at the flight's ground speed, positive when the camera exposes after the reference. Only the relative timing between cameras is observable, since a delay shared by the whole rig is absorbed by the position priors. The camera yaml positions carry these offsets, which is correct at similar ground speeds. + +**Lever arms.** Beyond that timing signal the lever arms are weakly determined: at 400 to 900 m range a 30 cm baseline subtends less than one IR pixel. Read the reported translations with that in mind. The INS lever arm is the median offset of the rig origin from the INS position over all frames. -The cameras do not expose at the same instant after the shared trigger; see -[exposure_timing.md](exposure_timing.md) for the measurements, the manuals, and what the -pipeline does about it. +**Homographies.** A homography maps one camera onto another exactly only for flat ground at one range, and the timing baseline above makes the range matter. Each pair is fit for the range in its page title (the survey altitude if given, otherwise the calibration flight's median scene range). The fit residual, rms and 95th percentile in RGB pixels, measures the lens distortion a single matrix cannot carry, and the overlay shows it visually as coloured fringes. ## Conventions -- `camera_quaternion` (x, y, z, w) rotates camera vectors into the INS body frame - (forward, right, down); `camera_position` is in that frame, metres. +- `camera_quaternion` (x, y, z, w) rotates camera vectors into the INS body frame (forward, right, down); `camera_position` is in that frame, in metres. - COLMAP's `cam_from_rig` maps rig (= reference camera) coordinates into the camera. -- The INS body-to-ENU rotation is `NED_TO_ENU * R_z(heading) R_y(pitch) R_x(roll)`, identical - to `kamera.sensor_models.nav_state`. +- The INS body-to-ENU rotation is `NED_TO_ENU * R_z(heading) R_y(pitch) R_x(roll)`, identical to `kamera.sensor_models.nav_state`. diff --git a/kamera/calibration/how_it_works.md b/kamera/calibration/how_it_works.md index bb81f502..2b79579e 100644 --- a/kamera/calibration/how_it_works.md +++ b/kamera/calibration/how_it_works.md @@ -153,18 +153,11 @@ defaults to the calibration flight's median scene range and should be set to the survey altitude. The files use DIVE's registration format version 2, one matrix-only pair each. -The GIFs warp the first camera's picture onto the second with that homography for -five frames spread across the flight and flip between the two, so a misregistration is -visible at a glance. +The GIFs show the registration the way DIVE does: for five frames spread across the flight, each one flips between the RGB frame and the same frame with the first camera warped onto it over its footprint, so a misregistration is visible at a glance. ## Step 10: Report (`report.py`) -A PDF with a flight summary page (dates, frames on disk, selected and registered, -images per camera, the flight track), the camera intrinsics table, the rig geometry -with a sketch of the optical axes in aircraft body axes, and one page per homography -pair showing the RGB frame with the warped camera blended over its footprint, as DIVE -displays a registration. The boresight numbers and their per-frame scatter are in the -rig yaml. +A PDF with a flight summary page (dates, frames on disk, selected and registered, images per camera, the flight track), the camera intrinsics table, the rig geometry with a sketch of the optical axes in aircraft body axes, and one page per homography pair showing the RGB frame with the warped camera blended over its footprint, as DIVE displays a registration. The boresight numbers and their per-frame scatter are in the rig yaml, and the README describes what limits their accuracy. ## Running it again From 89c3721992dedbbea9b8a9b055c5de09992d1d8a Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Tue, 22 Sep 2026 11:09:52 -0400 Subject: [PATCH 31/32] Clean up ruff findings in the legacy geo and postflight modules Format geo_conversions, nav_conversions, postflight/utilities and create_flight_summary with ruff, drop unused imports and dead assignments, narrow a bare except, and rename single-letter variables. No behaviour change apart from one real fix: enu_to_llh built its ECEF x and y as one-element tuples (trailing commas), which numpy 2 refuses to convert to scalars, so every scalar call raised a TypeError. The round trip llh -> enu -> llh now closes to a micro-degree. --- kamera/colmap_processing/geo_conversions.py | 355 ++++++++++-------- .../scripts/create_flight_summary.py | 8 +- kamera/postflight/utilities.py | 67 ++-- kamera/sensor_models/nav_conversions.py | 220 ++++++----- 4 files changed, 357 insertions(+), 293 deletions(-) diff --git a/kamera/colmap_processing/geo_conversions.py b/kamera/colmap_processing/geo_conversions.py index 92952982..052f618b 100644 --- a/kamera/colmap_processing/geo_conversions.py +++ b/kamera/colmap_processing/geo_conversions.py @@ -5,6 +5,7 @@ try: from sklearn.preprocessing import PolynomialFeatures + sklearn_imported = True except ImportError: sklearn_imported = False @@ -15,16 +16,16 @@ # WGS84 constants _a = 6378137 -_f = 1/(298257223563/1000000000) -_e2 = _f*(2-_f) -_e2m = np.square(1-_f) +_f = 1 / (298257223563 / 1000000000) +_e2 = _f * (2 - _f) +_e2m = np.square(1 - _f) _e2a = abs(_e2) _e4a = np.square(_e2) epsilon = np.finfo(float).eps _maxrad = 2 * _a / epsilon -deg2rad = np.pi/180 -rad2deg = 180/np.pi +deg2rad = np.pi / 180 +rad2deg = 180 / np.pi class FastENUConverter(object): @@ -66,8 +67,17 @@ class FastENUConverter(object): """ - def __init__(self, lat_range, lon_range, height_range, lat0, lon0, h0, - accuracy=[1e-2, 1e-2, 1e-2]): + + def __init__( + self, + lat_range, + lon_range, + height_range, + lat0, + lon0, + h0, + accuracy=[1e-2, 1e-2, 1e-2], + ): """ :param lat_range: Range of latitudes (degrees) to support. :type lat_range: array-like shape (2,) @@ -92,7 +102,7 @@ def __init__(self, lat_range, lon_range, height_range, lat0, lon0, h0, for h in hs: LATS, LONS = np.meshgrid(lats, lons) L = len(LATS.ravel()) - llhi = np.vstack([LATS.ravel(), LONS.ravel(), np.ones(L)*h]).T + llhi = np.vstack([LATS.ravel(), LONS.ravel(), np.ones(L) * h]).T llh = np.vstack([llh, llhi]) enu = [llh_to_enu(_[0], _[1], _[2], lat0, lon0, h0) for _ in llh] @@ -105,9 +115,10 @@ def __init__(self, lat_range, lon_range, height_range, lat0, lon0, h0, degree += 1 if degree == 10: - raise Exception('Failed to fit to required accuracy=%0.3f. ' - 'Try reducing required accuracy.' % - accuracy) + raise Exception( + "Failed to fit to required accuracy=%0.3f. " + "Try reducing required accuracy." % accuracy + ) self._llh_feature_poly = PolynomialFeatures(degree=degree) features = self._llh_feature_poly.fit_transform(llh) @@ -132,9 +143,10 @@ def __init__(self, lat_range, lon_range, height_range, lat0, lon0, h0, degree += 1 if degree == 10: - raise Exception('Failed to fit to required accuracy=%0.3f. ' - 'Try reducing required accuracy.' % - accuracy) + raise Exception( + "Failed to fit to required accuracy=%0.3f. " + "Try reducing required accuracy." % accuracy + ) self._enu_feature_poly = PolynomialFeatures(degree=degree) features = self._enu_feature_poly.fit_transform(enu) @@ -146,15 +158,14 @@ def __init__(self, lat_range, lon_range, height_range, lat0, lon0, h0, self._enu_coeff = np.array(coeff).T fit = np.dot(features, self._enu_coeff) - enu_fit = [llh_to_enu(_[0], _[1], _[2], lat0, lon0, h0) - for _ in fit] + enu_fit = [llh_to_enu(_[0], _[1], _[2], lat0, lon0, h0) for _ in fit] if np.any(np.abs(enu_fit - enu) > accuracy): continue break def llh_to_enu(self, lat, lon, h): - if hasattr(lat, '__len__'): + if hasattr(lat, "__len__"): llh = np.vstack([lat, lon, h]).T features = self._llh_feature_poly.transform(llh) enu = np.dot(features, self._llh_coeff) @@ -166,7 +177,7 @@ def llh_to_enu(self, lat, lon, h): return float(enu[0, 0]), float(enu[0, 1]), float(enu[0, 2]) def enu_to_llh(self, east, north, up): - if hasattr(east, '__len__'): + if hasattr(east, "__len__"): enu = np.vstack([east, north, up]).T features = self._enu_feature_poly.transform(enu) enu = np.dot(features, self._enu_coeff) @@ -227,32 +238,40 @@ def llh_to_enu(lat, lon, h, lat0, lon0, h0, in_degrees=True, pure_python=True): """ if not in_degrees: - lat = lat*180/np.pi - lon = lon*180/np.pi - lat0 = lat0*180/np.pi - lon0 = lon0*180/np.pi + lat = lat * 180 / np.pi + lon = lon * 180 / np.pi + lat0 = lat0 * 180 / np.pi + lon0 = lon0 * 180 / np.pi if pure_python: sphi, cphi = sincosd(lat0) slam, clam = sincosd(lon0) _r = geocentric_rotation(sphi, cphi, slam, clam) - xc,yc,zc = llh_to_ecef(lat, lon, h, in_degrees=True) - _x0,_y0,_z0 = llh_to_ecef(lat0, lon0, h0, in_degrees=True) - xc -= _x0; yc -= _y0; zc -= _z0; - x = _r[0] * xc + _r[3] * yc + _r[6] * zc; - y = _r[1] * xc + _r[4] * yc + _r[7] * zc; - z = _r[2] * xc + _r[5] * yc + _r[8] * zc; - return [x,y,z] + xc, yc, zc = llh_to_ecef(lat, lon, h, in_degrees=True) + _x0, _y0, _z0 = llh_to_ecef(lat0, lon0, h0, in_degrees=True) + xc -= _x0 + yc -= _y0 + zc -= _z0 + x = _r[0] * xc + _r[3] * yc + _r[6] * zc + y = _r[1] * xc + _r[4] * yc + _r[7] * zc + z = _r[2] * xc + _r[5] * yc + _r[8] * zc + return [x, y, z] else: - output = subprocess.check_output(['CartConvert','-l', - str(lat0),str(lon0), - str(h0),'--input-string', - ' '.join([str(lat),str(lon),str(h)])]) - return [float(s) for s in output.split('\n')[0].split(' ')] - - -def enu_to_llh(east, north, up, lat0, lon0, h0, in_degrees=True, - pure_python=True): + output = subprocess.check_output( + [ + "CartConvert", + "-l", + str(lat0), + str(lon0), + str(h0), + "--input-string", + " ".join([str(lat), str(lon), str(h)]), + ] + ) + return [float(s) for s in output.split("\n")[0].split(" ")] + + +def enu_to_llh(east, north, up, lat0, lon0, h0, in_degrees=True, pure_python=True): """Convert latitude, longitude, and height to east, north, up. East, north, and up are coordinates within a local level Cartesian @@ -302,36 +321,46 @@ def enu_to_llh(east, north, up, lat0, lon0, h0, in_degrees=True, """ if not in_degrees: - lat0 = lat0*180/np.pi - lon0 = lon0*180/np.pi + lat0 = lat0 * 180 / np.pi + lon0 = lon0 * 180 / np.pi if pure_python: x, y, z = east, north, up sphi, cphi = sincosd(lat0) slam, clam = sincosd(lon0) _r = geocentric_rotation(sphi, cphi, slam, clam) - _x0,_y0,_z0 = llh_to_ecef(lat0, lon0, h0, in_degrees=True) + _x0, _y0, _z0 = llh_to_ecef(lat0, lon0, h0, in_degrees=True) - xc = _x0 + _r[0] * x + _r[1] * y + _r[2] * z, - yc = _y0 + _r[3] * x + _r[4] * y + _r[5] * z, - zc = _z0 + _r[6] * x + _r[7] * y + _r[8] * z; + xc = (_x0 + _r[0] * x + _r[1] * y + _r[2] * z,) + yc = (_y0 + _r[3] * x + _r[4] * y + _r[5] * z,) + zc = _z0 + _r[6] * x + _r[7] * y + _r[8] * z lat, lon, h = ecef_to_llh(xc, yc, zc, in_degrees) else: - output = subprocess.check_output(['CartConvert','-r','-l',str(lat0), - str(lon0),str(h0),'--input-string', - ' '.join([str(east),str(north), - str(up)])]) - - lat, lon, h = [float(s) for s in output.split('\n')[0].split(' ')] + output = subprocess.check_output( + [ + "CartConvert", + "-r", + "-l", + str(lat0), + str(lon0), + str(h0), + "--input-string", + " ".join([str(east), str(north), str(up)]), + ] + ) + + lat, lon, h = [float(s) for s in output.split("\n")[0].split(" ")] if not in_degrees: - lat = lat*180/np.pi - lon = lon*180/np.pi + lat = lat * 180 / np.pi + lon = lon * 180 / np.pi + + return [lat, lon, h] + - return [lat,lon,h] +_cached1 = _a * (1 - _e2) -_cached1 = _a*(1-_e2) def dlat_dlon_per_meter(lat, in_degrees=True): """Return latitude and longitude degrees change per meter east and north. @@ -352,18 +381,18 @@ def dlat_dlon_per_meter(lat, in_degrees=True): """ if in_degrees: - lat_ = lat*deg2rad + lat_ = lat * deg2rad - east = _cached1*(1 - _e2*sin(lat_)**2)**(-3/2) + east = _cached1 * (1 - _e2 * sin(lat_) ** 2) ** (-3 / 2) # Parameter (or reduced) latitude. - beta = np.arctan((1-_f)*np.tan(lat_)) + beta = np.arctan((1 - _f) * np.tan(lat_)) - north = _a*np.cos(beta) + north = _a * np.cos(beta) if in_degrees: - east = east*deg2rad - north = north*deg2rad + east = east * deg2rad + north = north * deg2rad return east, north @@ -379,7 +408,7 @@ def ned_quat_to_enu_quat(quat): :rtype: 4-array """ - return quaternion_multiply([np.sqrt(2)/2,np.sqrt(2)/2,0,0], quat) + return quaternion_multiply([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], quat) def enu_quat_to_ned_quat(quat): @@ -393,7 +422,7 @@ def enu_quat_to_ned_quat(quat): :rtype: 4-array """ - return quaternion_multiply([np.sqrt(2)/2,np.sqrt(2)/2,0,0], quat) + return quaternion_multiply([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], quat) def ecef_to_llh(X, Y, Z, in_degrees=True): @@ -422,7 +451,7 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): z = 2167698 """ - R = np.hypot(X,Y) + R = np.hypot(X, Y) if R == 0: slam = 0 clam = 1 @@ -430,25 +459,25 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): slam = Y / R clam = X / R - h = np.hypot(R,Z) # Distance to center of earth - if (h > _maxrad): + h = np.hypot(R, Z) # Distance to center of earth + if h > _maxrad: # We really far away (> 12 million light years) treat the earth as a # point and h, above, is an acceptable approximation to the height. # This avoids overflow, e.g., in the computation of disc below. It's # possible that h has overflowed to inf but that's OK. # # Treat the case X, Y finite, but R overflows to +inf by scaling by 2. - R = np.hypot(X/2, Y/2) + R = np.hypot(X / 2, Y / 2) if R == 0: slam = 0 clam = 1 else: - slam = (Y/2) / R - clam = (X/2) / R + slam = (Y / 2) / R + clam = (X / 2) / R - H = np.hypot(Z/2,R) - sphi = (Z/2) / H + H = np.hypot(Z / 2, R) + sphi = (Z / 2) / H cphi = R / H elif _e4a == 0: # Treat the spherical case. Dealing with underflow in the general case @@ -470,17 +499,17 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): q = _e2m * np.square(Z / _a) r = (p + q - _e4a) / 6 if _f < 0: - p,q = q,p + p, q = q, p if not (_e4a * q == 0 and r <= 0): # Avoid possible division by zero when r = 0 by multiplying # equations for s and t by r^3 and r, resp. - S = _e4a * p * q / 4 # S = r^3 * s + S = _e4a * p * q / 4 # S = r^3 * s r2 = np.square(r) r3 = r * r2 disc = S * (2 * r3 + S) u = r - if (disc >= 0): + if disc >= 0: T3 = S + r3 # Pick the sign on the sqrt to maximize abs(T3). This # minimizes loss of precision due to cancellation. The result @@ -492,7 +521,7 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): T3 += np.sqrt(disc) # N.B. cbrt always returns the real root. cbrt(-8) = -2. - T = np.cbrt(T3) # T = r * t + T = np.cbrt(T3) # T = r * t # T can be zero but then r2 / T -> 0. if T != 0: u += T + (r2 / T) @@ -505,7 +534,7 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): # r < 0. u += 2 * r * np.cos(ang / 3) - v = np.sqrt(np.square(u) + _e4a * q) # guaranteed positive + v = np.sqrt(np.square(u) + _e4a * q) # guaranteed positive # Avoid loss of accuracy when u < 0. Underflow doesn't occur in # e4 * q / (v - u) because u ~ e^4 when q is small and u < 0. if u < 0: # u+v guaranteed positive @@ -526,12 +555,12 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): k2 = k d = k1 * R / k2 - H = np.hypot(Z/k1, R/k2) - sphi = (Z/k1) / H - cphi = (R/k2) / H - h = (1 - _e2m/k1) * np.hypot(d, Z) + H = np.hypot(Z / k1, R / k2) + sphi = (Z / k1) / H + cphi = (R / k2) / H + h = (1 - _e2m / k1) * np.hypot(d, Z) - else: # e4 * q == 0 && r <= 0 + else: # e4 * q == 0 && r <= 0 # This leads to k = 0 (oblate, equatorial plane) and k + e^2 = 0 # (prolate, rotation axis) and the generation of 0/0 in the general # formulas for phi and h. using the general formula and division by 0 @@ -543,7 +572,7 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): else: zz = np.sqrt(p / _e2m) - if _f < 0: + if _f < 0: xx = np.sqrt(_e4a - p) else: xx = np.sqrt(p) @@ -552,15 +581,15 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): sphi = zz / H cphi = xx / H if Z < 0: - sphi = -sphi # for tiny negative Z (not for prolate) + sphi = -sphi # for tiny negative Z (not for prolate) if _f >= 0: - h = - _a * (_e2m) * H / _e2a + h = -_a * (_e2m) * H / _e2a else: - h = - _a * (1) * H / _e2a + h = -_a * (1) * H / _e2a - lat = float(np.arctan2(sphi, cphi)*180/np.pi) - lon = float(np.arctan2(slam, clam)*180/np.pi) + lat = float(np.arctan2(sphi, cphi) * 180 / np.pi) + lon = float(np.arctan2(slam, clam) * 180 / np.pi) h = float(h) return lat, lon, h @@ -586,18 +615,18 @@ def llh_to_ecef(lat, lon, h, in_degrees=True): """ if not in_degrees: - lat = lat*180/np.pi - lon = lon*180/np.pi + lat = lat * 180 / np.pi + lon = lon * 180 / np.pi - sphi,cphi = sincosd(lat) - slam,clam = sincosd(lon) + sphi, cphi = sincosd(lat) + slam, clam = sincosd(lon) - n = _a/np.sqrt(1-_e2*np.square(sphi)) + n = _a / np.sqrt(1 - _e2 * np.square(sphi)) Z = (_e2m * n + h) * sphi X = (n + h) * cphi Y = X * slam X *= clam - return [float(X),float(Y),float(Z)] + return [float(X), float(Y), float(Z)] def geocentric_rotation(sphi, cphi, slam, clam): @@ -612,11 +641,17 @@ def geocentric_rotation(sphi, cphi, slam, clam): """ M = np.zeros(9) # Local X axis (east) in geocentric coords - M[0] = -slam; M[3] = clam; M[6] = 0; + M[0] = -slam + M[3] = clam + M[6] = 0 # Local Y axis (north) in geocentric coords - M[1] = -clam * sphi; M[4] = -slam * sphi; M[7] = cphi; + M[1] = -clam * sphi + M[4] = -slam * sphi + M[7] = cphi # Local Z axis (up) in geocentric coords - M[2] = clam * cphi; M[5] = slam * cphi; M[8] = sphi; + M[2] = clam * cphi + M[5] = slam * cphi + M[8] = sphi return M @@ -648,13 +683,17 @@ def sincosd(x): s = x if np.uint8(q) & np.uint8(3) == np.uint(0): - sinx = s; cosx = c + sinx = s + cosx = c elif np.uint8(q) & np.uint8(3) == np.uint(1): - sinx = c; cosx = -s + sinx = c + cosx = -s elif np.uint8(q) & np.uint8(3) == np.uint(2): - sinx = -s; cosx = -c + sinx = -s + cosx = -c else: - sinx = -c; cosx = s + sinx = -c + cosx = s # Set sign of 0 results. -0 only produced for sin(-0) if x: @@ -679,17 +718,21 @@ def rmat_enu_ecef(lat, lon, in_degrees=True): """ if in_degrees: - lat = lat/180*np.pi - lon = lon/180*np.pi + lat = lat / 180 * np.pi + lon = lon / 180 * np.pi clat = cos(lat) slat = sin(lat) clon = cos(lon) slon = sin(lon) - return np.array([[-slon, -slat*clon, clat*clon], - [clon, -slat*slon, clat*slon], - [0, clat, slat]]) + return np.array( + [ + [-slon, -slat * clon, clat * clon], + [clon, -slat * slon, clat * slon], + [0, clat, slat], + ] + ) def rmat_ecef_enu(lat, lon, in_degrees=True): @@ -707,17 +750,21 @@ def rmat_ecef_enu(lat, lon, in_degrees=True): """ if in_degrees: - lat = lat/180*np.pi - lon = lon/180*np.pi + lat = lat / 180 * np.pi + lon = lon / 180 * np.pi clat = cos(lat) slat = sin(lat) clon = cos(lon) slon = sin(lon) - return np.array([[-slon, clon, 0], - [-clon*slat, -slon*slat, clat], - [clon*clat, slon*clat, slat]]) + return np.array( + [ + [-slon, clon, 0], + [-clon * slat, -slon * slat, clat], + [clon * clat, slon * clat, slat], + ] + ) def quat_std_to_ypr_std(quat, qx_std, qy_std, qz_std, qw_std=None): @@ -770,53 +817,59 @@ def yaw(qx, qy, qz, qw): qx2 = qx**2 qy2 = qy**2 qz2 = qz**2 - qwqy = qw*qy - qxqz = qx*qz - qyqz = qy*qz - qwqx = qw*qx - qwqz = qw*qz - qxqy = qx*qy - C1 = qwqz+qxqy - C2 = qz2+qy2 - C4 = qyqz+qwqx - C5 = qy2+qx2 - C6 = 1-2*(C2) - C7 = C6**2+4*C1**2 - C8 = (1-2*C5)**2 - C9 = 1-2*C5 - C10 = 4*C4**2+C8 - C11 = C9/C10 - C12 = C4*8 + qwqy = qw * qy + qxqz = qx * qz + qyqz = qy * qz + qwqx = qw * qx + qwqz = qw * qz + qxqy = qx * qy + C1 = qwqz + qxqy + C2 = qz2 + qy2 + C4 = qyqz + qwqx + C5 = qy2 + qx2 + C6 = 1 - 2 * (C2) + C7 = C6**2 + 4 * C1**2 + C8 = (1 - 2 * C5) ** 2 + C9 = 1 - 2 * C5 + C10 = 4 * C4**2 + C8 + C11 = C9 / C10 + C12 = C4 * 8 # Heading - dheading_dx = (2*qy*C6)/C7 - dheading_dy = (2*qx*C6)/C7+(8*qy*C1)/C7 - dheading_dz = (2*qw*C6)/C7+(8*qz*C1)/C7 - dheading_dw = (2*qz*C6)/C7 + dheading_dx = (2 * qy * C6) / C7 + dheading_dy = (2 * qx * C6) / C7 + (8 * qy * C1) / C7 + dheading_dz = (2 * qw * C6) / C7 + (8 * qz * C1) / C7 + dheading_dw = (2 * qz * C6) / C7 # Pitch - C3 = sqrt(1-4*(qwqy-qxqz)**2) - dpitch_dx = -2*qz/C3 - dpitch_dy = 2*qw/C3 - dpitch_dz = -2*qx/C3 - dpitch_dw = 2*qy/C3 + C3 = sqrt(1 - 4 * (qwqy - qxqz) ** 2) + dpitch_dx = -2 * qz / C3 + dpitch_dy = 2 * qw / C3 + dpitch_dz = -2 * qx / C3 + dpitch_dw = 2 * qy / C3 # Change in roll as function of qx - droll_dx = qx*C12/C10+2*qw*C11 - droll_dy = qy*C12/C10+2*qz*C11 - droll_dz = 2*qy*C11 - droll_dw = 2*qx*C11 - - heading_std = abs(dheading_dx - dheading_dy/3 - dheading_dz/3 - dheading_dw/3)*qx_std - heading_std += abs(dheading_dy - dheading_dx/3 - dheading_dz/3 - dheading_dw/3)*qy_std - heading_std += abs(dheading_dz - dheading_dx/3 - dheading_dy/3 - dheading_dw/3)*qz_std - - pitch_std = abs(dpitch_dx - dpitch_dy/3 - dpitch_dz/3 - dpitch_dw/3)*qx_std - pitch_std += abs(dpitch_dy - dpitch_dx/3 - dpitch_dz/3 - dpitch_dw/3)*qy_std - pitch_std += abs(dpitch_dz - dpitch_dx/3 - dpitch_dy/3 - dpitch_dw/3)*qz_std - - roll_std = abs(droll_dx - droll_dy/3 - droll_dz/3 - droll_dw/3)*qx_std - roll_std += abs(droll_dy - droll_dx/3 - droll_dz/3 - droll_dw/3)*qy_std - roll_std += abs(droll_dz - droll_dx/3 - droll_dy/3 - droll_dw/3)*qz_std + droll_dx = qx * C12 / C10 + 2 * qw * C11 + droll_dy = qy * C12 / C10 + 2 * qz * C11 + droll_dz = 2 * qy * C11 + droll_dw = 2 * qx * C11 + + heading_std = ( + abs(dheading_dx - dheading_dy / 3 - dheading_dz / 3 - dheading_dw / 3) * qx_std + ) + heading_std += ( + abs(dheading_dy - dheading_dx / 3 - dheading_dz / 3 - dheading_dw / 3) * qy_std + ) + heading_std += ( + abs(dheading_dz - dheading_dx / 3 - dheading_dy / 3 - dheading_dw / 3) * qz_std + ) + + pitch_std = abs(dpitch_dx - dpitch_dy / 3 - dpitch_dz / 3 - dpitch_dw / 3) * qx_std + pitch_std += abs(dpitch_dy - dpitch_dx / 3 - dpitch_dz / 3 - dpitch_dw / 3) * qy_std + pitch_std += abs(dpitch_dz - dpitch_dx / 3 - dpitch_dy / 3 - dpitch_dw / 3) * qz_std + + roll_std = abs(droll_dx - droll_dy / 3 - droll_dz / 3 - droll_dw / 3) * qx_std + roll_std += abs(droll_dy - droll_dx / 3 - droll_dz / 3 - droll_dw / 3) * qy_std + roll_std += abs(droll_dz - droll_dx / 3 - droll_dy / 3 - droll_dw / 3) * qz_std return heading_std, pitch_std, roll_std diff --git a/kamera/postflight/scripts/create_flight_summary.py b/kamera/postflight/scripts/create_flight_summary.py index 1b98aa9f..4634635b 100644 --- a/kamera/postflight/scripts/create_flight_summary.py +++ b/kamera/postflight/scripts/create_flight_summary.py @@ -8,7 +8,7 @@ def main(): parser = argparse.ArgumentParser( - description="Convert all images from a " "flight into shapefiles." + description="Convert all images from a flight into shapefiles." ) parser.add_argument( "-flight_dir", @@ -35,10 +35,12 @@ def main(): # output_dir = '/example_output_dir' if not flight_dir: - raise SystemError("No flight dir specified! Please pass one as an argument or hardcode one in the file.") + raise SystemError( + "No flight dir specified! Please pass one as an argument or hardcode one in the file." + ) utilities.create_flight_summary(flight_dir, output_dir=output_dir) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/kamera/postflight/utilities.py b/kamera/postflight/utilities.py index 70f9cc34..b17983ba 100644 --- a/kamera/postflight/utilities.py +++ b/kamera/postflight/utilities.py @@ -5,7 +5,6 @@ import json import time import glob -import warnings import threading from shutil import copyfile import exifread @@ -22,21 +21,12 @@ import pygeodesy from osgeo import osr, gdal import simplekml -from shapely.geometry import Polygon, mapping +from shapely.geometry import Polygon import shapefile # Custom package imports. -import sys sys.path.insert(0, "C:/Users/path_to/postflight_scripts/sensor_models/src") -from kamera.sensor_models import ( - quaternion_multiply, - quaternion_from_matrix, - quaternion_from_euler, - quaternion_slerp, - quaternion_inverse, - quaternion_matrix, -) from kamera.colmap_processing.camera_models import load_from_file from kamera.sensor_models.nav_conversions import enu_to_llh, llh_to_enu from kamera.sensor_models.nav_state import NavStateINSJson @@ -415,17 +405,17 @@ def decompose_affine(A): def get_image_chip(image, left, right, top, bottom): - l = np.maximum(left, 0) - r = np.maximum(l, right) - r = np.minimum(r, image.shape[1]) - t = np.maximum(top, 0) - b = np.maximum(t, bottom) - b = np.minimum(b, image.shape[0]) + x0 = np.maximum(left, 0) + x1 = np.maximum(x0, right) + x1 = np.minimum(x1, image.shape[1]) + y0 = np.maximum(top, 0) + y1 = np.maximum(y0, bottom) + y1 = np.minimum(y1, image.shape[0]) if image.ndim == 3: - return image[t:b, l:r, :] + return image[y0:y1, x0:x1, :] else: - return image[t:b, l:r] + return image[y0:y1, x0:x1] def points_along_image_border(width, height, num_points=4): @@ -523,7 +513,7 @@ def parse_image_directory(image_dir, modality=None): with open(json_fname) as json_file: try: d = json.load(json_file) - except json.decoder.JSONDecodeError as e: + except json.decoder.JSONDecodeError: print("Failed to decode file %s." % json_file) continue @@ -772,7 +762,7 @@ def affine_not_valid(h): try: translation, R, scale, S = decompose_affine(h) - except: + except Exception: return True # translation = h[:2, 2] @@ -878,10 +868,6 @@ def affine_not_valid(h): mask = mask.ravel().astype(bool) - # Verify whether homography is acceptable. If not, do RANSAC with - # only acceptable test cases. - det = np.linalg.det(h) - pts0 = pts0[mask] pts1 = pts1[mask] @@ -1323,11 +1309,8 @@ def create_geotiffs_glob( # This will do some duplication of NavState parsing but I do not have time to fix ret = parse_image_directory(image_dir, modality=modality) - img_fname_to_time = ret[0] img_time_to_fname = ret[1] platform_pose_provider = ret[2] - effort_type = ret[3] - trigger_type = ret[4] camera_model = load_from_file(camera_model_fname, platform_pose_provider) @@ -1515,7 +1498,7 @@ def get_review_fate( t = basename_to_time[base_name] try: nth = nav_state_provider.time_to_save_every_x_image[t] - except KeyError as e: + except KeyError: print(f"Could not find 'save_every_x_image' for time {t}.") nth = None @@ -1577,9 +1560,7 @@ def get_basename_to_time(flight_dir) -> dict: return basename_to_time -def create_flight_summary( - flight_dir, save_shapefile_per_image=False, output_dir=None -): +def create_flight_summary(flight_dir, save_shapefile_per_image=False, output_dir=None): """Create flight summary for a flight directory. A flight directory contains a folder structure where different @@ -1630,7 +1611,7 @@ def create_flight_summary( for f in det_txts: print(f) with open(f, "r") as of: - sets_detector_processed += [get_base(l) for l in of.readlines()] + sets_detector_processed += [get_base(line) for line in of.readlines()] sets_detector_processed = set(sets_detector_processed) print("Number of sets of images detected on: %s" % len(sets_detector_processed)) @@ -1641,8 +1622,8 @@ def create_flight_summary( for f in det_csvs: with open(f, "r") as of: lines = of.readlines() - lines = [l for l in lines if l[0] != "#"] - files = [get_base(l.split(",")[1]) for l in lines] + lines = [line for line in lines if line[0] != "#"] + files = [get_base(line.split(",")[1]) for line in lines] sets_with_detections += files sets_with_detections = set(sets_with_detections) print("Number of sets with detections: %s" % len(sets_with_detections)) @@ -1654,10 +1635,7 @@ def create_flight_summary( raise SystemExit("No meta jsons were found, please check your filepaths.") nav_state_provider = NavStateINSJson(json_glob) - fn_glob = os.path.join(flight_dir, "*/*/*meta.json") count = 0 - est_metas = glob.glob(fn_glob) - total = len(est_metas) * 3 for sys_config in os.listdir(flight_dir): sys_config_dir = "%s/%s" % (flight_dir, sys_config) @@ -2070,12 +2048,10 @@ def visualize_registration_homographies(flight_dir, sys_str="rgb"): """ img_to_lonlat_homog_dir = ( - "%s/processed_results/" "homographies_img_to_lonlat" % flight_dir + "%s/processed_results/homographies_img_to_lonlat" % flight_dir ) - img_to_img_homog_dir = ( - "%s/processed_results/" "homographies_img_to_img" % flight_dir - ) + img_to_img_homog_dir = "%s/processed_results/homographies_img_to_img" % flight_dir dir_out = "%s/processed_results/ins_registration_viz" % flight_dir @@ -2173,7 +2149,6 @@ def get_image(fname): img_pair_fnames = sorted(list(img_to_img_homog.keys())) for img_pair_fname in img_pair_fnames: - fname1, fname2 = img_pair_fname.split("_to_") h12 = img_to_img_homog[img_pair_fname] img1 = get_image(fname1) @@ -2281,12 +2256,12 @@ def detection_summary( if img_to_lonlat_homog_dir is None: img_to_lonlat_homog_dir = ( - "%s/processed_results/" "homographies_img_to_lonlat" % flight_dir + "%s/processed_results/homographies_img_to_lonlat" % flight_dir ) if img_to_img_homog_dir is None: img_to_img_homog_dir = ( - "%s/processed_results/" "homographies_img_to_img" % flight_dir + "%s/processed_results/homographies_img_to_img" % flight_dir ) if not os.path.isdir(img_to_lonlat_homog_dir): @@ -2478,7 +2453,7 @@ def get_image(fname): return img # Track redundant detections. - print2("Comparing detections between frames to identify redundant " "detections...") + print2("Comparing detections between frames to identify redundant detections...") num_suppressed = 0 img_fnames = sorted(img_fnames) diff --git a/kamera/sensor_models/nav_conversions.py b/kamera/sensor_models/nav_conversions.py index 4c45c2b9..ec349b4f 100644 --- a/kamera/sensor_models/nav_conversions.py +++ b/kamera/sensor_models/nav_conversions.py @@ -37,22 +37,22 @@ - sudo apt-get install geographiclib-tools """ + from __future__ import division, print_function import numpy as np import subprocess from math import cos, sin, sqrt from kamera.sensor_models import ( - quaternion_multiply, - quaternion_inverse, - quaternion_slerp - ) + quaternion_multiply, + quaternion_inverse, +) # WGS84 constants _a = 6378137 -_f = 1/(298257223563/1000000000) -_e2 = _f*(2-_f) -_e2m = np.square(1-_f) +_f = 1 / (298257223563 / 1000000000) +_e2 = _f * (2 - _f) +_e2m = np.square(1 - _f) _e2a = abs(_e2) _e4a = np.square(_e2) epsilon = np.finfo(float).eps @@ -108,32 +108,40 @@ def llh_to_enu(lat, lon, h, lat0, lon0, h0, in_degrees=True, pure_python=True): """ if not in_degrees: - lat = lat*180/np.pi - lon = lon*180/np.pi - lat0 = lat0*180/np.pi - lon0 = lon0*180/np.pi + lat = lat * 180 / np.pi + lon = lon * 180 / np.pi + lat0 = lat0 * 180 / np.pi + lon0 = lon0 * 180 / np.pi if pure_python: sphi, cphi = sincosd(lat0) slam, clam = sincosd(lon0) _r = geocentric_rotation(sphi, cphi, slam, clam) - xc,yc,zc = llh_to_ecef(lat, lon, h, in_degrees=True) - _x0,_y0,_z0 = llh_to_ecef(lat0, lon0, h0, in_degrees=True) - xc -= _x0; yc -= _y0; zc -= _z0; - x = _r[0] * xc + _r[3] * yc + _r[6] * zc; - y = _r[1] * xc + _r[4] * yc + _r[7] * zc; - z = _r[2] * xc + _r[5] * yc + _r[8] * zc; - return [x,y,z] + xc, yc, zc = llh_to_ecef(lat, lon, h, in_degrees=True) + _x0, _y0, _z0 = llh_to_ecef(lat0, lon0, h0, in_degrees=True) + xc -= _x0 + yc -= _y0 + zc -= _z0 + x = _r[0] * xc + _r[3] * yc + _r[6] * zc + y = _r[1] * xc + _r[4] * yc + _r[7] * zc + z = _r[2] * xc + _r[5] * yc + _r[8] * zc + return [x, y, z] else: - output = subprocess.check_output(['CartConvert','-l', - str(lat0),str(lon0), - str(h0),'--input-string', - ' '.join([str(lat),str(lon),str(h)])]) - return [float(s) for s in output.split('\n')[0].split(' ')] + output = subprocess.check_output( + [ + "CartConvert", + "-l", + str(lat0), + str(lon0), + str(h0), + "--input-string", + " ".join([str(lat), str(lon), str(h)]), + ] + ) + return [float(s) for s in output.split("\n")[0].split(" ")] -def enu_to_llh(east, north, up, lat0, lon0, h0, in_degrees=True, - pure_python=True): +def enu_to_llh(east, north, up, lat0, lon0, h0, in_degrees=True, pure_python=True): """Convert latitude, longitude, and height to east, north, up. East, north, and up are coordinates within a local level Cartesian @@ -183,33 +191,41 @@ def enu_to_llh(east, north, up, lat0, lon0, h0, in_degrees=True, """ if not in_degrees: - lat0 = lat0*180/np.pi - lon0 = lon0*180/np.pi + lat0 = lat0 * 180 / np.pi + lon0 = lon0 * 180 / np.pi if pure_python: x, y, z = east, north, up sphi, cphi = sincosd(lat0) slam, clam = sincosd(lon0) _r = geocentric_rotation(sphi, cphi, slam, clam) - _x0,_y0,_z0 = llh_to_ecef(lat0, lon0, h0, in_degrees=True) + _x0, _y0, _z0 = llh_to_ecef(lat0, lon0, h0, in_degrees=True) - xc = _x0 + _r[0] * x + _r[1] * y + _r[2] * z, - yc = _y0 + _r[3] * x + _r[4] * y + _r[5] * z, - zc = _z0 + _r[6] * x + _r[7] * y + _r[8] * z; + xc = _x0 + _r[0] * x + _r[1] * y + _r[2] * z + yc = _y0 + _r[3] * x + _r[4] * y + _r[5] * z + zc = _z0 + _r[6] * x + _r[7] * y + _r[8] * z lat, lon, h = ecef_to_llh(xc, yc, zc, in_degrees) else: - output = subprocess.check_output(['CartConvert','-r','-l',str(lat0), - str(lon0),str(h0),'--input-string', - ' '.join([str(east),str(north), - str(up)])]) + output = subprocess.check_output( + [ + "CartConvert", + "-r", + "-l", + str(lat0), + str(lon0), + str(h0), + "--input-string", + " ".join([str(east), str(north), str(up)]), + ] + ) - lat, lon, h = [float(s) for s in output.split('\n')[0].split(' ')] + lat, lon, h = [float(s) for s in output.split("\n")[0].split(" ")] if not in_degrees: - lat = lat*180/np.pi - lon = lon*180/np.pi + lat = lat * 180 / np.pi + lon = lon * 180 / np.pi - return [lat,lon,h] + return [lat, lon, h] def ned_quat_to_enu_quat(quat): @@ -223,7 +239,7 @@ def ned_quat_to_enu_quat(quat): :rtype: 4-array """ - return quaternion_multiply([np.sqrt(2)/2,np.sqrt(2)/2,0,0], quat) + return quaternion_multiply([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], quat) def enu_quat_to_ned_quat(quat): @@ -237,7 +253,7 @@ def enu_quat_to_ned_quat(quat): :rtype: 4-array """ - return quaternion_multiply([np.sqrt(2)/2,np.sqrt(2)/2,0,0], quat) + return quaternion_multiply([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], quat) def ecef_to_llh(X, Y, Z, in_degrees=True): @@ -266,7 +282,7 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): z = 2167698 """ - R = np.hypot(X,Y) + R = np.hypot(X, Y) if R == 0: slam = 0 clam = 1 @@ -274,25 +290,25 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): slam = Y / R clam = X / R - h = np.hypot(R,Z) # Distance to center of earth - if (h > _maxrad): + h = np.hypot(R, Z) # Distance to center of earth + if h > _maxrad: # We really far away (> 12 million light years) treat the earth as a # point and h, above, is an acceptable approximation to the height. # This avoids overflow, e.g., in the computation of disc below. It's # possible that h has overflowed to inf but that's OK. # # Treat the case X, Y finite, but R overflows to +inf by scaling by 2. - R = np.hypot(X/2, Y/2) + R = np.hypot(X / 2, Y / 2) if R == 0: slam = 0 clam = 1 else: - slam = (Y/2) / R - clam = (X/2) / R + slam = (Y / 2) / R + clam = (X / 2) / R - H = np.hypot(Z/2,R) - sphi = (Z/2) / H + H = np.hypot(Z / 2, R) + sphi = (Z / 2) / H cphi = R / H elif _e4a == 0: # Treat the spherical case. Dealing with underflow in the general case @@ -314,17 +330,17 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): q = _e2m * np.square(Z / _a) r = (p + q - _e4a) / 6 if _f < 0: - p,q = q,p + p, q = q, p if not (_e4a * q == 0 and r <= 0): # Avoid possible division by zero when r = 0 by multiplying # equations for s and t by r^3 and r, resp. - S = _e4a * p * q / 4 # S = r^3 * s + S = _e4a * p * q / 4 # S = r^3 * s r2 = np.square(r) r3 = r * r2 disc = S * (2 * r3 + S) u = r - if (disc >= 0): + if disc >= 0: T3 = S + r3 # Pick the sign on the sqrt to maximize abs(T3). This # minimizes loss of precision due to cancellation. The result @@ -336,7 +352,7 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): T3 += np.sqrt(disc) # N.B. cbrt always returns the real root. cbrt(-8) = -2. - T = np.cbrt(T3) # T = r * t + T = np.cbrt(T3) # T = r * t # T can be zero but then r2 / T -> 0. if T != 0: u += T + (r2 / T) @@ -349,7 +365,7 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): # r < 0. u += 2 * r * np.cos(ang / 3) - v = np.sqrt(np.square(u) + _e4a * q) # guaranteed positive + v = np.sqrt(np.square(u) + _e4a * q) # guaranteed positive # Avoid loss of accuracy when u < 0. Underflow doesn't occur in # e4 * q / (v - u) because u ~ e^4 when q is small and u < 0. if u < 0: # u+v guaranteed positive @@ -370,12 +386,12 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): k2 = k d = k1 * R / k2 - H = np.hypot(Z/k1, R/k2) - sphi = (Z/k1) / H - cphi = (R/k2) / H - h = (1 - _e2m/k1) * np.hypot(d, Z) + H = np.hypot(Z / k1, R / k2) + sphi = (Z / k1) / H + cphi = (R / k2) / H + h = (1 - _e2m / k1) * np.hypot(d, Z) - else: # e4 * q == 0 && r <= 0 + else: # e4 * q == 0 && r <= 0 # This leads to k = 0 (oblate, equatorial plane) and k + e^2 = 0 # (prolate, rotation axis) and the generation of 0/0 in the general # formulas for phi and h. using the general formula and division by 0 @@ -387,7 +403,7 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): else: zz = np.sqrt(p / _e2m) - if _f < 0: + if _f < 0: xx = np.sqrt(_e4a - p) else: xx = np.sqrt(p) @@ -396,15 +412,15 @@ def ecef_to_llh(X, Y, Z, in_degrees=True): sphi = zz / H cphi = xx / H if Z < 0: - sphi = -sphi # for tiny negative Z (not for prolate) + sphi = -sphi # for tiny negative Z (not for prolate) if _f >= 0: - h = - _a * (_e2m) * H / _e2a + h = -_a * (_e2m) * H / _e2a else: - h = - _a * (1) * H / _e2a + h = -_a * (1) * H / _e2a - lat = float(np.arctan2(sphi, cphi)*180/np.pi) - lon = float(np.arctan2(slam, clam)*180/np.pi) + lat = float(np.arctan2(sphi, cphi) * 180 / np.pi) + lon = float(np.arctan2(slam, clam) * 180 / np.pi) return lat, lon, h @@ -429,18 +445,18 @@ def llh_to_ecef(lat, lon, h, in_degrees=True): """ if not in_degrees: - lat = lat*180/np.pi - lon = lon*180/np.pi + lat = lat * 180 / np.pi + lon = lon * 180 / np.pi - sphi,cphi = sincosd(lat) - slam,clam = sincosd(lon) + sphi, cphi = sincosd(lat) + slam, clam = sincosd(lon) - n = _a/np.sqrt(1-_e2*np.square(sphi)) + n = _a / np.sqrt(1 - _e2 * np.square(sphi)) Z = (_e2m * n + h) * sphi X = (n + h) * cphi Y = X * slam X *= clam - return [float(X),float(Y),float(Z)] + return [float(X), float(Y), float(Z)] def geocentric_rotation(sphi, cphi, slam, clam): @@ -455,11 +471,17 @@ def geocentric_rotation(sphi, cphi, slam, clam): """ M = np.zeros(9) # Local X axis (east) in geocentric coords - M[0] = -slam; M[3] = clam; M[6] = 0; + M[0] = -slam + M[3] = clam + M[6] = 0 # Local Y axis (north) in geocentric coords - M[1] = -clam * sphi; M[4] = -slam * sphi; M[7] = cphi; + M[1] = -clam * sphi + M[4] = -slam * sphi + M[7] = cphi # Local Z axis (up) in geocentric coords - M[2] = clam * cphi; M[5] = slam * cphi; M[8] = sphi; + M[2] = clam * cphi + M[5] = slam * cphi + M[8] = sphi return M @@ -491,13 +513,17 @@ def sincosd(x): s = x if np.uint8(q) & np.uint8(3) == np.uint(0): - sinx = s; cosx = c + sinx = s + cosx = c elif np.uint8(q) & np.uint8(3) == np.uint(1): - sinx = c; cosx = -s + sinx = c + cosx = -s elif np.uint8(q) & np.uint8(3) == np.uint(2): - sinx = -s; cosx = -c + sinx = -s + cosx = -c else: - sinx = -c; cosx = s + sinx = -c + cosx = s # Set sign of 0 results. -0 only produced for sin(-0) if x: @@ -522,17 +548,21 @@ def rmat_enu_ecef(lat, lon, in_degrees=True): """ if in_degrees: - lat = lat/180*np.pi - lon = lon/180*np.pi + lat = lat / 180 * np.pi + lon = lon / 180 * np.pi clat = cos(lat) slat = sin(lat) clon = cos(lon) slon = sin(lon) - return np.array([[-slon, -slat*clon, clat*clon], - [clon, -slat*slon, clat*slon], - [0, clat, slat]]) + return np.array( + [ + [-slon, -slat * clon, clat * clon], + [clon, -slat * slon, clat * slon], + [0, clat, slat], + ] + ) def quat_enu_ecef(lat, lon, in_degrees=True): @@ -570,20 +600,20 @@ def quat_ecef_enu(lat, lon, in_degrees=True): """ if in_degrees: - lat = lat/180*np.pi - lon = lon/180*np.pi + lat = lat / 180 * np.pi + lon = lon / 180 * np.pi # This operator needs to rotate the axes of the ECEF coordinate system into # the ENU coordinate system. # First rotate around 90 degrees around ECEF Z. - q1 = np.array([0, 0, 1/sqrt(2), 1/sqrt(2)]) + q1 = np.array([0, 0, 1 / sqrt(2), 1 / sqrt(2)]) # Rotate around ECEF Y by latitude. - q2 = np.array([0, sin((np.pi/2 - lat)/2), 0, cos((np.pi/2 - lat)/2)]) + q2 = np.array([0, sin((np.pi / 2 - lat) / 2), 0, cos((np.pi / 2 - lat) / 2)]) # Rotate around ECEF Z by longitude. - q3 = np.array([0, 0, sin(lon/2), cos(lon/2)]) + q3 = np.array([0, 0, sin(lon / 2), cos(lon / 2)]) q = quaternion_multiply(q3, quaternion_multiply(q2, q1)) @@ -605,14 +635,18 @@ def rmat_ecef_enu(lat, lon, in_degrees=True): """ if in_degrees: - lat = lat/180*np.pi - lon = lon/180*np.pi + lat = lat / 180 * np.pi + lon = lon / 180 * np.pi clat = cos(lat) slat = sin(lat) clon = cos(lon) slon = sin(lon) - return np.array([[-slon, clon, 0], - [-clon*slat, -slon*slat, clat], - [clon*clat, slon*clat, slat]]) + return np.array( + [ + [-slon, clon, 0], + [-clon * slat, -slon * slat, clat], + [clon * clat, slon * clat, slat], + ] + ) From cffab6c6c3d5e7fba591410954d4b96f2971e9fd Mon Sep 17 00:00:00 2001 From: Adam Romlein Date: Wed, 23 Sep 2026 08:52:00 -0400 Subject: [PATCH 32/32] Report error in source pixels as well as target pixels --- kamera/calibration/report.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/kamera/calibration/report.py b/kamera/calibration/report.py index 53642618..3b741ead 100644 --- a/kamera/calibration/report.py +++ b/kamera/calibration/report.py @@ -414,19 +414,32 @@ def rig_page(pdf: PdfPages, cal: RigCalibration) -> None: plt.close(fig) -def homography_page(pdf: PdfPages, pair: dict) -> None: - s = pair["stats"] +def homography_page(pdf: PdfPages, cal: RigCalibration, pair: dict) -> None: + s, left, right = pair["stats"], pair["left"], pair["right"] + # The fit residual is in right-camera pixels; restate it in the left camera's own + # pixels and on the ground, since one IR pixel is many RGB pixels. + scale = cal.cameras[right].K[0, 0] / cal.cameras[left].K[0, 0] + gsd_cm = 100 * s["rangeM"] / cal.cameras[right].K[0, 0] fig = plt.figure(figsize=PAGE) fig.text( 0.03, - 0.955, - f"{pair['left']} -> {pair['right']} at {s['rangeM']:.0f} m: " + 0.965, + f"{left} -> {right} at {s['rangeM']:.0f} m: " f"fit rms {s['rmsPx']:.2f} px, p95 {s['p95Px']:.2f} px, " - f"max {s['maxPx']:.2f} px, coverage {100 * s['coverage']:.0f}%", + f"max {s['maxPx']:.2f} px in {right} pixels, coverage {100 * s['coverage']:.0f}%", fontsize=11, weight="bold", ) - ax = fig.add_axes((0.03, 0.07, 0.94, 0.86)) + fig.text( + 0.03, + 0.94, + f"in {left} pixels: rms {s['rmsPx'] / scale:.2f}, " + f"p95 {s['p95Px'] / scale:.2f}, max {s['maxPx'] / scale:.2f} " + f"(one {left} pixel = {scale:.1f} {right} pixels); " + f"rms {s['rmsPx'] * gsd_cm:.0f} cm on the ground", + fontsize=9, + ) + ax = fig.add_axes((0.03, 0.06, 0.94, 0.835)) # No GIF frame had both images (or --gif_frames 0): keep the page for its fit. if "overlay_img" in pair: ax.imshow(pair["overlay_img"]) @@ -454,4 +467,4 @@ def write_report( camera_page(pdf, cal) rig_page(pdf, cal) for pair in pairs: - homography_page(pdf, pair) + homography_page(pdf, cal, pair)