AI-assisted automatic development of RAW photographs.
An AI decides what to change. A deterministic Rust engine does it. The AI never touches a pixel.
What it is · See it work · Quick start · Feature tour · Formats · Measured results · The honesty model · Privacy & trust · AI setup · Commands · Configuration · Honest scope · Tech · Credits
The judgement-heavy part of developing a photo is deciding what to change — this sky is blown, those shadows are crushed, the white balance is too cool. The mechanical part is applying it. Autoshop splits exactly there:
RAW ─► decode + features ─► [vision advisor] ─► EditRecipe ─► [verifier] ─► [visual judge] ─► Rust render engine
24 fmts preview+EXIF+hist looks at photo JSON data-only QA scores + 1 revision │
▼
XMP sidecar + 16-bit master
The vision model only ever emits an EditRecipe — a small,
bounded, Lightroom/ACR-style JSON of slider values — and a deterministic engine
renders from the original RAW. Five consequences, and they are the whole point:
- Reproducible. The same recipe and the same RAW give the same pixels, on any machine, forever. Nothing is sampled, nothing is temperature-dependent.
- Non-destructive. Your library is opened read-only. Develop state lives in a per-user store keyed by the photo's own path; the RAW is never rewritten.
- Auditable. Every edit is ~2 KB of readable JSON you can diff, hand-edit, version, or throw away. Nothing about the result is locked inside a model.
- Interoperable. The same recipe serialises to an ACR/Lightroom
.xmpsidecar, so the AI's edit opens in your catalog as adjustable sliders — and a sidecar Lightroom already wrote is read back the other way. - No hallucinated pixels. Detail that was not in the sensor data does not appear. There is no upscaler, no generative "enhance" in the main path.
Three opt-in, clearly-labelled exceptions do touch pixels, and each one says so on itself: AI denoise (SCUNet), the experimental generative restyle and object removal, and pixel heal (which samples surrounding real pixels and invents nothing).
Three frames, developed end to end by the closed loop below — no human slider moves, no presets. Sony A7R V, 61 MP, shot by the author. The before is Autoshop's own neutral develop of the same RAW (not the camera JPEG), so the comparison isolates what the AI decided.
Each run: an OpenAI-compatible vision advisor proposed the recipe, the
claude CLI verified it against the histogram and clipping statistics
without ever seeing the image, and the visual judge then scored the actual
render and bought exactly one guided revision — adopted only because it
re-scored higher.
![]() Before — neutral develop |
![]() After — AI develop |
The widest tonal spread in the set: sunlit white brick against a fully shaded porch wall. The recipe answers it with Highlights −38 / Shadows +27 / Whites +18 / Blacks −17 over a five-point S-curve, Vibrance +17 at Saturation 0, white balance pinned to 5700 K, blue HSL luminance −12, and two local masks — a linear Hold Bright Sky (−0.18 EV, highlights −25) and a radial Lift Main House (+0.18 EV, shadows +21). It also crops 1 % left, 3.5 % top, 1.5 % bottom.
The brick reads white with its texture still resolved instead of clipping to a flat patch, the black wall and porch open into real detail, and the blacks are set rather than lifted — no grey veil. Judge 84 → revision → 86/100, verdict Accept, confidence 0.91. Blemish, named: the linear sky mask leaves a faintly lighter band across the top-left corner if you go looking for it.
![]() Before — neutral develop |
![]() After — AI develop |
A vertical frame of repeating lap siding, hard shadow and a diagonal railing — the one that shows what Texture and Clarity actually do. Texture +10 / Clarity +8 / Dehaze +4, Shadows +29 / Highlights −34, an S-curve with a lifted toe, blue HSL luminance −16, plus two linear masks. The siding gets warmer and board-by-board legible, the shaded tree and the railing shadows open instead of going to mud, and the white window reveals hold. Judge 78 → revision → 84/100, Accept, confidence 0.87.
Counter-example, kept in on purpose: the sky here came out paler than the neutral base, not deeper — the global toe and white-point lift outvote the recipe's own −0.20 EV Sky depth mask. The recipe says one thing and the render does another, and this README is not going to caption that as a win.
![]() Before — neutral develop |
![]() After — AI develop |
The deepest frame — sky, hillside architecture, mid-ground greenery, foreground road. The only one of the three where the AI reached for the global Contrast slider (+17) and deliberately left the tone curve empty, with restrained colour (Vibrance +8, Saturation −2), as-shot white balance kept, and green/aqua HSL saturation pulled down.
This is also where the visual judge earned its cost: it scored the first proposal 63/100 — «foliage turns acidic yellow-green, deep trees lose shadow detail, uneven cyan patch at upper left» — and the guided revision came back at 87/100. The retaining walls, boxwoods, crosswalk and pond all gain detail instead of reading as flat grey, and the greens are calmed rather than acid. Same caveat as above: the sky is paler and milkier than the neutral before. Two of three frames show it; it is a real behaviour of the current global/local composition, and it is written down rather than curated away.
One of the four analyse calls failed, and that is worth showing too. The first attempt on
_DSC9706lost its vision arm to a transient HTTP 524 from the endpoint. Autoshop degraded to its histogram heuristic (exposure −0.2 EV, confidence 0.4), and the verifier refused it: «Heuristic fallback recipe (AI vision failed); confidence 0.4 too low to auto-apply.» No silent bad edit. The retry went through cleanly.Reproducibility footnote: the JPEGs above are downscaled from full-sensor renders (9504×6336 / 6336×9504), and the resize was done outside Autoshop, because at the time there was no export-at-size command. There is one now —
apply … --long-edge 1200. How much it matters, measured on a 9504×6336 frame at--long-edge 2048against the resize applied to the 16-bit render (the reference, since that is the same Lanczos3 kernel with no encoder in the way — from a lossless intermediate the two routes are identical, worst channel difference 0):
route mean |Δ| vs the 16-bit reference worst --long-edge 2048straight to JPEG q951.82/255 22/255 full-res JPEG q95, then resample it 3.11/255 70/255 So the flag is worth about one JPEG generation — the two deliverables differ from each other by a mean 3.00/255 (worst 76/255). Notably it is not the intermediate's block noise doing the damage: resampling averages nearly all of that away (0.22/255 before the second encode). It is simply that the old route encodes twice. It also skips writing a 33 MB file to deliver a 1.3 MB one.
The faint
© skymanbpwatermark protects the author's photographs and sits identically on both halves of every pair — it is not part of the develop.
Each tagged release ships both front-ends prebuilt for Windows. For v0.34.0:
| file | size | sha256 |
|---|---|---|
autoshop.exe (CLI) |
30,752,116 B | 1697fe87f4eefe28b002fdede65254eeffe47de15ee872c0d0e4d262f4c07231 |
autoshop-gui.exe (desktop app) |
40,373,202 B | 53cba5b1188012e91ac20b42a0dde36a94cd29e05b1d338acb79d8d2bebb14d2 |
Both digests were verified by re-downloading the published assets and comparing them byte-for-byte against the local build.
cargo build --release # CLI → target/release/autoshop(.exe)
cargo build --release --features gui --bin autoshop-gui # desktop app → autoshop-gui(.exe)
cargo test # unit + engine testsThe GUI sits behind the gui feature, so a plain cargo build / cargo test
never pulls in eframe + winit + the GL stack. Both binaries link the same engine
library — the desktop app is not a wrapper around the CLI, it links the crate
in-process.
Cross-platform compilation and the test battery are CI-verified: the
build workflow compiles both feature
configurations and runs cargo test --locked (default and --features gui)
on ubuntu-latest and macos-latest on every push to main and on every
pull request (first fully green run:
32405954918,
2026-08-20 — 728 library, 10 CLI, 131 GUI and 4 contract tests per platform;
the 12 Windows-gated tests, the byte-exact dehaze golden among them, compile
only on Windows). Interactive runtime behaviour on those platforms is still
unverified, and prebuilt binaries remain Windows-only.
| how | when | |
|---|---|---|
| Desktop app | run autoshop-gui and point it at a folder |
the full editor — library grid, develop panel, masks, crop, versions. No server, no browser. English / 中文 |
| Local web UI | double-click Autoshop-UI.bat, or autoshop serve "…\photos" --port 8080 |
a quick gallery on 127.0.0.1 with live before/after and a text direction box |
| CLI | autoshop auto "photo.ARW" --guidance "warm golden-hour, lift shadows" |
scripting, batches, evaluation, CI |
| runs without any API key | needs a vision key |
|---|---|
the whole render engine (apply) |
analyze / auto (the AI proposal) |
match — reverse-fit a finished look into an editable recipe |
the visual judge and --ai-judge / --deep |
| XMP read + write, mask import/export | reimagine / retouch (generative) |
| crop, masks, curves, every slider in the GUI | AI auto-detect in heal (painting a mask works offline) |
| AI denoise (local SCUNet on your GPU) | |
| local AI mask re-derivation (SAM 2.1 / OneFormer / BiRefNet) | |
| style indexing and retrieval |
The verifier role needs no API key at all when it runs over OAuth: it shells
out to the claude CLI you are already signed in to. Without a vision key,
analyze falls back to a histogram heuristic and says so in the rationale —
which, as the failure above shows, the verifier is entitled to refuse.
auto decodes a RAW, asks the vision advisor for an EditRecipe, has the
verifier acceptance-check it against EXIF/histogram/clipping statistics
(data-only — the verifier is never sent pixels), then renders a 16-bit TIFF
master. Since v0.26.2 the interactive surfaces close the loop visually: the
proposal is rendered and scored by the vision model, which may buy one guided
revision — adopted only if it re-scores at least as high. batch and eval
skip the paid loop, and the rationale discloses every branch taken.
The recipe serialises to an ACR/Lightroom .xmp: global sliders, Texture, the
Detail axes, de-fringe, post-crop vignetting and grain, local linear/radial
masks with their own point curves, and the Transform/Calibration blocks carried
verbatim. The XMP layer is hand-rolled (src/xmp.rs) and merges
into an existing document, preserving byte-for-byte everything this engine does
not model — a serialise-from-a-DOM round trip cannot promise that.
Reading back is the harder direction, and v0.31.0 fixed a real hole: every
Lightroom mask used to be discarded on import, because LR writes crs:Angle and
crs:MaskBlendMode on all of them and either one was enough to drop the whole
correction. On the 7-file forensic corpus that is now 42 of 42 corrections
imported, 0 refused.
Lightroom's AI masks (Sky / Subject / Object) arrive too — but as a re-derivation, not an import. The sidecar carries only the intent and the click point, never a raster, so the alpha is recomputed here by a local segmenter and every surface says so rather than letting it pass for Adobe's own mask.
Twelve purpose-shot Lightroom exports plus pixel measurement of the results
settled what Mask/CircularGradient actually means. crs:Top/Left/Bottom/Right
is not a bounding box: it is the pair of rotated corners of the ellipse's
own box, in pixel space. Reading it naively — which this app, and every other
implementation we could find, did — gets the axis ratio wrong by a median factor
of 1.84 and leaves 8 % of real masks unreadable. Sixteen real sidecars now
round-trip their corners byte for byte.
The method policy, stated once so it can be cited: every piece of format knowledge here comes from behavioural measurement — sidecars and exports of the author's own photographs from the author's own Lightroom, compared at the XMP and pixel level. Nothing was decompiled or disassembled. The one published decompile-derived model in this space (of the brush alpha kernel) is precisely what this project refuses to build on.
The follow-up round then refuted one of its own constants, which is the part
worth reading. The 1.032 frame scale measured in v0.32.0 turned out to be one
frame's Adobe lens-profile warp mistaken for a universal affine — proven by a
LensProfileEnable A/B export and an 11-dab displacement field. In v0.33.0 it
is 1.0: an imported radial renders at the geometry the sidecar actually
stores, instead of 3.2 % dilated. The residual on any frame is now that frame's
own unmodelled lens warp (0–3.4 % observed), and an .lcp reader is the named
candidate fix.
Linear gradients, radials (rotatable), free-form rasters, AI-selected subject / sky / object, add–subtract–intersect shape composition, per-mask eye toggle and duplicate, per-mask point curves, brush-editable AI rasters with feather / expand / contract and full-resolution guided refine.
Brush masks are the honest exception. Lightroom's dab-stream brush groups
are imported, carried and written back byte-exact — and rendered nowhere.
The engine's mask_weight answers 0 for that geometry, and both the import and
the export disclose it by name (BrushCarried). The alpha model is now largely
measured — screen accumulation, density as a pre-screen scale, a one-parameter
flow law (κ = 0.1219 ± 0.0027), an 11-rung hardness kernel table — but the
kernel has no closed form and the mask lives in Lightroom's pre-lens-correction
frame, which this engine cannot yet reproduce. Drawing it anyway would mean
burning pixels at a position known to be wrong. Autoshop's own painted and
AI-derived rasters are a different thing entirely and do render.
Style learns from similar past edits you have made (k-NN over EXIF +
histogram, optionally a SigLIP 2 image embedding) and offers them to the advisor
as soft reference. Build the index from the GUI, the web panel, or
autoshop style-index <dir>; every analysis names the shots it actually leaned
on.
Strength is the second, independent dial: Style asks how close to your own
past edits, Strength asks how committed the result should be. One value drives
all six places that used to decide restraint on their own — the proposer's
numeric guardrails and wording, the recipe's soft caps, the verifier's two-sided
band, the judge's rubric, whether a retrieved reference is a ceiling or a floor,
and the no-key fallback. 0.50 reproduces the calibrated numbers of releases
up to v0.28.0 bit-for-bit; the default 0.65 pushes a little further; above
0.70 the AI is told to commit. The clipping and white-point safeguards never
move with it.
match takes the same frame twice — your source and a finished rendition of it
(a Lightroom export, the camera JPEG, a reimagine output) — and solves for
the EditRecipe that reproduces it through the engine: CDF tone matching, then
saturation, then colour cast. No pixels are copied, so the fit applies at full
sensor resolution and writes recipe.json + XMP. Deterministic and key-free.
Two honesty readings ride every fit: the frame-global look residual, and a joint
distribution check over luminance × chroma ranges which can only lower the
reported confidence, never raise it. Opt-in AI review (--ai-judge) scores
how faithfully the fitted render matches the target; opt-in --deep lets that
review act, buying one guided retry that is kept only if it re-scores at least
as high.
ACR/LR-style denoise for high-ISO and astro frames, via a local Python sidecar.
Off by default; triggered by --denoise, the denoise command, or a UI button.
Runs entirely on your machine.
batch processes a folder; eval runs the AI against RAWs that have your own
.xmp beside them and reports per-control error and bias. Both take --jobs N,
capped by a memory budget: one 61 MP photo's pipeline pass peaks at ~1.77 GB
of commit charge (measured, not guessed — 1,771 MB, re-measured at v0.34.0 with
the full-resolution render included), so the worker count is bounded by free
memory — and the run discloses when the cap overrules your flag. Where a
file's own header is free to read, the budget asks it instead of assuming the
corpus average, and says which file raised the bill. The 147-photo
eval went from ~2.3 h serial to a measured 38 min at --jobs 3. Transcripts
are index-ordered, so re-running the same folder gives the same record.
Feed an already-processed image — denoised in Lightroom, exported from Photoshop — and Autoshop grades it directly. Auto-detected by file type; no RAW required. Embedded ICC profiles (LR's "Edit in…" exports ProPhoto 16-bit by default) are normalised into the sRGB working space with 16-bit depth preserved, instead of being read as if they were sRGB.
reimagine (generative restyle) and retouch (generative object removal) go
through OpenAI Images and are labelled experimental everywhere. heal is the
non-generative retoucher: it removes dust, blemishes and specks by sampling
surrounding real pixels and invents nothing.
Nine cameras, one per format, each a full neutral develop by
autoshop apply from a CC0 sample file — not the camera's embedded preview.
The .raf tile shows the v0.34.0 render — the X-Trans colour fix, on the
sample that exposed the defect. Through v0.33.0 this tile was visibly green and
dark (measured channel means 53.6/82.9/39.9 against the camera preview's
110.2/104.9/99.8): the Bayer demosaic's chroma pass left R and B unwritten at
16 of every 36 photosites. v0.34.0 demosaics over the array's own geometry;
this very render measures whole-frame G/R 0.9473 against the preview's
0.95, inside the 0.81–1.08 band the other eight formats occupy. Fine detail is
still softer than a dedicated X-Trans converter, and the render says so.
See X-Trans, restated.
Camera RAW — 24 extensions, one predicate app-wide (decode::is_raw):
arw dng raw raf nef cr2 cr3 orf rw2 pef srw 3fr fff iiq
mef mos erf kdc dcr dcs crw nrw mrw ari
Baked rasters — 8 extensions: png tif tiff jpg jpeg webp bmp gif. All
decoders are pure Rust. AVIF and HEIC are deliberately excluded — they would need a C image
codec (dav1d), and every image decoder in this tree is pure Rust. (Full
honesty on the wider graph: the TLS stack's ring compiles C via cc —
the one transitive C build dependency in the tree, and it is not ours to
swap without changing TLS stacks.)
Decoding is rawler 0.7.2, which carries 725 camera models. An extension
being on that list means the file reaches the RAW engine — not that your
particular body is in the database. Nine cameras, one per format, are verified
end to end on CC0 sample files, and that zoo is a release gate
(AUTOSHOP_RAW_ZOO, 9/9 at v0.34.0).
If your camera is not recognised, or its format is not listed: run the file
through the free Adobe DNG Converter and open the .dng. That is not a
second-class path — rawler builds a DNG's entire camera profile, colour matrices
included, from the file's own tags, so a converted file needs no database entry
at all. Autoshop's error message says this too, with the file named.
.x3f (Sigma Foveon) is permanently excluded: rawler 0.7.2's metadata reader
for it is a literal todo!(), which panics.
Autoshop grades itself against the photographer's own edits. eval takes RAWs
that have your Lightroom .xmp beside them, runs the full AI pipeline, and
reports per-control error and bias plus one aggregate gap score — the mean
per-control divergence including the tone curve, where lower means closer to
your look.
The current baseline, re-run on all 147 pairs at v0.33.0 (an
OpenAI-compatible endpoint, --jobs 3, 147/147 completed, zero fallbacks,
~38 minutes):
| metric | value |
|---|---|
| Overall gap score | 15.5 % |
| Mask-import refusals | 2.35 → 0.05 per photo |
whites bias |
+22.55 → +16.20 |
blacks bias |
−14.18 → −10.87 |
The mask row is the one to read twice. Before the v0.31.0 import fix, eval
counted 2.35 of the photographer's own masks refused by our importer on the
average photo — a number the tool reported about itself, on its own corpus,
because the refusal was counted rather than swallowed. That is what got fixed,
and this is the proof on the full corpus.
The remaining bias is real and unresolved: the AI still sets whites about 16
points higher and blacks about 11 points lower than this photographer does.
That is a taste gap, stated as a number instead of a claim.
Release gates at v0.34.0 (both build configurations): clippy clean ×2;
727 library (9 #[ignore]d forensic probes) / 11 CLI / 131 GUI / 2+2 contract
tests; 16/16 real Lightroom radial sidecars byte-round-tripped; 42/42 mask
imports on the forensic corpus with 0 refusals; RAW zoo 9/9; font subset
check 803/803 needed codepoints embedded (68 symbols + 735 CJK); i18n audit
0 findings; doc gate 11/11 with the battery transcript. That doc-drift gate
(scripts/check_docs.py) re-derives the shipped
version, the battery's test counts, the format counts, the dependency
inventory and the toolchain from the tree — and pins this README's own
copies of the version, battery line and format counts — before each release.
Most of the interesting engineering in this project is in what it refuses to
pretend. Every degradation has a name, and that name reaches every front-end —
the desktop app's open banner, the web reply, and analyze / auto / match
on stderr beside the xmp -> line.
| what | what Autoshop does |
|---|---|
| Mask import losses | Named, not counted: brush mask(s) drawn from Autoshop's measured model of Lightroom's brush - not Adobe's own rasteriser, AI mask(s) re-derived by the local segmenter - not Adobe's own raster, radial rotation(s) read as 0, local point curve(s) unreadable, unmodelled local slider(s), unknown local setting(s), and more |
| Mask export losses | The same list in the other direction — a mask this engine drew that the sidecar cannot carry says which one and why |
| Brush masks | Carried byte-exact and drawn — from a measured model of Lightroom's own brush (kernel, flow law and screen accumulation all read off controlled exports), never from a guess. Disclosed in both directions, because the alpha is ours |
| AI masks | The alpha is recomputed by the local segmenter and every surface says so. Never passed off as Adobe's raster |
| No embedded preview | 12 of the 24 formats store none. Those degrade to Autoshop's own neutral develop and say so — you are told you are looking at our render, not the camera's |
| X-Trans | Disclosed on every render (see below) |
| Untagged 16-bit input | Read as sRGB, and flagged — right for 8-bit JPEG, often wrong for the 16-bit files an editor produces |
| Unsupported sensors | Monochrome and 4-colour (CYGM/RGBE) arrays are refused before the develop, not after it |
| Refusal causes | Unknown make, unknown model and no decoder are three different failures with three different messages, each offering the DNG on-ramp |
| Parser panics | A third-party decoder panic becomes a named error; the process survives, so one bad file cannot kill a batch run |
| Carried, not rendered | Every control declares which of three things it is (below). No control moves a number without either moving a pixel or admitting that it doesn't |
| Memory cap | When the --jobs budget overrules your flag, it prints a line. A silent downgrade reads as "the flag didn't work" |
| A file too big to develop | A photo whose develop would peak over 4 GiB is refused by name, with the estimate and what it is based on — and told outright that --jobs 1 will not help, because one file's peak is not a concurrency budget. RAW and baked share the one ceiling |
| Style embedding gaps | If the optional embedding sidecar fails for some photos, the index is still built — and the count of photos without a vector is reported, so "all of them failed" no longer looks identical to "all of them worked" |
| AI fallback | A failed vision call degrades to the histogram heuristic with confidence 0.4 — and the verifier is allowed to refuse it, as it did in the showcase above |
Each control is exactly one of three kinds, and the registry states it once:
- Rendered — drawn here approximately and written to the sidecar. The ordinary sliders.
- Carried, not rendered — written to the sidecar; the canvas deliberately
does not draw it, because the operator behind it is unpublished and
approximating it would mean putting an operator we invented between you and
your picture. Film grain, the post-crop vignette family, the Sharpen and
Noise-Reduction detail axes, colour noise reduction, de-fringe, the auto-CA
switch,
crs:Roundness,crs:Midpoint. - Passed through verbatim — the Transform/Upright and Camera Calibration blocks, carried byte-for-byte and never interpreted, which is why the app shows their values and offers no slider for them.
Through v0.33.0, non-Bayer colour filter arrays developed through rawler's Bayer demosaic, whose guard checks the pattern's name rather than its geometry. The measured consequence on the X-S10 zoo sample was a strong green-and-dark cast — and the mechanism turned out to be starker than a detail approximation: the Bayer chroma pass fills a green photosite's missing channels from exactly the neighbour to its right and the neighbour below, and inside X-Trans's four 2×2 all-green blocks per tile that assumption is false. R was simply never written (left at zero) at 8 of every 36 photosites, B at a different 8. Correct white balance cannot repair that — a per-channel gain and a per-channel hole are both diagonal, so they commute — which is why the cast survived metadata that read perfectly.
Fixed in v0.34.0: a non-2×2 RGB array is now demosaiced in-tree over the array's own geometry — every channel is interpolated only from photosites that actually measured it, with per-phase plane-fit weights. On the same sample the whole-frame G/R moved 1.5503 → 0.9473 (measured on the release binary) and G/B 2.08 → 1.03, inside the 0.81–1.08 band the other eight formats occupy; per-phase channel spread fell from 158 % to 0.34 % of the channel mean. The render still disclaims what remains true: fine detail is reconstructed by a general rule rather than by an algorithm built for this array (Markesteijn), and is softer than a dedicated converter would resolve.
Your photos never leave your machine except to the AI endpoints you configured, and only when you ask for an AI operation. Everything else — render, XMP, masks, denoise, segmentation, style indexing, reverse-fit — is local.
store: falseon every AI request body. Nothing you send persists in the key owner's account. There is a unit test asserting it on every frame, including the style reference image, because a stored response is an exfil channel.- The verifier never sees pixels. It judges the recipe against EXIF, histogram and clipping statistics plus the advisor's rationale.
- The local server is loopback-only and hostile to the rest of your browser.
servebinds127.0.0.1, refuses any request whoseHost/Originis not that exact loopback authority on its own port, requires the page's per-run session token on every state-changing request, marks all/api/*responsesno-store, never returns a key (settings answerkey_present: true/false), and sendsX-Frame-Options: DENYso a page you visit cannot frame the UI. - Your library is read-only. The engine refuses to write into a source RAW's
folder. Exports go to
./outor a destination you choose; develop state lives in a per-user store keyed by path hash, so two same-named photos never collide. The one deliberate exception is «Export .xmp beside the photo» — a per-photo click, never batched, that refuses to replace an existing.xmpwithout a second confirming click, because beside the photo is the only place Lightroom reads one.
Autoshop reads an autoshop.local.json sitting in the folder it was launched
from — that is the pre-store layout, kept so old setups keep working — but since
v0.18.0 such a file may only choose models and providers. Settings resolve per
field, so a file supplying just image_base_url would have redirected the
endpoint while your real key still came from .env, and the next Analyze would
have posted that key to whoever wrote the file. Extracting a shared archive of
photos and running Autoshop inside it was enough.
Since v0.23.2 that rule is stated once, as a property of each setting
(config::SETTINGS), rather than as three hand-kept lists:
| kind | examples | who may set it |
|---|---|---|
| secret | OPENAI_API_KEY, AUTOSHOP_ANALYSIS_API_KEY |
your environment, your .env, the Settings panel |
| destination | the two base URLs, AUTOSHOP_CLAUDE_BIN, AUTOSHOP_PYTHON, the sidecar scripts and weight cache, AUTOSHOP_DATA_DIR, PATH, PYTHONPATH, PYTHONHOME, ANTHROPIC_* |
your environment and the Settings panel only |
| preference | every model id, both provider selectors, both reasoning-effort tiers, the tuning numbers | anything, including an ambient file |
So keys and models from a .env work, while nothing ambient can name where
bytes go, which account pays, or what program runs. Two further routes were
closed with it: saving settings no longer launders an ambient file into your
trusted one (both writers read-merge-write, so one ordinary "save" used to
copy a planted base URL into your user profile), and a .env may no longer
choose the endpoint — dotenvy searches every parent directory and its
override mode beats a variable you really set.
One place the table deliberately does not apply: what a .env may push into
the child processes (the claude verifier and the Python sidecars). There,
"unlisted" is not a closed set — it includes every loader hook the platform
defines — so that block is an allowlist of compute knobs
(CUDA_VISIBLE_DEVICES, thread counts, the offline flags). Anything else a
.env names is dropped, and the app says which. Your own environment is
unaffected: the children inherit it.
A saved key also remembers which endpoint it was saved for. The two image providers share one key slot, and flipping providers swaps the base URL — which used to leave the previous key armed for the new endpoint. Each save now records the base the key was typed beside, and the key is simply not sent anywhere else. Keys from your environment are exempt; that pairing is your own.
Bundled Python sidecar scripts and the weight cache resolve against the
program's own directory tree, never the folder you run from — an unzipped
photo pack cannot substitute its own denoise.py or its own weights.
Two roles, each configurable in the in-app Settings (⚙) panel or via env:
| Role | What it does | Default | Other option |
|---|---|---|---|
| 分析 / Analysis (verifier) | data-only acceptance check of each recipe | OAuth — the claude CLI on PATH, signed in (reuses Claude Code OAuth, no API key), model opus |
API — any OpenAI-compatible chat endpoint (base URL + key + model) |
| 图像 / Image (vision advisor) | looks at the photo → EditRecipe |
API — OPENAI_API_KEY; model = AUTOSHOP_OPENAI_MODEL (default in the env table) |
any OpenAI-compatible vision endpoint. Without a key, a histogram heuristic is used |
The claude CLI has no image input in print mode, so the image role always
speaks the OpenAI-compatible HTTP protocol. The Settings panel's "OAuth" choice
for the image role is a preset that points the endpoint at a local subscription
bridge instead of a keyed API — same wire protocol, different
endpoint/credentials.
Keys and models are written to autoshop.local.json in your per-user Autoshop
folder (never in a repo), which overrides the environment. You can also use a
.env:
OPENAI_API_KEY=sk-...
Reasoning effort is a suggestion, not a contract: the tiers differ per
provider (the claude CLI documents low, medium, high, xhigh, max;
OpenAI-compatible endpoints take the first three), so the pickers offer the
right list beside a free-text field, and an endpoint that does not know the tier
is automatically retried without it. Blank means "send no such parameter" — the
only correct request for a model that does not reason — and a blank saved in
Settings is a real choice that silences an AUTOSHOP_*_EFFORT from the
environment. The model lists beside those pickers are whatever the endpoint's
own /models returned, minus the ids recognisable as something else; a name can
only rule things OUT, so a wrong pick fails loudly, and unbilled, on the first
call.
The denoiser is a Python sidecar (python/denoise.py)
running SCUNet on the GPU. It needs Python with:
pip install torch --index-url https://download.pytorch.org/whl/cu128 # CUDA build
pip install opencv-python numpy einops requestsOn first use it downloads model weights (~72 MB each) into python/weights/
(gitignored), gated on sha256 and an exact byte count. Trigger it via
autoshop auto --denoise, autoshop denoise <src>, or the AI Denoise
checkbox in the UIs. Models: color_real_psnr (default, blind, best for real
high-ISO/astro), color_real_gan, color_15/25/50.
autoshop decode <src> # preview + EXIF + histogram
autoshop analyze <src> [--guidance "..."] [--style 0..1] [--strength 0..1] # AI → recipe.json + .xmp (no render; incl. visual review loop)
autoshop apply <src> <recipe.json> -o out [--long-edge N] # render a recipe to an image
autoshop auto <src> [--denoise] [--guidance "..."] [--style 0..1] [--strength 0..1] [--long-edge N] # analyze + render, end-to-end
autoshop denoise <src> [--strength 0..1] [--model ...] # AI denoise → clean 16-bit master
autoshop batch <dir> [--render] [--limit N] [--jobs N] [--include-baked] [--long-edge N] # a whole folder (--jobs = photos in flight, default 3)
autoshop eval <dir> [--limit N] [--jobs N] # compare AI edits vs your own .xmp (--jobs default 1 = serial)
autoshop style-index <dir> # build the "your taste" reference index (also in the GUI: AI panel › Style reference library)
autoshop serve <dir> [--port 8080] # local web UI
autoshop reimagine <raw> --prompt "..." # experimental generative restyle
autoshop match <raw> <target> [--render] [--zoned] [--ai-judge] [--deep] # reverse-fit a look → editable recipe + XMP (no key; --ai-judge scores the match and --deep lets it buy one guided retry, both need a key)
autoshop retouch <raw> --mask m.png --prompt "..." # experimental generative object removal
autoshop heal <src> [--mask m.png] [--no-auto] # pixel heal: spot/blemish removal (NOT generative)
autoshop recipe-schema # print the EditRecipe JSON shape
<src> is a RAW or a baked image — the develop pipeline runs on either.
RAWs also get an .xmp; baked sources get recipe.json only, because XMP is
meaningful only for RAW in Lightroom. batch skips baked photos by default and
takes --include-baked to opt in: shooting RAW+JPEG is common, and analyzing —
and billing — the camera JPEG beside every RAW is not a default.
--long-edge N exports at a size: the develop still runs at full sensor
resolution and the finished pixels are resampled last (Lanczos3, aspect kept,
never enlarged — a frame already under N is saved untouched, and 0 means
full resolution). It is a flag, not a recipe field, because one develop
legitimately delivers both a 61 MP master and a 2048 px web copy; nothing is
written into recipe.json. On batch it applies per photo — a portrait and a
landscape in the same folder each come out at N on their long edge — and
it requires --render, since a size for files that are never written is a typo.
denoise deliberately has no such flag: its output is a master a later develop
reads back, not a deliverable.
The AI-provider rows are also settable in the Settings (⚙) panel; the
per-user autoshop.local.json overrides the environment. The two sidecar knobs
(AUTOSHOP_PYTHON, AUTOSHOP_DENOISE_MODEL) are environment-only.
| Variable | Default | Purpose |
|---|---|---|
OPENAI_API_KEY |
— | image (vision) advisor + generative key |
AUTOSHOP_OPENAI_MODEL |
gpt-5.5 |
image/vision model id |
AUTOSHOP_OPENAI_BASE_URL |
https://api.openai.com/v1 |
image API base (any OpenAI-compatible) |
AUTOSHOP_OPENAI_IMAGE_MODEL |
gpt-image-1.5 |
generative (retouch/reimagine) model |
AUTOSHOP_ANALYSIS_PROVIDER |
oauth |
verifier provider: oauth (claude CLI) or api |
AUTOSHOP_ANALYSIS_MODEL |
opus |
verifier model (claude alias for oauth; chat id for api) |
AUTOSHOP_ANALYSIS_API_KEY |
— | verifier key when provider = api |
AUTOSHOP_ANALYSIS_BASE_URL |
https://api.openai.com/v1 |
verifier API base when provider = api |
AUTOSHOP_IMAGE_EFFORT |
— | image-role reasoning effort; blank ⇒ the provider decides |
AUTOSHOP_ANALYSIS_EFFORT |
— | verifier reasoning effort; blank ⇒ the provider decides |
AUTOSHOP_DATA_DIR |
%LOCALAPPDATA%/autoshop |
the develop store root (recipes, XMP, versions, mask rasters) |
AUTOSHOP_PYTHON |
python |
interpreter for the ML sidecars (denoise / segment / embed) |
AUTOSHOP_DENOISE_MODEL |
color_real_psnr |
default SCUNet weights |
AUTOSHOP_EMBED_SCRIPT |
bundled python/embed.py |
style-embedding sidecar (SigLIP 2) |
AUTOSHOP_STYLE_EMBED |
off | opt in to SigLIP 2 style embeddings — the first run downloads 1.50 GB of weights, so this is never taken without being asked |
AUTOSHOP_STYLE_EMBED_WEIGHT |
2.0 |
weight of the embedding block in the retrieval distance; 0 reproduces the pre-embedding ranking exactly. Not calibrated — the value was chosen, not measured |
AUTOSHOP_EMBED_FP32 |
off (i.e. half precision is ON) | force the embedding sidecar to load in fp32. Half precision halves the model on the GPU (1.50 → 0.75 GB) and is a no-op on CPU; the vector is still normalised in fp32, so nothing the Rust side checks changes. Set this to reproduce an index built before v0.34.0 in exactly the arithmetic it was built with |
-
Render ops are tasteful approximations of Lightroom, not bit-exact. Which control is rendered, carried or passed through verbatim is stated per control — see the honesty model.
-
Radial masks have named remaining gaps.
crs:Roundnessandcrs:Midpointare carried and never rendered — every radial reachable here writes their defaults and current Lightroom has no slider for either on a radial, so there is nothing to calibrate against.|Angle| > 45°is untested. A sidecar that declares no frame size still exports a rotated radial unrotated, because the pixel↔normalised fold needs the aspect — and the save says so in those words. The feather falloff is no longer a law at all: four rounds of measurement refuted three successive closed forms, so the engine now renders Lightroom's measured α(ρ) out of an eight-column table. What is still open there is named in the table's own provenance — the support radius is 1.43 ± 0.015 (√2 is inside the bar), the narrowest rung's far tail is unresolved, and every rung was shot on one ellipse aspect. -
Radial masks with feather ≥ 10 render differently from v0.35.0. The falloff moved from a fitted smoothstep to the measured table above; at Feather 100 the old law painted 2.08× the area Lightroom does. Feather 0 is byte-identical, and version snapshots keep the earlier render.
-
Imported radial geometry changed in v0.33.0. Previous builds dilated every imported radial by 3.2 %. They now render at the sidecar's stored geometry. Renders of the same recipe differ across that boundary; version snapshots keep the old one.
-
Every Sony ARW render moved by (32, 20) px in v0.32.0. The develop window now starts at the RAW's own
DefaultCropOrigin, where the camera and Lightroom put the picture, instead of the sensor's top-left corner. -
Forward compatibility breaks at v0.33.0. A v0.32.0 binary hard-refuses a v0.33.0 recipe carrying a 90° rotation, a brush mask or an AI mask — a loud schema refusal by design, never a silent drop. Recipes using none of those serialise byte-identically and stay readable both ways.
-
v0.34.0 changes no recipe schema at all. A v0.33.0 binary reads a v0.34.0 recipe and vice versa; what changed is renders (X-Trans colour, negative texture, the ≥138.5 MP RAW refusal) and the AI-mask cache key, which simply re-derives each alpha once on the first develop.
-
AI denoise runs on the demosaiced RGB, not the raw Bayer mosaic like Adobe Denoise, and takes ~3 min for a 60 MP frame on an RTX 4060 Ti. Excellent, not identical to Adobe.
-
Kelvin white balance is absolute only on a RAW, where the as-shot temperature is read from camera metadata. A baked PNG/TIFF has no as-shot reading, so the slider anchors at 5500 K and acts as a relative shift — it moves pixels, it just cannot claim to be the scene's true colour temperature.
-
The blackbody curve behind the Temp slider was repaired, and that changed white-balance gains. The published piecewise fit's branches did not meet: at 6600 K green jumped 1.31 % and blue 0.96 %, and red sat clamped flat from 6600 to 6688 K, so an 88 K band carried no temperature signal at all. Measured against a 5500 K shot, as the change in each channel's gain:
Temp target red blue 2000 K (candle — the slider's floor) 0 % −4.43 % 2500 K 0 % −0.67 % 3000–5000 K 0 % −0.32 % … −0.03 % 6500 K and below 0 % +0.03 % above 6600 K (any cool target) +3.19 % +2.35 % Re-exports of older work at those settings will differ by that much, and this change has not had visual acceptance.
-
Generative
reimagineis a low-res, lossy re-render — an experiment, not a master.retouchregenerates only the masked region and composites it back onto the source's own develop with a feathered seam, so the rest of the frame keeps its original pixels. That base is the engine's own neutral develop, capped at 2048 px on the long edge by default, never the camera's embedded JPEG.--full-rescomposites at full resolution instead (slow). -
Pixel heal removes only small defects — dust, blemishes, specks — by sampling surrounding real pixels. Best on fairly uniform backgrounds (sky, skin, wall, water); busy backgrounds heal approximately. AI auto-detection needs the vision key; painting a mask works offline.
-
Export-at-size is one knob, not an export panel.
apply,autoandbatch --rendertake--long-edge N(Lanczos3, aspect kept, never enlarged,0= full resolution), applied to the finished pixels after a full-resolution develop. That is the whole CLI delivery surface: JPEG quality, output sharpening, 8-bit TIFF/PNG and the Display P3 / Adobe RGB delivery spaces are all things the engine can do and only the desktop Export panel exposes, so a CLI-only workflow still gets q95 sRGB and nothing else.denoisehas no size flag on purpose — its output is a master, not a deliverable.
Rust (rustc/cargo 1.94, edition 2024) · rawler (RAW decode, 24 formats /
725 bodies) · image (pure-Rust codecs, opt-in one at a time) · qcms (ICC →
sRGB for baked imports) · rayon (row-parallel per-pixel stages) · clap ·
serde · ureq · eframe/egui (desktop GUI) · tiny_http (local web UI,
one include_str!'d HTML file, zero build step, zero CDN) · a hand-rolled XMP
reader/writer with no XML crate, because a Lightroom sidecar must be merged
into.
Six ML models run locally through three Python sidecars, none of them shipped in this repository — the weights are fetched on first use and pinned:
| model | job | licence |
|---|---|---|
| SCUNet ×5 | AI denoise | Apache-2.0 (KAIR) |
| BiRefNet (general) | subject segmentation | MIT |
| U²-Net | subject segmentation — fallback tier | Apache-2.0 |
| OneFormer ADE20K Swin-L | sky segmentation | MIT |
| SAM 2.1 Hiera-Large | point-prompted object masks | Apache-2.0 |
| SigLIP 2 base/16 @384 | style embeddings (opt-in) | Apache-2.0 |
Licence is a selection criterion here, not a footnote: candidates whose model cards restrict use were passed over even though the weights are never redistributed — and in each case the licence-clean option was also the stronger model.
See docs/ARCHITECTURE.md §5 for the complete dependency list with each entry's reason, the two-tier weight-pinning policy, and the rest of the design.
Autoshop is MIT-licensed — see LICENSE.
RAW format samples. The nine files behind the format strip come from the raw.pixls.us community sample repository and are all licensed CC0 1.0 Public Domain (https://creativecommons.org/publicdomain/zero/1.0/). Only CC0 rows of that index were eligible; every sha256 was verified against the index before use.
| format | camera | MP | sample |
|---|---|---|---|
| CR2 | Canon EOS 40D | 10.08 | RAW (3:2) |
| CR3 | Canon EOS R6 | 19.96 | 3:2 |
| NEF | Nikon D700 | 12.2 | 14bit 14bit compressed (Lossless) (3:2) |
| RAF | Fujifilm X-S10 | 26.7 | 14bit 14bit compressed (3:2) |
| ORF | Olympus E-M5 | 16.11 | 16bit (4:3) |
| RW2 | Panasonic DMC-GX85 | 15.9 | 4:3 |
| PEF | Pentax K-5 | 16.39 | 14bit (3:2) |
| DNG | Ricoh GR II | 16.27 | 12bit (3:2) |
| ARW | Sony ILCE-7M3 | 24.34 | 14bit 14bit compressed (3:2) |
Showcase photographs (_DSC9706, _DSC9711, _DSC9712) are the author's
own, shot on a Sony A7R V — © 2026 skymanbp, all rights reserved. Unlike
the code, these images are not MIT-licensed: they are included solely to
document Autoshop's output and may not be reused without permission. Each file
carries a visible watermark and embedded copyright metadata on purpose.
Fonts. The GUI bundles subset Noto faces under the SIL Open Font License —
see assets/fonts/ for each licence text.
Model weights are the property of their respective authors under the licences in the table above; none of them are redistributed by this project.















