diff --git a/pyproject.toml b/pyproject.toml index 8f8f8e5ac..8077a159a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,7 +119,7 @@ journal-host = [ "mistune", "python-frontmatter", "packaging", - "Pillow", + "Pillow>=11.0.0", "numpy", "av", "openai>=1.2.0", diff --git a/solstone/observe/describe.py b/solstone/observe/describe.py index 633a93121..1a7d64a5e 100644 --- a/solstone/observe/describe.py +++ b/solstone/observe/describe.py @@ -106,6 +106,13 @@ def _winnow_decision( return (True, False, "kept") +def _flattened_image_data(img: Image.Image): + get_flattened_data = getattr(img, "get_flattened_data", None) + if get_flattened_data is not None: + return get_flattened_data() + return img.getdata() + + class RequestType(Enum): """Type of vision analysis request.""" @@ -614,7 +621,7 @@ class VideoProcessor: def _dhash(self, img: Image.Image) -> int: """Compute 64-bit dHash (difference hash) for perceptual comparison.""" small = img.resize(self.DHASH_SIZE, Image.BILINEAR).convert("L") - pixels = list(small.getdata()) + pixels = list(_flattened_image_data(small)) hash_val = 0 for row in range(8): for col in range(8): diff --git a/tests/test_describe_scene_cut.py b/tests/test_describe_scene_cut.py index 51a0f97b1..2d6732a44 100644 --- a/tests/test_describe_scene_cut.py +++ b/tests/test_describe_scene_cut.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc +import warnings + from PIL import Image from solstone.observe import describe as describe_module @@ -44,6 +46,34 @@ def test_dhash_identical_images_have_zero_distance(): assert bin(processor._dhash(image) ^ processor._dhash(image.copy())).count("1") == 0 +def test_dhash_uses_current_pillow_accessor_without_deprecation_warning(): + processor = describe_module.VideoProcessor.__new__(describe_module.VideoProcessor) + image = Image.new("RGB", (9, 8)) + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + processor._dhash(image) + + +def test_flattened_image_data_prefers_replacement_accessor(): + class FakeImage: + def get_flattened_data(self): + return (1, 2) + + def getdata(self): + raise AssertionError("deprecated accessor should not be called") + + assert describe_module._flattened_image_data(FakeImage()) == (1, 2) + + +def test_flattened_image_data_falls_back_for_legacy_pillow(): + class FakeLegacyImage: + def getdata(self): + return (3, 4) + + assert describe_module._flattened_image_data(FakeLegacyImage()) == (3, 4) + + def test_dhash_reversed_horizontal_ramps_have_full_distance(): processor = describe_module.VideoProcessor.__new__(describe_module.VideoProcessor) ramp = [col * 28 for col in range(9)] diff --git a/uv.lock b/uv.lock index 36eb24ef8..e261a7867 100644 --- a/uv.lock +++ b/uv.lock @@ -4435,7 +4435,7 @@ requires-dist = [ { name = "opentelemetry-semantic-conventions", marker = "extra == 'journal-host'", specifier = "==0.60b1" }, { name = "packaging", marker = "extra == 'journal-host'" }, { name = "pdf2image", marker = "extra == 'pdf'", specifier = ">=1.16.0" }, - { name = "pillow", marker = "extra == 'journal-host'" }, + { name = "pillow", marker = "extra == 'journal-host'", specifier = ">=11.0.0" }, { name = "psutil" }, { name = "pyopenssl", specifier = ">=24.0" }, { name = "pypdf", marker = "extra == 'pdf'", specifier = ">=4.0.0" },