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.
@@ -0,0 +1,5 @@
1
+ """image_bridge_toolkit: perceptual image fingerprint cache + cross-dataset bridge matcher."""
2
+ from ._imaging_backend import HAS_OPENCV
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["HAS_OPENCV", "__version__"]
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/image-bridge-toolkit.svg)](https://pypi.org/project/image-bridge-toolkit/)
136
+ [![License: MIT](https://img.shields.io/badge/Code_License-MIT-yellow.svg)](LICENSE)
137
+ [![License: CC BY-NC-ND 4.0](https://img.shields.io/badge/Docs_License-CC_BY--NC--ND_4.0-lightgrey.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ imgcache-build = image_bridge_toolkit.cache_builder:main
3
+ imgmatch-bridge = image_bridge_toolkit.matcher:main
@@ -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