python-libphash 2.0.1.0__cp315-cp315t-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- libphash/__init__.py +216 -0
- libphash/__main__.py +7 -0
- libphash/_algorithms.py +88 -0
- libphash/_batch.py +366 -0
- libphash/_cli.py +518 -0
- libphash/_config.py +210 -0
- libphash/_context.py +388 -0
- libphash/_digest.py +293 -0
- libphash/_errors.py +109 -0
- libphash/_native.pyd +0 -0
- libphash/_native.pyi +944 -0
- libphash/_sources.py +278 -0
- libphash/_version.py +3 -0
- libphash/exceptions.py +157 -0
- libphash/py.typed +0 -0
- python_libphash-2.0.1.0.dist-info/METADATA +138 -0
- python_libphash-2.0.1.0.dist-info/RECORD +29 -0
- python_libphash-2.0.1.0.dist-info/WHEEL +5 -0
- python_libphash-2.0.1.0.dist-info/entry_points.txt +2 -0
- python_libphash-2.0.1.0.dist-info/licenses/LICENSE +21 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/THIRD-PARTY-NOTICES.md +139 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libjpeg-turbo/LICENSE.md +147 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libjpeg-turbo/README.ijg +260 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libpng/LICENSE +134 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libwebp/COPYING +30 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libwebp/PATENTS +23 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/zlib-ng/LICENSE.md +19 -0
- python_libphash-2.0.1.0.dist-info/sboms/python-libphash.cdx.json +161 -0
- python_libphash-2.0.1.0.dist-info/top_level.txt +1 -0
libphash/_sources.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Turning what a caller has into C arguments.
|
|
2
|
+
|
|
3
|
+
A caller has a path, bytes, an array of pixels or a Pillow image.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, cast, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from ._errors import PH_ERR_IO, make_error
|
|
14
|
+
from ._native import ffi
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
|
|
19
|
+
from typing_extensions import Buffer
|
|
20
|
+
|
|
21
|
+
from ._native import char_ptr, uint8_t_ptr
|
|
22
|
+
|
|
23
|
+
#: A file system path, as ``open()`` takes it.
|
|
24
|
+
StrPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"]
|
|
25
|
+
#: Order of the color channels in an array of pixels: ``"bgr"`` is OpenCV's.
|
|
26
|
+
ChannelOrder = Literal["rgb", "bgr"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SupportsRead(Protocol):
|
|
30
|
+
"""A binary file object: an open file, ``io.BytesIO``, a response body."""
|
|
31
|
+
|
|
32
|
+
def read(self) -> bytes: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class SupportsArrayInterface(Protocol):
|
|
37
|
+
"""An array exposing numpy's ``__array_interface__``.
|
|
38
|
+
|
|
39
|
+
A numpy array is one, and so is a Pillow image.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def __array_interface__(self) -> Mapping[str, Any]: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PILImage(Protocol):
|
|
47
|
+
"""The part of ``PIL.Image.Image`` the binding reads."""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def mode(self) -> str: ...
|
|
51
|
+
@property
|
|
52
|
+
def size(self) -> tuple[int, int]: ...
|
|
53
|
+
def tobytes(self) -> bytes: ...
|
|
54
|
+
def convert(self, mode: str) -> PILImage: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
#: Decoded 8-bit pixels of shape (h, w) or (h, w, 1|3|4): a numpy array, a memoryview.
|
|
58
|
+
PixelArray = SupportsArrayInterface | memoryview
|
|
59
|
+
|
|
60
|
+
#: Everything :func:`libphash.hash` and :meth:`ImageContext.load` accept: a path,
|
|
61
|
+
#: encoded bytes, a binary file object, an array of pixels or a Pillow image. ``bytes``
|
|
62
|
+
#: are always encoded image data, never a path.
|
|
63
|
+
Source = Union[
|
|
64
|
+
str,
|
|
65
|
+
"os.PathLike[str]",
|
|
66
|
+
"os.PathLike[bytes]",
|
|
67
|
+
bytes,
|
|
68
|
+
bytearray,
|
|
69
|
+
memoryview,
|
|
70
|
+
SupportsArrayInterface,
|
|
71
|
+
SupportsRead,
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
#: Paths reach the C library's narrow-character open(), which on Windows reads the ANSI
|
|
75
|
+
#: code page, not the UTF-8 that os.fsencode() gives. On Windows the binding therefore
|
|
76
|
+
#: reads the file itself and loads it from memory.
|
|
77
|
+
READ_FILES_IN_PYTHON = sys.platform == "win32"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def encode_path(path: StrPath) -> bytes:
|
|
81
|
+
"""Return the path as the bytes the C library gets.
|
|
82
|
+
|
|
83
|
+
They are the file system encoding of the path: on POSIX, the path's bytes unchanged.
|
|
84
|
+
A path with a null byte raises ValueError, as ``open()`` does: C would read only the
|
|
85
|
+
part before it and open another file.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
encoded = os.fsencode(path)
|
|
89
|
+
except TypeError:
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"expected a path (str, bytes or os.PathLike), got {type(path).__name__}"
|
|
92
|
+
) from None
|
|
93
|
+
if b"\0" in encoded:
|
|
94
|
+
raise ValueError("embedded null byte")
|
|
95
|
+
return encoded
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def encode_text(text: str) -> bytes:
|
|
99
|
+
"""Return a name or a hex form as the ASCII string a C parser reads.
|
|
100
|
+
|
|
101
|
+
C reads up to the first null byte, so a null byte, like any other character outside
|
|
102
|
+
ASCII, becomes ``?``, which no parser of libphash accepts.
|
|
103
|
+
"""
|
|
104
|
+
return text.encode("ascii", "replace").replace(b"\0", b"?")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_pil_image(obj: object) -> bool:
|
|
108
|
+
module = str(type(obj).__module__)
|
|
109
|
+
return (
|
|
110
|
+
module.startswith("PIL.") and hasattr(obj, "mode") and hasattr(obj, "tobytes")
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Pixels:
|
|
116
|
+
"""Arguments for ph_load_from_pixels().
|
|
117
|
+
|
|
118
|
+
The pointer is either ``ffi.from_buffer()``'s, which keeps its object alive, or an
|
|
119
|
+
array's own address, valid while the caller holds the array.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
pointer: char_ptr | uint8_t_ptr
|
|
123
|
+
width: int
|
|
124
|
+
height: int
|
|
125
|
+
channels: int
|
|
126
|
+
stride: int
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pixels_from_array(obj: PixelArray, channel_order: ChannelOrder) -> Pixels:
|
|
130
|
+
"""Read the pixels of an 8-bit array of shape (h, w) or (h, w, 1|3|4).
|
|
131
|
+
|
|
132
|
+
A numpy array whose rows are spaced apart (a crop, ``arr[10:-10, 5:-5]``) is passed
|
|
133
|
+
without a copy, its row step as the C stride; one with gaps or reversed order inside
|
|
134
|
+
a row, or in BGR order, is copied once.
|
|
135
|
+
"""
|
|
136
|
+
if channel_order not in ("rgb", "bgr"):
|
|
137
|
+
raise ValueError(f"channel_order must be 'rgb' or 'bgr', got {channel_order!r}")
|
|
138
|
+
iface = getattr(obj, "__array_interface__", None)
|
|
139
|
+
if iface is not None:
|
|
140
|
+
return _from_array_interface(obj, iface, channel_order)
|
|
141
|
+
try:
|
|
142
|
+
# Any object with the buffer protocol and a shape, beyond what PixelArray names.
|
|
143
|
+
view = memoryview(cast("Buffer", obj))
|
|
144
|
+
except TypeError:
|
|
145
|
+
raise TypeError(
|
|
146
|
+
"expected an array of pixels (numpy, memoryview with a shape), "
|
|
147
|
+
f"got {type(obj).__name__}"
|
|
148
|
+
) from None
|
|
149
|
+
if view.format not in ("B", "c"):
|
|
150
|
+
raise TypeError(
|
|
151
|
+
f"pixels must be 8-bit unsigned (format 'B'), got format {view.format!r}; "
|
|
152
|
+
"convert the array to uint8 first"
|
|
153
|
+
)
|
|
154
|
+
height, width, channels = _shape(tuple(view.shape or ()))
|
|
155
|
+
if view.c_contiguous and channel_order == "rgb":
|
|
156
|
+
return Pixels(ffi.from_buffer(view), width, height, channels, 0)
|
|
157
|
+
data = view.tobytes()
|
|
158
|
+
return _packed(data, width, height, channels, channel_order)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _from_array_interface(
|
|
162
|
+
obj: object, iface: Mapping[str, Any], channel_order: ChannelOrder
|
|
163
|
+
) -> Pixels:
|
|
164
|
+
typestr = iface.get("typestr", "")
|
|
165
|
+
if typestr[1:] != "u1":
|
|
166
|
+
raise TypeError(
|
|
167
|
+
f"pixels must be uint8, got typestr {typestr!r}; "
|
|
168
|
+
"convert the array to uint8 first"
|
|
169
|
+
)
|
|
170
|
+
height, width, channels = _shape(tuple(iface["shape"]))
|
|
171
|
+
strides = iface.get("strides")
|
|
172
|
+
if strides is None: # C-contiguous
|
|
173
|
+
strides = (width * channels, channels, 1)[: len(iface["shape"])]
|
|
174
|
+
row = strides[0]
|
|
175
|
+
pixel = strides[1]
|
|
176
|
+
channel = strides[2] if len(strides) == 3 else 1
|
|
177
|
+
data: object = iface.get("data")
|
|
178
|
+
# numpy states the address as (pointer, read_only); other exporters may not.
|
|
179
|
+
address = cast("tuple[object, ...]", data)[0] if isinstance(data, tuple) else None
|
|
180
|
+
zero_copy = (
|
|
181
|
+
channel_order == "rgb"
|
|
182
|
+
and pixel == channels
|
|
183
|
+
and channel == 1
|
|
184
|
+
and row >= width * channels
|
|
185
|
+
)
|
|
186
|
+
if zero_copy and isinstance(address, int):
|
|
187
|
+
pointer = ffi.cast("const uint8_t *", address)
|
|
188
|
+
return Pixels(pointer, width, height, channels, row)
|
|
189
|
+
# Any other layout: one C-ordered copy through the buffer protocol.
|
|
190
|
+
try:
|
|
191
|
+
packed = memoryview(cast("Buffer", obj)).tobytes()
|
|
192
|
+
except TypeError:
|
|
193
|
+
raise TypeError(
|
|
194
|
+
"the array does not expose its data through the buffer protocol"
|
|
195
|
+
) from None
|
|
196
|
+
return _packed(packed, width, height, channels, channel_order)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _packed(
|
|
200
|
+
data: bytes, width: int, height: int, channels: int, order: ChannelOrder
|
|
201
|
+
) -> Pixels:
|
|
202
|
+
if order == "bgr":
|
|
203
|
+
if channels < 3:
|
|
204
|
+
raise ValueError("channel_order='bgr' needs 3 or 4 channels")
|
|
205
|
+
swapped = bytearray(data)
|
|
206
|
+
swapped[0::channels], swapped[2::channels] = (
|
|
207
|
+
data[2::channels],
|
|
208
|
+
data[0::channels],
|
|
209
|
+
)
|
|
210
|
+
data = bytes(swapped)
|
|
211
|
+
return Pixels(ffi.from_buffer(data), width, height, channels, 0)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _shape(shape: tuple[int, ...]) -> tuple[int, int, int]:
|
|
215
|
+
if len(shape) == 2:
|
|
216
|
+
return shape[0], shape[1], 1
|
|
217
|
+
if len(shape) == 3 and shape[2] in (1, 3, 4):
|
|
218
|
+
return shape[0], shape[1], shape[2]
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"pixels must have shape (height, width) or (height, width, 1|3|4), got {shape}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
_PIL_DIRECT = {"L": 1, "RGB": 3, "RGBA": 4}
|
|
225
|
+
_PIL_UNSUPPORTED = ("I", "I;16", "I;16B", "I;16L", "I;16N", "F")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def pixels_from_pil(img: PILImage) -> Pixels:
|
|
229
|
+
"""Read the pixels of a Pillow image.
|
|
230
|
+
|
|
231
|
+
``L``, ``RGB`` and ``RGBA`` are read as they are. ``1`` becomes ``L``; palette and
|
|
232
|
+
gray-with-alpha images become ``RGBA``, which hashes as ``RGB`` where every pixel is
|
|
233
|
+
opaque; ``CMYK``, ``YCbCr``, ``HSV`` and ``LAB`` become ``RGB``. 16-bit, 32-bit
|
|
234
|
+
integer and float modes are refused: there is no single right way to bring them to
|
|
235
|
+
8 bits.
|
|
236
|
+
Pillow does not apply the EXIF orientation to the pixels; pass the image through
|
|
237
|
+
``PIL.ImageOps.exif_transpose`` first if it matters.
|
|
238
|
+
"""
|
|
239
|
+
mode = img.mode
|
|
240
|
+
if mode in _PIL_UNSUPPORTED:
|
|
241
|
+
raise TypeError(
|
|
242
|
+
f"Pillow mode {mode!r} is not 8-bit; "
|
|
243
|
+
"convert it (img.convert('L') or 'RGB') first"
|
|
244
|
+
)
|
|
245
|
+
if mode not in _PIL_DIRECT:
|
|
246
|
+
if mode == "1":
|
|
247
|
+
target = "L"
|
|
248
|
+
elif mode in ("P", "PA", "LA", "La", "RGBa"):
|
|
249
|
+
if mode == "La":
|
|
250
|
+
# Pillow converts premultiplied gray-with-alpha only to LA.
|
|
251
|
+
img = img.convert("LA")
|
|
252
|
+
target = "RGBA"
|
|
253
|
+
else:
|
|
254
|
+
target = "RGB"
|
|
255
|
+
img = img.convert(target)
|
|
256
|
+
mode = target
|
|
257
|
+
width, height = img.size
|
|
258
|
+
data = img.tobytes()
|
|
259
|
+
channels = _PIL_DIRECT[mode]
|
|
260
|
+
return Pixels(ffi.from_buffer(data), width, height, channels, 0)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def read_file(path: StrPath) -> bytes:
|
|
264
|
+
"""Read an image file for the C library to load from memory.
|
|
265
|
+
|
|
266
|
+
A file that cannot be opened, or is empty, raises the ImageIOError that the C
|
|
267
|
+
library's own open would, with the same message.
|
|
268
|
+
"""
|
|
269
|
+
try:
|
|
270
|
+
with open(path, "rb") as f:
|
|
271
|
+
data = f.read()
|
|
272
|
+
except OSError as e:
|
|
273
|
+
detail = f"Cannot open '{os.fsdecode(path)}': {e.strerror}"
|
|
274
|
+
raise make_error(PH_ERR_IO, detail=detail, path=path) from e
|
|
275
|
+
if not data:
|
|
276
|
+
detail = f"Cannot read '{os.fsdecode(path)}': file is empty"
|
|
277
|
+
raise make_error(PH_ERR_IO, detail=detail, path=path)
|
|
278
|
+
return data
|
libphash/_version.py
ADDED
libphash/exceptions.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Exceptions raised by libphash.
|
|
2
|
+
|
|
3
|
+
Every failure of the C library becomes a subclass of :class:`PhashError`, chosen by its
|
|
4
|
+
``ph_error_t`` code. The classes also derive from the built-in exception a Python
|
|
5
|
+
programmer would reach for: an unreadable file is an :class:`OSError` (with ``errno``,
|
|
6
|
+
so ``except FileNotFoundError`` works), a bad argument a :class:`ValueError`, a failed
|
|
7
|
+
allocation a :class:`MemoryError`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ._native import lib
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AllocationError",
|
|
18
|
+
"BatchCancelledError",
|
|
19
|
+
"CorruptDataError",
|
|
20
|
+
"DecodeError",
|
|
21
|
+
"DecoderUnavailableError",
|
|
22
|
+
"DigestKindError",
|
|
23
|
+
"EmptyImageError",
|
|
24
|
+
"ImageIOError",
|
|
25
|
+
"ImageTooLargeError",
|
|
26
|
+
"InvalidArgumentError",
|
|
27
|
+
"NoStructureError",
|
|
28
|
+
"PhashError",
|
|
29
|
+
"RequiresColorError",
|
|
30
|
+
"UnsupportedFormatError",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PhashError(Exception):
|
|
35
|
+
"""Base class of every libphash error.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
code: the ``ph_error_t`` value, or 0 for an error raised by the binding itself.
|
|
39
|
+
code_name: the C name of the code (``"PH_ERR_IO"``), or ``""``.
|
|
40
|
+
detail: the C library's description of this failure (for loading: the path and
|
|
41
|
+
the reason), or ``""``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
code: int
|
|
45
|
+
code_name: str
|
|
46
|
+
detail: str
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str, *, code: int = 0, detail: str = "") -> None:
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.code = code
|
|
51
|
+
self.code_name = _CODE_NAMES.get(code, "") if code else ""
|
|
52
|
+
self.detail = detail
|
|
53
|
+
|
|
54
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
55
|
+
return (_rebuild, (type(self), str(self), self.code, self.detail))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _rebuild(cls: type[PhashError], message: str, code: int, detail: str) -> PhashError:
|
|
59
|
+
exc = cls.__new__(cls)
|
|
60
|
+
PhashError.__init__(exc, message, code=code, detail=detail)
|
|
61
|
+
return exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ImageIOError(PhashError, OSError):
|
|
65
|
+
"""The path could not be used as an image source (``PH_ERR_IO``).
|
|
66
|
+
|
|
67
|
+
``errno`` and ``filename`` are filled in when the cause can be told (a missing file,
|
|
68
|
+
a directory, no permission), so the matching :class:`OSError` subclass catches it:
|
|
69
|
+
``except FileNotFoundError`` sees a missing image file.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, message: str, *, code: int = 0, detail: str = "") -> None:
|
|
73
|
+
PhashError.__init__(self, message, code=code, detail=detail)
|
|
74
|
+
|
|
75
|
+
def __str__(self) -> str:
|
|
76
|
+
# OSError's own would print "[Errno 2] ..." once errno is set.
|
|
77
|
+
return str(self.args[0])
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
# The errno-specific subclasses are an implementation detail.
|
|
81
|
+
return f"ImageIOError({str(self)!r})"
|
|
82
|
+
|
|
83
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
84
|
+
return (
|
|
85
|
+
_rebuild_io,
|
|
86
|
+
(type(self), str(self), self.code, self.detail, self.errno, self.filename),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _rebuild_io( # noqa: PLR0913 -- one argument per pickled field
|
|
91
|
+
cls: type[ImageIOError],
|
|
92
|
+
message: str,
|
|
93
|
+
code: int,
|
|
94
|
+
detail: str,
|
|
95
|
+
err: int | None,
|
|
96
|
+
filename: object,
|
|
97
|
+
) -> ImageIOError:
|
|
98
|
+
exc = cls.__new__(cls)
|
|
99
|
+
ImageIOError.__init__(exc, message, code=code, detail=detail)
|
|
100
|
+
exc.errno = err
|
|
101
|
+
exc.filename = filename
|
|
102
|
+
return exc
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DecodeError(PhashError):
|
|
106
|
+
"""The data could not be decoded into an image. Parent of the four reasons below."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class UnsupportedFormatError(DecodeError):
|
|
110
|
+
"""The data is in no image format libphash recognizes."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CorruptDataError(DecodeError):
|
|
114
|
+
"""The format was recognized, but the data is malformed or truncated."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class DecoderUnavailableError(DecodeError):
|
|
118
|
+
"""The format was recognized, but this build has no decoder for it."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ImageTooLargeError(DecodeError):
|
|
122
|
+
"""The image is larger than ``Config.max_pixels`` or the library's fixed limits."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class InvalidArgumentError(PhashError, ValueError):
|
|
126
|
+
"""An argument is outside the range the C library accepts."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class AllocationError(PhashError, MemoryError):
|
|
130
|
+
"""A memory allocation failed in the C library."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class EmptyImageError(PhashError):
|
|
134
|
+
"""A hash was requested from a context that holds no image."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class RequiresColorError(PhashError):
|
|
138
|
+
"""A color algorithm was asked for an image loaded in grayscale."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class NoStructureError(PhashError):
|
|
142
|
+
"""A Radial digest has no structure to correlate (a flat image)."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class BatchCancelledError(PhashError):
|
|
146
|
+
"""A batch was stopped before every item was started."""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class DigestKindError(PhashError, TypeError):
|
|
150
|
+
"""Two digests were compared by a metric their kind does not support."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_CODE_NAMES: dict[int, str] = {
|
|
154
|
+
int(getattr(lib, name)): name
|
|
155
|
+
for name in dir(lib)
|
|
156
|
+
if name.startswith("PH_ERR_") and not name.endswith("_")
|
|
157
|
+
}
|
libphash/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-libphash
|
|
3
|
+
Version: 2.0.1.0
|
|
4
|
+
Summary: Perceptual image hashing: Python binding for the libphash C library
|
|
5
|
+
Author-email: gudoshnikovn <gudoshnikovna@gmail.com>
|
|
6
|
+
License-Expression: MIT AND IJG AND Zlib AND libpng-2.0 AND BSD-3-Clause AND (MIT OR Unlicense)
|
|
7
|
+
Project-URL: Homepage, https://github.com/gudoshnikovn/python-libphash
|
|
8
|
+
Project-URL: Source, https://github.com/gudoshnikovn/python-libphash
|
|
9
|
+
Project-URL: Issues, https://github.com/gudoshnikovn/python-libphash/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/gudoshnikovn/python-libphash/blob/main/CHANGELOG.md
|
|
11
|
+
Project-URL: libphash, https://gudoshnikovn.github.io/libphash/
|
|
12
|
+
Keywords: perceptual hash,phash,image hashing,duplicate images,dhash
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: C
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
27
|
+
Classifier: Programming Language :: Python :: Free Threading :: 2 - Beta
|
|
28
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
29
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
30
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
31
|
+
Classifier: Typing :: Typed
|
|
32
|
+
Requires-Python: >=3.10
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
License-File: LICENSE
|
|
35
|
+
License-File: native/libphash/THIRD-PARTY-NOTICES.md
|
|
36
|
+
License-File: native/libphash/vendor/libjpeg-turbo/LICENSE.md
|
|
37
|
+
License-File: native/libphash/vendor/libjpeg-turbo/README.ijg
|
|
38
|
+
License-File: native/libphash/vendor/libpng/LICENSE
|
|
39
|
+
License-File: native/libphash/vendor/libwebp/COPYING
|
|
40
|
+
License-File: native/libphash/vendor/libwebp/PATENTS
|
|
41
|
+
License-File: native/libphash/vendor/zlib-ng/LICENSE.md
|
|
42
|
+
Requires-Dist: cffi>=2.0
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: pytest; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-randomly; extra == "dev"
|
|
46
|
+
Requires-Dist: pytest-timeout; extra == "dev"
|
|
47
|
+
Requires-Dist: hypothesis; extra == "dev"
|
|
48
|
+
Requires-Dist: psutil; extra == "dev"
|
|
49
|
+
Requires-Dist: coverage; extra == "dev"
|
|
50
|
+
Requires-Dist: mypy; extra == "dev"
|
|
51
|
+
Requires-Dist: basedpyright; extra == "dev"
|
|
52
|
+
Requires-Dist: ruff; extra == "dev"
|
|
53
|
+
Requires-Dist: typos; extra == "dev"
|
|
54
|
+
Requires-Dist: setuptools; extra == "dev"
|
|
55
|
+
Requires-Dist: types-setuptools; extra == "dev"
|
|
56
|
+
Requires-Dist: types-cffi; extra == "dev"
|
|
57
|
+
Requires-Dist: types-psutil; extra == "dev"
|
|
58
|
+
Requires-Dist: typing_extensions; extra == "dev"
|
|
59
|
+
Requires-Dist: numpy; extra == "dev"
|
|
60
|
+
Requires-Dist: Pillow; extra == "dev"
|
|
61
|
+
Requires-Dist: opencv-python-headless; extra == "dev"
|
|
62
|
+
Requires-Dist: mutmut; sys_platform != "win32" and extra == "dev"
|
|
63
|
+
Requires-Dist: lief; extra == "dev"
|
|
64
|
+
Dynamic: license-file
|
|
65
|
+
|
|
66
|
+
# python-libphash
|
|
67
|
+
|
|
68
|
+
Perceptual image hashing in Python, with the [libphash](https://github.com/gudoshnikovn/libphash)
|
|
69
|
+
C library inside.
|
|
70
|
+
|
|
71
|
+
[](https://github.com/gudoshnikovn/python-libphash/actions/workflows/ci.yml)
|
|
72
|
+
[](https://pypi.org/project/python-libphash/)
|
|
73
|
+
[](https://pypi.org/project/python-libphash/)
|
|
74
|
+
[](https://github.com/gudoshnikovn/python-libphash/blob/main/LICENSE)
|
|
75
|
+
|
|
76
|
+
A perceptual hash changes little when an image is resized, recompressed or slightly
|
|
77
|
+
edited, so comparing hashes finds the copies of a picture among many images; an
|
|
78
|
+
unrelated picture lands about half the bits away. The package computes nine algorithms
|
|
79
|
+
in C, decodes JPEG, PNG and WebP with libraries linked into the wheel, and gives the same
|
|
80
|
+
hash for the same file on every platform, bit for bit libphash's.
|
|
81
|
+
|
|
82
|
+
**[Documentation](https://gudoshnikovn.github.io/python-libphash/)** · the theory
|
|
83
|
+
(algorithms, comparing, choosing a threshold) is on
|
|
84
|
+
[libphash's site](https://gudoshnikovn.github.io/libphash/).
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install python-libphash
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Wheels for Linux (x86-64, arm64), macOS (arm64) and Windows (x86-64), CPython 3.10 and
|
|
91
|
+
later, free-threaded builds included; elsewhere pip builds from source with a C compiler.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import libphash as ph
|
|
95
|
+
from libphash import Algorithm
|
|
96
|
+
|
|
97
|
+
a = ph.hash("photo.jpg") # pHash, a 64-bit int
|
|
98
|
+
b = ph.hash("photo_copy.jpg")
|
|
99
|
+
print(ph.hamming_distance(a, b)) # 0..64 bits differ: 0 for a copy
|
|
100
|
+
|
|
101
|
+
r = ph.hash("photo.jpg", Algorithm.RADIAL) # a Digest
|
|
102
|
+
print(r.similarity(ph.hash("photo_rotated.jpg", Algorithm.RADIAL)))
|
|
103
|
+
|
|
104
|
+
results = ph.hash_files(["photo.jpg", "photo_copy.jpg"], threads=0) # all CPUs
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`ph.hash()` takes a path, encoded bytes, a file object, a numpy array of pixels or a
|
|
108
|
+
Pillow image; `ImageContext` computes several hashes of one image and reuses its buffers
|
|
109
|
+
from image to image; `Config` holds every setting of libphash and checks it.
|
|
110
|
+
|
|
111
|
+
From a shell, `libphash hash`, `compare` and `dupes` do the same for files
|
|
112
|
+
(`python -m libphash` where the command is not on `PATH`):
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
libphash dupes --max-bits 10 ~/Pictures
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Scope
|
|
119
|
+
|
|
120
|
+
Hashes are deterministic and unkeyed: they find the copies of a picture in a collection
|
|
121
|
+
you control. They are not for moderation, blocklists or checking untrusted input, since
|
|
122
|
+
anyone who can choose the images can make two pictures hash alike or keep a copy from
|
|
123
|
+
matching. Decoding files from anywhere is the expected use, and a crash on one is a
|
|
124
|
+
vulnerability: see [SECURITY.md](https://github.com/gudoshnikovn/python-libphash/blob/main/SECURITY.md).
|
|
125
|
+
|
|
126
|
+
## Versions
|
|
127
|
+
|
|
128
|
+
The version is `X.Y.Z.N`: `X.Y.Z` is the bundled libphash, `N` the revision of the
|
|
129
|
+
binding. Hash values depend on `X.Y.Z` and the settings only, so
|
|
130
|
+
`pip install "python-libphash==2.0.1.*"` keeps a stored collection comparable while
|
|
131
|
+
taking the binding's fixes. Upgrading from 1.x changes hash values and the API: read
|
|
132
|
+
[MIGRATION.md](https://github.com/gudoshnikovn/python-libphash/blob/main/MIGRATION.md).
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT, see [LICENSE](https://github.com/gudoshnikovn/python-libphash/blob/main/LICENSE). The
|
|
137
|
+
wheels include libphash and the libraries it bundles (libjpeg-turbo, libpng, libwebp,
|
|
138
|
+
zlib-ng, stb_image), each under its own license, carried in the wheel.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
libphash/__init__.py,sha256=gjy7yG4OfjBDpB4p7GtzHHoCJvr85bbbJOQ0p3xaENA,5775
|
|
2
|
+
libphash/__main__.py,sha256=sLikFD6ofBKa5swu8fWWCxDH_FlzidWH3JJcmXH1CnA,145
|
|
3
|
+
libphash/_algorithms.py,sha256=ZAk8KqF_d1XyDHaMpCMQJSeSu6XYnsPHbu2c_6eTdBQ,2689
|
|
4
|
+
libphash/_batch.py,sha256=mDxyMOpVsKeLikotxGQNuU24LsaC1UllSlbwsJQlPYs,13263
|
|
5
|
+
libphash/_cli.py,sha256=Au033w2Q2ww50i-294aoC7mSR4SfUiobLOgaL6KYHZ8,18598
|
|
6
|
+
libphash/_config.py,sha256=s8hy6sbVZ2_pW-aOJsMFN1RkGWL7gmzrc4RHBIut0WI,8213
|
|
7
|
+
libphash/_context.py,sha256=QrGMeI9VZUvqmfTm0OQpbWqIZfjbN5rQbPq5iQceLm8,14309
|
|
8
|
+
libphash/_digest.py,sha256=pBRXOwDXPcvP-L9qR2rDqrUwuoonRxnQgqksB9Ucbjs,11201
|
|
9
|
+
libphash/_errors.py,sha256=nnOrzpFt4sW8I3-tO60MKWTIfSYh8DvgmRgOxuEAtec,3695
|
|
10
|
+
libphash/_native.pyd,sha256=_2mf2F4YV12ONgcNnmHbz9W-w-hi34MKeCC3hD_BSUw,1292288
|
|
11
|
+
libphash/_native.pyi,sha256=DxfxZtvVnG2FiRZbYqGzL_USBEaB0NRNuGmsgUoAshY,31335
|
|
12
|
+
libphash/_sources.py,sha256=fiR6PMkQGP9KP4GrbjX-yn72B4Zz9siSNf8Z3EdzB1w,9888
|
|
13
|
+
libphash/_version.py,sha256=hzV08Wy1L0XhmKoiIdHtGxsgDAIydpymWju7paJUGlA,173
|
|
14
|
+
libphash/exceptions.py,sha256=zIxDbRIDhI1SnYlxIcwZdMTkppKoy1GtQT4lD0zKmQM,4881
|
|
15
|
+
libphash/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
python_libphash-2.0.1.0.dist-info/licenses/LICENSE,sha256=y0PvbnMHXl-CFTfDpc55kAgVCUT22vsWFpdkOp11-N8,1090
|
|
17
|
+
python_libphash-2.0.1.0.dist-info/licenses/native/libphash/THIRD-PARTY-NOTICES.md,sha256=e95tvS69Y0JFF2BnpN91ymIuOERf3u0qe4kR-t4hZjA,8236
|
|
18
|
+
python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libjpeg-turbo/LICENSE.md,sha256=z_hwHcmZ45tQROzo8i7WWzoQ1qjb3cqP-4AtJFgBgds,6224
|
|
19
|
+
python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libjpeg-turbo/README.ijg,sha256=2xagQSgXGHnGBwjRcbiNlzRaLdIPm_wXNoCkSXxz9wQ,13059
|
|
20
|
+
python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libpng/LICENSE,sha256=RSOQQzug-Iqj4rEixkd0G3KgwRfNbtejKbSXha7LVRE,5479
|
|
21
|
+
python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libwebp/COPYING,sha256=F8ApqQKtRaGZ3-jjocOTBaso0wKwcDNgxKJzUaRnPcs,1526
|
|
22
|
+
python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libwebp/PATENTS,sha256=1Xi3pHHUhAahbGTjxqhoXmYpLzxnCSFMScpVsvIu180,1459
|
|
23
|
+
python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/zlib-ng/LICENSE.md,sha256=bJ8Nl1tBr6o00i9Vu4mGzmnly3rTJ8srKIIM1CXt9e4,867
|
|
24
|
+
python_libphash-2.0.1.0.dist-info/sboms/python-libphash.cdx.json,sha256=H9ZrHM4lLPhzasvsTUvVUi1lpKovJ33FGKS6S3Jn07c,3814
|
|
25
|
+
python_libphash-2.0.1.0.dist-info/METADATA,sha256=m7B84MRFIVQvwCMTq-faVuDZlmOBSK8V7_coXGFyzLQ,6765
|
|
26
|
+
python_libphash-2.0.1.0.dist-info/WHEEL,sha256=_nx_6fOfFB2ir5m3GgtWLIoU4BeBcd0Dd-_9POcQKIM,102
|
|
27
|
+
python_libphash-2.0.1.0.dist-info/entry_points.txt,sha256=JBVFrsnjo0jM6NzdRnl_goKwNQAg5ciaUTy4e8nfFHk,48
|
|
28
|
+
python_libphash-2.0.1.0.dist-info/top_level.txt,sha256=_Iys95-wvePR99MGJ1ikgjscpcotnUJ2m3JzmLvCG7w,9
|
|
29
|
+
python_libphash-2.0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gudoshnikovn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|