pith-hash 0.1.1__py3-none-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.
pith_hash/__init__.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 pith-hash
|
|
3
|
+
"""pith-hash SDK: the suite curator through ctypes.
|
|
4
|
+
|
|
5
|
+
Detect, tier-1 content hash, tier-2 signatures, match and describe via
|
|
6
|
+
the `pith_hash` Rust cdylib. The byte streams the cdylib hands out are
|
|
7
|
+
the canonical serializations of the Rust `reference` module — the same
|
|
8
|
+
layouts the repository-root `reference.json` records its fnv1a64 /
|
|
9
|
+
sha256 sub-digests over — so this wrapper parses them back into plain
|
|
10
|
+
Python objects and re-hashes every payload.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ctypes
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Detection",
|
|
22
|
+
"Signature",
|
|
23
|
+
"Description",
|
|
24
|
+
"MatchResult",
|
|
25
|
+
"FfiError",
|
|
26
|
+
"LibraryNotFoundError",
|
|
27
|
+
"find_cdylib",
|
|
28
|
+
"detect",
|
|
29
|
+
"signature",
|
|
30
|
+
"describe",
|
|
31
|
+
"match_signatures",
|
|
32
|
+
"content_hash",
|
|
33
|
+
"parse_signature",
|
|
34
|
+
"parse_describe",
|
|
35
|
+
"fnv1a64",
|
|
36
|
+
"FORMATS",
|
|
37
|
+
"MODALITIES",
|
|
38
|
+
"MATCH_TAGS",
|
|
39
|
+
"STATUS_OK",
|
|
40
|
+
"STATUS_INVALID",
|
|
41
|
+
"STATUS_REJECTED",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
#: Status: success.
|
|
45
|
+
STATUS_OK = 0
|
|
46
|
+
#: Status: a caller argument is invalid (a null pointer, a capacity too
|
|
47
|
+
#: small for the fixed wire shape).
|
|
48
|
+
STATUS_INVALID = -1
|
|
49
|
+
#: Status: the core pipeline refused the input (decode failure in any
|
|
50
|
+
#: modality, a pending lane, a PDF/audio-layer rejection, or a
|
|
51
|
+
#: cross-modality match).
|
|
52
|
+
STATUS_REJECTED = -2
|
|
53
|
+
|
|
54
|
+
#: Format names by wire code (`pith_digest::Format` declaration order).
|
|
55
|
+
FORMATS = ("png", "jpeg", "bmp", "zip", "mp4", "mp3", "wav", "flac", "pdf", "gif", "unknown")
|
|
56
|
+
|
|
57
|
+
#: Modality names by wire code (declaration order).
|
|
58
|
+
MODALITIES = ("image", "audio", "text", "binary", "video")
|
|
59
|
+
|
|
60
|
+
#: Match-outcome tags by wire code (declaration order).
|
|
61
|
+
MATCH_TAGS = ("image", "audio", "text", "binary", "video")
|
|
62
|
+
|
|
63
|
+
#: Signature-stream tag bytes (declaration order).
|
|
64
|
+
SIGNATURE_TAGS = ("image", "audio", "text", "binary", "video")
|
|
65
|
+
|
|
66
|
+
#: Every cdylib file name cargo may drop into the build directory, per
|
|
67
|
+
#: platform (windows / linux / macOS).
|
|
68
|
+
CDYLIB_NAMES = ("pith_hash.dll", "libpith_hash.so", "libpith_hash.dylib")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class LibraryNotFoundError(OSError):
|
|
72
|
+
"""No cdylib was found through the discovery chain."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FfiError(Exception):
|
|
76
|
+
"""A non-zero status code came back from the cdylib."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, op: str, status: int) -> None:
|
|
79
|
+
kind = {
|
|
80
|
+
STATUS_INVALID: "invalid argument",
|
|
81
|
+
STATUS_REJECTED: "input rejected",
|
|
82
|
+
}.get(status, "unknown failure")
|
|
83
|
+
super().__init__(f"{op} failed: {kind} (status {status})")
|
|
84
|
+
#: The raw status code the FFI returned.
|
|
85
|
+
self.status = status
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Detection:
|
|
90
|
+
"""What `detect` found, re-expressed from the wire codes."""
|
|
91
|
+
|
|
92
|
+
#: The sniffed container name (`unknown` for bare text/binary).
|
|
93
|
+
format: str
|
|
94
|
+
#: The modality lane that handles it.
|
|
95
|
+
modality: str
|
|
96
|
+
#: `True` when the lane is named but not served yet.
|
|
97
|
+
pending: bool
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Signature:
|
|
102
|
+
"""A tier-2 signature, re-expressed from the signature stream.
|
|
103
|
+
|
|
104
|
+
The `*_count` fields mirror `reference.json`'s semantics: they are
|
|
105
|
+
the *byte length* of the canonical payload the sub-digests cover
|
|
106
|
+
(594 = 99 peaks × 6 B, 1024 = 128 words × 8 B).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
#: The modality tag (`image` … `video`).
|
|
110
|
+
modality: str
|
|
111
|
+
#: Image: the 64-bit perceptual hash.
|
|
112
|
+
phash: int | None = None
|
|
113
|
+
#: Audio: peak payload byte count, analysis frames, `(t, f)` peaks.
|
|
114
|
+
peak_count: int | None = None
|
|
115
|
+
#: Audio: analysis frames the signature covered.
|
|
116
|
+
frames: int | None = None
|
|
117
|
+
#: Audio: parsed `(t, f)` peak pairs.
|
|
118
|
+
peaks: tuple[tuple[int, int], ...] | None = None
|
|
119
|
+
#: Audio: the canonical peaks payload the digests cover.
|
|
120
|
+
peaks_bytes: bytes | None = None
|
|
121
|
+
#: Text/binary/video: payload byte counts.
|
|
122
|
+
word_count: int | None = None
|
|
123
|
+
#: Text: the 128 big-endian-carried MinHash words.
|
|
124
|
+
words: tuple[int, ...] | None = None
|
|
125
|
+
#: Binary: digest payload byte count and the 32-byte digests.
|
|
126
|
+
chunk_count: int | None = None
|
|
127
|
+
#: Binary: the sorted unique chunk digests, concatenated.
|
|
128
|
+
digest_bytes: bytes | None = None
|
|
129
|
+
#: Video: frame payload byte count, geometry, duration IEEE bits.
|
|
130
|
+
frame_count: int | None = None
|
|
131
|
+
#: Video: displayed geometry.
|
|
132
|
+
width: int | None = None
|
|
133
|
+
#: Video: displayed height.
|
|
134
|
+
height: int | None = None
|
|
135
|
+
#: Video: track duration as the raw IEEE-754 bit pattern.
|
|
136
|
+
duration_bits: int | None = None
|
|
137
|
+
#: Video: the frame-pHash chain, little-endian words.
|
|
138
|
+
frame_hashes: tuple[int, ...] | None = None
|
|
139
|
+
#: Video: the MinHash words over the chain's shingles.
|
|
140
|
+
minhash: tuple[int, ...] | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class Description:
|
|
145
|
+
"""Both tiers plus facts, re-expressed from the describe stream."""
|
|
146
|
+
|
|
147
|
+
#: The sniffed container name.
|
|
148
|
+
format: str
|
|
149
|
+
#: The modality lane that answered.
|
|
150
|
+
modality: str
|
|
151
|
+
#: Tier 1: hex of the 32-byte content digest.
|
|
152
|
+
tier1: str
|
|
153
|
+
#: Tier 2: the parsed signature stream.
|
|
154
|
+
signature: Signature
|
|
155
|
+
#: Modality facts (dimensions, rate, counts).
|
|
156
|
+
facts: dict
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class MatchResult:
|
|
161
|
+
"""A match outcome, re-expressed from the slots."""
|
|
162
|
+
|
|
163
|
+
#: The outcome tag (`image` … `video`).
|
|
164
|
+
tag: str
|
|
165
|
+
#: The outcome's fields: `hamming`; `votes, delta_t` (delta_t is
|
|
166
|
+
#: the `i64` two's-complement pattern, sign-extended back);
|
|
167
|
+
#: `jaccard`; `score, minhash_jaccard` (f64 bit patterns).
|
|
168
|
+
slots: tuple[int, ...]
|
|
169
|
+
#: Advisory verdict.
|
|
170
|
+
matched: bool
|
|
171
|
+
|
|
172
|
+
def slot_float(self, index: int) -> float:
|
|
173
|
+
"""Reinterprets slot `index` as an IEEE-754 double."""
|
|
174
|
+
import struct
|
|
175
|
+
|
|
176
|
+
return struct.unpack("<d", struct.pack("<Q", self.slots[index]))[0]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def fnv1a64(data: bytes) -> int:
|
|
180
|
+
"""The 64-bit FNV-1a fold the reference sub-digests use."""
|
|
181
|
+
h = 0xCBF29CE484222325
|
|
182
|
+
for b in data:
|
|
183
|
+
h ^= b
|
|
184
|
+
h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
|
185
|
+
return h
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_cdylib() -> Path:
|
|
189
|
+
"""Locates the cdylib through the suite's discovery chain."""
|
|
190
|
+
explicit = os.environ.get("PITH_CDYLIB")
|
|
191
|
+
if explicit:
|
|
192
|
+
p = Path(explicit)
|
|
193
|
+
if p.is_file():
|
|
194
|
+
return p
|
|
195
|
+
env_dir = os.environ.get("PITH_CDYLIB_DIR")
|
|
196
|
+
candidates: list[Path] = []
|
|
197
|
+
if env_dir:
|
|
198
|
+
env_dir_path = Path(env_dir)
|
|
199
|
+
candidates.append(env_dir_path)
|
|
200
|
+
if not env_dir_path.is_absolute():
|
|
201
|
+
# CD and local runs invoke tools from the repository root or
|
|
202
|
+
# from sdk/<lang>; resolve the env value against both.
|
|
203
|
+
candidates.append(Path.cwd() / env_dir_path)
|
|
204
|
+
candidates.append(Path(__file__).resolve().parents[3] / env_dir_path)
|
|
205
|
+
candidates.append(Path(__file__).resolve().parent) # packaged wheel
|
|
206
|
+
candidates.append(Path(__file__).resolve().parents[3] / "target" / "release")
|
|
207
|
+
for directory in candidates:
|
|
208
|
+
for name in CDYLIB_NAMES:
|
|
209
|
+
p = directory / name
|
|
210
|
+
if p.is_file():
|
|
211
|
+
return p
|
|
212
|
+
raise LibraryNotFoundError(
|
|
213
|
+
"no pith-hash cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, "
|
|
214
|
+
"the package directory and <repo>/target/release); "
|
|
215
|
+
"run `cargo build --release` first"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
_lib: ctypes.CDLL | None = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _load() -> ctypes.CDLL:
|
|
223
|
+
global _lib
|
|
224
|
+
if _lib is None:
|
|
225
|
+
lib = ctypes.CDLL(str(find_cdylib()))
|
|
226
|
+
lib.pith_hash_detect.argtypes = [
|
|
227
|
+
ctypes.c_void_p, # data
|
|
228
|
+
ctypes.c_size_t, # len
|
|
229
|
+
ctypes.POINTER(ctypes.c_uint32), # out format
|
|
230
|
+
ctypes.POINTER(ctypes.c_uint32), # out modality
|
|
231
|
+
ctypes.POINTER(ctypes.c_uint32), # out pending
|
|
232
|
+
]
|
|
233
|
+
lib.pith_hash_detect.restype = ctypes.c_int32
|
|
234
|
+
for op in ("pith_hash_signature", "pith_hash_describe"):
|
|
235
|
+
fn = getattr(lib, op)
|
|
236
|
+
fn.argtypes = [
|
|
237
|
+
ctypes.c_void_p, # data
|
|
238
|
+
ctypes.c_size_t, # len
|
|
239
|
+
ctypes.POINTER(ctypes.c_void_p), # out buffer
|
|
240
|
+
ctypes.POINTER(ctypes.c_size_t), # out length
|
|
241
|
+
]
|
|
242
|
+
fn.restype = ctypes.c_int32
|
|
243
|
+
lib.pith_hash_match.argtypes = [
|
|
244
|
+
ctypes.c_void_p, # a
|
|
245
|
+
ctypes.c_size_t, # a_len
|
|
246
|
+
ctypes.c_void_p, # b
|
|
247
|
+
ctypes.c_size_t, # b_len
|
|
248
|
+
ctypes.POINTER(ctypes.c_uint32), # out tag
|
|
249
|
+
ctypes.POINTER(ctypes.c_uint64), # out slots
|
|
250
|
+
ctypes.c_size_t, # slots capacity
|
|
251
|
+
ctypes.POINTER(ctypes.c_uint32), # out matched
|
|
252
|
+
]
|
|
253
|
+
lib.pith_hash_match.restype = ctypes.c_int32
|
|
254
|
+
lib.pith_hash_content_hash.argtypes = [
|
|
255
|
+
ctypes.c_void_p, # data
|
|
256
|
+
ctypes.c_size_t, # len
|
|
257
|
+
ctypes.POINTER(ctypes.c_ubyte), # out buffer
|
|
258
|
+
ctypes.c_size_t, # out capacity
|
|
259
|
+
ctypes.POINTER(ctypes.c_size_t), # out length
|
|
260
|
+
]
|
|
261
|
+
lib.pith_hash_content_hash.restype = ctypes.c_int32
|
|
262
|
+
lib.pith_hash_free.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
|
|
263
|
+
lib.pith_hash_free.restype = None
|
|
264
|
+
_lib = lib
|
|
265
|
+
return _lib
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _hand_out(status: int, op: str, out: ctypes.c_void_p, out_len: ctypes.c_size_t) -> bytes:
|
|
269
|
+
"""Copies a handed-out buffer and frees it (null-safe on error)."""
|
|
270
|
+
if status != STATUS_OK:
|
|
271
|
+
raise FfiError(op, status)
|
|
272
|
+
try:
|
|
273
|
+
return ctypes.string_at(out, out_len.value)
|
|
274
|
+
finally:
|
|
275
|
+
_load().pith_hash_free(out, out_len.value)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def detect(data: bytes) -> Detection:
|
|
279
|
+
"""Sniffs `data` without decoding: format, modality, pending."""
|
|
280
|
+
fmt = ctypes.c_uint32()
|
|
281
|
+
mod = ctypes.c_uint32()
|
|
282
|
+
pending = ctypes.c_uint32()
|
|
283
|
+
status = _load().pith_hash_detect(
|
|
284
|
+
data, len(data), ctypes.byref(fmt), ctypes.byref(mod), ctypes.byref(pending)
|
|
285
|
+
)
|
|
286
|
+
if status != STATUS_OK:
|
|
287
|
+
raise FfiError("pith_hash_detect", status)
|
|
288
|
+
return Detection(
|
|
289
|
+
format=FORMATS[fmt.value],
|
|
290
|
+
modality=MODALITIES[mod.value],
|
|
291
|
+
pending=pending.value == 1,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _stream_op(op: str, data: bytes) -> bytes:
|
|
296
|
+
out = ctypes.c_void_p()
|
|
297
|
+
out_len = ctypes.c_size_t()
|
|
298
|
+
status = getattr(_load(), op)(data, len(data), ctypes.byref(out), ctypes.byref(out_len))
|
|
299
|
+
return _hand_out(status, op, out, out_len)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def signature(data: bytes) -> Signature:
|
|
303
|
+
"""Computes the tier-2 signature and parses the signature stream."""
|
|
304
|
+
return parse_signature(_stream_op("pith_hash_signature", data))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def describe(data: bytes) -> Description:
|
|
308
|
+
"""Computes both tiers and parses the describe stream."""
|
|
309
|
+
return parse_describe(_stream_op("pith_hash_describe", data))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def match_signatures(a: bytes, b: bytes) -> MatchResult:
|
|
313
|
+
"""Scores two raw inputs, slots flattened per the wire table.
|
|
314
|
+
|
|
315
|
+
Raises :class:`FfiError` with ``status == STATUS_REJECTED`` for a
|
|
316
|
+
cross-modality pair (nothing is written on that path).
|
|
317
|
+
"""
|
|
318
|
+
tag = ctypes.c_uint32()
|
|
319
|
+
slots = (ctypes.c_uint64 * 4)()
|
|
320
|
+
matched = ctypes.c_uint32()
|
|
321
|
+
status = _load().pith_hash_match(
|
|
322
|
+
a,
|
|
323
|
+
len(a),
|
|
324
|
+
b,
|
|
325
|
+
len(b),
|
|
326
|
+
ctypes.byref(tag),
|
|
327
|
+
slots,
|
|
328
|
+
len(slots),
|
|
329
|
+
ctypes.byref(matched),
|
|
330
|
+
)
|
|
331
|
+
if status != STATUS_OK:
|
|
332
|
+
raise FfiError("pith_hash_match", status)
|
|
333
|
+
return MatchResult(
|
|
334
|
+
tag=MATCH_TAGS[tag.value],
|
|
335
|
+
slots=tuple(s for s in slots),
|
|
336
|
+
matched=matched.value == 1,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def content_hash(data: bytes) -> bytes:
|
|
341
|
+
"""Computes the 32-byte tier-1 content digest."""
|
|
342
|
+
buf = (ctypes.c_ubyte * 32)()
|
|
343
|
+
out_len = ctypes.c_size_t()
|
|
344
|
+
status = _load().pith_hash_content_hash(
|
|
345
|
+
data, len(data), buf, len(buf), ctypes.byref(out_len)
|
|
346
|
+
)
|
|
347
|
+
if status != STATUS_OK:
|
|
348
|
+
raise FfiError("pith_hash_content_hash", status)
|
|
349
|
+
return bytes(buf[: out_len.value])
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def parse_signature(raw: bytes) -> Signature:
|
|
353
|
+
"""Re-expresses the canonical signature stream as a :class:`Signature`."""
|
|
354
|
+
if len(raw) < 1:
|
|
355
|
+
raise ValueError("signature stream is empty")
|
|
356
|
+
tag = raw[0]
|
|
357
|
+
if tag >= len(SIGNATURE_TAGS):
|
|
358
|
+
raise ValueError(f"unknown signature tag {tag}")
|
|
359
|
+
modality = SIGNATURE_TAGS[tag]
|
|
360
|
+
if modality == "image":
|
|
361
|
+
if len(raw) != 9:
|
|
362
|
+
raise ValueError("image stream is not tag + u64")
|
|
363
|
+
return Signature(modality=modality, phash=int.from_bytes(raw[1:9], "big"))
|
|
364
|
+
if modality == "audio":
|
|
365
|
+
if len(raw) < 13:
|
|
366
|
+
raise ValueError("audio stream is shorter than its 13-byte header")
|
|
367
|
+
peak_count = int.from_bytes(raw[1:9], "big")
|
|
368
|
+
frames = int.from_bytes(raw[9:13], "big")
|
|
369
|
+
payload = raw[13:]
|
|
370
|
+
if peak_count != len(payload) or len(payload) % 6 != 0:
|
|
371
|
+
raise ValueError("audio peak count disagrees with the payload")
|
|
372
|
+
peaks = tuple(
|
|
373
|
+
(
|
|
374
|
+
int.from_bytes(payload[i : i + 4], "little"),
|
|
375
|
+
int.from_bytes(payload[i + 4 : i + 6], "little"),
|
|
376
|
+
)
|
|
377
|
+
for i in range(0, len(payload), 6)
|
|
378
|
+
)
|
|
379
|
+
return Signature(
|
|
380
|
+
modality=modality,
|
|
381
|
+
peak_count=peak_count,
|
|
382
|
+
frames=frames,
|
|
383
|
+
peaks=peaks,
|
|
384
|
+
peaks_bytes=payload,
|
|
385
|
+
)
|
|
386
|
+
if modality == "text":
|
|
387
|
+
if len(raw) < 9:
|
|
388
|
+
raise ValueError("text stream is shorter than its 9-byte header")
|
|
389
|
+
word_count = int.from_bytes(raw[1:9], "big")
|
|
390
|
+
payload = raw[9:]
|
|
391
|
+
if word_count != len(payload) or len(payload) % 8 != 0:
|
|
392
|
+
raise ValueError("text word count disagrees with the payload")
|
|
393
|
+
words = tuple(int.from_bytes(payload[i : i + 8], "big") for i in range(0, len(payload), 8))
|
|
394
|
+
return Signature(modality=modality, word_count=word_count, words=words)
|
|
395
|
+
if modality == "binary":
|
|
396
|
+
if len(raw) < 9:
|
|
397
|
+
raise ValueError("binary stream is shorter than its 9-byte header")
|
|
398
|
+
chunk_count = int.from_bytes(raw[1:9], "big")
|
|
399
|
+
payload = raw[9:]
|
|
400
|
+
if chunk_count != len(payload) or len(payload) % 32 != 0:
|
|
401
|
+
raise ValueError("binary chunk count disagrees with the payload")
|
|
402
|
+
return Signature(modality=modality, chunk_count=chunk_count, digest_bytes=payload)
|
|
403
|
+
# video
|
|
404
|
+
if len(raw) < 18:
|
|
405
|
+
raise ValueError("video stream is shorter than its 18-byte header")
|
|
406
|
+
frame_count = int.from_bytes(raw[1:5], "big")
|
|
407
|
+
width = int.from_bytes(raw[5:9], "big")
|
|
408
|
+
height = int.from_bytes(raw[9:13], "big")
|
|
409
|
+
duration_bits = int.from_bytes(raw[13:21], "big")
|
|
410
|
+
rest = raw[21:]
|
|
411
|
+
if frame_count > len(rest) or frame_count % 8 != 0:
|
|
412
|
+
raise ValueError("video frame count disagrees with the payload")
|
|
413
|
+
frames_payload = rest[:frame_count]
|
|
414
|
+
minhash_payload = rest[frame_count:]
|
|
415
|
+
if len(minhash_payload) % 8 != 0:
|
|
416
|
+
raise ValueError("video minhash payload is not whole words")
|
|
417
|
+
return Signature(
|
|
418
|
+
modality=modality,
|
|
419
|
+
frame_count=frame_count,
|
|
420
|
+
width=width,
|
|
421
|
+
height=height,
|
|
422
|
+
duration_bits=duration_bits,
|
|
423
|
+
frame_hashes=tuple(
|
|
424
|
+
int.from_bytes(frames_payload[i : i + 8], "little")
|
|
425
|
+
for i in range(0, len(frames_payload), 8)
|
|
426
|
+
),
|
|
427
|
+
minhash=tuple(
|
|
428
|
+
int.from_bytes(minhash_payload[i : i + 8], "little")
|
|
429
|
+
for i in range(0, len(minhash_payload), 8)
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def parse_describe(raw: bytes) -> Description:
|
|
435
|
+
"""Re-expresses the canonical describe stream as a :class:`Description`."""
|
|
436
|
+
if len(raw) < 35:
|
|
437
|
+
raise ValueError("describe stream is shorter than its 35-byte header")
|
|
438
|
+
fmt = raw[0]
|
|
439
|
+
mod = raw[1]
|
|
440
|
+
if fmt >= len(FORMATS) or mod >= len(MODALITIES):
|
|
441
|
+
raise ValueError(f"unknown describe codes {fmt}/{mod}")
|
|
442
|
+
tier1 = raw[2:34].hex()
|
|
443
|
+
modality = MODALITIES[mod]
|
|
444
|
+
# The facts block terminates the stream; its size is known from
|
|
445
|
+
# the modality code, so the signature stream is the exact middle
|
|
446
|
+
# slice (a bare parse_signature would see the facts as payload).
|
|
447
|
+
sig_end = len(raw) - _facts_len(modality)
|
|
448
|
+
sig = parse_signature(raw[34:sig_end])
|
|
449
|
+
facts_raw = raw[sig_end:]
|
|
450
|
+
facts: dict = {}
|
|
451
|
+
if modality == "image":
|
|
452
|
+
_need(facts_raw, 16)
|
|
453
|
+
facts = {"width": _be32(facts_raw, 0), "height": _be32(facts_raw, 4), "keypoints": _be64(facts_raw, 8)}
|
|
454
|
+
elif modality == "audio":
|
|
455
|
+
_need(facts_raw, 18)
|
|
456
|
+
facts = {
|
|
457
|
+
"sample_rate": _be32(facts_raw, 0),
|
|
458
|
+
"channels": int.from_bytes(facts_raw[4:6], "big"),
|
|
459
|
+
"frames": _be32(facts_raw, 6),
|
|
460
|
+
"peaks": _be64(facts_raw, 10),
|
|
461
|
+
}
|
|
462
|
+
elif modality == "text":
|
|
463
|
+
_need(facts_raw, 16)
|
|
464
|
+
facts = {"words": _be64(facts_raw, 0), "canonical_len": _be64(facts_raw, 8)}
|
|
465
|
+
elif modality == "binary":
|
|
466
|
+
_need(facts_raw, 16)
|
|
467
|
+
facts = {"len": _be64(facts_raw, 0), "chunks": _be64(facts_raw, 8)}
|
|
468
|
+
else:
|
|
469
|
+
_need(facts_raw, 24)
|
|
470
|
+
facts = {
|
|
471
|
+
"width": _be32(facts_raw, 0),
|
|
472
|
+
"height": _be32(facts_raw, 4),
|
|
473
|
+
"duration_bits": _be64(facts_raw, 8),
|
|
474
|
+
"frames_sampled": _be64(facts_raw, 16),
|
|
475
|
+
}
|
|
476
|
+
return Description(format=FORMATS[fmt], modality=modality, tier1=tier1, signature=sig, facts=facts)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _be32(raw: bytes, off: int) -> int:
|
|
480
|
+
return int.from_bytes(raw[off : off + 4], "big")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _be64(raw: bytes, off: int) -> int:
|
|
484
|
+
return int.from_bytes(raw[off : off + 8], "big")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _need(raw: bytes, n: int) -> None:
|
|
488
|
+
if len(raw) != n:
|
|
489
|
+
raise ValueError(f"facts block is {len(raw)} bytes, want {n}")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _facts_len(modality: str) -> int:
|
|
493
|
+
"""The byte size of each modality's facts block."""
|
|
494
|
+
if modality == "audio":
|
|
495
|
+
return 18
|
|
496
|
+
if modality == "video":
|
|
497
|
+
return 24
|
|
498
|
+
return 16
|
pith_hash/pith_hash.dll
ADDED
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pith-hash
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: The pith suite curator SDK: detect, tier-1 content hash, tier-2 signatures, match and describe via the pith-hash Rust cdylib (ctypes, zero-dep)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/pith-hash/pith-hash
|
|
7
|
+
Project-URL: Homepage, https://pith-hash.n24q02m.com
|
|
8
|
+
Keywords: hashing,perceptual-hash,minhash,content-defined-chunking,similarity
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Rust
|
|
11
|
+
Classifier: Topic :: Security :: Cryptography
|
|
12
|
+
Requires-Python: >=3.9
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pith_hash/__init__.py,sha256=Z2bamYsYVDrYzAAFYEJ9PR3lLLSrrxOxkNko6LWTj_U,17658
|
|
2
|
+
pith_hash/pith_hash.dll,sha256=9HTOHoXpos6gJQX1wYXdiXid_Lr0OItYZQoO0IhWkiQ,1163776
|
|
3
|
+
pith_hash-0.1.1.dist-info/METADATA,sha256=d0ZuKHJF2Y8NVw8qXdwg-PgN70Zg1cvvYL73fYucUh4,595
|
|
4
|
+
pith_hash-0.1.1.dist-info/WHEEL,sha256=Zx98gwb_dQKckJK3HEOVKnxxD52EfU_PXDG_usMJ2ng,98
|
|
5
|
+
pith_hash-0.1.1.dist-info/top_level.txt,sha256=TT0LYUXFGlPseRbcyge9VGG13UvBDtJGTxvzsGnC0BY,10
|
|
6
|
+
pith_hash-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pith_hash
|