From f1c3782c0f644a7c8f6b033877f9129a00617bf6 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 16 Jan 2026 13:40:40 -0500 Subject: [PATCH 01/42] initial testing and integration --- BATBOT_Integration_Notes.md | 31 ++ pyproject.toml | 4 + scripts/.gitignore | 1 + scripts/batbot_spectrogram.py | 203 ++++++++++++ uv.lock | 580 +++++++++++++++++++++++++++++++++- 5 files changed, 818 insertions(+), 1 deletion(-) create mode 100644 BATBOT_Integration_Notes.md create mode 100644 scripts/batbot_spectrogram.py diff --git a/BATBOT_Integration_Notes.md b/BATBOT_Integration_Notes.md new file mode 100644 index 00000000..a9e88dda --- /dev/null +++ b/BATBOT_Integration_Notes.md @@ -0,0 +1,31 @@ +BatBot has some git lfs issues with installing initially +requires the `UV_GIT_LFS=1` to be set +As well as `GIT_LFS_SKIP_SMUDGE=1` + + +Batbot data exported: +Files of the structure: '01of01.compressed.jpg' +Also for uncompressed is '01of02.jpg' and '02of02.jpg' + + +There us a metadata.json file that is exported out from the code + +Document the structure of this metadata json data +It needs to be converted into the widths, starts, stops, and length +`size.uncompressed.width.px` +`size.compressed.width.px` +`segments` is an array that can be formatted by extracting +`segments[0]["start.ms"]` +`segments[0]["end.ms"]` + +From there we have the total width and the total time for the invidiual segments we can calulate a pixels/ms + +Might need to see if we can change it so that the sytstem instead uses a way where we use the raw time and don't use the invividual widths at all for calculations. This may require some front end work as well. + + +Taks: + +- Add batbot dependency to UV installation +- Swap spectrogram creation to use batbot pipeline without a config +- Create a converter to calculate the length, starts, stops, widths +- Determine if widths are needed or just a universal \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6f24774a..601e29da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ # Production-only "gunicorn", "geopandas>=1.1.1", + "batbot", ] [project.optional-dependencies] @@ -111,6 +112,9 @@ constraint-dependencies = [ "numba >= 0.61.0", ] +[tool.uv.sources] +batbot = { git = "https://github.com/kitware/batbot" } + [tool.black] line-length = 100 skip-string-normalization = true diff --git a/scripts/.gitignore b/scripts/.gitignore index 0fc0351c..fd97de8c 100644 --- a/scripts/.gitignore +++ b/scripts/.gitignore @@ -1 +1,2 @@ /**/*.json +*.jpg \ No newline at end of file diff --git a/scripts/batbot_spectrogram.py b/scripts/batbot_spectrogram.py new file mode 100644 index 00000000..d203c794 --- /dev/null +++ b/scripts/batbot_spectrogram.py @@ -0,0 +1,203 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "batbot", +# "click", +# ] +# +# [tool.uv.sources] +# batbot = { git = "https://github.com/Kitware/batbot" } +# /// +import json +from os.path import exists + +import click + +import batbot +from batbot import log + + +def pipeline_filepath_validator(ctx, param, value): + if not exists(value): + log.error(f'Input filepath does not exist: {value}') + ctx.exit() + return value + + +@click.command('fetch') +@click.option( + '--config', + help='Which ML model to use for inference', + default=None, + type=click.Choice(['usgs']), +) +def fetch(config): + """ + Fetch the required machine learning ONNX model for the classifier + """ + batbot.fetch(config=config) + + +@click.command('pipeline') +@click.argument( + 'filepath', + nargs=1, + type=str, + callback=pipeline_filepath_validator, +) +@click.option( + '--config', + help='Which ML model to use for inference', + default=None, + type=click.Choice(['usgs']), +) +@click.option( + '--output', + help='Path to output JSON (if unspecified, results are printed to screen)', + default=None, + type=str, +) +# @click.option( +# '--classifier_thresh', +# help='Classifier confidence threshold', +# default=int(classifier.CONFIGS[None]['thresh'] * 100), +# type=click.IntRange(0, 100, clamp=True), +# ) +def pipeline( + filepath, + config, + output, + # classifier_thresh, +): + """ + Run the BatBot pipeline on an input WAV filepath. An example output of the JSON + can be seen below. + + .. code-block:: javascript + + { + '/path/to/file.wav': { + 'classifier': 0.5, + } + } + """ + if config is not None: + config = config.strip().lower() + # classifier_thresh /= 100.0 + + score = batbot.pipeline( + filepath, + config=config, + # classifier_thresh=classifier_thresh, + ) + + data = { + filepath: { + 'classifier': score, + } + } + + log.debug('Outputting results...') + if output: + with open(output, 'w') as outfile: + json.dump(data, outfile) + else: + print(data) + + +@click.command('batch') +@click.argument( + 'filepaths', + nargs=-1, + type=str, +) +@click.option( + '--config', + help='Which ML model to use for inference', + default=None, + type=click.Choice(['usgs']), +) +@click.option( + '--output', + help='Path to output JSON (if unspecified, results are printed to screen)', + default=None, + type=str, +) +# @click.option( +# '--classifier_thresh', +# help='Classifier confidence threshold', +# default=int(classifier.CONFIGS[None]['thresh'] * 100), +# type=click.IntRange(0, 100, clamp=True), +# ) +def batch( + filepaths, + config, + output, + # classifier_thresh, +): + """ + Run the BatBot pipeline in batch on a list of input WAV filepaths. + An example output of the JSON can be seen below. + + .. code-block:: javascript + + { + '/path/to/file1.wav': { + 'classifier': 0.5, + }, + '/path/to/file2.wav': { + 'classifier': 0.8, + }, + ... + } + """ + if config is not None: + config = config.strip().lower() + # classifier_thresh /= 100.0 + + log.debug(f'Running batch on {len(filepaths)} files...') + + score_list = batbot.batch( + filepaths, + config=config, + # classifier_thresh=classifier_thresh, + ) + + data = {} + for filepath, score in zip(filepaths, score_list): + data[filepath] = { + 'classifier': score, + } + + log.debug('Outputting results...') + if output: + with open(output, 'w') as outfile: + json.dump(data, outfile) + else: + print(data) + + +@click.command('example') +def example(): + """ + Run a test of the pipeline on an example WAV with the default configuration. + """ + batbot.example() + + +@click.group() +def cli(): + """ + BatBot CLI + """ + pass + + +cli.add_command(fetch) +cli.add_command(pipeline) +cli.add_command(batch) +cli.add_command(example) + + +if __name__ == '__main__': + cli() diff --git a/uv.lock b/uv.lock index 896e53b8..eea5f44a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13' and sys_platform == 'darwin'", @@ -19,6 +19,15 @@ resolution-markers = [ [manifest] constraints = [{ name = "numba", specifier = ">=0.61.0" }] +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + [[package]] name = "amqp" version = "5.3.1" @@ -152,11 +161,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/8d/30aa32745af16af0a9a650115fbe81bde7c610ed5c21b381fca0196f3a7f/audioread-3.0.1-py3-none-any.whl", hash = "sha256:4cdce70b8adc0da0a3c9e0d85fb10b3ace30fbdf8d1670fd443929b61d117c33", size = 23492, upload-time = "2023-09-27T19:27:51.334Z" }, ] +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "batbot" +version = "0.1.0" +source = { git = "https://github.com/kitware/batbot#5703a1d1dea3059dffe085d28c1792d43d7c37f3" } +dependencies = [ + { name = "click" }, + { name = "cryptography" }, + { name = "cython" }, + { name = "librosa" }, + { name = "line-profiler" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "pyastar2d" }, + { name = "rich" }, + { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "shapely" }, + { name = "sphinx-click" }, + { name = "tqdm" }, +] + [[package]] name = "bats-ai" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "batbot" }, { name = "celery" }, { name = "django", extra = ["argon2"] }, { name = "django-allauth" }, @@ -238,6 +281,7 @@ type = [ [package.metadata] requires-dist = [ + { name = "batbot", git = "https://github.com/kitware/batbot" }, { name = "celery" }, { name = "django", extras = ["argon2"], specifier = ">=4.2,<5" }, { name = "django-allauth" }, @@ -823,6 +867,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "cython" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/85/7574c9cd44b69a27210444b6650f6477f56c75fee1b70d7672d3e4166167/cython-3.2.4.tar.gz", hash = "sha256:84226ecd313b233da27dc2eb3601b4f222b8209c3a7216d8733b031da1dc64e6", size = 3280291, upload-time = "2026-01-04T14:14:14.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/10/720e0fb84eab4c927c4dd6b61eb7993f7732dd83d29ba6d73083874eade9/cython-3.2.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02cb0cc0f23b9874ad262d7d2b9560aed9c7e2df07b49b920bda6f2cc9cb505e", size = 2960836, upload-time = "2026-01-04T14:14:51.103Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3d/b26f29092c71c36e0462752885bdfb18c23c176af4de953fdae2772a8941/cython-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f136f379a4a54246facd0eb6f1ee15c3837cb314ce87b677582ec014db4c6845", size = 3370134, upload-time = "2026-01-04T14:14:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/539fb0d09e4f5251b5b14f8daf77e71fee021527f1013791038234618b6b/cython-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35ab0632186057406ec729374c737c37051d2eacad9d515d94e5a3b3e58a9b02", size = 3537552, upload-time = "2026-01-04T14:14:56.852Z" }, + { url = "https://files.pythonhosted.org/packages/10/c6/82d19a451c050d1be0f05b1a3302267463d391db548f013ee88b5348a8e9/cython-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:ca2399dc75796b785f74fb85c938254fa10c80272004d573c455f9123eceed86", size = 2766191, upload-time = "2026-01-04T14:14:58.709Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/8f06145ec3efa121c8b1b67f06a640386ddacd77ee3e574da582a21b14ee/cython-3.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff9af2134c05e3734064808db95b4dd7341a39af06e8945d05ea358e1741aaed", size = 2953769, upload-time = "2026-01-04T14:15:00.361Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/706cf830eddd831666208af1b3058c2e0758ae157590909c1f634b53bed9/cython-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67922c9de058a0bfb72d2e75222c52d09395614108c68a76d9800f150296ddb3", size = 3243841, upload-time = "2026-01-04T14:15:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/ac/25/58893afd4ef45f79e3d4db82742fa4ff874b936d67a83c92939053920ccd/cython-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b362819d155fff1482575e804e43e3a8825332d32baa15245f4642022664a3f4", size = 3378083, upload-time = "2026-01-04T14:15:04.248Z" }, + { url = "https://files.pythonhosted.org/packages/32/e4/424a004d7c0d8a4050c81846ebbd22272ececfa9a498cb340aa44fccbec2/cython-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:1a64a112a34ec719b47c01395647e54fb4cf088a511613f9a3a5196694e8e382", size = 2769990, upload-time = "2026-01-04T14:15:06.53Z" }, + { url = "https://files.pythonhosted.org/packages/91/4d/1eb0c7c196a136b1926f4d7f0492a96c6fabd604d77e6cd43b56a3a16d83/cython-3.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64d7f71be3dd6d6d4a4c575bb3a4674ea06d1e1e5e4cd1b9882a2bc40ed3c4c9", size = 2970064, upload-time = "2026-01-04T14:15:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/03/1c/46e34b08bea19a1cdd1e938a4c123e6299241074642db9d81983cef95e9f/cython-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:869487ea41d004f8b92171f42271fbfadb1ec03bede3158705d16cd570d6b891", size = 3226757, upload-time = "2026-01-04T14:15:10.812Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/3298a44d201c45bcf0d769659725ae70e9c6c42adf8032f6d89c8241098d/cython-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55b6c44cd30821f0b25220ceba6fe636ede48981d2a41b9bbfe3c7902ce44ea7", size = 3388969, upload-time = "2026-01-04T14:15:12.45Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f3/4275cd3ea0a4cf4606f9b92e7f8766478192010b95a7f516d1b7cf22cb10/cython-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:767b143704bdd08a563153448955935844e53b852e54afdc552b43902ed1e235", size = 2756457, upload-time = "2026-01-04T14:15:14.67Z" }, + { url = "https://files.pythonhosted.org/packages/18/b5/1cfca43b7d20a0fdb1eac67313d6bb6b18d18897f82dd0f17436bdd2ba7f/cython-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28e8075087a59756f2d059273184b8b639fe0f16cf17470bd91c39921bc154e0", size = 2960506, upload-time = "2026-01-04T14:15:16.733Z" }, + { url = "https://files.pythonhosted.org/packages/71/bb/8f28c39c342621047fea349a82fac712a5e2b37546d2f737bbde48d5143d/cython-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03893c88299a2c868bb741ba6513357acd104e7c42265809fd58dce1456a36fc", size = 3213148, upload-time = "2026-01-04T14:15:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d2/16fa02f129ed2b627e88d9d9ebd5ade3eeb66392ae5ba85b259d2d52b047/cython-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f81eda419b5ada7b197bbc3c5f4494090e3884521ffd75a3876c93fbf66c9ca8", size = 3375764, upload-time = "2026-01-04T14:15:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/91/3f/deb8f023a5c10c0649eb81332a58c180fad27c7533bb4aae138b5bc34d92/cython-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:83266c356c13c68ffe658b4905279c993d8a5337bb0160fa90c8a3e297ea9a2e", size = 2754238, upload-time = "2026-01-04T14:15:23.001Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d7/3bda3efce0c5c6ce79cc21285dbe6f60369c20364e112f5a506ee8a1b067/cython-3.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4b4fd5332ab093131fa6172e8362f16adef3eac3179fd24bbdc392531cb82fa", size = 2971496, upload-time = "2026-01-04T14:15:25.038Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/1021ffc80b9c4720b7ba869aea8422c82c84245ef117ebe47a556bdc00c3/cython-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3b5ac54e95f034bc7fb07313996d27cbf71abc17b229b186c1540942d2dc28e", size = 3256146, upload-time = "2026-01-04T14:15:26.741Z" }, + { url = "https://files.pythonhosted.org/packages/0c/51/ca221ec7e94b3c5dc4138dcdcbd41178df1729c1e88c5dfb25f9d30ba3da/cython-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f43be4eaa6afd58ce20d970bb1657a3627c44e1760630b82aa256ba74b4acb", size = 3383458, upload-time = "2026-01-04T14:15:28.425Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/1388fc0243240cd54994bb74f26aaaf3b2e22f89d3a2cf8da06d75d46ca2/cython-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:983f9d2bb8a896e16fa68f2b37866ded35fa980195eefe62f764ddc5f9f5ef8e", size = 2791241, upload-time = "2026-01-04T14:15:30.448Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/fd393f0923c82be4ec0db712fffb2ff0a7a131707b842c99bf24b549274d/cython-3.2.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36bf3f5eb56d5281aafabecbaa6ed288bc11db87547bba4e1e52943ae6961ccf", size = 2875622, upload-time = "2026-01-04T14:15:39.749Z" }, + { url = "https://files.pythonhosted.org/packages/73/48/48530d9b9d64ec11dbe0dd3178a5fe1e0b27977c1054ecffb82be81e9b6a/cython-3.2.4-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6d5267f22b6451eb1e2e1b88f6f78a2c9c8733a6ddefd4520d3968d26b824581", size = 3210669, upload-time = "2026-01-04T14:15:41.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/91/4865fbfef1f6bb4f21d79c46104a53d1a3fa4348286237e15eafb26e0828/cython-3.2.4-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b6e58f73a69230218d5381817850ce6d0da5bb7e87eb7d528c7027cbba40b06", size = 2856835, upload-time = "2026-01-04T14:15:43.815Z" }, + { url = "https://files.pythonhosted.org/packages/fa/39/60317957dbef179572398253f29d28f75f94ab82d6d39ea3237fb6c89268/cython-3.2.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e71efb20048358a6b8ec604a0532961c50c067b5e63e345e2e359fff72feaee8", size = 2994408, upload-time = "2026-01-04T14:15:45.422Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/7c24d9292650db4abebce98abc9b49c820d40fa7c87921c0a84c32f4efe7/cython-3.2.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:28b1e363b024c4b8dcf52ff68125e635cb9cb4b0ba997d628f25e32543a71103", size = 2891478, upload-time = "2026-01-04T14:15:47.394Z" }, + { url = "https://files.pythonhosted.org/packages/86/70/03dc3c962cde9da37a93cca8360e576f904d5f9beecfc9d70b1f820d2e5f/cython-3.2.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:31a90b4a2c47bb6d56baeb926948348ec968e932c1ae2c53239164e3e8880ccf", size = 3225663, upload-time = "2026-01-04T14:15:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/b1/97/10b50c38313c37b1300325e2e53f48ea9a2c078a85c0c9572057135e31d5/cython-3.2.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e65e4773021f8dc8532010b4fbebe782c77f9a0817e93886e518c93bd6a44e9d", size = 3115628, upload-time = "2026-01-04T14:15:51.323Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b1/d6a353c9b147848122a0db370863601fdf56de2d983b5c4a6a11e6ee3cd7/cython-3.2.4-cp39-abi3-win32.whl", hash = "sha256:2b1f12c0e4798293d2754e73cd6f35fa5bbdf072bdc14bc6fc442c059ef2d290", size = 2437463, upload-time = "2026-01-04T14:15:53.787Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d8/319a1263b9c33b71343adfd407e5daffd453daef47ebc7b642820a8b68ed/cython-3.2.4-cp39-abi3-win_arm64.whl", hash = "sha256:3b8e62049afef9da931d55de82d8f46c9a147313b69d5ff6af6e9121d545ce7a", size = 2442754, upload-time = "2026-01-04T14:15:55.382Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -1138,6 +1220,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/3e/2448e93f4f87fc9a9f35e73e3c05669e0edd0c2526834686e949bb1fd303/djangorestframework-3.16.0-py3-none-any.whl", hash = "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", size = 1067305, upload-time = "2025-03-28T14:18:39.489Z" }, ] +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -1379,6 +1495,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "imageio" +version = "2.37.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -1481,6 +1619,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jmespath" version = "1.0.1" @@ -1653,6 +1803,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl", hash = "sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1", size = 260749, upload-time = "2025-03-11T15:09:52.982Z" }, ] +[[package]] +name = "line-profiler" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/5c/bbe9042ef5cf4c6cad4bf4d6f7975193430eba9191b7278ea114a3993fbb/line_profiler-5.0.0.tar.gz", hash = "sha256:a80f0afb05ba0d275d9dddc5ff97eab637471167ff3e66dcc7d135755059398c", size = 376919, upload-time = "2025-07-23T20:15:41.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/b8/8eb2bd10873a7bb93f412db8f735ff5b708bfcedef6492f2ec0a1f4dc55a/line_profiler-5.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5cd1621ff77e1f3f423dcc2611ef6fba462e791ce01fb41c95dce6d519c48ec8", size = 631005, upload-time = "2025-07-23T20:14:25.307Z" }, + { url = "https://files.pythonhosted.org/packages/36/af/a7fd6b2a83fbc10427e1fdd178a0a80621eeb3b29256b1856d459abb7d2a/line_profiler-5.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:17a44491d16309bc39fc6197b376a120ebc52adc3f50b0b6f9baf99af3124406", size = 490943, upload-time = "2025-07-23T20:14:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/dcfc2e5386f5b3177cdad8eaa912482fe6a9218149a5cb5e85e871b55f2c/line_profiler-5.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a36a9a5ea5e37b0969a451f922b4dbb109350981187317f708694b3b5ceac3a5", size = 476487, upload-time = "2025-07-23T20:14:30.818Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cdcfc640d5b3338891cd336b465c6112d9d5c2f56ced4f9ea3e795b192c6/line_profiler-5.0.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b67e6e292efaf85d9678fe29295b46efd72c0d363b38e6b424df39b6553c49b3", size = 1412553, upload-time = "2025-07-23T20:14:32.07Z" }, + { url = "https://files.pythonhosted.org/packages/ca/23/bcdf3adf487917cfe431cb009b184c1a81a5099753747fe1a4aee42493f0/line_profiler-5.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9c92c28ee16bf3ba99966854407e4bc927473a925c1629489c8ebc01f8a640", size = 1461495, upload-time = "2025-07-23T20:14:33.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/04/1360ff19c4c426352ed820bba315670a6d52b3194fcb80af550a50e09310/line_profiler-5.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:51609cc264df6315cd9b9fa76d822a7b73a4f278dcab90ba907e32dc939ab1c2", size = 2510352, upload-time = "2025-07-23T20:14:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/138966a6edfd95208e92e9cfef79595d6890df31b1749cc0244d36127473/line_profiler-5.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67f9721281655dc2b6763728a63928e3b8a35dfd6160c628a3c599afd0814a71", size = 2445103, upload-time = "2025-07-23T20:14:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/92/95/5c9e4771f819b4d81510fa90b20a608bd3f91c268acd72747cd09f905de9/line_profiler-5.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c2c27ac0c30d35ca1de5aeebe97e1d9c0d582e3d2c4146c572a648bec8efcfac", size = 461940, upload-time = "2025-07-23T20:14:37.422Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f7/4e0fd2610749136d60f3e168812b5f6c697ffcfbb167b10d4aac24af1223/line_profiler-5.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f32d536c056393b7ca703e459632edc327ff9e0fc320c7b0e0ed14b84d342b7f", size = 634587, upload-time = "2025-07-23T20:14:39.069Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fe/b5458452c2dbf7a9590b5ad3cf4250710a2554a5a045bfa6395cdea1b2d5/line_profiler-5.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a7da04ffc5a0a1f6653f43b13ad2e7ebf66f1d757174b7e660dfa0cbe74c4fc6", size = 492744, upload-time = "2025-07-23T20:14:40.632Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e1/b69e20aeea8a11340f8c5d540c88ecf955a3559d8fbd5034cfe5677c69cf/line_profiler-5.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2746f6b13c19ca4847efd500402d53a5ebb2fe31644ce8af74fbeac5ea4c54c", size = 478101, upload-time = "2025-07-23T20:14:42.306Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3b/b29e5539b2c98d2bd9f5651f10597dd70e07d5b09bb47cc0aa8d48927d72/line_profiler-5.0.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b4290319a59730c04cbd03755472d10524130065a20a695dc10dd66ffd92172", size = 1455927, upload-time = "2025-07-23T20:14:44.139Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/dcc75d2cf82bbe6ef65d0f39cc32410e099e7e1cd7f85b121a8d440ce8bc/line_profiler-5.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cd168a8af0032e8e3cb2fbb9ffc7694cdcecd47ec356ae863134df07becb3a2", size = 1508770, upload-time = "2025-07-23T20:14:45.868Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9f/cbf9d011381c878f848f824190ad833fbfeb5426eb6c42811b5b759d5d54/line_profiler-5.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cbe7b095865d00dda0f53d7d4556c2b1b5d13f723173a85edb206a78779ee07a", size = 2551269, upload-time = "2025-07-23T20:14:47.279Z" }, + { url = "https://files.pythonhosted.org/packages/7c/86/06999bff316e2522fc1d11fcd3720be81a7c47e94c785a9d93c290ae0415/line_profiler-5.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff176045ea8a9e33900856db31b0b979357c337862ae4837140c98bd3161c3c7", size = 2491091, upload-time = "2025-07-23T20:14:48.637Z" }, + { url = "https://files.pythonhosted.org/packages/61/d1/758f2f569b5d4fdc667b88e88e7424081ba3a1d17fb531042ed7f0f08d7f/line_profiler-5.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:474e0962d02123f1190a804073b308a67ef5f9c3b8379184483d5016844a00df", size = 462954, upload-time = "2025-07-23T20:14:50.094Z" }, + { url = "https://files.pythonhosted.org/packages/73/d8/383c37c36f888c4ca82a28ffea27c589988463fc3f0edd6abae221c35275/line_profiler-5.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:729b18c0ac66b3368ade61203459219c202609f76b34190cbb2508b8e13998c8", size = 628109, upload-time = "2025-07-23T20:14:51.71Z" }, + { url = "https://files.pythonhosted.org/packages/54/a3/75a27b1f3e14ae63a2e99f3c7014dbc1e3a37f56c91b63a2fc171e72990d/line_profiler-5.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:438ed24278c428119473b61a473c8fe468ace7c97c94b005cb001137bc624547", size = 489142, upload-time = "2025-07-23T20:14:52.993Z" }, + { url = "https://files.pythonhosted.org/packages/8b/85/f65cdbfe8537da6fab97c42958109858df846563546b9c234a902a98c313/line_profiler-5.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:920b0076dca726caadbf29f0bfcce0cbcb4d9ff034cd9445a7308f9d556b4b3a", size = 475838, upload-time = "2025-07-23T20:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/cfa53c8ede0ef539cfe767a390d7ccfc015f89c39cc2a8c34e77753fd023/line_profiler-5.0.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53326eaad2d807487dcd45d2e385feaaed81aaf72b9ecd4f53c1a225d658006f", size = 1402290, upload-time = "2025-07-23T20:14:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/e4/2c/3467cd5051afbc0eb277ee426e8dffdbd1fcdd82f1bc95a0cd8945b6c106/line_profiler-5.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e3995a989cdea022f0ede5db19a6ab527f818c59ffcebf4e5f7a8be4eb8e880", size = 1457827, upload-time = "2025-07-23T20:14:58.158Z" }, + { url = "https://files.pythonhosted.org/packages/d9/87/d5039608979b37ce3dadfa3eed7bf8bfec53b645acd30ca12c8088cf738d/line_profiler-5.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8bf57892a1d3a42273652506746ba9f620c505773ada804367c42e5b4146d6b6", size = 2497423, upload-time = "2025-07-23T20:15:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/59/3e/e5e09699e2841b4f41c16d01ff2adfd20fde6cb73cfa512262f0421e15e0/line_profiler-5.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43672085f149f5fbf3f08bba072ad7014dd485282e8665827b26941ea97d2d76", size = 2439733, upload-time = "2025-07-23T20:15:02.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/18d8fefabd8a56fb963f944149cadb69be67a479ce6723275cae2c943af5/line_profiler-5.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:446bd4f04e4bd9e979d68fdd916103df89a9d419e25bfb92b31af13c33808ee0", size = 460852, upload-time = "2025-07-23T20:15:03.827Z" }, + { url = "https://files.pythonhosted.org/packages/7d/eb/bc4420cf68661406c98d590656d72eed6f7d76e45accf568802dc83615ef/line_profiler-5.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9873fabbae1587778a551176758a70a5f6c89d8d070a1aca7a689677d41a1348", size = 624828, upload-time = "2025-07-23T20:15:05.315Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6e/6e0a4c1009975d27810027427d601acbad75b45947040d0fd80cec5b3e94/line_profiler-5.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2cd6cdb5a4d3b4ced607104dbed73ec820a69018decd1a90904854380536ed32", size = 487651, upload-time = "2025-07-23T20:15:06.961Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2c/e60e61f24faa0e6eca375bdac9c4b4b37c3267488d7cb1a8c5bd74cf5cdc/line_profiler-5.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:34d6172a3bd14167b3ea2e629d71b08683b17b3bc6eb6a4936d74e3669f875b6", size = 474071, upload-time = "2025-07-23T20:15:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d5/6f178e74746f84cc17381f607d191c54772207770d585fda773b868bfe28/line_profiler-5.0.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5edd859be322aa8252253e940ac1c60cca4c385760d90a402072f8f35e4b967", size = 1405434, upload-time = "2025-07-23T20:15:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/9b/32/ce67bbf81e5c78cc8d606afe6a192fbef30395021b2aaffe15681e186e3f/line_profiler-5.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d4f97b223105eed6e525994f5653061bd981e04838ee5d14e01d17c26185094", size = 1467553, upload-time = "2025-07-23T20:15:11.195Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c1/431ffb89a351aaa63f8358442e0b9456a3bb745cebdf9c0d7aa4d47affca/line_profiler-5.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4758007e491bee3be40ebcca460596e0e28e7f39b735264694a9cafec729dfa9", size = 2442489, upload-time = "2025-07-23T20:15:12.602Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9d/e34cc99c8abca3a27911d3542a87361e9c292fa1258d182e4a0a5c442850/line_profiler-5.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:213b19c4b65942db5d477e603c18c76126e3811a39d8bab251d930d8ce82ffba", size = 461377, upload-time = "2025-07-23T20:15:13.871Z" }, +] + [[package]] name = "llvmlite" version = "0.44.0" @@ -1966,6 +2158,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + [[package]] name = "numba" version = "0.61.2" @@ -2503,6 +2729,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pyastar2d" +version = "1.1.0" +source = { git = "https://github.com/bluemellophone/batbot-pyastar2d?rev=master#192535b43a50fc4e0f80d4d731fa799f3559354b" } +dependencies = [ + { name = "imageio" }, + { name = "numpy" }, +] + [[package]] name = "pycodestyle" version = "2.14.0" @@ -2996,6 +3231,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + [[package]] name = "s3transfer" version = "0.13.0" @@ -3008,6 +3252,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/17/22bf8155aa0ea2305eefa3a6402e040df7ebe512d1310165eda1e233c3f8/s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be", size = 85152, upload-time = "2025-05-22T19:24:48.703Z" }, ] +[[package]] +name = "scikit-image" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +dependencies = [ + { name = "imageio", marker = "python_full_version < '3.11'" }, + { name = "lazy-loader", marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/cb/016c63f16065c2d333c8ed0337e18a5cdf9bc32d402e4f26b0db362eb0e2/scikit_image-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3278f586793176599df6a4cf48cb6beadae35c31e58dc01a98023af3dc31c78", size = 13988922, upload-time = "2025-02-18T18:04:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/30/ca/ff4731289cbed63c94a0c9a5b672976603118de78ed21910d9060c82e859/scikit_image-0.25.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5c311069899ce757d7dbf1d03e32acb38bb06153236ae77fcd820fd62044c063", size = 13192698, upload-time = "2025-02-18T18:04:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/39/6d/a2aadb1be6d8e149199bb9b540ccde9e9622826e1ab42fe01de4c35ab918/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be455aa7039a6afa54e84f9e38293733a2622b8c2fb3362b822d459cc5605e99", size = 14153634, upload-time = "2025-02-18T18:04:18.496Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/916e7d9ee4721031b2f625db54b11d8379bd51707afaa3e5a29aecf10bc4/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c464b90e978d137330be433df4e76d92ad3c5f46a22f159520ce0fdbea8a09", size = 14767545, upload-time = "2025-02-18T18:04:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ee/c53a009e3997dda9d285402f19226fbd17b5b3cb215da391c4ed084a1424/scikit_image-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:60516257c5a2d2f74387c502aa2f15a0ef3498fbeaa749f730ab18f0a40fd054", size = 12812908, upload-time = "2025-02-18T18:04:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/c4/97/3051c68b782ee3f1fb7f8f5bb7d535cf8cb92e8aae18fa9c1cdf7e15150d/scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17", size = 14003057, upload-time = "2025-02-18T18:04:30.395Z" }, + { url = "https://files.pythonhosted.org/packages/19/23/257fc696c562639826065514d551b7b9b969520bd902c3a8e2fcff5b9e17/scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0", size = 13180335, upload-time = "2025-02-18T18:04:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/ef/14/0c4a02cb27ca8b1e836886b9ec7c9149de03053650e9e2ed0625f248dd92/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173", size = 14144783, upload-time = "2025-02-18T18:04:36.594Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9b/9fb556463a34d9842491d72a421942c8baff4281025859c84fcdb5e7e602/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641", size = 14785376, upload-time = "2025-02-18T18:04:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/b57c500ee85885df5f2188f8bb70398481393a69de44a00d6f1d055f103c/scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b", size = 12791698, upload-time = "2025-02-18T18:04:42.868Z" }, + { url = "https://files.pythonhosted.org/packages/35/8c/5df82881284459f6eec796a5ac2a0a304bb3384eec2e73f35cfdfcfbf20c/scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb", size = 13986000, upload-time = "2025-02-18T18:04:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e6/93bebe1abcdce9513ffec01d8af02528b4c41fb3c1e46336d70b9ed4ef0d/scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed", size = 13235893, upload-time = "2025-02-18T18:04:51.049Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/eda616e33f67129e5979a9eb33c710013caa3aa8a921991e6cc0b22cea33/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d", size = 14178389, upload-time = "2025-02-18T18:04:54.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824", size = 15003435, upload-time = "2025-02-18T18:04:57.586Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/49beb08ebccda3c21e871b607c1cb2f258c3fa0d2f609fed0a5ba741b92d/scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2", size = 12899474, upload-time = "2025-02-18T18:05:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841, upload-time = "2025-02-18T18:05:03.963Z" }, + { url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862, upload-time = "2025-02-18T18:05:06.986Z" }, + { url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785, upload-time = "2025-02-18T18:05:10.69Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119, upload-time = "2025-02-18T18:05:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116, upload-time = "2025-02-18T18:05:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801, upload-time = "2025-02-18T18:05:20.783Z" }, +] + +[[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +dependencies = [ + { name = "imageio", marker = "python_full_version >= '3.11'" }, + { name = "lazy-loader", marker = "python_full_version >= '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tifffile", version = "2026.1.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/16/8a407688b607f86f81f8c649bf0d68a2a6d67375f18c2d660aba20f5b648/scikit_image-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1ede33a0fb3731457eaf53af6361e73dd510f449dac437ab54573b26788baf0", size = 12355510, upload-time = "2025-12-20T17:10:31.628Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f9/7efc088ececb6f6868fd4475e16cfafc11f242ce9ab5fc3557d78b5da0d4/scikit_image-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7af7aa331c6846bd03fa28b164c18d0c3fd419dbb888fb05e958ac4257a78fdd", size = 12056334, upload-time = "2025-12-20T17:10:34.559Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/bc7fb91fb5ff65ef42346c8b7ee8b09b04eabf89235ab7dbfdfd96cbd1ea/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea6207d9e9d21c3f464efe733121c0504e494dbdc7728649ff3e23c3c5a4953", size = 13297768, upload-time = "2025-12-20T17:10:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2a/e71c1a7d90e70da67b88ccc609bd6ae54798d5847369b15d3a8052232f9d/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74aa5518ccea28121f57a95374581d3b979839adc25bb03f289b1bc9b99c58af", size = 13711217, upload-time = "2025-12-20T17:10:40.935Z" }, + { url = "https://files.pythonhosted.org/packages/d4/59/9637ee12c23726266b91296791465218973ce1ad3e4c56fc81e4d8e7d6e1/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c244656de905e195a904e36dbc18585e06ecf67d90f0482cbde63d7f9ad59d", size = 14337782, upload-time = "2025-12-20T17:10:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5c/a3e1e0860f9294663f540c117e4bf83d55e5b47c281d475cc06227e88411/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21a818ee6ca2f2131b9e04d8eb7637b5c18773ebe7b399ad23dcc5afaa226d2d", size = 14805997, upload-time = "2025-12-20T17:10:45.93Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c6/2eeacf173da041a9e388975f54e5c49df750757fcfc3ee293cdbbae1ea0a/scikit_image-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:9490360c8d3f9a7e85c8de87daf7c0c66507960cf4947bb9610d1751928721c7", size = 11878486, upload-time = "2025-12-20T17:10:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a4/a852c4949b9058d585e762a66bf7e9a2cd3be4795cd940413dfbfbb0ce79/scikit_image-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:0baa0108d2d027f34d748e84e592b78acc23e965a5de0e4bb03cf371de5c0581", size = 11346518, upload-time = "2025-12-20T17:10:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, + { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, + { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, +] + [[package]] name = "scikit-learn" version = "1.7.0" @@ -3289,6 +3654,179 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/10/440f1ba3d4955e0dc740bbe4ce8968c254a3d644d013eb75eea729becdb8/soxr-0.5.0.post1-cp312-abi3-win_amd64.whl", hash = "sha256:b1be9fee90afb38546bdbd7bde714d1d9a8c5a45137f97478a83b65e7f3146f6", size = 164937, upload-time = "2024-08-31T03:43:23.671Z" }, ] +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-click" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ed/a9767cd1b8b7fbdf260a89d5c8c86e20e3536b9878579e5ab7965a291e55/sphinx_click-6.2.0.tar.gz", hash = "sha256:fc78b4154a4e5159462e36de55b8643747da6cda86b3b52a8bb62289e603776c", size = 27035, upload-time = "2025-12-04T19:33:05.437Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/bd/cb244695f67f77b0a36200ce1670fc42a6fe2770847e870daab99cc2b177/sphinx_click-6.2.0-py3-none-any.whl", hash = "sha256:1fb1851cb4f2c286d43cbcd57f55db6ef5a8d208bfc3370f19adde232e5803d7", size = 8939, upload-time = "2025-12-04T19:33:04.037Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + [[package]] name = "sqlparse" version = "0.5.3" @@ -3367,6 +3905,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tifffile" +version = "2025.5.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/06/bd0a6097da704a7a7c34a94cfd771c3ea3c2f405dd214e790d22c93f6be1/tifffile-2025.5.10-py3-none-any.whl", hash = "sha256:e37147123c0542d67bc37ba5cdd67e12ea6fbe6e86c52bee037a9eb6a064e5ad", size = 226533, upload-time = "2025-05-10T19:22:27.279Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.1.14" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/19/a41ab0dc1b314da952d99957289944c3b8b76021399c72693e4c1fddc6c3/tifffile-2026.1.14.tar.gz", hash = "sha256:a423c583e1eecd9ca255642d47f463efa8d7f2365a0e110eb0167570493e0c8c", size = 373639, upload-time = "2026-01-14T22:40:43.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/4d/3fd60d3a37b544cb59463add86e4dfbb485880225115341281906a7b140e/tifffile-2026.1.14-py3-none-any.whl", hash = "sha256:29cf4adb43562a4624fc959018ab1b44e0342015d3db4581b983fe40e05f5924", size = 232213, upload-time = "2026-01-14T22:40:41.553Z" }, +] + [[package]] name = "tomli" version = "2.2.1" From db3834a023ac28dc0b06670d5b29b1c149334eb1 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 16 Jan 2026 13:58:54 -0500 Subject: [PATCH 02/42] batbot metadata parser --- bats_ai/core/utils/batbot_metadata.py | 286 ++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 bats_ai/core/utils/batbot_metadata.py diff --git a/bats_ai/core/utils/batbot_metadata.py b/bats_ai/core/utils/batbot_metadata.py new file mode 100644 index 00000000..75fd95e0 --- /dev/null +++ b/bats_ai/core/utils/batbot_metadata.py @@ -0,0 +1,286 @@ +"""Utilities for parsing and converting BatBot metadata JSON files.""" + +from typing import Any +import json +from pathlib import Path + +from pydantic import BaseModel, Field, field_validator + + +class SpectrogramMetadata(BaseModel): + """Metadata about the spectrogram.""" + uncompressed_path: list[str] = Field(alias="uncompressed.path") + compressed_path: list[str] = Field(alias="compressed.path") + + +class UncompressedSize(BaseModel): + """Uncompressed spectrogram dimensions.""" + width_px: int = Field(alias="width.px") + height_px: int = Field(alias="height.px") + + +class CompressedSize(BaseModel): + """Compressed spectrogram dimensions.""" + width_px: int = Field(alias="width.px") + height_px: int = Field(alias="height.px") + + +class SizeMetadata(BaseModel): + """Size metadata for spectrograms.""" + uncompressed: UncompressedSize + compressed: list[CompressedSize] + + +class FrequencyMetadata(BaseModel): + """Frequency range metadata.""" + min_hz: int = Field(alias="min.hz") + max_hz: int = Field(alias="max.hz") + pixels_hz: list[int] = Field(alias="pixels.hz") + + +class SegmentCurvePoint(BaseModel): + """A single point in a segment curve.""" + frequency_hz: int + time_ms: float + + +class Segment(BaseModel): + """A detected segment in the spectrogram.""" + curve_hz_ms: list[list[float]] = Field(alias="curve.(hz,ms)") + start_ms: float = Field(alias="start.ms") + end_ms: float = Field(alias="end.ms") + duration_ms: float = Field(alias="duration.ms") + threshold_amp: int = Field(alias="threshold.amp") + peak_f_ms: float | None = Field(None, alias="peak f.ms") + fc_ms: float | None = Field(None, alias="fc.ms") + hi_fc_knee_ms: float | None = Field(None, alias="hi fc:knee.ms") + lo_fc_heel_ms: float | None = Field(None, alias="lo fc:heel.ms") + bandwidth_hz: int | None = Field(None, alias="bandwidth.hz") + hi_f_hz: int | None = Field(None, alias="hi f.hz") + lo_f_hz: int | None = Field(None, alias="lo f.hz") + peak_f_hz: int | None = Field(None, alias="peak f.hz") + fc_hz: int | None = Field(None, alias="fc.hz") + hi_fc_knee_hz: int | None = Field(None, alias="hi fc:knee.hz") + lo_fc_heel_hz: int | None = Field(None, alias="lo fc:heel.hz") + harmonic_flag: bool = Field(False, alias="harmonic.flag") + harmonic_peak_f_ms: float | None = Field(None, alias="harmonic peak f.ms") + harmonic_peak_f_hz: int | None = Field(None, alias="harmonic peak f.hz") + echo_flag: bool = Field(False, alias="echo.flag") + echo_peak_f_ms: float | None = Field(None, alias="echo peak f.ms") + echo_peak_f_hz: int | None = Field(None, alias="echo peak f.hz") + # Slope fields (optional, many variations) + slope_at_hi_fc_knee_khz_per_ms: float | None = Field(None, alias="slope@hi fc:knee.khz/ms") + slope_at_fc_khz_per_ms: float | None = Field(None, alias="slope@fc.khz/ms") + slope_at_low_fc_heel_khz_per_ms: float | None = Field(None, alias="slope@low fc:heel.khz/ms") + slope_at_peak_khz_per_ms: float | None = Field(None, alias="slope@peak.khz/ms") + slope_avg_khz_per_ms: float | None = Field(None, alias="slope[avg].khz/ms") + slope_hi_avg_khz_per_ms: float | None = Field(None, alias="slope/hi[avg].khz/ms") + slope_mid_avg_khz_per_ms: float | None = Field(None, alias="slope/mid[avg].khz/ms") + slope_lo_avg_khz_per_ms: float | None = Field(None, alias="slope/lo[avg].khz/ms") + slope_box_khz_per_ms: float | None = Field(None, alias="slope[box].khz/ms") + slope_hi_box_khz_per_ms: float | None = Field(None, alias="slope/hi[box].khz/ms") + slope_mid_box_khz_per_ms: float | None = Field(None, alias="slope/mid[box].khz/ms") + slope_lo_box_khz_per_ms: float | None = Field(None, alias="slope/lo[box].khz/ms") + + @field_validator("curve_hz_ms", mode="before") + @classmethod + def validate_curve(cls, v: Any) -> list[list[float]]: + """Ensure curve is a list of [frequency, time] pairs.""" + if isinstance(v, list): + return v + return [] + + +class BatbotMetadata(BaseModel): + """Complete BatBot metadata structure.""" + wav_path: str = Field(alias="wav.path") + spectrogram: SpectrogramMetadata + global_threshold_amp: int = Field(alias="global_threshold.amp") + sr_hz: int = Field(alias="sr.hz") + duration_ms: float = Field(alias="duration.ms") + frequencies: FrequencyMetadata + size: SizeMetadata + segments: list[Segment] + + class Config: + populate_by_name = True + + +class SpectrogramData(BaseModel): + """Data structure for creating a Spectrogram model.""" + width: int + height: int + duration: int # milliseconds + frequency_min: int # hz + frequency_max: int # hz + + +class CompressedSpectrogramData(BaseModel): + """Data structure for creating a CompressedSpectrogram model.""" + length: int + starts: list[list[int]] # 2D array: one list per compressed image + stops: list[list[int]] # 2D array: one list per compressed image + widths: list[list[int]] # 2D array: one list per compressed image + + +def parse_batbot_metadata(file_path: str | Path) -> BatbotMetadata: + """Parse a BatBot metadata JSON file. + + Args: + file_path: Path to the metadata JSON file + + Returns: + Parsed BatbotMetadata object + """ + file_path = Path(file_path) + with open(file_path, 'r') as f: + data = json.load(f) + return BatbotMetadata(**data) + + +def convert_to_spectrogram_data(metadata: BatbotMetadata) -> SpectrogramData: + """Convert BatBot metadata to Spectrogram model data. + + Args: + metadata: Parsed BatBot metadata + + Returns: + SpectrogramData with fields for Spectrogram model + """ + return SpectrogramData( + width=metadata.size.uncompressed.width_px, + height=metadata.size.uncompressed.height_px, + duration=int(round(metadata.duration_ms)), + frequency_min=metadata.frequencies.min_hz, + frequency_max=metadata.frequencies.max_hz, + ) + + +def convert_to_compressed_spectrogram_data( + metadata: BatbotMetadata +) -> CompressedSpectrogramData: + """Convert BatBot metadata to CompressedSpectrogram model data. + + This function calculates starts, stops, and widths for each compressed image + based on the segments and the relationship between uncompressed and compressed widths. + + The compressed image is a concatenation of segments from the uncompressed image. + - starts/stops: time values in milliseconds (matching the pattern in spectrogram_utils.py) + - widths: pixel widths in the compressed image (where segments are concatenated) + + Args: + metadata: Parsed BatBot metadata + + Returns: + CompressedSpectrogramData with fields for CompressedSpectrogram model + """ + uncompressed_width = metadata.size.uncompressed.width_px + duration_ms = metadata.duration_ms + + # Process each compressed image + all_starts: list[list[int]] = [] + all_stops: list[list[int]] = [] + all_widths: list[list[int]] = [] + + for compressed_size in metadata.size.compressed: + compressed_width = compressed_size.width_px + + # Convert segment times to milliseconds (starts/stops are in milliseconds) + starts_ms: list[int] = [] + stops_ms: list[int] = [] + + # If we have segments, use them to determine which parts are kept + if metadata.segments: + # Calculate which time ranges correspond to segments + segment_ranges: list[tuple[float, float]] = [] + + for segment in metadata.segments: + start_ms_val = segment.start_ms + stop_ms_val = segment.end_ms + + # Clamp to valid range + start_ms_val = max(0.0, min(start_ms_val, duration_ms)) + stop_ms_val = max(0.0, min(stop_ms_val, duration_ms)) + + if stop_ms_val > start_ms_val: + segment_ranges.append((start_ms_val, stop_ms_val)) + + # Merge overlapping segments + if segment_ranges: + # Sort by start time + sorted_segments = sorted(segment_ranges) + merged: list[tuple[float, float]] = [] + + for start, stop in sorted_segments: + if not merged: + merged.append((start, stop)) + else: + last_start, last_stop = merged[-1] + if start <= last_stop: + # Merge with previous + merged[-1] = (last_start, max(last_stop, stop)) + else: + # New segment + merged.append((start, stop)) + + # Extract starts and stops (in milliseconds, rounded to integers) + for start_ms_val, stop_ms_val in merged: + starts_ms.append(int(round(start_ms_val))) + stops_ms.append(int(round(stop_ms_val))) + else: + # No segments - the entire image is compressed + starts_ms = [0] + stops_ms = [int(round(duration_ms))] + + # Calculate widths in compressed space + # The compressed image is a concatenation of the segments + # We need to determine how wide each segment is in the compressed image + widths_px_compressed: list[int] = [] + + if starts_ms and stops_ms: + # Calculate total time covered by segments + total_time_ms = sum( + stop - start for start, stop in zip(starts_ms, stops_ms) + ) + + if total_time_ms > 0: + # Calculate compression ratio for segments + # The compressed image width represents the total width of all segments + # Each segment's width is proportional to its time duration + for start_ms_val, stop_ms_val in zip(starts_ms, stops_ms): + segment_time_ms = stop_ms_val - start_ms_val + segment_width_compressed = int(round( + compressed_width * (segment_time_ms / total_time_ms) + )) + widths_px_compressed.append(max(1, segment_width_compressed)) + else: + # Fallback: divide compressed width equally among segments + if len(starts_ms) > 0: + width_per_segment = compressed_width // len(starts_ms) + widths_px_compressed = [width_per_segment] * len(starts_ms) + else: + # No segments - entire compressed image is one segment + widths_px_compressed = [compressed_width] + + all_starts.append(starts_ms) + all_stops.append(stops_ms) + all_widths.append(widths_px_compressed) + + # If no compressed images, return empty structure + if not all_starts: + return CompressedSpectrogramData( + length=0, + starts=[], + stops=[], + widths=[], + ) + + # Calculate total length (sum of all widths in compressed space) + total_length = sum(sum(widths) for widths in all_widths) + + return CompressedSpectrogramData( + length=total_length, + starts=all_starts, + stops=all_stops, + widths=all_widths, + ) From 5c3d9a8c240dd1ac076d0aa787794fd71c36af39 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 22 Jan 2026 13:35:16 -0500 Subject: [PATCH 03/42] batbot spectrogram generation --- BATBOT_Integration_Notes.md | 32 ++- bats_ai/core/tasks/tasks.py | 16 +- bats_ai/core/utils/batbot_metadata.py | 356 ++++++++++++----------- docker-compose.override.yml | 2 + pyproject.toml | 2 +- scripts/batbot/batbot_spectrogram.py | 387 ++++++++++++++++++++++++++ scripts/batbot_spectrogram.py | 203 -------------- uv.lock | 33 ++- 8 files changed, 634 insertions(+), 397 deletions(-) create mode 100644 scripts/batbot/batbot_spectrogram.py delete mode 100644 scripts/batbot_spectrogram.py diff --git a/BATBOT_Integration_Notes.md b/BATBOT_Integration_Notes.md index a9e88dda..dd2db8a3 100644 --- a/BATBOT_Integration_Notes.md +++ b/BATBOT_Integration_Notes.md @@ -1,13 +1,13 @@ +# BatBot Integration Notes + BatBot has some git lfs issues with installing initially requires the `UV_GIT_LFS=1` to be set As well as `GIT_LFS_SKIP_SMUDGE=1` - Batbot data exported: Files of the structure: '01of01.compressed.jpg' Also for uncompressed is '01of02.jpg' and '02of02.jpg' - There us a metadata.json file that is exported out from the code Document the structure of this metadata json data @@ -20,12 +20,28 @@ It needs to be converted into the widths, starts, stops, and length From there we have the total width and the total time for the invidiual segments we can calulate a pixels/ms -Might need to see if we can change it so that the sytstem instead uses a way where we use the raw time and don't use the invividual widths at all for calculations. This may require some front end work as well. +Might need to see if we can change it so that the sytstem instead uses a way where we use the raw time and +don't use the invividual widths at all for calculations. This may require some front end work as well. + +Tasks: + +- ~~Add batbot dependency to UV installation~~ +- ~~Swap spectrogram creation to use batbot pipeline without a config~~ +- ~~Create a converter to calculate the length, starts, stops, widths~~ + +Updates: +- batbot is added a dependency +- tasks.py is updated so that the utilities the new batbot function to create spectrograms +- inference is disabled -Taks: +TODO: -- Add batbot dependency to UV installation -- Swap spectrogram creation to use batbot pipeline without a config -- Create a converter to calculate the length, starts, stops, widths -- Determine if widths are needed or just a universal \ No newline at end of file +- Remove older generation code from the system +- remove inference code from the system +- Remove the local installation of batbot once updated from: + - docker-compose.overrride.yml + - pyproject.toml + - uv.lock +- Can temporarily use my branch on the repo (issue-4-output-folder-option) +- Update sample script uv dependencies when batbot is published diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index ef0d5097..d6a3aa53 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -1,5 +1,6 @@ import logging import os +import shutil import tempfile from django.contrib.contenttypes.models import ContentType @@ -15,7 +16,8 @@ Spectrogram, SpectrogramImage, ) -from bats_ai.utils.spectrogram_utils import generate_spectrogram_assets, predict_from_compressed +from bats_ai.core.utils.batbot_metadata import generate_spectrogram_assets +from bats_ai.utils.spectrogram_utils import predict_from_compressed logging.basicConfig(level=logging.INFO) logger = logging.getLogger('NABatDataRetrieval') @@ -26,7 +28,15 @@ def recording_compute_spectrogram(recording_id: int): recording = Recording.objects.get(pk=recording_id) with tempfile.TemporaryDirectory() as tmpdir: - results = generate_spectrogram_assets(recording.audio_file, tmpdir) + # Copy the audio file from FileField to a temporary file + audio_filename = os.path.basename(recording.audio_file.name) + temp_audio_path = os.path.join(tmpdir, audio_filename) + + with recording.audio_file.open('rb') as source_file: + with open(temp_audio_path, 'wb') as dest_file: + shutil.copyfileobj(source_file, dest_file) + + results = generate_spectrogram_assets(temp_audio_path, output_folder=tmpdir) # Create or get Spectrogram spectrogram, _ = Spectrogram.objects.get_or_create( recording=recording, @@ -79,7 +89,7 @@ def recording_compute_spectrogram(recording_id: int): ) config = Configuration.objects.first() - if config and config.run_inference_on_upload: + if config and config.run_inference_on_upload and False: predict_results = predict_from_compressed(compressed_obj) label = predict_results['label'] score = predict_results['score'] diff --git a/bats_ai/core/utils/batbot_metadata.py b/bats_ai/core/utils/batbot_metadata.py index 75fd95e0..ed7a2526 100644 --- a/bats_ai/core/utils/batbot_metadata.py +++ b/bats_ai/core/utils/batbot_metadata.py @@ -1,88 +1,96 @@ -"""Utilities for parsing and converting BatBot metadata JSON files.""" - -from typing import Any +from contextlib import contextmanager import json +import os from pathlib import Path +from typing import Any, TypedDict -from pydantic import BaseModel, Field, field_validator +import batbot +from pydantic import BaseModel, ConfigDict, Field, field_validator class SpectrogramMetadata(BaseModel): """Metadata about the spectrogram.""" - uncompressed_path: list[str] = Field(alias="uncompressed.path") - compressed_path: list[str] = Field(alias="compressed.path") + + uncompressed_path: list[str] = Field(alias='uncompressed.path') + compressed_path: list[str] = Field(alias='compressed.path') class UncompressedSize(BaseModel): """Uncompressed spectrogram dimensions.""" - width_px: int = Field(alias="width.px") - height_px: int = Field(alias="height.px") + + width_px: int = Field(alias='width.px') + height_px: int = Field(alias='height.px') class CompressedSize(BaseModel): """Compressed spectrogram dimensions.""" - width_px: int = Field(alias="width.px") - height_px: int = Field(alias="height.px") + + width_px: int = Field(alias='width.px') + height_px: int = Field(alias='height.px') class SizeMetadata(BaseModel): """Size metadata for spectrograms.""" + uncompressed: UncompressedSize - compressed: list[CompressedSize] + compressed: CompressedSize class FrequencyMetadata(BaseModel): """Frequency range metadata.""" - min_hz: int = Field(alias="min.hz") - max_hz: int = Field(alias="max.hz") - pixels_hz: list[int] = Field(alias="pixels.hz") + + min_hz: int = Field(alias='min.hz') + max_hz: int = Field(alias='max.hz') + pixels_hz: list[int] = Field(alias='pixels.hz') class SegmentCurvePoint(BaseModel): """A single point in a segment curve.""" + frequency_hz: int time_ms: float class Segment(BaseModel): """A detected segment in the spectrogram.""" - curve_hz_ms: list[list[float]] = Field(alias="curve.(hz,ms)") - start_ms: float = Field(alias="start.ms") - end_ms: float = Field(alias="end.ms") - duration_ms: float = Field(alias="duration.ms") - threshold_amp: int = Field(alias="threshold.amp") - peak_f_ms: float | None = Field(None, alias="peak f.ms") - fc_ms: float | None = Field(None, alias="fc.ms") - hi_fc_knee_ms: float | None = Field(None, alias="hi fc:knee.ms") - lo_fc_heel_ms: float | None = Field(None, alias="lo fc:heel.ms") - bandwidth_hz: int | None = Field(None, alias="bandwidth.hz") - hi_f_hz: int | None = Field(None, alias="hi f.hz") - lo_f_hz: int | None = Field(None, alias="lo f.hz") - peak_f_hz: int | None = Field(None, alias="peak f.hz") - fc_hz: int | None = Field(None, alias="fc.hz") - hi_fc_knee_hz: int | None = Field(None, alias="hi fc:knee.hz") - lo_fc_heel_hz: int | None = Field(None, alias="lo fc:heel.hz") - harmonic_flag: bool = Field(False, alias="harmonic.flag") - harmonic_peak_f_ms: float | None = Field(None, alias="harmonic peak f.ms") - harmonic_peak_f_hz: int | None = Field(None, alias="harmonic peak f.hz") - echo_flag: bool = Field(False, alias="echo.flag") - echo_peak_f_ms: float | None = Field(None, alias="echo peak f.ms") - echo_peak_f_hz: int | None = Field(None, alias="echo peak f.hz") + + curve_hz_ms: list[list[float]] = Field(alias='curve.(hz,ms)') + start_ms: float = Field(alias='start.ms') + end_ms: float = Field(alias='end.ms') + duration_ms: float = Field(alias='duration.ms') + threshold_amp: int = Field(alias='threshold.amp') + peak_f_ms: float | None = Field(None, alias='peak f.ms') + fc_ms: float | None = Field(None, alias='fc.ms') + hi_fc_knee_ms: float | None = Field(None, alias='hi fc:knee.ms') + lo_fc_heel_ms: float | None = Field(None, alias='lo fc:heel.ms') + bandwidth_hz: int | None = Field(None, alias='bandwidth.hz') + hi_f_hz: int | None = Field(None, alias='hi f.hz') + lo_f_hz: int | None = Field(None, alias='lo f.hz') + peak_f_hz: int | None = Field(None, alias='peak f.hz') + fc_hz: int | None = Field(None, alias='fc.hz') + hi_fc_knee_hz: int | None = Field(None, alias='hi fc:knee.hz') + lo_fc_heel_hz: int | None = Field(None, alias='lo fc:heel.hz') + harmonic_flag: bool = Field(False, alias='harmonic.flag') + harmonic_peak_f_ms: float | None = Field(None, alias='harmonic peak f.ms') + harmonic_peak_f_hz: int | None = Field(None, alias='harmonic peak f.hz') + echo_flag: bool = Field(False, alias='echo.flag') + echo_peak_f_ms: float | None = Field(None, alias='echo peak f.ms') + echo_peak_f_hz: int | None = Field(None, alias='echo peak f.hz') # Slope fields (optional, many variations) - slope_at_hi_fc_knee_khz_per_ms: float | None = Field(None, alias="slope@hi fc:knee.khz/ms") - slope_at_fc_khz_per_ms: float | None = Field(None, alias="slope@fc.khz/ms") - slope_at_low_fc_heel_khz_per_ms: float | None = Field(None, alias="slope@low fc:heel.khz/ms") - slope_at_peak_khz_per_ms: float | None = Field(None, alias="slope@peak.khz/ms") - slope_avg_khz_per_ms: float | None = Field(None, alias="slope[avg].khz/ms") - slope_hi_avg_khz_per_ms: float | None = Field(None, alias="slope/hi[avg].khz/ms") - slope_mid_avg_khz_per_ms: float | None = Field(None, alias="slope/mid[avg].khz/ms") - slope_lo_avg_khz_per_ms: float | None = Field(None, alias="slope/lo[avg].khz/ms") - slope_box_khz_per_ms: float | None = Field(None, alias="slope[box].khz/ms") - slope_hi_box_khz_per_ms: float | None = Field(None, alias="slope/hi[box].khz/ms") - slope_mid_box_khz_per_ms: float | None = Field(None, alias="slope/mid[box].khz/ms") - slope_lo_box_khz_per_ms: float | None = Field(None, alias="slope/lo[box].khz/ms") - - @field_validator("curve_hz_ms", mode="before") + slope_at_hi_fc_knee_khz_per_ms: float | None = Field(None, alias='slope@hi fc:knee.khz/ms') + slope_at_fc_khz_per_ms: float | None = Field(None, alias='slope@fc.khz/ms') + slope_at_low_fc_heel_khz_per_ms: float | None = Field(None, alias='slope@low fc:heel.khz/ms') + slope_at_peak_khz_per_ms: float | None = Field(None, alias='slope@peak.khz/ms') + slope_avg_khz_per_ms: float | None = Field(None, alias='slope[avg].khz/ms') + slope_hi_avg_khz_per_ms: float | None = Field(None, alias='slope/hi[avg].khz/ms') + slope_mid_avg_khz_per_ms: float | None = Field(None, alias='slope/mid[avg].khz/ms') + slope_lo_avg_khz_per_ms: float | None = Field(None, alias='slope/lo[avg].khz/ms') + slope_box_khz_per_ms: float | None = Field(None, alias='slope[box].khz/ms') + slope_hi_box_khz_per_ms: float | None = Field(None, alias='slope/hi[box].khz/ms') + slope_mid_box_khz_per_ms: float | None = Field(None, alias='slope/mid[box].khz/ms') + slope_lo_box_khz_per_ms: float | None = Field(None, alias='slope/lo[box].khz/ms') + + @field_validator('curve_hz_ms', mode='before') @classmethod def validate_curve(cls, v: Any) -> list[list[float]]: """Ensure curve is a list of [frequency, time] pairs.""" @@ -93,21 +101,21 @@ def validate_curve(cls, v: Any) -> list[list[float]]: class BatbotMetadata(BaseModel): """Complete BatBot metadata structure.""" - wav_path: str = Field(alias="wav.path") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + wav_path: str = Field(alias='wav.path') spectrogram: SpectrogramMetadata - global_threshold_amp: int = Field(alias="global_threshold.amp") - sr_hz: int = Field(alias="sr.hz") - duration_ms: float = Field(alias="duration.ms") + global_threshold_amp: int = Field(alias='global_threshold.amp') + sr_hz: int = Field(alias='sr.hz') + duration_ms: float = Field(alias='duration.ms') frequencies: FrequencyMetadata size: SizeMetadata segments: list[Segment] - class Config: - populate_by_name = True - class SpectrogramData(BaseModel): """Data structure for creating a Spectrogram model.""" + width: int height: int duration: int # milliseconds @@ -117,33 +125,33 @@ class SpectrogramData(BaseModel): class CompressedSpectrogramData(BaseModel): """Data structure for creating a CompressedSpectrogram model.""" - length: int - starts: list[list[int]] # 2D array: one list per compressed image - stops: list[list[int]] # 2D array: one list per compressed image - widths: list[list[int]] # 2D array: one list per compressed image + + starts: list[float] + stops: list[float] + widths: list[int] def parse_batbot_metadata(file_path: str | Path) -> BatbotMetadata: """Parse a BatBot metadata JSON file. - + Args: file_path: Path to the metadata JSON file - + Returns: Parsed BatbotMetadata object """ file_path = Path(file_path) - with open(file_path, 'r') as f: + with open(file_path) as f: data = json.load(f) return BatbotMetadata(**data) def convert_to_spectrogram_data(metadata: BatbotMetadata) -> SpectrogramData: """Convert BatBot metadata to Spectrogram model data. - + Args: metadata: Parsed BatBot metadata - + Returns: SpectrogramData with fields for Spectrogram model """ @@ -156,131 +164,121 @@ def convert_to_spectrogram_data(metadata: BatbotMetadata) -> SpectrogramData: ) -def convert_to_compressed_spectrogram_data( - metadata: BatbotMetadata -) -> CompressedSpectrogramData: +def convert_to_compressed_spectrogram_data(metadata: BatbotMetadata) -> CompressedSpectrogramData: """Convert BatBot metadata to CompressedSpectrogram model data. - + This function calculates starts, stops, and widths for each compressed image based on the segments and the relationship between uncompressed and compressed widths. - + The compressed image is a concatenation of segments from the uncompressed image. - starts/stops: time values in milliseconds (matching the pattern in spectrogram_utils.py) - widths: pixel widths in the compressed image (where segments are concatenated) - + Args: metadata: Parsed BatBot metadata - + Returns: CompressedSpectrogramData with fields for CompressedSpectrogram model """ - uncompressed_width = metadata.size.uncompressed.width_px duration_ms = metadata.duration_ms - + # Process each compressed image - all_starts: list[list[int]] = [] - all_stops: list[list[int]] = [] - all_widths: list[list[int]] = [] - - for compressed_size in metadata.size.compressed: - compressed_width = compressed_size.width_px - - # Convert segment times to milliseconds (starts/stops are in milliseconds) - starts_ms: list[int] = [] - stops_ms: list[int] = [] - - # If we have segments, use them to determine which parts are kept - if metadata.segments: - # Calculate which time ranges correspond to segments - segment_ranges: list[tuple[float, float]] = [] - - for segment in metadata.segments: - start_ms_val = segment.start_ms - stop_ms_val = segment.end_ms - - # Clamp to valid range - start_ms_val = max(0.0, min(start_ms_val, duration_ms)) - stop_ms_val = max(0.0, min(stop_ms_val, duration_ms)) - - if stop_ms_val > start_ms_val: - segment_ranges.append((start_ms_val, stop_ms_val)) - - # Merge overlapping segments - if segment_ranges: - # Sort by start time - sorted_segments = sorted(segment_ranges) - merged: list[tuple[float, float]] = [] - - for start, stop in sorted_segments: - if not merged: - merged.append((start, stop)) - else: - last_start, last_stop = merged[-1] - if start <= last_stop: - # Merge with previous - merged[-1] = (last_start, max(last_stop, stop)) - else: - # New segment - merged.append((start, stop)) - - # Extract starts and stops (in milliseconds, rounded to integers) - for start_ms_val, stop_ms_val in merged: - starts_ms.append(int(round(start_ms_val))) - stops_ms.append(int(round(stop_ms_val))) - else: - # No segments - the entire image is compressed - starts_ms = [0] - stops_ms = [int(round(duration_ms))] - - # Calculate widths in compressed space - # The compressed image is a concatenation of the segments - # We need to determine how wide each segment is in the compressed image - widths_px_compressed: list[int] = [] - - if starts_ms and stops_ms: - # Calculate total time covered by segments - total_time_ms = sum( - stop - start for start, stop in zip(starts_ms, stops_ms) - ) - - if total_time_ms > 0: - # Calculate compression ratio for segments - # The compressed image width represents the total width of all segments - # Each segment's width is proportional to its time duration - for start_ms_val, stop_ms_val in zip(starts_ms, stops_ms): - segment_time_ms = stop_ms_val - start_ms_val - segment_width_compressed = int(round( - compressed_width * (segment_time_ms / total_time_ms) - )) - widths_px_compressed.append(max(1, segment_width_compressed)) - else: - # Fallback: divide compressed width equally among segments - if len(starts_ms) > 0: - width_per_segment = compressed_width // len(starts_ms) - widths_px_compressed = [width_per_segment] * len(starts_ms) - else: - # No segments - entire compressed image is one segment - widths_px_compressed = [compressed_width] - - all_starts.append(starts_ms) - all_stops.append(stops_ms) - all_widths.append(widths_px_compressed) - - # If no compressed images, return empty structure - if not all_starts: - return CompressedSpectrogramData( - length=0, - starts=[], - stops=[], - widths=[], - ) - - # Calculate total length (sum of all widths in compressed space) - total_length = sum(sum(widths) for widths in all_widths) - + starts_ms: list[int] = [] + stops_ms: list[int] = [] + widths_px_compressed: list[int] = [] + segment_times: list[int] = [] + compressed_width = metadata.size.compressed.width_px + total_time = 0.0 + + # If we have segments, use them to determine which parts are kept + if metadata.segments: + for segment in metadata.segments: + starts_ms.append(segment.start_ms) + stops_ms.append(segment.end_ms) + time = segment.end_ms - segment.start_ms + segment_times.append(time) + total_time += time + # Calculate width in compressed space + # The width in compressed space is proportional to the time duration + for time in segment_times: + width_px = int(round((time / total_time) * compressed_width)) + widths_px_compressed.append(width_px) + else: + # No segments - the entire image is compressed + starts_ms = [0] + stops_ms = [int(round(duration_ms))] + widths_px_compressed = [compressed_width] + return CompressedSpectrogramData( - length=total_length, - starts=all_starts, - stops=all_stops, - widths=all_widths, + starts=starts_ms, + stops=stops_ms, + widths=widths_px_compressed, ) + + +class SpectrogramAssetResult(TypedDict): + paths: list[str] + width: int + height: int + + +class SpectrogramCompressedAssetResult(TypedDict): + paths: list[str] + width: int + height: int + widths: list[float] + starts: list[float] + stops: list[float] + + +class SpectrogramAssets(TypedDict): + duration: float + freq_min: int + freq_max: int + normal: SpectrogramAssetResult + compressed: SpectrogramCompressedAssetResult + + +@contextmanager +def working_directory(path): + previous = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def generate_spectrogram_assets(recording_path: str, output_folder: str): + batbot.pipeline(recording_path, config=None, output_folder=output_folder) + # There should be a .metadata.json file in the output_base directory by replacing extentions + metadata_file = Path(recording_path).with_suffix('.metadata.json').name + metadata_file = Path(output_folder) / metadata_file + metadata = parse_batbot_metadata(metadata_file) + # from the metadata we should have the images that are used + uncompressed_paths = metadata.spectrogram.uncompressed_path + compressed_paths = metadata.spectrogram.compressed_path + + metadata.frequencies.min_hz + metadata.frequencies.max_hz + + compressed_metadata = convert_to_compressed_spectrogram_data(metadata) + result = { + 'duration': metadata.duration_ms, + 'freq_min': metadata.frequencies.min_hz, + 'freq_max': metadata.frequencies.max_hz, + 'normal': { + 'paths': uncompressed_paths, + 'width': metadata.size.uncompressed.width_px, + 'height': metadata.size.uncompressed.height_px, + }, + 'compressed': { + 'paths': compressed_paths, + 'width': metadata.size.compressed.width_px, + 'height': metadata.size.compressed.height_px, + 'widths': compressed_metadata.widths, + 'starts': compressed_metadata.starts, + 'stops': compressed_metadata.stops, + }, + } + return result diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 0b0f399f..934e7c82 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -14,6 +14,7 @@ services: working_dir: /opt/django-project volumes: - .:/opt/django-project + - ../batbot:/opt/batbot - uv_cache:/var/cache/uv ports: - 8000:8000 @@ -44,6 +45,7 @@ services: working_dir: /opt/django-project volumes: - .:/opt/django-project + - ../batbot:/opt/batbot - uv_cache:/var/cache/uv depends_on: postgres: diff --git a/pyproject.toml b/pyproject.toml index 192be2d3..7b4ff43f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,7 @@ explicit = true [tool.uv.sources] gdal = { index = "large_image_wheels" } -batbot = { git = "https://github.com/kitware/batbot" } +batbot = { path = "../batbot", editable = true } [tool.black] diff --git a/scripts/batbot/batbot_spectrogram.py b/scripts/batbot/batbot_spectrogram.py new file mode 100644 index 00000000..8ad18893 --- /dev/null +++ b/scripts/batbot/batbot_spectrogram.py @@ -0,0 +1,387 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "batbot", +# "click", +# "pydantic", +# ] +# +# [tool.uv.sources] +# batbot = { path = "../../../batbot", editable = true } +# /// +from contextlib import contextmanager +import json +import os +from os.path import exists +from pathlib import Path +from typing import Any, TypedDict + +import batbot +from batbot import log +import click +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class SpectrogramMetadata(BaseModel): + """Metadata about the spectrogram.""" + + uncompressed_path: list[str] = Field(alias='uncompressed.path') + compressed_path: list[str] = Field(alias='compressed.path') + + +class UncompressedSize(BaseModel): + """Uncompressed spectrogram dimensions.""" + + width_px: int = Field(alias='width.px') + height_px: int = Field(alias='height.px') + + +class CompressedSize(BaseModel): + """Compressed spectrogram dimensions.""" + + width_px: int = Field(alias='width.px') + height_px: int = Field(alias='height.px') + + +class SizeMetadata(BaseModel): + """Size metadata for spectrograms.""" + + uncompressed: UncompressedSize + compressed: CompressedSize + + +class FrequencyMetadata(BaseModel): + """Frequency range metadata.""" + + min_hz: int = Field(alias='min.hz') + max_hz: int = Field(alias='max.hz') + pixels_hz: list[int] = Field(alias='pixels.hz') + + +class SegmentCurvePoint(BaseModel): + """A single point in a segment curve.""" + + frequency_hz: int + time_ms: float + + +class Segment(BaseModel): + """A detected segment in the spectrogram.""" + + curve_hz_ms: list[list[float]] = Field(alias='curve.(hz,ms)') + start_ms: float = Field(alias='start.ms') + end_ms: float = Field(alias='end.ms') + duration_ms: float = Field(alias='duration.ms') + threshold_amp: int = Field(alias='threshold.amp') + peak_f_ms: float | None = Field(None, alias='peak f.ms') + fc_ms: float | None = Field(None, alias='fc.ms') + hi_fc_knee_ms: float | None = Field(None, alias='hi fc:knee.ms') + lo_fc_heel_ms: float | None = Field(None, alias='lo fc:heel.ms') + bandwidth_hz: int | None = Field(None, alias='bandwidth.hz') + hi_f_hz: int | None = Field(None, alias='hi f.hz') + lo_f_hz: int | None = Field(None, alias='lo f.hz') + peak_f_hz: int | None = Field(None, alias='peak f.hz') + fc_hz: int | None = Field(None, alias='fc.hz') + hi_fc_knee_hz: int | None = Field(None, alias='hi fc:knee.hz') + lo_fc_heel_hz: int | None = Field(None, alias='lo fc:heel.hz') + harmonic_flag: bool = Field(False, alias='harmonic.flag') + harmonic_peak_f_ms: float | None = Field(None, alias='harmonic peak f.ms') + harmonic_peak_f_hz: int | None = Field(None, alias='harmonic peak f.hz') + echo_flag: bool = Field(False, alias='echo.flag') + echo_peak_f_ms: float | None = Field(None, alias='echo peak f.ms') + echo_peak_f_hz: int | None = Field(None, alias='echo peak f.hz') + # Slope fields (optional, many variations) + slope_at_hi_fc_knee_khz_per_ms: float | None = Field(None, alias='slope@hi fc:knee.khz/ms') + slope_at_fc_khz_per_ms: float | None = Field(None, alias='slope@fc.khz/ms') + slope_at_low_fc_heel_khz_per_ms: float | None = Field(None, alias='slope@low fc:heel.khz/ms') + slope_at_peak_khz_per_ms: float | None = Field(None, alias='slope@peak.khz/ms') + slope_avg_khz_per_ms: float | None = Field(None, alias='slope[avg].khz/ms') + slope_hi_avg_khz_per_ms: float | None = Field(None, alias='slope/hi[avg].khz/ms') + slope_mid_avg_khz_per_ms: float | None = Field(None, alias='slope/mid[avg].khz/ms') + slope_lo_avg_khz_per_ms: float | None = Field(None, alias='slope/lo[avg].khz/ms') + slope_box_khz_per_ms: float | None = Field(None, alias='slope[box].khz/ms') + slope_hi_box_khz_per_ms: float | None = Field(None, alias='slope/hi[box].khz/ms') + slope_mid_box_khz_per_ms: float | None = Field(None, alias='slope/mid[box].khz/ms') + slope_lo_box_khz_per_ms: float | None = Field(None, alias='slope/lo[box].khz/ms') + + @field_validator('curve_hz_ms', mode='before') + @classmethod + def validate_curve(cls, v: Any) -> list[list[float]]: + """Ensure curve is a list of [frequency, time] pairs.""" + if isinstance(v, list): + return v + return [] + + +class BatbotMetadata(BaseModel): + """Complete BatBot metadata structure.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + wav_path: str = Field(alias='wav.path') + spectrogram: SpectrogramMetadata + global_threshold_amp: int = Field(alias='global_threshold.amp') + sr_hz: int = Field(alias='sr.hz') + duration_ms: float = Field(alias='duration.ms') + frequencies: FrequencyMetadata + size: SizeMetadata + segments: list[Segment] + + +class SpectrogramData(BaseModel): + """Data structure for creating a Spectrogram model.""" + + width: int + height: int + duration: int # milliseconds + frequency_min: int # hz + frequency_max: int # hz + + +class CompressedSpectrogramData(BaseModel): + """Data structure for creating a CompressedSpectrogram model.""" + + starts: list[float] + stops: list[float] + widths: list[int] + + +def parse_batbot_metadata(file_path: str | Path) -> BatbotMetadata: + """Parse a BatBot metadata JSON file. + + Args: + file_path: Path to the metadata JSON file + + Returns: + Parsed BatbotMetadata object + """ + file_path = Path(file_path) + with open(file_path) as f: + data = json.load(f) + return BatbotMetadata(**data) + + +def convert_to_spectrogram_data(metadata: BatbotMetadata) -> SpectrogramData: + """Convert BatBot metadata to Spectrogram model data. + + Args: + metadata: Parsed BatBot metadata + + Returns: + SpectrogramData with fields for Spectrogram model + """ + return SpectrogramData( + width=metadata.size.uncompressed.width_px, + height=metadata.size.uncompressed.height_px, + duration=int(round(metadata.duration_ms)), + frequency_min=metadata.frequencies.min_hz, + frequency_max=metadata.frequencies.max_hz, + ) + + +def convert_to_compressed_spectrogram_data(metadata: BatbotMetadata) -> CompressedSpectrogramData: + """Convert BatBot metadata to CompressedSpectrogram model data. + + This function calculates starts, stops, and widths for each compressed image + based on the segments and the relationship between uncompressed and compressed widths. + + The compressed image is a concatenation of segments from the uncompressed image. + - starts/stops: time values in milliseconds (matching the pattern in spectrogram_utils.py) + - widths: pixel widths in the compressed image (where segments are concatenated) + + Args: + metadata: Parsed BatBot metadata + + Returns: + CompressedSpectrogramData with fields for CompressedSpectrogram model + """ + duration_ms = metadata.duration_ms + + # Process each compressed image + starts_ms: list[int] = [] + stops_ms: list[int] = [] + widths_px_compressed: list[int] = [] + segment_times: list[int] = [] + compressed_width = metadata.size.compressed.width_px + total_time = 0.0 + + # If we have segments, use them to determine which parts are kept + if metadata.segments: + for segment in metadata.segments: + starts_ms.append(round(segment.start_ms)) + stops_ms.append(round(segment.end_ms)) + time = round(segment.end_ms) - round(segment.start_ms) + segment_times.append(time) + total_time += time + # Calculate width in compressed space + # The width in compressed space is proportional to the time duration + for time in segment_times: + width_px = int(round((time / total_time) * compressed_width)) + widths_px_compressed.append(width_px) + else: + # No segments - the entire image is compressed + starts_ms = [0] + stops_ms = [int(round(duration_ms))] + widths_px_compressed = [compressed_width] + + return CompressedSpectrogramData( + starts=[starts_ms], + stops=[stops_ms], + widths=[widths_px_compressed], + ) + + +class SpectrogramAssetResult(TypedDict): + paths: list[str] + width: int + height: int + + +class SpectrogramCompressedAssetResult(TypedDict): + paths: list[str] + width: int + height: int + widths: list[float] + starts: list[float] + stops: list[float] + + +class SpectrogramAssets(TypedDict): + duration: float + freq_min: int + freq_max: int + normal: SpectrogramAssetResult + compressed: SpectrogramCompressedAssetResult + + +@contextmanager +def working_directory(path): + previous = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def generate_spectrogram_assets(recording_path: str, output_folder: str): + batbot.pipeline(recording_path, config=None, output_folder=output_folder) + # There should be a .metadata.json file in the output_base directory by replacing extentions + metadata_file = Path(recording_path).with_suffix('.metadata.json').name + metadata_file = Path(output_folder) / metadata_file + metadata = parse_batbot_metadata(metadata_file) + # from the metadata we should have the images that are used + uncompressed_paths = metadata.spectrogram.uncompressed_path + compressed_paths = metadata.spectrogram.compressed_path + + metadata.frequencies.min_hz + metadata.frequencies.max_hz + + compressed_metadata = convert_to_compressed_spectrogram_data(metadata) + result = { + 'duration': metadata.duration_ms, + 'freq_min': metadata.frequencies.min_hz, + 'freq_max': metadata.frequencies.max_hz, + 'normal': { + 'paths': uncompressed_paths, + 'width': metadata.size.uncompressed.width_px, + 'height': metadata.size.uncompressed.height_px, + }, + 'compressed': { + 'paths': compressed_paths, + 'width': metadata.size.compressed.width_px, + 'height': metadata.size.compressed.height_px, + 'widths': compressed_metadata.widths, + 'starts': compressed_metadata.starts, + 'stops': compressed_metadata.stops, + }, + } + return result + + +def pipeline_filepath_validator(ctx, param, value): + if not exists(value): + log.error(f'Input filepath does not exist: {value}') + ctx.exit() + return value + + +@click.command('pipeline') +@click.argument( + 'filepath', + nargs=1, + type=str, + callback=pipeline_filepath_validator, +) +@click.option( + '--config', + help='Which ML model to use for inference', + default=None, + type=click.Choice(['usgs']), +) +@click.option( + '--output', + 'output_path', + help='Path to output Folder', + default=None, + type=str, +) +def pipeline( + filepath, + config, + output_path, +): + """ + Run the BatBot pipeline on an input WAV filepath. + + An example output of the JSON can be seen below. + + .. code-block:: javascript + + { + '/path/to/file.wav': { + 'classifier': 0.5, + } + } + """ + if config is not None: + config = config.strip().lower() + # classifier_thresh /= 100.0 + + results = generate_spectrogram_assets(filepath, output_folder=output_path) + # save the assets to a json file + with open(Path(output_path) / 'spectrogram_assets.json', 'w') as f: + json.dump(results, f, indent=4) + + +@click.command('metadata') +@click.argument( + 'metadata_filepath', + nargs=1, + type=str, + callback=pipeline_filepath_validator, +) +def metadata(metadata_filepath): + """Parse and display BatBot metadata JSON file.""" + metadata = parse_batbot_metadata(metadata_filepath) + print(len(metadata.segments), 'segments found.') + convert_to_spectrogram_data(metadata) + compressed_data = convert_to_compressed_spectrogram_data(metadata) + + # dump spectrogram assets + + # dump compressed spectrogram assets + with open(Path(metadata_filepath).with_suffix('.compressed_spectrogram_data.json'), 'w') as f: + json.dump(compressed_data.model_dump(), f, indent=4) + + +@click.group() +def cli(): + """Batbot CLI.""" + + +cli.add_command(pipeline) +cli.add_command(metadata) + + +if __name__ == '__main__': + cli() diff --git a/scripts/batbot_spectrogram.py b/scripts/batbot_spectrogram.py deleted file mode 100644 index d203c794..00000000 --- a/scripts/batbot_spectrogram.py +++ /dev/null @@ -1,203 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "batbot", -# "click", -# ] -# -# [tool.uv.sources] -# batbot = { git = "https://github.com/Kitware/batbot" } -# /// -import json -from os.path import exists - -import click - -import batbot -from batbot import log - - -def pipeline_filepath_validator(ctx, param, value): - if not exists(value): - log.error(f'Input filepath does not exist: {value}') - ctx.exit() - return value - - -@click.command('fetch') -@click.option( - '--config', - help='Which ML model to use for inference', - default=None, - type=click.Choice(['usgs']), -) -def fetch(config): - """ - Fetch the required machine learning ONNX model for the classifier - """ - batbot.fetch(config=config) - - -@click.command('pipeline') -@click.argument( - 'filepath', - nargs=1, - type=str, - callback=pipeline_filepath_validator, -) -@click.option( - '--config', - help='Which ML model to use for inference', - default=None, - type=click.Choice(['usgs']), -) -@click.option( - '--output', - help='Path to output JSON (if unspecified, results are printed to screen)', - default=None, - type=str, -) -# @click.option( -# '--classifier_thresh', -# help='Classifier confidence threshold', -# default=int(classifier.CONFIGS[None]['thresh'] * 100), -# type=click.IntRange(0, 100, clamp=True), -# ) -def pipeline( - filepath, - config, - output, - # classifier_thresh, -): - """ - Run the BatBot pipeline on an input WAV filepath. An example output of the JSON - can be seen below. - - .. code-block:: javascript - - { - '/path/to/file.wav': { - 'classifier': 0.5, - } - } - """ - if config is not None: - config = config.strip().lower() - # classifier_thresh /= 100.0 - - score = batbot.pipeline( - filepath, - config=config, - # classifier_thresh=classifier_thresh, - ) - - data = { - filepath: { - 'classifier': score, - } - } - - log.debug('Outputting results...') - if output: - with open(output, 'w') as outfile: - json.dump(data, outfile) - else: - print(data) - - -@click.command('batch') -@click.argument( - 'filepaths', - nargs=-1, - type=str, -) -@click.option( - '--config', - help='Which ML model to use for inference', - default=None, - type=click.Choice(['usgs']), -) -@click.option( - '--output', - help='Path to output JSON (if unspecified, results are printed to screen)', - default=None, - type=str, -) -# @click.option( -# '--classifier_thresh', -# help='Classifier confidence threshold', -# default=int(classifier.CONFIGS[None]['thresh'] * 100), -# type=click.IntRange(0, 100, clamp=True), -# ) -def batch( - filepaths, - config, - output, - # classifier_thresh, -): - """ - Run the BatBot pipeline in batch on a list of input WAV filepaths. - An example output of the JSON can be seen below. - - .. code-block:: javascript - - { - '/path/to/file1.wav': { - 'classifier': 0.5, - }, - '/path/to/file2.wav': { - 'classifier': 0.8, - }, - ... - } - """ - if config is not None: - config = config.strip().lower() - # classifier_thresh /= 100.0 - - log.debug(f'Running batch on {len(filepaths)} files...') - - score_list = batbot.batch( - filepaths, - config=config, - # classifier_thresh=classifier_thresh, - ) - - data = {} - for filepath, score in zip(filepaths, score_list): - data[filepath] = { - 'classifier': score, - } - - log.debug('Outputting results...') - if output: - with open(output, 'w') as outfile: - json.dump(data, outfile) - else: - print(data) - - -@click.command('example') -def example(): - """ - Run a test of the pipeline on an example WAV with the default configuration. - """ - batbot.example() - - -@click.group() -def cli(): - """ - BatBot CLI - """ - pass - - -cli.add_command(fetch) -cli.add_command(pipeline) -cli.add_command(batch) -cli.add_command(example) - - -if __name__ == '__main__': - cli() diff --git a/uv.lock b/uv.lock index 4b4b3c2f..618015ff 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13' and sys_platform == 'darwin'", @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "batbot" version = "0.1.0" -source = { git = "https://github.com/kitware/batbot#5703a1d1dea3059dffe085d28c1792d43d7c37f3" } +source = { editable = "../batbot" } dependencies = [ { name = "click" }, { name = "cryptography" }, @@ -194,6 +194,33 @@ dependencies = [ { name = "tqdm" }, ] +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "cryptography" }, + { name = "cython" }, + { name = "librosa" }, + { name = "line-profiler" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "pyastar2d", git = "https://github.com/bluemellophone/batbot-pyastar2d?rev=master" }, + { name = "pycodestyle", marker = "extra == 'all'" }, + { name = "pycodestyle", marker = "extra == 'test'" }, + { name = "pytest", marker = "extra == 'all'", specifier = ">=6.2.2" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=6.2.2" }, + { name = "pytest-cov", marker = "extra == 'all'" }, + { name = "pytest-cov", marker = "extra == 'test'" }, + { name = "rich" }, + { name = "scikit-image" }, + { name = "shapely" }, + { name = "sphinx-click" }, + { name = "tqdm" }, +] +provides-extras = ["test", "all"] + [[package]] name = "bats-ai" version = "0.0.0" @@ -282,7 +309,7 @@ type = [ [package.metadata] requires-dist = [ - { name = "batbot", git = "https://github.com/kitware/batbot" }, + { name = "batbot", editable = "../batbot" }, { name = "celery" }, { name = "django", extras = ["argon2"], specifier = ">=4.2,<5" }, { name = "django-allauth" }, From 1c243396a87050612d05861128d2e25e0f514e7d Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 22 Jan 2026 13:59:47 -0500 Subject: [PATCH 04/42] remove old spectrogram generation code --- BATBOT_Integration_Notes.md | 2 +- bats_ai/core/tasks/nabat/tasks.py | 7 +- bats_ai/core/tasks/tasks.py | 1 + bats_ai/utils/spectrogram_utils.py | 273 ----------------------------- 4 files changed, 7 insertions(+), 276 deletions(-) diff --git a/BATBOT_Integration_Notes.md b/BATBOT_Integration_Notes.md index dd2db8a3..1baa1f2c 100644 --- a/BATBOT_Integration_Notes.md +++ b/BATBOT_Integration_Notes.md @@ -37,7 +37,7 @@ Updates: TODO: -- Remove older generation code from the system +- ~~Remove older generation code from the system~~ - remove inference code from the system - Remove the local installation of batbot once updated from: - docker-compose.overrride.yml diff --git a/bats_ai/core/tasks/nabat/tasks.py b/bats_ai/core/tasks/nabat/tasks.py index b5bea8f9..072d94c5 100644 --- a/bats_ai/core/tasks/nabat/tasks.py +++ b/bats_ai/core/tasks/nabat/tasks.py @@ -14,7 +14,9 @@ NABatRecordingAnnotation, NABatSpectrogram, ) -from bats_ai.utils.spectrogram_utils import generate_spectrogram_assets, predict_from_compressed +from bats_ai.utils.spectrogram_utils import predict_from_compressed + +from bats_ai.core.utils.batbot_metadata import generate_spectrogram_assets logging.basicConfig(level=logging.INFO) logger = logging.getLogger('NABatDataRetrieval') @@ -106,7 +108,8 @@ def generate_spectrograms( try: config = Configuration.objects.first() - if config and config.run_inference_on_upload: + # TODO: Disabled until prediction is in batbot + if config and config.run_inference_on_upload and False: self.update_state( state='Progress', meta={'description': 'Running Prediction on Spectrogram'}, diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index d6a3aa53..fca776f4 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -89,6 +89,7 @@ def recording_compute_spectrogram(recording_id: int): ) config = Configuration.objects.first() + # TODO: Disabled until prediction is in batbot if config and config.run_inference_on_upload and False: predict_results = predict_from_compressed(compressed_obj) label = predict_results['label'] diff --git a/bats_ai/utils/spectrogram_utils.py b/bats_ai/utils/spectrogram_utils.py index 441b9fcc..5df3ee03 100644 --- a/bats_ai/utils/spectrogram_utils.py +++ b/bats_ai/utils/spectrogram_utils.py @@ -22,34 +22,6 @@ logger = logging.getLogger(__name__) -FREQ_MIN = 5e3 -FREQ_MAX = 120e3 -FREQ_PAD = 2e3 - - -class SpectrogramAssetResult(TypedDict): - paths: list[str] - width: int - height: int - - -class SpectrogramCompressedAssetResult(TypedDict): - paths: list[str] - width: int - height: int - widths: list[float] - starts: list[float] - stops: list[float] - - -class SpectrogramAssets(TypedDict): - duration: float - freq_min: int - freq_max: int - normal: SpectrogramAssetResult - compressed: SpectrogramCompressedAssetResult - - class PredictionOutput(TypedDict): label: str score: float @@ -137,248 +109,3 @@ def predict_from_compressed( confs = dict(zip(labels, outputs)) return {'label': label, 'score': score, 'confs': confs} - - -def generate_spectrogram_assets( - recording_path: str, output_base: str, dpi: int = 520 -) -> SpectrogramAssets: - sig, sr = librosa.load(recording_path, sr=None) - duration = len(sig) / sr - - size_mod = 1 - size = int(0.001 * sr) - size = 2 ** (math.ceil(math.log(size, 2)) + size_mod) - hop_length = int(size / 4) - - window = librosa.stft(sig, n_fft=size, hop_length=hop_length, window='hamming') - window = np.abs(window) ** 2 - window = librosa.power_to_db(window) - window -= np.median(window, axis=1, keepdims=True) - window_ = window[window > 0] - thresh = np.median(window_) - window[window <= thresh] = 0 - - bands = librosa.fft_frequencies(sr=sr, n_fft=size) - for index in range(len(bands)): - band_min = bands[index] - band_max = bands[index + 1] if index < len(bands) - 1 else np.inf - if band_max <= FREQ_MIN or FREQ_MAX <= band_min: - window[index, :] = -1 - - window = np.clip(window, 0, None) - freq_low = int(FREQ_MIN - FREQ_PAD) - freq_high = int(FREQ_MAX + FREQ_PAD) - vmin = window.min() - vmax = window.max() - - chunksize = int(2e3) - arange = np.arange(chunksize, window.shape[1], chunksize) - chunks = np.array_split(window, arange, axis=1) - - imgs = [] - for chunk in chunks: - h, w = chunk.shape - alpha = 3 - figsize = (int(math.ceil(w / h)) * alpha + 1, alpha) - fig = plt.figure(figsize=figsize, facecolor='black', dpi=dpi) - ax = plt.axes() - plt.margins(0) - - kwargs = { - 'sr': sr, - 'n_fft': size, - 'hop_length': hop_length, - 'x_axis': 's', - 'y_axis': 'fft', - 'ax': ax, - 'vmin': vmin, - 'vmax': vmax, - } - - librosa.display.specshow(chunk, cmap='gray', **kwargs) - ax.set_ylim(freq_low, freq_high) - ax.axis('off') - - buf = io.BytesIO() - fig.savefig(buf, bbox_inches='tight', pad_inches=0) - plt.close(fig) - - buf.seek(0) - img = Image.open(buf) - img = np.array(img) - mask = img[:, :, -1] - flags = np.where(np.sum(mask != 0, axis=0) == 0)[0] - index = flags.min() if len(flags) > 0 else img.shape[1] - img = img[:, :index, :3] - - imgs.append(img) - - normal_img = np.hstack(imgs) - normal_width = int(8.0 * duration * 1e3) - normal_height = 1200 - normal_img_resized = cv2.resize( - normal_img, (normal_width, normal_height), interpolation=cv2.INTER_LANCZOS4 - ) - - normal_out_path_base = os.path.join( - os.path.dirname(output_base), - 'spectrogram', - os.path.splitext(os.path.basename(output_base))[0] + '_spectrogram', - ) - os.makedirs(os.path.dirname(normal_out_path_base), exist_ok=True) - normal_paths = save_img(normal_img_resized, normal_out_path_base) - real_duration = math.ceil(duration * 1e3) - compressed_img, compressed_paths, widths, starts, stops = generate_compressed( - normal_img_resized, real_duration, output_base - ) - - result = { - 'duration': real_duration, - 'freq_min': freq_low, - 'freq_max': freq_high, - 'normal': { - 'paths': normal_paths, - 'width': normal_img_resized.shape[1], - 'height': normal_img_resized.shape[0], - }, - 'compressed': { - 'paths': compressed_paths, - 'width': compressed_img.shape[1], - 'height': compressed_img.shape[0], - 'widths': widths, - 'starts': starts, - 'stops': stops, - }, - } - - return result - - -def generate_compressed(img: np.ndarray, duration: float, output_base: str): - threshold = 0.5 - compressed_img = img.copy() - starts, stops = [], [] - - while True: - canvas = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY).astype(np.float32) - is_light = np.median(canvas) > 128.0 - if is_light: - canvas = 255.0 - canvas - - amplitude = canvas.max(axis=0) - amplitude -= amplitude.min() - if amplitude.max() != 0: - amplitude /= amplitude.max() - amplitude[amplitude < threshold] = 0.0 - amplitude[amplitude > 0] = 1.0 - amplitude = amplitude.reshape(1, -1) - - canvas -= canvas.min() - if canvas.max() != 0: - canvas /= canvas.max() - canvas *= 255.0 - canvas *= amplitude - canvas = np.around(canvas).astype(np.uint8) - - mask = canvas.max(axis=0) - mask = scipy.signal.medfilt(mask, 3) - mask[0] = 0 - mask[-1] = 0 - - starts, stops = [], [] - for index in range(1, len(mask) - 1): - if mask[index] != 0: - if mask[index - 1] == 0: - starts.append(index) - if mask[index + 1] == 0: - stops.append(index) - - starts = [val - 40 for val in starts] - stops = [val + 40 for val in stops] - ranges = list(zip(starts, stops)) - - while True: - found = False - merged = [] - index = 0 - while index < len(ranges) - 1: - start1, stop1 = ranges[index] - start2, stop2 = ranges[index + 1] - - # Clamp values within mask length - start1 = min(max(start1, 0), len(mask)) - start2 = min(max(start2, 0), len(mask)) - stop1 = min(max(stop1, 0), len(mask)) - stop2 = min(max(stop2, 0), len(mask)) - - if stop1 >= start2: - found = True - merged.append((start1, stop2)) - index += 2 - else: - merged.append((start1, stop1)) - index += 1 - if index == len(ranges) - 1: - merged.append((start2, stop2)) - ranges = merged - if not found: - break - - starts = [start for start, _ in ranges] - stops = [stop for _, stop in ranges] - - segments = [] - domain = img.shape[1] - widths = [] - for start, stop in ranges: - start_clamped = max(start, 0) - stop_clamped = min(stop, domain) - segment = img[:, start_clamped:stop_clamped] - segments.append(segment) - widths.append(stop_clamped - start_clamped) - - if segments: - compressed_img = np.hstack(segments) - break - - threshold -= 0.05 - if threshold < 0: - compressed_img = img.copy() - widths = [] - starts = [] - stops = [] - break - - # Convert starts and stops to time values relative to duration - starts_time = [int(round(duration * (max(s, 0) / domain))) for s in starts] - stops_time = [int(round(duration * (min(e, domain) / domain))) for e in stops] - - out_folder = os.path.join(os.path.dirname(output_base), 'compressed') - os.makedirs(out_folder, exist_ok=True) - base_name = os.path.splitext(os.path.basename(output_base))[0] - compressed_out_path = os.path.join(out_folder, f'{base_name}_compressed') - - # save_img should be your existing function to save images and return file paths - paths = save_img(compressed_img, compressed_out_path) - - return compressed_img, paths, widths, starts_time, stops_time - - -def save_img(img: np.ndarray, output_base: str): - chunksize = int(5e4) - length = img.shape[1] - chunks = ( - np.split(img, np.arange(chunksize, length, chunksize), axis=1) - if length > chunksize - else [img] - ) - total = len(chunks) - output_paths = [] - for index, chunk in enumerate(chunks): - out_path = f'{output_base}.{index + 1:02d}_of_{total:02d}.jpg' - out_img = Image.fromarray(chunk, 'RGB') - out_img.save(out_path, format='JPEG', optimize=True, quality=80) - output_paths.append(out_path) - logger.info(f'Saved image: {out_path}') - - return output_paths From 88786201ff2da6788402680ad841a844de1e8c68 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 26 Jan 2026 15:37:53 -0500 Subject: [PATCH 05/42] swap back to using the github installation for batbot --- docker-compose.override.yml | 2 -- pyproject.toml | 2 +- scripts/batbot/batbot_spectrogram.py | 2 +- uv.lock | 31 ++-------------------------- 4 files changed, 4 insertions(+), 33 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 934e7c82..0b0f399f 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -14,7 +14,6 @@ services: working_dir: /opt/django-project volumes: - .:/opt/django-project - - ../batbot:/opt/batbot - uv_cache:/var/cache/uv ports: - 8000:8000 @@ -45,7 +44,6 @@ services: working_dir: /opt/django-project volumes: - .:/opt/django-project - - ../batbot:/opt/batbot - uv_cache:/var/cache/uv depends_on: postgres: diff --git a/pyproject.toml b/pyproject.toml index 7b4ff43f..db2a00c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,7 @@ explicit = true [tool.uv.sources] gdal = { index = "large_image_wheels" } -batbot = { path = "../batbot", editable = true } +batbot = { git = "https://github.com/Kitware/batbot" } [tool.black] diff --git a/scripts/batbot/batbot_spectrogram.py b/scripts/batbot/batbot_spectrogram.py index 8ad18893..33434cad 100644 --- a/scripts/batbot/batbot_spectrogram.py +++ b/scripts/batbot/batbot_spectrogram.py @@ -7,7 +7,7 @@ # ] # # [tool.uv.sources] -# batbot = { path = "../../../batbot", editable = true } +# batbot = { git = "https://github.com/Kitware/batbot" } # /// from contextlib import contextmanager import json diff --git a/uv.lock b/uv.lock index 618015ff..95869b59 100644 --- a/uv.lock +++ b/uv.lock @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "batbot" version = "0.1.0" -source = { editable = "../batbot" } +source = { git = "https://github.com/Kitware/batbot#0fdf8b93540662c5f642705c50ce9aa991ba6036" } dependencies = [ { name = "click" }, { name = "cryptography" }, @@ -194,33 +194,6 @@ dependencies = [ { name = "tqdm" }, ] -[package.metadata] -requires-dist = [ - { name = "click" }, - { name = "cryptography" }, - { name = "cython" }, - { name = "librosa" }, - { name = "line-profiler" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "pillow" }, - { name = "pooch" }, - { name = "pyastar2d", git = "https://github.com/bluemellophone/batbot-pyastar2d?rev=master" }, - { name = "pycodestyle", marker = "extra == 'all'" }, - { name = "pycodestyle", marker = "extra == 'test'" }, - { name = "pytest", marker = "extra == 'all'", specifier = ">=6.2.2" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=6.2.2" }, - { name = "pytest-cov", marker = "extra == 'all'" }, - { name = "pytest-cov", marker = "extra == 'test'" }, - { name = "rich" }, - { name = "scikit-image" }, - { name = "shapely" }, - { name = "sphinx-click" }, - { name = "tqdm" }, -] -provides-extras = ["test", "all"] - [[package]] name = "bats-ai" version = "0.0.0" @@ -309,7 +282,7 @@ type = [ [package.metadata] requires-dist = [ - { name = "batbot", editable = "../batbot" }, + { name = "batbot", git = "https://github.com/Kitware/batbot" }, { name = "celery" }, { name = "django", extras = ["argon2"], specifier = ">=4.2,<5" }, { name = "django-allauth" }, From 9623991d391183044214bee356cd998ab75beebd Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 27 Jan 2026 13:07:37 -0500 Subject: [PATCH 06/42] use temp branch for start/stop fixes --- BATBOT_Integration_Notes.md | 13 +++++++------ pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/BATBOT_Integration_Notes.md b/BATBOT_Integration_Notes.md index 1baa1f2c..409639ac 100644 --- a/BATBOT_Integration_Notes.md +++ b/BATBOT_Integration_Notes.md @@ -39,9 +39,10 @@ TODO: - ~~Remove older generation code from the system~~ - remove inference code from the system -- Remove the local installation of batbot once updated from: - - docker-compose.overrride.yml - - pyproject.toml - - uv.lock -- Can temporarily use my branch on the repo (issue-4-output-folder-option) -- Update sample script uv dependencies when batbot is published +- ~~Remove the local installation of batbot once updated from:~~ + - ~~docker-compose.overrride.yml~~ + - ~~pyproject.toml~~ + - ~uv.lock~~ +- ~~Can temporarily use my branch on the repo (issue-4-output-folder-option)~~ +- using `temp-compressed-time-fix` branch because it has alignment fixes for start/stops +- Update sample script uv dependencies when batbot is published~~ diff --git a/pyproject.toml b/pyproject.toml index db2a00c0..e6404ad0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,7 @@ explicit = true [tool.uv.sources] gdal = { index = "large_image_wheels" } -batbot = { git = "https://github.com/Kitware/batbot" } +batbot = { git = "https://github.com/Kitware/batbot", branch = "temp-compressed-time-fix" } [tool.black] diff --git a/uv.lock b/uv.lock index 95869b59..27b64de9 100644 --- a/uv.lock +++ b/uv.lock @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "batbot" version = "0.1.0" -source = { git = "https://github.com/Kitware/batbot#0fdf8b93540662c5f642705c50ce9aa991ba6036" } +source = { git = "https://github.com/Kitware/batbot?branch=temp-compressed-time-fix#0dba532c90885e1b88c07f6078dc42077cb72775" } dependencies = [ { name = "click" }, { name = "cryptography" }, @@ -282,7 +282,7 @@ type = [ [package.metadata] requires-dist = [ - { name = "batbot", git = "https://github.com/Kitware/batbot" }, + { name = "batbot", git = "https://github.com/Kitware/batbot?branch=temp-compressed-time-fix" }, { name = "celery" }, { name = "django", extras = ["argon2"], specifier = ">=4.2,<5" }, { name = "django-allauth" }, From a1d5eedbbc90bb1fe5ad84d045c4df06bdc84b5d Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 27 Jan 2026 13:14:30 -0500 Subject: [PATCH 07/42] increase accuracy for spectrograms and annotations --- ...026_alter_annotations_end_time_and_more.py | 163 ++++++++++++++++++ bats_ai/core/models/annotations.py | 8 +- bats_ai/core/models/compressed_spectrogram.py | 8 +- .../nabat/nabat_compressed_spectrogram.py | 8 +- .../core/models/nabat/nabat_spectrogram.py | 10 +- bats_ai/core/models/sequence_annotations.py | 4 +- bats_ai/core/models/spectrogram.py | 10 +- bats_ai/core/tasks/nabat/tasks.py | 3 +- bats_ai/core/utils/batbot_metadata.py | 6 +- bats_ai/utils/spectrogram_utils.py | 8 +- 10 files changed, 192 insertions(+), 36 deletions(-) create mode 100644 bats_ai/core/migrations/0026_alter_annotations_end_time_and_more.py diff --git a/bats_ai/core/migrations/0026_alter_annotations_end_time_and_more.py b/bats_ai/core/migrations/0026_alter_annotations_end_time_and_more.py new file mode 100644 index 00000000..50f2631b --- /dev/null +++ b/bats_ai/core/migrations/0026_alter_annotations_end_time_and_more.py @@ -0,0 +1,163 @@ +# Generated by Django 4.2.23 on 2026-01-27 18:11 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0025_configuration_mark_annotations_completed_enabled_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='annotations', + name='end_time', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='annotations', + name='high_freq', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='annotations', + name='low_freq', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='annotations', + name='start_time', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='compressedspectrogram', + name='length', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='compressedspectrogram', + name='starts', + field=django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.FloatField(), size=None + ), + size=None, + ), + ), + migrations.AlterField( + model_name='compressedspectrogram', + name='stops', + field=django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.FloatField(), size=None + ), + size=None, + ), + ), + migrations.AlterField( + model_name='compressedspectrogram', + name='widths', + field=django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.FloatField(), size=None + ), + size=None, + ), + ), + migrations.AlterField( + model_name='nabatcompressedspectrogram', + name='length', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='nabatcompressedspectrogram', + name='starts', + field=django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.FloatField(), size=None + ), + size=None, + ), + ), + migrations.AlterField( + model_name='nabatcompressedspectrogram', + name='stops', + field=django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.FloatField(), size=None + ), + size=None, + ), + ), + migrations.AlterField( + model_name='nabatcompressedspectrogram', + name='widths', + field=django.contrib.postgres.fields.ArrayField( + base_field=django.contrib.postgres.fields.ArrayField( + base_field=models.FloatField(), size=None + ), + size=None, + ), + ), + migrations.AlterField( + model_name='nabatspectrogram', + name='duration', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='nabatspectrogram', + name='frequency_max', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='nabatspectrogram', + name='frequency_min', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='nabatspectrogram', + name='height', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='nabatspectrogram', + name='width', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='sequenceannotations', + name='end_time', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='sequenceannotations', + name='start_time', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='spectrogram', + name='duration', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='spectrogram', + name='frequency_max', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='spectrogram', + name='frequency_min', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='spectrogram', + name='height', + field=models.FloatField(), + ), + migrations.AlterField( + model_name='spectrogram', + name='width', + field=models.FloatField(), + ), + ] diff --git a/bats_ai/core/models/annotations.py b/bats_ai/core/models/annotations.py index 6028ffb4..7feee8c7 100644 --- a/bats_ai/core/models/annotations.py +++ b/bats_ai/core/models/annotations.py @@ -10,10 +10,10 @@ class Annotations(TimeStampedModel, models.Model): recording = models.ForeignKey(Recording, on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE) - start_time = models.IntegerField(blank=True, null=True) - end_time = models.IntegerField(blank=True, null=True) - low_freq = models.IntegerField(blank=True, null=True) - high_freq = models.IntegerField(blank=True, null=True) + start_time = models.FloatField(blank=True, null=True) + end_time = models.FloatField(blank=True, null=True) + low_freq = models.FloatField(blank=True, null=True) + high_freq = models.FloatField(blank=True, null=True) type = models.TextField(blank=True, null=True) species = models.ManyToManyField(Species) comments = models.TextField(blank=True, null=True) diff --git a/bats_ai/core/models/compressed_spectrogram.py b/bats_ai/core/models/compressed_spectrogram.py index f4fa734a..089dd4e6 100644 --- a/bats_ai/core/models/compressed_spectrogram.py +++ b/bats_ai/core/models/compressed_spectrogram.py @@ -15,11 +15,11 @@ class CompressedSpectrogram(TimeStampedModel, models.Model): recording = models.ForeignKey(Recording, on_delete=models.CASCADE) spectrogram = models.ForeignKey(Spectrogram, on_delete=models.CASCADE) - length = models.IntegerField() + length = models.FloatField() images = GenericRelation(SpectrogramImage) - starts = ArrayField(ArrayField(models.IntegerField())) - stops = ArrayField(ArrayField(models.IntegerField())) - widths = ArrayField(ArrayField(models.IntegerField())) + starts = ArrayField(ArrayField(models.FloatField())) + stops = ArrayField(ArrayField(models.FloatField())) + widths = ArrayField(ArrayField(models.FloatField())) cache_invalidated = models.BooleanField(default=True) @property diff --git a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py index 14bd9310..d6b584bd 100644 --- a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py +++ b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py @@ -17,10 +17,10 @@ class NABatCompressedSpectrogram(TimeStampedModel, models.Model): nabat_recording = models.ForeignKey(NABatRecording, on_delete=models.CASCADE) spectrogram = models.ForeignKey(NABatSpectrogram, on_delete=models.CASCADE) images = GenericRelation(SpectrogramImage) - length = models.IntegerField() - starts = ArrayField(ArrayField(models.IntegerField())) - stops = ArrayField(ArrayField(models.IntegerField())) - widths = ArrayField(ArrayField(models.IntegerField())) + length = models.FloatField() + starts = ArrayField(ArrayField(models.FloatField())) + stops = ArrayField(ArrayField(models.FloatField())) + widths = ArrayField(ArrayField(models.FloatField())) cache_invalidated = models.BooleanField(default=True) @property diff --git a/bats_ai/core/models/nabat/nabat_spectrogram.py b/bats_ai/core/models/nabat/nabat_spectrogram.py index 97c5e10c..59aaaa68 100644 --- a/bats_ai/core/models/nabat/nabat_spectrogram.py +++ b/bats_ai/core/models/nabat/nabat_spectrogram.py @@ -18,11 +18,11 @@ class NABatSpectrogram(TimeStampedModel, models.Model): nabat_recording = models.ForeignKey(NABatRecording, on_delete=models.CASCADE) images = GenericRelation(SpectrogramImage) - width = models.IntegerField() # pixels - height = models.IntegerField() # pixels - duration = models.IntegerField() # milliseconds - frequency_min = models.IntegerField() # hz - frequency_max = models.IntegerField() # hz + width = models.FloatField() # pixels + height = models.FloatField() # pixels + duration = models.FloatField() # milliseconds + frequency_min = models.FloatField() # hz + frequency_max = models.FloatField() # hz @property def image_url_list(self): diff --git a/bats_ai/core/models/sequence_annotations.py b/bats_ai/core/models/sequence_annotations.py index 5326aeb0..1007c4fb 100644 --- a/bats_ai/core/models/sequence_annotations.py +++ b/bats_ai/core/models/sequence_annotations.py @@ -8,8 +8,8 @@ class SequenceAnnotations(models.Model): recording = models.ForeignKey(Recording, on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE) - start_time = models.IntegerField(blank=True, null=True) - end_time = models.IntegerField(blank=True, null=True) + start_time = models.FloatField(blank=True, null=True) + end_time = models.FloatField(blank=True, null=True) type = models.TextField(blank=True, null=True) comments = models.TextField(blank=True, null=True) species = models.ManyToManyField(Species) diff --git a/bats_ai/core/models/spectrogram.py b/bats_ai/core/models/spectrogram.py index 1d200878..b4dfd5ce 100644 --- a/bats_ai/core/models/spectrogram.py +++ b/bats_ai/core/models/spectrogram.py @@ -12,11 +12,11 @@ class Spectrogram(TimeStampedModel, models.Model): recording = models.ForeignKey(Recording, on_delete=models.CASCADE) images = GenericRelation(SpectrogramImage) - width = models.IntegerField() # pixels - height = models.IntegerField() # pixels - duration = models.IntegerField() # milliseconds - frequency_min = models.IntegerField() # hz - frequency_max = models.IntegerField() # hz + width = models.FloatField() # pixels + height = models.FloatField() # pixels + duration = models.FloatField() # milliseconds + frequency_min = models.FloatField() # hz + frequency_max = models.FloatField() # hz @property def image_url_list(self): diff --git a/bats_ai/core/tasks/nabat/tasks.py b/bats_ai/core/tasks/nabat/tasks.py index 072d94c5..bde17106 100644 --- a/bats_ai/core/tasks/nabat/tasks.py +++ b/bats_ai/core/tasks/nabat/tasks.py @@ -14,9 +14,8 @@ NABatRecordingAnnotation, NABatSpectrogram, ) -from bats_ai.utils.spectrogram_utils import predict_from_compressed - from bats_ai.core.utils.batbot_metadata import generate_spectrogram_assets +from bats_ai.utils.spectrogram_utils import predict_from_compressed logging.basicConfig(level=logging.INFO) logger = logging.getLogger('NABatDataRetrieval') diff --git a/bats_ai/core/utils/batbot_metadata.py b/bats_ai/core/utils/batbot_metadata.py index ed7a2526..bb38ce67 100644 --- a/bats_ai/core/utils/batbot_metadata.py +++ b/bats_ai/core/utils/batbot_metadata.py @@ -128,7 +128,7 @@ class CompressedSpectrogramData(BaseModel): starts: list[float] stops: list[float] - widths: list[int] + widths: list[float] def parse_batbot_metadata(file_path: str | Path) -> BatbotMetadata: @@ -201,12 +201,12 @@ def convert_to_compressed_spectrogram_data(metadata: BatbotMetadata) -> Compress # Calculate width in compressed space # The width in compressed space is proportional to the time duration for time in segment_times: - width_px = int(round((time / total_time) * compressed_width)) + width_px = (time / total_time) * compressed_width widths_px_compressed.append(width_px) else: # No segments - the entire image is compressed starts_ms = [0] - stops_ms = [int(round(duration_ms))] + stops_ms = [duration_ms] widths_px_compressed = [compressed_width] return CompressedSpectrogramData( diff --git a/bats_ai/utils/spectrogram_utils.py b/bats_ai/utils/spectrogram_utils.py index 5df3ee03..32f1bafb 100644 --- a/bats_ai/utils/spectrogram_utils.py +++ b/bats_ai/utils/spectrogram_utils.py @@ -1,20 +1,13 @@ -import io import json import logging -import math import os from pathlib import Path from typing import TypedDict -from PIL import Image import cv2 -import librosa -import librosa.display -import matplotlib.pyplot as plt import numpy as np import onnx import onnxruntime as ort -import scipy.signal import tqdm from bats_ai.core.models import CompressedSpectrogram @@ -22,6 +15,7 @@ logger = logging.getLogger(__name__) + class PredictionOutput(TypedDict): label: str score: float From a438016c16aefa8712519fc2e442067fe9480a8e Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 27 Jan 2026 16:20:21 -0500 Subject: [PATCH 08/42] thumbnail centering fixes --- client/src/components/ThumbnailViewer.vue | 19 ++++++++----------- client/src/components/geoJS/geoJSUtils.ts | 17 ++++++++++------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/client/src/components/ThumbnailViewer.vue b/client/src/components/ThumbnailViewer.vue index 716b66ec..4374653c 100644 --- a/client/src/components/ThumbnailViewer.vue +++ b/client/src/components/ThumbnailViewer.vue @@ -45,11 +45,12 @@ export default defineComponent({ if (containerRef.value && !geoJS.getGeoViewer().value) { geoJS.initializeViewer(containerRef.value, width, height, true); } - geoJS.resetMapDimensions(width, height); - geoJS.getGeoViewer().value.bounds({ left: 0, top: 0, bottom: height, right: width }); + const finalWidth = scaledWidth.value || width; + const finalHeight = scaledHeight.value || height; + geoJS.resetMapDimensions(finalWidth, finalHeight, 0.3, true); if (props.images.length) { - geoJS.drawImages(props.images, scaledWidth.value || width, scaledHeight.value || height); + geoJS.drawImages(props.images, finalWidth, finalHeight); } initialized.value = true; nextTick(() => createPolyLayer()); @@ -127,15 +128,11 @@ export default defineComponent({ watch([scaledHeight, scaledWidth], () => { const { width, height } = getImageDimensions(props.images); - geoJS.resetMapDimensions(scaledWidth.value || width, scaledHeight.value || height); - geoJS.getGeoViewer().value.bounds({ - left: 0, - top: 0, - bottom: scaledHeight.value || height, - right: scaledWidth.value || width, - }); + const finalWidth = scaledWidth.value || width; + const finalHeight = scaledHeight.value || height; + geoJS.resetMapDimensions(finalWidth, finalHeight, 0.3, true); if (props.images.length) { - geoJS.drawImages(props.images, scaledWidth.value || width, scaledHeight.value | height); + geoJS.drawImages(props.images, finalWidth, finalHeight); } }); diff --git a/client/src/components/geoJS/geoJSUtils.ts b/client/src/components/geoJS/geoJSUtils.ts index 2d7bfe6b..0446f3ae 100644 --- a/client/src/components/geoJS/geoJSUtils.ts +++ b/client/src/components/geoJS/geoJSUtils.ts @@ -174,10 +174,10 @@ const useGeoJS = () => { bottom: originalBounds.bottom, } : { - left: 0, + left: -geoViewer.value.bounds().right * 0.1, top: 0, - right: originalDimensions.width, - bottom: originalDimensions.height, + right: geoViewer.value.bounds().right * 1.1, + bottom: geoViewer.value.bounds().bottom, }; const zoomAndCenter = geoViewer.value.zoomAndCenterFromBounds(bounds, 0); geoViewer.value.zoom(zoomAndCenter.zoom); @@ -194,11 +194,14 @@ const useGeoJS = () => { }); const params = geo.util.pixelCoordinateParams(container.value, width, height, width, height); const { right, bottom } = params.map.maxBounds; + // For thumbnails, use 0.1 margin for left and right, keep default margin for top/bottom + const horizontalMargin = thumbnail.value ? 0.1 : margin; + const verticalMargin = margin; geoViewer.value.maxBounds({ - left: 0 - right * margin, - top: 0 - bottom * margin, - right: right * (1 + margin), - bottom: bottom * (1 + margin), + left: 0 - right * horizontalMargin, + top: 0 - bottom * verticalMargin, + right: right * (1 + horizontalMargin), + bottom: bottom * (1 + verticalMargin), }); originalBounds = geoViewer.value.maxBounds(); geoViewer.value.zoomRange({ From b1ae3121ab00aa5f4cbb600e09c39361843415c5 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 28 Jan 2026 13:19:04 -0500 Subject: [PATCH 09/42] add noise filter --- client/src/components/ColorSchemeDialog.vue | 2 +- .../components/TransparencyFilterControl.vue | 65 +++++++++++++++++++ client/src/components/geoJS/LayerManager.vue | 64 +++++++++++++++++- client/src/components/geoJS/geoJSUtils.ts | 2 +- client/src/use/useState.ts | 3 + client/src/views/NABat/NABatSpectrogram.vue | 5 ++ client/src/views/Spectrogram.vue | 5 ++ 7 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 client/src/components/TransparencyFilterControl.vue diff --git a/client/src/components/ColorSchemeDialog.vue b/client/src/components/ColorSchemeDialog.vue index 874e8512..9a102e6c 100644 --- a/client/src/components/ColorSchemeDialog.vue +++ b/client/src/components/ColorSchemeDialog.vue @@ -41,7 +41,7 @@ watch(colorScheme, () => { diff --git a/client/src/components/SpectrogramViewer.vue b/client/src/components/SpectrogramViewer.vue index 8f0a71aa..a48d1bae 100644 --- a/client/src/components/SpectrogramViewer.vue +++ b/client/src/components/SpectrogramViewer.vue @@ -37,7 +37,8 @@ export default defineComponent({ configuration, scaledWidth, scaledHeight, - spectrogramContentMode, + contoursEnabled, + imageOpacity, } = useState(); const containerRef: Ref = ref(); @@ -116,6 +117,8 @@ export default defineComponent({ emit("hoverData", { time, freq }); }; + const effectiveImageOpacity = () => (contoursEnabled.value ? imageOpacity.value : 1); + function initializeViewerAndImages() { updateScaledDimensions(); if (containerRef.value && !geoJS.getGeoViewer().value) { @@ -123,7 +126,7 @@ export default defineComponent({ geoJS.getGeoViewer().value.geoOn(geo.event.mousemove, mouseMoveEvent); } if (props.images.length) { - geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value); + geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, true, effectiveImageOpacity()); } initialized.value = true; emit("geoViewerRef", geoJS.getGeoViewer()); @@ -132,7 +135,7 @@ export default defineComponent({ scaledVals.value = { x: configuration.value.spectrogram_x_stretch, y: 1 }; updateScaledDimensions(); if (props.images.length) { - geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false); + geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false, effectiveImageOpacity()); } } } @@ -156,7 +159,7 @@ export default defineComponent({ right: scaledWidth.value, }); if (props.images.length) { - geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value); + geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, true, effectiveImageOpacity()); } }); @@ -214,27 +217,23 @@ export default defineComponent({ if (scaledVals.value.x < 1) scaledVals.value.x = 1; updateScaledDimensions(); if (props.images.length) { - geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false); + geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false, effectiveImageOpacity()); } } else if (event.shiftKey) { scaledVals.value.y += event.deltaY > 0 ? -incrementY : incrementY; if (scaledVals.value.y < 1) scaledVals.value.y = 1; updateScaledDimensions(); if (props.images.length) { - geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false); + geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false, effectiveImageOpacity()); } } }; onUnmounted(() => geoJS.destroyGeoViewer()); - watch(spectrogramContentMode, () => { - // If the user has chosen to look at the contours, hide - // the images. - if (spectrogramContentMode.value === 'contour') { - geoJS.clearQuadFeatures(true); - } else if (props.images.length) { - geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false); + watch([contoursEnabled, imageOpacity], () => { + if (props.images.length) { + geoJS.drawImages(props.images, scaledWidth.value, scaledHeight.value, false, effectiveImageOpacity()); } }); diff --git a/client/src/components/TransparencyFilterControl.vue b/client/src/components/TransparencyFilterControl.vue index 26d0a358..291bc9f8 100644 --- a/client/src/components/TransparencyFilterControl.vue +++ b/client/src/components/TransparencyFilterControl.vue @@ -21,22 +21,22 @@ export default defineComponent({ open-on-hover > props.recordingId, () => computedPulseAnnotations.value = []); - watch(spectrogramContentMode, async () => { + watch(contoursEnabled, async () => { if (props.thumbnail) { return; } @@ -478,12 +479,20 @@ export default defineComponent({ ); } contourLayer.setScaledDimensions(props.scaledWidth, props.scaledHeight); - if (spectrogramContentMode.value === 'contour' || spectrogramContentMode.value === 'both') { + contourLayer.setContourOpacity(contourOpacity.value); + if (contoursEnabled.value) { contourLayer.drawContours(); } else { contourLayer.removeContours(); } }); + watch(contourOpacity, () => { + if (contourLayer && contoursEnabled.value) { + contourLayer.setContourOpacity(contourOpacity.value); + contourLayer.removeContours(); + contourLayer.drawContours(); + } + }); onUnmounted(() => { if (editAnnotationLayer) { editAnnotationLayer.destroy(); @@ -686,6 +695,13 @@ export default defineComponent({ compressedOverlayLayer?.disable(); } } + if (contourLayer) { + contourLayer.setScaledDimensions(props.scaledWidth, props.scaledHeight); + if (contoursEnabled.value) { + contourLayer.removeContours(); + contourLayer.drawContours(); + } + } editAnnotationLayer?.setScaledDimensions(props.scaledWidth, props.scaledHeight); if (editing.value && editingAnnotation.value) { setTimeout(() => { @@ -914,7 +930,8 @@ export default defineComponent({ /> + result="transparency-mask" + > { } }; - const drawImages = (images: HTMLImageElement[], width = 0, height = 0, resetCam = true) => { + const drawImages = (images: HTMLImageElement[], width = 0, height = 0, resetCam = true, imageOpacity = 1) => { let previousWidth = 0; let totalBaseWidth = 0; images.forEach((image) => (totalBaseWidth += image.naturalWidth)); + quadFeatureLayer.node().css("opacity", String(imageOpacity)); images.forEach((image, index) => { const scaledWidth = width / totalBaseWidth; const currentWidth = image.width * scaledWidth; diff --git a/client/src/components/geoJS/layers/contourLayer.ts b/client/src/components/geoJS/layers/contourLayer.ts index c1975b77..305691b7 100644 --- a/client/src/components/geoJS/layers/contourLayer.ts +++ b/client/src/components/geoJS/layers/contourLayer.ts @@ -29,6 +29,8 @@ export default class ContourLayer { maxLevel: number; + contourOpacity: number; + scaledHeight: number; scaledWidth: number; @@ -53,6 +55,7 @@ export default class ContourLayer { this.computedPulseAnnotations = computedPulseAnnotations; this.features = []; this.maxLevel = 0; + this.contourOpacity = 1.0; this.init(); } @@ -180,10 +183,14 @@ export default class ContourLayer { fillColor: (_val: number, _idx: number, coords: number[][]) => { return this.colorScheme((coords[0][2] || 0) / this.maxLevel); }, - fillOpacity: 1.0, + fillOpacity: this.contourOpacity, }; } + setContourOpacity(opacity: number) { + this.contourOpacity = opacity; + } + setColorScheme(colorScheme: (t: number) => string) { this.colorScheme = colorScheme; // Redraw diff --git a/client/src/use/useState.ts b/client/src/use/useState.ts index 900a23fa..e826d755 100644 --- a/client/src/use/useState.ts +++ b/client/src/use/useState.ts @@ -16,7 +16,6 @@ import { ComputedPulseAnnotation, getVettingDetailsForUser, } from "../api/api"; -import { SpectrogramView } from "@/constants"; import { interpolateCividis, interpolateViridis, @@ -90,10 +89,15 @@ const toggleFixedAxes = () => { }; const computedPulseAnnotations: Ref = ref([]); -const spectrogramContentMode = ref('image' as SpectrogramView); +const contoursEnabled = ref(false); +const imageOpacity = ref(1.0); +const contourOpacity = ref(1.0); const contoursLoading = ref(false); -const setSpectrogramContentMode = (newVal: SpectrogramView) => { - spectrogramContentMode.value = newVal; +const setContoursEnabled = (value: boolean) => { + contoursEnabled.value = value; +}; +const toggleContoursEnabled = () => { + contoursEnabled.value = !contoursEnabled.value; }; async function loadContours(recordingId: number) { contoursLoading.value = true; @@ -367,9 +371,12 @@ export default function useState() { fixedAxes, toggleFixedAxes, transparencyThreshold, - spectrogramContentMode, + contoursEnabled, + imageOpacity, + contourOpacity, contoursLoading, - setSpectrogramContentMode, + setContoursEnabled, + toggleContoursEnabled, loadContours, clearContours, computedPulseAnnotations, diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index 9b74a3fb..9255206a 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -78,9 +78,7 @@ export default defineComponent({ toggleDrawingBoundingBox, fixedAxes, toggleFixedAxes, - spectrogramContentMode, contoursLoading, - setSpectrogramContentMode, clearContours, nextUnsubmittedRecordingId, previousUnsubmittedRecordingId, @@ -291,13 +289,6 @@ export default defineComponent({ router.push({ path: `/recording/${previousUnsubmittedRecordingId.value}/spectrogram`, replace: true }); } - function toggleContentMode() { - if (spectrogramContentMode.value === 'image') { - setSpectrogramContentMode('contour'); - } else { - setSpectrogramContentMode('image'); - } - } return { configuration, annotationState, @@ -336,8 +327,6 @@ export default defineComponent({ boundingBoxError, fixedAxes, toggleFixedAxes, - spectrogramContentMode, - toggleContentMode, contoursLoading, // Other user selection otherUserAnnotations, @@ -588,8 +577,8 @@ export default defineComponent({
-
- +
+
From a5ebef324023039813723feffe3eeaf06b24ceeb Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 30 Jan 2026 08:15:56 -0500 Subject: [PATCH 16/42] contour testing --- scripts/.gitignore | 3 ++ scripts/batbot/batbot_spectrogram.py | 20 ++++++----- scripts/contours/extract_countours.py | 49 ++++++++++++--------------- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/scripts/.gitignore b/scripts/.gitignore index 87078541..b50c6e1f 100644 --- a/scripts/.gitignore +++ b/scripts/.gitignore @@ -1,3 +1,6 @@ /**/*.json /**/*.svg +/**/*.jpg +/**/*.png +/**/*.tif *.jpg diff --git a/scripts/batbot/batbot_spectrogram.py b/scripts/batbot/batbot_spectrogram.py index fb2ab2c4..39c84cf2 100644 --- a/scripts/batbot/batbot_spectrogram.py +++ b/scripts/batbot/batbot_spectrogram.py @@ -268,8 +268,10 @@ def working_directory(path): os.chdir(previous) -def generate_spectrogram_assets(recording_path: str, output_folder: str): - batbot.pipeline(recording_path, output_folder=output_folder) +def generate_spectrogram_assets( + recording_path: str, output_folder: str, debug: bool = False +) -> SpectrogramAssets: + batbot.pipeline(recording_path, output_folder=output_folder, debug=debug) # There should be a .metadata.json file in the output_base directory by replacing extentions metadata_file = Path(recording_path).with_suffix('.metadata.json').name metadata_file = Path(output_folder) / metadata_file @@ -359,11 +361,13 @@ def pipeline_filepath_validator(ctx, param, value): default=None, type=str, ) -def pipeline( - filepath, - config, - output_path, -): +@click.option( + '-d', + '--debug/', + default=False, + help='Enable debug mode with more verbose logging', +) +def pipeline(filepath, config, output_path, debug): """ Run the BatBot pipeline on an input WAV filepath. @@ -381,7 +385,7 @@ def pipeline( config = config.strip().lower() # classifier_thresh /= 100.0 - results = generate_spectrogram_assets(filepath, output_folder=output_path) + results = generate_spectrogram_assets(filepath, output_folder=output_path, debug=debug) # save the assets to a json file with open(Path(output_path) / 'spectrogram_assets.json', 'w') as f: json.dump(results, f, indent=4) diff --git a/scripts/contours/extract_countours.py b/scripts/contours/extract_countours.py index 66ad6fa4..a6fae579 100644 --- a/scripts/contours/extract_countours.py +++ b/scripts/contours/extract_countours.py @@ -312,6 +312,8 @@ def apply_transparency_mask(mat, threshold_percent): def extract_contours( image_path: Path, + output_path: Path | None = None, + debug: bool = False, *, levels_mode: str, percentile_values, @@ -328,11 +330,23 @@ def extract_contours( # Convert to grayscale first gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) - print(f'Applying noise filter: {apply_noise_filter} with threshold: {noise_threshold}') if apply_noise_filter and noise_threshold is not None: + print(f'Applying noise filter: {apply_noise_filter} with threshold: {noise_threshold}') # Create mask of pixels above threshold in original image # print out min and max of gray - gray = np.where(gray < noise_threshold, 0, gray) + filtered = gray.copy() + filtered[filtered < noise_threshold] = 0 + print(gray.mean()) + if debug: + debug_path = output_path / 'filtered.jpg' if output_path else None + unfiltered_path = output_path / 'unfiltered.jpg' if output_path else None + if debug_path: + cv2.imwrite(str(debug_path), filtered) + print('Wrote filtered debug image:', debug_path) + if unfiltered_path: + cv2.imwrite(str(unfiltered_path), gray) + print('Wrote unfiltered debug image:', unfiltered_path) + blurred = cv2.GaussianBlur(gray, (15, 15), 3) else: blurred = cv2.GaussianBlur(gray, (15, 15), 3) @@ -442,6 +456,8 @@ def main(input_path: str, out_dir, verbose, debug_images, **kwargs): # Extract all contours from the compressed image contours, shape = extract_contours( img_path, + output_path=out_dir, + debug=debug_images, levels_mode=kwargs['levels_mode'], percentile_values=kwargs['percentiles'], min_area=kwargs['min_area'], @@ -456,31 +472,6 @@ def main(input_path: str, out_dir, verbose, debug_images, **kwargs): apply_noise_filter=False, ) - # Optionally write debug images (unfiltered and filtered) so that - # the effect of the noise filter can be visualized. These are - # written into the same output directory as the contour SVG/JSON. - if debug_images: - img_color = cv2.imread(str(img_path)) - if img_color is None: - logger.warning('Could not read image for debug output: %s', img_path) - else: - gray = cv2.cvtColor(img_color, cv2.COLOR_BGR2GRAY) - blurred = cv2.GaussianBlur(gray, (15, 15), 3) - - unfiltered_path = out_dir / f'{img_path.stem}.unfiltered.jpg' - cv2.imwrite(str(unfiltered_path), blurred) - logger.info('Wrote unfiltered debug image: %s', unfiltered_path) - - if noise_threshold is not None: - # Match extract_contours behavior: blur first, then mask out pixels - # that were below threshold in original (so zeros don't spread) - mask = gray >= noise_threshold - filtered = cv2.GaussianBlur(gray, (15, 15), 3) - filtered[~mask] = 0 - filtered_path = out_dir / f'{img_path.stem}.filtered.jpg' - cv2.imwrite(str(filtered_path), filtered) - logger.info('Wrote filtered debug image: %s', filtered_path) - # Calculate segment boundaries based on widths segment_boundaries: list[tuple[float, float]] = [] cumulative_x = 0.0 @@ -557,6 +548,8 @@ def main(input_path: str, out_dir, verbose, debug_images, **kwargs): if noise_threshold is not None: contours_nf, _shape_nf = extract_contours( img_path, + debug=debug_images, + output_path=out_dir, levels_mode=kwargs['levels_mode'], percentile_values=kwargs['percentiles'], min_area=kwargs['min_area'], @@ -601,6 +594,8 @@ def main(input_path: str, out_dir, verbose, debug_images, **kwargs): img_path = path contours, shape = extract_contours( img_path, + output_path=out_dir, + debug=debug_images, levels_mode=kwargs['levels_mode'], percentile_values=kwargs['percentiles'], min_area=kwargs['min_area'], From d95e58b9b079dd090da356f11f355b193aaceeb0 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 30 Jan 2026 08:42:21 -0500 Subject: [PATCH 17/42] fix NABat spectrogram generation --- .../core/management/commands/generateNABat.py | 2 +- bats_ai/core/utils/batbot_metadata.py | 2 +- bats_ai/utils/spectrogram_utils.py | 92 ++++++++++++++++++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/bats_ai/core/management/commands/generateNABat.py b/bats_ai/core/management/commands/generateNABat.py index d8d6dc81..49aceec4 100644 --- a/bats_ai/core/management/commands/generateNABat.py +++ b/bats_ai/core/management/commands/generateNABat.py @@ -12,10 +12,10 @@ from bats_ai.core.models import Species from bats_ai.core.models.nabat import NABatRecording, NABatRecordingAnnotation +from bats_ai.core.utils.batbot_metadata import generate_spectrogram_assets from bats_ai.utils.spectrogram_utils import ( generate_nabat_compressed_spectrogram, generate_nabat_spectrogram, - generate_spectrogram_assets, ) fake = Faker() diff --git a/bats_ai/core/utils/batbot_metadata.py b/bats_ai/core/utils/batbot_metadata.py index 802e95ca..dab0c0bc 100644 --- a/bats_ai/core/utils/batbot_metadata.py +++ b/bats_ai/core/utils/batbot_metadata.py @@ -266,7 +266,7 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str): metadata.frequencies.max_hz compressed_metadata = convert_to_compressed_spectrogram_data(metadata) - result = { + result: SpectrogramAssets = { 'duration': metadata.duration_ms, 'freq_min': metadata.frequencies.min_hz, 'freq_max': metadata.frequencies.max_hz, diff --git a/bats_ai/utils/spectrogram_utils.py b/bats_ai/utils/spectrogram_utils.py index 32f1bafb..2f60a3bc 100644 --- a/bats_ai/utils/spectrogram_utils.py +++ b/bats_ai/utils/spectrogram_utils.py @@ -5,17 +5,42 @@ from typing import TypedDict import cv2 +from django.contrib.contenttypes.models import ContentType +from django.core.files import File import numpy as np import onnx import onnxruntime as ort import tqdm -from bats_ai.core.models import CompressedSpectrogram -from bats_ai.core.models.nabat import NABatCompressedSpectrogram +from bats_ai.core.models import CompressedSpectrogram, SpectrogramImage +from bats_ai.core.models.nabat import NABatCompressedSpectrogram, NABatRecording, NABatSpectrogram logger = logging.getLogger(__name__) +class SpectrogramAssetResult(TypedDict): + paths: list[str] + width: int + height: int + + +class SpectrogramCompressedAssetResult(TypedDict): + paths: list[str] + width: int + height: int + widths: list[float] + starts: list[float] + stops: list[float] + + +class SpectrogramAssets(TypedDict): + duration: float + freq_min: int + freq_max: int + normal: SpectrogramAssetResult + compressed: SpectrogramCompressedAssetResult + + class PredictionOutput(TypedDict): label: str score: float @@ -103,3 +128,66 @@ def predict_from_compressed( confs = dict(zip(labels, outputs)) return {'label': label, 'score': score, 'confs': confs} + + +def generate_nabat_spectrogram( + nabat_recording: NABatRecording, results: SpectrogramAssets +) -> NABatSpectrogram: + spectrogram, _ = NABatSpectrogram.objects.get_or_create( + nabat_recording=nabat_recording, + defaults={ + 'width': results['normal']['width'], + 'height': results['normal']['height'], + 'duration': results['duration'], + 'frequency_min': results['freq_min'], + 'frequency_max': results['freq_max'], + }, + ) + + # Create SpectrogramImage objects for each normal image + for idx, img_path in enumerate(results['normal']['paths']): + with open(img_path, 'rb') as f: + SpectrogramImage.objects.get_or_create( + content_type=ContentType.objects.get_for_model(spectrogram), + object_id=spectrogram.id, + index=idx, + defaults={ + 'image_file': File(f, name=os.path.basename(img_path)), + 'type': 'spectrogram', + }, + ) + + return spectrogram + + +def generate_nabat_compressed_spectrogram( + nabat_recording: NABatRecording, + spectrogram: NABatSpectrogram, + compressed_results: SpectrogramCompressedAssetResult, +) -> NABatCompressedSpectrogram: + compressed_obj, _ = NABatCompressedSpectrogram.objects.get_or_create( + nabat_recording=nabat_recording, + spectrogram=spectrogram, + defaults={ + 'length': compressed_results['width'], + 'widths': compressed_results['widths'], + 'starts': compressed_results['starts'], + 'stops': compressed_results['stops'], + 'cache_invalidated': False, + }, + ) + + # Save compressed images + for idx, img_path in enumerate(compressed_results['paths']): + with open(img_path, 'rb') as f: + SpectrogramImage.objects.get_or_create( + content_type=ContentType.objects.get_for_model(compressed_obj), + object_id=compressed_obj.id, + index=idx, + defaults={ + 'image_file': File(f, name=os.path.basename(img_path)), + 'type': 'compressed', + }, + ) + + return compressed_obj From 0bed12ad242818cf73f1f1bccd4577f9b55d10e8 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 30 Jan 2026 08:56:14 -0500 Subject: [PATCH 18/42] client linting --- .../src/components/TransparencyFilterControl.vue | 16 ++++++++-------- client/src/components/geoJS/LayerManager.vue | 3 ++- client/src/views/Recordings.vue | 1 - 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/client/src/components/TransparencyFilterControl.vue b/client/src/components/TransparencyFilterControl.vue index 26d0a358..291bc9f8 100644 --- a/client/src/components/TransparencyFilterControl.vue +++ b/client/src/components/TransparencyFilterControl.vue @@ -21,22 +21,22 @@ export default defineComponent({ open-on-hover > + result="transparency-mask" + > Date: Fri, 30 Jan 2026 08:56:54 -0500 Subject: [PATCH 19/42] removing integration notes --- BATBOT_Integration_Notes.md | 48 ------------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 BATBOT_Integration_Notes.md diff --git a/BATBOT_Integration_Notes.md b/BATBOT_Integration_Notes.md deleted file mode 100644 index 409639ac..00000000 --- a/BATBOT_Integration_Notes.md +++ /dev/null @@ -1,48 +0,0 @@ -# BatBot Integration Notes - -BatBot has some git lfs issues with installing initially -requires the `UV_GIT_LFS=1` to be set -As well as `GIT_LFS_SKIP_SMUDGE=1` - -Batbot data exported: -Files of the structure: '01of01.compressed.jpg' -Also for uncompressed is '01of02.jpg' and '02of02.jpg' - -There us a metadata.json file that is exported out from the code - -Document the structure of this metadata json data -It needs to be converted into the widths, starts, stops, and length -`size.uncompressed.width.px` -`size.compressed.width.px` -`segments` is an array that can be formatted by extracting -`segments[0]["start.ms"]` -`segments[0]["end.ms"]` - -From there we have the total width and the total time for the invidiual segments we can calulate a pixels/ms - -Might need to see if we can change it so that the sytstem instead uses a way where we use the raw time and -don't use the invividual widths at all for calculations. This may require some front end work as well. - -Tasks: - -- ~~Add batbot dependency to UV installation~~ -- ~~Swap spectrogram creation to use batbot pipeline without a config~~ -- ~~Create a converter to calculate the length, starts, stops, widths~~ - -Updates: - -- batbot is added a dependency -- tasks.py is updated so that the utilities the new batbot function to create spectrograms -- inference is disabled - -TODO: - -- ~~Remove older generation code from the system~~ -- remove inference code from the system -- ~~Remove the local installation of batbot once updated from:~~ - - ~~docker-compose.overrride.yml~~ - - ~~pyproject.toml~~ - - ~uv.lock~~ -- ~~Can temporarily use my branch on the repo (issue-4-output-folder-option)~~ -- using `temp-compressed-time-fix` branch because it has alignment fixes for start/stops -- Update sample script uv dependencies when batbot is published~~ From 52f9ba62f740a6be2a60cd4e6d9da6025551815f Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 30 Jan 2026 10:49:59 -0500 Subject: [PATCH 20/42] use masks for contours --- bats_ai/core/management/commands/loadGRTS.py | 62 +++++++++++++------- bats_ai/core/utils/batbot_metadata.py | 4 ++ bats_ai/core/utils/contour_utils.py | 9 +-- pyproject.toml | 2 +- scripts/batbot/batbot_spectrogram.py | 6 +- scripts/contours/extract_countours.py | 16 ++--- uv.lock | 6 +- 7 files changed, 67 insertions(+), 38 deletions(-) diff --git a/bats_ai/core/management/commands/loadGRTS.py b/bats_ai/core/management/commands/loadGRTS.py index f6548eb2..502b2712 100644 --- a/bats_ai/core/management/commands/loadGRTS.py +++ b/bats_ai/core/management/commands/loadGRTS.py @@ -1,6 +1,7 @@ import logging import os import tempfile +import urllib from urllib.request import urlretrieve import zipfile @@ -18,27 +19,29 @@ 'https://www.sciencebase.gov/catalog/file/get/5b7753bde4b0f5d578820455?facet=conus_mastersample_10km_GRTS', # noqa: E501 14, 'CONUS', + # Backup URL + 'https://data.kitware.com/api/v1/item/697cc601e7dea9be44ec5aee/download', # noqa: E501 ), # CONUS - ( - 'https://www.sciencebase.gov/catalog/file/get/5b7753a8e4b0f5d578820452?facet=akcan_mastersample_10km_GRTS', # noqa: E501 - 20, - 'Alaska/Canada', - ), # Alaska/Canada - ( - 'https://www.sciencebase.gov/catalog/file/get/5b7753c2e4b0f5d578820457?facet=HI_mastersample_5km_GRTS', # noqa: E501 - 15, - 'Hawaii', - ), # Hawaii - ( - 'https://www.sciencebase.gov/catalog/file/get/5b7753d3e4b0f5d578820459?facet=mex_mastersample_10km_GRTS', # noqa: E501 - 12, - 'Mexico', - ), # Mexico - ( - 'https://www.sciencebase.gov/catalog/file/get/5b7753d8e4b0f5d57882045b?facet=PR_mastersample_5km_GRTS', # noqa: E501 - 21, - 'Puerto Rico', - ), # Puerto Rico + # ( + # 'https://www.sciencebase.gov/catalog/file/get/5b7753a8e4b0f5d578820452?facet=akcan_mastersample_10km_GRTS', # noqa: E501 + # 20, + # 'Alaska/Canada', + # ), # Alaska/Canada + # ( + # 'https://www.sciencebase.gov/catalog/file/get/5b7753c2e4b0f5d578820457?facet=HI_mastersample_5km_GRTS', # noqa: E501 + # 15, + # 'Hawaii', + # ), # Hawaii + # ( + # 'https://www.sciencebase.gov/catalog/file/get/5b7753d3e4b0f5d578820459?facet=mex_mastersample_10km_GRTS', # noqa: E501 + # 12, + # 'Mexico', + # ), # Mexico + # ( + # 'https://www.sciencebase.gov/catalog/file/get/5b7753d8e4b0f5d57882045b?facet=PR_mastersample_5km_GRTS', # noqa: E501 + # 21, + # 'Puerto Rico', + # ), # Puerto Rico ] @@ -56,11 +59,26 @@ def handle(self, *args, **options): # Track existing IDs to avoid duplicates existing_ids = set(GRTSCells.objects.values_list('id', flat=True)) - for url, sample_frame_id, name in SHAPEFILES: + for url, sample_frame_id, name, backup_url in SHAPEFILES: logger.info(f'Downloading shapefile for Location {name}...') with tempfile.TemporaryDirectory() as tmpdir: zip_path = os.path.join(tmpdir, 'file.zip') - urlretrieve(url, zip_path) + try: + urlretrieve(url, zip_path) + except urllib.error.URLError as e: + logger.warning( + f'Failed to download from primary URL: {e}. \ + Attempting backup URL...' + ) + if backup_url is None: + logger.warning('No backup URL provided, skipping this shapefile.') + continue + try: + urlretrieve(backup_url, zip_path) + except urllib.error.URLError as e2: + raise CommandError( + f'Failed to download from backup URL as well: {e2}' + ) from e2 logger.info(f'Downloaded to {zip_path}') logger.info('Extracting zip file...') diff --git a/bats_ai/core/utils/batbot_metadata.py b/bats_ai/core/utils/batbot_metadata.py index 90da4815..730a5083 100644 --- a/bats_ai/core/utils/batbot_metadata.py +++ b/bats_ai/core/utils/batbot_metadata.py @@ -15,6 +15,7 @@ class SpectrogramMetadata(BaseModel): uncompressed_path: list[str] = Field(alias='uncompressed.path') compressed_path: list[str] = Field(alias='compressed.path') + mask_path: list[str] = Field(alias='mask.path') class UncompressedSize(BaseModel): @@ -229,6 +230,7 @@ class SpectrogramAssetResult(TypedDict): class SpectrogramCompressedAssetResult(TypedDict): paths: list[str] + masks: list[str] width: int height: int widths: list[float] @@ -286,6 +288,7 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str): # from the metadata we should have the images that are used uncompressed_paths = metadata.spectrogram.uncompressed_path compressed_paths = metadata.spectrogram.compressed_path + mask_paths = metadata.spectrogram.mask_path metadata.frequencies.min_hz metadata.frequencies.max_hz @@ -302,6 +305,7 @@ def generate_spectrogram_assets(recording_path: str, output_folder: str): }, 'compressed': { 'paths': compressed_paths, + 'masks': mask_paths, 'width': metadata.size.compressed.width_px, 'height': metadata.size.compressed.height_px, 'widths': compressed_metadata.widths, diff --git a/bats_ai/core/utils/contour_utils.py b/bats_ai/core/utils/contour_utils.py index 3861b2d6..46b25f14 100644 --- a/bats_ai/core/utils/contour_utils.py +++ b/bats_ai/core/utils/contour_utils.py @@ -351,8 +351,8 @@ def extract_contours( def process_spectrogram_assets_for_contours( assets: dict[str, Any], levels_mode: str = 'percentile', - percentile_values: list[float] = (90, 92, 94, 96, 98), - min_area: float = 500.0, + percentile_values: list[float] = (60, 70, 80, 90, 92, 94, 96, 98), + min_area: float = 30.0, smoothing_factor: float = 0.08, min_intensity: float = 1.0, multi_otsu_classes: int = 4, @@ -364,7 +364,8 @@ def process_spectrogram_assets_for_contours( apply_noise_filter: bool = False, ): compressed_data = assets.get('compressed', {}) - compressed_paths = compressed_data.get('paths', []) + compressed_data.get('paths', []) + mask_paths = compressed_data.get('masks', []) widths = compressed_data.get('widths', []) height = compressed_data.get('height', 0) starts = compressed_data.get('starts', []) @@ -374,7 +375,7 @@ def process_spectrogram_assets_for_contours( all_segments_data = [] processed_images: set[Path] = set() - for path_str in compressed_paths: + for path_str in mask_paths: img_path = Path(path_str).resolve() if not img_path.exists(): logger.warning('Image path does not exist: %s', img_path) diff --git a/pyproject.toml b/pyproject.toml index 35a0f4b4..057cb1c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,7 +123,7 @@ explicit = true [tool.uv.sources] gdal = { index = "large_image_wheels" } -batbot = { git = "https://github.com/Kitware/batbot" } +batbot = { git = "https://github.com/Kitware/batbot", branch = "jrp/compressed-mask" } [tool.black] diff --git a/scripts/batbot/batbot_spectrogram.py b/scripts/batbot/batbot_spectrogram.py index 39c84cf2..03c260cb 100644 --- a/scripts/batbot/batbot_spectrogram.py +++ b/scripts/batbot/batbot_spectrogram.py @@ -7,7 +7,7 @@ # ] # # [tool.uv.sources] -# batbot = { git = "https://github.com/Kitware/batbot" } +# batbot = { git = "https://github.com/Kitware/batbot", branch = "jrp/compressed-mask" } # /// from contextlib import contextmanager import json @@ -27,6 +27,7 @@ class SpectrogramMetadata(BaseModel): uncompressed_path: list[str] = Field(alias='uncompressed.path') compressed_path: list[str] = Field(alias='compressed.path') + mask_path: list[str] = Field(alias='mask.path') class UncompressedSize(BaseModel): @@ -286,6 +287,7 @@ def _normalize_paths(paths: list[str]) -> list[str]: uncompressed_paths = _normalize_paths(metadata.spectrogram.uncompressed_path) compressed_paths = _normalize_paths(metadata.spectrogram.compressed_path) + mask_paths = _normalize_paths(metadata.spectrogram.mask_path) metadata.frequencies.min_hz metadata.frequencies.max_hz @@ -324,6 +326,7 @@ def _normalize_paths(paths: list[str]) -> list[str]: }, 'compressed': { 'paths': compressed_paths, + 'masks': mask_paths, 'width': metadata.size.compressed.width_px, 'height': metadata.size.compressed.height_px, 'widths': compressed_metadata.widths, @@ -364,6 +367,7 @@ def pipeline_filepath_validator(ctx, param, value): @click.option( '-d', '--debug/', + is_flag=True, default=False, help='Enable debug mode with more verbose logging', ) diff --git a/scripts/contours/extract_countours.py b/scripts/contours/extract_countours.py index a6fae579..f933c8fe 100644 --- a/scripts/contours/extract_countours.py +++ b/scripts/contours/extract_countours.py @@ -347,10 +347,7 @@ def extract_contours( cv2.imwrite(str(unfiltered_path), gray) print('Wrote unfiltered debug image:', unfiltered_path) - blurred = cv2.GaussianBlur(gray, (15, 15), 3) - else: - blurred = cv2.GaussianBlur(gray, (15, 15), 3) - data = blurred + data = gray levels = compute_auto_levels( data, @@ -359,6 +356,8 @@ def extract_contours( **level_kwargs, ) + print(levels) + contours = [] for level in levels: for c in measure.find_contours(data, level): @@ -392,8 +391,8 @@ def extract_contours( type=click.Choice(['percentile', 'histogram', 'multi-otsu']), default='percentile', ) -@click.option('--percentiles', multiple=True, default=(90, 92, 94, 96, 98)) -@click.option('--min-area', default=500.0) +@click.option('--percentiles', multiple=True, default=(60, 70, 80, 90, 92, 94, 96, 98)) +@click.option('--min-area', default=30.0) @click.option('--smoothing-factor', default=0.08) @click.option('--multi-otsu-classes', default=4) @click.option('--hist-bins', default=512) @@ -432,7 +431,8 @@ def main(input_path: str, out_dir, verbose, debug_images, **kwargs): # Get compressed paths and widths/starts/stops compressed_data = assets_data.get('compressed', {}) - compressed_paths = compressed_data.get('paths', []) + compressed_data.get('paths', []) + mask_paths = compressed_data.get('masks', []) widths = compressed_data.get('widths', []) starts = compressed_data.get('starts', []) stops = compressed_data.get('stops', []) @@ -442,7 +442,7 @@ def main(input_path: str, out_dir, verbose, debug_images, **kwargs): # Process each unique compressed image processed_images: set[Path] = set() - for path_str in compressed_paths: + for path_str in mask_paths: img_path = (assets_dir / path_str).resolve() if not img_path.exists(): logger.warning('Image path does not exist: %s', img_path) diff --git a/uv.lock b/uv.lock index e12657b9..db9df72b 100644 --- a/uv.lock +++ b/uv.lock @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "batbot" version = "0.1.0" -source = { git = "https://github.com/Kitware/batbot#570a97db3a1eab6890667b26e77575cc9d40454d" } +source = { git = "https://github.com/Kitware/batbot?branch=jrp%2Fcompressed-mask#6a5a95e84e79bb30ca9a24afd2644c1a32a940b5" } dependencies = [ { name = "click" }, { name = "cryptography" }, @@ -233,6 +233,7 @@ dependencies = [ { name = "rich" }, { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sentry-sdk", extra = ["celery", "django", "pure-eval"] }, { name = "svgwrite" }, { name = "tqdm" }, { name = "whitenoise", extra = ["brotli"] }, @@ -285,7 +286,7 @@ type = [ [package.metadata] requires-dist = [ - { name = "batbot", git = "https://github.com/Kitware/batbot" }, + { name = "batbot", git = "https://github.com/Kitware/batbot?branch=jrp%2Fcompressed-mask" }, { name = "celery" }, { name = "django", extras = ["argon2"], specifier = ">=4.2,<5" }, { name = "django-allauth" }, @@ -323,6 +324,7 @@ requires-dist = [ { name = "pydantic" }, { name = "rich" }, { name = "scikit-image", specifier = ">=0.25.2" }, + { name = "sentry-sdk", extras = ["celery", "django", "pure-eval"] }, { name = "svgwrite", specifier = ">=1.4.3" }, { name = "tqdm" }, { name = "watchdog", marker = "extra == 'development'" }, From 6a6fa8fa622ca0ca4c6c7b7848b109743469f1ce Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 30 Jan 2026 10:59:09 -0500 Subject: [PATCH 21/42] save mask images along compressed spectrograms --- bats_ai/core/admin/compressed_spectrogram.py | 12 +++++++++ .../0028_alter_spectrogramimage_type.py | 25 +++++++++++++++++++ bats_ai/core/models/compressed_spectrogram.py | 12 +++++++++ .../nabat/nabat_compressed_spectrogram.py | 12 +++++++++ bats_ai/core/models/spectrogram_image.py | 1 + bats_ai/core/tasks/tasks.py | 13 ++++++++++ bats_ai/core/views/nabat/nabat_recording.py | 1 + bats_ai/core/views/recording.py | 1 + bats_ai/utils/spectrogram_utils.py | 14 +++++++++++ 9 files changed, 91 insertions(+) create mode 100644 bats_ai/core/migrations/0028_alter_spectrogramimage_type.py diff --git a/bats_ai/core/admin/compressed_spectrogram.py b/bats_ai/core/admin/compressed_spectrogram.py index 5744c7d9..96d27923 100644 --- a/bats_ai/core/admin/compressed_spectrogram.py +++ b/bats_ai/core/admin/compressed_spectrogram.py @@ -15,6 +15,7 @@ class CompressedSpectrogramAdmin(admin.ModelAdmin): 'starts', 'stops', 'image_url_list_display', + 'mask_url_list_display', ] list_display_links = ['pk', 'recording', 'spectrogram'] list_select_related = True @@ -28,6 +29,7 @@ class CompressedSpectrogramAdmin(admin.ModelAdmin): 'starts', 'stops', 'image_url_list_display', + 'mask_url_list_display', ] @admin.display(description='Image URLs') @@ -39,3 +41,13 @@ def image_url_list_display(self, obj): return format_html_join( '\n', '', ((url, url) for url in urls) ) + + @admin.display(description='Mask URLs') + def mask_url_list_display(self, obj): + """Render each mask URL as a clickable link in admin detail view.""" + urls = obj.mask_url_list + if not urls: + return '(No masks)' + return format_html_join( + '\n', '', ((url, url) for url in urls) + ) diff --git a/bats_ai/core/migrations/0028_alter_spectrogramimage_type.py b/bats_ai/core/migrations/0028_alter_spectrogramimage_type.py new file mode 100644 index 00000000..2d04205d --- /dev/null +++ b/bats_ai/core/migrations/0028_alter_spectrogramimage_type.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.23 on 2026-01-30 15:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0027_pulsemetadata'), + ] + + operations = [ + migrations.AlterField( + model_name='spectrogramimage', + name='type', + field=models.CharField( + choices=[ + ('spectrogram', 'Spectrogram'), + ('compressed', 'Compressed'), + ('masks', 'Masks'), + ], + default='spectrogram', + max_length=20, + ), + ), + ] diff --git a/bats_ai/core/models/compressed_spectrogram.py b/bats_ai/core/models/compressed_spectrogram.py index 089dd4e6..4ccdbb86 100644 --- a/bats_ai/core/models/compressed_spectrogram.py +++ b/bats_ai/core/models/compressed_spectrogram.py @@ -28,12 +28,24 @@ def image_url_list(self): images = self.images.filter(type='compressed').order_by('index') return [default_storage.url(img.image_file.name) for img in images] + @property + def mask_url_list(self): + """Ordered list of mask image URLs for this spectrogram.""" + images = self.images.filter(type='masks').order_by('index') + return [default_storage.url(img.image_file.name) for img in images] + @property def image_pil_list(self): """List of PIL images in order.""" images = self.images.filter(type='compressed').order_by('index') return [Image.open(img.image_file) for img in images] + @property + def mask_pil_list(self): + """List of PIL mask images in order.""" + images = self.images.filter(type='masks').order_by('index') + return [Image.open(img.image_file) for img in images] + @property def image_np(self): """Combined image as a single numpy array by horizontal stacking.""" diff --git a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py index d6b584bd..bf6306fe 100644 --- a/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py +++ b/bats_ai/core/models/nabat/nabat_compressed_spectrogram.py @@ -29,12 +29,24 @@ def image_url_list(self): images = self.images.filter(type='compressed').order_by('index') return [default_storage.url(img.image_file.name) for img in images] + @property + def mask_url_list(self): + """Ordered list of mask image URLs for this spectrogram.""" + images = self.images.filter(type='masks').order_by('index') + return [default_storage.url(img.image_file.name) for img in images] + @property def image_pil_list(self): """List of PIL images in order.""" images = self.images.filter(type='compressed').order_by('index') return [Image.open(img.image_file) for img in images] + @property + def mask_pil_list(self): + """List of PIL mask images in order.""" + images = self.images.filter(type='masks').order_by('index') + return [Image.open(img.image_file) for img in images] + @property def image_np(self): """Combined image as a single numpy array by horizontal stacking.""" diff --git a/bats_ai/core/models/spectrogram_image.py b/bats_ai/core/models/spectrogram_image.py index f9ac7188..08b14ce1 100644 --- a/bats_ai/core/models/spectrogram_image.py +++ b/bats_ai/core/models/spectrogram_image.py @@ -20,6 +20,7 @@ class SpectrogramImage(models.Model): SPECTROGRAM_TYPE_CHOICES = [ ('spectrogram', 'Spectrogram'), ('compressed', 'Compressed'), + ('masks', 'Masks'), ] content_object = GenericForeignKey('content_type', 'object_id') diff --git a/bats_ai/core/tasks/tasks.py b/bats_ai/core/tasks/tasks.py index 0c1a18e1..e221571a 100644 --- a/bats_ai/core/tasks/tasks.py +++ b/bats_ai/core/tasks/tasks.py @@ -90,6 +90,19 @@ def recording_compute_spectrogram(recording_id: int): }, ) + # Save mask images (from batbot metadata mask_path) + for idx, mask_path in enumerate(compressed.get('masks', [])): + with open(mask_path, 'rb') as f: + SpectrogramImage.objects.get_or_create( + content_type=ContentType.objects.get_for_model(compressed_obj), + object_id=compressed_obj.id, + index=idx, + type='masks', + defaults={ + 'image_file': File(f, name=os.path.basename(mask_path)), + }, + ) + # Create SpectrogramContour objects for each segment for segment in results['segments']['segments']: PulseMetadata.objects.get_or_create( diff --git a/bats_ai/core/views/nabat/nabat_recording.py b/bats_ai/core/views/nabat/nabat_recording.py index 9ce57349..dd0dc705 100644 --- a/bats_ai/core/views/nabat/nabat_recording.py +++ b/bats_ai/core/views/nabat/nabat_recording.py @@ -318,6 +318,7 @@ def get_spectrogram_compressed(request: HttpRequest, id: int, apiToken: str): spectro_data = { 'urls': compressed_spectrogram.image_url_list, + 'mask_urls': compressed_spectrogram.mask_url_list, 'spectroInfo': { 'spectroId': compressed_spectrogram.pk, 'width': compressed_spectrogram.spectrogram.width, diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 5cb5309c..3763f681 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -476,6 +476,7 @@ def get_spectrogram_compressed(request: HttpRequest, id: int): spectro_data = { 'urls': compressed_spectrogram.image_url_list, + 'mask_urls': compressed_spectrogram.mask_url_list, 'spectroInfo': { 'spectroId': compressed_spectrogram.pk, 'width': compressed_spectrogram.spectrogram.width, diff --git a/bats_ai/utils/spectrogram_utils.py b/bats_ai/utils/spectrogram_utils.py index 2f60a3bc..1d2d178a 100644 --- a/bats_ai/utils/spectrogram_utils.py +++ b/bats_ai/utils/spectrogram_utils.py @@ -26,6 +26,7 @@ class SpectrogramAssetResult(TypedDict): class SpectrogramCompressedAssetResult(TypedDict): paths: list[str] + masks: list[str] width: int height: int widths: list[float] @@ -190,4 +191,17 @@ def generate_nabat_compressed_spectrogram( }, ) + # Save mask images (from batbot metadata mask_path) + for idx, mask_path in enumerate(compressed_results.get('masks', [])): + with open(mask_path, 'rb') as f: + SpectrogramImage.objects.get_or_create( + content_type=ContentType.objects.get_for_model(compressed_obj), + object_id=compressed_obj.id, + index=idx, + type='masks', + defaults={ + 'image_file': File(f, name=os.path.basename(mask_path)), + }, + ) + return compressed_obj From 6b43dfe062390ab640d111b7ae82ce36d1289ac5 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 30 Jan 2026 12:04:05 -0500 Subject: [PATCH 22/42] contour and mask UI --- client/src/api/api.ts | 1 + .../SpectrogramImageContentMenu.vue | 162 ++++++++++++------ client/src/components/SpectrogramViewer.vue | 26 +++ client/src/components/ThumbnailViewer.vue | 24 +++ client/src/components/geoJS/LayerManager.vue | 6 +- client/src/components/geoJS/geoJSUtils.ts | 107 +++++++++--- .../components/geoJS/layers/contourLayer.ts | 75 ++++---- client/src/use/useState.ts | 5 + client/src/views/Spectrogram.vue | 19 +- 9 files changed, 321 insertions(+), 104 deletions(-) diff --git a/client/src/api/api.ts b/client/src/api/api.ts index eb2443cd..271cd31e 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -120,6 +120,7 @@ export interface UpdateFileAnnotation { export interface Spectrogram { urls: string[]; + mask_urls: string[]; filename?: string; annotations?: SpectrogramAnnotation[]; fileAnnotations: FileAnnotation[]; diff --git a/client/src/components/SpectrogramImageContentMenu.vue b/client/src/components/SpectrogramImageContentMenu.vue index c955c26d..9063d7b8 100644 --- a/client/src/components/SpectrogramImageContentMenu.vue +++ b/client/src/components/SpectrogramImageContentMenu.vue @@ -4,6 +4,7 @@ import useState from '@use/useState'; defineProps<{ compressed: boolean; + hasMaskUrls?: boolean; }>(); const { @@ -11,7 +12,9 @@ const { imageOpacity, contourOpacity, contoursLoading, - toggleContoursEnabled, + viewMaskOverlay, + maskOverlayOpacity, + setContoursEnabled, } = useState(); const hover = ref(false); @@ -59,6 +62,21 @@ function onContourVisibilityChange(visible: boolean | null) { }); } } + +// Mask and contours are mutually exclusive +function onMaskOverlayChange(checked: boolean) { + viewMaskOverlay.value = checked; + if (checked) { + setContoursEnabled(false); + } +} + +function onContoursEnabledChange(checked: boolean) { + setContoursEnabled(checked); + if (checked) { + viewMaskOverlay.value = false; + } +}