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/_digest.py ADDED
@@ -0,0 +1,293 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, NoReturn, TypeVar, final
4
+
5
+ from ._algorithms import DigestKind
6
+ from ._errors import PH_SUCCESS, make_error
7
+ from ._native import ffi, lib
8
+ from ._sources import encode_text
9
+ from .exceptions import DigestKindError, InvalidArgumentError
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ from ._native import double_ptr, ph_digest_t_ptr
15
+
16
+ _N = TypeVar("_N", int, float)
17
+
18
+ PH_DIGEST_MAX_BYTES: int = int(lib.PH_DIGEST_MAX_BYTES)
19
+ _UINT64_MAX = (1 << 64) - 1
20
+
21
+ # Comparison -> the kinds it accepts (UNSPECIFIED is accepted by all, as in C).
22
+ _METRIC_KINDS = {
23
+ "hamming_distance": (DigestKind.BITS,),
24
+ "l2_distance": (DigestKind.VECTOR, DigestKind.VECTOR16),
25
+ "histogram_intersection": (DigestKind.HISTOGRAM,),
26
+ "radial_similarity": (DigestKind.COEFFICIENTS,),
27
+ }
28
+
29
+
30
+ @final
31
+ class Digest:
32
+ """A multi-byte hash: the bytes and the kind that says how they may be compared.
33
+
34
+ BMH and mHash give bit vectors, Radial quantized coefficients, ColorHash a
35
+ histogram, ColorMoments a vector of signed 16-bit features. Each comparison method
36
+ accepts only the kinds it is meaningful for and raises :class:`DigestKindError` for
37
+ the others; :meth:`similarity` picks the comparison that belongs to the kind.
38
+
39
+ Digests are immutable, hashable, comparable for equality and picklable. ``hex()``
40
+ and ``from_hex()`` give a text form that keeps the kind.
41
+ """
42
+
43
+ __slots__ = ("_data", "_kind")
44
+ _data: bytes
45
+ _kind: DigestKind
46
+
47
+ def __init__(self, data: bytes, kind: DigestKind | int) -> None:
48
+ data = bytes(data)
49
+ if not 0 < len(data) <= PH_DIGEST_MAX_BYTES:
50
+ raise InvalidArgumentError(
51
+ f"a digest holds 1 to {PH_DIGEST_MAX_BYTES} bytes, got {len(data)}"
52
+ )
53
+ try:
54
+ kind = DigestKind(kind)
55
+ except ValueError:
56
+ raise InvalidArgumentError(f"unknown digest kind {kind!r}") from None
57
+ object.__setattr__(self, "_data", data)
58
+ object.__setattr__(self, "_kind", kind)
59
+
60
+ @classmethod
61
+ def from_uint64(cls, value: int) -> Digest:
62
+ """Wrap a 64-bit hash as an 8-byte ``BITS`` digest.
63
+
64
+ The most significant byte comes first, as in :func:`hash_to_hex`.
65
+ """
66
+ return cls(_check_uint64(value).to_bytes(8, "big"), DigestKind.BITS)
67
+
68
+ @classmethod
69
+ def from_hex(cls, text: str) -> Digest:
70
+ """Parse the text form written by :meth:`hex` (``"<kind>:<hex>"``)."""
71
+ out = ffi.new("ph_digest_t *")
72
+ code = lib.ph_digest_from_hex(encode_text(text), out)
73
+ if code != PH_SUCCESS:
74
+ raise make_error(code, context=f"parsing digest {text[:40]!r}")
75
+ return digest_from_c(out)
76
+
77
+ @property
78
+ def data(self) -> bytes:
79
+ return self._data
80
+
81
+ @property
82
+ def kind(self) -> DigestKind:
83
+ return self._kind
84
+
85
+ def __len__(self) -> int:
86
+ return len(self._data)
87
+
88
+ def __bytes__(self) -> bytes:
89
+ return self._data
90
+
91
+ def __eq__(self, other: object) -> bool:
92
+ if not isinstance(other, Digest):
93
+ return NotImplemented
94
+ return self._kind == other._kind and self._data == other._data
95
+
96
+ def __hash__(self) -> int:
97
+ return hash((self._kind, self._data))
98
+
99
+ def __repr__(self) -> str:
100
+ return f"Digest.from_hex({self.hex()!r})"
101
+
102
+ def __setattr__(self, name: str, value: object) -> NoReturn:
103
+ raise AttributeError("Digest is immutable")
104
+
105
+ def __reduce__(self) -> tuple[type[Digest], tuple[bytes, int]]:
106
+ return (Digest, (self._data, int(self._kind)))
107
+
108
+ def hex(self) -> str:
109
+ """Return the text form: the kind, a colon, the bytes in lowercase hex.
110
+
111
+ For example ``"bits:3fa0..."``; :meth:`from_hex` parses it back.
112
+ """
113
+ size = int(lib.PH_DIGEST_HEX_BUFFER_SIZE)
114
+ out = ffi.new("char[]", size)
115
+ code = lib.ph_digest_to_hex(digest_to_c(self), out, size)
116
+ if code != PH_SUCCESS:
117
+ raise make_error(code, context="encoding digest")
118
+ return str(ffi.string(out).decode("ascii"))
119
+
120
+ # --- Comparisons -----------------------------------------------------------------
121
+
122
+ def _pair(
123
+ self, other: object, metric: str
124
+ ) -> tuple[ph_digest_t_ptr, ph_digest_t_ptr]:
125
+ other = _as_digest(other)
126
+ allowed = _METRIC_KINDS[metric]
127
+ for d in (self, other):
128
+ if d._kind != DigestKind.UNSPECIFIED and d._kind not in allowed:
129
+ _wrong_kind(metric, d._kind)
130
+ if len(self._data) != len(other._data):
131
+ raise InvalidArgumentError(
132
+ f"{metric}: digests of different sizes "
133
+ f"({len(self._data)} and {len(other._data)} bytes)"
134
+ )
135
+ return digest_to_c(self), digest_to_c(other)
136
+
137
+ def hamming_distance(self, other: Digest) -> int:
138
+ """Count the differing bits between two ``BITS`` digests (BMH, mHash)."""
139
+ a, b = self._pair(other, "hamming_distance")
140
+ return int(
141
+ _sentinel(int(lib.ph_hamming_distance_digest(a, b)), "hamming_distance")
142
+ )
143
+
144
+ def l2_distance(self, other: Digest) -> float:
145
+ """Euclidean distance between two feature vectors (ColorMoments)."""
146
+ a, b = self._pair(other, "l2_distance")
147
+ return float(_sentinel(float(lib.ph_l2_distance(a, b)), "l2_distance"))
148
+
149
+ def histogram_intersection(self, other: Digest) -> float:
150
+ """Share of color two ``HISTOGRAM`` digests (ColorHash) have in common.
151
+
152
+ The result is 0.0 to 1.0.
153
+ """
154
+ a, b = self._pair(other, "histogram_intersection")
155
+ return _out_double(
156
+ lib.ph_histogram_intersection, a, b, "histogram_intersection"
157
+ )
158
+
159
+ def radial_similarity(self, other: Digest) -> float:
160
+ """Peak cross-correlation of two Radial digests, -1.0 to 1.0.
161
+
162
+ Raises :class:`NoStructureError` when either digest is flat (all bytes equal),
163
+ as the Radial hash of an image without angular structure is: any score would be
164
+ invented. :data:`RADIAL_PCC_THRESHOLD` is the source's cut for "same image".
165
+ """
166
+ a, b = self._pair(other, "radial_similarity")
167
+ return _out_double(lib.ph_radial_similarity, a, b, "radial_similarity")
168
+
169
+ def similarity(self, other: Digest) -> float:
170
+ """Compare with the similarity measure that belongs to the digests' kind.
171
+
172
+ ``BITS``: share of equal bits, 0.0 to 1.0. ``HISTOGRAM``: histogram
173
+ intersection, 0.0 to 1.0. ``COEFFICIENTS``: radial peak correlation, -1.0 to
174
+ 1.0. ``VECTOR16`` (ColorMoments) has no normalized similarity: use
175
+ :meth:`l2_distance`.
176
+ """
177
+ other = _as_digest(other)
178
+ kind = self._kind if self._kind != DigestKind.UNSPECIFIED else other._kind
179
+ if kind == DigestKind.BITS:
180
+ a, b = self._pair(other, "hamming_distance")
181
+ value = float(lib.ph_similarity_digest(a, b))
182
+ return float(_sentinel(value, "similarity"))
183
+ if kind == DigestKind.HISTOGRAM:
184
+ return self.histogram_intersection(other)
185
+ if kind == DigestKind.COEFFICIENTS:
186
+ return self.radial_similarity(other)
187
+ if kind == DigestKind.UNSPECIFIED:
188
+ raise DigestKindError(
189
+ "similarity: neither digest states its kind, so the measure is "
190
+ "unknown; call hamming_distance(), l2_distance(), "
191
+ "histogram_intersection() or radial_similarity() explicitly"
192
+ )
193
+ raise DigestKindError(
194
+ f"similarity: {kind.name} digests have no normalized similarity; "
195
+ "use l2_distance()"
196
+ )
197
+
198
+
199
+ def digest_from_c(c_digest: ph_digest_t_ptr) -> Digest:
200
+ """Copy a ``ph_digest_t *`` into a Digest."""
201
+ return Digest(bytes(ffi.buffer(c_digest.data, c_digest.size)), int(c_digest.kind))
202
+
203
+
204
+ def digest_to_c(digest: Digest) -> ph_digest_t_ptr:
205
+ """Return a new ``ph_digest_t *`` holding ``digest``."""
206
+ data = digest.data
207
+ c_digest = ffi.new("ph_digest_t *")
208
+ c_digest.size = len(data)
209
+ c_digest.kind = int(digest.kind)
210
+ ffi.memmove(c_digest.data, data, len(data))
211
+ return c_digest
212
+
213
+
214
+ def _as_digest(other: object) -> Digest:
215
+ if not isinstance(other, Digest):
216
+ raise TypeError(f"expected a Digest, got {type(other).__name__}")
217
+ return other
218
+
219
+
220
+ def _wrong_kind(metric: str, kind: DigestKind) -> NoReturn:
221
+ right = {
222
+ DigestKind.BITS: "hamming_distance() or similarity()",
223
+ DigestKind.VECTOR: "l2_distance()",
224
+ DigestKind.VECTOR16: "l2_distance()",
225
+ DigestKind.HISTOGRAM: "histogram_intersection() or similarity()",
226
+ DigestKind.COEFFICIENTS: "radial_similarity() or similarity()",
227
+ }[kind]
228
+ raise DigestKindError(
229
+ f"{metric} does not apply to {kind.name} digests; use {right}"
230
+ )
231
+
232
+
233
+ def _sentinel(value: _N, metric: str) -> _N:
234
+ # The checks in _pair leave the C library nothing to reject; -1 here would be a
235
+ # contract the binding missed.
236
+ if value < 0:
237
+ raise InvalidArgumentError(f"{metric}: the C library rejected the digests")
238
+ return value
239
+
240
+
241
+ def _out_double(
242
+ func: Callable[[ph_digest_t_ptr, ph_digest_t_ptr, double_ptr], int],
243
+ a: ph_digest_t_ptr,
244
+ b: ph_digest_t_ptr,
245
+ metric: str,
246
+ ) -> float:
247
+ out = ffi.new("double *")
248
+ code = func(a, b, out)
249
+ if code != PH_SUCCESS:
250
+ raise make_error(code, context=metric)
251
+ return float(out[0])
252
+
253
+
254
+ def _check_uint64(value: object) -> int:
255
+ if isinstance(value, bool) or not isinstance(value, int):
256
+ raise TypeError(f"a 64-bit hash is an int, got {type(value).__name__}")
257
+ if not 0 <= value <= _UINT64_MAX:
258
+ raise InvalidArgumentError(f"a 64-bit hash is in [0, 2**64), got {value}")
259
+ return value
260
+
261
+
262
+ def hamming_distance(a: int, b: int) -> int:
263
+ """Count the differing bits between two 64-bit hashes, 0 to 64."""
264
+ return int(lib.ph_hamming_distance(_check_uint64(a), _check_uint64(b)))
265
+
266
+
267
+ def similarity(a: int, b: int) -> float:
268
+ """Share of equal bits between two 64-bit hashes.
269
+
270
+ 1.0 means identical; unrelated images score near 0.5.
271
+ """
272
+ return float(lib.ph_similarity(_check_uint64(a), _check_uint64(b)))
273
+
274
+
275
+ def hash_to_hex(value: int) -> str:
276
+ """Format a 64-bit hash as 16 lowercase hex digits, most significant first."""
277
+ out = ffi.new("char[]", 17)
278
+ code = lib.ph_hash_to_hex(_check_uint64(value), out, 17)
279
+ if code != PH_SUCCESS:
280
+ raise make_error(code, context="encoding hash")
281
+ return str(ffi.string(out).decode("ascii"))
282
+
283
+
284
+ def hash_from_hex(text: str) -> int:
285
+ """Parse the 16 hex digits written by :func:`hash_to_hex`."""
286
+ out = ffi.new("uint64_t *")
287
+ code = lib.ph_hash_from_hex(encode_text(text), out)
288
+ if code != PH_SUCCESS:
289
+ raise make_error(code, context=f"parsing hash {text[:40]!r}")
290
+ return int(out[0])
291
+
292
+
293
+ RADIAL_PCC_THRESHOLD: float = float(lib.PH_RADIAL_PCC_THRESHOLD)
libphash/_errors.py ADDED
@@ -0,0 +1,109 @@
1
+ """Turning a ``ph_error_t`` into the exception of :mod:`libphash.exceptions`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import os
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ._native import ffi, lib
10
+ from .exceptions import (
11
+ AllocationError,
12
+ BatchCancelledError,
13
+ CorruptDataError,
14
+ DecoderUnavailableError,
15
+ EmptyImageError,
16
+ ImageIOError,
17
+ ImageTooLargeError,
18
+ InvalidArgumentError,
19
+ NoStructureError,
20
+ PhashError,
21
+ RequiresColorError,
22
+ UnsupportedFormatError,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from ._sources import StrPath
27
+
28
+ PH_SUCCESS = int(lib.PH_SUCCESS)
29
+ PH_ERR_IO = int(lib.PH_ERR_IO)
30
+ PH_ERR_CANCELLED = int(lib.PH_ERR_CANCELLED)
31
+
32
+
33
+ # The OSError subclasses an ImageIOError is raised as, so that ``except
34
+ # FileNotFoundError`` catches a missing image file. Their repr is ImageIOError's.
35
+ class FileNotFoundImageError(ImageIOError, FileNotFoundError):
36
+ pass
37
+
38
+
39
+ class PermissionDeniedImageError(ImageIOError, PermissionError):
40
+ pass
41
+
42
+
43
+ class IsADirectoryImageError(ImageIOError, IsADirectoryError):
44
+ pass
45
+
46
+
47
+ _CLASSES: dict[int, type[PhashError]] = {
48
+ int(lib.PH_ERR_ALLOCATION_FAILED): AllocationError,
49
+ int(lib.PH_ERR_INVALID_ARGUMENT): InvalidArgumentError,
50
+ int(lib.PH_ERR_EMPTY_IMAGE): EmptyImageError,
51
+ int(lib.PH_ERR_IMAGE_TOO_LARGE): ImageTooLargeError,
52
+ int(lib.PH_ERR_UNSUPPORTED_FORMAT): UnsupportedFormatError,
53
+ int(lib.PH_ERR_CORRUPT_DATA): CorruptDataError,
54
+ int(lib.PH_ERR_DECODER_UNAVAILABLE): DecoderUnavailableError,
55
+ PH_ERR_IO: ImageIOError,
56
+ int(lib.PH_ERR_REQUIRES_COLOR): RequiresColorError,
57
+ PH_ERR_CANCELLED: BatchCancelledError,
58
+ int(lib.PH_ERR_NO_STRUCTURE): NoStructureError,
59
+ }
60
+
61
+
62
+ def error_string(code: int) -> str:
63
+ """Return the C library's one-line description of a ``ph_error_t``."""
64
+ return str(ffi.string(lib.ph_get_error_string(code)).decode())
65
+
66
+
67
+ def make_error(
68
+ code: int, *, context: str = "", detail: str = "", path: StrPath | None = None
69
+ ) -> PhashError:
70
+ """Build the exception for a ``ph_error_t``.
71
+
72
+ ``context`` says what the binding was doing ("computing phash"); ``detail`` is the C
73
+ library's message for a load, and is passed only by the load functions, the only
74
+ ones that write it.
75
+ """
76
+ cls = _CLASSES.get(code, PhashError)
77
+ message = detail or error_string(code)
78
+ if context:
79
+ message = f"{context}: {message}"
80
+ if cls is ImageIOError:
81
+ return _io_error(message, code, detail, path)
82
+ return cls(message, code=code, detail=detail)
83
+
84
+
85
+ def _io_error(
86
+ message: str, code: int, detail: str, path: StrPath | None
87
+ ) -> ImageIOError:
88
+ err: int | None = None
89
+ cls: type[ImageIOError] = ImageIOError
90
+ if path is not None:
91
+ # The C library reports every unusable path as PH_ERR_IO; the file system tells
92
+ # which OSError it is. isdir() and exists() answer False for a path they cannot
93
+ # check, so access() sees only paths that exist.
94
+ if os.path.isdir(path):
95
+ err, cls = errno.EISDIR, IsADirectoryImageError
96
+ elif not os.path.exists(path):
97
+ err, cls = errno.ENOENT, FileNotFoundImageError
98
+ elif not os.access(path, os.R_OK):
99
+ err, cls = errno.EACCES, PermissionDeniedImageError
100
+ exc = cls(message, code=code, detail=detail)
101
+ exc.errno = err
102
+ exc.filename = os.fsdecode(path) if path is not None else None
103
+ return exc
104
+
105
+
106
+ def check(code: int, *, context: str = "") -> None:
107
+ """Raise the exception for ``code`` unless it is ``PH_SUCCESS``."""
108
+ if code != PH_SUCCESS:
109
+ raise make_error(code, context=context)
libphash/_native.pyd ADDED
Binary file