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/__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/_cli.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""The command line, ``libphash``: ``info``, ``hash``, ``compare``, ``dupes``.
|
|
2
|
+
|
|
3
|
+
Results go to standard output, one line per item, or as JSON with ``--json``; a file
|
|
4
|
+
that cannot be hashed is reported on standard error (in the JSON itself with ``--json``)
|
|
5
|
+
and does not stop the others. The exit status is 0 when every file was hashed, 1 when
|
|
6
|
+
some were not, and 2 for a wrong command line.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import dataclasses
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, TextIO, cast
|
|
20
|
+
|
|
21
|
+
from . import (
|
|
22
|
+
Algorithm,
|
|
23
|
+
Config,
|
|
24
|
+
Digest,
|
|
25
|
+
DigestKind,
|
|
26
|
+
ImageContext,
|
|
27
|
+
PhashError,
|
|
28
|
+
build_info,
|
|
29
|
+
can_decode,
|
|
30
|
+
hamming_distance,
|
|
31
|
+
hash_files,
|
|
32
|
+
hash_to_hex,
|
|
33
|
+
libphash_version,
|
|
34
|
+
similarity,
|
|
35
|
+
)
|
|
36
|
+
from ._algorithms import UINT64_ALGORITHMS
|
|
37
|
+
from ._version import __version__
|
|
38
|
+
from .exceptions import ImageIOError, NoStructureError
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Iterator, Sequence
|
|
42
|
+
|
|
43
|
+
#: Suffixes taken from a directory: the formats libphash decodes. A file named on the
|
|
44
|
+
#: command line is hashed whatever its name.
|
|
45
|
+
IMAGE_SUFFIXES = frozenset(
|
|
46
|
+
{
|
|
47
|
+
".jpg", ".jpeg", ".jpe", ".jfif", ".png", ".webp", ".gif", ".bmp", ".tga",
|
|
48
|
+
".psd", ".hdr", ".pic", ".pnm", ".pbm", ".pgm", ".ppm",
|
|
49
|
+
}
|
|
50
|
+
) # fmt: skip
|
|
51
|
+
|
|
52
|
+
EXIT_OK = 0
|
|
53
|
+
EXIT_FAILED = 1
|
|
54
|
+
EXIT_USAGE = 2
|
|
55
|
+
|
|
56
|
+
_ALGORITHMS = {str(a): a for a in Algorithm}
|
|
57
|
+
_FIELDS = {f.name: f for f in dataclasses.fields(Config)}
|
|
58
|
+
_TRUE = frozenset({"1", "true", "yes", "on"})
|
|
59
|
+
_FALSE = frozenset({"0", "false", "no", "off"})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: Sequence[str] | None = None, prog: str = "libphash") -> int:
|
|
63
|
+
"""Run the command line with ``argv`` (``sys.argv[1:]`` by default).
|
|
64
|
+
|
|
65
|
+
``prog`` is the command its help and errors name. Return the exit status.
|
|
66
|
+
"""
|
|
67
|
+
parser = _parser(prog)
|
|
68
|
+
args = parser.parse_args(argv)
|
|
69
|
+
try:
|
|
70
|
+
config = parse_config(args.config)
|
|
71
|
+
except (ValueError, TypeError) as exc:
|
|
72
|
+
parser.error(str(exc))
|
|
73
|
+
write_any_file_name(sys.stdout)
|
|
74
|
+
try:
|
|
75
|
+
status: int = args.run(args, config, sys.stdout, sys.stderr)
|
|
76
|
+
sys.stdout.flush()
|
|
77
|
+
except KeyboardInterrupt:
|
|
78
|
+
return 128 + 2
|
|
79
|
+
except BrokenPipeError:
|
|
80
|
+
# The reader went away (`| head`): stop quietly. Python would report the failed
|
|
81
|
+
# flush of stdout at exit, so stdout is pointed at devnull first.
|
|
82
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
83
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
84
|
+
return EXIT_FAILED
|
|
85
|
+
return status
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- Command line ---------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parser(prog: str) -> argparse.ArgumentParser:
|
|
92
|
+
parser = argparse.ArgumentParser(
|
|
93
|
+
prog=prog,
|
|
94
|
+
description="Perceptual image hashing with libphash.",
|
|
95
|
+
epilog=(
|
|
96
|
+
"Exit status: 0 when every file was hashed, 1 when some were not, "
|
|
97
|
+
"2 for a wrong command line."
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument("--version", action="version", version=_version_line())
|
|
101
|
+
commands = parser.add_subparsers(title="commands", required=True)
|
|
102
|
+
|
|
103
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
104
|
+
common.add_argument("--json", action="store_true", help="write the results as JSON")
|
|
105
|
+
|
|
106
|
+
configured = argparse.ArgumentParser(add_help=False)
|
|
107
|
+
configured.add_argument(
|
|
108
|
+
"-c",
|
|
109
|
+
"--config",
|
|
110
|
+
action="append",
|
|
111
|
+
default=[],
|
|
112
|
+
metavar="FIELD=VALUE",
|
|
113
|
+
help=(
|
|
114
|
+
"a Config field, as auto_orient=false or gray_weights=38,75,15; "
|
|
115
|
+
"repeat for several"
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
info = commands.add_parser(
|
|
120
|
+
"info",
|
|
121
|
+
parents=[common],
|
|
122
|
+
help="versions and build of the bundled libphash, for bug reports",
|
|
123
|
+
description="Print the versions and the build of the bundled libphash.",
|
|
124
|
+
)
|
|
125
|
+
info.set_defaults(run=_run_info, config=[])
|
|
126
|
+
|
|
127
|
+
hash_cmd = commands.add_parser(
|
|
128
|
+
"hash",
|
|
129
|
+
parents=[common, configured],
|
|
130
|
+
help="hash image files",
|
|
131
|
+
description=(
|
|
132
|
+
"Print the hashes of image files: 64-bit hashes as 16 hex digits, "
|
|
133
|
+
"digests as kind:hex. A directory is searched recursively for images; "
|
|
134
|
+
"'-' reads one image from standard input."
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
hash_cmd.add_argument("paths", nargs="+", metavar="PATH")
|
|
138
|
+
_algorithm_option(hash_cmd, "algorithms to compute (default: phash)")
|
|
139
|
+
hash_cmd.set_defaults(run=_run_hash)
|
|
140
|
+
|
|
141
|
+
compare = commands.add_parser(
|
|
142
|
+
"compare",
|
|
143
|
+
parents=[common, configured],
|
|
144
|
+
help="compare two images",
|
|
145
|
+
description=(
|
|
146
|
+
"Compare two images with each algorithm, by the measures that belong to "
|
|
147
|
+
"it: Hamming distance and similarity for bit hashes, similarity for "
|
|
148
|
+
"ColorHash and Radial, L2 distance for ColorMoments."
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
compare.add_argument("first", metavar="IMAGE")
|
|
152
|
+
compare.add_argument("second", metavar="IMAGE")
|
|
153
|
+
_algorithm_option(compare, "algorithms to compare with (default: phash)")
|
|
154
|
+
compare.set_defaults(run=_run_compare)
|
|
155
|
+
|
|
156
|
+
dupes = commands.add_parser(
|
|
157
|
+
"dupes",
|
|
158
|
+
parents=[common, configured],
|
|
159
|
+
help="find images that hash alike",
|
|
160
|
+
description=(
|
|
161
|
+
"Hash every image with a 64-bit algorithm on all CPUs and print the "
|
|
162
|
+
"groups of images whose hashes differ in at most --max-bits bits, one "
|
|
163
|
+
"group per paragraph. Measure the threshold on your own images: "
|
|
164
|
+
"https://gudoshnikovn.github.io/libphash/theory/comparing/"
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
dupes.add_argument("paths", nargs="+", metavar="PATH")
|
|
168
|
+
dupes.add_argument(
|
|
169
|
+
"--max-bits",
|
|
170
|
+
type=_bits,
|
|
171
|
+
required=True,
|
|
172
|
+
metavar="N",
|
|
173
|
+
help="the most bits two hashes of one picture may differ in, 0 to 64",
|
|
174
|
+
)
|
|
175
|
+
dupes.add_argument(
|
|
176
|
+
"-a",
|
|
177
|
+
"--algorithm",
|
|
178
|
+
type=_uint64_algorithm,
|
|
179
|
+
default=Algorithm.PHASH,
|
|
180
|
+
metavar="NAME",
|
|
181
|
+
help="ahash, dhash, phash (default) or whash",
|
|
182
|
+
)
|
|
183
|
+
dupes.add_argument(
|
|
184
|
+
"--threads",
|
|
185
|
+
type=_threads,
|
|
186
|
+
default=0,
|
|
187
|
+
metavar="N",
|
|
188
|
+
help="worker threads; 0 (default) is one per CPU",
|
|
189
|
+
)
|
|
190
|
+
dupes.set_defaults(run=_run_dupes)
|
|
191
|
+
return parser
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _algorithm_option(parser: argparse.ArgumentParser, help_text: str) -> None:
|
|
195
|
+
names = ", ".join(_ALGORITHMS)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"-a",
|
|
198
|
+
"--algorithm",
|
|
199
|
+
dest="algorithms",
|
|
200
|
+
type=_algorithm_list,
|
|
201
|
+
action="extend",
|
|
202
|
+
metavar="NAME[,NAME...]",
|
|
203
|
+
help=f"{help_text}; any of {names}",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _algorithm_list(text: str) -> list[Algorithm]:
|
|
208
|
+
out: list[Algorithm] = []
|
|
209
|
+
for name in text.split(","):
|
|
210
|
+
algo = _ALGORITHMS.get(name.strip().lower())
|
|
211
|
+
if algo is None:
|
|
212
|
+
names = ", ".join(_ALGORITHMS)
|
|
213
|
+
raise argparse.ArgumentTypeError(
|
|
214
|
+
f"unknown algorithm {name!r}; expected one of {names}"
|
|
215
|
+
)
|
|
216
|
+
out.append(algo)
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _uint64_algorithm(text: str) -> Algorithm:
|
|
221
|
+
[algo] = _algorithm_list(text)
|
|
222
|
+
if algo not in UINT64_ALGORITHMS:
|
|
223
|
+
raise argparse.ArgumentTypeError(
|
|
224
|
+
f"{algo} is not a 64-bit hash; expected ahash, dhash, phash or whash"
|
|
225
|
+
)
|
|
226
|
+
return algo
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _bits(text: str) -> int:
|
|
230
|
+
value = _integer(text)
|
|
231
|
+
if not 0 <= value <= 64:
|
|
232
|
+
raise argparse.ArgumentTypeError(f"expected 0 to 64 bits, got {value}")
|
|
233
|
+
return value
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _threads(text: str) -> int:
|
|
237
|
+
value = _integer(text)
|
|
238
|
+
if value < 0:
|
|
239
|
+
raise argparse.ArgumentTypeError(f"expected 0 or more threads, got {value}")
|
|
240
|
+
return value
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _integer(text: str) -> int:
|
|
244
|
+
try:
|
|
245
|
+
return int(text)
|
|
246
|
+
except ValueError:
|
|
247
|
+
raise argparse.ArgumentTypeError(f"expected a number, got {text!r}") from None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def parse_config(assignments: list[str]) -> Config:
|
|
251
|
+
"""Build a Config from ``field=value`` strings; Config checks the values."""
|
|
252
|
+
changes: dict[str, object] = {}
|
|
253
|
+
for assignment in assignments:
|
|
254
|
+
name, sep, text = assignment.partition("=")
|
|
255
|
+
name = name.strip()
|
|
256
|
+
if not sep:
|
|
257
|
+
raise ValueError(f"--config expects FIELD=VALUE, got {assignment!r}")
|
|
258
|
+
if name not in _FIELDS:
|
|
259
|
+
fields = ", ".join(_FIELDS)
|
|
260
|
+
raise ValueError(f"unknown Config field {name!r}; fields: {fields}")
|
|
261
|
+
changes[name] = _parse_value(name, text.strip())
|
|
262
|
+
return Config().replace(**changes)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _parse_value(name: str, text: str) -> object:
|
|
266
|
+
default = _FIELDS[name].default
|
|
267
|
+
if isinstance(default, bool):
|
|
268
|
+
if text.lower() in _TRUE:
|
|
269
|
+
return True
|
|
270
|
+
if text.lower() in _FALSE:
|
|
271
|
+
return False
|
|
272
|
+
raise ValueError(f"{name}: expected true or false, got {text!r}")
|
|
273
|
+
if isinstance(default, tuple):
|
|
274
|
+
return tuple(_number(name, part, int) for part in text.split(","))
|
|
275
|
+
if isinstance(default, int):
|
|
276
|
+
return _number(name, text, int)
|
|
277
|
+
if isinstance(default, float):
|
|
278
|
+
return _number(name, text, float)
|
|
279
|
+
return text
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _number(name: str, text: str, kind: type[int | float]) -> int | float:
|
|
283
|
+
try:
|
|
284
|
+
return kind(text.strip())
|
|
285
|
+
except ValueError:
|
|
286
|
+
raise ValueError(f"{name}: expected a number, got {text!r}") from None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --- Commands -------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _run_info(args: argparse.Namespace, _cfg: Config, out: TextIO, _err: TextIO) -> int:
|
|
293
|
+
info = {
|
|
294
|
+
"python-libphash": __version__,
|
|
295
|
+
"libphash": libphash_version(),
|
|
296
|
+
"build": build_info(),
|
|
297
|
+
"decoders": [f for f in ("jpeg", "png", "webp") if can_decode(f)],
|
|
298
|
+
"python": f"{platform.python_implementation()} {platform.python_version()}",
|
|
299
|
+
"gil": _gil(),
|
|
300
|
+
"platform": platform.platform(),
|
|
301
|
+
}
|
|
302
|
+
if args.json:
|
|
303
|
+
json.dump(info, out, indent=2)
|
|
304
|
+
out.write("\n")
|
|
305
|
+
return EXIT_OK
|
|
306
|
+
width = max(len(key) for key in info)
|
|
307
|
+
for key, value in info.items():
|
|
308
|
+
text = " ".join(value) if isinstance(value, list) else value
|
|
309
|
+
out.write(f"{key:<{width}} {text}\n")
|
|
310
|
+
return EXIT_OK
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _gil() -> str:
|
|
314
|
+
"""Say whether the GIL is on, and whether the interpreter can run without it."""
|
|
315
|
+
is_enabled = getattr(sys, "_is_gil_enabled", None)
|
|
316
|
+
if is_enabled is None:
|
|
317
|
+
return "enabled"
|
|
318
|
+
return "enabled" if is_enabled() else "disabled (free-threaded)"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _run_hash(
|
|
322
|
+
args: argparse.Namespace, config: Config, out: TextIO, err: TextIO
|
|
323
|
+
) -> int:
|
|
324
|
+
algorithms: list[Algorithm] = args.algorithms or [Algorithm.PHASH]
|
|
325
|
+
status = EXIT_OK
|
|
326
|
+
with ImageContext(config) as ctx:
|
|
327
|
+
for path in _expand(args.paths):
|
|
328
|
+
try:
|
|
329
|
+
if path == "-":
|
|
330
|
+
ctx.load_bytes(sys.stdin.buffer.read())
|
|
331
|
+
else:
|
|
332
|
+
ctx.load_file(path)
|
|
333
|
+
hashes = ctx.hashes(*algorithms)
|
|
334
|
+
except PhashError as exc:
|
|
335
|
+
status = EXIT_FAILED
|
|
336
|
+
if args.json:
|
|
337
|
+
out.write(json.dumps({"path": path, "error": _error(exc)}) + "\n")
|
|
338
|
+
else:
|
|
339
|
+
_report(err, path, exc)
|
|
340
|
+
continue
|
|
341
|
+
texts = {str(a): _text(hashes[a]) for a in algorithms}
|
|
342
|
+
if args.json:
|
|
343
|
+
out.write(json.dumps({"path": path, "hashes": texts}) + "\n")
|
|
344
|
+
else:
|
|
345
|
+
out.write(f"{' '.join(texts.values())} {path}\n")
|
|
346
|
+
return status
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _run_compare(
|
|
350
|
+
args: argparse.Namespace, config: Config, out: TextIO, err: TextIO
|
|
351
|
+
) -> int:
|
|
352
|
+
algorithms: list[Algorithm] = args.algorithms or [Algorithm.PHASH]
|
|
353
|
+
hashes: list[dict[Algorithm, int | Digest]] = []
|
|
354
|
+
for path in (args.first, args.second):
|
|
355
|
+
with ImageContext(config) as ctx:
|
|
356
|
+
try:
|
|
357
|
+
ctx.load_file(path)
|
|
358
|
+
hashes.append(ctx.hashes(*algorithms))
|
|
359
|
+
except PhashError as exc:
|
|
360
|
+
if args.json:
|
|
361
|
+
json.dump({"path": path, "error": _error(exc)}, out, indent=2)
|
|
362
|
+
out.write("\n")
|
|
363
|
+
else:
|
|
364
|
+
_report(err, path, exc)
|
|
365
|
+
return EXIT_FAILED
|
|
366
|
+
first, second = hashes
|
|
367
|
+
results = {str(a): _measures(first[a], second[a]) for a in algorithms}
|
|
368
|
+
if args.json:
|
|
369
|
+
json.dump(results, out, indent=2)
|
|
370
|
+
out.write("\n")
|
|
371
|
+
return EXIT_OK
|
|
372
|
+
width = max(len(name) for name in results)
|
|
373
|
+
for name, measures in results.items():
|
|
374
|
+
text = ", ".join(_measure_text(key, value) for key, value in measures.items())
|
|
375
|
+
out.write(f"{name:<{width}} {text}\n")
|
|
376
|
+
return EXIT_OK
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _measures(a: int | Digest, b: int | Digest) -> dict[str, int | float | None]:
|
|
380
|
+
"""Return the measures that belong to the hashes' kind, by name."""
|
|
381
|
+
if not (isinstance(a, Digest) and isinstance(b, Digest)):
|
|
382
|
+
a, b = cast("int", a), cast("int", b)
|
|
383
|
+
return {"distance": hamming_distance(a, b), "similarity": similarity(a, b)}
|
|
384
|
+
if a.kind == DigestKind.BITS:
|
|
385
|
+
return {"distance": a.hamming_distance(b), "similarity": a.similarity(b)}
|
|
386
|
+
if a.kind == DigestKind.VECTOR16:
|
|
387
|
+
return {"l2_distance": a.l2_distance(b)}
|
|
388
|
+
try:
|
|
389
|
+
return {"similarity": a.similarity(b)}
|
|
390
|
+
except NoStructureError:
|
|
391
|
+
# A flat image has no Radial structure to correlate: no score exists.
|
|
392
|
+
return {"similarity": None}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _measure_text(name: str, value: float | None) -> str:
|
|
396
|
+
if value is None:
|
|
397
|
+
return f"{name} none (an image without structure)"
|
|
398
|
+
if isinstance(value, int):
|
|
399
|
+
return f"{name} {value}"
|
|
400
|
+
return f"{name} {value:.4f}"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _run_dupes(
|
|
404
|
+
args: argparse.Namespace, config: Config, out: TextIO, err: TextIO
|
|
405
|
+
) -> int:
|
|
406
|
+
algo: Algorithm = args.algorithm
|
|
407
|
+
paths = list(_expand(args.paths))
|
|
408
|
+
results = hash_files(paths, algo, config=config, threads=args.threads)
|
|
409
|
+
hashed: list[tuple[str, int]] = []
|
|
410
|
+
failed: list[tuple[str, PhashError]] = []
|
|
411
|
+
for result in results:
|
|
412
|
+
if result.hashes is not None:
|
|
413
|
+
hashed.append((result.source, result.hashes[algo]))
|
|
414
|
+
else:
|
|
415
|
+
failed.append((result.source, cast("PhashError", result.error)))
|
|
416
|
+
groups = group_hashes(hashed, args.max_bits)
|
|
417
|
+
status = EXIT_FAILED if failed else EXIT_OK
|
|
418
|
+
if args.json:
|
|
419
|
+
doc = {
|
|
420
|
+
"groups": [
|
|
421
|
+
[{"path": p, "hash": hash_to_hex(h)} for p, h in g] for g in groups
|
|
422
|
+
],
|
|
423
|
+
"errors": [{"path": p, "error": _error(e)} for p, e in failed],
|
|
424
|
+
}
|
|
425
|
+
json.dump(doc, out, indent=2)
|
|
426
|
+
out.write("\n")
|
|
427
|
+
return status
|
|
428
|
+
for path, exc in failed:
|
|
429
|
+
_report(err, path, exc)
|
|
430
|
+
for i, group in enumerate(groups):
|
|
431
|
+
if i:
|
|
432
|
+
out.write("\n")
|
|
433
|
+
for path, value in group:
|
|
434
|
+
out.write(f"{hash_to_hex(value)} {path}\n")
|
|
435
|
+
return status
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def group_hashes(
|
|
439
|
+
hashed: list[tuple[str, int]], max_bits: int
|
|
440
|
+
) -> list[list[tuple[str, int]]]:
|
|
441
|
+
"""Group the images linked by a chain of pairs within ``max_bits`` of each other.
|
|
442
|
+
|
|
443
|
+
Every pair is compared once. The groups and the images in each keep the order of
|
|
444
|
+
``hashed``; images alone in their group are left out.
|
|
445
|
+
"""
|
|
446
|
+
parent = list(range(len(hashed)))
|
|
447
|
+
|
|
448
|
+
def root(i: int) -> int:
|
|
449
|
+
while parent[i] != i:
|
|
450
|
+
parent[i] = parent[parent[i]]
|
|
451
|
+
i = parent[i]
|
|
452
|
+
return i
|
|
453
|
+
|
|
454
|
+
for i, (_, a) in enumerate(hashed):
|
|
455
|
+
for j in range(i + 1, len(hashed)):
|
|
456
|
+
if (a ^ hashed[j][1]).bit_count() <= max_bits:
|
|
457
|
+
parent[root(j)] = root(i)
|
|
458
|
+
members: dict[int, list[tuple[str, int]]] = {}
|
|
459
|
+
for i, item in enumerate(hashed):
|
|
460
|
+
members.setdefault(root(i), []).append(item)
|
|
461
|
+
return [group for group in members.values() if len(group) > 1]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# --- Helpers --------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def write_any_file_name(stream: TextIO) -> None:
|
|
468
|
+
"""Let the output stream write every file name it is given.
|
|
469
|
+
|
|
470
|
+
A POSIX file name is bytes, which need not decode in the file system encoding: the
|
|
471
|
+
command line and os.walk() give the undecodable bytes as surrogates, written back as
|
|
472
|
+
those bytes, the name as ``ls`` prints it. A Windows file name is Unicode, which the
|
|
473
|
+
console's code page may not hold: a character it lacks is written as an escape.
|
|
474
|
+
Either way the output goes on instead of stopping at UnicodeEncodeError.
|
|
475
|
+
"""
|
|
476
|
+
if isinstance(stream, io.TextIOWrapper) and stream.errors == "strict":
|
|
477
|
+
errors = "backslashreplace" if sys.platform == "win32" else "surrogateescape"
|
|
478
|
+
stream.reconfigure(errors=errors)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _expand(paths: Sequence[str]) -> Iterator[str]:
|
|
482
|
+
"""Yield the files named, and the images in the directories named, in order.
|
|
483
|
+
|
|
484
|
+
A directory is walked recursively, its entries sorted by name, and contributes the
|
|
485
|
+
files whose suffix is in IMAGE_SUFFIXES. Anything else is yielded as given, so that
|
|
486
|
+
a missing file is reported by the load.
|
|
487
|
+
"""
|
|
488
|
+
for path in paths:
|
|
489
|
+
if path != "-" and Path(path).is_dir():
|
|
490
|
+
for root, dirs, files in os.walk(path):
|
|
491
|
+
dirs.sort()
|
|
492
|
+
for name in sorted(files):
|
|
493
|
+
if Path(name).suffix.lower() in IMAGE_SUFFIXES:
|
|
494
|
+
yield os.path.join(root, name)
|
|
495
|
+
else:
|
|
496
|
+
yield path
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _text(value: int | Digest) -> str:
|
|
500
|
+
return hash_to_hex(value) if isinstance(value, int) else value.hex()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _error(exc: PhashError) -> dict[str, str]:
|
|
504
|
+
# An ImageIOError is raised as a private subclass per errno; name the public one.
|
|
505
|
+
name = "ImageIOError" if isinstance(exc, ImageIOError) else type(exc).__name__
|
|
506
|
+
return {"type": name, "code": exc.code_name, "message": str(exc)}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _report(err: TextIO, path: str, exc: PhashError) -> None:
|
|
510
|
+
# Most messages of a failed load name the file already.
|
|
511
|
+
message = str(exc)
|
|
512
|
+
if path not in message:
|
|
513
|
+
message = f"{path}: {message}"
|
|
514
|
+
err.write(f"libphash: {message}\n")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _version_line() -> str:
|
|
518
|
+
return f"python-libphash {__version__} (libphash {libphash_version()})"
|