python-libphash 2.0.1.0__cp310-abi3-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/_config.py ADDED
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import math
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, Literal, NoReturn, cast
7
+
8
+ from ._errors import PH_SUCCESS, check, error_string
9
+ from ._native import ffi, lib
10
+ from .exceptions import InvalidArgumentError
11
+
12
+ if TYPE_CHECKING:
13
+ from ._native import ph_context_t_ptr
14
+
15
+ WHashMode = Literal["fast", "full"]
16
+ AlphaMode = Literal["grey", "white", "black", "ignore"]
17
+ DecodeScale = Literal[1, 2, 4, 8]
18
+
19
+ _WHASH_MODES = {"fast": lib.PH_WHASH_FAST, "full": lib.PH_WHASH_FULL}
20
+ _ALPHA_MODES = {
21
+ "grey": lib.PH_ALPHA_BLEND_GREY,
22
+ "white": lib.PH_ALPHA_BLEND_WHITE,
23
+ "black": lib.PH_ALPHA_BLEND_BLACK,
24
+ "ignore": lib.PH_ALPHA_IGNORE,
25
+ }
26
+ _DECODE_SCALES = {
27
+ 1: lib.PH_DECODE_SCALE_FULL,
28
+ 2: lib.PH_DECODE_SCALE_HALF,
29
+ 4: lib.PH_DECODE_SCALE_QUARTER,
30
+ 8: lib.PH_DECODE_SCALE_EIGHTH,
31
+ }
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Config:
36
+ """How images are loaded and hashed: every setting of a libphash context.
37
+
38
+ The defaults are libphash's. A ``Config`` is checked when it is created, against the
39
+ ranges the C library accepts, so a bad value fails here with the name of the field
40
+ and not at the first hash. It is immutable; :meth:`replace` gives a modified copy.
41
+ The same object configures an :class:`ImageContext`, :func:`hash` and a batch.
42
+
43
+ Hash values depend on the settings: two hashes are comparable only when computed
44
+ with equal configurations (and the same libphash version).
45
+ """
46
+
47
+ #: Gamma applied before the Radial hash's projections; read by Radial only.
48
+ gamma: float = 1.0
49
+ #: Relative R, G, B weights of the grayscale conversion.
50
+ gray_weights: tuple[int, int, int] = (38, 75, 15)
51
+ #: pHash: side of the DCT grid (``phash_reduction_size``..32).
52
+ phash_dct_size: int = 32
53
+ #: pHash: side of the low-frequency block the hash is made of, 4..8 (the hash has
54
+ #: its square in bits).
55
+ phash_reduction_size: int = 8
56
+ #: Radial: number of lines through the center, 40..4096.
57
+ radial_projections: int = 180
58
+ #: Radial: points read on each line, 2..4096.
59
+ radial_samples: int = 128
60
+ #: Radial: Gaussian blur sigma before the projections, (0, 64/3].
61
+ radial_sigma: float = 3.5
62
+ #: BMH: side of the block grid, 2..32 (the digest has its square in bits).
63
+ bmh_block_size: int = 16
64
+ #: mHash: scale base (> 1).
65
+ mhash_alpha: float = 2.0
66
+ #: mHash: scale exponent (>= 0).
67
+ mhash_level: float = 1.0
68
+ #: mHash: normalization side, 62..4096.
69
+ mhash_size: int = 512
70
+ #: wHash: ``"fast"`` (16x16, one Haar level) or ``"full"`` (ImageHash's scale).
71
+ whash_mode: WHashMode = "fast"
72
+ #: wHash: zero the coarsest LL coefficient (ImageHash's ``remove_max_haar_ll``).
73
+ whash_remove_max_haar_ll: bool = False
74
+ #: Decode to one gray channel: faster, but the color algorithms then raise
75
+ #: RequiresColorError.
76
+ grayscale: bool = False
77
+ #: How an alpha channel is resolved: blended over grey, white or black, or ignored.
78
+ alpha_mode: AlphaMode = "grey"
79
+ #: Apply the EXIF orientation of JPEG and PNG files and buffers.
80
+ auto_orient: bool = True
81
+ #: Largest image accepted, in pixels; 0 leaves only the library's fixed limit.
82
+ max_pixels: int = 256 * 1024 * 1024
83
+ #: Decode at 1/1, 1/2, 1/4 or 1/8 of the resolution where the decoder can (JPEG).
84
+ decode_scale: DecodeScale = 1
85
+
86
+ def __post_init__(self) -> None:
87
+ _check_types(self)
88
+ ctx = new_context()
89
+ try:
90
+ apply_config(self, ctx)
91
+ finally:
92
+ lib.ph_free(ctx)
93
+
94
+ def replace(self, **changes: Any) -> Config: # noqa: ANN401 -- checked as a new Config
95
+ """Return a copy with the given fields changed, checked like a new Config."""
96
+ return dataclasses.replace(self, **changes)
97
+
98
+
99
+ def apply_config(cfg: Config, ctx: ph_context_t_ptr) -> None:
100
+ """Set every field of ``cfg`` on a ``ph_context_t``."""
101
+ steps: list[tuple[str, int]] = [
102
+ ("gamma", lib.ph_context_set_gamma(ctx, cfg.gamma)),
103
+ ("gray_weights", lib.ph_context_set_gray_weights(ctx, *cfg.gray_weights)),
104
+ (
105
+ "phash_dct_size/phash_reduction_size",
106
+ lib.ph_context_set_phash_params(
107
+ ctx, cfg.phash_dct_size, cfg.phash_reduction_size
108
+ ),
109
+ ),
110
+ (
111
+ "radial_projections/radial_samples/radial_sigma",
112
+ lib.ph_context_set_radial_params(
113
+ ctx, cfg.radial_projections, cfg.radial_samples, cfg.radial_sigma
114
+ ),
115
+ ),
116
+ (
117
+ "bmh_block_size",
118
+ lib.ph_context_set_block_params(ctx, cfg.bmh_block_size),
119
+ ),
120
+ (
121
+ "mhash_alpha/mhash_level/mhash_size",
122
+ lib.ph_context_set_mhash_params(
123
+ ctx, cfg.mhash_alpha, cfg.mhash_level, cfg.mhash_size
124
+ ),
125
+ ),
126
+ (
127
+ "whash_mode",
128
+ lib.ph_context_set_whash_mode(ctx, _WHASH_MODES[cfg.whash_mode]),
129
+ ),
130
+ (
131
+ "whash_remove_max_haar_ll",
132
+ lib.ph_context_set_whash_remove_max_haar_ll(
133
+ ctx, int(cfg.whash_remove_max_haar_ll)
134
+ ),
135
+ ),
136
+ ("grayscale", lib.ph_context_set_load_grayscale(ctx, int(cfg.grayscale))),
137
+ (
138
+ "alpha_mode",
139
+ lib.ph_context_set_alpha_mode(ctx, _ALPHA_MODES[cfg.alpha_mode]),
140
+ ),
141
+ ("auto_orient", lib.ph_context_set_auto_orient(ctx, int(cfg.auto_orient))),
142
+ ("max_pixels", lib.ph_context_set_max_pixels(ctx, cfg.max_pixels)),
143
+ (
144
+ "decode_scale",
145
+ lib.ph_context_set_decode_scale(ctx, _DECODE_SCALES[cfg.decode_scale]),
146
+ ),
147
+ ]
148
+ for name, code in steps:
149
+ if code != PH_SUCCESS:
150
+ values = ", ".join(f"{f}={getattr(cfg, f)!r}" for f in name.split("/"))
151
+ raise InvalidArgumentError(
152
+ f"Config: {values} is out of range: {error_string(code)}", code=code
153
+ )
154
+
155
+
156
+ def new_context() -> ph_context_t_ptr:
157
+ out = ffi.new("ph_context_t **")
158
+ check(lib.ph_create(out), context="creating a context")
159
+ return out[0]
160
+
161
+
162
+ def _check_types(cfg: Config) -> None:
163
+ # Annotations do not bind an untyped caller: every value is checked as an object.
164
+ def fail(name: str, expected: str) -> NoReturn:
165
+ raise InvalidArgumentError(
166
+ f"Config: {name} must be {expected}, got {getattr(cfg, name)!r}"
167
+ )
168
+
169
+ def value(name: str) -> object:
170
+ return getattr(cfg, name)
171
+
172
+ for name in ("gamma", "radial_sigma", "mhash_alpha", "mhash_level"):
173
+ v = value(name)
174
+ if (
175
+ isinstance(v, bool)
176
+ or not isinstance(v, (int, float))
177
+ or not math.isfinite(v)
178
+ ):
179
+ fail(name, "a finite number")
180
+ for name in (
181
+ "phash_dct_size",
182
+ "phash_reduction_size",
183
+ "radial_projections",
184
+ "radial_samples",
185
+ "bmh_block_size",
186
+ "mhash_size",
187
+ "max_pixels",
188
+ ):
189
+ if not _is_int(value(name)):
190
+ fail(name, "an int")
191
+ if not (0 <= cfg.max_pixels < 1 << 64):
192
+ fail("max_pixels", "in [0, 2**64)")
193
+ weights = value("gray_weights")
194
+ items = cast("tuple[object, ...]", weights) if isinstance(weights, tuple) else ()
195
+ if len(items) != 3 or not all(_is_int(w) for w in items):
196
+ fail("gray_weights", "a tuple of three ints")
197
+ for name in ("whash_remove_max_haar_ll", "grayscale", "auto_orient"):
198
+ if not isinstance(value(name), bool):
199
+ fail(name, "a bool")
200
+ if value("whash_mode") not in _WHASH_MODES:
201
+ fail("whash_mode", "one of " + ", ".join(map(repr, _WHASH_MODES)))
202
+ if value("alpha_mode") not in _ALPHA_MODES:
203
+ fail("alpha_mode", "one of " + ", ".join(map(repr, _ALPHA_MODES)))
204
+ scale = value("decode_scale")
205
+ if not _is_int(scale) or scale not in _DECODE_SCALES:
206
+ fail("decode_scale", "1, 2, 4 or 8")
207
+
208
+
209
+ def _is_int(v: object) -> bool:
210
+ return isinstance(v, int) and not isinstance(v, bool)
libphash/_context.py ADDED
@@ -0,0 +1,388 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ from typing import TYPE_CHECKING, Literal, NoReturn, cast, overload
6
+
7
+ from ._algorithms import Algorithm, AlgorithmLike, to_algorithm, uint64_flags
8
+ from ._config import Config, apply_config, new_context
9
+ from ._digest import Digest, digest_from_c
10
+ from ._errors import PH_SUCCESS, make_error
11
+ from ._native import ffi, lib
12
+ from ._sources import (
13
+ READ_FILES_IN_PYTHON,
14
+ ChannelOrder,
15
+ PILImage,
16
+ PixelArray,
17
+ Pixels,
18
+ Source,
19
+ StrPath,
20
+ SupportsArrayInterface,
21
+ SupportsRead,
22
+ encode_path,
23
+ is_pil_image,
24
+ pixels_from_array,
25
+ pixels_from_pil,
26
+ read_file,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from typing_extensions import Self
31
+
32
+ from ._native import ph_context_t_ptr
33
+
34
+ #: Names of the algorithms that produce a 64-bit ``int``.
35
+ Uint64Name = Literal["ahash", "dhash", "phash", "whash"]
36
+ #: Names of the algorithms that produce a :class:`Digest`.
37
+ DigestName = Literal["bmh", "mhash", "radial", "color_hash", "color_moments"]
38
+ #: The algorithms that produce a 64-bit ``int``.
39
+ Uint64Algorithm = Literal[
40
+ Algorithm.AHASH, Algorithm.DHASH, Algorithm.PHASH, Algorithm.WHASH
41
+ ]
42
+ #: The algorithms that produce a :class:`Digest`.
43
+ DigestAlgorithm = Literal[
44
+ Algorithm.BMH,
45
+ Algorithm.MHASH,
46
+ Algorithm.RADIAL,
47
+ Algorithm.COLOR_HASH,
48
+ Algorithm.COLOR_MOMENTS,
49
+ ]
50
+
51
+ _UINT64_COMPUTE = {
52
+ Algorithm.AHASH: lib.ph_compute_ahash,
53
+ Algorithm.DHASH: lib.ph_compute_dhash,
54
+ Algorithm.PHASH: lib.ph_compute_phash,
55
+ Algorithm.WHASH: lib.ph_compute_whash,
56
+ }
57
+
58
+
59
+ class ImageContext:
60
+ """One image and the configuration to hash it with: a libphash ``ph_context_t``.
61
+
62
+ Load an image with one of the ``load_*`` methods, then compute any number of hashes
63
+ from it; loading the next image reuses the context's buffers, which makes a context
64
+ kept across a loop faster than :func:`hash` per image.
65
+
66
+ A context is safe to share between threads: the calls into C are serialized on a
67
+ lock of the context. Threads that work in parallel each need their own context (or a
68
+ batch, :func:`hash_files`). Close it with :meth:`close` or a ``with`` block; one
69
+ left to the garbage collector is freed too.
70
+ """
71
+
72
+ __slots__ = ("__weakref__", "_config", "_lock", "_ptr")
73
+
74
+ def __init__(self, config: Config | None = None) -> None:
75
+ self._lock = threading.Lock()
76
+ self._config = _config_or_default(config)
77
+ ptr = new_context()
78
+ self._ptr: ph_context_t_ptr | None = ffi.gc(ptr, lib.ph_free)
79
+ apply_config(self._config, self._ptr)
80
+
81
+ # --- Lifecycle -------------------------------------------------------------------
82
+
83
+ def close(self) -> None:
84
+ """Free the C context. Idempotent; any later use raises ValueError."""
85
+ with self._lock:
86
+ if self._ptr is not None:
87
+ ffi.release(self._ptr)
88
+ self._ptr = None
89
+
90
+ @property
91
+ def closed(self) -> bool:
92
+ return self._ptr is None
93
+
94
+ def __enter__(self) -> Self:
95
+ self._live()
96
+ return self
97
+
98
+ def __exit__(self, *exc_info: object) -> None:
99
+ self.close()
100
+
101
+ def __repr__(self) -> str:
102
+ if self._ptr is None:
103
+ return "<ImageContext closed>"
104
+ if not self.is_loaded:
105
+ return "<ImageContext empty>"
106
+ return f"<ImageContext {self.width}x{self.height}x{self.channels}>"
107
+
108
+ def __reduce__(self) -> NoReturn:
109
+ raise TypeError("ImageContext cannot be pickled; pickle its Config instead")
110
+
111
+ def __copy__(self) -> NoReturn:
112
+ raise TypeError(
113
+ "ImageContext cannot be copied; create one from its Config instead"
114
+ )
115
+
116
+ def __deepcopy__(self, memo: object) -> NoReturn:
117
+ self.__copy__()
118
+
119
+ def _live(self) -> ph_context_t_ptr:
120
+ ptr = self._ptr
121
+ if ptr is None:
122
+ raise ValueError("operation on a closed ImageContext")
123
+ return ptr
124
+
125
+ # --- State -----------------------------------------------------------------------
126
+
127
+ @property
128
+ def config(self) -> Config:
129
+ return self._config
130
+
131
+ @property
132
+ def is_loaded(self) -> bool:
133
+ with self._lock:
134
+ return bool(lib.ph_is_loaded(self._live()))
135
+
136
+ def _dimensions(self) -> tuple[int, int, int]:
137
+ out = ffi.new("int[]", 3)
138
+ with self._lock:
139
+ lib.ph_context_get_dimensions(self._live(), out, out + 1, out + 2)
140
+ return int(out[0]), int(out[1]), int(out[2])
141
+
142
+ @property
143
+ def width(self) -> int:
144
+ """Width of the loaded image (after orientation and decode scale), 0 if none."""
145
+ return self._dimensions()[0]
146
+
147
+ @property
148
+ def height(self) -> int:
149
+ return self._dimensions()[1]
150
+
151
+ @property
152
+ def channels(self) -> int:
153
+ """3 for color, 1 for grayscale.
154
+
155
+ An alpha channel is resolved at load and not kept.
156
+ """
157
+ return self._dimensions()[2]
158
+
159
+ # --- Loading ---------------------------------------------------------------------
160
+
161
+ def load(self, source: Source) -> None:
162
+ """Load whatever ``source`` is.
163
+
164
+ ``source`` is a path, encoded bytes, a binary file object, an array of pixels or
165
+ a Pillow image. Bytes are always the encoded file, never a path.
166
+ """
167
+ if isinstance(source, (str, os.PathLike)):
168
+ self.load_file(source)
169
+ elif isinstance(source, (bytes, bytearray)):
170
+ self.load_bytes(source)
171
+ elif isinstance(source, memoryview):
172
+ if source.ndim in (2, 3):
173
+ self.load_pixels(source)
174
+ else:
175
+ self.load_bytes(source)
176
+ elif is_pil_image(source):
177
+ self.load_pil(cast("PILImage", source))
178
+ elif isinstance(source, SupportsArrayInterface):
179
+ self.load_pixels(source)
180
+ elif callable(getattr(source, "read", None)):
181
+ self.load_fileobj(source)
182
+ else:
183
+ raise TypeError(
184
+ "expected a path, bytes, a binary file object, an array of pixels or a "
185
+ f"Pillow image, got {type(source).__name__}"
186
+ )
187
+
188
+ def load_file(self, path: StrPath) -> None:
189
+ """Load an image file. On failure the context holds no image."""
190
+ encoded = encode_path(path)
191
+ if READ_FILES_IN_PYTHON:
192
+ self.load_bytes(read_file(path))
193
+ return
194
+ with self._lock:
195
+ ptr = self._live()
196
+ code = lib.ph_load_from_file(ptr, encoded)
197
+ if code != PH_SUCCESS:
198
+ raise make_error(code, detail=self._last_message(ptr), path=path)
199
+
200
+ def load_bytes(self, data: bytes | bytearray | memoryview) -> None:
201
+ """Load an encoded image (JPEG, PNG, WebP, ...) from memory, without a copy.
202
+
203
+ The buffer must not change while the call runs. On failure the context holds no
204
+ image.
205
+ """
206
+ try:
207
+ buf = ffi.from_buffer("const uint8_t[]", data)
208
+ except TypeError:
209
+ raise TypeError(
210
+ f"expected bytes-like encoded image data, got {type(data).__name__}"
211
+ ) from None
212
+ with self._lock:
213
+ ptr = self._live()
214
+ code = lib.ph_load_from_memory(ptr, buf, len(buf))
215
+ if code != PH_SUCCESS:
216
+ raise make_error(code, detail=self._last_message(ptr))
217
+
218
+ def load_fileobj(self, fileobj: SupportsRead) -> None:
219
+ """Read a binary file object to its end and load the bytes.
220
+
221
+ The object is an open file, an ``io.BytesIO``, a response body or anything else
222
+ with a ``read()`` that returns bytes.
223
+ """
224
+ self.load_bytes(_read_all(fileobj))
225
+
226
+ def load_pixels(
227
+ self, pixels: PixelArray, channel_order: ChannelOrder = "rgb"
228
+ ) -> None:
229
+ """Load decoded pixels.
230
+
231
+ ``pixels`` is an 8-bit array of shape (h, w), (h, w, 1), (h, w, 3) or (h, w, 4),
232
+ e.g. a numpy ``uint8`` array. ``channel_order="bgr"`` reads OpenCV's order. The
233
+ pixels are copied into the context, and no EXIF orientation applies. On failure
234
+ the previously loaded image stays loaded.
235
+ """
236
+ self._load_pixels(pixels_from_array(pixels, channel_order))
237
+
238
+ def load_pil(self, image: PILImage) -> None:
239
+ """Load a Pillow image.
240
+
241
+ :func:`pixels_from_pil` describes how each mode is read.
242
+ """
243
+ self._load_pixels(pixels_from_pil(image))
244
+
245
+ def _load_pixels(self, px: Pixels) -> None:
246
+ with self._lock:
247
+ ptr = self._live()
248
+ code = lib.ph_load_from_pixels(
249
+ ptr, px.pointer, px.width, px.height, px.channels, px.stride
250
+ )
251
+ if code != PH_SUCCESS:
252
+ # libphash writes no message for a pixel load.
253
+ raise make_error(code)
254
+ del px
255
+
256
+ @staticmethod
257
+ def _last_message(ptr: ph_context_t_ptr) -> str:
258
+ # Never NULL: an empty string when the last load set no message.
259
+ msg = lib.ph_get_last_error_message(ptr)
260
+ return str(ffi.string(msg).decode("utf-8", "replace"))
261
+
262
+ # --- Hashing ---------------------------------------------------------------------
263
+
264
+ @overload
265
+ def hash(self, algorithm: Uint64Algorithm | Uint64Name = ...) -> int: ...
266
+ @overload
267
+ def hash(self, algorithm: DigestAlgorithm | DigestName) -> Digest: ...
268
+ @overload
269
+ def hash(self, algorithm: AlgorithmLike) -> int | Digest: ...
270
+
271
+ def hash(self, algorithm: AlgorithmLike = Algorithm.PHASH) -> int | Digest:
272
+ """Compute one hash of the loaded image.
273
+
274
+ Returns an ``int`` for aHash, dHash, pHash and wHash, a :class:`Digest` for the
275
+ others.
276
+ """
277
+ algo = to_algorithm(algorithm)
278
+ with self._lock:
279
+ return _compute(self._live(), algo)
280
+
281
+ def hashes(self, *algorithms: AlgorithmLike) -> dict[Algorithm, int | Digest]:
282
+ """Compute several hashes at once, all of the same loaded image.
283
+
284
+ The 64-bit ones share their work on the full image (one ``ph_compute_multi``
285
+ call); digests are computed one by one. No other thread loads an image in
286
+ between.
287
+ """
288
+ algos = [to_algorithm(a) for a in algorithms] or [Algorithm.PHASH]
289
+ uint64 = sorted({a for a in algos if a in _UINT64_COMPUTE})
290
+ result: dict[Algorithm, int | Digest] = {}
291
+ with self._lock:
292
+ ptr = self._live()
293
+ if uint64:
294
+ out = ffi.new("uint64_t[]", len(uint64))
295
+ code = lib.ph_compute_multi(ptr, uint64_flags(uint64), out)
296
+ if code != PH_SUCCESS:
297
+ raise make_error(
298
+ code, context="computing " + ", ".join(map(str, uint64))
299
+ )
300
+ # Slots follow ascending flag bits, which is ascending Algorithm value.
301
+ result.update(zip(uint64, (int(v) for v in out), strict=True))
302
+ for algo in algos:
303
+ if algo not in result:
304
+ result[algo] = _compute(ptr, algo)
305
+ return {a: result[a] for a in algos}
306
+
307
+ def ahash(self) -> int:
308
+ """Average hash: each cell of an 8x8 grayscale reduction against their mean."""
309
+ return self.hash(Algorithm.AHASH)
310
+
311
+ def dhash(self) -> int:
312
+ """Difference hash: horizontal neighbor comparisons in a 9x8 reduction."""
313
+ return self.hash(Algorithm.DHASH)
314
+
315
+ def phash(self) -> int:
316
+ """Perceptual hash: the low-frequency DCT block of a reduction.
317
+
318
+ Each coefficient is compared with the block's median.
319
+ """
320
+ return self.hash(Algorithm.PHASH)
321
+
322
+ def whash(self) -> int:
323
+ """Wavelet hash: the low-frequency Haar band of a reduction.
324
+
325
+ Each coefficient is compared with the band's median.
326
+ """
327
+ return self.hash(Algorithm.WHASH)
328
+
329
+ def bmh(self) -> Digest:
330
+ """Block mean hash: one bit per block of a ``bmh_block_size`` grid (BITS)."""
331
+ return self.hash(Algorithm.BMH)
332
+
333
+ def mhash(self) -> Digest:
334
+ """Marr-Hildreth hash: a Laplacian-of-Gaussian response (BITS).
335
+
336
+ Nine bits per window, 576 bits in all.
337
+ """
338
+ return self.hash(Algorithm.MHASH)
339
+
340
+ def radial(self) -> Digest:
341
+ """Radial variance hash: rotation-tolerant projections (COEFFICIENTS)."""
342
+ return self.hash(Algorithm.RADIAL)
343
+
344
+ def color_hash(self) -> Digest:
345
+ """Color histogram, compared by histogram intersection (HISTOGRAM)."""
346
+ return self.hash(Algorithm.COLOR_HASH)
347
+
348
+ def color_moments(self) -> Digest:
349
+ """Color moments per channel (VECTOR16).
350
+
351
+ The mean, the standard deviation and the cube root of the third moment.
352
+ """
353
+ return self.hash(Algorithm.COLOR_MOMENTS)
354
+
355
+
356
+ def _compute(ptr: ph_context_t_ptr, algo: Algorithm) -> int | Digest:
357
+ """Compute one hash; the caller holds the context's lock."""
358
+ compute = _UINT64_COMPUTE.get(algo)
359
+ if compute is not None:
360
+ out64 = ffi.new("uint64_t *")
361
+ code = compute(ptr, out64)
362
+ if code != PH_SUCCESS:
363
+ raise make_error(code, context=f"computing {algo}")
364
+ return int(out64[0])
365
+ digest = ffi.new("ph_digest_t *")
366
+ code = lib.ph_compute_digest(ptr, int(algo), digest)
367
+ if code != PH_SUCCESS:
368
+ raise make_error(code, context=f"computing {algo}")
369
+ return digest_from_c(digest)
370
+
371
+
372
+ def _config_or_default(config: object) -> Config:
373
+ if config is None:
374
+ return Config()
375
+ if not isinstance(config, Config):
376
+ raise TypeError(f"expected a Config, got {type(config).__name__}")
377
+ return config
378
+
379
+
380
+ def _read_all(fileobj: SupportsRead) -> bytes | bytearray | memoryview:
381
+ data = cast("object", fileobj.read()) # checked: an untyped read() may return str
382
+ if isinstance(data, (bytes, bytearray)):
383
+ return data
384
+ if isinstance(data, memoryview):
385
+ return cast("memoryview", data)
386
+ raise TypeError(
387
+ f"{type(fileobj).__name__}.read() returned {type(data).__name__}, not bytes"
388
+ )