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.
v2.45 — the 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.3 — speaker 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.2 — one-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.1 — edit 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.44 — a 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.4 — every 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.43 — transcription (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.42 — shot-boundary detection (pixcull[shots], PySceneDetect,
BSD-3), so a reel candidate no longer spans a hard cut.
v2.41 — the 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.2 — dead 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.1 — the 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.40 — the 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.39 — switch 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.38 — contrast 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.37 — big 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.36 — real 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.35 — near-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.34 — cross-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.33 — big 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.32 — cross-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.29 — the 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 2030Q4 — post 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.28 — serve_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.27 — results.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.26 — true 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.25 — n-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.24 — image-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.23 — pip 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.22 — the 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 2030Q3 — the 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.20 — the 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.19 — audio 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.18 — the 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.17 — the 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.16 — paying 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.15 — the 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.14 — real-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 None→NaN 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.13 — root-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 mounted — render() 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.11 — discoverability + 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.9 — transparency + 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.3 — video 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.
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.
- 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.
- 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.
- 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).
- 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.
- 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.
- GPS location clustering. Haversine DBSCAN groups photos by capture spot (~100 m radius). "Pick one per location" surfaces the best frame from each.
- Burst-peak ranking. Sub-second bursts get a calibrated peak pick (best focus, expression, action moment).
- Cull-reason taxonomy. When you cull, optionally tag why —
focus_miss,eyes_closed,motion_blur,framing,duplicate,exposure,other. Powers a filter pill and builds a richer training signal. - 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.
- 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?".
- 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.
- 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.
- 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. - Lr / Capture One tether mode. Point it at the tether
destination folder; PixCull watches and emits live verdicts as
the camera shoots. Partial
scores.csvsurvives Ctrl-C. - Multi-machine sync. Symlink-based folder mirror over iCloud / Dropbox / NAS — your face library + verticals + LLM-spend ledger follow you between studio & laptop.
- Active-learning queue. The next photos most worth labeling, ranked by rescorer disagreement + uncertainty + threshold- proximity. Your personalized model improves silently as you label.
- Multi-user profiles. Studio with two shooters? Each user has their own verticals + face library; shared team verticals for house style.
- Video culling.
pixcull videoscores 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. - Transcription and edit-by-text.
pixcull transcribewritestranscript.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--speakerslabels who is talking.
| 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 |
Real product UI captured against a 200-photo Canon EOS card from 2022(
/100CANON/3J0A8133.JPG–3J0A8332.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。
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.
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.
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.
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?".
Two modes: drop a copy into /tmp (default, non-destructive) or scan
an existing folder in place (zero-copy, RAW + DNG friendly).
Linear/Raycast pattern. ⌘K opens the palette anywhere; fuzzy match across 27 actions surfaced in < 50 ms. Recent-used at top.
/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.
Every past run is one card. Decision distribution bar + thumbnail of the highest-scoring keep. Click → back into the grid where you left off.
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 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.
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.
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.
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.
390-wide viewport with the Inspector pulling up as a bottom-sheet, LR Mobile-Library style.
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.
/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.
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 "不再显示".
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.
| 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 |
If you've seen Aftershoot, FilterPixel, Narrative, or any other "AI photo culling" SaaS, the things you'll notice immediately on PixCull:
- 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).
- 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".
- 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.
- 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.
- A real keyboard product. Photo Mechanic-grade hotkeys (1/2/3 +
Shift-modified rhythm +
[/]for verdict tweaks +cfor compare + ⌘K command palette + hold-Space cheat sheet +?full shortcut overlay). - 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.
# 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:8770Drop 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.
python scripts/pixcull_tether.py \
--vertical wedding \
~/Pictures/Lightroom-Tether/2026-05-16-weddingPixCull 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.
A signed + notarized .app bundle (PyInstaller + Apple Developer
ID) lives at app/. See app/RELEASE.md for the build / notarize /
Sparkle-update pipeline.
| 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 |
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/.
System architecture · input → CLI →
run_pipeline → on-device scoring engine → outputs → web report, over an IO / formats foundation
Video culling sequence ·
pixcull video → extract frames → score → temporal / reel select → assemble reel + open report
Data flow · pixels →
rubric.jsonl → scores.csv → manifest.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/<token>"| C
PIX -.->|"opt-in · text only"| DS
style PIX fill:#241d12,color:#f3ede1,stroke:#c4b9a9
style DS fill:#1b1712,stroke:#6a6052
The architecture has a few non-obvious commitments worth calling out:
- Zero external web framework — Python's built-in
http.server, 15k LOC inscripts/serve_demo.py, deliberately flat for easy audit. No Flask / Django / FastAPI. - No database —
scores.csv+ append-onlyannotations.jsonl- per-event JSON files. Recovery is
cat | tail; cross-machine migration isrsync.
- per-event JSON files. Recovery is
- 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.
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
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_closedbecomes 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.
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).
See CONTRIBUTING.md. PRs welcome; bug reports welcome (use the issue template); the highest-leverage first PRs are listed in the contributing doc.
MIT. Use it commercially, fork it freely, send a pull request.
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.
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。
- 野生 / 风光摄影师 —— 同场景连拍一组,需要自动选峰值帧而又不 丢失起跑那几张。
- 自学摄影爱好者 —— 想要工具 解释 评判 —— 优点、缺点、改进 建议 —— 而不是只给排序。
- 6 维评分细则。 技术 / 主体 / 构图 / 光线 / 瞬间 / 美感,每维 1-5 星,带理由。用数千条人工标注校准,每维都有独立的 rescorer 模型。
- 9 种细分领域 (verticals)。 婚礼 · 野生 · 体育 · 风光 · 人像 · 活动 · 新闻 · 商业 · 静物。每种领域调整 keep/maybe 阈值并按品味重 加权 (比如野生奖励瞬间维度的清晰度,即使构图不那么稳;婚礼奖励表 情,即使光线一般)。
- V20 建议信封。 每张照片附带:简短 verdict、引用摄影正典的 strengths 列表 (Adams Zone System、决定性瞬间、三分法 等等)、 weaknesses 列表、具体可执行的 suggestions 列表。
- 本地人脸聚类。 InsightFace ArcFace embedding → DBSCAN → 跨 run 的人脸库,识别同一个新娘 / 孩子 / 宠物 跨越所有拍摄。
- GPS 位置聚类。 Haversine DBSCAN 按拍摄地点 (~100 m 半径) 分组。 "每个地点选一张" 凸显每个地点的最佳。
- 连拍峰值排序。 亚秒级的连拍组自动选峰值帧 (最佳对焦、表情、 动作瞬间)。
- Cull 原因分类。 Cull 时可选标 为什么:
focus_miss(焦点不准)、eyes_closed(闭眼)、motion_blur(模糊抖动)、framing(构图差)、duplicate(与更佳重复)、exposure(曝光问题)、other。驱动一个 筛选条目,并建立更丰富的训练信号。 - 类似照片查找。 复合特征 (连拍组 + 场景 + 人脸重叠 + GPS + 评分 邻近) 排序前 5 张视觉相似帧;点击跳转,Shift+ 点击 加入 A/B 对比。
- 自选 A/B 对比。 在任意两张照片上点 ⇆ 按钮 → 并排比较,两张图同步 1:1 缩放、平移、滚轮缩放。专为 "这两张相似的我到底留哪个" 设计。
- 1:1 焦点检查。 大图窗中点任意位置 1:1 放大,拖动平移,滚轮细 调。首次缩放时自动加载高分辨率原图。
- XMP / IPTC / 相册 导出。 XMP sidecar 进 Lr/C1,IPTC Caption- Abstract 由 场景+人物+地点+建议 自动合成 (免费) 或 DeepSeek 润色 (INFRA-4 budget 内),独立 HTML 相册打包成 zip 直接发客户。
- iOS 滑动伴侣 App。 SwiftUI 写的手机端滑动选片 App,后台跑笔记
本上的重活。走
/api/v1/接口。 - Lr / C1 Tether 模式。 指向 tether 目录;PixCull 监控,每个快门 ~2 s 内给出实时 verdict,partial scores.csv 在 Ctrl-C 后保留。
- 跨机同步 (INFRA-2)。 基于符号链接的目录镜像,走 iCloud / Dropbox / NAS —— 人脸库 + 细分领域 + LLM 花费账本跟着你在工作室 ↔ 笔记本之间 切换。
- 主动学习队列 (P2.4)。 按 rescorer 分歧度 + 不确定度 + 阈值附 近度 排序的 "下一张最值得标的照片"。你的个性化模型在你标注的过程 中静默改进。
- 多用户 profile (V28)。 工作室里两个二摄?各有自己的 vertical + 人脸库;共享 team vertical 用于工作室主基调。
| 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.JPG–3J0A8332.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 数据):
网格空白处按住鼠标拖矩形,松手所有框中的卡进入"已选"状态。
底部出现 Keep/Maybe/Cull/入桶 工具栏。⌘A 全选当前可见,
Esc 取消。Lightroom Library 标杆体验。
/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。
score_final ∈ [0.45, 0.55] 的临界 maybe 卡,鼠标悬停弹出小 popover:
"62% sure · 同组邻居高 0.04 · 最弱轴 · light 2.5★"。可"不再显示"
per-run 关闭(v0.13-P0-3)。
Lightbox 按 A 弹出 6 轴选择条(技术 / 主体 / 构图 / 光线 / 时刻
/ 美感),点任意轴 → 该轴的 Integrated Gradients 显著度图叠加在
原图上(0.5 alpha),espresso→brass 暖色渐变配色。Heatmap 缓存到
output/attribution/<axis>/<sha>.png,后续打开秒级出图。
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 参数化预览(仅预览,不改原片)。
照片 + 视频同一条时间线(/timeline/<run_id>) —— 一次拍摄里的照片与视频片段
按拍摄时间排在一起,视频卡片显示时长 · 帧数 · 候选数 · 卖点标签,一键跳进审片台。
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。
🎬 Scenes 时序叙事导航(v2.9-P1-1) — 按拍摄时间自适应切段,点场景跳到那一段。
scoring/scenes.py 用 median+MAD 自适应间隙阈值把一次拍摄切成时序场景,导航条
显示每段时间范围 · 张数 · keep 数;点 chip 即把网格筛到那一段(叙事流,而非一格
格扁平网格)。
🔍 判定 glass box(v2.9-P1-2) — 默认一行「为什么是这个判定」,展开看逐轴。
lightbox inspector 顶部的玻璃箱:默认只显判定徽标 + 一句话理由(渐进披露,取代 过去默认 6 个展开区);展开才看逐轴评分 + 最强信号(✓优点 / →改进)+ AI 判读。
另两个 v2.9 切片——相似度滑块(Peakto 式可调近重复阈值)与 人脸 Close-ups 轨(Narrative 式 lightbox 人脸特写)——见
docs/ROADMAP-v2.9-charter.md。
整理 · 折叠 组 + 首次 coachmark — 透明度工具不再藏起来,每个 run 都看得到入口。
近重复折叠(+ 相似度滑块)和 🎬 时序场景 从默认隐藏的「连拍」组迁到常显的 「整理 · 折叠」 侧栏组;首次进入用一次性 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 实测)。
python scripts/pixcull_tether.py \
--vertical wedding \
~/Pictures/Lightroom-Tether/2026-05-16-weddingPixCull 监控目录,每张快门 ~2 秒内出 verdict,实时写 scores.csv。
Ctrl-C 退出,部分结果保留。
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/<token>"| C
PIX -.->|"opt-in · 仅文本"| DS
style PIX fill:#241d12,color:#f3ede1,stroke:#c4b9a9
style DS fill:#1b1712,stroke:#6a6052
几个不太显眼但值得点出的工程承诺:
- 无 Web 框架依赖 —— Python 内置
http.server,15k 行单文件scripts/serve_demo.py,故意保持平铺以便审计。无 Flask / Django / FastAPI - 无数据库 ——
scores.csv+ append-onlyannotations.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 开源,让下一个摄影师不用再从头造一遍。






















