pixcull 2.43.4__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 (164) hide show
  1. pixcull/__init__.py +55 -0
  2. pixcull/__main__.py +4 -0
  3. pixcull/audio_sync.py +269 -0
  4. pixcull/cli.py +1377 -0
  5. pixcull/config.py +85 -0
  6. pixcull/db/__init__.py +0 -0
  7. pixcull/db/models.py +33 -0
  8. pixcull/detectors/__init__.py +3 -0
  9. pixcull/detectors/base.py +30 -0
  10. pixcull/detectors/blur.py +60 -0
  11. pixcull/detectors/canon.py +407 -0
  12. pixcull/detectors/composition.py +224 -0
  13. pixcull/detectors/duplicate.py +198 -0
  14. pixcull/detectors/exposure.py +32 -0
  15. pixcull/detectors/face.py +283 -0
  16. pixcull/detectors/scene.py +200 -0
  17. pixcull/detectors/subject.py +31 -0
  18. pixcull/detectors/wedding_moment.py +71 -0
  19. pixcull/error_reporting.py +264 -0
  20. pixcull/events.py +160 -0
  21. pixcull/i18n.py +175 -0
  22. pixcull/io/__init__.py +5 -0
  23. pixcull/io/exif.py +161 -0
  24. pixcull/io/exif_audit.py +170 -0
  25. pixcull/io/formats.py +8 -0
  26. pixcull/io/gpmf.py +513 -0
  27. pixcull/io/gps_map.py +127 -0
  28. pixcull/io/icc.py +205 -0
  29. pixcull/io/iptc_embed.py +166 -0
  30. pixcull/io/loader.py +272 -0
  31. pixcull/io/raw_proxy.py +132 -0
  32. pixcull/io/reel_assembly.py +577 -0
  33. pixcull/io/video.py +495 -0
  34. pixcull/io/xmp.py +457 -0
  35. pixcull/license/__init__.py +521 -0
  36. pixcull/llm_budget.py +213 -0
  37. pixcull/locale/ar_SA.json +194 -0
  38. pixcull/locale/de_DE.json +194 -0
  39. pixcull/locale/en_US.json +194 -0
  40. pixcull/locale/es_ES.json +194 -0
  41. pixcull/locale/fr_FR.json +194 -0
  42. pixcull/locale/it_IT.json +194 -0
  43. pixcull/locale/ja_JP.json +194 -0
  44. pixcull/locale/ko_KR.json +194 -0
  45. pixcull/locale/nl_NL.json +194 -0
  46. pixcull/locale/pt_BR.json +194 -0
  47. pixcull/locale/ru_RU.json +194 -0
  48. pixcull/locale/tr_TR.json +194 -0
  49. pixcull/locale/zh_CN.json +194 -0
  50. pixcull/models_manager.py +262 -0
  51. pixcull/phrase_generator.py +483 -0
  52. pixcull/pipeline/__init__.py +4 -0
  53. pixcull/pipeline/burst_peak.py +267 -0
  54. pixcull/pipeline/face_audit.py +199 -0
  55. pixcull/pipeline/face_clustering.py +528 -0
  56. pixcull/pipeline/face_library.py +261 -0
  57. pixcull/pipeline/location_clustering.py +189 -0
  58. pixcull/pipeline/orchestrator.py +614 -0
  59. pixcull/pipeline/parallel.py +261 -0
  60. pixcull/pipeline/worker.py +231 -0
  61. pixcull/plugins/__init__.py +422 -0
  62. pixcull/plugins/builtin/example_wildlife.py +39 -0
  63. pixcull/policy_tuner.py +432 -0
  64. pixcull/preferred_axes.py +146 -0
  65. pixcull/qrcode_svg.py +488 -0
  66. pixcull/report/__init__.py +13 -0
  67. pixcull/report/contact_sheet.py +278 -0
  68. pixcull/report/csv.py +15 -0
  69. pixcull/report/executive_pdf.py +946 -0
  70. pixcull/report/gallery.py +303 -0
  71. pixcull/report/serve_app.py +13014 -0
  72. pixcull/report/serve_util.py +139 -0
  73. pixcull/report/templates/pages/admin.html +661 -0
  74. pixcull/report/templates/pages/admin_perf.html +475 -0
  75. pixcull/report/templates/pages/disagreement.html +1 -0
  76. pixcull/report/templates/pages/first_run.html +210 -0
  77. pixcull/report/templates/pages/history.html +143 -0
  78. pixcull/report/templates/pages/library.html +205 -0
  79. pixcull/report/templates/pages/privacy.html +109 -0
  80. pixcull/report/templates/pages/tether.html +220 -0
  81. pixcull/report/templates/pages/upload.html +1715 -0
  82. pixcull/report/templates/pages/vertical_bulk.html +331 -0
  83. pixcull/report/templates/pages/verticals.html +1848 -0
  84. pixcull/report/templates/report.html.j2 +30 -0
  85. pixcull/report/templates/results.html +19224 -0
  86. pixcull/report/templates/timeline.html +91 -0
  87. pixcull/report/templates/video_review.html +517 -0
  88. pixcull/scoring/__init__.py +44 -0
  89. pixcull/scoring/aesthetic.py +55 -0
  90. pixcull/scoring/attribution.py +265 -0
  91. pixcull/scoring/audio_events.py +392 -0
  92. pixcull/scoring/audio_tagger.py +353 -0
  93. pixcull/scoring/axis_rescorer.py +264 -0
  94. pixcull/scoring/bias_audit.py +411 -0
  95. pixcull/scoring/burst_peak.py +494 -0
  96. pixcull/scoring/caption_gen.py +382 -0
  97. pixcull/scoring/color_grade.py +243 -0
  98. pixcull/scoring/composition_classifier.py +255 -0
  99. pixcull/scoring/counterfactual.py +201 -0
  100. pixcull/scoring/data/audio_tagger_thresholds.json +4 -0
  101. pixcull/scoring/decision.py +175 -0
  102. pixcull/scoring/dup_frames.py +113 -0
  103. pixcull/scoring/eval_metrics.py +296 -0
  104. pixcull/scoring/fusion.py +175 -0
  105. pixcull/scoring/genre_strategies.py +279 -0
  106. pixcull/scoring/hard_examples.py +187 -0
  107. pixcull/scoring/library_index.py +504 -0
  108. pixcull/scoring/meta_judge.py +458 -0
  109. pixcull/scoring/near_dup.py +175 -0
  110. pixcull/scoring/nl_explain.py +175 -0
  111. pixcull/scoring/personal_learn.py +199 -0
  112. pixcull/scoring/personalized.py +178 -0
  113. pixcull/scoring/photo_advice.py +1576 -0
  114. pixcull/scoring/photography_canon.py +355 -0
  115. pixcull/scoring/reel.py +666 -0
  116. pixcull/scoring/reel_caption.py +465 -0
  117. pixcull/scoring/rescorer.py +190 -0
  118. pixcull/scoring/rubric.py +271 -0
  119. pixcull/scoring/rubric_decompose.py +359 -0
  120. pixcull/scoring/scenes.py +156 -0
  121. pixcull/scoring/self_tune.py +292 -0
  122. pixcull/scoring/semantic_search.py +190 -0
  123. pixcull/scoring/shot_boundaries.py +123 -0
  124. pixcull/scoring/style_guide.py +355 -0
  125. pixcull/scoring/style_modes.py +300 -0
  126. pixcull/scoring/templates/__init__.py +0 -0
  127. pixcull/scoring/templates/scene_templates.yaml +197 -0
  128. pixcull/scoring/temporal.py +669 -0
  129. pixcull/scoring/transcribe.py +553 -0
  130. pixcull/scoring/video_quality.py +320 -0
  131. pixcull/scoring/vlm_judge.py +656 -0
  132. pixcull/scoring/vlm_vertical_prompts.py +133 -0
  133. pixcull/scoring/wedding_moments.py +261 -0
  134. pixcull/shortcuts.py +268 -0
  135. pixcull/shortlink.py +196 -0
  136. pixcull/style/__init__.py +51 -0
  137. pixcull/style/clip_clone.py +228 -0
  138. pixcull/style/clone.py +237 -0
  139. pixcull/sync/__init__.py +84 -0
  140. pixcull/sync/discovery.py +277 -0
  141. pixcull/sync/event.py +397 -0
  142. pixcull/sync/presence.py +219 -0
  143. pixcull/sync/push.py +217 -0
  144. pixcull/sync/webrtc.py +217 -0
  145. pixcull/sync.py +319 -0
  146. pixcull/team/__init__.py +38 -0
  147. pixcull/team/roles.py +111 -0
  148. pixcull/team/taste.py +162 -0
  149. pixcull/telemetry.py +175 -0
  150. pixcull/tether.py +369 -0
  151. pixcull/tether_stream.py +161 -0
  152. pixcull/unsplash.py +394 -0
  153. pixcull/users.py +354 -0
  154. pixcull/utils/__init__.py +0 -0
  155. pixcull/utils/image.py +15 -0
  156. pixcull/utils/logging.py +26 -0
  157. pixcull/utils/timing.py +21 -0
  158. pixcull/verticals.py +549 -0
  159. pixcull/workflow.py +326 -0
  160. pixcull-2.43.4.dist-info/METADATA +147 -0
  161. pixcull-2.43.4.dist-info/RECORD +164 -0
  162. pixcull-2.43.4.dist-info/WHEEL +4 -0
  163. pixcull-2.43.4.dist-info/entry_points.txt +2 -0
  164. pixcull-2.43.4.dist-info/licenses/LICENSE +21 -0
pixcull/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """PixCull — AI photo culling & scoring."""
2
+
3
+ import sys
4
+
5
+ # v2.19 — single-source the version from package metadata (pyproject);
6
+ # the literal is only the fallback for running from a raw source tree.
7
+ try:
8
+ from importlib.metadata import version as _pkg_version
9
+ __version__ = _pkg_version("pixcull")
10
+ except Exception:
11
+ __version__ = "2.43.4"
12
+
13
+
14
+ def _check_numpy_compatibility() -> None:
15
+ """ROADMAP INFRA-5 — runtime guard against the numpy 2.x regression.
16
+
17
+ We've been bitten twice (V18.1 and V22.0.1) when a transitive
18
+ ``pip install`` upgraded numpy to 2.x. The symptoms are subtle:
19
+ mediapipe imports cleanly but its face detector silently returns
20
+ empty results, and pre-V18.3 rescorer joblibs fail to unpickle
21
+ because the ``numpy.random._pcg64.PCG64`` paths differ between
22
+ 1.x and 2.x. Neither failure is loud — face_count just stays 0
23
+ on all images, the rescorer silently falls back to rule-only.
24
+
25
+ Pin in pyproject.toml is ``numpy>=1.26,<2``, but a third-party
26
+ install command (``pip install some-other-package``) can still
27
+ blow past the pin. We don't fail hard here — that would break
28
+ perfectly fine pipelines that don't touch faces — but we DO
29
+ print a loud warning on every import so the regression is
30
+ visible at the top of the log.
31
+ """
32
+ try:
33
+ import numpy
34
+ except ImportError:
35
+ return # numpy missing is a different problem; let downstream report
36
+ ver = getattr(numpy, "__version__", "")
37
+ try:
38
+ major = int(ver.split(".")[0])
39
+ except (ValueError, IndexError):
40
+ return
41
+ if major >= 2:
42
+ print(
43
+ "\n"
44
+ "⚠ PixCull: numpy " + ver + " detected.\n"
45
+ " mediapipe (face detector) and the V18.3 rescorer joblibs\n"
46
+ " need numpy 1.x. Run:\n"
47
+ " pip install 'numpy<2'\n"
48
+ " to fix. Without this, faces won't be detected (face_count\n"
49
+ " will be 0 on every photo) and the rescorer falls back to\n"
50
+ " rule-only mode.\n",
51
+ file=sys.stderr,
52
+ )
53
+
54
+
55
+ _check_numpy_compatibility()
pixcull/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pixcull.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
pixcull/audio_sync.py ADDED
@@ -0,0 +1,269 @@
1
+ """v0.10-P1-4 — audio-photo sync for the wedding tether flow.
2
+
3
+ Experimental. Wedding-day shooters often want photos that
4
+ correspond to specific ceremony moments (vows / ring exchange /
5
+ first kiss / cake cutting) auto-flagged with the right
6
+ ``wedding_moment`` so the post-shoot delivery groups them
7
+ cleanly. The hard part is *detecting* the moment in real time —
8
+ we offload that to a client (iOS PixCullCompanion with
9
+ WhisperKit, or an external CLI hooked to the venue's PA system)
10
+ which sends us transcripts.
11
+
12
+ Wire model
13
+ ==========
14
+ Client posts transcript chunks via:
15
+
16
+ POST /api/v1/runs/<run_id>/audio_sync
17
+ body: {
18
+ "transcripts": [
19
+ {"ts_ms": 1700000000000, "text": "I now pronounce you...",
20
+ "confidence": 0.92},
21
+ ...
22
+ ]
23
+ }
24
+
25
+ We:
26
+
27
+ 1. Run keyword matching against a built-in vocabulary
28
+ (CEREMONY_KEYWORDS below). Returns the matched moments
29
+ each transcript hit.
30
+ 2. Correlate transcript ts_ms ± window_s with photo capture
31
+ times (read from scores.csv's mtime column).
32
+ 3. Boost the wedding_moment field on matched rows.
33
+
34
+ The keyword vocabulary is intentionally conservative — we want
35
+ high precision (false moment tags would mislead the delivery)
36
+ at the cost of recall. A photographer who wants more moments
37
+ matched can extend CEREMONY_KEYWORDS in their own deployment.
38
+
39
+ Opt-in only. PIXCULL_AUDIO_SYNC=1 env var required to enable
40
+ the route; otherwise the POST returns 403. Privacy guarantee:
41
+ audio bytes are NEVER sent to the server — only the transcript
42
+ text + timestamps. WhisperKit on iOS does all the STT locally.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import os
48
+ import re
49
+ from typing import Iterable
50
+
51
+
52
+ # Keyword → wedding_moment vocabulary. Lowercased ASCII keys
53
+ # only — clients normalise transcripts to ASCII before sending.
54
+ # Format: phrase → moment_id. ``moment_id`` matches the
55
+ # canonical wedding_moment vocabulary used by
56
+ # pixcull.scoring.wedding_moment.WEDDING_MOMENTS.
57
+ #
58
+ # Conservative — only phrases that are extremely unlikely to
59
+ # appear outside the corresponding moment. "ring" alone is
60
+ # excluded (it shows up in too many other contexts). "the
61
+ # rings" / "exchange rings" are included because those phrasings
62
+ # are strongly ceremony-bound.
63
+ CEREMONY_KEYWORDS: dict[str, str] = {
64
+ # Vows
65
+ "i, take you to be my": "vows",
66
+ "to have and to hold": "vows",
67
+ "from this day forward": "vows",
68
+ "for better or for worse": "vows",
69
+ "till death do us part": "vows",
70
+ "vows": "vows",
71
+ "我愿意": "vows", # Chinese ceremony
72
+
73
+ # Ring exchange
74
+ "exchange rings": "ring_exchange",
75
+ "with this ring": "ring_exchange",
76
+ "place this ring": "ring_exchange",
77
+ "the rings": "ring_exchange",
78
+ "交换戒指": "ring_exchange",
79
+
80
+ # First kiss
81
+ "you may now kiss": "kiss",
82
+ "you may kiss the bride": "kiss",
83
+ "kiss the bride": "kiss",
84
+ "kiss the partner": "kiss",
85
+ "first kiss as": "kiss",
86
+
87
+ # Pronouncement
88
+ "i now pronounce you": "pronouncement",
89
+ "by the power vested in me": "pronouncement",
90
+ "husband and wife": "pronouncement",
91
+ "wife and wife": "pronouncement",
92
+ "husband and husband": "pronouncement",
93
+
94
+ # First dance
95
+ "first dance": "first_dance",
96
+ "their first dance": "first_dance",
97
+ "请新郎新娘跳第一支舞": "first_dance",
98
+
99
+ # Cake cutting
100
+ "cake cutting": "cake_cutting",
101
+ "cut the cake": "cake_cutting",
102
+ "cutting the cake": "cake_cutting",
103
+ "切蛋糕": "cake_cutting",
104
+
105
+ # Toasts
106
+ "to the bride and groom": "toast",
107
+ "raise a glass": "toast",
108
+ "raise our glasses": "toast",
109
+ "举杯": "toast",
110
+
111
+ # Chinese-tradition moments
112
+ "敬茶": "tea_ceremony",
113
+ "tea ceremony": "tea_ceremony",
114
+ "三鞠躬": "bow",
115
+ "跪拜": "bow",
116
+ }
117
+
118
+
119
+ # Default time window: when a transcript matches at ts, photos
120
+ # whose mtime falls within ±window_s get the wedding_moment
121
+ # boost. 90 s is a Goldilocks default — large enough to cover
122
+ # "celebrant says 'I now pronounce you...' then 60s of kiss +
123
+ # applause" but small enough that consecutive ceremony moments
124
+ # (vows then ring then pronouncement) don't all get the same
125
+ # tag. Override via the POST body's `window_s` field.
126
+ DEFAULT_WINDOW_S = 90.0
127
+
128
+
129
+ def is_enabled() -> bool:
130
+ """v0.10-P1-4 is opt-in to keep privacy posture conservative.
131
+
132
+ Photographers who want it set PIXCULL_AUDIO_SYNC=1 in their
133
+ shell env; the server route returns 403 otherwise so a
134
+ runaway client doesn't accidentally boost wedding moments
135
+ on a run that should have stayed scene-only.
136
+ """
137
+ return os.environ.get("PIXCULL_AUDIO_SYNC", "").strip() in ("1", "true", "yes")
138
+
139
+
140
+ def match_transcript(text: str) -> str | None:
141
+ """Return the wedding_moment matched by this transcript, or None.
142
+
143
+ Lowercases the input before matching so the vocab keys can
144
+ stay lowercase. Uses substring match (not regex) for both
145
+ speed and to keep the vocab obvious to a human auditor.
146
+ Matches the FIRST keyword found scanning the vocab in
147
+ insertion order — most-specific phrases first by convention.
148
+ """
149
+ if not text:
150
+ return None
151
+ t = text.lower()
152
+ for kw, moment in CEREMONY_KEYWORDS.items():
153
+ if kw in t:
154
+ return moment
155
+ return None
156
+
157
+
158
+ def match_transcripts(
159
+ transcripts: Iterable[dict],
160
+ *,
161
+ min_confidence: float = 0.6,
162
+ ) -> list[dict]:
163
+ """Apply match_transcript to a batch, with confidence gate.
164
+
165
+ Returns the SUBSET of transcripts that matched, each enriched
166
+ with the inferred ``moment``. Drops anything below
167
+ min_confidence (defensive against noisy STT environments;
168
+ weddings are loud). Order is preserved from the input.
169
+ """
170
+ out = []
171
+ for t in transcripts:
172
+ if not isinstance(t, dict):
173
+ continue
174
+ try:
175
+ ts_ms = int(t.get("ts_ms") or 0)
176
+ except (TypeError, ValueError):
177
+ continue
178
+ try:
179
+ conf = float(t.get("confidence") or 0)
180
+ except (TypeError, ValueError):
181
+ conf = 0.0
182
+ text = str(t.get("text") or "")
183
+ if conf < min_confidence:
184
+ continue
185
+ moment = match_transcript(text)
186
+ if moment is None:
187
+ continue
188
+ out.append({
189
+ "ts_ms": ts_ms,
190
+ "text": text,
191
+ "moment": moment,
192
+ "confidence": conf,
193
+ })
194
+ return out
195
+
196
+
197
+ def correlate_with_rows(
198
+ matches: list[dict],
199
+ rows: list[dict],
200
+ *,
201
+ window_s: float = DEFAULT_WINDOW_S,
202
+ ) -> dict[str, str]:
203
+ """Assign wedding_moment to photo rows whose mtime falls
204
+ within ±window_s of any transcript match.
205
+
206
+ Returns a {filename: moment} dict — caller persists. Rows
207
+ that fall inside multiple moments' windows take the LATEST
208
+ match (the transcript chronologically closest "before" the
209
+ photo wins, because the photographer typically captures
210
+ AFTER the celebrant says the words).
211
+
212
+ Defensive against rows missing mtime: those are skipped
213
+ silently.
214
+ """
215
+ if not matches or not rows:
216
+ return {}
217
+ window_ms = window_s * 1000.0
218
+ sorted_matches = sorted(matches, key=lambda m: m["ts_ms"])
219
+ out: dict[str, str] = {}
220
+ for row in rows:
221
+ if not isinstance(row, dict):
222
+ continue
223
+ fn = row.get("filename")
224
+ if not isinstance(fn, str) or not fn:
225
+ continue
226
+ try:
227
+ # rows store seconds-since-epoch; transcripts ms.
228
+ mtime_ms = float(row.get("mtime") or 0) * 1000.0
229
+ except (TypeError, ValueError):
230
+ continue
231
+ if mtime_ms <= 0:
232
+ continue
233
+ # Walk matches from latest-before to earliest-before so
234
+ # the first valid window wins.
235
+ best: str | None = None
236
+ for m in reversed(sorted_matches):
237
+ if m["ts_ms"] - window_ms <= mtime_ms <= m["ts_ms"] + window_ms:
238
+ best = m["moment"]
239
+ break
240
+ if best is not None:
241
+ out[fn] = best
242
+ return out
243
+
244
+
245
+ def apply_audio_sync(
246
+ transcripts: Iterable[dict],
247
+ rows: list[dict],
248
+ *,
249
+ window_s: float = DEFAULT_WINDOW_S,
250
+ min_confidence: float = 0.6,
251
+ ) -> dict:
252
+ """Top-level — what the HTTP handler calls.
253
+
254
+ Returns the summary the client renders + the suggested
255
+ wedding_moment overrides. The actual write-back to
256
+ scores.csv / annotations.jsonl is the caller's job (the
257
+ server-side handler decides whether to persist or just
258
+ preview).
259
+ """
260
+ matches = match_transcripts(transcripts, min_confidence=min_confidence)
261
+ suggestions = correlate_with_rows(matches, rows, window_s=window_s)
262
+ return {
263
+ "n_transcripts": len(list(transcripts) if not isinstance(transcripts, list) else transcripts),
264
+ "n_matched": len(matches),
265
+ "matches": matches,
266
+ "n_suggestions": len(suggestions),
267
+ "suggestions": suggestions,
268
+ "window_s": window_s,
269
+ }