image-bridge-toolkit 0.1.0__py3-none-any.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.
- image_bridge_toolkit/__init__.py +5 -0
- image_bridge_toolkit/_imaging_backend.py +12 -0
- image_bridge_toolkit/cache_builder.py +160 -0
- image_bridge_toolkit/cache_io.py +49 -0
- image_bridge_toolkit/calculators.py +153 -0
- image_bridge_toolkit/constants.py +17 -0
- image_bridge_toolkit/dataset_loader.py +94 -0
- image_bridge_toolkit/logging_utils.py +15 -0
- image_bridge_toolkit/matcher.py +240 -0
- image_bridge_toolkit/planner.py +33 -0
- image_bridge_toolkit/scanner.py +58 -0
- image_bridge_toolkit/similarity.py +81 -0
- image_bridge_toolkit-0.1.0.dist-info/METADATA +234 -0
- image_bridge_toolkit-0.1.0.dist-info/RECORD +18 -0
- image_bridge_toolkit-0.1.0.dist-info/WHEEL +5 -0
- image_bridge_toolkit-0.1.0.dist-info/entry_points.txt +3 -0
- image_bridge_toolkit-0.1.0.dist-info/licenses/LICENSE.md +38 -0
- image_bridge_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Single source of truth for the optional OpenCV backend.
|
|
3
|
+
|
|
4
|
+
Every other module imports HAS_OPENCV / cv2 from here instead of doing its
|
|
5
|
+
own try/except, so the fallback decision is made exactly once per process.
|
|
6
|
+
"""
|
|
7
|
+
try:
|
|
8
|
+
import cv2 # noqa: F401
|
|
9
|
+
HAS_OPENCV = True
|
|
10
|
+
except ImportError: # pragma: no cover - exercised only when cv2 absent
|
|
11
|
+
cv2 = None
|
|
12
|
+
HAS_OPENCV = False
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
imgcache-build: production entry point for (re)generating image_cache.json
|
|
4
|
+
files across a directory tree.
|
|
5
|
+
|
|
6
|
+
Pipeline:
|
|
7
|
+
1. scanner.scan_tree -> fast, single-pass directory discovery + mtimes
|
|
8
|
+
2. cache_io.load_cache -> per-directory cache load (corruption-safe)
|
|
9
|
+
3. planner.plan_entry -> per-file: which calc groups actually need work
|
|
10
|
+
4. worker processes -> run only the needed calculators, in parallel
|
|
11
|
+
5. cache_io.write_cache_atomic -> durable, corruption-proof write back
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, FrozenSet, List, Tuple
|
|
22
|
+
|
|
23
|
+
from PIL import Image
|
|
24
|
+
|
|
25
|
+
from . import calculators
|
|
26
|
+
from .cache_io import load_cache, write_cache_atomic
|
|
27
|
+
from .constants import ALL_GROUPS
|
|
28
|
+
from .logging_utils import get_logger
|
|
29
|
+
from .planner import plan_entry
|
|
30
|
+
from .scanner import scan_tree
|
|
31
|
+
|
|
32
|
+
log = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
# What a worker process needs to do one file, and what it hands back.
|
|
35
|
+
_Job = Tuple[str, FrozenSet[str]] # (image_path, groups_to_compute)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _compute_groups(image_path: str, groups: FrozenSet[str]) -> Dict[str, Any]:
|
|
39
|
+
"""Runs in a worker process. Never raises -- an unreadable/corrupt image
|
|
40
|
+
yields an empty update rather than killing the whole batch."""
|
|
41
|
+
if not groups:
|
|
42
|
+
return {}
|
|
43
|
+
try:
|
|
44
|
+
with Image.open(image_path) as img:
|
|
45
|
+
img.load()
|
|
46
|
+
out: Dict[str, Any] = {}
|
|
47
|
+
if "hash" in groups:
|
|
48
|
+
out.update(calculators.compute_hashes(img))
|
|
49
|
+
if "sig" in groups:
|
|
50
|
+
out.update(calculators.compute_visual_signature(img))
|
|
51
|
+
if "lab" in groups:
|
|
52
|
+
out.update(calculators.compute_lab_metrics(img))
|
|
53
|
+
if "waveform" in groups:
|
|
54
|
+
out.update(calculators.compute_hist_waveform(img))
|
|
55
|
+
return out
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
log.error("Failed to process %s: %s", image_path, exc)
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _worker(job: _Job) -> Tuple[str, Dict[str, Any]]:
|
|
62
|
+
path, groups = job
|
|
63
|
+
return path, _compute_groups(path, groups)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ImageCacheBuilder:
|
|
67
|
+
def __init__(self, root_dir: str, workers: int = 0, force: bool = False):
|
|
68
|
+
self.root_dir = Path(root_dir).resolve()
|
|
69
|
+
self.workers = workers or max(1, os.cpu_count() or 1)
|
|
70
|
+
self.force = force
|
|
71
|
+
|
|
72
|
+
def run(self) -> Dict[str, int]:
|
|
73
|
+
stats = {"dirs": 0, "files_seen": 0, "files_updated": 0, "files_reused": 0}
|
|
74
|
+
t0 = time.time()
|
|
75
|
+
|
|
76
|
+
for dir_path, images in scan_tree(self.root_dir):
|
|
77
|
+
stats["dirs"] += 1
|
|
78
|
+
cache = {} if self.force else load_cache(dir_path)
|
|
79
|
+
new_cache: Dict[str, Any] = {}
|
|
80
|
+
jobs: List[_Job] = []
|
|
81
|
+
job_meta: Dict[str, Dict[str, Any]] = {}
|
|
82
|
+
|
|
83
|
+
for img_path, mtime in images:
|
|
84
|
+
stats["files_seen"] += 1
|
|
85
|
+
fname = img_path.name
|
|
86
|
+
needed, base_entry = plan_entry(None if self.force else cache.get(fname), mtime)
|
|
87
|
+
if not needed:
|
|
88
|
+
new_cache[fname] = base_entry
|
|
89
|
+
stats["files_reused"] += 1
|
|
90
|
+
continue
|
|
91
|
+
jobs.append((str(img_path), needed))
|
|
92
|
+
job_meta[str(img_path)] = {"fname": fname, "base": base_entry}
|
|
93
|
+
|
|
94
|
+
if jobs:
|
|
95
|
+
new_cache.update(self._run_jobs(jobs, job_meta))
|
|
96
|
+
stats["files_updated"] += len(jobs)
|
|
97
|
+
|
|
98
|
+
# Only touch disk if something actually changed for this dir.
|
|
99
|
+
if jobs or len(new_cache) != len(cache) or self.force:
|
|
100
|
+
write_cache_atomic(dir_path, new_cache)
|
|
101
|
+
|
|
102
|
+
log.info("%s: %d image(s), %d updated", dir_path, len(images), len(jobs))
|
|
103
|
+
|
|
104
|
+
log.info(
|
|
105
|
+
"Done in %.1fs - dirs=%d files_seen=%d updated=%d reused=%d",
|
|
106
|
+
time.time() - t0, stats["dirs"], stats["files_seen"],
|
|
107
|
+
stats["files_updated"], stats["files_reused"],
|
|
108
|
+
)
|
|
109
|
+
return stats
|
|
110
|
+
|
|
111
|
+
def _run_jobs(self, jobs: List[_Job], job_meta: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
|
112
|
+
results: Dict[str, Any] = {}
|
|
113
|
+
if self.workers <= 1 or len(jobs) < 4:
|
|
114
|
+
for path, groups in jobs:
|
|
115
|
+
meta = job_meta[path]
|
|
116
|
+
entry = dict(meta["base"])
|
|
117
|
+
entry.update(_compute_groups(path, groups))
|
|
118
|
+
results[meta["fname"]] = entry
|
|
119
|
+
return results
|
|
120
|
+
|
|
121
|
+
with ProcessPoolExecutor(max_workers=self.workers) as pool:
|
|
122
|
+
futures = {pool.submit(_worker, job): job[0] for job in jobs}
|
|
123
|
+
for fut in as_completed(futures):
|
|
124
|
+
path = futures[fut]
|
|
125
|
+
meta = job_meta[path]
|
|
126
|
+
try:
|
|
127
|
+
_, computed = fut.result()
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
log.error("Worker failed for %s: %s", path, exc)
|
|
130
|
+
computed = {}
|
|
131
|
+
entry = dict(meta["base"])
|
|
132
|
+
entry.update(computed)
|
|
133
|
+
results[meta["fname"]] = entry
|
|
134
|
+
return results
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
138
|
+
p = argparse.ArgumentParser(prog="imgcache-build", description="Build/update image_cache.json across a directory tree.")
|
|
139
|
+
p.add_argument("root", help="Root directory to scan (recursively).")
|
|
140
|
+
p.add_argument("-w", "--workers", type=int, default=0, help="Process pool size (default: CPU count).")
|
|
141
|
+
p.add_argument("--force", action="store_true", help="Ignore existing caches and recompute everything.")
|
|
142
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
143
|
+
return p
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main(argv: List[str] = None) -> int:
|
|
147
|
+
args = build_parser().parse_args(argv)
|
|
148
|
+
if args.verbose:
|
|
149
|
+
get_logger(__name__, verbose=True)
|
|
150
|
+
root = Path(args.root)
|
|
151
|
+
if not root.is_dir():
|
|
152
|
+
print(f"Not a directory: {root}", file=sys.stderr)
|
|
153
|
+
return 2
|
|
154
|
+
builder = ImageCacheBuilder(str(root), workers=args.workers, force=args.force)
|
|
155
|
+
builder.run()
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
sys.exit(main())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Loading / writing image_cache.json with corruption tolerance and atomic writes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
|
|
10
|
+
from .constants import CACHE_FILE_NAME
|
|
11
|
+
from .logging_utils import get_logger
|
|
12
|
+
|
|
13
|
+
log = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_cache(dir_path: Path) -> Dict[str, Any]:
|
|
17
|
+
"""Returns {} for a missing OR corrupt cache file. Corruption is logged,
|
|
18
|
+
never raised -- a bad JSON file must not abort the whole run, and per the
|
|
19
|
+
spec it is treated exactly like "no cache" (everything gets recomputed)."""
|
|
20
|
+
cache_path = dir_path / CACHE_FILE_NAME
|
|
21
|
+
if not cache_path.exists():
|
|
22
|
+
return {}
|
|
23
|
+
try:
|
|
24
|
+
with open(cache_path, "r", encoding="utf-8") as f:
|
|
25
|
+
data = json.load(f)
|
|
26
|
+
if not isinstance(data, dict):
|
|
27
|
+
raise ValueError("cache root is not a JSON object")
|
|
28
|
+
return data
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
log.warning("Corrupt cache at %s (%s) - treating as empty", cache_path, exc)
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_cache_atomic(dir_path: Path, cache: Dict[str, Any]) -> None:
|
|
35
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
cache_path = dir_path / CACHE_FILE_NAME
|
|
37
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{CACHE_FILE_NAME}.", suffix=".tmp", dir=str(dir_path))
|
|
38
|
+
try:
|
|
39
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
40
|
+
json.dump(cache, f, indent=2)
|
|
41
|
+
f.flush()
|
|
42
|
+
os.fsync(f.fileno())
|
|
43
|
+
os.replace(tmp_name, cache_path)
|
|
44
|
+
except Exception:
|
|
45
|
+
try:
|
|
46
|
+
os.unlink(tmp_name)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
raise
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Perceptual fingerprint calculators. Algorithms are kept numerically
|
|
3
|
+
equivalent to the original gridview.py reference implementation so that
|
|
4
|
+
existing caches / downstream consumers stay compatible.
|
|
5
|
+
|
|
6
|
+
Every function is pure (path/PIL.Image in -> plain-python-serialisable
|
|
7
|
+
values out) and never raises: on internal failure it returns a neutral
|
|
8
|
+
zero-value so a single unreadable image can't take down a batch job.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from typing import Any, Dict, List, Tuple
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from PIL import Image
|
|
18
|
+
|
|
19
|
+
from ._imaging_backend import HAS_OPENCV, cv2
|
|
20
|
+
|
|
21
|
+
HASH_SIZE = 8
|
|
22
|
+
PHASH_IMG_SIZE = 32
|
|
23
|
+
SIG_POINTS = 16
|
|
24
|
+
LAB_RESIZE = 64
|
|
25
|
+
WAVEFORM_BINS = 256
|
|
26
|
+
WAVEFORM_KEEP = 16
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _dct_phash_pil(image: Image.Image, hash_size: int = HASH_SIZE) -> str:
|
|
30
|
+
try:
|
|
31
|
+
img = image.convert("L").resize((PHASH_IMG_SIZE, PHASH_IMG_SIZE), Image.Resampling.LANCZOS)
|
|
32
|
+
pixels = np.asarray(img, dtype=np.float64)
|
|
33
|
+
dct_abs = np.abs(np.fft.fft2(pixels))
|
|
34
|
+
dct_slice = dct_abs[1:hash_size + 1, 1:hash_size + 1].flatten()
|
|
35
|
+
median_val = np.median(dct_slice)
|
|
36
|
+
bits = (dct_slice > median_val).astype(np.uint8)
|
|
37
|
+
hex_str = "".join(f"{int(''.join(map(str, bits[i:i + 4])), 2):x}" for i in range(0, 64, 4))
|
|
38
|
+
return hex_str.zfill(16)
|
|
39
|
+
except Exception:
|
|
40
|
+
return "0" * 16
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def compute_hashes(image: Image.Image) -> Dict[str, str]:
|
|
44
|
+
"""phash_luma / phash_color from an already-open PIL image."""
|
|
45
|
+
luma = _dct_phash_pil(image)
|
|
46
|
+
color = luma
|
|
47
|
+
try:
|
|
48
|
+
hsv = image.convert("HSV")
|
|
49
|
+
hue_channel = Image.fromarray(np.asarray(hsv)[:, :, 0])
|
|
50
|
+
color = _dct_phash_pil(hue_channel)
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
return {"phash_luma": luma, "phash_color": color}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compute_visual_signature(image: Image.Image, num_points: int = SIG_POINTS) -> Dict[str, Any]:
|
|
57
|
+
try:
|
|
58
|
+
small = image.resize((16, 16), resample=Image.Resampling.LANCZOS)
|
|
59
|
+
pixels = np.array(small.convert("RGB")).reshape(-1, 3) / 255.0
|
|
60
|
+
coords = np.mgrid[0:1:16j, 0:1:16j].reshape(2, -1).T
|
|
61
|
+
|
|
62
|
+
selected = [int(np.argmax(np.std(pixels, axis=1)))]
|
|
63
|
+
for _ in range(num_points - 1):
|
|
64
|
+
c_dists = np.min([np.linalg.norm(pixels - pixels[s], axis=1) for s in selected], axis=0)
|
|
65
|
+
s_dists = np.min([np.linalg.norm(coords - coords[s], axis=1) for s in selected], axis=0)
|
|
66
|
+
selected.append(int(np.argmax(c_dists * s_dists)))
|
|
67
|
+
|
|
68
|
+
weights: Dict[int, int] = defaultdict(int)
|
|
69
|
+
for p in pixels:
|
|
70
|
+
dists = [np.linalg.norm(p - pixels[s]) for s in selected]
|
|
71
|
+
weights[int(np.argmin(dists))] += 1
|
|
72
|
+
|
|
73
|
+
sig = []
|
|
74
|
+
for i, idx in enumerate(selected):
|
|
75
|
+
r, g, b = (pixels[idx] * 255).astype(int)
|
|
76
|
+
sig.append({
|
|
77
|
+
"hex": f"#{r:02x}{g:02x}{b:02x}",
|
|
78
|
+
"x": round(float(coords[idx][1]) * 100, 1),
|
|
79
|
+
"y": round(float(coords[idx][0]) * 100, 1),
|
|
80
|
+
"size": round((weights[i] / 256) * 100, 1),
|
|
81
|
+
})
|
|
82
|
+
return {"visual_sig": sig}
|
|
83
|
+
except Exception:
|
|
84
|
+
return {"visual_sig": []}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _srgb_to_linear(c: np.ndarray) -> np.ndarray:
|
|
88
|
+
a = 0.055
|
|
89
|
+
return np.where(c <= 0.04045, c / 12.92, ((c + a) / (1 + a)) ** 2.4)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _rgb_to_lab(arr: np.ndarray) -> np.ndarray:
|
|
93
|
+
lin = _srgb_to_linear(arr)
|
|
94
|
+
m = np.array([[0.4124564, 0.3575761, 0.1804375],
|
|
95
|
+
[0.2126729, 0.7151522, 0.0721750],
|
|
96
|
+
[0.0193339, 0.1191920, 0.9503041]])
|
|
97
|
+
xyz = (lin.reshape(-1, 3) @ m.T).reshape(lin.shape)
|
|
98
|
+
xn, yn, zn = 0.95047, 1.0, 1.08883
|
|
99
|
+
xyz[..., 0] /= xn
|
|
100
|
+
xyz[..., 1] /= yn
|
|
101
|
+
xyz[..., 2] /= zn
|
|
102
|
+
t = np.where(xyz > 0.008856, np.cbrt(xyz), 7.787 * xyz + 16 / 116)
|
|
103
|
+
L = 116 * t[..., 1] - 16
|
|
104
|
+
a = 500 * (t[..., 0] - t[..., 1])
|
|
105
|
+
b = 200 * (t[..., 1] - t[..., 2])
|
|
106
|
+
return np.stack([L, a, b], axis=-1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def compute_lab_metrics(image: Image.Image) -> Dict[str, float]:
|
|
110
|
+
try:
|
|
111
|
+
img = image.convert("RGB").resize((LAB_RESIZE, LAB_RESIZE), Image.Resampling.LANCZOS)
|
|
112
|
+
arr = np.array(img, dtype=np.float64) / 255.0
|
|
113
|
+
lab = _rgb_to_lab(arr)
|
|
114
|
+
L, a, b = tuple(lab.mean(axis=(0, 1)))
|
|
115
|
+
return {
|
|
116
|
+
"lab_value": round(float(L), 2),
|
|
117
|
+
"lab_hue": round(float(np.arctan2(b, a)), 2),
|
|
118
|
+
"lab_chroma": round(float(math.sqrt(a ** 2 + b ** 2)), 2),
|
|
119
|
+
"lab_warmth": round(float(b), 2),
|
|
120
|
+
}
|
|
121
|
+
except Exception:
|
|
122
|
+
return {"lab_value": 0.0, "lab_hue": 0.0, "lab_chroma": 0.0, "lab_warmth": 0.0}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _dct_1d_numpy(hist_norm: np.ndarray) -> np.ndarray:
|
|
126
|
+
n = len(hist_norm)
|
|
127
|
+
out = np.zeros(n, dtype=np.float64)
|
|
128
|
+
k = np.arange(n)
|
|
129
|
+
factor = np.pi / (2.0 * n)
|
|
130
|
+
# vectorised type-II DCT (equivalent to the reference's nested-loop version)
|
|
131
|
+
n_idx = np.arange(n).reshape(-1, 1)
|
|
132
|
+
cos_table = np.cos((2 * n_idx + 1) * k.reshape(1, -1) * factor)
|
|
133
|
+
s = hist_norm @ cos_table
|
|
134
|
+
c = np.full(n, math.sqrt(2.0 / n))
|
|
135
|
+
c[0] = math.sqrt(1.0 / n)
|
|
136
|
+
return c * s
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def compute_hist_waveform(image: Image.Image) -> Dict[str, List[float]]:
|
|
140
|
+
try:
|
|
141
|
+
gray = np.array(image.convert("L"), dtype=np.uint8)
|
|
142
|
+
hist, _ = np.histogram(gray, bins=WAVEFORM_BINS, range=(0, WAVEFORM_BINS))
|
|
143
|
+
total = hist.sum()
|
|
144
|
+
hist_norm = (hist.astype(np.float32) / total) if total > 0 else np.zeros(WAVEFORM_BINS, dtype=np.float32)
|
|
145
|
+
|
|
146
|
+
if HAS_OPENCV:
|
|
147
|
+
dct_hist = cv2.dct(hist_norm.reshape(-1, 1)).flatten()
|
|
148
|
+
else:
|
|
149
|
+
dct_hist = _dct_1d_numpy(hist_norm.astype(np.float64))
|
|
150
|
+
|
|
151
|
+
return {"hist_waveform": [round(float(v), 6) for v in dct_hist[:WAVEFORM_KEEP]]}
|
|
152
|
+
except Exception:
|
|
153
|
+
return {"hist_waveform": [0.0] * WAVEFORM_KEEP}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
CACHE_FILE_NAME = "image_cache.json"
|
|
2
|
+
IGNORE_MARKER = ".ignore_subdir"
|
|
3
|
+
VALID_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
|
|
4
|
+
|
|
5
|
+
# Field groups. A cache entry is only "complete" once every group's keys are
|
|
6
|
+
# present; mtime mismatch invalidates ALL groups (the file content changed),
|
|
7
|
+
# while a missing group with a matching mtime just means the schema grew
|
|
8
|
+
# (e.g. you added hist_waveform after caches already existed) and only that
|
|
9
|
+
# group needs to be (re)computed.
|
|
10
|
+
GROUP_KEYS = {
|
|
11
|
+
"hash": ("phash_luma", "phash_color"),
|
|
12
|
+
"sig": ("visual_sig",),
|
|
13
|
+
"lab": ("lab_value", "lab_hue", "lab_chroma", "lab_warmth"),
|
|
14
|
+
"waveform": ("hist_waveform",),
|
|
15
|
+
}
|
|
16
|
+
ALL_GROUPS = frozenset(GROUP_KEYS.keys())
|
|
17
|
+
REQUIRED_KEYS = frozenset(k for keys in GROUP_KEYS.values() for k in keys)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Loads all image_cache.json files under a root into flat numpy vector arrays
|
|
3
|
+
for the matcher, skipping .ignore_subdir trees exactly like the builder does."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .cache_io import load_cache
|
|
13
|
+
from .constants import CACHE_FILE_NAME, IGNORE_MARKER
|
|
14
|
+
from .logging_utils import get_logger
|
|
15
|
+
|
|
16
|
+
log = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Dataset:
|
|
20
|
+
__slots__ = ("paths", "phash_luma", "phash_color", "waveform", "lab", "signatures")
|
|
21
|
+
|
|
22
|
+
def __init__(self, paths, phash_luma, phash_color, waveform, lab, signatures):
|
|
23
|
+
self.paths = paths
|
|
24
|
+
self.phash_luma = phash_luma
|
|
25
|
+
self.phash_color = phash_color
|
|
26
|
+
self.waveform = waveform
|
|
27
|
+
self.lab = lab
|
|
28
|
+
self.signatures = signatures
|
|
29
|
+
|
|
30
|
+
def __len__(self) -> int:
|
|
31
|
+
return len(self.paths)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_dataset(root_dir: Path) -> Dataset:
|
|
35
|
+
paths: List[str] = []
|
|
36
|
+
phashes_luma: List[int] = []
|
|
37
|
+
phashes_color: List[int] = []
|
|
38
|
+
waveforms: List[List[float]] = []
|
|
39
|
+
labs: List[List[float]] = []
|
|
40
|
+
signatures: List[List[Dict[str, Any]]] = []
|
|
41
|
+
|
|
42
|
+
root_dir = Path(root_dir).resolve()
|
|
43
|
+
stack = [root_dir]
|
|
44
|
+
while stack:
|
|
45
|
+
current = stack.pop()
|
|
46
|
+
try:
|
|
47
|
+
with os.scandir(current) as it:
|
|
48
|
+
entries = list(it)
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
log.warning("Cannot read directory %s (%s) - skipping", current, exc)
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if any(e.name == IGNORE_MARKER and e.is_file(follow_symlinks=False) for e in entries):
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
has_cache = any(e.name == CACHE_FILE_NAME for e in entries)
|
|
57
|
+
for e in entries:
|
|
58
|
+
if e.is_dir(follow_symlinks=False):
|
|
59
|
+
stack.append(Path(e.path))
|
|
60
|
+
|
|
61
|
+
if not has_cache:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
cache = load_cache(current)
|
|
65
|
+
for fname, meta in cache.items():
|
|
66
|
+
try:
|
|
67
|
+
record = (
|
|
68
|
+
str(current / fname),
|
|
69
|
+
int(meta.get("phash_luma", "0") or "0", 16),
|
|
70
|
+
int(meta.get("phash_color", meta.get("phash_luma", "0")) or "0", 16),
|
|
71
|
+
meta.get("hist_waveform", [0.0] * 16) or [0.0] * 16,
|
|
72
|
+
[meta.get("lab_value", 0.0), meta.get("lab_hue", 0.0),
|
|
73
|
+
meta.get("lab_chroma", 0.0), meta.get("lab_warmth", 0.0)],
|
|
74
|
+
meta.get("visual_sig", []) or [],
|
|
75
|
+
)
|
|
76
|
+
except (TypeError, ValueError) as exc:
|
|
77
|
+
log.warning("Skipping malformed cache entry %s/%s: %s", current, fname, exc)
|
|
78
|
+
continue
|
|
79
|
+
path, ph_luma, ph_color, wf, lab, sig = record
|
|
80
|
+
paths.append(path)
|
|
81
|
+
phashes_luma.append(ph_luma)
|
|
82
|
+
phashes_color.append(ph_color)
|
|
83
|
+
waveforms.append(wf)
|
|
84
|
+
labs.append(lab)
|
|
85
|
+
signatures.append(sig)
|
|
86
|
+
|
|
87
|
+
return Dataset(
|
|
88
|
+
paths=paths,
|
|
89
|
+
phash_luma=np.array(phashes_luma, dtype=np.uint64),
|
|
90
|
+
phash_color=np.array(phashes_color, dtype=np.uint64),
|
|
91
|
+
waveform=np.array(waveforms, dtype=np.float32) if waveforms else np.zeros((0, 16), dtype=np.float32),
|
|
92
|
+
lab=np.array(labs, dtype=np.float32) if labs else np.zeros((0, 4), dtype=np.float32),
|
|
93
|
+
signatures=signatures,
|
|
94
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_logger(name: str, verbose: bool = False) -> logging.Logger:
|
|
6
|
+
logger = logging.getLogger(name)
|
|
7
|
+
if not logger.handlers:
|
|
8
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
9
|
+
handler.setFormatter(
|
|
10
|
+
logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s", "%H:%M:%S")
|
|
11
|
+
)
|
|
12
|
+
logger.addHandler(handler)
|
|
13
|
+
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
14
|
+
logger.propagate = False
|
|
15
|
+
return logger
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
imgmatch-bridge: cache-backed multi-tier matcher that builds a bridge map
|
|
3
|
+
from a (watermarked / derivative) dataset to a much larger originals dataset.
|
|
4
|
+
|
|
5
|
+
Tiers, cheapest -> most expensive, each narrowing the candidate pool:
|
|
6
|
+
1. Dual pHash (luma+color) Hamming distance -- vectorised over the whole
|
|
7
|
+
originals array, this is what makes 1,000 x 50,000 tractable at all.
|
|
8
|
+
2. DCT histogram waveform distance (within Tier-1 survivors).
|
|
9
|
+
3. LAB perceptual colour distance.
|
|
10
|
+
4. Visual-signature spatial/colour layout distance (watermark-robust).
|
|
11
|
+
5. Optional direct pixel SSIM re-ranking of the surviving near-ties.
|
|
12
|
+
|
|
13
|
+
Output is streamed to disk as JSON Lines (one query result per line) so a
|
|
14
|
+
50k x 1k run is resumable and never holds the whole result set in memory,
|
|
15
|
+
and a crash partway through doesn't lose completed work.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Set
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from .dataset_loader import Dataset, load_dataset
|
|
30
|
+
from .logging_utils import get_logger
|
|
31
|
+
from .similarity import direct_ssim, hamming_matrix, visual_sig_distance
|
|
32
|
+
|
|
33
|
+
log = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Populated once per worker process via ProcessPoolExecutor(initializer=...)
|
|
37
|
+
# so the (potentially large, 50k-entry) datasets are pickled to each worker
|
|
38
|
+
# exactly once, not re-serialised on every one of the 1,000 submitted tasks.
|
|
39
|
+
_WORKER_DA: Dataset = None
|
|
40
|
+
_WORKER_ORIG: Dataset = None
|
|
41
|
+
_WORKER_ARGS: Dict[str, Any] = {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _init_worker(da: Dataset, orig: Dataset, max_hamming: int, candidate_threshold: float) -> None:
|
|
45
|
+
global _WORKER_DA, _WORKER_ORIG, _WORKER_ARGS
|
|
46
|
+
_WORKER_DA, _WORKER_ORIG = da, orig
|
|
47
|
+
_WORKER_ARGS = {"max_hamming": max_hamming, "candidate_threshold": candidate_threshold}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _worker_match(q_idx: int) -> Dict[str, Any]:
|
|
51
|
+
return match_one(q_idx, _WORKER_DA, _WORKER_ORIG, **_WORKER_ARGS)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _already_done(output_path: Path) -> Set[str]:
|
|
55
|
+
done: Set[str] = set()
|
|
56
|
+
if not output_path.exists():
|
|
57
|
+
return done
|
|
58
|
+
with open(output_path, "r", encoding="utf-8") as f:
|
|
59
|
+
for line in f:
|
|
60
|
+
line = line.strip()
|
|
61
|
+
if not line:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
done.add(json.loads(line)["query_path"])
|
|
65
|
+
except Exception:
|
|
66
|
+
continue
|
|
67
|
+
return done
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def match_one(
|
|
71
|
+
q_idx: int,
|
|
72
|
+
da: Dataset,
|
|
73
|
+
orig: Dataset,
|
|
74
|
+
max_hamming: int,
|
|
75
|
+
candidate_threshold: float,
|
|
76
|
+
) -> Dict[str, Any]:
|
|
77
|
+
q_path = da.paths[q_idx]
|
|
78
|
+
q_luma = int(da.phash_luma[q_idx])
|
|
79
|
+
q_color = int(da.phash_color[q_idx])
|
|
80
|
+
q_wf = da.waveform[q_idx]
|
|
81
|
+
q_lab = da.lab[q_idx]
|
|
82
|
+
q_sig = da.signatures[q_idx]
|
|
83
|
+
|
|
84
|
+
dist_luma = hamming_matrix(q_luma, orig.phash_luma)
|
|
85
|
+
dist_color = hamming_matrix(q_color, orig.phash_color)
|
|
86
|
+
hamming_combined = 0.7 * dist_luma + 0.3 * dist_color
|
|
87
|
+
|
|
88
|
+
cand_indices = np.where(dist_luma <= max_hamming)[0]
|
|
89
|
+
if len(cand_indices) == 0:
|
|
90
|
+
return {"query_path": q_path, "top_matches": []}
|
|
91
|
+
|
|
92
|
+
wf_dists = np.linalg.norm(orig.waveform[cand_indices] - q_wf, axis=1)
|
|
93
|
+
lab_dists = np.linalg.norm(orig.lab[cand_indices] - q_lab, axis=1) / 100.0
|
|
94
|
+
|
|
95
|
+
scores = []
|
|
96
|
+
for pos, orig_idx in enumerate(cand_indices):
|
|
97
|
+
h_dist = hamming_combined[orig_idx]
|
|
98
|
+
w_dist = wf_dists[pos]
|
|
99
|
+
l_dist = lab_dists[pos]
|
|
100
|
+
|
|
101
|
+
s_h = max(0.0, 1.0 - (h_dist / 20.0))
|
|
102
|
+
s_w = max(0.0, 1.0 - (w_dist / 0.5))
|
|
103
|
+
s_l = max(0.0, 1.0 - l_dist)
|
|
104
|
+
|
|
105
|
+
sig_dist = visual_sig_distance(q_sig, orig.signatures[orig_idx])
|
|
106
|
+
s_sig = max(0.0, 1.0 - sig_dist)
|
|
107
|
+
|
|
108
|
+
composite = float(0.35 * s_h + 0.35 * s_w + 0.15 * s_l + 0.15 * s_sig)
|
|
109
|
+
scores.append({
|
|
110
|
+
"orig_idx": int(orig_idx),
|
|
111
|
+
"match_path": orig.paths[orig_idx],
|
|
112
|
+
"confidence": composite,
|
|
113
|
+
"metrics": {
|
|
114
|
+
"hamming_luma": int(dist_luma[orig_idx]),
|
|
115
|
+
"hamming_color": int(dist_color[orig_idx]),
|
|
116
|
+
"waveform_dist": round(float(w_dist), 6),
|
|
117
|
+
"lab_dist": round(float(l_dist), 6),
|
|
118
|
+
"sig_dist": round(float(sig_dist), 6),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
scores.sort(key=lambda c: c["confidence"], reverse=True)
|
|
123
|
+
best = scores[0]["confidence"]
|
|
124
|
+
eligible = [c for c in scores if (best - c["confidence"]) <= candidate_threshold]
|
|
125
|
+
return {"query_path": q_path, "top_matches": eligible, "_q_idx": q_idx}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _finalize(result: Dict[str, Any], da: Dataset, direct_compare: bool) -> Dict[str, Any]:
|
|
129
|
+
q_idx = result.pop("_q_idx", None)
|
|
130
|
+
if direct_compare and result["top_matches"] and q_idx is not None:
|
|
131
|
+
q_path = da.paths[q_idx]
|
|
132
|
+
for c in result["top_matches"]:
|
|
133
|
+
ssim_val = direct_ssim(q_path, c["match_path"])
|
|
134
|
+
c["metrics"]["ssim"] = round(ssim_val, 4)
|
|
135
|
+
c["confidence"] = round(0.5 * c["confidence"] + 0.5 * ssim_val, 4)
|
|
136
|
+
result["top_matches"].sort(key=lambda c: c["confidence"], reverse=True)
|
|
137
|
+
else:
|
|
138
|
+
for c in result["top_matches"]:
|
|
139
|
+
c["confidence"] = round(c["confidence"], 4)
|
|
140
|
+
for c in result["top_matches"]:
|
|
141
|
+
c.pop("orig_idx", None)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run_match(
|
|
146
|
+
da_root: str,
|
|
147
|
+
orig_root: str,
|
|
148
|
+
output_path: str,
|
|
149
|
+
max_hamming: int = 16,
|
|
150
|
+
candidate_threshold: float = 0.03,
|
|
151
|
+
do_direct_compare: bool = False,
|
|
152
|
+
workers: int = 0,
|
|
153
|
+
resume: bool = True,
|
|
154
|
+
) -> None:
|
|
155
|
+
da = load_dataset(Path(da_root))
|
|
156
|
+
orig = load_dataset(Path(orig_root))
|
|
157
|
+
log.info("Loaded %d derivative images, %d original images", len(da), len(orig))
|
|
158
|
+
|
|
159
|
+
if len(da) == 0 or len(orig) == 0:
|
|
160
|
+
log.warning("One of the datasets is empty (no image_cache.json entries found) - nothing to do.")
|
|
161
|
+
Path(output_path).touch()
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
out_path = Path(output_path)
|
|
165
|
+
done = _already_done(out_path) if resume else set()
|
|
166
|
+
if done:
|
|
167
|
+
log.info("Resuming: %d/%d already completed", len(done), len(da))
|
|
168
|
+
|
|
169
|
+
pending = [i for i in range(len(da)) if da.paths[i] not in done]
|
|
170
|
+
t0 = time.time()
|
|
171
|
+
|
|
172
|
+
with open(out_path, "a", encoding="utf-8") as out_f:
|
|
173
|
+
if workers and workers > 1 and not do_direct_compare:
|
|
174
|
+
# Direct-compare (SSIM) reads image bytes and is worth parallelising
|
|
175
|
+
# separately; the vectorised numpy stages below are already fast
|
|
176
|
+
# enough single-threaded and cheaper to run in-process.
|
|
177
|
+
with ProcessPoolExecutor(
|
|
178
|
+
max_workers=workers,
|
|
179
|
+
initializer=_init_worker,
|
|
180
|
+
initargs=(da, orig, max_hamming, candidate_threshold),
|
|
181
|
+
) as pool:
|
|
182
|
+
futures = {pool.submit(_worker_match, i): i for i in pending}
|
|
183
|
+
for n, fut in enumerate(as_completed(futures), 1):
|
|
184
|
+
result = _finalize(fut.result(), da, do_direct_compare)
|
|
185
|
+
out_f.write(json.dumps(result) + "\n")
|
|
186
|
+
out_f.flush()
|
|
187
|
+
if n % 50 == 0:
|
|
188
|
+
log.info("Matched %d/%d", n, len(pending))
|
|
189
|
+
else:
|
|
190
|
+
for n, i in enumerate(pending, 1):
|
|
191
|
+
result = match_one(i, da, orig, max_hamming, candidate_threshold)
|
|
192
|
+
result = _finalize(result, da, do_direct_compare)
|
|
193
|
+
out_f.write(json.dumps(result) + "\n")
|
|
194
|
+
out_f.flush()
|
|
195
|
+
if n % 50 == 0:
|
|
196
|
+
log.info("Matched %d/%d", n, len(pending))
|
|
197
|
+
|
|
198
|
+
log.info("Done in %.1fs -> %s", time.time() - t0, out_path)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
202
|
+
p = argparse.ArgumentParser(prog="imgmatch-bridge", description="Build a confidence-scored bridge map between two cached image datasets.")
|
|
203
|
+
p.add_argument("da_root", help="Root of the derivative/watermarked dataset (e.g. DeviantArt scrape).")
|
|
204
|
+
p.add_argument("orig_root", help="Root of the originals dataset.")
|
|
205
|
+
p.add_argument("-o", "--output", default="bridge_map.jsonl", help="Output JSONL path (default: bridge_map.jsonl).")
|
|
206
|
+
p.add_argument("--max-hamming", type=int, default=16, help="Tier-1 pHash-luma Hamming cutoff (default: 16).")
|
|
207
|
+
p.add_argument("--candidate-threshold", type=float, default=0.03, help="Keep candidates within this confidence gap of the best match (default: 0.03).")
|
|
208
|
+
p.add_argument("--direct-compare", action="store_true", help="Run Tier-5 pixel SSIM re-ranking on surviving candidates (slow).")
|
|
209
|
+
p.add_argument("-w", "--workers", type=int, default=0, help="Process pool size for per-query matching (default: single process).")
|
|
210
|
+
p.add_argument("--no-resume", action="store_true", help="Ignore/overwrite any existing output file instead of resuming.")
|
|
211
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
212
|
+
return p
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main(argv: List[str] = None) -> int:
|
|
216
|
+
args = build_parser().parse_args(argv)
|
|
217
|
+
if args.verbose:
|
|
218
|
+
get_logger(__name__, verbose=True)
|
|
219
|
+
|
|
220
|
+
if args.no_resume and Path(args.output).exists():
|
|
221
|
+
Path(args.output).unlink()
|
|
222
|
+
|
|
223
|
+
for root_name, root in (("da_root", args.da_root), ("orig_root", args.orig_root)):
|
|
224
|
+
if not Path(root).is_dir():
|
|
225
|
+
print(f"Not a directory: {root} ({root_name})", file=sys.stderr)
|
|
226
|
+
return 2
|
|
227
|
+
|
|
228
|
+
run_match(
|
|
229
|
+
args.da_root, args.orig_root, args.output,
|
|
230
|
+
max_hamming=args.max_hamming,
|
|
231
|
+
candidate_threshold=args.candidate_threshold,
|
|
232
|
+
do_direct_compare=args.direct_compare,
|
|
233
|
+
workers=args.workers,
|
|
234
|
+
resume=not args.no_resume,
|
|
235
|
+
)
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
sys.exit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decides, per image, which calculation groups (if any) need to run.
|
|
3
|
+
|
|
4
|
+
Rule (this is the spec you gave, made explicit):
|
|
5
|
+
- No cache entry at all -> compute every group.
|
|
6
|
+
- Cache entry exists but mtime differs -> the file content changed, so ALL
|
|
7
|
+
previously-computed groups are stale and must be recomputed. (A naive
|
|
8
|
+
"only fill in missing keys" approach would silently keep stale phash/lab/
|
|
9
|
+
etc. values for a changed file just because the keys happen to already
|
|
10
|
+
exist -- that's a correctness bug, not a caching optimisation, so it's
|
|
11
|
+
deliberately not what this does.)
|
|
12
|
+
- Cache entry exists and mtime matches -> only groups whose keys are
|
|
13
|
+
missing get (re)computed; everything else is reused untouched. This is
|
|
14
|
+
what lets you cheaply backfill a new field type across a huge existing
|
|
15
|
+
cache without re-hashing every image.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Dict, FrozenSet, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from .constants import ALL_GROUPS, GROUP_KEYS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def plan_entry(cached_entry: Optional[Dict[str, Any]], mtime: int) -> Tuple[FrozenSet[str], Dict[str, Any]]:
|
|
25
|
+
"""Returns (groups_needing_compute, base_entry_to_extend)."""
|
|
26
|
+
if not cached_entry or cached_entry.get("mtime") != mtime:
|
|
27
|
+
return ALL_GROUPS, {"mtime": mtime}
|
|
28
|
+
|
|
29
|
+
missing = frozenset(
|
|
30
|
+
group for group, keys in GROUP_KEYS.items()
|
|
31
|
+
if not all(k in cached_entry for k in keys)
|
|
32
|
+
)
|
|
33
|
+
return missing, dict(cached_entry)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fast filesystem discovery.
|
|
3
|
+
|
|
4
|
+
Why this is faster than a plain os.walk + Path.stat() pass: os.scandir's
|
|
5
|
+
DirEntry caches the stat result from the readdir call on most platforms, so
|
|
6
|
+
entry.stat() below is (usually) free -- no extra stat(2) syscall per file the
|
|
7
|
+
way Path(f).stat() or os.stat(f) would cost. We grab mtime here, once, while
|
|
8
|
+
we're already iterating the directory to decide what's an image / a
|
|
9
|
+
subdirectory / the ignore marker, instead of doing a second full walk later
|
|
10
|
+
just to stat files for cache invalidation.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterator, List, Tuple
|
|
17
|
+
|
|
18
|
+
from .constants import IGNORE_MARKER, VALID_EXTENSIONS
|
|
19
|
+
from .logging_utils import get_logger
|
|
20
|
+
|
|
21
|
+
log = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
# (absolute path, mtime as int seconds)
|
|
24
|
+
ImageEntry = Tuple[Path, int]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def scan_tree(root_dir: Path) -> Iterator[Tuple[Path, List[ImageEntry]]]:
|
|
28
|
+
"""Yields (directory, [ (image_path, mtime), ... ]) for every directory
|
|
29
|
+
under root_dir that contains at least one image, skipping any directory
|
|
30
|
+
(and everything beneath it) that contains an IGNORE_MARKER file."""
|
|
31
|
+
stack = [Path(root_dir)]
|
|
32
|
+
while stack:
|
|
33
|
+
current = stack.pop()
|
|
34
|
+
try:
|
|
35
|
+
with os.scandir(current) as it:
|
|
36
|
+
entries = list(it)
|
|
37
|
+
except OSError as exc:
|
|
38
|
+
log.warning("Cannot read directory %s (%s) - skipping", current, exc)
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
if any(e.name == IGNORE_MARKER and e.is_file(follow_symlinks=False) for e in entries):
|
|
42
|
+
continue # this directory and its subtree are opted out
|
|
43
|
+
|
|
44
|
+
images: List[ImageEntry] = []
|
|
45
|
+
for entry in entries:
|
|
46
|
+
try:
|
|
47
|
+
if entry.is_dir(follow_symlinks=False):
|
|
48
|
+
stack.append(Path(entry.path))
|
|
49
|
+
continue
|
|
50
|
+
suffix = os.path.splitext(entry.name)[1].lower()
|
|
51
|
+
if suffix in VALID_EXTENSIONS:
|
|
52
|
+
mtime = int(entry.stat(follow_symlinks=False).st_mtime)
|
|
53
|
+
images.append((Path(entry.path), mtime))
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
log.warning("Cannot stat %s (%s) - skipping", entry.path, exc)
|
|
56
|
+
|
|
57
|
+
if images:
|
|
58
|
+
yield current, images
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Distance / similarity primitives used by the multi-tier matcher."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ._imaging_backend import HAS_OPENCV, cv2
|
|
9
|
+
|
|
10
|
+
if not HAS_OPENCV:
|
|
11
|
+
from PIL import Image
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def hamming_matrix(query_hash: int, dataset_hashes: np.ndarray) -> np.ndarray:
|
|
15
|
+
"""Vectorised popcount of (query XOR every hash in dataset_hashes)."""
|
|
16
|
+
xor = np.bitwise_xor(dataset_hashes, np.uint64(query_hash))
|
|
17
|
+
dist = np.zeros(len(dataset_hashes), dtype=np.int32)
|
|
18
|
+
for b in range(64):
|
|
19
|
+
dist += ((xor >> np.uint64(b)) & np.uint64(1)).astype(np.int32)
|
|
20
|
+
return dist
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _hex_to_rgb(hex_str: str) -> np.ndarray:
|
|
24
|
+
h = hex_str.lstrip("#")
|
|
25
|
+
return np.array([int(h[i:i + 2], 16) for i in (0, 2, 4)], dtype=np.float32)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def visual_sig_distance(sig1: List[Dict[str, Any]], sig2: List[Dict[str, Any]]) -> float:
|
|
29
|
+
"""Spatial+colour layout discrepancy; border points (watermark zones,
|
|
30
|
+
x/y outside 10-90%) are dropped so DeviantArt watermarks/frames don't
|
|
31
|
+
dominate the score."""
|
|
32
|
+
if not sig1 or not sig2:
|
|
33
|
+
return 1.0
|
|
34
|
+
|
|
35
|
+
p1 = [pt for pt in sig1 if 10.0 <= pt["x"] <= 90.0 and 10.0 <= pt["y"] <= 90.0] or sig1
|
|
36
|
+
p2 = [pt for pt in sig2 if 10.0 <= pt["x"] <= 90.0 and 10.0 <= pt["y"] <= 90.0] or sig2
|
|
37
|
+
|
|
38
|
+
rgb2 = np.array([_hex_to_rgb(pt["hex"]) for pt in p2])
|
|
39
|
+
xy2 = np.array([[pt["x"], pt["y"]] for pt in p2], dtype=np.float32)
|
|
40
|
+
|
|
41
|
+
total = 0.0
|
|
42
|
+
for pt1 in p1:
|
|
43
|
+
rgb1 = _hex_to_rgb(pt1["hex"])
|
|
44
|
+
xy1 = np.array([pt1["x"], pt1["y"]], dtype=np.float32)
|
|
45
|
+
c_dist = np.linalg.norm(rgb2 - rgb1, axis=1) / 441.67
|
|
46
|
+
s_dist = np.linalg.norm(xy2 - xy1, axis=1) / 141.42
|
|
47
|
+
combined = 0.6 * c_dist + 0.4 * s_dist
|
|
48
|
+
total += float(np.min(combined))
|
|
49
|
+
return total / len(p1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def direct_ssim(img_path1: str, img_path2: str) -> float:
|
|
53
|
+
"""Optional, expensive pixel-level structural similarity for the final
|
|
54
|
+
verification tier. Never raises - returns 0.0 (no similarity) on any
|
|
55
|
+
read/decode failure so a bad file can't crash a batch run."""
|
|
56
|
+
try:
|
|
57
|
+
if HAS_OPENCV:
|
|
58
|
+
i1 = cv2.imread(img_path1, cv2.IMREAD_GRAYSCALE)
|
|
59
|
+
i2 = cv2.imread(img_path2, cv2.IMREAD_GRAYSCALE)
|
|
60
|
+
if i1 is None or i2 is None:
|
|
61
|
+
return 0.0
|
|
62
|
+
i2 = cv2.resize(i2, (i1.shape[1], i1.shape[0]), interpolation=cv2.INTER_AREA)
|
|
63
|
+
c1, c2 = (0.01 * 255) ** 2, (0.03 * 255) ** 2
|
|
64
|
+
i1 = i1.astype(np.float64)
|
|
65
|
+
i2 = i2.astype(np.float64)
|
|
66
|
+
mu1 = cv2.GaussianBlur(i1, (11, 11), 1.5)
|
|
67
|
+
mu2 = cv2.GaussianBlur(i2, (11, 11), 1.5)
|
|
68
|
+
mu1_sq, mu2_sq, mu1_mu2 = mu1 ** 2, mu2 ** 2, mu1 * mu2
|
|
69
|
+
sigma1_sq = cv2.GaussianBlur(i1 ** 2, (11, 11), 1.5) - mu1_sq
|
|
70
|
+
sigma2_sq = cv2.GaussianBlur(i2 ** 2, (11, 11), 1.5) - mu2_sq
|
|
71
|
+
sigma12 = cv2.GaussianBlur(i1 * i2, (11, 11), 1.5) - mu1_mu2
|
|
72
|
+
ssim_map = ((2 * mu1_mu2 + c1) * (2 * sigma12 + c2)) / ((mu1_sq + mu2_sq + c1) * (sigma1_sq + sigma2_sq + c2))
|
|
73
|
+
return float(np.mean(ssim_map))
|
|
74
|
+
else:
|
|
75
|
+
with Image.open(img_path1) as im1, Image.open(img_path2) as im2:
|
|
76
|
+
g1 = np.array(im1.convert("L").resize((256, 256)), dtype=np.float64)
|
|
77
|
+
g2 = np.array(im2.convert("L").resize((256, 256)), dtype=np.float64)
|
|
78
|
+
mse = np.mean((g1 - g2) ** 2)
|
|
79
|
+
return max(0.0, 1.0 - (mse / 65535.0))
|
|
80
|
+
except Exception:
|
|
81
|
+
return 0.0
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: image-bridge-toolkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cache-backed perceptual image fingerprinting and cross-dataset bridge matching.
|
|
5
|
+
Author: Andrew Kingdom
|
|
6
|
+
License: MIT for Code / CC BY-NC-ND 4.0 for Documentation
|
|
7
|
+
Project-URL: Homepage, https://github.com/akingdom/image_bridge_toolkit
|
|
8
|
+
Project-URL: Repository, https://github.com/akingdom/image_bridge_toolkit
|
|
9
|
+
Project-URL: Issues, https://github.com/akingdom/image_bridge_toolkit/issues
|
|
10
|
+
Keywords: image-processing,perceptual-hash,phash,deduplication,dct,computer-vision,reverse-image-search
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE.md
|
|
23
|
+
Requires-Dist: numpy>=1.23
|
|
24
|
+
Requires-Dist: Pillow>=9.0
|
|
25
|
+
Provides-Extra: opencv
|
|
26
|
+
Requires-Dist: opencv-python-headless>=4.6; extra == "opencv"
|
|
27
|
+
Provides-Extra: progress
|
|
28
|
+
Requires-Dist: tqdm>=4.65; extra == "progress"
|
|
29
|
+
Provides-Extra: all
|
|
30
|
+
Requires-Dist: opencv-python-headless>=4.6; extra == "all"
|
|
31
|
+
Requires-Dist: tqdm>=4.65; extra == "all"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="100%" height="400">
|
|
36
|
+
<defs>
|
|
37
|
+
<linearGradient id="leftNodeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
38
|
+
<stop offset="0%" stop-color="#06B6D4" stop-opacity="0.25"/>
|
|
39
|
+
<stop offset="100%" stop-color="#0F172A" stop-opacity="0.8"/>
|
|
40
|
+
</linearGradient>
|
|
41
|
+
|
|
42
|
+
<linearGradient id="rightNodeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
43
|
+
<stop offset="0%" stop-color="#EC4899" stop-opacity="0.3"/>
|
|
44
|
+
<stop offset="100%" stop-color="#06B6D4" stop-opacity="0.15"/>
|
|
45
|
+
</linearGradient>
|
|
46
|
+
|
|
47
|
+
<linearGradient id="bridgeGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
48
|
+
<stop offset="0%" stop-color="#06B6D4"/>
|
|
49
|
+
<stop offset="50%" stop-color="#FFFFFF"/>
|
|
50
|
+
<stop offset="100%" stop-color="#EC4899"/>
|
|
51
|
+
</linearGradient>
|
|
52
|
+
|
|
53
|
+
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
|
54
|
+
<feGaussianBlur stdDeviation="8" result="blur"/>
|
|
55
|
+
<feMerge>
|
|
56
|
+
<feMergeNode in="blur"/>
|
|
57
|
+
<feMergeNode in="SourceGraphic"/>
|
|
58
|
+
</feMerge>
|
|
59
|
+
</filter>
|
|
60
|
+
|
|
61
|
+
<filter id="glow-subtle" x="-20%" y="-20%" width="140%" height="140%">
|
|
62
|
+
<feGaussianBlur stdDeviation="4" result="blur"/>
|
|
63
|
+
<feMerge>
|
|
64
|
+
<feMergeNode in="blur"/>
|
|
65
|
+
<feMergeNode in="SourceGraphic"/>
|
|
66
|
+
</feMerge>
|
|
67
|
+
</filter>
|
|
68
|
+
|
|
69
|
+
<pattern id="fingerprintGrid" width="16" height="16" patternUnits="userSpaceOnUse">
|
|
70
|
+
<path d="M 16 0 L 0 0 0 16" fill="none" stroke="#06B6D4" stroke-width="0.8" opacity="0.3"/>
|
|
71
|
+
<circle cx="8" cy="8" r="1.5" fill="#06B6D4" opacity="0.4"/>
|
|
72
|
+
</pattern>
|
|
73
|
+
</defs>
|
|
74
|
+
|
|
75
|
+
<circle cx="280" cy="300" r="160" fill="#06B6D4" opacity="0.05" filter="url(#glow)"/>
|
|
76
|
+
<circle cx="520" cy="280" r="170" fill="#EC4899" opacity="0.05" filter="url(#glow)"/>
|
|
77
|
+
|
|
78
|
+
<path d="M 140 420 L 220 420 L 260 460" stroke="#06B6D4" stroke-width="1.5" fill="none" opacity="0.3" stroke-dasharray="4 4"/>
|
|
79
|
+
<path d="M 660 180 L 580 180 L 540 140" stroke="#EC4899" stroke-width="1.5" fill="none" opacity="0.3" stroke-dasharray="4 4"/>
|
|
80
|
+
|
|
81
|
+
<g transform="translate(180, 190)">
|
|
82
|
+
<rect x="0" y="0" width="200" height="240" rx="16" fill="#000000" opacity="0.4"/>
|
|
83
|
+
<rect x="0" y="0" width="200" height="240" rx="16" fill="url(#leftNodeGrad)" stroke="#06B6D4" stroke-width="2" opacity="0.9"/>
|
|
84
|
+
<rect x="16" y="16" width="168" height="208" rx="8" fill="url(#fingerprintGrid)"/>
|
|
85
|
+
<path d="M 40 60 L 90 60 L 130 100 L 130 160 L 80 180" stroke="#06B6D4" stroke-width="1.5" fill="none" opacity="0.6"/>
|
|
86
|
+
<path d="M 90 60 L 70 120 L 140 120" stroke="#06B6D4" stroke-width="1.5" fill="none" stroke-dasharray="2 2" opacity="0.5"/>
|
|
87
|
+
<circle cx="40" cy="60" r="4" fill="#FFFFFF" filter="url(#glow-subtle)"/>
|
|
88
|
+
<circle cx="90" cy="60" r="3.5" fill="#06B6D4"/>
|
|
89
|
+
<circle cx="130" cy="100" r="3.5" fill="#06B6D4"/>
|
|
90
|
+
<circle cx="70" cy="120" r="3.5" fill="#EC4899"/>
|
|
91
|
+
<circle cx="140" cy="120" r="3.5" fill="#06B6D4"/>
|
|
92
|
+
<circle cx="130" cy="160" r="4" fill="#FFFFFF" filter="url(#glow-subtle)"/>
|
|
93
|
+
<circle cx="80" cy="180" r="3.5" fill="#06B6D4"/>
|
|
94
|
+
<path d="M 24 36 L 24 24 L 36 24" stroke="#06B6D4" stroke-width="2" fill="none"/>
|
|
95
|
+
<path d="M 176 36 L 176 24 L 164 24" stroke="#06B6D4" stroke-width="2" fill="none"/>
|
|
96
|
+
<path d="M 24 184 L 24 196 L 36 196" stroke="#06B6D4" stroke-width="2" fill="none"/>
|
|
97
|
+
<path d="M 176 184 L 176 196 L 164 196" stroke="#06B6D4" stroke-width="2" fill="none"/>
|
|
98
|
+
</g>
|
|
99
|
+
|
|
100
|
+
<path d="M 330 330 C 400 420, 440 180, 500 270" stroke="url(#bridgeGrad)" stroke-width="12" fill="none" opacity="0.3" filter="url(#glow)"/>
|
|
101
|
+
<path d="M 320 340 C 390 440, 430 170, 510 260" stroke="url(#bridgeGrad)" stroke-width="4" fill="none" stroke-dasharray="12 6 4 6" stroke-linecap="round" filter="url(#glow-subtle)"/>
|
|
102
|
+
<path d="M 350 290 C 410 230, 430 350, 480 300" stroke="#FFFFFF" stroke-width="1.5" fill="none" stroke-dasharray="6 6" opacity="0.7"/>
|
|
103
|
+
|
|
104
|
+
<circle cx="375" cy="355" r="3" fill="#06B6D4" filter="url(#glow-subtle)"/>
|
|
105
|
+
<circle cx="415" cy="310" r="4.5" fill="#FFFFFF" filter="url(#glow)"/>
|
|
106
|
+
<circle cx="445" cy="255" r="3" fill="#EC4899" filter="url(#glow-subtle)"/>
|
|
107
|
+
<rect x="470" y="270" width="5" height="5" transform="rotate(45 472.5 272.5)" fill="#06B6D4"/>
|
|
108
|
+
|
|
109
|
+
<g transform="translate(420, 150)">
|
|
110
|
+
<rect x="0" y="0" width="220" height="260" rx="16" fill="#000000" opacity="0.5"/>
|
|
111
|
+
<rect x="0" y="0" width="220" height="260" rx="16" fill="url(#rightNodeGrad)" stroke="#EC4899" stroke-width="2.5" opacity="0.95"/>
|
|
112
|
+
<rect x="14" y="14" width="192" height="232" rx="10" fill="#0F172A" stroke="#FFFFFF" stroke-width="1" stroke-opacity="0.2"/>
|
|
113
|
+
<g opacity="0.9">
|
|
114
|
+
<circle cx="150" cy="75" r="18" fill="#EC4899" filter="url(#glow-subtle)"/>
|
|
115
|
+
<circle cx="150" cy="75" r="10" fill="#FFFFFF"/>
|
|
116
|
+
<polygon points="40,190 100,100 160,190" fill="#06B6D4" opacity="0.4"/>
|
|
117
|
+
<polygon points="80,200 140,120 200,200" fill="#EC4899" opacity="0.6"/>
|
|
118
|
+
<polygon points="140,120 200,200 140,200" fill="#FFFFFF" opacity="0.15"/>
|
|
119
|
+
<line x1="14" y1="160" x2="206" y2="160" stroke="#06B6D4" stroke-width="1" opacity="0.4" stroke-dasharray="4 4"/>
|
|
120
|
+
<line x1="80" y1="14" x2="80" y2="246" stroke="#06B6D4" stroke-width="1" opacity="0.2" stroke-dasharray="4 4"/>
|
|
121
|
+
</g>
|
|
122
|
+
<rect x="-5" y="-5" width="10" height="10" fill="#FFFFFF" stroke="#EC4899" stroke-width="2"/>
|
|
123
|
+
<rect x="215" y="-5" width="10" height="10" fill="#FFFFFF" stroke="#EC4899" stroke-width="2"/>
|
|
124
|
+
<rect x="-5" y="255" width="10" height="10" fill="#FFFFFF" stroke="#EC4899" stroke-width="2"/>
|
|
125
|
+
<rect x="215" y="255" width="10" height="10" fill="#FFFFFF" stroke="#EC4899" stroke-width="2"/>
|
|
126
|
+
</g>
|
|
127
|
+
|
|
128
|
+
<circle cx="320" cy="340" r="6" fill="#06B6D4" stroke="#FFFFFF" stroke-width="2" filter="url(#glow-subtle)"/>
|
|
129
|
+
<circle cx="510" cy="260" r="6" fill="#EC4899" stroke="#FFFFFF" stroke-width="2" filter="url(#glow-subtle)"/>
|
|
130
|
+
</svg>
|
|
131
|
+
</p>
|
|
132
|
+
|
|
133
|
+
# image-bridge-toolkit
|
|
134
|
+
|
|
135
|
+
[](https://pypi.org/project/image-bridge-toolkit/)
|
|
136
|
+
[](LICENSE)
|
|
137
|
+
[](https://creativecommons.org/licenses/by-nc-nd/4.0/)
|
|
138
|
+
|
|
139
|
+
`image-bridge-toolkit` provides cache-backed perceptual image fingerprinting and cross-dataset bridge matching designed to handle large scale (50,000+ image) libraries. It finds identical or highly similar images across disparate folders while remaining resilient to scale shifts, aspect ratio changes, heavy compression, and intrusive watermarks.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Key Features
|
|
144
|
+
|
|
145
|
+
- **Per-Directory JSON Caching**: Generates and maintains lightweight `image_cache.json` files within each directory, keyed by filename and modification time (`mtime`).
|
|
146
|
+
- **Scale-Invariant Waveform Approximations**: Uses 16-float 1D Discrete Cosine Transform (DCT) grayscale histogram vectors to ignore local watermark text spikes and compression artifacts.
|
|
147
|
+
- **5-Tier Matching Funnel**:
|
|
148
|
+
1. **Tier 1 (Bitwise pHash Luma + Color)**: Generous 64-bit Hamming filtering to eliminate obvious non-matches.
|
|
149
|
+
2. **Tier 2 (Histogram Waveform)**: Euclidean distance on 16 low-frequency DCT coefficients to filter lighting distribution changes.
|
|
150
|
+
3. **Tier 3 (LAB Perceptual Metrics)**: Vector distance across `lab_value`, `lab_hue`, `lab_chroma`, and `lab_warmth`.
|
|
151
|
+
4. **Tier 4 (Spatial Layout Tie-Breaker)**: 16-point color spatial coordinates down-weighting image outer margins where signatures and borders reside.
|
|
152
|
+
5. **Tier 5 (Optional SSIM Direct Comparison)**: Full structural similarity pixel comparison on top candidate matches.
|
|
153
|
+
- **Resumable & High Performance**: Output streams as `.jsonl` objects, allowing runs over huge original sets to resume instantly without re-processing.
|
|
154
|
+
- **Zero-Dependency Fallback**: Runs with `OpenCV` if available for acceleration, or falls back to pure `NumPy` + `Pillow`.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Installation
|
|
159
|
+
|
|
160
|
+
Install via PyPI:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# Minimal installation (NumPy + Pillow)
|
|
164
|
+
pip install image-bridge-toolkit
|
|
165
|
+
|
|
166
|
+
# Recommended installation (includes OpenCV acceleration & progress bars)
|
|
167
|
+
pip install "image-bridge-toolkit[all]"
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
For development:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
git clone [https://github.com/akingdom/image_bridge_toolkit.git](https://github.com/akingdom/image_bridge_toolkit.git)
|
|
175
|
+
cd image_bridge_toolkit
|
|
176
|
+
pip install -e ".[all]"
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Usage
|
|
183
|
+
|
|
184
|
+
### 1. Build or Update Metadata Caches (`imgcache-build`)
|
|
185
|
+
|
|
186
|
+
Walks a root directory, scanning all subdirectories (skipping any directory containing `.ignore_subdir`), and generates or updates `image_cache.json`.
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
# Build cache for a query dataset (e.g., 1,000 watermarked images)
|
|
190
|
+
imgcache-build /path/to/thumbnails_set
|
|
191
|
+
|
|
192
|
+
# Build cache for a target dataset (e.g., 50,000 original images)
|
|
193
|
+
imgcache-build /path/to/originals_set
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### 2. Match Datasets (`imgmatch-bridge`)
|
|
198
|
+
|
|
199
|
+
Matches images from the query dataset against the target library, generating a bridge mapping file with confidence scores and top candidate lists.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
imgmatch-bridge /path/to/thumbnails_set /path/to/originals_set \
|
|
203
|
+
-o bridge_map.jsonl \
|
|
204
|
+
--max-hamming 16 \
|
|
205
|
+
--candidate-threshold 0.03 \
|
|
206
|
+
--direct-compare
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### Command-Line Options
|
|
211
|
+
|
|
212
|
+
| Flag | Default | Description |
|
|
213
|
+
| --- | --- | --- |
|
|
214
|
+
| `-o`, `--output` | `bridge_map.jsonl` | File path for output streaming results. |
|
|
215
|
+
| `--max-hamming` | `16` | Maximum allowed Tier 1 pHash Hamming distance threshold. |
|
|
216
|
+
| `--candidate-threshold` | `0.03` | Percentage band (e.g., 3%) to include close secondary matches. |
|
|
217
|
+
| `--direct-compare` | `False` | Performs Tier 5 pixel-level SSIM re-ranking on candidates. |
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Cache Integrity & Updating
|
|
222
|
+
|
|
223
|
+
* **`mtime` Matching**: A cached entry is preserved as long as the file's modification time matches the record in `image_cache.json`.
|
|
224
|
+
* **Partial Recomputation**: If an existing cache entry is missing a newly added metric type (e.g., `hist_waveform`), only the missing calculation is executed; existing valid metrics are preserved.
|
|
225
|
+
* **Corrupt Cache Handling**: Invalid or unparseable JSON files are automatically caught and rebuilt without interrupting directory traversal.
|
|
226
|
+
* **Subdirectory Exclusion**: Placing a `.ignore_subdir` marker inside any directory causes the builder and matcher to ignore that folder and all nested subdirectories.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## License & Copyright
|
|
231
|
+
|
|
232
|
+
* **Source Code (`src/`)**: Licensed under the [MIT License](https://www.google.com/search?q=LICENSE).
|
|
233
|
+
* **Documentation & Branding**: Copyright © 2026 Andrew Kingdom. Licensed under the [Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND 4.0)](https://creativecommons.org/licenses/by-nc-nd/4.0/).
|
|
234
|
+
* **Translation Permission**: Permission is explicitly granted to translate this documentation into other languages, provided that full attribution to Andrew Kingdom is maintained, a direct link to the original repository is included, and no non-linguistic modifications or structural alterations are made to the content.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
image_bridge_toolkit/__init__.py,sha256=clqQ3meBLXZjPDxFVhDswnf43A2vFsWHL9H4wGWzizw,199
|
|
2
|
+
image_bridge_toolkit/_imaging_backend.py,sha256=h9lWrNWuGv0tgSgt3bzslgdZN67u9fDSA5ZFu8_fCMk,382
|
|
3
|
+
image_bridge_toolkit/cache_builder.py,sha256=_iimAXMQ2YFheZ_OviHU_zGC1aHs-csxWRzOJGLhd2I,6087
|
|
4
|
+
image_bridge_toolkit/cache_io.py,sha256=I04o5H05VDRUZBsfJIOTMDfRf1CT3ja_pS6Hjo8QYyI,1647
|
|
5
|
+
image_bridge_toolkit/calculators.py,sha256=589ihMo3C_QdoF6xb252Rs7uRH1r2fI06KA1kdMV-hk,5742
|
|
6
|
+
image_bridge_toolkit/constants.py,sha256=q4MjZwQdrMlPzkcTcsqgy5kvjkcUQh6F2ZfVOFnuhTA,793
|
|
7
|
+
image_bridge_toolkit/dataset_loader.py,sha256=2d7ChU3tpoqFbZheCr7gH9wKatpRyAEneeuruYN2fnE,3322
|
|
8
|
+
image_bridge_toolkit/logging_utils.py,sha256=MSDp3nwJ73eMVHCyRC2i0ISY8XEyWaR833Iwjcf_oC4,494
|
|
9
|
+
image_bridge_toolkit/matcher.py,sha256=t7idIm0Uv0xZ61XNgBDuLmQgGeP5dvYF9MEYooJvtFs,9539
|
|
10
|
+
image_bridge_toolkit/planner.py,sha256=p94pj0gR4qjwS26QhOgUsPjnPBjUIZI56nH8i9kY0eM,1469
|
|
11
|
+
image_bridge_toolkit/scanner.py,sha256=EBf27Etp8xJK-KZihDRRmKKmjetCTE2eGOzKilqvnyE,2238
|
|
12
|
+
image_bridge_toolkit/similarity.py,sha256=2cBGqqcU-Tldfh-UT54a1zitMUk92WSrc6LhYf1CskY,3495
|
|
13
|
+
image_bridge_toolkit-0.1.0.dist-info/licenses/LICENSE.md,sha256=Uvr2imokjzok77N-IjLJFu2cyELzeBRyMomqfN1LKGE,2093
|
|
14
|
+
image_bridge_toolkit-0.1.0.dist-info/METADATA,sha256=P5jHMsRrKyn50rjZb-W2J_iopJLehjfTJ3rfmS2jzAs,12669
|
|
15
|
+
image_bridge_toolkit-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
image_bridge_toolkit-0.1.0.dist-info/entry_points.txt,sha256=yv1qlswe9wfWBGmyiOMzwQQIIwVArtFLHGRLg_Lwpjg,127
|
|
17
|
+
image_bridge_toolkit-0.1.0.dist-info/top_level.txt,sha256=xiJOp4KY-WInli_rbZyrCMHI7eH4Cd0dsmjjckHJ-O4,21
|
|
18
|
+
image_bridge_toolkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
========================================================================
|
|
2
|
+
SOURCE CODE LICENSE (MIT)
|
|
3
|
+
========================================================================
|
|
4
|
+
The source code within the `src/` directory is licensed under the MIT License.
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2026 Andrew Kingdom
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
in the Software without restriction, including without limitation the rights
|
|
11
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
furnished to do so, subject to the following conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM OR OTHER LIABILITY, WHETHER
|
|
22
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
23
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
24
|
+
|
|
25
|
+
========================================================================
|
|
26
|
+
DOCUMENTATION & BRANDING LICENSE (CC BY-NC-ND 4.0 + TRANSLATION WAIVER)
|
|
27
|
+
========================================================================
|
|
28
|
+
All non-code assets, including README files, documentation, logo designs,
|
|
29
|
+
and diagrams, are Copyright (c) 2026 Andrew Kingdom.
|
|
30
|
+
|
|
31
|
+
Licensed under Creative Commons Attribution-NonCommercial-NoDerivatives 4.0
|
|
32
|
+
International (CC BY-NC-ND 4.0).
|
|
33
|
+
|
|
34
|
+
Additional Permission: Direct language translations of this documentation are
|
|
35
|
+
expressly permitted without prior written authorization, provided that:
|
|
36
|
+
1. Full attribution to Andrew Kingdom is maintained.
|
|
37
|
+
2. A direct hyperlink to the original repository is included.
|
|
38
|
+
3. No non-linguistic modifications or structural alterations are made.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
image_bridge_toolkit
|