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/_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
|
+
)
|