photo-stack-finder 0.1.7__py3-none-any.whl → 0.1.8__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.
- orchestrator/__init__.py +2 -2
- orchestrator/app.py +6 -11
- orchestrator/build_pipeline.py +19 -21
- orchestrator/orchestrator_runner.py +11 -8
- orchestrator/pipeline_builder.py +126 -126
- orchestrator/pipeline_orchestrator.py +604 -604
- orchestrator/review_persistence.py +162 -162
- orchestrator/static/orchestrator.css +76 -76
- orchestrator/static/orchestrator.html +11 -5
- orchestrator/static/orchestrator.js +3 -1
- overlap_metrics/__init__.py +1 -1
- overlap_metrics/config.py +135 -135
- overlap_metrics/core.py +284 -284
- overlap_metrics/estimators.py +292 -292
- overlap_metrics/metrics.py +307 -307
- overlap_metrics/registry.py +99 -99
- overlap_metrics/utils.py +104 -104
- photo_compare/__init__.py +1 -1
- photo_compare/base.py +285 -285
- photo_compare/config.py +225 -225
- photo_compare/distance.py +15 -15
- photo_compare/feature_methods.py +173 -173
- photo_compare/file_hash.py +29 -29
- photo_compare/hash_methods.py +99 -99
- photo_compare/histogram_methods.py +118 -118
- photo_compare/pixel_methods.py +58 -58
- photo_compare/structural_methods.py +104 -104
- photo_compare/types.py +28 -28
- {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/METADATA +21 -22
- photo_stack_finder-0.1.8.dist-info/RECORD +75 -0
- scripts/orchestrate.py +12 -10
- utils/__init__.py +4 -3
- utils/base_pipeline_stage.py +171 -171
- utils/base_ports.py +176 -176
- utils/benchmark_utils.py +823 -823
- utils/channel.py +74 -74
- utils/comparison_gates.py +40 -21
- utils/compute_benchmarks.py +355 -355
- utils/compute_identical.py +94 -24
- utils/compute_indices.py +235 -235
- utils/compute_perceptual_hash.py +127 -127
- utils/compute_perceptual_match.py +240 -240
- utils/compute_sha_bins.py +64 -20
- utils/compute_template_similarity.py +1 -1
- utils/compute_versions.py +483 -483
- utils/config.py +8 -5
- utils/data_io.py +83 -83
- utils/graph_context.py +44 -44
- utils/logger.py +2 -2
- utils/models.py +2 -2
- utils/photo_file.py +90 -91
- utils/pipeline_graph.py +334 -334
- utils/pipeline_stage.py +408 -408
- utils/plot_helpers.py +123 -123
- utils/ports.py +136 -136
- utils/progress.py +415 -415
- utils/report_builder.py +139 -139
- utils/review_types.py +55 -55
- utils/review_utils.py +10 -19
- utils/sequence.py +10 -8
- utils/sequence_clustering.py +1 -1
- utils/template.py +57 -57
- utils/template_parsing.py +71 -0
- photo_stack_finder-0.1.7.dist-info/RECORD +0 -74
- {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/WHEEL +0 -0
- {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/entry_points.txt +0 -0
- {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/licenses/LICENSE +0 -0
- {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/top_level.txt +0 -0
|
@@ -1,162 +1,162 @@
|
|
|
1
|
-
"""Functions for persisting and loading review decisions."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import csv
|
|
6
|
-
import json
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
from utils.review_types import (
|
|
11
|
-
DeletionIndexEntry,
|
|
12
|
-
IdenticalDecision,
|
|
13
|
-
PhotoIdentifier,
|
|
14
|
-
ReviewIndexEntry,
|
|
15
|
-
SequenceDecision,
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def append_decision_to_log(decision: IdenticalDecision | SequenceDecision, work_dir: Path) -> None:
|
|
20
|
-
"""Append a review decision to the JSONL log.
|
|
21
|
-
|
|
22
|
-
Args:
|
|
23
|
-
decision: Decision object (IdenticalDecision or SequenceDecision)
|
|
24
|
-
work_dir: Work directory containing the log file
|
|
25
|
-
"""
|
|
26
|
-
log_path: Path = work_dir / "review_decisions.jsonl"
|
|
27
|
-
|
|
28
|
-
# Ensure work directory exists
|
|
29
|
-
work_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
-
|
|
31
|
-
# Append decision as JSON line
|
|
32
|
-
with log_path.open("a", encoding="utf-8") as f:
|
|
33
|
-
json.dump(decision, f, ensure_ascii=False)
|
|
34
|
-
f.write("\n")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def build_review_index(work_dir: Path) -> dict[str, Any]:
|
|
38
|
-
"""Build in-memory indices from JSONL log and generate CSV files.
|
|
39
|
-
|
|
40
|
-
Reads review_decisions.jsonl and creates:
|
|
41
|
-
- review_index_identical.csv: identical group decisions
|
|
42
|
-
- review_index_sequences.csv: sequence group decisions
|
|
43
|
-
- review_index_deletions.csv: individual photo deletions
|
|
44
|
-
|
|
45
|
-
Args:
|
|
46
|
-
work_dir: Work directory containing the log file
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
Dictionary with 'identical', 'sequences', and 'deletions' indices
|
|
50
|
-
"""
|
|
51
|
-
log_path: Path = work_dir / "review_decisions.jsonl"
|
|
52
|
-
|
|
53
|
-
if not log_path.exists():
|
|
54
|
-
# No decisions yet, return empty indices
|
|
55
|
-
return {"identical": {}, "sequences": {}, "deletions": {}}
|
|
56
|
-
|
|
57
|
-
# Parse JSONL log
|
|
58
|
-
identical_index: dict[str, ReviewIndexEntry] = {}
|
|
59
|
-
sequences_index: dict[str, ReviewIndexEntry] = {}
|
|
60
|
-
deletions_index: dict[PhotoIdentifier, DeletionIndexEntry] = {}
|
|
61
|
-
|
|
62
|
-
with log_path.open(encoding="utf-8") as f:
|
|
63
|
-
for _line_num, raw_line in enumerate(f, 1):
|
|
64
|
-
line = raw_line.strip()
|
|
65
|
-
if not line:
|
|
66
|
-
continue
|
|
67
|
-
|
|
68
|
-
try:
|
|
69
|
-
decision: dict[str, Any] = json.loads(line)
|
|
70
|
-
decision_type: str | None = decision.get("type")
|
|
71
|
-
|
|
72
|
-
if decision_type == "identical":
|
|
73
|
-
# Index identical group decision
|
|
74
|
-
group_id: str = decision["group_id"]
|
|
75
|
-
identical_index[group_id] = {
|
|
76
|
-
"group_id": group_id,
|
|
77
|
-
"decision_type": "identical",
|
|
78
|
-
"action": decision["action"],
|
|
79
|
-
"timestamp": decision["timestamp"],
|
|
80
|
-
"user": decision["user"],
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
# Index deletions
|
|
84
|
-
deleted_photos: list[tuple[str, str]] = decision.get("deleted_photos", [])
|
|
85
|
-
sha256: str
|
|
86
|
-
path: str
|
|
87
|
-
for sha256, path in deleted_photos:
|
|
88
|
-
deletions_index[(sha256, path)] = {
|
|
89
|
-
"sha256": sha256,
|
|
90
|
-
"path": path,
|
|
91
|
-
"reason": "identical_group",
|
|
92
|
-
"group_id": group_id,
|
|
93
|
-
"timestamp": decision["timestamp"],
|
|
94
|
-
"user": decision["user"],
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
elif decision_type == "sequences":
|
|
98
|
-
# Index sequence group decision
|
|
99
|
-
seq_group_id: str = decision["group_id"]
|
|
100
|
-
sequences_index[seq_group_id] = {
|
|
101
|
-
"group_id": seq_group_id,
|
|
102
|
-
"decision_type": "sequences",
|
|
103
|
-
"action": decision["action"],
|
|
104
|
-
"timestamp": decision["timestamp"],
|
|
105
|
-
"user": decision["user"],
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
# Index deletions
|
|
109
|
-
seq_deleted_photos: list[tuple[str, str]] = decision.get("deleted_photos", [])
|
|
110
|
-
seq_sha256: str
|
|
111
|
-
seq_path: str
|
|
112
|
-
for seq_sha256, seq_path in seq_deleted_photos:
|
|
113
|
-
deletions_index[(seq_sha256, seq_path)] = {
|
|
114
|
-
"sha256": seq_sha256,
|
|
115
|
-
"path": seq_path,
|
|
116
|
-
"reason": "sequence_group",
|
|
117
|
-
"group_id": seq_group_id,
|
|
118
|
-
"timestamp": decision["timestamp"],
|
|
119
|
-
"user": decision["user"],
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
except json.JSONDecodeError:
|
|
123
|
-
continue
|
|
124
|
-
|
|
125
|
-
# Write CSV indices
|
|
126
|
-
_write_csv_index(
|
|
127
|
-
work_dir / "review_index_identical.csv",
|
|
128
|
-
identical_index.values(),
|
|
129
|
-
["group_id", "decision_type", "action", "timestamp", "user"],
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
_write_csv_index(
|
|
133
|
-
work_dir / "review_index_sequences.csv",
|
|
134
|
-
sequences_index.values(),
|
|
135
|
-
["group_id", "decision_type", "action", "timestamp", "user"],
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
_write_csv_index(
|
|
139
|
-
work_dir / "review_index_deletions.csv",
|
|
140
|
-
deletions_index.values(),
|
|
141
|
-
["sha256", "path", "reason", "group_id", "timestamp", "user"],
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
"identical": identical_index,
|
|
146
|
-
"sequences": sequences_index,
|
|
147
|
-
"deletions": deletions_index,
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def _write_csv_index(path: Path, rows: list[dict[str, Any]] | Any, fieldnames: list[str]) -> None:
|
|
152
|
-
"""Write index data to CSV file.
|
|
153
|
-
|
|
154
|
-
Args:
|
|
155
|
-
path: Output CSV file path
|
|
156
|
-
rows: Iterable of dictionaries to write
|
|
157
|
-
fieldnames: CSV column names
|
|
158
|
-
"""
|
|
159
|
-
with path.open("w", newline="", encoding="utf-8") as f:
|
|
160
|
-
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
161
|
-
writer.writeheader()
|
|
162
|
-
writer.writerows(rows)
|
|
1
|
+
"""Functions for persisting and loading review decisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from utils.review_types import (
|
|
11
|
+
DeletionIndexEntry,
|
|
12
|
+
IdenticalDecision,
|
|
13
|
+
PhotoIdentifier,
|
|
14
|
+
ReviewIndexEntry,
|
|
15
|
+
SequenceDecision,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def append_decision_to_log(decision: IdenticalDecision | SequenceDecision, work_dir: Path) -> None:
|
|
20
|
+
"""Append a review decision to the JSONL log.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
decision: Decision object (IdenticalDecision or SequenceDecision)
|
|
24
|
+
work_dir: Work directory containing the log file
|
|
25
|
+
"""
|
|
26
|
+
log_path: Path = work_dir / "review_decisions.jsonl"
|
|
27
|
+
|
|
28
|
+
# Ensure work directory exists
|
|
29
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
# Append decision as JSON line
|
|
32
|
+
with log_path.open("a", encoding="utf-8") as f:
|
|
33
|
+
json.dump(decision, f, ensure_ascii=False)
|
|
34
|
+
f.write("\n")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_review_index(work_dir: Path) -> dict[str, Any]:
|
|
38
|
+
"""Build in-memory indices from JSONL log and generate CSV files.
|
|
39
|
+
|
|
40
|
+
Reads review_decisions.jsonl and creates:
|
|
41
|
+
- review_index_identical.csv: identical group decisions
|
|
42
|
+
- review_index_sequences.csv: sequence group decisions
|
|
43
|
+
- review_index_deletions.csv: individual photo deletions
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
work_dir: Work directory containing the log file
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Dictionary with 'identical', 'sequences', and 'deletions' indices
|
|
50
|
+
"""
|
|
51
|
+
log_path: Path = work_dir / "review_decisions.jsonl"
|
|
52
|
+
|
|
53
|
+
if not log_path.exists():
|
|
54
|
+
# No decisions yet, return empty indices
|
|
55
|
+
return {"identical": {}, "sequences": {}, "deletions": {}}
|
|
56
|
+
|
|
57
|
+
# Parse JSONL log
|
|
58
|
+
identical_index: dict[str, ReviewIndexEntry] = {}
|
|
59
|
+
sequences_index: dict[str, ReviewIndexEntry] = {}
|
|
60
|
+
deletions_index: dict[PhotoIdentifier, DeletionIndexEntry] = {}
|
|
61
|
+
|
|
62
|
+
with log_path.open(encoding="utf-8") as f:
|
|
63
|
+
for _line_num, raw_line in enumerate(f, 1):
|
|
64
|
+
line = raw_line.strip()
|
|
65
|
+
if not line:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
decision: dict[str, Any] = json.loads(line)
|
|
70
|
+
decision_type: str | None = decision.get("type")
|
|
71
|
+
|
|
72
|
+
if decision_type == "identical":
|
|
73
|
+
# Index identical group decision
|
|
74
|
+
group_id: str = decision["group_id"]
|
|
75
|
+
identical_index[group_id] = {
|
|
76
|
+
"group_id": group_id,
|
|
77
|
+
"decision_type": "identical",
|
|
78
|
+
"action": decision["action"],
|
|
79
|
+
"timestamp": decision["timestamp"],
|
|
80
|
+
"user": decision["user"],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Index deletions
|
|
84
|
+
deleted_photos: list[tuple[str, str]] = decision.get("deleted_photos", [])
|
|
85
|
+
sha256: str
|
|
86
|
+
path: str
|
|
87
|
+
for sha256, path in deleted_photos:
|
|
88
|
+
deletions_index[(sha256, path)] = {
|
|
89
|
+
"sha256": sha256,
|
|
90
|
+
"path": path,
|
|
91
|
+
"reason": "identical_group",
|
|
92
|
+
"group_id": group_id,
|
|
93
|
+
"timestamp": decision["timestamp"],
|
|
94
|
+
"user": decision["user"],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
elif decision_type == "sequences":
|
|
98
|
+
# Index sequence group decision
|
|
99
|
+
seq_group_id: str = decision["group_id"]
|
|
100
|
+
sequences_index[seq_group_id] = {
|
|
101
|
+
"group_id": seq_group_id,
|
|
102
|
+
"decision_type": "sequences",
|
|
103
|
+
"action": decision["action"],
|
|
104
|
+
"timestamp": decision["timestamp"],
|
|
105
|
+
"user": decision["user"],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Index deletions
|
|
109
|
+
seq_deleted_photos: list[tuple[str, str]] = decision.get("deleted_photos", [])
|
|
110
|
+
seq_sha256: str
|
|
111
|
+
seq_path: str
|
|
112
|
+
for seq_sha256, seq_path in seq_deleted_photos:
|
|
113
|
+
deletions_index[(seq_sha256, seq_path)] = {
|
|
114
|
+
"sha256": seq_sha256,
|
|
115
|
+
"path": seq_path,
|
|
116
|
+
"reason": "sequence_group",
|
|
117
|
+
"group_id": seq_group_id,
|
|
118
|
+
"timestamp": decision["timestamp"],
|
|
119
|
+
"user": decision["user"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
except json.JSONDecodeError:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Write CSV indices
|
|
126
|
+
_write_csv_index(
|
|
127
|
+
work_dir / "review_index_identical.csv",
|
|
128
|
+
identical_index.values(),
|
|
129
|
+
["group_id", "decision_type", "action", "timestamp", "user"],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
_write_csv_index(
|
|
133
|
+
work_dir / "review_index_sequences.csv",
|
|
134
|
+
sequences_index.values(),
|
|
135
|
+
["group_id", "decision_type", "action", "timestamp", "user"],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
_write_csv_index(
|
|
139
|
+
work_dir / "review_index_deletions.csv",
|
|
140
|
+
deletions_index.values(),
|
|
141
|
+
["sha256", "path", "reason", "group_id", "timestamp", "user"],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
"identical": identical_index,
|
|
146
|
+
"sequences": sequences_index,
|
|
147
|
+
"deletions": deletions_index,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _write_csv_index(path: Path, rows: list[dict[str, Any]] | Any, fieldnames: list[str]) -> None:
|
|
152
|
+
"""Write index data to CSV file.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
path: Output CSV file path
|
|
156
|
+
rows: Iterable of dictionaries to write
|
|
157
|
+
fieldnames: CSV column names
|
|
158
|
+
"""
|
|
159
|
+
with path.open("w", newline="", encoding="utf-8") as f:
|
|
160
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
161
|
+
writer.writeheader()
|
|
162
|
+
writer.writerows(rows)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Photo
|
|
1
|
+
/* Photo Stack Finder Orchestrator Styles */
|
|
2
2
|
|
|
3
3
|
:root {
|
|
4
4
|
--primary-color: #2563eb;
|
|
@@ -520,78 +520,78 @@ h2 {
|
|
|
520
520
|
max-height: 90vh;
|
|
521
521
|
}
|
|
522
522
|
}
|
|
523
|
-
|
|
524
|
-
/* Info Section (Getting Started) */
|
|
525
|
-
.info-section {
|
|
526
|
-
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
|
|
527
|
-
border-left: 4px solid var(--primary-color);
|
|
528
|
-
border-radius: 8px;
|
|
529
|
-
padding: 0;
|
|
530
|
-
margin-bottom: 2rem;
|
|
531
|
-
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
.info-section details summary {
|
|
535
|
-
padding: 1rem 1.5rem;
|
|
536
|
-
cursor: pointer;
|
|
537
|
-
user-select: none;
|
|
538
|
-
font-size: 1.1rem;
|
|
539
|
-
color: var(--primary-color);
|
|
540
|
-
list-style: none;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
.info-section details summary::-webkit-details-marker {
|
|
544
|
-
display: none;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
.info-section details[open] summary {
|
|
548
|
-
border-bottom: 1px solid var(--border-color);
|
|
549
|
-
margin-bottom: 1rem;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
.info-section .info-content {
|
|
553
|
-
padding: 0 1.5rem 1.5rem 1.5rem;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
.info-section h3 {
|
|
557
|
-
color: var(--text-primary);
|
|
558
|
-
margin-top: 1rem;
|
|
559
|
-
margin-bottom: 0.5rem;
|
|
560
|
-
font-size: 1.1rem;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
.info-section ul, .info-section ol {
|
|
564
|
-
margin-left: 1.5rem;
|
|
565
|
-
margin-bottom: 1rem;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
.info-section li {
|
|
569
|
-
margin-bottom: 0.5rem;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
.info-section .checklist {
|
|
573
|
-
list-style: none;
|
|
574
|
-
margin-left: 0;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
.info-section .checklist li {
|
|
578
|
-
padding-left: 1.5rem;
|
|
579
|
-
position: relative;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
.info-section code {
|
|
583
|
-
background: rgba(0, 0, 0, 0.05);
|
|
584
|
-
padding: 0.2rem 0.4rem;
|
|
585
|
-
border-radius: 3px;
|
|
586
|
-
font-family: 'Consolas', 'Monaco', monospace;
|
|
587
|
-
font-size: 0.9em;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
.info-section a {
|
|
591
|
-
color: var(--primary-color);
|
|
592
|
-
text-decoration: none;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
.info-section a:hover {
|
|
596
|
-
text-decoration: underline;
|
|
597
|
-
}
|
|
523
|
+
|
|
524
|
+
/* Info Section (Getting Started) */
|
|
525
|
+
.info-section {
|
|
526
|
+
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
|
|
527
|
+
border-left: 4px solid var(--primary-color);
|
|
528
|
+
border-radius: 8px;
|
|
529
|
+
padding: 0;
|
|
530
|
+
margin-bottom: 2rem;
|
|
531
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.info-section details summary {
|
|
535
|
+
padding: 1rem 1.5rem;
|
|
536
|
+
cursor: pointer;
|
|
537
|
+
user-select: none;
|
|
538
|
+
font-size: 1.1rem;
|
|
539
|
+
color: var(--primary-color);
|
|
540
|
+
list-style: none;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
.info-section details summary::-webkit-details-marker {
|
|
544
|
+
display: none;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
.info-section details[open] summary {
|
|
548
|
+
border-bottom: 1px solid var(--border-color);
|
|
549
|
+
margin-bottom: 1rem;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.info-section .info-content {
|
|
553
|
+
padding: 0 1.5rem 1.5rem 1.5rem;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.info-section h3 {
|
|
557
|
+
color: var(--text-primary);
|
|
558
|
+
margin-top: 1rem;
|
|
559
|
+
margin-bottom: 0.5rem;
|
|
560
|
+
font-size: 1.1rem;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.info-section ul, .info-section ol {
|
|
564
|
+
margin-left: 1.5rem;
|
|
565
|
+
margin-bottom: 1rem;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.info-section li {
|
|
569
|
+
margin-bottom: 0.5rem;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.info-section .checklist {
|
|
573
|
+
list-style: none;
|
|
574
|
+
margin-left: 0;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.info-section .checklist li {
|
|
578
|
+
padding-left: 1.5rem;
|
|
579
|
+
position: relative;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
.info-section code {
|
|
583
|
+
background: rgba(0, 0, 0, 0.05);
|
|
584
|
+
padding: 0.2rem 0.4rem;
|
|
585
|
+
border-radius: 3px;
|
|
586
|
+
font-family: 'Consolas', 'Monaco', monospace;
|
|
587
|
+
font-size: 0.9em;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.info-section a {
|
|
591
|
+
color: var(--primary-color);
|
|
592
|
+
text-decoration: none;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
.info-section a:hover {
|
|
596
|
+
text-decoration: underline;
|
|
597
|
+
}
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>Photo
|
|
6
|
+
<title>Photo Stack Finder Orchestrator</title>
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
|
8
8
|
<link rel="stylesheet" href="/static/orchestrator.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div class="container">
|
|
12
12
|
<header>
|
|
13
|
-
<h1>📸 Photo
|
|
13
|
+
<h1>📸 Photo Stack Finder Orchestrator</h1>
|
|
14
14
|
<p>Automated pipeline for finding and reviewing duplicate photos</p>
|
|
15
15
|
</header>
|
|
16
16
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
<div class="info-content">
|
|
23
23
|
<h3>What This Tool Does</h3>
|
|
24
24
|
<p>
|
|
25
|
-
Photo
|
|
25
|
+
Photo Stack Finder finds photos that originate from the <strong>same source image</strong>:
|
|
26
26
|
byte-identical files, different resolutions (low-res vs. high-res), edited versions vs. originals,
|
|
27
27
|
rotation variants, format conversions (JPEG vs. HEIC), and cloud sync duplicates like
|
|
28
28
|
<code>IMG_1234.jpg</code> and <code>IMG_1234(1).jpg</code>.
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
|
|
64
64
|
<p>
|
|
65
65
|
<strong>📖 Detailed Guide:</strong> See
|
|
66
|
-
<a href="https://github.com/gbarrett28/
|
|
66
|
+
<a href="https://github.com/gbarrett28/photo_stack_finder/blob/master/GETTING_STARTED.md" target="_blank" rel="noopener">
|
|
67
67
|
GETTING_STARTED.md
|
|
68
68
|
</a>
|
|
69
69
|
for step-by-step instructions including Google Takeout export.
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
placeholder="/path/to/your/photos">
|
|
85
85
|
<button type="button" id="browse-source" class="btn-browse">📁 Browse</button>
|
|
86
86
|
</div>
|
|
87
|
-
<small>Directory containing your photos to
|
|
87
|
+
<small>Directory containing your photos to analyze</small>
|
|
88
88
|
</div>
|
|
89
89
|
|
|
90
90
|
<div class="form-group">
|
|
@@ -120,6 +120,12 @@
|
|
|
120
120
|
Debug Mode (sequential processing)
|
|
121
121
|
</label>
|
|
122
122
|
</div>
|
|
123
|
+
<div class="form-group">
|
|
124
|
+
<label for="skip-byte-identical">
|
|
125
|
+
<input type="checkbox" id="skip-byte-identical" name="skip_byte_identical" checked>
|
|
126
|
+
Skip byte-identical detection (trust SHA256 uniqueness)
|
|
127
|
+
</label>
|
|
128
|
+
</div>
|
|
123
129
|
</div>
|
|
124
130
|
|
|
125
131
|
<div class="option-group">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Photo
|
|
1
|
+
// Photo Stack Finder Orchestrator Client-side Logic
|
|
2
2
|
|
|
3
3
|
// State
|
|
4
4
|
let currentBrowsePath = '';
|
|
@@ -313,6 +313,7 @@ function populateForm(config) {
|
|
|
313
313
|
document.getElementById('max-workers').value = config.max_workers || '';
|
|
314
314
|
document.getElementById('batch-size').value = config.batch_size || '';
|
|
315
315
|
document.getElementById('debug-mode').checked = config.debug_mode || false;
|
|
316
|
+
document.getElementById('skip-byte-identical').checked = config.skip_byte_identical !== false; // Default true
|
|
316
317
|
|
|
317
318
|
document.getElementById('comparison-method').value = config.comparison_method || 'SSIM';
|
|
318
319
|
document.getElementById('ssim-threshold').value = config.gate_thresholds?.SSIM || 0.95;
|
|
@@ -369,6 +370,7 @@ async function handleFormSubmit(event) {
|
|
|
369
370
|
max_workers: maxWorkers ? parseInt(maxWorkers) : null,
|
|
370
371
|
batch_size: batchSize ? parseInt(batchSize) : null,
|
|
371
372
|
debug_mode: formData.get('debug_mode') === 'on',
|
|
373
|
+
skip_byte_identical: formData.get('skip_byte_identical') === 'on',
|
|
372
374
|
comparison_method: formData.get('comparison_method'),
|
|
373
375
|
gate_thresholds: {
|
|
374
376
|
SSIM: parseFloat(formData.get('ssim_threshold')),
|
overlap_metrics/__init__.py
CHANGED
|
@@ -40,7 +40,7 @@ if version.parse(scipy.__version__) < version.parse(MINIMUM_SCIPY_VERSION):
|
|
|
40
40
|
|
|
41
41
|
# Version info
|
|
42
42
|
__version__ = "1.0.0"
|
|
43
|
-
__author__ = "Photo
|
|
43
|
+
__author__ = "Photo Stack Finder Team"
|
|
44
44
|
__description__ = "Distribution separation and overlap metrics with pluggable estimators"
|
|
45
45
|
|
|
46
46
|
# Default metric suite for compute_suite()
|