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.
Files changed (68) hide show
  1. orchestrator/__init__.py +2 -2
  2. orchestrator/app.py +6 -11
  3. orchestrator/build_pipeline.py +19 -21
  4. orchestrator/orchestrator_runner.py +11 -8
  5. orchestrator/pipeline_builder.py +126 -126
  6. orchestrator/pipeline_orchestrator.py +604 -604
  7. orchestrator/review_persistence.py +162 -162
  8. orchestrator/static/orchestrator.css +76 -76
  9. orchestrator/static/orchestrator.html +11 -5
  10. orchestrator/static/orchestrator.js +3 -1
  11. overlap_metrics/__init__.py +1 -1
  12. overlap_metrics/config.py +135 -135
  13. overlap_metrics/core.py +284 -284
  14. overlap_metrics/estimators.py +292 -292
  15. overlap_metrics/metrics.py +307 -307
  16. overlap_metrics/registry.py +99 -99
  17. overlap_metrics/utils.py +104 -104
  18. photo_compare/__init__.py +1 -1
  19. photo_compare/base.py +285 -285
  20. photo_compare/config.py +225 -225
  21. photo_compare/distance.py +15 -15
  22. photo_compare/feature_methods.py +173 -173
  23. photo_compare/file_hash.py +29 -29
  24. photo_compare/hash_methods.py +99 -99
  25. photo_compare/histogram_methods.py +118 -118
  26. photo_compare/pixel_methods.py +58 -58
  27. photo_compare/structural_methods.py +104 -104
  28. photo_compare/types.py +28 -28
  29. {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/METADATA +21 -22
  30. photo_stack_finder-0.1.8.dist-info/RECORD +75 -0
  31. scripts/orchestrate.py +12 -10
  32. utils/__init__.py +4 -3
  33. utils/base_pipeline_stage.py +171 -171
  34. utils/base_ports.py +176 -176
  35. utils/benchmark_utils.py +823 -823
  36. utils/channel.py +74 -74
  37. utils/comparison_gates.py +40 -21
  38. utils/compute_benchmarks.py +355 -355
  39. utils/compute_identical.py +94 -24
  40. utils/compute_indices.py +235 -235
  41. utils/compute_perceptual_hash.py +127 -127
  42. utils/compute_perceptual_match.py +240 -240
  43. utils/compute_sha_bins.py +64 -20
  44. utils/compute_template_similarity.py +1 -1
  45. utils/compute_versions.py +483 -483
  46. utils/config.py +8 -5
  47. utils/data_io.py +83 -83
  48. utils/graph_context.py +44 -44
  49. utils/logger.py +2 -2
  50. utils/models.py +2 -2
  51. utils/photo_file.py +90 -91
  52. utils/pipeline_graph.py +334 -334
  53. utils/pipeline_stage.py +408 -408
  54. utils/plot_helpers.py +123 -123
  55. utils/ports.py +136 -136
  56. utils/progress.py +415 -415
  57. utils/report_builder.py +139 -139
  58. utils/review_types.py +55 -55
  59. utils/review_utils.py +10 -19
  60. utils/sequence.py +10 -8
  61. utils/sequence_clustering.py +1 -1
  62. utils/template.py +57 -57
  63. utils/template_parsing.py +71 -0
  64. photo_stack_finder-0.1.7.dist-info/RECORD +0 -74
  65. {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/WHEEL +0 -0
  66. {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/entry_points.txt +0 -0
  67. {photo_stack_finder-0.1.7.dist-info → photo_stack_finder-0.1.8.dist-info}/licenses/LICENSE +0 -0
  68. {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 Dedup Orchestrator Styles */
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 Dedup Orchestrator</title>
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 Deduplication Orchestrator</h1>
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 Dedup finds photos that originate from the <strong>same source image</strong>:
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/photo_dedup/blob/master/GETTING_STARTED.md" target="_blank" rel="noopener">
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 deduplicate</small>
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 Dedup Orchestrator Client-side Logic
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')),
@@ -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 Deduplication Team"
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()