From efeced3818783ede8f42cd2ed5fe8e483a744728 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 23 Jul 2026 15:53:13 -0700 Subject: [PATCH] fix: remove SciPy from core runtime --- .github/workflows/canvas-nodom-ladder.yml | 2 +- .../workflows/citrix-workspace-standin.yml | 2 +- .../workflows/docker-rdp-vision-ladder.yml | 2 +- docs/ECOSYSTEM_INTEGRATION.md | 6 +- openadapt_flow/adapters/capture.py | 42 ++++---- openadapt_flow/image_hash.py | 88 +++++++++++++++++ openadapt_flow/recorder.py | 10 +- .../validation/pixel_identity_probe.py | 20 ++-- openadapt_flow/vision/hashing.py | 8 +- public-artifacts.json | 6 +- pyproject.toml | 1 - tests/test_dependency_security.py | 17 ++++ tests/test_image_hash.py | 97 +++++++++++++++++++ uv.lock | 52 ---------- 14 files changed, 255 insertions(+), 98 deletions(-) create mode 100644 openadapt_flow/image_hash.py create mode 100644 tests/test_image_hash.py diff --git a/.github/workflows/canvas-nodom-ladder.yml b/.github/workflows/canvas-nodom-ladder.yml index 24663dd8..30add0fb 100644 --- a/.github/workflows/canvas-nodom-ladder.yml +++ b/.github/workflows/canvas-nodom-ladder.yml @@ -31,7 +31,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[ocr,cv]" || pip install -e . - pip install opencv-python-headless rapidocr-onnxruntime pillow imagehash playwright + pip install opencv-python-headless rapidocr-onnxruntime pillow numpy playwright python -m playwright install --with-deps chromium - name: Build the no-DOM canvas fixture image diff --git a/.github/workflows/citrix-workspace-standin.yml b/.github/workflows/citrix-workspace-standin.yml index 32aecf50..6c67338a 100644 --- a/.github/workflows/citrix-workspace-standin.yml +++ b/.github/workflows/citrix-workspace-standin.yml @@ -63,7 +63,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[ocr,cv]" || pip install -e . - pip install opencv-python-headless rapidocr-onnxruntime pillow imagehash playwright + pip install opencv-python-headless rapidocr-onnxruntime pillow numpy playwright python -m playwright install --with-deps chromium - name: Build the no-DOM canvas fixture image (Part-1) diff --git a/.github/workflows/docker-rdp-vision-ladder.yml b/.github/workflows/docker-rdp-vision-ladder.yml index 4718819f..09300290 100644 --- a/.github/workflows/docker-rdp-vision-ladder.yml +++ b/.github/workflows/docker-rdp-vision-ladder.yml @@ -58,7 +58,7 @@ jobs: - name: Install flow with the vision + rdp stack run: | python -m pip install --upgrade pip - # Core owns cv2/RapidOCR/Pillow/imagehash; the rdp extra installs the + # Core owns cv2/RapidOCR/Pillow/NumPy hashing; the rdp extra installs the # product transport dependency even though this Linux fixture injects # the swappable DockerX11RdpTransport into the same backend. pip install -e ".[rdp]" diff --git a/docs/ECOSYSTEM_INTEGRATION.md b/docs/ECOSYSTEM_INTEGRATION.md index 0546eb32..0d03f56f 100644 --- a/docs/ECOSYSTEM_INTEGRATION.md +++ b/docs/ECOSYSTEM_INTEGRATION.md @@ -367,7 +367,7 @@ tech debt. Explicit "keep it home-grown" calls: canonical schema. Those are flow's differentiators and don't belong in a passive shared type. 2. **The replay hot path stays dependency-minimal.** `pydantic + opencv + rapidocr + - pillow + imagehash`. Grounding, capture, privacy, and types-interop are all **edges** + pillow + numpy`. Grounding, capture, privacy, and types-interop are all **edges** (compile-time or opt-in), never core. The property that "the whole loop runs in CI with no OS permissions and zero model calls" is the product; protect it. 3. **Postcondition + identity verification stays home-grown.** Nothing in the ecosystem @@ -406,6 +406,6 @@ The governing test for any future integration proposal: | Package | Import weight | On flow's hot path? | |---|---|---| | openadapt-types | pydantic only (light) | No — boundary/optional | -| openadapt-capture | pynput + mss + PyAV + sounddevice (heavy, native, needs perms to record; import stays headless-safe) | No — compile-time on-ramp, optional extra | +| openadapt-capture | Native input/screen APIs plus separately provisioned external FFmpeg for video | No — compile-time recorder on-ramp, optional extra | | openadapt-verifier | stdlib-only (light) | N/A — not integrated | -| flow core | pydantic, numpy, opencv-headless, pillow, imagehash, rapidocr, playwright, httpx | This IS the hot path; keep it here | +| flow core | pydantic, numpy, opencv-headless, pillow, rapidocr, playwright, httpx | This IS the hot path; keep it here | diff --git a/openadapt_flow/adapters/capture.py b/openadapt_flow/adapters/capture.py index 7c137073..1ecd8d31 100644 --- a/openadapt_flow/adapters/capture.py +++ b/openadapt_flow/adapters/capture.py @@ -16,7 +16,7 @@ # mouse_y, mouse_dx, mouse_dy, # mouse_button_name, mouse_pressed, key_name, # key_char, canonical_key_name, ...) - oa_recording-*.mp4 # action-gated screen video + captured media # external-FFmpeg video or supported frame store The adapter is a thin bridge over openadapt-capture's **public API** — it does *no* raw SQL and knows nothing about capture's schema. It calls @@ -54,8 +54,8 @@ key), unmapped named keys, and any unknown input action type all raise instead of being ignored. -Coordinate spaces: capture mouse coordinates are in *logical points* (pynput); -video frames are *physical pixels*. openadapt-flow requires event coordinates in +Coordinate spaces: capture mouse coordinates are in *logical points*; +captured frames are *physical pixels*. openadapt-flow requires event coordinates in the same pixel space as the frames, so points are scaled by ``CaptureSession.pixel_ratio`` (physical / logical). NOTE: sessions recorded with capture >=0.5.4 persist ``pixel_ratio`` on the recording model itself, so @@ -78,7 +78,7 @@ silent-mis-conversion this detection exists to prevent); * takes frames as-is (already the client-window viewport); * validates the static viewport and bounds timeline, refuses a mid-session - resize (capture's MP4 and Flow recordings each have one fixed viewport), + resize (capture media and Flow recordings each have one fixed viewport), verifies extracted frame sizes, and screens every mouse action against that pixel space: window capture records out-of-window input at out-of-range coordinates instead of clamping, and such an action targeted @@ -98,15 +98,14 @@ A window-scoped session that declares a coordinate space this adapter does not understand is refused loudly rather than converted with guessed scaling. -Frame selection: openadapt-capture records **action-gated** video (frames are -encoded around user actions, not continuously). For an event at wall-clock time -``T`` the *before* frame is ``get_frame_at(T)`` and the *after* frame is +Frame selection: ``CaptureSession.get_frame_at`` abstracts captured media +(default external-FFmpeg video and any supported frame store). For an event at +wall-clock time ``T`` the *before* frame is ``get_frame_at(T)`` and the *after* frame is ``get_frame_at(T + settle_s)`` clamped to just before the next event — an approximation of the live Recorder's perceptual-hash settle wait (see -docs/desktop/PHASE1.md). Because the video is action-gated, a per-action frame -may be unavailable; a missing *before* frame for a click is fatal (the compiler -requires it), a missing *after* frame simply yields no postconditions for that -step. +docs/desktop/PHASE1.md). A per-action frame may be unavailable; a missing +*before* frame for a click is fatal (the compiler requires it), while a missing +*after* frame simply yields no postconditions for that step. Scroll deltas: pynput reports wheel *notches* with positive ``dy`` = scroll up, while the flow recording stores *pixels* with positive ``dy`` = view down @@ -121,7 +120,7 @@ a recording of the SAME shape as a web recording, and it compiles into a valid bundle, but it CANNOT carry the ``structural`` locator (UIA ``AutomationId`` or AT-SPI accessible ID / role+name) that a DOM-armed web bundle gets: capture records only -mouse/keyboard/video, so there is no live accessibility tree at conversion time +mouse/keyboard/frame evidence, so there is no live accessibility tree at conversion time to read an element identity from. Every ``anchor.structural`` is therefore None and replay uses the VISUAL ladder (template/ocr/geometry). To get the deterministic structural top rung on desktop, record LIVE over ``WindowsBackend`` @@ -149,8 +148,8 @@ # after each gesture). SCROLL_PIXELS_PER_NOTCH = 100 -# Search window (seconds) for get_frame_at. Capture is action-gated, so frames -# cluster around action timestamps; a generous window finds the nearest one. +# Search window (seconds) for get_frame_at. Frames cluster around action +# timestamps; a generous window finds the nearest one. FRAME_TOLERANCE_S = 2.0 # The one coordinate space a window-scoped capture session may declare today: @@ -283,8 +282,8 @@ def _window_viewport_timeline( ``session.window_events`` accessor when the installed capture exposes one, else via the session's underlying recording model (same rows). - Flow recordings and capture's MP4 stream each have one fixed viewport. - Capture currently skips video frames after a window resize because they no + Flow recordings and capture's frame timeline each have one fixed viewport. + Capture currently skips frames after a window resize because they no longer match the stream size. Therefore a timeline viewport change cannot be represented faithfully and is refused instead of pairing coordinates in the new pixel space with a stale frame in the old pixel space. @@ -662,7 +661,8 @@ def convert_capture( Args: capture_dir: An openadapt-capture session directory (contains - ``recording.db`` and an ``oa_recording-*.mp4`` video). + ``recording.db`` and frame media readable through + ``CaptureSession.get_frame_at``). out_recording_dir: Output recording directory (created if missing). params: Optional ``{param_name: demonstrated_value}`` map. A coalesced ``type`` event whose text equals a demonstrated value is marked as @@ -683,7 +683,7 @@ def convert_capture( flow equivalent (drag, non-left click, modifier chord, unmapped named key, unknown input type), multiple params demonstrating the same value, or a click whose before frame is missing from the - action-gated video. Also, for a window-scoped session: on an + captured frame timeline. Also, for a window-scoped session: on an out-of-window action, an unknown declared coordinate space, malformed selector/viewport metadata, a mid-recording resize, or an extracted frame whose dimensions disagree with the recorded @@ -769,9 +769,9 @@ def convert_capture( ) if event["kind"] in ("click", "double_click") and before_img is None: raise ValueError( - f"no video frame available for {event['kind']} at " - f"t={ts - started_at:.3f}s: the action-gated capture video " - "has no frame near this action, so its target cannot be " + f"no captured frame available for {event['kind']} at " + f"t={ts - started_at:.3f}s: capture has no frame near " + "this action, so its target cannot be " "anchored" ) if before_img is not None: diff --git a/openadapt_flow/image_hash.py b/openadapt_flow/image_hash.py new file mode 100644 index 00000000..5c0e5840 --- /dev/null +++ b/openadapt_flow/image_hash.py @@ -0,0 +1,88 @@ +"""Deterministic perceptual hashes without SciPy. + +The algorithms and serialized hexadecimal representation intentionally match +``ImageHash==4.3.2``. OpenAdapt persisted those strings in compiled bundles, +so this module is a compatibility implementation rather than a new hash +format. +""" + +from __future__ import annotations + +import cv2 +import numpy as np +from PIL import Image + + +def _binary_array_to_hex(bits: np.ndarray) -> str: + """Serialize a boolean array in ImageHash-compatible row-major order.""" + flat = np.asarray(bits, dtype=np.bool_).ravel() + bit_string = "".join("1" if bit else "0" for bit in flat) + width = (len(bit_string) + 3) // 4 + return f"{int(bit_string, 2):0{width}x}" + + +def _unnormalized_dct2(pixels: np.ndarray) -> np.ndarray: + """Return SciPy fftpack-compatible unnormalized type-II 2-D DCT. + + OpenCV computes an orthonormal DCT-II. Scaling its frequency rows and + columns converts it to the unnormalized transform used by + ``scipy.fftpack.dct(..., type=2, norm=None)``. Using OpenCV also preserves + exact zero-frequency symmetry for constant images; a direct cosine-matrix + implementation can leave tiny platform-dependent residuals that flip + median-threshold bits. + """ + height, width = pixels.shape + if height != width: + raise ValueError("perceptual hash DCT input must be square") + normalized = cv2.dct(np.asarray(pixels, dtype=np.float64)) + scale = np.full(width, np.sqrt(2.0 * width), dtype=np.float64) + scale[0] = 2.0 * np.sqrt(width) + return normalized * scale[:, np.newaxis] * scale[np.newaxis, :] + + +def perceptual_hash( + image: Image.Image, + *, + hash_size: int = 8, + highfreq_factor: int = 4, +) -> str: + """Return an ImageHash-compatible perceptual hash.""" + if hash_size < 2: + raise ValueError("Hash size must be greater than or equal to 2") + + image_size = hash_size * highfreq_factor + pixels = np.asarray( + image.convert("L").resize( + (image_size, image_size), + Image.Resampling.LANCZOS, + ), + dtype=np.float64, + ) + dct = _unnormalized_dct2(pixels) + low_frequencies = dct[:hash_size, :hash_size] + return _binary_array_to_hex(low_frequencies > np.median(low_frequencies)) + + +def difference_hash(image: Image.Image, *, hash_size: int = 8) -> str: + """Return an ImageHash-compatible horizontal difference hash.""" + if hash_size < 2: + raise ValueError("Hash size must be greater than or equal to 2") + + pixels = np.asarray( + image.convert("L").resize( + (hash_size + 1, hash_size), + Image.Resampling.LANCZOS, + ) + ) + return _binary_array_to_hex(pixels[:, 1:] > pixels[:, :-1]) + + +def hash_distance(a: str, b: str) -> int: + """Return the Hamming distance between equal-width hexadecimal hashes.""" + if len(a) != len(b): + raise TypeError( + "Image hashes must have the same bit length.", + len(a) * 4, + len(b) * 4, + ) + return (int(a, 16) ^ int(b, 16)).bit_count() diff --git a/openadapt_flow/recorder.py b/openadapt_flow/recorder.py index 34f72c4d..5d7055f0 100644 --- a/openadapt_flow/recorder.py +++ b/openadapt_flow/recorder.py @@ -17,7 +17,7 @@ frames/{i:04d}_before.png frames/{i:04d}_after.png # captured after the action settled -The settle wait is implemented inline here (imagehash polling) on purpose: +The settle wait is implemented inline here (perceptual-hash polling) on purpose: this module must not depend on `openadapt_flow.vision`. """ @@ -31,17 +31,17 @@ from pathlib import Path from typing import Any, Callable, Optional -import imagehash from PIL import Image, ImageDraw from openadapt_flow.backend import Backend +from openadapt_flow.image_hash import perceptual_hash from openadapt_flow.ir import StructuralLocator -def _phash(png: bytes) -> imagehash.ImageHash: +def _phash(png: bytes) -> str: """Perceptual hash of a PNG frame.""" with Image.open(io.BytesIO(png)) as img: - return imagehash.phash(img) + return perceptual_hash(img) class Recorder: @@ -373,7 +373,7 @@ def _wait_settled(self) -> bytes: time.sleep(self._settle_interval_s) png = self._backend.screenshot() cur = _phash(png) - if cur - prev == 0: + if cur == prev: consecutive += 1 else: consecutive = 1 diff --git a/openadapt_flow/validation/pixel_identity_probe.py b/openadapt_flow/validation/pixel_identity_probe.py index bdb21827..46d68b5c 100644 --- a/openadapt_flow/validation/pixel_identity_probe.py +++ b/openadapt_flow/validation/pixel_identity_probe.py @@ -56,6 +56,12 @@ import numpy as np from PIL import Image +from openadapt_flow.image_hash import ( + difference_hash, + hash_distance, + perceptual_hash, +) + # Reuse the dense-surface fixture's RENDERING unchanged (read it, call it, # never edit it): the HTML/CSS that defines exactly how an MRN cell paints. from openadapt_flow.validation.dense_surface import ( @@ -373,18 +379,20 @@ def m_ssim(a: np.ndarray, b: np.ndarray) -> float: def m_phash(a: np.ndarray, b: np.ndarray) -> float: - import imagehash - return float( - imagehash.phash(_pil(a), hash_size=16) - imagehash.phash(_pil(b), hash_size=16) + hash_distance( + perceptual_hash(_pil(a), hash_size=16), + perceptual_hash(_pil(b), hash_size=16), + ) ) def m_dhash(a: np.ndarray, b: np.ndarray) -> float: - import imagehash - return float( - imagehash.dhash(_pil(a), hash_size=16) - imagehash.dhash(_pil(b), hash_size=16) + hash_distance( + difference_hash(_pil(a), hash_size=16), + difference_hash(_pil(b), hash_size=16), + ) ) diff --git a/openadapt_flow/vision/hashing.py b/openadapt_flow/vision/hashing.py index c04f8ec1..a02e18e3 100644 --- a/openadapt_flow/vision/hashing.py +++ b/openadapt_flow/vision/hashing.py @@ -1,4 +1,4 @@ -"""Perceptual hashing helpers (imagehash-based). +"""Perceptual hashing helpers. Used for change detection (settle polling) and REGION_STABLE postconditions. @@ -15,9 +15,9 @@ import io -import imagehash from PIL import Image, ImageFilter +from openadapt_flow.image_hash import hash_distance, perceptual_hash from openadapt_flow.ir import Region @@ -47,9 +47,9 @@ def phash_png(png: bytes, region: Region | None = None) -> str: raise ValueError(f"region {region} is empty after clamping") img = img.crop((x0, y0, x1, y1)) edges = img.convert("L").filter(ImageFilter.FIND_EDGES) - return str(imagehash.phash(edges)) + return perceptual_hash(edges) def phash_distance(a: str, b: str) -> int: """Return the Hamming distance between two hex phash strings.""" - return imagehash.hex_to_hash(a) - imagehash.hex_to_hash(b) + return hash_distance(a, b) diff --git a/public-artifacts.json b/public-artifacts.json index 2e41a19e..ed4ff101 100644 --- a/public-artifacts.json +++ b/public-artifacts.json @@ -76,7 +76,7 @@ }, { "path": ".github/workflows/canvas-nodom-ladder.yml", - "sha256": "ffcd2c3d1db82eec5bd92012f079c1eee4fbff69cf0c726465ebce2dddc91838" + "sha256": "3fe1fe1277df4bcd85a4ba00ffbb75f34395c31b26fd3038b7d0b6f7b31cc27c" }, { "path": ".github/workflows/ci.yml", @@ -84,11 +84,11 @@ }, { "path": ".github/workflows/citrix-workspace-standin.yml", - "sha256": "166d41756f2c7b0d29965729c4251296e3312cb713cb1ea3889b49d048f36896" + "sha256": "7ffdf4e530dd85d13f101ae43ca210034a6f6fae8aaf6e86664d4db867f0d67b" }, { "path": ".github/workflows/docker-rdp-vision-ladder.yml", - "sha256": "fadd77ccfbc1e868ac3306a7abb5d8f2ac020e8cf09c6fe7668343a4beb3210d" + "sha256": "bf53a9a348f6c1b539e33826530cd78287047799583dce180622a0a56ee303fb" }, { "path": ".github/workflows/paper.yml", diff --git a/pyproject.toml b/pyproject.toml index a958eb5e..35466df7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ "numpy>=1.26", "opencv-python-headless>=4.9", "pillow>=10.0", - "imagehash>=4.3", "rapidocr-onnxruntime>=1.3", # The reference backend and the CLI's demo-record/replay self-serve # path drive a Playwright browser; core, not a dev extra. diff --git a/tests/test_dependency_security.py b/tests/test_dependency_security.py index af60d51c..b4b60ac9 100644 --- a/tests/test_dependency_security.py +++ b/tests/test_dependency_security.py @@ -51,6 +51,23 @@ def test_transformers_is_confined_to_the_mlx_research_extra() -> None: assert {"mlx-vlm", "transformers"} <= mlx_names +def test_core_runtime_excludes_imagehash_scipy_closure() -> None: + """Keep SciPy and its compiled libraries out of the desktop/runtime core.""" + roots = [ + package + for package in _lock_packages() + if package["name"] == "openadapt-flow" + and package.get("source") == {"editable": "."} + ] + assert len(roots) == 1 + core_names = {dependency["name"] for dependency in roots[0]["dependencies"]} + assert core_names.isdisjoint({"imagehash", "pywavelets", "scipy"}) + + locked_names = {package["name"] for package in _lock_packages()} + assert "imagehash" not in locked_names + assert "pywavelets" not in locked_names + + def test_privacy_extra_declares_spacy_runtime_click_dependency() -> None: """Do not rely on Typer to make spaCy's direct Click import available.""" diff --git a/tests/test_image_hash.py b/tests/test_image_hash.py new file mode 100644 index 00000000..510514e9 --- /dev/null +++ b/tests/test_image_hash.py @@ -0,0 +1,97 @@ +"""Compatibility tests for the dependency-free ImageHash implementation.""" + +from __future__ import annotations + +import io + +import numpy as np +import pytest +from PIL import Image + +from openadapt_flow.image_hash import ( + difference_hash, + hash_distance, + perceptual_hash, +) +from openadapt_flow.recorder import _phash +from openadapt_flow.validation import pixel_identity_probe +from openadapt_flow.vision.hashing import phash_png + + +@pytest.mark.parametrize( + ("shade", "expected"), + [ + (0, "0000000000000000"), + (1, "8000000000000000"), + (127, "8000000000000000"), + (255, "8000000000000000"), + ], +) +def test_perceptual_hash_matches_imagehash_constant_vectors( + shade: int, expected: str +) -> None: + """Constants cover the DCT zero-frequency/tie corner.""" + assert perceptual_hash(Image.new("L", (32, 32), shade)) == expected + + +def test_seeded_random_vectors_match_imagehash_4_3_2() -> None: + """Fixed vectors prevent a silent persisted-bundle hash migration.""" + expected = [ + ("f0b4856b51ccaba6", "a22c35940959e3ca"), + ("fe9f404085fd53d0", "966d6d57a415a3ec"), + ("dcc1f3204c826bfb", "c2d6553434a73c30"), + ("a189bdc43e450bfc", "32bb53c82dcaa41b"), + ("8d9974e98c908bf6", "325248b26643ad66"), + ("e67ad3b56d02d02c", "4515ad55369acd56"), + ("812050cebbe15def", "bb999a9b69d5ca9a"), + ("b5bae2511305933f", "8c175dce149926cd"), + ("f8b9d8161cc59e19", "b48a9b979974d3b5"), + ("c1d9772cc3b1ae09", "93b25992d324d64a"), + ("dc43610d6fa4da4d", "c288b9ac6231b629"), + ("c8ba36909939f95a", "ad25625aaba1e446"), + ] + rng = np.random.default_rng(20260723) + for expected_pair in expected: + image = Image.fromarray(rng.integers(0, 256, size=(32, 32, 3), dtype=np.uint8)) + assert ( + perceptual_hash(image), + difference_hash(image), + ) == expected_pair + + +def _geometric_png() -> bytes: + array = np.full((96, 144, 3), 255, np.uint8) + array[10:60, 7:90] = (20, 40, 180) + array[14:56, 11:86] = (245, 245, 245) + array[25:45, 20:35] = (0, 0, 0) + array[25:45, 45:60] = (80, 80, 80) + array[20:50, 100:130] = (220, 20, 20) + output = io.BytesIO() + Image.fromarray(array).save(output, format="PNG") + return output.getvalue() + + +def test_recorder_and_edge_hashes_match_persisted_imagehash_vectors() -> None: + png = _geometric_png() + assert _phash(png) == "aa0ab5b4b4db1fc0" + assert phash_png(png) == "fbd38c49c522ca36" + + +def test_pixel_probe_phash_and_dhash_keep_imagehash_16_bit_contract() -> None: + before = np.full((40, 200, 3), 255, np.uint8) + for x in (20, 60, 100, 140): + before[10:30, x : x + 16] = 0 + after = before.copy() + after[14:26, 104:112] = 255 + + assert pixel_identity_probe.m_phash(before, after) == 31.0 + assert pixel_identity_probe.m_dhash(before, after) == 0.0 + + +def test_hex_hash_distance_matches_imagehash_semantics() -> None: + assert hash_distance("0f", "f0") == 8 + assert hash_distance("0000000000000000", "8000000000000000") == 1 + with pytest.raises(TypeError): + hash_distance("00", "0000") + with pytest.raises(ValueError): + hash_distance("zz", "00") diff --git a/uv.lock b/uv.lock index 3aee269c..8a3da417 100644 --- a/uv.lock +++ b/uv.lock @@ -1219,21 +1219,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, ] -[[package]] -name = "imagehash" -version = "4.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "pillow" }, - { name = "pywavelets" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/de/5c0189b0582e21583c2a213081c35a2501c0f9e51f21f6a52f55fbb9a4ff/ImageHash-4.3.2.tar.gz", hash = "sha256:e54a79805afb82a34acde4746a16540503a9636fd1ffb31d8e099b29bbbf8156", size = 303190 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/2c/5f0903a53a62029875aaa3884c38070cc388248a2c1b9aa935632669e5a7/ImageHash-4.3.2-py2.py3-none-any.whl", hash = "sha256:02b0f965f8c77cd813f61d7d39031ea27d4780e7ebcad56c6cd6a709acc06e5f", size = 296657 }, -] - [[package]] name = "imageio" version = "2.37.3" @@ -2209,7 +2194,6 @@ dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "idna" }, - { name = "imagehash" }, { name = "numpy" }, { name = "opencv-python-headless" }, { name = "pillow" }, @@ -2298,7 +2282,6 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100" }, { name = "idna", specifier = ">=3.6" }, - { name = "imagehash", specifier = ">=4.3" }, { name = "jsonschema", marker = "extra == 'dev'", specifier = ">=4.21" }, { name = "keyring", marker = "extra == 'hosted'", specifier = ">=25" }, { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.8" }, @@ -3313,41 +3296,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141 }, ] -[[package]] -name = "pywavelets" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/45/bfaaab38545a33a9f06c61211fc3bea2e23e8a8e00fedeb8e57feda722ff/pywavelets-1.8.0.tar.gz", hash = "sha256:f3800245754840adc143cbc29534a1b8fc4b8cff6e9d403326bd52b7bb5c35aa", size = 3935274 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/7e/c5e398f25c70558ca195dd4144ee004666401f6167084c1e76059d7e68d8/pywavelets-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f5c86fcb203c8e61d1f3d4afbfc08d626c64e4e3708207315577264c724632bf", size = 4323291 }, - { url = "https://files.pythonhosted.org/packages/d0/d7/2fc8067c3520ce25f7632b0f47b89d1b75653cab804a42700e95126f2679/pywavelets-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fafb5fa126277e1690c3d6329287122fc08e4d25a262ce126e3d81b1f5709308", size = 4291864 }, - { url = "https://files.pythonhosted.org/packages/2f/17/a868aa26e45c104613d9069f9d8ec0123687cb6945062d274f20a3992436/pywavelets-1.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec23dfe6d5a3f4312b12456b8c546aa90a11c1138e425a885987505f0658ae0", size = 4447532 }, - { url = "https://files.pythonhosted.org/packages/53/7a/7f5889a57177e2b1182080fc2c52236d1e03a0fad5e0b3d7c5312070c0be/pywavelets-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:880a0197e9fa108939af50a95e97c1bf9b7d3e148e0fad92ea60a9ed8c8947c0", size = 4487695 }, - { url = "https://files.pythonhosted.org/packages/f9/e6/04d76d93c158919ef0d8e1d40d1d453168305031eca6733fdc844f7cbb07/pywavelets-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bfa833d08b60d0bf53a7939fbbf3d98015dd34efe89cbe4e53ced880d085fc1", size = 4473752 }, - { url = "https://files.pythonhosted.org/packages/3b/a7/42ea5bbb6055abd312e45b27d931200fd6eed5414a87ec5d62020a4c651b/pywavelets-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e10c3fc7f4a796e94da4bca9871be2186a7bb7a3b3536a0ca9376d84263140f0", size = 4504191 }, - { url = "https://files.pythonhosted.org/packages/8c/7e/52df87a9e77adfb12c1b8be79a2053f2eb4c2507dec96ebfd2333b15ff03/pywavelets-1.8.0-cp310-cp310-win32.whl", hash = "sha256:31baf4be6940fde72cc85663154360857ac1b93c251822deaf72bb804da95031", size = 4143794 }, - { url = "https://files.pythonhosted.org/packages/01/e2/06e08230c26049740b2773952fbb12cc7186e5df655a73b1c30ba493e864/pywavelets-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:560c39f1ff8cb37f8b8ea4b7b6eb8a14f6926c11f5cf8c09f013a58f895ed5bc", size = 4214262 }, - { url = "https://files.pythonhosted.org/packages/6c/8a/9f8e794120b55caa1c4ae8d72696111bc408251615f351a8e54a5d8c4d4e/pywavelets-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e8dd5be4faed994581a8a4b3c0169be20567a9346e523f0b57f903c8f6722bce", size = 4324170 }, - { url = "https://files.pythonhosted.org/packages/3e/b8/f6246be5c78e9fa73fcbba9ab4cbfe0d4dcb79ea5491f28d673a53466134/pywavelets-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d8abaf7c120b151ef309c9ff57e0a44ba9febf49045056dbc1577526ecec6c8", size = 4294254 }, - { url = "https://files.pythonhosted.org/packages/2c/dc/ba1f212e9b43117ed28e0fd092e72e817790427400f88937ea742d260153/pywavelets-1.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b43a4c58707b1e8d941bec7f1d83e67c482278575ff0db3189d5c0dfae23a57", size = 4447178 }, - { url = "https://files.pythonhosted.org/packages/58/10/e59c162a11d2fedb4454abbf7b74a52390aba5edc9605bf829bfa8708dac/pywavelets-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1aad0b97714e3079a2bfe48e4fb8ccd60778d0427e9ee5e0a9ff922e6c61e4", size = 4486799 }, - { url = "https://files.pythonhosted.org/packages/03/ee/90c3d0a0a3bda74e6e097e4c06bff9446ff2a4c90b8617aaf4902c46966b/pywavelets-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0e1db96dcf3ce08156859df8b359e9ff66fa15061a1b90e70e020bf4cd077a0", size = 4486403 }, - { url = "https://files.pythonhosted.org/packages/05/54/58b87f8b636a9f044f3f9814d2ec696cf25f3b33af97c11811f13c364085/pywavelets-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e62c8fb52ab0e8ff212fff9acae681a8f12d68b76c36fe24cc48809d5b6825ba", size = 4515011 }, - { url = "https://files.pythonhosted.org/packages/a1/d0/f755cee11ff20668114942d0e777e2b502a8e4665e1fdb2553b587aac637/pywavelets-1.8.0-cp311-cp311-win32.whl", hash = "sha256:bf327528d10de471b04bb725c4e10677fac5a49e13d41bf0d0b3a1f6d7097abf", size = 4139934 }, - { url = "https://files.pythonhosted.org/packages/7b/0b/f4b92d4f00565280ea3e62a8e3dc81a667d67ed7bd59232f2f18d55f9aff/pywavelets-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3814d354dd109e244ffaac3d480d29a5202212fe24570c920268237c8d276f95", size = 4214321 }, - { url = "https://files.pythonhosted.org/packages/2d/8b/4870f11559307416470158a5aa6f61e5c2a910f1645a7a836ffae580b7ad/pywavelets-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f431c9e2aff1a2240765eff5e804975d0fcc24c82d6f3d4271243f228e5963b", size = 4326187 }, - { url = "https://files.pythonhosted.org/packages/c4/35/66835d889fd7fbf3119c7a9bd9d9bd567fc0bb603dfba408e9226db7cb44/pywavelets-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e39b0e2314e928cb850ee89b9042733a10ea044176a495a54dc84d2c98407a51", size = 4295428 }, - { url = "https://files.pythonhosted.org/packages/63/1c/42e5130226538c70d4bbbaee00eb1bc06ec3287f7ea43d5fcf85bfc761ce/pywavelets-1.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cae701117f5c7244b7c8d48b9e92a0289637cdc02a9c205e8be83361f0c11fae", size = 4421259 }, - { url = "https://files.pythonhosted.org/packages/6f/c5/1ce93657432e22a5debc21e8b52ec6980f819ecb7fa727bb86744224d967/pywavelets-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649936baee933e80083788e0adc4d8bc2da7cdd8b10464d3b113475be2cc5308", size = 4447650 }, - { url = "https://files.pythonhosted.org/packages/b9/d6/b54ef30daca71824f811f9d2322a978b0a58d27674b8e3af6520f67e9ec6/pywavelets-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8c68e9d072c536bc646e8bdce443bb1826eeb9aa21b2cb2479a43954dea692a3", size = 4448538 }, - { url = "https://files.pythonhosted.org/packages/ce/8c/1688b790e55674667ad644262f174405c2c9873cb13e773432e78b1b33e4/pywavelets-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:63f67fa2ee1610445de64f746fb9c1df31980ad13d896ea2331fc3755f49b3ae", size = 4485228 }, - { url = "https://files.pythonhosted.org/packages/c9/9b/69de31c3b663dadd76d1da6bf8af68d8cefff55df8e880fe96a94bb8c9ac/pywavelets-1.8.0-cp312-cp312-win32.whl", hash = "sha256:4b3c2ab669c91e3474fd63294355487b7dd23f0b51d32f811327ddf3546f4f3d", size = 4134850 }, - { url = "https://files.pythonhosted.org/packages/1c/88/9e2aa9d5fde08bfc0fb18ffb1b5307c1ed49c24930b4147e5f48571a7251/pywavelets-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:810a23a631da596fef7196ddec49b345b1aab13525bb58547eeebe1769edbbc1", size = 4210786 }, -] - [[package]] name = "pywin32" version = "312"