Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

427 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PixCull · Local-first AI photo culling for working photographers
PixCull hero reveal — workspace bar slides in, Library sidebar slides in, 24 photo cards stagger fade-up, keep/maybe/cull stats count from 0 to final

tests MIT License Python Platform Local-first stars latest release

English · 简体中文 · 日本語 (in-app) · ModelScope · Releases

Local-first AI culling for working photographers.
Six calibrated axes · style clone (CLIP) · LAN multi-shooter sync ·
tethered live scoring · client share links + QR · Lr/C1 round-trip.
No photo ever leaves your disk.

v0.9 is in flight. Hero reveal · signature soft-bounce motion · brand identity refresh · ⌘K command palette already shipped (4 / 13). Full charter at docs/ROADMAP-v0.9-charter.md.


What's new

v2.45the video chain is in the end-to-end smoke. Only four of seventeen CLI commands had a journey test, and the whole video block added across v2.42–v2.44.3 had none — the same "advertised but unreachable" gap the 2031Q1 audit named, this time opened by the work that closed the audit's own recommendations. Four tests now drive video → cut --render, asserting the render is shorter than the source (equal length means the edit was ignored and the clip merely re-encoded) and that an edit keeping nothing is refused rather than emitting a zero-length file. Both mutations reproduce the historical symptoms exactly. CI installs ffmpeg, without which the four would skip and report green having tested nothing.

v2.44.3speaker diarization, verified and stopped from lying. The adapter's sentence_info/spk branch had existed for three versions and never run. It does produce labels — but every segment came back as speaker 0, and no amount of acoustically distinct material or explicit speaker counts changed that. FunASR's clusterer hard-returns "everybody is speaker 0" under 20 embeddings, before looking at them; a 59s dialogue clears the bar and separates two voices with 18/18 lines correct. Since its output is identical whether one person spoke or the clip was too short to tell, PixCull now reports speaker=None in both cases rather than asserting a finding the model never made.

v2.44.2one-click render: pixcull cut --render, and an 出片 button in the review page. Deliberately a hard cut, not the reel's half-second dissolve — the kept spans are usually two halves of one sentence, and a dissolve there eats the words you chose to keep. Verified by feeding the rendered mp4 back through ASR: the deleted words are gone from the audio and the kept ones survive.

v2.44.1edit by text in the browser (screenshot below). Strike a line or select words inside one; the video goes with them. Word selection is offered only when the engine reported per-character times, because inventing them puts cuts on the wrong frames. Also fixed 32 CSS variable references in video_review.html that had never resolved — that page carries its own palette and the v2.43 transcript panel had been styled with the shared design tokens, so hover backgrounds silently did nothing.

v2.44a Mandarin lexicon for shoot jargon, and the edit-by-text state model. Generic ASR hears 掌交 for 长焦 and 被选 for 备选, and one wrong character ruins a subtitle line. An 88-term lexicon chosen from domain knowledge — not from the errors of the evaluation set — cuts held-out CER from 2.59% to 1.11%, a 57% error reduction, on ten sentences it had never seen. pixcull cut layers deletions over an immutable transcript, so undo cannot leave the text disagreeing with the timeline.

v2.43.4every published wheel contained no Python code. The sdist allowlist had no *.py pattern and python -m build builds the wheel from the sdist, so nine releases shipped 35 data files and nothing else; the smoke test passed because it ran import pixcull from the checkout root and picked up the source tree. Nothing was ever installed from PyPI, so only direct Release downloads were affected. tests/test_packaging.py now builds both artifacts and looks inside them.

v2.43transcription (pixcull transcribe): Paraformer or Whisper behind an extra, transcript.json + an SRT sidecar, and click-a-line-to-seek in the video review page.

v2.42shot-boundary detection (pixcull[shots], PySceneDetect, BSD-3), so a reel candidate no longer spans a hard cut.

v2.41the end-to-end smoke test the 2031Q1 audit put first: one journey through run → serve → export, driving the real CLI and a real HTTP server. All four of the audit's cited outages replay as red against it.

v2.40.2dead stubs removed, plus a fresh design audit (docs/DESIGN-AUDIT-2031Q1.md). v2.40's guard only looked for typer.Exit, so three V0.3-era NotImplementedError stubs survived — including pixcull.report.export_html, which sat in __all__ as public API guaranteed to crash. All three were deleted rather than implemented: each had long been overtaken by something real (the HTML report is the review workspace; scripts/bench.py is now pixcull bench). The guard now covers both shapes. The audit scores 3.9/5 (2030Q4: 3.4) and names the pattern behind this repo's most common defect: advertised but unreachable — cross-run search that indexed nothing, video runs that wouldn't load, light theme that never applied, an export command that exited silently. All four were live features with one real user path that couldn't reach them, and all four would have been caught by a single end-to-end smoke test, which is now the top engineering recommendation. It also surveys the AI-editing OSS landscape and gates it on licence first: OpenChatCut is AGPL-3.0 and therefore cannot be vendored into an MIT project, while FunClip (MIT) and PySceneDetect (BSD-3) can — and they fill verified gaps, since SceneDetector is CLIP scene classification rather than shot-cut detection, and there is no ASR anywhere in the codebase.

v2.40.1the last linear cost in library indexing, solved without adding a file (see docs/ROADMAP-v2.40.1-charter.md). v2.39 deferred this as "needs a key-index file, not worth it". Profiling showed that framing was wrong: on a 300,000-row manifest the cost wasn't a missing index, it was json.loads — 0.525s of 0.720s — parsing rows that could never match. The dedup key starts with run_id and append_run handles one run at a time, so rows from other runs are irrelevant by construction; a raw substring pre-filter (0.013s for all 300k lines) keeps them out of the parser. Appending a 2,000-photo shoot: 0.159s → 0.062s at 50k, 0.697s → 0.068s at 300k, and flat in library size instead of linear — ~48x faster than v2.38 at that scale. The pre-filter is deliberately a superset test, with run_id re-checked after parsing, so a filename that happens to contain the needle costs one wasted parse and never a wrong answer — there's a test that plants exactly such a filename. The degenerate case (re-indexing a run that is the whole library) still costs 0.708s and is documented rather than glossed over.

v2.40the gate stops lying, and the CLI does what the box says (see docs/ROADMAP-v2.40-charter.md). Real-model tests used to guard themselves with a blanket except Exception: skip, which conflates "the model isn't on this machine" with "the model is right here and failed to load". During the v2.39 gate that let Hub rate-limiting silently stop three tests — including the one pinning v2.34's image_embeds ≡ get_image_features, the sole guard on every cached vector the pipeline writes. Now: if the weights are on disk the test runs, loaded with HF_HUB_OFFLINE=1 so the network can't turn a cached model into a failure, and anything after that is a failure rather than a skip. Following the same question — what does green actually cover? — a new guard asserting no command is a silent raise typer.Exit(1) found two that had been stubs since V0.5. pixcull export was one of them: no output, exit 1, while the package blurb advertised "XMP/IPTC export, Lightroom & Capture One ready". Export existed, but only inside the web workspace, so the CLI path v2.31 opened for pip users dead-ended. It now runs the same code the server does — verified by writing sidecars for real JPEGs and reading them back: keep→5★/Green, maybe→3★/Yellow, cull→1★/Red all round-trip. pixcull bench was the other, now reporting real throughput translated into shoot sizes — and running it immediately showed it was filing its throwaway scratch sample into the user's cross-run library, where every row would become a permanently stale hit.

v2.39switch the theme where you are, and stop rewriting the whole library on every cull (see docs/ROADMAP-v2.39-charter.md). v2.35 made the standalone pages obey the theme; they still had no way to change it, so opening /library directly meant going back to the review workspace to switch. The control is injected once in _read_template as a fixed-position pill rather than wired into each page's header — three of them have no header, and per-page wiring is exactly how ten of eleven pages missed the boot script before. It mirrors the workspace toggle's contract exactly (dark → light → system, same storage key), with a test pinning the two implementations together. One deliberate exception: /share/<run>/<token> still obeys the theme but carries no app chrome — that page is the photographer's delivery to a client, not the application. Second half: append_run used to np.vstack the entire index and rewrite vectors.npy, which measured 0.37s → 1.08s → 3.30s as the library grew 50k → 150k → 300k photos — and since v2.34 auto-index runs that after every cull, i.e. a 614 MB rewrite each time. Vectors now live in a headerless float32 file appended in O(new); the row count lives in meta.json, which is also what makes it crash-safe (bytes and manifest are fsynced first, meta is swapped in last, so a torn tail is inert rather than corrupt). Same three sizes: 0.159s / 0.380s / 0.697s. What remains linear is the manifest dedup scan (0.556s of that 0.697s), not the vector write (0.005s) — documented rather than papered over as O(1).

v2.38contrast audit of the light theme I'd just rolled out (see docs/ROADMAP-v2.38-charter.md). v2.35 and v2.37 brought light theme to a dozen surfaces that had never rendered light before, and nobody had checked what that did to legibility. Measured against the real tokens: the palette is healthy — every text tier clears WCAG AA on both themes — with one exception. --muted-soft was 3.01:1 in light and 3.65:1 in dark while colouring 9.5–11px text (timestamps, hints, input placeholders, hotkey labels); WCAG's "large text" allowance starts at 18.66px bold, so that text needed the full 4.5:1. Solving for a compliant value showed there's no room for a tier softer than --muted that still passes on deeper surfaces, so this became a design decision rather than a colour tweak: --muted-soft is now decoration-only (dividers, chevrons, dots, borders, SVG strokes — the 3:1 graphical bar) and all 16 text uses moved to --muted. The static token analysis nearly missed the point: the review workspace derives its surfaces via OKLCH, not hex, so the same check was re-run in a real browser by rasterising each colour to a canvas — the two agree, but only measurement could say so. This release also reverts an optimisation of its own: batching the per-row XMP sidecar probe into one scandir was 3–5% slower in both tested shapes, so it was dropped rather than kept on an untested "it'd help on network storage" rationale.

v2.37big shoots open 3x faster, and the page your client sees finally looks like this product (see docs/ROADMAP-v2.37-charter.md). v2.33's cache only helped from the second request on; the first open of a 20,000-photo shoot still took 8.5s. Profiling found the loop touching ~58 cells per row, and against a pandas Series every one is an Index.get_loc hash lookup — 1.22M Series.get() calls, 43% of the build, versus 9% doing the actual work. Switching to df.to_dict("records") took the build 6.59s → 1.66s and the cold page 8.55s → 2.85s. Because iterrows() silently upcasts int columns to float and to_dict doesn't, the bar for that change was a field-by-field diff of all 20,000 rows × 52 fields, not a stopwatch — identical. Separately, /share/<run>/<token> — the gallery a photographer sends to their client — turned out to be running on a third, unrelated palette (#0a0a1e/#1a1230, purple-navy) with color-scheme: dark hard-locked, so it could never follow the theme and looked like different software; the bias-audit and companion windows were still on the pre-v2.21 gold. All three now use the shared tokens. Finding the last of it needed a real browser: the sticky brand bar was rgba(10,10,30,0.85), invisible to a hex grep — the same failure mode as the v2.3.1 palette leak — so the new lint checks both notations.

v2.36real video footage on stage, and a bug it flushed out (see docs/ROADMAP-v2.36-charter.md). The task was just to close an owner action: publishable video footage. The owner authorised their own GoPro clips, and every frame was vetted before publishing — GPMF carries no GPS samples, 38 sampled frames through PixCull's own FaceDetector flagged 14 which visual review confirmed were all false positives (fur trim and knit patterning; the subject faces away throughout), a wedding-motorcade clip with plates and bystanders was dropped, and the working copy is re-encoded with -map_metadata -1 under a neutral name so no drive name or original path can appear on screen. Worth swapping because the old shots used a stock clip whose reel-candidate thumbnails had to be blurred to mush — hiding the very thing that panel exists to show. Shooting it flushed out a real bug: /timeline rendered 50 broken images with 50 /thumb 404s, because _reload_run_from_disk demanded either a manifest.json or an input/ dir and pixcull video produces neither (its frames live in video_frames/) — so a video run never reloaded at all. Compounded by _resolve_image_source also ignoring scores.csv's path column, the same root cause as v2.34's. Now scores.csv is the marker of "this is a run", both output layouts are accepted, and the path map is mtime-cached because that resolver runs once per thumbnail. Re-verified: thumbnails 50/50, zero 4xx.

v2.35near-dup grouping stops recomputing itself, and light theme finally works everywhere (see docs/ROADMAP-v2.35-charter.md). Both items came off the roadmap with a plan attached, and measurement killed both plans. The near-dup plan said "prune by time window" — wrong: that grouping exists precisely to catch near-duplicates cluster_bursts missed because they aren't time-adjacent, and the real cost wasn't the pipeline but a per-request handler with a user-adjustable threshold, so every nudge of the slider re-paid the whole O(N²). Profiling found the surprise: np.nonzero cost nearly as much as the matmul (1.15s vs 1.35s at 20k) because it walks all N² booleans, while the Python union loop everyone suspects was 0.01s. Computing only the upper triangle — the old code did every pair twice and threw half away after paying — gives 2.69s → 1.50s at 20k and 14.7s → 7.7s at 50k with provably identical grouping, and caching the result on the vector file's mtime makes repeat requests free. For theming, a real-browser sweep found the problem was bigger than "nobody sets data-theme": five pages never injected the shared tokens at all, because their module constants are built above where _DESIGN_TOKENS_CSS was defined — so the replace would have raised NameError and was simply never written, leaving them on a hand-rolled pre-v2.21 palette where the light-theme rule didn't exist. Both injections now happen once inside _read_template, verified across 9 routes × 2 preferences.

v2.34cross-run search works out of the box now (see docs/ROADMAP-v2.34-charter.md). The task was "auto-index a shoot after culling", but the investigation found v2.32's library was empty for real CLI users no matter what they ran: the pipeline never wrote embeddings.npz (it was lazily built only if you'd already searched that shoot), and the path resolver consulted manifest.json and an input/ dir but never scores.csv's own path column — the one source a plain pixcull run actually produces. So pixcull library index reported "nothing resolvable" and indexed zero photos. Both fixed, and the first fix turned out to be free: scene detection already runs the full CLIP forward on every photo, and that pass must project and L2-normalize the image tower before it can form logits_per_image — so out.image_embeds is the 512-d vector semantic search was re-encoding the whole shoot to obtain (verified cosine 1.000000 against get_image_features, pinned by a real-model test). The pipeline now persists those vectors at zero extra inference cost, which also means a shoot's first semantic query no longer re-encodes it. Culling then files the shoot into the library automatically — on by default, because searching everything is the point of the page; the index is local-only in ~/.pixcull/library/, never synced, and PIXCULL_NO_AUTO_INDEX=1 turns it off.

v2.33big shoots stop re-rendering themselves (see docs/ROADMAP-v2.33-charter.md). This one started by disproving its own roadmap: the design audit predicted the DOM was the bottleneck on huge runs, but a 20,000-row measurement found only 700 placeholder nodes / 7,362 total / 30 MB heap — v2.18 chunked hydration already bounds it. The real cost was on the server: _build_results() re-parsed the entire CSV, re-merged annotations and re-derived all six rubric axes on every request, and one page load calls it 5+ times. Caching it on the mtimes of its only two inputs took the page from 6.2s → 0.055s and each hydration chunk from 6.3s → 0.045s (~140x); cold start is unchanged because the CSV has to be parsed once. Invalidation piggybacks on the discipline _JSONL_CACHE already uses — a re-score or a saved annotation changes an mtime, so writers never need to know the cache exists. The care went into correctness, not speed: the cached rows are handed back by reference, so all 18 call sites and every helper they forward rows into were AST-audited as read-only (that contract is now written above the function), and the nine regression tests were mutation-verified — blanking the annotation mtime out of the key turns exactly the two invalidation tests red.

v2.32cross-run library search: ask your whole archive at once (see docs/ROADMAP-v2.32-charter.md). Per-run semantic search answered "in THIS shoot, where is the backlit shot"; the new /library page and pixcull library commands answer it across every shoot you've ever culled. Architecture is measurement-driven (the eval): no ANN index — brute-force is 8ms at 100k photos and 34.5ms at 1M, still ~2x the 17.4ms CLIP text encoding every query already pays, and memory (not speed) is the wall that arrives first, so compression is the eventual optimisation and pure numpy keeps pip install free of compiled deps. One merged vectors.npy opened via mmap beats stacking per-run caches 18ms vs 174ms. Indexing reuses each run's existing embeddings.npz — a copy, not a re-encode — keyed on (run_id, filename, mtime) so it's idempotent and a re-scored photo re-indexes by itself. Liveness is first-class: a hit whose file is gone comes back flagged stale, never silently dropped — when an external drive is unplugged, "found it, but it's not reachable" is the honest answer. Results group by shoot with a jump back into the run. The index stores real absolute paths, so it lives only in ~/.pixcull/library/.

v2.29the frosted-glass system lands (adopt-scoped, as the audit ruled) (see docs/ROADMAP-v2.29-charter.md). The ~30 ad-hoc backdrop-filter uses scattered through the UI (blur 2/4/6/8/10/12/20px + saturate 140/180% all hand-written) converge into one tokenized material: --glass-filter (blur 16px · saturate 130% — pulled back from the neon-vibrating 180%, the same retreat Apple made with Liquid Glass in 2026) for frosted panels, --glass-scrim-filter (blur 4px) for modal backdrops, and --glass-edge — the 1px top highlight that makes chrome read as real glass (8% white in dark, 65% in light where a faint highlight is invisible). Seven panels + seven scrims converged; three of the panels (compare header, RGB readout, key cheat-sheet) had opaque backgrounds that made their old blur a silent no-op — now translucent chrome films so the glass is real. Three photo-top micro-glass sites stay deliberately untokenized (reviewed keeps). The whole system honors @media (prefers-reduced-transparency: reduce) — every glass surface falls back to solid chrome (the codebase previously had zero such fallback), and a new lint fails any raw backdrop-filter: outside the keeps so the material can't fragment again. Photo surround and thumbnail mat: untouched — Studio-Neutral color-judgment discipline holds.

DESIGN-AUDIT 2030Q4post v2.21–v2.28 recheck + a frosted-glass direction verdict (see docs/DESIGN-AUDIT-2030Q4.md). Overall 3.4/5 (Q3: 3.1) — every dimension moved up (UX 3.9, intelligence 3.6, reach+release 2.4→2.9, architecture+perf 3.2→3.6), with reach still the laggard behind owner-only actions. The audit answers the frosted-glass (毛玻璃) question with an adopt-scoped verdict: the UI already carries ~30 ad-hoc backdrop-filter uses with inconsistent values and zero prefers-reduced-transparency fallback, so the move is to systematize glass into a tokenized, accessible layer on chrome / panels / overlays / modals — never on the photo surround or thumbnail mat (that would break the v2.21 Studio-Neutral color-judgment discipline; even Apple pulled back Liquid Glass's transparency in 2026 for legibility). Ranked v2.29 candidates: frosted-glass system · results.js modularization · packaged pixcull serve · near-dup CLIP collapse.

v2.28serve_demo inline-HTML extraction (the v2.27 deferral, done right) (see docs/ROADMAP-v2.28-charter.md). v2.27 assessed and deferred this; here it lands behind a byte-identical route-diff safety net — capture each route's rendered bytes before, extract, restart, capture after, diff must be empty. Three cleanly static-shell-shaped handlers move out to templates/pages/*.html: _serve_tether_page (fully static, 219 lines), _serve_history_page and _serve_disagreement_page (static shell + a few placeholder injections). Templates are generated mechanically via source-block eval (dynamic operands swapped for placeholder literals, then evaluated — zero hand-transcription). serve_demo.py drops 12,909 → 12,518 lines; all three routes verified byte-identical after extraction. The other three handlers (_render_share_html, _serve_bias_audit_page, _serve_companion_page) stay inline on purpose — they're heavily f-string-interleaved dynamic builders (up to ~40 interpolations) whose template extraction would hurt readability, and whose dynamic paths (e.g. bias's inline annotator-chip generator) can't be byte-verified from the empty-state route alone. The rationale now lives in CLAUDE.md so it isn't re-litigated.

v2.27results.css modularization continues (see docs/ROADMAP-v2.27-charter.md). Building on the v2.22 @@CSS: splice infrastructure, five more cohesive blocks move out of the results.css monolith into src/modules/*.css: card (557 lines), modal (283), chips (1,398 — the unified chip system + legacy aliases), marquee (183), and the left library panel (142). results.css drops 4,812 → 2,268 lines (from 5,797 originally; seven CSS modules now: tokens / lightbox / card / modal / chips / marquee / library-panel), artifact byte-identical (same hash), marker discipline + brace balance linted. The remaining serve_demo inline-HTML methods (_render_share_html and five others) were assessed and deferred: unlike the static pages v2.16 extracted, these are heavily dynamic f-string handlers (15–55 interpolations each), whose safe extraction needs a placeholder-not- format templating pass plus per-route byte-identical verification with real data fixtures — a dedicated slice, not something to bundle into a low-risk CSS refactor unverified.

v2.26true de-materialization: card DOM stays bounded on any run size (see docs/ROADMAP-v2.26-charter.md). v2.24 bounded decoded-image RAM; this bounds the card DOM node count too, completing windowed virtual scroll. P-UX-18 materialized placeholders on scroll but never recycled them — scroll a 10k wedding through and you'd accrete ~10k card DOM subtrees. Now a card that recedes past ~5 viewports (a 300% hysteresis gap over the ~200% materialize margin, so no boundary thrash) is torn back to a placeholder of its measured height and re-materialized on re-approach. The correctness-critical part: re-materialization renders from the current row (renderCard(segRows[idx]), not the frozen segment string), so a decision made while a card was live survives the recycle — rows[] is the source of truth and every decision path mutates it. Verified on a 600-row run: scrolling through all 600 kept the card count at 172 (not 600), back at top it recycled to 100, order and filenames intact, zero JS errors; a cull decision persisted across a scroll-away-and-back. Together v2.24 + v2.26 decouple both image RAM and DOM node count from run size.

v2.25n-way A/B compare: line up 3–5 near-dups, not just pairs (see docs/ROADMAP-v2.25-charter.md). The compare modal was already n-cell internally, but the free-pick entry point opened the instant you picked a second photo — a pro comparing 4–5 near-identical frames across two burst clusters had to do repeated 2-way rounds (a 2030Q3-audit UX gap). Picks now accumulate into a set: each pick toggles a photo in or out, the tray shows the running count with a Compare (N) button, and Enter / the button open the whole set in one n-cell compare (Esc clears). The lightbox c key keeps its fast 1:1-check-then-pair flow. Eight new locale keys wire the tray and toasts through _t(), and the compare modal's own title/meta join them (they were hardcoded Chinese). Verified: three picks → "Compare 3" → a 3-cell modal; re-picking one toggles it back out.

v2.24image-memory virtualization: decoded thumbnails stay bounded (see docs/ROADMAP-v2.24-charter.md). P-UX-18 bounded the initial card DOM on huge runs, but once a placeholder materialized its thumbnail lived forever — scroll a 10k wedding to the bottom and you'd decoded 10k JPEGs into RAM (loading="lazy" only defers the first load, it never reclaims). A second IntersectionObserver now keeps decoded thumbnails to a window around the viewport: a card that recedes past ~3 viewports parks its <img> (src → data-parked-src, src cleared, so the browser drops the decode) and restores it on re-approach. The card element, its decision badge, keyboard index and focus are untouched — only the <img src> toggles, and .thumb-wrap's aspect-ratio holds the layout so the scrollbar never jumps. Measured on a 600-row run: as materialized cards grew 100 → 352, live thumbnails stayed pinned at ~48–76 (parked climbed to 276) — image RAM now decouples from run size, deliberately without touching the fragile decision/render path.

v2.23pip install pixcull gets its rails + the audit queue closes (see docs/ROADMAP-v2.23-charter.md). Three threads land together. PyPI rail: the package metadata is PyPI-ready with a dedicated README-PYPI.md (the repo README's relative-path screenshots render broken on pypi.org), the 13 locale JSONs now ship inside the wheel (they load at runtime — without them _t() falls back to keys), twine check passes on both wheel and sdist, and release.yml gains a token-gated Publish to PyPI step so a v* tag auto-publishes once the owner adds a PYPI_API_TOKEN secret (clean skip until then). English-first first-run (2030Q3 audit): a fresh non-Chinese browser used to be forced into Chinese — now _getStoredLang() detects navigator.language (with a _normalizeLang() that mirrors the server's fold, parity-tested), the switcher cycle expands from 3 to all 13 locales (+ RTL for Arabic), and the onboarding card — a new user's very first interaction — speaks every locale via _t(). Verified: an en-US browser boots to English onboarding ("Getting started"), review chip ("To review N"), the lot. Shadow-queue unlock (audit): the shadow rescorer's model↔rule disagreements — the highest-value correction labels, computed every run but never routed anywhere — now have a ⚖ Review disagreements button that opens a queue sorted most-confident-split-first; deciding there writes straight to the correction set. (Flipping the rescorer to adjudicate stays owner-gated — it needs those real disagreement labels first; this ships the flow that collects them.)

v2.22the audit queue lands + the gallery wears the new skin (see docs/ROADMAP-v2.22-charter.md). The three 2030Q3-audit themes ship in one release: i18n gap-fill — the v2.15 session-close flow (待审 counter, completion chip, resolve-maybes queue, hydration chips, four end-of-session toasts) finally speaks all 13 locales (9 new keys × 13 files, native translations), and fixing it surfaced a boot-order bug that would have voided the whole fix (dynamic strings rendered before the async locale fetch resolved — now rebuilt after apply); the born-dead 20-undo-stack module (wrapped a window.setDecision nothing ever assigned) is deleted. Release rail — a tag-triggered release.yml builds the wheel, smoke-tests it in a clean venv and creates a GitHub Release using only the built-in token; sync-modelscope stops failing red when the secret is absent (warn + skip); the PyInstaller spec reads its version from pyproject (it hardcoded "4.0.0" against pyproject's 2.19.0); version bumps to 2.22.0 in lockstep. CSS split_assemble_css() joins its JS twin in the build: design tokens (370 lines) and the lightbox block (651 lines) now live as src/modules/*.css behind @@CSS: markers, byte-identical artifact, marker-discipline linted. And the 19-shot gallery is recaptured on the Studio Neutral skin with the real museum-artifact run rebuilt from the source drive — every screenshot below except the two video frames (18/19, pending owner-approved footage) now shows the current design.

v2.21"Studio Neutral": the research-driven design overhaul (see docs/ROADMAP-v2.21-design-charter.md). A six-lens survey of the field (Narrative Select, Aftershoot, Imagen, FilterPixel, Photo Mechanic, Lightroom, Capture One, plus Linear/Raycast-class tool design and dark-UI color science) converged on one hard fact: a warm-tinted surround skews color judgment of the photos themselves (ISO 3664 viewing-surround guidance — every top tool judges on neutral gray; PixCull's espresso brown was an industry outlier). The whole UI moves to an achromatic studio ramp (#161616 → #1d1d1d → #242424 → #2e2e2e, c=0 in OKLCH) with brand warmth concentrated in ONE champagne-gold accent (#d5b584, 3× the old brass chroma) reserved strictly for selection/focus/CTA. The v2.3 "Double-Bezel tray" cards are retired — photos now fill the card edge-to-edge with a hairline border, like every reference tool; the every-card amber "needs-review" ring is demoted to a whisper; score numbers switch from editorial serif to mono tabular; motion returns to flat ease-out (the 6% overshoot spring now reserved for signature moments); radii tighten to the pro register. The swap ran as a full-tree palette migration (~430 replacements across 24 files incl. the video surfaces' stray navy mini-palette and three latent bugs: a dead --surface-1 fallback, Tailwind amber literals bypassing the semantic system, and the last hex-arithmetic score ramp). Both themes stay; light mode becomes neutral paper. The 19-shot gallery below still shows the previous skin — recapture is queued.

DESIGN-AUDIT 2030Q3the post-v2.14–v2.20 full-body recheck (see docs/DESIGN-AUDIT-2030Q3.md). Five lenses re-read the code and regrade every dimension: 3.1/5 overall (Q2: 3.0) — core UX 3.5→3.8, intelligence 3.1→3.5, architecture 2.5→3.2, reach 2.5→2.8, plus a new release-&-CI-hygiene lens at 2.0 (no tag, no GitHub Release, no published artifact since v0.7.0). The sharpest pure-code finding: the v2.15 session-close flow — Q2's own declared top-gap fix — renders entirely in hardcoded Chinese for non-Chinese users, bypassing the mature 13-locale _t() shim. Four ranked v2.21 candidates (i18n gap-fill · release rail · English-first first-run · CSS module split) plus the owner-only unlock list.

v2.20the video theme closes its loop + three long-standing tails (see docs/ROADMAP-v2.20-charter.md). Reel candidates' why now mentions overlapping audio events (现场笑声/掌声/配乐); and the review page's Keep/Cull — localStorage-only until now — persists server-side and teaches a reel taste profile (keep-vs-cull signal contrast, ≥20-decision activation gate, ranking tilt capped at ±15%) that pixcull reel applies next run: the video side finally learns like the photo side does. Tails: the per-axis why-low reads real moment/aesthetic signals (blink, non-peak burst frame, smile strength, CLIP-IQA/LAION scores); the Lr-sync reload restores scroll+focus instead of dumping you at the top; and the ⌘K palette becomes the 29th boundary-linted module.

v2.19audio reaches the timelines + the first shippable artifact (see docs/ROADMAP-v2.19-charter.md). The learned audio tagger's events (laughter / applause / music) have been computed since v2.1 and never drawn — both timelines now carry a bottom event lane (kind-colored bands, emoji labels, span+confidence tooltips) on the /video review page and the lightbox scrubber, riding along in /video/data and PAYLOAD.video with graceful absence. And the distribution theme gets its first concrete step: make wheel builds a verified pixcull-2.19.0 wheel (all templates/calibration data packaged, entry point intact, clean-venv import smoke-tested; version single-sourced with a lockstep guard test) — publishing to PyPI / a GitHub Release is a one-command owner action.

v2.18the 5k performance debt is paid (see docs/ROADMAP-v2.18-charter.md). The /rows pagination endpoint was built in v0.13.5 "as the foundation for virtual scroll" and never wired — big runs still inlined every row as JSON in the HTML. Now the server inlines only the first slice (default 800, PIXCULL_INLINE_ROWS) with full-run counts still computed server-side, and the page hydrates the remainder in background chunks into the same rows array every module shares — a live progress chip, a full sidebar/grid rebuild on completion, and an honest degraded state on failure. Measured on a 2,500-row run: HTML 6.45MB → 2.53MB (−60%), all 2,500 cards rendered post-hydration, zero JS errors. Also unearthed and routed around a latent shadowing bug: the iOS slim /api/v1 rows alias had made the full-shape endpoint unreachable since P2.1 — hydration gets its own /results_rows/ route (one row in the v2.16 table), the slim alias keeps serving iOS.

v2.17the glass box reaches video (see docs/ROADMAP-v2.17-charter.md). Photos have had per-axis "why-low" prose since v2.9–v2.12; reel candidates only showed a score. Each candidate now carries its per-window sub-signal breakdown (camera-motion smoothness / content stability as window means, peak-instant as window max) plus a deterministic why-low line naming the signal that drags it below the clip median ("运镜平稳度拖分:0.32,低于全片中位 0.78") — computed at generation time from the window's own signals, no models. The review panel renders three labelled mini-bars + the amber why-low under each candidate, ternary-guarded so old runs' JSON degrades gracefully. Same contract as the photo side's _axisWhyLow.

v2.16paying down the monolith, slice 1 (see docs/ROADMAP-v2.16-charter.md). The audit's top-recommended theme, promised back in v2.4 and deferred for 11 releases: serve_demo.py's seven inline HTML page blobs (~5,300 lines of raw upload/verticals/admin/… markup inside Python strings) now live as real files under templates/pages/, loaded through the same _read_template mechanism as video_review/timeline — 18,225 → 12,884 lines (−29%) with an AST-driven extraction (shared design-tokens CSS re-spliced via a placeholder) and the only acceptance bar that matters for a refactor: all seven routes byte-identical before vs after (curl-diff), guarded by a new template test. Slice 2 — results.js modules: eight clean-boundary subsystems (undo stack, Selects mode, Smart Collections, bookmark/conflicts, marquee select, WebRTC, onboarding, transparency hint — 802 lines) now live in src/modules/*.js, re-spliced at build time via @@MODULE: markers with the artifact hash unchanged, and a machine-enforced boundary lint (each module a single self-contained IIFE; cross-module talk via window.PixCull* only) — cutting off a stretch of the "one broken invariant → nine simultaneous bugs" propagation path. Slices 3+4: twenty more mid-file subsystems (multi-tab sync, confidence modal, EXIF overlay, tour, …) extracted the same hash-preserving way — 28 modules / 2,160 lines total, results.js core down to 9.6k — and do_GET's 258-line if/elif over 65 paths became a declarative route table (31 exact + 30 ordered prefixes + 5 hand-written compounds), verified by a 31-route sweep: 28 byte-identical, 3 dynamic same-status. Adding an endpoint is now one table row.

v2.15the culling pass finally has a finish line (see docs/ROADMAP-v2.15-charter.md). The workspace bar gains a live 待审 N counter (photos still without a human-confirmed decision — re-confirming the model also counts); at zero it flips to a "全部已审 ✓ · 导出 XMP" completion chip that triggers the (previously buried) XMP export with one click, and the state survives reload. A new ◐ 决议 maybe button enters a maybe-resolution queue — filtered to the maybe band, sorted most-ambiguous-first (|P(keep)−0.5| from the v2.14 shadow rescorer, score-based fallback), focus pre-placed on the hardest frame — and auto-exits restoring your filter+sort when the last maybe is decided. The keep↔maybe overrides this queue produces are exactly the corrective labels the v2.14 gate ③ still needs — UX and the training loop close into one circle. Also fixes a latent bulk-marquee bug (decisions patched only card DOM; rows[] and header tallies went stale until the next repaint silently reverted them).

v2.14real-data learning: de-stub the "moment" axis so it can actually be learned (see docs/ROADMAP-v2.14-charter.md + docs/DESIGN-AUDIT-2030Q2.md). The audit found the "moment" axis — the decisive-moment axis the product most loudly markets — was a constant 0.5 placeholder for every photo in fusion, plus two of its three rubric checks always returned None. A constant feature carries zero information, so the rescorer could never learn it. Now moment_score is a real signal where one honestly exists (wedding-moment confidence; face smile/eyes), left neutral where no signal exists (landscapes unchanged); emotion_present is evaluated from wedding-moment confidence and the face smile blendshape; and action_at_peak now resolves from the burst-peak ranker — within a real burst, the crowned frame is the captured action moment (singletons honestly stay unscored). The once-constant "moment" axis is finally a learnable, non-degenerate feature. An end-to-end A/B regression caught a latent NaN→1.0 bug (a pandas NoneNaN slipped past an is None check and clamped score_final to 1.0 = always-keep for every no-signal frame) — now fixed and guarded by a test. The 400-sample real-label training session + flipping the rescorer to adjudicate mode is owner-gated (fabricated labels poison the model — the RESCORER-V3 lesson). Also wires axis-aware personalization: once you have ≥50 corrections, fusion's per-axis weights now tilt toward the axes you demonstrably value (a composition-lover's runs reward composition-strong frames and demote weak ones), not just a global threshold nudge — clamped to a gentle ±2× and a no-op without a profile (generic runs stay byte-identical, verified by an A/B regression). Adds an aerial scene for DJI/drone footage: detected deterministically from the drone camera's EXIF model code (DJI FC####; the Mavic 2 Pro/3's Hasselblad L1D-20c/L2D-20c) — matching the model, not the make, so a genuine Hasselblad body isn't mistaken for a drone — with a DJI_ filename fallback. Non-drone frames are untouched (16 real aerials → aerial, 10 Canon frames byte-identical in an A/B).

v2.13root-caused the "screenshot hang" and fixed a real UI bug (see docs/ROADMAP-v2.13-charter.md). The v2.12 "body-not-delivered to headless chromium" theory was wrong: the similarity slider simply never mountedrender() only repaints the grid, never the sidebar #viewToggles group that the slider lives in, and nothing called buildViewToggles() after the fold toggle (this reproduces in a real browser, not just headless). Fixed, then an adversarial review pass swept the same bug class across the frontend and fixed 8 more: preset-apply / ⌘K-reset / "reset all filters" / Smart-Collection restore all left sidebar pills visually stale (and "reset all" was silently keeping face/location/burst filters active; Smart- Collection restore's window.render() was a dead no-op that never repainted at all). The same dead-window.render() pattern also left Selects mode (⌘1) completely inert — it set the filter sentinel but never re-rendered, and the "keep + maybe only" filter was never actually wired into render(); it now filters for real, with a brass top-rule cue. Plus a module-level debounce fix + detached-node guards. A new DRY helper _rebuildFilterControls() keeps the sidebar in sync with filterState.

v2.12 — explanation goes one level deeper + local discoverability metrics (see docs/ROADMAP-v2.12-charter.md). The verdict glass box no longer just names the weakest axis — it says why it's low, mapped from the row's own signals ("光线偏低 · 高光过曝 12%", "构图偏低 · 地平线倾斜 5°", "主体偏低 · 无明确主体"). And the transparency tools now record local-only usage counts (localStorage.pixcull_metrics, never sent anywhere) so you can see whether near-dup / Scenes / glass box actually get used. (The deferred slider/face-Close-ups screenshots are best captured locally — the headless capture is killed by the dev host; the features themselves are verified.)

v2.11discoverability + explanation for the v2.9/v2.10 transparency features (see docs/ROADMAP-v2.11-charter.md / docs/DESIGN-AUDIT-2030Q1.md). The near-dup fold + Scenes toggles were buried in a burst-only sidebar group that vanished on burst-less runs — they now live in an always-visible 「整理 · 折叠」 group, so the tools are findable on every run (this also un-broke the similarity slider, whose CSS had been mis-scoped since v2.9). A one-time coachmark introduces the transparency trio, and the verdict glass box's one-liner is now a per-axis driver — "构图 4.8★ 撑分,光线 2.5★ 拖后腿" straight from the rubric.

v2.10 — polish on the v2.9 transparency slices: Scenes now also renders inline section headers in the grid (time-ordered, header per scene — not just the navigator strip), and a face Close-up click locates that face on the main photo (a pulsing box maps the crop back onto the full frame). Small-batch grids (≤200) get the inline sections; larger keep the navigator.

v2.9transparency + content-first viewing (the deferred competitor patterns from the v2.8 reflection — see docs/ROADMAP-v2.9-charter.md and docs/DESIGN-AUDIT-2029Q3.md): a similarity slider turns the near-dup fold from a fixed-threshold black box into a glass box — drag 0.80–0.99 and the grouping re-folds live (Peakto-style) · a face Close-ups rail in the lightbox shows a zoomed crop of every detected face so you can check eyes / expression without manual zoom (Narrative Select) · a Scenes navigator segments a shoot by capture-time gaps (adaptive median+MAD) into a time-grouped narrative · a verdict glass box makes the inspector's default read a single line — "why this decision" — and folds the per-axis breakdown behind one tap (progressive disclosure).

v2.8 — UI/UX subtraction + colour-system pass: grid cards shed the badge wall, decision badges go outline (not solid fills), the lightbox gains a discoverable "zen" toggle (i key / button → photo claims the full viewport), header stats + toolbar move to progressive disclosure / grouping, and the palette becomes an OKLCH three-variable system (base / accent / contrast → relative-colour-derived surfaces, with a hex fallback for older engines). Two lightbox freeze root-causes fixed. Editorial restraint after Linear / Narrative Select — see docs/DESIGN-REFLECTION-v2.8.md.

v2.7 — four intelligence slices: bilingual reel captions (zh + en, locale-selected) · cross-shoot dedup (pixcull dedup-across — the same frame recurring across separate sessions) · video duplicate-frame trimming (pixcull trim-dupes — dHash near-static runs) · self-hosted VLM ONNX (BLIP → onnxruntime; real-export captions match transformers, no transformers needed at inference).

v2.6 — CLIP visual near-duplicate fold (catches the re-shot composition that time-bucketed bursts miss; ≈N badge → side-by-side compare) + lightbox- freeze & thumbnail-starvation stability fixes.

v2.5 — single-file frontend split into a build artifact (templates/src + make results-html) · contact-sheet / client-proof PDF export (pixcull contact-sheet).

v2.4 — intelligence + workflow: personalisation-from-corrections (threshold shift learned from your edits) · keyboard-first cull loop · natural-language semantic search (CLIP) · audio-threshold calibration (laughter recall 0.25→0.85) · burst "collapse to peak" + ⧉N stack · true VLM best-frame caption (opt-in BLIP).

v2.0–v2.3video culling + reel pipeline (temporal scoring / shake-blur cull / audio-event tagging / GoPro·DJI GPMF / reel auto-assembly + export presets) · editorial-warm rebrand (espresso + brass, vendored Geist).

v1.0 — learned rescorer · bias-audit dashboard · per-axis attribution heatmap.

v0.9 (in flight) — Brand identity refresh (signature gradient

  • logo redo + serif accent) · Hero reveal "first 2 seconds" signature moment · soft-bounce motion curve project-wide · ⌘K command palette (27 actions, fuzzy match, recent-used). See docs/ROADMAP-v0.9-charter.md.

v0.8 — i18n 中 / EN / 日 · LAN collaboration (event token + 5s polling + conflict markers) · style clone V2 (CLIP embedding centroid) · short links + QR + share-URL modal · structured CSV / JSON export (annotations + style distances joined) · docs/ROADMAP-v0.8-charter.md.

v0.7 — A/B compare modal redesign · annotation rubric modal redesign · 5k+ photo stability (IndexedDB adapter) · Loupe RGB readout · Inspector mobile bottom-sheet · view-preset v2 · /share/<run>/<token> · style clone V1 · tethered live · Sparkle auto-update infra · /history. See docs/RELEASE_NOTES-v0.7.md.


Why PixCull

A 1,500-frame wedding takes a human ~6 hours to cull. AI-assist tools exist, but the popular ones make three trade-offs working photographers shouldn't have to swallow:

  • They upload your photos. Wedding contracts and journalism NDAs routinely forbid third-party cloud processing of client images. Most "AI culling" SaaS apps need an upload to even start.
  • They give you a score, not a reason. A single 0..1 number tells you nothing about why a frame got picked. Defending a culling decision to a client — or learning from your own taste — needs an audit trail.
  • They live outside your tooling. Lightroom, Capture One, Photo Mechanic, your tethered shoot — that's where the work happens. A walled-garden web app forces a context switch on every batch.

PixCull is the alternative that flips all three:

  • Local-first. RAW decode, scoring, faces, GPS — everything runs on your machine. The optional DeepSeek meta-judge runs against your API token; the photos stay on disk either way.
  • 6-axis rubric. Every frame gets stars on technical, subject, composition, light, moment, and aesthetic — each with a short rationale and (for V5.2+ advice) a canon citation (Adams' Zone System, Cartier-Bresson decisive-moment, etc).
  • Sidecar-native. Verdicts ship as XMP files Lightroom and Capture One pick up natively. IPTC captions, standalone HTML galleries, Lr plugin, iOS swipe companion — all included.

Who it helps

  • Wedding & event photographers shooting 1,000+ frames a day who need to triage by tomorrow morning and defend the pick to the client without breaking NDA.
  • Sports / action shooters running tethered to Lightroom — PixCull watches the tether folder and emits a live keep/maybe/cull verdict per shutter click.
  • Photojournalists under embargo or IP contract who literally cannot upload to a SaaS culling service.
  • Studios with second shooters who need to merge coverage of the same moment from multiple cameras and reconcile face IDs across cards.
  • Wildlife / landscape photographers who shoot bursts of the same scene and want the burst-peak picked automatically without losing the run-up frames.
  • Self-taught photographers who want the tool to explain decisions — strengths, weaknesses, suggestions — not just rank.

What you get today

  1. 6-axis rubric scoring. Technical, subject, composition, light, moment, aesthetic. Each axis: 1–5 stars with rationale. Calibrated against thousands of human labels; per-axis rescorer trained on the same data.
  2. Per-genre verticals. Wedding · wildlife · sports · landscape · portrait · event · journalism · commercial · still-life. Each vertical adjusts the keep/maybe thresholds and weights the axes to taste (e.g. wildlife rewards moment-axis sharpness even when composition slips, weddings reward expression even when light is marginal).
  3. V20 advice envelope. Every photo carries a short verdict, a list of strengths cited to canon (Adams Zone System, Cartier- Bresson decisive moment, Rule of Thirds, etc.), a list of weaknesses, and a list of concrete suggestions. Pros use it to defend picks to clients; learners use it as a teacher.
  4. Local face clustering. InsightFace ArcFace embeddings → DBSCAN clustering → cross-run face library that recognizes the same bride / kid / pet across all your shoots. Avatars + inline renaming in the UI.
  5. GPS location clustering. Haversine DBSCAN groups photos by capture spot (~100 m radius). "Pick one per location" surfaces the best frame from each.
  6. Burst-peak ranking. Sub-second bursts get a calibrated peak pick (best focus, expression, action moment).
  7. Cull-reason taxonomy. When you cull, optionally tag whyfocus_miss, eyes_closed, motion_blur, framing, duplicate, exposure, other. Powers a filter pill and builds a richer training signal.
  8. Similar-photos lookup. Composite signature (burst-cluster + scene + face overlap + GPS + rubric proximity) ranks the top-5 visually similar frames; one click jumps to them, Shift+click pins for compare.
  9. Free-pick A/B compare. Click ⇆ on any two photos → side-by-side with synced 1:1 zoom across both cells. Built for "which one of these two near-dupes do I keep?".
  10. 1:1 focus check. Click any photo in the lightbox to pixel- peep at 100%, drag to pan, mouse-wheel to fine-tune. Auto-loads hi-res when zoom activates.
  11. XMP / IPTC / gallery export. XMP sidecars for Lightroom & Capture One, IPTC Caption-Abstract auto-composed from scene + faces + location + advice (free) or LLM-polished (DeepSeek, INFRA-4 budgeted), standalone HTML gallery as a zip you can email to a client.
  12. iOS swipe companion. SwiftUI app for swipe-style triage on your phone while the laptop runs the heavy work. Talks to the /api/v1/ namespace.
  13. Lr / Capture One tether mode. Point it at the tether destination folder; PixCull watches and emits live verdicts as the camera shoots. Partial scores.csv survives Ctrl-C.
  14. Multi-machine sync. Symlink-based folder mirror over iCloud / Dropbox / NAS — your face library + verticals + LLM-spend ledger follow you between studio & laptop.
  15. Active-learning queue. The next photos most worth labeling, ranked by rescorer disagreement + uncertainty + threshold- proximity. Your personalized model improves silently as you label.
  16. Multi-user profiles. Studio with two shooters? Each user has their own verticals + face library; shared team verticals for house style.
  17. Video culling. pixcull video scores a clip on the same 6 axes plus a temporal pass, finds reel candidates, and splits them on real shot boundaries (pixcull[shots]) so a candidate never spans a hard cut.
  18. Transcription and edit-by-text. pixcull transcribe writes transcript.json + an SRT sidecar (Paraformer or Whisper, both optional extras). Strike a line — or select words inside one — and the video goes with the text; export an EDL for Premiere / Resolve or render the cut directly. Mandarin ships with an 88-term shoot lexicon, and --speakers labels who is talking.

Why it's different from a generic AI culling app

PixCull typical SaaS culling Lightroom AI Select
Photos leave your disk No Yes (upload required) No, but vendor-locked
Scoring rationale 6-axis stars + canon citations Single 0..1 score "Best of this group"
Workflow integration XMP sidecars + Lr plugin + iOS + tether Web app only Lightroom only
Per-genre tuning 9 verticals + extensible One model Hidden
Open source MIT Closed Closed, subscription
Active learning Built-in Closed re-train cycle None visible
Face library across runs Yes (V22.2) Per-batch Per-catalog
Burst peak picker Yes Yes Yes (Stack)
Cull-reason taxonomy Yes (taxonomy + filter) No No
1:1 focus check + sync Lightbox + compare Limited Yes
Hackable Plain Python + plain JS No No

Screenshots

Real product UI captured against a 200-photo Canon EOS card from 2022(/100CANON/3J0A8133.JPG3J0A8332.JPG),mostly coastal / landscape / architecture frames. Pipeline ran end-to-end: 200 张 → keep 104, maybe 1, cull 95, 178 burst clusters. Every screenshot below is the live page rendered from that real run (/tmp/pixcull_demo/realdemo01/)—not a mockup or template-skeleton.

新手指南: 完整的"从安装到选完 200 张照片"操作流程见 docs/USER-GUIDE.md

The culling surface

Results grid with rubric scores on real landscape + wildlife photos

Drag a folder of JPG / RAW / HEIC into the upload page, pick a vertical, and you get this back. Each card carries a decision badge (keep / maybe / cull), a final composite score, the 6-axis rubric stars, the detected scene + style chips, and the V20 advice one-liner. The colored left edge is a glanceable decision indicator.

Transcript + edit by text (v2.43 – v2.44.2)

Transcript panel beside the video: one line struck out, one cut at word level, undo / redo / export EDL / render, and a kept-duration readout

pixcull transcribe writes transcript.json and an SRT sidecar; the review page puts the lines beside the video. Click a line to seek to it. Strike a line with ✂, or select words inside one and delete just those — the video goes with the text, and the readout tells you what is left (保留 11.8s · 3 段). Undo and redo replay an operation log, so the transcript and the timeline cannot drift apart. Export a CMX-3600 EDL for Premiere or Resolve, or press 出片 and get the cut mp4.

Word-level selection appears only when the engine reported a real time span per character — Paraformer does, Whisper does not — because interpolating inside a segment invents precision the model never gave and lands cuts on the wrong frames. The panel says which mode it is in.

This capture is synthetic end to end: an ffmpeg test pattern with macOS TTS speaking four on-set directions. Re-take it with scripts/brand/capture_transcript_edit.py.

Lightbox with V20 advice + sticky decision toolbar

Lightbox with 6-axis stars, V20 advice, similar photos, decision toolbar

Click any thumbnail and the lightbox opens with the full rubric on the right: 6-axis stars + 4-source breakdown (auto / model / VLM / human), DeepSeek meta-judge reasoning, V5.2 advice with canon citations (Adams' Zone System, Rule of Space, etc), a top-5 similar-photo strip, and sticky keep / maybe / cull decision buttons. Click the image to 1:1 focus-check; mouse-wheel to fine-tune zoom.

A/B compare with synced 1:1 zoom

A/B compare modal — two photos with synced 1:1 zoom toolbar

Pin any two photos via the button (or Shift-click a thumb) and they open side-by-side. Click either image to 1:1 zoom on both simultaneously, drag to pan in lockstep, mouse-wheel to fine-tune. Built for "which one of these two near-dupes do I keep?".

Drag-drop upload

Upload page — drag-drop area with format support

Two modes: drop a copy into /tmp (default, non-destructive) or scan an existing folder in place (zero-copy, RAW + DNG friendly).

Cmd+K command palette (v0.9-P0-4)

Cmd+K palette — fuzzy-matched action list with 27 commands across 7 groups

Linear/Raycast pattern. ⌘K opens the palette anywhere; fuzzy match across 27 actions surfaced in < 50 ms. Recent-used at top.

Client-facing portfolio share (v0.9-P0-5)

Share portfolio page — serif gradient hero, 3 keynum tiles, chapter-grouped grid

/share/<run>/<token> reads as the photographer's portfolio, not a software dashboard. Brand-mark bar + serif gradient hero title + 3 keynum tiles (n_total / n_keeps / ratio%) + chapter-grouped grid of cards. Adaptive layout from iPhone portrait to iPad landscape.

History timeline (v0.7-P2-4)

History page — date-sorted timeline cards, decision distribution chips

Every past run is one card. Decision distribution bar + thumbnail of the highest-scoring keep. Click → back into the grid where you left off.

Tethered live (v0.7-P2-2)

Tether control panel — folder watcher with live status cards

Watch a Lightroom / Capture One tether folder; new RAW lands on disk → analysed within ~2 s → result card appears. Wedding shoot in-camera workflow.

Admin perf data table (v0.9-P2-2)

Admin perf — sortable + draggable + hideable column data table

/admin/perf is a first-class data table (clickable sort, draggable columns, toggle visibility, sticky header, zebra rows, size-class chips on the cache column). Layout preferences persist in localStorage.

Light theme V2 (v0.9-P2-1)

Light theme — warm sand-cream palette, burnt-sienna shadows, type-weight bumps

Sand-cream palette + warm burnt-sienna shadows + display-weight bumps (700/600/450). Light isn't an "invert the dark theme" afterthought — it's editorial-paper feel.

iPad lightbox + gestures (v0.9-P1-5)

iPad lightbox — Apple Photos-style swipe + pinch + tap-zoom

Apple Photos-style gesture suite: horizontal swipe for prev/next, vertical swipe-down to dismiss, two-finger pinch to zoom, tap to toggle fit ↔ 1:1. Vanilla TouchEvent, no third-party gesture lib.

Empty-state illustrations (v0.9-P2-3)

Buckets panel before any bucket created — illustrated empty state with brand-gradient accents

10 illustrations across the v0.4 + v0.9 + v0.10 empty surfaces. Consistent editorial-line treatment with one brand-gradient accent area per illustration. Phase B Brief 02 will replace these with hand-drawn versions.

Mobile grid (v0.6, P-UX-17 responsive)

Mobile grid at 390 px — bottom-sheet inspector

390-wide viewport with the Inspector pulling up as a bottom-sheet, LR Mobile-Library style.

Marquee select + bulk toolbar (v0.11-P1-2)

Marquee select — 6 cards highlighted, bulk toolbar with Keep/Maybe/Cull/Bucket

Drag a rectangle in the grid's empty space → every intersected card is added to the selection. Bottom toolbar surfaces keep/maybe/cull/ bucket bulk actions. ⌘A selects all visible, Esc clears. Lightroom-Library parity.

Bias audit dashboard (v0.13-P0-4)

Bias audit — 偏差审计 page in no-findings state

/admin/bias aggregates every annotation across every run + buckets by scene / time-of-day / aperture. Red callouts when a bucket deviates > 1.5σ from the family mean ("rescorer 在 夜景人像 上 cull rate 38% (全局 22%) — 模型可能过严"). 24h cache; ?force=1 to rebuild; /admin/bias.md for markdown export. Shown empty because the real demo run hasn't accumulated annotations yet.

Confidence-weighted modal (v0.13-P0-3)

Maybe-band card hover popover — model 不确定 with top reasons

Cards in the maybe-band (0.45 ≤ score_final ≤ 0.55) hover-surface a small popover explaining "62% sure · top reason: 同组邻居高 0.04 · 最弱轴 · light 2.5★". Dismissable per-run via "不再显示".

Per-axis attribution heatmap (v0.13-P0-1)

Lightbox with composition-axis attribution heatmap overlay + 6-axis chip strip

Press A in the lightbox → 6-axis chip strip (技术/主体/构图/光线/ 时刻/美感) appears, click any axis → that axis's Integrated-Gradients heatmap (over the timm mobilenetv3_small_100 backbone) overlays the photo at 0.5 alpha. Espresso→brass warm colorize matches the editorial brand. Per-axis cache at output/attribution/<axis>/<sha>.png.

Every surface at a glance

Surface What it does Shipped
/ upload page Drag-drop a folder; live progress as scoring runs. Vertical chooser + active user switcher. Brand-gradient hero. v0.1 + v0.9-P0-3
/results/<run> The main culling surface. LR Library left sidebar (8 collapsible filter groups) + 3-col grid + LR Develop right Inspector (9 collapsible sections). Hero reveal on open. v0.6 + v0.9-P0-2
/results/<run> lightbox Rubric stars + V20 advice + GPS map + face clusters + similar photos + sticky decision toolbar. RGB readout in 1:1 mode. v0.1 + v0.7-P1-1
/results/<run> Inspector mobile At ≤640px the Inspector becomes a pull-up bottom sheet (LR Mobile-Library style). v0.7-P1-2
/results/<run> 1:1 zoom Click any photo to zoom to 100%; drag to pan; wheel to fine-tune. Loupe RGB readout follows the cursor. v0.7-P1-1
/results/<run> A/B compare Pin any 2 photos via ⇆ button; synced 1:1 zoom + pixel readout across both cells. v0.7-P0-1
/results/<run> ⌘K command palette Linear/Raycast-style keyboard-first action entry. 27 actions across 7 groups, fuzzy match, recent-used. v0.9-P0-4
/results/<run> hold-Space Press & hold Space for ~350ms surfaces a context-aware shortcut cheat-sheet (macOS Finder pattern). v0.6 (5/5)
/results/<run>?event=<token> LAN collaboration: second-shooter / editor opens this URL, polls host every 5s for annotation changes, shows conflict markers. v0.8-P0-2
/share/<run>/<token> Token-gated client delivery page; only keeps surfaced; photographer brand + client watermark; share-URL modal with QR. v0.7-P1-4 + v0.8-P1-3
/tether Watch a Lr/C1 tether folder; new RAWs analyze on landing; live status cards. v0.7-P2-2
/history Date-sorted timeline of every past run; decision distribution chips; one-click jump back. v0.7-P2-4
/s/<6-char> Short-link issuer + inline SVG QR (pure-Python QR encoder, no JS bundle). v0.8-P1-3
/admin Storage info; run management; license token; sync configuration. v0.1
/verticals Per-genre policy editor; promote a sample to the team bank. v0.4
iOS companion SwiftUI grid + per-photo swipe annotator + rich lightbox. v0.5

What sets PixCull apart

If you've seen Aftershoot, FilterPixel, Narrative, or any other "AI photo culling" SaaS, the things you'll notice immediately on PixCull:

  1. Photos never leave your disk. RAW decode, scoring, faces, GPS, CLIP embeddings — every byte stays on your machine. There's an optional DeepSeek meta-judge that calls your API token, and even that just sends the rubric numbers (not the image).
  2. Style clone learns YOU, not the average photographer. Give PixCull 5-20 of your past keepers, it learns a personal style centroid (V1 axis-MAD + V2 CLIP embedding). Next event, it re-ranks by "would the user keep this?" — not a hardcoded notion of "good".
  3. LAN multi-shooter sync. Main shooter on Mac, second shooter on iPad, editor on a laptop. One token; all three see annotations merge in real-time. No cloud round-trip. v0.8-P0-2.
  4. Lightroom round-trip both ways. XMP sidecars Lightroom writes pulled BACK into PixCull annotations — your manual Lr edits feed the next training cycle. Not just "export to XMP", actual bidirectional integration.
  5. A real keyboard product. Photo Mechanic-grade hotkeys (1/2/3 + Shift-modified rhythm + [ / ] for verdict tweaks + c for compare + ⌘K command palette + hold-Space cheat sheet + ? full shortcut overlay).
  6. Open source, MIT. Bring your own training data. Bring your own scene model. The pipeline.py is 600 lines of Python you can actually read.

Quick start

# 1. Clone
git clone https://github.com/ChrisChen667788/pixcull.git
cd pixcull

# 2. Python 3.11 or 3.12 (mediapipe pins numpy<2 which forces 3.12-max)
python3.12 -m venv .venv
source .venv/bin/activate

# 3. Install (this pulls torch CPU + InsightFace ONNX + MediaPipe)
pip install -e ".[dev]"

# 4. Run the demo server
python scripts/serve_demo.py
# → open http://127.0.0.1:8770

Drop a folder of JPG / RAW / HEIC into the upload page; first run warms the models (~30 s on Apple Silicon), subsequent batches score at roughly 1 s / photo on M2 Pro.

Tether mode (Lr / Capture One)

python scripts/pixcull_tether.py \
    --vertical wedding \
    ~/Pictures/Lightroom-Tether/2026-05-16-wedding

PixCull watches the folder, scores each frame within ~2 s of the shutter click, and writes a live scores.csv. Ctrl-C to stop; partial results are preserved.

Standalone macOS app

A signed + notarized .app bundle (PyInstaller + Apple Developer ID) lives at app/. See app/RELEASE.md for the build / notarize / Sparkle-update pipeline.

Configuration

What Where Default
Server port --port flag on scripts/serve_demo.py 8770
API key (for LAN deploy) PIXCULL_API_KEY env / X-PixCull-API-Key header unset
CORS allowlist PIXCULL_API_CORS_ORIGINS env (comma-sep) * if unset
Active user PIXCULL_USER env / X-PixCull-User header / cookie none
App data dir ~/Library/Application Support/PixCull (macOS) per-platform
DeepSeek API key (optional) DEEPSEEK_API_KEY env / config.json in app data unset
Sync target (optional) pixcull/sync.py configure_sync_for_user(path) none

Architecture at a glance

Three editorial-warm diagrams, animated on GitHub — data flows along the connectors and each stage pulses as it activates (reduced-motion users get a clean static frame). Editable draw.io sources sit beside them in docs/diagrams/.

PixCull system architecture — input to CLI to orchestrator to an on-device scoring engine to outputs and the web report, over an IO/formats foundation
System architecture · input → CLI → run_pipeline → on-device scoring engine → outputs → web report, over an IO / formats foundation

PixCull video culling sequence across photographer, CLI, extract, score, select and output
Video culling sequence · pixcull video → extract frames → score → temporal / reel select → assemble reel + open report

PixCull data flow — pixels to rubric.jsonl to scores.csv to manifest.json to the report, with a video reel branch
Data flow · pixels → rubric.jsonlscores.csvmanifest.json → report, plus the video-reel branch

For the full engineering-grade architecture (C4 system context + container diagram + photo-pipeline sequence + LAN sync sequence + 16-row ML model card + storage layout + tech-decision table), see docs/ARCHITECTURE.md — all diagrams are Mermaid, rendered inline on GitHub + ModelScope.

The 10-second version, showing how PixCull is positioned in the team workflow:

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#241d12','primaryTextColor':'#f3ede1','lineColor':'#c4b9a9','primaryBorderColor':'#3a3122','tertiaryColor':'#161310'}}}%%
flowchart LR
    P[("📷 Head shooter")]
    S[("📷 Second shooter")]
    E[("✎ Editor")]
    C[("👤 Client")]
    PIX{{"<b>PixCull</b><br/>local-first<br/>AI photo culling"}}
    DS["DeepSeek API<br/>(opt-in)"]

    P -->|"upload RAW/JPEG"| PIX
    S -->|"join LAN event"| PIX
    E -->|"label + push edits"| PIX
    PIX -->|"portfolio share link<br/>/share/&lt;token&gt;"| C
    PIX -.->|"opt-in · text only"| DS

    style PIX fill:#241d12,color:#f3ede1,stroke:#c4b9a9
    style DS  fill:#1b1712,stroke:#6a6052
Loading

The architecture has a few non-obvious commitments worth calling out:

  • Zero external web framework — Python's built-in http.server, 15k LOC in scripts/serve_demo.py, deliberately flat for easy audit. No Flask / Django / FastAPI.
  • No databasescores.csv + append-only annotations.jsonl
    • per-event JSON files. Recovery is cat | tail; cross-machine migration is rsync.
  • Multi-model fusion — 8 ONNX models (U²-Net / ArcFace / scene CNN / wedding-moment CNN / CLIP ViT-L/14 / rubric V2 / …) pulled together by a fusion layer + an optional VLM and DeepSeek meta-judge. Any external source missing → pipeline gracefully degrades. See the model card for per-model latency + size.
  • Local-first sync over LAN — token + 5 s HTTP polling + mDNS auto-discovery. No WebSocket, no cloud signalling server, no NAT traversal — runs entirely inside the same WiFi.

Design quality, honest: the engineering layer is mature (614 tests passing, 7 charters shipped, 57 slices); the visual design layer is still "developer + AI" rather than "designer-curated". We name this gap openly and have drafted a concrete uplift plan in docs/DESIGN-SYSTEM-ROADMAP.md covering tool selection (Figma + Penpot + Tokens Studio + Rive), commissioned-illustration brief, and three phases over the next six months — the goal is to move from "iconic functionality" to "iconic-craft visual product" before v1.0.

Repository structure

pixcull/
├── pixcull/                    # the actual Python package
│   ├── scoring/                # 6-axis rubric, scene templates, style modes
│   ├── pipeline/               # orchestrator, worker, face / GPS clustering, advice
│   ├── detectors/              # blur, eye-state, exposure, composition, etc.
│   ├── io/                     # RAW loader, XMP / IPTC writers, EXIF
│   ├── db/                     # annotations.jsonl + scores.csv schema helpers
│   ├── report/templates/       # the results.html web UI (zero-build, vanilla JS)
│   ├── license/                # local license-token state machine
│   ├── verticals.py            # per-genre scoring policy
│   ├── sync.py                 # INFRA-2 multi-machine folder mirror
│   └── tether.py               # P2.2 Lr/C1 tether watcher
├── scripts/                    # runnable entry points
│   ├── serve_demo.py           # the HTTP server + web UI host (10k lines)
│   ├── pixcull_tether.py       # the tether CLI
│   ├── train_rescorer.py       # per-axis rescorer training
│   └── ...                     # ~30 maintenance + analysis scripts
├── mobile/PixCullCompanion/    # SwiftUI iOS app (Swift Package)
├── lr_plugin/PixCull.lrplugin/ # Lightroom plugin (Lua)
├── app/                        # PyInstaller spec for the .app bundle
├── tests/                      # pytest suite (1,200+ tests across 88 files)
├── training.csv                # sanitized rubric ground truth (130 rows)
├── training_axis.csv           # sanitized per-axis ground truth (3,000 rows)
├── ROADMAP.md                  # the next ~12 months of work
└── pyproject.toml              # MIT, Python 3.11–3.12

Roadmap

The full ROADMAP.md has the running plan with rough sizing. The current focus areas:

  • Photo evaluation intelligence. Reject-reason taxonomy → rubric model retraining (so your cull because eyes_closed becomes a real signal); per-axis confidence intervals; meta-judge inconsistency detection.
  • Pro-grade workflows. Tighter Lr / Capture One round-trip; Photo Mechanic-equivalent culling hotkeys; auto-IPTC keywords from face labels + locations + advice.
  • Mobile companion V0.4+. Pull-to-refresh, swipe-down dismiss, haptic feedback on quick-label, photo-library import in addition to server-side runs.

Security and privacy

PixCull is local-first by design. The default serve_demo.py binds to 127.0.0.1 only; the optional LAN deploy is gated by an X-PixCull-API-Key header you set via PIXCULL_API_KEY.

See SECURITY.md for the full threat model and disclosure policy. TL;DR: trusted local user, untrusted image input (Pillow is pinned ≥ 10.2), no telemetry, optional DeepSeek calls go straight to DeepSeek with your token (we never proxy).

Contributing

See CONTRIBUTING.md. PRs welcome; bug reports welcome (use the issue template); the highest-leverage first PRs are listed in the contributing doc.

License

MIT. Use it commercially, fork it freely, send a pull request.

About

PixCull started as a single-developer project to stop personally spending an evening per shoot in Lightroom's catalog. Eighteen months and a lot of small commits later, it's the AI culling tool I wish had existed when I picked up my first camera. Open-sourcing it under MIT so the next photographer doesn't have to rebuild it from scratch.

@ChrisChen667788


PixCull — 摄影师本地优先的 AI 选片工具

English · 简体中文 · ModelScope

专业摄影师的本地优先 AI 选片工具。
6 维评分,XMP / IPTC / 相册一键导出,Lightroom & Capture One 直通,照片永远不出本机。

为什么有这个项目

一场 1,500 张的婚礼,人工选片平均要花一个晚上。市面上的 AI 选片工具 存在,但主流方案都让职业摄影师作出三个不该接受的妥协:

  • 它们会把你的照片上传。 婚礼合同和新闻摄影的 NDA 都明令禁止把 客户照片送到第三方云上。绝大多数 "AI 选片" SaaS 不上传就跑不起来。
  • 它们只给一个分数,没有理由。 0..1 的总分告诉不了你为什么这张 入选。给客户解释、或者从自己的选择中学习,都需要审计轨迹。
  • 它们活在你工作流之外。 Lightroom、Capture One、Photo Mechanic、 tether 拍摄 —— 真正的工作发生在这些地方。封闭的 Web App 每批都 逼你切换上下文。

PixCull 把这三件事全部翻过来:

  • 本地优先。 RAW 解码、评分、人脸、GPS —— 全在你电脑上跑。 可选的 DeepSeek meta-judge 走的是 你的 API token;不论哪种情况 照片都在你的硬盘上。
  • 6 维评分细则。 每张照片在 技术 / 主体 / 构图 / 光线 / 瞬间 / 美感 六个维度上都打 1-5 星,每个维度都有简短的理由 (V5.2+ 还附带摄影 正典引用 —— Adams 的 Zone System、Cartier-Bresson 的决定性瞬间等等)。
  • Sidecar 原生。 评分以 XMP 文件输出,Lightroom 和 Capture One 直接识别。IPTC 标题、独立 HTML 相册、Lr 插件、iOS 滑动伴侣 App —— 都内置。

适合谁

  • 婚礼 / 活动摄影师 —— 每天 1,000+ 张,明早就要交,而且要在 不破坏 NDA 的前提下能给客户解释为什么这张入选。
  • 体育 / 动作摄影师 —— tether 接 Lightroom,PixCull 监控 tether 目录,每张快门 ~2 s 给出 keep/maybe/cull 实时判断。
  • 新闻摄影师 —— 在 embargo 或 IP 合同下根本不能上传到 SaaS。
  • 多人摄影工作室 —— 多个二摄拍同一时刻,需要跨相机合并覆盖、 跨卡同步人脸 ID。
  • 野生 / 风光摄影师 —— 同场景连拍一组,需要自动选峰值帧而又不 丢失起跑那几张。
  • 自学摄影爱好者 —— 想要工具 解释 评判 —— 优点、缺点、改进 建议 —— 而不是只给排序。

现在就能用的能力

  1. 6 维评分细则。 技术 / 主体 / 构图 / 光线 / 瞬间 / 美感,每维 1-5 星,带理由。用数千条人工标注校准,每维都有独立的 rescorer 模型。
  2. 9 种细分领域 (verticals)。 婚礼 · 野生 · 体育 · 风光 · 人像 · 活动 · 新闻 · 商业 · 静物。每种领域调整 keep/maybe 阈值并按品味重 加权 (比如野生奖励瞬间维度的清晰度,即使构图不那么稳;婚礼奖励表 情,即使光线一般)。
  3. V20 建议信封。 每张照片附带:简短 verdict、引用摄影正典的 strengths 列表 (Adams Zone System、决定性瞬间、三分法 等等)、 weaknesses 列表、具体可执行的 suggestions 列表。
  4. 本地人脸聚类。 InsightFace ArcFace embedding → DBSCAN → 跨 run 的人脸库,识别同一个新娘 / 孩子 / 宠物 跨越所有拍摄。
  5. GPS 位置聚类。 Haversine DBSCAN 按拍摄地点 (~100 m 半径) 分组。 "每个地点选一张" 凸显每个地点的最佳。
  6. 连拍峰值排序。 亚秒级的连拍组自动选峰值帧 (最佳对焦、表情、 动作瞬间)。
  7. Cull 原因分类。 Cull 时可选标 为什么:focus_miss (焦点不准)、 eyes_closed (闭眼)、motion_blur (模糊抖动)、framing (构图差)、 duplicate (与更佳重复)、exposure (曝光问题)、other。驱动一个 筛选条目,并建立更丰富的训练信号。
  8. 类似照片查找。 复合特征 (连拍组 + 场景 + 人脸重叠 + GPS + 评分 邻近) 排序前 5 张视觉相似帧;点击跳转,Shift+ 点击 加入 A/B 对比。
  9. 自选 A/B 对比。 在任意两张照片上点 ⇆ 按钮 → 并排比较,两张图同步 1:1 缩放、平移、滚轮缩放。专为 "这两张相似的我到底留哪个" 设计。
  10. 1:1 焦点检查。 大图窗中点任意位置 1:1 放大,拖动平移,滚轮细 调。首次缩放时自动加载高分辨率原图。
  11. XMP / IPTC / 相册 导出。 XMP sidecar 进 Lr/C1,IPTC Caption- Abstract 由 场景+人物+地点+建议 自动合成 (免费) 或 DeepSeek 润色 (INFRA-4 budget 内),独立 HTML 相册打包成 zip 直接发客户。
  12. iOS 滑动伴侣 App。 SwiftUI 写的手机端滑动选片 App,后台跑笔记 本上的重活。走 /api/v1/ 接口。
  13. Lr / C1 Tether 模式。 指向 tether 目录;PixCull 监控,每个快门 ~2 s 内给出实时 verdict,partial scores.csv 在 Ctrl-C 后保留。
  14. 跨机同步 (INFRA-2)。 基于符号链接的目录镜像,走 iCloud / Dropbox / NAS —— 人脸库 + 细分领域 + LLM 花费账本跟着你在工作室 ↔ 笔记本之间 切换。
  15. 主动学习队列 (P2.4)。 按 rescorer 分歧度 + 不确定度 + 阈值附 近度 排序的 "下一张最值得标的照片"。你的个性化模型在你标注的过程 中静默改进。
  16. 多用户 profile (V28)。 工作室里两个二摄?各有自己的 vertical + 人脸库;共享 team vertical 用于工作室主基调。

和其他 AI 选片工具的对比

PixCull 主流 SaaS 选片 Lightroom AI Select
照片要不要离开本机 不需要 必须上传 不离开但厂商锁定
评分理由 6 维 + 正典引用 单一 0..1 分 "这组的最佳"
工作流融入度 XMP + Lr 插件 + iOS + tether 仅 Web App 仅 Lightroom
按拍摄类型调权 9 种 vertical + 可扩展 单一模型 不透明
开源 MIT 闭源 闭源、订阅制
主动学习 内置 闭源再训练循环 不可见
跨 run 人脸库 支持 (V22.2) 每批独立 每个 catalog 独立
连拍峰值选择 支持 支持 支持 (Stack)
Cull 原因分类 支持 (分类 + 筛选) 不支持 不支持
1:1 焦点检查 + 同步 大图窗 + 比较窗 有限 支持
可定制 纯 Python + 纯 JS 不可定制 不可定制

截图

UI 是一个零构建的 HTML 模板 (pixcull/report/templates/results.html) 加一个 SwiftUI App (mobile/PixCullCompanion/)。两者都是黑色主题、 键鼠优先、无 webpack / 无 Xcode workspace。

真机数据来源:Canon EOS 卡 100CANON/3J0A8133.JPG3J0A8332.JPG 连续 200 张(海岸 / 风光 / 建筑 / 纪实混合)。完整 pipeline 跑完: keep 104 · maybe 1 · cull 95 · 178 个连拍组。所有截图都是这一个 真机 run(/tmp/pixcull_demo/realdemo01/)的实时页面,不是 mockup。

新手 0→1 操作指南: 见 docs/USER-GUIDE.md ——20 分钟跟着步骤跑完第一批照片,每个功能都配真机截图。

以下截图全部用 Playwright headless 抓取的真实运行界面(运行 bash scripts/brand/capture_real_screenshots.sh realdemo01 自动 再生成,前提是先 pixcull/.venv/bin/python -m pixcull run <photos> -o /tmp/pixcull_demo/realdemo01/output 跑出 run 数据):

选片主界面 · v0.9 reveal + brand gradient

结果网格视图 · 实机截图

大图窗 · V20 advice + AI 视觉化(v0.9-P1-4)

大图窗 · sparkline + 6 维评分 + 决策工具栏

Cmd+K 命令面板(v0.9-P0-4)

Cmd+K · 27 个 action 跨 7 个 group · fuzzy match

客户分享作品集(v0.9-P0-5)

/share/<token> · serif gradient 标题 + 3 keynum tiles + 章节网格

历史时间线(v0.7-P2-4)

/history · 日期排序的所有 run + 决策分布条

Tethered Live(v0.7-P2-2)

/tether · 监控 Lr/C1 tether 目录,新 RAW 落盘即分析

管理 perf 数据表(v0.9-P2-2)

/admin/perf · 列可点排序 / 拖拽重排 / 隐藏 / size chip 颜色编码

Light theme V2 · 暖色 sand-cream 调色板(v0.9-P2-1)

Light theme V2 · 暖 burnt-sienna 阴影 + 加重字体

iPad 大图窗 · Apple Photos 手势(v0.9-P1-5)

iPad lightbox · swipe + pinch + tap-zoom · vanilla TouchEvent

10 个 empty-state SVG(v0.9-P2-3,Phase B brief 02 将由真人插画师重画)

/buckets 空状态 · brand-gradient 强调区

响应式移动端(v0.6,P-UX-17)

390 px 视宽 · bottom-sheet Inspector

上传页 · brand gradient hero

/(upload page) · 拖文件夹 + 实时进度

A/B 比较窗(v0.7-P0-1)

A/B 比较 · 同步 1:1 缩放 + RGB readout

Marquee 框选 + 批量工具栏(v0.11-P1-2)

Marquee select · 6 张已选 · 底部弹出 Keep/Maybe/Cull/入桶 toolbar

网格空白处按住鼠标拖矩形,松手所有框中的卡进入"已选"状态。 底部出现 Keep/Maybe/Cull/入桶 工具栏。⌘A 全选当前可见, Esc 取消。Lightroom Library 标杆体验。

偏差审计 dashboard(v0.13-P0-4)

/admin/bias · 偏差审计 · 无告警 empty-state

/admin/bias 汇总所有 run 的标注,按 scene / time-of-day / aperture 分桶,红色高亮偏离均值 > 1.5σ 的桶("rescorer 在 夜景人像 上 cull rate 38% (全局 22%) — 模型可能过严")。24h 缓存; ?force=1 强制刷新;/admin/bias.md 导出 markdown 给客户。 真机 demo run 还没积累标注,因此显示 empty-state。

置信度弹窗(v0.13-P0-3)

maybe 边缘卡 hover popover · 62% sure + top reasons

score_final ∈ [0.45, 0.55] 的临界 maybe 卡,鼠标悬停弹出小 popover: "62% sure · 同组邻居高 0.04 · 最弱轴 · light 2.5★"。可"不再显示" per-run 关闭(v0.13-P0-3)。

像素级 attribution heatmap(v0.13-P0-1)

Lightbox 内构图轴 attribution heatmap 叠加 + 6 轴选择条

Lightbox 按 A 弹出 6 轴选择条(技术 / 主体 / 构图 / 光线 / 时刻 / 美感),点任意轴 → 该轴的 Integrated Gradients 显著度图叠加在 原图上(0.5 alpha),espresso→brass 暖色渐变配色。Heatmap 缓存到 output/attribution/<axis>/<sha>.png,后续打开秒级出图。

🎬 视频审片 · 时间线 scrubber V2(v2.0-P0-4)

视频审片 lightbox · score_temporal 山峰时间轴 + reel 候选带 + J/K/L shuttle

pixcull video <片子.mp4> 会抽关键帧 → 跑现有 6 轴评分 → 加时间维 评分(score_temporal = 动作连续性 + 时间稳定性 + 突发峰值)→ 找 出 reel 候选,然后在 /video/<run_id> 用视频原生 lightbox 审片: 时间轴画每帧 score_temporal 山峰 + 候选片段暖色带,拖动播放头实 时切帧,J/K/L 倒退/暂停/前进(DaVinci 式,再按加速),右栏候选像 照片一样 Keep / Cull。上图是真机跑一段 99s 实拍样片渲染的实页 (聚焦 lightbox + 时间轴)。

头部 🎨 调色下拉(v2.0-P2-2)一键套用胶片预置(Fuji Eterna / Kodak Vision3 / Arri 709A / Teal-Orange / B&W),主画面 + 每个 reel 候选缩略图实时套用 ASC-CDL 参数化预览(仅预览,不改原片)。

视频审片 · 🎨 调色预览 — 整段套用 Kodak / Arri / Teal-Orange / B&W LUT,主画面 + 候选缩略图实时预览(此处 Kodak Vision3) 照片 + 视频同一条时间线(/timeline/<run_id>) —— 一次拍摄里的照片与视频片段 按拍摄时间排在一起,视频卡片显示时长 · 帧数 · 候选数 · 卖点标签,一键跳进审片台。

照片 + 视频时间线 — 视频片段与照片按时间同轴排列,50 帧全部可点

🗣 转录 + 按文字剪(v2.43 – v2.44.2)

转录面板贴在画面右侧:一行被划掉、一行按词剪过、撤销/重做/导出 EDL/出片,以及保留时长读数

pixcull transcribe <片子.mp4> 产出 transcript.json + SRT,审片页把台词 排在画面旁边。点一行跳到那一秒;点 ✂ 划掉整行,或选中行内几个字只删 这几个字 —— 画面跟着文字一起没,读数实时告诉你还剩多少(保留 11.8s · 3 段)。撤销/重做重放操作日志,所以文字和时间轴不可能对不上。导出 CMX-3600 EDL 进 Premiere / Resolve,或者按出片直接得到剪好的 mp4。

按词选只在引擎给了每字真实时间时才出现 —— Paraformer 有,Whisper 没有。 在段内线性插值是在伪造模型没给过的精度,会把切点放到错误的帧上,所以宁可 不提供;面板会显示当前是哪种模式。

中文识别带一份 88 条领域词表(机位 / 曝光 / 备选 / 长焦 / 接亲 / 证婚人 …):通用模型会把「长焦」听成「掌交」、「备选」听成「被选」,一个字错整条 字幕就废。词表按领域知识先验写死,在从未见过的 10 句留出集上把 CER 从 2.59% 降到 1.11%(错误数 −57%)。--hotword 还能加场地名、新人姓名。

--speakers 可标注谁在说话(需片子够长:FunASR 的聚类器在少于 20 个语音 片段时直接返回"只有一个人"),分不出时明说分不出,不会伪造一个 0 号 说话人。

上图全程为合成素材:ffmpeg 测试图 + macOS TTS 念四句现场指令,不涉及任何 真实拍摄素材。重拍用 scripts/brand/capture_transcript_edit.py

v2.9 · 智能透明 + 内容优先观看

🎬 Scenes 时序叙事导航(v2.9-P1-1) — 按拍摄时间自适应切段,点场景跳到那一段。

scoring/scenes.py 用 median+MAD 自适应间隙阈值把一次拍摄切成时序场景,导航条 显示每段时间范围 · 张数 · keep 数;点 chip 即把网格筛到那一段(叙事流,而非一格 格扁平网格)。

Scenes 时序导航条 — 真机博物馆 run 切成多个时序场景, 每段显示时间范围/张数/keep

🔍 判定 glass box(v2.9-P1-2) — 默认一行「为什么是这个判定」,展开看逐轴。

lightbox inspector 顶部的玻璃箱:默认只显判定徽标 + 一句话理由(渐进披露,取代 过去默认 6 个展开区);展开才看逐轴评分 + 最强信号(✓优点 / →改进)+ AI 判读。

判定 glass box — 展开后显示判定 + 一句话理由 + 6 轴评分 + 信号 + AI 判读

另两个 v2.9 切片——相似度滑块(Peakto 式可调近重复阈值)与 人脸 Close-ups 轨(Narrative 式 lightbox 人脸特写)——见 docs/ROADMAP-v2.9-charter.md

v2.11 · 透明度的可发现性

整理 · 折叠 组 + 首次 coachmark — 透明度工具不再藏起来,每个 run 都看得到入口。

近重复折叠(+ 相似度滑块)和 🎬 时序场景 从默认隐藏的「连拍」组迁到常显的 「整理 · 折叠」 侧栏组;首次进入用一次性 coachmark 把透明度三件套指出来。

整理·折叠 组常显 + 透明度首次 coachmark

快速开始

# 1. 克隆
git clone https://github.com/ChrisChen667788/pixcull.git
cd pixcull

# 2. Python 3.11 或 3.12 (mediapipe 把 numpy 钉死在 <2,所以 3.12 是上限)
python3.12 -m venv .venv
source .venv/bin/activate

# 3. 安装 (会拉 torch CPU + InsightFace ONNX + MediaPipe)
pip install -e ".[dev]"

# 4. 跑起来
python scripts/serve_demo.py
# → 浏览器开 http://127.0.0.1:8770

把一个 JPG / RAW / HEIC 的文件夹拖到上传页;首次约 30 秒预热模型 (Apple Silicon),之后每张 ~1 秒 (M2 Pro 实测)。

Tether 实时选片 (Lr / Capture One)

python scripts/pixcull_tether.py \
    --vertical wedding \
    ~/Pictures/Lightroom-Tether/2026-05-16-wedding

PixCull 监控目录,每张快门 ~2 秒内出 verdict,实时写 scores.csv。 Ctrl-C 退出,部分结果保留。

macOS 独立 App

app/ 下有签名 + 公证过的 .app 打包配置 (PyInstaller + Apple Developer ID)。app/RELEASE.md 里有完整的构建 / 公证 / Sparkle 更 新 pipeline。

配置项

内容 位置 默认值
端口 scripts/serve_demo.py --port 8770
API key (LAN 部署) PIXCULL_API_KEY 环境变量 / X-PixCull-API-Key 未设置
CORS 白名单 PIXCULL_API_CORS_ORIGINS (逗号分隔) 未设置时 *
当前用户 PIXCULL_USER env / X-PixCull-User 头 / cookie
App 数据目录 ~/Library/Application Support/PixCull (macOS) 因平台而异
DeepSeek API key (可选) DEEPSEEK_API_KEY env / app-data 下 config.json 未设置
同步目标 (可选) pixcull/sync.py::configure_sync_for_user(path)

架构速览

完整工程架构(C4 系统上下文 + 容器图 + 拍摄 pipeline 时序 + LAN 同步 时序 + 16 行 ML 模型表 + 存储布局 + 技术决策表)见 docs/ARCHITECTURE.md —— 全部用 Mermaid 绘制,GitHub + ModelScope 均原生渲染。

10 秒版,PixCull 在团队工作流中的位置:

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#241d12','primaryTextColor':'#f3ede1','lineColor':'#c4b9a9','primaryBorderColor':'#3a3122','tertiaryColor':'#161310'}}}%%
flowchart LR
    P[("📷 主摄")]
    S[("📷 二摄")]
    E[("✎ 编辑")]
    C[("👤 客户")]
    PIX{{"<b>PixCull</b><br/>本地优先<br/>AI 选片"}}
    DS["DeepSeek API<br/>(可选)"]

    P -->|"上传 RAW/JPEG"| PIX
    S -->|"加入 LAN event"| PIX
    E -->|"标注 + 推决定"| PIX
    PIX -->|"作品集分享链接<br/>/share/&lt;token&gt;"| C
    PIX -.->|"opt-in · 仅文本"| DS

    style PIX fill:#241d12,color:#f3ede1,stroke:#c4b9a9
    style DS  fill:#1b1712,stroke:#6a6052
Loading

几个不太显眼但值得点出的工程承诺:

  • 无 Web 框架依赖 —— Python 内置 http.server,15k 行单文件 scripts/serve_demo.py,故意保持平铺以便审计。无 Flask / Django / FastAPI
  • 无数据库 —— scores.csv + append-only annotations.jsonl + 按事件的 JSON 文件。崩溃恢复就是 cat | tail;跨机迁移就是 rsync
  • 多模型融合 —— 8 个 ONNX 模型(U²-Net / ArcFace / scene CNN / wedding-moment CNN / CLIP ViT-L/14 / 评分 V2 / …)由 fusion 层
    • 可选 VLM + DeepSeek 元判断综合;任一外部源缺失时 pipeline 降级跑通。每模型推理延迟 + 大小见 模型表
  • LAN 同步本地优先 —— token + 5 秒 HTTP polling + mDNS 自动发现。 无 WebSocket,无云端 signalling,无 NAT 穿透 —— 全在同一个 WiFi 内

设计质感坦白: 工程层已经成熟(614 个测试通过、7 个 charter 落地、57 个 slice),但视觉设计层仍是"开发者 + AI"而非"设计师 介入"。这是我们公开承认的差距。详见 docs/DESIGN-SYSTEM-ROADMAP.md —— 包含工具链选型(Figma + Penpot + Tokens Studio + Rive)、自定义插 画委托清单、未来 6 个月分三阶段的升级计划。目标:v1.0 前从 "功能 iconic"升级到"工艺 iconic"

仓库结构

pixcull/
├── pixcull/                    # Python 包本体
│   ├── scoring/                # 6 维评分 + 场景模板 + 风格模式
│   ├── pipeline/               # 编排器 + worker + 人脸/GPS 聚类 + 建议
│   ├── detectors/              # 模糊 / 闭眼 / 曝光 / 构图 / ... 检测器
│   ├── io/                     # RAW 加载 + XMP / IPTC 写 + EXIF
│   ├── db/                     # annotations.jsonl + scores.csv schema
│   ├── report/templates/       # results.html 主 UI (零构建,vanilla JS)
│   ├── license/                # 本地 license token 状态机
│   ├── verticals.py            # 按拍摄类型的评分策略
│   ├── sync.py                 # 多机同步 (folder mirror)
│   └── tether.py               # Lr/C1 tether 监控
├── scripts/                    # CLI 入口
│   ├── serve_demo.py           # HTTP 服务 + Web UI 主程序 (10k 行)
│   ├── pixcull_tether.py       # Tether CLI
│   ├── train_rescorer.py       # rescorer 训练脚本
│   └── ...                     # ~30 个维护 + 分析脚本
├── mobile/PixCullCompanion/    # SwiftUI iOS App (Swift Package)
├── lr_plugin/PixCull.lrplugin/ # Lightroom 插件 (Lua)
├── app/                        # PyInstaller 打包配置
├── tests/                      # pytest 测试套 (240+ 用例)
├── training.csv                # 脱敏后的 rubric ground truth (130 行)
├── training_axis.csv           # 脱敏后的 per-axis ground truth (3,000 行)
├── ROADMAP.md                  # 未来 12 个月规划
└── pyproject.toml              # MIT,Python 3.11–3.12

路线图

完整 ROADMAP.md 在仓库根。当前重点:

  • 照片评价智能化。 Cull 原因 → rubric 模型再训练 (让你的 "因为闭眼 cull" 变成真实信号);各维度的置信区间;meta-judge 矛 盾检测。
  • 专业工作流。 更紧的 Lr / C1 round-trip;Photo Mechanic 级别 的选片快捷键;从 人脸标签 + 地点 + 建议 自动生成 IPTC 关键字。
  • iOS 伴侣 V0.4+。 下拉刷新、下滑关闭、快速标注的触感反馈、 本地相册导入 (除了从服务器同步)。

安全与隐私

PixCull 默认本地优先。serve_demo.py 只绑定 127.0.0.1;LAN 部 署由 PIXCULL_API_KEY 环境变量设置 X-PixCull-API-Key 头进行 控制。

完整威胁模型和漏洞披露政策见 SECURITY.md。 TL;DR:可信本地用户,不可信图像输入 (Pillow 钉在 ≥ 10.2);无遥 测;可选的 DeepSeek 调用走的是 你的 token,我们绝不代理转发。

参与贡献

详见 CONTRIBUTING.md。欢迎 PR;欢迎报 bug (用 issue 模板);最容易上手的几个 PR 类型在贡献指南里。

协议

MIT。可商用、自由 fork、欢迎 PR。

作者

PixCull 始于一个简单想法:不要再花一个晚上在 Lightroom catalog 里挑片。十八个月、无数个小 commit 之后,它变成了我刚摸相机时就 希望存在的 AI 选片工具。MIT 开源,让下一个摄影师不用再从头造一遍。

@ChrisChen667788 · ModelScope @haozi667788